Compare commits
23
Commits
413c201d97
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976271f948 | ||
|
|
63d20b10fc | ||
|
|
e753f7a0a4 | ||
|
|
badaada043 | ||
|
|
b44378b899 | ||
|
|
504b01b244 | ||
|
|
13a6cb9922 | ||
|
|
3b1ef2031c | ||
|
|
86b31f2840 | ||
|
|
f5f5649cfc | ||
|
|
c22846b2b1 | ||
|
|
bf84575d10 | ||
|
|
2134aa084b | ||
|
|
c6373365bf | ||
|
|
6d14b4e6f0 | ||
|
|
fa0e656214 | ||
|
|
da8e4c663f | ||
|
|
6e806cf149 | ||
|
|
a684d49b3d | ||
|
|
c855df8389 | ||
|
|
48347e2d3a | ||
|
|
0235c7609e | ||
|
|
465426847e |
@@ -71,3 +71,6 @@ COMMON_PAYABLES_ENABLED=false
|
||||
COMMON_PAYABLES_HISTORY_ENABLED=false
|
||||
COMMON_PAYABLES_HISTORY_DECISIONS_ENABLED=false
|
||||
PAYABLE_SOURCE_INSTANCE=
|
||||
|
||||
# Adesão voluntária MFA, só depois da migração e validação da instalação.
|
||||
SGC_TWO_FACTOR_ENABLED=false
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
* 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
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
@@ -9,3 +13,6 @@
|
||||
CHANGELOG.md export-ignore
|
||||
README.md export-ignore
|
||||
.github/workflows/browser-tests.yml export-ignore
|
||||
|
||||
/packages/budget-control/** -export-ignore
|
||||
/packages/payables/** -export-ignore
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
# Sistema de Gestão de Compras (SGC)
|
||||
# Sistema de Gestão de Contratações (SGC)
|
||||
|
||||
Sistema de gestão de compras em Laravel 13, React, TypeScript e Inertia. Esta entrega implementa a base de requisições e aprovações da especificação de procurement.
|
||||
**Aquisições, serviços e pessoas · Uma solução TEMPO.**
|
||||
|
||||
Sistema em Laravel 13, React, TypeScript e Inertia que reúne compras, contratação de prestadores individuais e recrutamento de trabalhadores, com perfis, aprovações e histórico próprios de cada percurso.
|
||||
|
||||
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
|
||||
|
||||
Durante o desenvolvimento, use `composer run dev` e abra **http://127.0.0.1:8005/**. Este é o ambiente persistente com entrada directa no dashboard, os menus organizados e os processos em avaliação. `/avaliar` permite mudar de perfil. O arranque reutiliza a avaliação existente e inicia o Vite quando necessário, sem migrações, seeds ou alterações de contas.
|
||||
|
||||
Conferência dos pagamentos pendentes após importação: [entrega e provas](docs/conferencia-pendentes-importados.md). Implementada em testes, com migração 000023 e activação ainda por aplicar; demonstração actual preservada.
|
||||
|
||||
### 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.
|
||||
|
||||
Retomar o servidor existente, sem apagar dados: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\laragon\www\sgf-tempo\scripts\payables-demo.ps1 start`. Se já estiver activo, basta abrir a ligação. Verificação HTTP: `scripts/evaluation-access-check.mjs`; resultados em `docs/proofs/evaluation-access.json` (20 páginas, seis perfis, CSRF, recusa de identificadores arbitrários e saída; sem operações de negócio). A revisão visual do utilizador é separada.
|
||||
Retomar a avaliação existente, sem apagar dados: `composer run dev`. Se já estiver activa, basta abrir a ligação. O comando não provisiona outra base nem reinicia o SGF. Verificação HTTP: `scripts/evaluation-access-check.mjs`; resultados em `docs/proofs/evaluation-access.json` (20 páginas, seis perfis, CSRF, recusa de identificadores arbitrários e saída; sem operações de negócio). A revisão visual do utilizador é separada.
|
||||
|
||||
Endereço local deste projecto: **http://127.0.0.1:8015**. Use `SERVER_PORT=8015`, `APP_URL=http://127.0.0.1:8015` e `SESSION_COOKIE=procuraflow_session` no `.env` para evitar colisões com outros projectos. O comando `php artisan serve` respeita `SERVER_PORT`.
|
||||
Instalação habitual separada: **http://127.0.0.1:8015**, com autenticação e dados próprios, preservada no `.env`. O arranque antigo permanece disponível explicitamente como `composer run dev:standard`; não é a entrada de avaliação usada durante o desenvolvimento.
|
||||
|
||||
As dependências e o `.env` devem estar configurados. O projecto suporta a ligação PostgreSQL existente; os testes usam SQLite em memória.
|
||||
### Preparação da instalação habitual, quando pretendida
|
||||
|
||||
As dependências e o `.env` devem estar configurados. O projecto suporta a ligação PostgreSQL existente; os testes usam SQLite em memória. Esta preparação é separada da avaliação persistente usada no desenvolvimento.
|
||||
|
||||
```sh
|
||||
php artisan migrate
|
||||
npm install
|
||||
npm run build
|
||||
composer dev
|
||||
composer run dev:standard
|
||||
```
|
||||
|
||||
Abra a aplicação, registe uma conta e configure a empresa no painel. A primeira conta torna-se administradora exclusivamente da empresa que cria. Em **Empresa e equipa**, adicione departamentos, centros de custo e utilizadores com os perfis necessários. Contas registadas publicamente não entram automaticamente numa empresa existente.
|
||||
@@ -33,6 +41,8 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
|
||||
|
||||
## 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).
|
||||
|
||||
- Regras de investimento próprias por empresa: tecto por processo, base de IVA
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\OperationalHealth;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class OperationsCheck extends Command
|
||||
{
|
||||
protected $signature = 'sgc:operations-check {--json : Resultado agregado para monitorização} {--document-limit=1000 : Máximo de documentos e ficheiros a inspeccionar, de 1 a 10000}';
|
||||
|
||||
protected $description = 'Consultar reposição e integridade documental sem processar compras, corrigir ficheiros ou alterar configurações';
|
||||
|
||||
public function handle(OperationalHealth $health): int
|
||||
{
|
||||
$limit = filter_var($this->option('document-limit'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10000]]);
|
||||
if ($limit === false) {
|
||||
$this->error('O limite deve ser um inteiro entre 1 e 10000.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$result = $health->inspect($limit);
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
} else {
|
||||
$this->line($result['ok'] ? 'Operação: verificações concluídas sem divergências observadas.' : 'Operação: verificações requerem atenção.');
|
||||
$labels = ['replenishment' => 'Reposição', 'receipt_documents' => 'Documentos da recepção'];
|
||||
$states = ['passed' => 'sem divergências observadas', 'attention' => 'requer atenção', 'incomplete' => 'verificação incompleta', 'unavailable' => 'consulta indisponível'];
|
||||
foreach ($result['checks'] as $key => $check) {
|
||||
$this->line($labels[$key].': '.$states[$check['status']].'.');
|
||||
foreach ($check['result']['reasons'] as $reason) {
|
||||
$this->line(' '.$reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result['ok'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ReceiptDocumentHealth;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ReceiptDocumentsCheck extends Command
|
||||
{
|
||||
protected $signature = 'sgc:receipt-documents-check {--json : Resultado agregado para operação} {--limit=1000 : Máximo de registos e ficheiros, de 1 a 10000}';
|
||||
|
||||
protected $description = 'Verificar documentos privados da recepção sem modificar registos ou ficheiros';
|
||||
|
||||
public function handle(ReceiptDocumentHealth $health): int
|
||||
{
|
||||
$limit = filter_var($this->option('limit'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10000]]);
|
||||
if ($limit === false) {
|
||||
$this->error('O limite deve ser um inteiro entre 1 e 10000.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
try {
|
||||
$result = $health->inspect($limit);
|
||||
} catch (Throwable $error) {
|
||||
report($error);
|
||||
$result = ['checked_at' => now()->toIso8601String(), 'ok' => false, 'complete' => false, 'reasons' => ['unavailable']];
|
||||
}
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
|
||||
} else {
|
||||
$this->line($result['ok'] ? 'Documentos da recepção: verificação concluída sem divergências observadas.' : 'Documentos da recepção: verificação requer atenção.');
|
||||
$labels = ['not_installed' => 'Estrutura documental não instalada.', 'storage_unavailable' => 'Armazenamento privado indisponível.',
|
||||
'missing' => 'Existem ficheiros em falta.', 'invalid_reference' => 'Existem referências inválidas.', 'altered' => 'Existem divergências de tamanho ou conteúdo.',
|
||||
'unreferenced_recent' => 'Existem ficheiros recentes sem referência; podem estar em processamento.', 'unreferenced_older' => 'Existem ficheiros sem referência há pelo menos uma hora.',
|
||||
'limit_reached' => 'Verificação incompleta: atingido o limite configurado.', 'unavailable' => 'Consulta indisponível; verificar o registo técnico.'];
|
||||
foreach ($result['reasons'] as $reason) {
|
||||
$this->line($labels[$reason]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result['ok'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ReplenishmentHealth extends Command
|
||||
{
|
||||
protected $signature = 'sgc:replenishment-health {--json : Resultado técnico para integração operacional}';
|
||||
|
||||
protected $description = 'Consultar o estado técnico da reposição sem processar trabalhos ou aprovar compras';
|
||||
|
||||
public function handle(ReplenishmentQueue $queue): int
|
||||
{
|
||||
try {
|
||||
$result = $queue->health();
|
||||
} catch (Throwable $error) {
|
||||
report($error);
|
||||
$result = ['checked_at' => now()->toIso8601String(), 'ok' => false, 'reasons' => ['unavailable']];
|
||||
}
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
} else {
|
||||
$this->line($result['ok'] ? 'Reposição: verificação técnica sem pendências observadas.' : 'Reposição: verificação técnica requer atenção.');
|
||||
$labels = ['not_installed' => 'Reposição durável não instalada nesta composição.', 'not_recent' => 'Sem passagem recente confirmada.',
|
||||
'last_run_not_successful' => 'Última passagem sem sucesso confirmado.', 'pending_signals' => 'Existem sinais duráveis por processar.',
|
||||
'failed_work' => 'Existem trabalhos a recuperar.', 'unbound_observation' => 'Passagem sem associação confirmada a esta instalação/base.', 'unavailable' => 'Não foi possível consultar. Verifique o registo técnico.'];
|
||||
foreach ($result['reasons'] as $reason) {
|
||||
$this->line($labels[$reason]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result['ok'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ReplenishmentHeartbeat;
|
||||
use App\Services\ReplenishmentProcessLock;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReplenishmentTick extends Command
|
||||
{
|
||||
protected $signature = 'sgc:replenishment-tick {--limit= : Máximo de artigos/armazéns por execução}';
|
||||
|
||||
protected $description = 'Recuperar reposições pendentes e reavaliar mínimos e validade, sem aprovar compras';
|
||||
|
||||
public function handle(ReplenishmentQueue $queue): int
|
||||
{
|
||||
if (! $queue->ready()) {
|
||||
$this->warn('Reposição durável não instalada nesta composição.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$limit = $this->option('limit') ?? config('replenishment.batch_size');
|
||||
if (! filter_var($limit, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 500]])) {
|
||||
$this->error('Limite entre 1 e 500.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$started = now()->toIso8601String();
|
||||
$exitCode = self::FAILURE;
|
||||
$lock = app(ReplenishmentProcessLock::class);
|
||||
if (! $lock->acquire()) {
|
||||
$this->info('Verificação periódica já em execução; aguarde a próxima passagem.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$queue->discover();
|
||||
$result = $queue->drain((int) $limit);
|
||||
$this->info('Reposição: '.$result['processed'].' trabalhos conferidos; '.$result['failed'].' a recuperar.');
|
||||
$exitCode = $result['failed'] === 0 ? self::SUCCESS : self::FAILURE;
|
||||
|
||||
return $exitCode;
|
||||
} finally {
|
||||
try {
|
||||
app(ReplenishmentHeartbeat::class)->record($started, $exitCode);
|
||||
} catch (\Throwable $error) {
|
||||
report($error);
|
||||
$this->warn('Não foi possível actualizar o acompanhamento técnico desta execução.');
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SyntheticAiAssessment;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class SgcAiAssess extends Command
|
||||
{
|
||||
protected $signature = 'sgc:ai-assess {result : Ficheiro JSON UTF-8 do ensaio fictício}';
|
||||
|
||||
protected $description = 'Conferir localmente cobertura e referências de um resultado do ensaio, sem rede';
|
||||
|
||||
public function handle(SyntheticAiAssessment $assessment): int
|
||||
{
|
||||
try {
|
||||
$path = realpath((string) $this->argument('result'));
|
||||
if ($path === false || ! is_file($path) || filesize($path) > 100000) {
|
||||
throw new \RuntimeException('Local result file required.');
|
||||
}
|
||||
$input = json_decode((string) file_get_contents($path), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (! is_array($input) || ($input['synthetic_only'] ?? null) !== true || ! is_array($input['claims'] ?? null)) {
|
||||
throw new \RuntimeException('Synthetic result required.');
|
||||
}
|
||||
$result = $assessment->assess($input['claims']);
|
||||
$this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable) {
|
||||
$this->error('Não foi possível conferir o resultado fictício. Verifique o ficheiro e as referências; nenhum pedido externo foi efectuado.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SyntheticAiRehearsal;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class SgcAiRehearsal extends Command
|
||||
{
|
||||
protected $signature = 'sgc:ai-rehearsal {--live : Executar uma chamada paga, apenas com o cenário fictício incorporado}';
|
||||
|
||||
protected $description = 'Pré-visualizar o ensaio de síntese IA; sem rede por defeito e sem acesso a processos reais';
|
||||
|
||||
public function handle(SyntheticAiRehearsal $rehearsal): int
|
||||
{
|
||||
try {
|
||||
$result = $this->option('live') ? $rehearsal->run() : $rehearsal->preview();
|
||||
$this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable) {
|
||||
// Never render request bodies, provider errors or credentials in console logs.
|
||||
$this->error('Ensaio não concluído. Confira activação/credencial e o resultado dos testes; nenhuma decisão do SGC foi alterada.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class SgcDeliveryCheck extends Command
|
||||
{
|
||||
protected $signature = 'sgc:delivery-check {tenant : ID da empresa}';
|
||||
|
||||
protected $description = 'Conferir configuração e ausência de dados de desenvolvimento antes da entrega; não altera dados';
|
||||
|
||||
public function handle(WorkContext $contexts): int
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->argument('tenant'));
|
||||
if ($tenant === null || ! Schema::hasColumn('tenants', 'work_context_policy')) {
|
||||
$this->error('Empresa inexistente ou instalação do contexto pendente.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$users = User::query()->where('tenant_id', $tenant->id)->get();
|
||||
$actor = $users->first();
|
||||
if ($actor === null || $contexts->policy($actor) === null || $contexts->purchased($actor) === [] || ! app(OrganizationAccess::class)->active($tenant->id)) {
|
||||
$this->error('Defina produto contratado, opções e administração da empresa antes da entrega.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$administrators = 0;
|
||||
foreach ($users as $user) {
|
||||
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
|
||||
->where('m.tenant_id', $tenant->id)->where('p.tenant_id', $tenant->id)->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))) {
|
||||
$administrators++;
|
||||
}
|
||||
}
|
||||
$clean = true;
|
||||
foreach (Schema::getTables() as $table) {
|
||||
$name = $table['name'];
|
||||
if (in_array($name, ['users', 'departments', 'cost_centers', 'organization_profiles', 'organization_memberships', 'organization_access', 'audit_logs'], true)) {
|
||||
continue;
|
||||
}
|
||||
if (Schema::hasColumn($name, 'tenant_id') && DB::table($name)->where('tenant_id', $tenant->id)->exists()) {
|
||||
$this->error('Existem registos a reconciliar antes da entrega: '.$name);
|
||||
$clean = false;
|
||||
}
|
||||
}
|
||||
$enabled = $contexts->policy($actor)['enabled'] ?? [];
|
||||
if ($administrators < 2 || $enabled === [] || array_diff($enabled, $contexts->purchased($actor)) !== []) {
|
||||
$this->error('Confira dois administradores permanentes e opções activadas dentro do produto contratado.');
|
||||
$clean = false;
|
||||
}
|
||||
foreach ($enabled as $key) {
|
||||
if (! (WorkContext::OPTIONS[$key]['operational'] ?? false)) {
|
||||
$this->error('Percurso operacional ainda pendente: '.$key);
|
||||
$clean = false;
|
||||
}
|
||||
if (! $users->contains(fn ($user) => in_array($key, $contexts->available($user), true))) {
|
||||
$this->error('Opção sem utilizador com perfil/contexto autorizado: '.$key);
|
||||
$clean = false;
|
||||
}
|
||||
}
|
||||
if ($clean) {
|
||||
$this->info('Conferência técnica aprovada. Não substitui aceitação funcional, revisão dos cadastros de configuração ou instalação de RH.');
|
||||
}
|
||||
|
||||
return $clean ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ enum PurchasingRole: string
|
||||
case Finance = 'finance';
|
||||
case Warehouse = 'warehouse';
|
||||
case Admin = 'admin';
|
||||
case Configured = 'configured';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
@@ -20,6 +21,7 @@ enum PurchasingRole: string
|
||||
self::Finance => 'Finanças',
|
||||
self::Warehouse => 'Recepção / Armazém',
|
||||
self::Admin => 'Administrador',
|
||||
self::Configured => 'Perfil definido pela empresa',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AdministrativeGeographyController extends Controller
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
private function reviewFilters(Request $request, AdministrativeGeography $geography): array
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
app(OrganizationConfiguration::class)->administrator($actor);
|
||||
abort_unless($geography->enabled(), 503);
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:120', 'issue' => ['nullable', Rule::in(array_keys(AdministrativeGeography::ISSUES))], 'page' => 'sometimes|integer|min:1|max:10000']);
|
||||
|
||||
return ['search' => trim($filters['search'] ?? ''), 'issue' => $filters['issue'] ?? '', 'page' => (int) ($filters['page'] ?? 1)];
|
||||
}
|
||||
|
||||
public function review(Request $request, AdministrativeGeography $geography): Response
|
||||
{
|
||||
$filters = $this->reviewFilters($request, $geography);
|
||||
$rows = $geography->inconsistencies($filters);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('company/geography-review', ['filters' => $filters, 'records' => array_slice($rows, ($filters['page'] - 1) * 50, 50), 'total' => count($rows), 'manifest' => $geography->catalogue()['manifest'], 'issues' => AdministrativeGeography::ISSUES])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function exportReview(Request $request, AdministrativeGeography $geography): StreamedResponse
|
||||
{
|
||||
$filters = $this->reviewFilters($request, $geography);
|
||||
$rows = $geography->inconsistencies($filters);
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$output = fopen('php://output', 'w');
|
||||
if ($output === false) {
|
||||
throw new \RuntimeException('Não foi possível preparar a exportação.');
|
||||
}
|
||||
$safe = fn ($value) => preg_match('/^[\s]*[=+@-]|^[\t\r\n]/u', $value) ? "'".$value : $value;
|
||||
fputcsv($output, ['Linha', 'ID', 'Provincia', 'Distrito', 'Posto_Administrativo', 'Localidade', 'Bairro_Urbano', 'Aldeia_Quarteirao', 'Designacao', 'Diagnosticos'], ',', '"', '');
|
||||
foreach ($rows as $record) {
|
||||
$raw = $record['raw'];
|
||||
fputcsv($output, array_map($safe, [(string) $record['source_row'], $record['id'], ...array_values(array_diff_key($raw, ['ID' => true])), implode('; ', array_map(fn ($issue) => AdministrativeGeography::ISSUES[$issue], $record['issues']))]), ',', '"', '');
|
||||
}
|
||||
fclose($output);
|
||||
}, 'sgc-cartografia-pendencias.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
public function index(Request $request, AdministrativeGeography $geography): JsonResponse
|
||||
{
|
||||
abort_unless($request->user('web') instanceof User && $request->user('web')->tenant_id !== null, 403);
|
||||
abort_unless($geography->enabled(), 503);
|
||||
$data = $request->validate(['parent' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:10000']);
|
||||
|
||||
return response()->json($geography->children($data['parent'] ?? null, (int) ($data['page'] ?? 1)));
|
||||
}
|
||||
|
||||
public function company(Request $request, AdministrativeGeography $geography): RedirectResponse
|
||||
{
|
||||
abort_unless($geography->enabled(), 503);
|
||||
$data = $request->validate(['geography_reference' => 'present|nullable|array:country,catalogue,record_id', 'geography_reference.catalogue' => 'required_with:geography_reference|string|size:64', 'geography_reference.record_id' => 'required_with:geography_reference|string|regex:/^[0-9]+$/D|max:20', 'expected_reference' => 'present|nullable|array:country,catalogue,record_id']);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
DB::transaction(function () use ($actor, $data, $geography) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(OrganizationConfiguration::class)->administrator($actor);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$company = Tenant::query()->lockForUpdate()->findOrFail($actor->tenant_id);
|
||||
if ($company->geography_reference != $data['expected_reference']) {
|
||||
throw ValidationException::withMessages(['geography_reference' => 'A localização foi alterada. Reabra a parametrização.']);
|
||||
}
|
||||
$before = $company->geography_reference;
|
||||
$after = $geography->canonical($data['geography_reference']);
|
||||
if ($before != $after) {
|
||||
$company->update(['geography_reference' => $after]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'company.geography_updated', null, ['geography_reference' => $before], ['geography_reference' => $after]);
|
||||
}
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuditMetadataController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$user = $request->user('web');
|
||||
abort_unless($user instanceof User && $user->tenant_id !== null, 403);
|
||||
$user = User::query()->where('tenant_id', $user->tenant_id)->findOrFail($user->id);
|
||||
$access = app(OrganizationAccess::class);
|
||||
abort_unless(! $access->maintenanceOnly($user) && in_array('audit_metadata_read', $access->permissions($user), true), 403);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function filters(Request $request): array
|
||||
{
|
||||
return $request->validate(['action' => 'nullable|string|max:100', 'actor_id' => 'nullable|integer|min:1',
|
||||
'from' => 'nullable|date_format:Y-m-d', 'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')], 'page' => 'nullable|integer|min:1|max:1000000']);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
private function query(User $actor, array $filters): Builder
|
||||
{
|
||||
return DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)
|
||||
->when(! empty($filters['action']), fn ($q) => $q->where('action', $filters['action']))
|
||||
->when(! empty($filters['actor_id']), fn ($q) => $q->where('user_id', $filters['actor_id']))
|
||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('created_at', '<=', $filters['to']))
|
||||
->select(['id', 'created_at', 'action', 'user_id']);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->actor($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $filters) {
|
||||
$this->readOnly();
|
||||
$actor = $this->actor($request);
|
||||
Inertia::encryptHistory();
|
||||
|
||||
$response = Inertia::render('audit/metadata', ['filters' => $filters,
|
||||
'events' => $this->query($actor, $filters)->orderBy('id')->paginate(25)->withQueryString()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function csv(Request $request): Response
|
||||
{
|
||||
$this->actor($request);
|
||||
$filters = $this->filters($request);
|
||||
|
||||
return response()->streamDownload(function () use ($request, $filters) {
|
||||
DB::transaction(function () use ($request, $filters) {
|
||||
$this->readOnly();
|
||||
$actor = $this->actor($request);
|
||||
$out = fopen('php://output', 'w');
|
||||
if ($out === false) {
|
||||
throw new \RuntimeException('Não foi possível preparar a exportação.');
|
||||
}
|
||||
$text = static function (mixed $value): string {
|
||||
$value = (string) ($value ?? '');
|
||||
|
||||
return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value;
|
||||
};
|
||||
try {
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['Metadados de eventos da empresa', 'Consulta', now()->toIso8601String()], ';', '"', '');
|
||||
fputcsv($out, ['Registo', 'Data', 'Acção', 'Utilizador (id)'], ';', '"', '');
|
||||
foreach ($this->query($actor, $filters)->lazyById(100) as $event) {
|
||||
fputcsv($out, array_map($text, [$event->id, $event->created_at, $event->action, $event->user_id]), ';', '"', '');
|
||||
}
|
||||
} finally {
|
||||
fclose($out);
|
||||
}
|
||||
});
|
||||
}, 'eventos-metadados-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
|
||||
private function readOnly(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\AutomaticPurchases;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ReplenishmentQueue;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
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 AutomaticPurchaseController extends Controller
|
||||
{
|
||||
public function index(Request $request, AutomaticPurchases $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'supply_read', 'supply_prepare', 'supply_review']), 403);
|
||||
$prepare = app(SupplyPlanning::class)->allows($actor, 'supply_prepare');
|
||||
$ready = $service->ready();
|
||||
$planning = app(SupplyPlanning::class);
|
||||
$filters = $request->validate(['rule_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'auto_rule')], 'need_state' => 'nullable|in:all,pending,created,covered,satisfied', 'rules_page' => 'nullable|integer|min:1|max:1000000', 'needs_page' => 'nullable|integer|min:1|max:1000000', 'history_page' => 'nullable|integer|min:1|max:1000000', 'work_page' => 'nullable|integer|min:1|max:1000000']);
|
||||
$focus = isset($filters['rule_id']) ? SupplyRecord::query()->where('kind', 'auto_rule')->findOrFail((int) $filters['rule_id']) : null;
|
||||
$rules = $planning->latest('auto_rule')->when($focus !== null, fn ($query) => $query->where('record_key', $focus->record_key));
|
||||
$latestFocus = $focus === null ? null : (clone $rules)->first();
|
||||
$needs = $planning->latest('auto_need');
|
||||
if (($filters['need_state'] ?? 'all') !== 'all') {
|
||||
$needs->where('state', $filters['need_state']);
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
|
||||
$response = Inertia::render('stock/replenishment', [
|
||||
'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'),
|
||||
'filters' => ['need_state' => $filters['need_state'] ?? 'all'],
|
||||
'focusRuleId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $latestFocus?->id !== $focus->id,
|
||||
'rules' => $ready ? $rules->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString()->through(fn (SupplyRecord $rule) => [...$rule->toArray(), 'canApprove' => $service->canReview($actor, $rule), 'canSuspend' => $service->canReview($actor, $rule, 'suspend')]) : null,
|
||||
'needs' => $ready ? $needs->orderByRaw("CASE WHEN state = 'pending' THEN 0 ELSE 1 END")->oldest('id')->paginate(20, ['*'], 'needs_page')->withQueryString() : null,
|
||||
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->paginate(30, ['*'], 'history_page')->withQueryString() : null,
|
||||
...app(ReplenishmentQueue::class)->status($actor),
|
||||
'workItems' => app(ReplenishmentQueue::class)->ready() ? DB::table('replenishment_work as w')->join('stock_items as i', 'i.id', '=', 'w.stock_item_id')->join('warehouses as a', 'a.id', '=', 'w.warehouse_id')->where('w.tenant_id', $actor->tenant_id)->where('i.tenant_id', $actor->tenant_id)->where('a.tenant_id', $actor->tenant_id)->where(fn ($q) => $q->whereColumn('w.generation', '>', 'w.processed_generation')->orWhere('w.failures', '>', 0))->orderBy('w.due_at')->orderBy('w.id')->paginate(20, ['w.id', 'i.code', 'i.name', 'a.name as warehouse', 'w.last_error', 'w.due_at', 'w.failures'], 'work_page')->withQueryString() : null,
|
||||
'policies' => Schema::hasTable('supply_records') && $prepare ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->get() : [],
|
||||
'requesters' => $prepare ? User::query()->where('tenant_id', $actor->tenant_id)->get(['id', 'name', 'department_id', 'purchasing_role', 'tenant_id'])->filter(fn ($u) => $service->eligibleRequester($u))->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->values() : [],
|
||||
'centers' => $prepare ? CostCenter::query()->orderBy('code')->get(['id', 'department_id', 'code', 'name']) : [],
|
||||
'equipment' => $prepare ? Equipment::query()->orderBy('code')->get(['id', 'code', 'description', 'department_id']) : [],
|
||||
'catalogueTypes' => $prepare && Schema::hasTable('requisition_catalogue') ? DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->pluck('purchase_type', 'stock_item_id') : [],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, AutomaticPurchases $service): RedirectResponse
|
||||
{
|
||||
$service->save($request->user('web'), $request->all());
|
||||
|
||||
return redirect('/stock/reposicao');
|
||||
}
|
||||
|
||||
public function evaluate(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare', 'supply_review'])) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$need = SupplyRecord::query()->where('kind', 'auto_need')->findOrFail($id);
|
||||
if (app(ReplenishmentQueue::class)->ready()) {
|
||||
$service->signal($actor, $need->stock_item_id, $need->warehouse_id);
|
||||
} else {
|
||||
$service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
|
||||
}
|
||||
|
||||
return redirect('/stock/reposicao');
|
||||
}
|
||||
|
||||
public function recover(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['supply_prepare', 'supply_review']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$work = DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->where('id', $id)->firstOrFail();
|
||||
$service->signal($actor, $work->stock_item_id, $work->warehouse_id);
|
||||
|
||||
return redirect('/stock/reposicao');
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class BudgetController extends Controller
|
||||
{
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ class BudgetReinforcementController extends Controller
|
||||
{
|
||||
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']);
|
||||
$workflow->propose($request->user(), $budget, $data);
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CandidateAccount;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\CandidateAccess;
|
||||
use App\Services\CandidateDocuments;
|
||||
use App\Services\CandidateProfiles;
|
||||
use App\Services\CandidateSubmissions;
|
||||
use App\Services\CandidateTerritory;
|
||||
use App\Services\CandidateTriage;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\CurrentActor;
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SubmissionForms;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CandidatePortalController extends Controller
|
||||
{
|
||||
public function settings(Request $request, CandidateProfiles $profiles, OrganizationConfiguration $configuration): Response
|
||||
{
|
||||
$actor = $configuration->administrator($this->staff($request));
|
||||
$portal = $profiles->portal($actor->tenant_id);
|
||||
|
||||
return $this->page($request, 'company/candidate-portal', ['portal' => $portal === null ? ['version' => 0, 'registration_open' => false, 'notice' => ''] : (array) $portal,
|
||||
'canManage' => app(SubmissionForms::class)->canManage($actor), 'portalUrl' => '/candidaturas/'.$actor->tenant_id, 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable()]);
|
||||
}
|
||||
|
||||
public function configure(Request $request, CandidateProfiles $profiles): Response
|
||||
{
|
||||
$profiles->configure($this->staff($request), $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function entry(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
{
|
||||
$portal = $profiles->portal($company);
|
||||
abort_unless($portal !== null, 404);
|
||||
$tenant = Tenant::query()->findOrFail($company);
|
||||
$filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']);
|
||||
|
||||
return $this->page($request, 'candidate/entry', ['company' => ['id' => $company, 'name' => $tenant->name],
|
||||
'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice],
|
||||
'opportunities' => app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))]);
|
||||
}
|
||||
|
||||
public function register(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
{
|
||||
$selection = $request->validate(['opportunity_id' => 'nullable|integer|min:1|max:2147483647']);
|
||||
$account = $profiles->register($company, $request->all());
|
||||
Auth::guard('candidate')->login($account, false);
|
||||
$request->session()->put('candidate.session_version', $account->session_version);
|
||||
$request->session()->regenerate();
|
||||
Inertia::clearHistory();
|
||||
|
||||
$selected = $this->rememberOpportunity($request, $account, isset($selection['opportunity_id']) ? (int) $selection['opportunity_id'] : null);
|
||||
$response = redirect('/candidaturas/'.$company.'/perfil');
|
||||
if (isset($selection['opportunity_id']) && $selected === null) {
|
||||
$response->with('candidate_access_status', 'opportunity_unavailable');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function login(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
{
|
||||
abort_unless($profiles->portal($company) !== null, 404);
|
||||
$data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128',
|
||||
'opportunity_id' => 'nullable|integer|min:1|max:2147483647']);
|
||||
$account = CandidateAccount::query()->where('tenant_id', $company)->where('email', mb_strtolower(trim($data['email'])))->first();
|
||||
$dummy = Hash::make('candidate-unavailable-account');
|
||||
$valid = Hash::check($data['password'], $account->password ?? $dummy);
|
||||
if ($account === null || ! $valid) {
|
||||
throw ValidationException::withMessages(['email' => 'Não foi possível entrar com os dados indicados.']);
|
||||
}
|
||||
$profiles->event($account, 'account.login', []);
|
||||
Auth::guard('candidate')->login($account, false);
|
||||
$request->session()->put('candidate.session_version', $account->session_version);
|
||||
$request->session()->regenerate();
|
||||
Inertia::clearHistory();
|
||||
|
||||
$selected = $this->rememberOpportunity($request, $account, isset($data['opportunity_id']) ? (int) $data['opportunity_id'] : null);
|
||||
if ($selected !== null) {
|
||||
return redirect('/candidaturas/'.$company.'/oportunidades/'.$selected['id']);
|
||||
}
|
||||
if (isset($data['opportunity_id'])) {
|
||||
return redirect('/candidaturas/'.$company.'/perfil')->with('candidate_access_status', 'opportunity_unavailable');
|
||||
}
|
||||
|
||||
return redirect('/candidaturas/'.$company.'/perfil');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
if ($account === null) {
|
||||
return redirect('/candidaturas/'.$company);
|
||||
}
|
||||
$profiles->event($account, 'profile.consulted', []);
|
||||
$filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1|max:2147483647', 'submissions_page' => 'sometimes|integer|min:1|max:2147483647']);
|
||||
$opportunities = app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))->withPath('/candidaturas/'.$company.'/perfil')->appends(array_intersect_key($filter, ['submissions_page' => true]));
|
||||
$receipts = app(CandidateSubmissions::class)->receipts($account, (int) ($filter['submissions_page'] ?? 1))->appends(array_intersect_key($filter, ['opportunities_page' => true]));
|
||||
$selection = $request->session()->get('candidate.selected_opportunity');
|
||||
$scopedSelection = is_array($selection) && ($selection['company'] ?? null) === $company && ($selection['account'] ?? null) === $account->id && is_int($selection['id'] ?? null) && $selection['id'] > 0;
|
||||
$selected = $this->rememberOpportunity($request, $account, $scopedSelection ? (int) $selection['id'] : null);
|
||||
|
||||
$profile = $profiles->profile($account);
|
||||
|
||||
return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profile,
|
||||
'geographyEnabled' => app(AdministrativeGeography::class)->enabled(),
|
||||
'availabilityGeography' => app(AdministrativeGeography::class)->describe($profile['data']['availability_reference'] ?? null),
|
||||
'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $scopedSelection && $selected === null ? 'opportunity_unavailable' : $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(),
|
||||
'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()], 'selectedOpportunity' => $selected,
|
||||
'submissions' => $receipts->items(), 'submissionPagination' => ['prev' => $receipts->previousPageUrl(), 'next' => $receipts->nextPageUrl(), 'total' => $receipts->total()]]);
|
||||
}
|
||||
|
||||
public function geography(Request $request, int $company, AdministrativeGeography $geography): JsonResponse
|
||||
{
|
||||
abort_unless($this->account($request, $company) !== null, 401);
|
||||
abort_unless($geography->enabled(), 503);
|
||||
$data = $request->validate(['parent' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:10000']);
|
||||
|
||||
return response()->json($geography->children($data['parent'] ?? null, (int) ($data['page'] ?? 1)), 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
|
||||
public function save(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$profiles->save($account, $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function logout(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$profiles->event($account, 'account.logout', []);
|
||||
Auth::guard('candidate')->logout();
|
||||
$request->session()->forget(['candidate.session_version', 'candidate.selected_opportunity']);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->regenerateToken();
|
||||
Inertia::clearHistory();
|
||||
|
||||
return redirect('/candidaturas/'.$company);
|
||||
}
|
||||
|
||||
/** @return array{id:int,title:string}|null */
|
||||
private function rememberOpportunity(Request $request, CandidateAccount $account, ?int $id): ?array
|
||||
{
|
||||
$request->session()->forget('candidate.selected_opportunity');
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$opportunity = app(CandidateSubmissions::class)->opportunity($account->tenant_id, $id);
|
||||
} catch (NotFoundHttpException|ModelNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
$request->session()->put('candidate.selected_opportunity', ['company' => $account->tenant_id, 'account' => $account->id, 'id' => $id]);
|
||||
|
||||
return ['id' => $id, 'title' => $opportunity['title']];
|
||||
}
|
||||
|
||||
private function account(Request $request, int $company): ?CandidateAccount
|
||||
{
|
||||
$account = Auth::guard('candidate')->user();
|
||||
if ($account instanceof CandidateAccount) {
|
||||
$account = CandidateAccount::query()->where('id', $account->id)->where('tenant_id', $company)->first();
|
||||
}
|
||||
|
||||
return $account instanceof CandidateAccount && $account->tenant_id === $company && $request->session()->get('candidate.session_version') === $account->session_version ? $account : null;
|
||||
}
|
||||
|
||||
public function accessPage(Request $request, int $company, string $purpose, CandidateProfiles $profiles): Response
|
||||
{
|
||||
abort_unless($profiles->portal($company) !== null, 404);
|
||||
|
||||
return $this->page($request, 'candidate/access', ['companyId' => $company, 'purpose' => $purpose,
|
||||
'accessStatus' => $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable()]);
|
||||
}
|
||||
|
||||
public function requestAccess(Request $request, int $company, CandidateAccess $access, CandidateProfiles $profiles): Response
|
||||
{
|
||||
abort_unless($profiles->portal($company) !== null, 404);
|
||||
$data = $request->validate(['email' => 'required|string|email|max:254']);
|
||||
$access->request($company, $data['email'], 'reset');
|
||||
|
||||
return back()->with('candidate_access_status', 'requested');
|
||||
}
|
||||
|
||||
public function requestVerification(Request $request, int $company, CandidateAccess $access): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$access->request($company, $account->email, 'verify');
|
||||
|
||||
return back()->with('candidate_access_status', 'requested');
|
||||
}
|
||||
|
||||
public function confirmAccess(Request $request, int $company, string $purpose, CandidateAccess $access): Response
|
||||
{
|
||||
$access->consume($company, $purpose === 'confirmar-email' ? 'verify' : 'reset', $request->all());
|
||||
Inertia::clearHistory();
|
||||
|
||||
return redirect('/candidaturas/'.$company.'/'.$purpose)->with('candidate_access_status', 'completed');
|
||||
}
|
||||
|
||||
private function staff(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function opportunity(Request $request, int $company, int $process, CandidateSubmissions $submissions): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
|
||||
return $this->page($request, 'candidate/opportunity', ['companyId' => $company, 'opportunity' => $submissions->opportunity($company, $process),
|
||||
'profile' => app(CandidateProfiles::class)->profile($account), 'email' => $account->email, 'emailVerified' => $account->email_verified_at !== null]);
|
||||
}
|
||||
|
||||
public function submit(Request $request, int $company, int $process, CandidateSubmissions $submissions): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$id = $submissions->submit($account, $process, $request->all());
|
||||
$selection = $request->session()->get('candidate.selected_opportunity');
|
||||
if (is_array($selection) && ($selection['company'] ?? null) === $company && ($selection['account'] ?? null) === $account->id && ($selection['id'] ?? null) === $process) {
|
||||
$request->session()->forget('candidate.selected_opportunity');
|
||||
}
|
||||
|
||||
return redirect('/candidaturas/'.$company.'/submissoes/'.$id);
|
||||
}
|
||||
|
||||
public function receipt(Request $request, int $company, int $submission): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$row = DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->where('id', $submission)->first();
|
||||
abort_unless($row !== null, 404);
|
||||
app(CandidateProfiles::class)->event($account, 'submission.consulted', ['submission_id' => $row->id]);
|
||||
|
||||
return $this->page($request, 'candidate/receipt', ['companyId' => $company, 'decision' => app(CandidateTriage::class)->publicDecision($company, $submission), 'receipt' => ['id' => $row->id, 'reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR)]]);
|
||||
}
|
||||
|
||||
public function candidateDocument(Request $request, int $company, int $submission, string $document): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$row = DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->where('id', $submission)->first();
|
||||
abort_unless($row !== null, 404);
|
||||
$file = $this->submissionDocument($company, $row->people_process_id, $submission, $document);
|
||||
app(CandidateProfiles::class)->event($account, 'submission.document_downloaded', ['submission_id' => $submission, 'document_key' => $document]);
|
||||
|
||||
return app(CandidateDocuments::class)->download((array) $file);
|
||||
}
|
||||
|
||||
public function staffDocument(Request $request, int $process, int $submission, string $document): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $process, $submission, $document) {
|
||||
$actor = $this->staff($request);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail();
|
||||
abort_unless(app(CandidateTriage::class)->canRead($actor, $record), 403);
|
||||
$file = $this->submissionDocument($actor->tenant_id, $process, $submission, $document);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'candidate_submission.document_downloaded', null, null, ['process_id' => $process, 'submission_id' => $submission, 'document_key' => $document]);
|
||||
|
||||
return app(CandidateDocuments::class)->download((array) $file);
|
||||
});
|
||||
}
|
||||
|
||||
private function submissionDocument(int $company, int $process, int $submission, string $key): \stdClass
|
||||
{
|
||||
abort_unless(Schema::hasTable('candidate_submission_documents'), 404);
|
||||
$row = DB::table('candidate_submission_documents')->where('tenant_id', $company)->where('people_process_id', $process)->where('candidate_submission_id', $submission)->where('document_key', $key)->first();
|
||||
abort_unless($row !== null, 404);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function inbox(Request $request, int $process, CandidateSubmissions $submissions): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $process, $submissions) {
|
||||
$actor = $this->staff($request);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail();
|
||||
$triage = app(CandidateTriage::class);
|
||||
abort_unless($triage->canRead($actor, $record), 403);
|
||||
$filter = $request->validate(['submission' => 'nullable|integer|min:1', 'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:1000000']);
|
||||
$query = DB::table('candidate_submissions')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $process);
|
||||
if (isset($filter['submission'])) {
|
||||
$query->where('id', $filter['submission']);
|
||||
}
|
||||
if (! empty($filter['location'])) {
|
||||
app(CandidateTerritory::class)->apply($query, $filter['location']);
|
||||
}
|
||||
$rows = $query->orderBy('id')->paginate(20)->withQueryString();
|
||||
$decisions = Schema::hasTable('candidate_triage') ? DB::table('candidate_triage')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $process)->whereIn('candidate_submission_id', $rows->pluck('id'))->get(['candidate_submission_id', 'outcome', 'reason', 'public_message', 'created_at', 'people_application_id'])->keyBy('candidate_submission_id') : collect();
|
||||
$rows->through(fn ($row) => ['id' => $row->id, 'reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR), 'decision' => $decisions->get($row->id)]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'candidate_submissions.consulted', null, null, ['process_id' => $process]);
|
||||
|
||||
return $this->page($request, 'people/submissions', ['process' => ['id' => $record->id, 'title' => $record->title, 'version' => $record->version], 'publication' => $submissions->publication($record), 'submissions' => $rows,
|
||||
'filters' => $filter, 'geographyEnabled' => app(AdministrativeGeography::class)->enabled() && in_array(DB::getDriverName(), ['sqlite', 'pgsql'], true),
|
||||
'geographyFilter' => empty($filter['location']) ? null : app(AdministrativeGeography::class)->describe(['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $filter['location']]),
|
||||
'canPrepare' => $triage->canPrepare($actor, $record), 'canTriage' => Schema::hasTable('candidate_triage') && $triage->canPrepare($actor, $record) && in_array($record->status, ['open', 'assessment'], true),
|
||||
'publicUrl' => '/candidaturas/'.$actor->tenant_id.'/oportunidades/'.$record->id]);
|
||||
});
|
||||
}
|
||||
|
||||
public function processGeography(Request $request, int $process, AdministrativeGeography $geography): JsonResponse
|
||||
{
|
||||
return DB::transaction(function () use ($request, $process, $geography) {
|
||||
$actor = $this->staff($request);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail();
|
||||
abort_unless(app(CandidateTriage::class)->canRead($actor, $record), 403);
|
||||
abort_unless($geography->enabled(), 503);
|
||||
$data = $request->validate(['parent' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:10000']);
|
||||
|
||||
return response()->json($geography->children($data['parent'] ?? null, (int) ($data['page'] ?? 1)), 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||
});
|
||||
}
|
||||
|
||||
public function publish(Request $request, int $process, CandidateSubmissions $submissions): Response
|
||||
{
|
||||
$submissions->publish($this->staff($request), $process, $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function triage(Request $request, int $process, int $submission, CandidateTriage $triage): Response
|
||||
{
|
||||
$triage->decide($this->staff($request), $process, $submission, $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $props */
|
||||
private function page(Request $request, string $component, array $props): Response
|
||||
{
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render($component, $props)->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||
$response->headers->set('Referrer-Policy', 'no-referrer');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CommonStockReadController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// Native authority, reloaded on every request; no SGF grants or identity assumptions.
|
||||
$user=User::query()->findOrFail($request->user()->id);
|
||||
abort_unless($user->tenant_id!==null && $user->hasFunctions([PurchasingRole::Admin,PurchasingRole::Warehouse,PurchasingRole::Procurement,PurchasingRole::Finance])
|
||||
&& !app(OrganizationAccess::class)->maintenanceOnly($user),403);
|
||||
$d=$request->validate(['view'=>'sometimes|in:items,warehouses,balances,movements','search'=>'nullable|string|max:100',
|
||||
'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|min:1|max:100','snapshot'=>'sometimes|string|max:8192']);
|
||||
abort_if(array_diff(array_keys($request->query()),array_keys($d))!==[],422);
|
||||
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'','warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,
|
||||
'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
|
||||
$binding=config('stock_read.synthetic_sources.'.$user->tenant_id.':'.$user->id);
|
||||
if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Fonte autorizada de stock ainda não ligada a este contexto.'],503)->header('Cache-Control','no-store');
|
||||
abort_unless(app()->environment('testing')&&DB::connection()->getDriverName()==='pgsql',403);
|
||||
$identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
abort_unless(preg_match('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name,$m)
|
||||
&& $identity->role==='stock_read_qa_'.$m[1] && (int)$identity->port===55439
|
||||
&& preg_match('/^[a-f0-9]{12}$/D',(string)config('stock_read.qa_run'))
|
||||
&& $identity->cluster==='sgf_modular_regression_'.config('stock_read.qa_run'),403);
|
||||
abort_unless(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$binding['schema']??'')&&($binding['append_only']??false)===true
|
||||
&& ($binding['source_tenant_id']??null)===(int)$user->tenant_id,503,'Fonte de stock incompatível.');
|
||||
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
|
||||
$root=base_path('packages/inventory-read/releases/0.1.0');
|
||||
abort_unless(is_file($root.'/manifest.json')&&hash_equals(config('stock_read.package_manifest_sha256'),hash_file('sha256',$root.'/manifest.json')),503,'Pacote de consulta incompatível.');
|
||||
$manifest=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
foreach(['bootstrap.php','src/InventoryReader.php','src/InventoryReadSession.php'] as $file)
|
||||
abort_unless(is_file($root.'/'.$file)&&isset($manifest['files'][$file])&&hash_equals($manifest['files'][$file],hash_file('sha256',$root.'/'.$file)),503,'Pacote de consulta incompatível.');
|
||||
require_once $root.'/bootstrap.php';
|
||||
$fingerprint=hash('sha256',json_encode(['compras',$user->tenant_id,$user->id,$request->session()->get('work_context'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
|
||||
try { $result=(new \Tempo\Inventory\InventoryReadSession(DB::connection()))->read($binding,$fingerprint,$filters,$d['snapshot']??null); }
|
||||
catch(\Illuminate\Database\QueryException $e){
|
||||
if(!in_array($e->errorInfo[0]??null,['42P01','42703','42883','57014'],true))throw $e;
|
||||
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
|
||||
}
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -24,30 +26,39 @@ class CompanyController extends Controller
|
||||
|
||||
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) {
|
||||
$user = User::query()->lockForUpdate()->findOrFail($request->user()->id);
|
||||
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']]);
|
||||
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();
|
||||
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name']]);
|
||||
$user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Configured])->save();
|
||||
$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');
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
if (app(OrganizationAccess::class)->active($request->user()->tenant_id)) {
|
||||
return to_route('organization.parameters');
|
||||
}
|
||||
|
||||
return Inertia::render('company/index', [
|
||||
'company' => Tenant::query()->findOrFail($request->user()->tenant_id),
|
||||
'departments' => Department::query()->orderBy('name')->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']),
|
||||
'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
|
||||
{
|
||||
$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([
|
||||
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:12|confirmed',
|
||||
@@ -99,6 +111,6 @@ class CompanyController extends Controller
|
||||
|
||||
private function admin(Request $request): void
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
app(OrganizationConfiguration::class)->administrator($request->user());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,30 @@ use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentaryQualificationWorkflow;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DocumentaryQualificationController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
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($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $opening): Response
|
||||
@@ -32,11 +40,13 @@ class DocumentaryQualificationController extends Controller
|
||||
$sets = QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->get(['id', 'revision', 'reason', 'created_at']);
|
||||
$latest = $sets->first() ? QualificationSet::query()->findOrFail($sets->first()->id) : null;
|
||||
$selected = isset($filters['version']) ? QualificationSet::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filters['version']) : $latest;
|
||||
$focusInput = $request->validate(['quotation_id' => ['nullable', 'integer', 'min:1', Rule::exists('quotations', 'id')->where('tenant_id', $requisition->tenant_id)->where('requisition_id', $requisition->id), Rule::in(array_column($selected->snapshot['proposals'] ?? [], 'id'))]]);
|
||||
$focus = isset($focusInput['quotation_id']) ? (int) $focusInput['quotation_id'] : null;
|
||||
$proposals = $opening->proposals($requisition);
|
||||
$basis = $opening->basis($proposals);
|
||||
$current = $selected && $selected->id === $latest?->id && $selected->basis_hash === $basis;
|
||||
$workflow = app(DocumentaryQualificationWorkflow::class);
|
||||
$current = $selected !== null && $workflow->currentSet($requisition, $selected);
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$query = QualificationAssessment::query()->where('qualification_set_id', $selected->id ?? 0);
|
||||
$recordIds = (clone $query)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id');
|
||||
$records = QualificationAssessment::query()->whereIn('id', $recordIds)->orderBy('quotation_id')->orderBy('analyst_id')->get();
|
||||
@@ -46,9 +56,10 @@ class DocumentaryQualificationController extends Controller
|
||||
'latestId' => $latest->id ?? 0, 'latestCriteria' => $latest->criteria ?? [], 'basisHash' => $basis, 'isCurrent' => (bool) $current,
|
||||
'proposals' => $proposals,
|
||||
'closedForChanges' => $closed,
|
||||
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
|
||||
'canDefine' => $open && $proposals !== [] && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
||||
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
|
||||
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $this->actor($request)->id,
|
||||
'focusQuotationId' => $focus,
|
||||
'canDefine' => $workflow->canDefine($this->actor($request), $requisition),
|
||||
'canAssess' => $selected !== null && $workflow->canAssess($this->actor($request), $requisition, $selected),
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
@@ -64,7 +75,7 @@ class DocumentaryQualificationController extends Controller
|
||||
'criteria.*.label' => 'required|string|min:3|max:500', 'criteria.*.category' => 'required|in:legal,financial,fiscal,technical,administrative', 'criteria.*.required' => 'required|boolean',
|
||||
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
|
||||
]);
|
||||
$workflow->define($request->user(), $requisition, $data);
|
||||
$workflow->define($this->actor($request), $requisition, $data);
|
||||
|
||||
return to_route('qualifications.show', $requisition);
|
||||
}
|
||||
@@ -80,7 +91,7 @@ class DocumentaryQualificationController extends Controller
|
||||
'snapshot' => 'prohibited', 'analyst_id' => 'prohibited', 'tenant_id' => 'prohibited', 'revision' => 'prohibited',
|
||||
]);
|
||||
$data['answers'] = array_map(fn ($answer) => [...$answer, 'criterion_id' => (int) $answer['criterion_id']], $data['answers']);
|
||||
$workflow->assess($request->user(), $set, $data);
|
||||
$workflow->assess($this->actor($request), $set, $data);
|
||||
|
||||
return to_route('qualifications.show', $set->requisition_id);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\CurrentActor;
|
||||
use App\Services\EquipmentWarehouseRequests;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
@@ -26,17 +32,66 @@ use Inertia\Response;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function show(Request $request, int $equipment): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$request->validate(['purchases_page' => 'nullable|integer|min:1|max:1000000', 'movements_page' => 'nullable|integer|min:1|max:1000000', 'requests_page' => 'nullable|integer|min:1|max:1000000']);
|
||||
|
||||
return DB::transaction(function () use ($request, $actor, $equipment) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$asset = Equipment::query()->when($actor->departmentScoped(), fn ($q) => $q->where('department_id', $actor->department_id))->findOrFail($equipment);
|
||||
$purchases = Requisition::query()->visibleTo($actor)->whereHas('items', fn ($q) => $q->where('equipment_id', $asset->id))
|
||||
->with(['items' => fn ($q) => $q->where('equipment_id', $asset->id)->select(['id', 'requisition_id', 'description', 'quantity', 'unit'])])
|
||||
->orderBy('id')->paginate(15, ['id', 'number', 'status', 'needed_by'], 'purchases_page')->withQueryString();
|
||||
$movements = StockOperation::query()->where('equipment_id', $asset->id)->orderBy('id')
|
||||
->paginate(15, ['id', 'kind', 'reference', 'quantity_milli', 'recorded_at', 'snapshot'], 'movements_page')->withQueryString()
|
||||
->through(fn (StockOperation $operation) => [
|
||||
'id' => $operation->id, 'kind' => $operation->kind, 'reference' => $operation->reference,
|
||||
'quantity_milli' => $operation->quantity_milli, 'recorded_at' => $operation->recorded_at,
|
||||
'item' => $operation->snapshot['stock_item'] ?? null, 'warehouse' => $operation->snapshot['warehouse'] ?? null,
|
||||
]);
|
||||
Inertia::encryptHistory();
|
||||
|
||||
$response = Inertia::render('equipment/show', ['equipment' => $asset->only(['id', 'code', 'description', 'area', 'status']),
|
||||
'geography' => app(AdministrativeGeography::class)->describe($asset->geography_reference),
|
||||
'warehouseRequests' => app(EquipmentWarehouseRequests::class)->page($actor, $asset),
|
||||
'purchases' => $purchases, 'movements' => $movements])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $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($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$filters = $request->validate(['location' => 'nullable|string|regex:/^[0-9]+$/D|max:20']);
|
||||
$geography = app(AdministrativeGeography::class);
|
||||
$assets = Equipment::query();
|
||||
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
|
||||
$assets->where('department_id', $request->user()->department_id);
|
||||
if (! empty($filters['location'])) {
|
||||
abort_unless($geography->availableFor('equipment'), 503);
|
||||
$assets->where('geography_reference->catalogue', config('geography.source_sha256'))->whereIn('geography_reference->record_id', $geography->descendants($filters['location']));
|
||||
}
|
||||
if ($this->actor($request)->departmentScoped()) {
|
||||
$assets->where('department_id', $this->actor($request)->department_id);
|
||||
}
|
||||
$equipment = $assets->orderBy('code')->get();
|
||||
$links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get();
|
||||
$parts = SparePart::query();
|
||||
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
|
||||
if ($this->actor($request)->departmentScoped() || ! empty($filters['location'])) {
|
||||
$parts->whereIn('id', $links->pluck('spare_part_id'));
|
||||
}
|
||||
$parts = $parts->get()->map(function (SparePart $part) {
|
||||
@@ -47,26 +102,30 @@ class EquipmentController extends Controller
|
||||
});
|
||||
$items = StockItem::query();
|
||||
$departments = Department::query();
|
||||
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
|
||||
if ($this->actor($request)->departmentScoped() || ! empty($filters['location'])) {
|
||||
$items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter()));
|
||||
$departments->whereKey($request->user()->department_id);
|
||||
if ($this->actor($request)->departmentScoped()) {
|
||||
$departments->whereKey($this->actor($request)->department_id);
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('equipment/index', [
|
||||
'geographyEnabled' => $geography->availableFor('equipment'), 'equipmentGeography' => $equipment->mapWithKeys(fn (Equipment $asset) => [$asset->id => $geography->describe($asset->geography_reference)])->all(),
|
||||
'filters' => $filters, 'geographyFilter' => empty($filters['location']) ? null : $geography->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']),
|
||||
'equipment' => $equipment, 'parts' => $parts, 'links' => $links, 'levels' => CriticalityLevel::query()->orderBy('code')->get(),
|
||||
'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']),
|
||||
'departments' => $departments->orderBy('name')->get(['id', 'name']),
|
||||
'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]),
|
||||
'canConfigure' => $request->user()->purchasing_role === PurchasingRole::Admin,
|
||||
'canManage' => $this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
|
||||
'canConfigure' => $this->actor($request)->hasFunctions([PurchasingRole::Admin]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, string $kind): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $this->actor($request);
|
||||
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;
|
||||
$exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant);
|
||||
$request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]);
|
||||
@@ -76,9 +135,17 @@ class EquipmentController extends Controller
|
||||
'pecas' => ['id' => ['nullable', 'integer', $exists('spare_parts')], 'version' => 'nullable|required_with:id|integer|min:1', 'stock_item_id' => ['required', 'integer', $exists('stock_items'), Rule::unique('spare_parts')->ignore($request->input('id'))], 'criticality_level_id' => ['required', 'integer', $exists('criticality_levels')], 'minimum_quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/']],
|
||||
'associacoes' => ['equipment_id' => ['required', 'integer', $exists('equipment')], 'spare_part_id' => ['required', 'integer', $exists('spare_parts')]],
|
||||
};
|
||||
if ($kind === 'activos') {
|
||||
$rules += app(AdministrativeGeography::class)->referenceRules('equipment');
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
DB::transaction(function () use ($data, $kind, $user) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, $kind === 'categorias' ? [PurchasingRole::Admin] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
if ($kind === 'activos' && array_key_exists('geography_reference', $data)) {
|
||||
$data['geography_reference'] = app(AdministrativeGeography::class)->canonicalFor('equipment', $data['geography_reference']);
|
||||
}
|
||||
$model = match ($kind) {
|
||||
'categorias' => new CriticalityLevel,'activos' => new Equipment,'pecas' => new SparePart,'associacoes' => new EquipmentSparePart
|
||||
};
|
||||
|
||||
@@ -5,13 +5,17 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierBankDetail;
|
||||
use App\Models\User;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierBankWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -19,20 +23,27 @@ class ExternalPaymentController extends Controller
|
||||
{
|
||||
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$canManage = $request->user()->purchasing_role === 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]);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$filter = $request->validate(['payment_id' => ['nullable', 'integer', 'min:1', Rule::exists('external_payments', 'id')->where('tenant_id', $actor->tenant_id)], 'invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)]]);
|
||||
$focus = isset($filter['payment_id']) ? (int) $filter['payment_id'] : null;
|
||||
$focusInvoice = isset($filter['invoice_id']) ? (int) $filter['invoice_id'] : null;
|
||||
$canManage = $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
$visibleInvoices = Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'));
|
||||
$payments = ExternalPayment::query()->whereIn('invoice_id', (clone $visibleInvoices)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusInvoice !== null, fn ($q) => $q->where('invoice_id', $focusInvoice))->latest('id')->paginate(15)->withQueryString()
|
||||
->through(fn (ExternalPayment $payment) => [...$payment->toArray(), 'canReview' => $workflow->reviewerEligible($actor, $payment), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
|
||||
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($actor, 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
|
||||
}
|
||||
$invoices = Invoice::query()->where('status', 'validated')->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
|
||||
$invoices = $visibleInvoices->where('status', 'validated')->when($focus !== null, fn ($q) => $q->whereIn('id', $payments->pluck('invoice_id')))->when($focusInvoice !== null, fn ($q) => $q->whereKey($focusInvoice))->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
|
||||
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
|
||||
$bank = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
|
||||
|
||||
return ['id' => $invoice->id, 'number' => $invoice->number, 'supplier_id' => $supplier->id, 'supplier_name' => $supplier->name, 'total_minor' => $invoice->total_minor, 'outstanding_minor' => $workflow->outstanding($invoice), 'bank_id' => $bank->id ?? 0, 'bank_revision' => $bank->revision ?? 0, 'bank_state' => app(SupplierBankWorkflow::class)->state($supplier)];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('payments/index', ['invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
|
||||
$response = Inertia::render('payments/index', ['focusPaymentId' => $focus, 'focusInvoiceId' => $focusInvoice, 'invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
@@ -40,17 +51,20 @@ class ExternalPaymentController extends Controller
|
||||
|
||||
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->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']);
|
||||
$workflow->register($request->user(), $invoice, $data);
|
||||
$workflow->register($actor, $invoice, $data);
|
||||
|
||||
return to_route('payments.index');
|
||||
}
|
||||
|
||||
public function review(Request $request, ExternalPayment $payment, ExternalPaymentWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$data = $request->validate(['decision' => 'required|in:confirmed,rejected', 'reason' => 'required|string|max:2000', 'independently_verified' => 'accepted']);
|
||||
$workflow->review($request->user(), $payment, $data['decision'], $data['reason']);
|
||||
$workflow->review($actor, $payment, $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@@ -9,26 +9,35 @@ use App\Models\AdmissionPolicy;
|
||||
use App\Models\AdmissionReview;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Models\User;
|
||||
use App\Services\FormalAdmission;
|
||||
use App\Services\ProposalOpeningWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FormalAdmissionController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
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($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
|
||||
}
|
||||
|
||||
public function policy(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
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->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
@@ -40,7 +49,7 @@ class FormalAdmissionController extends Controller
|
||||
{
|
||||
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
|
||||
$data['enabled'] = (bool) $data['enabled'];
|
||||
$workflow->policy($request->user(), $data);
|
||||
$workflow->policy($this->actor($request), $data);
|
||||
|
||||
return to_route('admissions.policy');
|
||||
}
|
||||
@@ -51,27 +60,27 @@ class FormalAdmissionController extends Controller
|
||||
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||
$adoption = $workflow->adoption($requisition);
|
||||
$selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
|
||||
$focusInput = $request->validate(['decision_id' => ['nullable', 'integer', 'min:1', Rule::exists('admission_decisions', 'id')->where('tenant_id', $requisition->tenant_id)->where('admission_adoption_id', $selected->id ?? 0)]]);
|
||||
$focus = isset($focusInput['decision_id']) ? (int) $focusInput['decision_id'] : null;
|
||||
$current = $selected && $selected->id === $adoption?->id;
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$open = $workflow->canParticipate($this->actor($request), $requisition);
|
||||
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get();
|
||||
$latestIds = $decisions->unique('quotation_id')->pluck('id');
|
||||
$proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) {
|
||||
$basis = $workflow->basis($requisition, $quote['id']);
|
||||
|
||||
return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0];
|
||||
});
|
||||
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString();
|
||||
$history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) {
|
||||
$basis = $workflow->basis($requisition, $decision->quotation_id);
|
||||
$fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash;
|
||||
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('id')->paginate(15)->withQueryString();
|
||||
$history->through(function ($decision) use ($workflow, $selected, $current, $requisition, $request) {
|
||||
$fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $decision);
|
||||
|
||||
return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh,
|
||||
'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(),
|
||||
'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' => $fresh && $workflow->canReview($this->actor($request), $requisition, $decision)];
|
||||
});
|
||||
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', ['focusDecisionId' => $focus, '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 && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
@@ -81,7 +90,7 @@ class FormalAdmissionController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
|
||||
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
$workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
|
||||
return to_route('admissions.show', $requisition);
|
||||
}
|
||||
@@ -90,7 +99,7 @@ class FormalAdmissionController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'outcome' => 'required|in:admit,exclude,clarify', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
|
||||
$workflow->propose($request->user(), $requisition, $data);
|
||||
$workflow->propose($this->actor($request), $requisition, $data);
|
||||
|
||||
return to_route('admissions.show', $requisition);
|
||||
}
|
||||
@@ -100,7 +109,7 @@ class FormalAdmissionController extends Controller
|
||||
$adoption = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
|
||||
$this->access($request, $adoption->requisition_id);
|
||||
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
|
||||
$workflow->review($request->user(), $decision, $data['verdict'], $data['reason']);
|
||||
$workflow->review($this->actor($request), $decision, $data['verdict'], $data['reason']);
|
||||
|
||||
return to_route('admissions.show', $adoption->requisition_id);
|
||||
}
|
||||
|
||||
@@ -10,27 +10,35 @@ use App\Models\RecommendationPolicy;
|
||||
use App\Models\RecommendationReview;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\FormalAdmission;
|
||||
use App\Services\FormalRecommendation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FormalRecommendationController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
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($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
|
||||
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
|
||||
}
|
||||
|
||||
public function policy(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
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->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
@@ -42,7 +50,7 @@ class FormalRecommendationController extends Controller
|
||||
{
|
||||
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
|
||||
$data['enabled'] = (bool) $data['enabled'];
|
||||
$workflow->policy($request->user(), $data);
|
||||
$workflow->policy($this->actor($request), $data);
|
||||
|
||||
return to_route('recommendations.policy');
|
||||
}
|
||||
@@ -53,22 +61,24 @@ class FormalRecommendationController extends Controller
|
||||
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
|
||||
$adoption = $workflow->adoption($requisition);
|
||||
$selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
|
||||
$focusInput = $request->validate(['attempt_id' => ['nullable', 'integer', 'min:1', Rule::exists('recommendation_attempts', 'id')->where('tenant_id', $requisition->tenant_id)->where('recommendation_adoption_id', $selected->id ?? 0)]]);
|
||||
$focus = isset($focusInput['attempt_id']) ? (int) $focusInput['attempt_id'] : null;
|
||||
$current = $selected && $selected->id === $adoption?->id;
|
||||
$basis = $workflow->basis($requisition);
|
||||
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first();
|
||||
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
|
||||
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString();
|
||||
$history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) {
|
||||
$fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
|
||||
$open = $workflow->canParticipate($this->actor($request), $requisition);
|
||||
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('revision')->paginate(10)->withQueryString();
|
||||
$history->through(function ($attempt) use ($workflow, $selected, $current, $requisition, $request) {
|
||||
$fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $attempt);
|
||||
|
||||
return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(),
|
||||
'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()];
|
||||
'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $attempt)];
|
||||
});
|
||||
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', ['focusAttemptId' => $focus, '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,
|
||||
'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 && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
@@ -78,7 +88,7 @@ class FormalRecommendationController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
|
||||
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
$workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
|
||||
|
||||
return to_route('recommendations.show', $requisition);
|
||||
}
|
||||
@@ -87,7 +97,7 @@ class FormalRecommendationController extends Controller
|
||||
{
|
||||
$this->access($request, $requisition->id);
|
||||
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
|
||||
$workflow->propose($request->user(), $requisition, $data);
|
||||
$workflow->propose($this->actor($request), $requisition, $data);
|
||||
|
||||
return to_route('recommendations.show', $requisition);
|
||||
}
|
||||
@@ -97,7 +107,7 @@ class FormalRecommendationController extends Controller
|
||||
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||
$this->access($request, $adoption->requisition_id);
|
||||
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
|
||||
$workflow->review($request->user(), $attempt, $data['verdict'], $data['reason']);
|
||||
$workflow->review($this->actor($request), $attempt, $data['verdict'], $data['reason']);
|
||||
|
||||
return to_route('recommendations.show', $adoption->requisition_id);
|
||||
}
|
||||
@@ -108,7 +118,7 @@ class FormalRecommendationController extends Controller
|
||||
if (DB::connection()->getDriverName() === 'pgsql') {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
}
|
||||
$actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id);
|
||||
$actor = User::query()->where('tenant_id', $this->actor($request)->tenant_id)->findOrFail($this->actor($request)->id);
|
||||
$request->setUserResolver(fn () => $actor);
|
||||
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
|
||||
$requisition = $this->access($request, $adoption->requisition_id);
|
||||
@@ -118,7 +128,7 @@ class FormalRecommendationController extends Controller
|
||||
|
||||
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]);
|
||||
|
||||
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $this->actor($request)->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\GeneralLedger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Tempo\Finance\GeneralLedger\CycleWorkspace;
|
||||
use Tempo\Finance\GeneralLedger\ReportsWorkspace;
|
||||
|
||||
final class GeneralLedgerController extends Controller
|
||||
{
|
||||
public function report(Request $r, string $kind, GeneralLedger $ledger): Response|JsonResponse
|
||||
{
|
||||
$d = $r->validate(['from_on' => 'required|date_format:Y-m-d', 'to_on' => 'required|date_format:Y-m-d|after_or_equal:from_on', 'account_id' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
try {
|
||||
$data = $ledger->report($this->actor($r), $kind, $d);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
abort(422, $e->getMessage());
|
||||
} catch (\DomainException $e) {
|
||||
abort(403, $e->getMessage());
|
||||
}
|
||||
if ($r->is('api/*')) {
|
||||
return response()->json($data)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
return response(ReportsWorkspace::render($data, ['host' => 'SGC autónomo', 'return_url' => '/general-ledger/cycle']))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
private function actor(Request $r): User
|
||||
{
|
||||
$user = $r->user();
|
||||
abort_unless($user instanceof User, 403);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function page(Request $r, GeneralLedger $ledger): Response
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000']);
|
||||
abort_if(array_diff(array_keys($r->query()), ['page']) !== [], 422);
|
||||
$data = $ledger->workspace($this->actor($r), (int) ($d['page'] ?? 1));
|
||||
|
||||
return response(CycleWorkspace::render($data, ['host' => 'SGC autónomo', 'csrf' => csrf_token(), 'financial_url' => '/supplier-payments', 'return_url' => '/obrigacoes-fornecedores', 'page_url' => '/general-ledger/cycle']))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, GeneralLedger $ledger): JsonResponse
|
||||
{
|
||||
abort_unless(config('general-ledger.cycle_enabled') === true, 403);
|
||||
try {
|
||||
return response()->json($ledger->engine($this->actor($r))->command($action, $r->all(), (string) $r->header('Idempotency-Key')))->header('Cache-Control', 'no-store');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
abort(422, $e->getMessage());
|
||||
} catch (\DomainException $e) {
|
||||
abort(409, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function index(Request $r, GeneralLedger $ledger): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000']);
|
||||
abort_if(array_diff(array_keys($r->query()), ['page']) !== [], 422);
|
||||
|
||||
return response()->json($ledger->workspace($this->actor($r), (int) ($d['page'] ?? 1)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,45 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Http\Requests\ConfirmReceiptRequest;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\ReceiptDocument;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\ReceiptDocuments;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
/** @return Builder<PurchaseOrder> */
|
||||
private function visibleOrders(Request $request): Builder
|
||||
{
|
||||
Gate::authorize('viewAny', GoodsReceipt::class);
|
||||
$orders = PurchaseOrder::query();
|
||||
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
|
||||
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'));
|
||||
$orders = PurchaseOrder::query()->operational();
|
||||
if ($this->actor($request)->departmentScoped()) {
|
||||
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($this->actor($request))->select('id'));
|
||||
}
|
||||
|
||||
return $orders;
|
||||
@@ -56,12 +69,14 @@ class GoodsReceiptController extends Controller
|
||||
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']),
|
||||
'stockItems' => StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']),
|
||||
'canConfirm' => $request->user()->can('create', [GoodsReceipt::class, $order]),
|
||||
'lotScopes' => Schema::hasTable('inventory_lot_allocations')
|
||||
? app(InventoryLots::class)->activeSetups()->get()->map(fn ($r) => ['stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'quarantine_on_receipt' => $r->data['quarantine_on_receipt'] ?? true])->values()->all() : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ConfirmReceiptRequest $request, PurchaseOrder $order, ReceiptWorkflow $workflow): JsonResponse|RedirectResponse
|
||||
{
|
||||
$receipt = $workflow->confirm($request->user(), $order, $request->validated());
|
||||
$receipt = $workflow->confirm($this->actor($request), $order, $request->validated());
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['id' => $receipt->id, 'number' => $receipt->number, 'created' => $receipt->wasRecentlyCreated, 'status' => $receipt->status], $receipt->wasRecentlyCreated ? 201 : 200);
|
||||
}
|
||||
@@ -69,13 +84,37 @@ class GoodsReceiptController extends Controller
|
||||
return to_route('receipts.show', $receipt);
|
||||
}
|
||||
|
||||
public function show(GoodsReceipt $receipt): Response
|
||||
public function show(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): Response
|
||||
{
|
||||
Gate::authorize('view', $receipt);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$ready = $documents->ready();
|
||||
$order = PurchaseOrder::query()->findOrFail($receipt->purchase_order_id);
|
||||
$source = Requisition::query()->visibleTo($actor)->whereKey($order->requisition_id)->first(['id']);
|
||||
Inertia::encryptHistory();
|
||||
|
||||
return Inertia::render('receipts/show', [
|
||||
'documentsReady' => $ready,
|
||||
'purchaseProcessHref' => $source === null ? null : '/requisicoes/'.$source->id.'/processo',
|
||||
'canAddDocuments' => $ready && $documents->canAdd($actor, $receipt),
|
||||
'documents' => $ready && $documents->canRead($actor, $receipt) ? ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->orderBy('id')->get(['id', 'document_type', 'original_name', 'size', 'sha256', 'uploaded_by', 'created_at']) : [],
|
||||
'correctedReceiptItems' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all())->map(fn ($r) => $r->data['reference']),
|
||||
'receipt' => $receipt->load('items'),
|
||||
'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): \Illuminate\Http\Response
|
||||
{
|
||||
Gate::authorize('view', $receipt);
|
||||
$actor = $this->actor($request);
|
||||
|
||||
return response()->view('receipts.print', [
|
||||
'receipt' => $receipt->load('items'),
|
||||
'corrections' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all()),
|
||||
'documents' => $documents->ready() && $documents->canRead($actor, $receipt)
|
||||
? ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->orderBy('id')->get(['document_type', 'original_name', 'sha256', 'created_at']) : collect(),
|
||||
])->header('Cache-Control', 'no-store, private')->header('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryCorrectionPage;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\InventoryReversals;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InventoryDispositionController extends Controller
|
||||
{
|
||||
public function index(Request $request, InventoryDispositions $workflow): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$capabilities = [];
|
||||
foreach (['read', 'prepare', 'review', 'execute'] as $capability) {
|
||||
$capabilities[$capability] = $workflow->allows($actor, $capability);
|
||||
}
|
||||
$canViewReversals = collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => app(InventoryReversals::class)->allows($actor, $a));
|
||||
if (! collect($capabilities)->contains(fn (bool $allow) => $allow) && $canViewReversals) {
|
||||
return redirect('/stock/estornos');
|
||||
}
|
||||
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $workflow, $actor, $capabilities, $canViewReversals) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $this->page($request, $workflow, $actor, $capabilities, $canViewReversals);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,bool> $capabilities */
|
||||
private function page(Request $request, InventoryDispositions $workflow, User $actor, array $capabilities, bool $canViewReversals): Response
|
||||
{
|
||||
$page = app(InventoryCorrectionPage::class)->read($request, $actor, 'stock_event');
|
||||
['ready' => $ready, 'focus' => $focus, 'records' => $records] = $page;
|
||||
$receipts = [];
|
||||
$lots = [];
|
||||
if ($ready && $capabilities['prepare']) {
|
||||
foreach (app(InventoryCorrectionPage::class)->receipts($records->pluck('data.receipt_item_id')->push($page['lookup']['receipt']), $page['lookup']['search']) as $line) {
|
||||
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
|
||||
$receipts[] = ['id' => $line->id, 'stock_item_id' => $line->stock_item_id, 'warehouse_id' => $receipt->warehouse_id, 'accepted_milli' => $line->accepted_milli,
|
||||
'returned_milli' => $workflow->returnedForReceipt($line->id), 'label' => $receipt->number.' · '.$receipt->snapshot['supplier']['name'].' · '.$line->snapshot['stock_item']['name']];
|
||||
}
|
||||
foreach (app(InventoryLots::class)->activeSetups()->get() as $setup) {
|
||||
$context = app(InventoryLots::class)->context($setup->stock_item_id, $setup->warehouse_id);
|
||||
foreach ($context['lots'] ?? [] as $lot) {
|
||||
if ($lot['quantity_milli'] > 0) {
|
||||
$lots[] = ['id' => $lot['id'], 'stock_item_id' => $setup->stock_item_id, 'warehouse_id' => $setup->warehouse_id, 'code' => $lot['data']['code'], 'quantity_milli' => $lot['quantity_milli'],
|
||||
'receipt_item_id' => $lot['origin']['receipt_item_id'] ?? null, 'reconciled' => $context['reconciled']];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
|
||||
'recordPagination' => $page['pagination'], 'filters' => $page['filters'],
|
||||
'sourceSearch' => $page['lookup']['search'], 'sourceFormKey' => $page['lookup']['form'],
|
||||
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $workflow->canAct($actor, $record, 'approve'),
|
||||
'canReturn' => $workflow->canAct($actor, $record, 'return'), 'canExecute' => $workflow->canAct($actor, $record, 'execute'),
|
||||
'executionSourceCurrent' => $record->state === 'approved' ? $workflow->executionSourceCurrent($record) : null]),
|
||||
'canViewOperations' => $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'inventory_adjust_review']),
|
||||
'canViewReversals' => $canViewReversals,
|
||||
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->only($records->pluck('data.stock_operation_id')->filter()->all())->map(fn ($r) => $r->data['reference']),
|
||||
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
|
||||
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'receipts' => $receipts, 'lots' => $lots,
|
||||
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, InventoryDispositions $workflow): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário de stock pendente.');
|
||||
$workflow->save($actor, $request->all());
|
||||
|
||||
return back()->with('success', 'Processo de stock registado.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\SupplyPlanning;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InventoryLotController extends Controller
|
||||
{
|
||||
public function index(Request $request, InventoryLots $lots): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$capabilities = [];
|
||||
foreach (['read', 'prepare', 'review', 'hold', 'release'] as $capability) {
|
||||
$capabilities[$capability] = $lots->allows($actor, 'inventory_lot_'.$capability);
|
||||
}
|
||||
abort_unless(collect($capabilities)->contains(fn (bool $allowed) => $allowed), 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $lots, $actor, $capabilities) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $this->page($request, $lots, $actor, $capabilities);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,bool> $capabilities */
|
||||
private function page(Request $request, InventoryLots $lots, User $actor, array $capabilities): Response
|
||||
{
|
||||
$ready = Schema::hasTable('inventory_lot_allocations');
|
||||
$filter = $request->validate([
|
||||
'record_id' => ['nullable', Rule::prohibitedIf($request->filled('lot_id')), 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'lot_setup')],
|
||||
'lot_id' => ['nullable', 'required_with:warehouse_id', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'lot')->where('version', 1)],
|
||||
'warehouse_id' => ['nullable', 'required_with:lot_id', 'integer', 'min:1', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'controls_page' => 'nullable|integer|min:1|max:1000000',
|
||||
'history_page' => 'nullable|integer|min:1|max:1000000',
|
||||
]);
|
||||
$focus = isset($filter['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', 'lot_setup')->findOrFail((int) $filter['record_id']) : null;
|
||||
if (isset($filter['lot_id'])) {
|
||||
$identity = SupplyRecord::query()->findOrFail((int) $filter['lot_id']);
|
||||
$focus = $lots->setup($identity->stock_item_id, (int) $filter['warehouse_id']);
|
||||
abort_if($focus === null, 404);
|
||||
}
|
||||
$controls = $ready && $focus === null ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->paginate(25, ['record_key'], 'controls_page')->withQueryString() : null;
|
||||
$keys = $focus !== null ? collect([$focus->record_key]) : ($controls?->getCollection()->pluck('record_key') ?? collect());
|
||||
$records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->whereIn('record_key', $keys)->latest('id')->get() : collect();
|
||||
if ($ready) {
|
||||
$records = $records->concat($lots->activeSetups()->whereIn('record_key', $keys)->latest('id')->get())->unique('id')->sortByDesc('id')->values();
|
||||
}
|
||||
$contexts = [];
|
||||
foreach ($records->where('state', 'active') as $record) {
|
||||
$contexts[$record->record_key] = $lots->context($record->stock_item_id, $record->warehouse_id);
|
||||
foreach ($contexts[$record->record_key]['lots'] as &$entry) {
|
||||
$metadata = SupplyRecord::query()->whereKey($entry['metadata_id'])->firstOrFail();
|
||||
$entry['canIdentify'] = $lots->canIdentify($actor, $metadata);
|
||||
$entry['canRelease'] = $lots->canDecideLot($actor, $metadata, $contexts[$record->record_key], $entry, 'release');
|
||||
$entry['canHold'] = $lots->canDecideLot($actor, $metadata, $contexts[$record->record_key], $entry, 'hold');
|
||||
}
|
||||
unset($entry);
|
||||
}
|
||||
if (isset($filter['lot_id'])) {
|
||||
abort_unless(collect($contexts[$focus->record_key]['lots'] ?? [])->contains('id', (int) $filter['lot_id']), 404);
|
||||
}
|
||||
$history = $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->paginate(50, ['*'], 'history_page')->withQueryString() : null;
|
||||
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities,
|
||||
'focusRecordId' => $focus->id ?? null, 'focusLotId' => isset($filter['lot_id']) ? (int) $filter['lot_id'] : null,
|
||||
'focusChanged' => ! isset($filter['lot_id']) && $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'controlPagination' => $this->pagination($controls), 'historyPagination' => $this->pagination($history),
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canActivate' => $lots->canActivate($actor, $record)]), 'contexts' => $contexts,
|
||||
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
|
||||
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
|
||||
'history' => $history?->getCollection() ?? []])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** @param LengthAwarePaginator<int,SupplyRecord>|null $page
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function pagination(?LengthAwarePaginator $page): ?array
|
||||
{
|
||||
return $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
|
||||
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()];
|
||||
}
|
||||
|
||||
public function save(Request $request, InventoryLots $lots): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless(Schema::hasTable('inventory_lot_allocations'), 503, 'Instalação do controlo por lotes pendente.');
|
||||
$lots->save($actor, $request->all());
|
||||
|
||||
return back()->with('success', 'Conferência de lotes registada.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\InventoryCorrectionPage;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryReversals;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InventoryReversalController extends Controller
|
||||
{
|
||||
public function index(Request $request, InventoryReversals $workflow): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$capabilities = [];
|
||||
foreach (['read', 'prepare', 'review', 'execute'] as $capability) {
|
||||
$capabilities[$capability] = $workflow->allows($actor, $capability);
|
||||
}
|
||||
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $workflow, $actor, $capabilities) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $this->page($request, $workflow, $actor, $capabilities);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,bool> $capabilities */
|
||||
private function page(Request $request, InventoryReversals $workflow, User $actor, array $capabilities): Response
|
||||
{
|
||||
$page = app(InventoryCorrectionPage::class)->read($request, $actor, 'stock_reversal');
|
||||
['ready' => $ready, 'focus' => $focus, 'records' => $records] = $page;
|
||||
$operations = [];
|
||||
$receipts = [];
|
||||
if ($ready && $capabilities['prepare']) {
|
||||
$operations = app(InventoryCorrectionPage::class)->operations($records->pluck('data.source_operation_id')->push($page['lookup']['operation']), $page['lookup']['search'])
|
||||
->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all();
|
||||
foreach (app(InventoryCorrectionPage::class)->receipts($records->pluck('data.receipt_item_id')->push($page['lookup']['receipt']), $page['lookup']['search']) as $line) {
|
||||
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
|
||||
$receipts[] = ['id' => $line->id, 'label' => $receipt->number.' · '.$line->snapshot['stock_item']['name'].' · '.$line->snapshot['warehouse']['name']];
|
||||
}
|
||||
}
|
||||
$other = app(InventoryDispositions::class);
|
||||
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
|
||||
'recordPagination' => $page['pagination'], 'filters' => $page['filters'],
|
||||
'sourceSearch' => $page['lookup']['search'], 'sourceFormKey' => $page['lookup']['form'],
|
||||
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'canViewDispositions' => collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => $other->allows($actor, $a)),
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $workflow->canAct($actor, $record, 'approve'),
|
||||
'canReturn' => $workflow->canAct($actor, $record, 'return'), 'canExecute' => $workflow->canAct($actor, $record, 'execute'),
|
||||
'executionSourceCurrent' => $record->state === 'approved' ? $workflow->executionSourceCurrent($record) : null]), 'operations' => $operations, 'receipts' => $receipts,
|
||||
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, InventoryReversals $workflow): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário pendente.');
|
||||
$workflow->save($actor, $request->all());
|
||||
|
||||
return back()->with('success', 'Estorno registado com a origem preservada.');
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,15 @@ class InvestmentPolicyController extends Controller
|
||||
{
|
||||
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();
|
||||
|
||||
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
|
||||
{
|
||||
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']);
|
||||
$workflow->propose($request->user(), $data);
|
||||
|
||||
|
||||
@@ -5,35 +5,58 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\User;
|
||||
use App\Services\InventoryDispositions;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\InvoiceWorkflow;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\SupplierPayables;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$filters = $request->validate(['invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'purchase_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)]]);
|
||||
$focus = isset($filters['invoice_id']) ? (int) $filters['invoice_id'] : null;
|
||||
$focusOrder = isset($filters['purchase_order_id']) ? (int) $filters['purchase_order_id'] : null;
|
||||
Inertia::encryptHistory();
|
||||
|
||||
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]);
|
||||
$response = Inertia::render('invoices/index', ['focusInvoiceId' => $focus, 'focusOrderId' => $focusOrder, 'stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(),
|
||||
'invoices' => Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusOrder !== null, fn ($q) => $q->where('purchase_order_id', $focusOrder))->with('items')->latest('id')->paginate(15)->withQueryString()
|
||||
->through(fn (Invoice $invoice) => [...$invoice->toArray(), 'canValidate' => app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)]),
|
||||
'orders' => PurchaseOrder::query()->visibleTo($actor)->operational()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]),
|
||||
'sgfDispatchAvailable' => config('sgf_invoice.workspace_enabled')===true && $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor),
|
||||
'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($actor, 'view'), 'canManage' => $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor)])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
$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})?$/']]);
|
||||
$workflow->register($request->user(), $order, $data);
|
||||
$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' => 'sometimes|required|string|max:30', 'items.*.unit_price' => ['sometimes', ...$decimal], 'items.*.tax_rate' => ['sometimes', 'required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
|
||||
$workflow->register($actor, $order, $data);
|
||||
|
||||
return to_route('invoices.index');
|
||||
}
|
||||
|
||||
public function validateInvoice(Request $request, Invoice $invoice, InvoiceWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->validate($request->user(), $invoice);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$workflow->validate($actor, $invoice);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\OrderCancellation;
|
||||
use App\Models\OrderCancellationPolicy;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\OrderCancellationPolicyWorkflow;
|
||||
use App\Services\OrderCancellationWorkflow;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class OrderCancellationController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless(config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'), 503, 'Cancelamento de ordens não activado.');
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function index(Request $request, OrderCancellationWorkflow $workflow): Response
|
||||
{
|
||||
$actor = $this->actor($request);
|
||||
$focus = $request->validate(['cancellation_id' => 'nullable|integer|min:1'])['cancellation_id'] ?? null;
|
||||
abort_unless($actor->hasFunctions(['admin', 'procurement', 'finance', 'requester', 'department_head']), 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $actor, $workflow, $focus) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$records = OrderCancellation::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))
|
||||
->when($focus !== null, fn ($q) => $q->whereKey($focus))
|
||||
->orderByDesc('id')->paginate(15)->withQueryString()->through(function ($record) use ($actor, $workflow) {
|
||||
$order = PurchaseOrder::query()->findOrFail($record->purchase_order_id);
|
||||
$source = Requisition::query()->findOrFail($order->requisition_id);
|
||||
|
||||
return [...$record->only(['id', 'purchase_order_id', 'created_by', 'reason', 'evidence', 'status', 'review_reason', 'reviewed_at', 'revised_requisition_id']),
|
||||
'order_number' => $order->number, 'canDecide' => $workflow->canDecide($actor, $record),
|
||||
'source_href' => $actor->hasFunctions(['admin', 'procurement', 'finance', 'department_head']) ? '/ordens/'.$order->id : '/requisicoes/'.$source->id.'/processo',
|
||||
'canRevise' => $workflow->canRevise($actor, $record)];
|
||||
});
|
||||
$response = Inertia::render('purchase-controls/orders', ['records' => $records,
|
||||
'policies' => $actor->hasFunctions(['admin']) ? OrderCancellationPolicy::query()->latest('revision')->get()->map(fn ($p) => [...$p->toArray(),
|
||||
'canReview' => app(OrderCancellationPolicyWorkflow::class)->canReview($actor, $p)]) : [],
|
||||
'canAdminister' => $actor->hasFunctions(['admin']), 'canDecide' => $actor->hasFunctions(['finance'])])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function request(Request $request, PurchaseOrder $order, OrderCancellationWorkflow $workflow): Response
|
||||
{
|
||||
$workflow->request($this->actor($request), $order, $request->all());
|
||||
|
||||
return redirect('/ordens/cancelamentos');
|
||||
}
|
||||
|
||||
public function decide(Request $request, int $cancellation, OrderCancellationWorkflow $workflow): Response
|
||||
{
|
||||
$actor = $this->actor($request);
|
||||
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000']);
|
||||
$workflow->decide($actor, OrderCancellation::query()->findOrFail($cancellation), $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function revise(Request $request, int $cancellation, OrderCancellationWorkflow $workflow): Response
|
||||
{
|
||||
$actor = $this->actor($request);
|
||||
$data = $request->validate(['needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'reason' => 'required|string|min:10|max:4000']);
|
||||
$record = $workflow->revise($actor, OrderCancellation::query()->findOrFail($cancellation), $data['needed_by'], $data['reason']);
|
||||
|
||||
return redirect('/requisicoes/'.$record->id);
|
||||
}
|
||||
|
||||
public function policy(Request $request, OrderCancellationPolicyWorkflow $workflow): Response
|
||||
{
|
||||
$workflow->propose($this->actor($request), $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function policyReview(Request $request, int $policy, OrderCancellationPolicyWorkflow $workflow): Response
|
||||
{
|
||||
$actor = $this->actor($request);
|
||||
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000']);
|
||||
$workflow->review($actor, OrderCancellationPolicy::query()->findOrFail($policy), $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\AiPreparationPlan;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\IntelligencePolicy;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\OrganizationActivationPreview;
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\WorkContext;
|
||||
use App\Services\WorkflowTasks;
|
||||
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
|
||||
{
|
||||
return DB::transaction(function () use ($request, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$user = $request->user('web');
|
||||
abort_unless($user instanceof User, 403);
|
||||
$actor = $service->administrator($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,
|
||||
'workflowTasks' => app(WorkflowTasks::class)->catalogue(),
|
||||
'activationPreview' => app(OrganizationActivationPreview::class)->build($actor),
|
||||
'geographyEnabled' => app(AdministrativeGeography::class)->enabled(),
|
||||
'geography' => app(AdministrativeGeography::class)->describe(Tenant::query()->findOrFail($actor->tenant_id)->geography_reference),
|
||||
'companyRules' => $this->companyRules((int) $actor->tenant_id),
|
||||
'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
|
||||
'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
|
||||
'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
|
||||
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
|
||||
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
|
||||
'permissions' => OrganizationAccess::PERMISSIONS,
|
||||
'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null,
|
||||
'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [],
|
||||
'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), 'work_contexts' => json_decode($p->work_contexts ?? '[]', 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
|
||||
{
|
||||
$user = $request->user('web');
|
||||
abort_unless($user instanceof User, 403);
|
||||
$actor = $service->administrator($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 += ['work_contexts' => 'sometimes|array', 'work_contexts.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))]];
|
||||
$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 === 'work_contexts') {
|
||||
$rules += ['enabled' => 'present|array', 'enabled.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))], 'reason' => 'required|string|min:10|max:4000', 'confirm' => 'accepted'];
|
||||
} 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'];
|
||||
} elseif ($command === 'comparison') {
|
||||
$rules += ['minimum_proposals' => 'required|integer|between:1,100', 'minimum_evaluators' => 'required|integer|between:1,100',
|
||||
'weights' => 'required|array:price,quality,delivery,history,payment', 'reason' => 'required|string|min:10|max:4000'];
|
||||
foreach (array_keys(ComparisonPolicy::DEFAULTS['weights']) as $criterion) {
|
||||
$rules['weights.'.$criterion] = 'required|integer|between:0,100';
|
||||
}
|
||||
} elseif ($command === 'ai_plan') {
|
||||
$rules += app(AiPreparationPlan::class)->rules() + ['reason' => 'required|string|min:10|max:4000'];
|
||||
} elseif ($command === 'intelligence') {
|
||||
$rules += ['procurement_brief' => 'required|boolean', 'people_brief' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000',
|
||||
'api_key' => 'prohibited', 'external_processing_enabled' => 'prohibited'];
|
||||
} elseif ($command === 'tax') {
|
||||
$rules += ['default_tax_rate' => 'required|numeric|decimal:0,2|between:0,100', '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,
|
||||
'default_tax_rate' => (string) ($company->default_tax_rate ?? '16.00')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\AiPreparationPlan;
|
||||
use App\Services\CandidateTriage;
|
||||
use App\Services\IntelligencePolicy;
|
||||
use App\Services\PeopleAccess;
|
||||
use App\Services\PeopleClarifications;
|
||||
use App\Services\PeopleEvidenceBrief;
|
||||
use App\Services\PeopleForms;
|
||||
use App\Services\PeopleHiring;
|
||||
use App\Services\PeopleOperationalInsights;
|
||||
use App\Services\PeopleProcessProjection;
|
||||
use App\Services\PeopleRegistry;
|
||||
use App\Services\PeopleWorkdesk;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PeopleHiringController extends Controller
|
||||
{
|
||||
public function index(Request $request, PeopleHiring $workflow): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $workflow) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$kinds = $workflow->kinds($this->actor($request));
|
||||
abort_unless($kinds !== [], 403);
|
||||
$availableKinds = $kinds;
|
||||
$filter = $request->validate(['kind' => 'nullable|in:service,staff', 'search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(PeopleWorkdesk::STATES)],
|
||||
'from' => 'nullable|date_format:Y-m-d', 'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')]]);
|
||||
if (isset($filter['kind'])) {
|
||||
abort_unless(in_array($filter['kind'], $kinds, true), 403);
|
||||
$kinds = [$filter['kind']];
|
||||
}
|
||||
$ready = Schema::hasTable('people_processes') && Schema::hasTable('people_process_access');
|
||||
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $this->actor($request));
|
||||
if (($filter['search'] ?? '') !== '') {
|
||||
$search = $filter['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereLike('title', '%'.$search.'%');
|
||||
if (preg_match('/^(?:PES-)?([1-9][0-9]*)$/D', $search, $matches)) {
|
||||
$reference = filter_var($matches[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
if ($reference !== false) {
|
||||
$q->orWhere('id', $reference);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (! empty($filter['status'])) {
|
||||
$query->where('status', $filter['status']);
|
||||
}
|
||||
if (! empty($filter['from'])) {
|
||||
$query->whereDate('created_at', '>=', $filter['from']);
|
||||
}
|
||||
if (! empty($filter['to'])) {
|
||||
$query->whereDate('created_at', '<=', $filter['to']);
|
||||
}
|
||||
$workdesk = $ready ? app(PeopleWorkdesk::class)->build(clone $query, $this->actor($request)) : null;
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('people/index', ['ready' => $ready, 'kinds' => $kinds, 'availableKinds' => $availableKinds,
|
||||
'filters' => $filter, 'workdesk' => $workdesk, 'insights' => $workdesk === null ? null : app(PeopleOperationalInsights::class)->build($workdesk),
|
||||
'company' => Tenant::query()->findOrFail($this->actor($request)->tenant_id)->only(['name']),
|
||||
'canPrepare' => array_values(array_filter($availableKinds, fn ($kind) => $workflow->allowed($this->actor($request), $kind, 'prepare'))),
|
||||
'formFields' => ['staff' => PeopleForms::STAFF, 'service' => PeopleForms::SERVICE],
|
||||
'administration' => $ready && $this->actor($request)->hasFunctions(['admin']) ? PeopleProcess::query()->whereIn('kind', $kinds)->orderByDesc('id')->get(['id', 'kind', 'status']) : [],
|
||||
'processes' => $ready ? (clone $query)->select(['id', 'title', 'kind', 'status', 'closes_on', 'created_at'])->latest('id')->paginate(15)->withQueryString() : null])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function show(Request $request, int $process, PeopleHiring $workflow): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $process, $workflow) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$record = PeopleProcess::query()->findOrFail($process);
|
||||
$access = app(PeopleAccess::class);
|
||||
abort_unless($access->can($this->actor($request), $record, 'view'), 403);
|
||||
$registrySearch = $request->validate(['cadastro' => 'nullable|string|max:100'])['cadastro'] ?? '';
|
||||
$projection = app(PeopleProcessProjection::class)->read($this->actor($request), $record);
|
||||
$snapshot = $projection['process'];
|
||||
$rows = $projection['candidates'];
|
||||
$clarifications = app(PeopleClarifications::class)->page($this->actor($request), $record);
|
||||
$company = Tenant::query()->findOrFail($this->actor($request)->tenant_id);
|
||||
$policy = app(IntelligencePolicy::class)->company($company);
|
||||
$brief = app(PeopleEvidenceBrief::class)->build($snapshot, $rows, $clarifications, $access->can($this->actor($request), $record, 'financial'),
|
||||
$access->can($this->actor($request), $record, 'contract') || $access->can($this->actor($request), $record, 'contract_view'));
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('people/show', ['process' => $snapshot, 'candidates' => $rows, 'formFields' => $record->kind === 'staff' ? PeopleForms::STAFF : PeopleForms::SERVICE,
|
||||
'clarifications' => $clarifications, 'brief' => $policy['people_brief'] ? $brief : null,
|
||||
'registry' => app(PeopleRegistry::class)->lookup($this->actor($request), $record, $registrySearch),
|
||||
'aiPreparation' => app(AiPreparationPlan::class)->preview($company, 'people', $brief),
|
||||
'canPrepare' => $workflow->allowed($this->actor($request), $record->kind, 'prepare') && $access->can($this->actor($request), $record, 'prepare'),
|
||||
'canViewElectronicSources' => app(CandidateTriage::class)->canRead($this->actor($request), $record),
|
||||
'canReview' => $workflow->allowed($this->actor($request), $record->kind, 'review') && $access->can($this->actor($request), $record, 'review') && $this->actor($request)->id !== $record->created_by,
|
||||
'canApprove' => $workflow->allowed($this->actor($request), $record->kind, 'review') && $access->can($this->actor($request), $record, 'approve') && ! in_array($this->actor($request)->id, $workflow->decisionContributors($record), true),
|
||||
'canContract' => $workflow->allowed($this->actor($request), $record->kind, 'prepare') && $access->can($this->actor($request), $record, 'contract'),
|
||||
'canManage' => $access->manages($this->actor($request), $record),
|
||||
'history' => AuditLog::query()->where('action', 'like', 'people.%')->where('after->id', $record->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at'])])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function dossier(Request $request, int $process, PeopleProcessProjection $projection): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $process, $projection) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$record = PeopleProcess::query()->findOrFail($process);
|
||||
$data = $projection->read($this->actor($request), $record);
|
||||
|
||||
return response()->json(['schema' => 'sgc.people.process-dossier.v1', 'exported_at' => now()->toIso8601String(),
|
||||
'effect' => 'preparation_only',
|
||||
'process' => Arr::only($data['process'], ['id', 'kind', 'title', 'purpose', 'closes_on', 'status', 'version', 'selected_application_id', 'rules', 'selection', 'contract']),
|
||||
'candidates' => $data['candidates']])
|
||||
->header('Content-Disposition', 'attachment; filename="PES-'.$record->id.'-dossier-v'.$record->version.'.json"')
|
||||
->header('Cache-Control', 'private, no-store, max-age=0')->header('X-Content-Type-Options', 'nosniff');
|
||||
});
|
||||
}
|
||||
|
||||
public function access(Request $request, int $process, PeopleAccess $access): Response
|
||||
{
|
||||
$record = PeopleProcess::query()->findOrFail($process);
|
||||
abort_unless($access->manages($this->actor($request), $record), 403);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('people/access', ['process' => $record->only(['id', 'kind', 'status']),
|
||||
'version' => DB::table('people_access_states')->where('people_process_id', $record->id)->value('version'), 'scopes' => PeopleAccess::SCOPES,
|
||||
'users' => User::query()->where('tenant_id', $record->tenant_id)->orderBy('name')->get(['id', 'name', 'tenant_id', 'purchasing_role'])
|
||||
->filter(fn ($user) => app(PeopleHiring::class)->allowed($user, $record->kind) || DB::table('people_process_access')->where('people_process_id', $record->id)->where('user_id', $user->id)->exists())
|
||||
->map(fn ($user) => $user->only(['id', 'name']))->values(),
|
||||
'assignments' => DB::table('people_process_access')->where('tenant_id', $record->tenant_id)->where('people_process_id', $record->id)->get()->map(fn ($row) => ['user_id' => $row->user_id, 'scopes' => json_decode($row->scopes, true, 16, JSON_THROW_ON_ERROR), 'expires_at' => $row->expires_at]),
|
||||
'canView' => $access->can($this->actor($request), $record, 'view')])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function assign(Request $request, int $process, PeopleAccess $access): Response
|
||||
{
|
||||
$access->assign($this->actor($request), $process, $request->all());
|
||||
|
||||
return redirect('/pessoas/'.$process.'/acessos');
|
||||
}
|
||||
|
||||
public function command(Request $request, int $process, string $command, PeopleHiring $workflow): Response
|
||||
{
|
||||
abort_unless(Schema::hasTable('people_processes'), 503, 'Instalação de Pessoas e contratação pendente.');
|
||||
$record = $workflow->execute($this->actor($request), $process, $command, $request->all());
|
||||
|
||||
return redirect('/pessoas/'.$record->id);
|
||||
}
|
||||
|
||||
public function store(Request $request, PeopleHiring $workflow): Response
|
||||
{
|
||||
abort_unless(Schema::hasTable('people_processes'), 503);
|
||||
$record = $workflow->execute($this->actor($request), null, 'create', $request->all());
|
||||
|
||||
return redirect('/pessoas/'.$record->id);
|
||||
}
|
||||
|
||||
public function clarification(Request $request, int $process, string $command, PeopleClarifications $workflow): Response
|
||||
{
|
||||
abort_unless(Schema::hasTable('people_clarifications'), 503, 'Instalação dos esclarecimentos pendente.');
|
||||
$data = $request->validate(['clarification_id' => 'nullable|integer|min:1']);
|
||||
$workflow->execute($this->actor($request), $process, $data['clarification_id'] ?? null, $command, $request->all());
|
||||
|
||||
return redirect('/pessoas/'.$process.'#people-clarifications');
|
||||
}
|
||||
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PeopleRegistry;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PeopleRegistryController extends Controller
|
||||
{
|
||||
public function index(Request $request, PeopleRegistry $registry): Response
|
||||
{
|
||||
abort_unless($registry->canRead($request->user()), 403);
|
||||
$filter = $request->validate(['search' => 'nullable|string|max:100', 'status' => 'nullable|in:active,archived,all']);
|
||||
$ready = Schema::hasTable('people_registry');
|
||||
$records = null;
|
||||
if ($ready) {
|
||||
$query = $registry->visible($request->user());
|
||||
if (($filter['search'] ?? '') !== '') {
|
||||
$query->where(fn ($q) => $q->whereLike('name', '%'.$filter['search'].'%')->orWhereLike('reference', '%'.$filter['search'].'%'));
|
||||
}
|
||||
if (($filter['status'] ?? 'active') !== 'all') {
|
||||
$query->where('active', ($filter['status'] ?? 'active') === 'active');
|
||||
}
|
||||
$records = $query->orderBy('name')->orderBy('id')->paginate(15)->withQueryString()->through(fn ($row) => $registry->item($request->user(), $row));
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('people/registry', ['ready' => $ready, 'canCreate' => $registry->canCreate($request->user()), 'records' => $records, 'filters' => $filter])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, PeopleRegistry $registry, ?int $person = null): Response
|
||||
{
|
||||
abort_unless(Schema::hasTable('people_registry'), 503, 'Instalação do cadastro de pessoas pendente.');
|
||||
$record = $registry->execute($request->user(), $person, $request->all());
|
||||
|
||||
return redirect('/pessoas/cadastro?'.http_build_query(['search' => $record->reference, 'status' => 'all']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\User;
|
||||
use App\Services\PeopleAccess;
|
||||
use App\Services\PeopleApplicationReport;
|
||||
use App\Services\PeopleDeadlineReport;
|
||||
use App\Services\PeopleHiring;
|
||||
use App\Services\PeopleSelectionReport;
|
||||
use App\Services\PeopleWorkdesk;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PeopleReportController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$kinds = app(PeopleHiring::class)->kinds($actor);
|
||||
abort_unless($kinds !== [], 403);
|
||||
$filters = $request->validate([
|
||||
'view' => 'nullable|in:processes,applications,selections,deadlines',
|
||||
'deadline_window' => 'nullable|in:all,overdue,today,future,undated',
|
||||
'deadline_page' => 'nullable|integer|min:1|max:100000',
|
||||
'kind' => ['nullable', Rule::in($kinds)], 'status' => ['nullable', Rule::in(PeopleWorkdesk::STATES)],
|
||||
'search' => 'nullable|string|max:100', 'from' => 'nullable|date_format:Y-m-d',
|
||||
'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')],
|
||||
]);
|
||||
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
|
||||
foreach (['kind', 'status'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$query->where($field, $filters[$field]);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereLike('title', '%'.$search.'%');
|
||||
if (preg_match('/^(?:PES-)?([1-9][0-9]*)$/D', $search, $matches)) {
|
||||
$id = filter_var($matches[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
if ($id !== false) {
|
||||
$q->orWhere('id', $id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (! empty($filters['from'])) {
|
||||
$query->whereDate('created_at', '>=', $filters['from']);
|
||||
}
|
||||
if (! empty($filters['to'])) {
|
||||
$query->whereDate('created_at', '<=', $filters['to']);
|
||||
}
|
||||
$columns = ['id', 'title', 'kind', 'status', 'closes_on', 'created_at'];
|
||||
$page = $request->routeIs('people.reports.csv') ? null : (clone $query)->latest('id')->paginate(25, $columns)->withQueryString();
|
||||
$applications = ($filters['view'] ?? 'processes') === 'applications';
|
||||
$selections = ($filters['view'] ?? 'processes') === 'selections';
|
||||
$deadlines = ($filters['view'] ?? 'processes') === 'deadlines';
|
||||
$deadlineReport = $deadlines ? app(PeopleDeadlineReport::class)->build(clone $query, $actor, $filters['deadline_window'] ?? 'all') : null;
|
||||
if ($deadlineReport !== null && ! $request->routeIs('people.reports.csv')) {
|
||||
$number = (int) ($filters['deadline_page'] ?? 1);
|
||||
$pager = (new LengthAwarePaginator(array_slice($deadlineReport['items'], ($number - 1) * 25, 25), $deadlineReport['total_items'], 25, $number,
|
||||
['path' => $request->url(), 'pageName' => 'deadline_page']))->appends($request->except('deadline_page'));
|
||||
$deadlineReport['items'] = $pager->items();
|
||||
$deadlineReport['pagination'] = ['current_page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'prev_page_url' => $pager->previousPageUrl(), 'next_page_url' => $pager->nextPageUrl()];
|
||||
}
|
||||
$metrics = [];
|
||||
if ($applications || $selections) {
|
||||
$metricQuery = clone $query;
|
||||
if ($page !== null) {
|
||||
$metricQuery->whereIn('id', $page->getCollection()->modelKeys());
|
||||
}
|
||||
foreach ($metricQuery->orderBy('id')->lazyById(100) as $process) {
|
||||
$metrics[$process->id] = $applications ? app(PeopleApplicationReport::class)->counts($process, $actor) : app(PeopleSelectionReport::class)->facts($process, $actor);
|
||||
}
|
||||
}
|
||||
if ($request->routeIs('people.reports.csv') && $deadlineReport !== null) {
|
||||
$response = $this->deadlineCsv($deadlineReport);
|
||||
} elseif ($request->routeIs('people.reports.csv')) {
|
||||
// Materialise within the read-only snapshot; streaming runs after the transaction.
|
||||
$rows = (clone $query)->orderBy('id')->get($columns);
|
||||
$response = response()->streamDownload(function () use ($rows, $applications, $selections, $metrics) {
|
||||
$file = fopen('php://output', 'w');
|
||||
if ($file === false) {
|
||||
throw new \RuntimeException('Não foi possível abrir a exportação.');
|
||||
}
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
$extra = ['Registadas para avaliação', 'Avaliação completa', 'Por concluir avaliação', 'Sem avaliação', 'Avaliação em curso', 'Base da avaliação', 'Recebidas no portal', 'Admitidas para análise no portal', 'Não admitidas no portal', 'Triagem pendente'];
|
||||
$selectionColumns = ['Situação da selecção', 'Proposta em', 'Decisão em', 'Pendência', 'Acesso à minuta', 'Referência da minuta', 'Minuta preparada em', 'Início previsto na minuta'];
|
||||
fputcsv($file, ['Referência', 'Título', 'Percurso', 'Estado', 'Fecho de candidaturas', 'Criado em', ...($applications ? $extra : ($selections ? $selectionColumns : []))], ';', '"', '');
|
||||
foreach ($rows as $row) {
|
||||
$title = preg_match('/^[\s]*[=+@-]/u', $row->title) ? "'".$row->title : $row->title;
|
||||
$counts = $metrics[$row->id] ?? [];
|
||||
$values = $applications ? array_values(array_map(fn ($value) => $value === null ? 'Sem acesso' : ($value === 'selection_snapshot' ? 'Selecção preservada' : ($value === 'current_eligibility' ? 'Elegibilidade actual' : $value)), $counts)) : [];
|
||||
if ($selections) {
|
||||
$values = array_values(array_map(fn ($value) => $value === null ? 'Não registado' : (is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value), $counts));
|
||||
}
|
||||
fputcsv($file, ['PES-'.$row->id, $title, $row->kind === 'service' ? 'Serviços individuais' : 'Recrutamento e selecção', self::states()[$row->status] ?? $row->status, $row->closes_on, $row->created_at?->toDateString(), ...$values], ';', '"', '');
|
||||
}
|
||||
fclose($file);
|
||||
}, 'relatorio-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
} else {
|
||||
$summary = (clone $query)->selectRaw('kind, status, count(*) as total')->groupBy('kind', 'status')->get();
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('people/reports', [
|
||||
'filters' => $filters, 'kinds' => $kinds, 'summary' => $summary,
|
||||
'applicationMetrics' => $applications ? $metrics : [],
|
||||
'selectionMetrics' => $selections ? $metrics : [],
|
||||
'deadlineReport' => $deadlineReport,
|
||||
'generatedAt' => now()->toIso8601String(),
|
||||
'processes' => $page,
|
||||
])->toResponse($request);
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $report */
|
||||
private function deadlineCsv(array $report): Response
|
||||
{
|
||||
return response()->streamDownload(function () use ($report) {
|
||||
$file = fopen('php://output', 'w');
|
||||
if ($file === false) {
|
||||
throw new \RuntimeException('Não foi possível abrir a exportação.');
|
||||
}
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
fputcsv($file, ['Referência', 'Processo', 'Categoria', 'Intervenção', 'Responsabilidade', 'Prazo', 'Dias de atraso', 'Consulta em', 'Filtro de prazo'], ';', '"', '');
|
||||
$windows = ['all' => 'Todos', 'overdue' => 'Vencidos', 'today' => 'Hoje', 'future' => 'Futuros', 'undated' => 'Sem prazo definido'];
|
||||
foreach ($report['items'] as $item) {
|
||||
$values = [$item['reference'], $item['title'], $item['category'], $item['label'], $item['responsibility'], $item['due_on'] ?? 'Sem prazo definido', $item['days_overdue'] ?? 'Não determinável', $report['as_of'], $windows[$report['window']]];
|
||||
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value, $values);
|
||||
fputcsv($file, $values, ';', '"', '');
|
||||
}
|
||||
fclose($file);
|
||||
}, 'relatorio-prazos-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function states(): array
|
||||
{
|
||||
return ['draft' => 'Abertura por conferir', 'open' => 'Recepção de candidaturas', 'assessment' => 'Avaliação',
|
||||
'selection_review' => 'Selecção por conferir', 'selected' => 'Selecção aprovada', 'contract_draft' => 'Minuta contratual preparada'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?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 App\Services\OrganizationAccess;
|
||||
use App\Services\ProcurementOperationalInsights;
|
||||
use Carbon\CarbonImmutable;
|
||||
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 actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$current = $request->user('web');
|
||||
abort_unless($current instanceof User, 403);
|
||||
$actor = User::query()->where('tenant_id', $current->tenant_id)->findOrFail($current->id);
|
||||
abort_unless(! app(OrganizationAccess::class)->maintenanceOnly($actor) && $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',
|
||||
'attention' => 'nullable|in:unapproved_needed,approved_without_quotes,approved_without_order',
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
/** @return Builder<Quotation> */
|
||||
private function registeredQuotes(): Builder
|
||||
{
|
||||
return Quotation::query()->whereColumn('quotations.requisition_id', 'requisitions.id');
|
||||
}
|
||||
|
||||
/** @return Builder<PurchaseOrder> */
|
||||
private function visibleOrders(User $actor): Builder
|
||||
{
|
||||
return PurchaseOrder::query()->visibleTo($actor)->whereColumn('purchase_orders.requisition_id', 'requisitions.id');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters
|
||||
* @return Builder<Requisition>
|
||||
*/
|
||||
private function query(User $actor, array $filters, string $today): 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 (($filters['attention'] ?? '') === 'unapproved_needed') {
|
||||
$query->whereIn('status', ['draft', 'pending'])->whereDate('needed_by', '<', $today);
|
||||
}
|
||||
if (($filters['attention'] ?? '') === 'approved_without_quotes') {
|
||||
$query->where('status', 'approved')->whereNotExists($this->registeredQuotes()->selectRaw('1'));
|
||||
}
|
||||
if (($filters['attention'] ?? '') === 'approved_without_order') {
|
||||
$query->where('status', 'approved')->whereNotExists($this->visibleOrders($actor)->selectRaw('1'));
|
||||
}
|
||||
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, User $actor): Builder
|
||||
{
|
||||
return $query->select('requisitions.*')->with(['department:id,name', 'requester:id,name'])->addSelect([
|
||||
'quotations_count' => $this->registeredQuotes()->selectRaw('COUNT(*)'),
|
||||
'order_number' => $this->visibleOrders($actor)->select('number')->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(),
|
||||
'needed_by' => $record->needed_by?->toDateString(),
|
||||
'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 {
|
||||
$instant = CarbonImmutable::now(config('app.timezone'));
|
||||
$filters = $this->filters($request);
|
||||
$actor = $this->actor($request);
|
||||
$query = $this->query($actor, $filters, $instant->toDateString());
|
||||
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($value) => (int) $value);
|
||||
$records = $this->records(clone $query, $actor)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record));
|
||||
$departments = Department::query()->when($actor->departmentScoped(), fn ($q) => $q->whereKey($actor->department_id))->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
$insights = app(ProcurementOperationalInsights::class)->build($this->records(clone $query, $actor), $instant);
|
||||
|
||||
return ['records' => $records, 'counts' => $counts, 'filters' => $filters, 'departments' => $departments, 'generatedAt' => $instant->toIso8601String(), 'insights' => $insights];
|
||||
});
|
||||
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
|
||||
{
|
||||
$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, $text) {
|
||||
$instant = CarbonImmutable::now(config('app.timezone'));
|
||||
$filters = $this->filters($request);
|
||||
$actor = $this->actor($request);
|
||||
$query = $this->records($this->query($actor, $filters, $instant->toDateString()), $actor);
|
||||
$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', $instant->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', 'Data de necessidade'], ';', '"', '');
|
||||
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', $row['needed_by']], ';', '"', '');
|
||||
}
|
||||
fclose($output);
|
||||
}), 'relatorio-compras-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AiPreparationPlan;
|
||||
use App\Services\IntelligencePolicy;
|
||||
use App\Services\ProcurementEvidenceBrief;
|
||||
use App\Services\ProcurementReviewReport;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
@@ -14,7 +18,11 @@ class ProcurementReviewReportController extends Controller
|
||||
{
|
||||
$report = $builder->build($request->user(), $requisition->id);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('purchases/review-report', ['report' => $report])->toResponse($request);
|
||||
$company = Tenant::query()->findOrFail($request->user()->tenant_id);
|
||||
$policy = app(IntelligencePolicy::class)->company($company);
|
||||
$brief = app(ProcurementEvidenceBrief::class)->build($report);
|
||||
$response = Inertia::render('purchases/review-report', ['report' => $report, 'brief' => $policy['procurement_brief'] ? $brief : null,
|
||||
'aiPreparation' => app(AiPreparationPlan::class)->preview($company, 'procurement', $brief)])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -19,7 +19,7 @@ class ProposalOpeningController extends Controller
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class ProposalOpeningController extends Controller
|
||||
'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings,
|
||||
'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash,
|
||||
'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);
|
||||
$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\PurchaseRevision;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\PurchaseControlPolicyWorkflow;
|
||||
use App\Services\PurchaseRevisionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -22,13 +23,13 @@ class PurchaseControlController extends Controller
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$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');
|
||||
$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']);
|
||||
|
||||
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() : [],
|
||||
'reviews' => $operational ? PurchaseControlReview::query()->get() : [],
|
||||
'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
|
||||
{
|
||||
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']);
|
||||
$workflow->propose($request->user(), $data);
|
||||
|
||||
|
||||
@@ -9,12 +9,16 @@ use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\RecommendationAttempt;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\FormalRecommendation;
|
||||
use App\Services\OrderCancellationWorkflow;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\PurchaseWorkflow;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
@@ -22,15 +26,19 @@ use Inertia\Response;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
private function access(Request $request): User
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$actor = $this->access($request);
|
||||
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
|
||||
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
|
||||
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
|
||||
$recommendation = app(FormalRecommendation::class);
|
||||
@@ -50,17 +58,17 @@ class PurchaseController extends Controller
|
||||
|
||||
return Inertia::render('purchases/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' => ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions([PurchasingRole::Procurement]) && $award === null && $actor->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
|
||||
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
|
||||
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
|
||||
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
||||
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($actor, $award, $requisition),
|
||||
'canIssue' => $award !== null && $workflow->canIssue($actor, $award, $requisition),
|
||||
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
$data = $request->validate([
|
||||
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
|
||||
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
|
||||
@@ -68,58 +76,62 @@ class PurchaseController extends Controller
|
||||
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
|
||||
'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$workflow->select($request->user(), $requisition, $data);
|
||||
$workflow->select($actor, $requisition, $data);
|
||||
|
||||
return to_route('purchases.award', $requisition);
|
||||
}
|
||||
|
||||
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
|
||||
$workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
|
||||
$workflow->decide($actor, $award, $data['decision'], $data['comment'] ?? null);
|
||||
|
||||
return to_route('purchases.award', $award->requisition_id);
|
||||
}
|
||||
|
||||
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
$request->validate([
|
||||
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
|
||||
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$order = $workflow->issue($request->user(), $award);
|
||||
$order = $workflow->issue($actor, $award);
|
||||
|
||||
return to_route('orders.show', $order);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
|
||||
return Inertia::render('purchases/index', [
|
||||
'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
|
||||
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
|
||||
'orders' => PurchaseOrder::query()->visibleTo($actor)->latest('id')->paginate(15),
|
||||
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($actor)->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, PurchaseOrder $order): Response
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
$actor = $this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
|
||||
|
||||
return Inertia::render('purchases/show', [
|
||||
'order' => $order->load('items'),
|
||||
'orderCancellation' => ['enabled' => (bool) config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'),
|
||||
'state' => app(OrderCancellationWorkflow::class)->state($order),
|
||||
'canRequest' => app(OrderCancellationWorkflow::class)->canRequest($actor, $order)],
|
||||
'history' => AuditLog::query()->where('requisition_id', $order->requisition_id)->whereIn('action', ['award.created', 'award.approved', 'award.rejected', 'purchase_order.issued'])->orderBy('id')->get(['id', 'user_id', 'action', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(Request $request, PurchaseOrder $order): View
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
$actor = $this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
|
||||
|
||||
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
|
||||
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot,
|
||||
'operationalState' => app(OrderCancellationWorkflow::class)->state($order)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class PurchasePlanningController extends Controller
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$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']);
|
||||
$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();
|
||||
|
||||
@@ -27,13 +27,13 @@ class PurchaseProcessController extends Controller
|
||||
{
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
|
||||
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
|
||||
$role = $actor->purchasing_role;
|
||||
$purchases = in_array($role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$finance = in_array($role, [PurchasingRole::Admin, PurchasingRole::Finance]);
|
||||
$inventory = $purchases || $role === PurchasingRole::Warehouse;
|
||||
$purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$finance = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
|
||||
$inventory = $purchases || $actor->hasFunctions([PurchasingRole::Warehouse]);
|
||||
$base = '/requisicoes/'.$requisition->id;
|
||||
$stages = [];
|
||||
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
|
||||
@@ -91,10 +91,13 @@ class PurchaseProcessController extends Controller
|
||||
}
|
||||
$invoices = $finance && $order ? Invoice::query()->where('purchase_order_id', $order->id)->get(['id', 'number', 'status']) : collect();
|
||||
$payments = $finance ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->get(['id', 'status']) : collect();
|
||||
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/facturas' : null];
|
||||
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance && $order ? '/facturas?purchase_order_id='.$order->id : null];
|
||||
$stages[] = ['label' => 'Pagamentos externos', 'state' => $finance ? ($payments->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $payments->count().' registos; '.$payments->where('status', 'confirmed')->count().' confirmados. Não comprova liquidação integral e não executa transferências.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/pagamentos' : null];
|
||||
foreach ($invoices as $invoice) {
|
||||
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas'];
|
||||
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
|
||||
}
|
||||
foreach ($payments as $payment) {
|
||||
$documents[] = ['label' => 'Registo externo #'.$payment->id, 'kind' => 'Pagamento externo', 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
|
||||
}
|
||||
$cancellation = $award ? AwardCancellation::query()->where('purchase_award_id', $award->id)->first(['id', 'status']) : null;
|
||||
$actors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', array_column($decisions, 'actor_id'))->pluck('name', 'id');
|
||||
|
||||
@@ -4,75 +4,98 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\CurrentActor;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\QuotationParticipation;
|
||||
use App\Services\QuotationPricing;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as HttpResponse;
|
||||
|
||||
class QuotationController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
private function access(Request $request): User
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
$actor = $this->access($request);
|
||||
|
||||
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
|
||||
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($actor)->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
public function show(Request $request, Requisition $requisition): HttpResponse
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$actor = $this->access($request);
|
||||
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
|
||||
$filters = $request->validate(['quotation_id' => ['nullable', 'integer', Rule::exists('quotations', 'id')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $requisition->id)], 'prepare' => 'nullable|boolean']);
|
||||
$access = app(QuotationParticipation::class);
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->with('supplier')->get()->keyBy('id');
|
||||
$guard = app(ConflictGuard::class);
|
||||
$result = app(ProposalComparison::class)->inspect($requisition);
|
||||
$scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
|
||||
$closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
$scores = array_map(function ($row) use ($quotes, $actor, $access, $requisition, $guard) {
|
||||
$quote = $quotes->get($row['id']);
|
||||
|
||||
return Inertia::render('quotations/show', [
|
||||
return [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $actor), 'canEvaluate' => $row['eligible'] && $quote !== null && $access->canEvaluate($actor, $requisition, $quote)];
|
||||
}, $result['rows']);
|
||||
Inertia::encryptHistory();
|
||||
|
||||
$response = Inertia::render('quotations/show', [
|
||||
'requisition' => $requisition->load('items'), 'quotations' => $scores,
|
||||
'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),
|
||||
'canEvaluate' => ! $closed && $request->user()->purchasing_role !== PurchasingRole::Admin && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'canCreate' => $access->canPrepare($actor, $requisition),
|
||||
'canEvaluate' => $access->canEvaluateProcess($actor, $requisition),
|
||||
'focusQuotationId' => isset($filters['quotation_id']) ? (int) $filters['quotation_id'] : null,
|
||||
'prepare' => (bool) ($filters['prepare'] ?? false),
|
||||
'ready' => $result['ready'],
|
||||
]);
|
||||
'topIds' => $result['top_ids'],
|
||||
'comparisonPolicy' => $result['policy'],
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
$actor = $this->access($request);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
$data = $request->validate([
|
||||
'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',
|
||||
'payment_terms' => 'required|string|min:3|max:500', 'no_conflict' => 'accepted',
|
||||
'items' => 'required|array|min:1|max:100', 'items.*.requisition_item_id' => 'required|integer|distinct',
|
||||
'items.*.unit_price' => ['required', 'numeric', 'decimal:0,2', 'min:0.01', 'max:1000000000'],
|
||||
'items.*.discount_type' => 'sometimes|required|in:none,percent,amount',
|
||||
'items.*.discount_value' => 'required_with:items.*.discount_type|numeric|decimal:0,2|min:0|max:1000000000',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $requisition, $data, $workflow) {
|
||||
DB::transaction(function () use ($actor, $requisition, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$guard->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->status === 'approved', 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
app(QuotationParticipation::class)->assertPrepare($actor, $requisition);
|
||||
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
|
||||
$guard->assertProcess($requisition, $supplier->id, $request->user());
|
||||
$guard->assertProcess($requisition, $supplier->id, $actor);
|
||||
if (! $supplier->eligible()) {
|
||||
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
|
||||
}
|
||||
@@ -84,6 +107,7 @@ class QuotationController extends Controller
|
||||
throw ValidationException::withMessages(['items' => 'A proposta deve conter todas as linhas da requisição.']);
|
||||
}
|
||||
$items = [];
|
||||
$terms = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
foreach ($data['items'] as $input) {
|
||||
@@ -91,7 +115,9 @@ class QuotationController extends Controller
|
||||
if (! $line) {
|
||||
throw ValidationException::withMessages(['items' => 'Artigo não pertence a esta requisição.']);
|
||||
}
|
||||
$price = BigDecimal::of($input['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$pricing = app(QuotationPricing::class)->unit((string) $input['unit_price'], $input['discount_type'] ?? 'none', (string) ($input['discount_value'] ?? '0'));
|
||||
$terms[] = ['requisition_item_id' => $line->id, ...$pricing];
|
||||
$price = $pricing['net_unit_minor'];
|
||||
$amount = BigDecimal::of($line->quantity)->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$vat = BigDecimal::of($amount)->multipliedBy($line->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
$subtotal += $amount;
|
||||
@@ -99,19 +125,20 @@ class QuotationController extends Controller
|
||||
if ($subtotal + $tax > 100000000000000) {
|
||||
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
|
||||
}
|
||||
$items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
|
||||
$items[] = ['tenant_id' => $actor->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
|
||||
}
|
||||
if ($subtotal <= 0) {
|
||||
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
|
||||
}
|
||||
$quote = Quotation::query()->create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
|
||||
'tenant_id' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $actor->id,
|
||||
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
|
||||
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
|
||||
'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10],
|
||||
'weights' => app(ComparisonPolicy::class)->forRequest($requisition)['weights'],
|
||||
'commercial_terms' => array_filter($terms, fn ($term) => $term['discount_type'] !== 'none') === [] ? null : ['version' => 1, 'discount_basis' => 'unit_before_vat', 'rounding' => 'net_unit_half_up_cent', 'lines' => $terms],
|
||||
]);
|
||||
$quote->items()->createMany($items);
|
||||
$workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
|
||||
$workflow->audit($actor, 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
|
||||
});
|
||||
|
||||
return back();
|
||||
@@ -119,28 +146,18 @@ class QuotationController extends Controller
|
||||
|
||||
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
$actor = $this->access($request);
|
||||
abort_unless($actor->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']);
|
||||
DB::transaction(function () use ($request, $quotation, $data, $workflow) {
|
||||
DB::transaction(function () use ($actor, $quotation, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
|
||||
abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
$guard->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$requisition = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($quotation->requisition_id);
|
||||
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
|
||||
$guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
|
||||
if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
|
||||
}
|
||||
if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
|
||||
}
|
||||
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
|
||||
$workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
|
||||
app(QuotationParticipation::class)->assertEvaluate($actor, $requisition, $quotation);
|
||||
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $actor->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $actor->id]);
|
||||
$workflow->audit($actor, 'quotation.evaluated', $requisition, null, $evaluation->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\ReceiptDocument;
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptDocuments;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ReceiptDocumentController extends Controller
|
||||
{
|
||||
public function store(Request $request, GoodsReceipt $receipt, ReceiptDocuments $documents): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
Gate::authorize('view', $receipt);
|
||||
abort_unless($documents->canRead($actor, $receipt), 403);
|
||||
abort_unless($documents->canAdd($actor, $receipt), 403);
|
||||
$file = $request->file('file');
|
||||
abort_unless($file instanceof UploadedFile, 422);
|
||||
$documents->add($actor, $receipt, $request->only(['operation_key', 'document_type']), $file);
|
||||
|
||||
return redirect('/recepcoes/'.$receipt->id.'#receipt-documents');
|
||||
}
|
||||
|
||||
public function download(Request $request, GoodsReceipt $receipt, int $document, ReceiptDocuments $documents): StreamedResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $documents->canRead($actor, $receipt), 403);
|
||||
Gate::authorize('view', $receipt);
|
||||
abort_unless($documents->ready(), 503);
|
||||
$file = ReceiptDocument::query()->where('goods_receipt_id', $receipt->id)->findOrFail($document);
|
||||
$disk = Storage::disk('receipt_documents');
|
||||
abort_unless($disk->exists($file->path), 404);
|
||||
$stream = $disk->readStream($file->path);
|
||||
abort_unless(is_resource($stream), 404);
|
||||
$hash = hash_init('sha256');
|
||||
hash_update_stream($hash, $stream);
|
||||
if (! hash_equals($file->sha256, hash_final($hash))) {
|
||||
fclose($stream);
|
||||
abort(409, 'Documento indisponível para descarga. Solicite conferência ao responsável.');
|
||||
}
|
||||
rewind($stream);
|
||||
|
||||
return response()->streamDownload(function () use ($stream) {
|
||||
try {
|
||||
fpassthru($stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
}, $file->original_name, ['Content-Type' => 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'private, no-store', 'Content-Security-Policy' => "default-src 'none'; sandbox"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionAuditTrail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class RequisitionAuditController extends Controller
|
||||
{
|
||||
public function csv(Request $request, int $requisition, RequisitionAuditTrail $trail): StreamedResponse
|
||||
{
|
||||
$this->visible($request, $requisition);
|
||||
|
||||
return response()->streamDownload(function () use ($request, $requisition, $trail) {
|
||||
DB::transaction(function () use ($request, $requisition, $trail) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$source = $this->visible($request, $requisition);
|
||||
$out = fopen('php://output', 'w');
|
||||
if ($out === false) {
|
||||
throw new \RuntimeException('Não foi possível preparar a exportação.');
|
||||
}
|
||||
$text = static function (mixed $value): string {
|
||||
$value = (string) ($value ?? '');
|
||||
|
||||
return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value;
|
||||
};
|
||||
try {
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['Metadados do histórico da requisição', $text($source->number), 'Conferido em', now()->toIso8601String()], ';', '"', '');
|
||||
fputcsv($out, ['Registo', 'Data registada', 'Acção', 'Utilizador (id)', 'Modo de execução'], ';', '"', '');
|
||||
foreach ($trail->query($source)->lazyById(100) as $record) {
|
||||
$row = $trail->row($record);
|
||||
fputcsv($out, array_map($text, [$row['id'], $row['created_at'], $row['action'], $row['user_id'], $row['execution']]), ';', '"', '');
|
||||
}
|
||||
} finally {
|
||||
fclose($out);
|
||||
}
|
||||
});
|
||||
}, 'historico-requisicao-'.$requisition.'-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
|
||||
private function visible(Request $request, int $id): Requisition
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
|
||||
$access = app(OrganizationAccess::class);
|
||||
abort_unless(! $access->maintenanceOnly($actor) && $access->permissions($actor) !== [], 403);
|
||||
|
||||
return Requisition::query()->visibleTo($actor)->findOrFail($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RequisitionCatalogueController extends Controller
|
||||
{
|
||||
public function index(Request $request, RequisitionCatalogue $catalogue): Response
|
||||
{
|
||||
abort_unless($request->user()->hasFunctions(['admin', 'warehouse']), 403);
|
||||
$filter = $request->validate(['search' => 'nullable|string|max:100']);
|
||||
$ready = Schema::hasTable('requisition_catalogue');
|
||||
$query = StockItem::query()->where('tenant_id', $request->user()->tenant_id);
|
||||
if (($filter['search'] ?? '') !== '') {
|
||||
$query->where(fn ($q) => $q->whereLike('name', '%'.$filter['search'].'%')->orWhereLike('code', '%'.$filter['search'].'%'));
|
||||
}
|
||||
$records = $query->orderBy('code')->paginate(15)->withQueryString();
|
||||
$entries = $ready ? DB::table('requisition_catalogue')->where('tenant_id', $request->user()->tenant_id)->whereIn('stock_item_id', $records->pluck('id'))->get()->keyBy('stock_item_id') : collect();
|
||||
$records->through(function (StockItem $stock) use ($entries, $catalogue) {
|
||||
$entry = $entries->get($stock->id);
|
||||
|
||||
return [...$catalogue->source($stock), 'stock_basis' => $catalogue->basis($stock), 'version' => $entry->version ?? 0,
|
||||
'category' => $entry->category ?? '', 'purchase_type' => $entry->purchase_type ?? 'consumable', 'published' => (bool) ($entry->published ?? false),
|
||||
'source_current' => $entry === null || json_decode($entry->source_snapshot, true, 16, JSON_THROW_ON_ERROR) === $catalogue->source($stock)];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/catalogue', ['records' => $records, 'ready' => $ready, 'canManage' => $catalogue->canManage($request->user()), 'filters' => $filter])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, int $stock, RequisitionCatalogue $catalogue): Response
|
||||
{
|
||||
abort_unless(Schema::hasTable('requisition_catalogue'), 503, 'Instalação do catálogo pendente.');
|
||||
$catalogue->save($request->user(), $stock, $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreRequisitionRequest;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
@@ -12,29 +11,48 @@ use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\TechnicalReview;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ApprovalRouting;
|
||||
use App\Services\ComparisonPolicy;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\EquipmentWorkflow;
|
||||
use App\Services\InternalSupplyTrace;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionAuditTrail;
|
||||
use App\Services\RequisitionCatalogue;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplySuggestions;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RequisitionController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]);
|
||||
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
|
||||
$query = Requisition::query()->visibleTo($this->actor($request))->with(['requester:id,name', 'department:id,name', 'steps']);
|
||||
if (($filters['view'] ?? 'all') === 'mine') {
|
||||
$query->where('requester_id', $request->user()->id);
|
||||
$query->where('requester_id', $this->actor($request)->id);
|
||||
} elseif (($filters['view'] ?? 'all') === 'approvals') {
|
||||
$eligible = (clone $query)->where('status', 'pending')->get()
|
||||
->filter(fn (Requisition $requisition) => $requisition->canDecide($request->user()))->modelKeys();
|
||||
->filter(fn (Requisition $requisition) => $requisition->canDecide($this->actor($request)))->modelKeys();
|
||||
$query->whereIn('id', $eligible);
|
||||
} elseif (($filters['view'] ?? 'all') === 'decided') {
|
||||
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $request->user()->id)->whereIn('decision', ['approved', 'rejected']));
|
||||
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $this->actor($request)->id)->whereIn('decision', ['approved', 'rejected']));
|
||||
}
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where('number', 'like', '%'.$filters['search'].'%');
|
||||
@@ -51,33 +69,59 @@ class RequisitionController extends Controller
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
|
||||
|
||||
return Inertia::render('requisitions/form', [
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
|
||||
'requisition' => null,
|
||||
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
|
||||
...$this->equipmentOptions($request),
|
||||
'catalogue' => $this->catalogueOptions($request),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$requisition = $workflow->save($request->user(), $request->validated());
|
||||
$requisition = $workflow->save($this->actor($request), $request->validated());
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
return DB::transaction(function () use ($request, $requisition) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $this->detail($request, $requisition);
|
||||
});
|
||||
}
|
||||
|
||||
private function detail(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
$requisition = Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
|
||||
Inertia::encryptHistory();
|
||||
$request->validate(['audit_page' => 'nullable|integer|min:1|max:1000000']);
|
||||
$trail = app(RequisitionAuditTrail::class);
|
||||
$audit = $trail->query($requisition)->orderBy('id')->paginate(25, ['*'], 'audit_page')->withQueryString();
|
||||
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
|
||||
|
||||
return Inertia::render('requisitions/show', [
|
||||
'requisition' => $requisition,
|
||||
'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
|
||||
'canDecide' => $requisition->canDecide($request->user()),
|
||||
'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']),
|
||||
'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $this->actor($request)),
|
||||
'canRefreshRouting' => $requisition->status === 'pending' && $this->actor($request)->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($this->actor($request)) && Schema::hasTable('workflow_notices'),
|
||||
'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition),
|
||||
'automaticSource' => app(InternalSupplyTrace::class)->purchaseOrigin($this->actor($request), $requisition),
|
||||
'comparisonPolicy' => app(ComparisonPolicy::class)->forRequest($requisition),
|
||||
'canEdit' => app(OrganizationAccess::class)->canRequest($this->actor($request)) && $requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft',
|
||||
'canDecide' => $requisition->canDecide($this->actor($request)),
|
||||
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($this->actor($request), $requisition),
|
||||
'audit' => $audit->getCollection()->map(fn ($a) => $trail->row($a)),
|
||||
'auditPagination' => ['total' => $audit->total(), 'current_page' => $audit->currentPage(), 'last_page' => $audit->lastPage(),
|
||||
'prev_page_url' => $audit->previousPageUrl(), 'next_page_url' => $audit->nextPageUrl()],
|
||||
'technical' => [
|
||||
'canReview' => app(EquipmentWorkflow::class)->canReview($request->user(), $requisition),
|
||||
'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition),
|
||||
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
|
||||
'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(),
|
||||
],
|
||||
@@ -86,42 +130,54 @@ class RequisitionController extends Controller
|
||||
|
||||
public function edit(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
|
||||
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
|
||||
abort_unless($requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft', 403);
|
||||
|
||||
return Inertia::render('requisitions/form', [
|
||||
'requisition' => $requisition->load('items'),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
|
||||
...$this->equipmentOptions($request),
|
||||
'catalogue' => $this->catalogueOptions($request),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function equipmentOptions(Request $request): array
|
||||
{
|
||||
$equipment = Equipment::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(['id', 'code', 'description']);
|
||||
$equipment = Equipment::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(['id', 'code', 'description']);
|
||||
$parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']);
|
||||
|
||||
return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function catalogueOptions(Request $request): array
|
||||
{
|
||||
$search = $request->validate(['catalogo' => 'nullable|string|max:100'])['catalogo'] ?? '';
|
||||
|
||||
return app(RequisitionCatalogue::class)->options($this->actor($request), $search);
|
||||
}
|
||||
|
||||
public function technical(Request $request, Requisition $requisition): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']);
|
||||
app(EquipmentWorkflow::class)->review($request->user(), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
|
||||
app(EquipmentWorkflow::class)->review($this->actor($request), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->save($request->user(), $request->validated(), $requisition);
|
||||
$workflow->save($this->actor($request), $request->validated(), $requisition);
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->submit($request->user(), $requisition);
|
||||
$command = $request->validate(['operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1']);
|
||||
$workflow->submit($this->actor($request), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
|
||||
|
||||
return back();
|
||||
}
|
||||
@@ -131,28 +187,35 @@ class RequisitionController extends Controller
|
||||
$data = $request->validate([
|
||||
'decision' => ['required', Rule::in(['approved', 'rejected'])],
|
||||
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
|
||||
'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1',
|
||||
]);
|
||||
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null);
|
||||
$workflow->decide($this->actor($request), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$visible = Requisition::query()->visibleTo($request->user());
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$contexts = app(WorkContext::class);
|
||||
if ($contexts->enforced($actor)) {
|
||||
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
|
||||
}
|
||||
$visible = Requisition::query()->visibleTo($this->actor($request));
|
||||
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->tenant_id) : null,
|
||||
'company' => $this->actor($request)->tenant_id ? Tenant::query()->find($this->actor($request)->tenant_id) : null,
|
||||
'stats' => [
|
||||
'total' => (clone $visible)->count(),
|
||||
'pending' => $pending->count(),
|
||||
'approved' => (clone $visible)->where('status', 'approved')->count(),
|
||||
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
|
||||
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->count(),
|
||||
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->count(),
|
||||
],
|
||||
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
|
||||
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
|
||||
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->take(5)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ namespace App\Http\Controllers\Settings;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Middleware\RequirePassword;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
@@ -17,11 +21,34 @@ class SecurityController extends Controller
|
||||
*/
|
||||
public function edit(TwoFactorAuthenticationRequest $request): Response
|
||||
{
|
||||
if (Features::enabled(Features::twoFactorAuthentication())) {
|
||||
return app(RequirePassword::class)->handle($request, fn () => $this->page($request));
|
||||
}
|
||||
|
||||
return $this->page($request);
|
||||
}
|
||||
|
||||
private function page(TwoFactorAuthenticationRequest $request): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$props = [
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
];
|
||||
if (Features::enabled(Features::twoFactorAuthentication())) {
|
||||
$props += [
|
||||
'canManageTwoFactor' => true,
|
||||
'twoFactorEnabled' => $actor->hasEnabledTwoFactorAuthentication(),
|
||||
'twoFactorPending' => $actor->two_factor_secret !== null && ! $actor->hasEnabledTwoFactorAuthentication(),
|
||||
'requiresConfirmation' => Fortify::confirmsTwoFactorAuthentication(),
|
||||
];
|
||||
}
|
||||
|
||||
return Inertia::render('settings/security', $props);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('settings/security', $props)->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\SgfInvoiceDispatches;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SgfInvoiceDispatchController extends Controller
|
||||
{
|
||||
private function actor(Request $r,SgfInvoiceDispatches $service):User
|
||||
{ $user=$r->user('web');abort_unless($user instanceof User,403);return $service->actor($user); }
|
||||
public function index(Request $r,SgfInvoiceDispatches $service)
|
||||
{
|
||||
$user=$this->actor($r,$service);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','phase'=>'nullable|in:draft,previewing,previewed,sending,uncertain,received_pending,blocked,review_required,not_sent','connection'=>'nullable|string|max:64','mapping'=>'nullable|string|max:64']);
|
||||
abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
$rows=DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->when($d['phase']??null,fn($q,$v)=>$q->where('phase',$v))
|
||||
->orderByDesc('id')->paginate(25,['id','reference','number','phase','version','updated_at'])->withQueryString();
|
||||
$mapping=null;foreach($service->mappings($user)as$m)if($m['connection']===($d['connection']??null)&&$m['id']===($d['mapping']??null))$mapping=$m;
|
||||
return response()->view('sgf-invoices.index',['rows'=>$rows,'options'=>$service->mappings($user),'selection'=>$mapping])->header('Cache-Control','private, no-store');
|
||||
}
|
||||
public function show(int $id,Request $r,SgfInvoiceDispatches $service)
|
||||
{
|
||||
$user=$this->actor($r,$service);$d=$r->validate(['events_page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
|
||||
$row=$service->row($user,$id);$events=DB::table('sgf_invoice_dispatch_events')->where('tenant_id',$user->tenant_id)->where('dispatch_id',$id)->orderByDesc('id')
|
||||
->paginate(25,['actor_id','action','reason','before_state','after_state','created_at'],'events_page')->withQueryString();
|
||||
return response()->view('sgf-invoices.show',['row'=>$service->presentation($user,$row),'events'=>$events,'options'=>$service->mappings($user),'transportEnabled'=>config('sgf_invoice.enabled')===true])->header('Cache-Control','private, no-store');
|
||||
}
|
||||
public function create(Request $r,SgfInvoiceDispatches $service)
|
||||
{
|
||||
$user=$this->actor($r,$service);$input=$r->except('_token');$id=$service->create($user,$input);
|
||||
return redirect('/facturas/envio-sgf/'.$id);
|
||||
}
|
||||
public function revise(int $id,Request $r,SgfInvoiceDispatches $service)
|
||||
{
|
||||
$user=$this->actor($r,$service);$d=$r->validate(['version'=>'required|integer|min:1']);
|
||||
$service->revise($user,$id,(int)$d['version'],$r->except('_token','version'));
|
||||
return redirect('/facturas/envio-sgf/'.$id);
|
||||
}
|
||||
public function dispatch(int $id,Request $r,SgfInvoiceDispatches $service)
|
||||
{
|
||||
$user=$this->actor($r,$service);$d=$r->validate(['version'=>'required|integer|min:1','action'=>'required|in:preview,confirm,recover','reason'=>'required|string|min:10|max:2000','approval_hash'=>'nullable|string|size:64']);
|
||||
abort_if(array_diff(array_keys($r->except('_token')),array_keys($d)),422);
|
||||
$service->dispatch($user,$id,(int)$d['version'],$d['action'],$d['reason'],$d['approval_hash']??null);
|
||||
return redirect('/facturas/envio-sgf/'.$id);
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,39 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\CurrentActor;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class StockController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function access(Request $request, bool $manage = false): void
|
||||
{
|
||||
$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($this->actor($request)->hasFunctions($roles), 403);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -29,27 +45,45 @@ class StockController extends Controller
|
||||
$this->access($request);
|
||||
$filters = $request->validate([
|
||||
'search' => 'nullable|string|max:100',
|
||||
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20',
|
||||
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $this->actor($request)->tenant_id)],
|
||||
]);
|
||||
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
|
||||
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
|
||||
$geography = app(AdministrativeGeography::class);
|
||||
$warehouseQuery = Warehouse::query();
|
||||
if (! empty($filters['location'])) {
|
||||
abort_unless($geography->availableFor('warehouses'), 503);
|
||||
$warehouseQuery->where('geography_reference->catalogue', config('geography.source_sha256'))->whereIn('geography_reference->record_id', $geography->descendants($filters['location']));
|
||||
}
|
||||
$columns = ['id', 'code', 'name'];
|
||||
if (Schema::hasColumn('warehouses', 'geography_reference')) {
|
||||
$columns[] = 'geography_reference';
|
||||
}
|
||||
$warehouses = $warehouseQuery->orderBy('code')->get($columns);
|
||||
$warehouseGeography = $warehouses->mapWithKeys(fn (Warehouse $warehouse) => [$warehouse->id => $geography->describe($warehouse->geography_reference)])->all();
|
||||
$selected = StockItem::query();
|
||||
if (! empty($filters['search'])) {
|
||||
$selected->where(fn ($q) => $q->where('code', 'like', '%'.$filters['search'].'%')->orWhere('name', 'like', '%'.$filters['search'].'%'));
|
||||
}
|
||||
$movements = StockMovement::query()->whereIn('stock_item_id', $selected->select('id'));
|
||||
if (! empty($filters['location'])) {
|
||||
$movements->whereIn('warehouse_id', $warehouses->pluck('id'));
|
||||
}
|
||||
if (! empty($filters['warehouse_id'])) {
|
||||
$movements->where('warehouse_id', $filters['warehouse_id']);
|
||||
}
|
||||
$balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [
|
||||
'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id,
|
||||
'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(),
|
||||
'lot_control' => app(InventoryLots::class)->summary($balance->stock_item_id, $balance->warehouse_id),
|
||||
]);
|
||||
|
||||
return Inertia::render('stock/index', [
|
||||
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
|
||||
'geographyEnabled' => $geography->availableFor('warehouses'), 'warehouseGeography' => $warehouseGeography,
|
||||
'geographyFilter' => empty($filters['location']) ? null : $geography->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']),
|
||||
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
|
||||
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse]),
|
||||
'canManage' => $this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -58,12 +92,12 @@ class StockController extends Controller
|
||||
$this->access($request, true);
|
||||
$request->merge(['code' => Str::upper(trim($request->string('code')->toString())), 'unit' => Str::lower(trim($request->string('unit')->toString()))]);
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $this->actor($request)->tenant_id)],
|
||||
'name' => 'required|string|max:200', 'unit' => 'required|string|max:30',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $audit, $data) {
|
||||
$item = StockItem::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]);
|
||||
$audit->audit($request->user(), 'stock.item_created', null, null, $item->toArray());
|
||||
$item = StockItem::query()->create([...$data, 'tenant_id' => $this->actor($request)->tenant_id, 'created_by' => $this->actor($request)->id]);
|
||||
$audit->audit($this->actor($request), 'stock.item_created', null, null, $item->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
@@ -74,12 +108,44 @@ class StockController extends Controller
|
||||
$this->access($request, true);
|
||||
$request->merge(['code' => Str::upper(trim($request->string('code')->toString()))]);
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $this->actor($request)->tenant_id)],
|
||||
'name' => 'required|string|max:200',
|
||||
...app(AdministrativeGeography::class)->referenceRules('warehouses'),
|
||||
]);
|
||||
DB::transaction(function () use ($request, $audit, $data) {
|
||||
$warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]);
|
||||
$audit->audit($request->user(), 'stock.warehouse_created', null, null, $warehouse->toArray());
|
||||
app(ConflictGuard::class)->lockCompany($this->actor($request));
|
||||
$actor = app(CurrentActor::class)->lock($this->actor($request), [PurchasingRole::Admin, PurchasingRole::Warehouse]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
if (array_key_exists('geography_reference', $data)) {
|
||||
$data['geography_reference'] = app(AdministrativeGeography::class)->canonicalFor('warehouses', $data['geography_reference']);
|
||||
}
|
||||
$warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $this->actor($request)->tenant_id, 'created_by' => $this->actor($request)->id]);
|
||||
$audit->audit($this->actor($request), 'stock.warehouse_created', null, null, $warehouse->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function warehouseGeography(Request $request, int $warehouse, RequisitionWorkflow $audit): RedirectResponse
|
||||
{
|
||||
$this->access($request, true);
|
||||
$geography = app(AdministrativeGeography::class);
|
||||
abort_unless($geography->availableFor('warehouses'), 503);
|
||||
$data = $request->validate([...$geography->referenceRules('warehouses'), 'geography_reference' => 'present|nullable|array:country,catalogue,record_id', 'expected_reference' => 'present|nullable|array:country,catalogue,record_id']);
|
||||
DB::transaction(function () use ($request, $warehouse, $audit, $data, $geography) {
|
||||
app(ConflictGuard::class)->lockCompany($this->actor($request));
|
||||
$actor = app(CurrentActor::class)->lock($this->actor($request), [PurchasingRole::Admin, PurchasingRole::Warehouse]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$record = Warehouse::query()->lockForUpdate()->findOrFail($warehouse);
|
||||
if ($record->geography_reference != $data['expected_reference']) {
|
||||
throw ValidationException::withMessages(['geography_reference' => 'A localização mudou. Actualize antes de guardar.']);
|
||||
}
|
||||
$before = $record->toArray();
|
||||
$after = $geography->canonicalFor('warehouses', $data['geography_reference']);
|
||||
if ($record->geography_reference != $after) {
|
||||
$record->update(['geography_reference' => $after]);
|
||||
$audit->audit($actor, 'stock.warehouse_geography_updated', null, $before, $record->toArray());
|
||||
}
|
||||
});
|
||||
|
||||
return back();
|
||||
|
||||
@@ -8,6 +8,9 @@ use App\Models\StockItem;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryCounts;
|
||||
use App\Services\InventoryLots;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\StockWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -15,6 +18,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -22,31 +26,48 @@ class StockOperationController extends Controller
|
||||
{
|
||||
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);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, 'inventory_adjust_review']), 403);
|
||||
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
|
||||
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
|
||||
$balances = [];
|
||||
$lotCounts = [];
|
||||
foreach ($items as $item) {
|
||||
foreach ($warehouses as $warehouse) {
|
||||
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)];
|
||||
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id),
|
||||
'lot_control' => app(InventoryLots::class)->summary($item->id, $warehouse->id)];
|
||||
if ($actor->hasFunctions([PurchasingRole::Warehouse]) && app(InventoryLots::class)->setup($item->id, $warehouse->id) !== null) {
|
||||
try {
|
||||
$source = app(InventoryCounts::class)->source($item->id, $warehouse->id);
|
||||
$lotCounts[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$source];
|
||||
} catch (ValidationException) {
|
||||
// The summary still reports the divergence; no count can use an invented baseline.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('stock/operations', [
|
||||
'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && ! app(\App\Services\OrganizationAccess::class)->maintenanceOnly($actor),
|
||||
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->map(fn ($r) => $r->data['reference']),
|
||||
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
|
||||
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
|
||||
'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
|
||||
'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(),
|
||||
'canOperate' => $request->user()->purchasing_role === PurchasingRole::Warehouse,
|
||||
'canOperate' => $actor->hasFunctions([PurchasingRole::Warehouse]),
|
||||
'canAdjust' => app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 'lotCounts' => $lotCounts,
|
||||
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Warehouse, 403);
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
abort_unless($actor->hasFunctions($request->input('kind') === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [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())]);
|
||||
$tenant = $request->user()->tenant_id;
|
||||
$tenant = $actor->tenant_id;
|
||||
$data = $request->validate([
|
||||
'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])],
|
||||
'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000',
|
||||
@@ -59,10 +80,31 @@ class StockOperationController extends Controller
|
||||
'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'],
|
||||
'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0',
|
||||
'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')],
|
||||
'lot_version' => 'exclude_unless:kind,count|nullable|string|size:64',
|
||||
'lot_counts' => 'exclude_unless:kind,count|sometimes|array|max:500',
|
||||
'lot_counts.*.lot_id' => 'nullable|integer|min:1', 'lot_counts.*.new_key' => 'nullable|uuid',
|
||||
'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
|
||||
'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean',
|
||||
]);
|
||||
if (empty($data['equipment_id'])) {
|
||||
unset($data['equipment_id']);
|
||||
}
|
||||
if (empty($data['lot_version'])) {
|
||||
unset($data['lot_version']);
|
||||
}
|
||||
if (empty($data['lot_counts']) && empty($data['lot_version'])) {
|
||||
unset($data['lot_counts']);
|
||||
}
|
||||
if (isset($data['lot_counts'])) {
|
||||
$data['lot_counts'] = array_map(function (array $row) {
|
||||
if (! empty($row['lot_id'])) {
|
||||
$row['lot_id'] = (int) $row['lot_id'];
|
||||
}
|
||||
$row['quantity'] = (string) BigDecimal::of($row['quantity'])->toScale(3);
|
||||
|
||||
return $row;
|
||||
}, $data['lot_counts']);
|
||||
}
|
||||
foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) {
|
||||
if (isset($data[$key])) {
|
||||
$data[$key] = (int) $data[$key];
|
||||
@@ -70,7 +112,7 @@ class StockOperationController extends Controller
|
||||
}
|
||||
$data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000';
|
||||
ksort($data);
|
||||
$operation = $workflow->record($request->user(), $data);
|
||||
$operation = $workflow->record($actor, $data);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\OrganizationConfiguration;
|
||||
use App\Services\SubmissionForms;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SubmissionFormsController extends Controller
|
||||
{
|
||||
public function index(Request $request, SubmissionForms $forms, OrganizationConfiguration $configuration): Response
|
||||
{
|
||||
$actor = $configuration->administrator($request->user());
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('company/submission-forms', ['forms' => $forms->listing($actor), 'canManage' => $forms->canManage($actor),
|
||||
'ready' => Schema::hasTable('submission_forms'), 'externalPortalAvailable' => false])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function save(Request $request, SubmissionForms $forms): Response
|
||||
{
|
||||
$forms->save($request->user(), $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ class SupplierBankController extends Controller
|
||||
{
|
||||
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();
|
||||
$current = $details->first();
|
||||
$canRead = in_array($request->user()->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]);
|
||||
$canRead = $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]);
|
||||
$data = null;
|
||||
if ($current && $canRead) {
|
||||
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();
|
||||
$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');
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -5,7 +5,13 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Http\Requests\SaveSupplierRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\CurrentActor;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierGeography;
|
||||
use App\Services\SupplierParticipation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -14,51 +20,81 @@ use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as HttpResponse;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
private function actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
private function currentActor(Request $request, PurchasingRole $role): User
|
||||
{
|
||||
$actor = $this->actor($request);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
|
||||
return app(CurrentActor::class)->lock($actor, [$role]);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100']);
|
||||
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100', 'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20']);
|
||||
$query = Supplier::query();
|
||||
if (! empty($filters['location'])) {
|
||||
$query = app(SupplierGeography::class)->matching($filters['location']);
|
||||
}
|
||||
if (! empty($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, 'geographyFilter' => empty($filters['location']) ? null : app(AdministrativeGeography::class)->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']), 'geographyEnabled' => app(AdministrativeGeography::class)->enabled(), 'canCreate' => app(SupplierParticipation::class)->canPrepare($this->actor($request)), 'canReview' => app(SupplierParticipation::class)->canReviewRole($this->actor($request))]);
|
||||
}
|
||||
|
||||
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'last_changed_by' => $request->user()->id, 'status' => 'pending']);
|
||||
$workflow->audit($request->user(), 'supplier.created', null, null, $supplier->toArray());
|
||||
$actor = $this->currentActor($request, PurchasingRole::Procurement);
|
||||
abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403);
|
||||
if (array_key_exists('geography_reference', $data)) {
|
||||
$data['geography_reference'] = app(AdministrativeGeography::class)->canonical($data['geography_reference']);
|
||||
}
|
||||
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'last_changed_by' => $actor->id, 'status' => 'pending']);
|
||||
$workflow->audit($actor, 'supplier.created', null, null, $supplier->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function show(Request $request, Supplier $supplier): Response
|
||||
public function show(Request $request, Supplier $supplier): HttpResponse
|
||||
{
|
||||
Gate::authorize('view', $supplier);
|
||||
$canDocuments = $request->user()->can('viewDocuments', $supplier);
|
||||
$canDocuments = $this->actor($request)->can('viewDocuments', $supplier);
|
||||
|
||||
return Inertia::render('suppliers/show', [
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('suppliers/show', [
|
||||
'supplier' => $supplier,
|
||||
'geography' => app(AdministrativeGeography::class)->describe($supplier->geography_reference),
|
||||
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
|
||||
'canEdit' => $request->user()->can('update', $supplier),
|
||||
'canEdit' => $this->actor($request)->can('update', $supplier),
|
||||
'canDocuments' => $canDocuments,
|
||||
'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance,
|
||||
]);
|
||||
'canReview' => app(SupplierParticipation::class)->canReview($this->actor($request), $supplier),
|
||||
])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function edit(Supplier $supplier): Response
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
|
||||
return Inertia::render('suppliers/edit', ['supplier' => $supplier]);
|
||||
return Inertia::render('suppliers/edit', ['supplier' => $supplier, 'geographyEnabled' => app(AdministrativeGeography::class)->enabled(), 'geography' => app(AdministrativeGeography::class)->describe($supplier->geography_reference)]);
|
||||
}
|
||||
|
||||
public function update(SaveSupplierRequest $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
@@ -66,6 +102,11 @@ class SupplierController extends Controller
|
||||
Gate::authorize('update', $supplier);
|
||||
$data = $request->validated();
|
||||
DB::transaction(function () use ($request, $supplier, $workflow, $data) {
|
||||
$actor = $this->currentActor($request, PurchasingRole::Procurement);
|
||||
abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403);
|
||||
if (array_key_exists('geography_reference', $data)) {
|
||||
$data['geography_reference'] = app(AdministrativeGeography::class)->canonical($data['geography_reference']);
|
||||
}
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
if ($supplier->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']);
|
||||
@@ -74,8 +115,8 @@ class SupplierController extends Controller
|
||||
unset($data['version']);
|
||||
$supplier->fill($data);
|
||||
if ($supplier->isDirty()) {
|
||||
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1])->save();
|
||||
$workflow->audit($request->user(), 'supplier.updated', null, $before, $supplier->toArray());
|
||||
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $actor->id, 'version' => $supplier->version + 1])->save();
|
||||
$workflow->audit($actor, 'supplier.updated', null, $before, $supplier->toArray());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -84,12 +125,12 @@ class SupplierController extends Controller
|
||||
|
||||
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
|
||||
abort_unless($this->actor($request)->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']);
|
||||
DB::transaction(function () use ($request, $supplier, $data, $workflow) {
|
||||
$actor = $this->currentActor($request, PurchasingRole::Finance);
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
abort_if($supplier->created_by === $request->user()->id, 403);
|
||||
abort_if($supplier->last_changed_by === $request->user()->id, 403);
|
||||
abort_unless(app(SupplierParticipation::class)->canReview($actor, $supplier), 403);
|
||||
if ($supplier->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A ficha ou os documentos foram alterados. Reabra a ficha antes de validar.']);
|
||||
}
|
||||
@@ -97,8 +138,8 @@ class SupplierController extends Controller
|
||||
throw ValidationException::withMessages(['status' => 'Fornecedor impedido ou com revisão vencida. Não pode ser activado.']);
|
||||
}
|
||||
$before = $supplier->toArray();
|
||||
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
|
||||
$workflow->audit($request->user(), 'supplier.reviewed', null, $before, $supplier->toArray());
|
||||
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $actor->id, 'version' => $supplier->version + 1]);
|
||||
$workflow->audit($actor, 'supplier.reviewed', null, $before, $supplier->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?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 actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
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($this->actor($request));
|
||||
$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, 'canCreate' => $ready && $workflow->canPrepare($actor), '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($this->actor($request));
|
||||
$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 && $workflow->canAct($actor, $evaluation, 'edit'),
|
||||
'canReview' => ! $report && $workflow->canAct($actor, $evaluation, 'review'),
|
||||
'canRevise' => ! $report && $workflow->canAct($actor, $evaluation, 'revise') && $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 && app(SupplierResponseWorkflow::class)->canAct($actor, $evaluation, $r, 'edit'),
|
||||
'canReview' => ! $report && app(SupplierResponseWorkflow::class)->canAct($actor, $evaluation, $r, 'review')])],
|
||||
'improvements' => ['ready' => $measuresReady, 'canCreate' => ! $report && $measuresReady && $writable && $evaluation->status === 'approved',
|
||||
'today' => today()->toDateString(),
|
||||
'records' => $measures->map(fn (SupplierImprovement $m) => [...$improvementWorkflow->snapshot($m),
|
||||
'canPlan' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'plan'),
|
||||
'canExecute' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'execute'),
|
||||
'canReview' => ! $report && $improvementWorkflow->canAct($actor, $evaluation, $m, 'review')]),
|
||||
'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($this->actor($request), (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($this->actor($request), $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($this->actor($request), $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($this->actor($request), $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,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierImprovement;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\SupplierEvaluationWorkflow;
|
||||
use App\Services\SupplierGeography;
|
||||
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 actor(Request $request): User
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function territory(array $filters): ?array
|
||||
{
|
||||
return empty($filters['location']) ? null : app(AdministrativeGeography::class)->describe(['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location']]);
|
||||
}
|
||||
|
||||
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($this->actor($request));
|
||||
$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', 'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20']);
|
||||
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($this->actor($request));
|
||||
if (! empty($filters['location'])) {
|
||||
$suppliers = app(SupplierGeography::class)->matching($filters['location'])->select('id');
|
||||
$query->whereHas('evaluation', fn ($evaluation) => $evaluation->whereIn('supplier_id', $suppliers));
|
||||
}
|
||||
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', $this->actor($request)->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($this->actor($request));
|
||||
$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($this->actor($request))->whereIn('id', (clone $all)->select('supplier_evaluation_id'))->orderByDesc('id')->get(['supplier_id', 'supplier_snapshot']);
|
||||
|
||||
return ['filters' => $filters, 'geographyEnabled' => app(AdministrativeGeography::class)->enabled(), 'geographyFilter' => $this->territory($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', $this->actor($request)->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);
|
||||
if (! empty($filters['location'])) {
|
||||
app(SupplierGeography::class)->matching($filters['location']);
|
||||
}
|
||||
$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], ';', '"', '');
|
||||
$exportFilters = $filters;
|
||||
if (! empty($filters['location'])) {
|
||||
$territory = $this->territory($filters);
|
||||
$exportFilters['localizacao_actual_da_ficha'] = implode(' / ', array_column($territory['path'] ?? [], 'name'));
|
||||
}
|
||||
fputcsv($out, ['Filtros', $text(json_encode($exportFilters, 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']);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplierPayables;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -11,20 +12,42 @@ use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
private function actor(Request $r): User
|
||||
{
|
||||
$user = $r->user();
|
||||
abort_unless($user instanceof User, 403);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function filters(Request $r): array
|
||||
{
|
||||
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000',
|
||||
'proposal_page' => 'sometimes|integer|min:1|max:100000', 'event_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'per_page' => 'sometimes|integer|in:25,100', 'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($filter)) !== [], 422);
|
||||
|
||||
return $filter;
|
||||
}
|
||||
|
||||
public function index(Request $r, SupplierPayables $s): JsonResponse
|
||||
{
|
||||
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']);
|
||||
$filter = $this->filters($r);
|
||||
$options = array_diff_key($filter, ['invoice' => true]);
|
||||
|
||||
return response()->json($s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null))->header('Cache-Control', 'no-store');
|
||||
return response()->json($s->listing($this->actor($r), isset($filter['invoice']) ? (int) $filter['invoice'] : null, $options))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s): Response
|
||||
{
|
||||
$filter = $r->validate(['invoice' => 'sometimes|required|integer|min:1']);
|
||||
$data = $s->listing($r->user(), isset($filter['invoice']) ? (int) $filter['invoice'] : null);
|
||||
$company = Tenant::query()->findOrFail($r->user()->tenant_id)->name;
|
||||
$filter = $this->filters($r);
|
||||
$options = array_diff_key($filter, ['invoice' => true]);
|
||||
$actor = $this->actor($r);
|
||||
$data = $s->listing($actor, isset($filter['invoice']) ? (int) $filter['invoice'] : null, $options);
|
||||
$company = Tenant::query()->findOrFail($actor->tenant_id)->name;
|
||||
|
||||
return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $r->user()->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store');
|
||||
return response(Workspace::render($data, ['host' => 'Compras autónomo', 'actor' => $actor->name, 'organisation' => $company, 'api' => '/api/supplier-payables', 'page_url' => '/obrigacoes-fornecedores', 'csrf' => csrf_token(), 'return_url' => '/facturas', 'return_label' => 'Voltar às facturas']))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action, SupplierPayables $s): JsonResponse
|
||||
@@ -33,7 +56,7 @@ final class SupplierPayablesController extends Controller
|
||||
$d = $r->validate(['id' => 'required|integer|min:1', 'reason' => 'required|string|min:3|max:2000']);
|
||||
abort_if(array_diff(array_keys($r->all()), ['id', 'reason']) !== [], 422);
|
||||
try {
|
||||
return response()->json($s->engine($r->user())->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
|
||||
return response()->json($s->engine($this->actor($r))->command($action, $d['id'], $d['reason'], (string) $r->header('Idempotency-Key')));
|
||||
} catch (\DomainException $e) {
|
||||
abort(409, $e->getMessage());
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\GeneralLedger;
|
||||
use App\Services\SupplierPayables;
|
||||
use Illuminate\Http\Request;
|
||||
use Tempo\Finance\Payables\SettlementHttp;
|
||||
|
||||
final class SupplierPaymentsController extends SettlementHttp
|
||||
{
|
||||
protected const MODE = 'compras';
|
||||
|
||||
protected const SERVICE = SupplierPayables::class;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
protected function ledgerSettings(Request $request): array
|
||||
{
|
||||
if (config('general-ledger.cycle_enabled') !== true) {
|
||||
return [];
|
||||
}
|
||||
$user = $request->user();
|
||||
abort_unless($user instanceof User, 403);
|
||||
|
||||
return app(GeneralLedger::class)->can($user, 'view') ? ['ledger_cycle_url' => '/general-ledger/cycle'] : [];
|
||||
}
|
||||
}
|
||||
@@ -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,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyCoordinationRead;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplyCoordinationReportController extends Controller
|
||||
{
|
||||
public function csv(Request $request, SupplyCoordinationRead $read): StreamedResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$view = $request->validate(['view' => 'required|in:reservations,deliveries,alternatives'])['view'];
|
||||
$kind = $read->kind($actor, $view);
|
||||
$filters = $read->filters($request, $kind);
|
||||
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do fornecimento pendente.');
|
||||
// Validate the same focus as the page, without silently exporting other records.
|
||||
$focus = $request->validate(['record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)]]);
|
||||
|
||||
return response()->streamDownload(function () use ($read, $actor, $view, $kind, $filters, $focus) {
|
||||
DB::transaction(function () use ($read, $actor, $view, $kind, $filters, $focus) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
// Recheck access when the response is consumed.
|
||||
$read->kind($actor, $view);
|
||||
$query = $read->query($kind, isset($focus['record_id']) ? ['record_search' => '', 'record_state' => ''] : $filters);
|
||||
if (isset($focus['record_id'])) {
|
||||
$key = SupplyRecord::query()->whereKey($focus['record_id'])->value('record_key');
|
||||
$query->where('record_key', $key);
|
||||
}
|
||||
$out = fopen('php://output', 'w');
|
||||
if ($out === false) {
|
||||
throw new \RuntimeException('Não foi possível preparar a exportação.');
|
||||
}
|
||||
$text = static function (mixed $value): string {
|
||||
$value = (string) ($value ?? '');
|
||||
|
||||
return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value;
|
||||
};
|
||||
try {
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['Acompanhamento de fornecimentos', $view, 'Conferido em', now()->toIso8601String()], ';', '"', '');
|
||||
fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), 'Registo em foco', $focus['record_id'] ?? ''], ';', '"', '');
|
||||
fputcsv($out, ['Registo', 'Versão', 'Estado registado', 'Artigo', 'Designação preservada', 'Local preservado', 'Referência', 'Encomenda', 'Quantidade', 'Unidade', 'Data prevista/necessidade', 'Quantidade por satisfazer (ver estado)', 'Substituto', 'Aplicação', 'Validade', 'Processo'], ';', '"', '');
|
||||
foreach ($query->lazyById(100) as $record) {
|
||||
$pending = match ($kind) {
|
||||
'reservation' => app(SupplyCoordination::class)->remainingReservation($record),
|
||||
'delivery' => app(SupplyCoordination::class)->remainingDelivery($record),
|
||||
default => null,
|
||||
};
|
||||
fputcsv($out, array_map($text, [$record->record_key, $record->version, $record->state,
|
||||
$record->snapshot['item']['code'] ?? '', $record->snapshot['item']['name'] ?? '', $record->snapshot['warehouse']['name'] ?? '',
|
||||
$record->data['reference'] ?? '', $record->snapshot['order']['number'] ?? '',
|
||||
isset($record->data['quantity_milli']) ? $record->data['quantity_milli'] / 1000 : '', $record->snapshot['item']['unit'] ?? '',
|
||||
$record->data['needed_on'] ?? $record->data['expected_on'] ?? '', $pending === null ? '' : $pending / 1000,
|
||||
$record->snapshot['substitute']['code'] ?? '', $record->data['application'] ?? '', $record->data['valid_until'] ?? '',
|
||||
url('/compras/fornecimento?view='.$view.'&record_id='.$record->id).'#supply-record-'.$record->id]), ';', '"', '');
|
||||
}
|
||||
} finally {
|
||||
fclose($out);
|
||||
}
|
||||
});
|
||||
}, 'fornecimentos-'.$view.'-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseOrderItem;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\RequisitionItem;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryReversals;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\SupplyCoordination;
|
||||
use App\Services\SupplyCoordinationRead;
|
||||
use App\Services\SupplyHistory;
|
||||
use App\Services\SupplyPlanning;
|
||||
use App\Services\SupplySuggestions;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as HttpResponse;
|
||||
|
||||
class SupplyPlanningController extends Controller
|
||||
{
|
||||
public function index(Request $request, SupplyPlanning $planning): HttpResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $planning, $actor) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $this->page($request, $planning, $actor);
|
||||
});
|
||||
}
|
||||
|
||||
private function page(Request $request, SupplyPlanning $planning, User $actor): HttpResponse
|
||||
{
|
||||
Inertia::encryptHistory();
|
||||
$view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements';
|
||||
if ($view === 'history') {
|
||||
return $this->privateResponse($request, $this->history($request, $actor, $planning));
|
||||
}
|
||||
if ($view === 'suggestions') {
|
||||
return $this->privateResponse($request, $this->suggestions($request, $actor, $planning));
|
||||
}
|
||||
if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) {
|
||||
return $this->privateResponse($request, $this->coordination($request, $actor, $view, $planning));
|
||||
}
|
||||
$import = $view === 'imports';
|
||||
$capabilities = $import ? ['import_read', 'import_prepare', 'import_customs'] : ['supply_read', 'supply_prepare', 'supply_review'];
|
||||
abort_unless(collect($capabilities)->contains(fn ($p) => $planning->allows($actor, $p)), 403);
|
||||
$ready = Schema::hasTable('supply_records');
|
||||
$kind = $import ? 'import' : ($view === 'calendar' ? 'policy' : 'agreement');
|
||||
$focus = $this->focusedRecord($request, $actor, $kind);
|
||||
[$records, $page] = $this->recordPage($request, $planning, $ready, $kind, $focus);
|
||||
$projections = [];
|
||||
if ($view === 'calendar' && $ready) {
|
||||
foreach ($records->where('state', 'approved') as $record) {
|
||||
$projections[$record->record_key] = $planning->calendar($record, today()->toDateString());
|
||||
}
|
||||
}
|
||||
$prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare');
|
||||
$orderFilters = $import ? $request->validate([
|
||||
'order_search' => 'nullable|string|max:100',
|
||||
'selected_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
]) : [];
|
||||
$historyFilters = $kind === 'policy' ? $request->validate([
|
||||
'history_stock_item_id' => ['nullable', 'required_with:history_period_from,history_period_until,selected_history_key', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'history_warehouse_id' => ['nullable', 'required_with:history_stock_item_id', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'history_purpose' => 'nullable|required_with:history_stock_item_id|in:internal,resale',
|
||||
'history_period_from' => 'nullable|date_format:Y-m-d',
|
||||
'history_period_until' => ['nullable', 'date_format:Y-m-d', $request->filled('history_period_from') ? 'after_or_equal:history_period_from' : 'nullable'],
|
||||
'selected_history_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'history')],
|
||||
'history_form_key' => 'nullable|string|max:80',
|
||||
]) : [];
|
||||
|
||||
$agreementFilters = $kind === 'policy' ? $request->validate([
|
||||
'agreement_search' => 'nullable|string|max:100',
|
||||
'agreement_stock_item_id' => ['nullable', 'required_with:agreement_search,selected_agreement_key', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'agreement_warehouse_id' => ['nullable', 'required_with:agreement_stock_item_id', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'selected_agreement_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'agreement')],
|
||||
'agreement_form_key' => 'nullable|string|max:80',
|
||||
]) : [];
|
||||
|
||||
return $this->privateResponse($request, Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready,
|
||||
'agreementSearch' => $agreementFilters['agreement_search'] ?? '', 'agreementFormKey' => $agreementFilters['agreement_form_key'] ?? null,
|
||||
'recordPagination' => $this->pagination($page),
|
||||
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $planning->canReview($actor, $record), 'canReturn' => $planning->canReview($actor, $record, 'return'), 'canSuspend' => $planning->canReview($actor, $record, 'suspend')]), 'projections' => $projections,
|
||||
'asOf' => today()->toDateString(),
|
||||
'historyFormKey' => $historyFilters['history_form_key'] ?? null,
|
||||
'histories' => $ready && $prepare && $kind === 'policy' ? $this->approvedOptions($planning, 'history', $records->pluck('data.history_key')->push($historyFilters['selected_history_key'] ?? null), $historyFilters)->map(fn ($r) => [
|
||||
'key' => $r->record_key, 'version' => $r->version, 'stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'purpose' => $r->data['purpose'],
|
||||
'period_from' => $r->data['period_from'], 'period_until' => $r->data['period_until'], 'quality' => $r->data['quality'], 'pattern' => $r->data['pattern'], 'days' => $r->snapshot['analysis']['days'], 'daily_mean_milli' => $r->snapshot['analysis']['daily_mean_milli'],
|
||||
]) : [],
|
||||
'agreements' => $ready && $prepare && $kind === 'policy' ? $this->approvedOptions($planning, 'agreement', $records->pluck('data.agreement_key')->push($agreementFilters['selected_agreement_key'] ?? null), $agreementFilters)->map(fn ($r) => $r->only(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot'])) : [],
|
||||
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
|
||||
'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'), 'canCustoms' => $planning->allows($actor, 'import_customs'),
|
||||
'items' => $prepare && ! $import ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
|
||||
'warehouses' => $prepare && ! $import ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
|
||||
'suppliers' => $prepare && $kind === 'agreement' ? Supplier::query()->where('status', 'active')->orderBy('name')->get(['id', 'name']) : [],
|
||||
'orderSearch' => $orderFilters['order_search'] ?? '',
|
||||
'orders' => $prepare && $import ? $this->orderOptions($records->pluck('purchase_order_id')->push($orderFilters['selected_order_id'] ?? null), $orderFilters['order_search'] ?? '') : [],
|
||||
]));
|
||||
}
|
||||
|
||||
private function privateResponse(Request $request, Response $page): HttpResponse
|
||||
{
|
||||
$response = $page->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function focusedRecord(Request $request, User $actor, string $kind): ?SupplyRecord
|
||||
{
|
||||
$filter = $request->validate(['record_id' => ['nullable', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', $kind)]]);
|
||||
|
||||
return isset($filter['record_id']) ? SupplyRecord::query()->where('tenant_id', $actor->tenant_id)->where('kind', $kind)->findOrFail((int) $filter['record_id']) : null;
|
||||
}
|
||||
|
||||
/** @return array{Collection<int,SupplyRecord>,LengthAwarePaginator<int,SupplyRecord>|null} */
|
||||
private function recordPage(Request $request, SupplyPlanning $planning, bool $ready, string $kind, ?SupplyRecord $focus): array
|
||||
{
|
||||
$request->validate(['records_page' => 'nullable|integer|min:1|max:1000000']);
|
||||
if (! $ready) {
|
||||
return [collect(), null];
|
||||
}
|
||||
$query = $planning->latest($kind)->latest('id');
|
||||
if ($focus !== null) {
|
||||
return [$query->where('record_key', $focus->record_key)->get(), null];
|
||||
}
|
||||
$page = $query->paginate(25, ['*'], 'records_page')->withQueryString();
|
||||
|
||||
return [$page->getCollection(), $page];
|
||||
}
|
||||
|
||||
/** @param LengthAwarePaginator<int,SupplyRecord>|null $page
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function pagination(?LengthAwarePaginator $page): ?array
|
||||
{
|
||||
return $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
|
||||
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()];
|
||||
}
|
||||
|
||||
/** Preserve already selected sources even outside the recent choices.
|
||||
* @param Collection<array-key,mixed> $selected
|
||||
* @param array<string,mixed> $filters
|
||||
* @return Collection<int,SupplyRecord>
|
||||
*/
|
||||
private function approvedOptions(SupplyPlanning $planning, string $kind, Collection $selected, array $filters = []): Collection
|
||||
{
|
||||
$query = $planning->latest($kind)->where('state', 'approved');
|
||||
if ($kind === 'agreement' && isset($filters['agreement_stock_item_id'])) {
|
||||
$query->where('stock_item_id', $filters['agreement_stock_item_id'])->where('warehouse_id', $filters['agreement_warehouse_id']);
|
||||
}
|
||||
if ($kind === 'history' && isset($filters['history_stock_item_id'])) {
|
||||
$query->where('stock_item_id', $filters['history_stock_item_id'])->where('warehouse_id', $filters['history_warehouse_id'])->where('data->purpose', $filters['history_purpose']);
|
||||
}
|
||||
$recent = (clone $query)
|
||||
->when(trim($filters['agreement_search'] ?? '') !== '', fn ($q) => $q->where(fn ($match) => $match
|
||||
->whereLike('data->reference', '%'.trim($filters['agreement_search']).'%')
|
||||
->orWhereLike('snapshot->supplier->name', '%'.trim($filters['agreement_search']).'%')))
|
||||
->when(trim($filters['policy_search'] ?? '') !== '', fn ($q) => $q->where(fn ($match) => $match
|
||||
->whereLike('snapshot->item->code', '%'.trim($filters['policy_search']).'%')
|
||||
->orWhereLike('snapshot->item->name', '%'.trim($filters['policy_search']).'%')
|
||||
->orWhereLike('snapshot->warehouse->name', '%'.trim($filters['policy_search']).'%')))
|
||||
->when($filters['history_period_from'] ?? null, fn ($q, $date) => $q->where('data->period_from', '>=', $date))
|
||||
->when($filters['history_period_until'] ?? null, fn ($q, $date) => $q->where('data->period_until', '<=', $date))
|
||||
->latest('id')->limit(100)->pluck('id');
|
||||
|
||||
return $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('record_key', $selected->filter()->unique()))->latest('id')->get();
|
||||
}
|
||||
|
||||
/** @param Collection<array-key,mixed> $selected
|
||||
* @return Collection<int,PurchaseOrder>
|
||||
*/
|
||||
private function orderOptions(Collection $selected, string $search = ''): Collection
|
||||
{
|
||||
$recent = PurchaseOrder::query()->when(trim($search) !== '', fn ($q) => $q->whereRaw('LOWER(number) LIKE ?', ['%'.mb_strtolower(trim($search)).'%']))->latest('id')->limit(100)->pluck('id');
|
||||
|
||||
return PurchaseOrder::query()->where(fn ($q) => $q->whereIn('id', $recent)->orWhereIn('id', $selected->filter()->unique()))->latest('id')->get(['id', 'number']);
|
||||
}
|
||||
|
||||
private function history(Request $request, User $actor, SupplyPlanning $planning): Response
|
||||
{
|
||||
$prepare = $planning->allows($actor, 'supply_history_prepare');
|
||||
$review = $planning->allows($actor, 'supply_history_review');
|
||||
abort_unless($prepare || $review || $planning->allows($actor, 'supply_read'), 403);
|
||||
$ready = Schema::hasTable('supply_records');
|
||||
$focus = $this->focusedRecord($request, $actor, 'history');
|
||||
[$records, $page] = $this->recordPage($request, $planning, $ready, 'history', $focus);
|
||||
$service = app(SupplyHistory::class);
|
||||
|
||||
return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id,
|
||||
'recordPagination' => $this->pagination($page),
|
||||
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $service->canReview($actor, $record), 'canReturn' => $service->canReview($actor, $record, 'return'), 'canSuspend' => $service->canReview($actor, $record, 'suspend')]), 'canPrepare' => $prepare, 'canReview' => $review,
|
||||
'defaultPeriod' => ['from' => today()->subDays(30)->toDateString(), 'until' => today()->subDay()->toDateString()],
|
||||
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
|
||||
'history' => $ready ? SupplyRecord::query()->where('kind', 'history')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
|
||||
]);
|
||||
}
|
||||
|
||||
private function suggestions(Request $request, User $actor, SupplyPlanning $planning): Response
|
||||
{
|
||||
$prepare = $planning->allows($actor, 'supply_suggest_prepare');
|
||||
$review = $planning->allows($actor, 'supply_suggest_review');
|
||||
$convertPermission = $planning->allows($actor, 'supply_suggest_convert');
|
||||
$convert = $convertPermission && app(OrganizationAccess::class)->canRequest($actor);
|
||||
abort_unless($planning->allows($actor, 'supply_read') || $prepare || $review || $convertPermission, 403);
|
||||
$ready = Schema::hasTable('supply_records');
|
||||
$focus = $this->focusedRecord($request, $actor, 'suggestion');
|
||||
[$records, $page] = $this->recordPage($request, $planning, $ready, 'suggestion', $focus);
|
||||
$filters = $request->validate([
|
||||
'policy_search' => 'nullable|string|max:100',
|
||||
'selected_policy_key' => ['nullable', 'uuid', Rule::exists('supply_records', 'record_key')->where('tenant_id', $actor->tenant_id)->where('kind', 'policy')],
|
||||
'policy_form_key' => 'nullable|string|max:80',
|
||||
]);
|
||||
$policies = [];
|
||||
if ($ready && $prepare) {
|
||||
foreach ($this->approvedOptions($planning, 'policy', $records->pluck('data.policy_key')->push($filters['selected_policy_key'] ?? null), $filters) as $policy) {
|
||||
$option = ['key' => $policy->record_key, 'version' => $policy->version, 'label' => $policy->snapshot['item']['code'].' · '.$policy->snapshot['item']['name'].' · '.$policy->snapshot['warehouse']['name'], 'issue' => null];
|
||||
try {
|
||||
$basis = app(SupplySuggestions::class)->basis($policy->record_key, today()->toDateString());
|
||||
$option += ['unit_price' => $basis['unit_price'], 'tax_rate' => $basis['tax_rate'], 'quantity_milli' => $basis['projection']['quantity_milli'] ?? 0,
|
||||
'delivery_on' => $basis['projection']['risk_date'] ?? null, 'unit' => $basis['item']['unit']];
|
||||
} catch (ValidationException $e) {
|
||||
$option['issue'] = $e->getMessage();
|
||||
}
|
||||
$policies[] = $option;
|
||||
}
|
||||
}
|
||||
$requests = Requisition::query()->visibleTo($actor)->whereIn('id', $records->pluck('data.requisition_id')->filter())->get(['id', 'number'])->keyBy('id');
|
||||
|
||||
$service = app(SupplySuggestions::class);
|
||||
|
||||
return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id,
|
||||
'policySearch' => $filters['policy_search'] ?? '', 'policyFormKey' => $filters['policy_form_key'] ?? null,
|
||||
'recordPagination' => $this->pagination($page),
|
||||
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(),
|
||||
'canApprove' => $service->canAct($actor, $record, 'approve'), 'canReturn' => $service->canAct($actor, $record, 'return'),
|
||||
'canConvert' => $service->canAct($actor, $record, 'convert'), 'canCancel' => $service->canAct($actor, $record, 'cancel'), 'canClose' => $service->canAct($actor, $record, 'close')]),
|
||||
'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert,
|
||||
'costCenters' => $convert ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
|
||||
'requestLinks' => $requests,
|
||||
'excess' => $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplySuggestions::class)->hasExcess($r)]),
|
||||
'history' => $ready ? SupplyRecord::query()->where('kind', 'suggestion')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
|
||||
]);
|
||||
}
|
||||
|
||||
private function coordination(Request $request, User $actor, string $view, SupplyPlanning $planning): Response
|
||||
{
|
||||
[$kind, $prepare, $review] = match ($view) {
|
||||
'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'],
|
||||
'reservations' => ['reservation', 'supply_reserve', 'supply_reserve'],
|
||||
'deliveries' => ['delivery', 'supply_delivery', 'supply_delivery'],
|
||||
default => abort(404),
|
||||
};
|
||||
$read = app(SupplyCoordinationRead::class);
|
||||
$read->kind($actor, $view);
|
||||
$ready = Schema::hasTable('supply_records');
|
||||
$focus = $this->focusedRecord($request, $actor, $kind);
|
||||
$request->validate(['records_page' => 'nullable|integer|min:1|max:1000000']);
|
||||
$filters = $read->filters($request, $kind);
|
||||
$query = $ready ? ($focus === null ? $read->query($kind, $filters) : $planning->latest($kind))->latest('id') : null;
|
||||
$page = $focus === null ? $query?->paginate(25, ['*'], 'records_page')->withQueryString() : null;
|
||||
$records = $focus !== null ? $query?->where('record_key', $focus->record_key)->get() ?? collect() : $page?->getCollection() ?? collect();
|
||||
$canPrepare = $planning->allows($actor, $prepare);
|
||||
$lineFilters = in_array($kind, ['reservation', 'delivery'], true) ? $request->validate([
|
||||
'line_search' => 'nullable|string|max:100', 'line_form_key' => 'nullable|string|max:80',
|
||||
'selected_order_line_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_order_items', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'selected_request_line_id' => ['nullable', 'integer', 'min:1', Rule::exists('requisition_items', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
]) : [];
|
||||
$lineSearch = trim($lineFilters['line_search'] ?? '');
|
||||
$reversedIds = $ready && $kind === 'reservation' ? array_keys(app(InventoryReversals::class)->corrections('operation')) : [];
|
||||
$orders = collect();
|
||||
if ($canPrepare && $kind === 'delivery') {
|
||||
$recentOrders = PurchaseOrder::query()->where('status', 'issued')->when($lineSearch !== '', fn ($q) => $q->whereLike('number', '%'.$lineSearch.'%'))->latest('id')->limit(100)->pluck('id');
|
||||
$selectedOrders = $records->pluck('purchase_order_id')->filter();
|
||||
if (isset($lineFilters['selected_order_line_id'])) {
|
||||
$selectedOrders->push(PurchaseOrderItem::query()->whereKey($lineFilters['selected_order_line_id'])->value('purchase_order_id'));
|
||||
}
|
||||
$orders = PurchaseOrder::query()->where('status', 'issued')->where(fn ($q) => $q->whereIn('id', $recentOrders)->orWhereIn('id', $selectedOrders))->latest('id')->get()->keyBy('id');
|
||||
}
|
||||
$orderLines = PurchaseOrderItem::query()->whereIn('purchase_order_id', $orders->keys())->get()->map(fn ($line) => [
|
||||
'id' => $line->id, 'label' => $orders[$line->purchase_order_id]->number.' · '.$line->description.' · '.$line->quantity.' '.$line->unit,
|
||||
'stock_item_id' => $orders[$line->purchase_order_id]->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null,
|
||||
'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
|
||||
]);
|
||||
$requests = collect();
|
||||
if ($canPrepare && $kind === 'reservation') {
|
||||
$recentRequests = Requisition::query()->where('status', 'approved')->when($lineSearch !== '', fn ($q) => $q->whereLike('number', '%'.$lineSearch.'%'))->latest('id')->limit(100)->pluck('id');
|
||||
$selectedRequests = RequisitionItem::query()->whereIn('id', $records->pluck('data.requisition_item_id')->push($lineFilters['selected_request_line_id'] ?? null)->filter())->pluck('requisition_id');
|
||||
$requests = Requisition::query()->where('status', 'approved')->where(fn ($q) => $q->whereIn('id', $recentRequests)->orWhereIn('id', $selectedRequests))->latest('id')->get()->keyBy('id');
|
||||
}
|
||||
$requestLines = RequisitionItem::query()->whereIn('requisition_id', $requests->keys())->get()->filter(fn ($line) => isset($line->catalogue_snapshot['stock_item_id']))->map(fn ($line) => [
|
||||
'id' => $line->id, 'label' => $requests[$line->requisition_id]->number.' · '.$line->description,
|
||||
'stock_item_id' => $line->catalogue_snapshot['stock_item_id'], 'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
|
||||
'reference' => $requests[$line->requisition_id]->number.' / '.$line->id, 'needed_on' => $requests[$line->requisition_id]->needed_by->toDateString(),
|
||||
])->values();
|
||||
|
||||
$coordination = app(SupplyCoordination::class);
|
||||
$issueFilters = $kind === 'reservation' ? $request->validate([
|
||||
'issue_search' => 'nullable|string|max:100',
|
||||
'issue_record_id' => ['nullable', 'required_with:issue_search,selected_issue_id', 'integer', 'min:1', Rule::exists('supply_records', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'reservation')],
|
||||
'selected_issue_id' => ['nullable', 'integer', 'min:1', Rule::exists('stock_operations', 'id')->where('tenant_id', $actor->tenant_id)->where('kind', 'issue')],
|
||||
]) : [];
|
||||
$operations = collect();
|
||||
if ($ready && $canPrepare && $kind === 'reservation') {
|
||||
$issueRecord = isset($issueFilters['issue_record_id']) ? SupplyRecord::query()->findOrFail((int) $issueFilters['issue_record_id']) : null;
|
||||
if ($issueRecord !== null) {
|
||||
$issueRecord = $planning->latest('reservation')->where('record_key', $issueRecord->record_key)->firstOrFail();
|
||||
abort_unless($coordination->canManage($actor, $issueRecord, 'fulfil'), 403);
|
||||
}
|
||||
$query = StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds)
|
||||
->when($issueRecord !== null, fn ($q) => $q->where('stock_item_id', $issueRecord->stock_item_id)->where('warehouse_id', $issueRecord->warehouse_id));
|
||||
$search = trim($issueFilters['issue_search'] ?? '');
|
||||
$recent = (clone $query)->when($search !== '', fn ($q) => $q->whereLike('reference', '%'.$search.'%'))->latest('id')->limit(100)->pluck('id');
|
||||
$operations = $query->where(fn ($q) => $q->whereIn('id', $recent)->orWhere('id', $issueFilters['selected_issue_id'] ?? 0))
|
||||
->latest('id')->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']);
|
||||
}
|
||||
|
||||
return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready,
|
||||
'recordFilters' => ['record_search' => trim($filters['record_search'] ?? ''), 'record_state' => $filters['record_state'] ?? ''],
|
||||
'recordPagination' => $page === null ? null : ['current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(),
|
||||
'prev_page_url' => $page->previousPageUrl(), 'next_page_url' => $page->nextPageUrl()],
|
||||
'focusRecordId' => $focus->id ?? null, 'focusChanged' => $focus !== null && $records->first()?->id !== $focus->id,
|
||||
'records' => $records->map(fn (SupplyRecord $record) => [...$record->toArray(), 'canApprove' => $coordination->canReviewAlternative($actor, $record),
|
||||
'canReturn' => $coordination->canReviewAlternative($actor, $record, 'return'), 'canSuspend' => $coordination->canReviewAlternative($actor, $record, 'suspend'),
|
||||
'canEdit' => $coordination->canManage($actor, $record, 'edit'), 'canFulfil' => $coordination->canManage($actor, $record, 'fulfil'),
|
||||
'canCancel' => $coordination->canManage($actor, $record, 'cancel')]),
|
||||
'eligible' => $kind === 'alternative' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->eligibleAlternative($r)]) : [],
|
||||
'pending' => $kind === 'delivery' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingDelivery($r)]) : ($kind === 'reservation' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingReservation($r)]) : []),
|
||||
'reversedOperations' => $reversedIds,
|
||||
'canPrepare' => $canPrepare, 'canReview' => $planning->allows($actor, $review),
|
||||
'items' => $canPrepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
|
||||
'warehouses' => $canPrepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
|
||||
'orderLines' => $orderLines, 'requestLines' => $requestLines,
|
||||
'lineSearch' => $lineSearch, 'lineFormKey' => $lineFilters['line_form_key'] ?? null,
|
||||
'operations' => $operations, 'issueSearch' => $issueFilters['issue_search'] ?? '',
|
||||
'issueRecordId' => isset($issueFilters['issue_record_id']) ? (int) $issueFilters['issue_record_id'] : null,
|
||||
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request, string $kind, SupplyPlanning $planning): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$planning->save($actor, $kind, $request->all());
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\WarehouseAvailability;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WarehouseAvailabilityController extends Controller
|
||||
{
|
||||
public function index(Request $request, WarehouseAvailability $availability): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'warehouse_availability_read']), 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $actor, $availability) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$filters = $request->validate(['warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
|
||||
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:100000']);
|
||||
$warehouse = empty($filters['warehouse_id']) ? null : Warehouse::query()->whereKey($filters['warehouse_id'])->firstOrFail();
|
||||
$planning = $actor->hasFunctions(['admin', 'supply_read', 'supply_reserve', 'warehouse_availability_read']);
|
||||
$query = StockItem::query();
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where(fn ($q) => $q->whereLike('code', '%'.$filters['search'].'%')->orWhereLike('name', '%'.$filters['search'].'%'));
|
||||
}
|
||||
$page = $warehouse === null ? null : $query->orderBy('code')->paginate(25)->withQueryString()->through(fn ($item) => $availability->row($item, $warehouse, $planning));
|
||||
$response = Inertia::render('stock/availability', ['filters' => $filters, 'planningVisible' => $planning, 'rows' => $page,
|
||||
'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'checkedAt' => now()->toIso8601String(),
|
||||
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name'])])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WarehouseExchange;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WarehouseExchangeController extends Controller
|
||||
{
|
||||
public function index(Request $request, WarehouseExchange $exchange): JsonResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$input = $request->validate(['cursor' => 'nullable|string|max:4096', 'limit' => 'sometimes|integer|min:1|max:100']);
|
||||
|
||||
return response()->json($exchange->page($actor, $input['cursor'] ?? null, (int) ($input['limit'] ?? 50)))
|
||||
->header('Cache-Control', 'private, no-store, max-age=0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WarehousePilotReadiness;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WarehousePilotController extends Controller
|
||||
{
|
||||
public function export(Request $request, WarehousePilotReadiness $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
|
||||
$report = DB::transaction(function () use ($actor, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $service->inspect($actor);
|
||||
});
|
||||
$rows = [['Conferido em', 'Âmbito', 'Conferência', 'Resultado', 'Observação', 'Onde configurar']];
|
||||
foreach ($report['checks'] as $check) {
|
||||
$rows[] = [$report['checked_at'], 'SGC autónomo · diagnóstico de configuração', $check['label'], $check['ok'] ? 'Conferido' : 'A preparar', $check['detail'], $check['path'] ?? ''];
|
||||
}
|
||||
|
||||
return response()->streamDownload(static function () use ($rows) {
|
||||
$stream = fopen('php://output', 'wb');
|
||||
if ($stream === false) {
|
||||
throw new \RuntimeException('Não foi possível preparar a exportação.');
|
||||
}
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
foreach ($rows as $row) {
|
||||
$safe = array_map(static fn (string $value) => preg_match('/^[\s\x{FEFF}]*[=+@-]/u', $value) ? "'".$value : $value, $row);
|
||||
fputcsv($stream, $safe, ';', '"', '');
|
||||
}
|
||||
fclose($stream);
|
||||
}, 'preparacao-piloto-sgc-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
|
||||
public function index(Request $request, WarehousePilotReadiness $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
|
||||
|
||||
return DB::transaction(function () use ($actor, $request, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/pilot', ['report' => $service->inspect($actor), 'syntheticEvaluation' => app()->environment(['testing', 'payable-testing'])])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use App\Services\InternalSupplyTrace;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\WarehouseFulfilment;
|
||||
use App\Services\WarehouseRequests;
|
||||
use App\Services\WarehouseWorkdesk;
|
||||
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 WarehouseRequestController extends Controller
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
private function filters(Request $request, User $actor): array
|
||||
{
|
||||
return $request->validate(['view' => 'nullable|in:all,review,reserve,delivery,receipt', 'state' => 'nullable|in:all,draft,submitted,authorized,partial,delivered,closed,rejected,cancelled', 'search' => 'nullable|string|max:100', 'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], 'page' => 'nullable|integer|min:1|max:1000000']);
|
||||
}
|
||||
|
||||
public function index(Request $request, WarehouseWorkdesk $desk): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $actor, $desk) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$filters = $this->filters($request, $actor);
|
||||
$prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
$ready = Schema::hasTable('warehouse_requests');
|
||||
$base = $desk->filteredBase($actor, $filters);
|
||||
$views = $ready ? $desk->views(clone $base, $actor) : [];
|
||||
$query = $desk->forView(clone $base, $actor, $filters['view'] ?? 'all');
|
||||
$states = $ready ? DB::query()->fromSub((clone $query)->selectRaw($desk->stateExpression().' as projected_state')->toBase(), 'request_states')->selectRaw('projected_state, COUNT(*) as total')->groupBy('projected_state')->pluck('total', 'projected_state') : [];
|
||||
if (($filters['state'] ?? 'all') !== 'all') {
|
||||
$query->whereRaw($desk->stateExpression().' = ?', [$filters['state']]);
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
|
||||
'filters' => ['view' => $filters['view'] ?? 'all', 'state' => $filters['state'] ?? 'all', 'search' => $filters['search'] ?? '', 'warehouse_id' => $filters['warehouse_id'] ?? null], 'views' => $views, 'states' => $states, 'checkedAt' => now()->toIso8601String(),
|
||||
'records' => $ready ? $query->oldest('id')->paginate(15, ['id', 'tenant_id', 'warehouse_id', 'created_by', 'status', 'snapshot', 'created_at'])->withQueryString()->through(function ($r) use ($desk, $actor) {
|
||||
$p = app(WarehouseFulfilment::class)->progress($r);
|
||||
|
||||
return ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => $p, 'health' => $desk->health($r, $p), 'actions' => $desk->actions($actor, $r, $p)];
|
||||
}) : null,
|
||||
'warehouses' => Warehouse::query()->when(! $prepare, fn ($q) => $q->whereIn('id', $ready ? $desk->visible($actor)->select('warehouse_id') : []))->orderBy('code')->get(['id', 'code', 'name']),
|
||||
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
|
||||
'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
|
||||
'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function csv(Request $request, WarehouseWorkdesk $desk): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
|
||||
$filters = $this->filters($request, $actor);
|
||||
abort_unless(Schema::hasTable('warehouse_requests'), 503);
|
||||
|
||||
return response()->streamDownload(function () use ($actor, $filters, $desk) {
|
||||
DB::transaction(function () use ($actor, $filters, $desk) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$actor = User::query()->findOrFail($actor->id);
|
||||
abort_unless($actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
|
||||
$query = $desk->forView($desk->filteredBase($actor, $filters), $actor, $filters['view'] ?? 'all');
|
||||
if (($filters['state'] ?? 'all') !== 'all') {
|
||||
$query->whereRaw($desk->stateExpression().' = ?', [$filters['state']]);
|
||||
}
|
||||
$out = fopen('php://output', 'w');
|
||||
if ($out === false) {
|
||||
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
|
||||
}
|
||||
$safe = fn (string $value): string => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value) ? "'".$value : $value;
|
||||
try {
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, ['Requisições internas', now()->toIso8601String(), 'Sem factura; entregas acumuladas não descontam devoluções ou correcções.'], ';', '"', '');
|
||||
fputcsv($out, ['Filtros', json_encode(array_diff_key($filters, ['page' => true]), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)], ';', '"', '');
|
||||
fputcsv($out, ['Requisição', 'Estado', 'Versão', 'Requisitante', 'Armazém', 'Centro de custo', 'Destino', 'Finalidade', 'Linha', 'Artigo', 'Descrição', 'Unidade', 'Utilização', 'Equipamento', 'Pedida', 'Reservada agora', 'Entregue acumulada', 'Por entregar', 'Cancelada', 'Reservas disponíveis', 'Consulta'], ';', '"', '');
|
||||
foreach ($query->lazyById(100) as $record) {
|
||||
$p = app(WarehouseFulfilment::class)->progress($record);
|
||||
$s = $record->snapshot;
|
||||
foreach ($s['lines'] as $index => $line) {
|
||||
$row = ['RA-'.$record->id, $p['state'], (string) $p['version'], $s['requester']['name'], $s['warehouse']['code'], $s['cost_center']['code'], $s['destination'], $s['reason'], (string) ($index + 1), $line['item']['code'], $line['item']['name'], $line['item']['unit'], $line['destination'], $line['equipment']['code'] ?? '', ...array_map(fn ($quantity) => number_format($quantity / 1000, 3, '.', ''), [$line['quantity_milli'], $p['reserved'][$index], $p['delivered'][$index], $p['remaining'][$index], $p['cancelled'][$index]]), $p['reservation_ready'] ? 'Sim' : 'Não', url('/stock/requisicoes/'.$record->id)];
|
||||
fputcsv($out, array_map($safe, $row), ';', '"', '');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($out);
|
||||
}
|
||||
});
|
||||
}, 'requisicoes-internas-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
|
||||
}
|
||||
|
||||
public function store(Request $request, WarehouseRequests $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$service->draft($actor, $request->all());
|
||||
|
||||
return redirect('/stock/requisicoes');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id, WarehouseFulfilment $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return DB::transaction(function () use ($request, $id, $service, $actor) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$r = WarehouseRequest::query()->findOrFail($id);
|
||||
$desk = app(WarehouseWorkdesk::class);
|
||||
abort_unless($desk->canView($actor, $r), 403);
|
||||
$p = $service->progress($r);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
|
||||
'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor),
|
||||
'health' => $desk->health($r, $p), 'people' => $desk->people($r, $p),
|
||||
'supplyTrace' => app(InternalSupplyTrace::class)->forRequest($actor, $r, $p), ...$desk->actions($actor, $r, $p)])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
|
||||
public function command(Request $request, int $id, WarehouseFulfilment $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$service->command($actor, $id, $request->all());
|
||||
|
||||
return redirect('/stock/requisicoes/'.$id);
|
||||
}
|
||||
|
||||
public function printRequest(Request $request, int $id, WarehouseFulfilment $service): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $service) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$record = WarehouseRequest::query()->findOrFail($id);
|
||||
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $record), 403);
|
||||
|
||||
return response()->view('stock.request-print', ['record' => $record, 'progress' => $service->progress($record), 'generatedAt' => now()])
|
||||
->header('Cache-Control', 'private, no-store, max-age=0')->header('X-Content-Type-Options', 'nosniff');
|
||||
});
|
||||
}
|
||||
|
||||
public function printDelivery(Request $request, int $id, int $delivery): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $delivery) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$record = WarehouseRequest::query()->findOrFail($id);
|
||||
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $record), 403);
|
||||
abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Registo de entregas indisponível.');
|
||||
$event = WarehouseRequestEvent::query()->where('warehouse_request_id', $record->id)->where('action', 'deliver')->findOrFail($delivery);
|
||||
$confirmation = WarehouseRequestEvent::query()->where('warehouse_request_id', $record->id)->where('action', 'receive')
|
||||
->where('data->delivery_event_id', $event->id)->orderBy('version')->first();
|
||||
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $record->id)->where('action', 'authorize')
|
||||
->where('version', '<', $event->version)->orderByDesc('version')->first();
|
||||
|
||||
return response()->view('stock.delivery-print', ['record' => $record, 'delivery' => $event, 'confirmation' => $confirmation, 'authorization' => $authorization])
|
||||
->header('Cache-Control', 'private, no-store, max-age=0')->header('X-Content-Type-Options', 'nosniff');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\WarehouseReturns;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class WarehouseReturnController extends Controller
|
||||
{
|
||||
private function actor(Request $r): User
|
||||
{
|
||||
$actor = $r->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
|
||||
public function page(Request $r, WarehouseReturns $flow): Response
|
||||
{
|
||||
$flow->authorize($this->actor($r), 'read');
|
||||
$d = $r->validate(['request_id' => 'sometimes|integer|min:1']);
|
||||
$actor = $this->actor($r);
|
||||
$source = isset($d['request_id']) ? $flow->availableDeliveries($actor, (int) $d['request_id'], 1) : null;
|
||||
$response = Inertia::render('stock/returns', ['requestId' => isset($d['request_id']) ? (int) $d['request_id'] : null, 'canPrepare' => $source['can_prepare'] ?? false, 'canWithdraw' => $flow->allows($actor, 'prepare'), 'canConfirm' => $flow->allows($actor, 'confirm'), 'actorId' => $actor->id])->toResponse($r);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function sources(Request $r, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['request_id' => 'required|integer|min:1', 'source_page' => 'sometimes|integer|min:1|max:100000']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
|
||||
return $this->response(fn () => $flow->availableDeliveries($this->actor($r), (int) $d['request_id'], (int) ($d['source_page'] ?? 1)), true);
|
||||
}
|
||||
|
||||
/** @param callable():array<string,mixed> $run */
|
||||
private function response(callable $run,bool $readOnly=false): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result=$readOnly?DB::transaction(function()use($run){if(DB::getDriverName()==='pgsql'&&DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $run();}):$run();
|
||||
return response()->json($result)->header('Cache-Control', 'no-store');
|
||||
} catch (QueryException $e) {
|
||||
if (($e->errorInfo[0] ?? null) === '55P03') {
|
||||
return response()->json(['code'=>'warehouse_return_busy','message' => 'Devolução ocupada. Aguarde e repita manualmente a conferência.'], 423)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function index(Request $r, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'state' => 'sometimes|in:all,prepared,confirmed,withdrawn,rejected']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
|
||||
return $this->response(fn () => $flow->listing($this->actor($r), (int) ($d['page'] ?? 1), $d['state'] ?? 'all'), true);
|
||||
}
|
||||
|
||||
public function prepare(Request $r, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['operation_key' => 'required|uuid', 'delivery_event_id' => 'required|integer|min:1', 'line_index' => 'required|integer|min:0|max:49', 'quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'declared_unused' => 'required|accepted', 'evidence' => 'required|string|min:10|max:1000', 'reason' => 'required|string|min:10|max:1000']);
|
||||
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
|
||||
$d['quantity_milli'] = ReceiptWorkflow::milli($d['quantity']);
|
||||
$d['line_index'] = (int) $d['line_index'];
|
||||
$d['delivery_event_id'] = (int) $d['delivery_event_id'];
|
||||
$d['declared_unused'] = true;
|
||||
|
||||
return $this->response(fn () => $flow->prepare($this->actor($r), $d));
|
||||
}
|
||||
|
||||
public function confirm(Request $r, int $id, WarehouseReturns $flow): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['version' => 'required|integer|min:1', 'received_in_warehouse' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000']);
|
||||
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
|
||||
$d['version'] = (int) $d['version'];
|
||||
$d['received_in_warehouse'] = true;
|
||||
|
||||
return $this->response(fn () => $flow->confirm($this->actor($r),$id,$d));
|
||||
}
|
||||
public function withdraw(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'withdrawn');}
|
||||
public function reject(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'rejected');}
|
||||
private function close(Request $r,int $id,WarehouseReturns $flow,string $state): JsonResponse
|
||||
{
|
||||
$d=$r->validate(['version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000']);abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);$d['version']=(int)$d['version'];return $this->response(fn()=>$flow->close($this->actor($r),$id,$d,$state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WorkContextController extends Controller
|
||||
{
|
||||
public function select(Request $request, WorkContext $contexts): RedirectResponse
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$data = $request->validate(['area' => ['required', Rule::in(['procurement', 'people'])], 'option' => ['nullable', Rule::in(array_keys(WorkContext::OPTIONS))]]);
|
||||
$available = array_values(array_filter($contexts->available($actor), fn ($key) => WorkContext::OPTIONS[$key]['area'] === $data['area']));
|
||||
$key = $data['option'] ?? (count($available) === 1 ? $available[0] : null);
|
||||
abort_unless($key !== null && in_array($key, $available, true), 403, 'Escolha uma opção configurada, contratada e autorizada.');
|
||||
$state = ['user_id' => $actor->id, 'tenant_id' => $actor->tenant_id, 'key' => $key];
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'work_context.selected', null, $request->session()->get('work_context'), $state);
|
||||
$request->session()->put('work_context', $state);
|
||||
|
||||
return redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WorkflowNotices;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WorkflowNoticeController extends Controller
|
||||
{
|
||||
public function index(Request $request, WorkflowNotices $notices): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $notices) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
|
||||
return $this->page($request, $notices);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function filters(Request $request): array
|
||||
{
|
||||
return $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000', 'search' => 'nullable|string|max:100']);
|
||||
}
|
||||
|
||||
private function page(Request $request, WorkflowNotices $notices): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$filter = $this->filters($request);
|
||||
$visible = collect($notices->visible($actor));
|
||||
$unread = $visible->whereNull('read_at')->count();
|
||||
$items = ($filter['view'] ?? 'all') === 'unread' ? $visible->whereNull('read_at')->values() : $visible;
|
||||
if (in_array($filter['view'] ?? '', ['tasks', 'outcomes'], true)) {
|
||||
$items = $visible->where('kind', $filter['view'] === 'tasks' ? 'task' : 'outcome')->values();
|
||||
}
|
||||
$search = trim($filter['search'] ?? '');
|
||||
if ($search !== '') {
|
||||
$items = $items->filter(fn ($notice) => mb_stripos($notice['reference'], $search) !== false)->values();
|
||||
}
|
||||
$page = min((int) ($filter['page'] ?? 1), max(1, (int) ceil($items->count() / 25)));
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('workflow/notices', ['ready' => Schema::hasTable('workflow_notices'), 'unread' => $unread, 'view' => $filter['view'] ?? 'all',
|
||||
'search' => $search,
|
||||
'notices' => (new LengthAwarePaginator($items->slice(($page - 1) * 25, 25)->values(), $items->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function read(Request $request, int $notice, WorkflowNotices $notices): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$filters = $this->filters($request);
|
||||
$notices->read($actor, $notice);
|
||||
|
||||
return redirect('/requisicoes/avisos'.($filters === [] ? '' : '?'.http_build_query($filters)));
|
||||
}
|
||||
|
||||
public function refresh(Request $request, int $requisition, WorkflowNotices $notices): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$notices->refresh($actor, $requisition, $request->all());
|
||||
|
||||
return redirect('/requisicoes/'.$requisition);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WorkflowTaskInbox;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WorkflowTaskInboxController extends Controller
|
||||
{
|
||||
public function procurement(Request $request, WorkflowTaskInbox $inbox): Response
|
||||
{
|
||||
return $this->page($request, $inbox, 'procurement');
|
||||
}
|
||||
|
||||
public function people(Request $request, WorkflowTaskInbox $inbox): Response
|
||||
{
|
||||
return $this->page($request, $inbox, 'people');
|
||||
}
|
||||
|
||||
private function page(Request $request, WorkflowTaskInbox $inbox, string $area): Response
|
||||
{
|
||||
$actor = $request->user('web');
|
||||
abort_unless($actor instanceof User, 403);
|
||||
$modules = $area === 'people' ? ['people' => 'Pessoas e contratações'] : ['purchases' => 'Compras', 'warehouse' => 'Armazém', 'supply' => 'Fornecimentos e reposição', 'finance' => 'Finanças'];
|
||||
$filters = $request->validate(['module' => ['nullable', Rule::in(['all', ...array_keys($modules)])],
|
||||
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000',
|
||||
'due' => 'nullable|in:all,overdue,today,upcoming,undated']);
|
||||
|
||||
return DB::transaction(function () use ($request, $actor, $area, $inbox, $modules, $filters) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$today = today()->toDateString();
|
||||
$all = collect($inbox->build($actor, $area))->map(function ($row) use ($today) {
|
||||
$date = $row['due_on'] ?? null;
|
||||
$row['due_status'] = $date === null ? 'undated' : ($date < $today ? 'overdue' : ($date === $today ? 'today' : 'upcoming'));
|
||||
|
||||
return $row;
|
||||
});
|
||||
$counts = [];
|
||||
foreach ($modules as $key => $label) {
|
||||
$counts[$key] = $all->where('module', $key)->count();
|
||||
}
|
||||
$rows = $all->filter(fn ($row) => (($filters['module'] ?? 'all') === 'all' || $row['module'] === $filters['module'])
|
||||
&& (($filters['search'] ?? '') === '' || mb_stripos($row['reference'].' '.$row['label'], trim($filters['search'])) !== false))->values();
|
||||
$deadlineCounts = [];
|
||||
foreach (['overdue', 'today', 'upcoming', 'undated'] as $status) {
|
||||
$deadlineCounts[$status] = $rows->where('due_status', $status)->count();
|
||||
}
|
||||
if (($filters['due'] ?? 'all') !== 'all') {
|
||||
$rows = $rows->where('due_status', $filters['due'])->values();
|
||||
}
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('workflow/tasks', ['area' => $area, 'modules' => $modules, 'counts' => $counts,
|
||||
'checkedAt' => now()->toIso8601String(), 'asOf' => $today, 'deadlineCounts' => $deadlineCounts,
|
||||
'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? '', 'due' => $filters['due'] ?? 'all'],
|
||||
'tasks' => (new LengthAwarePaginator($rows->slice(($page - 1) * 25, 25)->values(), $rows->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
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('web');
|
||||
if ($user instanceof User && $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.');
|
||||
$contextSelection = $request->isMethod('POST') && $request->routeIs('work-context.select');
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe() && ! $contextSelection, 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.');
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
if ($user instanceof User && app(OrganizationAccess::class)->maintenanceOnly($user)) {
|
||||
app(RequisitionWorkflow::class)->audit($user, 'maintenance.consulted', null, null, ['path' => $request->path(), 'status' => $response->getStatusCode()]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WorkContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureWorkContext
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$contexts = app(WorkContext::class);
|
||||
$user = $request->user('web');
|
||||
if ($user instanceof User && $contexts->enforced($user) && ($area = WorkContext::area($request->path())) !== null) {
|
||||
$key = $contexts->active($request);
|
||||
abort_unless($key !== null && WorkContext::OPTIONS[$key]['area'] === $area, 403, 'Seleccione uma área e opção autorizadas no Painel Inicial.');
|
||||
if (! WorkContext::OPTIONS[$key]['operational'] && $request->isMethod('GET')) {
|
||||
return redirect('/dashboard');
|
||||
}
|
||||
abort_unless(WorkContext::OPTIONS[$key]['operational'], 409, 'O percurso operacional desta opção ainda está em implementação.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\EvaluationAccess;
|
||||
use App\Services\PublicNavigation;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Fortify\Features;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnterLocalEvaluation
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Features::enabled(Features::twoFactorAuthentication())) {
|
||||
if ($request->is('avaliar', 'avaliar/*', 'demo', 'demo/*')) {
|
||||
return redirect('/login')->header('Cache-Control', 'private, no-store, max-age=0');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
$access = app(EvaluationAccess::class);
|
||||
if (app(PublicNavigation::class)->enabled()) {
|
||||
return $next($request);
|
||||
}
|
||||
if (! $access->dashboardEntry($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
if ($request->user() === null) {
|
||||
$owner = $access->owner($request);
|
||||
Auth::login($owner, false);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->regenerateToken();
|
||||
$request->session()->forget('url.intended');
|
||||
app(RequisitionWorkflow::class)->audit($owner, 'evaluation.auto_entered', null, null, ['path' => '/dashboard', 'synthetic' => true]);
|
||||
}
|
||||
$response = $next($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\SupplierPayables;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
@@ -36,13 +40,30 @@ class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
if ($request->is('candidaturas/*')) {
|
||||
return [...parent::share($request), 'name' => 'SGC', 'auth' => ['user' => null], 'canRequest' => false,
|
||||
'organizationAccess' => null, 'payablesAvailable' => false, 'sidebarOpen' => false];
|
||||
}
|
||||
|
||||
$actor = $request->user('web');
|
||||
$actor = $actor instanceof User ? $actor : null;
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'internalRequisitionReady' => $actor !== null && Schema::hasTable('requisition_commands'),
|
||||
'workContext' => $actor === null ? null : app(WorkContext::class)->shared($request),
|
||||
'canRequest' => $actor !== null && app(OrganizationAccess::class)->canRequest($actor),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'user' => $actor,
|
||||
],
|
||||
'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'),
|
||||
'organizationAccess' => $actor === null ? null : [
|
||||
'permissions' => app(OrganizationAccess::class)->permissions($actor),
|
||||
'active' => app(OrganizationAccess::class)->active($actor->tenant_id),
|
||||
'profileLabel' => app(OrganizationAccess::class)->profileLabel($actor),
|
||||
],
|
||||
'payablesAvailable' => $actor !== null && app(SupplierPayables::class)->can($actor, 'view'),
|
||||
'orderCancellationAvailable' => $actor !== null && (bool) config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'),
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PrivateTwoFactorResponse
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! str_starts_with($request->route()?->getName() ?? '', 'two-factor.')) {
|
||||
return $next($request);
|
||||
}
|
||||
try {
|
||||
$response = $next($request);
|
||||
} catch (ValidationException $error) {
|
||||
// Exclude authentication material from old input without affecting other forms' code fields.
|
||||
$request->request->remove('code');
|
||||
$request->request->remove('recovery_code');
|
||||
$request->json()->remove('code');
|
||||
$request->json()->remove('recovery_code');
|
||||
throw $error;
|
||||
}
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->forget(['_old_input.code', '_old_input.recovery_code']);
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PublicNavigation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PublicDevelopmentNavigation
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$navigation = app(PublicNavigation::class);
|
||||
if (Features::enabled(Features::twoFactorAuthentication()) || ! $navigation->enabled() || $request->user('web') !== null || ! in_array($request->method(), ['GET', 'HEAD'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
$path = '/'.$request->path();
|
||||
if ($request->path() === '/') {
|
||||
return redirect('/dashboard');
|
||||
}
|
||||
if (! isset(PublicNavigation::PAGES[$path])) {
|
||||
return $next($request);
|
||||
}
|
||||
$response = Inertia::render(PublicNavigation::PAGES[$path], $navigation->props($request))->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ class ConfirmReceiptRequest extends FormRequest
|
||||
'items.*.accepted_quantity' => ['required', 'string', 'numeric', 'min:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
|
||||
'items.*.quality_status' => ['required', Rule::in(['compliant', 'reservations', 'nonconforming'])],
|
||||
'items.*.notes' => 'nullable|string|max:3000',
|
||||
'items.*.lots' => 'sometimes|array|max:100',
|
||||
'items.*.lots.*.code' => 'nullable|string|max:100',
|
||||
'items.*.lots.*.expires_on' => 'nullable|date_format:Y-m-d',
|
||||
'items.*.lots.*.no_expiry' => 'sometimes|boolean',
|
||||
'items.*.lots.*.quantity' => ['nullable', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
|
||||
'items.*.stock_item_id' => ['nullable', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $this->user()?->tenant_id)],
|
||||
'tenant_id' => 'prohibited', 'received_by' => 'prohibited', 'status' => 'prohibited', 'snapshot' => 'prohibited',
|
||||
];
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\SupplierParticipation;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -11,7 +13,9 @@ class SaveSupplierRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->purchasing_role === PurchasingRole::Procurement;
|
||||
$actor = $this->user('web');
|
||||
|
||||
return $actor instanceof User && app(SupplierParticipation::class)->canPrepare($actor);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
@@ -24,6 +28,9 @@ class SaveSupplierRequest extends FormRequest
|
||||
}
|
||||
|
||||
return [
|
||||
'geography_reference' => app(AdministrativeGeography::class)->enabled() ? 'sometimes|nullable|array:country,catalogue,record_id' : 'prohibited',
|
||||
'geography_reference.catalogue' => 'required_with:geography_reference|string|size:64',
|
||||
'geography_reference.record_id' => 'required_with:geography_reference|string|regex:/^[0-9]+$/D|max:20',
|
||||
'name' => 'required|string|max:200',
|
||||
'nuit' => ['required', 'regex:/^\d{9}$/', $uniqueNuit],
|
||||
'contact' => 'required|string|max:200', 'email' => 'required|email|max:200',
|
||||
|
||||
@@ -18,20 +18,25 @@ class StoreRequisitionRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'operation_key' => 'nullable|uuid',
|
||||
'process_version' => 'nullable|integer|min:1',
|
||||
'cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)],
|
||||
'priority' => ['required', Rule::in(['normal', 'high', 'urgent'])],
|
||||
'needed_by' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'],
|
||||
'justification' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'items' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'items.*.description' => ['required', 'string', 'max:255'],
|
||||
'items.*.category' => ['required', 'string', 'max:100'],
|
||||
'items.*.purchase_type' => ['required', Rule::in(['consumable', 'service', 'other', 'equipment', 'spare_part'])],
|
||||
'items.*.catalogue_entry_id' => 'nullable|integer|min:1',
|
||||
'items.*.catalogue_version' => 'nullable|required_with:items.*.catalogue_entry_id|integer|min:1',
|
||||
'items.*.source_line_id' => 'nullable|integer|min:1',
|
||||
'items.*.description' => ['required_without:items.*.catalogue_entry_id', 'string', 'max:255'],
|
||||
'items.*.category' => ['required_without:items.*.catalogue_entry_id', 'string', 'max:100'],
|
||||
'items.*.purchase_type' => ['required_without:items.*.catalogue_entry_id', Rule::in(['consumable', 'service', 'other', 'equipment', 'spare_part'])],
|
||||
'items.*.equipment_id' => ['exclude_unless:items.*.purchase_type,spare_part', 'required', 'integer', Rule::exists('equipment', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)],
|
||||
'items.*.spare_part_id' => ['exclude_unless:items.*.purchase_type,spare_part', 'required', 'integer', Rule::exists('spare_parts', 'id')->where('tenant_id', $this->user()?->tenant_id)],
|
||||
'items.*.production_area' => 'exclude_unless:items.*.purchase_type,equipment|required|string|max:200',
|
||||
'items.*.downtime_started_at' => 'exclude_unless:items.*.purchase_type,spare_part|nullable|date_format:Y-m-d\TH:i|before_or_equal:now',
|
||||
'items.*.quantity' => ['required', 'numeric', 'min:0.001', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
|
||||
'items.*.unit' => ['required', 'string', 'max:30'],
|
||||
'items.*.unit' => ['required_without:items.*.catalogue_entry_id', 'string', 'max:30'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'items.*.tax_rate' => ['required', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
|
||||
class CandidateAccessLink extends Mailable
|
||||
{
|
||||
public function __construct(public string $purpose, public string $link) {}
|
||||
|
||||
public function build(): static
|
||||
{
|
||||
return $this->subject($this->purpose === 'verify' ? 'SGC — confirmar email' : 'SGC — recuperar acesso')
|
||||
->text('mail.candidate-access', ['link' => $this->link, 'purpose' => $this->purpose]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* @property array<string,mixed>|null $before
|
||||
* @property array<string,mixed> $after
|
||||
*/
|
||||
class AuditLog extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/** @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $email
|
||||
* @property string $password
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property int $session_version
|
||||
*/
|
||||
class CandidateAccount extends Authenticatable
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['session_version' => 1];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['password' => 'hashed', 'email_verified_at' => 'datetime', 'session_version' => 'integer'];
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ trait BelongsToTenant
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $query) {
|
||||
$tenantId = auth()->check() ? auth()->user()->tenant_id : null;
|
||||
$tenantId = auth()->check() ? auth()->user()->tenant_id : app(\App\Services\ReplenishmentRun::class)->tenantId();
|
||||
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WorkContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
trait HasWorkContext
|
||||
{
|
||||
protected static function bootHasWorkContext(): void
|
||||
{
|
||||
static::addGlobalScope('work_context', function (Builder $query) {
|
||||
if (! app()->bound('request') || ! request()->hasSession() || request()->is('candidaturas/*')) {
|
||||
return;
|
||||
}
|
||||
$actor = request()->user('web');
|
||||
if ($actor instanceof User && app(WorkContext::class)->enforced($actor)) {
|
||||
$key = app(WorkContext::class)->active(request());
|
||||
$query->where($query->getModel()->qualifyColumn('work_context_key'), $key ?? '__no_context__');
|
||||
}
|
||||
});
|
||||
static::creating(function (Model $model) {
|
||||
if (! app()->bound('request') || ! request()->hasSession()) {
|
||||
return;
|
||||
}
|
||||
$actor = request()->user('web');
|
||||
if ($actor instanceof User && app(WorkContext::class)->enforced($actor)) {
|
||||
$key = app(WorkContext::class)->active(request());
|
||||
$area = $model->getTable() === 'people_processes' ? 'people' : 'procurement';
|
||||
abort_unless($key !== null && WorkContext::OPTIONS[$key]['operational'] && WorkContext::OPTIONS[$key]['area'] === $area, 403);
|
||||
$model->setAttribute('work_context_key', $key);
|
||||
}
|
||||
});
|
||||
static::updating(function (Model $model) {
|
||||
if ($model->isDirty('work_context_key')) {
|
||||
throw ValidationException::withMessages(['context' => 'O contexto de origem do processo não pode ser alterado.']);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class ConflictDeclaration extends Model
|
||||
/** @param Builder<ConflictDeclaration> $query */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@ namespace App\Models;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property array{country:string,catalogue:string,record_id:string}|null $geography_reference */
|
||||
class Equipment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['geography_reference' => 'array'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property int $tenant_id
|
||||
* @property int $purchase_order_id
|
||||
* @property int $received_by
|
||||
* @property CarbonInterface $received_on
|
||||
* @property string $payload_hash
|
||||
* @property array<string, mixed> $snapshot
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property array<string,mixed> $snapshot */
|
||||
class GoodsReceiptItem extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $lot_id */
|
||||
class InventoryLotAllocation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new \LogicException('Preservar repartições de lotes.'));
|
||||
static::deleting(fn () => throw new \LogicException('Preservar repartições de lotes.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LogicException;
|
||||
|
||||
/** @property array<string,mixed> $snapshot */
|
||||
class OrderCancellation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['status' => 'pending'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(function (self $record) {
|
||||
if ($record->getRawOriginal('status') === 'pending' && (! in_array($record->status, ['approved', 'rejected'], true)
|
||||
|| $record->reviewed_by === null || $record->reviewed_at === null || trim((string) $record->review_reason) === '')) {
|
||||
throw new LogicException('Decisão completa e definitiva obrigatória.');
|
||||
}
|
||||
$allowed = $record->getRawOriginal('status') === 'pending' ? ['status', 'reviewed_by', 'review_reason', 'reviewed_at', 'updated_at']
|
||||
: ($record->getRawOriginal('status') === 'approved' && $record->getRawOriginal('revised_requisition_id') === null ? ['revised_requisition_id', 'updated_at'] : []);
|
||||
if (array_diff(array_keys($record->getDirty()), $allowed) !== []) {
|
||||
throw new LogicException('Preservar pedido e decisão de cancelamento.');
|
||||
}
|
||||
});
|
||||
static::deleting(fn () => throw new LogicException('Preservar cancelamento.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class OrderCancellationPolicy extends PurchaseControlPolicy
|
||||
{
|
||||
protected $table = 'order_cancellation_policies';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class OrderCancellationPolicyReview extends PurchaseControlReview
|
||||
{
|
||||
protected $table = 'order_cancellation_policy_reviews';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\HasWorkContext;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $created_by
|
||||
* @property string $kind
|
||||
* @property string $status
|
||||
* @property int $version
|
||||
* @property string $closes_on
|
||||
* @property string $creation_hash
|
||||
* @property int|null $selected_application_id
|
||||
* @property array<string,mixed> $rules
|
||||
* @property array<string,mixed>|null $selection
|
||||
* @property array<string,mixed>|null $contract
|
||||
*/
|
||||
class PeopleProcess extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
use HasWorkContext;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['status' => 'draft', 'version' => 1];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['rules' => 'array', 'selection' => 'array', 'contract' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -39,4 +40,15 @@ class PurchaseOrder extends Model
|
||||
{
|
||||
$query->whereIn('requisition_id', Requisition::query()->visibleTo($user)->select('id'));
|
||||
}
|
||||
|
||||
/** @param Builder<PurchaseOrder> $query */
|
||||
public function scopeOperational(Builder $query): void
|
||||
{
|
||||
$query->where('purchase_orders.status', 'issued');
|
||||
if (Schema::hasTable('order_cancellations')) {
|
||||
$query->whereNotExists(fn ($q) => $q->selectRaw('1')->from('order_cancellations as oc')
|
||||
->whereColumn('oc.purchase_order_id', 'purchase_orders.id')->whereColumn('oc.tenant_id', 'purchase_orders.tenant_id')
|
||||
->whereIn('oc.status', ['pending', 'approved']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ class PurchasePlanItem extends Model
|
||||
/** @param Builder<PurchasePlanItem> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
match ($user->purchasing_role) {
|
||||
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
|
||||
PurchasingRole::DepartmentHead => $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id)),
|
||||
default => $query->where('responsible_id', $user->id),
|
||||
};
|
||||
if ($user->departmentScoped()) {
|
||||
$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])) {
|
||||
$query->where('responsible_id', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Models\Concerns\BelongsToTenant;
|
||||
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
|
||||
*/
|
||||
class QualificationAssessment extends Model
|
||||
|
||||
@@ -7,7 +7,10 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/** @property array<string, mixed> $weights */
|
||||
/**
|
||||
* @property array<string, mixed> $weights
|
||||
* @property array<string, mixed>|null $commercial_terms Stored JSON is validated against the pricing contract before comparison.
|
||||
*/
|
||||
class Quotation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
@@ -16,7 +19,7 @@ class Quotation extends Model
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['weights' => 'array', 'valid_until' => 'date:Y-m-d'];
|
||||
return ['weights' => 'array', 'commercial_terms' => 'array', 'valid_until' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Supplier, $this> */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $id
|
||||
* @property int $goods_receipt_id
|
||||
* @property string $path
|
||||
* @property string $sha256
|
||||
* @property string $original_name
|
||||
*/
|
||||
class ReceiptDocument extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['path', 'tenant_id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['created_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -4,12 +4,15 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\HasWorkContext;
|
||||
use App\Services\ConflictGuard;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tempo\Purchasing\Internal\InternalRequisitions;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -21,18 +24,22 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property int $subtotal_minor
|
||||
* @property int $tax_minor
|
||||
* @property int $total_minor
|
||||
* @property Carbon|null $submitted_at
|
||||
* @property Carbon|null $needed_by
|
||||
* @property array<string,mixed>|null $comparison_policy
|
||||
* @property Collection<int, ApprovalStep> $steps
|
||||
* @property Collection<int, RequisitionItem> $items
|
||||
*/
|
||||
class Requisition extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
use HasWorkContext;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_snapshot' => 'array', 'submitted_at' => 'datetime', 'needed_by' => 'date:Y-m-d'];
|
||||
return ['approval_snapshot' => 'array', 'comparison_policy' => 'array', 'submitted_at' => 'datetime', 'needed_by' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return HasMany<RequisitionItem, $this> */
|
||||
@@ -68,27 +75,23 @@ class Requisition extends Model
|
||||
/** @param Builder<Requisition> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
match ($user->purchasing_role) {
|
||||
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
|
||||
PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id),
|
||||
default => $query->where('requester_id', $user->id),
|
||||
};
|
||||
if ($user->departmentScoped()) {
|
||||
$query->where('department_id', $user->department_id);
|
||||
} elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
|
||||
$query->where('requester_id', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
public function canDecide(User $user): bool
|
||||
{
|
||||
if ($this->tenant_id !== $user->tenant_id || $this->status !== 'pending' || $this->requester_id === $user->id) {
|
||||
if ($this->tenant_id !== $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->steps->contains('approver_id', $user->id)) {
|
||||
return false;
|
||||
}
|
||||
$next = InternalRequisitions::nextStep(['status' => $this->status, 'requester_id' => $this->requester_id, 'steps' => $this->steps->toArray()], $user->id);
|
||||
|
||||
$next = $this->steps->firstWhere('decision', 'pending');
|
||||
|
||||
return $next !== null && $next->role === $user->purchasing_role->value
|
||||
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
|
||||
return $next !== null && $user->hasFunctions([$next['role']])
|
||||
&& ($next['role'] !== 'department_head' || $this->department_id === $user->department_id)
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user