first commit

This commit is contained in:
2026-10-27 12:26:52 +02:00
parent 7628645fb6
commit ea9dcb3314
19 changed files with 701 additions and 1 deletions
+4
View File
@@ -1,5 +1,9 @@
# SGF Tempo — instruções locais
- Decisões de desenho confirmadas em 27/10/2026: primeiro ensaio de custo directo de uma ordem de oficina, reutilizando apontamentos verificados por ID/versão, sem duplicar origens. 2 UN × 50,00 + 105/60 H × 100,00 = 275,00 MZN de custo directo conhecido, nunca custo completo/margem; sem taxa material, 175,00 conhecidos e total incompleto. Osmane prepara; Samira valida versões de custos/taxas sem alterar apontamentos técnicos. Futuras provas sintéticas com identidades distintas, sem novas concessões reais. Ver secção 14 de `docs/INDUSTRIAL_COST_ACCOUNTING_SCOPE.md`. Apenas decisões registadas, sem ordem de implementação; lote industrial/CIF adiados. Preservar entregas aceites e bloqueios.
- Autorização específica posterior de 27/10/2026 executada: apenas `070000` aplicada em `sgf_tempo_dev`, por caminho explícito com `sgf_dev_owner`, e oito concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `service_receivables.view/prepare/allocate/correction_request`; conta #2 `service_receivables.view/confirm/reconcile/correction_approve`. Ver actualização de `docs/SERVICE_RECEIVABLES_DELIVERY.md`. Preservadas 54 tabelas anteriores, acessos e instalação de oficinas `080000`; seis tabelas novas vazias. Aplicação mantém `sgf_dev_app`; recebimentos, reconciliação, oficinas e emissão reais continuam bloqueados na aplicação/base. Nenhuma operação real ou concessão para técnico/verificador. As indicações anteriores de `070000`/oito concessões pendentes são históricas; esta instalação não activa uso real ou publicação.
- Encerramento de 27/10/2026: entrega isolada `SGF-RECEIVABLES-SIM-v1` aceite pelo utilizador, com registo e checkpoint exclusivamente local. Ver `docs/SERVICE_RECEIVABLES_ACCEPTANCE.md`. Esta aceitação substitui a pendência de aceitação funcional, mas NÃO aplica `070000` nem as oito concessões, não activa utilização real e mantém bloqueios operacionais/fiscais. Oficinas permanece aceite, com `080000` e os quatro direitos de abertura já aplicados tecnicamente; não repetir a sua aprovação. Sem custos industriais ou publicação.
- Autorização específica posterior de 27/10/2026 executada: apenas `080000` aplicada em `sgf_tempo_dev`, por caminho explícito e proprietário `sgf_dev_owner`; quatro concessões auditadas para conta #1, tenant #1 / Tempo Lda #1 / SEGI #1: `workshop.view/open/assign/authorize`. Ver actualização de `docs/WORKSHOP_RESOURCE_DELIVERY.md`. `070000` permanece pendente, sem aceitação de recebimentos implícita. Dados/acessos anteriores preservados; sete tabelas de oficina vazias. Aplicação mantém `sgf_dev_app`; guardas operacionais e de emissão real preservadas e verificadas. Técnico/verificador reais continuam por identificar, sem concessões. Esta instalação não activa utilização operacional; indicações anteriores de `080000`/quatro direitos pendentes são históricas.
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Modules\Workshop;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Services\ServiceCalculation;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Brick\Math\BigDecimal;
use Brick\Math\BigRational;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
final class DirectCosts
{
public const POLICY='WORKSHOP-DIRECT-COST-SIM-v1';
public const NOTICE='ENSAIO — SEM UTILIZAÇÃO OPERACIONAL';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
public static function guard(): void {abort_unless(ServiceCalculation::isolated(),403,'Custeio disponível apenas em testes isolados.');TestingDatabaseGuard::assertSafe();}
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(['view',$right] as $r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'direct_costs.'.$r,$s['unit_id']);
return $s;
}
private function order(int $id,array $s): object {return DB::table('wo_orders')->where($s)->where('id',$id)->first()??abort(403,'Ordem fora do âmbito de custos.');}
private function exact(array $in,array $rules): array {abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do âmbito de custos.');return Validator::make($in,$rules)->validate();}
private function json(mixed $v): string {return json_encode($v,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);}
private function canonical(mixed $v): mixed {if(!is_array($v))return $v;if(!array_is_list($v))ksort($v);return array_map(fn($x)=>$this->canonical($x),$v);}
private function command(int $order,int $actor,string $action,string $right,array $in,string $key,callable $write): array {
self::guard();$s=$this->scope($actor,$right);$this->order($order,$s);
abort_unless(preg_match('/^[A-Za-z0-9_-]{8,100}$/D',$key),422,'Chave de repetição obrigatória.');$hash=hash('sha256',$this->json($this->canonical(['order_id'=>$order]+$in)));
return DB::transaction(function()use($order,$actor,$action,$right,$in,$key,$hash,$write,$s){
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$right);$this->order($order,$s);
$old=DB::table('dc_commands')->where($s)->where(['actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();
if($old){abort_unless($old->order_id===$order&&hash_equals($old->request_hash,$hash),409,'Repetição com conteúdo diferente.');return json_decode($old->result,true,64,JSON_THROW_ON_ERROR)+['replayed'=>true];}
$out=$write($s);
$cmd=DB::table('dc_commands')->insertGetId($s+['order_id'=>$order,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);
Audit::record('direct_costs.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]);
$event=DB::table('dc_events')->insertGetId(['command_id'=>$cmd,'schema_version'=>1,'payload'=>$this->json(['policy'=>self::POLICY,'action'=>$action,'result'=>$out,'scope'=>$s,'actor_id'=>$actor]),'created_at'=>now()]);
DB::table('dc_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_accounting']);return $out+['replayed'=>false];
},3);
}
private function source(int $id,int $version,int $order): object {
$e=DB::table('wo_entries')->where(['id'=>$id,'version'=>$version,'order_id'=>$order])->first();abort_unless($e,403,'Origem fora da ordem/versão.');
$e->data=json_decode($e->data,true,32,JSON_THROW_ON_ERROR);
abort_if(DB::table('wo_entries')->where('previous_id',$e->id)->exists()||DB::table('wo_entries')->where('previous_id',$e->task_id)->exists(),409,'Origem ou tarefa desactualizada.');
abort_unless(!$e->outside_authority&&DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->exists(),409,'Origem não verificada/elegível.');
abort_unless(($e->kind==='time'&&$e->data['time_type']==='work')||($e->kind==='material'&&$e->data['event']==='consumed'&&$e->data['owner']==='company'),422,'Só trabalho efectivo e consumo da empresa; entrega/devolução não são consumo.');
return $e;
}
private function row(string $kind,int $id,int $order): object {return DB::table($kind==='rate'?'dc_rates':'dc_calculations')->where(['id'=>$id,'order_id'=>$order])->first()??abort(403,'Versão de custo fora da ordem.');}
private function current(string $kind,object $row): bool {return !DB::table($kind==='rate'?'dc_rates':'dc_calculations')->where('previous_id',$row->id)->exists();}
private function sourceSet(int $order): array {
return DB::table('wo_entries as e')->where('e.order_id',$order)->whereIn('e.kind',['time','material'])->whereNotExists(fn($q)=>$q->selectRaw('1')->from('wo_entries as n')->whereColumn('n.previous_id','e.id'))->orderBy('e.id')->pluck('e.id')->all();
}
public function rate(int $order,int $actor,array $input,string $key): array {
self::guard();
$d=$this->exact($input,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','identifier'=>'required|string|min:3|max:80','version'=>'required|integer|min:1','previous_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','unit'=>'required|in:UN,KG,M,L,H','amount'=>['required','string','regex:/^(?:0|[1-9][0-9]{0,15})(?:\.[0-9]{1,8})?$/D'],'evidence'=>'required|string|min:3|max:1000']);
return $this->command($order,$actor,'rate','prepare',$d,$key,function($s)use($order,$actor,$d){
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_unless($d['unit']===($e->kind==='time'?'H':$e->data['unit']),422,'Unidade da taxa incompatível com a origem.');
$last=DB::table('dc_rates')->where(['order_id'=>$order,'identifier'=>$d['identifier']])->orderByDesc('version')->first();
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Versão anterior exacta necessária.');
$id=DB::table('dc_rates')->insertGetId($s+array_merge($d,['order_id'=>$order,'amount'=>(string)BigDecimal::of($d['amount'])->stripTrailingZeros(),'actor_id'=>$actor,'created_at'=>now()]));return ['id'=>$id,'version'=>$d['version'],'kind'=>'rate'];
});
}
private function value(BigRational $n): string {try{return (string)$n->toBigDecimal()->stripTrailingZeros();}catch(\Brick\Math\Exception\RoundingNecessaryException){return (string)$n->simplified();}}
private function compose(int $order,array $lines): array {
abort_unless(count($lines)===2,422,'Este ensaio exige um consumo e um tempo efectivos.');$out=[];$seen=[];$kinds=[];$pending=[];$sum=BigRational::of(0);
foreach($lines as $line){
$d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','rate_id'=>'present|nullable|integer|min:1','rate_version'=>'present|nullable|integer|min:1']);
$e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->id]),422,'Origem duplicada.');$seen[$e->id]=true;$kinds[]=$e->kind;
$quantity=$e->kind==='time'?BigRational::of($e->minutes)->dividedBy(60):BigRational::of($e->quantity);
$rate=null;$cost=null;
if($d['rate_id']!==null){
$r=$this->row('rate',$d['rate_id'],$order);
abort_unless($d['rate_version']===$r->version&&$r->entry_id===$e->id&&$r->entry_version===$e->version&&$r->currency==='MZN'&&$r->unit===($e->kind==='time'?'H':$e->data['unit']),409,'Taxa/versão/unidade/moeda incompatível.');
abort_unless($this->current('rate',$r)&&DB::table('dc_decisions')->where(['rate_id'=>$r->id,'decision'=>'approved'])->exists(),409,'Taxa exige versão actual aprovada; selecção explícita.');
$rate=(string)BigDecimal::of($r->amount)->stripTrailingZeros();$cost=$quantity->multipliedBy($rate);$sum=$sum->plus($cost);
}else{abort_unless($d['rate_version']===null,422,'Taxa ausente não tem versão.');$pending[]='Taxa em falta para origem #'.$e->id.' v'.$e->version;}
$out[]=$d+['kind'=>$e->kind,'origin'=>$e->origin,'quantity'=>$e->kind==='time'?(string)$e->minutes:(string)BigDecimal::of($e->quantity)->stripTrailingZeros(),'source_unit'=>$e->kind==='time'?'MIN':$e->data['unit'],'cost_quantity_exact'=>$this->value($quantity),'rate_unit'=>$e->kind==='time'?'H':$e->data['unit'],'rate_amount'=>$rate,'cost_exact'=>$cost===null?null:$this->value($cost)];
}
sort($kinds);abort_unless($kinds===['material','time'],422,'Um consumo e um tempo, sem duplicação.');usort($out,fn($a,$b)=>$a['entry_id']<=>$b['entry_id']);
return ['policy'=>self::POLICY,'currency'=>'MZN','label'=>'Custo directo conhecido — não é custo completo ou margem','lines'=>$out,'known_subtotal_exact'=>$this->value($sum),'selected_direct_total_exact'=>$pending?null:$this->value($sum),'incomplete'=>$pending!==[],'pending'=>$pending,'source_set'=>$this->sourceSet($order),'scope'=>'Duas origens seleccionadas; sem custos indirectos ou valorização de inventário'];
}
private function calculationCurrent(object $row): bool {
if(!$this->current('calculation',$row))return false;
try{$snapshot=json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR);$lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['entry_id','entry_version','rate_id','rate_version'])),$snapshot['lines']);return hash_equals($row->fingerprint,hash('sha256',$this->json($this->compose($row->order_id,$lines))));}
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){return false;}
}
public function calculate(int $order,int $actor,array $input,string $key): array {
self::guard();
$d=$this->exact($input,['version'=>'required|integer|min:1','previous_id'=>'nullable|integer|min:1','currency'=>'required|in:MZN','lines'=>'required|array|size:2']);
return $this->command($order,$actor,'calculate','prepare',$d,$key,function($s)use($order,$actor,$d){
$last=DB::table('dc_calculations')->where('order_id',$order)->orderByDesc('version')->first();
abort_unless($d['version']===($last?->version??0)+1&&($d['previous_id']??null)===$last?->id,409,'Cálculo mudou; rever versão anterior.');
$snapshot=$this->compose($order,$d['lines']);$hash=hash('sha256',$this->json($snapshot));
if($last&&hash_equals($last->fingerprint,$hash))return ['id'=>$last->id,'version'=>$last->version,'kind'=>'calculation','unchanged'=>true];
$id=DB::table('dc_calculations')->insertGetId($s+['order_id'=>$order,'version'=>$d['version'],'previous_id'=>$last?->id,'fingerprint'=>$hash,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version'],'kind'=>'calculation'];
});
}
public function decide(int $order,int $actor,array $input,string $key): array {
self::guard();
$d=$this->exact($input,['kind'=>'required|in:rate,calculation','id'=>'required|integer|min:1','version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|min:3|max:1000']);
return $this->command($order,$actor,'decide','validate',$d,$key,function($s)use($order,$actor,$d){
$r=$this->row($d['kind'],$d['id'],$order);abort_if($r->actor_id===$actor,403,'Validador deve ser distinto do preparador.');
abort_unless($r->version===$d['version']&&$this->current($d['kind'],$r),409,'Versão antiga.');
if($d['kind']==='rate')$this->source($r->entry_id,$r->entry_version,$order);else abort_unless($this->calculationCurrent($r),409,'Dependências alteradas; preparar nova versão.');
$field=$d['kind']==='rate'?'rate_id':'calculation_id';abort_if(DB::table('dc_decisions')->where($field,$r->id)->exists(),409,'Versão já decidida.');
$id=DB::table('dc_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>'decision','target_id'=>$r->id];
});
}
public function listing(int $actor): array {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[],'notice'=>self::NOTICE];self::guard();
return DB::transaction(function()use($s){
DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$orders=[];
foreach(DB::table('wo_orders')->where($s)->orderBy('id')->get(['id','reference']) as $o){
$sources=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->orderBy('id')->get(['id','version']) as $entry){
try{$e=$this->source($entry->id,$entry->version,$o->id);$sources[]=['id'=>$e->id,'version'=>$e->version,'kind'=>$e->kind,'origin'=>$e->origin,'quantity'=>$e->kind==='time'?(string)$e->minutes:(string)$e->quantity,'unit'=>$e->kind==='time'?'MIN':$e->data['unit']];}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}
}
$rates=DB::table('dc_rates')->where('order_id',$o->id)->orderBy('id')->get();foreach($rates as $r){$r->current=$this->current('rate',$r);$r->decision=DB::table('dc_decisions')->where('rate_id',$r->id)->first();}
$calculations=DB::table('dc_calculations')->where('order_id',$o->id)->orderBy('id')->get();foreach($calculations as $c){$c->snapshot=json_decode($c->snapshot,true,64,JSON_THROW_ON_ERROR);$copy=clone $c;$copy->snapshot=$this->json($c->snapshot);$c->current=$this->calculationCurrent($copy);$c->decision=DB::table('dc_decisions')->where('calculation_id',$c->id)->first();}
$orders[]=(array)$o+['sources'=>$sources,'rates'=>$rates->all(),'calculations'=>$calculations->all()];
}return ['blocked'=>false,'notice'=>self::NOTICE,'orders'=>$orders,'policy'=>self::POLICY];
});
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Modules\Workshop\Http;
use App\Http\Controllers\Controller;
use App\Modules\Workshop\DirectCosts;
use Illuminate\Http\Request;
final class DirectCostsController extends Controller {
private function response(array $d){return response()->json($d)->header('Cache-Control','no-store, private');}
public function index(Request $r,DirectCosts $d){return $this->response($d->listing($r->user()->id));}
public function rate(Request $r,int $id,DirectCosts $d){return $this->response($d->rate($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
public function calculate(Request $r,int $id,DirectCosts $d){return $this->response($d->calculate($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
public function decide(Request $r,int $id,DirectCosts $d){return $this->response($d->decide($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));}
}
@@ -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 ('direct_costs.view','direct_costs.prepare','direct_costs.validate') AND unit_id IS NOT NULL))");
DB::unprepared(file_get_contents(__DIR__.'/../sql/direct-costs.sql'));
foreach(['dc_rates','dc_calculations','dc_decisions','dc_commands','dc_events','dc_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}_test_only BEFORE INSERT ON {$t} FOR EACH ROW EXECUTE FUNCTION sgf_dc_test_only()");
}
}
public function down(): void {throw new RuntimeException('Direct cost history requires a reviewed preservation procedure.');}
};
+103
View File
@@ -0,0 +1,103 @@
CREATE TABLE dc_rates (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
order_id bigint NOT NULL REFERENCES wo_orders(id), entry_id bigint NOT NULL REFERENCES wo_entries(id), entry_version integer NOT NULL,
identifier text NOT NULL, version integer NOT NULL CHECK(version>0), previous_id bigint UNIQUE REFERENCES dc_rates(id),
currency text NOT NULL CHECK(currency='MZN'), unit text NOT NULL CHECK(unit IN ('UN','KG','M','L','H')),
amount numeric NOT NULL CHECK(amount>=0 AND amount<10000000000000000 AND amount=trunc(amount,8)), evidence text NOT NULL CHECK(length(evidence)>=3),
actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL,
UNIQUE(order_id,identifier,version), FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE dc_calculations (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
order_id bigint NOT NULL REFERENCES wo_orders(id), version integer NOT NULL CHECK(version>0), previous_id bigint UNIQUE REFERENCES dc_calculations(id),
fingerprint text NOT NULL, snapshot jsonb NOT NULL, actor_id bigint NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL,
UNIQUE(order_id,version), FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE dc_decisions (
id bigserial PRIMARY KEY, rate_id bigint UNIQUE REFERENCES dc_rates(id), calculation_id bigint UNIQUE REFERENCES dc_calculations(id),
version integer NOT NULL CHECK(version>0), 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,
CHECK(num_nonnulls(rate_id,calculation_id)=1)
);
CREATE TABLE dc_commands (
id bigserial PRIMARY KEY, tenant_id bigint NOT NULL, company_id bigint NOT NULL, unit_id bigint NOT NULL,
order_id bigint NOT NULL REFERENCES wo_orders(id), actor_id bigint NOT NULL REFERENCES users(id), action text NOT NULL CHECK(action IN ('rate','calculate','decide')),
command_key text NOT NULL, request_hash text NOT NULL, result jsonb NOT NULL, created_at timestamptz NOT NULL,
UNIQUE(company_id,unit_id,actor_id,action,command_key), FOREIGN KEY(tenant_id,company_id,unit_id) REFERENCES units(tenant_id,company_id,id)
);
CREATE TABLE dc_events (id bigserial PRIMARY KEY, command_id bigint NOT NULL UNIQUE REFERENCES dc_commands(id), schema_version integer NOT NULL CHECK(schema_version=1), payload jsonb NOT NULL, created_at timestamptz NOT NULL);
CREATE TABLE dc_outbox (event_id bigint PRIMARY KEY REFERENCES dc_events(id), state text NOT NULL CHECK(state='captured_no_accounting'));
CREATE OR REPLACE FUNCTION sgf_dc_environment() RETURNS void LANGUAGE plpgsql AS $$
BEGIN IF current_database() IS DISTINCT FROM 'sgf_tempo_test' OR inet_server_port() IS DISTINCT FROM 55432 THEN RAISE EXCEPTION 'SGF_DC_TEST_DATABASE_ONLY'; END IF; END; $$;
CREATE OR REPLACE FUNCTION sgf_dc_test_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN PERFORM sgf_dc_environment(); RETURN NEW; END; $$;
CREATE OR REPLACE FUNCTION sgf_dc_source(eid bigint, oid bigint, ev integer) RETURNS void LANGUAGE plpgsql AS $$
DECLARE e wo_entries; d wo_decisions;
BEGIN
SELECT * INTO STRICT e FROM wo_entries WHERE id=eid AND order_id=oid AND version=ev;
SELECT * INTO STRICT d FROM wo_decisions WHERE entry_id=e.id AND entry_version=e.version AND decision='verified';
IF EXISTS(SELECT 1 FROM wo_entries WHERE previous_id=e.id) OR e.outside_authority
OR NOT ((e.kind='time' AND e.data->>'time_type'='work') OR (e.kind='material' AND e.data->>'event'='consumed' AND e.data->>'owner'='company'))
OR EXISTS(SELECT 1 FROM wo_entries WHERE previous_id=e.task_id) THEN RAISE EXCEPTION 'SGF_DC_INELIGIBLE_SOURCE'; END IF;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_DC_UNVERIFIED_SOURCE'; END; $$;
CREATE OR REPLACE FUNCTION sgf_dc_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE o wo_orders; e wo_entries; p record; target record; line jsonb; r dc_rates; latest integer;
BEGIN
PERFORM sgf_dc_environment();
IF TG_TABLE_NAME='dc_decisions' THEN
IF NEW.rate_id IS NOT NULL THEN SELECT order_id INTO STRICT latest FROM dc_rates WHERE id=NEW.rate_id;
ELSE SELECT order_id INTO STRICT latest FROM dc_calculations WHERE id=NEW.calculation_id; END IF;
SELECT * INTO STRICT o FROM wo_orders WHERE id=latest; PERFORM 1 FROM companies WHERE id=o.company_id FOR UPDATE;
IF NEW.rate_id IS NOT NULL THEN
SELECT id,actor_id,version,order_id INTO STRICT target FROM dc_rates WHERE id=NEW.rate_id;
IF EXISTS(SELECT 1 FROM dc_rates WHERE previous_id=target.id) THEN RAISE EXCEPTION 'SGF_DC_OLD_RATE'; END IF;
SELECT * INTO r FROM dc_rates WHERE id=target.id; PERFORM sgf_dc_source(r.entry_id,r.order_id,r.entry_version);
ELSE
SELECT id,actor_id,version,order_id INTO STRICT target FROM dc_calculations WHERE id=NEW.calculation_id;
IF EXISTS(SELECT 1 FROM dc_calculations WHERE previous_id=target.id) THEN RAISE EXCEPTION 'SGF_DC_OLD_CALCULATION'; END IF;
FOR line IN SELECT * FROM jsonb_array_elements((SELECT snapshot->'lines' FROM dc_calculations WHERE id=target.id)) LOOP
PERFORM sgf_dc_source((line->>'entry_id')::bigint,target.order_id,(line->>'entry_version')::integer);
IF line->>'rate_id' IS NOT NULL THEN
SELECT * INTO STRICT r FROM dc_rates WHERE id=(line->>'rate_id')::bigint;
IF EXISTS(SELECT 1 FROM dc_rates WHERE previous_id=r.id) OR NOT EXISTS(SELECT 1 FROM dc_decisions WHERE rate_id=r.id AND decision='approved') THEN RAISE EXCEPTION 'SGF_DC_OLD_RATE'; END IF;
END IF;
END LOOP;
END IF;
SELECT * INTO STRICT o FROM wo_orders WHERE id=target.order_id; PERFORM 1 FROM companies WHERE id=o.company_id FOR UPDATE;
IF target.actor_id=NEW.actor_id OR target.version<>NEW.version THEN RAISE EXCEPTION 'SGF_DC_SEPARATION_OR_VERSION'; END IF;
RETURN NEW;
END IF;
SELECT * INTO STRICT o FROM wo_orders WHERE id=NEW.order_id; PERFORM 1 FROM companies WHERE id=o.company_id FOR UPDATE;
IF ROW(NEW.tenant_id,NEW.company_id,NEW.unit_id) IS DISTINCT FROM ROW(o.tenant_id,o.company_id,o.unit_id) THEN RAISE EXCEPTION 'SGF_DC_SCOPE'; END IF;
IF TG_TABLE_NAME='dc_commands' THEN RETURN NEW; END IF;
IF TG_TABLE_NAME='dc_rates' THEN
PERFORM sgf_dc_source(NEW.entry_id,NEW.order_id,NEW.entry_version);
SELECT * INTO STRICT e FROM wo_entries WHERE id=NEW.entry_id;
IF (e.kind='time' AND NEW.unit<>'H') OR (e.kind='material' AND NEW.unit IS DISTINCT FROM e.data->>'unit') THEN RAISE EXCEPTION 'SGF_DC_UNIT'; END IF;
SELECT coalesce(max(version),0) INTO latest FROM dc_rates WHERE order_id=o.id AND identifier=NEW.identifier;
IF NEW.version<>latest+1 THEN RAISE EXCEPTION 'SGF_DC_VERSION'; END IF;
IF latest=0 THEN IF NEW.previous_id IS NOT NULL THEN RAISE EXCEPTION 'SGF_DC_PREVIOUS'; END IF;
ELSE
SELECT * INTO STRICT p FROM dc_rates WHERE id=NEW.previous_id AND order_id=o.id AND identifier=NEW.identifier AND version=latest;
IF NOT EXISTS(SELECT 1 FROM wo_entries pe WHERE pe.id=p.entry_id AND pe.origin=e.origin AND pe.kind=e.kind) THEN RAISE EXCEPTION 'SGF_DC_SOURCE_TRANSFER'; END IF;
END IF;
ELSE
SELECT coalesce(max(version),0) INTO latest FROM dc_calculations WHERE order_id=o.id;
IF NEW.version<>latest+1 OR NEW.snapshot->>'policy' IS DISTINCT FROM 'WORKSHOP-DIRECT-COST-SIM-v1' OR NEW.snapshot->>'currency' IS DISTINCT FROM 'MZN' THEN RAISE EXCEPTION 'SGF_DC_VERSION_POLICY'; END IF;
IF latest=0 THEN IF NEW.previous_id IS NOT NULL THEN RAISE EXCEPTION 'SGF_DC_PREVIOUS'; END IF;
ELSE PERFORM 1 FROM dc_calculations WHERE id=NEW.previous_id AND order_id=o.id AND version=latest; IF NOT FOUND THEN RAISE EXCEPTION 'SGF_DC_PREVIOUS'; END IF; END IF;
IF jsonb_array_length(NEW.snapshot->'lines')<>2 THEN RAISE EXCEPTION 'SGF_DC_TWO_SOURCES'; END IF;
FOR line IN SELECT * FROM jsonb_array_elements(NEW.snapshot->'lines') LOOP
PERFORM sgf_dc_source((line->>'entry_id')::bigint,o.id,(line->>'entry_version')::integer);
IF line->>'rate_id' IS NOT NULL THEN
SELECT * INTO STRICT r FROM dc_rates WHERE id=(line->>'rate_id')::bigint AND order_id=o.id AND entry_id=(line->>'entry_id')::bigint AND entry_version=(line->>'entry_version')::integer AND version=(line->>'rate_version')::integer;
IF EXISTS(SELECT 1 FROM dc_rates WHERE previous_id=r.id) OR NOT EXISTS(SELECT 1 FROM dc_decisions WHERE rate_id=r.id AND decision='approved') THEN RAISE EXCEPTION 'SGF_DC_RATE_NOT_CURRENT_APPROVED'; END IF;
END IF;
END LOOP;
END IF;
RETURN NEW;
EXCEPTION WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'SGF_DC_REFERENCE'; END; $$;
CREATE TRIGGER dc_rate_integrity BEFORE INSERT ON dc_rates FOR EACH ROW EXECUTE FUNCTION sgf_dc_guard();
CREATE TRIGGER dc_calculation_integrity BEFORE INSERT ON dc_calculations FOR EACH ROW EXECUTE FUNCTION sgf_dc_guard();
CREATE TRIGGER dc_decision_integrity BEFORE INSERT ON dc_decisions FOR EACH ROW EXECUTE FUNCTION sgf_dc_guard();
CREATE TRIGGER dc_command_integrity BEFORE INSERT ON dc_commands FOR EACH ROW EXECUTE FUNCTION sgf_dc_guard();
+30
View File
@@ -220,3 +220,33 @@ Em 27/10/2026, o utilizador propôs começar pela ordem de oficina, com materiai
O custeio posterior ligará quantidade/tempo **já registados e verificados**, por ID e versão, a uma valorização e destino de custo próprios. Não voltará a registar consumo, tempo, devolução ou produção para conseguir calcular. O primeiro incremento não calcula custos nem exige que se escolha um método industrial. Uma ordem de oficina continua a ser ordem técnica; não nasce automaticamente uma ordem/lote de produção.
Assim, as fases 1/2 da secção 12 ficam inicialmente reduzidas à ordem, apontamentos e base de origem estritamente necessária; a proposta mais ampla `WORKSHOP-ASSISTANCE-DRAFT-v1` fica para evolução posterior. `INDUSTRIAL-COST-SIM-v1`, critérios CIF, custos e margens permanecem em especificação. Preservar bloqueios reais e a aceitação independente de `SGF-RECEIVABLES-SIM-v1`.
## 14. Decisões confirmadas — primeiro custo directo da ordem
Em 27/10/2026, o utilizador confirmou as três decisões seguintes. Este recorte antecede o lote industrial e a imputação de custos indirectos das secções anteriores; não converte a ordem de oficina em ordem de produção. Oficinas e recebimentos já têm aceitação própria e instalação técnica documentada; não se reabrem essas aprovações. As referências anteriores a componentes ainda não entregues são históricas e devem ser confrontadas com as respectivas entregas actuais.
### Ensaio e variante
Uma ordem sintética, empresa/unidade autorizadas, moeda MZN explícita, apenas origens técnicas verificadas e versões exactas:
| Componente | Origem reutilizada | Taxa sintética | Custo conhecido |
| --- | --- | --- | ---: |
| Material | 2 UN efectivamente consumidas e verificadas | 50,00 MZN/UN | 100,00 MZN |
| Mão-de-obra | 105 MIN efectivos e verificados; 105/60 = 1,75 H | 100,00 MZN/H | 175,00 MZN |
| **Soma do perímetro directo conhecido** | Mesma ordem e corte | Sem custos indirectos | **275,00 MZN** |
O resultado é **custo directo conhecido**, nunca custo completo, custo industrial unitário, margem ou lucro. As taxas são pressupostos identificados de ensaio, não valorização real de inventário ou política salarial.
Na variante sem taxa do material: **175,00 MZN conhecidos**, material pendente, total incompleto. Não usar zero, taxa anterior, preço de venda ou taxa de outra origem por omissão. Não apresentar 175,00 como total completo.
### Origem única e preservação
Ligar valorização e cálculo aos IDs e versões dos apontamentos existentes da oficina, sem recapturar quantidades ou criar segundo consumo/tempo. Material entregue/devolvido não substitui consumo; não descontar devolução novamente do consumo já líquido. Usar apenas apontamentos verificados elegíveis, preservando excepções e decisões técnicas. A validação de custos não altera nem valida automaticamente esses apontamentos.
Guardar a origem e versão da taxa, a unidade, moeda, composição e pendências. Alteração da origem ou taxa exige nova versão/revisão de custos; não reescrever cálculo histórico ou utilizar aprovação antiga para dependências novas. Reutilizar aritmética exacta; nenhuma regra de truncagem de IVA é transferida aos custos.
### Responsabilidades confirmadas e limites
**Osmane prepara; Samira valida a versão dos custos e das taxas.** Samira pode validar/recusar a proposta de custos, sem editar os apontamentos técnicos ou receber supervisão técnica por consequência. Testes futuros com identidades sintéticas distintas entre preparação e validação. Nenhuma nova concessão real é autorizada por estas decisões.
Lote industrial e custos indirectos ficam para incremento seguinte. Sem stock, compras, salários reais, facturação, IVA, recebimentos, lançamentos, fecho ou publicação. Manter bloqueios operacionais/fiscais e isolamento em `sgf_tempo_test` nas futuras provas. Esta confirmação fecha as decisões do recorte; não constitui ordem de implementação, migração, concessão ou activação operacional.
+19
View File
@@ -103,6 +103,25 @@ Exigem confirmação específica antes da aplicação, separada da migração. S
Entrega isolada encerrada por aceitação do utilizador, registada em [SERVICE_RECEIVABLES_ACCEPTANCE.md](SERVICE_RECEIVABLES_ACCEPTANCE.md), com checkpoint exclusivamente local. As referências anteriores a aceitação pendente descrevem o estado da entrega técnica antes deste encerramento. `070000` e as oito concessões permanecem por aplicar; os bloqueios operacionais e fiscais mantêm-se. Oficinas não exige nova aprovação e custos industriais não foram implementados.
## Instalação técnica especificamente autorizada — 27/10/2026
Após autorização expressa, foi aplicada **apenas** `2026_10_27_070000_create_simulated_receivables` em `sgf_tempo_dev`, por caminho explícito, com o proprietário separado `sgf_dev_owner`. Acrescentou-se exactamente essa entrada no registo de migrações. Não se reaplicou ou alterou a instalação de oficinas `080000`. As referências anteriores a migração/concessões pendentes descrevem o estado anterior a esta autorização.
Aplicadas exactamente oito concessões, todas tenant #1 / Tempo Lda #1 / SEGI #1:
| Conta existente | Direitos, prefixo `service_receivables.` |
| --- | --- |
| Osmane #1 | `view`, `prepare`, `allocate`, `correction_request` |
| Samira #2 | `view`, `confirm`, `reconcile`, `correction_approve` |
Cada concessão tem evento de auditoria. Não se criaram contas, direitos de gestão ou concessões para técnico/verificador. Impressões comparadas apenas em memória confirmaram a preservação dos registos das **54 tabelas preexistentes**, incluindo acessos, auditoria e oficinas. Os únicos novos registos são uma entrada de migração, oito concessões e oito eventos de auditoria; as seis tabelas novas estão vazias.
Procedimento: `scripts/receivables-dev-install.php`, dirigido à operação exacta e deliberadamente recusado se já instalada ou se contas/âmbitos/estado diferirem. Resumo local em `storage/app/private/receivables-dev-install-proof.json`, excluído do Git, sem conteúdos dos registos ou credenciais. A aplicação continua ligada como `sgf_dev_app`, sem privilégios de proprietário/administração.
Conferências no ambiente normal: listagens de recebimentos de ambas as contas bloqueadas/vazias; guarda, proposta, decisão, afectação e consumidor de eventos recusados com 403; listagem/guarda de oficinas e guarda de emissão real igualmente bloqueadas. As funções SQL de ambiente de recebimentos e oficinas recusaram `sgf_tempo_dev`. Código funcional/guardas inalterados. Não se criaram operações reais ou demonstrações na base normal. Não se repetiram testes destrutivos, compilação ou demonstração aceite por uma instalação sem alteração funcional.
Checkpoint aceite `7628645` preservado. Esta instalação técnica não activa recebimentos reais, reconciliação bancária, emissão fiscal, lançamentos, fecho ou publicação, nem altera as aprovações existentes.
## Ficheiros e limites
Código novo: `app/Modules/Services/ServiceReceivables.php`, `Http/ServiceReceivablesController.php`, `resources/js/ServiceReceivablesWorkspace.jsx`; integração em `routes/web.php` e `resources/js/app.jsx`. Migração/SQL acima. Testes e fixtures: `tests/Feature/ServiceReceivablesTest.php`, `tests/Support/ReceivablesFixture.php`; `InvoiceFixture` ganhou parâmetro de preço com valor anterior preservado por omissão. Scripts: `receivables-fixture.php`, `receivables-worker.php`, `receivables-concurrency.php`, `receivables-proof.php`, `receivables-grants-preview.php`. Documentação e provas referidas neste relatório.
@@ -0,0 +1,26 @@
{
"database": "sgf_tempo_dev",
"preexisting_tables_checked": 31,
"preexisting_records_unchanged": true,
"prior_grants_and_audit_unchanged": true,
"new_tables": {
"si_proposals": 0,
"si_decisions": 0,
"si_counters": 0,
"si_invoices": 0,
"si_events": 0,
"si_outbox": 0,
"si_commands": 0,
"si_correction_requests": 0
},
"confirmed_grants_verified": 8,
"guard_code_unchanged": true,
"real_entry_denials": {
"guard": 403,
"issue": 403,
"propose": 403
},
"real_issuance_blocked": true,
"real_series_created": false,
"documents_issued": false
}
+26
View File
@@ -0,0 +1,26 @@
{
"database": "sgf_tempo_dev",
"preexisting_tables_checked": 31,
"preexisting_records_unchanged": true,
"prior_grants_and_audit_unchanged": true,
"new_tables": {
"si_proposals": 0,
"si_decisions": 0,
"si_counters": 0,
"si_invoices": 0,
"si_events": 0,
"si_outbox": 0,
"si_commands": 0,
"si_correction_requests": 0
},
"confirmed_grants_verified": 0,
"guard_code_unchanged": true,
"real_entry_denials": {
"guard": 403,
"issue": 403,
"propose": 403
},
"real_issuance_blocked": true,
"real_series_created": false,
"documents_issued": false
}
+32
View File
@@ -0,0 +1,32 @@
import React, {useEffect,useRef,useState} from 'react';
const decisionLabel={approved:'Validado',refused:'Recusado'};
const money=v=>v===null?'Pendente':`${v} MZN`;
export default function DirectCostsWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[error,setError]=useState(''),[working,setWorking]=useState(false),[orderId,setOrderId]=useState('');
const [selection,setSelection]=useState({}),[sourceId,setSourceId]=useState('');const retry=useRef(null);
const allowed=r=>can(`direct_costs.${r}`,context.unit?.id??null);
const load=async()=>{const d=await api('/api/direct-costs');setData(d);return d;};
useEffect(()=>{setSelection({});load().catch(e=>setError(e.message));},[context.token]);
const order=data?.orders.find(o=>String(o.id)===orderId)??data?.orders[0];
async function send(path,input){setWorking(true);setError('');const 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);}}
const disabled=busy||working;
const latest=order?.calculations.at(-1);
const source=order?.sources.find(s=>String(s.id)===sourceId)??order?.sources[0];
const decide=(kind,row,decision,reason)=>send(`/api/direct-costs/orders/${order.id}/decisions`,{kind,id:row.id,version:row.version,decision,reason});
return <section className="workspace"><p className="eyebrow">ENSAIO DE CUSTO DIRECTO</p><h2>Custos directos da ordem</h2><div className="alert">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Custo directo conhecido; não é custo completo ou margem. Sem stock, IVA, lançamentos ou facturação.</div>
{error&&<p role="alert" className="alert error">{error}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>O custeio permanece bloqueado nesta instância. Use apenas a demonstração isolada.</p>:<>
<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar versões explicitamente</button>
<label>Ordem de teste<select value={order?.id??''} onChange={e=>{setOrderId(e.target.value);setSelection({});setSourceId('');}}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>
{!order?<p>Sem ordens no âmbito autorizado.</p>:<>
<section className="panel"><h3>Origens técnicas verificadas</h3><p>Referências aos apontamentos existentes. A validação de custos não modifica nem verifica a execução técnica.</p><ul>{order.sources.map(s=><li key={s.id}>#{s.id} v{s.version} · {s.origin} · {s.quantity} {s.unit} · {s.kind==='time'?'Trabalho efectivo':'Consumo efectivo'}</li>)}</ul></section>
{allowed('prepare')&&source&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);const previous=order.rates.filter(r=>r.identifier===f.get('identifier')).at(-1);send(`/api/direct-costs/orders/${order.id}/rates`,{entry_id:source.id,entry_version:source.version,identifier:f.get('identifier'),version:(previous?.version??0)+1,previous_id:previous?.id??null,currency:'MZN',unit:source.kind==='time'?'H':source.unit,amount:f.get('amount'),evidence:f.get('evidence')});}}><h3>Preparar taxa sintética versionada</h3>
<label>Origem da taxa<select value={source.id} onChange={e=>setSourceId(e.target.value)}>{order.sources.map(s=><option key={s.id} value={s.id}>#{s.id} · {s.kind==='time'?'Tempo':'Material'} v{s.version}</option>)}</select></label>
<p>Âmbito: esta ordem e versão da origem; MZN/{source.kind==='time'?'H':source.unit}. Zero é uma taxa explícita; campo vazio não vale zero.</p>
<label>Identificador da taxa<input name="identifier" required minLength={3} placeholder="TEST-MATERIAL ou TEST-TRABALHO"/></label><label>Taxa de custo exacta<input name="amount" required inputMode="decimal" placeholder="50.00"/></label><label>Origem / evidência da taxa<input name="evidence" required minLength={3} placeholder="Pressuposto sintético identificado"/></label><button disabled={disabled}>Propor taxa</button></form>}
<section className="panel"><h3>Taxas e decisões</h3>{order.rates.map(r=><article key={r.id} className="panel"><strong>#{r.id} · {r.identifier} v{r.version}</strong><p>Origem #{r.entry_id} v{r.entry_version} · {r.amount} {r.currency}/{r.unit}</p><p>{r.evidence}</p><p>{r.current?'Última versão da taxa':'Versão anterior'} · {r.decision?decisionLabel[r.decision.decision]:'Aguarda validação'}{r.decision&&` — ${r.decision.reason}`}</p>{allowed('validate')&&r.current&&!r.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('rate',r,d,reason)}/>}</article>)}</section>
{allowed('prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const lines=order.sources.map(s=>{const r=order.rates.find(r=>String(r.id)===String(selection[s.id]??''));return {entry_id:s.id,entry_version:s.version,rate_id:r?.id??null,rate_version:r?.version??null};});send(`/api/direct-costs/orders/${order.id}/calculations`,{currency:'MZN',version:(latest?.version??0)+1,previous_id:latest?.id??null,lines});}}><h3>Preparar cálculo com taxas explícitas</h3><p>Escolher cada versão. Nenhuma taxa é escolhida automaticamente, mesmo que seja a única ou a mais recente.</p>{order.sources.map(s=><label key={s.id}>Origem #{s.id} v{s.version}<select value={selection[s.id]??''} onChange={e=>setSelection({...selection,[s.id]:e.target.value})}><option value="">Taxa em falta — manter pendência</option>{order.rates.filter(r=>r.entry_id===s.id&&r.entry_version===s.version&&r.current&&r.decision?.decision==='approved').map(r=><option key={r.id} value={r.id}>#{r.id} · {r.identifier} v{r.version} · {r.amount} MZN/{r.unit}</option>)}</select></label>)}<button disabled={disabled}>Calcular custo directo conhecido</button></form>}
<section className="panel"><h3>Histórico de cálculos</h3>{order.calculations.map(c=><article key={c.id} className="panel"><h3>Cálculo #{c.id} · v{c.version}</h3><p>{c.current?'Dependências actuais':'DESACTUALIZADO / HISTÓRICO — não usar como actual'} · {c.decision?decisionLabel[c.decision.decision]:'Aguarda validação'}{c.decision&&` — ${c.decision.reason}`}</p><p>{c.snapshot.label}</p><table><thead><tr><th>Origem / versão</th><th>Quantidade exacta</th><th>Taxa / versão</th><th>Custo exacto</th></tr></thead><tbody>{c.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>{l.quantity} {l.source_unit}{l.kind==='time'&&` ÷ 60 = ${l.cost_quantity_exact} H`}</td><td>{l.rate_id===null?'Em falta':`#${l.rate_id} v${l.rate_version} · ${l.rate_amount} MZN/${l.rate_unit}`}</td><td>{money(l.cost_exact)}</td></tr>)}</tbody></table><p><strong>Subtotal directo conhecido: {money(c.snapshot.known_subtotal_exact)}</strong></p><p>{c.snapshot.incomplete?'TOTAL INCOMPLETO — taxas em falta':'Perímetro directo seleccionado valorizado; custo completo não apurado.'}</p><ul>{c.snapshot.pending.map(p=><li key={p}>{p}</li>)}</ul><small>{c.snapshot.policy} · {c.snapshot.scope}</small>{allowed('validate')&&c.current&&!c.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('calculation',c,d,reason)}/>}</article>)}</section>
</> }</> }</section>;
}
function DecisionForm({disabled,onDecision}) {return <form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);onDecision(f.get('decision'),f.get('reason'));}}><label>Decisão<select name="decision"><option value="approved">Validar esta versão</option><option value="refused">Recusar esta versão</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar decisão de custos</button></form>;}
+3 -1
View File
@@ -9,6 +9,7 @@ import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
import DirectCostsWorkspace from './DirectCostsWorkspace.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' };
@@ -53,7 +54,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('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('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}>
@@ -64,6 +65,7 @@ function App() {
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
{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 === '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} />}
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
+4
View File
@@ -16,6 +16,10 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
Route::post('/api/context', [IdentityController::class, 'select']);
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::post('/api/direct-costs/orders/{id}/rates', [\App\Modules\Workshop\Http\DirectCostsController::class,'rate'])->whereNumber('id');
Route::post('/api/direct-costs/orders/{id}/calculations', [\App\Modules\Workshop\Http\DirectCostsController::class,'calculate'])->whereNumber('id');
Route::post('/api/direct-costs/orders/{id}/decisions', [\App\Modules\Workshop\Http\DirectCostsController::class,'decide'])->whereNumber('id');
Route::post('/api/workshop/orders', [\App\Modules\Workshop\Http\WorkshopController::class,'open']);
Route::post('/api/workshop/orders/{id}/entries', [\App\Modules\Workshop\Http\WorkshopController::class,'record'])->whereNumber('id');
Route::post('/api/workshop/entries/{id}/verification', [\App\Modules\Workshop\Http\WorkshopController::class,'verify'])->whereNumber('id');
+10
View File
@@ -0,0 +1,10 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Modules\Workshop\DirectCosts::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('wo_orders')->exists()||\Illuminate\Support\Facades\DB::table('dc_rates')->exists())throw new RuntimeException('Fresh isolated proof required; nothing deleted.');
$f=\Tests\Support\DirectCostFixture::create($base+['manager'=>\App\Models\User::findOrFail($base['manager_id']),'reader'=>\App\Models\User::findOrFail($base['reader_id'])]);
$safe=array_intersect_key($f,array_flip(['order','time','material','day','task','auth']));
file_put_contents($file,json_encode($base+['direct_costs'=>$safe],JSON_THROW_ON_ERROR));
echo "Verified workshop sources prepared via existing commands in sgf_tempo_test. Cost rates/calculations are empty; no copied operational events.\n";
+7
View File
@@ -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\Workshop\DirectCosts::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{$d=app(\App\Modules\Workshop\DirectCosts::class);$out=$d->{$in['method']}($in['order'],$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;}
+34
View File
@@ -0,0 +1,34 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own SGF development database required.');
$args=array_values(array_filter(array_slice($argv,1),fn($v)=>$v!=='--apply'));
if(count($args)!==4 || array_filter($args,fn($v)=>!ctype_digit($v)))throw new RuntimeException('Usage: invoice-access.php proposer validator company unit [--apply]; exact prior confirmation required.');
[$proposer,$validator,$companyId,$unitId]=array_map('intval',$args);
if($proposer===$validator)throw new RuntimeException('Distinct proposer and validator required.');
$db=\Illuminate\Support\Facades\DB::class;
$preview=function()use($db,$proposer,$validator,$companyId,$unitId){
$company=$db::table('companies')->where('id',$companyId)->first(['id','tenant_id','name']);
if(!$company)throw new RuntimeException('Existing company required.');
$unit=$db::table('units')->where('tenant_id',$company->tenant_id)->where('company_id',$companyId)->where('id',$unitId)->first(['id','name']);
$accounts=$db::table('users')->whereIn('id',[$proposer,$validator])->orderBy('id')->get(['id','name','email']);
if(!$unit || $accounts->count()!==2)throw new RuntimeException('Existing unit and two existing accounts required.');
$grants=[];
foreach($accounts as $a){
if(!$db::table('company_memberships')->where('user_id',$a->id)->where('tenant_id',$company->tenant_id)->where('company_id',$companyId)->exists())throw new RuntimeException('Membership missing; no association inferred.');
app(\App\Modules\Identity\Services\Permissions::class)->require($a->id,$company->tenant_id,$companyId,'company.view');
app(\App\Modules\Identity\Services\Permissions::class)->require($a->id,$company->tenant_id,$companyId,'unit.view',$unitId);
foreach(['invoice_series.view',$a->id===$proposer?'invoice_series.propose':'invoice_series.approve'] as $permission)$grants[]=['user_id'=>$a->id,'tenant_id'=>$company->tenant_id,'company_id'=>$companyId,'unit_id'=>null,'permission'=>$permission];
foreach($a->id===$proposer?['service_invoices.view','service_invoices.issue','service_invoices.correction_request']:['service_invoices.view'] as $permission)$grants[]=['user_id'=>$a->id,'tenant_id'=>$company->tenant_id,'company_id'=>$companyId,'unit_id'=>$unitId,'permission'=>$permission];
}
return ['company'=>$company,'unit'=>$unit,'accounts'=>$accounts,'proposed_grants'=>$grants,'applied'=>false];
};
if(!in_array('--apply',$argv,true)){echo json_encode($preview(),JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;exit;}
$count=$db::transaction(function()use($db,$companyId,$preview){
$db::table('companies')->where('id',$companyId)->lockForUpdate()->firstOrFail();$p=$preview();$count=0;
foreach($p['proposed_grants'] as $g)if($db::table('permission_grants')->insertOrIgnore($g+['created_at'=>now(),'updated_at'=>now()])){
$count++;\App\Modules\Identity\Services\Audit::record('service_invoice.initial_grant',null,$g['tenant_id'],$g['company_id'],$g['unit_id'],subject:$g['user_id'],details:['permission'=>$g['permission'],'reason'=>'Eight initial grants specifically confirmed; real issuance remains blocked.']);
}
return $count;
});
echo "Applied $count specifically confirmed grants; no series or document created.\n";
+45
View File
@@ -0,0 +1,45 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if(!\App\Modules\Identity\Services\LocalDatabaseGuard::allows())throw new RuntimeException('Own SGF development database required.');
$db=\Illuminate\Support\Facades\DB::class;
$before=in_array('--before',$argv,true);$grants=in_array('--after-grants',$argv,true);
$file=storage_path('app/invoice-dev-preservation-before.json');
$baseline=$before?null:json_decode(file_get_contents($file),true,64,JSON_THROW_ON_ERROR);
$tables=$before?array_map(fn($r)=>$r->tablename,$db::select("SELECT tablename FROM pg_tables WHERE schemaname='public' AND (tablename ~ '^(a_|b_|ca_|sf_|doc_|document_|service_calculation_)' OR tablename IN ('users','tenants','companies','units','company_memberships','permission_grants','identity_audit')) ORDER BY tablename")):array_keys($baseline['tables']);
$summary=[];
foreach($tables as $table){
if(!preg_match('/^[a-z_]+$/D',$table))throw new RuntimeException('Unexpected table identifier.');
$where='';if($grants&&in_array($table,['permission_grants','identity_audit'],true))$where=' WHERE id <= '.(int)$baseline['max'][$table];
$r=$db::selectOne('SELECT count(*) AS n, md5(COALESCE(string_agg(row_to_json(t)::text, E\'\\n\' ORDER BY row_to_json(t)::text),\'\')) AS h FROM (SELECT * FROM "'.$table.'"'.$where.') t');
$summary[$table]=['count'=>(int)$r->n,'hash'=>$r->h];
}
if($before){
file_put_contents($file,json_encode(['tables'=>$summary,'max'=>['permission_grants'=>(int)$db::table('permission_grants')->max('id'),'identity_audit'=>(int)$db::table('identity_audit')->max('id')],'guard_file_hash'=>hash_file('sha256',base_path('app/Modules/Services/ServiceInvoicing.php'))],JSON_THROW_ON_ERROR));
echo 'Fingerprinted '.count($summary)." preexisting tables; no record contents disclosed.\n";exit;
}
if($summary!==$baseline['tables'])throw new RuntimeException('Preexisting data or grants changed; review before proceeding.');
if(hash_file('sha256',base_path('app/Modules/Services/ServiceInvoicing.php'))!==$baseline['guard_file_hash'])throw new RuntimeException('Issuance guard code changed.');
$counts=[];foreach(['si_proposals','si_decisions','si_counters','si_invoices','si_events','si_outbox','si_commands','si_correction_requests'] as $table){$counts[$table]=$db::table($table)->count();if($counts[$table]!==0)throw new RuntimeException('New series/document tables not empty.');}
$denials=[];
foreach(['guard','issue','propose'] as $entry){
try{
if($entry==='guard')\App\Modules\Services\ServiceInvoicing::guard();
elseif($entry==='issue')app(\App\Modules\Services\ServiceInvoicing::class)->issue(0,1,[],'blocked-verification');
else app(\App\Modules\Services\ServiceInvoicing::class)->propose(1,['kind'=>'series']);
throw new RuntimeException('Real issuance entry did not refuse.');
}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){if($e->getStatusCode()!==403||!str_contains($e->getMessage(),'conformidade'))throw $e;$denials[$entry]=403;}
}
if($grants){
$expected=[];foreach([1=>['invoice_series.view','invoice_series.propose','service_invoices.view','service_invoices.issue','service_invoices.correction_request'],2=>['invoice_series.view','invoice_series.approve','service_invoices.view']] as $user=>$rights)foreach($rights as $right)$expected[]=['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'unit_id'=>str_starts_with($right,'service_invoices.')?1:null,'permission'=>$right];
usort($expected,fn($a,$b)=>[$a['user_id'],$a['permission']]<=>[$b['user_id'],$b['permission']]);
$actual=$db::table('permission_grants')->where(fn($q)=>$q->where('permission','like','invoice_series.%')->orWhere('permission','like','service_invoices.%'))->orderBy('user_id')->orderBy('permission')->get(['user_id','tenant_id','company_id','unit_id','permission'])->map(fn($r)=>(array)$r)->all();
if($actual!==$expected)throw new RuntimeException('Grants differ from the eight specifically approved operations.');
if($db::table('identity_audit')->where('id','>',$baseline['max']['identity_audit'])->where('action','service_invoice.initial_grant')->count()!==8)throw new RuntimeException('Eight initial grant audit entries required.');
app(\App\Shared\Context\CompanyContext::class)->selectFor(1,1,1,1);
$listing=app(\App\Modules\Services\ServiceInvoicing::class)->listing(1);
if(!$listing['real_blocked'] || $listing['test_enabled'] || $listing['invoices']!==[] || $listing['proposals']!==[])throw new RuntimeException('Normal application must report blocked.');
}
$proof=['database'=>'sgf_tempo_dev','preexisting_tables_checked'=>count($summary),'preexisting_records_unchanged'=>true,'prior_grants_and_audit_unchanged'=>true,'new_tables'=>$counts,'confirmed_grants_verified'=>$grants?8:0,'guard_code_unchanged'=>true,'real_entry_denials'=>$denials,'real_issuance_blocked'=>true,'real_series_created'=>false,'documents_issued'=>false];
file_put_contents(base_path('docs/proofs/invoice-dev-preservation'.($grants?'-grants':'').'.json'),json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo $grants?"Verified exactly eight audited grants, prior data preserved, all new tables empty and real issuance blocked.\n":"Verified preserved data and grants, eight empty tables and real issuance blocked.\n";
+99
View File
@@ -0,0 +1,99 @@
<?php
// Only the specifically authorised 070000 and eight unit grants; no real operations.
require __DIR__.'/../vendor/autoload.php';
$app=require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Artisan;
use App\Modules\Identity\Services\LocalDatabaseGuard;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Identity\Services\Audit;
use App\Modules\Services\ServiceReceivables;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
function ensureReceivables(bool $ok,string $message): void { if(!$ok)throw new RuntimeException($message); }
function receivablesFingerprint(string $table,?int $max=null): array {
ensureReceivables((bool)preg_match('/^[a-z_]+$/D',$table),'Unexpected table identifier.');
$where=$max===null?'':' WHERE id <= '.$max;
$r=DB::selectOne('SELECT count(*) AS n, md5(COALESCE(string_agg(row_to_json(t)::text, E\'\\n\' ORDER BY row_to_json(t)::text),\'\')) AS h FROM (SELECT * FROM "'.$table.'"'.$where.') t');
return [(int)$r->n,$r->h]; // In memory only; no record contents exported.
}
ensureReceivables(LocalDatabaseGuard::allows(),'Own SGF development application connection required.');
ensureReceivables($argv===[$argv[0],'--apply-confirmed-070000-and-eight-grants'],'Exact confirmed operation argument required.');
$migration='2026_10_27_070000_create_simulated_receivables';
ensureReceivables(!DB::table('migrations')->where('migration',$migration)->exists(),'070000 already installed; review changed state.');
ensureReceivables(DB::table('migrations')->where('migration','2026_10_27_080000_create_workshop_resource_records')->exists(),'Expected workshop installation missing.');
$newTables=['sr_records','sr_effects','sr_commands','sr_events','sr_outbox','sr_deliveries'];
$tables=array_map(fn($r)=>$r->tablename,DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"));
ensureReceivables(!array_intersect($tables,$newTables),'Receivables tables already exist; review first.');
$rights=[1=>['view','prepare','allocate','correction_request'],2=>['view','confirm','reconcile','correction_approve']];
$checkScope=function(): void {
foreach([1=>['Osmane Nala','nala@tempo.co.mz'],2=>['Samira Nal','onala@me.com']] as $id=>[$name,$email]){
ensureReceivables(DB::table('users')->where(compact('id','name','email'))->exists(),'Confirmed account identity differs.');
ensureReceivables(DB::table('company_memberships')->where(['user_id'=>$id,'tenant_id'=>1,'company_id'=>1])->exists(),'Existing membership required.');
$p=app(Permissions::class);$p->require($id,1,1,'company.view');$p->require($id,1,1,'unit.view',1);
}
ensureReceivables(DB::table('companies')->where(['id'=>1,'tenant_id'=>1,'name'=>'Tempo Lda'])->exists(),'Confirmed company differs.');
ensureReceivables(DB::table('units')->where(['id'=>1,'tenant_id'=>1,'company_id'=>1,'name'=>'SEGI'])->exists(),'Confirmed unit differs.');
ensureReceivables(!DB::table('permission_grants')->where('permission','like','service_receivables.%')->exists(),'Unexpected existing receivables rights; review first.');
};
$checkScope();
$baseline=[];foreach($tables as $t)$baseline[$t]=receivablesFingerprint($t);
$max=[];foreach(['permission_grants','identity_audit'] as $t)$max[$t]=(int)DB::table($t)->max('id');
$ledger=DB::table('migrations')->orderBy('id')->get()->map(fn($r)=>(array)$r)->all();
$sourceHashes=[];foreach(['app/Modules/Services/ServiceReceivables.php','app/Modules/Services/ServiceInvoicing.php','app/Modules/Workshop/WorkshopWorkflow.php'] as $p)$sourceHashes[$p]=hash_file('sha256',base_path($p));
$secrets=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/database.json'),true,32,JSON_THROW_ON_ERROR);
$owner=config('database.connections.'.config('database.default'));
$owner['username']='sgf_dev_owner';$owner['password']=$secrets['dev_owner'];$owner['url']=null;
config(['database.connections.sgf_receivables_owner'=>$owner]);unset($owner,$secrets);
try {
$connection=DB::connection('sgf_receivables_owner');
$identity=$connection->selectOne('SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user');
ensureReceivables($identity->db==='sgf_tempo_dev'&&$identity->role==='sgf_dev_owner'&&(int)$identity->port===55432&&!$identity->rolsuper&&!$identity->rolcreatedb&&!$identity->rolcreaterole&&!$identity->rolbypassrls,'Unexpected migration owner/destination.');
$connection->transaction(function()use($migration,$baseline,$newTables): void {
$applicationConnection=DB::getDefaultConnection();DB::setDefaultConnection('sgf_receivables_owner');
try {
$exit=Artisan::call('migrate',['--database'=>'sgf_receivables_owner','--path'=>'database/migrations/'.$migration.'.php','--force'=>true,'--no-interaction'=>true]);
ensureReceivables($exit===0,'Targeted migration failed.');
foreach($baseline as $t=>$fp)if($t!=='migrations')ensureReceivables(receivablesFingerprint($t)===$fp,'Existing records changed during migration: '.$t);
foreach($newTables as $t)ensureReceivables(DB::table($t)->count()===0,'New tables must be empty.');
} finally { DB::setDefaultConnection($applicationConnection); }
});
} finally { DB::purge('sgf_receivables_owner');config(['database.connections.sgf_receivables_owner'=>null]); }
ensureReceivables(LocalDatabaseGuard::allows(),'Application must remain sgf_dev_app.');
$afterLedger=DB::table('migrations')->orderBy('id')->get()->map(fn($r)=>(array)$r)->all();
ensureReceivables(count($afterLedger)===count($ledger)+1&&array_slice($afterLedger,0,count($ledger))===$ledger&&end($afterLedger)['migration']===$migration,'Unexpected migration ledger delta.');
$denials=[];
$grants=DB::transaction(function()use($rights,$checkScope,$baseline,$max,&$denials): array {
DB::table('companies')->where('id',1)->lockForUpdate()->firstOrFail();$checkScope();$grants=[];
foreach($rights as $user=>$actions)foreach($actions as $action){
$g=['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>'service_receivables.'.$action];
DB::table('permission_grants')->insert($g+['created_at'=>now(),'updated_at'=>now()]);
Audit::record('service_receivables.initial_grant',null,1,1,1,subject:$user,details:['permission'=>$g['permission'],'reason'=>'Eight unit grants specifically authorised; real operations remain blocked.']);$grants[]=$g;
}
foreach($baseline as $t=>$fp)if($t!=='migrations')ensureReceivables(receivablesFingerprint($t,$max[$t]??null)===$fp,'Prior records changed: '.$t);
ensureReceivables(DB::table('permission_grants')->where('id','>',$max['permission_grants'])->count()===8,'Exactly eight new grants required.');
ensureReceivables(DB::table('identity_audit')->where('id','>',$max['identity_audit'])->count()===8&&DB::table('identity_audit')->where('id','>',$max['identity_audit'])->where('action','service_receivables.initial_grant')->count()===8,'Exactly eight grant audit entries required.');
$actual=DB::table('permission_grants')->where('permission','like','service_receivables.%')->get(['user_id','tenant_id','company_id','unit_id','permission'])->map(fn($r)=>(array)$r)->all();
$sort=fn($a,$b)=>[$a['user_id'],$a['permission']]<=>[$b['user_id'],$b['permission']];usort($actual,$sort);$expected=$grants;usort($expected,$sort);ensureReceivables($actual===$expected,'Granted accounts/rights/scope differ.');
foreach([1,2] as $actor){app(CompanyContext::class)->selectFor($actor,1,1,1);$listing=app(ServiceReceivables::class)->listing($actor);ensureReceivables($listing['blocked']&&$listing['records']===[]&&$listing['obligations']===[]&&$listing['documents']===[],'Normal receivables listing must remain blocked.');}
foreach(['guard','propose','decide','allocate','deliver','workshop','issuance'] as $entry){
try {
$r=app(ServiceReceivables::class);
match($entry){'guard'=>ServiceReceivables::guard(),'propose'=>$r->propose(1,[],'blocked-probe'),'decide'=>$r->decide(0,2,[],'blocked-probe'),'allocate'=>$r->allocate(0,1,[],'blocked-probe'),'deliver'=>$r->deliver(0),'workshop'=>WorkshopWorkflow::guard(),'issuance'=>\App\Modules\Services\ServiceInvoicing::guard()};
throw new RuntimeException('Operational entry did not refuse.');
}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){ensureReceivables($e->getStatusCode()===403,'Expected operational refusal.');$denials[$entry]=403;}
}
app(CompanyContext::class)->selectFor(1,1,1,1);$w=app(WorkshopWorkflow::class)->listing(1);ensureReceivables($w['blocked']&&$w['orders']===[],'Workshop operational block must remain.');
return $grants;
});
foreach(['sr','wo'] as $prefix){
try {DB::select('SELECT sgf_'.$prefix.'_environment()');throw new RuntimeException('SQL environment guard did not refuse.');}
catch(\Illuminate\Database\QueryException $e){ensureReceivables(str_contains($e->getMessage(),'SGF_'.strtoupper($prefix).'_TEST_DATABASE_ONLY'),'Unexpected SQL guard response.');}
}
foreach($sourceHashes as $p=>$hash)ensureReceivables(hash_file('sha256',base_path($p))===$hash,'Functional source or guard changed.');
foreach($newTables as $t)ensureReceivables(DB::table($t)->count()===0,'New receivables tables must remain empty.');
$proof=['date'=>'2026-10-27','database'=>'sgf_tempo_dev','migration_applied'=>$migration,'only_one_migration_added'=>true,'workshop_080000_preserved'=>true,'preexisting_tables_checked'=>count($baseline),'prior_records_and_accesses_preserved'=>true,'new_empty_tables'=>$newTables,'new_grants'=>$grants,'audited_new_grants'=>8,'application_role'=>'sgf_dev_app','both_normal_account_listings_blocked'=>true,'entry_denials'=>$denials,'receivables_and_workshop_sql_guards_refused'=>true,'functional_and_guard_sources_unchanged'=>true,'real_operations_created'=>false];
file_put_contents(storage_path('app/private/receivables-dev-install-proof.json'),json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo 'Applied only 070000 and eight audited grants; '.count($baseline)." prior tables preserved, six new tables empty, workshop and operational/fiscal guards verified.\n";
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use App\Modules\Workshop\DirectCosts;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\DirectCostFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class DirectCostsTest extends TestCase {
use RefreshDatabase;
private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=DirectCostFixture::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 $d,?string $key=null,?int $order=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/direct-costs/orders/'.($order??$this->f['order']).'/'.$action,$d);}
private function rate(string $kind,string $amount='50',array $extra=[]){return $this->send('rates',array_replace(['entry_id'=>$this->f[$kind],'entry_version'=>1,'identifier'=>'TEST-'.$kind,'version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>$kind==='time'?'H':'UN','amount'=>$amount,'evidence'=>'Taxa sintética, não custo real.'],$extra));}
private function decide(string $kind,int $id,array $extra=[]){$r=DB::table($kind==='rate'?'dc_rates':'dc_calculations')->find($id);return $this->send('decisions',array_replace(['kind'=>$kind,'id'=>$id,'version'=>$r->version,'decision'=>'approved','reason'=>'Revisão sintética de custos e taxas.'],$extra));}
private function rates(): array {$m=$this->rate('material')->assertOk()->json('id');$t=$this->rate('time','100')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$m)->assertOk();$this->decide('rate',$t)->assertOk();$this->login();return [$m,$t];}
private function input(?int $m,?int $t,array $extra=[]): array {return array_replace(['currency'=>'MZN','version'=>1,'previous_id'=>null,'lines'=>[['entry_id'=>$this->f['material'],'entry_version'=>1,'rate_id'=>$m,'rate_version'=>$m?1:null],['entry_id'=>$this->f['time'],'entry_version'=>1,'rate_id'=>$t,'rate_version'=>$t?1:null]]],$extra);}
private function listing(){return $this->getJson('/api/direct-costs')->assertOk();}
private function snapshot(int $id): array {return json_decode(DB::table('dc_calculations')->find($id)->snapshot,true,64,JSON_THROW_ON_ERROR);}
public function test_exact_known_cost_and_distinct_validation_without_new_consumption(): void {
$before=DB::table('wo_entries')->get()->toJson();[$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$s=$this->snapshot($id);
$this->assertSame('275',$s['known_subtotal_exact']);$this->assertSame('275',$s['selected_direct_total_exact']);$this->assertFalse($s['incomplete']);$this->assertSame(['175','100'],array_column($s['lines'],'cost_exact'));$this->assertSame('1.75',$s['lines'][0]['cost_quantity_exact']);
$this->decide('calculation',$id)->assertForbidden();$this->login('reader');$this->decide('calculation',$id)->assertOk();$this->assertSame($before,DB::table('wo_entries')->get()->toJson());
$this->assertSame(DB::table('dc_commands')->count(),DB::table('dc_events')->count());$this->assertSame(DB::table('dc_events')->count(),DB::table('dc_outbox')->count());
}
public function test_missing_rate_is_not_zero_and_partial_can_be_validated_only_as_partial(): void {
[$m,$t]=$this->rates();$id=$this->send('calculations',$this->input(null,$t))->assertOk()->json('id');$s=$this->snapshot($id);$this->assertSame('175',$s['known_subtotal_exact']);$this->assertNull($s['selected_direct_total_exact']);$this->assertTrue($s['incomplete']);$this->assertCount(1,$s['pending']);
$this->login('reader');$this->decide('calculation',$id)->assertOk();$this->login();$zero=$this->rate('material','0',['identifier'=>'TEST-ZERO'])->assertOk()->json('id');$this->login('reader');$this->decide('rate',$zero)->assertOk();$this->login();$in=$this->input($zero,$t,['version'=>2,'previous_id'=>$id]);$second=$this->send('calculations',$in)->assertOk()->json('id');$this->assertFalse($this->snapshot($second)['incomplete']);$this->assertSame('0',$this->snapshot($second)['lines'][1]['cost_exact']);$this->assertSame('175',$this->snapshot($second)['selected_direct_total_exact']);
}
public function test_new_rate_preserves_and_invalidates_approved_calculation_no_latest_default(): void {
[$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$this->login('reader');$this->decide('calculation',$id)->assertOk();$this->login();$old=DB::table('dc_calculations')->find($id)->snapshot;
$next=$this->rate('material','60',['version'=>2,'previous_id'=>$m])->assertOk()->json('id');$this->listing()->assertJsonPath('orders.0.calculations.0.current',false);$this->send('calculations',$this->input($m,$t,['version'=>2,'previous_id'=>$id]))->assertStatus(409);
$this->login('reader');$this->decide('rate',$m)->assertStatus(409);$this->decide('rate',$next)->assertOk();$this->login();$in=$this->input($next,$t,['version'=>2,'previous_id'=>$id]);$in['lines'][0]['rate_version']=2;$new=$this->send('calculations',$in)->assertOk()->json('id');$this->assertSame('295',$this->snapshot($new)['known_subtotal_exact']);$this->assertSame($old,DB::table('dc_calculations')->find($id)->snapshot);
// A different approved identifier is not silently selected.
$this->rate('material','99',['identifier'=>'TEST-OTHER'])->assertOk();$this->listing()->assertJsonPath('orders.0.calculations.1.snapshot.known_subtotal_exact','295');
}
public function test_invalid_source_types_unverified_versions_currency_units_and_precision_refused(): void {
$delivered=DB::table('wo_entries')->where('order_id',$this->f['order'])->whereRaw("data->>'event'='delivered'")->value('id');$this->rate('material','50',['entry_id'=>$delivered])->assertStatus(422);
$this->rate('material','50',['entry_version'=>99])->assertForbidden();$this->rate('material','50',['unit'=>'H'])->assertStatus(422);$this->rate('material','50',['currency'=>'USD'])->assertStatus(422);
foreach(['','1.000000001','-1','1e2','1,00'] as $value)$this->rate('material',$value)->assertStatus(422);
$this->rate('material','50',['amount'=>50])->assertStatus(422);
$e=DB::table('wo_entries')->find($this->f['material']);$d=json_decode($e->data,true);unset($d['policy']);$rev=(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v;
app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(WorkshopWorkflow::class)->record($e->order_id,$this->f['technician']->id,array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>$rev,'quantity'=>'2.1','correction_reason'=>'Correcção sintética de quantidade.']),(string)Str::uuid());$this->login();$this->rate('material')->assertStatus(409);$latest=DB::table('wo_entries')->where('previous_id',$e->id)->first();$this->rate('material','50',['entry_id'=>$latest->id,'entry_version'=>2])->assertStatus(409);
}
public function test_roles_scope_revocation_and_idempotent_response_recovery(): void {
$key=(string)Str::uuid();$d=['entry_id'=>$this->f['material'],'entry_version'=>1,'identifier'=>'TEST-REPLAY','version'=>1,'previous_id'=>null,'currency'=>'MZN','unit'=>'UN','amount'=>'50','evidence'=>'TEST-REPLAY'];$id=$this->send('rates',$d,$key)->assertOk()->json('id');$this->send('rates',$d,$key)->assertOk()->assertJsonPath('replayed',true)->assertJsonPath('id',$id);$this->send('rates',array_replace($d,['amount'=>'51']),$key)->assertStatus(409);
$this->send('rates',$d,null,$this->f['order']+999)->assertForbidden();$this->login('reader');$this->rate('time')->assertForbidden();$this->login('technician');$this->getJson('/api/direct-costs')->assertForbidden();$this->login();DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'direct_costs.prepare'])->delete();$this->send('rates',$d,$key)->assertForbidden();
}
public function test_failure_rolls_back_rate_audit_event_outbox_and_retry_is_safe(): void {
$before=DB::table('dc_rates')->count();DB::unprepared("CREATE FUNCTION dc_fail_test() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'DC_TEST_OUTBOX_FAILURE'; END; \$\$; CREATE TRIGGER dc_fail_test BEFORE INSERT ON dc_outbox FOR EACH ROW EXECUTE FUNCTION dc_fail_test()");
$response=$this->rate('material');$this->assertGreaterThanOrEqual(500,$response->status());$this->assertSame($before,DB::table('dc_rates')->count());$this->assertSame(0,DB::table('dc_commands')->count());$this->assertSame(0,DB::table('dc_events')->count());DB::unprepared('DROP TRIGGER dc_fail_test ON dc_outbox; DROP FUNCTION dc_fail_test()');$this->rate('material')->assertOk();
}
public function test_refusal_and_immutable_bulk_changes_and_wrong_database_guard(): void {
$id=$this->rate('material')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$id,['decision'=>'refused'])->assertOk();$this->login();$t=$this->rate('time','100')->assertOk()->json('id');$this->login('reader');$this->decide('rate',$t)->assertOk();$this->login();$this->send('calculations',$this->input($id,$t))->assertStatus(409);
foreach(['UPDATE dc_rates SET unit_id=999','DELETE FROM dc_rates'] as $sql){try{DB::transaction(fn()=>DB::statement($sql));$this->fail('Immutable SQL accepted');}catch(\Illuminate\Database\QueryException){$this->assertTrue(true);}}
$this->app['env']='local';$this->getJson('/api/direct-costs')->assertOk()->assertJsonPath('blocked',true);try{DirectCosts::guard();$this->fail('Normal guard accepted');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$this->assertSame(403,$e->getStatusCode());}$this->app['env']='testing';$old=config('database.connections.pgsql.database');config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);try{DirectCosts::guard();$this->fail('Wrong database accepted');}catch(\RuntimeException $e){$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage());}finally{config(['database.connections.pgsql.database'=>$old]);}
}
public function test_corrected_source_stales_history_exact_nonfinite_cost_and_cross_scope_refusal(): void {
[$m,$t]=$this->rates();$id=$this->send('calculations',$this->input($m,$t))->assertOk()->json('id');$original=DB::table('dc_calculations')->find($id)->snapshot;
$e=DB::table('wo_entries')->find($this->f['time']);$d=json_decode($e->data,true);unset($d['policy']);$d=array_replace($d,['version'=>2,'previous_id'=>$e->id,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'ends_at'=>$this->f['day'].'T09:01:00+02:00','pauses'=>[],'correction_reason'=>'Um minuto no novo ensaio exacto.']);
app(CompanyContext::class)->selectFor($this->f['technician']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w=app(WorkshopWorkflow::class);$next=$w->record($e->order_id,$this->f['technician']->id,$d,(string)Str::uuid())['id'];
$this->login('reader');$this->decide('calculation',$id)->assertStatus(409);
app(CompanyContext::class)->selectFor($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$w->verify($next,$this->f['reviewer']->id,['version'=>2,'revision'=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$e->order_id])->v,'decision'=>'verified','reason'=>'Verificação técnica da correcção.'],(string)Str::uuid());$this->login();
$newRate=$this->rate('time','1',['entry_id'=>$next,'entry_version'=>2,'version'=>2,'previous_id'=>$t])->assertOk()->json('id');$this->login('reader');$this->decide('rate',$newRate)->assertOk();$this->login();$in=$this->input(null,$newRate,['version'=>2,'previous_id'=>$id]);$in['lines'][1]=['entry_id'=>$next,'entry_version'=>2,'rate_id'=>$newRate,'rate_version'=>2];$result=$this->send('calculations',$in)->assertOk()->json('id');$this->assertSame('1/60',$this->snapshot($result)['known_subtotal_exact']);$this->assertSame($original,DB::table('dc_calculations')->find($id)->snapshot);
// Own tenant does not imply sibling company or hidden unit access.
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'unit.view',$this->f['hidden']);IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.view',$this->f['hidden']);IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'direct_costs.prepare',$this->f['hidden']);
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertOk()->json('context.token');$this->listing()->assertJsonCount(0,'orders');$this->rate('material')->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();$this->getJson('/api/direct-costs')->assertForbidden();
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Tests\Support;
use App\Modules\Workshop\WorkshopWorkflow;
use App\Modules\Workshop\DirectCosts;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Carbon\Carbon;
final class DirectCostFixture {
public static function create(?array $base=null): array {
DirectCosts::guard();$f=WorkshopFixture::create($base);
foreach(['manager'=>['view','prepare'],'reader'=>['view','validate']] as $who=>$rights)foreach($rights as $r)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],'direct_costs.'.$r,$f['unit']);
$old=Carbon::getTestNow();$day=now(WorkshopWorkflow::ZONE)->subDay()->format('Y-m-d');
$select=fn($id)=>app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']);$key=fn()=>(string)Str::uuid();$w=app(WorkshopWorkflow::class);
try {
Carbon::setTestNow(Carbon::parse($day.' 08:00:00',WorkshopWorkflow::ZONE));$select($f['manager']->id);
$order=$w->open($f['manager']->id,['reference'=>'OT-CUSTO-TEST-'.Str::uuid(),'client_id'=>$f['client'],'equipment_identifier'=>'EQ-CUSTO-'.Str::uuid(),'equipment_description'=>'Equipamento sintético','request'=>'Intervenção de teste','location'=>'Local sintético','evidence'=>'TEST-REQUEST'],$key())['id'];
$rev=fn()=>(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$order])->v;
$record=fn($actor,$kind,$data)=>$w->record($order,$actor,['kind'=>$kind,'origin'=>'TEST-'.$kind.'-'.Str::uuid(),'version'=>1,'revision'=>$rev()]+$data,$key())['id'];
$record($f['manager']->id,'assignment',['technician_id'=>$f['technician']->id]);$select($f['technician']->id);
$task=$record($f['technician']->id,'task',['description'=>'Tarefa de ensaio de custo directo']);$select($f['manager']->id);
$auth=$record($f['manager']->id,'authorization',['task_id'=>$task,'authorized_by'=>'Representante sintético','capacity'=>'Cliente de teste','authorized_at'=>$day.'T07:59:00+02:00','channel'=>'Documento de teste','scope'=>'Tarefa identificada','evidence'=>'TEST-AUTH']);
Carbon::setTestNow(Carbon::parse($day.' 12:00:00',WorkshopWorkflow::ZONE));$select($f['technician']->id);
$time=$record($f['technician']->id,'time',['task_id'=>$task,'authorization_id'=>$auth,'starts_at'=>$day.'T09:00:00+02:00','ends_at'=>$day.'T11:00:00+02:00','mode'=>'reported','time_type'=>'work','pauses'=>[['start'=>$day.'T10:00:00+02:00','end'=>$day.'T10:15:00+02:00']],'evidence'=>'TEST-TIME']);
$points=[$task,$time];foreach(['delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$quantity){$id=$record($f['technician']->id,'material',['task_id'=>$task,'authorization_id'=>$auth,'starts_at'=>$day.'T11:00:00+02:00','resource'=>'CABO-CUSTO-TEST','unit'=>'UN','owner'=>'company','event'=>$event,'quantity'=>$quantity,'evidence'=>'TEST-MATERIAL']);$points[]=$id;if($event==='consumed')$material=$id;}
$select($f['reviewer']->id);foreach($points as $id)$w->verify($id,$f['reviewer']->id,['version'=>1,'revision'=>$rev(),'decision'=>'verified','reason'=>'Verificação técnica sintética separada dos custos.'],$key());
} finally {Carbon::setTestNow($old);}
$select($f['manager']->id);return $f+compact('order','task','auth','time','material','day');
}
}