commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 10:55:18 +02:00
parent 0235c7609e
commit 48347e2d3a
161 changed files with 11814 additions and 265 deletions
+7
View File
@@ -1,5 +1,9 @@
* text=auto eol=lf * text=auto eol=lf
# Preserve the exact bytes of pinned shared runtime artifacts and their hashes.
/packages/budget-control/** -text
/packages/payables/** -text
*.blade.php diff=html *.blade.php diff=html
*.css diff=css *.css diff=css
*.html diff=html *.html diff=html
@@ -9,3 +13,6 @@
CHANGELOG.md export-ignore CHANGELOG.md export-ignore
README.md export-ignore README.md export-ignore
.github/workflows/browser-tests.yml export-ignore .github/workflows/browser-tests.yml export-ignore
/packages/budget-control/** -export-ignore
/packages/payables/** -export-ignore
+5 -1
View File
@@ -4,6 +4,8 @@ Sistema de gestão de compras em Laravel 13, React, TypeScript e Inertia. Esta e
Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existente. A implementação Python criada separadamente fica apenas como referência; consulte [o mapa de compatibilidade](docs/compatibilidade.md) antes de adaptar funcionalidades. Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existente. A implementação Python criada separadamente fica apenas como referência; consulte [o mapa de compatibilidade](docs/compatibilidade.md) antes de adaptar funcionalidades.
O produto inclui os artefactos dos núcleos comuns em `packages/`, conferidos por SHA-256 com `composer run runtime:check`. A execução da aplicação já não exige a pasta vizinha do SGF. Actualizar esses artefactos a partir da origem comum, conforme `packages/README.md`. Esta distribuição não activa automaticamente capacidades financeiras nem altera dados/configuração de instalações existentes.
## Executar localmente ## Executar localmente
Durante o desenvolvimento, use `composer run dev` e abra **http://127.0.0.1:8005/avaliar**. Este é o ambiente persistente com os menus organizados e os processos em avaliação. Escolha **Entrar como proprietário** ou o perfil operacional pretendido. O arranque reutiliza a avaliação existente e inicia o Vite quando necessário, sem migrações, seeds ou alterações de contas. Durante o desenvolvimento, use `composer run dev` e abra **http://127.0.0.1:8005/avaliar**. Este é o ambiente persistente com os menus organizados e os processos em avaliação. Escolha **Entrar como proprietário** ou o perfil operacional pretendido. O arranque reutiliza a avaliação existente e inicia o Vite quando necessário, sem migrações, seeds ou alterações de contas.
@@ -12,7 +14,7 @@ Conferência dos pagamentos pendentes após importação: [entrega e provas](doc
### Explorar a aplicação sem palavra-passe ### Explorar a aplicação sem palavra-passe
Na demonstração local isolada, abrir **http://127.0.0.1:8005/avaliar** e escolher **Entrar como proprietário**. A página «Explorar Compras», acessível também pela ligação acima da aplicação, reúne os 20 módulos/páginas principais. Disponibiliza ainda os seis perfis operacionais sintéticos para experimentar os respectivos passos sem procurar credenciais. Os dados e alterações desta demonstração persistem entre visitas. Na demonstração local isolada, abrir **http://127.0.0.1:8005/avaliar** e escolher **Entrar como proprietário**. A página «Explorar Compras», acessível também pela ligação acima da aplicação, reúne os 21 módulos/páginas principais. Links protegidos, como `/relatorios`, encaminham para esta entrada quando a sessão expira e regressam ao destino após a escolha do perfil, sem pedir credenciais. Disponibiliza ainda os seis perfis operacionais sintéticos para experimentar os respectivos passos. Os dados e alterações desta demonstração persistem entre visitas.
A entrada pertence exclusivamente à base QA local existente de obrigações, com empresa e exemplos sintéticos; não altera contas/dados da instalação normal em 8015. As regras de aprovação independente e de integridade permanecem. O proprietário tem o perfil administrativo e as três acções do ensaio de obrigações; os passos reservados a Procurement, Finanças ou Armazém usam os perfis de avaliação disponíveis. Não se declara que todos os percursos já tenham exemplos completos nesta base. A entrada pertence exclusivamente à base QA local existente de obrigações, com empresa e exemplos sintéticos; não altera contas/dados da instalação normal em 8015. As regras de aprovação independente e de integridade permanecem. O proprietário tem o perfil administrativo e as três acções do ensaio de obrigações; os passos reservados a Procurement, Finanças ou Armazém usam os perfis de avaliação disponíveis. Não se declara que todos os percursos já tenham exemplos completos nesta base.
@@ -37,6 +39,8 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
## Entrega actual ## Entrega actual
- **Relatórios de compras:** menu Relatórios → Relatórios de compras (`/relatorios`), com filtros, resumo, exportação CSV completa e ligações ao processo/relatório individual. O relatório imprimível inclui recepções e, para os perfis financeiros autorizados, facturas e registos de pagamentos. Estados preservados, sem cálculo de saldo por este relatório. Ver o inventário actualizado em `docs/menus-benchmark-20261029.md`.
- Passagem explícita de pagamentos históricos para a obrigação comum: principal integral, confirmados deduzidos uma vez, pendentes/rejeitados preservados e validação distinta. Migração 000022 aplicada e activação histórica concluída em `compras_dev`, após backup/restauro e preservação conferida; sem incorporação automática. A demonstração em 8005 mantém o seu cenário anterior. Não executa pagamentos nem confirma pendentes após a passagem. Ver [entrega, aplicação e provas](docs/passagem-historico-pagamentos.md). - Passagem explícita de pagamentos históricos para a obrigação comum: principal integral, confirmados deduzidos uma vez, pendentes/rejeitados preservados e validação distinta. Migração 000022 aplicada e activação histórica concluída em `compras_dev`, após backup/restauro e preservação conferida; sem incorporação automática. A demonstração em 8005 mantém o seu cenário anterior. Não executa pagamentos nem confirma pendentes após a passagem. Ver [entrega, aplicação e provas](docs/passagem-historico-pagamentos.md).
- Regras de investimento próprias por empresa: tecto por processo, base de IVA - Regras de investimento próprias por empresa: tecto por processo, base de IVA
+2
View File
@@ -10,6 +10,7 @@ enum PurchasingRole: string
case Finance = 'finance'; case Finance = 'finance';
case Warehouse = 'warehouse'; case Warehouse = 'warehouse';
case Admin = 'admin'; case Admin = 'admin';
case Configured = 'configured';
public function label(): string public function label(): string
{ {
@@ -20,6 +21,7 @@ enum PurchasingRole: string
self::Finance => 'Finanças', self::Finance => 'Finanças',
self::Warehouse => 'Recepção / Armazém', self::Warehouse => 'Recepção / Armazém',
self::Admin => 'Administrador', self::Admin => 'Administrador',
self::Configured => 'Perfil definido pela empresa',
}; };
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@ class BudgetController extends Controller
{ {
private function access(Request $request): void private function access(Request $request): void
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
} }
public function index(Request $request): Response public function index(Request $request): Response
@@ -13,7 +13,7 @@ class BudgetReinforcementController extends Controller
{ {
public function store(Request $request, Budget $budget, BudgetReinforcementWorkflow $workflow): RedirectResponse public function store(Request $request, Budget $budget, BudgetReinforcementWorkflow $workflow): RedirectResponse
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$data = $request->validate(['reference' => 'required|string|max:100', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']); $data = $request->validate(['reference' => 'required|string|max:100', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $budget, $data); $workflow->propose($request->user(), $budget, $data);
+19 -7
View File
@@ -7,6 +7,8 @@ use App\Models\CostCenter;
use App\Models\Department; use App\Models\Department;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow; use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -24,30 +26,39 @@ class CompanyController extends Controller
public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{ {
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100']); abort_if($request->user()->tenant_id !== null, 403);
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100',
'second_name' => 'required|string|max:100', 'second_email' => 'required|email|max:255|unique:users,email', 'second_password' => 'required|string|min:12|confirmed',
'first_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'second_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'includes_tax' => 'required|boolean']);
DB::transaction(function () use ($request, $data, $workflow) { DB::transaction(function () use ($request, $data, $workflow) {
$user = User::query()->lockForUpdate()->findOrFail($request->user()->id); $user = User::query()->lockForUpdate()->findOrFail($request->user()->id);
abort_if($user->tenant_id !== null, 403); abort_if($user->tenant_id !== null, 403);
$tenant = Tenant::query()->create(['name' => $data['name']]); $tenant = Tenant::query()->create(['name' => $data['name'], ...OrganizationConfiguration::rules($data)]);
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => $data['department']]); $department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => $data['department']]);
CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'GERAL', 'name' => 'Centro de custo geral']); CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'GERAL', 'name' => 'Centro de custo geral']);
$user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Admin])->save(); $user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Configured])->save();
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name']]); $second = new User;
$second->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'name' => $data['second_name'], 'email' => $data['second_email'], 'password' => $data['second_password'], 'purchasing_role' => PurchasingRole::Configured])->save();
app(OrganizationConfiguration::class)->initialise($user, $second);
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name'], 'initial_administrators' => [$user->id, $second->id], 'rules' => $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional'])]);
}); });
return to_route('dashboard'); return to_route('dashboard');
} }
public function index(Request $request): Response public function index(Request $request): Response|RedirectResponse
{ {
$this->admin($request); $this->admin($request);
if (app(OrganizationAccess::class)->active($request->user()->tenant_id)) {
return to_route('organization.parameters');
}
return Inertia::render('company/index', [ return Inertia::render('company/index', [
'company' => Tenant::query()->findOrFail($request->user()->tenant_id), 'company' => Tenant::query()->findOrFail($request->user()->tenant_id),
'departments' => Department::query()->orderBy('name')->get(), 'departments' => Department::query()->orderBy('name')->get(),
'costCenters' => CostCenter::query()->orderBy('code')->get(), 'costCenters' => CostCenter::query()->orderBy('code')->get(),
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id', 'purchasing_role']), 'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id', 'purchasing_role']),
'roles' => collect(PurchasingRole::cases())->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]), 'roles' => collect(PurchasingRole::cases())->reject(fn (PurchasingRole $role) => $role === PurchasingRole::Configured)->values()->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
]); ]);
} }
@@ -82,6 +93,7 @@ class CompanyController extends Controller
public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{ {
$this->admin($request); $this->admin($request);
abort_if(app(OrganizationAccess::class)->active($request->user()->tenant_id), 409, 'Crie utilizadores e atribua perfis na parametrização.');
$data = $request->validate([ $data = $request->validate([
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
'password' => 'required|string|min:12|confirmed', 'password' => 'required|string|min:12|confirmed',
@@ -99,6 +111,6 @@ class CompanyController extends Controller
private function admin(Request $request): void private function admin(Request $request): void
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403); app(OrganizationConfiguration::class)->administrator($request->user());
} }
} }
@@ -20,7 +20,7 @@ class DocumentaryQualificationController extends Controller
{ {
private function access(Request $request, int $id): Requisition private function access(Request $request, int $id): Requisition
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id); return Requisition::query()->visibleTo($request->user())->findOrFail($id);
} }
@@ -47,7 +47,7 @@ class DocumentaryQualificationController extends Controller
'proposals' => $proposals, 'proposals' => $proposals,
'closedForChanges' => $closed, 'closedForChanges' => $closed,
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id, 'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
'canDefine' => $open && $proposals !== [] && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canDefine' => $open && $proposals !== [] && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition), 'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
])->toResponse($request); ])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+7 -7
View File
@@ -28,15 +28,15 @@ class EquipmentController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$assets = Equipment::query(); $assets = Equipment::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { if ($request->user()->departmentScoped()) {
$assets->where('department_id', $request->user()->department_id); $assets->where('department_id', $request->user()->department_id);
} }
$equipment = $assets->orderBy('code')->get(); $equipment = $assets->orderBy('code')->get();
$links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get(); $links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get();
$parts = SparePart::query(); $parts = SparePart::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { if ($request->user()->departmentScoped()) {
$parts->whereIn('id', $links->pluck('spare_part_id')); $parts->whereIn('id', $links->pluck('spare_part_id'));
} }
$parts = $parts->get()->map(function (SparePart $part) { $parts = $parts->get()->map(function (SparePart $part) {
@@ -47,7 +47,7 @@ class EquipmentController extends Controller
}); });
$items = StockItem::query(); $items = StockItem::query();
$departments = Department::query(); $departments = Department::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { if ($request->user()->departmentScoped()) {
$items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter())); $items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter()));
$departments->whereKey($request->user()->department_id); $departments->whereKey($request->user()->department_id);
} }
@@ -57,8 +57,8 @@ class EquipmentController extends Controller
'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']), 'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'departments' => $departments->orderBy('name')->get(['id', 'name']), 'departments' => $departments->orderBy('name')->get(['id', 'name']),
'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15), 'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15),
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
'canConfigure' => $request->user()->purchasing_role === PurchasingRole::Admin, 'canConfigure' => $request->user()->hasFunctions([PurchasingRole::Admin]),
]); ]);
} }
@@ -66,7 +66,7 @@ class EquipmentController extends Controller
{ {
$user = $request->user(); $user = $request->user();
abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404); abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404);
abort_unless($kind === 'categorias' ? $user->purchasing_role === PurchasingRole::Admin : in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403); abort_unless($kind === 'categorias' ? $user->hasFunctions([PurchasingRole::Admin]) : $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
$tenant = $user->tenant_id; $tenant = $user->tenant_id;
$exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant); $exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant);
$request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]); $request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]);
@@ -19,8 +19,8 @@ class ExternalPaymentController extends Controller
{ {
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$canManage = $request->user()->purchasing_role === PurchasingRole::Finance; $canManage = $request->user()->hasFunctions([PurchasingRole::Finance]);
$payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]); $payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
if ($canManage && $payments->count() > 0) { if ($canManage && $payments->count() > 0) {
app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]); app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
@@ -40,7 +40,7 @@ class ExternalPaymentController extends Controller
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']); $data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']);
$workflow->register($request->user(), $invoice, $data); $workflow->register($request->user(), $invoice, $data);
@@ -21,14 +21,14 @@ class FormalAdmissionController extends Controller
{ {
private function access(Request $request, int $id): Requisition private function access(Request $request, int $id): Requisition
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id); return Requisition::query()->visibleTo($request->user())->findOrFail($id);
} }
public function policy(Request $request): Response public function policy(Request $request): Response
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory(); Inertia::encryptHistory();
$response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request); $response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -71,7 +71,7 @@ class FormalAdmissionController extends Controller
'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()]; 'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
}); });
Inertia::encryptHistory(); Inertia::encryptHistory();
$response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request); $response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response; return $response;
@@ -23,14 +23,14 @@ class FormalRecommendationController extends Controller
{ {
private function access(Request $request, int $id): Requisition private function access(Request $request, int $id): Requisition
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id); return Requisition::query()->visibleTo($request->user())->findOrFail($id);
} }
public function policy(Request $request): Response public function policy(Request $request): Response
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory(); Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request); $response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -68,7 +68,7 @@ class FormalRecommendationController extends Controller
Inertia::encryptHistory(); Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null, $response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0, 'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $basis['ready'] && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id])->toResponse($request); 'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response; return $response;
@@ -2,7 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Http\Requests\ConfirmReceiptRequest; use App\Http\Requests\ConfirmReceiptRequest;
use App\Models\GoodsReceipt; use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder; use App\Models\PurchaseOrder;
@@ -26,7 +25,7 @@ class GoodsReceiptController extends Controller
{ {
Gate::authorize('viewAny', GoodsReceipt::class); Gate::authorize('viewAny', GoodsReceipt::class);
$orders = PurchaseOrder::query(); $orders = PurchaseOrder::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { if ($request->user()->departmentScoped()) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id')); $orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'));
} }
@@ -15,15 +15,15 @@ class InvestmentPolicyController extends Controller
{ {
public function index(Request $request, InvestmentWorkflow $workflow): Response public function index(Request $request, InvestmentWorkflow $workflow): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$current = $workflow->current(); $current = $workflow->current();
return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->purchasing_role === PurchasingRole::Admin]); return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin])]);
} }
public function store(Request $request, InvestmentWorkflow $workflow): RedirectResponse public function store(Request $request, InvestmentWorkflow $workflow): RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:ceiling', 'scope' => 'required|in:process_total', 'currency' => 'required|in:MZN', 'ceiling' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'includes_tax' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|max:4000']); $data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:ceiling', 'scope' => 'required|in:process_total', 'currency' => 'required|in:MZN', 'ceiling' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'includes_tax' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $data); $workflow->propose($request->user(), $data);
+3 -3
View File
@@ -16,14 +16,14 @@ class InvoiceController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]); return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
} }
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/']; $decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
$data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]); $data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
$workflow->register($request->user(), $order, $data); $workflow->register($request->user(), $order, $data);
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use Brick\Math\BigDecimal;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class OrganizationConfigurationController extends Controller
{
public function index(Request $request, OrganizationConfiguration $service): Response
{
$actor = $service->administrator($request->user());
$ready = Schema::hasTable('organization_access');
$state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null;
Inertia::encryptHistory();
$response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1,
'companyRules' => $this->companyRules((int) $actor->tenant_id),
'permissions' => OrganizationAccess::PERMISSIONS,
'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR)]) : [],
'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [],
'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']),
'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
'notice' => $request->session()->get('organization_notice')])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function command(Request $request, string $command, OrganizationConfiguration $service): Response
{
$actor = $service->administrator($request->user());
abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.');
$rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited'];
if ($command === 'profile') {
$rules += ['id' => 'nullable|integer|min:1', 'name' => ['required', 'string', 'max:120', Rule::unique('organization_profiles')->where('tenant_id', $actor->tenant_id)->ignore($request->integer('id'))],
'permissions' => 'present|array', 'permissions.*' => ['string', 'distinct', Rule::in(array_keys(OrganizationAccess::PERMISSIONS))]];
} elseif ($command === 'user') {
$rules += ['name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|string|min:12|confirmed',
'department_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'reason' => 'required|string|min:10|max:4000'];
} elseif ($command === 'assign') {
$rules += ['user_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'maintenance' => 'required|boolean',
'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:10|max:4000'];
} elseif ($command === 'rules') {
$rules += ['first_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'second_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'includes_tax' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'];
} else {
abort_unless($command === 'activate', 404);
$rules += ['reason' => 'required|string|min:10|max:4000', 'confirm' => 'required|accepted'];
}
$service->execute($actor, $command, $request->validate($rules));
return to_route('organization.parameters')->with('organization_notice', 'Parametrização actualizada e registada na auditoria.');
}
/** @return array<string,mixed> */
private function companyRules(int $tenant): array
{
$company = Tenant::query()->findOrFail($tenant);
return ['first_limit' => (string) BigDecimal::of((string) $company->first_limit_minor)->dividedBy(100, 2),
'second_limit' => (string) BigDecimal::of((string) $company->second_limit_minor)->dividedBy(100, 2),
'includes_tax' => $company->approval_includes_tax, 'provisional' => $company->rules_provisional];
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Department;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementReportController extends Controller
{
private function snapshot(\Closure $read): mixed
{
if (DB::connection()->getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
throw new \LogicException('O relatório exige uma transacção própria de leitura.');
}
return DB::transaction(function () use ($read) {
if (DB::connection()->getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
return $read();
});
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
$actor = User::query()->findOrFail($request->user()->id);
abort_unless($actor->tenant_id === $request->user()->tenant_id && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request->setUserResolver(fn () => $actor);
$filters = $request->validate([
'search' => 'nullable|string|max:100',
'status' => 'nullable|in:draft,pending,approved,rejected,cancelled',
'from' => 'nullable|date_format:Y-m-d',
'to' => 'nullable|date_format:Y-m-d',
'department_id' => 'nullable|integer|min:1',
]);
if (! empty($filters['from']) && ! empty($filters['to']) && $filters['from'] > $filters['to']) {
throw ValidationException::withMessages(['to' => 'A data final deve ser igual ou posterior à data inicial.']);
}
return $filters;
}
/** @param array<string,mixed> $filters
* @return Builder<Requisition>
*/
private function query(User $actor, array $filters): Builder
{
$query = Requisition::query()->where('tenant_id', $actor->tenant_id)->visibleTo($actor);
if (isset($filters['search']) && $filters['search'] !== '') {
$query->where('number', 'like', '%'.$filters['search'].'%');
}
foreach (['status', 'department_id'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['from'])) {
$query->where('created_at', '>=', $filters['from'].' 00:00:00');
}
if (! empty($filters['to'])) {
$query->where('created_at', '<', (new \DateTimeImmutable($filters['to']))->modify('+1 day')->format('Y-m-d').' 00:00:00');
}
return $query;
}
/** @param Builder<Requisition> $query
* @return Builder<Requisition>
*/
private function records(Builder $query): Builder
{
return $query->select('requisitions.*')->with(['department:id,name', 'requester:id,name'])->addSelect([
'quotations_count' => Quotation::query()->selectRaw('COUNT(*)')->whereColumn('requisition_id', 'requisitions.id'),
'order_number' => PurchaseOrder::query()->visibleTo(request()->user())->select('number')->whereColumn('requisition_id', 'requisitions.id')->orderBy('id')->limit(1),
]);
}
/** @return array<string,mixed> */
private function row(Requisition $record): array
{
return [
'id' => $record->id, 'number' => $record->number, 'status' => $record->status,
'created_at' => $record->created_at?->toIso8601String(),
'department' => $record->department?->name, 'requester' => $record->requester?->name,
'total_minor' => (string) $record->total_minor,
'quotations_count' => (int) $record->getAttribute('quotations_count'),
'order_number' => $record->getAttribute('order_number'),
];
}
public function index(Request $request): Response
{
$props = $this->snapshot(function () use ($request): array {
$filters = $this->filters($request);
$query = $this->query($request->user(), $filters);
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($value) => (int) $value);
$records = $this->records(clone $query)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record));
$departments = Department::query()->when($request->user()->departmentScoped(), fn ($q) => $q->whereKey($request->user()->department_id))->orderBy('name')->get(['id', 'name']);
return ['records' => $records, 'counts' => $counts, 'filters' => $filters, 'departments' => $departments, 'generatedAt' => now()->toIso8601String()];
});
Inertia::encryptHistory();
$response = Inertia::render('reports/procurement', $props)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function export(Request $request): Response
{
$filters = $this->filters($request);
$query = $this->records($this->query($request->user(), $filters));
$text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
return response()->streamDownload(fn () => $this->snapshot(function () use ($query, $text, $filters) {
$output = fopen('php://output', 'w');
if ($output === false) {
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
}
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, ['Relatório de compras', 'Criado em', now()->toIso8601String()], ';', '"', '');
fputcsv($output, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência'], ';', '"', '');
foreach ($query->lazyById(200) as $record) {
$row = $this->row($record);
$base = url('/requisicoes/'.$record->id);
fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio'], ';', '"', '');
}
fclose($output);
}), 'relatorio-compras-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
}
@@ -19,7 +19,7 @@ class ProposalOpeningController extends Controller
{ {
private function access(Request $request, int $requisitionId): Requisition private function access(Request $request, int $requisitionId): Requisition
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($requisitionId); return Requisition::query()->visibleTo($request->user())->findOrFail($requisitionId);
} }
@@ -36,7 +36,7 @@ class ProposalOpeningController extends Controller
'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings, 'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings,
'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash, 'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash,
'latestId' => $latest->id ?? 0, 'latestId' => $latest->id ?? 0,
'canRecord' => $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition), 'canRecord' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
])->toResponse($request); ])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -10,6 +10,7 @@ use App\Models\PurchaseControlReview;
use App\Models\PurchaseOrder; use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision; use App\Models\PurchaseRevision;
use App\Models\Requisition; use App\Models\Requisition;
use App\Services\OrganizationAccess;
use App\Services\PurchaseControlPolicyWorkflow; use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow; use App\Services\PurchaseRevisionWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -22,13 +23,13 @@ class PurchaseControlController extends Controller
public function index(Request $request): Response public function index(Request $request): Response
{ {
$user = $request->user(); $user = $request->user();
$operational = in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]); $operational = $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
$sources = Requisition::query()->where('requester_id', $user->id)->select('id'); $sources = Requisition::query()->where('requester_id', $user->id)->select('id');
$ownAwards = PurchaseAward::query()->whereIn('requisition_id', $sources)->select('id'); $ownAwards = PurchaseAward::query()->whereIn('requisition_id', $sources)->select('id');
$cancellations = AwardCancellation::query()->whereIn('purchase_award_id', $ownAwards)->where('status', 'approved')->get(['id', 'purchase_award_id']); $cancellations = AwardCancellation::query()->whereIn('purchase_award_id', $ownAwards)->where('status', 'approved')->get(['id', 'purchase_award_id']);
return Inertia::render('purchase-controls/index', [ return Inertia::render('purchase-controls/index', [
'role' => $user->purchasing_role->value, 'actorId' => $user->id, 'role' => $user->purchasing_role->value ?? '', 'permissions' => app(OrganizationAccess::class)->permissions($user), 'actorId' => $user->id,
'policies' => $operational ? PurchaseControlPolicy::query()->latest('revision')->get() : [], 'policies' => $operational ? PurchaseControlPolicy::query()->latest('revision')->get() : [],
'reviews' => $operational ? PurchaseControlReview::query()->get() : [], 'reviews' => $operational ? PurchaseControlReview::query()->get() : [],
'awards' => $operational ? PurchaseAward::query()->whereNotIn('requisition_id', PurchaseOrder::query()->select('requisition_id'))->latest('id')->get(['id', 'requisition_id', 'status']) : [], 'awards' => $operational ? PurchaseAward::query()->whereNotIn('requisition_id', PurchaseOrder::query()->select('requisition_id'))->latest('id')->get(['id', 'requisition_id', 'status']) : [],
@@ -44,7 +45,7 @@ class PurchaseControlController extends Controller
public function propose(Request $request, PurchaseControlPolicyWorkflow $workflow): RedirectResponse public function propose(Request $request, PurchaseControlPolicyWorkflow $workflow): RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:unissued_award', 'enabled' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|min:10|max:4000']); $data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:unissued_award', 'enabled' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|min:10|max:4000']);
$workflow->propose($request->user(), $data); $workflow->propose($request->user(), $data);
+3 -3
View File
@@ -24,7 +24,7 @@ class PurchaseController extends Controller
{ {
private function access(Request $request): void private function access(Request $request): void
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
} }
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
@@ -50,10 +50,10 @@ class PurchaseController extends Controller
return Inertia::render('purchases/award', [ return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award, 'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null), 'canSelect' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate], 'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition), 'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']), 'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]); ]);
} }
@@ -21,7 +21,7 @@ class PurchasePlanningController extends Controller
public function index(Request $request): Response public function index(Request $request): Response
{ {
$actor = $request->user(); $actor = $request->user();
$canManage = in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]); $canManage = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
$filters = $request->validate(['item' => 'nullable|integer|min:1']); $filters = $request->validate(['item' => 'nullable|integer|min:1']);
$items = PurchasePlanItem::query()->visibleTo($actor)->when($filters['item'] ?? null, fn ($query, $id) => $query->whereKey($id))->latest('id')->paginate(15)->withQueryString(); $items = PurchasePlanItem::query()->visibleTo($actor)->when($filters['item'] ?? null, fn ($query, $id) => $query->whereKey($id))->latest('id')->paginate(15)->withQueryString();
$milestones = PurchasePlanMilestone::query()->whereIn('purchase_plan_item_id', $items->pluck('id'))->orderBy('planned_on')->orderBy('id')->get(); $milestones = PurchasePlanMilestone::query()->whereIn('purchase_plan_item_id', $items->pluck('id'))->orderBy('planned_on')->orderBy('id')->get();
@@ -30,10 +30,9 @@ class PurchaseProcessController extends Controller
$actor = $request->user(); $actor = $request->user();
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id); Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$requisition->load(['requester:id,name', 'department:id,name', 'steps']); $requisition->load(['requester:id,name', 'department:id,name', 'steps']);
$role = $actor->purchasing_role; $purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$purchases = in_array($role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]); $finance = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
$finance = in_array($role, [PurchasingRole::Admin, PurchasingRole::Finance]); $inventory = $purchases || $actor->hasFunctions([PurchasingRole::Warehouse]);
$inventory = $purchases || $role === PurchasingRole::Warehouse;
$base = '/requisicoes/'.$requisition->id; $base = '/requisicoes/'.$requisition->id;
$stages = []; $stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]]; $documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
+5 -5
View File
@@ -24,7 +24,7 @@ class QuotationController extends Controller
{ {
private function access(Request $request): void private function access(Request $request): void
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
} }
public function index(Request $request): Response public function index(Request $request): Response
@@ -46,15 +46,15 @@ class QuotationController extends Controller
return Inertia::render('quotations/show', [ return Inertia::render('quotations/show', [
'requisition' => $requisition->load('items'), 'quotations' => $scores, 'requisition' => $requisition->load('items'), 'quotations' => $scores,
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']), 'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
'canCreate' => ! $closed && $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition), 'canCreate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
'canEvaluate' => ! $closed && $request->user()->purchasing_role !== PurchasingRole::Admin && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition), 'canEvaluate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]) && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
'ready' => $result['ready'], 'ready' => $result['ready'],
]); ]);
} }
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Procurement, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement]), 403);
$data = $request->validate([ $data = $request->validate([
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100', 'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
@@ -120,7 +120,7 @@ class QuotationController extends Controller
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{ {
$this->access($request); $this->access($request);
abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']); $data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
DB::transaction(function () use ($request, $quotation, $data, $workflow) { DB::transaction(function () use ($request, $quotation, $data, $workflow) {
$guard = app(ConflictGuard::class); $guard = app(ConflictGuard::class);
@@ -14,6 +14,7 @@ use App\Models\TechnicalReview;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\ConflictGuard; use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow; use App\Services\EquipmentWorkflow;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow; use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -51,6 +52,8 @@ class RequisitionController extends Controller
public function create(Request $request): Response public function create(Request $request): Response
{ {
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
return Inertia::render('requisitions/form', [ return Inertia::render('requisitions/form', [
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(), 'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'requisition' => null, 'requisition' => null,
@@ -72,7 +75,7 @@ class RequisitionController extends Controller
return Inertia::render('requisitions/show', [ return Inertia::render('requisitions/show', [
'requisition' => $requisition, 'requisition' => $requisition,
'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 'canEdit' => app(OrganizationAccess::class)->canRequest($request->user()) && $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
'canDecide' => $requisition->canDecide($request->user()), 'canDecide' => $requisition->canDecide($request->user()),
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition), 'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']), 'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
@@ -86,6 +89,7 @@ class RequisitionController extends Controller
public function edit(Request $request, Requisition $requisition): Response public function edit(Request $request, Requisition $requisition): Response
{ {
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403); abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
return Inertia::render('requisitions/form', [ return Inertia::render('requisitions/form', [
+2 -2
View File
@@ -21,7 +21,7 @@ class StockController extends Controller
private function access(Request $request, bool $manage = false): void private function access(Request $request, bool $manage = false): void
{ {
$roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance]; $roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance];
abort_unless(in_array($request->user()->purchasing_role, $roles), 403); abort_unless($request->user()->hasFunctions($roles), 403);
} }
public function index(Request $request): Response public function index(Request $request): Response
@@ -49,7 +49,7 @@ class StockController extends Controller
return Inertia::render('stock/index', [ return Inertia::render('stock/index', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances, 'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse]), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
]); ]);
} }
@@ -22,7 +22,7 @@ class StockOperationController extends Controller
{ {
public function index(Request $request, StockWorkflow $workflow): Response public function index(Request $request, StockWorkflow $workflow): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']); $items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']); $warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = []; $balances = [];
@@ -37,14 +37,14 @@ class StockOperationController extends Controller
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']), 'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20), 'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(), 'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
'canOperate' => $request->user()->purchasing_role === PurchasingRole::Warehouse, 'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]),
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']), 'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]); ]);
} }
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Warehouse, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]); $request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id; $tenant = $request->user()->tenant_id;
$data = $request->validate([ $data = $request->validate([
@@ -18,10 +18,10 @@ class SupplierBankController extends Controller
{ {
public function show(Request $request, Supplier $supplier, SupplierBankWorkflow $workflow): Response public function show(Request $request, Supplier $supplier, SupplierBankWorkflow $workflow): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403);
$details = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->get(); $details = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->get();
$current = $details->first(); $current = $details->first();
$canRead = in_array($request->user()->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]); $canRead = $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]);
$data = null; $data = null;
if ($current && $canRead) { if ($current && $canRead) {
app(RequisitionWorkflow::class)->audit($request->user(), 'supplier.bank_viewed', null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $current->id]); app(RequisitionWorkflow::class)->audit($request->user(), 'supplier.bank_viewed', null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $current->id]);
@@ -29,7 +29,7 @@ class SupplierBankController extends Controller
} }
Inertia::encryptHistory(); Inertia::encryptHistory();
$response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $current && $request->user()->purchasing_role === PurchasingRole::Finance && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request); $response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $current && $request->user()->hasFunctions([PurchasingRole::Finance]) && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0'); $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response; return $response;
+4 -4
View File
@@ -19,14 +19,14 @@ class SupplierController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$filters = $request->validate(['search' => 'nullable|string|max:100']); $filters = $request->validate(['search' => 'nullable|string|max:100']);
$query = Supplier::query(); $query = Supplier::query();
if (! empty($filters['search'])) { if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%')); $query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
} }
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance]); return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
} }
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
@@ -50,7 +50,7 @@ class SupplierController extends Controller
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [], 'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
'canEdit' => $request->user()->can('update', $supplier), 'canEdit' => $request->user()->can('update', $supplier),
'canDocuments' => $canDocuments, 'canDocuments' => $canDocuments,
'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance, 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance]),
]); ]);
} }
@@ -84,7 +84,7 @@ class SupplierController extends Controller
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{ {
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403); abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']); $data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']);
DB::transaction(function () use ($request, $supplier, $data, $workflow) { DB::transaction(function () use ($request, $supplier, $data, $workflow) {
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id); $supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\SupplierResponse;
use App\Models\User;
use App\Services\SupplierEvaluationWorkflow;
use App\Services\SupplierImprovementWorkflow;
use App\Services\SupplierResponseWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class SupplierEvaluationController extends Controller
{
private function ready(): void
{
abort_unless(Schema::hasTable('supplier_evaluations'), 503, 'A instalação do módulo de avaliações ainda não foi concluída.');
}
/** @return array<string,mixed> */
private function payload(Request $request): array
{
return $request->validate([
'payload' => 'required|array:title,nature,scope,period_start,period_end,order_ids,criteria,conclusion,critical_occurrences,critical_evidence',
'payload.title' => 'required|string|min:3|max:200', 'payload.nature' => 'required|in:goods,services',
'payload.scope' => 'nullable|string|max:10000', 'payload.period_start' => 'nullable|date_format:Y-m-d', 'payload.period_end' => 'nullable|date_format:Y-m-d',
'payload.order_ids' => 'present|array|max:50', 'payload.order_ids.*' => 'integer|distinct|min:1',
'payload.criteria' => 'present|array|max:30', 'payload.criteria.*' => 'required|array:label,result,reason,evidence',
'payload.criteria.*.label' => 'nullable|string|max:300', 'payload.criteria.*.result' => 'required|in:pending,observed,insufficient,not_applicable',
'payload.criteria.*.reason' => 'nullable|string|max:4000', 'payload.criteria.*.evidence' => 'nullable|string|max:4000',
'payload.conclusion' => 'nullable|string|max:10000', 'payload.critical_occurrences' => 'nullable|string|max:4000', 'payload.critical_evidence' => 'nullable|string|max:4000',
'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'status' => 'prohibited', 'contributors' => 'prohibited', 'supplier_snapshot' => 'prohibited',
])['payload'];
}
public function index(Request $request, SupplierEvaluationWorkflow $workflow): Response
{
$actor = $workflow->reader($request->user());
$filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'status' => 'nullable|in:draft,review,returned,approved']);
$ready = Schema::hasTable('supplier_evaluations');
$records = $ready ? SupplierEvaluation::query()->visibleTo($actor)
->when($filters['supplier_id'] ?? null, fn ($q, $id) => $q->where('supplier_id', $id))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->latest('id')->paginate(20)->withQueryString()->through(fn (SupplierEvaluation $e) => [
...$e->only(['id', 'status', 'revision', 'version', 'previous_id']), 'supplier' => $e->supplier_snapshot['name'],
'title' => $e->payload['title'], 'period_start' => $e->payload['period_start'], 'period_end' => $e->payload['period_end'],
]) : null;
return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'records' => $records, 'filters' => $filters,
'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name', 'nuit']),
'orders' => PurchaseOrder::query()->visibleTo($actor)->orderByDesc('id')->get(['id', 'number', 'supplier_id'])]);
}
public function show(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): Response
{
return $this->view($request, $evaluation, $workflow, false);
}
public function report(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): Response
{
return $this->view($request, $evaluation, $workflow, true);
}
private function view(Request $request, int $id, SupplierEvaluationWorkflow $workflow, bool $report): Response
{
$actor = $workflow->reader($request->user());
$this->ready();
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->findOrFail($id);
$improvementWorkflow = app(SupplierImprovementWorkflow::class);
$measuresReady = Schema::hasTable('supplier_improvements');
$measures = $measuresReady ? SupplierImprovement::query()->where('supplier_evaluation_id', $id)->orderBy('due_on')->orderBy('id')->get() : collect();
$measureHistory = $measuresReady ? AuditLog::query()->where('action', 'like', 'supplier_improvement.%')->where('after->supplier_evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'measures_history_page')->withQueryString() : null;
$responsesReady = Schema::hasTable('supplier_responses');
$responses = $responsesReady ? SupplierResponse::query()->where('supplier_evaluation_id', $id)->orderBy('id')->get() : collect();
$responseHistory = $responsesReady ? AuditLog::query()->where('action', 'like', 'supplier_response.%')->where('after->supplier_evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'responses_history_page')->withQueryString() : null;
$history = AuditLog::query()->where('action', 'like', 'supplier_evaluation.%')->where('after->evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'history_page')->withQueryString();
$peopleIds = collect($evaluation->contributors)->merge($history->pluck('user_id'))->merge($measures->flatMap(fn (SupplierImprovement $m) => $m->contributors))->merge($measures->pluck('reviewed_by'))->merge($measureHistory?->pluck('user_id') ?? []);
$peopleIds = $peopleIds->merge($responses->flatMap(fn (SupplierResponse $r) => $r->contributors))->merge($responses->pluck('reviewed_by'))->merge($responseHistory?->pluck('user_id') ?? []);
$people = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $peopleIds)->pluck('name', 'id');
$writable = $improvementWorkflow->canParticipate($actor, $evaluation);
$next = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->value('id');
return $this->render($request, 'suppliers/evaluation', ['evaluation' => $workflow->snapshot($evaluation), 'reportOnly' => $report,
'canEdit' => ! $report && $writable && in_array($evaluation->status, ['draft', 'returned'], true),
'canReview' => ! $report && $writable && $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true),
'canRevise' => ! $report && $writable && $evaluation->status === 'approved' && $next === null,
'nextRevisionId' => $next, 'history' => $history, 'people' => $people,
'responses' => ['ready' => $responsesReady, 'canCreate' => ! $report && $responsesReady && $writable && $evaluation->status === 'approved',
'history' => $responseHistory,
'records' => $responses->map(fn (SupplierResponse $r) => [...app(SupplierResponseWorkflow::class)->snapshot($r),
'canEdit' => ! $report && $writable && in_array($r->status, ['draft', 'returned'], true),
'canReview' => ! $report && $writable && $r->status === 'review' && ! in_array($actor->id, $r->contributors, true)])],
'improvements' => ['ready' => $measuresReady, 'canCreate' => ! $report && $measuresReady && $writable && $evaluation->status === 'approved',
'today' => today()->toDateString(),
'records' => $measures->map(fn (SupplierImprovement $m) => [...$improvementWorkflow->snapshot($m),
'canPlan' => ! $report && $writable && in_array($m->status, ['open', 'returned'], true),
'canExecute' => ! $report && $writable && $actor->id === $m->responsible_id && in_array($m->status, ['open', 'returned'], true),
'canReview' => ! $report && $writable && $m->status === 'review' && ! in_array($actor->id, $m->contributors, true)]),
'history' => $measureHistory,
'assignees' => $report ? [] : User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->filter(fn (User $u) => $u->hasFunctions(SupplierEvaluationWorkflow::ROLES) && (! $u->departmentScoped() || ($u->department_id !== null && $u->department_id === $evaluation->department_id)))->map(fn (User $u) => $u->only(['id', 'name']))->values()],
'orders' => PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $evaluation->supplier_id)->orderByDesc('id')->get(['id', 'number', 'supplier_id']),
'generatedAt' => now()->toIso8601String()]);
}
/** @param array<string,mixed> $props */
private function render(Request $request, string $component, array $props): Response
{
Inertia::encryptHistory();
$response = Inertia::render($component, [...$props, 'notice' => $request->session()->get('supplier_evaluation_notice')])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['supplier_id' => 'required|integer|min:1', 'operation_key' => 'required|uuid']);
$evaluation = $workflow->create($request->user(), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request));
return to_route('supplier-evaluations.show', $evaluation->id)->with('supplier_evaluation_notice', 'Rascunho guardado. Os dados podem ser recuperados nesta avaliação.');
}
public function update(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1']);
$workflow->save($request->user(), $evaluation, (int) $data['version'], $this->payload($request));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Alterações guardadas no histórico.');
}
public function transition(Request $request, int $evaluation, string $action, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1', 'reason' => $action === 'submit' ? 'nullable|string|max:4000' : 'required|string|min:10|max:4000',
'payload' => 'prohibited', 'supplier_id' => 'prohibited', 'tenant_id' => 'prohibited', 'reviewed_by' => 'prohibited']);
$workflow->transition($request->user(), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null);
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Estado e decisão registados.');
}
public function revise(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1']);
$revision = $workflow->revise($request->user(), $evaluation, (int) $data['version']);
return to_route('supplier-evaluations.show', $revision->id)->with('supplier_evaluation_notice', 'Nova revisão preparada. A avaliação aprovada anterior mantém-se preservada.');
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Services\SupplierImprovementWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
class SupplierImprovementController extends Controller
{
public function store(Request $request, int $evaluation, SupplierImprovementWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, null, 'create', $workflow);
}
public function update(Request $request, int $evaluation, int $measure, string $command, SupplierImprovementWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, $measure, $command, $workflow);
}
private function execute(Request $request, int $evaluation, ?int $measure, string $command, SupplierImprovementWorkflow $workflow): RedirectResponse
{
abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
$rules = ['tenant_id' => 'prohibited', 'supplier_evaluation_id' => 'prohibited', 'created_by' => 'prohibited', 'status' => 'prohibited', 'contributors' => 'prohibited', 'reviewed_by' => 'prohibited', 'reviewed_at' => 'prohibited'];
if ($command === 'create' || $command === 'plan') {
$rules += ['title' => 'required|string|min:3|max:200', 'description' => 'required|string|min:10|max:10000', 'responsible_id' => 'required|integer|min:1', 'due_on' => 'required|date_format:Y-m-d', 'result' => 'prohibited', 'evidence' => 'prohibited'];
} else {
$rules += ['title' => 'prohibited', 'description' => 'prohibited', 'responsible_id' => 'prohibited', 'due_on' => 'prohibited'];
}
$rules += $command === 'create' ? ['operation_key' => 'required|uuid', 'version' => 'prohibited', 'reason' => 'prohibited'] : ['operation_key' => 'prohibited', 'version' => 'required|integer|min:1'];
if (in_array($command, ['progress', 'submit'], true)) {
$rules += ['result' => 'nullable|string|max:10000', 'evidence' => 'nullable|string|max:10000', 'reason' => 'prohibited'];
} elseif ($command !== 'create') {
$rules += ['reason' => 'required|string|min:10|max:4000'];
if ($command !== 'plan') {
$rules += ['result' => 'prohibited', 'evidence' => 'prohibited'];
}
}
$workflow->execute($request->user(), $evaluation, $measure, $command, $request->validate($rules));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Medida de melhoria e histórico actualizados.');
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\User;
use App\Services\SupplierEvaluationWorkflow;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class SupplierImprovementReportController extends Controller
{
private function snapshot(\Closure $read): mixed
{
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
throw new \LogicException('O relatório exige uma transacção própria de leitura.');
}
return DB::transaction(function () use ($read) {
if (DB::getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
return $read();
});
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
$actor = app(SupplierEvaluationWorkflow::class)->reader($request->user());
$request->setUserResolver(fn () => $actor);
abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
$filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'responsible_id' => 'nullable|integer|min:1',
'status' => 'nullable|in:open,review,returned,closed', 'from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d',
'overdue' => 'nullable|in:1', 'search' => 'nullable|string|max:200']);
if (! empty($filters['from']) && ! empty($filters['to']) && $filters['to'] < $filters['from']) {
throw ValidationException::withMessages(['to' => 'O fim do intervalo deve ser igual ou posterior ao início.']);
}
return $filters;
}
/** @param array<string,mixed> $filters
* @return Builder<SupplierImprovement>
*/
private function query(Request $request, array $filters, string $today): Builder
{
$query = SupplierImprovement::query()->visibleTo($request->user());
if (! empty($filters['supplier_id'])) {
$query->whereHas('evaluation', fn ($q) => $q->where('supplier_id', $filters['supplier_id']));
}
foreach (['responsible_id', 'status'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
foreach (['from' => '>=', 'to' => '<='] as $field => $operator) {
if (! empty($filters[$field])) {
$query->where('due_on', $operator, $filters[$field]);
}
}
if (! empty($filters['overdue'])) {
$query->where('status', '!=', 'closed')->where('due_on', '<', $today);
}
if (isset($filters['search']) && $filters['search'] !== '') {
$query->whereRaw('LOWER(title) LIKE ?', ['%'.mb_strtolower($filters['search']).'%']);
}
return $query;
}
/** @param Builder<SupplierImprovement> $query
* @return Builder<SupplierImprovement>
*/
private function records(Builder $query, Request $request): Builder
{
return $query->with(['evaluation:id,supplier_id,supplier_snapshot,revision', 'responsible' => fn ($q) => $q->where('tenant_id', $request->user()->tenant_id)->select(['id', 'name'])]);
}
/** @return array<string,mixed> */
private function row(SupplierImprovement $m, string $today): array
{
return ['id' => $m->id, 'evaluation_id' => $m->supplier_evaluation_id, 'revision' => $m->evaluation?->revision,
'supplier' => $m->evaluation?->supplier_snapshot['name'] ?? 'Fornecedor não disponível',
'responsible' => $m->responsible->name ?? '#'.$m->responsible_id,
...$m->only(['title', 'description', 'due_on', 'status', 'version', 'result', 'evidence', 'review_reason', 'reviewed_at']),
'overdue' => $m->status !== 'closed' && $m->due_on < $today];
}
public function index(Request $request): Response
{
$props = $this->snapshot(function () use ($request): array {
$filters = $this->filters($request);
$today = today()->toDateString();
$all = SupplierImprovement::query()->visibleTo($request->user());
$query = $this->query($request, $filters, $today);
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($n) => (int) $n);
$overdue = (clone $query)->where('status', '!=', 'closed')->where('due_on', '<', $today)->count();
// Choices derive only from visible measures, including prior revisions and former assignees.
$evaluations = SupplierEvaluation::query()->visibleTo($request->user())->whereIn('id', (clone $all)->select('supplier_evaluation_id'))->orderByDesc('id')->get(['supplier_id', 'supplier_snapshot']);
return ['filters' => $filters, 'today' => $today, 'generatedAt' => now()->toIso8601String(), 'counts' => $counts, 'overdue' => $overdue,
'records' => $this->records($query, $request)->orderBy('due_on')->orderBy('id')->paginate(25)->withQueryString()->through(fn (SupplierImprovement $m) => $this->row($m, $today)),
'suppliers' => $evaluations->map(fn (SupplierEvaluation $e) => ['id' => $e->supplier_id, 'name' => $e->supplier_snapshot['name']])->unique('id')->values(),
'responsibles' => User::query()->where('tenant_id', $request->user()->tenant_id)->whereIn('id', (clone $all)->select('responsible_id'))->orderBy('name')->get(['id', 'name'])];
});
Inertia::encryptHistory();
$response = Inertia::render('reports/supplier-improvements', $props)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function csv(Request $request): Response
{
$filters = $this->filters($request);
$text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
return response()->streamDownload(fn () => $this->snapshot(function () use ($request, $filters, $text) {
// Refresh the profile inside the export snapshot as well.
$this->filters($request);
$today = today()->toDateString();
$out = fopen('php://output', 'w');
if ($out === false) {
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
}
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Medidas de melhoria de fornecedores', 'Consulta', now()->toIso8601String(), 'Data de referência', $today], ';', '"', '');
fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
fputcsv($out, ['Medida', 'Fornecedor preservado', 'Avaliação', 'Revisão', 'Título', 'Objectivo', 'Responsável', 'Prazo', 'Estado', 'Prazo ultrapassado', 'Versão', 'Resultado', 'Evidência', 'Última conferência', 'Conferida em', 'Avaliação e acompanhamento', 'Relatório individual'], ';', '"', '');
foreach ($this->records($this->query($request, $filters, $today), $request)->lazyById(200) as $m) {
$row = $this->row($m, $today);
$url = url('/fornecedores/avaliacoes/'.$m->supplier_evaluation_id);
fputcsv($out, ['MM-'.$m->id, $text($row['supplier']), 'AVF-'.$m->supplier_evaluation_id, $row['revision'], $text($m->title), $text($m->description), $text($row['responsible']), $m->due_on, $m->status, $row['overdue'] ? 'Sim' : 'Não', $m->version, $text($m->result), $text($m->evidence), $text($m->review_reason), $m->reviewed_at?->toIso8601String(), $url.'#medida-'.$m->id, $url.'/relatorio#medida-'.$m->id], ';', '"', '');
}
fclose($out);
}), 'medidas-fornecedores-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Services\SupplierResponseWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
class SupplierResponseController extends Controller
{
public function store(Request $request, int $evaluation, SupplierResponseWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, null, 'create', $workflow);
}
public function update(Request $request, int $evaluation, int $record, string $command, SupplierResponseWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, $record, $command, $workflow);
}
private function execute(Request $request, int $evaluation, ?int $record, string $command, SupplierResponseWorkflow $workflow): RedirectResponse
{
abort_unless(Schema::hasTable('supplier_responses'), 503, 'A instalação do contraditório ainda não está concluída.');
$rules = ['tenant_id' => 'prohibited', 'supplier_evaluation_id' => 'prohibited', 'created_by' => 'prohibited', 'contributors' => 'prohibited', 'status' => 'prohibited', 'decision' => 'prohibited', 'reviewed_by' => 'prohibited', 'reviewed_at' => 'prohibited'];
$rules += $command === 'create' ? ['operation_key' => 'required|uuid', 'version' => 'prohibited'] : ['operation_key' => 'prohibited', 'version' => 'required|integer|min:1'];
if (in_array($command, ['create', 'save'], true)) {
$rules += ['payload' => 'required|array:title,received_on,source_reference,statement', 'payload.title' => 'required|string|min:3|max:200',
'payload.received_on' => 'nullable|date_format:Y-m-d', 'payload.source_reference' => 'nullable|string|max:4000', 'payload.statement' => 'nullable|string|max:20000'];
} else {
$rules['payload'] = 'prohibited';
}
$rules['reason'] = in_array($command, ['return', 'decide'], true) ? 'required|string|min:10|max:10000' : 'prohibited';
if ($command === 'decide') {
$rules += ['outcome' => 'required|in:accepted,partially_accepted,not_accepted', 'follow_up' => 'nullable|string|max:10000', 'response_reference' => 'nullable|string|max:4000'];
} else {
$rules += ['outcome' => 'prohibited', 'follow_up' => 'prohibited', 'response_reference' => 'prohibited'];
}
$workflow->execute($request->user(), $evaluation, $record, $command, $request->validate($rules));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Contraditório e histórico actualizados.');
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOrganizationAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && $user->tenant_id !== null) {
abort_unless(app(OrganizationAccess::class)->permissions($user) !== [], 403, 'Não existe uma atribuição de acesso válida. Contacte os administradores da empresa.');
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe(), 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.');
}
$response = $next($request);
if ($user !== null && app(OrganizationAccess::class)->maintenanceOnly($user)) {
app(RequisitionWorkflow::class)->audit($user, 'maintenance.consulted', null, null, ['path' => $request->path(), 'status' => $response->getStatusCode()]);
}
return $response;
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\OrganizationAccess;
use App\Services\SupplierPayables; use App\Services\SupplierPayables;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -39,9 +40,15 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'canRequest' => $request->user() !== null && app(OrganizationAccess::class)->canRequest($request->user()),
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user(),
], ],
'organizationAccess' => $request->user() === null ? null : [
'permissions' => app(OrganizationAccess::class)->permissions($request->user()),
'active' => app(OrganizationAccess::class)->active($request->user()->tenant_id),
'profileLabel' => app(OrganizationAccess::class)->profileLabel($request->user()),
],
'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'), 'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
]; ];
+1 -1
View File
@@ -11,7 +11,7 @@ class SaveSupplierRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->purchasing_role === PurchasingRole::Procurement; return $this->user()?->hasFunctions([PurchasingRole::Procurement]) ?? false;
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
+1 -1
View File
@@ -41,7 +41,7 @@ class ConflictDeclaration extends Model
/** @param Builder<ConflictDeclaration> $query */ /** @param Builder<ConflictDeclaration> $query */
public function scopeVisibleTo(Builder $query, User $user): void public function scopeVisibleTo(Builder $query, User $user): void
{ {
if (! in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) { if (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
$query->where('user_id', $user->id); $query->where('user_id', $user->id);
} }
} }
+2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord; use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant; use App\Models\Concerns\BelongsToTenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $tenant_id * @property int $tenant_id
* @property int $purchase_order_id * @property int $purchase_order_id
* @property int $received_by * @property int $received_by
* @property CarbonInterface $received_on
* @property string $payload_hash * @property string $payload_hash
* @property array<string, mixed> $snapshot * @property array<string, mixed> $snapshot
*/ */
+5 -5
View File
@@ -18,11 +18,11 @@ class PurchasePlanItem extends Model
/** @param Builder<PurchasePlanItem> $query */ /** @param Builder<PurchasePlanItem> $query */
public function scopeVisibleTo(Builder $query, User $user): void public function scopeVisibleTo(Builder $query, User $user): void
{ {
match ($user->purchasing_role) { if ($user->departmentScoped()) {
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null, $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id));
PurchasingRole::DepartmentHead => $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id)), } elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
default => $query->where('responsible_id', $user->id), $query->where('responsible_id', $user->id);
}; }
} }
protected static function booted(): void protected static function booted(): void
+1 -1
View File
@@ -7,7 +7,7 @@ use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* @property array{analyst:array{id:int,name:string,purchasing_role:string},basis_hash:string} $snapshot * @property array{analyst:array{id:int,name:string,purchasing_role:string,profile_label?:string},basis_hash:string} $snapshot
* @property list<array{criterion_id:int,result:string,evidence:string,reason:string}> $answers * @property list<array{criterion_id:int,result:string,evidence:string,reason:string}> $answers
*/ */
class QualificationAssessment extends Model class QualificationAssessment extends Model
+6 -6
View File
@@ -68,11 +68,11 @@ class Requisition extends Model
/** @param Builder<Requisition> $query */ /** @param Builder<Requisition> $query */
public function scopeVisibleTo(Builder $query, User $user): void public function scopeVisibleTo(Builder $query, User $user): void
{ {
match ($user->purchasing_role) { if ($user->departmentScoped()) {
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null, $query->where('department_id', $user->department_id);
PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id), } elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
default => $query->where('requester_id', $user->id), $query->where('requester_id', $user->id);
}; }
} }
public function canDecide(User $user): bool public function canDecide(User $user): bool
@@ -87,7 +87,7 @@ class Requisition extends Model
$next = $this->steps->firstWhere('decision', 'pending'); $next = $this->steps->firstWhere('decision', 'pending');
return $next !== null && $next->role === $user->purchasing_role->value return $next !== null && $user->hasFunctions([$next->role])
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id) && ($next->role !== 'department_head' || $this->department_id === $user->department_id)
&& ! app(ConflictGuard::class)->participationBlocked($user, $this); && ! app(ConflictGuard::class)->participationBlocked($user, $this);
} }
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $supplier_id
* @property int|null $department_id
* @property int $created_by
* @property int $version
* @property int $revision
* @property string $status
* @property array<string,mixed> $payload
* @property array<string,mixed> $supplier_snapshot
* @property list<int> $contributors
*/
class SupplierEvaluation extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1, 'revision' => 1, 'status' => 'draft'];
protected function casts(): array
{
return ['payload' => 'array', 'supplier_snapshot' => 'array', 'contributors' => 'array', 'reviewed_at' => 'datetime'];
}
/** @param Builder<SupplierEvaluation> $query */
public function scopeVisibleTo(Builder $query, User $actor): void
{
$query->where('tenant_id', $actor->tenant_id);
if ($actor->departmentScoped()) {
$query->where('department_id', $actor->department_id ?? 0);
}
}
protected static function booted(): void
{
static::updating(function (self $evaluation) {
if ($evaluation->getRawOriginal('status') === 'approved') {
throw new \LogicException('A avaliação aprovada conserva-se; prepare uma nova revisão.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar o histórico de avaliações.'));
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $tenant_id
* @property int $supplier_evaluation_id
* @property int $created_by
* @property int $responsible_id
* @property int $version
* @property string $status
* @property list<int> $contributors
* @property CarbonInterface|null $reviewed_at
*/
class SupplierImprovement extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1, 'status' => 'open'];
protected function casts(): array
{
return ['contributors' => 'array', 'reviewed_at' => 'datetime'];
}
/** @param Builder<SupplierImprovement> $query */
public function scopeVisibleTo(Builder $query, User $actor): void
{
$query->where('tenant_id', $actor->tenant_id)->whereIn('supplier_evaluation_id', SupplierEvaluation::query()->visibleTo($actor)->select('id'));
}
/** @return BelongsTo<SupplierEvaluation, $this> */
public function evaluation(): BelongsTo
{
return $this->belongsTo(SupplierEvaluation::class, 'supplier_evaluation_id');
}
/** @return BelongsTo<User, $this> */
public function responsible(): BelongsTo
{
return $this->belongsTo(User::class, 'responsible_id');
}
protected static function booted(): void
{
static::updating(function (self $measure) {
if ($measure->getRawOriginal('status') === 'closed') {
throw new \LogicException('A medida encerrada conserva-se no histórico.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar as medidas de melhoria.'));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $tenant_id
* @property int $supplier_evaluation_id
* @property int $created_by
* @property int $version
* @property string $status
* @property array<string,mixed> $payload
* @property list<int> $contributors
* @property array<string,mixed>|null $decision
*/
class SupplierResponse extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'draft', 'version' => 1];
protected function casts(): array
{
return ['payload' => 'array', 'contributors' => 'array', 'decision' => 'array', 'reviewed_at' => 'datetime'];
}
protected static function booted(): void
{
static::updating(function (self $record) {
if ($record->getRawOriginal('status') === 'decided') {
throw new \LogicException('A conclusão registada conserva-se no histórico.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar o contraditório do fornecedor.'));
}
}
+13 -1
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\PurchasingRole; use App\Enums\PurchasingRole;
use App\Services\OrganizationAccess;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -15,7 +16,7 @@ use Illuminate\Support\Carbon;
* @property int $id * @property int $id
* @property int|null $tenant_id * @property int|null $tenant_id
* @property int|null $department_id * @property int|null $department_id
* @property PurchasingRole $purchasing_role * @property PurchasingRole|null $purchasing_role
* @property string $name * @property string $name
* @property string $email * @property string $email
* @property Carbon|null $email_verified_at * @property Carbon|null $email_verified_at
@@ -31,6 +32,17 @@ use Illuminate\Support\Carbon;
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @param list<PurchasingRole|string> $functions */
public function hasFunctions(array $functions): bool
{
return app(OrganizationAccess::class)->allows($this, $functions);
}
public function departmentScoped(): bool
{
return app(OrganizationAccess::class)->departmentScoped($this);
}
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
+3 -3
View File
@@ -12,13 +12,13 @@ class GoodsReceiptPolicy
{ {
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]); return $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]);
} }
public function viewOrder(User $user, PurchaseOrder $order): bool public function viewOrder(User $user, PurchaseOrder $order): bool
{ {
return $this->viewAny($user) && $user->tenant_id === $order->tenant_id && return $this->viewAny($user) && $user->tenant_id === $order->tenant_id &&
($user->purchasing_role !== PurchasingRole::DepartmentHead || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists()); (! $user->departmentScoped() || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists());
} }
public function view(User $user, GoodsReceipt $receipt): bool public function view(User $user, GoodsReceipt $receipt): bool
@@ -30,6 +30,6 @@ class GoodsReceiptPolicy
public function create(User $user, PurchaseOrder $order): bool public function create(User $user, PurchaseOrder $order): bool
{ {
return $user->tenant_id === $order->tenant_id && $user->purchasing_role === PurchasingRole::Warehouse && $user->id !== $order->issued_by; return $user->tenant_id === $order->tenant_id && $user->hasFunctions([PurchasingRole::Warehouse]) && $user->id !== $order->issued_by;
} }
} }
+3 -3
View File
@@ -10,7 +10,7 @@ class SupplierPolicy
{ {
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]); return $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
} }
public function view(User $user, Supplier $supplier): bool public function view(User $user, Supplier $supplier): bool
@@ -20,11 +20,11 @@ class SupplierPolicy
public function update(User $user, Supplier $supplier): bool public function update(User $user, Supplier $supplier): bool
{ {
return $user->purchasing_role === PurchasingRole::Procurement && $user->tenant_id === $supplier->tenant_id; return $user->hasFunctions([PurchasingRole::Procurement]) && $user->tenant_id === $supplier->tenant_id;
} }
public function viewDocuments(User $user, Supplier $supplier): bool public function viewDocuments(User $user, Supplier $supplier): bool
{ {
return $this->view($user, $supplier) && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]); return $this->view($user, $supplier) && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
} }
} }
-6
View File
@@ -16,14 +16,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$package = config('common-budget.path'); $package = config('common-budget.path');
if (! is_file($package.'/bootstrap.php') && ! $this->app->environment('production')) {
$package = base_path('../sgf-tempo/packages/budget-control');
}
require_once $package.'/bootstrap.php'; require_once $package.'/bootstrap.php';
$payables = config('common-payables.path'); $payables = config('common-payables.path');
if (! is_file($payables.'/bootstrap.php') && ! $this->app->environment('production')) {
$payables = base_path('../sgf-tempo/packages/payables');
}
require_once $payables.'/bootstrap.php'; require_once $payables.'/bootstrap.php';
} }
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\ResetUserPassword;
use App\Services\EvaluationNavigation;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -48,7 +49,7 @@ class FortifyServiceProvider extends ServiceProvider
*/ */
private function configureViews(): void private function configureViews(): void
{ {
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [ Fortify::loginView(fn (Request $request) => EvaluationNavigation::local($request) ? redirect('/avaliar') : Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()), 'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'), 'status' => $request->session()->get('status'),
])); ]));
+1 -1
View File
@@ -36,7 +36,7 @@ final class CommonBudget
app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data); app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data);
} }
}, },
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(), fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->first()?->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]) ?? false,
); );
} }
+1 -1
View File
@@ -18,7 +18,7 @@ class CurrentActor
throw new \LogicException('Revalidar o operador dentro da transacção.'); throw new \LogicException('Revalidar o operador dentro da transacção.');
} }
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id); $user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id);
abort_unless($roles === null || in_array($user->purchasing_role, $roles, true), 403); abort_unless($roles === null || $user->hasFunctions($roles), 403);
return $user; return $user;
} }
@@ -110,7 +110,7 @@ class DocumentaryQualificationWorkflow
} }
$answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason]; $answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason];
} }
$assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => $actor->only(['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]); $assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]); app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]);
return $assessment; return $assessment;
+1 -1
View File
@@ -59,7 +59,7 @@ class EquipmentWorkflow
private function eligible(User $user, Requisition $requisition): bool private function eligible(User $user, Requisition $requisition): bool
{ {
return $requisition->tenant_id === $user->tenant_id && $user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition); return $requisition->tenant_id === $user->tenant_id && $user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
} }
public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Http\Request;
final class EvaluationNavigation
{
public static function local(Request $request): bool
{
return app()->environment('payable-testing')
&& in_array($request->ip(), ['127.0.0.1', '::1'], true)
&& in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true);
}
public static function returnPath(mixed $intended, Request $request): string
{
$fallback = '/dashboard';
if (! is_string($intended) || preg_match('/[\x00-\x20\\\\]/', $intended)) {
return $fallback;
}
$parts = parse_url($intended);
if ($parts === false) {
return $fallback;
}
if (isset($parts['scheme']) || isset($parts['host'])) {
$port = $parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80);
if (($parts['scheme'] ?? '') !== $request->getScheme() || ($parts['host'] ?? '') !== $request->getHost() || $port !== $request->getPort()) {
return $fallback;
}
}
$path = $parts['path'] ?? '';
if (! str_starts_with($path, '/') || str_starts_with($path, '//')
|| in_array($path, ['/login', '/logout', '/avaliar', '/avaliar/entrar'], true)) {
return $fallback;
}
return $path.(isset($parts['query']) ? '?'.$parts['query'] : '');
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ class ExternalPaymentWorkflow
{ {
private function actor(User $user, int $tenantId): void private function actor(User $user, int $tenantId): void
{ {
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->purchasing_role === PurchasingRole::Finance, 403); abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
} }
private function invalid(string $message): never private function invalid(string $message): never
@@ -127,7 +127,7 @@ class ExternalPaymentWorkflow
} }
$this->bank($user, $invoice, $payment->supplier_bank_detail_id, $payment->amount_minor); $this->bank($user, $invoice, $payment->supplier_bank_detail_id, $payment->amount_minor);
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($payment->created_by); $author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($payment->created_by);
if (! $author || $author->purchasing_role !== PurchasingRole::Finance) { if (! $author || ! $author->hasFunctions([PurchasingRole::Finance])) {
$this->invalid('O autor do registo deixou de ser elegível.'); $this->invalid('O autor do registo deixou de ser elegível.');
} }
} }
+2 -2
View File
@@ -174,7 +174,7 @@ class FormalAdmission
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
{ {
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id); return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
} }
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
@@ -213,7 +213,7 @@ class FormalAdmission
} }
} }
} }
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]); $review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray()); app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray());
}); });
} }
+2 -2
View File
@@ -152,7 +152,7 @@ class FormalRecommendation
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
{ {
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id); return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
} }
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
@@ -191,7 +191,7 @@ class FormalRecommendation
} }
} }
} }
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]); $review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray()); app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray());
}); });
} }
+2 -2
View File
@@ -19,7 +19,7 @@ class InvestmentWorkflow
{ {
private function admin(User $user): void private function admin(User $user): void
{ {
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403); abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
} }
/** @param array<string,mixed> $data */ /** @param array<string,mixed> $data */
@@ -68,7 +68,7 @@ class InvestmentWorkflow
if ($decision === 'approved') { if ($decision === 'approved') {
$ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique(); $ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get(); $actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) { if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']); throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
} }
} }
+1 -1
View File
@@ -19,7 +19,7 @@ class InvoiceWorkflow
{ {
private function access(User $user): void private function access(User $user): void
{ {
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Finance, 403); abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
} }
private function invalid(string $message): never private function invalid(string $message): never
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class OrganizationAccess
{
public const PERMISSIONS = [
'requester' => 'Solicitar e acompanhar compras próprias',
'department_head' => 'Supervisão e aprovação do departamento',
'procurement' => 'Preparação e gestão operacional de compras',
'finance' => 'Conferência e operações financeiras',
'warehouse' => 'Recepção e operações de armazém',
'admin' => 'Administrar a empresa, utilizadores e parametrização',
];
public function active(?int $tenant): bool
{
return $tenant !== null && Schema::hasTable('organization_access') && DB::table('organization_access')->where('tenant_id', $tenant)->where('active', true)->exists();
}
/** @return list<string> */
public function permissions(User $user): array
{
if ($user->tenant_id === null) {
return [];
}
if (! $this->active($user->tenant_id)) {
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
}
$rows = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.permissions']);
$permissions = [];
foreach ($rows as $row) {
$permissions = [...$permissions, ...json_decode($row->permissions, true, 32, JSON_THROW_ON_ERROR)];
}
return array_values(array_unique($permissions));
}
/** @param list<PurchasingRole|string> $functions */
public function allows(User $user, array $functions): bool
{
$requested = array_map(fn ($f) => $f instanceof PurchasingRole ? $f->value : $f, $functions);
return $user->tenant_id !== null && count(array_intersect($this->permissions($user), $requested)) > 0;
}
public function maintenanceOnly(User $user): bool
{
if (! $this->active($user->tenant_id)) {
return false;
}
$valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
}
public function departmentScoped(User $user): bool
{
$permissions = $this->permissions($user);
return in_array('department_head', $permissions, true) && ! array_intersect($permissions, ['admin', 'procurement', 'finance']);
}
public function canRequest(User $user): bool
{
return $user->tenant_id !== null && ($this->active($user->tenant_id) ? $this->allows($user, ['requester']) : ($user->purchasing_role !== null && $user->purchasing_role !== PurchasingRole::Configured));
}
public function profileLabel(User $user): string
{
if (! $this->active($user->tenant_id)) {
return $user->purchasing_role?->label() ?? 'Utilizador';
}
return DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->orderBy('p.name')->pluck('p.name')->unique()->implode(' · ');
}
/** @param list<string> $fields
* @return array<string,mixed>
*/
public function actorSnapshot(User $user, array $fields): array
{
$snapshot = $user->only($fields);
if ($this->active($user->tenant_id)) {
if (array_key_exists('purchasing_role', $snapshot)) {
$snapshot['purchasing_role'] = $user->purchasing_role->value ?? '';
}
$snapshot['profile_label'] = $this->profileLabel($user);
}
return $snapshot;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Tenant;
use App\Models\User;
use Brick\Math\BigDecimal;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class OrganizationConfiguration
{
/** @param array<string,mixed> $data
* @return array<string,int|bool>
*/
public static function rules(array $data): array
{
$first = BigDecimal::of($data['first_limit'])->multipliedBy(100)->toInt();
$second = BigDecimal::of($data['second_limit'])->multipliedBy(100)->toInt();
if ($second < $first) {
throw ValidationException::withMessages(['second_limit' => 'O segundo limite deve ser igual ou superior ao primeiro.']);
}
return ['first_limit_minor' => $first, 'second_limit_minor' => $second, 'approval_includes_tax' => (bool) $data['includes_tax'], 'rules_provisional' => false];
}
public function administrator(User $actor): User
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless($actor->hasFunctions(['admin']), 403);
return $actor;
}
/** @param array<string,mixed> $data */
public function execute(User $actor, string $command, array $data): void
{
DB::transaction(function () use ($actor, $command, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = $this->administrator($actor);
DB::table('organization_access')->insertOrIgnore(['tenant_id' => $actor->tenant_id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first();
if ((int) $data['version'] !== (int) $state->version) {
throw ValidationException::withMessages(['version' => 'A parametrização foi alterada. Actualize e compare antes de guardar.']);
}
$before = null;
if ($command === 'profile') {
$profile = isset($data['id']) ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('id', $data['id'])->first() : null;
abort_if(isset($data['id']) && ! $profile, 404);
if ($profile && in_array('admin', $data['permissions'], true) && DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('profile_id', $profile->id)->where('maintenance', true)->exists()) {
throw ValidationException::withMessages(['permissions' => 'Retire as atribuições de manutenção antes de dar administração a este perfil.']);
}
$before = $profile ? (array) $profile : null;
$values = ['name' => $data['name'], 'permissions' => json_encode(array_values(array_unique($data['permissions'])), JSON_THROW_ON_ERROR), 'updated_at' => now()];
if ($profile) {
DB::table('organization_profiles')->where('id', $profile->id)->update([...$values, 'version' => $profile->version + 1]);
$id = $profile->id;
} else {
$id = DB::table('organization_profiles')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'created_at' => now()]);
}
$after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions']];
} elseif ($command === 'user') {
$department = DB::table('departments')->where('tenant_id', $actor->tenant_id)->where('id', $data['department_id'])->exists();
abort_unless($department, 422, 'Departamento não autorizado.');
$user = new User;
$user->forceFill(['tenant_id' => $actor->tenant_id, 'department_id' => $data['department_id'], 'name' => $data['name'], 'email' => $data['email'], 'password' => $data['password'], 'purchasing_role' => PurchasingRole::Configured])->save();
$this->assign($actor, $user->id, $data['profile_ids'], false, null, $data['reason']);
$after = [...$user->only(['id', 'name', 'email', 'department_id']), 'profile_ids' => $data['profile_ids'], 'reason' => $data['reason']];
} elseif ($command === 'assign') {
$target = User::query()->where('tenant_id', $actor->tenant_id)->whereKey((int) $data['user_id'])->firstOrFail();
$before = ['user_id' => $target->id, 'memberships' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $target->id)->get(['profile_id', 'maintenance', 'expires_at', 'reason'])->map(fn ($m) => (array) $m)->all()];
if (($data['maintenance'] ?? false) && ! $state->active) {
throw ValidationException::withMessages(['maintenance' => 'Active a parametrização antes de atribuir manutenção temporária.']);
}
$this->assign($actor, $target->id, $data['profile_ids'], $data['maintenance'] ?? false, $data['expires_at'] ?? null, $data['reason']);
$after = ['user_id' => $target->id, 'profile_ids' => $data['profile_ids'], 'maintenance' => $data['maintenance'] ?? false, 'expires_at' => $data['expires_at'] ?? null, 'reason' => $data['reason']];
} elseif ($command === 'rules') {
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
$after = [...self::rules($data), 'reason' => $data['reason']];
$company->fill(self::rules($data))->save();
} else {
abort_unless($command === 'activate', 404);
if (Tenant::query()->findOrFail($actor->tenant_id)->rules_provisional) {
throw ValidationException::withMessages(['rules' => 'Defina e guarde as regras de aprovação da empresa antes da activação.']);
}
DB::table('organization_access')->where('id', $state->id)->update(['active' => true]);
$after = ['active' => true, 'reason' => $data['reason']];
}
if ($state->active || $command === 'activate') {
$this->twoAdministrators((int) $actor->tenant_id);
}
DB::table('organization_access')->where('id', $state->id)->update(['version' => $state->version + 1, 'updated_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'organization.'.$command, null, $before, $after);
});
}
/** @param list<int> $profiles */
private function assign(User $actor, int $user, array $profiles, bool $maintenance, ?string $expires, string $reason): void
{
$ids = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $profiles)->pluck('id')->all();
if (count($ids) !== count($profiles)) {
throw ValidationException::withMessages(['profile_ids' => 'Escolha apenas perfis desta empresa.']);
}
if ($maintenance && ($expires === null || now()->parse($expires)->isPast())) {
throw ValidationException::withMessages(['expires_at' => 'O acesso de manutenção exige uma data futura de expiração.']);
}
if ($maintenance && DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $ids)->pluck('permissions')->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
throw ValidationException::withMessages(['profile_ids' => 'A manutenção temporária não pode administrar os acessos da empresa.']);
}
DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $user)->delete();
foreach ($ids as $id) {
DB::table('organization_memberships')->insert(['tenant_id' => $actor->tenant_id, 'user_id' => $user, 'profile_id' => $id, 'maintenance' => $maintenance, 'expires_at' => $expires === null ? null : CarbonImmutable::parse($expires)->utc()->format('Y-m-d H:i:s'), 'reason' => $reason, 'created_at' => now(), 'updated_at' => now()]);
}
}
private function twoAdministrators(int $tenant): void
{
$count = 0;
foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
$count++;
}
}
if ($count < 2) {
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
}
}
public function initialise(User $first, User $second): void
{
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $first->tenant_id, 'name' => 'Administração da empresa', 'permissions' => json_encode(['admin'], JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
foreach ([$first, $second] as $user) {
$this->assign($first, $user->id, [$profile], false, null, 'Configuração inicial e entrega do produto.');
}
DB::table('organization_access')->insert(['tenant_id' => $first->tenant_id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
}
}
+17 -1
View File
@@ -3,6 +3,9 @@
namespace App\Services; namespace App\Services;
use App\Enums\PurchasingRole; use App\Enums\PurchasingRole;
use App\Models\ExternalPayment;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\ProposalOpening; use App\Models\ProposalOpening;
use App\Models\PurchaseAward; use App\Models\PurchaseAward;
use App\Models\PurchaseOrder; use App\Models\PurchaseOrder;
@@ -28,7 +31,7 @@ class ProcurementReviewReport
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'); DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
} }
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id); $actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless(in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request = Requisition::query()->visibleTo($actor)->with(['department:id,name', 'requester:id,name'])->findOrFail($requisitionId); $request = Requisition::query()->visibleTo($actor)->with(['department:id,name', 'requester:id,name'])->findOrFail($requisitionId);
$comparison = app(ProposalComparison::class)->inspect($request); $comparison = app(ProposalComparison::class)->inspect($request);
$rows = []; $rows = [];
@@ -59,6 +62,14 @@ class ProcurementReviewReport
$opening = ProposalOpening::query()->where('requisition_id', $request->id)->latest('revision')->first(); $opening = ProposalOpening::query()->where('requisition_id', $request->id)->latest('revision')->first();
$award = PurchaseAward::query()->where('requisition_id', $request->id)->first(); $award = PurchaseAward::query()->where('requisition_id', $request->id)->first();
$order = PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $request->id)->first(); $order = PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $request->id)->first();
$receipts = $order ? GoodsReceipt::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'received_on', 'delivery_note'])->map(fn (GoodsReceipt $receipt) => [
'id' => $receipt->id, 'received_on' => $receipt->received_on->format('Y-m-d'), 'delivery_note' => $receipt->delivery_note,
])->all() : [];
$financeVisible = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
$invoices = $financeVisible && $order ? Invoice::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'number', 'status', 'issued_on', 'total_minor', 'snapshot']) : collect();
$payments = $financeVisible ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->orderBy('id')->get(['id', 'invoice_id', 'status', 'amount_minor', 'currency', 'paid_on'])->map(fn ($payment) => [
'id' => $payment->id, 'invoice_id' => $payment->invoice_id, 'status' => $payment->status, 'amount_minor' => (string) $payment->amount_minor, 'currency' => $payment->currency, 'paid_on' => $payment->paid_on,
])->all() : null;
return [ return [
'generated_at' => now()->toIso8601String(), 'generated_by' => $actor->name, 'generated_at' => now()->toIso8601String(), 'generated_by' => $actor->name,
@@ -71,6 +82,11 @@ class ProcurementReviewReport
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null, 'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
'award' => $award?->only(['id', 'status', 'total_minor']), 'award' => $award?->only(['id', 'status', 'total_minor']),
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']), 'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
'fulfilment' => [
'receipts' => $receipts, 'finance_visible' => $financeVisible,
'invoices' => $financeVisible ? $invoices->map(fn (Invoice $invoice) => ['id' => $invoice->id, 'number' => $invoice->number, 'status' => $invoice->status, 'issued_on' => $invoice->issued_on, 'total_minor' => (string) $invoice->total_minor, 'currency' => $invoice->snapshot['currency'] ?? null])->all() : null,
'payments' => $payments,
],
]; ];
}); });
} }
+2 -2
View File
@@ -121,8 +121,8 @@ class ProposalComparison
public function eligibleRole(User $user, Requisition $requisition): bool public function eligibleRole(User $user, Requisition $requisition): bool
{ {
return $user->tenant_id === $requisition->tenant_id && return $user->tenant_id === $requisition->tenant_id &&
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) || ($user->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]) ||
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id)); ($user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id));
} }
/** @param array<string, mixed> $weights */ /** @param array<string, mixed> $weights */
@@ -13,7 +13,7 @@ class PurchaseControlPolicyWorkflow
{ {
private function admin(User $user): void private function admin(User $user): void
{ {
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403); abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
} }
/** @param array<string,mixed> $data */ /** @param array<string,mixed> $data */
@@ -62,7 +62,7 @@ class PurchaseControlPolicyWorkflow
if ($decision === 'approved') { if ($decision === 'approved') {
$ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique(); $ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get(); $actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) { if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']); throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@ class PurchasePlanningWorkflow
{ {
private function manager(User $actor): bool private function manager(User $actor): bool
{ {
return in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]); return $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
} }
private function responsible(User $actor, int $id): void private function responsible(User $actor, int $id): void
+4 -4
View File
@@ -29,7 +29,7 @@ class PurchaseWorkflow
$this->guard->lockCompany($user); $this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]); $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403); abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) { if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']); throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
} }
@@ -70,7 +70,7 @@ class PurchaseWorkflow
$steps = $award->approvals()->get(); $steps = $award->approvals()->get();
$next = $steps->firstWhere('decision', 'pending'); $next = $steps->firstWhere('decision', 'pending');
return $next !== null && $next->role === $user->purchasing_role->value && return $next !== null && $user->hasFunctions([$next->role]) &&
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) && ($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition); ! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
} }
@@ -103,7 +103,7 @@ class PurchaseWorkflow
return DB::transaction(function () use ($user, $award) { return DB::transaction(function () use ($user, $award) {
$this->guard->lockCompany($user); $this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]); $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403); abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id); $requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id); $award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
// An already committed issue is returned, never re-created or re-snapshotted. // An already committed issue is returned, never re-created or re-snapshotted.
@@ -281,7 +281,7 @@ class PurchaseWorkflow
} }
foreach ($steps as $index => $step) { foreach ($steps as $index => $step) {
$user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id); $user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id);
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role || if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || ! $user->hasFunctions([$step->role]) ||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) { ($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']); throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
} }
+2
View File
@@ -20,6 +20,7 @@ class RequisitionWorkflow
return DB::transaction(function () use ($user, $data, $existing) { return DB::transaction(function () use ($user, $data, $existing) {
app(ConflictGuard::class)->lockCompany($user); app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user); $user = app(CurrentActor::class)->lock($user);
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) { if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']); throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
} }
@@ -76,6 +77,7 @@ class RequisitionWorkflow
DB::transaction(function () use ($user, $requisition) { DB::transaction(function () use ($user, $requisition) {
app(ConflictGuard::class)->lockCompany($user); app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user); $user = app(CurrentActor::class)->lock($user);
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403); abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
if ($user->department_id !== $requisition->department_id) { if ($user->department_id !== $requisition->department_id) {
+1 -1
View File
@@ -27,7 +27,7 @@ class StockWorkflow
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */ /** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */
public function record(User $actor, array $data): StockOperation public function record(User $actor, array $data): StockOperation
{ {
abort_unless($actor->tenant_id !== null && $actor->purchasing_role === PurchasingRole::Warehouse && auth()->id() === $actor->id, 403); abort_unless($actor->tenant_id !== null && $actor->hasFunctions([PurchasingRole::Warehouse]) && auth()->id() === $actor->id, 403);
return DB::transaction(function () use ($actor, $data) { return DB::transaction(function () use ($actor, $data) {
// Same first lock as receipts: includes empty balances and concurrent receipts. // Same first lock as receipts: includes empty balances and concurrent receipts.
+3 -3
View File
@@ -24,7 +24,7 @@ class SupplierBankWorkflow
public function propose(User $user, Supplier $supplier, array $data): SupplierBankDetail public function propose(User $user, Supplier $supplier, array $data): SupplierBankDetail
{ {
$this->actor($user, $supplier); $this->actor($user, $supplier);
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403); abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
return DB::transaction(function () use ($user, $supplier, $data) { return DB::transaction(function () use ($user, $supplier, $data) {
app(ConflictGuard::class)->lockCompany($user); app(ConflictGuard::class)->lockCompany($user);
@@ -44,7 +44,7 @@ class SupplierBankWorkflow
public function review(User $user, SupplierBankDetail $detail, string $decision, string $reason): void public function review(User $user, SupplierBankDetail $detail, string $decision, string $reason): void
{ {
abort_unless($user->purchasing_role === PurchasingRole::Finance && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403); abort_unless($user->hasFunctions([PurchasingRole::Finance]) && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') { if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
throw ValidationException::withMessages(['bank' => 'Decisão e fundamento obrigatórios.']); throw ValidationException::withMessages(['bank' => 'Decisão e fundamento obrigatórios.']);
} }
@@ -92,7 +92,7 @@ class SupplierBankWorkflow
} }
foreach ($reviews as $review) { foreach ($reviews as $review) {
$user = User::query()->where('tenant_id', $supplier->tenant_id)->find($review->reviewed_by); $user = User::query()->where('tenant_id', $supplier->tenant_id)->find($review->reviewed_by);
if (! $user || $user->purchasing_role !== PurchasingRole::Finance || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) { if (! $user || ! $user->hasFunctions([PurchasingRole::Finance]) || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
return 'blocked'; return 'blocked';
} }
} }
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\SupplierEvaluation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplierEvaluationWorkflow
{
public const ROLES = [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead];
public function reader(User $actor): User
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
$fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless($fresh->hasFunctions(self::ROLES), 403);
return $fresh;
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function normalise(User $actor, Supplier $supplier, array $payload): array
{
$orders = array_values(array_unique(array_map('intval', $payload['order_ids'] ?? [])));
sort($orders);
$payload = ['title' => $payload['title'], 'nature' => $payload['nature'], 'scope' => $payload['scope'] ?? '',
'period_start' => $payload['period_start'] ?? null, 'period_end' => $payload['period_end'] ?? null,
'order_ids' => $orders,
'criteria' => array_map(fn ($row) => ['label' => $row['label'] ?? '', 'result' => $row['result'], 'reason' => $row['reason'] ?? '', 'evidence' => $row['evidence'] ?? ''], $payload['criteria']),
'conclusion' => $payload['conclusion'] ?? '', 'critical_occurrences' => $payload['critical_occurrences'] ?? '', 'critical_evidence' => $payload['critical_evidence'] ?? ''];
if ($payload['period_start'] && $payload['period_end'] && $payload['period_end'] < $payload['period_start']) {
throw ValidationException::withMessages(['payload.period_end' => 'O fim do período deve ser igual ou posterior ao início.']);
}
$orders = PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $supplier->id)->whereIn('id', $payload['order_ids'])->count();
if ($orders !== count($payload['order_ids'])) {
throw ValidationException::withMessages(['payload.order_ids' => 'Escolha apenas ordens autorizadas deste fornecedor.']);
}
foreach (PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->pluck('requisition_id') as $requisitionId) {
abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, (int) $requisitionId), 403, 'Existe uma declaração de conflito num dos processos abrangidos.');
}
return $payload;
}
/** @param array<string,mixed> $payload */
private function department(User $actor, array $payload): ?int
{
if (! $payload['order_ids']) {
return $actor->department_id;
}
$departments = Requisition::query()->whereIn('id', PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->select('requisition_id'))->distinct()->pluck('department_id');
return $departments->count() === 1 ? (int) $departments->first() : null;
}
private function actor(User $actor): User
{
app(ConflictGuard::class)->lockCompany($actor);
return app(CurrentActor::class)->lock($actor, self::ROLES);
}
private function participating(User $actor, int $supplierId): void
{
abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplierId, null), 403, 'Existe uma declaração de conflito com este fornecedor.');
}
/** @return array<string,mixed> */
public function snapshot(SupplierEvaluation $evaluation): array
{
return ['evaluation_id' => $evaluation->id, ...$evaluation->only(['id', 'supplier_id', 'department_id', 'created_by', 'submitted_by', 'reviewed_by', 'previous_id', 'revision', 'version', 'status', 'payload', 'supplier_snapshot', 'contributors', 'review_reason', 'reviewed_at', 'created_at', 'updated_at'])];
}
/** @param array<string,mixed>|null $before */
private function audit(User $actor, string $action, SupplierEvaluation $evaluation, ?array $before): void
{
app(RequisitionWorkflow::class)->audit($actor, 'supplier_evaluation.'.$action, null, $before, $this->snapshot($evaluation));
}
/** @param array<string,mixed> $payload */
public function create(User $actor, int $supplierId, string $operation, array $payload): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $supplierId, $operation, $payload) {
$actor = $this->actor($actor);
abort_if($actor->departmentScoped() && $actor->department_id === null, 403, 'O perfil de chefia precisa do departamento correspondente.');
$supplier = Supplier::query()->findOrFail($supplierId);
$this->participating($actor, $supplier->id);
$payload = $this->normalise($actor, $supplier, $payload);
$hash = hash('sha256', json_encode([$supplierId, $payload], JSON_THROW_ON_ERROR));
$existing = SupplierEvaluation::query()->where('operation_key', $operation)->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado com outros dados.');
return $existing;
}
$evaluation = SupplierEvaluation::query()->create([
'tenant_id' => $actor->tenant_id, 'supplier_id' => $supplier->id, 'department_id' => $this->department($actor, $payload),
'created_by' => $actor->id, 'operation_key' => $operation, 'creation_hash' => $hash,
'supplier_snapshot' => $supplier->only(['id', 'name', 'nuit']), 'contributors' => [$actor->id], 'payload' => $payload,
]);
$this->audit($actor, 'created', $evaluation, null);
return $evaluation;
});
}
/** @param array<string,mixed> $payload */
public function save(User $actor, int $id, int $version, array $payload): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $id, $version, $payload) {
$actor = $this->actor($actor);
$evaluation = $this->locked($actor, $id, $version);
$this->participating($actor, $evaluation->supplier_id);
abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409, 'A avaliação não está aberta para edição.');
$payload = $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $payload);
if ($payload === $evaluation->payload) {
return $evaluation;
}
$before = $this->snapshot($evaluation);
$evaluation->fill(['payload' => $payload, 'department_id' => $this->department($actor, $payload), 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])),
'version' => $version + 1, 'status' => 'draft', 'submitted_by' => null, 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null])->save();
$this->audit($actor, 'updated', $evaluation, $before);
return $evaluation;
});
}
private function locked(User $actor, int $id, int $version): SupplierEvaluation
{
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
if ($evaluation->version !== $version) {
throw ValidationException::withMessages(['version' => 'A avaliação foi alterada. Os seus dados não foram sobrepostos; consulte a versão actual antes de comparar.']);
}
return $evaluation;
}
/** @param array<string,mixed> $payload */
private function complete(array $payload, bool $approving): void
{
$errors = [];
foreach (['scope' => 'Descreva o âmbito avaliado.', 'period_start' => 'Indique o início do período.', 'period_end' => 'Indique o fim do período.', 'conclusion' => 'Registe a conclusão.'] as $key => $message) {
if (empty($payload[$key])) {
$errors['payload.'.$key] = $message;
}
}
if (! $payload['criteria']) {
$errors['payload.criteria'] = 'Indique pelo menos um critério e a situação da evidência.';
}
foreach ($payload['criteria'] as $index => $row) {
if (trim($row['label']) === '' || trim($row['reason']) === '') {
$errors['payload.criteria.'.$index.'.reason'] = 'Identifique e justifique cada critério.';
}
if ($row['result'] === 'observed' && trim($row['evidence']) === '') {
$errors['payload.criteria.'.$index.'.evidence'] = 'A observação precisa de uma referência de evidência.';
}
if ($approving && in_array($row['result'], ['pending', 'insufficient'], true)) {
$errors['payload.criteria.'.$index.'.result'] = 'Complete as evidências em falta ou devolva a avaliação para correcção.';
}
}
if ($payload['critical_occurrences'] !== '' && trim($payload['critical_evidence']) === '') {
$errors['payload.critical_evidence'] = 'Indique a evidência das ocorrências críticas.';
}
if ($errors) {
throw ValidationException::withMessages($errors);
}
}
public function transition(User $actor, int $id, int $version, string $action, ?string $reason): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason) {
$actor = $this->actor($actor);
$evaluation = $this->locked($actor, $id, $version);
$this->participating($actor, $evaluation->supplier_id);
$before = $this->snapshot($evaluation);
$this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $evaluation->payload);
if ($action === 'submit') {
abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409);
$this->complete($evaluation->payload, false);
$evaluation->fill(['status' => 'review', 'submitted_by' => $actor->id, 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null]);
} else {
abort_unless(in_array($action, ['approve', 'return'], true) && $evaluation->status === 'review', 409);
if ($reason === null || mb_strlen(trim($reason)) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão de revisão.']);
}
abort_if(in_array($actor->id, $evaluation->contributors, true), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.');
if ($action === 'approve') {
$this->complete($evaluation->payload, true);
}
$evaluation->fill(['status' => $action === 'approve' ? 'approved' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $reason]);
}
$evaluation->version = $version + 1;
$evaluation->save();
$this->audit($actor, $action, $evaluation, $before);
return $evaluation;
});
}
public function revise(User $actor, int $id, int $version): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $id, $version) {
$actor = $this->actor($actor);
$previous = $this->locked($actor, $id, $version);
$this->participating($actor, $previous->supplier_id);
$this->normalise($actor, Supplier::query()->findOrFail($previous->supplier_id), $previous->payload);
abort_unless($previous->status === 'approved', 409);
$child = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->first();
if ($child) {
abort_unless($child->created_by === $actor->id, 409, 'Já existe uma revisão posterior; consulte o histórico.');
return $child;
}
$evaluation = SupplierEvaluation::query()->create([
'tenant_id' => $actor->tenant_id, 'supplier_id' => $previous->supplier_id, 'department_id' => $previous->department_id,
'created_by' => $actor->id, 'previous_id' => $id, 'revision' => $previous->revision + 1,
'operation_key' => (string) Str::uuid(), 'creation_hash' => hash('sha256', 'revision:'.$id.':'.$version),
'contributors' => [$actor->id], 'supplier_snapshot' => $previous->supplier_snapshot, 'payload' => $previous->payload,
]);
$this->audit($actor, 'revised', $evaluation, null);
return $evaluation;
});
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use App\Models\PurchaseOrder;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SupplierImprovementWorkflow
{
public function canParticipate(User $actor, SupplierEvaluation $evaluation): bool
{
$guard = app(ConflictGuard::class);
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, null)) {
return false;
}
foreach (PurchaseOrder::query()->whereIn('id', $evaluation->payload['order_ids'])->pluck('requisition_id') as $id) {
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, (int) $id)) {
return false;
}
}
return true;
}
/** @return array<string,mixed> */
public function snapshot(SupplierImprovement $measure): array
{
return $measure->only(['id', 'supplier_evaluation_id', 'created_by', 'responsible_id', 'title', 'description', 'due_on', 'version', 'status', 'contributors', 'result', 'evidence', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
}
private function assignee(User $actor, SupplierEvaluation $evaluation, int $id): User
{
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($id);
if (! $user || ! $user->hasFunctions(SupplierEvaluationWorkflow::ROLES)
|| ($user->departmentScoped() && ($user->department_id === null || $user->department_id !== $evaluation->department_id))
|| ! $this->canParticipate($user, $evaluation)) {
throw ValidationException::withMessages(['responsible_id' => 'Escolha um responsável autorizado desta empresa, sem conflito no âmbito avaliado.']);
}
return $user;
}
/** @param array<string,mixed> $data */
public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierImprovement
{
return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
abort_unless($evaluation->status === 'approved', 409, 'Associe medidas à revisão aprovada da avaliação.');
abort_unless($this->canParticipate($actor, $evaluation), 403, 'Existe um conflito no âmbito desta avaliação.');
if ($command === 'create') {
$plan = ['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => (int) $data['responsible_id'], 'due_on' => $data['due_on']];
$hash = hash('sha256', json_encode([$evaluationId, $plan], JSON_THROW_ON_ERROR));
$existing = SupplierImprovement::query()->where('operation_key', $data['operation_key'])->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado.');
return $existing;
}
$responsible = $this->assignee($actor, $evaluation, $plan['responsible_id']);
$measure = SupplierImprovement::query()->create([...$plan, 'tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
'contributors' => array_values(array_unique([$actor->id, $responsible->id]))]);
$before = null;
} else {
$measure = SupplierImprovement::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
if ($measure->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A medida foi alterada. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
}
abort_if($measure->status === 'closed', 409, 'A medida já está encerrada e preservada.');
$before = $this->snapshot($measure);
if ($command === 'plan') {
abort_unless(in_array($measure->status, ['open', 'returned'], true), 409);
$responsible = $this->assignee($actor, $evaluation, (int) $data['responsible_id']);
$measure->fill(['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => $responsible->id, 'due_on' => $data['due_on'],
'contributors' => array_values(array_unique([...$measure->contributors, $actor->id, $responsible->id]))]);
if (! $measure->isDirty()) {
return $measure;
}
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Justifique a alteração do plano, prazo ou responsável.']);
}
} elseif (in_array($command, ['progress', 'submit'], true)) {
abort_unless($actor->id === $measure->responsible_id && in_array($measure->status, ['open', 'returned'], true), 403);
$measure->fill(['result' => $data['result'] ?? '', 'evidence' => $data['evidence'] ?? '']);
if ($command === 'submit') {
if (trim($measure->result ?? '') === '' || trim($measure->evidence ?? '') === '') {
throw ValidationException::withMessages(['evidence' => 'Registe resultado e referência da evidência antes de pedir encerramento.']);
}
$measure->status = 'review';
}
if (! $measure->isDirty()) {
return $measure;
}
} else {
abort_unless(in_array($command, ['close', 'return'], true) && $measure->status === 'review', 409);
abort_if(in_array($actor->id, $measure->contributors, true), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.');
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão sobre a execução.']);
}
$measure->fill(['status' => $command === 'close' ? 'closed' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
}
$measure->version++;
$measure->save();
}
$after = $this->snapshot($measure);
if ($command === 'plan') {
$after['change_reason'] = $data['reason'];
}
app(RequisitionWorkflow::class)->audit($actor, 'supplier_improvement.'.$command, null, $before, $after);
return $measure;
});
}
}
+4
View File
@@ -29,6 +29,10 @@ final class SupplierPayables
public function can(User $u, string $action): bool public function can(User $u, string $action): bool
{ {
if (app(OrganizationAccess::class)->active($u->tenant_id) && ! $u->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
return false;
}
return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists(); return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists();
} }
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\SupplierEvaluation;
use App\Models\SupplierResponse;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SupplierResponseWorkflow
{
/** @return array<string,mixed> */
public function snapshot(SupplierResponse $record): array
{
return $record->only(['id', 'supplier_evaluation_id', 'created_by', 'version', 'status', 'payload', 'contributors', 'decision', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
}
/** @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function normalise(array $payload): array
{
return ['title' => $payload['title'], 'received_on' => $payload['received_on'] ?? null, 'source_reference' => $payload['source_reference'] ?? '', 'statement' => $payload['statement'] ?? ''];
}
/** @param array<string,mixed> $data */
public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierResponse
{
return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
abort_unless($evaluation->status === 'approved', 409, 'O contraditório refere-se à revisão aprovada.');
abort_unless(app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation), 403, 'Existe conflito no âmbito da avaliação.');
if ($command === 'create') {
$payload = $this->normalise($data['payload']);
$hash = hash('sha256', json_encode([$evaluationId, $payload], JSON_THROW_ON_ERROR));
$existing = SupplierResponse::query()->where('operation_key', $data['operation_key'])->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido já utilizado com outro conteúdo.');
return $existing;
}
$record = SupplierResponse::query()->create(['tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
'payload' => $payload, 'contributors' => [$actor->id]]);
$before = null;
} else {
$record = SupplierResponse::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
if ($record->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'O registo foi alterado. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
}
abort_if($record->status === 'decided', 409, 'A conclusão já está registada e preservada.');
$before = $this->snapshot($record);
if ($command === 'save') {
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
$payload = $this->normalise($data['payload']);
if ($payload === $record->payload) {
return $record;
}
$record->fill(['payload' => $payload, 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
} elseif ($command === 'submit') {
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
$errors = [];
foreach (['received_on', 'source_reference', 'statement'] as $key) {
if (trim($record->payload[$key] ?? '') === '') {
$errors['payload.'.$key] = 'Complete a data, a origem documental e os argumentos recebidos antes de submeter.';
}
}
if (($record->payload['received_on'] ?? '') > today()->toDateString()) {
$errors['payload.received_on'] = 'A data de recepção não pode ser futura.';
}
if ($errors) {
throw ValidationException::withMessages($errors);
}
$record->fill(['status' => 'review', 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
} else {
abort_unless(in_array($command, ['return', 'decide'], true) && $record->status === 'review', 409);
abort_if(in_array($actor->id, $record->contributors, true), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.');
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a análise ou a devolução.']);
}
if ($command === 'decide') {
if (in_array($data['outcome'], ['accepted', 'partially_accepted'], true) && mb_strlen(trim($data['follow_up'] ?? '')) < 10) {
throw ValidationException::withMessages(['follow_up' => 'Descreva como será dado seguimento ao acolhimento dos argumentos.']);
}
$record->decision = ['outcome' => $data['outcome'], 'reason' => $data['reason'], 'follow_up' => $data['follow_up'] ?? '', 'response_reference' => $data['response_reference'] ?? ''];
}
$record->fill(['status' => $command === 'decide' ? 'decided' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
}
$record->version++;
$record->save();
}
app(RequisitionWorkflow::class)->audit($actor, 'supplier_response.'.$command, null, $before, $this->snapshot($record));
return $record;
});
}
}
+3 -1
View File
@@ -2,6 +2,7 @@
use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use App\Services\EvaluationNavigation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -15,6 +16,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->redirectGuestsTo(fn (Request $request) => EvaluationNavigation::local($request) ? '/avaliar' : route('login'));
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [ $middleware->web(append: [
@@ -24,7 +26,7 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontFlash(['account_identifier', 'beneficiary', 'external_reference', 'evidence_reference', 'reason']); $exceptions->dontFlash(['account_identifier', 'beneficiary', 'external_reference', 'evidence_reference', 'reason', 'second_password', 'second_password_confirmation']);
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(), fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
); );
+2
View File
@@ -43,6 +43,7 @@
} }
}, },
"scripts": { "scripts": {
"runtime:check": "@php scripts/check-runtime-packages.php",
"setup": [ "setup": [
"composer install", "composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
@@ -67,6 +68,7 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@runtime:check",
"npm run check", "npm run check",
"npm run types:check", "npm run types:check",
"@test" "@test"
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('supplier_evaluations', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('supplier_id')->constrained()->restrictOnDelete();
$table->foreignId('department_id')->nullable()->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->foreignId('submitted_by')->nullable()->constrained('users')->restrictOnDelete();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
$table->foreignId('previous_id')->nullable()->unique()->constrained('supplier_evaluations')->restrictOnDelete();
$table->uuid('operation_key');
$table->string('creation_hash', 64);
$table->unsignedInteger('revision')->default(1);
$table->unsignedInteger('version')->default(1);
$table->string('status')->default('draft');
$table->json('contributors');
$table->json('supplier_snapshot');
$table->json('payload');
$table->text('review_reason')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'operation_key']);
$table->index(['tenant_id', 'supplier_id', 'status']);
});
if (DB::getDriverName() === 'pgsql') {
DB::unprepared("CREATE OR REPLACE FUNCTION sgc_preserve_supplier_evaluation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF TG_OP = 'DELETE' OR OLD.status = 'approved' THEN RAISE EXCEPTION 'Preservar avaliação aprovada e histórico'; END IF;
RETURN NEW; END $$;
CREATE TRIGGER preserve_supplier_evaluation BEFORE UPDATE OR DELETE ON supplier_evaluations FOR EACH ROW EXECUTE FUNCTION sgc_preserve_supplier_evaluation();");
} elseif (DB::getDriverName() === 'sqlite') {
DB::unprepared("CREATE TRIGGER preserve_supplier_evaluation_delete BEFORE DELETE ON supplier_evaluations BEGIN SELECT RAISE(ABORT, 'Preservar histórico de avaliação'); END;");
DB::unprepared("CREATE TRIGGER preserve_supplier_evaluation_approved BEFORE UPDATE ON supplier_evaluations WHEN OLD.status = 'approved' BEGIN SELECT RAISE(ABORT, 'Preservar avaliação aprovada'); END;");
}
}
public function down(): void
{
throw new LogicException('Preservar avaliações, decisões e histórico.');
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('supplier_improvements', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('supplier_evaluation_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->foreignId('responsible_id')->constrained('users')->restrictOnDelete();
$table->uuid('operation_key');
$table->string('creation_hash', 64);
$table->string('title', 200);
$table->text('description');
$table->date('due_on');
$table->unsignedInteger('version')->default(1);
$table->string('status')->default('open');
$table->json('contributors');
$table->text('result')->nullable();
$table->text('evidence')->nullable();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
$table->text('review_reason')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'operation_key']);
$table->index(['supplier_evaluation_id', 'status']);
});
if (DB::getDriverName() === 'pgsql') {
DB::unprepared("CREATE FUNCTION sgc_preserve_supplier_improvement() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF TG_OP = 'DELETE' OR OLD.status = 'closed' THEN RAISE EXCEPTION 'Preservar medida encerrada e histórico'; END IF;
RETURN NEW; END $$;
CREATE TRIGGER preserve_supplier_improvement BEFORE UPDATE OR DELETE ON supplier_improvements FOR EACH ROW EXECUTE FUNCTION sgc_preserve_supplier_improvement();");
} elseif (DB::getDriverName() === 'sqlite') {
DB::unprepared("CREATE TRIGGER preserve_supplier_improvement_delete BEFORE DELETE ON supplier_improvements BEGIN SELECT RAISE(ABORT, 'Preservar histórico da medida'); END;");
DB::unprepared("CREATE TRIGGER preserve_supplier_improvement_closed BEFORE UPDATE ON supplier_improvements WHEN OLD.status = 'closed' BEGIN SELECT RAISE(ABORT, 'Preservar medida encerrada'); END;");
}
}
public function down(): void
{
throw new LogicException('Preservar medidas e decisões.');
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('supplier_responses', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('supplier_evaluation_id')->constrained()->restrictOnDelete();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->uuid('operation_key');
$table->string('creation_hash', 64);
$table->unsignedInteger('version')->default(1);
$table->string('status')->default('draft');
$table->json('payload');
$table->json('contributors');
$table->json('decision')->nullable();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
$table->text('review_reason')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'operation_key']);
$table->index(['supplier_evaluation_id', 'status']);
});
if (DB::getDriverName() === 'pgsql') {
DB::unprepared("CREATE FUNCTION sgc_preserve_supplier_response() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF TG_OP = 'DELETE' OR OLD.status = 'decided' THEN RAISE EXCEPTION 'Preservar contraditorio e conclusao'; END IF;
RETURN NEW; END $$;
CREATE TRIGGER preserve_supplier_response BEFORE UPDATE OR DELETE ON supplier_responses FOR EACH ROW EXECUTE FUNCTION sgc_preserve_supplier_response();");
} elseif (DB::getDriverName() === 'sqlite') {
DB::unprepared("CREATE TRIGGER preserve_supplier_response_delete BEFORE DELETE ON supplier_responses BEGIN SELECT RAISE(ABORT, 'Preservar contraditorio'); END;");
DB::unprepared("CREATE TRIGGER preserve_supplier_response_decided BEFORE UPDATE ON supplier_responses WHEN OLD.status = 'decided' BEGIN SELECT RAISE(ABORT, 'Preservar conclusao'); END;");
}
}
public function down(): void
{
throw new LogicException('Preservar contraditórios e conclusões.');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('organization_access', function (Blueprint $t) {
$t->id();
$t->foreignId('tenant_id')->unique()->constrained()->restrictOnDelete();
$t->boolean('active')->default(false);
$t->unsignedInteger('version')->default(1);
$t->timestamps();
});
Schema::create('organization_profiles', function (Blueprint $t) {
$t->id();
$t->foreignId('tenant_id')->constrained()->restrictOnDelete();
$t->string('name', 120);
$t->json('permissions');
$t->unsignedInteger('version')->default(1);
$t->timestamps();
$t->unique(['tenant_id', 'name']);
});
Schema::create('organization_memberships', function (Blueprint $t) {
$t->id();
$t->foreignId('tenant_id')->constrained()->restrictOnDelete();
$t->foreignId('user_id')->constrained()->restrictOnDelete();
$t->foreignId('profile_id')->constrained('organization_profiles')->restrictOnDelete();
$t->boolean('maintenance')->default(false);
$t->timestamp('expires_at')->nullable();
$t->text('reason')->nullable();
$t->timestamps();
$t->unique(['tenant_id', 'user_id', 'profile_id']);
});
}
public function down(): void
{
throw new LogicException('Preservar perfis, atribuições e auditoria.');
}
};
+2 -7
View File
@@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -15,11 +14,7 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); // Product installation creates its named administrators through company setup.
// Synthetic accounts belong only to explicitly isolated test/evaluation fixtures.
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
} }
} }
+104
View File
@@ -1,5 +1,109 @@
# Desenvolvimento do SGC # Desenvolvimento do SGC
## Administração, dois administradores e perfis da empresa — 30 de Outubro de 2026
Orientação do proprietário: Administração deve incluir parametrização; dois administradores serão definidos na configuração/entrega e poderão criar utilizadores e outorgar perfis/permissões escolhidos pela empresa. Substituir regras/perfis de desenvolvimento, conservando manutenção TEMPO. Implementado menu **Administração → Parametrização da aplicação**, `/administracao/parametrizacao`, com perfis de nomes próprios, combinação de permissões funcionais, múltiplos perfis por utilizador, criação de contas nominativas e atribuição/revogação justificada. Configuração versionada, isolada por empresa e auditada; alterações concorrentes antigas recusadas. Uma alteração não pode reduzir o conjunto a menos de dois administradores permanentes, incluindo por edição de perfil ou expiração de uma atribuição.
Novas empresas: a conta que configura a empresa e uma segunda pessoa identificada formam os dois administradores iniciais, com contas distintas e palavras-passe normais. Limites de aprovação e aplicação sobre valor com/sem imposto são expressamente preenchidos pela empresa, em valores decimais MZN convertidos exactamente para centavos; não entram como regras provisórias. O seeder normal deixa de criar `Test User`/`test@example.com` ou outra conta de exemplo. Os dois administradores iniciais recebem um perfil configurável da empresa. O campo antigo de perfil fica com marcador `configured`, sem autoridade própria; a validação usa as atribuições efectivas. O marcador não é um perfil concedível nem uma permissão funcional. Contas preparadas antes da activação não recebem poderes implícitos de desenvolvimento.
Empresas existentes: configuração em preparação conserva o funcionamento anterior até activação explícita, após definir as regras e associar pelo menos dois administradores. A página explica o efeito sobre utilizadores sem atribuições. Após activação, os perfis antigos deixam de autorizar; nomes/permissões dos perfis da empresa e respectivas vigências são a fonte efectiva. Não há comando de retorno silencioso ao modo anterior. Não foram apagadas contas, decisões, documentos ou referências históricas. Os processos já submetidos conservam as regras monetárias registadas; novos limites aplicam-se a novas submissões.
Integração transversal: verificações de perfil em controladores, políticas e serviços passam pela resolução comum `OrganizationAccess`/`User::hasFunctions`, incluindo revalidação transaccional de operadores, elegibilidade de aprovação, leitura por departamento, preparação/conferência, stock, equipamentos, bancos, facturas, pagamentos e relatórios. Menus e botões usam as permissões efectivas, incluindo múltiplos perfis; preparar requisições exige a respectiva função no modo configurado. Novas snapshots de qualificação/admissão/recomendação registam a designação do perfil da empresa; snapshots anteriores permanecem intactas. As permissões específicas/identidade de pessoa do núcleo financeiro comum continuam a ser conferidas pelo núcleo, com a autorização configurada como requisito adicional; não foram inventadas identidades financeiras ou concessões desse núcleo.
Manutenção TEMPO: implementada como atribuição a conta nominativa existente da empresa, autenticação normal, perfis sem administração, expiração obrigatória, revogação e auditoria das consultas. O modo temporário é somente diagnóstico/consulta; comandos de negócio e administração de acessos são recusados. Não existe rota secreta, conta global, palavra-passe universal ou autenticação contornada. O prazo termina no servidor; editar um perfil de manutenção para o transformar em administrador também é recusado. Esta é a concretização técnica do objectivo de manutenção referido como «backdoor», não uma confirmação de que foi criada uma entrada oculta. A entrega de credenciais de pessoas reais permanece no fluxo da empresa, sem palavras-passe nesta conversa.
Instalação local: somente migração aditiva **000027**, com três tabelas de configuração inicialmente vazias, depois de backup fresco/restauro descartável por hashes iguais e preservação das **84 tabelas** anteriores. Avaliação 8005 disponível, ainda sem activação da nova configuração, contas criadas ou permissões alteradas. As identidades dos dois administradores da entrega serão introduzidas na configuração; não foram inferidas de perfis sintéticos. A instalação habitual/domínio não foi migrada nem publicada. Provas e resultados finais em `proofs/organization-configuration.json`, `organization-install.json`, `organization-pg.json`, `organization-access-http.json` e `menu-code-contract.json`.
Verificação final: suíte integral com **603 testes: 601 aprovados, dois ignorados já existentes e 5 157 asserções**, sem falhas; ensaio próprio de parametrização em PostgreSQL descartável com **13 testes/96 asserções**, incluindo criação dos dois administradores, perfis efectivos, ausência de direitos implícitos antes da activação, mínimo de dois administradores, revogação/expiração da manutenção, isolamento, concorrência, rollback de auditoria, regras monetárias exactas e seeder sem contas sintéticas. A base descartável foi removida, sem alteração de privilégios do role reutilizado. PHPStan sem erros, TypeScript, lint/formatação dirigidos, Pint, build e `git diff --check` aprovados. Runtime: 18 ficheiros conferidos por SHA-256; pacotes fixados preservados. HTTP em 8005 conferiu os seis perfis de avaliação e a parametrização acessível apenas ao proprietário, sem retornar passwords e sem comandos de configuração. Menu: 107 entradas na matriz de compatibilidade e casos adicionais de permissões combinadas; catálogo: 23 páginas. Visual/impressão continuam sem execução nesta entrega.
Âmbito e evolução: esta entrega introduz a gestão efectiva de perfis por empresa e a configuração dos limites existentes. O catálogo inicial agrupa as funções operacionais actuais; o editor universal de etapas/condições, a decomposição das permissões por operação e a gestão das associações específicas do núcleo financeiro comum continuam incrementos próprios. Portanto não se declara toda a aplicação integralmente parametrizável. Fluxos de independência, integridade, histórico e isolamento continuam activos. Visual/impressão manual pendentes pela limitação do controlo de URL Windows já registada. A prioridade seguinte é ampliar a parametrização dos fluxos e das permissões mantendo esta fonte comum, em vez de voltar a listas de perfis dispersas.
## Instrução permanente: aplicação integralmente parametrizável — 30 de Outubro de 2026
O proprietário determinou que os utilizadores possam definir quem é quem, quando usar cada função/regra e como fazer as associações em toda a aplicação. Registado em `AGENTS.md` e na continuidade como requisito transversal dos módulos existentes e futuros. A configuração deve permitir funções/cargos e designações próprias, capacidades/permissões, pessoas/equipas/unidades, múltiplas responsabilidades, delegações/substituições, encaminhamentos, etapas, prazos, condições/limites, vigência e ligações a processos/documentos/fornecedores/relatórios, pela interface e sem alterações de código por cliente. A competência de configurar também deve poder ser atribuída/delegada de forma autorizada, evitando dependência permanente de um único perfil fixo.
Conferência inicial do estado actual: `PurchasingRole` contém seis perfis fixos; `CompanyController` permite cadastrar departamentos, centros e pessoas escolhendo esse enum e restringe a configuração ao perfil Admin; `SupplierEvaluationWorkflow::ROLES` e os fluxos de medidas/contraditório reutilizam uma lista fixa de quatro perfis; os menus também declaram listas de perfis. A requisição já regista parâmetros empresariais numa snapshot, uma prática a preservar e generalizar. Portanto, a aplicação ainda não satisfaz a parametrização integral; a existência de alguns parâmetros de empresa não equivale ao requisito transversal agora definido.
Sequência de implementação actualizada: estabelecer catálogo comum de capacidades, funções/perfis configuráveis por empresa e associações de pessoas/unidades com vigência; fornecer gestão na interface e migrar os perfis actuais como configuração inicial compatível. Depois substituir gradualmente as verificações organizacionais fixas nos controladores/serviços, menus, formulários e relatórios pela mesma resolução de capacidades/âmbito. Parametrizar condições e encaminhamentos com versões, publicação/activação validada e simulação do efeito sobre processos novos/em curso. Preservar as versões aplicadas aos processos e as decisões históricas, validar falta de responsáveis, incompatibilidades e dependências, e manter isolamento, autorização e auditoria efectivos. Não remover verificações dispersas antes de existir a substituição comum testada.
Esta passagem regista a decisão e o levantamento inicial; não altera permissões, contas, processos, schema ou dados. Não declara já disponível um editor de perfis/fluxos. A base de parametrização passa à frente da extensão isolada do consolidado de contraditórios indicado no registo anterior; essas funcionalidades permanecem no âmbito do produto. Critério de entrega: a organização consegue configurar os seus intervenientes, responsabilidades, condições e associações pela interface, com aplicação coerente no servidor e nos percursos/relatórios, sem intervenção do programador para essas adaptações organizacionais.
## Contraditório documentado do fornecedor — 30 de Outubro de 2026
Na revisão aprovada da avaliação, implementado o registo pela equipa dos documentos/argumentos recebidos: assunto, data de recepção, referência da origem e transcrição dos argumentos. Rascunhos recuperáveis, gravação idempotente e versionada, submissão para análise, devolução para completar e conclusão por pessoa independente de todos os editores/submetentes do registo. Recepção futura ou dados incompletos impedem a submissão, sem impedir guardar o rascunho. A conclusão fundamentada pode acolher, acolher parcialmente ou não acolher os argumentos; acolhimento exige seguimento documentado. Referência da resposta preparada é opcional. Não há envio automático, comunicação externa ou autenticação do fornecedor neste incremento; a equipa regista o recebido e prepara a comunicação.
Empresa/departamento, perfil recarregado e conflitos de fornecedor/processo são conferidos no servidor. Cada contraditório pertence à revisão exacta. Conteúdo anterior/posterior e decisões ficam no histórico próprio paginado, com autores e datas. Conclusões finais imutáveis no modelo e por trigger SQL; não há apagamento. O relatório individual inclui o contraditório como acompanhamento datado, sem reescrever a avaliação aprovada. Argumentos acolhidos não rectificam automaticamente avaliações, medidas, elegibilidade ou saldos; eventual rectificação usa o mecanismo existente de nova revisão. Fontes documentais são referências textuais; upload de anexos e prova de comunicação não estão incluídos.
Verificações: **39 testes/505 asserções**, incluindo 11 novos do contraditório e regressões das avaliações, medidas e consolidado. Cobertos dados incompletos/futuros, idempotência/canonicalização, autores/editores impedidos de concluir, devolução/correcção, seguimento obrigatório, preservação das versões, concorrência, perfis/empresa/departamento, conflitos novos, metadados forjados, imutabilidade SQL e rollback de falha da auditoria. PHPStan sem erros, tipos, lint/formatação dirigidos, Pint, build e `git diff --check` aprovados. HTTP com dois perfis existentes na base PostgreSQL conferiu o ciclo completo e o relatório; teste SQL recusou UPDATE/DELETE da conclusão final em transacções revertidas. Menu permanece com 106 entradas/seis perfis. Provas `proofs/supplier-responses-http.json`, `supplier-responses-pg.json`, `supplier-responses-install.json` e `supplier-responses.json`.
Aplicada apenas a migração aditiva **000026** na avaliação persistente 8005, com backup fresco e restauro descartável por hashes iguais, preservando **83 tabelas** anteriores e deixando a nova tabela inicialmente vazia. Exemplo fictício CTR-1 concluído na AVF-1, com argumentos parcialmente acolhidos, sem modificar essa avaliação. Nenhuma conta/concessão ou migração adicional; sem instalação habitual ou publicação no domínio. Visual/impressão manual continuam pendentes pela limitação do controlo de URL Windows anteriormente registada; sem nova aceitação visual presumida. Este incremento conclui o registo do contraditório proposto abaixo. Próxima evolução: consulta consolidada dos contraditórios por fornecedor/estado/recepção/conclusão, com ligação aos relatórios; anexos e registo da comunicação exigem o seu incremento próprio.
## Consulta consolidada e CSV das medidas — 30 de Outubro de 2026
Entregue `/relatorios/medidas-fornecedores`, em Relatórios → Medidas de melhoria e por atalho nas avaliações. Filtros combináveis por fornecedor, responsável, estado, título e intervalo de prazo, mais «só prazos ultrapassados». Inclui medidas de revisões anteriores: uma rectificação da avaliação não faz desaparecer uma medida pendente. Resumo calculado sobre todos os resultados autorizados dos filtros, paginação de 25 medidas, ligações directas à medida na avaliação e no relatório individual e impressão explicitamente da página visível.
Exportação `/relatorios/medidas-fornecedores.csv` inclui todos os resultados dos mesmos filtros, independentemente da página, em lotes de 200. Conserva título/objectivo, fornecedor preservado na avaliação, revisão, responsável, prazo, estado, resultado/evidência, última conferência, versão e ligações. Textos que podem ser interpretados como fórmula são protegidos. Inclui filtros e data de referência; a flag de atraso exclui medidas encerradas e prazos da própria data de referência, sem inferir atraso histórico à data do encerramento. Sem cálculo de elegibilidade ou efeitos financeiros.
Consulta e CSV reutilizam o âmbito da avaliação por empresa/departamento e recarregam o perfil no servidor; opções de fornecedor/responsável derivam apenas de medidas visíveis. Leituras PostgreSQL usam transacção própria repeatable-read/read-only; data de referência única por leitura. Consulta conserva histórico Inertia cifrado e resposta privada sem cache. A listagem envia apenas campos do relatório, sem chaves de idempotência ou hashes internos. Nenhuma migração, nova conta/concessão ou comando de negócio; a avaliação local 8005 já disponibiliza o código e os assets.
Verificações: **36 testes/542 asserções**, incluindo seis testes novos do consolidado e regressões de avaliações, medidas e relatórios. Exportação com 207 medidas conferida em dois lotes, filtros, limites de datas, versões anteriores, estados/atrasos, perfis actuais, isolamento por empresa/departamento, opções não expostas, texto CSV seguro e ausência de escritas sobre avaliação/fornecedor/auditoria. PHPStan sem erros, TypeScript, lint/formatação dirigidos, Pint, build e `git diff --check` aprovados. Prova HTTP na base de avaliação com seis perfis: quatro leitores autorizados e dois recusados; filtros/resumo/CSV e ligações conferidos. Contrato do menu actualizado para **106 entradas/seis perfis**; catálogo de avaliação para **22 páginas**, com acesso, CSRF e regresso sem credenciais conferidos. Provas em `proofs/supplier-improvement-report-http.json`, `supplier-improvement-report.json`, `menu-code-contract.json` e `evaluation-access.json`.
Visual/impressão manual continuam pendentes desde o encerramento do controlo da ferramenta por não conseguir confirmar o URL Windows; não se apresenta HTTP como prova visual. Não houve publicação no domínio. Esta entrega conclui a consulta consolidada proposta abaixo. Próximo âmbito funcional: registo do contraditório do fornecedor, com referências documentais e resposta fundamentada, preservando a avaliação original; a política quantitativa permanece por implementar.
## Medidas de melhoria das avaliações — 30 de Outubro de 2026
Implementado o acompanhamento na própria avaliação e no relatório: medidas associadas à revisão aprovada, objectivo, responsável interno autorizado e prazo; progresso e referência de evidência registados pelo responsável; pedido de encerramento, devolução/correcção e encerramento independente fundamentado. Autor, editores do plano e todos os responsáveis anteriores ficam excluídos do encerramento. Transferências de responsabilidade e alterações do prazo exigem justificação; o histórico conserva os valores anteriores e posteriores. O prazo ultrapassado é um aviso, sem encerramento ou penalização automática. Não há pontuação, suspensão, alteração de elegibilidade ou operação financeira.
Reutilizados os quatro perfis existentes autorizados a consultar fornecedores, isolamento de empresa/departamento, recarga do perfil e conflitos de fornecedor/processo. Operações versionadas, criação idempotente e auditoria atómica. Medidas encerradas imutáveis no modelo e por trigger SQL; não existe apagamento. Acompanhamento aparece como anexo datado no relatório, sem alterar o conteúdo da avaliação aprovada. As medidas pertencem à revisão exacta; não são copiadas ou encerradas automaticamente ao rectificar a avaliação. Histórico paginado próprio, com decisões e alterações justificadas.
Verificações: **72 testes/768 asserções**, abrangendo medidas, avaliações, conflitos e relatórios; PHPStan sem erros, TypeScript, lint/formatação dirigidos, Pint e build aprovados. Ensaio HTTP com três perfis na avaliação real conferiu repetição idempotente, evidência em falta recusada, auto-encerramento/encerramento pelo autor recusados, devolução, reagendamento, versão antiga recusada, correcção, encerramento independente e relatório. PostgreSQL recusou UPDATE/DELETE da medida encerrada em transacções revertidas, preservando a linha. Provas em `proofs/supplier-improvements-http.json`, `supplier-improvements-pg.json` e `supplier-improvements-install.json`.
Aplicada somente a migração aditiva **000025** na avaliação persistente 8005, com backup fresco/restauro descartável e hashes iguais, preservando **82 tabelas** anteriores; nova tabela inicialmente vazia. O exemplo QA MM-1 ficou encerrado na AVF-1, sem modificar essa avaliação. Sem contas/concessões novas, migração habitual ou publicação no domínio. A tentativa de conferência visual foi encerrada pela ferramenta por não conseguir confirmar o URL do navegador Windows com confiança suficiente para aplicar a política; não foram tentadas vias alternativas de controlo. Visual e impressão continuam pendentes, sem invalidar os ensaios HTTP nem equivaler a falha do portal. O estado anterior de formatação global fora do incremento permanece documentado abaixo.
Este incremento conclui o acompanhamento proposto no registo anterior. Próximo passo funcional: visão consolidada de medidas por fornecedor, responsável, prazo e estado, ligada aos relatórios, reutilizando estes registos; contraditório do fornecedor e política quantitativa continuam por desenvolver.
## Avaliações qualitativas de fornecedores — 30 de Outubro de 2026
Implementado `/fornecedores/avaliacoes`: criação e recuperação de rascunhos, filtros, âmbito/período, ordens autorizadas, critérios com justificação/evidência, ocorrências críticas e conclusão. Ciclo rascunho → revisão → devolução/correcção ou aprovação independente. Todos os editores e o submetente ficam excluídos da aprovação dessa revisão; perfis e conflitos são reconferidos no servidor. Empresa e departamento restringem as consultas. Reutilizados os quatro perfis existentes de fornecedores, sem contas ou concessões novas. O departamento deriva das ordens seleccionadas; avaliações de vários departamentos ficam no âmbito empresarial.
Gravação idempotente, versões contra sobreposição concorrente e auditoria atómica com conteúdo anterior/posterior. Aprovações imutáveis também por trigger SQL; rectificação cria revisão ligada, conservando o relatório aprovado. Relatório imprimível, estado explícito e histórico paginado. A proposta quantitativa continua apenas proposta: sem pesos, pontuação/classificação automática ou alteração da elegibilidade. Medidas de melhoria e contraditório do fornecedor continuam para incremento próprio.
Verificações: 61 testes/655 asserções (avaliações, conflitos e relatórios), PHPStan sem erros, TypeScript, Pint dirigido, lint dos novos ecrãs e build aprovados. Contrato do menu: 102 entradas/seis perfis; acesso: 21 páginas/seis perfis. HTTP no PostgreSQL local conferiu criação, submissão, auto-aprovação recusada, devolução, correcção, versão desactualizada recusada, aprovação independente, relatório e revisão posterior. Provas em `proofs/supplier-evaluations-http.json`, `supplier-evaluations-install.json` e `supplier-evaluations-pg.json`. Ensaio retomado após corrigir expectativas do script sobre redireccionamentos Inertia, conservando o mesmo exemplo QA e o histórico.
Aplicada **apenas** a migração aditiva 000024 na avaliação 8005, após backup fresco e restauro descartável com conteúdos/hashes iguais. Preservadas as 81 tabelas anteriores; nova tabela inicialmente vazia. Sem contas, concessões ou migrações adicionais. AVF-1 é exemplo fictício aprovado; AVF-2 é rascunho de rectificação; nenhuma operação financeira. Instalação habitual e domínio não migrados/publicados. Visual/impressão manual por executar. O `npm run check` global acusa formatação anterior fora deste incremento; não foi declarado aprovado nem foram reformatados ficheiros alheios por arrasto.
Esta entrega substitui a indicação abaixo de que a avaliação era apenas proposta. Próximo incremento: medidas de melhoria com responsáveis, prazos, evidências e encerramento independente, antes de converter a proposta quantitativa em política operacional.
## Entrada sem credenciais ao abrir links da avaliação — 30 de Outubro de 2026
O utilizador encontrou o formulário de credenciais ao abrir directamente `/relatorios` sem sessão. Corrigido o encaminhamento dos hóspedes no host local de avaliação: o link protegido e `/login` conduzem a `/avaliar`; após escolher o perfil existente, a entrada preserva o destino e os filtros antes de regenerar a sessão. A navegação de regresso só aceita caminhos locais ou URLs da mesma origem/porta; destinos externos, esquemas estranhos, caracteres de controlo e ciclos de login são recusados em favor do painel. As guardas de base/role QA, CSRF, perfis permitidos e isolamento foram preservadas. A instalação habitual continua a usar a autenticação própria.
Incluídos Relatórios no catálogo da avaliação (21 páginas) e testes de navegação/âmbito. Aprovações: 23 testes Feature/172 asserções (15 casos de navegação e oito dos relatórios), PHPStan e Pint dirigidos; prova HTTP repetível na avaliação real com 21 páginas, seis perfis, regresso ao relatório com filtros, CSRF/identificadores inválidos recusados e logout. `scripts/evaluation-access-check.mjs` e `docs/proofs/evaluation-access.json` actualizados. O ensaio usou uma sessão de teste própria, sem encerrar a sessão do navegador do utilizador e sem comandos de negócio. Sem redefinição de credenciais ou instalação no domínio.
## Compatibilização do menu e fluxo até aos relatórios — 30 de Outubro de 2026
Pedido do proprietário: continuar a aplicação, ligar funções do menu ao código necessário e optimizar os percursos até à geração de relatórios. Reutilizados os controllers, políticas, processo e relatório de conferência existentes. Inventário por grupo e lacunas concretas em `menus-benchmark-20261029.md`; prova repetível de 102 entradas/6 perfis em `scripts/menu-code-check.mjs` e `proofs/menu-code-contract.json`. A avaliação de fornecedores foi identificada como proposta de UI, não processo persistente; mantém-se explicitamente por desenvolver.
Implementação: grupo «Relatórios» com `/relatorios`, controller próprio e página Inertia, com filtro por número, estado da requisição, intervalo de criação e departamento autorizado; resumo de todos os resultados e paginação de 25 linhas. Exportação CSV em `/relatorios/compras.csv` aplica os mesmos filtros e âmbito, percorre lotes de 200, inclui metadados/filtros e valores monetários em centavos inteiros sem conversão para float. Textos potencialmente interpretáveis como fórmula são protegidos. O valor estimado não é apresentado como adjudicação, dívida ou pagamento. Leituras PostgreSQL usam snapshot repeatable-read/read-only, conforme o relatório existente; essa cláusula não equivale a novo ensaio de concorrência PostgreSQL nesta passagem.
Percurso entregue: menu → central/filtros → requisição e acompanhamento do processo → relatório de conferência/impressão já existente → regresso à central. O relatório individual passou a incluir recepções e links; facturas e registos de pagamento só aparecem aos perfis financeiros anteriormente autorizados. Estados e montantes são os factos guardados, sem recalcular saldos ou inferir liquidação. O relatório não carrega/exibe referências privadas ou snapshots bancários. Corrigida a descrição de tipo da data já convertida pelo modelo GoodsReceipt, sem mudança do cast/schema.
Verificações: 14 testes Feature/223 asserções em SQLite em memória; oito casos novos da central/ampliação e seis regressões do relatório existente. Incluem intervalo com dia final completo/data final isolada, 207 registos exportados em vários lotes, pesquisa, resumos, isolamento empresa/departamento, perfis revogados, hóspedes, filtros inválidos, inteiro monetário acima da precisão segura do JavaScript, CSV, ligações ao processo/relatório, recepção/factura/pagamento e saldos/auditoria preservados. TypeScript, lint/formatação dirigidos, Pint, PHPStan dos novos/alterados PHP, build e diff-check aprovados; módulo React novo e avaliação 8005 responderam HTTP200. Manifesto dos 18 ficheiros de execução continua íntegro. Conferência visual/impressão manual pendente, sem declarar um PDF gerado/ensaiado ou publicação.
Ficheiros principais: ProcurementReportController, ProcurementReviewReport, GoodsReceipt (tipo), rotas, procurement-navigation, páginas reports/procurement e purchases/review-report, ProcurementReportsTest e menu-code-check. Sem migrações, seeds, novas contas/direitos, escritas de negócio ou activação remota. Preservadas as alterações locais anteriores de empacotamento. Próximo incremento: ciclo persistente de avaliação de fornecedores, desde planeamento/rascunho até revisão, histórico e relatório, com critérios e evidências explícitos; demais lacunas mantidas no inventário. Fonte de partida `0235c76`; alterações desta entrega permanecem locais.
## Orientação comercial e remoção de dependência local — 30 de Outubro de 2026
O proprietário determinou a remoção/correcção dos bloqueios ao desenvolvimento, testes, ajustes e disponibilização, considerando a comercialização do produto. Orientação registada no início de `orientacoes-continuidade.md` e em `AGENTS.md`, prevalecendo sobre adiamentos genéricos históricos. Dependências reais devem ser identificadas e resolvidas; a pendência visual não suspende o desenvolvimento e os testes. Controlos funcionais de isolamento, aprovação independente, integridade e auditoria conservados.
Bloqueio concreto removido: o provider dependia de `../sgf-tempo/packages` quando faltavam os núcleos locais; em produção o fallback não existia e o Compras não era distribuível autonomamente. Incluídos 18 ficheiros de execução dos núcleos de orçamento/obrigações, byte a byte iguais à origem comum, com manifesto SHA-256. Incluídos os templates usados pelo workspace. Não foram copiados seeds, bases, credenciais ou ensaios de provisão. O provider usa agora os pacotes instalados/configurados em todos os ambientes, sem procurar a pasta vizinha. Os artefactos fixados não são uma segunda implementação financeira; actualizações vêm da origem comum com renovação do manifesto.
`composer run runtime:check` verifica versão, bootstraps, existência e hashes; integrado em `ci:check`. Git preserva os bytes dos artefactos e inclui-os nas exportações; os formatadores não reescrevem estes pacotes fixados. Configurações de paths continuam disponíveis para outras formas de instalação. Fonte local de partida `0235c76` (checkpoint do proprietário observado durante o trabalho), com as alterações desta entrega ainda locais.
Verificações aprovadas: 18 hashes iguais à origem; bootstrap com APP_ENV=production confirma por reflexão que BudgetControl e Payables foram carregados de `compras/packages`; 64 testes dirigidos de orçamento, reforços, integração de obrigações, importação histórica e decisões, com 277 asserções, em SQLite em memória. PHPStan do provider, Pint dirigido, sintaxe PHP, Composer validate e diff-check aprovados. Avaliação 8005 continua HTTP200. Não houve migrações, alterações de contas/permissões/dados, pagamentos ou instalação no domínio.
Dependências concretas seguintes: o arranque de avaliação ainda reutiliza o perfil/router local já existente; alguns pagamentos têm implementação e referências exclusivamente sintéticas e necessitam do percurso comercial, schema e configuração correspondentes; o instalador/acesso do domínio Compras não foi identificado e o nome com `www` continua por reconciliar. Estes pontos são trabalho técnico a executar, não novas aprovações organizacionais nem proibição genérica de desenvolvimento. Não confundir a remoção do caminho externo com entrega integral dos pagamentos reais ou publicação já executada.
## Paridade com o domínio pedida pela Tempo — 30 de Outubro de 2026 ## Paridade com o domínio pedida pela Tempo — 30 de Outubro de 2026
Requisito expresso: a versão vista localmente deve corresponder à de `www.compras.tempo.co.mz`, incluindo menus, dropdowns, visual e processos. A avaliação local passa a ser a referência da apresentação entregue; a aceitação exige conferir a mesma revisão e os assets, com diferenças de configuração/activação documentadas. Preservar dados e acessos de cada ambiente; não publicar os perfis sintéticos sem palavra-passe por consequência desta orientação. Requisito expresso: a versão vista localmente deve corresponder à de `www.compras.tempo.co.mz`, incluindo menus, dropdowns, visual e processos. A avaliação local passa a ser a referência da apresentação entregue; a aceitação exige conferir a mesma revisão e os assets, com diferenças de configuração/activação documentadas. Preservar dados e acessos de cada ambiente; não publicar os perfis sintéticos sem palavra-passe por consequência desta orientação.
+29
View File
@@ -1,5 +1,34 @@
# Menus de Compras — primeira aplicação do benchmark # Menus de Compras — primeira aplicação do benchmark
**Administração → Parametrização da aplicação, 30/10/2026:** nova entrada `/administracao/parametrizacao`, para perfis/permissões da empresa, utilizadores/atribuições, limites de aprovação, activação e manutenção TEMPO temporária de consulta. Dois administradores permanentes no arranque e na gestão; nenhum perfil de desenvolvimento é atribuído nas novas empresas. Menus passam a aceitar a combinação das permissões configuradas, com recusa correspondente no servidor. Novas empresas usam os seus perfis; a avaliação existente conserva a preparação sem activação automática. Contrato actual: **107 entradas nos seis perfis de compatibilidade**, incluindo provas de múltiplas permissões configuradas; catálogo da avaliação com **23 páginas**. Ver `proofs/organization-configuration.json` e provas relacionadas. A gestão universal de etapas/condições e a granularidade por operação ainda não estão concluídas. Visual pendente pela limitação da ferramenta anteriormente registada.
**Contraditório na avaliação, 30/10/2026:** Fornecedores → Avaliações → revisão aprovada → Contraditório do fornecedor. Entrada pela equipa dos argumentos/documentos recebidos, rascunho, análise independente, devolução/correcção e conclusão fundamentada com seguimento. Histórico e relatório individual conservam a origem e a avaliação aprovada. Mesmos perfis/âmbitos, sem nova permissão ou envio externo. Disponível em 8005 após migração 000026 com backup/restauro e preservação das 83 tabelas anteriores verificados. 39 testes/505 asserções e HTTP/PostgreSQL aprovados; menu permanece com 106 entradas/seis perfis. Provas `proofs/supplier-responses*.json`. Visual/impressão, consolidado dos contraditórios, anexos e prova de comunicação continuam pendentes; esta entrega substitui apenas a pendência do registo/análise nos estados abaixo.
**Consolidado de medidas, 30/10/2026:** grupo Relatórios inclui «Medidas de melhoria», com consulta paginada, filtros, resumo, ligações à avaliação/medida e relatório e CSV de todos os resultados. Atalho adicional nas avaliações. Mesmos quatro perfis de consulta, isolamento por empresa/departamento e nenhuma permissão nova. Contrato actual: **106 entradas somadas nos seis perfis**, catálogo de avaliação com 22 páginas; provas HTTP e automatizadas em `proofs/supplier-improvement-report*.json`. Sem migração ou escrita de negócio; visual/impressão manual pendentes. Esta actualização acrescenta o consolidado aos estados históricos abaixo.
**Medidas de melhoria disponíveis, 30/10/2026:** menu Fornecedores → Avaliações → revisão aprovada → medidas/execução → conferência independente → relatório com acompanhamento datado. Responsáveis/prazos, alterações justificadas, alertas de atraso, evidências, devolução/correcção e encerramento imutável, sem alteração automática da elegibilidade. Disponível em 8005, com migração 000025 e backup/restauro verificados. 72 testes/768 asserções e HTTP/PostgreSQL aprovados. Esta actualização substitui a pendência de medidas no registo abaixo; a proposta quantitativa e o contraditório continuam por desenvolver. Visual/impressão pendentes por encerramento do controlo da ferramenta, que não conseguiu confirmar o URL Windows para aplicar a política.
**Estado actual da avaliação de fornecedores, 30/10/2026:** o menu já abre um ciclo persistente de rascunho, critérios/evidências, submissão, devolução/correcção e aprovação independente. Inclui versões, histórico, rectificação preservando a aprovação anterior e relatório imprimível. Disponível na avaliação 8005; migração aditiva com backup/restauro e preservação das 81 tabelas anteriores conferidos. Sem pontuação automática ou alteração de elegibilidade. 61 testes/655 asserções, tipos, PHPStan, lint dirigido, build, HTTP e triggers PostgreSQL aprovados. Visual/impressão manual pendentes. Este estado substitui a indicação histórica de «proposta de interface» no inventário abaixo; a proposta quantitativa e medidas de melhoria continuam por implementar.
## Compatibilização entre menu, código e relatórios — 30/10/2026
Inventário do código actual, preservando as entregas anteriores. `scripts/menu-code-check.mjs` confere a definição real do menu contra as rotas Laravel GET e o destaque activo: **102 entradas somadas nos seis perfis**, com obrigações ligadas/desligadas. Prova em `proofs/menu-code-contract.json`. É prova de ligação/capacidade do menu, não declaração de funcionamento integral de cada módulo.
| Grupo do menu | Código e percurso existentes | Ligação ao relatório / saldo identificado |
| --- | --- | --- |
| Requisições | RequisitionController: criar, editar, submeter, decidir, vistas pessoais/rascunhos; PurchasePlanningController: itens, responsáveis e marcos | O processo reúne as etapas; a central nova localiza pedidos por número, data, estado e departamento. |
| Aprovações | Elegibilidade e decisões em RequisitionController/RequisitionWorkflow | Conservadas as decisões efectivas; relatórios não executam nem substituem aprovações. |
| Consultas e adjudicações | QuotationController, abertura, qualificação, admissão, recomendação e PurchaseController; revisão/cancelamento existente | Relatório de conferência recupera bases, versões e decisões; não inventa validade de documentos antigos. |
| Fornecedores | Cadastro/manutenção/documentos em SupplierController e SupplierDocumentController | **Avaliação de fornecedores continua como proposta de interface**: não existem ainda gravação/revisão/histórico operacional desse módulo. Desenvolver esse fluxo até ao seu relatório é a próxima lacuna funcional concreta. |
| Recepções e armazém | GoodsReceiptController, StockController, StockOperationController e EquipmentController | O relatório individual passa a incluir recepções associadas e a ligação às respectivas fichas; não presume entrega integral nem adiciona movimentos. |
| Finanças | Facturas, pagamentos externos, orçamento/reforços e políticas existentes; núcleo comum sob configuração própria | Relatório individual inclui facturas/registos de pagamentos apenas para Finanças/Administração. Montantes preservados, sem cálculo de saldo ou inferência de liquidação. Pagamentos/afectações sintéticos e relatórios agregados financeiros ainda requerem os incrementos próprios. |
| Administração e controlo | Empresa/equipa/centros, políticas de admissão/recomendação e conflitos | Mantidas regras e autorizações existentes; não há criação de contas ou novos direitos por abrir relatórios. |
| Relatórios | **Nova central `/relatorios` e exportação `/relatorios/compras.csv`**, ligadas ao backend | Menu → filtros → requisição/processo → relatório de conferência → impressão existente. CSV usa todos os resultados autorizados dos filtros, não só a página visível. |
O novo recorte não encerra contratos, multimoeda, relatórios financeiros agregados, avaliação de fornecedores ou o percurso comercial dos pagamentos ainda sintéticos. A prioridade seguinte é substituir a proposta de avaliação por um ciclo persistente, com critérios/configuração e evidências explícitos, revisão, histórico e relatório; não activar pesos/políticas provisórias silenciosamente.
Verificações deste recorte: 14 testes Feature/223 asserções, incluindo 207 requisições exportadas em mais de um lote, isolamento por empresa/departamento, recarga do perfil, filtros, montantes inteiros exactos, texto CSV não executável como fórmula, relatório até recepção/factura/pagamento, ausência de dados bancários e de efeitos nos saldos/auditoria. Tipos, lint, Pint, PHPStan e build aprovados. Conferência visual/impressão manual pendente; sem migrações, novas escritas de negócio ou publicação no domínio.
**Actualização de apresentação, 30/10/2026:** por pedido do proprietário, a navegação autenticada foi colocada na horizontal em ecrãs grandes, com dropdowns e o mesmo visual escuro, grupos, ícones, pesquisa e regras por perfil. O painel móvel mantém-se. Este estado substitui apenas a disposição lateral descrita no registo histórico abaixo; não altera os processos ou permissões. Tipos, lint, formatação e módulo Vite aprovados; conferência visual desta alteração ainda não executada por indisponibilidade do conector do navegador. **Actualização de apresentação, 30/10/2026:** por pedido do proprietário, a navegação autenticada foi colocada na horizontal em ecrãs grandes, com dropdowns e o mesmo visual escuro, grupos, ícones, pesquisa e regras por perfil. O painel móvel mantém-se. Este estado substitui apenas a disposição lateral descrita no registo histórico abaixo; não altera os processos ou permissões. Tipos, lint, formatação e módulo Vite aprovados; conferência visual desta alteração ainda não executada por indisponibilidade do conector do navegador.
29/10/2026. Pedido do proprietário: renovar os menus antes de retomar o trabalho funcional pendente. Referência fornecida: `referencias/benchmark-menus-20261029.txt`. Este incremento aplica a organização da navegação; não declara implementado todo o benchmark. 29/10/2026. Pedido do proprietário: renovar os menus antes de retomar o trabalho funcional pendente. Referência fornecida: `referencias/benchmark-menus-20261029.txt`. Este incremento aplica a organização da navegação; não declara implementado todo o benchmark.
+20
View File
@@ -1,5 +1,25 @@
# Continuidade do desenvolvimento do SGC # Continuidade do desenvolvimento do SGC
**Resultados da parametrização, 30/10/2026:** suíte integral 603 testes, 601 aprovados, dois ignorados anteriores, 5 157 asserções; parametrização PostgreSQL descartável 13 testes/96 asserções, com base removida; PHPStan/tipos/lint dirigido/Pint/build/runtime/HTTP aprovados. Menu 107 entradas e catálogo 23 páginas. Provas em `proofs/organization-configuration.json` e documentos associados. As tabelas de configuração da avaliação continuam sem activação/perfis/atribuições; nenhum administrador real ou conta TEMPO foi inventado/criado nessa base. Os dois administradores são definidos no formulário de configuração/entrega. Antes da activação, contas com marcador `configured` não herdam permissões de desenvolvimento; depois dela usam apenas os perfis atribuídos. Marcadores históricos e contas existentes conservam-se sem constituir autoridade de acesso no modo configurado.
**Administração parametrizável e entrega com dois administradores, 30/10/2026:** `/administracao/parametrizacao` no menu, perfis com nomes próprios e permissões funcionais combináveis, criação de utilizadores, atribuição/revogação justificada, versão/auditoria e mínimo de dois administradores permanentes. Novas empresas exigem as duas pessoas e limites de aprovação expressamente definidos; seeder sem contas de exemplo. Marcador interno `configured` não concede direitos; depois da activação os perfis antigos deixam de autorizar. Resolução comum aplicada aos serviços/políticas/controladores, menus e botões; preservar regras e referências históricas. Empresas existentes mantêm preparação até activação explícita, com efeito explicado sobre pessoas não associadas. O proprietário pediu manutenção TEMPO («backdoor»); concretizada como acesso nominativo autenticado, temporário, revogável e auditado de diagnóstico/consulta, sem conta global/entrada oculta ou comandos de negócio. Migração 000027 apenas em 8005, backup/restauro verificado e 84 tabelas preservadas; três tabelas novas vazias. Configuração da avaliação não activada e contas/permissões existentes não alteradas. Ver resultados em `proofs/organization-configuration.json` e provas associadas. Não declarar parametrização integral: catálogo ainda agregado por funções, editor universal de fluxos/permissões por operação e associações financeiras específicas continuam por desenvolver. Visual e publicação pendentes. Esta orientação passa à frente do consolidado do contraditório indicado nos estados anteriores.
**Instrução permanente de parametrização integral — proprietário, 30/10/2026:** toda a aplicação deve permitir aos utilizadores autorizados definir quem é quem, quando cada função/regra deve ser usada e como associar pessoas, funções, unidades, processos e documentos. Abrange perfis/permissões e nomes configuráveis, responsabilidades, múltiplas funções, delegações/substituições, etapas, condições, limites, prazos, vigência, aprovações, menus e relatórios. Configuração pela interface, sem adaptação de código por organização; servidor e interface devem usar a mesma fonte de capacidades/regras. Versão, vigência, auditoria e efeito explícito em processos novos/em curso, preservando decisões concluídas, isolamento e integridade. Orientação consolidada em `AGENTS.md`. **Ainda não implementada integralmente:** existem seis perfis em enum e listas fixas de autorização, inclusive nos módulos recentes; estes são configuração inicial a migrar, não limite definitivo do produto. A prioridade transversal passa a ser a base comum de funções/capacidades e associações configuráveis, com migração compatível das regras actuais, antes de multiplicar novas listas fixas. Entregas e provas anteriores continuam preservadas; o pedido não concede automaticamente novas permissões nas bases existentes.
**Contraditório registado e analisado, 30/10/2026:** revisão aprovada → documentos/argumentos recebidos → rascunho → análise → devolução/correcção ou conclusão independente. Acolhimento total/parcial exige seguimento; conclusão fundamentada imutável, com histórico e anexo datado no relatório, preservando a avaliação. Entrada pela equipa com referências documentais; sem envio externo, portal autenticado do fornecedor ou upload de anexos neste incremento. Reutilizados âmbito, perfis, conflitos, versões e auditoria atómica. 39 testes/505 asserções, PHPStan/tipos/lint/Pint/build, HTTP e triggers PostgreSQL aprovados; provas `proofs/supplier-responses*.json`. Migração 000026 só em 8005 após backup/restauro fresco verificado, 83 tabelas anteriores preservadas; exemplo QA CTR-1 concluído na AVF-1. Sem instalação habitual/publicação, efeitos fiscais/financeiros ou mudança automática da elegibilidade. Visual/impressão permanecem pendentes pela limitação do controlo de URL já registada. Substitui a pendência do registo do contraditório abaixo. Próxima evolução: consolidado por fornecedor/estado/recepção/conclusão e relatório; anexos e comunicação permanecem incrementos próprios.
**Consolidado de medidas entregue, 30/10/2026:** Relatórios → Medidas de melhoria, `/relatorios/medidas-fornecedores`, com filtros por fornecedor/responsável/estado/título/prazo/atraso, resumo de todos os resultados, paginação e ligações à medida na avaliação/relatório. CSV completo dos filtros em lotes de 200, texto seguro para folhas de cálculo; inclui revisões anteriores, sem esconder pendentes ao rectificar avaliações. Leituras PostgreSQL em snapshot read-only, com âmbito de empresa/departamento e perfil recarregado. Sem migrações, contas/concessões, alterações de negócio ou publicação. Disponível em 8005. 36 testes/542 asserções, PHPStan/tipos/lint/Pint/build e HTTP de seis perfis aprovados; menu 106 entradas e catálogo 22 páginas conferidos. Ver `proofs/supplier-improvement-report*.json`. Visual/impressão manual continuam pendentes pela limitação do controlo de URL Windows já registada. Substitui a pendência de consolidado abaixo. Próximo âmbito: contraditório documentado do fornecedor e resposta fundamentada, sem substituir a avaliação aprovada; política quantitativa por implementar.
**Medidas de melhoria entregues, 30/10/2026:** a avaliação aprovada permite registar medidas com responsável/prazo, execução/evidência, pedido de encerramento, devolução/correcção e encerramento independente. Transferências/reagendamentos justificados e histórico; autor/editores/responsáveis não encerram a própria medida. Encerramentos imutáveis também em SQL. Relatório inclui acompanhamento datado sem modificar a avaliação aprovada; sem efeitos automáticos na elegibilidade/finanças. Migração 000025 instalada apenas em 8005 após backup/restauro verificado e preservação de 82 tabelas. Exemplo QA MM-1 encerrado na AVF-1. 72 testes/768 asserções, PHPStan, tipos, lint/Pint/build, HTTP com três perfis e protecções PostgreSQL aprovados. Provas `proofs/supplier-improvements-*.json`. Visual/impressão pendentes: a ferramenta terminou o controlo por não conseguir confirmar o URL Windows para aplicação da política; sem contorno dessa limitação. Sem publicação no domínio ou migração habitual. Substitui a pendência de medidas abaixo. Próximo incremento: consulta consolidada por fornecedor/responsável/prazo/estado e relatório; contraditório e política quantitativa continuam por implementar.
**Avaliação de fornecedores implementada, 30/10/2026:** `/fornecedores/avaliacoes` guarda rascunhos, critérios/evidências e conclusão; submissão, devolução/correcção, aprovação independente de todos os editores, versões, histórico e relatório imprimível. Aprovação imutável; rectificação por revisão ligada. Conflitos e âmbito por empresa/departamento mantidos. Sem pontuação, pesos ou alteração automática de elegibilidade. 61 testes/655 asserções, PHPStan, tipos, lint dirigido e build aprovados; HTTP e triggers PostgreSQL conferidos. Migração 000024 aplicada apenas na avaliação 8005, com backup/restauro verificado e 81 tabelas anteriores preservadas. AVF-1 é exemplo fictício aprovado; AVF-2 é rascunho de rectificação. Ver `desenvolvimento.md` e `proofs/supplier-evaluations-*.json`. Sem publicação no domínio ou migração habitual; visual/impressão manual pendentes. Substitui a lacuna de persistência indicada abaixo. Próximo incremento: medidas de melhoria, responsáveis/prazos/evidências e encerramento independente; não activar pontuações provisórias.
**Acesso directo na avaliação corrigido, 30/10/2026:** links protegidos em 8005, incluindo `/relatorios`, conduzem a `/avaliar` quando não existe sessão; `/login` desse host também usa a entrada sem palavra-passe. Após escolher o proprietário/perfil, regressa ao caminho e filtros guardados. Destinos externos e ciclos de login não são aceites. Mantidas as guardas de host/base/role QA e CSRF; a autenticação da instalação habitual não foi alterada. 23 testes/172 asserções, PHPStan/Pint e prova HTTP das 21 páginas/seis perfis aprovados. Disponibilizar este percurso ao utilizador, sem lhe pedir credenciais da instalação habitual.
**Menu → operação → relatórios, 30/10/2026:** pedido do proprietário para compatibilizar as funções do menu com o código e optimizar os fluxos até à geração dos relatórios. Inventário actualizado em `menus-benchmark-20261029.md`, com 102 entradas/6 perfis conferidas por contrato de rotas/destaque. Entregue central «Relatórios de compras» em `/relatorios`, filtros por criação/estado/departamento/número, resumo paginado e CSV completo dos filtros, com ligações ao processo e relatório individual. Relatório individual ampliado com recepções e, no âmbito financeiro autorizado, facturas e pagamentos externos preservados, sem dados bancários nem cálculo de saldo. 14 testes/223 asserções, tipos, lint, Pint, PHPStan e build aprovados; provas em `proofs/procurement-reports.json`. Próxima lacuna concreta: Avaliação de fornecedores ainda é proposta de interface, sem persistência/revisão/histórico operacional; implementar esse ciclo até ao respectivo relatório. Não declarar todos os módulos concluídos apenas por as ligações do menu abrirem. Alterações locais, sem instalação no domínio; visual/impressão manual continuam por executar, sem bloquear os incrementos seguintes.
**Orientação permanente do proprietário — 30/10/2026:** a aplicação é destinada à comercialização; remover/corrigir os bloqueios que impedem desenvolvimento normal, testes, ajustes e disponibilização. Não exigir de novo autorização por etapas já abrangidas pelo pedido, nem usar pendência genérica de visual/aceitação como bloqueio do código e dos testes. Distinguir limitação técnica de regra funcional aprovada, identificar a sua origem e executar a solução concreta. Preservar dados, contas e os controlos funcionais de integridade/isolamento/aprovação/auditoria. Esta orientação prevalece sobre adiamentos genéricos dos registos históricos; não transforma capacidades ainda sintéticas em operação comercial por mera remoção de guardas. Regras locais consolidadas em `AGENTS.md`.
**Paridade de versão local/domínio — decisão da Tempo, 30/10/2026:** a versão acompanhada pelo utilizador durante o desenvolvimento é a referência para `www.compras.tempo.co.mz`: mesmos menus horizontais/dropdowns, visual e implementação dos processos. Não apresentar uma interface antiga como entrega equivalente. Conferir revisão, assets, flags e dependências de cada percurso antes de declarar paridade; dados, identidades e permissões existentes de cada ambiente devem ser preservados. O acesso sem palavra-passe e os perfis sintéticos da avaliação local não foram autorizados no domínio por este pedido. Pagamentos e outros recortes limitados à avaliação exigem reconciliação explícita do âmbito, não mera cópia de flags ou de dados QA. As alterações locais desta sessão ainda não foram enviadas/instaladas e não há confirmação de paridade no servidor. Na leitura de 30/10, o nome com `www` não resolveu por DNS; o endereço sem `www` respondeu a curl, mas isso não confirma a revisão ou os percursos autenticados. O processo/acesso de instalação de Compras não está identificado nos ficheiros de entrega consultados; não reutilizar por inferência o instalador do InvesteMoz. **Paridade de versão local/domínio — decisão da Tempo, 30/10/2026:** a versão acompanhada pelo utilizador durante o desenvolvimento é a referência para `www.compras.tempo.co.mz`: mesmos menus horizontais/dropdowns, visual e implementação dos processos. Não apresentar uma interface antiga como entrega equivalente. Conferir revisão, assets, flags e dependências de cada percurso antes de declarar paridade; dados, identidades e permissões existentes de cada ambiente devem ser preservados. O acesso sem palavra-passe e os perfis sintéticos da avaliação local não foram autorizados no domínio por este pedido. Pagamentos e outros recortes limitados à avaliação exigem reconciliação explícita do âmbito, não mera cópia de flags ou de dados QA. As alterações locais desta sessão ainda não foram enviadas/instaladas e não há confirmação de paridade no servidor. Na leitura de 30/10, o nome com `www` não resolveu por DNS; o endereço sem `www` respondeu a curl, mas isso não confirma a revisão ou os percursos autenticados. O processo/acesso de instalação de Compras não está identificado nos ficheiros de entrega consultados; não reutilizar por inferência o instalador do InvesteMoz.
**Entrada de desenvolvimento definida pela Tempo, 30/10/2026:** o utilizador quer acompanhar a avaliação persistente com menus organizados e processos avançados em **http://127.0.0.1:8005/avaliar**, com «Entrar como proprietário» e os perfis existentes. `composer run dev` usa agora essa avaliação e o Vite; não inicia a instalação habitual em 8015 nem a respectiva fila. Não voltar a apresentar 8015 como entrada normal de desenvolvimento. O arranque reutiliza os dados/configuração QA já existentes, sem provisionamento, migrações, seeds, alteração de contas ou reinício do SGF. A instalação habitual mantém-se separada e só arranca explicitamente com `composer run dev:standard`. Implementação em `scripts/dev-evaluation.php`; verificações e limites no registo de 30/10 em `desenvolvimento.md`. **Entrada de desenvolvimento definida pela Tempo, 30/10/2026:** o utilizador quer acompanhar a avaliação persistente com menus organizados e processos avançados em **http://127.0.0.1:8005/avaliar**, com «Entrar como proprietário» e os perfis existentes. `composer run dev` usa agora essa avaliação e o Vite; não inicia a instalação habitual em 8015 nem a respectiva fila. Não voltar a apresentar 8015 como entrada normal de desenvolvimento. O arranque reutiliza os dados/configuração QA já existentes, sem provisionamento, migrações, seeds, alteração de contas ou reinício do SGF. A instalação habitual mantém-se separada e só arranca explicitamente com `composer run dev:standard`. Implementação em `scripts/dev-evaluation.php`; verificações e limites no registo de 30/10 em `desenvolvimento.md`.
+8 -3
View File
@@ -1,5 +1,5 @@
{ {
"checked_at": "2026-10-29T18:55:02.410Z", "checked_at": "2026-10-30T08:46:24.426Z",
"base": "http://127.0.0.1:8005", "base": "http://127.0.0.1:8005",
"owner_modules": [ "owner_modules": [
"/dashboard", "/dashboard",
@@ -21,7 +21,10 @@
"/conflitos", "/conflitos",
"/revisao-compras", "/revisao-compras",
"/politica-admissao", "/politica-admissao",
"/politica-recomendacao" "/politica-recomendacao",
"/relatorios",
"/relatorios/medidas-fornecedores",
"/administracao/parametrizacao"
], ],
"roles": [ "roles": [
"requester", "requester",
@@ -38,5 +41,7 @@
"business_commands": 0, "business_commands": 0,
"real_accounts_changed": false, "real_accounts_changed": false,
"operational_entry_status": "not running", "operational_entry_status": "not running",
"visual_review": false "visual_review": false,
"guest_report_redirect": true,
"intended_report_and_filters_preserved": true
} }
+974
View File
@@ -0,0 +1,974 @@
{
"checked_at": "2026-10-30T08:46:13.504Z",
"result": "passed",
"entries": 107,
"roles": 6,
"configurable_permissions_and_multiple_profiles": true,
"source": "procurementNavigation compared with actual Laravel GET routes",
"scope": "Menu routing, active entry and visibility contract; not a substitute for functional or visual workflow acceptance",
"rows": [
{
"role": "admin",
"group": "Requisições",
"id": "new_request",
"label": "Nova requisição",
"path": "/requisicoes/criar",
"action": "App\\Http\\Controllers\\RequisitionController@create",
"full_page": false
},
{
"role": "admin",
"group": "Requisições",
"id": "my_requests",
"label": "Minhas requisições",
"path": "/requisicoes?view=mine",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "admin",
"group": "Requisições",
"id": "my_drafts",
"label": "Os meus rascunhos",
"path": "/requisicoes?view=mine&status=draft",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "admin",
"group": "Requisições",
"id": "all_requests",
"label": "Todas as autorizadas",
"path": "/requisicoes?view=all",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "admin",
"group": "Requisições",
"id": "planning",
"label": "Planeamento",
"path": "/planeamento",
"action": "App\\Http\\Controllers\\PurchasePlanningController@index",
"full_page": false
},
{
"role": "admin",
"group": "Aprovações",
"id": "pending_approvals",
"label": "Pendentes para mim",
"path": "/requisicoes?view=approvals",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "admin",
"group": "Aprovações",
"id": "decisions",
"label": "Minhas decisões",
"path": "/requisicoes?view=decided",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "admin",
"group": "Consultas e adjudicações",
"id": "quotations",
"label": "Cotações e comparação",
"path": "/cotacoes",
"action": "App\\Http\\Controllers\\QuotationController@index",
"full_page": false
},
{
"role": "admin",
"group": "Consultas e adjudicações",
"id": "orders",
"label": "Adjudicações e ordens",
"path": "/ordens",
"action": "App\\Http\\Controllers\\PurchaseController@index",
"full_page": false
},
{
"role": "admin",
"group": "Consultas e adjudicações",
"id": "purchase_review",
"label": "Revisão e cancelamento",
"path": "/revisao-compras",
"action": "App\\Http\\Controllers\\PurchaseControlController@index",
"full_page": false
},
{
"role": "admin",
"group": "Fornecedores",
"id": "suppliers",
"label": "Cadastro e documentos",
"path": "/fornecedores",
"action": "App\\Http\\Controllers\\SupplierController@index",
"full_page": false
},
{
"role": "admin",
"group": "Fornecedores",
"id": "supplier_evaluations",
"label": "Avaliação de fornecedores",
"path": "/fornecedores/avaliacoes",
"action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
"full_page": false
},
{
"role": "admin",
"group": "Recepções e armazém",
"id": "receipts",
"label": "Recepções e divergências",
"path": "/recepcoes",
"action": "App\\Http\\Controllers\\GoodsReceiptController@index",
"full_page": false
},
{
"role": "admin",
"group": "Recepções e armazém",
"id": "stock",
"label": "Artigos e stock",
"path": "/stock",
"action": "App\\Http\\Controllers\\StockController@index",
"full_page": false
},
{
"role": "admin",
"group": "Recepções e armazém",
"id": "stock_operations",
"label": "Saídas e inventários",
"path": "/stock/operacoes",
"action": "App\\Http\\Controllers\\StockOperationController@index",
"full_page": false
},
{
"role": "admin",
"group": "Recepções e armazém",
"id": "equipment",
"label": "Equipamentos e peças",
"path": "/equipamentos",
"action": "App\\Http\\Controllers\\EquipmentController@index",
"full_page": false
},
{
"role": "admin",
"group": "Finanças",
"id": "invoices",
"label": "Facturas e conferência",
"path": "/facturas",
"action": "App\\Http\\Controllers\\InvoiceController@index",
"full_page": false
},
{
"role": "admin",
"group": "Finanças",
"id": "payments",
"label": "Pagamentos externos",
"path": "/pagamentos",
"action": "App\\Http\\Controllers\\ExternalPaymentController@index",
"full_page": false
},
{
"role": "admin",
"group": "Finanças",
"id": "budgets",
"label": "Orçamentos e reforços",
"path": "/orcamentos",
"action": "App\\Http\\Controllers\\BudgetController@index",
"full_page": false
},
{
"role": "admin",
"group": "Finanças",
"id": "investment",
"label": "Regras de investimento",
"path": "/regras-investimento",
"action": "App\\Http\\Controllers\\InvestmentPolicyController@index",
"full_page": false
},
{
"role": "admin",
"group": "Administração e controlo",
"id": "company",
"label": "Empresa, equipa e centros",
"path": "/empresa",
"action": "App\\Http\\Controllers\\CompanyController@index",
"full_page": false
},
{
"role": "admin",
"group": "Administração e controlo",
"id": "organization_parameters",
"label": "Parametrização da aplicação",
"path": "/administracao/parametrizacao",
"action": "App\\Http\\Controllers\\OrganizationConfigurationController@index",
"full_page": false
},
{
"role": "admin",
"group": "Administração e controlo",
"id": "admission_policy",
"label": "Política de admissão",
"path": "/politica-admissao",
"action": "App\\Http\\Controllers\\FormalAdmissionController@policy",
"full_page": false
},
{
"role": "admin",
"group": "Administração e controlo",
"id": "recommendation_policy",
"label": "Política de recomendação",
"path": "/politica-recomendacao",
"action": "App\\Http\\Controllers\\FormalRecommendationController@policy",
"full_page": false
},
{
"role": "admin",
"group": "Administração e controlo",
"id": "conflicts",
"label": "Conflitos de interesses",
"path": "/conflitos",
"action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
"full_page": false
},
{
"role": "admin",
"group": "Relatórios",
"id": "procurement_reports",
"label": "Relatórios de compras",
"path": "/relatorios",
"action": "App\\Http\\Controllers\\ProcurementReportController@index",
"full_page": false
},
{
"role": "admin",
"group": "Relatórios",
"id": "supplier_improvement_reports",
"label": "Medidas de melhoria",
"path": "/relatorios/medidas-fornecedores",
"action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
"full_page": false
},
{
"role": "admin",
"group": "Finanças",
"id": "payables",
"label": "Obrigações a fornecedores",
"path": "/obrigacoes-fornecedores",
"action": "App\\Http\\Controllers\\SupplierPayablesController@page",
"full_page": true
},
{
"role": "procurement",
"group": "Requisições",
"id": "new_request",
"label": "Nova requisição",
"path": "/requisicoes/criar",
"action": "App\\Http\\Controllers\\RequisitionController@create",
"full_page": false
},
{
"role": "procurement",
"group": "Requisições",
"id": "my_requests",
"label": "Minhas requisições",
"path": "/requisicoes?view=mine",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Requisições",
"id": "my_drafts",
"label": "Os meus rascunhos",
"path": "/requisicoes?view=mine&status=draft",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Requisições",
"id": "all_requests",
"label": "Todas as autorizadas",
"path": "/requisicoes?view=all",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Requisições",
"id": "planning",
"label": "Planeamento",
"path": "/planeamento",
"action": "App\\Http\\Controllers\\PurchasePlanningController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Aprovações",
"id": "pending_approvals",
"label": "Pendentes para mim",
"path": "/requisicoes?view=approvals",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Aprovações",
"id": "decisions",
"label": "Minhas decisões",
"path": "/requisicoes?view=decided",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Consultas e adjudicações",
"id": "quotations",
"label": "Cotações e comparação",
"path": "/cotacoes",
"action": "App\\Http\\Controllers\\QuotationController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Consultas e adjudicações",
"id": "orders",
"label": "Adjudicações e ordens",
"path": "/ordens",
"action": "App\\Http\\Controllers\\PurchaseController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Consultas e adjudicações",
"id": "purchase_review",
"label": "Revisão e cancelamento",
"path": "/revisao-compras",
"action": "App\\Http\\Controllers\\PurchaseControlController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Fornecedores",
"id": "suppliers",
"label": "Cadastro e documentos",
"path": "/fornecedores",
"action": "App\\Http\\Controllers\\SupplierController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Fornecedores",
"id": "supplier_evaluations",
"label": "Avaliação de fornecedores",
"path": "/fornecedores/avaliacoes",
"action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Recepções e armazém",
"id": "receipts",
"label": "Recepções e divergências",
"path": "/recepcoes",
"action": "App\\Http\\Controllers\\GoodsReceiptController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Recepções e armazém",
"id": "stock",
"label": "Artigos e stock",
"path": "/stock",
"action": "App\\Http\\Controllers\\StockController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Recepções e armazém",
"id": "stock_operations",
"label": "Saídas e inventários",
"path": "/stock/operacoes",
"action": "App\\Http\\Controllers\\StockOperationController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Recepções e armazém",
"id": "equipment",
"label": "Equipamentos e peças",
"path": "/equipamentos",
"action": "App\\Http\\Controllers\\EquipmentController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Finanças",
"id": "investment",
"label": "Regras de investimento",
"path": "/regras-investimento",
"action": "App\\Http\\Controllers\\InvestmentPolicyController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Administração e controlo",
"id": "conflicts",
"label": "Conflitos de interesses",
"path": "/conflitos",
"action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Relatórios",
"id": "procurement_reports",
"label": "Relatórios de compras",
"path": "/relatorios",
"action": "App\\Http\\Controllers\\ProcurementReportController@index",
"full_page": false
},
{
"role": "procurement",
"group": "Relatórios",
"id": "supplier_improvement_reports",
"label": "Medidas de melhoria",
"path": "/relatorios/medidas-fornecedores",
"action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
"full_page": false
},
{
"role": "finance",
"group": "Requisições",
"id": "new_request",
"label": "Nova requisição",
"path": "/requisicoes/criar",
"action": "App\\Http\\Controllers\\RequisitionController@create",
"full_page": false
},
{
"role": "finance",
"group": "Requisições",
"id": "my_requests",
"label": "Minhas requisições",
"path": "/requisicoes?view=mine",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "finance",
"group": "Requisições",
"id": "my_drafts",
"label": "Os meus rascunhos",
"path": "/requisicoes?view=mine&status=draft",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "finance",
"group": "Requisições",
"id": "all_requests",
"label": "Todas as autorizadas",
"path": "/requisicoes?view=all",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "finance",
"group": "Requisições",
"id": "planning",
"label": "Planeamento",
"path": "/planeamento",
"action": "App\\Http\\Controllers\\PurchasePlanningController@index",
"full_page": false
},
{
"role": "finance",
"group": "Aprovações",
"id": "pending_approvals",
"label": "Pendentes para mim",
"path": "/requisicoes?view=approvals",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "finance",
"group": "Aprovações",
"id": "decisions",
"label": "Minhas decisões",
"path": "/requisicoes?view=decided",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "finance",
"group": "Consultas e adjudicações",
"id": "quotations",
"label": "Cotações e comparação",
"path": "/cotacoes",
"action": "App\\Http\\Controllers\\QuotationController@index",
"full_page": false
},
{
"role": "finance",
"group": "Consultas e adjudicações",
"id": "orders",
"label": "Adjudicações e ordens",
"path": "/ordens",
"action": "App\\Http\\Controllers\\PurchaseController@index",
"full_page": false
},
{
"role": "finance",
"group": "Consultas e adjudicações",
"id": "purchase_review",
"label": "Revisão e cancelamento",
"path": "/revisao-compras",
"action": "App\\Http\\Controllers\\PurchaseControlController@index",
"full_page": false
},
{
"role": "finance",
"group": "Fornecedores",
"id": "suppliers",
"label": "Cadastro e documentos",
"path": "/fornecedores",
"action": "App\\Http\\Controllers\\SupplierController@index",
"full_page": false
},
{
"role": "finance",
"group": "Fornecedores",
"id": "supplier_evaluations",
"label": "Avaliação de fornecedores",
"path": "/fornecedores/avaliacoes",
"action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
"full_page": false
},
{
"role": "finance",
"group": "Recepções e armazém",
"id": "receipts",
"label": "Recepções e divergências",
"path": "/recepcoes",
"action": "App\\Http\\Controllers\\GoodsReceiptController@index",
"full_page": false
},
{
"role": "finance",
"group": "Recepções e armazém",
"id": "stock",
"label": "Artigos e stock",
"path": "/stock",
"action": "App\\Http\\Controllers\\StockController@index",
"full_page": false
},
{
"role": "finance",
"group": "Recepções e armazém",
"id": "stock_operations",
"label": "Saídas e inventários",
"path": "/stock/operacoes",
"action": "App\\Http\\Controllers\\StockOperationController@index",
"full_page": false
},
{
"role": "finance",
"group": "Recepções e armazém",
"id": "equipment",
"label": "Equipamentos e peças",
"path": "/equipamentos",
"action": "App\\Http\\Controllers\\EquipmentController@index",
"full_page": false
},
{
"role": "finance",
"group": "Finanças",
"id": "invoices",
"label": "Facturas e conferência",
"path": "/facturas",
"action": "App\\Http\\Controllers\\InvoiceController@index",
"full_page": false
},
{
"role": "finance",
"group": "Finanças",
"id": "payments",
"label": "Pagamentos externos",
"path": "/pagamentos",
"action": "App\\Http\\Controllers\\ExternalPaymentController@index",
"full_page": false
},
{
"role": "finance",
"group": "Finanças",
"id": "budgets",
"label": "Orçamentos e reforços",
"path": "/orcamentos",
"action": "App\\Http\\Controllers\\BudgetController@index",
"full_page": false
},
{
"role": "finance",
"group": "Finanças",
"id": "investment",
"label": "Regras de investimento",
"path": "/regras-investimento",
"action": "App\\Http\\Controllers\\InvestmentPolicyController@index",
"full_page": false
},
{
"role": "finance",
"group": "Administração e controlo",
"id": "conflicts",
"label": "Conflitos de interesses",
"path": "/conflitos",
"action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
"full_page": false
},
{
"role": "finance",
"group": "Relatórios",
"id": "procurement_reports",
"label": "Relatórios de compras",
"path": "/relatorios",
"action": "App\\Http\\Controllers\\ProcurementReportController@index",
"full_page": false
},
{
"role": "finance",
"group": "Relatórios",
"id": "supplier_improvement_reports",
"label": "Medidas de melhoria",
"path": "/relatorios/medidas-fornecedores",
"action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
"full_page": false
},
{
"role": "finance",
"group": "Finanças",
"id": "payables",
"label": "Obrigações a fornecedores",
"path": "/obrigacoes-fornecedores",
"action": "App\\Http\\Controllers\\SupplierPayablesController@page",
"full_page": true
},
{
"role": "department_head",
"group": "Requisições",
"id": "new_request",
"label": "Nova requisição",
"path": "/requisicoes/criar",
"action": "App\\Http\\Controllers\\RequisitionController@create",
"full_page": false
},
{
"role": "department_head",
"group": "Requisições",
"id": "my_requests",
"label": "Minhas requisições",
"path": "/requisicoes?view=mine",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Requisições",
"id": "my_drafts",
"label": "Os meus rascunhos",
"path": "/requisicoes?view=mine&status=draft",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Requisições",
"id": "all_requests",
"label": "Todas as autorizadas",
"path": "/requisicoes?view=all",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Requisições",
"id": "planning",
"label": "Planeamento",
"path": "/planeamento",
"action": "App\\Http\\Controllers\\PurchasePlanningController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Aprovações",
"id": "pending_approvals",
"label": "Pendentes para mim",
"path": "/requisicoes?view=approvals",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Aprovações",
"id": "decisions",
"label": "Minhas decisões",
"path": "/requisicoes?view=decided",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Consultas e adjudicações",
"id": "quotations",
"label": "Cotações e comparação",
"path": "/cotacoes",
"action": "App\\Http\\Controllers\\QuotationController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Consultas e adjudicações",
"id": "orders",
"label": "Adjudicações e ordens",
"path": "/ordens",
"action": "App\\Http\\Controllers\\PurchaseController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Consultas e adjudicações",
"id": "purchase_review",
"label": "Revisão e cancelamento",
"path": "/revisao-compras",
"action": "App\\Http\\Controllers\\PurchaseControlController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Fornecedores",
"id": "suppliers",
"label": "Cadastro e documentos",
"path": "/fornecedores",
"action": "App\\Http\\Controllers\\SupplierController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Fornecedores",
"id": "supplier_evaluations",
"label": "Avaliação de fornecedores",
"path": "/fornecedores/avaliacoes",
"action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Recepções e armazém",
"id": "receipts",
"label": "Recepções e divergências",
"path": "/recepcoes",
"action": "App\\Http\\Controllers\\GoodsReceiptController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Recepções e armazém",
"id": "equipment",
"label": "Equipamentos e peças",
"path": "/equipamentos",
"action": "App\\Http\\Controllers\\EquipmentController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Administração e controlo",
"id": "conflicts",
"label": "Conflitos de interesses",
"path": "/conflitos",
"action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Relatórios",
"id": "procurement_reports",
"label": "Relatórios de compras",
"path": "/relatorios",
"action": "App\\Http\\Controllers\\ProcurementReportController@index",
"full_page": false
},
{
"role": "department_head",
"group": "Relatórios",
"id": "supplier_improvement_reports",
"label": "Medidas de melhoria",
"path": "/relatorios/medidas-fornecedores",
"action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Requisições",
"id": "new_request",
"label": "Nova requisição",
"path": "/requisicoes/criar",
"action": "App\\Http\\Controllers\\RequisitionController@create",
"full_page": false
},
{
"role": "warehouse",
"group": "Requisições",
"id": "my_requests",
"label": "Minhas requisições",
"path": "/requisicoes?view=mine",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Requisições",
"id": "my_drafts",
"label": "Os meus rascunhos",
"path": "/requisicoes?view=mine&status=draft",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Requisições",
"id": "all_requests",
"label": "Todas as autorizadas",
"path": "/requisicoes?view=all",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Requisições",
"id": "planning",
"label": "Planeamento",
"path": "/planeamento",
"action": "App\\Http\\Controllers\\PurchasePlanningController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Consultas e adjudicações",
"id": "purchase_review",
"label": "Revisão e cancelamento",
"path": "/revisao-compras",
"action": "App\\Http\\Controllers\\PurchaseControlController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Recepções e armazém",
"id": "receipts",
"label": "Recepções e divergências",
"path": "/recepcoes",
"action": "App\\Http\\Controllers\\GoodsReceiptController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Recepções e armazém",
"id": "stock",
"label": "Artigos e stock",
"path": "/stock",
"action": "App\\Http\\Controllers\\StockController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Recepções e armazém",
"id": "stock_operations",
"label": "Saídas e inventários",
"path": "/stock/operacoes",
"action": "App\\Http\\Controllers\\StockOperationController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Recepções e armazém",
"id": "equipment",
"label": "Equipamentos e peças",
"path": "/equipamentos",
"action": "App\\Http\\Controllers\\EquipmentController@index",
"full_page": false
},
{
"role": "warehouse",
"group": "Administração e controlo",
"id": "conflicts",
"label": "Conflitos de interesses",
"path": "/conflitos",
"action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
"full_page": false
},
{
"role": "requester",
"group": "Requisições",
"id": "new_request",
"label": "Nova requisição",
"path": "/requisicoes/criar",
"action": "App\\Http\\Controllers\\RequisitionController@create",
"full_page": false
},
{
"role": "requester",
"group": "Requisições",
"id": "my_requests",
"label": "Minhas requisições",
"path": "/requisicoes?view=mine",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "requester",
"group": "Requisições",
"id": "my_drafts",
"label": "Os meus rascunhos",
"path": "/requisicoes?view=mine&status=draft",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "requester",
"group": "Requisições",
"id": "all_requests",
"label": "Todas as autorizadas",
"path": "/requisicoes?view=all",
"action": "App\\Http\\Controllers\\RequisitionController@index",
"full_page": false
},
{
"role": "requester",
"group": "Requisições",
"id": "planning",
"label": "Planeamento",
"path": "/planeamento",
"action": "App\\Http\\Controllers\\PurchasePlanningController@index",
"full_page": false
},
{
"role": "requester",
"group": "Consultas e adjudicações",
"id": "purchase_review",
"label": "Revisão e cancelamento",
"path": "/revisao-compras",
"action": "App\\Http\\Controllers\\PurchaseControlController@index",
"full_page": false
},
{
"role": "requester",
"group": "Administração e controlo",
"id": "conflicts",
"label": "Conflitos de interesses",
"path": "/conflitos",
"action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
"full_page": false
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"checked_at": "2026-10-30T08:46:09.751Z",
"base": "http://127.0.0.1:8005",
"path": "/administracao/parametrizacao",
"roles": [
"admin",
"requester",
"department_head",
"procurement",
"finance",
"warehouse"
],
"owner_access": true,
"other_legacy_profiles_refused": true,
"passwords_not_returned": true,
"configuration_state": {
"active": false,
"version": 1,
"profiles": 0
},
"business_commands": 0,
"accounts_created": 0,
"permissions_changed": false,
"visual_review": false
}
@@ -0,0 +1,63 @@
{
"checked_at": "2026-10-30T08:47:37.236992+00:00",
"full_suite": {
"total": 603,
"passed": 601,
"skipped": 2,
"assertions": 5157,
"log": "storage/logs/organization-delivery-tests.log"
},
"postgresql_configuration": {
"tests": 13,
"passed": 13,
"assertions": 96,
"disposable_database_removed": true,
"log": "storage/logs/organization-pg-tests.log"
},
"phpstan_errors": 0,
"typescript": "passed",
"lint_format_targeted": "passed",
"pint": "passed",
"build": "passed",
"build_log": "storage/logs/organization-build.log",
"runtime_files_verified": 18,
"menu_entries": 107,
"evaluation_catalog_pages": 23,
"installed_only_in_evaluation": true,
"configuration_activated_in_evaluation": false,
"accounts_or_permissions_changed_in_evaluation": false,
"maintenance": {
"named_account": true,
"normal_authentication": true,
"expiry_required": true,
"revocable": true,
"audited": true,
"read_only": true,
"universal_password": false,
"hidden_entry": false
},
"domain_installed": false,
"visual_review": false,
"remaining": [
"permission granularity by operation",
"universal workflow stage/condition editor",
"specific common financial identity and entitlement associations",
"visual and print review"
],
"sources": {
"app/Services/OrganizationAccess.php": "872f06e50d561714ab50d88dc113c0fa054dea9f170a09bb37f8d39d07b78a09",
"app/Services/OrganizationConfiguration.php": "e2d6e725a78dc4f10d931017969d0fcdacaed4dd6b37a4a08a96cb44b09fd1b5",
"app/Http/Controllers/OrganizationConfigurationController.php": "9765cee7b1770f48fde4affcefc518a36576190bbaeac8bce079655353377c72",
"app/Http/Controllers/CompanyController.php": "5a8112e0f1535f90aeb8cf9785259da8d713d1bfcd3ad908aa3eabd161dba8a3",
"app/Http/Middleware/EnsureOrganizationAccess.php": "6782164f18f982fedb91dfb4fb2365895c757d776d7bf1728a91682fa95b8b02",
"app/Models/User.php": "835c119c097e7927136e8c7775ebb5ed677f964e766a213ec3c04e0b7388e740",
"app/Enums/PurchasingRole.php": "91da2d40bf11988fd6324aba8e5068e6be7c92222be31baf83844c134affe1c4",
"database/migrations/2026_10_30_000027_create_organization_access.php": "130d92ba8a093a8f77678e1ce4c1a90125bb996e88ed81633359d224fe77a853",
"database/seeders/DatabaseSeeder.php": "2aaea71ca2eba604abd5723e95cd1a99521c61b6f00b70dfedba246f3dcfae48",
"resources/js/pages/company/parameters.tsx": "c0760c50d100c8b2dfb3d3ad3b2a05fae14eed7900e95461c766a885d07677d1",
"resources/js/pages/company/setup.tsx": "614000ad22692ea3e707a00103227953d95f32fd7d9a7fc031bdbba7a1ccdb48",
"resources/js/lib/procurement-navigation.ts": "d7a42ec76e0166e4783bb496834325ad583b903ebf8c999c2ba5a2307431d56d",
"tests/Feature/OrganizationConfigurationTest.php": "eef7e82a6efc8ebad46cea8752861c01bec53c552874d72120a6e982f0114d73",
"scripts/organization-access-check.mjs": "1ca7cd2bdafa5f62afb26fa0b074d879273cda27c8350b968de54c8f53328bb0"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"checked_at": "2026-10-30T08:28:25+00:00",
"migration": "2026_10_30_000027_create_organization_access",
"environment": "local evaluation 8005",
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-organization-20261030-082820\/before.dump",
"backup_sha256": "5ae87fd10b3c7cde3021a8d0976f281a41db32d45e6d7f658de7e20e89e854f6",
"backup_bytes": 391786,
"restore_verified": true,
"existing_tables_preserved": 84,
"new_tables": {
"organization_access": 0,
"organization_profiles": 0,
"organization_memberships": 0
},
"accounts_or_grants_changed": false,
"other_migrations_applied": false,
"domain_installed": false
}
+9
View File
@@ -0,0 +1,9 @@
{
"checked_at": "2026-10-30T08:40:09+00:00",
"database": "disposable PostgreSQL",
"application_role_reused_without_privilege_change": true,
"result": "passed",
"test_file": "tests\/Feature\/OrganizationConfigurationTest.php",
"original_evaluation_untouched": true,
"database_removed": true
}
+50
View File
@@ -0,0 +1,50 @@
{
"date": "2026-10-30",
"base_commit": "0235c7609ed52345e91ae1a6b268e45e1eb991af",
"scope": "Menu routing inventory, central filtered procurement reports/CSV and existing individual report extended to receipt/invoice/external-payment records.",
"checks": {
"feature_passed": 14,
"assertions": 223,
"new_cases": 8,
"existing_report_regressions": 6,
"export_fixture_records": 207,
"menu_entries_across_roles": 102,
"roles": 6,
"types": "passed",
"lint": "passed",
"format": "passed",
"pint": "passed",
"phpstan": "passed after correcting the documented date cast type",
"build": "passed",
"new_module_http": 200,
"evaluation_entry_http": 200,
"runtime_manifest_files": 18
},
"source_sha256": {
"app/Http/Controllers/ProcurementReportController.php": "5161063f8f9684595c83300f22e6516e03700917e3b578651dee7254ee5f6a55",
"app/Services/ProcurementReviewReport.php": "1d5a41b59c471ad1db5e7f39eb15ac4b080239968341ccdf9a78ce17a301144c",
"app/Models/GoodsReceipt.php": "ec9b86ca625a8d4d8972ba1d91636bf37475f680f2212faa1e31c44709660a5a",
"routes/web.php": "1014ea42f0544428df49304c16cc55fb0632188ac0ad1940ba6a637b9f228be0",
"resources/js/lib/procurement-navigation.ts": "f250199564cd56e73ff5d5851a19dcbbe41df6da7b5c2cb9024c0965b8e087dc",
"resources/js/pages/reports/procurement.tsx": "a4ec4c51a8b7b8012852f1ad1d36ac931fec25c41a383c10a9449fddc3bd23d5",
"resources/js/pages/purchases/review-report.tsx": "8a5a02d99b5dd5d6f50a41e6e76711b15e578fd09dc039e2b6defc64d20abe04",
"tests/Feature/ProcurementReportsTest.php": "fc88ceef66d419ef72e9231e8273da20f2e1f400ad9610ad5ff85cadeb2259b2",
"scripts/menu-code-check.mjs": "8480ec5b1f3fd26aa7ff7cbc52c304a632b11e0eaa5a4cacadab0e119ceac236"
},
"menu_proof": "docs/proofs/menu-code-contract.json",
"build_log_local": "storage/logs/development-reports-build.log",
"build_log_sha256": "2709a9c383c3110469d6aa08a1efc8b821e24c3b7b8f9a5d3552e403ea9f5ad6",
"preservation": {
"migrations": 0,
"active_business_writes": 0,
"new_accounts_or_grants": 0,
"balance_or_tax_recalculation": false,
"remote_deployment": false
},
"limits": [
"Visual and manual printing not executed.",
"PostgreSQL snapshot clause implemented using the existing report pattern; new tests used isolated SQLite, not a PostgreSQL concurrency proof.",
"Supplier evaluations remain an interface proposal pending its operational implementation.",
"Not a consolidated debt/bank/accounting report; common settlements keep their own history."
]
}
@@ -0,0 +1,15 @@
{
"checked_at": "2026-10-30T07:33:29.638Z",
"base": "http://127.0.0.1:8005",
"report_path": "/fornecedores/avaliacoes/1/relatorio",
"revision_path": "/fornecedores/avaliacoes/2",
"create_submit_return_correct_approve": true,
"self_approval_refused": true,
"stale_write_refused": true,
"approved_edit_refused": true,
"prior_approved_revision_preserved": true,
"history_events": 6,
"synthetic_example_only": true,
"financial_commands": 0,
"visual_review": false
}
@@ -0,0 +1,16 @@
{
"checked_at": "2026-10-30T07:31:39+00:00",
"migration": "2026_10_30_000024_create_supplier_evaluations",
"environment": "local evaluation 8005",
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-evaluations-20261030-073134\/before.dump",
"backup_sha256": "a919349718964be17fafbe4ec407dc7ec5b096c9c1f961db42cb34dfe3ba1123",
"backup_bytes": 364285,
"restore_verified": true,
"existing_tables_preserved": 81,
"new_tables": {
"supplier_evaluations": 0
},
"accounts_or_grants_changed": false,
"other_migrations_applied": false,
"domain_installed": false
}
+8
View File
@@ -0,0 +1,8 @@
{
"checked_at": "2026-10-30T07:34:52+00:00",
"environment": "local evaluation 8005",
"approved_update": "refused by PostgreSQL trigger",
"approved_delete": "refused by PostgreSQL trigger",
"approved_row_unchanged": true,
"probe_transactions_rolled_back": true
}
@@ -0,0 +1,24 @@
{
"checked_at": "2026-10-30T07:51:21.312Z",
"base": "http://127.0.0.1:8005",
"path": "/relatorios/medidas-fornecedores",
"readers": [
"admin",
"procurement",
"finance",
"department_head"
],
"denied": [
"requester",
"warehouse"
],
"total": 1,
"filtered_total": 1,
"filters_and_csv_match": true,
"csv_ignores_page": true,
"closed_not_overdue": true,
"invalid_range_refused": true,
"linked_evaluation_and_report": true,
"business_commands": 0,
"visual_review": false
}
@@ -0,0 +1,29 @@
{
"checked_at": "2026-10-30T07:52:09.806123+00:00",
"targeted_tests": 36,
"assertions": 542,
"new_report_test_cases": 6,
"csv_batch_test_rows": 207,
"phpstan_errors": 0,
"typescript": "passed",
"lint_and_format_targeted": "passed",
"pint_targeted": "passed",
"build": "passed",
"build_log": "storage/logs/supplier-improvement-report-build.log",
"menu_entries": 106,
"roles": 6,
"evaluation_catalog_pages": 22,
"schema_changes": false,
"business_commands": 0,
"domain_installed": false,
"visual_review": false,
"sources": {
"app/Http/Controllers/SupplierImprovementReportController.php": "fdaf0d5484e91481cdd5d676eb6951f8575bcc328961e993acf3290f5d8343cc",
"app/Models/SupplierImprovement.php": "762af10d9c14d70dd03e51ef6d9a80efe36171a9a0121a985ae0c17f06f03d4e",
"resources/js/pages/reports/supplier-improvements.tsx": "070d4c5eaf0a75439751504b21262df1c2f0e60d59e93538ff5d9c5959fe6398",
"resources/js/components/supplier-improvements.tsx": "7add7f51d907943682d3f132dc135f0e41b726dd67ddf2478852cf30ba5a21dd",
"resources/js/lib/procurement-navigation.ts": "a2a26bbd50c288e7cde589c7d8c2aaa3929f5ae20fc8b01c502db031d95085d4",
"tests/Feature/SupplierImprovementReportTest.php": "daf90f8da729a489bdc5116b318514c82a91b89ffbfb2c082b11457661544985",
"scripts/supplier-improvement-report-check.mjs": "60c57d36ebc83928198734573b5072240291504b57f2b88be3167f8f7c40c906"
}
}
@@ -0,0 +1,19 @@
{
"checked_at": "2026-10-30T07:41:16.109Z",
"base": "http://127.0.0.1:8005",
"evaluation_path": "/fornecedores/avaliacoes/1",
"measure_id": 1,
"create_retry_idempotent": true,
"missing_evidence_refused": true,
"self_closure_refused": true,
"planner_closure_refused": true,
"return_replan_correct_submit_close": true,
"stale_plan_refused": true,
"closed_edit_refused": true,
"parent_evaluation_unchanged": true,
"history_events": 7,
"report_includes_closed_measure": true,
"synthetic_example_only": true,
"financial_commands": 0,
"visual_review": false
}
@@ -0,0 +1,16 @@
{
"checked_at": "2026-10-30T07:40:33+00:00",
"migration": "2026_10_30_000025_create_supplier_improvements",
"environment": "local evaluation 8005",
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-improvements-20261030-074028\/before.dump",
"backup_sha256": "57ae564afea50f15700003de3aeb7a8f2a9ecd72c76d545f9687cbe91d051811",
"backup_bytes": 375123,
"restore_verified": true,
"existing_tables_preserved": 82,
"new_tables": {
"supplier_improvements": 0
},
"accounts_or_grants_changed": false,
"other_migrations_applied": false,
"domain_installed": false
}
@@ -0,0 +1,8 @@
{
"checked_at": "2026-10-30T07:41:48+00:00",
"environment": "local evaluation 8005",
"closed_update": "refused by PostgreSQL trigger",
"closed_delete": "refused by PostgreSQL trigger",
"closed_row_unchanged": true,
"probe_transactions_rolled_back": true
}
+20
View File
@@ -0,0 +1,20 @@
{
"checked_at": "2026-10-30T07:57:43.108Z",
"base": "http://127.0.0.1:8005",
"evaluation_path": "/fornecedores/avaliacoes/1",
"response_id": 1,
"idempotent_creation": true,
"incomplete_submit_refused": true,
"stale_save_refused": true,
"self_decision_refused": true,
"return_correction_independent_conclusion": true,
"accepted_follow_up_required": true,
"concluded_edit_refused": true,
"parent_evaluation_unchanged": true,
"history_events": 7,
"individual_report": true,
"synthetic_example_only": true,
"external_messages_sent": 0,
"financial_commands": 0,
"visual_review": false
}
@@ -0,0 +1,16 @@
{
"checked_at": "2026-10-30T07:56:47+00:00",
"migration": "2026_10_30_000026_create_supplier_responses",
"environment": "local evaluation 8005",
"backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-responses-20261030-075643\/before.dump",
"backup_sha256": "278b88b2f46d9b80df3de878c3964a7f7ab62022bd082ce6a26fdd0e2ccccb54",
"backup_bytes": 383864,
"restore_verified": true,
"existing_tables_preserved": 83,
"new_tables": {
"supplier_responses": 0
},
"accounts_or_grants_changed": false,
"other_migrations_applied": false,
"domain_installed": false
}
+8
View File
@@ -0,0 +1,8 @@
{
"checked_at": "2026-10-30T07:58:09+00:00",
"environment": "local evaluation 8005",
"decided_update": "refused by PostgreSQL trigger",
"decided_delete": "refused by PostgreSQL trigger",
"decided_row_unchanged": true,
"probe_transactions_rolled_back": true
}
+28
View File
@@ -0,0 +1,28 @@
{
"checked_at": "2026-10-30T07:59:03.931961+00:00",
"targeted_tests": 39,
"assertions": 505,
"new_test_cases": 11,
"phpstan_errors": 0,
"typescript": "passed",
"lint_format_pint_targeted": "passed",
"build": "passed",
"build_log": "storage/logs/supplier-responses-build.log",
"menu_entries": 106,
"roles": 6,
"installed_in_local_evaluation": true,
"domain_installed": false,
"external_messages_sent": 0,
"visual_review": false,
"source_hashes": {
"app/Models/SupplierResponse.php": "fc3877f2e787483cb56f31ca877eb2dc4f2d266c2eefa47dab1b1ae3ecf163fc",
"app/Services/SupplierResponseWorkflow.php": "772af0878f55cd9818cddec62aca3850ae4868c13acf86fe7dd003bac1fbb0b5",
"app/Http/Controllers/SupplierResponseController.php": "6960500484974f6ba2be1c63bb1156abde976c9e42837f6d3101a1f8a73092d3",
"app/Http/Controllers/SupplierEvaluationController.php": "2523eb6a858fdb62c494d74a8ce7988e7f29d4e0c51c53d3f0899de02987f17d",
"database/migrations/2026_10_30_000026_create_supplier_responses.php": "d414f637587088e589d886f792d678674337707e8391a0ab0fcf86dc9ebfaaf4",
"resources/js/components/supplier-responses.tsx": "ec0becd110e879f5929a7179c61352e1f31478674e654a41b1f85c64ecd1adf1",
"resources/js/pages/suppliers/evaluation.tsx": "509d27438a2a3b5337572357b9c69a2703201789ea75a487b36dfc301f65c8db",
"tests/Feature/SupplierResponseTest.php": "00faa0131f7335e1d2f18d781aa16cb914bf13acb1090ab902269f737b87c3c6",
"scripts/supplier-response-check.mjs": "31c7b741bbb0becc0426d0437a1d9853616199a86730831dc09b902e66f469c2"
}
}

Some files were not shown because too many files have changed in this diff Show More