Accept isolated budget versus actual trial with documented capture limitation
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# SGF Tempo — instruções locais
|
||||
|
||||
- Aceitação específica de 28/10/2026: utilizador aceitou o ensaio de orçamento versus realizado com limitação das capturas documentada e autorizou checkpoint exclusivamente local, sem credenciais/dados privados. Ver `docs/INDUSTRIAL_BUDGET_ACCEPTANCE.md`. Não transformar provas pendentes em realizadas; automação Edge bloqueada e capturas não recebidas permanecem limitações. Substitui apenas a pendência de aceitação/encerramento. `110000`, sete concessões e utilização real continuam por autorizar; sem aplicação ou publicação. Próxima prioridade: página inicial e menus agrupados, preservando entregas e direitos existentes.
|
||||
|
||||
- Autorização de 28/10/2026: implementar o ensaio de orçamento versus realizado da secção 16 de `docs/INDUSTRIAL_COST_ACCOUNTING_SCOPE.md`, só sintético em `sgf_tempo_test`, preservando `cbdf2b4`, `4a94e8a` e alterações posteriores. Orçamento aprovado como hipótese: 900 material + 600 trabalho + 300 indirectos imputados; comparar versão validada de 1000/500/300, 10 UN, MZN, mesmo produto/lote/período, com desvios +100/−100/0 e total 0; residual 300 separado. Osmane prepara; Samira valida orçamento exacto por identidade distinta e consulta comparação. Direitos próprios, sem herança ou concessões reais. Sem taxa material: 800 conhecidos, comparação incompleta, nunca poupança. Preservar versões/origens e bloqueios SQL/aplicação. Migração `110000` e sete concessões apenas propostas, NÃO aplicar em desenvolvimento. Sem dependências novas, stock, lançamentos, facturação, publicação ou aceitação presumida; checkpoint de encerramento só após aceitação. Ver `docs/INDUSTRIAL_BUDGET_DELIVERY.md` para estado e provas.
|
||||
|
||||
- Autorização específica de 28/10/2026 executada: apenas `100000` aplicada em `sgf_tempo_dev`, por caminho explícito com `sgf_dev_owner`, e quatro concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `industrial_costs.view/prepare`; conta #2 `industrial_costs.view/validate`. Ver actualização de `docs/INDUSTRIAL_COST_DELIVERY.md`. Preservadas 66 tabelas anteriores e acessos; oito tabelas industriais vazias. Aplicação mantém `sgf_dev_app`; custeio industrial/directo, oficinas, recebimentos e emissão reais bloqueados, com guardas SQL verificadas. Sem utilização real ou publicação. Indicações anteriores de `100000`/concessões por autorizar são históricas; esta instalação não activa operações reais.
|
||||
|
||||
- Aceitação específica de 28/10/2026: o utilizador aceitou `INDUSTRIAL-COST-SIM-v1` e autorizou checkpoint Git exclusivamente local, sem credenciais ou dados reais. Ver `docs/INDUSTRIAL_COST_ACCEPTANCE.md`. Substitui apenas a pendência de aceitação/encerramento técnico do ensaio. Migração `100000`, quatro concessões e utilização real permanecem por autorizar; sem aplicação, activação operacional, publicação ou novo incremento.
|
||||
|
||||
@@ -121,4 +121,6 @@ O primeiro núcleo de cálculo de serviço e a política operacional de truncage
|
||||
O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md).
|
||||
## Ensaio isolado de custo directo
|
||||
|
||||
O ensaio de orçamento versus realizado está descrito em [INDUSTRIAL_BUDGET_DELIVERY.md](docs/INDUSTRIAL_BUDGET_DELIVERY.md), com percurso, estado das provas e propostas separadas. Continua limitado a `sgf_tempo_test`; a instalação normal e os acessos novos não são aplicados por arrasto.
|
||||
|
||||
O percurso `WORKSHOP-DIRECT-COST-SIM-v1` e as instruções de prova estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md). Reutiliza apontamentos verificados sem duplicar consumos; não activa custeio real. Migração `090000` e quatro concessões continuam propostas, não aplicadas.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace App\Modules\Industry\Http;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Industry\IndustrialBudgets;
|
||||
use Illuminate\Http\Request;
|
||||
final class IndustrialBudgetsController extends Controller {
|
||||
private function response(array $d){return response()->json($d)->header('Cache-Control','no-store, private');}
|
||||
public function index(Request $r,IndustrialBudgets $c){return $this->response($c->listing($r->user()->id));}
|
||||
public function propose(Request $r,int $id,IndustrialBudgets $c){return $this->response($c->propose($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
|
||||
public function decide(Request $r,int $id,IndustrialBudgets $c){return $this->response($c->decide($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
|
||||
public function compare(Request $r,int $id,IndustrialBudgets $c){return $this->response($c->compare($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
namespace App\Modules\Industry;
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Modules\Services\ServiceCalculation;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Costs\ExactCostMath as Exact;
|
||||
use Brick\Math\BigRational as N;
|
||||
use Illuminate\Support\Facades\{DB,Validator};
|
||||
|
||||
final class IndustrialBudgets {
|
||||
public const POLICY='INDUSTRIAL-BUDGET-ACTUAL-SIM-v1';
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions,private IndustrialCosts $costs){}
|
||||
public static function guard(): void {IndustrialCosts::guard();}
|
||||
private function json($v): string {return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
|
||||
private function canonical($v){if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
|
||||
private function fields(array $in,array $rules): array {abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do ensaio.');return Validator::make($in,$rules)->validate();}
|
||||
private function scope(int $actor,string $right): array {
|
||||
$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']!==null,403);
|
||||
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(['industrial_budgets.view','industrial_costs.view',$right] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);return $s;
|
||||
}
|
||||
private function lot(int $id,array $s): object {return DB::table('ic_lots')->where($s)->where('id',$id)->first()??abort(403,'Lote fora do contexto.');}
|
||||
private function cmd(int $lot,int $actor,string $action,array $in,string $key,callable $write): array {
|
||||
self::guard();$right=match($action){'propose'=>'industrial_budgets.prepare','decide'=>'industrial_budgets.validate','compare'=>'industrial_budget_comparisons.prepare'};
|
||||
$s=$this->scope($actor,$right);if($action==='compare')$this->scope($actor,'industrial_budget_comparisons.view');$this->lot($lot,$s);
|
||||
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de idempotência necessária.');$hash=hash('sha256',$this->json($this->canonical($in)));
|
||||
return DB::transaction(function()use($lot,$actor,$action,$in,$key,$write,$hash,$s,$right){
|
||||
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$right);if($action==='compare')$this->scope($actor,'industrial_budget_comparisons.view');$this->lot($lot,$s);
|
||||
$old=DB::table('ib_commands')->where(['lot_id'=>$lot,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave repetida com conteúdo diferente.');return json_decode($old->result,true)+['replayed'=>true];}
|
||||
$out=$write($s);$cmd=DB::table('ib_commands')->insertGetId(['lot_id'=>$lot,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);
|
||||
Audit::record('industrial_budgets.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]);
|
||||
$event=DB::table('ib_events')->insertGetId(['command_id'=>$cmd,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$out]),'created_at'=>now()]);
|
||||
DB::table('ib_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $out+['replayed'=>false];
|
||||
},3);
|
||||
}
|
||||
private function next(string $table,int $lot,array $d): void {
|
||||
$last=DB::table($table)->where('lot_id',$lot)->orderByDesc('version')->first();
|
||||
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Versão anterior mudou; rever explicitamente.');
|
||||
}
|
||||
private function row(string $table,int $lot,int $id,int $version): object {return DB::table($table)->where(['id'=>$id,'lot_id'=>$lot,'version'=>$version])->first()??abort(409,'Referência ou versão incompatível.');}
|
||||
private function current(string $table,object $r): bool {return !DB::table($table)->where('previous_id',$r->id)->exists();}
|
||||
public function dimensions(object $lot): array {return ['tenant_id'=>$lot->tenant_id,'company_id'=>$lot->company_id,'unit_id'=>$lot->unit_id,'product_id'=>$lot->product_id,'lot_id'=>$lot->id,'quantity'=>'10','unit'=>'UN','currency'=>$lot->currency,'period_start'=>$lot->period_start,'period_end'=>$lot->period_end,'scope'=>'material_labour_allocated'];}
|
||||
public function propose(int $lot,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->fields($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','dimensions'=>'required|array','amounts'=>'required|array','evidence'=>'required|string|min:3|max:1000']);
|
||||
return $this->cmd($lot,$actor,'propose',$d,$key,function($s)use($lot,$actor,$d){
|
||||
$this->next('ib_budgets',$lot,$d);$dims=$this->dimensions($this->lot($lot,$s));abort_unless($this->canonical($dims)===$this->canonical($d['dimensions']),409,'Produto, lote, quantidade, unidade, moeda, período ou âmbito incompatível.');
|
||||
abort_unless(count($d['amounts'])===3&&!array_diff(array_keys($d['amounts']),['material','labour','allocated']),422);
|
||||
foreach($d['amounts'] as $v)abort_unless($v===null||(is_string($v)&&preg_match('/^(?:0|[1-9][0-9]{0,15})(?:\.[0-9]{1,8})?$/D',$v)),422,'Montante exacto não negativo ou desconhecido necessário.');
|
||||
$id=DB::table('ib_budgets')->insertGetId(['lot_id'=>$lot,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'dimensions'=>$this->json($dims),'amounts'=>$this->json($d['amounts']),'evidence'=>$d['evidence'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version'],'lot_id'=>$lot];
|
||||
});
|
||||
}
|
||||
public function decide(int $lot,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->fields($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
|
||||
return $this->cmd($lot,$actor,'decide',$d,$key,function()use($lot,$actor,$d){
|
||||
$b=$this->row('ib_budgets',$lot,$d['id'],$d['version']);abort_if($b->actor_id===$actor,403,'Validador distinto necessário.');abort_unless($this->current('ib_budgets',$b),409,'Orçamento desactualizado.');abort_if(DB::table('ib_decisions')->where('budget_id',$b->id)->exists(),409,'Decisão imutável já registada.');
|
||||
$id=DB::table('ib_decisions')->insertGetId(['budget_id'=>$b->id,'version'=>$b->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$b->version,'lot_id'=>$lot];
|
||||
});
|
||||
}
|
||||
private function approvedBudget(int $lot,int $id,int $version): object {
|
||||
$b=$this->row('ib_budgets',$lot,$id,$version);abort_unless($this->current('ib_budgets',$b)&&DB::table('ib_decisions')->where(['budget_id'=>$id,'decision'=>'approved'])->exists(),409,'Orçamento não aprovado ou desactualizado.');return $b;
|
||||
}
|
||||
private function result(int $actor,int $lot,int $id,int $version): array {
|
||||
foreach($this->costs->listing($actor)['lots'] as $l)if($l['id']===$lot)foreach($l['results'] as $r)if($r->id===$id&&$r->version===$version){abort_unless($r->current&&$r->decision?->decision==='approved',409,'Realizado não validado ou desactualizado.');return $r->snapshot;}
|
||||
abort(409,'Resultado ou versão incompatível.');
|
||||
}
|
||||
public static function variance(array $p,array $r): array {
|
||||
$rows=[];$ps=N::of(0);$rs=N::of(0);$ds=N::of(0);$complete=true;
|
||||
foreach(['material','labour','allocated'] as $k){$a=$p[$k];$b=$r[$k];$d=$a===null||$b===null?null:N::of($b)->minus($a);$pct=$d===null||N::of($a)->isZero()?null:Exact::text($d->multipliedBy(100)->dividedBy($a));
|
||||
$rows[$k]=['planned'=>$a,'actual'=>$b,'delta'=>$d===null?null:Exact::text($d),'percent'=>$pct,'percent_state'=>$d===null?'unknown':(N::of($a)->isZero()?'zero_base':'available')];
|
||||
if($d===null){$complete=false;continue;}$ps=$ps->plus($a);$rs=$rs->plus($b);$ds=$ds->plus($d);
|
||||
}
|
||||
return ['rows'=>$rows,'incomplete'=>!$complete,'paired_planned'=>Exact::text($ps),'paired_actual'=>Exact::text($rs),'paired_delta'=>Exact::text($ds),'total_delta'=>$complete?Exact::text($ds):null,'total_percent'=>$complete&&!$ps->isZero()?Exact::text($ds->multipliedBy(100)->dividedBy($ps)):null,'total_percent_state'=>!$complete?'unknown':($ps->isZero()?'zero_base':'available'),'balanced'=>$ps->plus($ds)->isEqualTo($rs)];
|
||||
}
|
||||
public function compare(int $lot,int $actor,array $in,string $key): array {
|
||||
self::guard();$d=$this->fields($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','budget_id'=>'required|integer|min:1','budget_version'=>'required|integer|min:1','result_id'=>'required|integer|min:1','result_version'=>'required|integer|min:1']);
|
||||
return $this->cmd($lot,$actor,'compare',$d,$key,function($s)use($lot,$actor,$d){
|
||||
$this->next('ib_comparisons',$lot,$d);$b=$this->approvedBudget($lot,$d['budget_id'],$d['budget_version']);$r=$this->result($actor,$lot,$d['result_id'],$d['result_version']);$dims=$this->dimensions($this->lot($lot,$s));
|
||||
abort_unless($this->canonical(json_decode($b->dimensions,true))===$this->canonical($dims)&&N::of($r['good_units'])->isEqualTo($dims['quantity'])&&$r['currency']===$dims['currency'],409,'Quantidade, moeda ou âmbito incompatível.');
|
||||
$snapshot=self::variance(json_decode($b->amounts,true),['material'=>$r['material_exact'],'labour'=>$r['labour_exact'],'allocated'=>$r['allocated_exact']])+['policy'=>self::POLICY,'dimensions'=>$dims,'budget'=>['id'=>$b->id,'version'=>$b->version],'result'=>['id'=>$d['result_id'],'version'=>$d['result_version'],'refs'=>$r['refs']],'actual_known'=>$r['known_cost_exact'],'residual_separate'=>$r['residual_exact'],'actual_reconciliation'=>$r['reconciliation'],'retrospective_hypothesis'=>true];
|
||||
$id=DB::table('ib_comparisons')->insertGetId(['lot_id'=>$lot,'version'=>$d['version'],'previous_id'=>$d['previous_id'],'budget_id'=>$b->id,'result_id'=>$d['result_id'],'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version'],'lot_id'=>$lot];
|
||||
});
|
||||
}
|
||||
public function listing(int $actor): array {
|
||||
$s=$this->scope($actor,'industrial_budgets.view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[]];self::guard();
|
||||
return DB::transaction(function()use($actor,$s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,'industrial_budgets.view');$lots=$this->costs->listing($actor)['lots'];
|
||||
$view=$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'industrial_budget_comparisons.view',$s['unit_id']);
|
||||
foreach($lots as &$l){$l['dimensions']=$this->dimensions((object)$l);$l['budgets']=DB::table('ib_budgets')->where('lot_id',$l['id'])->orderBy('version')->get()->all();foreach($l['budgets'] as $b){$b->current=$this->current('ib_budgets',$b);$b->amounts=json_decode($b->amounts,true);$b->dimensions=json_decode($b->dimensions,true);$b->decision=DB::table('ib_decisions')->where('budget_id',$b->id)->first();}
|
||||
$l['comparisons']=$view?DB::table('ib_comparisons')->where('lot_id',$l['id'])->orderBy('version')->get()->all():[];
|
||||
foreach($l['comparisons'] as $c){$c->snapshot=json_decode($c->snapshot,true);try{$this->approvedBudget($l['id'],$c->budget_id,$c->snapshot['budget']['version']);$this->result($actor,$l['id'],$c->result_id,$c->snapshot['result']['version']);$c->current=$this->current('ib_comparisons',$c);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){$c->current=false;}}
|
||||
}return ['blocked'=>false,'lots'=>$lots,'policy'=>self::POLICY];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
$old=substr(DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d,6);
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission IN ('industrial_budgets.view','industrial_budgets.prepare','industrial_budgets.validate','industrial_budget_comparisons.view','industrial_budget_comparisons.prepare') AND unit_id IS NOT NULL))");
|
||||
DB::unprepared(file_get_contents(__DIR__.'/../sql/industrial-budgets.sql'));
|
||||
foreach(['ib_budgets','ib_decisions','ib_comparisons','ib_commands','ib_events','ib_outbox'] as $t){
|
||||
DB::statement("CREATE TRIGGER {$t}_immutable BEFORE UPDATE OR DELETE ON {$t} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
|
||||
DB::statement("CREATE TRIGGER {$t}_environment BEFORE INSERT ON {$t} FOR EACH ROW EXECUTE FUNCTION sgf_ic_test_only()");
|
||||
}
|
||||
}
|
||||
public function down(): void {throw new RuntimeException('Budget history requires reviewed preservation.');}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
CREATE TABLE ib_budgets (
|
||||
id bigserial PRIMARY KEY, lot_id bigint NOT NULL REFERENCES ic_lots(id),
|
||||
version integer NOT NULL CHECK(version>0), previous_id bigint UNIQUE REFERENCES ib_budgets(id),
|
||||
dimensions jsonb NOT NULL, amounts jsonb NOT NULL, evidence text NOT NULL CHECK(length(evidence)>=3),
|
||||
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL, UNIQUE(lot_id,version)
|
||||
);
|
||||
CREATE TABLE ib_decisions (
|
||||
id bigserial PRIMARY KEY, budget_id bigint UNIQUE NOT NULL REFERENCES ib_budgets(id), version integer NOT NULL,
|
||||
decision text NOT NULL CHECK(decision IN ('approved','refused')), reason text NOT NULL CHECK(length(reason)>=3),
|
||||
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE TABLE ib_comparisons (
|
||||
id bigserial PRIMARY KEY, lot_id bigint NOT NULL REFERENCES ic_lots(id), version integer NOT NULL CHECK(version>0),
|
||||
previous_id bigint UNIQUE REFERENCES ib_comparisons(id), budget_id bigint NOT NULL REFERENCES ib_budgets(id),
|
||||
result_id bigint NOT NULL REFERENCES ic_results(id), snapshot jsonb NOT NULL,
|
||||
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL, UNIQUE(lot_id,version)
|
||||
);
|
||||
CREATE TABLE ib_commands (
|
||||
id bigserial PRIMARY KEY, lot_id bigint NOT NULL REFERENCES ic_lots(id),actor_id bigint NOT NULL REFERENCES users(id),
|
||||
action text NOT NULL CHECK(action IN ('propose','decide','compare')),command_key text NOT NULL,request_hash text NOT NULL,
|
||||
result jsonb NOT NULL,created_at timestamptz NOT NULL,UNIQUE(lot_id,actor_id,action,command_key)
|
||||
);
|
||||
CREATE TABLE ib_events(id bigserial PRIMARY KEY,command_id bigint UNIQUE NOT NULL REFERENCES ib_commands(id),payload jsonb NOT NULL,created_at timestamptz NOT NULL);
|
||||
CREATE TABLE ib_outbox(event_id bigint PRIMARY KEY REFERENCES ib_events(id),state text NOT NULL CHECK(state='captured_no_stock_or_accounting'));
|
||||
CREATE OR REPLACE FUNCTION sgf_ib_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE lid bigint; prior bigint; n integer; b ib_budgets; r ic_results; ref jsonb;
|
||||
BEGIN
|
||||
PERFORM sgf_ic_environment();
|
||||
IF TG_TABLE_NAME='ib_decisions' THEN
|
||||
SELECT * INTO STRICT b FROM ib_budgets WHERE id=NEW.budget_id;
|
||||
lid=b.lot_id;
|
||||
ELSE lid=NEW.lot_id; END IF;
|
||||
PERFORM 1 FROM companies WHERE id=(SELECT company_id FROM ic_lots WHERE id=lid) FOR UPDATE;
|
||||
IF TG_TABLE_NAME='ib_decisions' THEN
|
||||
IF b.actor_id=NEW.actor_id OR b.version<>NEW.version OR EXISTS(SELECT 1 FROM ib_budgets WHERE previous_id=b.id) THEN RAISE EXCEPTION 'SGF_IB_DECISION_VERSION_IDENTITY'; END IF;
|
||||
ELSIF TG_TABLE_NAME='ib_commands' THEN RETURN NEW;
|
||||
ELSE
|
||||
IF TG_TABLE_NAME='ib_budgets' THEN
|
||||
SELECT coalesce(max(version),0) INTO n FROM ib_budgets WHERE lot_id=lid;
|
||||
IF n>0 THEN SELECT id INTO STRICT prior FROM ib_budgets WHERE lot_id=lid AND version=n; END IF;
|
||||
ELSE
|
||||
SELECT coalesce(max(version),0) INTO n FROM ib_comparisons WHERE lot_id=lid;
|
||||
IF n>0 THEN SELECT id INTO STRICT prior FROM ib_comparisons WHERE lot_id=lid AND version=n; END IF;
|
||||
SELECT * INTO STRICT b FROM ib_budgets WHERE id=NEW.budget_id AND lot_id=lid;
|
||||
SELECT * INTO STRICT r FROM ic_results WHERE id=NEW.result_id AND lot_id=lid;
|
||||
IF NOT EXISTS(SELECT 1 FROM ib_decisions WHERE budget_id=b.id AND decision='approved') OR EXISTS(SELECT 1 FROM ib_budgets WHERE previous_id=b.id)
|
||||
OR NOT EXISTS(SELECT 1 FROM ic_decisions WHERE result_id=r.id AND decision='approved') OR EXISTS(SELECT 1 FROM ic_results WHERE previous_id=r.id)
|
||||
THEN RAISE EXCEPTION 'SGF_IB_APPROVED_CURRENT_REFERENCES'; END IF;
|
||||
FOR ref IN SELECT value FROM jsonb_each(r.snapshot->'refs') LOOP
|
||||
IF ref<>'null'::jsonb THEN PERFORM sgf_ic_input_current((ref->>'id')::bigint,lid,(ref->>'version')::integer); END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
IF NEW.version<>n+1 OR (n=0 AND NEW.previous_id IS NOT NULL) OR (n>0 AND NEW.previous_id IS DISTINCT FROM prior) THEN RAISE EXCEPTION 'SGF_IB_VERSION'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER ib_budget_integrity BEFORE INSERT ON ib_budgets FOR EACH ROW EXECUTE FUNCTION sgf_ib_integrity();
|
||||
CREATE TRIGGER ib_decision_integrity BEFORE INSERT ON ib_decisions FOR EACH ROW EXECUTE FUNCTION sgf_ib_integrity();
|
||||
CREATE TRIGGER ib_comparison_integrity BEFORE INSERT ON ib_comparisons FOR EACH ROW EXECUTE FUNCTION sgf_ib_integrity();
|
||||
CREATE TRIGGER ib_command_integrity BEFORE INSERT ON ib_commands FOR EACH ROW EXECUTE FUNCTION sgf_ib_integrity();
|
||||
@@ -0,0 +1,13 @@
|
||||
# Aceitação isolada — orçamento versus realizado
|
||||
|
||||
28/10/2026, Africa/Johannesburg. O utilizador aceitou expressamente a entrega do ensaio de orçamento versus realizado com a limitação das capturas documentada e autorizou checkpoint exclusivamente local, sem credenciais ou dados privados. Confirmou que a aceitação não transforma provas pendentes em realizadas e que migração `110000`, sete concessões e utilização real permanecem por autorizar.
|
||||
|
||||
Âmbito: `INDUSTRIAL-BUDGET-ACTUAL-SIM-v1`, secção 16 de [INDUSTRIAL_COST_ACCOUNTING_SCOPE.md](INDUSTRIAL_COST_ACCOUNTING_SCOPE.md), entrega em [INDUSTRIAL_BUDGET_DELIVERY.md](INDUSTRIAL_BUDGET_DELIVERY.md). Preservados `cbdf2b4`, `4a94e8a` e alterações posteriores. Orçamento sintético 900/600/300, realizado 1000/500/300, desvios +100/−100/0 e total 0; residual 300 separado. Variante de 800 conhecidos mantém custo total/unitário indisponíveis e comparação incompleta, sem desvio total conclusivo.
|
||||
|
||||
Evidência aceite com limites: 169 testes / 3 004 asserções aprovados, seis casos concorrentes e compilação aprovada; percurso manual relatado pelo utilizador, separação funcional confirmada e fotografias/referências reconciliadas em consulta read-only. Automação do Edge permaneceu bloqueada; capturas não foram recebidas ou inspeccionadas. Não se converte esta limitação em prova visual concluída. Ver [registo manual](INDUSTRIAL_BUDGET_MANUAL_PROOF.md).
|
||||
|
||||
Os campos de aceitação/checkpoint negativos nos ficheiros de prova anteriores descrevem o estado à data da produção dessas provas e não são reescritos. As referências documentais anteriores a aceitação pendente são históricas, substituídas apenas por esta confirmação expressa. Não se repetiram testes por este registo documental de encerramento.
|
||||
|
||||
O commit que contém este registo constitui o checkpoint exclusivamente local, incluindo fontes, testes, scripts, documentação e provas sintéticas revistas. Excluídos `.env*` privados, manifesto/ficheiro de entrada, palavras-passe, ficheiros privados de execução e conteúdos de contas/dados reais. Sem push ou publicação. Migração `110000` e sete concessões não aplicadas; bloqueios operacionais/fiscais preservados, sem uso real, stock ou lançamentos.
|
||||
|
||||
Próxima prioridade indicada pelo utilizador: retomar a página inicial e os menus agrupados. Não integra a aceitação deste ensaio nem altera os seus direitos, cálculos ou limites.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Orçamento versus realizado — ensaio isolado
|
||||
|
||||
**Aceitação posterior de 28/10/2026:** entrega aceite com a limitação das capturas documentada e checkpoint exclusivamente local autorizado. Ver [INDUSTRIAL_BUDGET_ACCEPTANCE.md](INDUSTRIAL_BUDGET_ACCEPTANCE.md). Referências abaixo à aceitação/checkpoint pendentes são históricas. Provas pendentes não se tornam realizadas; `110000`, sete concessões e utilização real continuam por autorizar.
|
||||
|
||||
28/10/2026, Africa/Johannesburg. `INDUSTRIAL-BUDGET-ACTUAL-SIM-v1`, secção 16 de [INDUSTRIAL_COST_ACCOUNTING_SCOPE.md](INDUSTRIAL_COST_ACCOUNTING_SCOPE.md). Preservados `cbdf2b4`, `4a94e8a` e alterações posteriores. **Sem aceitação presumida ou checkpoint de encerramento.** Nenhuma migração ou concessão nova em desenvolvimento.
|
||||
|
||||
## Comportamento e limites
|
||||
|
||||
Orçamento submetido imutável por lote, produto, empresa/unidade, período, MZN e 10 UN. Preparador propõe; identidade distinta aprova/recusa versão exacta sem editar. Sucessora identifica a anterior e o motivo e requer nova validação. Proposta posterior ao realizado é hipótese retrospectiva explícita, sem alegar planeamento anterior. Orçamento 900 material + 600 trabalho + 300 indirectos imputados aprovado como pressuposto do ensaio pelo utilizador; a validação da versão no sistema permanece separada.
|
||||
|
||||
Comparação selecciona explicitamente orçamento aprovado e resultado industrial validado, ambos actuais, por ID/versão. O resultado reutilizado conserva origens, taxas, critério e respectiva fotografia: não cria outro consumo, tempo, resultado industrial ou imputação. Desvio R − P por componente: 1 000 − 900 = +100; 500 − 600 = −100; 300 − 300 = 0; total 0. Reconciliação: 1 800 + 0 = 1 800; soma dos desvios 100 − 100 + 0 = 0. Percentagens exactas 100/9 %, −50/3 % e 0 %; total 0 %. Não se somam percentagens das componentes.
|
||||
|
||||
Residual **300 MZN por imputar** fora do orçamento/desvio do lote. Reconciliação realizada preservada: **2 100 = 1 800 atribuídos + 300 por imputar**. Sem taxa material: 800 conhecidos, material indisponível, pares trabalho/indirectos com desvio −100/0, comparação incompleta e desvio total/percentagem global indisponíveis. Não se apresenta −900 ou outra suposta poupança pelo material desconhecido. Previsto desconhecido também impede o desvio da componente; previsto zero conhecido permite desvio monetário mas não percentagem. 0/0 permanece indisponível.
|
||||
|
||||
Aritmética racional exacta com `ExactCostMath`, sem float, arredondamento silencioso ou regra fiscal. Incompatibilidades de produto/lote, empresa/unidade, quantidade, unidade, moeda, período e âmbito são recusadas, sem conversões ou ajuste por volume. Só diferenças monetárias por componente; não atribui preço, eficiência, produtividade, margem ou causas.
|
||||
|
||||
Fotografias e decisões imutáveis; mudança de orçamento, resultado ou dependência identifica comparações antigas como históricas/desactualizadas. Não se seleccionam sucessoras automaticamente. Repetição com mesma chave/conteúdo recupera o mesmo ID; conteúdo diferente é conflito. Bloqueio de empresa serializa revisões; comando, auditoria, evento e outbox são transaccionais, sem consumidor contabilístico. Falha de outbox desfaz a operação. SQL reforça ambiente exclusivo de testes, cadeias, referências aprovadas/actuais, identidade distinta e imutabilidade; não constitui motor independente de cálculo nem RLS contra administrador de confiança.
|
||||
|
||||
## Percurso de prova e arranque
|
||||
|
||||
Menu **Orçamento versus realizado** com direitos explícitos, sem herança de `industrial_costs.*`. Preparador submete valores e fundamento; validador entra separadamente e decide orçamento por ID/versão; preparador escolhe orçamento e resultado exactos para comparar; validador consulta sem formulário de preparação. Actualização de versões é explícita. Histórico permanece visível, com aviso de desactualização.
|
||||
|
||||
Com base de testes vazia e sob `TestingDatabaseGuard`, preparar pelo fluxo existente, sem apagar registos para contornar recusa de fixture:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start -Port 8002
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/industrial-budget-fixture.php --env=testing
|
||||
```
|
||||
|
||||
Porta **8002** autorizada nesta sessão por a 8001 estar ocupada por outro projecto, cujo processo não foi alterado. `browser-proof.ps1` mantém 8001 como valor por omissão e permite porta explícita livre, verificando-a antes do arranque. Instância `browser-testing`, `sgf_tempo_test`/55432, sessão/cache isolados e CSRF activo preservados. Credenciais sintéticas aleatórias apenas no manifesto local protegido, nunca em documentação, argumentos ou relatório. Parar exclusivamente a prova SGF com `scripts/browser-proof.ps1 stop` antes de testes que recriam a base.
|
||||
|
||||
## Migração proposta — não aplicada em desenvolvimento
|
||||
|
||||
`2026_10_28_110000_create_industrial_budgets.php`, SQL `database/sql/industrial-budgets.sql`. Destino futuro proposto **sgf_tempo_dev / 127.0.0.1:55432**, por caminho exclusivo e proprietário separado `sgf_dev_owner`; aplicação permanece `sgf_dev_app`. Exige autorização específica antes da aplicação; não existe instalador de desenvolvimento deste incremento.
|
||||
|
||||
Cria seis tabelas `ib_budgets`, `ib_decisions`, `ib_comparisons`, `ib_commands`, `ib_events`, `ib_outbox`, índices/FKs/triggers e função própria. Amplia o CHECK com cinco nomes de direitos de unidade, preservando os anteriores; não atribui direitos, não modifica origens `ic_*` e não contém dados de demonstração. `down()` recusa destruição de história. INSERT em todas as novas tabelas exige `sgf_tempo_test`/55432 via guarda SQL já existente. Guarda da aplicação continua a exigir ambiente isolado seguro, sem opção de activar operações reais.
|
||||
|
||||
## Sete concessões propostas — não aplicadas
|
||||
|
||||
Todas no **tenant #1 / Tempo Lda #1 / SEGI #1**, contas existentes; sem gestão de acessos ou âmbito adicional:
|
||||
|
||||
| Conta | Direitos exactos propostos |
|
||||
| --- | --- |
|
||||
| Osmane #1 | `industrial_budgets.view`, `industrial_budgets.prepare`, `industrial_budget_comparisons.view`, `industrial_budget_comparisons.prepare` |
|
||||
| Samira #2 | `industrial_budgets.view`, `industrial_budgets.validate`, `industrial_budget_comparisons.view` |
|
||||
|
||||
Consulta da comparação exige também consulta do orçamento e do realizado; os direitos industriais existentes permanecem separados. `scripts/industrial-budget-permissions-preview.php` é só consulta, sem ramo de aplicação: confirmou as sete ausentes, `110000` pendente e guarda real recusada com 403. A distribuição funcional aprovada não autoriza estas concessões em desenvolvimento.
|
||||
|
||||
## Ficheiros e verificações
|
||||
|
||||
Novos: `app/Modules/Industry/IndustrialBudgets.php`, `app/Modules/Industry/Http/IndustrialBudgetsController.php`, `resources/js/IndustrialBudgetsWorkspace.jsx`, migração/SQL acima, `tests/Feature/IndustrialBudgetsTest.php`, `tests/Support/IndustrialBudgetFixture.php`, scripts `industrial-budget-fixture.php`, `industrial-budget-worker.php`, `industrial-budget-concurrency.php`, `industrial-budget-permissions-preview.php` e relatório/provas. Integrações em `routes/web.php`, `resources/js/app.jsx` e parâmetro de porta de `scripts/browser-proof.ps1`; registos em escopo, README e AGENTS. Nenhuma alteração às fontes funcionais industriais anteriores ou dependências.
|
||||
|
||||
Nove testes novos: igualdade, acima/abaixo, compensação, base zero, 0/0, desconhecido e falta material, dimensões incompatíveis, quantidade realizada alterada, proposta recusada, versões históricas, mudança de origem, permissões e revogação no pedido seguinte/replay, token de contexto antigo, acesso cruzado de empresa, exactidão, rollback de outbox, recuperação de resposta, imutabilidade SQL e guarda normal. Seis cenários com dois processos sobrepostos aprovados: proposta repetida, decisão repetida, comparação repetida, sucessoras incompatíveis, decisões concorrentes e comparações concorrentes. Ver [prova concorrente](proofs/industrial-budget-concurrency.json).
|
||||
|
||||
Compilação `scripts/sgf.ps1 npm run build` aprovada com Vite 8.3.5; sem instalação/substituição de dependências. Regressão completa `scripts/sgf.ps1 test --log-junit=storage/app/industrial-budget-regression.xml`: **169 testes / 3 004 asserções, zero falhas/erros**. XML e saída detalhada apenas locais. Ver [resumo de provas](proofs/industrial-budget-tests.json).
|
||||
|
||||
**Prova no navegador pendente:** instância isolada arrancada correctamente em `http://127.0.0.1:8002` e fixture industrial preparada com resultado validado de 1 800 MZN. Antes de qualquer login ou operação visual, a ferramenta de computer-use interrompeu o acesso por não conseguir determinar o URL actual do Edge no Windows com confiança suficiente para aplicar a política. Não houve tentativa de contornar a restrição, nova sessão autenticada, orçamento ou comparação demonstrados no navegador. A instância SGF foi parada; nenhum processo do outro projecto foi alterado. A entrega não está integralmente verificada até completar essa prova. O próximo percurso é submeter orçamento 900/600/300 pelo preparador, validar pela identidade distinta, comparar as referências exactas e consultar o resultado completo; depois seleccionar uma sucessora industrial validada sem taxa material e demonstrar os 800 conhecidos/incompletude, preservando a comparação anterior como histórica. Não se declara esta sequência executada.
|
||||
|
||||
Recorte de uma etapa, um produto/lote, 10 UN, MZN e três componentes. Sem orçamento flexível, decomposição causal, reservas/stock, previsto de residual, compras, salários reais, facturação, lançamentos, fecho, activação real ou publicação. Aceitação e checkpoint de encerramento permanecem pendentes.
|
||||
|
||||
## Retoma manual preparada — 28/10/2026
|
||||
|
||||
**Revisão técnica posterior concluída:** `industrial-budget-manual-verify.php` reconciliou ambas as fotografias com as fórmulas exactas e as referências/decisões, histórico, completude, residual e separação de direitos; sem alterar registos ou credenciais. Prova em [industrial-budget-manual-results.json](proofs/industrial-budget-manual-results.json). Resumo de testes corrigido para separar a tentativa automática histórica do percurso manual relatado. Capturas continuam indisponíveis; sem aceitação presumida ou checkpoint. Nenhuma correcção ao cálculo foi necessária nesta revisão; não se repetiu a bateria aceite de testes. `110000` e sete concessões reconfirmadas ausentes em desenvolvimento.
|
||||
|
||||
**Actualização posterior:** percurso manual confirmado pelo utilizador: orçamento #10 v2 aprovado por identidade distinta, comparação completa #8 v1 com realizado #23 v1, resultado incompleto #24 v2 validado e comparação #9 v2 com 800 conhecidos. Confirmada a ausência dos formulários de preparação na conta de Samira sintética. Histórico e residual separados preservados. Ver o [registo manual](INDUSTRIAL_BUDGET_MANUAL_PROOF.md) para evidência e limites: textos enviados pelo utilizador e consulta read-only aos IDs/decisão, sem capturas recebidas ou automação recuperada. Aceitação e checkpoint continuam pendentes. A descrição seguinte corresponde à preparação anterior ao percurso.
|
||||
|
||||
Após segunda interrupção da automação, o utilizador pediu preparação da prova manual sem nova tentativa automática. Instância 8002 retomada sobre os dados sintéticos existentes, sem reset/migrações/concessões, com diagnóstico HTTP a confirmar a ligação efectiva. Ver [guia e estado da prova manual](INDUSTRIAL_BUDGET_MANUAL_PROOF.md) e [ambiente confirmado](proofs/industrial-budget-manual-environment.json). Credenciais apenas em ficheiro local protegido; nenhum login, resultado manual ou captura é declarado como já observado. A instância fica ligada para o utilizador executar o percurso; aceitação e checkpoint continuam pendentes.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Prova manual guiada — orçamento versus realizado
|
||||
|
||||
**Actualização de encerramento:** em 28/10/2026, o utilizador aceitou expressamente a entrega com esta limitação de evidência e autorizou checkpoint local. Ver [aceitação](INDUSTRIAL_BUDGET_ACCEPTANCE.md). Menções anteriores a aceitação/checkpoint pendentes são históricas. Capturas continuam indisponíveis e automação bloqueada; nenhuma prova pendente é dada por realizada.
|
||||
|
||||
## Resultado do percurso manual confirmado pelo utilizador
|
||||
|
||||
Em 28/10/2026, após a preparação abaixo, o utilizador executou o percurso no navegador e enviou os textos dos painéis. Confirmou no final que, na consulta como Samira sintética, não aparecem os formulários de preparação de orçamento e de comparação. Não se atribui esta observação ao agente nem se declara recuperação da automação do Edge.
|
||||
|
||||
- Orçamento #9 v1 preservado como histórico sem decisão, sucedido por #10 v2 com os mesmos valores 900/600/300. Não foi determinada a causa da segunda submissão.
|
||||
- Orçamento #10 v2 validado, com fundamento «Conferidos 900/600/300 MZN, 10 UN, mesmo lote e período». Consulta read-only à base de testes confirmou preparador #509 e validador distinto #510.
|
||||
- Comparação #8 v1 referencia orçamento #10 v2 e realizado #23 v1: previsto/realizado 1 800/1 800, desvios +100/−100/0, total 0 e residual separado 300. O utilizador observou posteriormente o aviso de comparação histórica/desactualizada, mantendo os números anteriores.
|
||||
- Resultado #24 v2 observado e validado apenas para 800 conhecidos, material pendente e total/custo unitário indisponíveis; residual 300, reconciliação parcial 1 100 = 800 + 300.
|
||||
- Comparação #9 v2 referencia o mesmo orçamento #10 v2 e realizado #24 v2. Material e respectivo desvio indisponíveis; trabalho −100, indirectos 0; comparação incompleta, sem desvio total conclusivo. Pares conhecidos: 900 + (−100) = 800, sem tratar ausência como poupança. Residual 300 separado.
|
||||
- Consulta read-only confirmou os IDs/versões do orçamento, decisão e referências das duas comparações. Não houve alteração de contas ou dados por essa consulta.
|
||||
|
||||
**Limitação da evidência:** os textos foram enviados pelo utilizador; não foram recebidas nem inspeccionadas capturas de ecrã. Os nomes de PNG sugeridos abaixo continuam a ser apenas destinos previstos. Percurso manual confirmado por relato e conferência parcial read-only, não prova visual automática. Aceitação e checkpoint permanecem pendentes; nenhuma concessão/migração em desenvolvimento ou activação real decorre desta confirmação. A instância de prova permanece ligada para eventual captura manual.
|
||||
|
||||
### Revisão técnica posterior solicitada pelo utilizador
|
||||
|
||||
Executado `scripts/sgf.ps1 php scripts/industrial-budget-manual-verify.php --env=browser-testing`, sem alterar registos ou credenciais. A conferência adicional confirmou as duas fotografias contra as fórmulas exactas, referências ID/versão, aprovações por identidade distinta, cadeia resultado/comparação v1→v2, mesmo orçamento, residual 300 e reconciliações completa/parcial, ausência de duplicação de origens de oficina/custo directo e distribuição efectiva dos direitos. Ver [resultado estruturado](proofs/industrial-budget-manual-results.json). Isto completa a reconciliação técnica das fotografias descritas pelo utilizador; não constitui inspecção de capturas ou nova prova de navegador.
|
||||
|
||||
Corrigido o resumo de provas para identificar os campos de navegador falhado/parado como a tentativa automática histórica, acrescentando separadamente o percurso manual posterior. Não se alteraram cálculo, resultados, decisões ou acessos. Sintaxe do novo verificador conferida e execução aprovada; a comparação normaliza apenas a ordem das chaves JSON para conferir conteúdos com tipos exactos, sem mudar os dados. Não se repetiram testes destrutivos ou build por estas alterações de verificação/documentação; preservada a fixture manual. Consulta de desenvolvimento reconfirmou `110000` e sete concessões ausentes e guarda real bloqueada. Aceitação e checkpoint continuam pendentes.
|
||||
|
||||
## Registo histórico da preparação
|
||||
|
||||
28/10/2026. Preparação verificada, percurso **ainda não executado pelo utilizador**. Sem aceitação ou checkpoint. Não repetir a automação bloqueada do Edge.
|
||||
|
||||
## Entrada e isolamento confirmados
|
||||
|
||||
Endereço: `http://127.0.0.1:8002`. Resposta HTTP de `/api/industrial-budget-proof/environment` confirmou `browser-testing`, `sgf_tempo_test`, `sgf_test_runner`, PostgreSQL 55432, sessão `file`/`sgf_browser_proof` e cache `file`. Ver [prova de ambiente](proofs/industrial-budget-manual-environment.json). Endpoint read-only, sem dados de negócio/credenciais, disponível apenas em `browser-testing` e sob as guardas existentes; 404 fora desse ambiente.
|
||||
|
||||
Retomada a fixture existente por `scripts/browser-proof.ps1 start -Port 8002 -ResumeIndustrialBudget`. Sem reset, migração ou concessões; lote #20, realizado #23 v1 validado, nenhuma proposta/comparação no início. O preparador não tem validação de orçamento; a validadora não tem preparação de orçamento/comparação. IDs descrevem esta execução, não constantes do módulo.
|
||||
|
||||
Contas sintéticas: `gestor@example.test` representa Osmane; `consulta@example.test` representa Samira. Palavras-passe distintas renovadas apenas nestas contas da base de testes porque o manifesto anterior fora removido ao parar a prova. Instruções de entrada em `C:\laragon\data\sgf-tempo\secrets\industrial-budget-manual-entry.txt`, com ACL apenas do utilizador local e SYSTEM, fora do repositório. Abrir localmente; não copiar palavras-passe para chat, capturas ou relatório. Não guardar no navegador.
|
||||
|
||||
## Percurso a executar no navegador pelo utilizador
|
||||
|
||||
1. Entrar como `gestor@example.test`, seleccionar **Empresa Horizonte — Demonstração / Unidade Central — Demonstração**, abrir **Orçamento versus realizado**, lote #20. Confirmar que existe preparação e não validação do orçamento.
|
||||
2. Preparar material **900**, trabalho **600**, indirectos **300**, com fundamento «Hipótese sintética retrospectiva 900/600/300, 10 UN, mesmo lote/período, sem causas presumidas». Submeter orçamento e anotar ID/versão. Não criar outra proposta por repetir o clique.
|
||||
3. Sair da aplicação. Entrar como `consulta@example.test` no mesmo contexto. Confirmar ausência de preparação de orçamento/comparação e presença da decisão. Validar a versão exacta submetida, com fundamento explícito. Anotar ID/versão e guardar captura sem campos de entrada de credenciais.
|
||||
4. Sair e entrar como preparador. Seleccionar explicitamente o orçamento aprovado e o **realizado #23 v1**, comparar. Confirmar previsto/realizado **1 800/1 800 MZN**, desvios **+100/−100/0**, total **0**, reconciliação **1 800 + 0 = 1 800**, residual separado **300 por imputar**, origens **2 100 = 1 800 + 300**. Guardar captura da comparação.
|
||||
5. Para a variante, no menu **Custos industriais de teste**, mesmo lote #20, usar **Seleccionar versões para cálculo**: escolher explicitamente as versões validadas de material, trabalho, conjunto, máquina, produção boa, taxa de trabalho e critério; manter **taxa material ausente**. Calcular a sucessora do resultado #23 v1. Não alterar origens, taxas, produção, critério ou orçamento. Anotar novo ID/versão.
|
||||
6. Sair; entrar como validadora. No menu industrial, validar apenas essa versão incompleta, com fundamento «Só 800 MZN conhecidos; material pendente, total e custo unitário indisponíveis». Confirmar **800 conhecidos**, total/unitário indisponíveis e residual 300 separado; guardar captura.
|
||||
7. Sair; entrar como preparador. Em **Orçamento versus realizado**, actualizar explicitamente, seleccionar o mesmo orçamento aprovado e o novo realizado validado incompleto, criar a comparação sucessora. Confirmar material/desvio indisponíveis, trabalho −100, indirectos 0, **sem desvio total conclusivo** ou percentagem global, realizado conhecido **800** e residual **300** separado. A comparação anterior permanece histórica/desactualizada. Guardar captura.
|
||||
8. Sair; entrar como validadora. Consultar as duas comparações e confirmar que não recebe formulário para criar/editar orçamento ou preparar comparação. Guardar captura dessa separação de acessos.
|
||||
|
||||
Capturas sugeridas em `docs/proofs/industrial-budget-manual-approval.png`, `industrial-budget-manual-full.png`, `industrial-budget-manual-partial-result.png`, `industrial-budget-manual-partial-comparison.png` e `industrial-budget-manual-reader.png`. São nomes de destino, **não ficheiros já produzidos**. Capturar apenas os painéis de negócio, sem palavras-passe, ficheiro de entrada ou outras aplicações. Anotar os IDs/versões, o que foi observado e qualquer divergência; não declarar êxito antes da conferência. Se houver erro, conservar a mensagem e não tentar corrigir montantes ou substituir a referência aprovada para o contornar.
|
||||
|
||||
## Estado e validação desta preparação
|
||||
|
||||
Sintaxe PHP do preparador e de `routes/web.php` aprovada; arranque PowerShell e diagnóstico HTTP efectivos aprovados. Não se alterou cálculo ou interface, nem se repetiu a bateria de 169 testes já aprovada. Preparação manual não comprova login, submissão, decisão ou comparação pela interface. A instância fica ligada para o percurso; não executar testes destrutivos enquanto estiver activa. Após a prova, parar apenas a instância SGF com `scripts/browser-proof.ps1 stop`. Aceitação, eventual encerramento e checkpoint permanecem posteriores à prova.
|
||||
@@ -318,3 +318,78 @@ Exactidão, ausência diferente de zero, origem única, histórico, isolamento e
|
||||
Em 27/10/2026, após a confirmação dos números e limites, o utilizador respondeu «sim». Ficam aprovadas as duas decisões da secção 15.4: números e perímetro sintéticos da secção 15.1, com residual separado e variante incompleta; Osmane prepara e Samira valida/recusa versões do critério e do resultado, apenas com identidades sintéticas distintas nas provas e sem concessões reais.
|
||||
|
||||
O critério de capacidade/imputação continua hipótese de ensaio, não método contabilístico real aprovado. Previsto/realizado fica para depois. Não se reabrem estas decisões; a autorização para implementar código/testes, futuras migrações em desenvolvimento, concessões ou activação real continua separada. Este registo não executa o ensaio.
|
||||
|
||||
## 16. Orçamento versus custo realizado — especificação do recorte aprovado
|
||||
|
||||
28/10/2026. O utilizador aprovou o recorte de previsto versus realizado e autorizou nesta etapa **apenas actualizar esta documentação**. Preservar a aceitação `cbdf2b4` e o checkpoint da instalação técnica `4a94e8a`, sem reabrir as entregas. As exclusões de previsto/realizado na secção 15 descrevem o primeiro incremento; esta secção especifica a evolução seguinte, sem autorizar implementação, migrações, concessões ou utilização real. Identificador proposto: `INDUSTRIAL-BUDGET-ACTUAL-SIM-v1`.
|
||||
|
||||
### 16.1 Âmbito e referências exactas
|
||||
|
||||
Reutilizar o mesmo produto, lote sintético, empresa/unidade, centro, período de início/fim, moeda MZN e **10 UN boas** do resultado completo de `INDUSTRIAL-COST-SIM-v1`. Seleccionar explicitamente o ID e a versão validada do resultado de **1 800 MZN**, com referências às versões das suas dependências; não procurar automaticamente o resultado mais recente nem fixar IDs de uma execução anterior. Uma futura prova terá de identificar esses IDs/versões concretos e comprovar a validação e actualidade. Não se afirma que a base de testes ainda contenha os registos da demonstração anterior.
|
||||
|
||||
Perímetro comparável: material directo **1 000 MZN**, trabalho directo **500 MZN** e indirectos **imputados ao lote de 300 MZN**. Os **300 MZN por imputar** continuam apresentados separadamente, fora do orçamento e do desvio do lote. Preservar as reconciliações do realizado: conjunto **600 = 300 imputados + 300 por imputar** e origens **2 100 = 1 800 atribuídos + 300 por imputar**. Não comparar o orçamento do lote com 2 100, nem absorver o residual para ajustar desvios.
|
||||
|
||||
O orçamento declara o mesmo produto/lote, quantidade boa 10, unidade UN, MZN, período exacto e âmbito das três componentes atribuídas. Cada componente contém montante exacto não negativo ou estado explícito de desconhecido, fonte e fundamento sintéticos. Não é taxa, consumo, reserva de stock ou nova origem de custo. Referenciar o resultado realizado reutiliza a fotografia existente, sem copiar consumos, tempos, imputações ou gerar nova utilização do conjunto CIF.
|
||||
|
||||
### 16.2 Orçamento versionado e responsabilidades
|
||||
|
||||
**Osmane prepara; Samira valida/recusa por identidade distinta.** Nas futuras provas usam-se equivalentes sintéticos. Preparação, submissão e decisão conservam autor, data, versão e fundamento; Samira não edita a proposta nem fontes técnicas. Auto-validação é recusada mesmo que uma identidade venha a possuir ambos os direitos.
|
||||
|
||||
Uma proposta submetida e a respectiva decisão são imutáveis. Correcção cria sucessora explícita com ligação à anterior, motivo e nova validação; a aprovação anterior não se transfere. O orçamento aprovado usado como referência fica fixado pelo ID/versão. Não se substitui silenciosamente para coincidir com o realizado. Revisão posterior à disponibilidade do realizado fica assinalada como tal e nunca reescreve a referência original. Este ensaio sobre um resultado já existente é uma hipótese comparativa retrospectiva, não prova de que houve planeamento anterior à produção.
|
||||
|
||||
Comparação referencia explicitamente orçamento aprovado e resultado validado, com versões e composição. Nova versão de qualquer dependência sinaliza a comparação anterior como histórica/desactualizada, preservando números e decisões. Recalcular exige selecção explícita das novas versões elegíveis e gera nova revisão ligada à anterior; não acumula comparações como novos custos.
|
||||
|
||||
### 16.3 Valores de orçamento propostos — por confirmar
|
||||
|
||||
Os montantes seguintes são **proposta sintética para confirmação**, não orçamento aprovado nem decisão já tomada:
|
||||
|
||||
| Componente | Previsto proposto (MZN) | Realizado de referência (MZN) | Desvio: realizado − previsto (MZN) | Percentagem exacta |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Material | 900 | 1 000 | +100 | 100/9 % |
|
||||
| Trabalho | 600 | 500 | −100 | −50/3 % |
|
||||
| Indirectos imputados | 300 | 300 | 0 | 0 % |
|
||||
| **Total do lote** | **1 800** | **1 800** | **0** | **0 %** |
|
||||
|
||||
Esta proposta demonstra diferenças compensadas sem esconder componentes: **100 − 100 + 0 = 0**. Não atribui a diferença a preço, eficiência ou volume. O residual realizado de 300 MZN é mostrado fora desta tabela de desvios, com a reconciliação própria.
|
||||
|
||||
### 16.4 Cálculo, completude e compatibilidade
|
||||
|
||||
Por componente, **D = R − P**; total apenas completo se todas as componentes previstas e realizadas forem conhecidas. Reconciliação: **ΣR − ΣP = Σ(R − P)** e **ΣP + ΣD = ΣR**. No exemplo proposto: **1 800 + 0 = 1 800**. Total percentual calcula-se sobre o previsto total, nunca somando percentagens das componentes. Desvio positivo significa custo acima do previsto; negativo significa abaixo, sem concluir eficiência, poupança efectiva ou margem.
|
||||
|
||||
Percentagem **100 × D/P**, apenas com previsto conhecido e estritamente positivo e realizado conhecido. Conservar aritmética racional exacta, incluindo fracções não finitas em decimal, sem float ou truncagem fiscal; não escolher arredondamento silencioso. Previsto zero conhecido permite desvio monetário, mas a percentagem é indisponível por base zero, incluindo 0/0. Previsto desconhecido impede desvio monetário e percentual dessa componente: não é zero. Realizado desconhecido também os impede e nunca representa poupança. Valores negativos de orçamento ficam fora deste recorte.
|
||||
|
||||
Se faltar uma componente, apresentar pares conhecidos e respectivos desvios, indicar exclusões e comparação **incompleta**; soma parcial dos desvios só sobre pares completos e expressamente identificada, sem total ou percentagem global completos. Não subtrair totais parciais que cubram componentes diferentes. Na variante sem taxa material, os 800 MZN conhecidos do realizado não se comparam ao orçamento total de 1 800: só trabalho e indirectos têm pares completos, se os previstos forem conhecidos.
|
||||
|
||||
Antes da comparação, exigir igualdade explícita de empresa/unidade, produto, lote, quantidade boa, unidade, moeda, período e perímetro/componentes de custo. Rejeitar dados incompatíveis com motivo específico; não converter moeda/unidade ou normalizar quantidade automaticamente. Um lote de 8 ou 12 UN não se compara neste recorte ao orçamento de 10 UN, mesmo que seja possível calcular custo por unidade. Não misturar diferenças de volume com preço/eficiência; decomposição causal, orçamento flexível e análise de produtividade ficam fora do âmbito. Diferenças por componente são observações monetárias, sem causas atribuídas sem evidência.
|
||||
|
||||
### 16.5 Casos e critérios de aceitação para futura implementação
|
||||
|
||||
Os cenários abaixo são requisitos de prova, não orçamentos aprovados. Excepto indicação contrária, manter as 10 UN e restantes dimensões compatíveis, com realizado 1 000/500/300:
|
||||
|
||||
| Caso | Previsto de teste: material/trabalho/indirectos (MZN) | Resultado exigido |
|
||||
| --- | --- | --- |
|
||||
| Igualdade integral | 1 000 / 500 / 300 | Desvios 0/0/0; total 0 e 0 % |
|
||||
| Diferenças compensadas | 900 / 600 / 300, proposta da secção 16.3 | +100/−100/0, total 0; componentes visíveis |
|
||||
| Acima do previsto | 900 / 450 / 270 | Total previsto 1 620; desvio +180; percentagem 100/9 % |
|
||||
| Abaixo do previsto | 1 100 / 550 / 330 | Total previsto 1 980; desvio −180; percentagem −100/11 % |
|
||||
| Previsto zero numa componente | 0 / 500 / 300 | Material +1 000, percentagem material indisponível; total previsto 800, desvio +1 000 e 125 % |
|
||||
| Previsto todo zero | 0 / 0 / 0 | Desvio total +1 800, percentagem indisponível por base zero |
|
||||
| Ambos zero numa componente | Material previsto e realizado explicitamente zero em variante sintética própria | Desvio material 0; percentagem indisponível, sem alterar o resultado validado de referência |
|
||||
| Previsto material desconhecido | Desconhecido / 600 / 300 | Material sem desvio; pares trabalho/CIF somam −100; comparação incompleta, sem desvio total completo |
|
||||
| Realizado material em falta | 900 / 600 / 300; seleccionar versão incompleta de 800 conhecidos | Material sem desvio; trabalho −100 e CIF 0; comparação incompleta, nunca −900 de suposta poupança |
|
||||
| Versões alteradas | Sucessora de orçamento ou resultado/origem/taxa/critério | Referência antiga preservada e sinalizada; selecção/revisão explícitas, sem aprovação herdada ou substituição automática |
|
||||
| Dados incompatíveis | Alterar uma dimensão de cada vez: produto, lote, quantidade, unidade, moeda, período, âmbito ou empresa/unidade | Recusar comparação; nenhum ajuste automático ou acesso cruzado |
|
||||
|
||||
Aceitação futura exige demonstrar as fórmulas e reconciliações exactas, residual separado, ausência distinta de zero, estados incompletos e todos os casos acima. Exige ainda separação de identidades, recusa de proposta não aprovada/resultado não validado ou desactualizado, histórico imutável, referência original recuperável, ausência de duplicação de origens e resposta a repetição/concorrência sem duplicar revisões ou efeitos. Auditoria e eventual evento de comparação não constituem consumo, imputação, stock ou lançamento. Provas futuras exclusivamente sintéticas em `sgf_tempo_test`, preservando guardas reais; nenhuma aceitação de implementação é declarada nesta especificação.
|
||||
|
||||
### 16.6 Acessos propostos e decisões novas pendentes
|
||||
|
||||
Propor, apenas para uma futura implementação, direitos explícitos de unidade `industrial_budgets.view`, `industrial_budgets.prepare`, `industrial_budgets.validate` e `industrial_budget_comparisons.view`, `industrial_budget_comparisons.prepare`. Consulta de comparação exige também consulta dos dois lados; preparar comparação exige orçamento aprovado e resultado validado, sem conceder poder de os aprovar. Não herdar estes direitos de `industrial_costs.*`, contabilidade, oficina ou gestão de acessos.
|
||||
|
||||
Distribuição proposta: Osmane com consulta/preparação de orçamento e consulta/preparação de comparação; Samira com consulta/validação de orçamento e consulta de comparação. Nenhuma destas concessões existe por efeito desta especificação. Nomes técnicos e distribuição são proposta para decisão, não alteração dos quatro direitos industriais já instalados. Qualquer aplicação real exigirá apresentação de contas, destino e âmbito exactos e autorização específica separada; não se solicita essa aplicação nesta etapa.
|
||||
|
||||
**Apenas decisões novas a confirmar:** (1) os valores de orçamento propostos em 16.3; (2) os novos direitos e a distribuição propostos acima. O recorte documental, a separação Osmane/Samira e as regras solicitadas nesta secção já estão determinados pela instrução de 28/10 e não são pedidos novamente. Autorização de implementação continua separada e ainda não concedida. Sem código, migrações, concessões, stock, lançamentos, activação real ou publicação nesta etapa.
|
||||
|
||||
### 16.7 Aprovação e autorização posterior de implementação — 28/10/2026
|
||||
|
||||
O utilizador aprovou os valores 900/600/300 e a distribuição funcional da secção 16.6, autorizando implementar e demonstrar o ensaio exclusivamente em `sgf_tempo_test`, com identidades sintéticas distintas, regressões, compilação e prova no navegador isolado. Esta autorização substitui apenas as pendências de decisão e de implementação descritas acima; não aprova antecipadamente uma versão de orçamento criada no sistema, que exige o circuito de validação distinto. Migração e concessões em desenvolvimento continuam separadas e por autorizar; sem alteração de dependências sem apresentação prévia, utilização real, stock, lançamentos, facturação ou publicação. Não presumir aceitação nem criar checkpoint de encerramento antes dela. Preservar `cbdf2b4`, `4a94e8a` e alterações posteriores.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"policy": "INDUSTRIAL-BUDGET-ACTUAL-SIM-v1",
|
||||
"database": "sgf_tempo_test",
|
||||
"overlapping_processes_per_case": 2,
|
||||
"checks": {
|
||||
"proposal_replay_one_version": true,
|
||||
"decision_replay_one_effect": true,
|
||||
"comparison_replay_one_snapshot": true,
|
||||
"competing_successors_one_winner": true,
|
||||
"competing_decisions_one_winner": true,
|
||||
"competing_comparisons_one_revision": true
|
||||
},
|
||||
"synthetic_only": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"environment": "browser-testing",
|
||||
"database": "sgf_tempo_test",
|
||||
"role": "sgf_test_runner",
|
||||
"database_port": 55432,
|
||||
"session_driver": "file",
|
||||
"session_cookie": "sgf_browser_proof",
|
||||
"cache_store": "file",
|
||||
"synthetic_only": true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"policy": "INDUSTRIAL-BUDGET-ACTUAL-SIM-v1",
|
||||
"date": "2026-10-28",
|
||||
"database": "sgf_tempo_test",
|
||||
"method": "manual_user_report_and_read_only_database_reconciliation",
|
||||
"manual_user_reported_complete_and_incomplete": true,
|
||||
"manual_user_confirmed_reader_forms_absent": true,
|
||||
"screenshots_received": false,
|
||||
"automated_browser_recovered": false,
|
||||
"comparisons": [
|
||||
{
|
||||
"comparison_id": 8,
|
||||
"version": 1,
|
||||
"budget_id": 10,
|
||||
"budget_version": 2,
|
||||
"result_id": 23,
|
||||
"result_version": 1,
|
||||
"actual_known": "1800",
|
||||
"total_delta": "0",
|
||||
"incomplete": false,
|
||||
"residual_separate": "300",
|
||||
"exact_arithmetic_verified": true,
|
||||
"distinct_validation_verified": true
|
||||
},
|
||||
{
|
||||
"comparison_id": 9,
|
||||
"version": 2,
|
||||
"budget_id": 10,
|
||||
"budget_version": 2,
|
||||
"result_id": 24,
|
||||
"result_version": 2,
|
||||
"actual_known": "800",
|
||||
"total_delta": null,
|
||||
"incomplete": true,
|
||||
"residual_separate": "300",
|
||||
"exact_arithmetic_verified": true,
|
||||
"distinct_validation_verified": true
|
||||
}
|
||||
],
|
||||
"role_separation_verified": true,
|
||||
"duplicate_workshop_or_direct_cost_origins": false,
|
||||
"test_records_changed": false,
|
||||
"credentials_changed": false,
|
||||
"user_acceptance": false,
|
||||
"closing_checkpoint_created": false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"policy": "INDUSTRIAL-BUDGET-ACTUAL-SIM-v1",
|
||||
"date": "2026-10-28",
|
||||
"database": "sgf_tempo_test",
|
||||
"regression": {"scope":"full_suite","tests":169,"assertions":3004,"errors":0,"failures":0},
|
||||
"new_test_cases":9,
|
||||
"overlapping_process_cases":6,
|
||||
"build":{"vite":"8.3.5","passed":true,"dependencies_changed":false},
|
||||
"browser":{"scope":"historical_automated_attempt_only","port":8002,"server_started":true,"synthetic_fixture_prepared":true,"login_demonstrated":false,"completed":false,"blocker":"Computer Use stopped: unable to determine current Edge URL confidently for policy enforcement.","server_stopped":true},
|
||||
"manual_followup":{"user_reported_path_completed":true,"reader_forms_absent_confirmed_by_user":true,"database_reconciliation_passed":true,"screenshots_received":false,"evidence":"industrial-budget-manual-results.json","automated_browser_recovered":false},
|
||||
"development_migration_110000_applied":false,
|
||||
"new_real_grants_applied":false,
|
||||
"real_costing_guard_refused":true,
|
||||
"user_acceptance":false,
|
||||
"closing_checkpoint_created":false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
const labels={material:'Material',labour:'Trabalho',allocated:'Indirectos imputados'};
|
||||
const value=v=>v===null?'Indisponível':v;
|
||||
export default function IndustrialBudgetsWorkspace({context,can,api,busy}){
|
||||
const [data,setData]=useState(null),[lotId,setLotId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false);const retry=useRef(null);
|
||||
const allowed=r=>can(r,context.unit?.id??null);const disabled=busy||working;
|
||||
const load=async()=>setData(await api('/api/industrial-budgets'));
|
||||
useEffect(()=>{setData(null);setLotId('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
|
||||
const lot=data?.lots.find(l=>String(l.id)===lotId)??data?.lots[0];
|
||||
async function send(action,input){setWorking(true);setError('');const path=`/api/industrial-budgets/lots/${lot.id}/${action}`,signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};try{await api(path,'POST',input,context.token,retry.current.key);retry.current=null;await load();}catch(e){setError(e.message);}finally{setWorking(false);}}
|
||||
return <section><p className="eyebrow">INDUSTRIAL-BUDGET-ACTUAL-SIM-v1</p><h2>Orçamento versus realizado</h2><div className="alert">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Hipótese retrospectiva sintética; diferenças monetárias não demonstram causas ou eficiência.</div>{error&&<p role="alert" className="alert error">{error}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>Operações reais bloqueadas.</p>:<><button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar versões explicitamente</button><label>Lote<select value={lot?.id??''} onChange={e=>setLotId(e.target.value)}>{data.lots.map(l=><option value={l.id} key={l.id}>{l.reference} · #{l.id}</option>)}</select></label>{lot?<><p>{lot.product.name} · {lot.period_start} a {lot.period_end} · 10 UN · MZN · Material, trabalho e indirectos imputados</p>
|
||||
{allowed('industrial_budgets.prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget),last=lot.budgets.at(-1);send('proposals',{version:(last?.version??0)+1,previous_id:last?.id??null,dimensions:lot.dimensions,amounts:Object.fromEntries(Object.keys(labels).map(k=>[k,f.get(k)===''?null:f.get(k)])),evidence:f.get('evidence')});}}><h3>Preparar orçamento versionado</h3><p>{lot.budgets.length?`Sucessora explícita de #${lot.budgets.at(-1).id} v${lot.budgets.at(-1).version}. Referência histórica preservada.`:'Primeira proposta. Não é planeamento anterior comprovado.'}</p>{Object.entries(labels).map(([k,l])=><label key={k}>{l} previsto (MZN; vazio = desconhecido)<input name={k} inputMode="decimal" placeholder="Montante exacto ou vazio"/></label>)}<label>Fundamento / motivo da versão<input name="evidence" required minLength={3}/></label><button disabled={disabled}>Submeter orçamento</button></form>}
|
||||
<h3>Orçamentos e decisões</h3>{lot.budgets.map(b=><article className="panel" key={b.id}><h3>Orçamento #{b.id} · v{b.version}</h3><p>{b.current?'Última versão':'HISTÓRICO — substituído explicitamente'} · {b.decision?`${b.decision.decision==='approved'?'Validado':'Recusado'} — ${b.decision.reason}`:'Aguarda validação distinta'}</p><p>{b.evidence}</p><ul>{Object.entries(labels).map(([k,l])=><li key={k}>{l}: {value(b.amounts[k])} MZN</li>)}</ul>{allowed('industrial_budgets.validate')&&b.current&&!b.decision&&<form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('decisions',{id:b.id,version:b.version,decision:f.get('decision'),reason:f.get('reason')});}}><label>Decisão<select name="decision"><option value="approved">Validar versão exacta</option><option value="refused">Recusar versão exacta</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar decisão do orçamento</button></form>}</article>)}
|
||||
{allowed('industrial_budget_comparisons.prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget),b=lot.budgets.find(x=>String(x.id)===f.get('budget')),r=lot.results.find(x=>String(x.id)===f.get('result')),last=lot.comparisons.at(-1);send('comparisons',{version:(last?.version??0)+1,previous_id:last?.id??null,budget_id:b.id,budget_version:b.version,result_id:r.id,result_version:r.version});}}><h3>Comparar versões explícitas</h3><label>Orçamento aprovado<select name="budget" required defaultValue=""><option value="">Seleccionar ID e versão</option>{lot.budgets.filter(b=>b.current&&b.decision?.decision==='approved').map(b=><option key={b.id} value={b.id}>#{b.id} v{b.version}</option>)}</select></label><label>Realizado validado<select name="result" required defaultValue=""><option value="">Seleccionar ID e versão</option>{lot.results.filter(r=>r.current&&r.decision?.decision==='approved').map(r=><option key={r.id} value={r.id}>#{r.id} v{r.version} · {r.snapshot.incomplete?'800 conhecidos — incompleto':`${r.snapshot.known_cost_exact} MZN`}</option>)}</select></label><button disabled={disabled}>Comparar orçamento e realizado</button></form>}
|
||||
<h3>Comparações preservadas</h3>{lot.comparisons.map(c=><article key={c.id} className="panel"><h3>Comparação #{c.id} · v{c.version}</h3><p>{c.current?'Versões actuais':'DESACTUALIZADA / HISTÓRICA — não substituída silenciosamente'}</p><p>Orçamento #{c.snapshot.budget.id} v{c.snapshot.budget.version} · Realizado #{c.snapshot.result.id} v{c.snapshot.result.version}</p><table><thead><tr><th>Componente</th><th>Previsto MZN</th><th>Realizado MZN</th><th>Desvio R − P</th><th>Percentagem</th></tr></thead><tbody>{Object.entries(labels).map(([k,l])=>{const r=c.snapshot.rows[k];return <tr key={k}><td>{l}</td><td>{value(r.planned)}</td><td>{value(r.actual)}</td><td>{value(r.delta)}</td><td>{r.percent===null?`Indisponível — ${r.percent_state==='zero_base'?'base zero':'valor desconhecido'}`:`${r.percent} %`}</td></tr>;})}</tbody></table><p><strong>{c.snapshot.incomplete?'COMPARAÇÃO INCOMPLETA — sem desvio total conclusivo':`Desvio total: ${c.snapshot.total_delta} MZN · ${c.snapshot.total_percent===null?'Percentagem indisponível — base zero':c.snapshot.total_percent+' %'}`}</strong></p><p>Realizado conhecido: {c.snapshot.actual_known} MZN</p><p>Reconciliação {c.snapshot.incomplete?'dos pares conhecidos':'total'}: {c.snapshot.paired_planned} + ({c.snapshot.paired_delta}) = {c.snapshot.paired_actual} MZN</p><p><strong>Residual separado: {c.snapshot.residual_separate} MZN por imputar — fora do desvio do lote</strong></p><p>Origens realizadas: {c.snapshot.actual_reconciliation.known_origins_exact} = {c.snapshot.actual_reconciliation.assigned_exact} atribuídos + {c.snapshot.actual_reconciliation.unassigned_exact} por imputar{c.snapshot.incomplete?' (cobertura parcial)':''}.</p></article>)}</>:<p>Sem lotes autorizados.</p>}</>}</section>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
|
||||
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
|
||||
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
|
||||
import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
|
||||
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
|
||||
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
|
||||
|
||||
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
|
||||
@@ -55,7 +56,7 @@ function App() {
|
||||
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
|
||||
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
|
||||
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
|
||||
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('industrial_costs.view',context?.unit?.id ?? null) ? [['industrial_costs', 'Custos industriais de teste']] : []), ...(can('direct_costs.view',context?.unit?.id ?? null) ? [['direct_costs', 'Custos directos de teste']] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
||||
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('industrial_budgets.view',context?.unit?.id ?? null) ? [['industrial_budgets', 'Orçamento versus realizado']] : []), ...(can('industrial_costs.view',context?.unit?.id ?? null) ? [['industrial_costs', 'Custos industriais de teste']] : []), ...(can('direct_costs.view',context?.unit?.id ?? null) ? [['direct_costs', 'Custos directos de teste']] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
||||
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
|
||||
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
|
||||
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
|
||||
@@ -67,6 +68,7 @@ function App() {
|
||||
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'direct_costs' && <DirectCostsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'industrial_budgets' && <IndustrialBudgetsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
|
||||
@@ -10,6 +10,14 @@ Route::get('/', function () {
|
||||
});
|
||||
|
||||
Route::get('/api/session', [IdentityController::class, 'session']);
|
||||
// Read-only diagnostic for the explicitly isolated manual proof; no business data or secrets.
|
||||
Route::get('/api/industrial-budget-proof/environment', function () {
|
||||
abort_unless(app()->environment('browser-testing'),404);
|
||||
\App\Modules\Industry\IndustrialBudgets::guard();
|
||||
$db=\Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS database,current_user AS role,inet_server_port() AS port');
|
||||
abort_unless($db->database==='sgf_tempo_test'&&$db->role==='sgf_test_runner'&&(int)$db->port===55432,403);
|
||||
return response()->json(['environment'=>app()->environment(),'database'=>$db->database,'role'=>$db->role,'database_port'=>(int)$db->port,'session_driver'=>config('session.driver'),'session_cookie'=>config('session.cookie'),'cache_store'=>config('cache.default'),'synthetic_only'=>true])->header('Cache-Control','no-store, private');
|
||||
});
|
||||
Route::post('/api/login', [IdentityController::class, 'login']);
|
||||
Route::middleware(['auth', RequireValidSession::class])->group(function (): void {
|
||||
Route::post('/api/logout', [IdentityController::class, 'logout']);
|
||||
@@ -17,6 +25,10 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
|
||||
Route::middleware(RequireCompanyContext::class)->group(function (): void {
|
||||
Route::get('/api/workshop', [\App\Modules\Workshop\Http\WorkshopController::class,'index']);
|
||||
Route::get('/api/direct-costs', [\App\Modules\Workshop\Http\DirectCostsController::class,'index']);
|
||||
Route::get('/api/industrial-budgets', [\App\Modules\Industry\Http\IndustrialBudgetsController::class,'index']);
|
||||
Route::post('/api/industrial-budgets/lots/{id}/proposals', [\App\Modules\Industry\Http\IndustrialBudgetsController::class,'propose'])->whereNumber('id');
|
||||
Route::post('/api/industrial-budgets/lots/{id}/decisions', [\App\Modules\Industry\Http\IndustrialBudgetsController::class,'decide'])->whereNumber('id');
|
||||
Route::post('/api/industrial-budgets/lots/{id}/comparisons', [\App\Modules\Industry\Http\IndustrialBudgetsController::class,'compare'])->whereNumber('id');
|
||||
Route::get('/api/industrial-costs', [\App\Modules\Industry\Http\IndustrialCostsController::class,'index']);
|
||||
Route::post('/api/industrial-costs/lots', [\App\Modules\Industry\Http\IndustrialCostsController::class,'open']);
|
||||
Route::post('/api/industrial-costs/lots/{id}/inputs', [\App\Modules\Industry\Http\IndustrialCostsController::class,'input'])->whereNumber('id');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
param([ValidateSet('start','stop')][string]$Action = 'start')
|
||||
param([ValidateSet('start','stop')][string]$Action = 'start', [ValidateRange(1024,65535)][int]$Port = 8001, [switch]$ResumeIndustrialBudget)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
|
||||
@@ -21,13 +21,17 @@ if ($Action -eq 'stop') {
|
||||
Write-Output 'Browser proof server stopped. Synthetic data remains only in sgf_tempo_test until the next test run.'
|
||||
exit 0
|
||||
}
|
||||
if (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 8001 occupied; no process was stopped.' }
|
||||
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) { throw "Port $Port occupied; no process was stopped." }
|
||||
if (Test-Path -LiteralPath $recordFile) { throw 'Browser proof record exists; stop that proof first.' }
|
||||
& $sgfPhp scripts/prepare-browser-fixture.php --env=testing
|
||||
if ($ResumeIndustrialBudget) {
|
||||
& $sgfPhp scripts/industrial-budget-manual-prepare.php --env=testing
|
||||
} else {
|
||||
& $sgfPhp scripts/prepare-browser-fixture.php --env=testing
|
||||
}
|
||||
if ($LASTEXITCODE) { throw 'Could not prepare synthetic fixtures.' }
|
||||
$utf8 = New-Object Text.UTF8Encoding $false
|
||||
$lines = Get-Content -LiteralPath '.env.testing' | Where-Object { $_ -notmatch '^(APP_ENV|APP_URL|SESSION_DRIVER|SESSION_COOKIE|SESSION_FILES|CACHE_STORE|CACHE_FILE_PATH|CACHE_LOCK_PATH)=' }
|
||||
$lines += @('APP_ENV=browser-testing','APP_URL=http://127.0.0.1:8001','SESSION_DRIVER=file','SESSION_COOKIE=sgf_browser_proof',
|
||||
$lines += @('APP_ENV=browser-testing',"APP_URL=http://127.0.0.1:$Port",'SESSION_DRIVER=file','SESSION_COOKIE=sgf_browser_proof',
|
||||
'SESSION_FILES=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/sessions', 'CACHE_STORE=file',
|
||||
'CACHE_FILE_PATH=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/cache', 'CACHE_LOCK_PATH=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/locks')
|
||||
[IO.File]::WriteAllLines((Join-Path $sgfProject '.env.browser-testing'), $lines, $utf8)
|
||||
@@ -36,8 +40,8 @@ $account = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
New-Item -ItemType Directory -Path 'storage\framework\browser-proof\sessions','storage\framework\browser-proof\cache','storage\framework\browser-proof\locks' -Force | Out-Null
|
||||
$env:APP_ENV = 'browser-testing'
|
||||
try {
|
||||
if (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 8001 became occupied; no process stopped.' }
|
||||
$process = Start-Process -FilePath $sgfPhp -ArgumentList @('-S','127.0.0.1:8001','-t','public','scripts/router.php') -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\browser-proof.stdout.log" -RedirectStandardError "$sgfRun\browser-proof.stderr.log"
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $recordFile -Encoding UTF8
|
||||
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) { throw "Port $Port became occupied; no process stopped." }
|
||||
$process = Start-Process -FilePath $sgfPhp -ArgumentList @('-S',"127.0.0.1:$Port",'-t','public','scripts/router.php') -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\browser-proof.stdout.log" -RedirectStandardError "$sgfRun\browser-proof.stderr.log"
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().ToString('o');port=$Port} | ConvertTo-Json | Set-Content -LiteralPath $recordFile -Encoding UTF8
|
||||
} finally { Remove-Item Env:APP_ENV -ErrorAction SilentlyContinue }
|
||||
Write-Output 'Browser proof: http://127.0.0.1:8001 — synthetic data only, isolated session/cache, CSRF enabled.'
|
||||
Write-Output "Browser proof: http://127.0.0.1:$Port - synthetic data only, isolated session/cache, CSRF enabled."
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use App\Modules\Industry\IndustrialBudgets;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
IndustrialBudgets::guard();$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);$fx=$f['budget_fixture'];$proof=[];$key=fn()=>(string)Str::uuid();
|
||||
$base=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'lot'=>$fx['lot']];
|
||||
$pair=function($one,$two)use($base){
|
||||
$a=new Process([PHP_BINARY,'scripts/industrial-budget-worker.php','--env=testing'],base_path(),null,json_encode($one+$base+['pause'=>true]));$b=new Process([PHP_BINARY,'scripts/industrial-budget-worker.php','--env=testing'],base_path(),null,json_encode($two+$base));
|
||||
$a->start();if(!$a->waitUntil(fn($t,$v)=>str_contains($v,'LOCK_HELD')))throw new RuntimeException('Budget concurrent lock not observed.');$b->start();$a->wait();$b->wait();$out=[];
|
||||
foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Budget worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($v)=>str_starts_with($v,'{')));$out[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);}return $out;
|
||||
};
|
||||
$check=function($out,bool $replay){if($out[0]['status']!==200||$out[1]['status']!==($replay?200:409)||($replay&&(!$out[1]['result']['replayed']||$out[0]['result']['id']!==$out[1]['result']['id'])))throw new RuntimeException('Unexpected concurrent budget outcome.');return $out[0]['result']['id'];};
|
||||
$last=DB::table('ib_budgets')->where('lot_id',$fx['lot'])->orderByDesc('version')->first();
|
||||
$p=['actor'=>$f['manager_id'],'method'=>'propose','key'=>$key(),'input'=>['version'=>($last?->version??0)+1,'previous_id'=>$last?->id,'dimensions'=>app(IndustrialBudgets::class)->dimensions(DB::table('ic_lots')->find($fx['lot'])),'amounts'=>['material'=>'900','labour'=>'600','allocated'=>'300'],'evidence'=>'Successeur synthétique explicitement révisé.']];
|
||||
$id=$check($pair($p,$p),true);$proof['proposal_replay_one_version']=true;
|
||||
$d=['actor'=>$f['reader_id'],'method'=>'decide','key'=>$key(),'input'=>['id'=>$id,'version'=>$p['input']['version'],'decision'=>'approved','reason'=>'Revue synthétique distincte.']];$check($pair($d,$d),true);$proof['decision_replay_one_effect']=true;
|
||||
$result=DB::table('ic_results')->where('lot_id',$fx['lot'])->orderByDesc('version')->first();$lastC=DB::table('ib_comparisons')->where('lot_id',$fx['lot'])->orderByDesc('version')->first();
|
||||
$c=['actor'=>$f['manager_id'],'method'=>'compare','key'=>$key(),'input'=>['version'=>($lastC?->version??0)+1,'previous_id'=>$lastC?->id,'budget_id'=>$id,'budget_version'=>$p['input']['version'],'result_id'=>$result->id,'result_version'=>$result->version]];
|
||||
$cid=$check($pair($c,$c),true);$proof['comparison_replay_one_snapshot']=true;
|
||||
$p['key']=$key();$p['input']['version']++;$p['input']['previous_id']=$id;$other=$p;$other['key']=$key();$other['input']['amounts']['material']='950';$new=$check($pair($p,$other),false);$proof['competing_successors_one_winner']=true;
|
||||
$d['key']=$key();$d['input']['id']=$new;$d['input']['version']=$p['input']['version'];$other=$d;$other['key']=$key();$other['input']['decision']='refused';$check($pair($d,$other),false);$proof['competing_decisions_one_winner']=true;
|
||||
$c['key']=$key();$c['input']['version']++;$c['input']['previous_id']=$cid;$c['input']['budget_id']=$new;$c['input']['budget_version']=$p['input']['version'];$other=$c;$other['key']=$key();$check($pair($c,$other),false);$proof['competing_comparisons_one_revision']=true;
|
||||
file_put_contents(base_path('docs/proofs/industrial-budget-concurrency.json'),json_encode(['policy'=>IndustrialBudgets::POLICY,'database'=>'sgf_tempo_test','overlapping_processes_per_case'=>2,'checks'=>$proof,'synthetic_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Six overlapping budget concurrency cases passed.\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Modules\Industry\IndustrialBudgets::guard();$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';$base=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||
if(\Illuminate\Support\Facades\DB::table('ic_lots')->exists())throw new RuntimeException('Fresh synthetic proof required; nothing deleted.');
|
||||
$f=\Tests\Support\IndustrialBudgetFixture::create($base+['manager'=>\App\Models\User::findOrFail($base['manager_id']),'reader'=>\App\Models\User::findOrFail($base['reader_id'])]);
|
||||
file_put_contents($file,json_encode($base+['budget_fixture'=>array_intersect_key($f,array_flip(['lot','refs','result']))],JSON_THROW_ON_ERROR));
|
||||
echo "Synthetic industrial result validated at 1800; budget and comparisons remain for browser.\n";
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
// Read-only credential correspondence check; never prints or changes credentials.
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Modules\Industry\IndustrialBudgets::guard();
|
||||
$identity=\Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS db,current_user AS role,inet_server_port() AS port');
|
||||
if($identity->db!=='sgf_tempo_test'||$identity->role!=='sgf_test_runner'||(int)$identity->port!==55432)throw new RuntimeException('Unexpected database; no accounts changed.');
|
||||
$path='C:/laragon/data/sgf-tempo/secrets/industrial-budget-manual-entry.txt';
|
||||
if(!is_file($path))throw new RuntimeException('Entry file missing; no credentials reset.');
|
||||
$content=file_get_contents($path);
|
||||
if(!str_contains($content,'http://127.0.0.1:8002')||!preg_match_all('/(?:Osmane|Samira) synthetic: ([^\r\n]+)\r?\nPassword: ([^\r\n]+)/',$content,$matches,PREG_SET_ORDER)||count($matches)!==2)throw new RuntimeException('Unexpected entry file structure; no accounts changed.');
|
||||
$checks=[];
|
||||
foreach($matches as $match){$email=$match[1];if(!in_array($email,['gestor@example.test','consulta@example.test'],true))throw new RuntimeException('Non-synthetic account refused.');$user=\App\Models\User::where('email',$email)->first();$checks[]=['account'=>$email,'matches_current_test_account'=>$user&&\Illuminate\Support\Facades\Hash::check($match[2],$user->password)];}
|
||||
unset($content,$matches,$match);
|
||||
echo json_encode(['path'=>realpath($path),'database'=>$identity->db,'role'=>$identity->role,'accounts'=>$checks,'read_only'=>true,'credentials_changed'=>false],JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// Resume only the existing synthetic fixture. Never resets the database or creates grants.
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use App\Modules\Industry\IndustrialBudgets;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
IndustrialBudgets::guard();
|
||||
$identity=DB::selectOne('SELECT current_database() AS db,current_user AS role,inet_server_port() AS port');
|
||||
if($identity->db!=='sgf_tempo_test'||$identity->role!=='sgf_test_runner'||(int)$identity->port!==55432)throw new RuntimeException('Unsafe manual proof destination.');
|
||||
$m=User::where('email','gestor@example.test')->firstOrFail();$r=User::where('email','consulta@example.test')->firstOrFail();
|
||||
if(DB::table('ic_lots')->count()!==1)throw new RuntimeException('Exactly one existing synthetic lot required; nothing deleted.');
|
||||
$lot=DB::table('ic_lots')->first();$result=DB::table('ic_results')->where(['lot_id'=>$lot->id,'version'=>1])->first();
|
||||
if(!$result||!str_starts_with($lot->reference,'LOT-TEST-'))throw new RuntimeException('Expected synthetic result missing.');
|
||||
$s=json_decode($result->snapshot,true,64,JSON_THROW_ON_ERROR);
|
||||
foreach(['material_exact'=>'1000','labour_exact'=>'500','allocated_exact'=>'300','residual_exact'=>'300','selected_total_exact'=>'1800','good_units'=>'10','currency'=>'MZN'] as $k=>$v)if($s[$k]!==$v)throw new RuntimeException('Full fixture differs.');
|
||||
if(!DB::table('ic_decisions')->where(['result_id'=>$result->id,'decision'=>'approved','actor_id'=>$r->id])->exists())throw new RuntimeException('Distinct result approval required.');
|
||||
$p=app(Permissions::class);
|
||||
foreach([$m->id=>['industrial_budgets.view','industrial_budgets.prepare','industrial_budget_comparisons.view','industrial_budget_comparisons.prepare'],$r->id=>['industrial_budgets.view','industrial_budgets.validate','industrial_budget_comparisons.view']] as $u=>$rights)foreach($rights as $right)$p->require($u,$lot->tenant_id,$lot->company_id,$right,$lot->unit_id);
|
||||
if($p->has($m->id,$lot->tenant_id,$lot->company_id,'industrial_budgets.validate',$lot->unit_id)||$p->has($r->id,$lot->tenant_id,$lot->company_id,'industrial_budgets.prepare',$lot->unit_id)||$p->has($r->id,$lot->tenant_id,$lot->company_id,'industrial_budget_comparisons.prepare',$lot->unit_id))throw new RuntimeException('Role separation differs.');
|
||||
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';$entry='C:/laragon/data/sgf-tempo/secrets/industrial-budget-manual-entry.txt';
|
||||
if(!file_exists($file)){
|
||||
$mp=bin2hex(random_bytes(20));$rp=bin2hex(random_bytes(20));
|
||||
DB::transaction(function()use($m,$r,$mp,$rp){$m->password=$mp;$m->save();$r->password=$rp;$r->save();});
|
||||
$f=['tenant'=>$lot->tenant_id,'company'=>$lot->company_id,'unit'=>$lot->unit_id,'manager_id'=>$m->id,'reader_id'=>$r->id,'budget_fixture'=>['lot'=>$lot->id,'result'=>$result->id,'refs'=>$s['refs']]];
|
||||
file_put_contents($file,json_encode($f,JSON_THROW_ON_ERROR));
|
||||
file_put_contents($entry,"SGF synthetic manual proof only - http://127.0.0.1:8002\r\nOsmane synthetic: gestor@example.test\r\nPassword: ".$mp."\r\nSamira synthetic: consulta@example.test\r\nPassword: ".$rp."\r\nNever paste these passwords into chat or reports.\r\n");unset($mp,$rp);
|
||||
}
|
||||
if(!file_exists($entry))throw new RuntimeException('Protected entry instructions missing; review locally.');
|
||||
echo json_encode(['database'=>$identity->db,'role'=>$identity->role,'port'=>(int)$identity->port,'synthetic_roles_separate'=>true,'lot_id'=>$lot->id,'result_id'=>$result->id,'result_version'=>1,'existing_budgets'=>DB::table('ib_budgets')->count(),'existing_comparisons'=>DB::table('ib_comparisons')->count(),'credentials'=>'protected_local_file_only','database_reset'=>false,'grants_created'=>false],JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// Read-only status for guiding the isolated manual proof. No credentials or account changes.
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Modules\Industry\IndustrialBudgets::guard();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$rows=[];
|
||||
foreach(DB::table('ib_budgets')->orderBy('id')->get() as $b){
|
||||
$d=DB::table('ib_decisions')->where('budget_id',$b->id)->first();
|
||||
$rows[]=['id'=>$b->id,'lot_id'=>$b->lot_id,'version'=>$b->version,'previous_id'=>$b->previous_id,'current'=>!DB::table('ib_budgets')->where('previous_id',$b->id)->exists(),'amounts'=>json_decode($b->amounts,true),'decision'=>$d?->decision,'preparer_id'=>$b->actor_id,'validator_id'=>$d?->actor_id];
|
||||
}
|
||||
echo json_encode(['database'=>DB::selectOne('SELECT current_database() AS db')->db,'budgets'=>$rows,'comparisons'=>DB::table('ib_comparisons')->get(['id','lot_id','version','budget_id','result_id'])->all(),'read_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// Reconcile the preserved manual demonstration without changing test data or credentials.
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use App\Modules\Industry\IndustrialBudgets;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
function checkManualBudget(bool $ok,string $reason): void {if(!$ok)throw new RuntimeException($reason);}
|
||||
function canonicalManualBudget(mixed $v): mixed {if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map('canonicalManualBudget',$v);}
|
||||
IndustrialBudgets::guard();
|
||||
$identity=DB::selectOne('SELECT current_database() AS db,current_user AS role,inet_server_port() AS port');
|
||||
checkManualBudget($identity->db==='sgf_tempo_test'&&$identity->role==='sgf_test_runner'&&(int)$identity->port===55432,'Unexpected database or role.');
|
||||
$fixture=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
|
||||
$lot=$fixture['budget_fixture']['lot'];$manager=$fixture['manager_id'];$reader=$fixture['reader_id'];
|
||||
$comparisons=DB::table('ib_comparisons')->where('lot_id',$lot)->orderBy('version')->get();
|
||||
checkManualBudget($comparisons->count()===2,'Expected exactly two manual comparisons.');
|
||||
$proof=[];
|
||||
foreach($comparisons as $index=>$c){
|
||||
$snapshot=json_decode($c->snapshot,true,64,JSON_THROW_ON_ERROR);$budget=DB::table('ib_budgets')->find($c->budget_id);$result=DB::table('ic_results')->find($c->result_id);
|
||||
checkManualBudget($budget&&$result&&$budget->lot_id===$lot&&$result->lot_id===$lot,'Cross-lot reference.');
|
||||
checkManualBudget($budget->actor_id===$manager&&$c->actor_id===$manager,'Unexpected preparer.');
|
||||
foreach([['ib_decisions','budget_id',$budget],['ic_decisions','result_id',$result]] as [$table,$column,$target]){
|
||||
$decision=DB::table($table)->where([$column=>$target->id,'version'=>$target->version,'decision'=>'approved'])->first();
|
||||
checkManualBudget($decision&&$decision->actor_id===$reader&&$decision->actor_id!==$target->actor_id,'Distinct exact validation missing.');
|
||||
}
|
||||
$actual=json_decode($result->snapshot,true,64,JSON_THROW_ON_ERROR);$amounts=json_decode($budget->amounts,true);
|
||||
checkManualBudget($amounts==['material'=>'900','labour'=>'600','allocated'=>'300'],'Budget amounts differ.');
|
||||
$expected=IndustrialBudgets::variance($amounts,['material'=>$actual['material_exact'],'labour'=>$actual['labour_exact'],'allocated'=>$actual['allocated_exact']]);
|
||||
foreach($expected as $field=>$value)checkManualBudget(canonicalManualBudget($snapshot[$field])===canonicalManualBudget($value),'Comparison arithmetic differs: '.$field);
|
||||
checkManualBudget($snapshot['budget']===['id'=>$budget->id,'version'=>$budget->version]&&$snapshot['result']['id']===$result->id&&$snapshot['result']['version']===$result->version&&$snapshot['result']['refs']===$actual['refs'],'Exact version references differ.');
|
||||
checkManualBudget($snapshot['residual_separate']==='300'&&$actual['residual_exact']==='300'&&$snapshot['actual_reconciliation']===$actual['reconciliation'],'Residual or reconciliation differs.');
|
||||
checkManualBudget($actual['good_units']==='10'&&$actual['currency']==='MZN','Quantity or currency differs.');
|
||||
checkManualBudget($actual['labour_exact']==='500'&&$actual['allocated_exact']==='300','Known components differ.');
|
||||
if($index===0){
|
||||
checkManualBudget($actual['material_exact']==='1000'&&$actual['known_cost_exact']==='1800'&&$actual['selected_total_exact']==='1800'&&$actual['unit_cost_exact']==='180'&&!$expected['incomplete']&&$expected['total_delta']==='0','Full scenario differs.');
|
||||
checkManualBudget($snapshot['actual_reconciliation']['known_origins_exact']==='2100','Full origins differ.');
|
||||
}else{
|
||||
checkManualBudget($actual['material_exact']===null&&$actual['known_cost_exact']==='800'&&$actual['selected_total_exact']===null&&$actual['unit_cost_exact']===null&&$expected['incomplete']&&$expected['total_delta']===null&&$expected['total_percent']===null,'Incomplete scenario differs.');
|
||||
checkManualBudget($expected['rows']['material']['delta']===null&&$expected['paired_delta']==='-100'&&$snapshot['actual_reconciliation']['known_origins_exact']==='1100','Missing material treated incorrectly.');
|
||||
checkManualBudget($result->previous_id===$comparisons[0]->result_id&&$c->previous_id===$comparisons[0]->id&&$c->budget_id===$comparisons[0]->budget_id,'History chain differs.');
|
||||
}
|
||||
$proof[]=['comparison_id'=>$c->id,'version'=>$c->version,'budget_id'=>$budget->id,'budget_version'=>$budget->version,'result_id'=>$result->id,'result_version'=>$result->version,'actual_known'=>$actual['known_cost_exact'],'total_delta'=>$expected['total_delta'],'incomplete'=>$expected['incomplete'],'residual_separate'=>'300','exact_arithmetic_verified'=>true,'distinct_validation_verified'=>true];
|
||||
}
|
||||
$permissions=app(Permissions::class);
|
||||
foreach([$manager=>['industrial_budgets.view','industrial_budgets.prepare','industrial_budget_comparisons.view','industrial_budget_comparisons.prepare'],$reader=>['industrial_budgets.view','industrial_budgets.validate','industrial_budget_comparisons.view']] as $actor=>$rights)foreach($rights as $right)$permissions->require($actor,$fixture['tenant'],$fixture['company'],$right,$fixture['unit']);
|
||||
foreach([[$manager,'industrial_budgets.validate'],[$reader,'industrial_budgets.prepare'],[$reader,'industrial_budget_comparisons.prepare']] as [$actor,$right])checkManualBudget(!$permissions->has($actor,$fixture['tenant'],$fixture['company'],$right,$fixture['unit']),'Unexpected functional power.');
|
||||
checkManualBudget(!DB::table('wo_entries')->exists()&&!DB::table('dc_calculations')->exists(),'Unexpected workshop/direct cost origins.');
|
||||
$report=['policy'=>IndustrialBudgets::POLICY,'date'=>'2026-10-28','database'=>'sgf_tempo_test','method'=>'manual_user_report_and_read_only_database_reconciliation','manual_user_reported_complete_and_incomplete'=>true,'manual_user_confirmed_reader_forms_absent'=>true,'screenshots_received'=>false,'automated_browser_recovered'=>false,'comparisons'=>$proof,'role_separation_verified'=>true,'duplicate_workshop_or_direct_cost_origins'=>false,'test_records_changed'=>false,'credentials_changed'=>false,'user_acceptance'=>false,'closing_checkpoint_created'=>false];
|
||||
file_put_contents(base_path('docs/proofs/industrial-budget-manual-results.json'),json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo "Manual report reconciled: two exact comparisons, distinct validations, preserved history, residual and role separation. No database or credential changes. Screenshots remain unavailable.\n";
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
// Read-only; deliberately no application branch.
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own SGF development application database required.');
|
||||
$rights=[1=>['industrial_budgets.view','industrial_budgets.prepare','industrial_budget_comparisons.view','industrial_budget_comparisons.prepare'],2=>['industrial_budgets.view','industrial_budgets.validate','industrial_budget_comparisons.view']];$grants=[];
|
||||
if(!DB::table('companies')->where(['id'=>1,'tenant_id'=>1,'name'=>'Tempo Lda'])->exists()||!DB::table('units')->where(['id'=>1,'tenant_id'=>1,'company_id'=>1,'name'=>'SEGI'])->exists())throw new RuntimeException('Scope differs.');
|
||||
foreach($rights as $user=>$permissions){if(!DB::table('users')->where('id',$user)->exists()||!DB::table('company_memberships')->where(['user_id'=>$user,'tenant_id'=>1,'company_id'=>1])->exists())throw new RuntimeException('Existing account membership required.');foreach($permissions as $permission){$g=['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$permission];$grants[]=$g+['already_granted'=>DB::table('permission_grants')->where($g)->exists()];}}
|
||||
try{\App\Modules\Industry\IndustrialBudgets::guard();throw new RuntimeException('Real costing allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){if($e->getStatusCode()!==403)throw $e;}
|
||||
echo json_encode(['mode'=>'preview_only','destination'=>'sgf_tempo_dev','migration_110000_applied'=>DB::table('migrations')->where('migration','2026_10_28_110000_create_industrial_budgets')->exists(),'proposed_grants'=>$grants,'real_costing_blocked'=>true,'applied'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
require __DIR__.'/../vendor/autoload.php';$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Modules\Industry\IndustrialBudgets::guard();$in=json_decode(stream_get_contents(STDIN),true,32,JSON_THROW_ON_ERROR);
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($in['actor'],$in['tenant'],$in['company'],$in['unit']);
|
||||
if($in['pause']??false){$paused=false;\Illuminate\Support\Facades\DB::listen(function($q)use(&$paused){if(!$paused&&str_contains($q->sql,'"companies"')&&str_contains($q->sql,'for update')){$paused=true;echo "LOCK_HELD\n";flush();usleep(1200000);}});}
|
||||
try{$out=app(\App\Modules\Industry\IndustrialBudgets::class)->{$in['method']}($in['lot'],$in['actor'],$in['input'],$in['key']);echo json_encode(['status'=>200,'result'=>$out],JSON_THROW_ON_ERROR).PHP_EOL;}
|
||||
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){echo json_encode(['status'=>$e->getStatusCode()],JSON_THROW_ON_ERROR).PHP_EOL;}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
use App\Modules\Industry\{IndustrialBudgets,IndustrialCosts};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\{IndustrialBudgetFixture,IdentityFixture};
|
||||
use Tests\TestCase;
|
||||
final class IndustrialBudgetsTest extends TestCase {
|
||||
use RefreshDatabase;
|
||||
private array $f;private string $token;
|
||||
protected function setUp(): void {parent::setUp();$this->f=IndustrialBudgetFixture::create();$this->login();}
|
||||
private function login(string $who='manager'): void {$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');}
|
||||
private function send(string $action,array $in,?string $key=null,?int $lot=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/industrial-budgets/lots/'.($lot??$this->f['lot']).'/'.$action,$in);}
|
||||
private function proposal(array $extra=[]): array {return array_replace(['version'=>1,'previous_id'=>null,'dimensions'=>app(IndustrialBudgets::class)->dimensions(DB::table('ic_lots')->find($this->f['lot'])),'amounts'=>['material'=>'900','labour'=>'600','allocated'=>'300'],'evidence'=>'Hipótese retrospectiva sintética, sem causas presumidas.'],$extra);}
|
||||
private function approve(int $id,int $version=1): void {$this->login('reader');$this->send('decisions',['id'=>$id,'version'=>$version,'decision'=>'approved','reason'=>'Validação exacta sintética.'])->assertOk();$this->login();}
|
||||
private function comparison(int $b,array $extra=[]): array {return array_replace(['version'=>1,'previous_id'=>null,'budget_id'=>$b,'budget_version'=>1,'result_id'=>$this->f['result'],'result_version'=>1],$extra);}
|
||||
private function snapshot(int $id): array {return json_decode(DB::table('ib_comparisons')->find($id)->snapshot,true);}
|
||||
public function test_full_scenario_exact_reconciliation_roles_and_no_new_consumption(): void {
|
||||
$before=[];foreach(['ic_inputs','ic_results','wo_entries','dc_calculations'] as $t)$before[$t]=DB::table($t)->count();
|
||||
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->send('comparisons',$this->comparison($b))->assertStatus(409);
|
||||
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_budgets.validate',$this->f['unit']);$this->send('decisions',['id'=>$b,'version'=>1,'decision'=>'approved','reason'=>'Auto validação recusada.'])->assertForbidden();$this->approve($b);
|
||||
$c=$this->send('comparisons',$this->comparison($b))->assertOk()->json('id');$s=$this->snapshot($c);
|
||||
foreach(['material'=>'100','labour'=>'-100','allocated'=>'0'] as $k=>$v)$this->assertSame($v,$s['rows'][$k]['delta']);$this->assertSame('100/9',$s['rows']['material']['percent']);$this->assertSame('-50/3',$s['rows']['labour']['percent']);$this->assertSame('0',$s['total_delta']);$this->assertTrue($s['balanced']);$this->assertSame('300',$s['residual_separate']);$this->assertSame('2100',$s['actual_reconciliation']['known_origins_exact']);
|
||||
foreach($before as $t=>$n)$this->assertSame($n,DB::table($t)->count());$this->login('reader');$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',true);$this->send('proposals',$this->proposal())->assertForbidden();$this->send('comparisons',$this->comparison($b))->assertForbidden();
|
||||
}
|
||||
public function test_equal_above_below_zero_unknown_and_nonfinite_percentages(): void {
|
||||
$r=['material'=>'1000','labour'=>'500','allocated'=>'300'];
|
||||
foreach([[['1000','500','300'],'0','0'],[['900','450','270'],'180','100/9'],[['1100','550','330'],'-180','-100/11'],[['0','500','300'],'1000','125'],[['0','0','0'],'1800',null]] as [$p,$delta,$pct]){$s=IndustrialBudgets::variance(array_combine(array_keys($r),$p),$r);$this->assertSame($delta,$s['total_delta']);$this->assertSame($pct,$s['total_percent']);$this->assertTrue($s['balanced']);}
|
||||
$s=IndustrialBudgets::variance(['material'=>'0','labour'=>'0','allocated'=>'0'],['material'=>'0','labour'=>'0','allocated'=>'0']);$this->assertSame('0',$s['total_delta']);$this->assertSame('zero_base',$s['total_percent_state']);
|
||||
$s=IndustrialBudgets::variance(['material'=>null,'labour'=>'600','allocated'=>'300'],$r);$this->assertNull($s['rows']['material']['delta']);$this->assertNull($s['total_delta']);$this->assertSame('-100',$s['paired_delta']);$this->assertSame('unknown',$s['total_percent_state']);
|
||||
}
|
||||
public function test_missing_material_is_incomplete_and_old_comparison_becomes_historical(): void {
|
||||
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->approve($b);$c=$this->send('comparisons',$this->comparison($b))->assertOk()->json('id');$old=DB::table('ib_comparisons')->find($c)->snapshot;
|
||||
$refs=$this->f['refs'];$refs['material_rate']=null;app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$r=app(IndustrialCosts::class)->calculate($this->f['lot'],$this->f['manager']->id,['version'=>2,'previous_id'=>$this->f['result'],'currency'=>'MZN','refs'=>$refs],(string)Str::uuid())['id'];
|
||||
app(CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(IndustrialCosts::class)->decide($this->f['lot'],$this->f['reader']->id,['type'=>'result','id'=>$r,'version'=>2,'decision'=>'approved','reason'=>'Só perímetro conhecido.'],(string)Str::uuid());$this->login();
|
||||
$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',false);$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>$c]))->assertStatus(409);
|
||||
$p=$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>$c,'result_id'=>$r,'result_version'=>2]))->assertOk()->json('id');$s=$this->snapshot($p);$this->assertTrue($s['incomplete']);$this->assertNull($s['total_delta']);$this->assertNull($s['rows']['material']['delta']);$this->assertSame('800',$s['actual_known']);$this->assertSame('-100',$s['paired_delta']);$this->assertSame($old,DB::table('ib_comparisons')->find($c)->snapshot);
|
||||
}
|
||||
public function test_versions_refusal_dimensions_and_old_approval_not_transferred(): void {
|
||||
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->approve($b);$c=$this->send('comparisons',$this->comparison($b))->assertOk()->json('id');$old=DB::table('ib_budgets')->find($b)->amounts;
|
||||
foreach(['product_id'=>999,'lot_id'=>999,'tenant_id'=>999,'company_id'=>999,'unit_id'=>999,'quantity'=>'12','unit'=>'KG','currency'=>'USD','period_start'=>'2026-09-01','period_end'=>'2026-11-01','scope'=>'all_origins'] as $k=>$v){$d=$this->proposal(['version'=>2,'previous_id'=>$b]);$d['dimensions'][$k]=$v;$this->send('proposals',$d)->assertStatus(409);}
|
||||
$next=$this->send('proposals',$this->proposal(['version'=>2,'previous_id'=>$b,'amounts'=>['material'=>'1000','labour'=>'500','allocated'=>'300'],'evidence'=>'Révision après constat, original préservé.']))->assertOk()->json('id');
|
||||
$this->send('comparisons',$this->comparison($next,['version'=>2,'previous_id'=>$c,'budget_version'=>2]))->assertStatus(409);$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>$c]))->assertStatus(409);$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',false);$this->assertSame($old,DB::table('ib_budgets')->find($b)->amounts);
|
||||
$this->login('reader');$this->send('decisions',['id'=>$next,'version'=>1,'decision'=>'approved','reason'=>'Wrong version'])->assertStatus(409);$this->send('decisions',['id'=>$next,'version'=>2,'decision'=>'refused','reason'=>'Refus explicite.'])->assertOk();$this->login();$this->send('comparisons',$this->comparison($next,['version'=>2,'previous_id'=>$c,'budget_version'=>2]))->assertStatus(409);
|
||||
}
|
||||
public function test_replay_content_conflict_rollback_and_recovery(): void {
|
||||
$d=$this->proposal();$key=(string)Str::uuid();$id=$this->send('proposals',$d,$key)->assertOk()->json('id');$this->send('proposals',array_reverse($d,true),$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);$this->send('proposals',array_replace($d,['evidence'=>'Other content']),$key)->assertStatus(409);
|
||||
$this->approve($id);$input=$this->comparison($id);$key=(string)Str::uuid();DB::unprepared("CREATE OR REPLACE FUNCTION sgf_ib_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'IB_OUTBOX_FAIL'; END; \$\$; CREATE TRIGGER ib_fail BEFORE INSERT ON ib_outbox FOR EACH ROW EXECUTE FUNCTION sgf_ib_fail()");
|
||||
$n=DB::table('identity_audit')->count();$this->send('comparisons',$input,$key)->assertStatus(500);$this->assertSame(0,DB::table('ib_comparisons')->count());$this->assertSame($n,DB::table('identity_audit')->count());DB::unprepared('DROP TRIGGER ib_fail ON ib_outbox');$c=$this->send('comparisons',$input,$key)->assertOk()->json('id');$this->send('comparisons',$input,$key)->assertOk()->assertJsonPath('id',$c)->assertJsonPath('replayed',true);$this->assertSame(1,DB::table('ib_comparisons')->count());
|
||||
}
|
||||
public function test_isolation_revocation_next_request_and_no_inherited_permissions(): void {
|
||||
$this->send('proposals',$this->proposal(),null,999999)->assertForbidden();$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');
|
||||
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budgets.prepare'])->delete();$this->send('proposals',$this->proposal())->assertForbidden();
|
||||
$this->login('reader');DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'industrial_budgets.validate'])->delete();$this->send('decisions',['id'=>$b,'version'=>1,'decision'=>'approved','reason'=>'Revoked'])->assertForbidden();
|
||||
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'industrial_budgets.view'])->delete();$this->getJson('/api/industrial-budgets')->assertForbidden();
|
||||
}
|
||||
public function test_sql_immutability_and_normal_environment_guard(): void {
|
||||
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');foreach(['UPDATE ib_budgets SET evidence=\'changed\' WHERE id='.$b,'DELETE FROM ib_budgets WHERE id='.$b] as $sql){try{DB::transaction(fn()=>DB::unprepared($sql));$this->fail('SQL immutable guard absent');}catch(\Illuminate\Database\QueryException $e){$this->assertNotEmpty($e->getMessage());}}
|
||||
$this->app->detectEnvironment(fn()=>'local');
|
||||
try{IndustrialBudgets::guard();$this->fail('Real costing allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());}
|
||||
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$this->assertTrue(app(IndustrialBudgets::class)->listing($this->f['manager']->id)['blocked']);
|
||||
}
|
||||
public function test_source_change_wrong_quantity_and_comparison_permission_revocation(): void {
|
||||
$b=$this->send('proposals',$this->proposal())->assertOk()->json('id');$this->approve($b);$key=(string)Str::uuid();$input=$this->comparison($b);$this->send('comparisons',$input,$key)->assertOk();
|
||||
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budget_comparisons.prepare'])->delete();$this->send('comparisons',$input,$key)->assertForbidden();IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_budget_comparisons.prepare',$this->f['unit']);
|
||||
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budget_comparisons.view'])->delete();$this->send('comparisons',$input,$key)->assertForbidden();$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonCount(0,'lots.0.comparisons');IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_budget_comparisons.view',$this->f['unit']);
|
||||
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$cost=app(IndustrialCosts::class);$old=DB::table('ic_inputs')->find($this->f['refs']['production']['id']);$data=json_decode($old->data,true);$data['quantity']='12';$p=$cost->input($this->f['lot'],$this->f['manager']->id,['kind'=>'production','source_ref'=>$old->source_ref,'version'=>2,'previous_id'=>$old->id,'data'=>$data,'evidence'=>'Different quantity, no normalization.'],(string)Str::uuid())['id'];
|
||||
$this->getJson('/api/industrial-budgets')->assertOk()->assertJsonPath('lots.0.comparisons.0.current',false);
|
||||
app(CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$cost->decide($this->f['lot'],$this->f['reader']->id,['type'=>'input','id'=>$p,'version'=>2,'decision'=>'approved','reason'=>'Synthetic quantity revision.'],(string)Str::uuid());
|
||||
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$refs=$this->f['refs'];$refs['production']=['id'=>$p,'version'=>2];$r=$cost->calculate($this->f['lot'],$this->f['manager']->id,['version'=>2,'previous_id'=>$this->f['result'],'currency'=>'MZN','refs'=>$refs],(string)Str::uuid())['id'];
|
||||
app(CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$cost->decide($this->f['lot'],$this->f['reader']->id,['type'=>'result','id'=>$r,'version'=>2,'decision'=>'approved','reason'=>'12 units validated only for source result.'],(string)Str::uuid());$this->login();$this->send('comparisons',$this->comparison($b,['version'=>2,'previous_id'=>DB::table('ib_comparisons')->value('id'),'result_id'=>$r,'result_version'=>2]))->assertStatus(409);
|
||||
}
|
||||
public function test_other_company_unit_context_and_invalid_amounts_are_refused(): void {
|
||||
foreach([-1,0,'-1','1e3','1.000000001','10000000000000000'] as $v){$d=$this->proposal();$d['amounts']['material']=$v;$this->send('proposals',$d)->assertStatus(422);}
|
||||
$oldToken=$this->token;$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();$this->send('proposals',$this->proposal())->assertStatus(409);$this->login();
|
||||
DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_budgets.view'])->delete();$this->getJson('/api/industrial-budgets')->assertForbidden();
|
||||
foreach(['industrial_costs.view','industrial_budgets.view','industrial_budgets.prepare'] as $p)IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],$p,$this->f['siblingUnit']);
|
||||
app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],$this->f['siblingUnit']);
|
||||
try{app(IndustrialBudgets::class)->propose($this->f['lot'],$this->f['manager']->id,$this->proposal(),(string)Str::uuid());$this->fail('Cross company lot allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace Tests\Support;
|
||||
use App\Modules\Industry\{IndustrialCosts,IndustrialBudgets};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Str;
|
||||
final class IndustrialBudgetFixture {
|
||||
public static function create(?array $base=null): array {
|
||||
$f=IndustrialCostFixture::create($base);
|
||||
foreach(['manager'=>['industrial_budgets.view','industrial_budgets.prepare','industrial_budget_comparisons.view','industrial_budget_comparisons.prepare'],'reader'=>['industrial_budgets.view','industrial_budgets.validate','industrial_budget_comparisons.view']] as $who=>$rights)foreach($rights as $p)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],$p,$f['unit']);
|
||||
$c=app(IndustrialCosts::class);$result=$c->calculate($f['lot'],$f['manager']->id,['version'=>1,'previous_id'=>null,'currency'=>'MZN','refs'=>$f['refs']],(string)Str::uuid())['id'];
|
||||
app(CompanyContext::class)->selectFor($f['reader']->id,$f['tenant'],$f['company'],$f['unit']);$c->decide($f['lot'],$f['reader']->id,['type'=>'result','id'=>$result,'version'=>1,'decision'=>'approved','reason'=>'Resultado sintético exacto conferido.'],(string)Str::uuid());
|
||||
app(CompanyContext::class)->selectFor($f['manager']->id,$f['tenant'],$f['company'],$f['unit']);return $f+compact('result');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user