This commit is contained in:
2026-11-02 08:10:00 +02:00
parent 504b01b244
commit b44378b899
14 changed files with 655 additions and 121 deletions
@@ -16,35 +16,38 @@ class AutomaticPurchaseController extends Controller
{
public function index(Request $request, AutomaticPurchases $service)
{
$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);
$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);
Inertia::encryptHistory();
return Inertia::render('stock/replenishment',[
'ready'=>$ready,'canPrepare'=>$prepare,'canReview'=>$planning->allows($actor,'supply_review'),
'rules'=>$ready?$planning->latest('auto_rule')->latest('id')->limit(100)->get():[],
'needs'=>$ready?$planning->latest('auto_need')->latest('id')->limit(100)->get():[],
'history'=>$ready?SupplyRecord::query()->whereIn('kind',['auto_rule','auto_need'])->latest('id')->limit(100)->get():[],
'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)=>in_array('requester',app(OrganizationAccess::class)->permissions($u,'procurement.internal'),true))->map(fn($u)=>$u->only(['id','name','department_id']))->values():[],
'centers'=>$prepare?CostCenter::query()->orderBy('code')->get(['id','department_id','code','name']):[],
])->toResponse($request)->withHeaders(['Cache-Control'=>'private, no-store, max-age=0']);
return Inertia::render('stock/replenishment', [
'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'),
'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->limit(100)->get() : [],
'needs' => $ready ? $planning->latest('auto_need')->latest('id')->limit(100)->get() : [],
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->limit(100)->get() : [],
'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']) : [],
])->toResponse($request)->withHeaders(['Cache-Control' => 'private, no-store, max-age=0']);
}
public function save(Request $request, AutomaticPurchases $service)
{
$service->save($request->user('web'),$request->all());
$service->save($request->user('web'), $request->all());
return redirect('/stock/reposicao');
}
public function evaluate(Request $request, int $id, AutomaticPurchases $service)
{
$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);
$service->evaluate($actor,$need->stock_item_id,$need->warehouse_id);
$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);
$service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
return redirect('/stock/reposicao');
}
}
+7 -3
View File
@@ -17,6 +17,10 @@ use Illuminate\Validation\ValidationException;
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
class AutomaticPurchases
{
public function eligibleRequester(User $user): bool
{
return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && !app(OrganizationAccess::class)->maintenanceOnly($user);
}
public function ready(): bool
{
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
@@ -29,7 +33,7 @@ class AutomaticPurchases
$this->check($current !== null && $current->id === $rule->id && $current->tenant_id === $executor->tenant_id && $current->state === 'approved', 'A parametrização automática deixou de estar activa.');
$this->check(! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
$user = User::query()->where('tenant_id', $executor->tenant_id)->lockForUpdate()->find($rule->data['requester_id']);
$this->check($user !== null && in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Requisitante sem competência vigente no contexto de compras internas.');
$this->check($user !== null && $this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
$this->check(CostCenter::query()->whereKey($rule->data['cost_center_id'])->where('department_id', $user->department_id)->exists(), 'Centro de custo incompatível com o departamento actual do requisitante.');
$this->check($user->only(['id', 'name', 'department_id']) === $rule->snapshot['requester'], 'O requisitante mudou; reveja a parametrização.');
$center = CostCenter::query()->findOrFail($rule->data['cost_center_id']);
@@ -69,7 +73,7 @@ class AutomaticPurchases
$this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.');
$this->check($basis['catalogue']['purchase_type'] === 'consumable', 'Seleccione um consumível publicado; equipamento/peças exigem o seu destino técnico.');
$user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($values['requester_id']);
$this->check(in_array('requester', app(OrganizationAccess::class)->permissions($user, 'procurement.internal'), true), 'Seleccione um requisitante com competência vigente em compras internas.');
$this->check($this->eligibleRequester($user), 'Seleccione um requisitante com competência vigente em compras internas.');
$center = CostCenter::query()->where('department_id', $user->department_id)->findOrFail($values['cost_center_id']);
$snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id,
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
@@ -176,7 +180,7 @@ continue;
$this->check($line->catalogue_entry_id === $basis['catalogue']['id'] && ($line->catalogue_snapshot['version'] ?? null) === $basis['catalogue']['version'], 'O rascunho automático anterior mudou de artigo; reconcilie a necessidade.');
$received = 0;
foreach (DB::table('goods_receipt_items as gi')->join('goods_receipts as g', 'g.id', '=', 'gi.goods_receipt_id')->join('purchase_order_items as oi', 'oi.id', '=', 'gi.purchase_order_item_id')->where('gi.tenant_id', $actor->tenant_id)->where('oi.requisition_item_id', $line->id)->where('g.warehouse_id', $warehouse)->where('gi.stock_item_id', $item)->get(['gi.id', 'gi.accepted_milli']) as $receipt) {
$voided = collect(app(InventoryReversals::class)->corrections('receipt'))->contains(fn ($correction) => ($correction->snapshot['source']['receipt']['id'] ?? null) === $receipt->id);
$voided = collect(app(InventoryReversals::class)->corrections('receipt'))->contains(fn ($correction) => ($correction->snapshot['source']['receipt']['item_id'] ?? null) === $receipt->id);
if (! $voided) {
$received += (int) $receipt->accepted_milli;
}
+1 -1
View File
@@ -439,7 +439,7 @@ class InventoryLots
'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $reason, 'created_by' => $actor->id,
'operation_key' => $operation ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'inventory.'.$kind.'.'.$state, null, $old?->toArray(), $record->toArray());
app(AutomaticPurchases::class)->signal($actor,$data['stock_item_id'],$data['warehouse_id']);
app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']);
return $record;
}
+1 -1
View File
@@ -224,7 +224,7 @@ class ReceiptWorkflow
]);
$sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']);
app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']);
app(AutomaticPurchases::class)->signal($user,$line['stock_item_id'],$warehouse->id);
app(AutomaticPurchases::class)->signal($user, $line['stock_item_id'], $warehouse->id);
}
}
$progress = $this->progress($order);
@@ -2,24 +2,39 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('warehouse_reservations',function(Blueprint $table) {
$table->id(); $table->foreignId('tenant_id')->constrained()->restrictOnDelete();
return new class extends Migration
{
public function up(): void
{
Schema::create('warehouse_reservations', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->foreignId('warehouse_request_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('line_index'); $table->foreignId('stock_item_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('line_index');
$table->foreignId('stock_item_id')->constrained()->restrictOnDelete();
$table->foreignId('warehouse_id')->constrained()->restrictOnDelete();
$table->foreignId('authorization_event_id')->constrained('warehouse_request_events')->restrictOnDelete();
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$table->uuid('command_key'); $table->string('kind'); $table->bigInteger('quantity_milli'); $table->timestampTz('created_at');
$table->unique(['warehouse_request_id','line_index','command_key','kind'],'warehouse_reservation_command_unique');
$table->index(['tenant_id','stock_item_id','warehouse_id']);
$table->uuid('command_key');
$table->string('kind');
$table->bigInteger('quantity_milli');
$table->timestampTz('created_at');
$table->unique(['warehouse_request_id', 'line_index', 'command_key', 'kind'], 'warehouse_reservation_command_unique');
$table->index(['tenant_id', 'stock_item_id', 'warehouse_id']);
});
if(DB::getDriverName()==='sqlite')DB::unprepared("CREATE TRIGGER warehouse_reservations_update BEFORE UPDATE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END; CREATE TRIGGER warehouse_reservations_delete BEFORE DELETE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END;");
if(DB::getDriverName()==='pgsql')DB::unprepared("CREATE FUNCTION preserve_warehouse_reservations() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Preservar reservas'; END $$; CREATE TRIGGER warehouse_reservations_immutable BEFORE UPDATE OR DELETE ON warehouse_reservations FOR EACH ROW EXECUTE FUNCTION preserve_warehouse_reservations();");
if (DB::getDriverName() === 'sqlite') {
DB::unprepared("CREATE TRIGGER warehouse_reservations_update BEFORE UPDATE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END; CREATE TRIGGER warehouse_reservations_delete BEFORE DELETE ON warehouse_reservations BEGIN SELECT RAISE(ABORT, 'Preservar reservas'); END;");
}
if (DB::getDriverName() === 'pgsql') {
DB::unprepared("CREATE FUNCTION preserve_warehouse_reservations() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Preservar reservas'; END $$; CREATE TRIGGER warehouse_reservations_immutable BEFORE UPDATE OR DELETE ON warehouse_reservations FOR EACH ROW EXECUTE FUNCTION preserve_warehouse_reservations();");
}
}
public function down(): void
{
Schema::dropIfExists('warehouse_reservations');
}
public function down(): void { Schema::dropIfExists('warehouse_reservations'); }
};
+8
View File
@@ -1,5 +1,13 @@
# Desenvolvimento do SGC
## Composição de arranque e prova HTTP isolada SGF — 02/11/2026
Implementados `BootOverlay`, `Composition`, `RuntimeContext` e entry point local do bundle. A sobreposição do provider nativo carrega classes e rotas do módulo; descoberta de Compras reconhece `purchasing_requisitions.view`. Middleware específico é acrescentado depois da sessão/autenticação/contexto nativos e confere opção, destino/schema/âmbito, módulos adquiridos e selecção/geração do contexto. Autoridade inicial mantém âmbito administrativo, sem exigir direito funcional ainda não atribuído. A CLI aceita/verifica o entry point `bootstrap.php` do bundle. Candidatas anteriores não foram reescritas.
Prova nova `scripts/sgf-composition-proof.php`: **27 controlos aprovados**, usando cópia descartável do SGF com dependências próprias, sem .env/dados/documentos/sessões da instalação existente, PostgreSQL `sgc_composition_<12hex>`/role restrita e APP_ENV production. Provider, Composer, kernel, rotas, autenticação/CSRF e administração nativos; router de prova não regista rotas ou classes do módulo. Conferidos arranque efectivo da cópia, CLI plan, configuração inicial, delegação, contexto, percurso de requisição/reserva/aprovação independente/consulta e bloqueios por revogação, opção diferente, contexto antigo, módulo não adquirido/desactivado. Sem browser e sem suites anteriores repetidas. [Prova](proofs/sgf-composition-delivery.json), candidata `OPERATIONAL-CANDIDATE-4b25f81932efd69d`. Base/role removidas; fonte/código/schema/concessões e ponteiro 0.1.1 do SGF de destino preservados.
Próxima acção concreta: rotina de aplicação atómica das sobreposições e código, backup/reversão de ficheiros do host e gestão de caches/reinício. O ensaio HTTP usa caches não pré-compilados; não declarar a instalação operacional completa nem activar no destino sem decisão separada. A reorganização modular do SGF continua entrega distinta e sem resultado próprio apresentado. Matriz e limites actualizados em [transferencia-sgf.md](transferencia-sgf.md).
## Composição orçamental e preparação de schema SGF — 01/11/2026
Continuação sem instalar/activar no destino: criada `integrations/sgf/operational`, com schema orçamental do contrato SGF existente, composição alternativa do mesmo BudgetAdapter/motor, verificação de destino/schema/âmbito e registo imutável `sgc_installations`. O preparador `scripts/prepare-sgf-operational.php` produz artefacto de revisão com sobreposições e suporte local, sem escrever na pasta SGF. `scripts/sgf-internal-install.php` oferece `plan` read-only e `migrate` de preparação do schema: confere hashes do host/manifesto/destino/backup, índice pg_restore, fundação e contrato de orçamento; DDL transaccional com lock e revisão/hashes/operador/role/backup. Não concede direitos, activa rotas ou troca o ponteiro. Sem down automático; schema parcial/incompatível é recusado. Fundação orçamental nova é criada vazia, sem fixtures.
+2
View File
@@ -1,5 +1,7 @@
# Continuidade do desenvolvimento do SGC
**Arranque e HTTP SGF — 02/11/2026:** candidata `OPERATIONAL-CANDIDATE-4b25f81932efd69d` integra bundle/rotas pelo provider nativo e associa `purchasing_requisitions.view` à descoberta de Compras. Novo middleware mantém contexto/opção/âmbito/schema, módulo adquirido/seleccionado e geração de contexto; autoridade inicial administrativa permanece sem outorga automática. 27 controlos novos HTTP aprovados em instalação com código e dependências copiados, APP_ENV production, base/role próprias removidas; sem repetir suites aceites ou usar browser. [Prova](proofs/sgf-composition-delivery.json). Nenhuma aplicação no destino, ponteiro 0.1.1 e candidatas antigas preservados. Pendência seguinte: aplicação atómica/reversão das sobreposições/código e caches/reinício, antes de propor activação. Não declarar instalação comercial concluída nem confundir esta integração com reorganização modular posterior, que mantém entrega própria pendente.
**Composição operacional/preparação de schema — 01/11/2026:** candidata `OPERATIONAL-CANDIDATE-29687a1c9bc0e0b5`, sem instalação no SGF. `scripts/prepare-sgf-operational.php` prepara artefacto; `scripts/sgf-internal-install.php` distingue plan read-only e migrate transaccional de schema, com destino/manifesto/backup conferidos, contrato orçamental e journal imutável. Mesmo motor e permissões, novo ramo operacional por destino/schema/âmbito explicitamente habilitados, sem obrigar nome de base QA ou ambiente de ensaio. 20 controlos novos + restauro em production/base não-QA aprovados em [proofs/sgf-operational-delivery.json](proofs/sgf-operational-delivery.json), sem repetir suites aceites. Não confundir preparação de schema com activação: não alterou código/provider/rotas/concessões/dados no SGF instalado; ponteiro 0.1.1 e candidatas anteriores preservados. Próximo passo é composição de arranque/rotas e prova HTTP numa cópia isolada. Não executar migrate no destino, activar 0.1.2/outra versão ou publicar sem a decisão separada. Reorganização modular mantém-se distinta e sem resultado próprio apresentado.
**Preparação de acessos SGF — 01/11/2026:** criada sobreposição candidata de catálogo/gestão de permissões e atribuição inicial nominativa/auditada, com rotas/ecrã separados, sem alterações no host instalado. `scripts/prepare-sgf-access.php` produz hashes antes/depois e recusa divergências; não é instalador. 24 controlos novos PostgreSQL aprovados em [proofs/sgf-access-delivery.json](proofs/sgf-access-delivery.json), sem repetir suites aceites. Preservados os direitos anteriores na criação de unidades; nova autoridade inicial exige acção explícita de administrador autorizado e âmbito habilitado, nunca outorga automática. SGF mantém 0.1.1, candidata IR imutável e rotas não activadas. Próximo trabalho: composição orçamental operacional, instalador de schema/arranque, integração HTTP da preparação e pacote SGF específico; continuar sem instalar/publicar por arrasto. Reorganização modular não se confunde com este incremento e continua sem resultado próprio apresentado.
+15 -1
View File
@@ -1,5 +1,19 @@
# Transferência de Compras e actualizações do SGF
## Arranque e integração HTTP em cópia isolada — 02/11/2026
Preparada a candidata `OPERATIONAL-CANDIDATE-4b25f81932efd69d`, preservando os artefactos anteriores. A composição já inclui sobreposição do provider nativo para carregar o bundle local e registar as rotas, sem registo manual de rotas pelo harness HTTP. O bundle fica em `packages/internal-requisitions`, com classes e ficheiros próprios; a sobreposição do SGF reconhece `purchasing_requisitions.view` como autorização de descoberta do módulo Compras, sem conceder direitos nem substituir o catálogo/controlo funcional.
`RuntimeContext` é acrescentado às rotas do bundle **depois** dos middlewares nativos de sessão/autenticação/empresa. Confere opção operacional habilitada, destino/schema/âmbito, módulos adquiridos e habilitados, selecção explícita de Compras e geração de contexto nos APIs. A atribuição inicial permanece no âmbito de Administração: não exige um direito funcional de requisição que ainda não foi outorgado, mas exige módulo adquirido, âmbito habilitado e os controlos nominativos/administrativos já implementados. Não cria autoridade a partir da selecção do módulo. A composição desactivada recusa a execução das rotas.
O ensaio `scripts/sgf-composition-proof.php` usa uma instalação descartável com código, dependências e packages **copiados**, provider/configuração/rotas do próprio SGF e bootstrap nativo, sem `.env`, contas, documentos ou dados empresariais copiados. Usa base `sgc_composition_<12hex>` e role próprias, ambiente `production`, sessões privadas e acesso HTTP loopback com login/CSRF reais. A preparação de dados sintéticos e a activação ocorrem exclusivamente nessa cópia/base; não são passos aplicados ao SGF de destino. O router de teste só entrega o pedido ao kernel: não regista rotas, não substitui autenticação e não carrega classes da instalação original.
Resultados efectivos, caminho do artefacto e verificações em [proofs/sgf-composition-delivery.json](proofs/sgf-composition-delivery.json). A prova verifica o incremento de composição/HTTP, incluindo autoridade inicial, administração nativa, contexto, submissão/reserva/aprovação/consulta e revogação; não repete as suites anteriores nem declara revisão visual por navegador. Os resultados históricos seguintes conservam o seu âmbito.
**Resultado final: 27 controlos novos aprovados.** Confirmados provider e adaptador efectivamente carregados da cópia isolada; `plan` aceite pelo verificador de integridade do novo bundle; acesso anónimo e CSRF bloqueados; autoridade inicial sem direitos funcionais prévios; atribuição pela administração nativa; selecção explícita de Compras; recusa de token antigo, modalidade diferente, revogação, módulo não adquirido e composição desactivada; rascunho → submissão com uma reserva existente → aprovação por outra identidade → consulta com três versões/eventos. Base e role descartáveis removidas. Nenhum registo de dados ou direito do SGF de destino alterado.
**Limites de entrega:** esta composição ainda não foi aplicada ao destino. Falta a rotina operacional de aplicação atómica das sobreposições/código, preservação/reversão dos ficheiros do host e gestão dos caches/reinício de serviços. O ensaio usa configuração sem cache; não declara validada a activação com cache de rotas/configuração pré-existente. As alterações de configuração da prova foram seguidas de reinício do serviço PHP. `plan`/`migrate` continuam a preparar/conferir schema, sem aplicar estas sobreposições ou activar rotas. Não substituir esse procedimento por extracção cega do bundle. SGF de destino mantém **0.1.1**, sem schema, permissões, provider ou rotas alterados. Activação continua dependente da decisão separada. A reorganização modular posterior é uma entrega distinta, não concluída por este incremento.
## Objectivo e estado em 01/11/2026
O foco autorizado pelo proprietário é transferir as capacidades de Compras para o SGF e levar as melhorias seguintes por versões verificáveis. Não é sincronizar bases de desenvolvimento nem manter duas implementações. O SGC autónomo conserva todas as suas funcionalidades; no SGF, Pessoas e contratações destina-se apenas ao uso interno. Cada cliente recebe as capacidades adquiridas, com configuração e permissões próprias.
@@ -30,7 +44,7 @@ Estados: **Transferido** identifica a capacidade exacta instalada e executada no
| Pagamentos e afectações a fornecedores | **Parcialmente transferido** | Núcleo comum e entregas anteriores preservados | Adaptadores/autorizações delimitados nas respectivas entregas; não representam migração de contas ou histórico de cliente | Percurso anterior existe no âmbito documentado; integração completa com novas origens de Compras pendente | `SUPPLIER_SETTLEMENT_DELIVERY.md` e limites de histórico/origens; não afirmar financeiro integral |
| Identidade, empresa/unidade, perfis e módulos adquiridos | **Parcialmente transferido** | Fundações próprias existentes nos dois hosts; não se fundiram tabelas-base | SGF mantém tenant/empresa fiscal/unidade/concessões; correspondência explícita com o contexto Purchasing por concluir | Sessão/contexto SGF existentes; selector SGC não foi incorporado automaticamente | Sem associação por igualdade de nome, email ou ID; parametrização comercial e permissões continuam distintas |
| Reorganização modular do SGF pedida posteriormente | **Pendente — sem resultado apresentado** | Entrega distinta da integração candidata de requisições | Não avaliada nem concluída por esta conferência | Não inferir reorganização entregue a partir dos ecrãs candidatos | Exige apresentação própria do âmbito, alterações e resultado; não executada por arrasto |
| Requisição interna: rascunho, submissão, aprovação independente e consulta | **Parcialmente transferido — candidata; não activa** | Motor único `InternalRequisitions`, adaptadores dos dois hosts; recibos, versões e histórico; reserva existente na mesma transacção | Contexto interno, empresa/unidade, autorização renovada, identidade independente e fontes explicitamente associadas; sem novas concessões no destino | Rascunho/edição, lista, submissão, decisão, histórico e parametrização na candidata; sem novo menu/rota activa no SGF | HTTP, concorrência, revogação, falha/recuperação e schema em bases isoladas; equipamentos/peças e instalação comercial pendentes; destino 0.1.1 |
| Requisição interna: rascunho, submissão, aprovação independente e consulta | **Parcialmente transferido — candidata; não activa** | Motor único `InternalRequisitions`, adaptadores dos dois hosts; recibos, versões e histórico; reserva existente na mesma transacção | Contexto interno, empresa/unidade, autorização renovada e identidade independente; composição no provider e associação do novo direito de consulta ao módulo preparadas | Ecrãs e percurso integrados na cópia isolada pelo arranque nativo; nenhuma rota/menu novo activo no destino | Provas anteriores preservadas; novo ensaio de composição HTTP em `sgf-composition-delivery.json`; aplicação operacional do código/caches e equipamentos/peças pendentes; destino 0.1.1 |
| Planeamento de compras | **Pendente** | Itens, marcos e associação a requisições permanecem em Compras | Adaptar unidades, centros, intervenientes e vínculos de origem no SGF | Planeamento e acompanhamento de marcos SGF do roteiro de Compras pendentes | Não gerar requisições duplicadas nem inferir reserva/decisão a partir de um plano |
| Fornecedores, documentos e dados bancários | **Pendente** | Cadastros/versionamento de Compras ainda não extraídos | Definir correspondências autorizadas de fontes e permissões, sem fundir entidades por nome | Cadastro, validações e consulta SGF de todo o percurso pendentes | Preservar fotografias utilizadas e validação bancária independente |
| Cotações, descontos e comparação económica | **Pendente** | Descontos e pontuação foram preparados na origem/candidata `0.1.2`; SGF activo não contém estas classes | Elegibilidade, conflitos, avaliações autorizadas e decisão continuam no host; adaptador do processo SGF não implementado | Recepção de cotações, grelha, empates, recomendação e decisão SGF pendentes | Testes locais não equivalem a transferência. Candidata não instalada; nenhuma adjudicação automática |
+6 -1
View File
@@ -298,7 +298,12 @@ export function procurementNavigation(
id: 'warehouse_replenishment',
label: 'Reposição automática',
path: '/stock/reposicao',
roles: ['admin', 'supply_read', 'supply_prepare', 'supply_review'],
roles: [
'admin',
'supply_read',
'supply_prepare',
'supply_review',
],
},
{
id: 'warehouse_requests',
+18 -3
View File
@@ -169,8 +169,17 @@ export default function Availability(props: Props) {
)
: 'Sem acesso ao planeamento'}
</td>
<td className="border-b p-3">{quantity(row.physical_commitment_milli)} / {quantity(row.free_milli)}</td>
<td className="border-b p-3">{quantity(row.minimum_milli)}</td>
<td className="border-b p-3">
{quantity(
row.physical_commitment_milli,
)}{' '}
/ {quantity(row.free_milli)}
</td>
<td className="border-b p-3">
{quantity(
row.minimum_milli,
)}
</td>
<td className="border-b p-3">
{!row.reconciled
? 'Reconciliar repartição dos lotes'
@@ -192,7 +201,13 @@ export default function Availability(props: Props) {
<p>Sem artigos nos filtros seleccionados.</p>
)}
<p className="mt-4 text-sm">
A compra automática usa a parametrização do artigo e armazém. Esta consulta não cria documentos. <Link href="/stock/reposicao">Consultar reposição e necessidades pendentes</Link>.
A compra automática usa a parametrização do
artigo e armazém. Esta consulta não cria
documentos.{' '}
<Link href="/stock/reposicao">
Consultar reposição e necessidades pendentes
</Link>
.
</p>
<div className="mt-4 flex gap-4">
{props.rows.prev_page_url && (
+431 -28
View File
@@ -1,32 +1,435 @@
import { Head, Link, useForm, router } from '@inertiajs/react';
import { PageTitle, Panel, FormErrors, controlClass } from '@/components/procurement';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
type RecordRow = { id:number; record_key:string; version:number; state:string; reason:string; created_at:string; data:{ policy_key?:string; requester_id?:number; cost_center_id?:number; unit_price?:string; tax_rate?:string; needed_days?:number; valid_until?:string; message?:string; purchases?:{id:number;number:string;quantity_milli:number}[] }; snapshot:{item:{id:number;code?:string;name?:string};warehouse:{id:number;name?:string};requester?:{name:string};cost_center?:{code:string;name:string};origins?:{warehouse_request_id:number;destination:string;equipment:{code:string;description:string}|null;remaining_milli:number}[]} };
type Props={ready:boolean;canPrepare:boolean;canReview:boolean;rules:RecordRow[];needs:RecordRow[];history:RecordRow[];policies:RecordRow[];requesters:{id:number;name:string;department_id:number}[];centers:{id:number;code:string;name:string;department_id:number}[]};
const states:Record<string,string>={draft:'Por activar',approved:'Activa',suspended:'Suspensa',pending:'Necessidade pendente',created:'Rascunho criado',covered:'Reposição em curso',satisfied:'Disponibilidade suficiente'};
function RuleForm({props,rule}:{props:Props;rule?:RecordRow}) {
const form=useForm({operation_key:crypto.randomUUID(),record_key:rule?.record_key??null,version:rule?.version??0,action:'save',reason:'',data:{policy_key:rule?.data.policy_key??'',requester_id:rule?.data.requester_id?.toString()??'',cost_center_id:rule?.data.cost_center_id?.toString()??'',unit_price:rule?.data.unit_price??'',tax_rate:rule?.data.tax_rate??'',needed_days:rule?.data.needed_days?.toString()??'',valid_until:rule?.data.valid_until??''}});
const requester=props.requesters.find(u=>u.id.toString()===form.data.data.requester_id);
const set=(key:keyof typeof form.data.data,value:string)=>form.setData('data',{...form.data.data,[key]:value,...(key==='requester_id'?{cost_center_id:''}:{})});
return <form className="grid gap-3 md:grid-cols-2" onSubmit={e=>{e.preventDefault();form.post('/stock/reposicao',{preserveState:'errors'});}}><FormErrors errors={form.errors}/>
<label>Política aprovada · artigo/armazém<select required className={controlClass} value={form.data.data.policy_key} onChange={e=>set('policy_key',e.target.value)}><option value="">Seleccione</option>{props.policies.map(p=><option key={p.record_key} value={p.record_key}>{p.snapshot.item.code} · {p.snapshot.item.name} · {p.snapshot.warehouse.name}</option>)}</select></label>
<label>Requisitante responsável<select required className={controlClass} value={form.data.data.requester_id} onChange={e=>set('requester_id',e.target.value)}><option value="">Seleccione</option>{props.requesters.map(u=><option key={u.id} value={u.id}>{u.name}</option>)}</select></label>
<label>Centro de custo<select required className={controlClass} value={form.data.data.cost_center_id} onChange={e=>set('cost_center_id',e.target.value)}><option value="">Seleccione</option>{props.centers.filter(c=>c.department_id===requester?.department_id).map(c=><option key={c.id} value={c.id}>{c.code} · {c.name}</option>)}</select></label>
<label>Preço estimado (MZN), se não existir no acordo<input inputMode="decimal" placeholder="Vazio: reutilizar acordo em MZN" className={controlClass} value={form.data.data.unit_price} onChange={e=>set('unit_price',e.target.value)}/></label>
<label>Imposto estimado (%), se diferente do parâmetro da empresa<input inputMode="decimal" placeholder="Vazio: reutilizar parâmetro da empresa" className={controlClass} value={form.data.data.tax_rate} onChange={e=>set('tax_rate',e.target.value)}/></label>
<label>Prazo pretendido após geração (dias)<input required type="number" min="0" max="366" className={controlClass} value={form.data.data.needed_days} onChange={e=>set('needed_days',e.target.value)}/></label>
<label>Validade desta parametrização e estimativa<input required type="date" className={controlClass} value={form.data.data.valid_until} onChange={e=>set('valid_until',e.target.value)}/></label>
<label>Fundamento<textarea required minLength={10} className={controlClass} value={form.data.reason} onChange={e=>form.setData('reason',e.target.value)}/></label>
<p className="text-sm md:col-span-2">Mínimo, alvo, máximo e embalagem são reutilizados da política aprovada. A activação exige outro responsável. Alterar uma regra exige nova activação e afecta apenas novas gerações; documentos existentes conservam a sua origem.</p><Button disabled={form.processing}>Guardar para activação</Button></form>;
type RecordRow = {
id: number;
record_key: string;
version: number;
state: string;
reason: string;
created_at: string;
data: {
policy_key?: string;
requester_id?: number;
cost_center_id?: number;
unit_price?: string;
tax_rate?: string;
needed_days?: number;
valid_until?: string;
message?: string;
purchases?: { id: number; number: string; quantity_milli: number }[];
};
snapshot: {
item: { id: number; code?: string; name?: string };
warehouse: { id: number; name?: string };
requester?: { name: string };
cost_center?: { code: string; name: string };
origins?: {
warehouse_request_id: number;
destination: string;
equipment: { code: string; description: string } | null;
remaining_milli: number;
}[];
};
};
type Props = {
ready: boolean;
canPrepare: boolean;
canReview: boolean;
rules: RecordRow[];
needs: RecordRow[];
history: RecordRow[];
policies: RecordRow[];
requesters: { id: number; name: string; department_id: number }[];
centers: {
id: number;
code: string;
name: string;
department_id: number;
}[];
};
const states: Record<string, string> = {
draft: 'Por activar',
approved: 'Activa',
suspended: 'Suspensa',
pending: 'Necessidade pendente',
created: 'Rascunho criado',
covered: 'Reposição em curso',
satisfied: 'Disponibilidade suficiente',
};
function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: rule?.record_key ?? null,
version: rule?.version ?? 0,
action: 'save',
reason: '',
data: {
policy_key: rule?.data.policy_key ?? '',
requester_id: rule?.data.requester_id?.toString() ?? '',
cost_center_id: rule?.data.cost_center_id?.toString() ?? '',
unit_price: rule?.data.unit_price ?? '',
tax_rate: rule?.data.tax_rate ?? '',
needed_days: rule?.data.needed_days?.toString() ?? '',
valid_until: rule?.data.valid_until ?? '',
},
});
const requester = props.requesters.find(
(u) => u.id.toString() === form.data.data.requester_id,
);
const set = (key: keyof typeof form.data.data, value: string) =>
form.setData('data', {
...form.data.data,
[key]: value,
...(key === 'requester_id' ? { cost_center_id: '' } : {}),
});
return (
<form
className="grid gap-3 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
form.post('/stock/reposicao', { preserveState: 'errors' });
}}
>
<FormErrors errors={form.errors} />
<label>
Política aprovada · artigo/armazém
<select
required
className={controlClass}
value={form.data.data.policy_key}
onChange={(e) => set('policy_key', e.target.value)}
>
<option value="">Seleccione</option>
{props.policies.map((p) => (
<option key={p.record_key} value={p.record_key}>
{p.snapshot.item.code} · {p.snapshot.item.name} ·{' '}
{p.snapshot.warehouse.name}
</option>
))}
</select>
</label>
<label>
Requisitante responsável
<select
required
className={controlClass}
value={form.data.data.requester_id}
onChange={(e) => set('requester_id', e.target.value)}
>
<option value="">Seleccione</option>
{props.requesters.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label>
Centro de custo
<select
required
className={controlClass}
value={form.data.data.cost_center_id}
onChange={(e) => set('cost_center_id', e.target.value)}
>
<option value="">Seleccione</option>
{props.centers
.filter(
(c) => c.department_id === requester?.department_id,
)
.map((c) => (
<option key={c.id} value={c.id}>
{c.code} · {c.name}
</option>
))}
</select>
</label>
<label>
Preço estimado (MZN), se não existir no acordo
<input
inputMode="decimal"
placeholder="Vazio: reutilizar acordo em MZN"
className={controlClass}
value={form.data.data.unit_price}
onChange={(e) => set('unit_price', e.target.value)}
/>
</label>
<label>
Imposto estimado (%), se diferente do parâmetro da empresa
<input
inputMode="decimal"
placeholder="Vazio: reutilizar parâmetro da empresa"
className={controlClass}
value={form.data.data.tax_rate}
onChange={(e) => set('tax_rate', e.target.value)}
/>
</label>
<label>
Prazo pretendido após geração (dias)
<input
required
type="number"
min="0"
max="366"
className={controlClass}
value={form.data.data.needed_days}
onChange={(e) => set('needed_days', e.target.value)}
/>
</label>
<label>
Validade desta parametrização e estimativa
<input
required
type="date"
className={controlClass}
value={form.data.data.valid_until}
onChange={(e) => set('valid_until', e.target.value)}
/>
</label>
<label>
Fundamento
<textarea
required
minLength={10}
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<p className="text-sm md:col-span-2">
Mínimo, alvo, máximo e embalagem são reutilizados da política
aprovada. A activação exige outro responsável. Alterar uma regra
exige nova activação e afecta apenas novas gerações; documentos
existentes conservam a sua origem.
</p>
<Button disabled={form.processing}>Guardar para activação</Button>
</form>
);
}
function Decision({rule,action}:{rule:RecordRow;action:'approve'|'suspend'}) {
const form=useForm({operation_key:crypto.randomUUID(),record_key:rule.record_key,version:rule.version,action,reason:'',data:{}});
return <form className="mt-3 flex flex-wrap gap-3" onSubmit={e=>{e.preventDefault();form.post('/stock/reposicao',{preserveState:'errors'});}}><FormErrors errors={form.errors}/><input aria-label="Fundamento da decisão" required minLength={10} placeholder="Fundamento da decisão" className={controlClass} value={form.data.reason} onChange={e=>form.setData('reason',e.target.value)}/><Button disabled={form.processing}>{action==='approve'?'Activar parametrização':'Suspender parametrização'}</Button></form>;
function Decision({
rule,
action,
}: {
rule: RecordRow;
action: 'approve' | 'suspend';
}) {
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: rule.record_key,
version: rule.version,
action,
reason: '',
data: {},
});
return (
<form
className="mt-3 flex flex-wrap gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post('/stock/reposicao', { preserveState: 'errors' });
}}
>
<FormErrors errors={form.errors} />
<input
aria-label="Fundamento da decisão"
required
minLength={10}
placeholder="Fundamento da decisão"
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
<Button disabled={form.processing}>
{action === 'approve'
? 'Activar parametrização'
: 'Suspender parametrização'}
</Button>
</form>
);
}
export default function Replenishment(props: Props) {
return (
<>
<Head title="Reposição automática" />
<div className="space-y-6 p-6">
<PageTitle
eyebrow="Armazém e compras"
title="Reposição automática"
description="Reutilizar a política aprovada e preparar a compra quando houver falta ou atingir o mínimo. As aprovações de compra mantêm-se."
/>
<div className="flex gap-4">
<Link href="/stock/disponibilidade">Disponibilidade</Link>
<Link href="/stock/requisicoes">
Requisições ao armazém
</Link>
<Link href="/compras/fornecimento?view=calendar">
Políticas de reposição
</Link>
</div>
{!props.ready ? (
<Panel title="Instalação pendente">
<p>
Instale a reserva e reposição para activar estas
regras.
</p>
</Panel>
) : (
<>
{props.canPrepare && (
<Panel title="Parametrizar artigo e armazém">
<RuleForm props={props} />
</Panel>
)}
<Panel title="Parametrizações">
{props.rules.length === 0 ? (
<p>
Sem parametrizações. Não são assumidos
requisitantes ou centros de custo.
</p>
) : (
props.rules.map((r) => (
<div
className="mb-4 rounded border p-4"
key={r.id}
>
<p className="font-medium">
{r.snapshot.item.code} ·{' '}
{r.snapshot.item.name} ·{' '}
{r.snapshot.warehouse.name}
</p>
<p>
{states[r.state]} · Versão{' '}
{r.version} ·{' '}
{r.snapshot.requester?.name} ·{' '}
{r.snapshot.cost_center?.code}
</p>
<p>
Preço estimado {r.data.unit_price}{' '}
MZN · Imposto {r.data.tax_rate}% ·
Validade {r.data.valid_until}
</p>
{props.canReview &&
r.state === 'draft' && (
<Decision
key={`approve:${r.id}`}
rule={r}
action="approve"
/>
)}
{props.canReview &&
r.state === 'approved' && (
<Decision
key={`suspend:${r.id}`}
rule={r}
action="suspend"
/>
)}
{props.canPrepare && (
<details className="mt-3">
<summary>
Rever parametrização
</summary>
<RuleForm
key={r.id}
props={props}
rule={r}
/>
</details>
)}
</div>
))
)}
</Panel>
<Panel title="Necessidades e compras associadas">
{props.needs.length === 0 ? (
<p>
Sem necessidades registadas após alterações
de stock ou activação de regras.
</p>
) : (
props.needs.map((n) => (
<div
key={n.id}
className="mb-4 rounded border p-4"
>
<p className="font-medium">
Artigo #{n.snapshot.item.id} ·
Armazém #{n.snapshot.warehouse.id} ·{' '}
{states[n.state]}
</p>
<p>{n.data.message}</p>
{n.snapshot.origins?.map((o, i) => (
<p key={i}>
<Link
href={`/stock/requisicoes/${o.warehouse_request_id}`}
>
RA-{o.warehouse_request_id}
</Link>{' '}
· {o.destination}
{o.equipment
? ` · ${o.equipment.code} · ${o.equipment.description}`
: ''}{' '}
· Pendente{' '}
{o.remaining_milli / 1000}
</p>
))}
{n.data.purchases?.map((p) => (
<p key={p.id}>
<Link
href={`/requisicoes/${p.id}`}
>
{p.number}
</Link>{' '}
· Quantidade original{' '}
{p.quantity_milli / 1000}
</p>
))}
{(props.canPrepare ||
props.canReview) && (
<Button
variant="outline"
className="mt-3"
onClick={() =>
router.post(
`/stock/reposicao/${n.id}/avaliar`,
{},
{
preserveState:
'errors',
},
)
}
>
Reavaliar necessidade
</Button>
)}
</div>
))
)}
</Panel>
<Panel title="Histórico">
<details>
<summary>
Consultar versões e fundamentos
</summary>
{props.history.map((h) => (
<p key={h.id} className="mt-2">
#{h.id} · {h.created_at} · Versão{' '}
{h.version} · {states[h.state]} ·{' '}
{h.reason}
</p>
))}
</details>
</Panel>
</>
)}
</div>
</>
);
}
export default function Replenishment(props:Props){return <><Head title="Reposição automática"/><div className="space-y-6 p-6"><PageTitle eyebrow="Armazém e compras" title="Reposição automática" description="Reutilizar a política aprovada e preparar a compra quando houver falta ou atingir o mínimo. As aprovações de compra mantêm-se."/><div className="flex gap-4"><Link href="/stock/disponibilidade">Disponibilidade</Link><Link href="/stock/requisicoes">Requisições ao armazém</Link><Link href="/compras/fornecimento?view=calendar">Políticas de reposição</Link></div>
{!props.ready?<Panel title="Instalação pendente"><p>Instale a reserva e reposição para activar estas regras.</p></Panel>:<>
{props.canPrepare&&<Panel title="Parametrizar artigo e armazém"><RuleForm props={props}/></Panel>}
<Panel title="Parametrizações">{props.rules.length===0?<p>Sem parametrizações. Não são assumidos requisitantes ou centros de custo.</p>:props.rules.map(r=><div className="mb-4 rounded border p-4" key={r.id}><p className="font-medium">{r.snapshot.item.code} · {r.snapshot.item.name} · {r.snapshot.warehouse.name}</p><p>{states[r.state]} · Versão {r.version} · {r.snapshot.requester?.name} · {r.snapshot.cost_center?.code}</p><p>Preço estimado {r.data.unit_price} MZN · Imposto {r.data.tax_rate}% · Validade {r.data.valid_until}</p>{props.canReview&&r.state==='draft'&&<Decision key={`approve:${r.id}`} rule={r} action="approve"/>}{props.canReview&&r.state==='approved'&&<Decision key={`suspend:${r.id}`} rule={r} action="suspend"/>}{props.canPrepare&&<details className="mt-3"><summary>Rever parametrização</summary><RuleForm key={r.id} props={props} rule={r}/></details>}</div>)}</Panel>
<Panel title="Necessidades e compras associadas">{props.needs.length===0?<p>Sem necessidades registadas após alterações de stock ou activação de regras.</p>:props.needs.map(n=><div key={n.id} className="mb-4 rounded border p-4"><p className="font-medium">Artigo #{n.snapshot.item.id} · Armazém #{n.snapshot.warehouse.id} · {states[n.state]}</p><p>{n.data.message}</p>{n.snapshot.origins?.map((o,i)=><p key={i}><Link href={`/stock/requisicoes/${o.warehouse_request_id}`}>RA-{o.warehouse_request_id}</Link> · {o.destination}{o.equipment?` · ${o.equipment.code} · ${o.equipment.description}`:''} · Pendente {o.remaining_milli/1000}</p>)}{n.data.purchases?.map(p=><p key={p.id}><Link href={`/requisicoes/${p.id}`}>{p.number}</Link> · Quantidade original {p.quantity_milli/1000}</p>)}{(props.canPrepare||props.canReview)&&<Button variant="outline" className="mt-3" onClick={()=>router.post(`/stock/reposicao/${n.id}/avaliar`,{}, {preserveState:'errors'})}>Reavaliar necessidade</Button>}</div>)}</Panel>
<Panel title="Histórico"><details><summary>Consultar versões e fundamentos</summary>{props.history.map(h=><p key={h.id} className="mt-2">#{h.id} · {h.created_at} · Versão {h.version} · {states[h.state]} · {h.reason}</p>)}</details></Panel></>}
</div></>}
+25 -7
View File
@@ -278,8 +278,14 @@ export default function WarehouseRequest(props: Props) {
<td className="border-b p-3">
{p.delivered[index] / 1000}
</td>
<td className="border-b p-3">{p.reservation_ready ? p.reserved[index] / 1000 : 'Por instalar'}</td>
<td className="border-b p-3">{p.cancelled[index] / 1000}</td>
<td className="border-b p-3">
{p.reservation_ready
? p.reserved[index] / 1000
: 'Por instalar'}
</td>
<td className="border-b p-3">
{p.cancelled[index] / 1000}
</td>
<td className="border-b p-3">
{p.remaining[index] / 1000}
</td>
@@ -289,7 +295,9 @@ export default function WarehouseRequest(props: Props) {
</table>
</div>
<p className="mt-4 text-sm">
{p.reservation_ready ? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.' : 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
{p.reservation_ready
? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.'
: 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
</p>
</Panel>
{!props.ready ? (
@@ -326,10 +334,20 @@ export default function WarehouseRequest(props: Props) {
action="deliver"
/>
)}
{props.canManageReservation && p.reservation_ready && <>
<Command key={`reserve:${p.version}`} props={props} action="reserve" />
<Command key={`cancel:${p.version}`} props={props} action="cancel" />
</>}
{props.canManageReservation && p.reservation_ready && (
<>
<Command
key={`reserve:${p.version}`}
props={props}
action="reserve"
/>
<Command
key={`cancel:${p.version}`}
props={props}
action="cancel"
/>
</>
)}
</div>
)}
<Panel title="Decisões, entregas e recepção">
+53 -45
View File
@@ -1,7 +1,6 @@
<?php
use App\Models\Requisition;
use App\Models\User;
use App\Services\AutomaticPurchases;
use App\Services\RequisitionWorkflow;
use App\Services\StockWorkflow;
@@ -14,76 +13,85 @@ use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/SuggestionFixture.php';
beforeEach(function () {
$this->f=suggestionFixture();
$this->f = suggestionFixture();
app(SupplyPlanning::class)->save($this->f['receiver'], 'policy', supplyInput([], 'suspend', $this->f['policy']));
$this->actingAs($this->f['issuer']);
$this->input=['operation_key'=>(string)Str::uuid(),'version'=>0,'action'=>'save','reason'=>'Parametrização autorizada para o ensaio isolado.',
'data'=>['policy_key'=>$this->f['policy']->record_key,'requester_id'=>$this->f['issuer']->id,'cost_center_id'=>$this->f['r']->cost_center_id,'unit_price'=>null,'tax_rate'=>null,'needed_days'=>5,'valid_until'=>today()->addMonth()->toDateString()]];
$policy=app(SupplyPlanning::class)->save($this->f['issuer'],'policy',supplyInput([...supplyPolicy($this->f),'purpose'=>'internal','agreement_key'=>$this->f['agreement']->record_key]));
$this->actingAs($this->f['receiver']);
$this->f['policy']=app(SupplyPlanning::class)->save($this->f['receiver'],'policy',supplyInput([],'approve',$policy));
$this->actingAs($this->f['issuer']);
$this->input = ['operation_key' => (string) Str::uuid(), 'version' => 0, 'action' => 'save', 'reason' => 'Parametrização autorizada para o ensaio isolado.',
'data' => ['policy_key' => $this->f['policy']->record_key, 'requester_id' => $this->f['issuer']->id, 'cost_center_id' => $this->f['r']->cost_center_id, 'unit_price' => null, 'tax_rate' => null, 'needed_days' => 5, 'valid_until' => today()->addMonth()->toDateString()]];
});
function activateAutomaticRule($test) {
function activateAutomaticRule($test)
{
$test->actingAs($test->f['issuer']);
$rule=app(AutomaticPurchases::class)->save($test->f['issuer'],$test->input);
$rule = app(AutomaticPurchases::class)->save($test->f['issuer'], $test->input);
$test->actingAs($test->f['receiver']);
return app(AutomaticPurchases::class)->save($test->f['receiver'],['operation_key'=>(string)Str::uuid(),'record_key'=>$rule->record_key,'version'=>$rule->version,'action'=>'approve','reason'=>'Activação independente da parametrização verificada.','data'=>[]]);
return app(AutomaticPurchases::class)->save($test->f['receiver'], ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => $rule->version, 'action' => 'approve', 'reason' => 'Activação independente da parametrização verificada.', 'data' => []]);
}
function automaticIssue($test,string $quantity) {
function automaticIssue($test, string $quantity)
{
$test->actingAs($test->f['receiver']);
return app(StockWorkflow::class)->record($test->f['receiver'],['kind'=>'issue','reference'=>'TEST-'.Str::uuid(),'idempotency_key'=>(string)Str::uuid(),'responsible_id'=>$test->f['issuer']->id,'warehouse_id'=>$test->f['warehouse']->id,'stock_item_id'=>$test->f['stock']->id,'quantity'=>$quantity,'destination'=>'Manutenção interna','reason'=>'Consumo físico confirmado no ensaio isolado.']);
return app(StockWorkflow::class)->record($test->f['receiver'], ['kind' => 'issue', 'reference' => 'TEST-'.Str::uuid(), 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $test->f['issuer']->id, 'warehouse_id' => $test->f['warehouse']->id, 'stock_item_id' => $test->f['stock']->id, 'quantity' => $quantity, 'destination' => 'Manutenção interna', 'reason' => 'Consumo físico confirmado no ensaio isolado.']);
}
test('automatic purchase reuses approved policy catalogue agreement and tax while preserving human submission and approvals',function(){
$rule=activateAutomaticRule($this);
test('automatic purchase reuses approved policy catalogue agreement and tax while preserving human submission and approvals', function () {
$rule = activateAutomaticRule($this);
expect($rule->data['unit_price'])->toBe('10.00');
$count=Requisition::query()->count();
automaticIssue($this,'100.000');
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
$need=app(SupplyPlanning::class)->latest('auto_need')->sole();
$r=Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
expect(Requisition::query()->count())->toBe($count+1)->and($r->status)->toBe('draft')->and($r->requester_id)->toBe($this->f['issuer']->id)->and($r->cost_center_id)->toBe($this->f['r']->cost_center_id)
$count = Requisition::query()->count();
automaticIssue($this, '100.000');
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
$r = Requisition::query()->findOrFail($need->data['purchases'][0]['id']);
expect(Requisition::query()->count())->toBe($count + 1)->and($r->status)->toBe('draft')->and($r->requester_id)->toBe($this->f['issuer']->id)->and($r->cost_center_id)->toBe($this->f['r']->cost_center_id)
->and($r->items()->sole()->quantity)->toBe('84.000')->and($r->steps()->count())->toBe(0)->and(DB::table('budget_entries')->count())->toBe(0)
->and(DB::table('audit_logs')->where('action','requisition.automatically_created')->value('user_id'))->toBe($this->f['receiver']->id);
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
expect(Requisition::query()->count())->toBe($count+1)->and(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('covered');
->and(DB::table('audit_logs')->where('action', 'requisition.automatically_created')->value('user_id'))->toBe($this->f['receiver']->id);
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
expect(Requisition::query()->count())->toBe($count + 1)->and(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('covered');
$this->actingAs($this->f['issuer']);
app(RequisitionWorkflow::class)->submit($this->f['issuer'],$r);
app(RequisitionWorkflow::class)->submit($this->f['issuer'], $r);
expect($r->fresh()->status)->toBe('pending')->and($r->steps()->first()->role)->toBe('department_head');
});
test('missing configuration records a pending need and never invents a requester or budget',function(){
$before=Requisition::query()->count();
automaticIssue($this,'120.000');
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
$need=app(SupplyPlanning::class)->latest('auto_need')->sole();
test('missing configuration records a pending need and never invents a requester or budget', function () {
$before = Requisition::query()->count();
automaticIssue($this, '120.000');
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
$need = app(SupplyPlanning::class)->latest('auto_need')->sole();
expect($need->state)->toBe('pending')->and($need->data['purchases'])->toBe([])->and($need->data['message'])->toContain('Parametrize')->and(Requisition::query()->count())->toBe($before);
});
test('a preparer cannot activate their own rule and stale or changed commands do not create duplicate rules',function(){
supplyGrant($this->f['issuer'],['supply_review']);
$rule=app(AutomaticPurchases::class)->save($this->f['issuer'],$this->input);
expect(app(AutomaticPurchases::class)->save($this->f['issuer'],$this->input)->id)->toBe($rule->id);
$decision=['operation_key'=>(string)Str::uuid(),'record_key'=>$rule->record_key,'version'=>1,'action'=>'approve','reason'=>'Tentativa de activar a própria regra.','data'=>[]];
expect(fn()=>app(AutomaticPurchases::class)->save($this->f['issuer'],$decision))->toThrow(ValidationException::class);
$this->input['data']['needed_days']=9;
expect(fn()=>app(AutomaticPurchases::class)->save($this->f['issuer'],$this->input))->toThrow(ValidationException::class);
test('a preparer cannot activate their own rule and stale or changed commands do not create duplicate rules', function () {
supplyGrant($this->f['issuer'], ['supply_review']);
$rule = app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input);
expect(app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input)->id)->toBe($rule->id);
$decision = ['operation_key' => (string) Str::uuid(), 'record_key' => $rule->record_key, 'version' => 1, 'action' => 'approve', 'reason' => 'Tentativa de activar a própria regra.', 'data' => []];
expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $decision))->toThrow(ValidationException::class);
$this->input['data']['needed_days'] = 9;
expect(fn () => app(AutomaticPurchases::class)->save($this->f['issuer'], $this->input))->toThrow(ValidationException::class);
$this->actingAs($this->f['receiver']);
$decision['version']=0;
expect(fn()=>app(AutomaticPurchases::class)->save($this->f['receiver'],$decision))->toThrow(ValidationException::class);
$decision['version'] = 0;
expect(fn () => app(AutomaticPurchases::class)->save($this->f['receiver'], $decision))->toThrow(ValidationException::class);
});
test('expired or changed sources keep the need pending without preventing the physical operation',function(){
test('expired or changed sources keep the need pending without preventing the physical operation', function () {
activateAutomaticRule($this);
$before=Requisition::query()->count();
$before = Requisition::query()->count();
$this->travel(32)->days();
automaticIssue($this,'120.000');
app(AutomaticPurchases::class)->evaluate($this->f['receiver'],$this->f['stock']->id,$this->f['warehouse']->id);
automaticIssue($this, '120.000');
app(AutomaticPurchases::class)->evaluate($this->f['receiver'], $this->f['stock']->id, $this->f['warehouse']->id);
expect(app(SupplyPlanning::class)->latest('auto_need')->sole()->state)->toBe('pending')->and(Requisition::query()->count())->toBe($before)
->and(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(0);
->and(app(StockWorkflow::class)->balance($this->f['stock']->id, $this->f['warehouse']->id)['quantity'])->toBe(0);
});
test('configuration UI separates preparation review and read and does not generate purchases on GET',function(){
$before=Requisition::query()->count();
$this->get('/stock/reposicao')->assertOk()->assertInertia(fn(Assert $p)=>$p->component('stock/replenishment')->where('canPrepare',true)->where('canReview',false)->has('requesters',1));
$this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn(Assert $p)=>$p->where('canPrepare',false)->where('canReview',true)->has('requesters',0));
test('configuration UI separates preparation review and read and does not generate purchases on GET', function () {
$before = Requisition::query()->count();
$this->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/replenishment')->where('canPrepare', true)->where('canReview', false)->has('requesters', 1));
$this->actingAs($this->f['receiver'])->get('/stock/reposicao')->assertOk()->assertInertia(fn (Assert $p) => $p->where('canPrepare', false)->where('canReview', true)->has('requesters',0));
expect(Requisition::query()->count())->toBe($before);
});
+39
View File
@@ -59,6 +59,45 @@ test('internal request is the exit document and partial deliveries consume only
$this->get('/stock/requisicoes/'.$this->r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->component('stock/request')->where('progress.state', 'closed')->where('progress.delivered.0', 4000));
});
test('reservations protect stock from unrelated issues and cancellation releases only the undelivered balance', function () {
warehouseCommand($this, $this->f['issuer'], 'submit', ['partial_allowed' => true]);
warehouseCommand($this, $this->reviewer, 'authorize', ['partial_allowed' => true]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([4000]);
$this->actingAs($this->f['receiver']);
$issue=['kind'=>'issue','reference'=>'UNRELATED','idempotency_key'=>(string)Str::uuid(),'responsible_id'=>$this->f['issuer']->id,'warehouse_id'=>$this->f['warehouse']->id,'stock_item_id'=>$this->f['stock']->id,'quantity'=>'7.000','destination'=>'Outra oficina','reason'=>'Tentativa de saída que consumiria reserva.'];
expect(fn()=>app(StockWorkflow::class)->record($this->f['receiver'],$issue))->toThrow(\Illuminate\Validation\ValidationException::class);
expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(10000);
warehouseCommand($this,$this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'1.500']]]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([2500]);
$cancel=warehouseCommand($this,$this->reviewer,'cancel');
$this->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',$cancel)->assertRedirect();
$p=app(WarehouseFulfilment::class)->progress($this->r);
expect($p['state'])->toBe('cancelled')->and($p['reserved'])->toBe([0])->and($p['cancelled'])->toBe([2500])->and($p['remaining'])->toBe([0]);
$this->actingAs($this->f['receiver']);
app(StockWorkflow::class)->record($this->f['receiver'],$issue);
expect(app(StockWorkflow::class)->balance($this->f['stock']->id,$this->f['warehouse']->id)['quantity'])->toBe(1500);
});
test('competing integral authorizations do not overbook and partial reservations can be replenished explicitly', function () {
warehouseCommand($this,$this->f['issuer'],'submit');
warehouseCommand($this,$this->reviewer,'authorize');
$original=$this->r;
$this->actingAs($this->f['issuer']);
$data=$this->draftData;$data['operation_key']=(string)Str::uuid();$data['lines'][0]['quantity']='8.000';
$this->r=app(WarehouseRequests::class)->draft($this->f['issuer'],$data);
warehouseCommand($this,$this->f['issuer'],'submit',['partial_allowed'=>true]);
$this->actingAs($this->reviewer)->postJson('/stock/requisicoes/'.$this->r->id.'/eventos',['operation_key'=>(string)Str::uuid(),'version'=>1,'action'=>'authorize','reason'=>'Tentativa de reservar mais que o stock livre.'])->assertUnprocessable();
expect(app(WarehouseFulfilment::class)->progress($this->r)['state'])->toBe('submitted');
warehouseCommand($this,$this->reviewer,'authorize',['partial_allowed'=>true]);
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([6000]);
$second=$this->r;$this->r=$original;
warehouseCommand($this,$this->reviewer,'cancel');
$this->r=$second;
warehouseCommand($this,$this->reviewer,'reserve');
expect(app(WarehouseFulfilment::class)->progress($this->r)['reserved'])->toBe([8000]);
expect(fn()=>DB::table('warehouse_reservations')->where('warehouse_request_id',$second->id)->update(['quantity_milli'=>0]))->toThrow(\Illuminate\Database\QueryException::class);
});
test('warehouse authorization rejects self decision stale version and unauthorized physical delivery', function () {
supplyGrant($this->f['issuer'], ['warehouse_request_review']);
warehouseCommand($this, $this->f['issuer'], 'submit');