From 3932b94a16a5885a7d9225b96bf2b251ed3ac9f8 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 19:32:10 +0200 Subject: [PATCH] Accept isolated workshop billing draft trial with documented manual evidence --- AGENTS.md | 6 ++ README.md | 2 + app/Modules/Services/ServiceWorkflow.php | 7 ++ .../Http/WorkshopBillingController.php | 11 +++ app/Modules/Workshop/WorkshopBilling.php | 85 ++++++++++++++++ ..._120000_create_workshop_billing_drafts.php | 16 +++ database/sql/workshop-billing.sql | 46 +++++++++ docs/WORKSHOP_BILLING_ACCEPTANCE.md | 11 +++ docs/WORKSHOP_BILLING_DELIVERY.md | 73 ++++++++++++++ docs/WORKSHOP_BILLING_DRAFT_SCOPE.md | 98 +++++++++++++++++++ docs/proofs/workshop-billing-concurrency.json | 13 +++ docs/proofs/workshop-billing-environment.json | 10 ++ .../workshop-billing-manual-results.json | 27 +++++ docs/proofs/workshop-billing-tests.json | 17 ++++ resources/js/WorkshopBillingWorkspace.jsx | 14 +++ resources/js/WorkspaceHome.jsx | 2 + resources/js/WorkspaceNavigation.jsx | 2 +- resources/js/app.jsx | 2 + resources/js/workshopBillingProposal.js | 18 ++++ routes/web.php | 4 + scripts/workshop-billing-concurrency.php | 16 +++ scripts/workshop-billing-entry-check.php | 13 +++ scripts/workshop-billing-fixture.php | 10 ++ scripts/workshop-billing-form-check.mjs | 18 ++++ scripts/workshop-billing-manual-verify.php | 33 +++++++ .../workshop-billing-permissions-preview.php | 10 ++ scripts/workshop-billing-status.php | 6 ++ scripts/workshop-billing-worker.php | 6 ++ tests/Feature/WorkshopBillingTest.php | 45 +++++++++ tests/Support/WorkshopBillingFixture.php | 13 +++ 30 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 app/Modules/Workshop/Http/WorkshopBillingController.php create mode 100644 app/Modules/Workshop/WorkshopBilling.php create mode 100644 database/migrations/2026_10_28_120000_create_workshop_billing_drafts.php create mode 100644 database/sql/workshop-billing.sql create mode 100644 docs/WORKSHOP_BILLING_ACCEPTANCE.md create mode 100644 docs/WORKSHOP_BILLING_DELIVERY.md create mode 100644 docs/WORKSHOP_BILLING_DRAFT_SCOPE.md create mode 100644 docs/proofs/workshop-billing-concurrency.json create mode 100644 docs/proofs/workshop-billing-environment.json create mode 100644 docs/proofs/workshop-billing-manual-results.json create mode 100644 docs/proofs/workshop-billing-tests.json create mode 100644 resources/js/WorkshopBillingWorkspace.jsx create mode 100644 resources/js/workshopBillingProposal.js create mode 100644 scripts/workshop-billing-concurrency.php create mode 100644 scripts/workshop-billing-entry-check.php create mode 100644 scripts/workshop-billing-fixture.php create mode 100644 scripts/workshop-billing-form-check.mjs create mode 100644 scripts/workshop-billing-manual-verify.php create mode 100644 scripts/workshop-billing-permissions-preview.php create mode 100644 scripts/workshop-billing-status.php create mode 100644 scripts/workshop-billing-worker.php create mode 100644 tests/Feature/WorkshopBillingTest.php create mode 100644 tests/Support/WorkshopBillingFixture.php diff --git a/AGENTS.md b/AGENTS.md index 5811186..5f152ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # SGF Tempo — instruções locais +- Aceitação específica de 28/10/2026: utilizador aceitou `WORKSHOP-BILLING-DRAFT-SIM-v1` com limitação de capturas e autorizou checkpoint exclusivamente local, sem credenciais/dados privados. Ver `docs/WORKSHOP_BILLING_ACCEPTANCE.md`. Não transformar provas pendentes em realizadas; Edge não recuperado e capturas não recebidas permanecem limites. Substitui apenas a pendência de aceitação/encerramento deste ensaio. `120000`, seis concessões e utilização real continuam por autorizar; `110000` e sete concessões também pendentes. Sem aplicação, activação operacional ou publicação. + +- Autorização técnica de 28/10/2026: implementar `WORKSHOP-BILLING-DRAFT-SIM-v1` conforme `docs/WORKSHOP_BILLING_DRAFT_SCOPE.md`, exclusivamente sintético em `sgf_tempo_test`. Ver `docs/WORKSHOP_BILLING_DELIVERY.md`. Reutilizar origens verificadas por ID/versão, sem consumo duplicado, preços comerciais sintéticos 200 MZN/H e 75 MZN/UN (350+150=500 sem imposto), revisão comercial distinta. Não derivar preço do custo interno ou atribuir verificação técnica à revisora. Sem edição directa de rascunhos gerados ou confirmação B1 automática. `120000` e seis concessões reais só propostas, não aplicar sem autorização específica; `110000` e sete concessões continuam pendentes. Preservar `04870f0`, `090b7da` e alterações posteriores; sem utilização real, stock, emissão, lançamentos, publicação ou aceitação presumida. + +- Decisões confirmadas em 28/10/2026 para oficina → rascunho: ver secções 8/8.1 de `docs/WORKSHOP_BILLING_DRAFT_SCOPE.md`. Tempo verificado e material consumido da empresa; excluir material do cliente e dupla cobrança tarefa/tempo. Osmane prepara/gera; Samira revê versão comercial por identidade distinta, sem verificação técnica implícita. Uma ordem/rascunho, origens integrais, sem repartição. Tabela apenas sintética `WORKSHOP-SALES-SYNTHETIC-v1`, MZN sem imposto: 200/H × 105/60 H = 350; 75/UN × 2 = 150; soma 500. Sem custo interno convertido em preço. Apenas especificação nesta etapa; implementação, migrações/concessões e utilização real continuam separadas. Preservar `04870f0`, `090b7da` e bloqueios. + - Preservação autorizada em 28/10/2026: registar avaliação técnica da página inicial/menu e criar checkpoint local sem credenciais/dados privados; não presumir revisão visual ou aceitação visual. Encerramento do orçamento permanece em `04870f0` com limitações explícitas. Próximo passo autorizado apenas de especificação: oficina → rascunho de facturação; definir facturabilidade, revisão, preços e duplicações antes de implementar. `110000`, sete concessões e utilização real permanecem por autorizar. - Autorização posterior de 28/10/2026: implementar página inicial e navegação agrupada após a proposta `docs/HOME_NAVIGATION_PROPOSAL.md`. Ver `docs/HOME_NAVIGATION_DELIVERY.md`. Preservar `04870f0` e anteriores, direitos por contexto e bloqueios. Sem novos acessos, módulos financeiros, contagens/indicadores fictícios ou uso real; fontes agregadas de pendências não implementadas. Prova visual permanece separada, sem presumir aceitação ou checkpoint. `110000` e sete concessões continuam por autorizar. diff --git a/README.md b/README.md index 6720c90..83898aa 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ 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 +Ligação sintética oficina → prestação/rascunho rastreável: [âmbito implementado, provas e limites](docs/WORKSHOP_BILLING_DELIVERY.md). Migração `120000` e seis concessões apenas propostas; nenhuma conversão real activada. + Página inicial e menus agrupados: [alterações, verificações e limites visuais](docs/HOME_NAVIGATION_DELIVERY.md). Os atalhos dependem dos acessos existentes; não apresentam contagens de pendências ou indicadores financeiros simulados. 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. diff --git a/app/Modules/Services/ServiceWorkflow.php b/app/Modules/Services/ServiceWorkflow.php index cdf92bc..0f406e3 100644 --- a/app/Modules/Services/ServiceWorkflow.php +++ b/app/Modules/Services/ServiceWorkflow.php @@ -84,6 +84,10 @@ final class ServiceWorkflow $old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null; if ($id) { abort_unless($old, 403, 'Registo fora do âmbito autorizado.'); + if (in_array($area, ['service_drafts','billing_drafts'], true) && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) { + $column = $area === 'service_drafts' ? 'service_draft_id' : 'billing_draft_id'; + abort_if(DB::table('wb_generations')->where($column, $id)->exists(),409,'Rascunho ligado a oficina: correcção comercial própria necessária; a proposta revista não pode ser alterada por edição directa.'); + } abort_if($area === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'Confirmado internamente — sem validade fiscal. O registo está bloqueado para edição.'); abort_unless((int) $old->version === (int) ($input['version'] ?? 0), 409, 'O registo mudou. Reabra-o antes de guardar.'); } @@ -137,6 +141,9 @@ final class ServiceWorkflow $source = null; if ($area === 'billing_drafts') { $sourceId = $old ? $old->source_id : $input['source_id']; + if (!$old && \Illuminate\Support\Facades\Schema::hasTable('wb_generations')) { + abort_if(DB::table('wb_generations')->where('service_draft_id', $sourceId)->exists(),409,'Prestação de oficina já convertida: não pode gerar um segundo rascunho por outro percurso.'); + } $source = DB::table('a_service_drafts')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('id', $sourceId)->first(); abort_unless($source, 403, 'Prestação fora da unidade autorizada.'); $reload = ! $old || ($input['reload_source'] ?? false); diff --git a/app/Modules/Workshop/Http/WorkshopBillingController.php b/app/Modules/Workshop/Http/WorkshopBillingController.php new file mode 100644 index 0000000..0202156 --- /dev/null +++ b/app/Modules/Workshop/Http/WorkshopBillingController.php @@ -0,0 +1,11 @@ +json($w->listing($r->user()->id))->header('Cache-Control','no-store, private');} + public function prepare(Request $r,int $id,WorkshopBilling $w){return response()->json($w->prepare($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));} + public function review(Request $r,int $id,WorkshopBilling $w){return response()->json($w->review($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));} + public function generate(Request $r,int $id,WorkshopBilling $w){return response()->json($w->generate($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key')));} +} diff --git a/app/Modules/Workshop/WorkshopBilling.php b/app/Modules/Workshop/WorkshopBilling.php new file mode 100644 index 0000000..d99f182 --- /dev/null +++ b/app/Modules/Workshop/WorkshopBilling.php @@ -0,0 +1,85 @@ +$this->canonical($v),$x);} + private function exact(array $in,array $rules): array{ + abort_if(array_diff(array_keys($in),array_keys($rules)),422,'Campo fora do recorte.'); + return Validator::make($in,$rules,[ + 'required'=>'Preencha :attribute.','present'=>'Falta :attribute; actualize o formulário.','integer'=>':attribute deve ser um número inteiro.', + 'string'=>':attribute deve ser texto.','array'=>':attribute deve conter uma lista.','in'=>':attribute tem um valor não permitido.', + 'min'=>':attribute não cumpre o mínimo permitido (:min).','max'=>':attribute excede o máximo permitido (:max).', + 'lines.required'=>'Seleccione pelo menos uma origem verificada.','lines.min'=>'Seleccione pelo menos uma origem verificada.', + ],['entry_id'=>'a origem','entry_version'=>'a versão da origem','service_id'=>'o serviço comercial','service_version'=>'a versão do serviço','price'=>'o preço comercial','price_source'=>'a fonte comercial e versão','lines'=>'as origens seleccionadas','evidence'=>'o fundamento da proposta','reason'=>'o fundamento da revisão','client_id'=>'o cliente','currency'=>'a moeda','version'=>'a versão','previous_id'=>'a referência anterior','decision'=>'a decisão','id'=>'a proposta'])->validate(); + } + private function scope(int $actor,string $action): array { + $s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($this->context->userId()===$actor&&$s['unit_id']!==null,403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); + foreach(['workshop_billing.view','workshop_billing.'.$action,'workshop.view'] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,$s['unit_id']); + foreach(['clients.view','services.view'] as $right)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right);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 contexto.');} + 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(409,'Origem/versão incompatível.');$e->data=json_decode($e->data,true); + abort_unless(!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->exists(),409,'Origem não verificada ou desactualizada.'); + $task=DB::table('wo_entries')->where(['id'=>$e->task_id,'order_id'=>$order,'kind'=>'task'])->first(); + abort_unless($task&&!DB::table('wo_entries')->where('previous_id',$task->id)->exists()&&DB::table('wo_decisions')->where(['entry_id'=>$task->id,'entry_version'=>$task->version,'decision'=>'verified'])->exists(),409,'Tarefa não verificada/actual.'); + abort_unless(($e->kind==='time'&&$e->data['time_type']==='work')||($e->kind==='material'&&$e->data['event']==='consumed'&&$e->data['owner']==='company'),422,'Só tempo efectivo e material consumido da empresa.');return $e; + } + private function compose(int $order,array $input,array $s,bool $allowClaim=false): array { + $o=$this->order($order,$s);abort_unless($input['client_id']===$o->client_id&&$input['currency']==='MZN',409,'Cliente ou moeda incompatível.'); + $client=DB::table('a_clients')->where(['id'=>$o->client_id,'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409); + abort_unless(count($input['lines'])>0,422);$seen=[];$lines=[]; + foreach($input['lines'] as $line){ + $d=$this->exact($line,['entry_id'=>'required|integer|min:1','entry_version'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','price'=>'required|string','price_source'=>'required|string|min:3|max:120']); + $e=$this->source($d['entry_id'],$d['entry_version'],$order);abort_if(isset($seen[$e->origin]),409,'Origem repetida.');$seen[$e->origin]=true; + abort_if(!$allowClaim&&DB::table('wb_claims')->where(['order_id'=>$order,'origin'=>$e->origin])->exists(),409,'Origem já utilizada comercialmente.'); + $service=DB::table('a_services')->where(['id'=>$d['service_id'],'version'=>$d['service_version'],'tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->first()??abort(409,'Serviço/versão incompatível.'); + $price=DraftValues::decimal($d['price'],'price');abort_if($price===null,422,'Preço desconhecido não permite gerar.'); + $quantity=$e->kind==='time'?Exact::text(Exact::hours($e->minutes)):$e->quantity;$quantity=DraftValues::decimal($quantity,'quantity',true); + $lines[]=['entry_id'=>$e->id,'entry_version'=>$e->version,'origin'=>$e->origin,'task_id'=>$e->task_id,'source_kind'=>$e->kind,'source_quantity'=>$e->kind==='time'?(string)$e->minutes:$e->quantity,'unit'=>$e->kind==='time'?'H':$e->data['unit'],'service_id'=>$service->id,'service_version'=>$service->version,'quantity'=>$quantity,'unit_price'=>$price,'price_source'=>$d['price_source'],'description'=>"Oficina #{$order} · origem #{$e->id} v{$e->version} · ".($e->kind==='time'?'Trabalho efectivo':$e->data['resource'])]; + } + $data=['currency'=>'MZN','lines'=>$lines];return ['policy'=>self::POLICY,'order_id'=>$order,'client_id'=>$o->client_id,'client_version'=>$client->version,'currency'=>'MZN','lines'=>$lines,'base_exact'=>DraftValues::base($data),'price_policy'=>'explicit_commercial_not_internal_cost','evidence'=>$input['evidence']]; + } + private function command(int $order,int $actor,string $action,array $d,string $key,callable $write): array { + self::guard();$s=$this->scope($actor,$action);if($action==='generate'){$this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create');}$this->order($order,$s);abort_unless(preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422);$hash=hash('sha256',$this->json($this->canonical($d))); + return DB::transaction(function()use($order,$actor,$action,$d,$key,$hash,$write,$s){DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();$this->scope($actor,$action);if($action==='generate'){$this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create');}$this->order($order,$s); + $old=DB::table('wb_commands')->where(['order_id'=>$order,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key])->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409);return json_decode($old->result,true)+['replayed'=>true];} + $out=$write($s);$cmd=DB::table('wb_commands')->insertGetId(['order_id'=>$order,'actor_id'=>$actor,'action'=>$action,'command_key'=>$key,'request_hash'=>$hash,'result'=>$this->json($out),'created_at'=>now()]);Audit::record('workshop_billing.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$out['id'],'version'=>$out['version']]); + $event=DB::table('wb_events')->insertGetId(['command_id'=>$cmd,'payload'=>$this->json(['policy'=>self::POLICY,'scope'=>$s,'action'=>$action,'result'=>$out]),'created_at'=>now()]);DB::table('wb_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_stock_or_accounting']);return $out+['replayed'=>false];},3); + } + public function prepare(int $order,int $actor,array $in,string $key): array { + self::guard();$d=$this->exact($in,['version'=>'required|integer|min:1','previous_id'=>'present|nullable|integer|min:1','client_id'=>'required|integer|min:1','currency'=>'required|in:MZN','lines'=>'required|array|min:1|max:50','evidence'=>'required|string|min:3|max:1000']); + return $this->command($order,$actor,'prepare',$d,$key,function($s)use($order,$actor,$d){abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida; exige correcção própria.');$last=DB::table('wb_proposals')->where('order_id',$order)->orderByDesc('version')->first();abort_unless($d['version']===($last?->version??0)+1&&$d['previous_id']===$last?->id,409,'Rever versão anterior.');$snapshot=$this->compose($order,$d,$s);$id=DB::table('wb_proposals')->insertGetId(['order_id'=>$order,'version'=>$d['version'],'previous_id'=>$last?->id,'snapshot'=>$this->json($snapshot),'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$d['version']];}); + } + private function proposal(int $order,array $d): object {$p=DB::table('wb_proposals')->where(['id'=>$d['id'],'order_id'=>$order,'version'=>$d['version']])->first()??abort(409,'Proposta/versão incompatível.');abort_if(DB::table('wb_proposals')->where('previous_id',$p->id)->exists(),409,'Proposta desactualizada.');return $p;} + private function recheck(object $p,array $s,bool $allowClaim=false): array { + $snap=is_array($p->snapshot)?$p->snapshot:json_decode($p->snapshot,true);$lines=array_map(fn($l)=>['entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version'],'service_id'=>$l['service_id'],'service_version'=>$l['service_version'],'price'=>$l['unit_price'],'price_source'=>$l['price_source']],$snap['lines']);$new=$this->compose($p->order_id,['client_id'=>$snap['client_id'],'currency'=>$snap['currency'],'lines'=>$lines,'evidence'=>$snap['evidence']],$s,$allowClaim);abort_unless($this->canonical($snap)===$this->canonical($new),409,'Dados comerciais/origens mudaram; nova proposta necessária.');return $snap; + } + public function review(int $order,int $actor,array $in,string $key): array { + self::guard();$d=$this->exact($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->command($order,$actor,'review',$d,$key,function($s)use($order,$actor,$d){$p=$this->proposal($order,$d);abort_if($p->actor_id===$actor,403,'Revisor distinto necessário.');$this->recheck($p,$s);abort_if(DB::table('wb_decisions')->where('proposal_id',$p->id)->exists(),409);$id=DB::table('wb_decisions')->insertGetId(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$p->version];}); + } + public function generate(int $order,int $actor,array $in,string $key): array { + self::guard();$d=$this->exact($in,['id'=>'required|integer|min:1','version'=>'required|integer|min:1']); + return $this->command($order,$actor,'generate',$d,$key,function($s)use($order,$actor,$d,$key){$p=$this->proposal($order,$d);abort_unless(DB::table('wb_decisions')->where(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>'approved'])->exists(),409,'Revisão exacta necessária.');abort_if(DB::table('wb_generations')->where('order_id',$order)->exists(),409,'Ordem já convertida.');$snap=$this->recheck($p,$s); + $this->workflow->scope('service_drafts',$actor,'create');$this->workflow->scope('billing_drafts',$actor,'create'); + $lines=array_map(fn($l)=>array_intersect_key($l,array_flip(['service_id','quantity','unit_price','description'])),$snap['lines']);$name='Oficina #'.$order.' · proposta #'.$p->id.' v'.$p->version; + $service=$this->workflow->save('service_drafts',null,$actor,['client_id'=>$snap['client_id'],'name'=>$name,'currency'=>'MZN','lines'=>$lines,'description'=>'Origem rastreável de oficina; sem validade fiscal.'],substr(hash('sha256','wb-service-'.$key),0,40)); + $billing=$this->workflow->save('billing_drafts',null,$actor,['source_id'=>$service['id'],'name'=>$name],substr(hash('sha256','wb-billing-'.$key),0,40)); + $id=DB::table('wb_generations')->insertGetId(['order_id'=>$order,'proposal_id'=>$p->id,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'actor_id'=>$actor,'created_at'=>now()]);foreach($snap['lines'] as $l)DB::table('wb_claims')->insert(['generation_id'=>$id,'order_id'=>$order,'origin'=>$l['origin'],'entry_id'=>$l['entry_id'],'entry_version'=>$l['entry_version']]);return ['id'=>$id,'version'=>$p->version,'service_draft_id'=>$service['id'],'billing_draft_id'=>$billing['id'],'base_exact'=>$snap['base_exact']];}); + } + public function listing(int $actor): array { + $s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[]];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)->get() as $o){$entries=[];foreach(DB::table('wo_entries')->where('order_id',$o->id)->whereIn('kind',['time','material'])->get() as $e){try{$entries[]=$this->source($e->id,$e->version,$o->id);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface){}} + $proposals=DB::table('wb_proposals')->where('order_id',$o->id)->orderBy('version')->get()->all();foreach($proposals as $p){$p->snapshot=json_decode($p->snapshot,true);$p->decision=DB::table('wb_decisions')->where('proposal_id',$p->id)->first();$p->current=!DB::table('wb_proposals')->where('previous_id',$p->id)->exists();if($p->current){try{$this->recheck($p,$s,true);}catch(\Throwable){$p->current=false;}}} + $orders[]=(array)$o+['entries'=>$entries,'proposals'=>$proposals,'generation'=>DB::table('wb_generations')->where('order_id',$o->id)->first()];}return ['blocked'=>false,'orders'=>$orders,'services'=>DB::table('a_services')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->get()->all()];}); + } +} diff --git a/database/migrations/2026_10_28_120000_create_workshop_billing_drafts.php b/database/migrations/2026_10_28_120000_create_workshop_billing_drafts.php new file mode 100644 index 0000000..1b22612 --- /dev/null +++ b/database/migrations/2026_10_28_120000_create_workshop_billing_drafts.php @@ -0,0 +1,16 @@ +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 ('workshop_billing.view','workshop_billing.prepare','workshop_billing.review','workshop_billing.generate') AND unit_id IS NOT NULL))"); + DB::unprepared(file_get_contents(__DIR__.'/../sql/workshop-billing.sql')); + foreach(['wb_proposals','wb_decisions','wb_generations','wb_claims','wb_commands','wb_events','wb_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('Workshop commercial history requires reviewed preservation.');} +}; diff --git a/database/sql/workshop-billing.sql b/database/sql/workshop-billing.sql new file mode 100644 index 0000000..3fdcb24 --- /dev/null +++ b/database/sql/workshop-billing.sql @@ -0,0 +1,46 @@ +CREATE TABLE wb_proposals(id bigserial PRIMARY KEY,order_id bigint NOT NULL REFERENCES wo_orders(id),version integer NOT NULL CHECK(version>0),previous_id bigint UNIQUE REFERENCES wb_proposals(id),snapshot jsonb NOT NULL,actor_id bigint NOT NULL REFERENCES users(id),created_at timestamptz NOT NULL,UNIQUE(order_id,version)); +CREATE TABLE wb_decisions(id bigserial PRIMARY KEY,proposal_id bigint UNIQUE NOT NULL REFERENCES wb_proposals(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 wb_generations(id bigserial PRIMARY KEY,order_id bigint UNIQUE NOT NULL REFERENCES wo_orders(id),proposal_id bigint UNIQUE NOT NULL REFERENCES wb_proposals(id),service_draft_id bigint UNIQUE NOT NULL REFERENCES a_service_drafts(id),billing_draft_id bigint UNIQUE NOT NULL REFERENCES a_billing_drafts(id),actor_id bigint NOT NULL REFERENCES users(id),created_at timestamptz NOT NULL); +CREATE TABLE wb_claims(id bigserial PRIMARY KEY,generation_id bigint NOT NULL REFERENCES wb_generations(id),order_id bigint NOT NULL REFERENCES wo_orders(id),origin text NOT NULL,entry_id bigint UNIQUE NOT NULL REFERENCES wo_entries(id),entry_version integer NOT NULL,UNIQUE(order_id,origin)); +CREATE TABLE wb_commands(id bigserial PRIMARY KEY,order_id bigint NOT NULL REFERENCES wo_orders(id),actor_id bigint NOT NULL REFERENCES users(id),action text NOT NULL CHECK(action IN ('prepare','review','generate')),command_key text NOT NULL,request_hash text NOT NULL,result jsonb NOT NULL,created_at timestamptz NOT NULL,UNIQUE(order_id,actor_id,action,command_key)); +CREATE TABLE wb_events(id bigserial PRIMARY KEY,command_id bigint UNIQUE NOT NULL REFERENCES wb_commands(id),payload jsonb NOT NULL,created_at timestamptz NOT NULL); +CREATE TABLE wb_outbox(event_id bigint PRIMARY KEY REFERENCES wb_events(id),state text NOT NULL CHECK(state='captured_no_stock_or_accounting')); +CREATE OR REPLACE FUNCTION sgf_wb_integrity() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE p wb_proposals; n integer; prev bigint; e wo_entries; +BEGIN + PERFORM sgf_ic_environment(); + IF TG_TABLE_NAME='wb_decisions' THEN + SELECT * INTO STRICT p FROM wb_proposals WHERE id=NEW.proposal_id; + IF p.version<>NEW.version OR p.actor_id=NEW.actor_id OR EXISTS(SELECT 1 FROM wb_proposals WHERE previous_id=p.id) THEN RAISE EXCEPTION 'SGF_WB_REVIEW_IDENTITY_VERSION'; END IF; + ELSIF TG_TABLE_NAME='wb_proposals' THEN + PERFORM 1 FROM companies WHERE id=(SELECT company_id FROM wo_orders WHERE id=NEW.order_id) FOR UPDATE; + SELECT coalesce(max(version),0) INTO n FROM wb_proposals WHERE order_id=NEW.order_id; + SELECT id INTO prev FROM wb_proposals WHERE order_id=NEW.order_id AND version=n; + IF NEW.version<>n+1 OR NEW.previous_id IS DISTINCT FROM prev THEN RAISE EXCEPTION 'SGF_WB_VERSION'; END IF; + ELSIF TG_TABLE_NAME='wb_generations' THEN + SELECT * INTO STRICT p FROM wb_proposals WHERE id=NEW.proposal_id AND order_id=NEW.order_id; + IF EXISTS(SELECT 1 FROM wb_proposals WHERE previous_id=p.id) OR NOT EXISTS(SELECT 1 FROM wb_decisions WHERE proposal_id=p.id AND version=p.version AND decision='approved') THEN RAISE EXCEPTION 'SGF_WB_APPROVED_CURRENT'; END IF; + ELSIF TG_TABLE_NAME='wb_claims' THEN + SELECT * INTO STRICT e FROM wo_entries WHERE id=NEW.entry_id AND order_id=NEW.order_id AND version=NEW.entry_version AND origin=NEW.origin; + IF NOT EXISTS(SELECT 1 FROM wb_generations WHERE id=NEW.generation_id AND order_id=NEW.order_id) OR e.outside_authority OR EXISTS(SELECT 1 FROM wo_entries WHERE previous_id=e.id) OR NOT EXISTS(SELECT 1 FROM wo_decisions WHERE entry_id=e.id AND entry_version=e.version AND decision='verified') THEN RAISE EXCEPTION 'SGF_WB_SOURCE'; END IF; + END IF; + RETURN NEW; +END; $$; +CREATE TRIGGER wb_proposal_integrity BEFORE INSERT ON wb_proposals FOR EACH ROW EXECUTE FUNCTION sgf_wb_integrity(); +CREATE TRIGGER wb_review_integrity BEFORE INSERT ON wb_decisions FOR EACH ROW EXECUTE FUNCTION sgf_wb_integrity(); +CREATE TRIGGER wb_generation_integrity BEFORE INSERT ON wb_generations FOR EACH ROW EXECUTE FUNCTION sgf_wb_integrity(); +CREATE TRIGGER wb_claim_integrity BEFORE INSERT ON wb_claims FOR EACH ROW EXECUTE FUNCTION sgf_wb_integrity(); +CREATE OR REPLACE FUNCTION sgf_wb_draft_locked() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF (TG_TABLE_NAME='a_service_drafts' AND EXISTS(SELECT 1 FROM wb_generations WHERE service_draft_id=OLD.id)) OR (TG_TABLE_NAME='a_billing_drafts' AND EXISTS(SELECT 1 FROM wb_generations WHERE billing_draft_id=OLD.id)) THEN RAISE EXCEPTION 'SGF_WB_LINKED_DRAFT_REQUIRES_REVIEWED_CORRECTION'; END IF; + IF TG_OP='UPDATE' THEN RETURN NEW; END IF; + RETURN OLD; +END; $$; +CREATE TRIGGER wb_service_locked BEFORE UPDATE OR DELETE ON a_service_drafts FOR EACH ROW EXECUTE FUNCTION sgf_wb_draft_locked(); +CREATE TRIGGER wb_billing_locked BEFORE UPDATE OR DELETE ON a_billing_drafts FOR EACH ROW EXECUTE FUNCTION sgf_wb_draft_locked(); +CREATE OR REPLACE FUNCTION sgf_wb_no_second_billing() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF EXISTS(SELECT 1 FROM wb_generations WHERE service_draft_id=NEW.source_id) THEN RAISE EXCEPTION 'SGF_WB_SOURCE_ALREADY_CONVERTED'; END IF; + RETURN NEW; +END; $$; +CREATE TRIGGER wb_no_second_billing BEFORE INSERT ON a_billing_drafts FOR EACH ROW EXECUTE FUNCTION sgf_wb_no_second_billing(); diff --git a/docs/WORKSHOP_BILLING_ACCEPTANCE.md b/docs/WORKSHOP_BILLING_ACCEPTANCE.md new file mode 100644 index 0000000..5c9cc8d --- /dev/null +++ b/docs/WORKSHOP_BILLING_ACCEPTANCE.md @@ -0,0 +1,11 @@ +# Aceitação isolada — WORKSHOP-BILLING-DRAFT-SIM-v1 + +28/10/2026, Africa/Johannesburg. O utilizador confirmou «sim» à aceitação expressa desta entrega com a limitação de não haver capturas e à autorização de checkpoint exclusivamente local, sem credenciais ou dados privados. A migração `120000`, seis concessões e utilização real permanecem por autorizar. + +Aceitação limitada a [WORKSHOP_BILLING_DELIVERY.md](WORKSHOP_BILLING_DELIVERY.md) e [âmbito aprovado](WORKSHOP_BILLING_DRAFT_SCOPE.md), exclusivamente sintético em `sgf_tempo_test`. Percurso relatado: ordem #1 → proposta #1 v1 → prestação #8 → rascunho #4, com 2 UN × 75 e 1,75 H × 200 = 500 MZN sem imposto. Origens utilizadas uma vez, revisão comercial distinta, destino ainda rascunho, sem confirmação B1. Custos internos não determinam preços; imposto e contabilidade continuam pendentes. + +Preservada a evidência com limites: regressão completa de 175 testes / 3 094 asserções antes da última guarda de duplicação; depois, 24 testes / 271 asserções afectados e 14 testes / 156 asserções de A aprovados. Cinco provas concorrentes, compilação e verificação do formulário aprovadas. Percurso manual relatado pelo utilizador e reconciliação read-only das origens, decisões e linhas; separação de acessos observada manualmente. Capturas não recebidas/inspeccionadas e automação do Edge não recuperada. A aceitação não transforma estas limitações em provas realizadas. + +Os campos negativos de aceitação/checkpoint nas provas anteriores são históricos e permanecem preservados. As referências anteriores a aceitação pendente são substituídas apenas por esta confirmação. Não se repetiram testes por este registo exclusivamente documental. + +O commit que contém este registo é o checkpoint local autorizado, preservando `04870f0`, `090b7da` e alterações posteriores. Inclui fontes, testes, scripts, documentação e provas sintéticas revistas, sem `.env`/configurações privadas, ficheiros de entrada, palavras-passe ou dados reais. Sem push, publicação, novas concessões ou aplicação de migração. `110000` e as sete concessões do orçamento também continuam separadas e por autorizar. Guardas operacionais/fiscais mantidas; correcção/cancelamento comercial dos destinos gerados continua fora deste recorte. diff --git a/docs/WORKSHOP_BILLING_DELIVERY.md b/docs/WORKSHOP_BILLING_DELIVERY.md new file mode 100644 index 0000000..7124ec4 --- /dev/null +++ b/docs/WORKSHOP_BILLING_DELIVERY.md @@ -0,0 +1,73 @@ +# Oficina → rascunho — entrega técnica em verificação + +**Aceitação posterior de 28/10/2026:** utilizador aceitou a entrega com a limitação de capturas e autorizou checkpoint exclusivamente local. Ver [WORKSHOP_BILLING_ACCEPTANCE.md](WORKSHOP_BILLING_ACCEPTANCE.md). As referências abaixo a aceitação/checkpoint pendentes são históricas; nenhuma prova pendente passa a realizada. `120000`, seis concessões e uso real continuam por autorizar. + +## Percurso manual de geração relatado e reconciliado + +**Conferência manual posterior do destino e dos acessos:** o utilizador enviou o detalhe do rascunho #4, origem prestação #8 v1, moeda MZN, linhas 2 × 75 e 1,75 × 200, base aritmética 500 e pendências fiscais/contabilísticas preservadas. Na consulta como Samira sintética, enviou também o destino sem edição/confirmação e a página específica Oficina → rascunho com proposta revista #1 v1 e geração existente, sem formulário de preparação ou botão de geração. Esta observação conclui os passos manuais pendentes descritos abaixo, segundo os textos enviados pelo utilizador; não equivale a captura inspeccionada ou automação recuperada. Não houve autorização de confirmação B1 ou emissão. Aceitação e checkpoint continuam pendentes. + +Em 28/10/2026, o utilizador enviou os textos da proposta #1 v1, da revisão aprovada e da geração: ordem #1 → proposta #1 v1 → prestação #8 → rascunho de facturação #4. Material #6 v1, 2 UN × 75 = 150 MZN; trabalho #4 v1, 1,75 H × 200 = 350 MZN. Referência `WORKSHOP-SALES-SYNTHETIC-v1`, soma 500 MZN sem imposto. Relato evidencia alternância entre conta revisora sem botão de geração e preparador com botão; confirmação explícita da ausência dos formulários na revisora ainda não foi solicitada novamente como facto concluído. + +Executado `scripts/sgf.ps1 php scripts/workshop-billing-manual-verify.php --env=browser-testing` em consulta read-only: confirmou geração única, duas utilizações canónicas, origens verificadas, versão exacta revista por identidade distinta, linhas do destino correspondentes à proposta, prestação/rascunho em estado draft, 500 MZN, imposto pendente, contabilidade não processada e nenhuma confirmação B1 deste rascunho. Nenhum dado ou credencial alterado. Ver [resultado estruturado](proofs/workshop-billing-manual-results.json). + +Limites: textos enviados pelo utilizador e reconciliação técnica, sem capturas recebidas ou automação recuperada. Consulta visual do rascunho destino e confirmação explícita dos formulários ausentes na revisora continuam separadas; não se presumem aceitação ou checkpoint. As referências abaixo a percurso manual ainda não executado descrevem a preparação histórica. + +## Correcção durante a prova manual — mensagem de validação + +Na tentativa manual seguinte, o utilizador indicou ausência de resposta. Consulta read-only confirmou zero propostas/revisões/gerações: nenhuma submissão foi registada. Corrigida lacuna de feedback: a validação nativa do fundamento podia impedir a execução do handler sem mensagem da aplicação. O formulário passa a usar a validação explícita já testada; erros são trazidos para a zona visível e sucesso informa ID/versão. Falha de recarga após gravação é distinguida de falha da submissão, com instrução de não resubmeter. Substituído o placeholder que podia ser confundido com fonte já preenchida. Não se afirma qual campo concreto faltava no navegador do utilizador. Compilação aprovada; sem alteração de base ou credenciais. Novo script `workshop-billing-status.php` apenas para estado sintético read-only. + +O utilizador encontrou `validation.required` ao submeter. O texto enviado não permitia identificar qual campo faltou; não se presume que a proposta tenha sido criada. Acrescentada validação local das origens seleccionadas, serviço, preço, fonte e fundamento, com mensagens por ID de origem antes de enviar. Servidor passou a fornecer mensagens portuguesas com nomes de campos, mantendo as regras. Não se preenchem preços/serviços automaticamente; zero explícito continua válido. Novo auxiliar `resources/js/workshopBillingProposal.js` e verificação `scripts/workshop-billing-form-check.mjs`, aprovada para omissões, zero e valores exactos. Compilação e sintaxe PHP aprovadas. Base, propostas e credenciais não alteradas; testes destrutivos não repetidos durante a prova manual activa. Sem aceitação/checkpoint. + +**Estado final das verificações técnicas:** regressão completa 175 testes / 3 094 asserções sem falhas; após acrescentar protecção contra segundo rascunho pelo percurso normal, 24 testes / 271 asserções afectados e 14 testes / 156 asserções de A aprovados. Não somar estas execuções como casos distintos. Seis casos novos de conversão, cinco provas concorrentes e compilação aprovados. Ver [resumo exacto](proofs/workshop-billing-tests.json). Menções abaixo à regressão em execução são históricas. Prova manual no navegador ainda pendente, sem aceitação/checkpoint. + +28/10/2026. `WORKSHOP-BILLING-DRAFT-SIM-v1`, [âmbito e decisões](WORKSHOP_BILLING_DRAFT_SCOPE.md), autorizado pelo utilizador após confirmar preços sintéticos. Preservados `04870f0`, `090b7da`, alterações posteriores e entregas aceites. **Sem aceitação presumida ou checkpoint de encerramento.** Nada aplicado em desenvolvimento. + +## Percurso implementado + +Selecção integral de apontamentos actuais de tempo efectivo e materiais consumidos da empresa, com tarefa actual verificada, origem/versionamento e decisão técnica identificados. Recusa entrega/devolução, material do cliente, origem pendente/recusada/obsoleta ou fora de autorização. Só dados da empresa/unidade seleccionadas; consulta comercial não atribui verificação técnica. Quantidades derivam da origem: 105 MIN → 1,75 H; 2 UN consumidas permanecem 2, sem descontar novamente devoluções. + +Preparador liga cada origem a serviço de catálogo por ID/versão, preço comercial e fonte explícitos. Preço desconhecido impede proposta/geração, zero explícito é distinto. Moeda MZN, cliente da ordem conferido, catálogo e cliente versionados. A proposta guarda composição e fundamento/exclusões e é imutável; sucessora explícita requer nova revisão. Identidade distinta aprova/recusa versão exacta. Mudança da origem/tarefa/cadastro/cliente torna proposta desactualizada, sem escolha automática de sucessora ou aprovação herdada. + +Ensaio aprovado como hipótese: `WORKSHOP-SALES-SYNTHETIC-v1`, preços sem imposto de 200 MZN/H e 75 MZN/UN. Soma exacta: 350 + 150 = **500 MZN**. Não são tarifas reais nem derivação do custo interno de 275 MZN; imposto/aplicabilidade continuam pendentes, sem chamar 500 de total fiscal ou quantia exigível. Aritmética exacta; fracção que não caiba no contrato decimal 16/8 do rascunho é recusada, sem arredondamento silencioso. + +Geração reutiliza `ServiceWorkflow` e cria prestação de origem e rascunho de facturação na mesma transacção; a prestação é necessária pelo contrato existente. Não cria duas utilizações técnicas/comerciais. Vínculo único da origem canónica por ordem, geração única por ordem e referências exactas à proposta, apontamentos e versões. Chave idempotente recupera o mesmo destino; conteúdo diferente é conflito. Bloqueio de empresa serializa revisões e geração; auditoria/evento/outbox atómicos, rollback sem rascunhos ou vínculos órfãos. Nenhuma confirmação B1 automática. + +Rascunhos gerados não podem ser editados/apagados directamente no SQL; aplicação responde com 409 ao tentar edição pelo fluxo existente. Correcção/cancelamento comercial não integra este recorte; uma origem utilizada não é libertada por edição ou nova versão técnica. Histórico preservado, sem reescrever fotografias B1. Propostas com origens alteradas ficam sinalizadas; os rascunhos gerados conservam a composição original e exigem circuito próprio de correcção antes de reutilização comercial. + +Também bloqueada a criação de outro rascunho pelo percurso A usando a prestação já convertida: recusa 409 na aplicação e trigger de INSERT no SQL. A primeira geração ocorre atomicamente antes do registo final do vínculo; depois disso, não há caminho alternativo autorizado para reaproveitar essa prestação como segundo destino. Revogação de direitos de criação de destino é verificada também antes de recuperar um replay da geração. + +## Interface e prova + +Novo destino **Oficinas → Oficina → rascunho**, só com `workshop_billing.view`. Atalhos na página inicial por direitos explícitos de preparação/revisão, sem contagens fictícias. Revisor consulta proposta e toma decisão, sem formulário de preparação/geração. Preparador escolhe serviços/preços, submete, depois gera a partir da versão aprovada. Interface mostra IDs de prestação/rascunho, sem validade fiscal e fiscalidade pendente. + +Regressão inicial relevante: **41 testes / 660 asserções aprovados**, incluindo quatro testes iniciais de conversão e regressões de oficinas/custo directo/B1/guardas. Acrescentados casos de sucessão, catálogo alterado e revogação de criação do destino no replay; regressão completa final em execução. Compilação aprovada, Vite 8.3.5, sem alterações de dependências. Cinco cenários concorrentes com processos sobrepostos aprovados em [workshop-billing-concurrency.json](proofs/workshop-billing-concurrency.json): proposta repetida, propostas sucessoras concorrentes, revisão repetida, geração repetida e geração com novas chaves já utilizada. Uma geração, dois vínculos canónicos. + +**Navegador:** a automação do Edge já estava bloqueada, sem nova tentativa ou afirmação de recuperação. Nenhuma captura ou execução manual deste novo percurso é declarada. Preparação para prova manual, quando houver instância de teste vazia sob as guardas e nenhum teste destrutivo em execução: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start -Port 8002 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/workshop-billing-fixture.php --env=testing +``` + +Fixture cria ordem/recursos sintéticos com verificação por técnico/verificador distintos dos papéis comerciais. Não preenche proposta/revisão/geração. Usar contas sintéticas do manifesto protegido fora do repositório; não exibir ou copiar palavras-passe para provas. Preparador selecciona o tempo e consumo, serviços exactos, preços 200/75 e referência acima; revisor entra separadamente e aprova versão exacta; preparador gera prestação/rascunho; conferir 500 MZN, estados pendentes e rastreabilidade no destino. Não correr testes que recriam a base enquanto essa prova estiver activa; parar apenas a instância SGF por `scripts/browser-proof.ps1 stop`. + +Instância manual efectivamente preparada em 8002 após os testes finais, com ambiente confirmado por HTTP em [workshop-billing-environment.json](proofs/workshop-billing-environment.json). Entrada: `gestor@example.test` (preparação/geração) e `consulta@example.test` (revisão), mesmo contexto sintético. Instruções de entrada apenas em `C:\laragon\data\sgf-tempo\secrets\workshop-billing-manual-entry.txt`, fora do repositório, ACL do utilizador local/SYSTEM. Não usar as credenciais anteriores de outro ensaio. Nenhum passo manual foi declarado realizado; a instância permanece ligada para essa prova. Seleccionar o serviço «Serviço comercial exclusivamente sintético» e a sua versão apresentada. O validador comercial não recebeu direitos de verificação técnica. + +## Migração e concessões apenas propostas + +Migração **`2026_10_28_120000_create_workshop_billing_drafts.php`**, SQL `database/sql/workshop-billing.sql`, destino futuro **sgf_tempo_dev / 127.0.0.1:55432**, por caminho exclusivo com `sgf_dev_owner`; aplicação mantém `sgf_dev_app`. Depende da fundação/oficinas e guarda SQL de testes já existentes. Cria sete tabelas: `wb_proposals`, `wb_decisions`, `wb_generations`, `wb_claims`, `wb_commands`, `wb_events`, `wb_outbox`; índices, FKs, triggers de ambiente/imutabilidade, cadeia/revisão, vínculo único e protecção condicional dos rascunhos ligados. Amplia CHECK com quatro nomes de direitos; não atribui concessões. `down()` recusa destruição de história. Guardas da aplicação e INSERT nas sete tabelas mantêm uso restrito a `sgf_tempo_test`/55432; sem opção de activação real. SQL não é RLS nem uma segunda implementação do motor comercial completo. + +Consulta `scripts/workshop-billing-permissions-preview.php` confirmou migração ausente, conversão real recusada e as seis concessões abaixo ausentes. **Não aplicadas**, exigem autorização específica separada. Todas: **tenant #1 / Tempo Lda #1 / SEGI #1**. + +| Conta existente | Direitos propostos | +| --- | --- | +| Osmane #1 | `workshop_billing.view`, `workshop_billing.prepare`, `workshop_billing.generate` | +| Samira #2 | `workshop_billing.view`, `workshop_billing.review`, `workshop.view` | + +`workshop.view` para Samira é consulta técnica necessária à revisão comercial; não inclui `workshop.verify`, apontamentos, supervisão ou gestão de acessos. Os direitos de consulta empresarial de clientes/serviços das duas contas e os de consulta/criação de prestações/rascunhos da conta #1 já existem e foram conferidos; não são reaplicados ou derivados de custos. `110000` e sete concessões do orçamento permanecem separadas e pendentes. + +## Ficheiros e limitações + +Novos: `app/Modules/Workshop/WorkshopBilling.php`, controlador em `Http/WorkshopBillingController.php`, migração/SQL, `resources/js/WorkshopBillingWorkspace.jsx`, testes/fixture `WorkshopBillingTest.php`/`WorkshopBillingFixture.php`, scripts `workshop-billing-fixture.php`, `workshop-billing-worker.php`, `workshop-billing-concurrency.php`, `workshop-billing-permissions-preview.php`, documentação/provas. Integrações no router, navegação/página inicial e protecção de edição em `ServiceWorkflow.php`. + +Uma ordem/cliente/MZN, selecção integral, um destino, cobrança por tempo efectivo e material consumido da empresa. Sem tarefa a preço fixo, repartição parcial, descontos, margens, stock, emissão, cálculos fiscais novos, recebimentos, lançamentos, publicação ou utilização real. Correcção/cancelamento de rascunhos ligados bloqueados até circuito próprio. Prova visual e aceitação continuam separadas; nenhuma pendência é promovida a prova realizada. diff --git a/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md b/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md new file mode 100644 index 0000000..6cf8c76 --- /dev/null +++ b/docs/WORKSHOP_BILLING_DRAFT_SCOPE.md @@ -0,0 +1,98 @@ +# Oficina → rascunho de facturação — especificação para decisão + +**Autorização técnica posterior de 28/10/2026:** o utilizador autorizou implementar e demonstrar `WORKSHOP-BILLING-DRAFT-SIM-v1` exclusivamente em `sgf_tempo_test`, sem aplicar migrações/concessões em desenvolvimento, activar uso real ou publicar. A autorização substitui apenas as restrições anteriores de etapa documental; decisões comerciais e limites permanecem. Ver [entrega e estado das provas](WORKSHOP_BILLING_DELIVERY.md). Não presume aceitação ou checkpoint. + +28/10/2026. Próximo recorte proposto: `WORKSHOP-BILLING-DRAFT-SIM-v1`. Nesta etapa **apenas documentação**: sem implementação, migrações, concessões, alteração de operações reais ou publicação. Preservar `04870f0` (orçamento aceite com limitação de capturas), `090b7da` (página inicial/menu com revisão visual pendente) e anteriores. Sem reabrir as respectivas aprovações ou transformar provas pendentes em realizadas. + +## 1. Objectivo e menor incremento proposto + +Numa futura prova exclusivamente sintética em `sgf_tempo_test`, seleccionar numa ordem os recursos elegíveis já verificados, preparar a sua conversão comercial, rever preços e gerar **um novo rascunho de facturação rastreável** na mesma empresa/unidade. Não voltar a digitar consumos/tempos, não transformar ordem em produção industrial e não criar um segundo acontecimento técnico para conseguir facturar. + +Primeiro recorte recomendado: uma ordem, um cliente explicitamente confirmado, uma unidade, moeda de venda explícita, selecção integral de cada origem elegível, sem repartição parcial entre rascunhos. Prestações e facturação permanecem rascunhos; não confirmar internamente B1 automaticamente, emitir documentos, calcular/aprovar fiscalidade por consequência, receber ou contabilizar. Equipamentos e contratos reais não são criados como preparação deste ensaio. + +## 2. Origem técnica e decisão de facturabilidade + +Seleccionar IDs/versões actuais da ordem, tarefa e apontamento, autor, decisão de verificação e verificador distintos, quantidades/unidades e período de execução. Não seleccionar por descrição, último registo ou nome de técnico. Consulta da ordem exige direitos técnicos próprios; converter para rascunho exige direitos comerciais separados. Verificação técnica reconhece execução elegível; **não aprova cobrança ou preço**. + +| Origem | Proposta para o primeiro recorte | +| --- | --- | +| Trabalho efectivo verificado ligado à tarefa | Elegível apenas após decisão comercial explícita: ligação a serviço de catálogo e forma de cobrança aprovada | +| Material consumido verificado, propriedade da empresa | Elegível se comercialmente facturável, com descrição e unidade de venda compatíveis | +| Material do cliente | Excluído da cobrança de material por omissão; não facturar propriedade do cliente. Serviço de aplicação, se acordado, é origem comercial distinta, sem inventar consumo | +| Material requerido/entregue/devolvido | Não equivale a consumo facturável. Não somar entrega e consumo; não descontar novamente devolução de um consumo já líquido | +| Origem pendente, recusada, obsoleta ou ocorrência fora de autorização apenas reconhecida | Não elegível. Não promover reconhecimento de auditoria a verificação válida | +| Custo directo, orçamento, resultado industrial ou residual CIF | Não são preço, quantidade facturável ou nova origem técnica | + +Proposta: tarefa a preço fixo **ou** tempo facturável derivado dos apontamentos seleccionados, nunca ambos para a mesma parcela. Para não misturar bases, começar por **tempo efectivo verificado × tarifa comercial** e material consumido verificado × preço comercial por unidade; preço fixo por tarefa fica como alternativa a confirmar antes da execução. Horas humanas não se confundem com HM de máquina. Não acrescentar deslocação, mínimos, descontos, margens, IVA ou despesas sem âmbito e dados próprios. + +Excluir uma origem exige motivo visível, não apagamento. A revisão mostra a lista seleccionada, as exclusões e a diferença entre recurso executado e parcela comercial. Sem cobrança automática de todos os recursos só por estarem verificados. + +## 3. Preços de venda e compatibilidade + +Proposta: preços de venda introduzidos/seleccionados explicitamente pelo preparador a partir de uma referência comercial identificada e versionada; revisor confirma preço, unidade, moeda, data de referência e condições. Não usar taxa interna de material/trabalho, custo industrial ou preço de outra versão/cliente por omissão. Não adoptar tabela de preços, moeda ou imposto por suposição. + +Manter cadeias decimais exactas do contrato A (até 16 inteiros/8 decimais), sem float ou truncagem fiscal. MIN/H conserva conversão exacta. Se quantidade comercial ou preço necessário não for exactamente representável pelo contrato do rascunho, bloquear a conversão e pedir decisão explícita; não arredondar silenciosamente. Preço zero explícito e revisto é distinto de preço desconhecido. Enquanto preço, cliente ou ligação comercial estiverem pendentes, a proposta pode ser revista, mas **não gerar um rascunho com zero fictício ou omissão silenciosa de linhas**. + +Conferir empresa/unidade, cliente, serviço seleccionado, moeda, unidade física/de venda, quantidade, período e regra de cobrança. A ordem não prova por si a identidade fiscal do cliente. A ligação de cliente/serviço é explícita; criação ou correcção de cadastro permanece no circuito existente, sem concessão implícita. Compatibilidade fiscal/aplicabilidade mantém pendências próprias; gerar rascunho não resolve exigibilidade, apuramento ou emissão. + +## 4. Revisão e responsabilidades propostas — por confirmar + +**Proposta funcional:** Osmane selecciona recursos, prepara ligações comerciais e preços; Samira revê/valida ou recusa a versão exacta da proposta comercial por identidade distinta. Não atribui a Samira verificação técnica, gestão de acessos ou edição dos apontamentos. A geração é feita pelo preparador a partir da proposta revista, sem alterar as parcelas aprovadas; se mudar selecção, quantidade, preço, cliente, serviço ou moeda, criar sucessora e exigir nova revisão. Provas futuras com equivalentes sintéticos, sem contas ou concessões reais novas. + +Estados propostos: proposta preparada/submetida → revista ou recusada → geração do rascunho. Submissão e decisões imutáveis, com sucessora e motivo explícitos. Geração não executa uma segunda aprovação fiscal nem confirmação B1. A validação comercial desta proposta é uma decisão nova e necessita confirmação do utilizador; não decorre dos papéis aprovados para custos/orçamento. + +## 5. Rastreabilidade e não duplicação + +Cada linha gerada conserva ordem/tarefa/apontamento por ID/versão, decisão de verificação, quantidade/unidade seleccionada, conversão comercial, serviço/cadastro exacto, preço/fonte/versão, proposta/revisão e autor. Pode apresentar descrição legível da ficha, sem perder as referências. Uma linha pode agregar origens só se a composição exacta permanecer recuperável e não misturar unidades/preços/regras incompatíveis; para o primeiro recorte, recomendar uma linha por parcela comercial. + +Registar a utilização comercial da **identidade canónica da origem**, não só do ID da sua versão. Uma correcção técnica não permite voltar a facturar a mesma parcela com novo ID. A utilização comercial não é consumo de stock ou nova imputação de custos. Não duplicar a mesma parcela em prestação e factura como se fossem duas utilizações; o vínculo acompanha a origem até ao rascunho destino único. + +Proposta, revisão, vínculo à origem, linhas do rascunho, auditoria e evento/outbox são atómicos. Chave de idempotência inclui contexto, autor, acção e conteúdo; repetição recupera o mesmo rascunho, conteúdo diferente é conflito. Duas selecções concorrentes da mesma origem só permitem um destino activo; falha ou perda de resposta não deixa reserva órfã nem cria duplicados. + +Primeiro recorte sem facturação parcial, repartição entre clientes ou vários destinos. Correcção após geração conserva rascunho e referências originais; assinala proposta/rascunho desactualizados e bloqueia nova geração/uso da proposta antiga. **Não libertar a origem automaticamente** por editar/apagar um rascunho. Substituição/cancelamento comercial requer operação explícita, auditada e compatível com o estado do destino; se essa operação não integrar o recorte, a origem permanece bloqueada e o caso é remetido a revisão. Fotografia B1 já confirmada nunca é reescrita; geração não contorna correcções ou bloqueios de emissão. + +## 6. Critérios para futura entrega + +- Demonstrar uma ordem com trabalho e material verificados, ligações comerciais explícitas e rascunho com composição recuperável, sem reintroduzir quantidades técnicas. +- Demonstrar que preço comercial pode diferir do custo interno, sem conversão automática, e que preço desconhecido impede gerar, sem virar zero. Caso zero explícito exige decisão própria. +- Recusar material do cliente, apontamento não verificado/recusado/obsoleto e ocorrência apenas reconhecida; preservar motivos e histórico. +- Recusar cobrança simultânea da tarefa fixa e dos mesmos tempos, dupla selecção, versão corrigida como nova cobrança ou dois destinos concorrentes. Entregas/devoluções não viram consumo comercial por inferência. +- Refazer proposta materialmente alterada com nova versão/revisão, sem herdar aprovação; preservar origem e rascunho anteriores. Cliente/moeda/unidade/contexto incompatíveis recusados. +- Verificar separação de identidades e permissões no servidor, contexto revisto, revogação no pedido seguinte e isolamento entre empresas/unidades; menus não são prova de autorização. +- Demonstrar idempotência, concorrência, rollback de evento/outbox e recuperação de resposta, sem vínculos/reservas órfãos. +- Regressões relevantes de oficinas, rascunhos/B1, custos e guardas; compilação e demonstração isolada com evidência e limitações explícitas. Sem emissão real, stock, recebimentos, lançamentos ou aceitação presumida. + +## 7. Acessos e decisões novas antes de implementar + +Propor direitos próprios de unidade para consulta/preparação/revisão/geração da conversão, além dos direitos de consulta às origens e de criação do rascunho destino. Não derivar de `workshop.verify`, `direct_costs.*`, `industrial_budgets.*` ou nomes. Nomes técnicos, contas e número de concessões só serão propostos concretamente depois de fechar o fluxo; nenhuma concessão autorizada aqui. Migração futura também separada, com destino e guardas explícitos antes de aplicação. + +Decisões novas para o utilizador: + +1. Confirmar facturabilidade: tempo verificado e material consumido da empresa, com material do cliente excluído, **ou** escolher tarefa a preço fixo em vez do tempo para a mesma parcela. +2. Confirmar fonte/forma de aprovação dos preços comerciais e moeda do ensaio; custo interno nunca determina preço automaticamente. +3. Confirmar Osmane como preparador/gerador e Samira como revisora comercial distinta, sem supervisão técnica implícita. +4. Confirmar primeiro recorte com selecção integral por origem, um rascunho por proposta e sem repartição parcial, mantendo casos posteriores bloqueados até correcção/cancelamento próprios. + +Estas propostas não são decisões já acordadas nem autorização de implementação. A primeira geração no sistema e a activação operacional continuam fora desta etapa documental. + +## 8. Confirmação posterior e proposta concreta de preços do ensaio + +Em 28/10/2026, o utilizador respondeu «sim prossiga» às quatro opções apresentadas: tempo verificado e material consumido da empresa, excluindo material do cliente e dupla cobrança de tarefa/tempo; preços comerciais explícitos com fonte identificada e revisão distinta, sem derivação do custo; Osmane prepara/gera e Samira revê a versão comercial; origens integrais, uma ordem e um rascunho por proposta, sem repartição parcial. Estas decisões substituem as respectivas pendências da secção 7. Não constituem concessões reais nem autorização de utilização operacional. + +A fonte concreta dos preços, os valores e a moeda não tinham sido fornecidos. Para fechar esse ponto sem usar preços reais ou inferir valores a partir do custo, fica proposta para validação a referência **`WORKSHOP-SALES-SYNTHETIC-v1`**, tabela exclusivamente sintética, moeda **MZN**, preços sem imposto: + +| Parcela comercial | Quantidade de referência técnica | Preço de venda sintético proposto | Montante sem imposto | +| --- | --- | --- | ---: | +| Trabalho efectivo verificado | 105 MIN = 1,75 H | 200 MZN/H | 350 MZN | +| Material da empresa consumido e verificado | 2 UN | 75 MZN/UN | 150 MZN | +| **Soma comercial do exemplo** | Mesma ordem sintética | Sem cálculo fiscal | **500 MZN** | + +Valores **ainda não aprovados**. Não são tarifas do utilizador, preços de catálogo real, margens ou substitutos das taxas internas de custo. As origens técnicas do exemplo são as do percurso de oficina, reutilizadas por ID/versão, sem copiar consumos nem usar o resultado agregado de custo como origem comercial. Em futura prova, a referência e os preços exactos entram na proposta de conversão e só podem gerar rascunho depois da revisão comercial distinta. O cliente, serviços de catálogo e descrições devem ser sintéticos e explicitamente ligados, sem criar contas ou dados reais. IVA/aplicabilidade permanecem pendências separadas; 500 MZN é apenas soma de linhas sem imposto, não total fiscal ou montante exigível. + +Próxima validação necessária: confirmar ou substituir a referência sintética, **MZN**, **200 MZN/H** e **75 MZN/UN**. Nenhum código, migração ou concessão implementado por esta actualização documental. + +### 8.1 Aprovação dos valores pelo utilizador + +Em 28/10/2026, o utilizador confirmou «sim» à proposta concreta acima. Ficam aprovados, **exclusivamente como pressupostos sintéticos do ensaio**, a referência `WORKSHOP-SALES-SYNTHETIC-v1`, moeda MZN e preços de venda sem imposto de 200 MZN/H e 75 MZN/UN: 105 MIN = 1,75 H → 350 MZN; 2 UN → 150 MZN; soma comercial 500 MZN. A indicação de valores por aprovar na secção anterior passa a histórica. Não se adopta política comercial real, preço derivado de custo ou tratamento fiscal. + +Fechadas as decisões de facturabilidade, responsabilidades, selecção integral e base comercial deste recorte. Cada futura proposta continua a exigir revisão distinta da versão exacta antes da geração; a confirmação do desenho não constitui aprovação automática de registos no sistema. Preservar todos os checkpoints e bloqueios. Nesta etapa continua apenas a especificação: sem código, migrações, concessões, emissão, stock, lançamentos, publicação ou utilização real. Uma autorização técnica de implementação deverá delimitar provas sintéticas, direitos próprios e proposta separada de qualquer instalação em desenvolvimento. diff --git a/docs/proofs/workshop-billing-concurrency.json b/docs/proofs/workshop-billing-concurrency.json new file mode 100644 index 0000000..e917d9a --- /dev/null +++ b/docs/proofs/workshop-billing-concurrency.json @@ -0,0 +1,13 @@ +{ + "policy": "WORKSHOP-BILLING-DRAFT-SIM-v1", + "database": "sgf_tempo_test", + "overlapping_processes_per_case": 2, + "checks": { + "proposal_replay_one_version": true, + "competing_proposals_one_successor": true, + "review_replay_one_decision": true, + "generation_replay_one_draft": true, + "new_keys_cannot_duplicate_generation": true + }, + "one_generation_two_origin_claims": true +} \ No newline at end of file diff --git a/docs/proofs/workshop-billing-environment.json b/docs/proofs/workshop-billing-environment.json new file mode 100644 index 0000000..bd8cf7a --- /dev/null +++ b/docs/proofs/workshop-billing-environment.json @@ -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 +} diff --git a/docs/proofs/workshop-billing-manual-results.json b/docs/proofs/workshop-billing-manual-results.json new file mode 100644 index 0000000..b1a88a9 --- /dev/null +++ b/docs/proofs/workshop-billing-manual-results.json @@ -0,0 +1,27 @@ +{ + "policy": "WORKSHOP-BILLING-DRAFT-SIM-v1", + "date": "2026-10-28", + "database": "sgf_tempo_test", + "method": "user_reported_manual_browser_and_read_only_reconciliation", + "order_id": 1, + "proposal_id": 1, + "proposal_version": 1, + "service_draft_id": 8, + "billing_draft_id": 4, + "commercial_base_exact": "500", + "currency": "MZN", + "source_claims": 2, + "distinct_commercial_review": true, + "generated_lines_match_reviewed_proposal": true, + "internal_confirmation_created": false, + "fiscal_status": "pending", + "accounting_status": "not_processed", + "manual_user_reported_destination_lines_and_500_base": true, + "manual_user_reported_reviewer_conversion_without_prepare_or_generate_controls": true, + "manual_user_reported_reviewer_destination_read_only": true, + "screenshots_received": false, + "automated_browser_recovered": false, + "records_changed": false, + "user_acceptance": false, + "closing_checkpoint_created": false +} diff --git a/docs/proofs/workshop-billing-tests.json b/docs/proofs/workshop-billing-tests.json new file mode 100644 index 0000000..c7eb689 --- /dev/null +++ b/docs/proofs/workshop-billing-tests.json @@ -0,0 +1,17 @@ +{ + "policy":"WORKSHOP-BILLING-DRAFT-SIM-v1", + "date":"2026-10-28", + "database":"sgf_tempo_test", + "full_regression_before_final_duplicate_route_guard":{"tests":175,"assertions":3094,"errors":0,"failures":0}, + "final_targeted_after_guard":{"tests":24,"assertions":271,"errors":0,"failures":0}, + "final_services_a_after_guard":{"tests":14,"assertions":156,"errors":0,"failures":0}, + "new_workshop_billing_cases":6, + "overlapping_process_cases":5, + "build":{"vite":"8.3.5","passed":true,"dependencies_changed":false}, + "manual_browser":{"port":8002,"fixture_prepared":true,"environment_checked_by_http":true,"user_path_completed":false,"screenshots_received":false,"automatic_edge_retried":false}, + "development_migration_120000_applied":false, + "new_real_grants_applied":false, + "real_conversion_blocked":true, + "user_acceptance":false, + "closing_checkpoint_created":false +} diff --git a/resources/js/WorkshopBillingWorkspace.jsx b/resources/js/WorkshopBillingWorkspace.jsx new file mode 100644 index 0000000..bdabc72 --- /dev/null +++ b/resources/js/WorkshopBillingWorkspace.jsx @@ -0,0 +1,14 @@ +import React,{useEffect,useRef,useState} from 'react'; +import {workshopBillingProposal} from './workshopBillingProposal.js'; +export default function WorkshopBillingWorkspace({context,can,api,busy}){ + const [data,setData]=useState(null),[orderId,setOrderId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false),[notice,setNotice]=useState('');const retry=useRef(null);const feedback=useRef(null); + const allowed=r=>can(`workshop_billing.${r}`,context.unit?.id??null);const load=async()=>setData(await api('/api/workshop-billing')); + useEffect(()=>{setData(null);setOrderId('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]); + const order=data?.orders.find(o=>String(o.id)===orderId)??data?.orders[0],disabled=busy||working; + useEffect(()=>{if(error||notice)feedback.current?.scrollIntoView({behavior:'auto',block:'center'});},[error,notice]); + async function send(action,input){setWorking(true);setError('');setNotice('');const path=`/api/workshop-billing/orders/${order.id}/${action}`,signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};try{const result=await api(path,'POST',input,context.token,retry.current.key);retry.current=null;setNotice(action==='proposals'?`Proposta #${result.id} · v${result.version} registada. Aguarda revisão distinta.`:action==='reviews'?'Revisão comercial registada.':'Rascunho gerado.');try{await load();}catch{setError('O registo foi guardado, mas a lista não actualizou. Use Actualizar versões explicitamente; não volte a submeter.');}}catch(e){setError(e.message);}finally{setWorking(false);}} + return

WORKSHOP-BILLING-DRAFT-SIM-v1

Oficina → rascunho de facturação

ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Preços comerciais explícitos; custo interno não determina preço de venda. Sem emissão ou confirmação automática.
{notice&&

{notice}

}{error&&

{error}

}
{!data?

A carregar…

:data.blocked?

Conversão real bloqueada nesta instância.

:<>{order?<>

Cliente #{order.client_id} · MZN explícito · Seleccionar origens integrais verificadas.

+ {allowed('prepare')&&!order.generation&&
{e.preventDefault();try{const input=workshopBillingProposal(order,data.services,new FormData(e.currentTarget));send("proposals",input);}catch(error){setError(error.message);}}}>

Seleccionar e preparar proposta comercial

{order.entries.map(r=>
Origem #{r.id} v{r.version} · {r.kind==='time'?`${r.minutes} MIN verificados`:`${r.quantity} ${r.data.unit} consumidos — ${r.data.resource}`}
)}
} + {order.proposals.map(p=>

Proposta #{p.id} · v{p.version}

{p.current?'Versão actual':'HISTÓRICO / DESACTUALIZADA'} · {p.decision?`${p.decision.decision==='approved'?'Revista e aprovada':'Recusada'} — ${p.decision.reason}`:'Aguarda revisão distinta'}

{p.snapshot.evidence}

{p.snapshot.lines.map(l=>)}
Origem exactaServiço / versãoQuantidade comercialPreço MZNFonte
#{l.entry_id} v{l.entry_version}#{l.service_id} v{l.service_version}{l.quantity} {l.unit}{l.unit_price}{l.price_source}

Soma sem imposto: {p.snapshot.base_exact} MZN · Fiscalidade pendente.

{allowed('review')&&p.current&&!p.decision&&
{e.preventDefault();const f=new FormData(e.currentTarget);send('reviews',{id:p.id,version:p.version,decision:f.get('decision'),reason:f.get('reason')});}}>
}{allowed('generate')&&p.current&&p.decision?.decision==='approved'&&!order.generation&&}
)} + {order.generation&&

Rascunho gerado — sem validade fiscal

Prestação #{order.generation.service_draft_id} · Rascunho de facturação #{order.generation.billing_draft_id} · Proposta #{order.generation.proposal_id}

Origens utilizadas uma única vez. Edição directa bloqueada; correcção exige circuito próprio, fora deste recorte.

}:

Sem ordens autorizadas neste contexto.

}}
; +} diff --git a/resources/js/WorkspaceHome.jsx b/resources/js/WorkspaceHome.jsx index 1496b72..472a64c 100644 --- a/resources/js/WorkspaceHome.jsx +++ b/resources/js/WorkspaceHome.jsx @@ -5,12 +5,14 @@ export default function WorkspaceHome({context,can,groups,navigate}) { const available=groups.flatMap(g=>g.items); const links=(rules)=>rules.filter(([,permission,scope])=>can(permission,scope)).map(([id,,,label])=>({id,label:label??available.find(i=>i.id===id)?.label})).filter(i=>available.some(a=>a.id===i.id)); const prepare=links([ + ['workshop_billing','workshop_billing.prepare',unit], ['clients','clients.create',null],['services','services.create',null],['service_drafts','service_drafts.create',unit],['billing_drafts','billing_drafts.create',unit], ['documents','issuer_documents.edit',null],['documents','client_documents.edit',null],['documents','draft_documents.edit',unit], ['catalogue_accounting','catalogue_accounting.propose',null],['service_fiscal','service_fiscal.propose',null], ['workshop','workshop.open',unit],['direct_costs','direct_costs.prepare',unit],['industrial_costs','industrial_costs.prepare',unit],['industrial_budgets','industrial_budgets.prepare',unit], ]); const validate=links([ + ['workshop_billing','workshop_billing.review',unit], ['catalogue_accounting','catalogue_accounting.approve',null],['service_fiscal','service_fiscal.approve',null],['service_invoices','invoice_series.approve',null], ['service_receivables','service_receivables.confirm',unit],['service_receivables','service_receivables.correction_approve',unit],['workshop','workshop.verify',unit],['direct_costs','direct_costs.validate',unit],['industrial_costs','industrial_costs.validate',unit],['industrial_budgets','industrial_budgets.validate',unit], ]); diff --git a/resources/js/WorkspaceNavigation.jsx b/resources/js/WorkspaceNavigation.jsx index 3b819d5..2971102 100644 --- a/resources/js/WorkspaceNavigation.jsx +++ b/resources/js/WorkspaceNavigation.jsx @@ -7,7 +7,7 @@ export function navigationGroups(can, unit) { {id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]}, {id:'billing',label:'Facturação',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit),item('service_invoices','Documentos e séries de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]}, {id:'receivables',label:'Recebimentos',items:[item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit)]}, - {id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit)]}, + {id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)]}, {id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]}, {id:'accounting',label:'Contabilidade e fiscalidade',items:[item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]}, {id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('access','Acessos','access.manage')]}, diff --git a/resources/js/app.jsx b/resources/js/app.jsx index 5d49dbb..0334f88 100644 --- a/resources/js/app.jsx +++ b/resources/js/app.jsx @@ -15,6 +15,7 @@ import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx'; import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx'; import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx'; import WorkspaceHome from './WorkspaceHome.jsx'; +import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.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' }; @@ -68,6 +69,7 @@ function App() {

Apenas os contextos com acesso explícito são apresentados.

{!state.companies.length ?

Sem empresas autorizadas

A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.

: !context ?

Escolha onde vai trabalhar

Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.

:
{page === 'home' && } + {page === 'workshop_billing' && } {page === 'company' &&

DADOS BÁSICOS

Empresa

perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') &&

O seu acesso permite consulta. A edição exige uma concessão própria.

}
} {page === 'units' && <>

DADOS BÁSICOS

Unidades autorizadas

{!context.units.length &&

Não tem consulta concedida a nenhuma unidade desta empresa.

}
    {context.units.map(u =>
  • {u.name}
  • )}
{context.unit && perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}
{can('unit.create') &&

Criar unidade

A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.

perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} />
}} {serviceAreas[page] && { setASelection({area,id}); setPage(area); }} />} diff --git a/resources/js/workshopBillingProposal.js b/resources/js/workshopBillingProposal.js new file mode 100644 index 0000000..aad750a --- /dev/null +++ b/resources/js/workshopBillingProposal.js @@ -0,0 +1,18 @@ +// Validate only the user's selected origins; do not supply prices or services by default. +export function workshopBillingProposal(order, services, form) { + const selected=order.entries.filter(r=>form.get(`select-${r.id}`)==='on'); + if(!selected.length)throw new Error('Marque «Incluir origem integral» em pelo menos uma origem verificada.'); + const lines=selected.map(r=>{ + const service=services.find(s=>String(s.id)===form.get(`service-${r.id}`)); + if(!service)throw new Error(`Origem #${r.id}: seleccione o serviço comercial na lista.`); + const price=String(form.get(`price-${r.id}`)??'').trim(); + if(!price)throw new Error(`Origem #${r.id}: preencha o preço comercial.`); + const source=String(form.get(`source-${r.id}`)??'').trim(); + if(source.length<3)throw new Error(`Origem #${r.id}: preencha a fonte comercial e versão (mínimo 3 caracteres).`); + return {entry_id:r.id,entry_version:r.version,service_id:service.id,service_version:service.version,price,price_source:source}; + }); + const evidence=String(form.get('evidence')??'').trim(); + if(evidence.length<3)throw new Error('Preencha o fundamento da proposta (mínimo 3 caracteres).'); + const last=order.proposals.at(-1); + return {version:(last?.version??0)+1,previous_id:last?.id??null,client_id:order.client_id,currency:'MZN',lines,evidence}; +} diff --git a/routes/web.php b/routes/web.php index 7c357d1..5811d84 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,10 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void Route::post('/api/logout', [IdentityController::class, 'logout']); Route::post('/api/context', [IdentityController::class, 'select']); Route::middleware(RequireCompanyContext::class)->group(function (): void { + Route::get('/api/workshop-billing', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'index']); + Route::post('/api/workshop-billing/orders/{id}/proposals', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'prepare'])->whereNumber('id'); + Route::post('/api/workshop-billing/orders/{id}/reviews', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'review'])->whereNumber('id'); + Route::post('/api/workshop-billing/orders/{id}/generations', [\App\Modules\Workshop\Http\WorkshopBillingController::class,'generate'])->whereNumber('id'); 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']); diff --git a/scripts/workshop-billing-concurrency.php b/scripts/workshop-billing-concurrency.php new file mode 100644 index 0000000..7251a35 --- /dev/null +++ b/scripts/workshop-billing-concurrency.php @@ -0,0 +1,16 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use App\Modules\Workshop\WorkshopBilling; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; +use Symfony\Component\Process\Process; +WorkshopBilling::guard();$base=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);$f=$base+$base['workshop_billing'];$scope=['tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'order'=>$f['order']];$key=fn()=>(string)Str::uuid();$checks=[]; +$pair=function($one,$two)use($scope){$a=new Process([PHP_BINARY,'scripts/workshop-billing-worker.php','--env=testing'],base_path(),null,json_encode($one+$scope+['pause'=>true]));$b=new Process([PHP_BINARY,'scripts/workshop-billing-worker.php','--env=testing'],base_path(),null,json_encode($two+$scope));$a->start();if(!$a->waitUntil(fn($type,$v)=>str_contains($v,'LOCK_HELD')))throw new RuntimeException('Concurrent lock not observed.');$b->start();$a->wait();$b->wait();$out=[];foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($s)=>str_starts_with($s,'{')));$out[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);}return $out;}; +$check=function($out,$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 result.');return $out[0]['result']['id'];}; +$p=['actor'=>$f['manager_id'],'method'=>'prepare','key'=>$key(),'input'=>\Tests\Support\WorkshopBillingFixture::proposal($f)];$id=$check($pair($p,$p),true);$checks['proposal_replay_one_version']=true; +$p['key']=$key();$p['input']['version']=2;$p['input']['previous_id']=$id;$other=$p;$other['key']=$key();$other['input']['lines'][0]['price']='210';$id=$check($pair($p,$other),false);$checks['competing_proposals_one_successor']=true; +$r=['actor'=>$f['reader_id'],'method'=>'review','key'=>$key(),'input'=>['id'=>$id,'version'=>2,'decision'=>'approved','reason'=>'Revisão comercial concorrente distinta.']];$check($pair($r,$r),true);$checks['review_replay_one_decision']=true; +$g=['actor'=>$f['manager_id'],'method'=>'generate','key'=>$key(),'input'=>['id'=>$id,'version'=>2]];$gid=$check($pair($g,$g),true);$checks['generation_replay_one_draft']=true; +$g['key']=$key();$out=$pair($g,$g);if($out[0]['status']!==409||$out[1]['status']!==409)throw new RuntimeException('Duplicate generation not blocked.');$checks['new_keys_cannot_duplicate_generation']=true; +if(DB::table('wb_generations')->where('order_id',$f['order'])->count()!==1||DB::table('wb_claims')->where('order_id',$f['order'])->count()!==2)throw new RuntimeException('Generation or origin claims duplicated.'); +file_put_contents(base_path('docs/proofs/workshop-billing-concurrency.json'),json_encode(['policy'=>WorkshopBilling::POLICY,'database'=>'sgf_tempo_test','overlapping_processes_per_case'=>2,'checks'=>$checks,'one_generation_two_origin_claims'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Five overlapping commercial conversion cases passed; one generation and two origin claims.\n"; diff --git a/scripts/workshop-billing-entry-check.php b/scripts/workshop-billing-entry-check.php new file mode 100644 index 0000000..1509446 --- /dev/null +++ b/scripts/workshop-billing-entry-check.php @@ -0,0 +1,13 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +\App\Modules\Workshop\WorkshopBilling::guard(); +$identity=\Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS db,current_user AS role'); +if($identity->db!=='sgf_tempo_test'||$identity->role!=='sgf_test_runner')throw new RuntimeException('Wrong database; no accounts changed.'); +$path='C:/laragon/data/sgf-tempo/secrets/workshop-billing-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('/Temporary synthetic password for these two accounts: ([^\r\n]+)/',$content,$match))throw new RuntimeException('Unexpected entry format; no accounts changed.'); +$checks=[];foreach(['gestor@example.test','consulta@example.test'] as $email){$u=\App\Models\User::where('email',$email)->first();$checks[]=['account'=>$email,'exists'=>(bool)$u,'entry_matches'=>$u&&\Illuminate\Support\Facades\Hash::check($match[1],$u->password)];} +unset($content,$match); +echo json_encode(['path'=>realpath($path),'database'=>$identity->db,'accounts'=>$checks,'credentials_changed'=>false],JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/workshop-billing-fixture.php b/scripts/workshop-billing-fixture.php new file mode 100644 index 0000000..198d310 --- /dev/null +++ b/scripts/workshop-billing-fixture.php @@ -0,0 +1,10 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +\App\Modules\Workshop\WorkshopBilling::guard();$path='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';$base=json_decode(file_get_contents($path),true,32,JSON_THROW_ON_ERROR); +if(\Illuminate\Support\Facades\DB::table('wo_orders')->exists())throw new RuntimeException('Empty workshop proof required; nothing deleted.'); +$f=\Tests\Support\WorkshopBillingFixture::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','task','auth','time','material','day','service','client'])); +file_put_contents($path,json_encode($base+['workshop_billing'=>$safe],JSON_THROW_ON_ERROR)); +file_put_contents('C:/laragon/data/sgf-tempo/secrets/workshop-billing-manual-entry.txt',"Synthetic SGF proof only - http://127.0.0.1:8002\r\nPreparer: gestor@example.test\r\nReviewer: consulta@example.test\r\nTemporary synthetic password for these two accounts: ".$base['password']."\r\nDo not paste this password into chat, screenshots or reports.\r\n"); +// Existing protected manifest is kept outside Git; no credentials printed. +echo "Synthetic workshop sources verified by a technical identity, commercial roles separate. No proposal, review or generation prefilled.\n"; diff --git a/scripts/workshop-billing-form-check.mjs b/scripts/workshop-billing-form-check.mjs new file mode 100644 index 0000000..c45016c --- /dev/null +++ b/scripts/workshop-billing-form-check.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import {workshopBillingProposal} from '../resources/js/workshopBillingProposal.js'; +const order={client_id:16,entries:[{id:6,version:1},{id:4,version:1}],proposals:[]},services=[{id:9,version:1}]; +const form=new FormData(); +assert.throws(()=>workshopBillingProposal(order,services,form),/Incluir origem integral/); +form.set('select-6','on'); +assert.throws(()=>workshopBillingProposal(order,services,form),/Origem #6: seleccione o serviço/); +form.set('service-6','9'); +assert.throws(()=>workshopBillingProposal(order,services,form),/Origem #6: preencha o preço/); +form.set('price-6','0'); +assert.throws(()=>workshopBillingProposal(order,services,form),/fonte comercial/); +form.set('source-6','WORKSHOP-SALES-SYNTHETIC-v1'); +assert.throws(()=>workshopBillingProposal(order,services,form),/fundamento/); +form.set('evidence','Proposta sintética'); +const zero=workshopBillingProposal(order,services,form);assert.equal(zero.lines[0].price,'0');assert.equal(zero.lines.length,1); +form.set('select-4','on');form.set('service-4','9');form.set('price-4','200');form.set('source-4','WORKSHOP-SALES-SYNTHETIC-v1');form.set('price-6','75'); +const proposal=workshopBillingProposal(order,services,form);assert.deepEqual(proposal.lines.map(l=>l.price),['75','200']);assert.equal(proposal.previous_id,null);assert.equal(proposal.version,1); +console.log('Workshop form checks passed: missing selection/service/price/source/evidence; explicit zero; exact selected values. No database or browser used.'); diff --git a/scripts/workshop-billing-manual-verify.php b/scripts/workshop-billing-manual-verify.php new file mode 100644 index 0000000..557b9d8 --- /dev/null +++ b/scripts/workshop-billing-manual-verify.php @@ -0,0 +1,33 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use Illuminate\Support\Facades\DB; +use App\Modules\Workshop\WorkshopBilling; +use App\Modules\Services\DraftValues; +function requireWorkshopManual(bool $ok,string $reason): void {if(!$ok)throw new RuntimeException($reason);} +WorkshopBilling::guard(); +$identity=DB::selectOne('SELECT current_database() AS db,current_user AS role'); +requireWorkshopManual($identity->db==='sgf_tempo_test'&&$identity->role==='sgf_test_runner','Unexpected database.'); +$generations=DB::table('wb_generations')->get();requireWorkshopManual($generations->count()===1,'Exactly one synthetic generation required.');$g=$generations->first(); +$p=DB::table('wb_proposals')->find($g->proposal_id);$review=DB::table('wb_decisions')->where(['proposal_id'=>$p->id,'version'=>$p->version,'decision'=>'approved'])->first(); +requireWorkshopManual($review&&$review->actor_id!==$p->actor_id&&$g->actor_id===$p->actor_id,'Separate commercial identities required.'); +$snapshot=json_decode($p->snapshot,true,64,JSON_THROW_ON_ERROR);$service=DB::table('a_service_drafts')->find($g->service_draft_id);$billing=DB::table('a_billing_drafts')->find($g->billing_draft_id); +requireWorkshopManual($service&&$billing&&$billing->source_id===$service->id&&$billing->state==='draft'&&$service->state==='draft','Draft destination chain differs.'); +$sd=json_decode($service->data,true);$bd=json_decode($billing->data,true); +requireWorkshopManual($sd['currency']==='MZN'&&$bd['currency']==='MZN'&&DraftValues::base($sd)==='500'&&DraftValues::base($bd)==='500'&&$snapshot['base_exact']==='500','Commercial amounts differ.'); +requireWorkshopManual(count($snapshot['lines'])===2&&count($bd['lines'])===2,'Two selected lines required.'); +$claims=DB::table('wb_claims')->where('generation_id',$g->id)->get();requireWorkshopManual($claims->count()===2,'Two unique source claims required.'); +foreach($snapshot['lines'] as $i=>$line){ + $entry=DB::table('wo_entries')->where(['id'=>$line['entry_id'],'version'=>$line['entry_version'],'order_id'=>$g->order_id,'origin'=>$line['origin']])->first(); + requireWorkshopManual($entry&&!$entry->outside_authority,'Source differs.'); + requireWorkshopManual(DB::table('wo_decisions')->where(['entry_id'=>$entry->id,'entry_version'=>$entry->version,'decision'=>'verified'])->exists(),'Technical verification missing.'); + requireWorkshopManual($claims->contains(fn($c)=>$c->entry_id===$entry->id&&$c->entry_version===$entry->version&&$c->origin===$entry->origin),'Source claim differs.'); + foreach(['service_id','quantity','unit_price','description'] as $field)requireWorkshopManual($bd['lines'][$i][$field]===$line[$field],'Generated line differs: '.$field); + requireWorkshopManual($line['price_source']==='WORKSHOP-SALES-SYNTHETIC-v1','Unexpected price reference.'); + requireWorkshopManual($entry->kind==='time'?($line['quantity']==='1.75'&&$line['unit_price']==='200'):($line['quantity']==='2'&&$line['unit_price']==='75'),'Quantity/price differs.'); +} +requireWorkshopManual(!DB::table('b_internal_confirmations')->where('billing_draft_id',$billing->id)->exists(),'Unexpected internal confirmation.'); +requireWorkshopManual($bd['tax_status']==='pending'&&$bd['accounting_status']==='not_processed','Unexpected fiscal/accounting state.'); +$proof=['policy'=>WorkshopBilling::POLICY,'date'=>'2026-10-28','database'=>'sgf_tempo_test','method'=>'user_reported_manual_browser_and_read_only_reconciliation','order_id'=>$g->order_id,'proposal_id'=>$p->id,'proposal_version'=>$p->version,'service_draft_id'=>$service->id,'billing_draft_id'=>$billing->id,'commercial_base_exact'=>'500','currency'=>'MZN','source_claims'=>2,'distinct_commercial_review'=>true,'generated_lines_match_reviewed_proposal'=>true,'internal_confirmation_created'=>false,'fiscal_status'=>'pending','accounting_status'=>'not_processed','screenshots_received'=>false,'automated_browser_recovered'=>false,'records_changed'=>false,'user_acceptance'=>false,'closing_checkpoint_created'=>false]; +file_put_contents(base_path('docs/proofs/workshop-billing-manual-results.json'),json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL); +echo 'Manual generation reconciled: proposal #'.$p->id.' v'.$p->version.', service #'.$service->id.', billing draft #'.$billing->id.'; 500 MZN, two source claims, separate review, no internal confirmation. No records changed.'.PHP_EOL; diff --git a/scripts/workshop-billing-permissions-preview.php b/scripts/workshop-billing-permissions-preview.php new file mode 100644 index 0000000..c495bb0 --- /dev/null +++ b/scripts/workshop-billing-permissions-preview.php @@ -0,0 +1,10 @@ +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.'); +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.'); +$proposals=[];foreach([1=>['workshop_billing.view','workshop_billing.prepare','workshop_billing.generate'],2=>['workshop_billing.view','workshop_billing.review','workshop.view']] as $user=>$rights){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($rights as $permission){$g=['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$permission];$proposals[]=$g+['already_granted'=>DB::table('permission_grants')->where($g)->exists()];}} +$prerequisites=[];foreach([1,2] as $user){foreach(['clients.view','services.view'] as $permission)$prerequisites[]=['user_id'=>$user,'permission'=>$permission,'unit_id'=>null,'present'=>DB::table('permission_grants')->where(['user_id'=>$user,'tenant_id'=>1,'company_id'=>1,'permission'=>$permission])->whereNull('unit_id')->exists()];}foreach(['workshop.view','service_drafts.view','service_drafts.create','billing_drafts.view','billing_drafts.create'] as $permission)$prerequisites[]=['user_id'=>1,'permission'=>$permission,'unit_id'=>1,'present'=>DB::table('permission_grants')->where(['user_id'=>1,'tenant_id'=>1,'company_id'=>1,'unit_id'=>1,'permission'=>$permission])->exists()]; +try{\App\Modules\Workshop\WorkshopBilling::guard();throw new RuntimeException('Real conversion 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_120000_applied'=>DB::table('migrations')->where('migration','2026_10_28_120000_create_workshop_billing_drafts')->exists(),'proposed_grants'=>$proposals,'existing_prerequisites'=>$prerequisites,'real_conversion_blocked'=>true,'applied'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/workshop-billing-status.php b/scripts/workshop-billing-status.php new file mode 100644 index 0000000..536752f --- /dev/null +++ b/scripts/workshop-billing-status.php @@ -0,0 +1,6 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +\App\Modules\Workshop\WorkshopBilling::guard(); +$db=\Illuminate\Support\Facades\DB::class; +echo json_encode(['database'=>$db::selectOne('SELECT current_database() AS db')->db,'proposals'=>$db::table('wb_proposals')->orderBy('id')->get(['id','order_id','version','previous_id'])->all(),'reviews'=>$db::table('wb_decisions')->get(['proposal_id','version','decision'])->all(),'generations'=>$db::table('wb_generations')->get(['id','order_id','proposal_id','billing_draft_id'])->all(),'read_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/workshop-billing-worker.php b/scripts/workshop-billing-worker.php new file mode 100644 index 0000000..eae0c53 --- /dev/null +++ b/scripts/workshop-billing-worker.php @@ -0,0 +1,6 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +\App\Modules\Workshop\WorkshopBilling::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\Workshop\WorkshopBilling::class)->{$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;} diff --git a/tests/Feature/WorkshopBillingTest.php b/tests/Feature/WorkshopBillingTest.php new file mode 100644 index 0000000..b7b7b95 --- /dev/null +++ b/tests/Feature/WorkshopBillingTest.php @@ -0,0 +1,45 @@ +f=DirectCostFixture::create();foreach(['manager'=>['view','prepare','generate'],'reader'=>['view','review']] as $who=>$rights){foreach($rights as $r)IdentityFixture::grant($this->f[$who]->id,$this->f['tenant'],$this->f['company'],'workshop_billing.'.$r,$this->f['unit']);IdentityFixture::grant($this->f[$who]->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);}$this->service=app(ServiceWorkflow::class)->save('services',null,$this->f['manager']->id,['name'=>'Serviço comercial sintético'],(string)Str::uuid())['id'];$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){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/workshop-billing/orders/'.$this->f['order'].'/'.$action,$in);} + private function input(): array {return ['version'=>1,'previous_id'=>null,'client_id'=>$this->f['client'],'currency'=>'MZN','evidence'=>'Tarifas sintéticas revistas, não custos internos.','lines'=>array_map(fn($id,$price)=>['entry_id'=>$id,'entry_version'=>1,'service_id'=>$this->service,'service_version'=>1,'price'=>$price,'price_source'=>'WORKSHOP-SALES-SYNTHETIC-v1'],[$this->f['time'],$this->f['material']],['200','75'])];} + private function approve(int $id): void {$this->login('reader');$this->send('reviews',['id'=>$id,'version'=>1,'decision'=>'approved','reason'=>'Versão comercial exacta conferida.'])->assertOk();$this->login();} + public function test_exact_500_generation_distinct_review_history_and_replay(): void { + $count=DB::table('wo_entries')->count();$d=$this->input();$key=(string)Str::uuid();$p=$this->send('proposals',$d,$key)->assertOk()->json('id');$this->send('proposals',$d,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true); + $this->send('generations',['id'=>$p,'version'=>1])->assertStatus(409);$this->approve($p);$key=(string)Str::uuid();$g=$this->send('generations',['id'=>$p,'version'=>1],$key)->assertOk()->assertJsonPath('base_exact','500');$this->send('generations',['id'=>$p,'version'=>1],$key)->assertOk()->assertJsonPath('id',$g->json('id'))->assertJsonPath('replayed',true); + $bill=DB::table('a_billing_drafts')->find($g->json('billing_draft_id'));$data=json_decode($bill->data,true);$this->assertSame('MZN',$data['currency']);$this->assertSame('1.75',$data['lines'][0]['quantity']);$this->assertSame('200',$data['lines'][0]['unit_price']);$this->assertSame('2',$data['lines'][1]['quantity']);$this->assertSame('75',$data['lines'][1]['unit_price']);$this->assertSame(2,DB::table('wb_claims')->count());$this->assertSame($count,DB::table('wo_entries')->count());$this->assertSame(0,DB::table('b_internal_confirmations')->count());$this->assertSame('draft',$bill->state);$this->getJson('/api/workshop-billing')->assertOk()->assertJsonPath('orders.0.proposals.0.current',true); + $this->send('generations',['id'=>$p,'version'=>1])->assertStatus(409);try{DB::transaction(fn()=>DB::table('a_billing_drafts')->where('id',$bill->id)->update(['name'=>'Bypass','version'=>$bill->version+1]));$this->fail('Linked draft editable');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_WB_LINKED_DRAFT',$e->getMessage());} + app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']); + try{app(ServiceWorkflow::class)->save('billing_drafts',null,$this->f['manager']->id,['source_id'=>$g->json('service_draft_id')],(string)Str::uuid());$this->fail('Second billing through ordinary workflow');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());} + } + public function test_identity_permissions_revocation_and_unknown_price(): void { + $d=$this->input();$d['lines'][0]['price']='';$this->send('proposals',$d)->assertStatus(422);$d=$this->input();$d['lines'][]=$d['lines'][0];$this->send('proposals',$d)->assertStatus(409); + $p=$this->send('proposals',$this->input())->assertOk()->json('id');IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'workshop_billing.review',$this->f['unit']);$this->send('reviews',['id'=>$p,'version'=>1,'decision'=>'approved','reason'=>'Auto review'])->assertForbidden();$this->login('reader');$this->send('generations',['id'=>$p,'version'=>1])->assertForbidden();$this->send('proposals',$this->input())->assertForbidden();DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_billing.review'])->delete();$this->send('reviews',['id'=>$p,'version'=>1,'decision'=>'approved','reason'=>'Revoked review'])->assertForbidden(); + } + public function test_material_delivery_wrong_client_service_and_version_refused(): void { + $d=$this->input();$d['client_id']=999;$this->send('proposals',$d)->assertStatus(409);$d=$this->input();$d['lines'][0]['service_version']=2;$this->send('proposals',$d)->assertStatus(409);$d=$this->input();$delivered=DB::table('wo_entries')->where(['order_id'=>$this->f['order'],'kind'=>'material'])->whereRaw("data->>'event'='delivered'")->first();$d['lines'][1]['entry_id']=$delivered->id;$this->send('proposals',$d)->assertStatus(422); + } + public function test_rollback_outbox_preserves_no_orphan_drafts_and_recovery(): void { + $p=$this->send('proposals',$this->input())->assertOk()->json('id');$this->approve($p);$n=DB::table('a_billing_drafts')->count();$s=DB::table('a_service_drafts')->count();DB::unprepared("CREATE OR REPLACE FUNCTION sgf_wb_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'WB_OUTBOX_FAIL'; END; \$\$; CREATE TRIGGER wb_fail BEFORE INSERT ON wb_outbox FOR EACH ROW EXECUTE FUNCTION sgf_wb_fail()");$key=(string)Str::uuid();$this->send('generations',['id'=>$p,'version'=>1],$key)->assertStatus(500);$this->assertSame($n,DB::table('a_billing_drafts')->count());$this->assertSame($s,DB::table('a_service_drafts')->count());$this->assertSame(0,DB::table('wb_claims')->count());DB::unprepared('DROP TRIGGER wb_fail ON wb_outbox');$this->send('generations',['id'=>$p,'version'=>1],$key)->assertOk(); + } + public function test_successor_requires_fresh_review_and_changed_catalogue_invalidates(): void { + $p=$this->send('proposals',$this->input())->assertOk()->json('id');$this->approve($p);$d=$this->input();$d['version']=2;$d['previous_id']=$p;$d['lines'][0]['price']='210';$next=$this->send('proposals',$d)->assertOk()->json('id');$this->send('generations',['id'=>$p,'version'=>1])->assertStatus(409);$this->send('generations',['id'=>$next,'version'=>2])->assertStatus(409); + $this->login('reader');$this->send('reviews',['id'=>$next,'version'=>2,'decision'=>'approved','reason'=>'Nova revisão comercial.'])->assertOk();$this->login();app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);app(ServiceWorkflow::class)->save('services',$this->service,$this->f['manager']->id,['version'=>1,'name'=>'Serviço corrigido'],(string)Str::uuid());$this->send('generations',['id'=>$next,'version'=>2])->assertStatus(409);$this->getJson('/api/workshop-billing')->assertOk()->assertJsonPath('orders.0.proposals.1.current',false);$this->assertSame(0,DB::table('wb_generations')->count()); + } + public function test_generation_replay_rechecks_destination_creation_right_and_real_guard(): void { + $p=$this->send('proposals',$this->input())->assertOk()->json('id');$this->approve($p);$key=(string)Str::uuid();$this->send('generations',['id'=>$p,'version'=>1],$key)->assertOk();DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'billing_drafts.create'])->delete();$this->send('generations',['id'=>$p,'version'=>1],$key)->assertForbidden(); + $this->app->detectEnvironment(fn()=>'local');try{WorkshopBilling::guard();$this->fail('Real conversion allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(403,$e->getStatusCode());} + } +} diff --git a/tests/Support/WorkshopBillingFixture.php b/tests/Support/WorkshopBillingFixture.php new file mode 100644 index 0000000..8f70561 --- /dev/null +++ b/tests/Support/WorkshopBillingFixture.php @@ -0,0 +1,13 @@ +['view','prepare','generate'],'reader'=>['view','review']] as $who=>$rights){foreach($rights as $right)IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],'workshop_billing.'.$right,$f['unit']);IdentityFixture::grant($f[$who]->id,$f['tenant'],$f['company'],'workshop.view',$f['unit']);} + $service=app(ServiceWorkflow::class)->save('services',null,$f['manager']->id,['name'=>'Serviço comercial exclusivamente sintético'],(string)Str::uuid())['id'];return $f+compact('service'); + } + public static function proposal(array $f): array {return ['version'=>1,'previous_id'=>null,'client_id'=>$f['client'],'currency'=>'MZN','evidence'=>'WORKSHOP-SALES-SYNTHETIC-v1: 200/H e 75/UN, revisão comercial distinta.','lines'=>array_map(fn($id,$price)=>['entry_id'=>$id,'entry_version'=>1,'service_id'=>$f['service'],'service_version'=>1,'price'=>$price,'price_source'=>'WORKSHOP-SALES-SYNTHETIC-v1'],[$f['time'],$f['material']],['200','75'])];} +}