Accept isolated invoice issuance and reproducible PDF delivery
This commit is contained in:
@@ -9,3 +9,4 @@
|
|||||||
/.github export-ignore
|
/.github export-ignore
|
||||||
CHANGELOG.md export-ignore
|
CHANGELOG.md export-ignore
|
||||||
.styleci.yml export-ignore
|
.styleci.yml export-ignore
|
||||||
|
*.pdf binary
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# SGF Tempo — instruções locais
|
# SGF Tempo — instruções locais
|
||||||
|
|
||||||
|
- Em 27/10/2026, o utilizador confirmou **«Aceitar a entrega técnica e criar checkpoint local»** para navegador + PDF, após verificar a demonstração. Entrega técnica encerrada no âmbito de `docs/SERVICE_INVOICE_TEST_DELIVERY.md`, exclusivamente de teste. Registar/preservar checkpoint local sem credenciais/dados reais; não repetir validações aceites sem alteração/falha relevante. Esta aceitação não aplica a migração `060000`, oito concessões ou série real, não resolve conformidade/exigibilidade/apuramento e não autoriza retirar o bloqueio de emissão real, emitir, receber, contabilizar ou publicar.
|
||||||
|
|
||||||
|
- Autorização posterior de 26/10/2026: incremento técnico de emissão/reprodução no navegador e PDF, **exclusivamente demonstrável em sgf_tempo_test**, com bloqueio incondicional de emissão real. Ver `docs/SERVICE_INVOICE_TEST_DELIVERY.md` e `docs/SERVICE_INVOICE_ISSUANCE_SCOPE.md`. Série real será nova; proponente prepara/emite e pede correcções, validador fiscal aprova séries e regra temporal. Migração `2026_10_26_060000_create_test_invoice_issuance.php`, código/número inicial reais e oito concessões propostas permanecem sem aplicação e exigem confirmação específica. Não retirar `ServiceInvoicing::guard`, não criar documentos fictícios em desenvolvimento, não confundir pressupostos temporais sintéticos com pendências resolvidas do caso real. Preservar `9f48004`, B1 e aprovações existentes; sem recebimentos, lançamentos, comunicação externa ou publicação.
|
||||||
|
|
||||||
- Em 26/10/2026, o utilizador aceitou o cálculo condicionado e autorizou o registo e checkpoint Git exclusivamente local, sem credenciais/dados locais. Ver `docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md`. Perfil, concessões e activação local foram objecto de confirmações específicas; os detalhes nominativos permanecem na ficha local não versionada. Exigibilidade e período de apuramento permanecem pendentes; aprovação temporal não os determina. Preservar versões/âmbito exactos, separação de proponente/validador, B1 e aprovações contabilísticas. Calcular não altera decisão temporal validada. Sem avançar para emissão documental, recebimentos, lançamentos ou publicação.
|
- Em 26/10/2026, o utilizador aceitou o cálculo condicionado e autorizou o registo e checkpoint Git exclusivamente local, sem credenciais/dados locais. Ver `docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md`. Perfil, concessões e activação local foram objecto de confirmações específicas; os detalhes nominativos permanecem na ficha local não versionada. Exigibilidade e período de apuramento permanecem pendentes; aprovação temporal não os determina. Preservar versões/âmbito exactos, separação de proponente/validador, B1 e aprovações contabilísticas. Calcular não altera decisão temporal validada. Sem avançar para emissão documental, recebimentos, lançamentos ou publicação.
|
||||||
|
|
||||||
- Em 26/10/2026, o utilizador aceitou expressamente o cálculo de ensaio demonstrado (base 1000,03; IVA exacto 160,0048; IVA proposto 160,00; diferença preservada 0,0048; total proposto 1160,03) e autorizou registo e checkpoint Git apenas local. Ver `docs/SERVICE_CALCULATION_SEPARATE_REVIEW.md`. Esta confirmação substitui apenas a pendência de aceitação do ensaio: a aplicabilidade fiscal real permanece separada e pendente, sem autorização para activar perfil fiscal real, emitir, contabilizar ou publicar.
|
- Em 26/10/2026, o utilizador aceitou expressamente o cálculo de ensaio demonstrado (base 1000,03; IVA exacto 160,0048; IVA proposto 160,00; diferença preservada 0,0048; total proposto 1160,03) e autorizou registo e checkpoint Git apenas local. Ver `docs/SERVICE_CALCULATION_SEPARATE_REVIEW.md`. Esta confirmação substitui apenas a pendência de aceitação do ensaio: a aplicabilidade fiscal real permanece separada e pendente, sem autorização para activar perfil fiscal real, emitir, contabilizar ou publicar.
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ Não existe criação de tenants ou empresas pela interface, auto-registo, opç
|
|||||||
|
|
||||||
## Testes e ferramentas
|
## Testes e ferramentas
|
||||||
|
|
||||||
|
O percurso de **Facturas de teste**, navegador e PDF da mesma fotografia, está demonstrado apenas na instância isolada. A aplicação normal recusa emissão no servidor. Migração/concessões e série reais continuam pendentes de confirmações específicas; ver [entrega técnica, provas, percurso e limitações](docs/SERVICE_INVOICE_TEST_DELIVERY.md). Não executar scripts de fixtures na base normal ou interpretar os documentos de teste como facturas fiscais.
|
||||||
|
|
||||||
O incremento de cálculo condicionado foi aceite; ver [entrega, verificações e limites](docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md). Na empresa/unidade autorizadas, consultar **Perfil fiscal do serviço** e **Rascunhos de facturação → Pré-visualizar rascunho**. A activação é uma operação local especificamente autorizada, sem via no navegador. O cálculo exige decisão temporal aprovada e compatível; quando há pendência, não determina exigibilidade ou período de apuramento. Sem emissão, recebimentos ou lançamentos.
|
O incremento de cálculo condicionado foi aceite; ver [entrega, verificações e limites](docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md). Na empresa/unidade autorizadas, consultar **Perfil fiscal do serviço** e **Rascunhos de facturação → Pré-visualizar rascunho**. A activação é uma operação local especificamente autorizada, sem via no navegador. O cálculo exige decisão temporal aprovada e compatível; quando há pendência, não determina exigibilidade ou período de apuramento. Sem emissão, recebimentos ou lançamentos.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ final class DocumentPreview
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal composition under the issuer's company lock; ordinary preview reads remain read-only. */
|
||||||
|
public function captureDraftForIssuance(int $draft,int $actor): array
|
||||||
|
{
|
||||||
|
abort_unless(DB::transactionLevel()>0,409,'Captura documental exige transacção consistente.');
|
||||||
|
$scope=$this->workflow->scope('billing_drafts',$actor,'view');
|
||||||
|
$this->context->selectFor($actor,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
|
||||||
|
return $this->current($draft,$actor,$scope)+['notice'=>self::NOTICE];
|
||||||
|
}
|
||||||
|
|
||||||
private function current(int $draft, int $actor, array $scope): array
|
private function current(int $draft, int $actor, array $scope): array
|
||||||
{
|
{
|
||||||
foreach (['issuer','client','billing_drafts'] as $kind) { $this->preparation->scope($kind, $actor, 'view'); }
|
foreach (['issuer','client','billing_drafts'] as $kind) { $this->preparation->scope($kind, $actor, 'view'); }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Modules\Services\Http;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Services\ServiceInvoicing;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class ServiceInvoiceController extends Controller
|
||||||
|
{
|
||||||
|
private function response(array $data) { return response()->json($data)->header('Cache-Control','no-store, private'); }
|
||||||
|
public function index(Request $r,ServiceInvoicing $s) { return $this->response($s->listing($r->user()->id)); }
|
||||||
|
public function propose(Request $r,ServiceInvoicing $s) { return $this->response($s->propose($r->user()->id,$r->all())); }
|
||||||
|
public function decide(Request $r,int $id,ServiceInvoicing $s) { return $this->response($s->decide($id,$r->user()->id,$r->all())); }
|
||||||
|
public function review(Request $r,int $id,ServiceInvoicing $s) {
|
||||||
|
abort_if(array_diff(array_keys($r->all()),['series_id','rule_id'])!==[],422);
|
||||||
|
$d=$r->validate(['series_id'=>'required|integer|min:1','rule_id'=>'required|integer|min:1']);
|
||||||
|
return $this->response($s->review($id,(int)$d['series_id'],(int)$d['rule_id'],$r->user()->id));
|
||||||
|
}
|
||||||
|
public function issue(Request $r,int $id,ServiceInvoicing $s) { return $this->response($s->issue($id,$r->user()->id,$r->all(),(string)$r->header('Idempotency-Key'))); }
|
||||||
|
public function show(Request $r,int $id,ServiceInvoicing $s) { return $this->response($s->show($id,$r->user()->id)); }
|
||||||
|
public function pdf(Request $r,int $id,ServiceInvoicing $s) { return response($s->pdf($id,$r->user()->id))->header('Content-Type','application/pdf')->header('Content-Disposition','attachment; filename="test-invoice-'.$id.'.pdf"')->header('Cache-Control','no-store, private')->header('X-Content-Type-Options','nosniff'); }
|
||||||
|
public function correction(Request $r,int $id,ServiceInvoicing $s) { return $this->response($s->requestCorrection($id,$r->user()->id,$r->all())); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Modules\Services;
|
||||||
|
|
||||||
|
/** Deterministic text-only PDF v1. Stored bytes, never regenerated from live records. */
|
||||||
|
final class InvoicePdf
|
||||||
|
{
|
||||||
|
public const VERSION='sgf-test-invoice-text-v2';
|
||||||
|
public static function lines(array $s): array
|
||||||
|
{
|
||||||
|
$lines=[['title','Factura de serviço - demonstração isolada'],['text',$s['series_code'].' / '.$s['number']],['text','Emissão: '.$s['issued_local'].' ('.$s['timezone'].')'],['section','Emitente']];
|
||||||
|
foreach(['issuer'=>'Emitente','client'=>'Cliente'] as $key=>$title) {
|
||||||
|
if($key==='client') $lines[]=['section',$title];
|
||||||
|
$party=$s['document'][$key]; $lines[]=['text',$party['name']];
|
||||||
|
foreach(['nuit'=>'NUIT','address'=>'Morada','locality'=>'Localidade','country'=>'País'] as $field=>$label) $lines[]=['text',$label.': '.($party['data'][$field] ?? 'Não indicado')];
|
||||||
|
}
|
||||||
|
$p=$s['document']['prepared']; $line=$s['document']['data']['lines'][0];
|
||||||
|
$lines[]=['section','Serviço e período'];
|
||||||
|
foreach(['service_name'=>'Serviço','description'=>'Descrição','quantity'=>'Quantidade','unit_price'=>'Preço sem IVA'] as $key=>$label) if(!empty($line[$key]) || ($line[$key] ?? null)==='0') $lines[]=['text',$label.': '.$line[$key]];
|
||||||
|
$lines[]=['text','Unidade: '.($p['units'][0] ?? 'Não indicada')];
|
||||||
|
$lines[]=['text','Período: '.($p['period_start'] ?? 'Não indicado').' a '.($p['period_end'] ?? 'Não indicado')];
|
||||||
|
$lines[]=['text','Condições: '.($p['payment_terms'] ?? 'Não indicadas')];
|
||||||
|
$lines[]=['text','Vencimento proposto: '.($p['proposed_due_date'] ?? 'Não indicado')];
|
||||||
|
$lines[]=['section','Valores - '.$s['calculation']['currency']];
|
||||||
|
foreach(['base_exact'=>'Base exacta','vat_exact'=>'IVA exacto','vat_document_proposed'=>'IVA documental proposto','discarded_difference'=>'Diferença preservada','total_document_proposed'=>'Total proposto'] as $key=>$label) $lines[]=['text',$label.': '.$s['calculation'][$key]];
|
||||||
|
$lines[]=['text','Perfil: '.$s['profile_identifier'].' v'.$s['profile_version'].' | IVA '.$s['rate_percent'].'%'];
|
||||||
|
$lines[]=['text','Exigibilidade simulada: '.$s['exigibility_date'].' | Período: '.$s['assessment_period']];
|
||||||
|
foreach(['contacts'=>'Contactos','observations'=>'Observações'] as $key=>$label) if(!empty($s['document'][$key])) { $lines[]=['section',$label]; $lines[]=['text',$s['document'][$key]]; }
|
||||||
|
foreach($s['document']['payments'] as $payment) { $lines[]=['section','Instruções de pagamento']; foreach(['label'=>'Designação','bank'=>'Banco','holder'=>'Titular','account_number'=>'Conta','nib'=>'NIB','iban'=>'IBAN','swift'=>'SWIFT','bank_code'=>'Código bancário','currency'=>'Moeda','instructions'=>'Instruções'] as $key=>$label) if(isset($payment['data'][$key]) && $payment['data'][$key]!=='') $lines[]=['text',$label.': '.$payment['data'][$key]]; }
|
||||||
|
$lines[]=['section','Origem e limites']; $lines[]=['text','Rascunho #'.$s['draft_id'].' v'.$s['document']['draft_version'].' | Regra temporal #'.$s['rule_id'].' v'.$s['rule_version']];
|
||||||
|
$lines[]=['text','Sem recebimento, comunicação à AT ou lançamento contabilístico.'];
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
public static function supports(array $lines): bool
|
||||||
|
{
|
||||||
|
foreach($lines as [, $text]) if(@iconv('UTF-8','Windows-1252',$text)===false || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/',$text)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public function render(array $s): string
|
||||||
|
{
|
||||||
|
$lines=$s['document_lines'] ?? self::lines($s); abort_unless(self::supports($lines),422,'Texto fora do conjunto de caracteres suportado pelo PDF v1; não foi emitido.');
|
||||||
|
$rows=[];
|
||||||
|
foreach($lines as [$kind,$text]) {
|
||||||
|
foreach(explode("\n",str_replace("\r",'', $text)) as $paragraph) {
|
||||||
|
// <=50 glyphs even for the widest Helvetica letters; fixed A4 margins.
|
||||||
|
$words=preg_split('/\s+/u',$paragraph); $wrapped='';
|
||||||
|
foreach($words as $word) foreach(mb_str_split($word,50) as $part) {
|
||||||
|
if(mb_strlen($wrapped.($wrapped===''?'':' ').$part)>50) { $rows[]=[$kind,$wrapped]; $wrapped=$part; }
|
||||||
|
else $wrapped.=($wrapped===''?'':' ').$part;
|
||||||
|
}
|
||||||
|
if($wrapped!=='') $rows[]=[$kind,$wrapped];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pages=array_chunk($rows,44);
|
||||||
|
$objects=[]; $add=function($text)use(&$objects){$objects[]=$text; return count($objects);};
|
||||||
|
$add(''); $add(''); $font=$add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'); $bold=$add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>'); $kids=[];
|
||||||
|
$escape=function($s) { return str_replace(['\\','(',')'],['\\\\','\\(','\\)'],iconv('UTF-8','Windows-1252',$s)); };
|
||||||
|
foreach($pages as $n=>$rows) {
|
||||||
|
$stream="0.48 0.19 0.12 rg\nBT /F2 12 Tf 42 799 Td (".$escape('TESTE — SEM VALIDADE FISCAL').") Tj ET\n"; $y=766;
|
||||||
|
foreach($rows as [$kind,$text]) { $f=$kind==='text'?'F1':'F2'; $size=$kind==='title'?13:10; $color=$kind==='section'?'0.09 0.34 0.28':'0.12 0.21 0.23'; $stream.="$color rg\nBT /$f $size Tf 42 $y Td (".$escape($text).") Tj ET\n"; $y-=15; }
|
||||||
|
$stream.="0.4 0.4 0.4 rg\nBT /F1 8 Tf 42 30 Td (".$escape('TESTE — SEM VALIDADE FISCAL | Página '.($n+1).' / '.count($pages)).") Tj ET\n";
|
||||||
|
$content=$add('<< /Length '.strlen($stream).">>\nstream\n".$stream.'endstream');
|
||||||
|
$kids[]=$add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 $font 0 R /F2 $bold 0 R >> >> /Contents $content 0 R >>");
|
||||||
|
}
|
||||||
|
$objects[0]='<< /Type /Catalog /Pages 2 0 R >>'; $objects[1]='<< /Type /Pages /Count '.count($kids).' /Kids ['.implode(' ',array_map(fn($id)=>"$id 0 R",$kids)).'] >>';
|
||||||
|
$pdf="%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; $offset=[0];
|
||||||
|
foreach($objects as $i=>$obj) { $offset[]=strlen($pdf); $pdf.=($i+1)." 0 obj\n$obj\nendobj\n"; }
|
||||||
|
$xref=strlen($pdf); $pdf.='xref'."\n0 ".(count($objects)+1)."\n0000000000 65535 f \n";
|
||||||
|
foreach(array_slice($offset,1) as $pos) $pdf.=sprintf('%010d 00000 n ',$pos)."\n";
|
||||||
|
return $pdf.'trailer'."\n<< /Size ".(count($objects)+1).' /Root 1 0 R >>'."\nstartxref\n$xref\n%%EOF\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Modules\Services;
|
||||||
|
|
||||||
|
use App\Modules\Identity\Services\Audit;
|
||||||
|
use App\Modules\Identity\Services\Permissions;
|
||||||
|
use App\Shared\Context\CompanyContext;
|
||||||
|
use App\Shared\Testing\TestingDatabaseGuard;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
final class ServiceInvoicing
|
||||||
|
{
|
||||||
|
public const NOTICE='TESTE — SEM VALIDADE FISCAL';
|
||||||
|
public const ZONE='Africa/Johannesburg';
|
||||||
|
public function __construct(private CompanyContext $context,private Permissions $permissions,private DocumentPreview $preview,private InvoicePdf $pdf) {}
|
||||||
|
|
||||||
|
public static function guard(): void
|
||||||
|
{
|
||||||
|
// Deliberately unconditional outside the isolated environment: no enable flag or HTTP bypass.
|
||||||
|
abort_unless(ServiceCalculation::isolated(),403,'Emissão real bloqueada: conformidade ainda não demonstrada.');
|
||||||
|
TestingDatabaseGuard::assertSafe();
|
||||||
|
}
|
||||||
|
private function scope(int $actor,string $right): array
|
||||||
|
{
|
||||||
|
$s=$this->context->identifiers(); $unit=$this->context->unitId();
|
||||||
|
abort_unless($actor===$this->context->userId() && $unit!==null,403,'Seleccione uma unidade autorizada.');
|
||||||
|
$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'unit.view',$unit);
|
||||||
|
$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$right,(str_starts_with($right,'invoice_series.') || str_starts_with($right,'service_fiscal.'))?null:$unit);
|
||||||
|
return $s+['unit_id'=>$unit];
|
||||||
|
}
|
||||||
|
private function exact(array $data,array $rules): array
|
||||||
|
{
|
||||||
|
abort_if(array_diff(array_keys($data),array_keys($rules))!==[],422,'Campo protegido ou fora do âmbito documental.');
|
||||||
|
return Validator::make($data,$rules)->validate();
|
||||||
|
}
|
||||||
|
private function command(int $actor,string $right,callable $write): array
|
||||||
|
{
|
||||||
|
self::guard(); $s=$this->scope($actor,$right);
|
||||||
|
return DB::transaction(function()use($actor,$right,$write,$s) {
|
||||||
|
DB::table('companies')->where('id',$s['company_id'])->where('tenant_id',$s['tenant_id'])->lockForUpdate()->firstOrFail();
|
||||||
|
$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); $this->scope($actor,$right);
|
||||||
|
return $write($s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private function row(int $id,array $s): object { $r=DB::table('si_proposals')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Proposta fora do âmbito.'); return $r; }
|
||||||
|
private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
|
||||||
|
private function decision(int $id): ?object { return DB::table('si_decisions')->where('proposal_id',$id)->first(); }
|
||||||
|
private function approved(object $p,array $s,string $date): bool
|
||||||
|
{
|
||||||
|
if($this->decision($p->id)?->decision!=='approved') return false;
|
||||||
|
$d=$this->data($p);
|
||||||
|
if(($d['valid_from'] ?? '')>$date || (!empty($d['valid_until']) && $date>$d['valid_until'])) return false;
|
||||||
|
return !DB::table('si_proposals')->where($s)->where('kind',$p->kind)->where('identifier',$p->identifier)->where('version','>',$p->version)->whereRaw("data->>'valid_from' <= ?",[$date])
|
||||||
|
->whereExists(fn($q)=>$q->selectRaw('1')->from('si_decisions')->whereColumn('si_decisions.proposal_id','si_proposals.id')->where('decision','approved'))->exists();
|
||||||
|
}
|
||||||
|
public function propose(int $actor,array $input): array
|
||||||
|
{
|
||||||
|
$kind=$input['kind'] ?? ''; $right=$kind==='series'?'invoice_series.propose':'service_fiscal.propose';
|
||||||
|
return $this->command($actor,$right,function($s)use($actor,$input) {
|
||||||
|
$in=$this->exact($input,['kind'=>'required|in:series,temporal','identifier'=>'required|string|max:100|regex:/^[A-Za-z0-9_-]+$/D','previous_id'=>'nullable|integer|min:1','data'=>'required|array']);
|
||||||
|
$previous=isset($in['previous_id'])?$this->row((int)$in['previous_id'],$s):null;
|
||||||
|
$last=DB::table('si_proposals')->where('company_id',$s['company_id'])->where('kind',$in['kind'])->where('identifier',$in['identifier'])->orderByDesc('version')->first();
|
||||||
|
abort_unless(($last?->id ?? null)===($previous?->id ?? null),409,'Versão anterior exacta necessária.');
|
||||||
|
$common=['valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','evidence'=>'required|string|max:4000'];
|
||||||
|
if($in['kind']==='series') {
|
||||||
|
$d=$this->exact($in['data'],$common+['code'=>'required|string|max:40|regex:/^TEST-[A-Z0-9_-]+$/D','first_number'=>'required|integer|min:1|max:999999999','state'=>'required|in:open,closed']);
|
||||||
|
abort_unless($d['code']===$in['identifier'],422,'Identidade da série deve coincidir com o código sintético.');
|
||||||
|
if($previous) { $old=$this->data($previous); abort_unless($old['code']===$d['code'] && (int)$old['first_number']===(int)$d['first_number'],422,'Não reiniciar ou transferir contador existente.'); }
|
||||||
|
$d['first_number']=(int)$d['first_number'];
|
||||||
|
} else {
|
||||||
|
$d=$this->exact($in['data'],$common+['draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:1','profile_proposal_id'=>'required|integer|min:1','period_start'=>'required|date_format:Y-m-d','period_end'=>'required|date_format:Y-m-d|after_or_equal:period_start','issue_window_start'=>'required|date_format:Y-m-d','issue_window_end'=>'required|date_format:Y-m-d|after_or_equal:issue_window_start','event'=>'required|in:on_issue_within_deadline','readiness_basis'=>'required|string|max:4000']);
|
||||||
|
$view=$this->preview->captureDraftForIssuance((int)$d['draft_id'],$actor);
|
||||||
|
abort_unless($view['draft_version']===(int)$d['draft_version'] && $view['preparation_version']===(int)$d['preparation_version'] && ($view['prepared']['period_start'] ?? null)===$d['period_start'] && ($view['prepared']['period_end'] ?? null)===$d['period_end'],409,'Período/versões não correspondem à preparação.');
|
||||||
|
$profile=DB::table('sf_proposals')->where($s)->where('id',$d['profile_proposal_id'])->where('kind','profile')->first();
|
||||||
|
abort_unless($profile && DB::table('sf_decisions')->where('proposal_id',$profile->id)->where('decision','approved')->exists(),422,'Perfil fiscal exacto aprovado necessário.');
|
||||||
|
abort_if(DB::table('si_proposals')->where($s)->where('kind','temporal')->whereRaw("data->>'draft_id' = ?",[(string)$d['draft_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade da regra temporal existente.');
|
||||||
|
if($previous) abort_unless((int)$this->data($previous)['draft_id']===(int)$d['draft_id'],422,'Regra não pode trocar o rascunho.');
|
||||||
|
foreach(['draft_id','draft_version','preparation_version','profile_proposal_id'] as $f) $d[$f]=(int)$d[$f];
|
||||||
|
}
|
||||||
|
$id=DB::table('si_proposals')->insertGetId($s+['kind'=>$in['kind'],'identifier'=>$in['identifier'],'version'=>(int)($last?->version ?? 0)+1,'previous_id'=>$previous?->id,'actor_id'=>$actor,'data'=>json_encode($d,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||||
|
Audit::record('service_invoice.proposed',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>(int)($last?->version ?? 0)+1]);
|
||||||
|
return ['id'=>$id,'version'=>(int)($last?->version ?? 0)+1];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public function decide(int $id,int $actor,array $input): array
|
||||||
|
{
|
||||||
|
self::guard(); $scope=$this->context->identifiers()+['unit_id'=>$this->context->unitId()]; $p=$this->row($id,$scope);
|
||||||
|
return $this->command($actor,$p->kind==='series'?'invoice_series.approve':'service_fiscal.approve',function($s)use($id,$actor,$input) {
|
||||||
|
$d=$this->exact($input,['version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|max:4000']);
|
||||||
|
$p=$this->row($id,$s); abort_unless((int)$p->version===(int)$d['version'] && (int)$p->actor_id!==$actor,403,'Validador distinto e versão exacta obrigatórios.');
|
||||||
|
abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.');
|
||||||
|
DB::table('si_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]);
|
||||||
|
Audit::record('service_invoice.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$p->version,'reason'=>$d['reason']]);
|
||||||
|
return ['id'=>$id,'decision'=>$d['decision']];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public function listing(int $actor): array
|
||||||
|
{
|
||||||
|
$s=$this->scope($actor,'service_invoices.view');
|
||||||
|
if(!ServiceCalculation::isolated()) return ['real_blocked'=>true,'test_enabled'=>false,'notice'=>'Emissão real bloqueada: conformidade ainda não demonstrada.','proposals'=>[],'invoices'=>[]];
|
||||||
|
TestingDatabaseGuard::assertSafe();
|
||||||
|
if(!Schema::hasTable('si_invoices')) return ['real_blocked'=>true,'notice'=>'Migração documental ainda não aplicada.','proposals'=>[],'invoices'=>[]];
|
||||||
|
return ['real_blocked'=>true,'test_enabled'=>true,'notice'=>self::NOTICE,'proposals'=>DB::table('si_proposals')->where($s)->orderByDesc('id')->get()->map(fn($p)=>(array)$p+['definition'=>$this->data($p),'decision'=>$this->decision($p->id)])->all(),'invoices'=>DB::table('si_invoices')->where($s)->orderByDesc('id')->get(['id','series_code','number','draft_id','issued_at'])->all()];
|
||||||
|
}
|
||||||
|
public function review(int $draft,int $series,int $rule,int $actor): array
|
||||||
|
{
|
||||||
|
return $this->command($actor,'service_invoices.issue',fn($s)=>$this->reviewInside($draft,$series,$rule,$actor,$s));
|
||||||
|
}
|
||||||
|
private function reviewInside(int $draft,int $series,int $rule,int $actor,array $s): array
|
||||||
|
{
|
||||||
|
$view=$this->preview->captureDraftForIssuance($draft,$actor); $date=now(self::ZONE)->format('Y-m-d'); $blocks=[];
|
||||||
|
$p=$this->row($series,$s); $t=$this->row($rule,$s); $sd=$this->data($p); $td=$this->data($t);
|
||||||
|
if($p->kind!=='series' || !$this->approved($p,$s,$date) || ($sd['state'] ?? null)!=='open') $blocks[]='Série sintética sem configuração aprovada/vigente e aberta.';
|
||||||
|
if($t->kind!=='temporal' || !$this->approved($t,$s,$date) || (int)($td['draft_id'] ?? 0)!==$draft || ($td['event'] ?? '')!=='on_issue_within_deadline') $blocks[]='Regra temporal prospectiva aprovada necessária; pendência de cálculo não autoriza emissão.';
|
||||||
|
if(($td['draft_version'] ?? null)!==$view['draft_version'] || ($td['preparation_version'] ?? null)!==$view['preparation_version'] || ($td['period_start'] ?? null)!==($view['prepared']['period_start'] ?? null) || ($td['period_end'] ?? null)!==($view['prepared']['period_end'] ?? null)) $blocks[]='Regra temporal/período desactualizado.';
|
||||||
|
if(empty($td['issue_window_start']) || empty($td['issue_window_end']) || $date<$td['issue_window_start'] || $date>$td['issue_window_end']) $blocks[]='Tempestividade da emissão não demonstrada para a data actual.';
|
||||||
|
foreach(['issuer'=>'Emitente','client'=>'Cliente'] as $party=>$label) foreach(['name','nuit','address','locality','country'] as $field) {
|
||||||
|
$value=$field==='name'?($view[$party]['name'] ?? ''):($view[$party]['data'][$field] ?? '');
|
||||||
|
if(!is_string($value) || trim($value)==='') $blocks[]="$label: $field por completar no perfil técnico de teste.";
|
||||||
|
}
|
||||||
|
if(empty($view['prepared']['period_start']) || empty($view['prepared']['period_end']) || empty($view['prepared']['units'][0])) $blocks[]='Cobertura e unidade de medida precisam de estar identificadas neste recorte de teste.';
|
||||||
|
if(count($view['data']['lines'])!==1 || ($view['prepared']['price_mode'] ?? null)!=='added' || $view['data']['currency']!=='MZN') $blocks[]='Caso fora do recorte de uma linha, MZN e preço sem imposto.';
|
||||||
|
$current=array_values(array_filter($view['fiscal_history'],fn($r)=>$r['current'] && (int)$r['snapshot']['input']['profile_proposal_id']===(int)($td['profile_proposal_id'] ?? 0)));
|
||||||
|
$entry=$current[0] ?? null;
|
||||||
|
if(!$entry || $entry['snapshot']['result']['total_document_proposed']===null) $blocks[]='Cálculo actual compatível e representação monetária necessários.';
|
||||||
|
$profile=null;
|
||||||
|
if($entry) {
|
||||||
|
$in=$entry['snapshot']['input']; $profile=$in['profile'];
|
||||||
|
if($date<($profile['valid_from'] ?? '9999') || (!empty($profile['valid_until']) && $date>$profile['valid_until'])) $blocks[]='Perfil não vigente na data prevista do acontecimento de emissão.';
|
||||||
|
}
|
||||||
|
if(DB::table('si_invoices')->where('draft_id',$draft)->orWhere('source_id',$view['source_id'])->exists()) $blocks[]='Já existe documento para este rascunho ou prestação.';
|
||||||
|
unset($view['consulted_at'],$view['revision'],$view['calculation'],$view['fiscal_history']);
|
||||||
|
$payload=['draft_id'=>$draft,'series_id'=>$series,'series_version'=>(int)$p->version,'series_code'=>$sd['code'] ?? '', 'series_definition'=>$sd,'rule_id'=>$rule,'rule_version'=>(int)$t->version,'rule'=>$td,'expected_date'=>$date,'document'=>$view,'calculation_entry'=>$entry,'profile'=>$profile,'blocks'=>$blocks];
|
||||||
|
if(!$blocks) {
|
||||||
|
$sample=$this->snapshot($payload,0,now()->toIso8601String());
|
||||||
|
if(!InvoicePdf::supports(InvoicePdf::lines($sample))) $payload['blocks'][]='Texto incompatível com o PDF v1; rever representação sem substituir caracteres silenciosamente.';
|
||||||
|
}
|
||||||
|
$hash=hash_hmac('sha256',json_encode($payload,JSON_THROW_ON_ERROR),(string)config('app.key'));
|
||||||
|
return ['notice'=>self::NOTICE,'ready'=>$payload['blocks']===[],'review_hash'=>$hash,'payload'=>$payload];
|
||||||
|
}
|
||||||
|
private function snapshot(array $p,int $number,string $instant): array
|
||||||
|
{
|
||||||
|
return ['notice'=>self::NOTICE,'renderer'=>InvoicePdf::VERSION,'draft_id'=>$p['draft_id'],'series_code'=>$p['series_code'],'number'=>$number,'issued_at'=>$instant,'issued_local'=>\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d H:i:s'),'timezone'=>self::ZONE,
|
||||||
|
'series_id'=>$p['series_id'],'series_version'=>$p['series_version'],'series_definition'=>$p['series_definition'],'rule_id'=>$p['rule_id'],'rule_version'=>$p['rule_version'],'rule'=>$p['rule'],'document'=>$p['document'],
|
||||||
|
'calculation'=>array_replace($p['calculation_entry']['snapshot']['result'],['temporal_state'=>'issue_simulated','exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'pending'=>[]]),'prior_calculation_snapshot'=>$p['calculation_entry']['snapshot'],'calculation_id'=>$p['calculation_entry']['id'],'profile_identifier'=>$p['calculation_entry']['snapshot']['input']['profile_identifier'],'profile_version'=>$p['calculation_entry']['snapshot']['input']['profile_version'],'profile'=>$p['profile'],'rate_percent'=>$p['profile']['rate_percent'],
|
||||||
|
'exigibility_date'=>$p['expected_date'],'assessment_period'=>substr($p['expected_date'],0,7),'simulation_only'=>true];
|
||||||
|
}
|
||||||
|
public function issue(int $draft,int $actor,array $input,string $key): array
|
||||||
|
{
|
||||||
|
return $this->command($actor,'service_invoices.issue',function($s)use($draft,$actor,$input,$key) {
|
||||||
|
$in=$this->exact($input,['series_id'=>'required|integer|min:1','rule_id'=>'required|integer|min:1','review_hash'=>'required|string|regex:/^[a-f0-9]{64}$/D']);
|
||||||
|
abort_unless((bool)preg_match('/^[A-Za-z0-9-]{8,100}$/D',$key),422,'Chave de repetição necessária.');
|
||||||
|
$requestHash=hash('sha256',json_encode([$draft,(int)$in['series_id'],(int)$in['rule_id'],$in['review_hash']],JSON_THROW_ON_ERROR));
|
||||||
|
$old=DB::table('si_commands')->where('company_id',$s['company_id'])->where('command_key',$key)->first();
|
||||||
|
if($old) { abort_unless((int)$old->actor_id===$actor && (int)$old->unit_id===$s['unit_id'] && hash_equals($old->request_hash,$requestHash),409,'Repetição com conteúdo/autor/âmbito diferente.'); return json_decode($old->result,true,32,JSON_THROW_ON_ERROR)+['replayed'=>true]; }
|
||||||
|
$review=$this->reviewInside($draft,(int)$in['series_id'],(int)$in['rule_id'],$actor,$s);
|
||||||
|
abort_unless(hash_equals($review['review_hash'],$in['review_hash']),409,'Revisão desactualizada; reveja os dados e a data antes de emitir.');
|
||||||
|
abort_unless($review['ready'],422,implode(' ',$review['payload']['blocks']));
|
||||||
|
$p=$review['payload']; $sd=$p['series_definition'];
|
||||||
|
DB::table('si_counters')->insertOrIgnore($s+['code'=>$sd['code'],'last_number'=>$sd['first_number']-1]);
|
||||||
|
$counter=DB::table('si_counters')->where($s)->where('code',$sd['code'])->lockForUpdate()->firstOrFail();
|
||||||
|
$number=(int)$counter->last_number+1; abort_if($number>999999999,422,'Limite de numeração atingido.');
|
||||||
|
DB::table('si_counters')->where('id',$counter->id)->update(['last_number'=>$number]);
|
||||||
|
$instant=now()->toIso8601String();
|
||||||
|
abort_unless(\Carbon\CarbonImmutable::parse($instant)->timezone(self::ZONE)->format('Y-m-d')===$p['expected_date'],409,'Data mudou durante a confirmação; reveja antes de emitir.');
|
||||||
|
$snapshot=$this->snapshot($p,$number,$instant);
|
||||||
|
$snapshot['document_lines']=InvoicePdf::lines($snapshot); // Freeze browser representation as well as PDF bytes.
|
||||||
|
$bytes=$this->pdf->render($snapshot);
|
||||||
|
$id=DB::table('si_invoices')->insertGetId($s+['draft_id'=>$draft,'source_id'=>$p['document']['source_id'],'series_id'=>$p['series_id'],'rule_id'=>$p['rule_id'],'series_code'=>$sd['code'],'number'=>$number,'snapshot'=>json_encode($snapshot,JSON_THROW_ON_ERROR),'pdf_base64'=>base64_encode($bytes),'pdf_sha256'=>hash('sha256',$bytes),'actor_id'=>$actor,'issued_at'=>$instant]);
|
||||||
|
$event=DB::table('si_events')->insertGetId(['invoice_id'=>$id,'type'=>'test_invoice.captured','payload'=>json_encode(['invoice_id'=>$id,'simulation_only'=>true],JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||||
|
DB::table('si_outbox')->insert(['event_id'=>$event,'state'=>'captured_no_processing']);
|
||||||
|
Audit::record('service_invoice.test_issued',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'event_id'=>$event,'version'=>1]);
|
||||||
|
$result=['id'=>$id,'series_code'=>$sd['code'],'number'=>$number,'notice'=>self::NOTICE];
|
||||||
|
DB::table('si_commands')->insert($s+['actor_id'=>$actor,'command_key'=>$key,'request_hash'=>$requestHash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||||
|
return $result+['replayed'=>false];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public function show(int $id,int $actor): array
|
||||||
|
{
|
||||||
|
self::guard(); $s=$this->scope($actor,'service_invoices.view');
|
||||||
|
$r=DB::table('si_invoices')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Documento fora do âmbito.');
|
||||||
|
$snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR);
|
||||||
|
return ['id'=>$r->id,'snapshot'=>$snapshot,'document_lines'=>$snapshot['document_lines'] ?? InvoicePdf::lines($snapshot),'pdf_sha256'=>$r->pdf_sha256,'corrections'=>DB::table('si_correction_requests')->where('invoice_id',$id)->get(['id','reason','actor_id','created_at'])->all()];
|
||||||
|
}
|
||||||
|
public function pdf(int $id,int $actor): string
|
||||||
|
{
|
||||||
|
$this->show($id,$actor); $r=DB::table('si_invoices')->where('id',$id)->first(['pdf_base64','pdf_sha256']);
|
||||||
|
$bytes=base64_decode($r->pdf_base64,true); abort_unless($bytes!==false && hash_equals($r->pdf_sha256,hash('sha256',$bytes)),409,'Integridade do PDF não confirmada.'); return $bytes;
|
||||||
|
}
|
||||||
|
public function requestCorrection(int $id,int $actor,array $input): array
|
||||||
|
{
|
||||||
|
return $this->command($actor,'service_invoices.correction_request',function($s)use($id,$actor,$input) {
|
||||||
|
$d=$this->exact($input,['reason'=>'required|string|max:2000']); $this->show($id,$actor);
|
||||||
|
$request=DB::table('si_correction_requests')->insertGetId(['invoice_id'=>$id,'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()]);
|
||||||
|
Audit::record('service_invoice.correction_requested',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'reason'=>$d['reason']]);
|
||||||
|
return ['id'=>$request,'state'=>'pending_no_fiscal_effect'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$old=substr(DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d,6);
|
||||||
|
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
|
||||||
|
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission IN ('invoice_series.view','invoice_series.propose','invoice_series.approve') AND unit_id IS NULL) OR (permission IN ('service_invoices.view','service_invoices.issue','service_invoices.correction_request') AND unit_id IS NOT NULL))");
|
||||||
|
Schema::create('si_proposals',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
|
||||||
|
$t->string('kind'); $t->string('identifier'); $t->unsignedInteger('version'); $t->jsonb('data');
|
||||||
|
$t->foreignId('previous_id')->nullable()->constrained('si_proposals'); $t->foreignId('actor_id')->constrained('users'); $t->timestampTz('created_at');
|
||||||
|
$t->unique(['company_id','kind','identifier','version']);
|
||||||
|
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
|
||||||
|
});
|
||||||
|
Schema::create('si_decisions',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->foreignId('proposal_id')->unique()->constrained('si_proposals'); $t->foreignId('actor_id')->constrained('users'); $t->string('decision'); $t->text('reason'); $t->timestampTz('created_at');
|
||||||
|
});
|
||||||
|
Schema::create('si_counters',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id'); $t->string('code'); $t->unsignedBigInteger('last_number');
|
||||||
|
$t->unique(['company_id','code']);
|
||||||
|
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
|
||||||
|
});
|
||||||
|
Schema::create('si_invoices',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
|
||||||
|
$t->foreignId('draft_id')->unique()->constrained('a_billing_drafts'); $t->foreignId('source_id')->unique()->constrained('a_service_drafts');
|
||||||
|
$t->foreignId('series_id')->constrained('si_proposals'); $t->foreignId('rule_id')->constrained('si_proposals');
|
||||||
|
$t->string('series_code'); $t->unsignedBigInteger('number'); $t->jsonb('snapshot'); $t->text('pdf_base64'); $t->string('pdf_sha256',64);
|
||||||
|
$t->foreignId('actor_id')->constrained('users'); $t->timestampTz('issued_at');
|
||||||
|
$t->unique(['company_id','series_code','number']);
|
||||||
|
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
|
||||||
|
});
|
||||||
|
Schema::create('si_events',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->foreignId('invoice_id')->unique()->constrained('si_invoices'); $t->string('type'); $t->jsonb('payload'); $t->timestampTz('created_at');
|
||||||
|
});
|
||||||
|
Schema::create('si_outbox',function(Blueprint $t) {
|
||||||
|
$t->foreignId('event_id')->primary()->constrained('si_events'); $t->string('state')->default('captured_no_processing');
|
||||||
|
});
|
||||||
|
Schema::create('si_commands',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
|
||||||
|
$t->foreignId('actor_id')->constrained('users'); $t->string('command_key',100); $t->string('request_hash',64); $t->jsonb('result'); $t->timestampTz('created_at');
|
||||||
|
$t->unique(['company_id','command_key']);
|
||||||
|
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
|
||||||
|
});
|
||||||
|
Schema::create('si_correction_requests',function(Blueprint $t) {
|
||||||
|
$t->id(); $t->foreignId('invoice_id')->constrained('si_invoices'); $t->foreignId('actor_id')->constrained('users'); $t->text('reason'); $t->timestampTz('created_at');
|
||||||
|
});
|
||||||
|
DB::unprepared(<<<'SQL'
|
||||||
|
CREATE OR REPLACE FUNCTION sgf_si_proposal_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.kind NOT IN ('series','temporal') OR (NEW.previous_id IS NULL AND NEW.version<>1)
|
||||||
|
OR (NEW.previous_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM si_proposals p WHERE p.id=NEW.previous_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.kind=NEW.kind AND p.identifier=NEW.identifier AND p.version=NEW.version-1)) THEN RAISE EXCEPTION 'SGF_SI_PROPOSAL_SCOPE'; END IF;
|
||||||
|
IF NEW.kind='temporal' AND NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=(NEW.data->>'draft_id')::bigint AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id) THEN RAISE EXCEPTION 'SGF_SI_DRAFT_SCOPE'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END; $$;
|
||||||
|
CREATE TRIGGER si_proposal_guard BEFORE INSERT ON si_proposals FOR EACH ROW EXECUTE FUNCTION sgf_si_proposal_guard();
|
||||||
|
CREATE OR REPLACE FUNCTION sgf_si_decision_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.decision NOT IN ('approved','refused') OR EXISTS(SELECT 1 FROM si_proposals WHERE id=NEW.proposal_id AND actor_id=NEW.actor_id) THEN RAISE EXCEPTION 'SGF_SI_SELF_DECISION'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END; $$;
|
||||||
|
CREATE TRIGGER si_decision_guard BEFORE INSERT ON si_decisions FOR EACH ROW EXECUTE FUNCTION sgf_si_decision_guard();
|
||||||
|
CREATE OR REPLACE FUNCTION sgf_si_counter_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'SGF_SI_COUNTER_DELETE'; END IF;
|
||||||
|
IF ROW(NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.code,NEW.id) IS DISTINCT FROM ROW(OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.code,OLD.id) OR NEW.last_number<>OLD.last_number+1 THEN RAISE EXCEPTION 'SGF_SI_COUNTER_CHANGE'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END; $$;
|
||||||
|
CREATE TRIGGER si_counter_guard BEFORE UPDATE OR DELETE ON si_counters FOR EACH ROW EXECUTE FUNCTION sgf_si_counter_guard();
|
||||||
|
CREATE OR REPLACE FUNCTION sgf_si_invoice_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=NEW.draft_id AND b.source_id=NEW.source_id AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id)
|
||||||
|
OR NOT EXISTS(SELECT 1 FROM si_proposals p JOIN si_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.series_id AND p.kind='series' AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.data->>'code'=NEW.series_code)
|
||||||
|
OR NOT EXISTS(SELECT 1 FROM si_proposals p JOIN si_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.rule_id AND p.kind='temporal' AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND (p.data->>'draft_id')::bigint=NEW.draft_id)
|
||||||
|
OR NOT EXISTS(SELECT 1 FROM si_counters c WHERE c.tenant_id=NEW.tenant_id AND c.company_id=NEW.company_id AND c.unit_id=NEW.unit_id AND c.code=NEW.series_code AND c.last_number=NEW.number)
|
||||||
|
OR NEW.snapshot->>'notice' IS DISTINCT FROM 'TESTE — SEM VALIDADE FISCAL'
|
||||||
|
THEN RAISE EXCEPTION 'SGF_SI_INVOICE_SCOPE'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END; $$;
|
||||||
|
CREATE TRIGGER si_invoice_guard BEFORE INSERT ON si_invoices FOR EACH ROW EXECUTE FUNCTION sgf_si_invoice_guard();
|
||||||
|
SQL);
|
||||||
|
foreach(['si_proposals','si_decisions','si_invoices','si_events','si_outbox','si_commands','si_correction_requests'] as $table) DB::statement("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
|
||||||
|
}
|
||||||
|
public function down(): void { throw new RuntimeException('Document history requires a reviewed preservation procedure.'); }
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Factura de serviço — âmbito mínimo de emissão
|
||||||
|
|
||||||
|
26/10/2026, Africa/Johannesburg. Versão 0.3, **âmbito com implementação técnica isolada autorizada posteriormente**, conforme secção 10. Base preservada: checkpoint `9f48004`, cálculo condicionado aceite. Configuração/concessões reais e habilitação de emissão não são autorizadas pelo desenvolvimento técnico.
|
||||||
|
|
||||||
|
## 1. Base reutilizada e recorte proposto
|
||||||
|
|
||||||
|
Reutilizar a ficha local `REAL_SERVICE_FISCAL_ASSESSMENT.md`, [aceitação do cálculo condicionado](CONDITIONAL_CALCULATION_ACCEPTANCE.md), [matriz de informação documental](BILLING_DOCUMENTS_FIELD_SPEC.md), [especificação e matriz fiscal reutilizada](SERVICE_INVOICE_CALCULATION_SPEC.md), preparação documental, pré-visualização e B1. A ficha nominativa permanece local; esta especificação não exporta o cliente ou os registos da base.
|
||||||
|
|
||||||
|
O perfil aprovado/activo e a decisão temporal aprovada mantêm os seus âmbitos. A segunda permite hoje cálculo condicionado, não determina exigibilidade ou apuramento. Não repetir a sua aprovação para voltar a demonstrar o que já foi aceite; uma autorização para emitir é um acto distinto.
|
||||||
|
|
||||||
|
**Primeiro recorte:** uma factura de serviço, uma linha, uma moeda explícita, preço sem imposto, sem descontos, retenções, adiantamentos, câmbio ou recorrência automática. Uma prestação e um rascunho existentes originam no máximo uma factura neste recorte; facturação parcial/dividida fica excluída. Factura não é recibo ou factura-recibo; emissão não declara pagamento nem reconhecimento contabilístico.
|
||||||
|
|
||||||
|
**Entrega mínima proposta:** revisão de prontidão → confirmação explícita de emissão → identidade documental e fotografia imutável → consulta e reprodução fiel do documento. O desenho pode ser implementado e provado em testes isolados antes da habilitação real; enquanto faltarem condições legais, o ambiente normal recusa emitir e a prova continua claramente sintética. Não apresentar uma simulação como factura válida.
|
||||||
|
|
||||||
|
## 2. Fontes reutilizadas e limites jurídicos
|
||||||
|
|
||||||
|
Não foi repetido o cotejo do InvesteMoz, consultado o portal ou efectuada nova pesquisa normativa nesta etapa. Reutilizam-se as referências/versões e os hashes já registados na matriz e na ficha:
|
||||||
|
|
||||||
|
| Matéria | Origem já identificada | Uso e limite neste incremento |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Base, taxa, exactidão e representação | IM-F v1.2, IM-P, IM-A; CIVA arts. 15/17; Lei 22/2022 republicada | Cálculo aceite é reutilizado. Política operacional de truncagem não é prova de conformidade universal de factura |
|
||||||
|
| Acontecimento e exigibilidade | IM-E, recibos L1 de 25/10/2026; CIVA arts. 7/8/17(2) | Distinguir regra pré-validada, facto efectivo, parcela e mês. Não converter próxima facturação em emissão/exigibilidade |
|
||||||
|
| Informação da factura | Matriz documental; CIVA art. 27, incluindo n.º 5 c/d já cotejado | Inventário de campos e componentes; confirmar cobertura integral dos requisitos aplicáveis antes de habilitar emissão. Não declarar que apenas os campos já implementados bastam legalmente |
|
||||||
|
| Alterações de 2026 | Lei 10/2025, PDF 12–15 do exemplar BR 248/suplemento 1; art. 27(10), PDF 14, e art. 32 | A submissão à AT é matéria distinta da apresentação visual e da identidade interna. O art. 32 rege os ramos declarativos já documentados, não numeração ou homologação do SGF |
|
||||||
|
| Regulamentação de emissão e software | RIVA art. 21 nos exemplares históricos já sinalizados; Decreto 8/2017/rectificações; comunicação regulamentar de Setembro de 2026 | Referências históricas/anúncio não demonstram, por si, o procedimento vigente aplicável em 2026. Concluir apenas a lacuna específica necessária à emissão, sem refazer taxa/base/aritmética |
|
||||||
|
|
||||||
|
**Autorização/certificação do software ainda não demonstrada:** não foi apresentada prova de que o SGF esteja autorizado/certificado/homologado para emitir, nem prova documentada de dispensa/inaplicabilidade. Também não está demonstrado qual procedimento vigente distingue autorização de sistemas/numeração, tipografias, facturação electrónica e comunicação de documentos à AT. São questões distintas; não inventar um número de certificação, assinatura, QR, fornecedor ou integração, nem afirmar que todos são obrigatórios ou dispensados.
|
||||||
|
|
||||||
|
Antes de emissão real: obter disposição oficial aplicável, artigo, vigência, âmbito deste modo de emissão e evidência do cumprimento ou da dispensa. Se existir acto/autorização, conservar referência verificável, entidade competente, titular/versão/instalação abrangidos e limites. Se houver comunicação prévia, formato obrigatório ou aceitação externa necessária à validade, definir o protocolo e estados com fundamento. A ausência destas provas bloqueia habilitação real; testes de aritmética, Git ou aprovação empresarial não as substituem.
|
||||||
|
|
||||||
|
## 3. Prontidão: o que bloqueia e o que nasce ao emitir
|
||||||
|
|
||||||
|
| Elemento | Antes de emitir | No acto de emissão / efeito posterior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Emitente/cliente | Dados exigidos para o caso, origem e validação documental identificados. Nome suficiente para rascunho não significa prontidão de emissão | Fixar exactamente os dados usados; alterações posteriores do cadastro não mudam a factura |
|
||||||
|
| Serviço/prestação | Natureza, contraprestação, unidade de medida e cobertura relevantes demonstradas; não certificar execução a partir de rascunho | Preservar referências e versão da prestação. A factura não cria automaticamente prova de entrega |
|
||||||
|
| Perfil e resultado | Perfil aprovado, activo, vigente e compatível; resultado exacto actual. Representação documental aplicável validada, sem truncagem da base ou ajustes silenciosos | Revalidar contra a data/acontecimento efectivamente aplicável; se mudar a regra ou o montante, recusar e voltar à revisão |
|
||||||
|
| Decisão temporal | A actual aprovação de `pending` não basta. É necessária regra de determinação aprovada, com condições verificáveis para o ramo aplicável e prazo de emissão fundamentado | Quando a emissão for o acontecimento pertinente e tempestivo, capturar o instante real e determinar a consequência com essa regra. Não exigir uma factura já emitida para autorizar a primeira emissão |
|
||||||
|
| Datas anteriores relevantes | Se o facto gerador, prazo vencido ou outro acontecimento anterior for relevante, precisa de evidência antes da emissão | A emissão posterior não apaga ou desloca exigibilidade já ocorrida. Casos fora do primeiro recorte são recusados, não normalizados à data actual |
|
||||||
|
| Emissão efectiva | Não inventar uma data real antecipada para conseguir aprovação. Revisão pode mostrar «será fixada ao emitir» e a data esperada da operação | Instante do servidor com timezone explícita; guardar UTC, zona e data civil usada. Sem retrodatação/editabilidade neste primeiro recorte; mudança de dia desde a revisão obriga a rever |
|
||||||
|
| Período de apuramento | Não exige um mês inventado enquanto falta o acontecimento; exige regra aprovada que permita determiná-lo sem ambiguidade | Derivar do ramo de exigibilidade efectivamente fundamentado, na mesma confirmação; não deriva do ano da série ou do vencimento do cliente |
|
||||||
|
| Vencimento | Condições comerciais identificadas quando necessárias; «30 dias de serviço» não é prazo de crédito | Calcular apenas sob convenção acordada; não usar a próxima facturação como vencimento |
|
||||||
|
| Série e número | Série válida, âmbito/numeração inicial/fonte e processo de autorização identificados; ausência ou conflito bloqueia | Atribuir número definitivo uma única vez, dentro da operação controlada; nunca ao abrir a pré-visualização |
|
||||||
|
| Software e comunicação | Prova de conformidade/autorização/dispensa e processo de comunicação aplicável resolvidos | Se aceitação externa for condição, não marcar emitido antes dela; se comunicação for posterior, estado/prazo próprios e retries sem nova factura |
|
||||||
|
| Pagamento ao Estado | Não é necessário ter o apuramento líquido pago para criar uma factura; conhecer a regra declarativa não implica saldo calculado | Esta emissão não liquida o saldo ao Estado nem cria recebimento do cliente |
|
||||||
|
|
||||||
|
O sistema deve apresentar bloqueios concretos e a fonte/condição correspondente. Não promover todos os campos de gestão opcionais a bloqueios legais: logótipo, contactos e instruções bancárias não passam a obrigatórios por constarem dos exemplos. Não reabrir a classificação contabilística para satisfazer emissão; se a operação exigir classificação, reutilizar a resolução existente sob o seu âmbito.
|
||||||
|
|
||||||
|
**Estado do caso conhecido:** a última conferência da pré-visualização mostrava dados documentais do emitente/cliente por completar e cobertura final não indicada; essa observação não é uma nova leitura da base. O cálculo e perfil estão aceites, mas não há prova de prontidão documental/fiscal integral. A decisão temporal permanece `pending`. Só confirmar como bloqueante cada falta que seja necessária ao caso/regra de emissão; não preencher a partir das imagens ou da data prevista de facturação.
|
||||||
|
|
||||||
|
## 4. Série, identidade e numeração — proposta técnica
|
||||||
|
|
||||||
|
- Identidade estável da factura separada do ID do rascunho, referência INT-B1, série/contador e ficheiro de apresentação. Unicidade proposta **empresa emitente + tipo + série + número**; série não é necessariamente ano e não pertence ao tenant como autorização geral.
|
||||||
|
- Uma série inicialmente para o tipo factura no âmbito aprovado; relação à unidade, ano/período e continuação de numeração exige decisão própria. Não criar automaticamente «FT/2026», começar em 1 ou importar o número das imagens.
|
||||||
|
- Cadastro de série versionado, estado aberto/encerrado, vigência, fonte/autorizações e contador protegido. Série encerrada não recebe novos números. Abrir/encerrar série não apaga documentos ou reutiliza números.
|
||||||
|
- Alocação sob bloqueio transaccional do contador; proibir duplicação/reutilização por API e na base, incluindo operações em massa. Concorrência em rascunhos diferentes da mesma série dá números distintos.
|
||||||
|
- Proposta local: atribuição de número e factura na mesma transacção, sem reserva exterior prévia. Falha anterior ao commit não produz uma factura; número não comunicado fora da transacção. Se regras legais/serviço externo exigirem reserva ou numeração não transaccional, rever este ponto e registar reservas/falhas explicitamente; não prometer ausência universal de lacunas ou apagar evidência de falhas.
|
||||||
|
- Reimpressão/reprodução conserva identidade, número, data e fotografia; rótulos original/cópia/duplicado e formato dependem das regras documentais aplicáveis, não de nova numeração.
|
||||||
|
|
||||||
|
## 5. Comando de emissão, fotografia e duplicados
|
||||||
|
|
||||||
|
1. **Rever emissão:** no contexto validado, consultar o rascunho actual e compor revisão coerente de cliente/emitente, prestação, preparação, cálculo, perfil, decisão/regra temporal, série, modelo e eventuais autorizações. Mostrar consequências e bloqueios; nenhuma atribuição de número.
|
||||||
|
2. **Confirmar:** pedido autenticado, CSRF, permissão própria, token de contexto, versões exactas, chave de idempotência e resumo criptográfico da revisão. O navegador não autoriza âmbito nem escolhe livremente taxa, número, data efectiva ou estado.
|
||||||
|
3. **Revalidar e fixar:** sob bloqueios consistentes, verificar direitos, prontidão, série, versões e regra temporal. Capturar o instante de emissão, resolver a consequência fiscal quando suportada, reconciliar montantes e atribuir número. Alteração material/viragem de data exige nova revisão, não substituição silenciosa.
|
||||||
|
4. **Commit atómico:** factura, fotografia, identidade/série/número, vínculo exclusivo ao rascunho/prestação deste recorte, auditoria e evento durável/outbox. Falha de qualquer componente desfaz o conjunto local. Nenhum consumidor contabilístico ou financeiro é executado neste incremento.
|
||||||
|
5. **Recuperar:** chave repetida com mesmo autor/conteúdo devolve a factura existente; mesma chave com conteúdo distinto é conflito. Duplo clique, timeout após commit, repetição de tarefa ou nova chave sobre rascunho já emitido não criam segunda factura. Disponibilizar consulta do resultado após resposta perdida.
|
||||||
|
|
||||||
|
Fotografia imutável: emitente/cliente e dados documentais usados; serviço/descrição/unidade/quantidade/preço; moeda; período/condições/vencimento; base, imposto exacto/documental, diferença e total; tratamento/taxa/fontes/política e versões; decisão/regra temporal e acontecimentos determinados; série/número/data efectiva; contexto/autor; versão de modelo e activos usados; instruções de pagamento se apresentadas; referência da conformidade/autorização aplicável. Preservar só o necessário, sem segredos.
|
||||||
|
|
||||||
|
A consulta emitida usa exclusivamente essa fotografia. Não reconstruir dados com cadastros actuais; guardar versão do renderizador/modelo e hash do artefacto final se existir. Hash é controlo de integridade técnico, não assinatura fiscal ou certificação. Falha posterior de geração de ficheiro não provoca nova emissão/novo número: reproduzir o mesmo documento fixado, conforme os requisitos do canal.
|
||||||
|
|
||||||
|
B1 continua exclusivamente confirmação interna histórica; não é convertido em factura emitida nem reescrito para ganhar dados ausentes. No mínimo, rascunho já bloqueado por B1 não é emitido directamente. Eventual passagem a novo rascunho ligado à origem precisa de desenho explícito, sem duplicação oculta da prestação.
|
||||||
|
|
||||||
|
## 6. Permissões e correcções
|
||||||
|
|
||||||
|
**Direitos propostos, ainda sem concessão:** `service_invoices.view` e `.issue` na unidade; `invoice_series.view` e `.configure` no âmbito da empresa, com restrições de uso por unidade. Confirmar nomenclatura final e matriz de contas antes de aplicar qualquer direito. Calcular, aprovar perfil/decisão temporal, gerir aparência ou confirmar B1 não concede emitir ou configurar séries. Revogação tem efeito no próximo pedido. Não atribuir automaticamente emissão a Osmane ou Samira.
|
||||||
|
|
||||||
|
Emitida significa imutável: recusar edição, eliminação, transferência de empresa/unidade e reabertura do original, também por SQL em massa. Antes da emissão pode corrigir-se o rascunho com novas versões/revisões; depois dela, a correcção deve ser documento/acontecimento próprio, autorizado, com motivo, autor, datas e ligação exacta ao original/linhas e impacto fiscal explicado.
|
||||||
|
|
||||||
|
No primeiro incremento, propor **pedido de correcção pendente, sem efeito monetário**, e consulta do original. Não implementar nota de crédito/débito ou anulação fiscal sem fundamento e âmbito aprovados. Não usar «cancelar» como apagamento, reutilizar número ou emitir substituição independente como atalho. Antes de habilitar emissão real, definir o procedimento legal e operacional para corrigir erros inevitáveis; se não estiver disponível no SGF, identificar procedimento externo autorizado e preservação das evidências, sem presumir que existe. Ausência de uma via válida de correcção é bloqueio de prontidão real.
|
||||||
|
|
||||||
|
## 7. Ecrãs e critérios de aceitação
|
||||||
|
|
||||||
|
Ecrãs mínimos: revisão de emissão no rascunho, com bloqueios e consequências; detalhe da factura emitida/reprodução fiel; configuração controlada da série; histórico e eventual pedido de correcção. Sem portal de clientes, envio por email, fornecedores, cobranças, stock ou contabilidade neste recorte. Canal/artefacto final precisa de decisão antes de implementar emissão real; uma página HTML não é presumida suficiente para todas as obrigações de entrega/conservação.
|
||||||
|
|
||||||
|
Provas isoladas com identidades sintéticas e `sgf_tempo_test`, sem operações fictícias na empresa real:
|
||||||
|
|
||||||
|
1. Prontidão incompleta, conformidade do software não demonstrada, série ausente/encerrada, moeda/perfil/regra incompatíveis e permissões insuficientes recusam emissão e não consomem uma identidade definitiva.
|
||||||
|
2. Cenário completo sintético produz exactamente uma factura, número único, data efectiva controlada, fotografia imutável e evento durável. Mudar cadastros/apresentação posteriormente não muda o emitido.
|
||||||
|
3. Pendência temporal aceite para cálculo não passa como autorização de emissão. Ramo dependente da emissão fixa o facto na confirmação, sob regra previamente aprovada; ramo anterior não é deslocado por data de emissão. Data futura prevista não se torna facto; viragem de dia obriga a rever.
|
||||||
|
4. Duplo clique, mesma/diferente chave, timeout antes/depois do commit e dois processos concorrentes não duplicam factura ou número. Falhas injectadas em contador/fotografia/auditoria/outbox provam atomicidade e recuperação.
|
||||||
|
5. Isolamento entre empresas do mesmo tenant e unidades, identificadores adulterados, revogação, versões antigas e alterações em massa recusados. Aprovações contabilísticas e B1 preservados, sem reruns desnecessários das entregas aceites.
|
||||||
|
6. Reprodução posterior usa a fotografia, não recalcula com regras actuais e não renumera. Pedir correcção não altera montantes/estado fiscal do original; ausência de correcção fiscal implementada fica explícita.
|
||||||
|
7. Nenhuma emissão marca recebimento, liquidação ao Estado ou lançamento como realizado. Provas sintéticas não retiram bloqueios de conformidade real nem seleccionam fornecedores.
|
||||||
|
|
||||||
|
## 8. Decisões realmente novas para delimitar implementação
|
||||||
|
|
||||||
|
| Decisão | Natureza / saída necessária |
|
||||||
|
| --- | --- |
|
||||||
|
| Série inicial | Empresa, tipo, código, âmbito por unidade/período, numeração inicial/continuação e evidência de autorização aplicável. Não repetir decisões de regime, moeda ou taxa já aprovadas |
|
||||||
|
| Responsáveis e direitos | Quem pode emitir, configurar/encerrar série e pedir/tratar correcção; contas/âmbitos exactos sujeitos a confirmação específica |
|
||||||
|
| Canal documental | Representação a entregar/conservar e formato requerido: navegador, PDF ou outro com fundamento. Não escolher integração/fornecedor por omissão |
|
||||||
|
| Regra temporal para emissão | Quem propõe/valida a regra prospectiva e quais evidências fecham tempestividade/cobertura; preservar o facto efectivo para o momento de emissão. A aprovação anterior de pendência não é revogada nem convertida em determinação |
|
||||||
|
| Conformidade e correcção | Evidência oficial específica para software/séries/comunicação, representação monetária aplicável e procedimento válido de correcção. É validação normativa/operacional, não preferência visual ou novo ensaio aritmético |
|
||||||
|
|
||||||
|
**Recomendação de execução futura:** primeiro resolver os bloqueios de prontidão que dependem de evidência/decisão, mantendo o sistema normal sem emitir; depois autorizar um único incremento de emissão/reprodução com as regras fechadas e provas sintéticas. Não criar um documento com aparência de factura fiscal apenas para contornar uma pendência legal. Se só for autorizada preparação técnica antes dessas provas, o modo real permanece bloqueado explicitamente.
|
||||||
|
|
||||||
|
Nesta revisão alterou-se apenas documentação. Nenhuma emissão, concessão, série, migração, recebimento, lançamento, teste destrutivo, publicação ou novo checkpoint foi executado. Não reabre `9f48004`.
|
||||||
|
|
||||||
|
## 9. Decisões de desenho confirmadas pelo utilizador
|
||||||
|
|
||||||
|
Em 26/10/2026, o utilizador respondeu **SIM** à proposta de responsabilidades e navegador + PDF. Prevalece sobre as opções ainda abertas nas secções anteriores:
|
||||||
|
|
||||||
|
- **Osmane prepara e emite.** Emitir será uma permissão nova, a apresentar com conta, empresa/unidade e acção exactas para confirmação específica antes da concessão. Não é derivada de calcular, preparar ou gerir acessos; nenhuma concessão foi executada nesta revisão.
|
||||||
|
- **Samira mantém a validação fiscal.** Não recebe emissão ou administração por consequência desta escolha.
|
||||||
|
- **Osmane propõe a regra temporal de emissão; Samira valida ou recusa a versão exacta.** Reutilizar a separação de funções existente. Não converter a decisão temporal pendente aceite no incremento anterior em determinação de exigibilidade/apuramento ou autorização para emitir.
|
||||||
|
- **Consulta no navegador e PDF reproduzível da mesma fotografia imutável, sem envio automático.** Ambos apresentam o mesmo documento/identidade, conteúdo, datas e valores. Preservar a versão dos recursos/renderizador e, quando gerado, os bytes/hash do PDF; reconsultar ou descarregar não gera nova factura ou número. Não escolher agora biblioteca/dependência ou alterar versões.
|
||||||
|
- **Série real ainda por decidir:** continuação de série existente ou série própria do SGF, com suporte e controlo de transição para impedir sobreposição com outro emissor. Não atribuir código, número inicial ou data por omissão. Quem propõe/configura/encerra séries e quem trata correcções continua por indicar; não atribuir essas funções automaticamente a quem emite ou valida.
|
||||||
|
- **Bloqueio real mantido:** qualquer implementação futura antes de fechar a conformidade só pode ser demonstrada em ambiente isolado, com emissão real bloqueada. Esta confirmação de desenho não inicia implementação.
|
||||||
|
|
||||||
|
### Verificação dirigida das lacunas de conformidade
|
||||||
|
|
||||||
|
Consulta em 26/10/2026 limitada a software, numeração/comunicação e correcção; não repetiu o cotejo de taxa/base/exigibilidade já reutilizado. Não se alterou o InvesteMoz ou acederam às suas configurações/bases.
|
||||||
|
|
||||||
|
| Lacuna específica | Evidência localizada / alcance | Prova ainda necessária |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Software: submissão versus aprovação | O índice oficial da [AT — comunicados](https://www.at.gov.mz/informacoes/comunicado-de-imprensa) apresenta uma entrada datada de 01/02/2026 intitulada lista provisória de softwares de facturação. A abertura do endereço não disponibilizou articulado/anexo legível nesta consulta. A existência de lista provisória não demonstra certificação do SGF nem dispensa | Procedimento oficial aplicável, requisitos e evidência específica de autorização/certificação/dispensa para este software/instalação. Não declarar fechado com base em publicidade comercial ou lista de submissões |
|
||||||
|
| Regulamento e numeração/comunicação | A [comunicação oficial do Governo](https://portaldogoverno.gov.mz/governo-introduz-alteracoes-nos-regulamentos-fiscais-para-reforcar-eficiencia-tributaria/) anuncia alterações ao RIVA, incluindo o art. 21. É evidência de alteração anunciada, não substitui o articulado publicado e a sua eficácia. O art. 27(10) da Lei 10/2025 e o inventário anterior continuam reutilizados | Diploma publicado e disposições executórias que fechem autorização de emissão por computador, série/faixa/numeração e eventual comunicação à AT. Confirmar se a comunicação condiciona emissão ou constitui obrigação posterior. Não inventar API, prazo ou homologação |
|
||||||
|
| Correcção | O princípio aprovado de preservar o original e ligar a correcção permanece. A matriz documental e o percurso de serviços não comprovam forma/prazos fiscais da correcção real | Disposição oficial aplicável a anulação/rectificação e regularização de IVA, conteúdo do documento, evidência de conhecimento pelo destinatário quando exigível e procedimento autorizado. Não confundir recibo, estorno contabilístico ou pedido interno com nota fiscal válida |
|
||||||
|
|
||||||
|
**Resultado:** as lacunas não estão encerradas por esta pesquisa dirigida. Não se conclui que o procedimento ou conhecimento não exista; falta evidência primária suficiente nos materiais efectivamente acessíveis. Não repetir perguntas de desenho já resolvidas nem pesquisas gerais sobre 16% ou cálculo exacto.
|
||||||
|
|
||||||
|
**Lista reduzida de decisões pendentes:** escolha/suporte da série real; responsáveis por configurar/encerrar séries e tratar correcções; confirmação específica dos futuros direitos de emissão; provas oficiais de conformidade acima. Responsáveis de preparação/emissão/validação temporal e canal navegador + PDF ficam fechados. Os factos efectivos de emissão continuam a nascer no próprio acto, sob regra previamente validada.
|
||||||
|
|
||||||
|
## 10. Decisões posteriores e execução técnica autorizada
|
||||||
|
|
||||||
|
O utilizador confirmou **série nova**, Osmane prepara séries e pede correcções, Samira valida a configuração e o tratamento fiscal. Essas decisões estão encerradas, juntamente com Osmane preparar/emitir, propor regra temporal, Samira validar essa regra e navegador + PDF sem envio automático. Código/número inicial reais e novas concessões serão apresentados separadamente antes de aplicação.
|
||||||
|
|
||||||
|
Posteriormente autorizou a implementação técnica e os testes isolados, com emissão real bloqueada até resolver conformidade e com confirmações específicas antes de migração em desenvolvimento/concessões. Resultado, provas, delta e propostas concretas em [entrega técnica de teste](SERVICE_INVOICE_TEST_DELIVERY.md). Não reabre o cálculo aceite nem habilita emissão real.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Emissão documental técnica — demonstração isolada
|
||||||
|
|
||||||
|
26/10/2026, Africa/Johannesburg. Implementação do âmbito autorizado, preservando `9f48004` e as alterações posteriores. **Não é habilitação para emissão real nem aceitação automática da entrega.**
|
||||||
|
|
||||||
|
## Funcional e provado
|
||||||
|
|
||||||
|
- Séries sintéticas novas com proposta, versão anterior explícita e validação por identidade distinta. Série encerrada/futura/desactualizada não substitui silenciosamente configuração vigente; o contador não pode ser reiniciado por uma nova versão. Só códigos `TEST-...` são aceites neste incremento.
|
||||||
|
- Regra temporal prospectiva separada do perfil de cálculo, ligada à versão do rascunho/preparação e à cobertura. Proponente prepara; validador fiscal aprova/recusa. O modo suportado na prova é emissão dentro de uma janela explicitamente fundamentada. A data efectiva nasce na confirmação, com UTC/zona/data civil separados; revisão de outro dia é recusada.
|
||||||
|
- Revisão de prontidão sem número. Confirmação com sessão/CSRF, token de contexto, permissões próprias, hash da revisão e idempotência. Verifica dados do emitente/cliente, período, unidade de medida, cálculo actual e perfil vigente na data do acontecimento. São condições técnicas do cenário de teste, não declaração de catálogo legal universal.
|
||||||
|
- Contador, identidade, fotografia, PDF preservado, auditoria, evento e outbox numa transacção. Unicidade por empresa/série/número e por rascunho/prestação no recorte. Repetição após resposta perdida devolve o documento existente; nova chave não cria segunda factura do mesmo rascunho.
|
||||||
|
- Consulta do navegador e PDF partilham a mesma lista documental produzida da fotografia; essa lista também é fixada no snapshot para não depender de alterações futuras do renderizador. Mudança posterior de cadastros não altera o documento. O PDF é guardado com SHA-256 e devolvido pelos mesmos bytes; não se regenera do cadastro actual. Hash não é certificação ou assinatura fiscal.
|
||||||
|
- Pedido de correcção com motivo e autor, ligado ao original, sem mudar o documento e sem efeito monetário/fiscal. Sem nota de crédito/débito, anulação fiscal ou reabertura.
|
||||||
|
- Aviso **TESTE — SEM VALIDADE FISCAL** no navegador e no cabeçalho/rodapé de cada página PDF. A data de exigibilidade e o período da demonstração são explicitamente simulados; não alteram as pendências do caso real já aceite.
|
||||||
|
|
||||||
|
## Bloqueio real e preservação
|
||||||
|
|
||||||
|
`ServiceInvoicing::guard()` recusa todas as mutações e a via de documento/PDF fora de `testing`/`browser-testing`; dentro destes ambientes executa também `TestingDatabaseGuard`. Não existe flag para habilitar emissão real. A consulta normal apresenta o bloqueio e não depende das novas tabelas já existirem. Alterar a variável de ambiente sem trocar para o destino de testes seguro não ultrapassa a guarda.
|
||||||
|
|
||||||
|
Nenhuma migração, série, concessão, prestação, factura ou demonstração foi criada na base normal por esta entrega. O caso real e o cálculo condicionado permanecem como estavam; B1 e aprovações existentes preservados. Não se alterou o InvesteMoz, não houve comunicação à AT, publicação, recebimento ou lançamento. Nenhum novo checkpoint criado sem pedido.
|
||||||
|
|
||||||
|
## PDF: opção técnica e limites
|
||||||
|
|
||||||
|
`InvoicePdf` é um renderizador interno pequeno para este documento textual, sem bibliotecas/ferramentas ou versões novas. A versão final é `sgf-test-invoice-text-v2`. Suporta texto WinAnsi/Windows-1252, incluindo os caracteres portugueses usados no cenário; caracteres não representáveis causam recusa explícita na prontidão, sem substituição silenciosa. Não é um motor geral de HTML/PDF: não inclui logótipo/imagens, assinatura, QR ou anexos. A consulta emitida também usa o formato documental textual, sem procurar esses recursos no cadastro actual.
|
||||||
|
|
||||||
|
O PDF sintético final tem uma página. Caso de esforço com texto extenso e palavras longas produziu quatro páginas, com cabeçalho/rodapé e margens protegidos. O PDF foi extraído e renderizado para conferência; o hash da descarga final no navegador coincide com o artefacto preservado. Documentos anteriores da prova continuam com os bytes e versão originais do renderizador.
|
||||||
|
|
||||||
|
Artefacto de demonstração: `output/pdf/sgf-tempo-factura-teste.pdf`; contém apenas identidades e pressupostos sintéticos. [Prova agregada do PDF](proofs/invoice-pdf-results.json).
|
||||||
|
|
||||||
|
## Verificações
|
||||||
|
|
||||||
|
**53 testes, 924 asserções**, todos passados, exclusivamente em `sgf_tempo_test`, com filtro `ServiceInvoiceTest|ServiceFiscalTest|ServiceCalculationTest|DocumentPreviewTest|InternalConfirmationTest|DatabaseSafetyTest`. Compilação Vite 8.3.5 passou; versões de ferramentas/dependências mantidas.
|
||||||
|
|
||||||
|
Cobertura: revisão sem consumo de número; dados incompletos e caracteres não suportados; falta de regra prospectiva compatível; edição indevida de campos protegidos; versão/revisão/data antigas; série encerrada; separação de funções e revogação; isolamento; B1 recusado como origem de emissão directa; imutabilidade por operações em massa; repetição da emissão/PDF; pedido de correcção sem efeito; falha injectada no outbox com rollback de contador/documento/evento/auditoria/comando; retry usando o primeiro número válido.
|
||||||
|
|
||||||
|
Dois processos PHP realmente sobrepostos provaram uma inserção e um replay com o mesmo ID, mantendo documentos anteriores e numeração distinta na série partilhada. [Prova de concorrência](proofs/invoice-concurrency.json). O teste não afirma ser prova de RLS ou de conformidade fiscal.
|
||||||
|
|
||||||
|
Navegador Edge: login real sintético, CSRF activo, sessão/cache isolados em `http://127.0.0.1:8001`, apenas `sgf_tempo_test`. Não foi usado `actingAs` como demonstração de login.
|
||||||
|
|
||||||
|
1. Entrar com o gestor sintético; seleccionar **Empresa Horizonte — Demonstração → Unidade Central — Demonstração → Facturas de teste**.
|
||||||
|
2. Consultar série **TEST-SERIE-ISOLADA v1**, número inicial 1, e regra **TEST-ISSUE-RULE v1**, aprovadas por validador distinto. IDs do rascunho aparecem na regra; não dependem de um ID fixo entre repetições.
|
||||||
|
3. Seleccionar rascunho/série/regra → **Rever prontidão**. A revisão mostra 7850, IVA proposto 1256.00, total 9106.00 MZN e que o instante efectivo só será fixado ao confirmar.
|
||||||
|
4. **Confirmar emissão apenas de teste**: atribuição única do número 1, consulta da fotografia e descarga PDF autenticada.
|
||||||
|
5. Scripts concorrentes acrescentaram o documento 2; a revisão de paginação gerou o documento 3 no mesmo cenário, preservando os anteriores. Consultado o documento final e descarregado o respectivo PDF; hash igual ao artefacto entregue. [Imagem do navegador](proofs/invoice-browser.png).
|
||||||
|
6. Instância de prova encerrada antes da execução final dos testes; a aplicação normal não mudou de ligação. Os testes finais recriaram apenas a base de testes; os artefactos sintéticos de prova permanecem.
|
||||||
|
|
||||||
|
Repetição técnica:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test --stop-on-failure '--filter=ServiceInvoiceTest|ServiceFiscalTest|ServiceCalculationTest|DocumentPreviewTest|InternalConfirmationTest|DatabaseSafetyTest'
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/browser-proof.ps1 start
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 php scripts/invoice-fixture.php --env=testing
|
||||||
|
```
|
||||||
|
|
||||||
|
Após o percurso no navegador: `sgf.ps1 php scripts/invoice-concurrency.php --env=testing` e, para o PDF de demonstração, `sgf.ps1 php scripts/invoice-export-proof.php --env=testing`. Parar com `scripts/browser-proof.ps1 stop`. O preparador recusa fixture preenchida; não apaga dados para prosseguir. Credenciais aleatórias ficam apenas no armazenamento local protegido, nunca neste relatório.
|
||||||
|
|
||||||
|
## Delta para eventual aplicação em desenvolvimento — pendente
|
||||||
|
|
||||||
|
Migração `2026_10_26_060000_create_test_invoice_issuance.php`, confirmada **Pending** por leitura de `migrate:status` na base normal. Cria oito tabelas:
|
||||||
|
|
||||||
|
- `si_proposals`, `si_decisions`: configuração/regra e decisões imutáveis.
|
||||||
|
- `si_counters`: contador protegido por empresa/série, âmbito imutável, incremento unitário, sem apagamento.
|
||||||
|
- `si_invoices`: identidade, fotografia, PDF e hash; unicidades por origem e numeração, chaves e trigger de âmbito/configuração/regra.
|
||||||
|
- `si_events`, `si_outbox`: captura durável sem processamento financeiro/contabilístico.
|
||||||
|
- `si_commands`: recuperação/idempotência por empresa/chave/autor/unidade.
|
||||||
|
- `si_correction_requests`: pedidos pendentes sem efeito fiscal.
|
||||||
|
|
||||||
|
Amplia o CHECK de `permission_grants`, preservando as alternativas anteriores; não atribui direitos. Tabelas de história/documentos são append-only. Sem modificação de B1/catálogos/cálculos, sem reset/fresh ou rollback destrutivo automático. Qualquer aplicação em `sgf_tempo_dev` exige confirmação específica deste delta; não foi executada. A migração sozinha não habilita emissão real.
|
||||||
|
|
||||||
|
## Proposta separada de série real — não configurada
|
||||||
|
|
||||||
|
Decisão encerrada: **série nova**, sem continuidade da série anterior. Para discussão concreta, propõe-se **código `SGF-FT-A`, primeiro número `1`**, limitado inicialmente à empresa/unidade autorizadas. São valores técnicos propostos, não regra legal, número reservado ou configuração aplicada. Não há reinício anual implícito. Antes de aplicar: confirmação específica, ausência de sobreposição, suporte de autorização/numeração e vigência efectiva. O código real não é aceite pela actual via de teste, que só admite `TEST-...`.
|
||||||
|
|
||||||
|
Osmane prepara a proposta e pede correcções; Samira valida a configuração e o tratamento fiscal. A escolha dessas responsabilidades não atribui automaticamente permissões nem autoriza configurar a série real enquanto faltarem as condições.
|
||||||
|
|
||||||
|
## Proposta separada de concessões — nenhuma aplicada
|
||||||
|
|
||||||
|
Para as contas existentes já identificadas no âmbito aprovado (conferência final antes de aplicar):
|
||||||
|
|
||||||
|
| Conta/função acordada | Novos direitos propostos | Âmbito |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Osmane, conta #1 | `invoice_series.view`, `invoice_series.propose` | Empresa #1; actuação apenas nas unidades já autorizadas |
|
||||||
|
| Osmane, conta #1 | `service_invoices.view`, `service_invoices.issue`, `service_invoices.correction_request` | Exclusivamente unidade SEGI #1 da empresa #1 |
|
||||||
|
| Samira, conta #2 | `invoice_series.view`, `invoice_series.approve` | Empresa #1; validação apenas nas unidades já autorizadas |
|
||||||
|
| Samira, conta #2 | `service_invoices.view` | Exclusivamente unidade SEGI #1 da empresa #1 |
|
||||||
|
|
||||||
|
São **oito concessões propostas**. A proposta/validação da regra temporal reutiliza os direitos `service_fiscal.propose/approve` existentes e o modelo de identidades distintas; não exige nova aprovação do perfil fiscal aceite. Nenhuma concessão de emissão por Samira, validação por Osmane, administração, comunicação externa, lançamento ou correcção fiscal automática. Matriz real requer confirmação específica antes de aplicação. A emissão normal permanece bloqueada mesmo com estes direitos.
|
||||||
|
|
||||||
|
## Condições antes de emissão real
|
||||||
|
|
||||||
|
Continuam as provas específicas da secção 9 de [âmbito de emissão](SERVICE_INVOICE_ISSUANCE_SCOPE.md): autorização/certificação/dispensa aplicável ao software; suporte de série/numeração e eventual comunicação; procedimento fiscal válido de correcção; prontidão documental e regra temporal concretas. A janela de emissão e os dados completos da fixture são pressupostos sintéticos, não resolução dessas lacunas do caso real.
|
||||||
|
|
||||||
|
A activação real de emissão exigirá novo âmbito explícito e alteração revista do bloqueio; não é uma configuração disponível neste incremento. A aceitação anterior do cálculo condicionado e este desenvolvimento técnico não a autorizam.
|
||||||
|
|
||||||
|
## Ficheiros principais
|
||||||
|
|
||||||
|
`ServiceInvoicing.php`, `InvoicePdf.php`, `Http/ServiceInvoiceController.php`, migração `060000`, captura interna transaccional em `DocumentPreview.php`, rotas protegidas, `ServiceInvoiceWorkspace.jsx`, navegação/CSS, `ServiceInvoiceTest.php`, `InvoiceFixture.php` e scripts `invoice-*`. Artefactos/provas contêm apenas dados sintéticos. Sem novo checkpoint ou publicação.
|
||||||
|
|
||||||
|
## Aceitação da entrega técnica
|
||||||
|
|
||||||
|
**27/10/2026, Africa/Johannesburg.** Após informar que já tinha verificado a demonstração, o utilizador confirmou **«Aceitar a entrega técnica e criar checkpoint local»**. Fica aceite e encerrada a entrega técnica de navegador + PDF no âmbito isolado demonstrado, com os limites acima. As referências anteriores a aceitação/checkpoint ainda pendentes são históricas.
|
||||||
|
|
||||||
|
Autorizado registo e checkpoint Git exclusivamente local, sem credenciais ou dados reais. Incluídos código, testes, migração ainda não aplicada em desenvolvimento, especificação, relatório e provas sintéticas/agregadas; o PDF de demonstração contém só identidades sintéticas. Excluídos ficheiros de ambiente privados, manifestos, bases, logs, imagens reais e ficha nominativa local. Não se repetiram testes ou validações para este registo; resultados mantidos: 53 testes / 924 asserções e compilação aprovada.
|
||||||
|
|
||||||
|
A aceitação **não aplica a migração `060000`, as oito concessões ou a série real**, não resolve as provas de conformidade nem as pendências temporais do caso real, e não autoriza habilitar emissão real, recebimentos, lançamentos ou publicação. O bloqueio no servidor permanece. As próximas operações continuam sujeitas às confirmações específicas já delimitadas.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"database": "sgf_tempo_test",
|
||||||
|
"overlapping_processes": 2,
|
||||||
|
"one_invoice_one_replay": true,
|
||||||
|
"numbers_distinct_in_shared_series": true,
|
||||||
|
"prior_documents_preserved": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"database": "sgf_tempo_test",
|
||||||
|
"renderer": "sgf-test-invoice-text-v2",
|
||||||
|
"pdf_sha256": "d90162f537ecdccb90a425f82c19642d7c7fb3eb3bf3d26e38289334fdf5caf0",
|
||||||
|
"repeated_download_identical": true,
|
||||||
|
"browser_and_pdf_share_document_lines": true,
|
||||||
|
"synthetic_only": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"database": "sgf_tempo_test",
|
||||||
|
"test_filter": "ServiceInvoiceTest|ServiceFiscalTest|ServiceCalculationTest|DocumentPreviewTest|InternalConfirmationTest|DatabaseSafetyTest",
|
||||||
|
"tests": 53,
|
||||||
|
"assertions": 924,
|
||||||
|
"passed": true,
|
||||||
|
"vite_build_passed": true,
|
||||||
|
"real_issuance_blocked": true,
|
||||||
|
"development_migration_applied": false,
|
||||||
|
"real_grants_applied": false,
|
||||||
|
"synthetic_browser_login": "real_session_with_csrf",
|
||||||
|
"final_pdf_pages": 1,
|
||||||
|
"long_text_pdf_pages": 4,
|
||||||
|
"pdf_notices_per_page": 2,
|
||||||
|
"browser_download_matches_preserved_pdf": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%âãÏÓ
|
||||||
|
1 0 obj
|
||||||
|
<< /Type /Catalog /Pages 2 0 R >>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<< /Type /Pages /Count 1 /Kids [6 0 R] >>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<< /Length 2863>>
|
||||||
|
stream
|
||||||
|
0.48 0.19 0.12 rg
|
||||||
|
BT /F2 12 Tf 42 799 Td (TESTE — SEM VALIDADE FISCAL) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F2 13 Tf 42 766 Td (Factura de serviço - demonstração isolada) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 751 Td (TEST-SERIE-ISOLADA / 3) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 736 Td (Emissão: 2026-10-26 17:16:29 \(Africa/Johannesburg\)) Tj ET
|
||||||
|
0.09 0.34 0.28 rg
|
||||||
|
BT /F2 10 Tf 42 721 Td (Emitente) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 706 Td (Empresa Horizonte — Demonstração) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 691 Td (NUIT: 999999991) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 676 Td (Morada: Rua de demonstração, 1) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 661 Td (Localidade: Localidade sintética) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 646 Td (País: Moçambique) Tj ET
|
||||||
|
0.09 0.34 0.28 rg
|
||||||
|
BT /F2 10 Tf 42 631 Td (Cliente) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 616 Td (Cliente sintético Horizonte) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 601 Td (NUIT: 999999992) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 586 Td (Morada: Rua de demonstração, 1) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 571 Td (Localidade: Localidade sintética) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 556 Td (País: Moçambique) Tj ET
|
||||||
|
0.09 0.34 0.28 rg
|
||||||
|
BT /F2 10 Tf 42 541 Td (Serviço e período) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 526 Td (Serviço: Alojamento mensal sintético) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 511 Td (Quantidade: 1) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 496 Td (Preço sem IVA: 7850) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 481 Td (Unidade: período) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 466 Td (Período: 2026-09-26 a 2026-10-25) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 451 Td (Condições: Não indicadas) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 436 Td (Vencimento proposto: Não indicado) Tj ET
|
||||||
|
0.09 0.34 0.28 rg
|
||||||
|
BT /F2 10 Tf 42 421 Td (Valores - MZN) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 406 Td (Base exacta: 7850) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 391 Td (IVA exacto: 1256) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 376 Td (IVA documental proposto: 1256.00) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 361 Td (Diferença preservada: 0) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 346 Td (Total proposto: 9106.00) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 331 Td (Perfil: TEST-PROFILE-HOSTING v1 | IVA 16%) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 316 Td (Exigibilidade simulada: 2026-10-26 | Período:) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 301 Td (2026-10) Tj ET
|
||||||
|
0.09 0.34 0.28 rg
|
||||||
|
BT /F2 10 Tf 42 286 Td (Origem e limites) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 271 Td (Rascunho #52 v1 | Regra temporal #23 v1) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 256 Td (Sem recebimento, comunicação à AT ou lançamento) Tj ET
|
||||||
|
0.12 0.21 0.23 rg
|
||||||
|
BT /F1 10 Tf 42 241 Td (contabilístico.) Tj ET
|
||||||
|
0.4 0.4 0.4 rg
|
||||||
|
BT /F1 8 Tf 42 30 Td (TESTE — SEM VALIDADE FISCAL | Página 1 / 1) Tj ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 7
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000015 00000 n
|
||||||
|
0000000064 00000 n
|
||||||
|
0000000121 00000 n
|
||||||
|
0000000218 00000 n
|
||||||
|
0000000320 00000 n
|
||||||
|
0000003233 00000 n
|
||||||
|
trailer
|
||||||
|
<< /Size 7 /Root 1 0 R >>
|
||||||
|
startxref
|
||||||
|
3369
|
||||||
|
%%EOF
|
||||||
@@ -10,3 +10,7 @@
|
|||||||
.accounting-proposal dl > div { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr; gap: 1rem; }
|
.accounting-proposal dl > div { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr; gap: 1rem; }
|
||||||
.accounting-proposal dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
.accounting-proposal dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||||
.fiscal-page-heading h1{margin:8px 0 12px}.fiscal-page-heading>p:last-child{color:#5d716d;margin:0 0 24px}.fiscal-notice{display:flex;flex-direction:column;gap:7px;background:#fff7e7;border:1px solid #ead7ad;border-left:4px solid #c5983e;border-radius:8px;padding:16px 20px;margin-bottom:24px;color:#745723;font-size:.85rem;line-height:1.6}.fiscal-notice strong{font-size:.75rem;letter-spacing:.04em}.fiscal-list-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:26px 0 16px}.fiscal-list-heading h2{margin:0}.fiscal-list-heading>span{font-size:.8rem;color:#64796f}.fiscal-proposal{background:#fff;border:1px solid #dce6e1;border-radius:12px;padding:28px;margin-bottom:24px;box-shadow:0 3px 12px #123f3505}.fiscal-card-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.fiscal-card-heading h3{font-size:1.3rem;margin:8px 0;letter-spacing:-.02em}.fiscal-card-heading .eyebrow{margin-top:0}.fiscal-reference{color:#65786f;font-size:.78rem;margin:0;overflow-wrap:anywhere}.fiscal-statuses{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.fiscal-status{border-radius:20px;padding:6px 10px;white-space:nowrap;font-size:.72rem;font-weight:650;background:#edf2ef;color:#53665d}.fiscal-status.pending{background:#fff2d3;color:#80601c}.fiscal-status.approved{background:#e5f3e9;color:#286444}.fiscal-status.refused{background:#fbece8;color:#963f2d}.fiscal-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0;background:#f3f7f4;border:1px solid #e0e9e3;border-radius:8px;padding:18px;gap:20px}.fiscal-summary dt,.fiscal-facts dt{color:#667b70;font-size:.75rem;margin-bottom:8px}.fiscal-summary dd{margin:0;font-weight:650;font-size:.95rem;line-height:1.5}.fiscal-facts{display:grid;grid-template-columns:1fr 1fr;gap:18px 30px;margin:0 0 24px}.fiscal-facts dd{margin:0;line-height:1.5;font-size:.9rem}.fiscal-conditions{border-top:1px solid #e5ece7;padding-top:18px}.fiscal-conditions h4,.fiscal-review h4{font-size:.95rem;margin:0 0 10px}.fiscal-conditions p,.fiscal-evidence p,.fiscal-recorded p{font-size:.88rem;line-height:1.85;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 18px;max-width:75ch}.fiscal-evidence{border-top:1px solid #e5ece7;padding:16px 0}.fiscal-evidence summary,.fiscal-new>summary{cursor:pointer;color:#28634f;font-weight:650;font-size:.9rem}.fiscal-evidence p{margin:15px 0 0;color:#5d716d}.fiscal-review{background:#f5f8f6;border-top:1px solid #dce6e1;margin:8px -28px -28px;padding:24px 28px;border-radius:0 0 12px 12px}.fiscal-review>p{font-size:.85rem;color:#5d716d;margin:0 0 18px}.fiscal-review label{max-width:700px}.fiscal-review textarea{resize:vertical;min-height:110px}.fiscal-refuse{color:#923f2c;border-color:#dbb7ac}.fiscal-recorded{padding-top:16px;border-top:1px solid #e5ece7}.fiscal-recorded>strong{font-size:.85rem}.fiscal-new form{margin-top:24px}.fiscal-new textarea{resize:vertical}.fiscal-proposal dd{overflow-wrap:anywhere}@media(max-width:1000px){.fiscal-summary{grid-template-columns:1fr 1fr}.fiscal-card-heading{flex-direction:column}.fiscal-statuses{justify-content:flex-start}}@media(max-width:560px){.fiscal-proposal{padding:20px}.fiscal-review{margin:8px -20px -20px;padding:20px}.fiscal-list-heading{display:block}.fiscal-list-heading>span{display:block;margin-top:8px}.fiscal-facts{grid-template-columns:1fr}.fiscal-summary{gap:16px;padding:14px}.fiscal-card-heading h3{font-size:1.12rem}.fiscal-review .actions button{flex:1}}
|
.fiscal-page-heading h1{margin:8px 0 12px}.fiscal-page-heading>p:last-child{color:#5d716d;margin:0 0 24px}.fiscal-notice{display:flex;flex-direction:column;gap:7px;background:#fff7e7;border:1px solid #ead7ad;border-left:4px solid #c5983e;border-radius:8px;padding:16px 20px;margin-bottom:24px;color:#745723;font-size:.85rem;line-height:1.6}.fiscal-notice strong{font-size:.75rem;letter-spacing:.04em}.fiscal-list-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:26px 0 16px}.fiscal-list-heading h2{margin:0}.fiscal-list-heading>span{font-size:.8rem;color:#64796f}.fiscal-proposal{background:#fff;border:1px solid #dce6e1;border-radius:12px;padding:28px;margin-bottom:24px;box-shadow:0 3px 12px #123f3505}.fiscal-card-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.fiscal-card-heading h3{font-size:1.3rem;margin:8px 0;letter-spacing:-.02em}.fiscal-card-heading .eyebrow{margin-top:0}.fiscal-reference{color:#65786f;font-size:.78rem;margin:0;overflow-wrap:anywhere}.fiscal-statuses{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.fiscal-status{border-radius:20px;padding:6px 10px;white-space:nowrap;font-size:.72rem;font-weight:650;background:#edf2ef;color:#53665d}.fiscal-status.pending{background:#fff2d3;color:#80601c}.fiscal-status.approved{background:#e5f3e9;color:#286444}.fiscal-status.refused{background:#fbece8;color:#963f2d}.fiscal-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0;background:#f3f7f4;border:1px solid #e0e9e3;border-radius:8px;padding:18px;gap:20px}.fiscal-summary dt,.fiscal-facts dt{color:#667b70;font-size:.75rem;margin-bottom:8px}.fiscal-summary dd{margin:0;font-weight:650;font-size:.95rem;line-height:1.5}.fiscal-facts{display:grid;grid-template-columns:1fr 1fr;gap:18px 30px;margin:0 0 24px}.fiscal-facts dd{margin:0;line-height:1.5;font-size:.9rem}.fiscal-conditions{border-top:1px solid #e5ece7;padding-top:18px}.fiscal-conditions h4,.fiscal-review h4{font-size:.95rem;margin:0 0 10px}.fiscal-conditions p,.fiscal-evidence p,.fiscal-recorded p{font-size:.88rem;line-height:1.85;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 18px;max-width:75ch}.fiscal-evidence{border-top:1px solid #e5ece7;padding:16px 0}.fiscal-evidence summary,.fiscal-new>summary{cursor:pointer;color:#28634f;font-weight:650;font-size:.9rem}.fiscal-evidence p{margin:15px 0 0;color:#5d716d}.fiscal-review{background:#f5f8f6;border-top:1px solid #dce6e1;margin:8px -28px -28px;padding:24px 28px;border-radius:0 0 12px 12px}.fiscal-review>p{font-size:.85rem;color:#5d716d;margin:0 0 18px}.fiscal-review label{max-width:700px}.fiscal-review textarea{resize:vertical;min-height:110px}.fiscal-refuse{color:#923f2c;border-color:#dbb7ac}.fiscal-recorded{padding-top:16px;border-top:1px solid #e5ece7}.fiscal-recorded>strong{font-size:.85rem}.fiscal-new form{margin-top:24px}.fiscal-new textarea{resize:vertical}.fiscal-proposal dd{overflow-wrap:anywhere}@media(max-width:1000px){.fiscal-summary{grid-template-columns:1fr 1fr}.fiscal-card-heading{flex-direction:column}.fiscal-statuses{justify-content:flex-start}}@media(max-width:560px){.fiscal-proposal{padding:20px}.fiscal-review{margin:8px -20px -20px;padding:20px}.fiscal-list-heading{display:block}.fiscal-list-heading>span{display:block;margin-top:8px}.fiscal-facts{grid-template-columns:1fr}.fiscal-summary{gap:16px;padding:14px}.fiscal-card-heading h3{font-size:1.12rem}.fiscal-review .actions button{flex:1}}
|
||||||
|
|
||||||
|
.invoice-test-document{margin:24px 0}.invoice-test-document>p{white-space:pre-wrap;margin:8px 0;line-height:1.6}.invoice-test-document>h3{border-top:1px solid #dce6e1;padding-top:18px;margin-top:26px;color:#176b55}.invoice-test-document form{margin-top:28px;border-top:1px solid #dce6e1;padding-top:24px}.invoice-pdf-link{display:inline-block;padding:12px 18px;background:#176b55;color:#fff;border-radius:7px;text-decoration:none;font-weight:650}
|
||||||
|
|
||||||
|
.invoice-test-workspace>.fiscal-notice{position:sticky;top:0;z-index:20}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React, {useEffect,useState} from 'react';
|
||||||
|
|
||||||
|
const notice='TESTE — SEM VALIDADE FISCAL';
|
||||||
|
export default function ServiceInvoiceWorkspace({api,context,can,perform,busy}) {
|
||||||
|
const [list,setList]=useState(null),[error,setError]=useState(''),[review,setReview]=useState(null),[document,setDocument]=useState(null),[key,setKey]=useState(crypto.randomUUID());
|
||||||
|
const [choice,setChoice]=useState({draft_id:'',series_id:'',rule_id:''}),[reason,setReason]=useState(''),[reasons,setReasons]=useState({});
|
||||||
|
const [kind,setKind]=useState('series'),[f,setF]=useState({identifier:'',previous_id:'',code:'',first_number:'',state:'open',valid_from:'',valid_until:'',evidence:'',draft_id:'',draft_version:'',preparation_version:'',profile_proposal_id:'',period_start:'',period_end:'',issue_window_start:'',issue_window_end:'',readiness_basis:''});
|
||||||
|
async function load(){setList(await api('/api/service-invoices'));}
|
||||||
|
useEffect(()=>{setList(null);setReview(null);setDocument(null);setError('');if(context.unit)load().catch(e=>setError(e.message));},[context.token]);
|
||||||
|
const unit=context.unit?.id;
|
||||||
|
const fields=kind==='series'?{code:'Código da série de teste (TEST-...)',first_number:'Primeiro número de teste',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',evidence:'Fundamento da configuração sintética'}:{draft_id:'Rascunho existente',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',profile_proposal_id:'Proposta do perfil fiscal',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',period_start:'Início da cobertura',period_end:'Fim da cobertura',issue_window_start:'Janela fundamentada de emissão desde',issue_window_end:'Janela fundamentada até',readiness_basis:'Fundamento da prontidão documental e representação',evidence:'Evidências e pressupostos sintéticos'};
|
||||||
|
async function propose(e){e.preventDefault();await perform(async()=>{const data={};for(const field of Object.keys(fields))if(f[field]!=='')data[field]=field.endsWith('_id')||field.endsWith('_version')||field==='first_number'?Number(f[field]):f[field];if(kind==='series')data.state=f.state;else data.event='on_issue_within_deadline';await api('/api/service-invoices/proposals','POST',{kind,identifier:kind==='series'?f.code:f.identifier,...(f.previous_id?{previous_id:Number(f.previous_id)}:{}),data});await load();},'Proposta de teste submetida para validação.');}
|
||||||
|
return <section className="fiscal-workspace invoice-test-workspace"><p className="eyebrow">DEMONSTRAÇÃO DOCUMENTAL ISOLADA</p><h1>Facturas de teste</h1><div className="fiscal-notice"><strong>{notice}</strong><span>Emissão real bloqueada. Sem comunicação à AT, envio automático, recebimentos ou lançamentos.</span></div>{error&&<p role="alert">{error}</p>}
|
||||||
|
{!context.unit?<p>Seleccione uma unidade autorizada.</p>:!list?<p>A carregar…</p>:!list.test_enabled?<div className="panel"><h2>Emissão real indisponível</h2><p>{list.notice}</p></div>:<>
|
||||||
|
{(can('invoice_series.propose')||can('service_fiscal.propose'))&&<details className="panel fiscal-new"><summary>Preparar série ou regra temporal de teste</summary><form onSubmit={propose}><label>Tipo<select value={kind} onChange={e=>setKind(e.target.value)}><option value="series">Configuração de série sintética</option><option value="temporal">Regra temporal prospectiva</option></select></label>{kind==='temporal'&&<label>Identificador da regra<input required value={f.identifier} onChange={e=>setF({...f,identifier:e.target.value})}/></label>}<label>Proposta anterior (apenas nova versão)<input type="number" min="1" value={f.previous_id} onChange={e=>setF({...f,previous_id:e.target.value})}/></label>{kind==='series'&&<label>Estado da série<select value={f.state} onChange={e=>setF({...f,state:e.target.value})}><option value="open">Aberta</option><option value="closed">Encerrada</option></select></label>}{Object.entries(fields).map(([field,label])=><label key={field}>{label}{['evidence','readiness_basis'].includes(field)?<textarea required rows={4} value={f[field]} onChange={e=>setF({...f,[field]:e.target.value})}/>:<input type={field.includes('window')||field.startsWith('valid_')||field.startsWith('period_')?'date':'text'} required={field!=='valid_until'} value={f[field]} onChange={e=>setF({...f,[field]:e.target.value})}/>}</label>)}<button className="primary" disabled={busy||!can(kind==='series'?'invoice_series.propose':'service_fiscal.propose')}>Submeter proposta</button></form></details>}
|
||||||
|
<h2>Séries e regras temporais</h2>{list.proposals.map(p=><article className="fiscal-proposal" key={p.id}><div className="fiscal-card-heading"><div><p className="eyebrow">{p.kind==='series'?'SÉRIE SINTÉTICA':'REGRA TEMPORAL'}</p><h3>{p.identifier} · v{p.version}</h3><p>Proposta #{p.id} · {p.decision?.decision==='approved'?'Validada':p.decision?.decision==='refused'?'Recusada':'Aguarda validação'}</p></div></div>{p.kind==='series'?<p>Número inicial: {p.definition.first_number} · {p.definition.state==='open'?'Aberta':'Encerrada'} · Desde {p.definition.valid_from}</p>:<p>Rascunho #{p.definition.draft_id} · Emissão dentro da janela fundamentada {p.definition.issue_window_start} a {p.definition.issue_window_end}. A data efectiva só é fixada na confirmação.</p>}<p>{p.definition.evidence}</p>{p.definition.readiness_basis&&<p>{p.definition.readiness_basis}</p>}{!p.decision&&can(p.kind==='series'?'invoice_series.approve':'service_fiscal.approve')&&<div className="fiscal-review"><label>Fundamento da decisão<textarea value={reasons[p.id]||''} onChange={e=>setReasons({...reasons,[p.id]:e.target.value})}/></label><div className="actions">{['approved','refused'].map(decision=><button key={decision} className={decision==='approved'?'primary':''} disabled={busy||!reasons[p.id]} onClick={()=>perform(async()=>{await api(`/api/service-invoices/proposals/${p.id}/decision`,'POST',{version:p.version,decision,reason:reasons[p.id]});await load();},'Decisão registada apenas para a versão de teste.')}>{decision==='approved'?'Validar versão':'Recusar versão'}</button>)}</div></div>}</article>)}
|
||||||
|
{can('service_invoices.issue',unit)&&<section className="panel"><h2>Rever emissão de teste</h2><form onSubmit={e=>{e.preventDefault();perform(async()=>{setReview(null);setDocument(null);const r=await api(`/api/service-invoices/drafts/${choice.draft_id}/review`,'POST',{series_id:Number(choice.series_id),rule_id:Number(choice.rule_id)});setReview({...r,draft_id:Number(choice.draft_id)});setKey(crypto.randomUUID());},'Revisão preparada sem atribuir número.');}}><div className="form-grid"><label>Rascunho<input required type="number" min="1" value={choice.draft_id} onChange={e=>{setChoice({...choice,draft_id:e.target.value});setReview(null);}}/></label><label>Série aprovada<select required value={choice.series_id} onChange={e=>{setChoice({...choice,series_id:e.target.value});setReview(null);}}><option value="">Seleccionar série</option>{list.proposals.filter(p=>p.kind==='series'&&p.decision?.decision==='approved').map(p=><option value={p.id} key={p.id}>{p.identifier} v{p.version}</option>)}</select></label><label>Regra temporal aprovada<select required value={choice.rule_id} onChange={e=>{setChoice({...choice,rule_id:e.target.value});setReview(null);}}><option value="">Seleccionar regra</option>{list.proposals.filter(p=>p.kind==='temporal'&&p.decision?.decision==='approved').map(p=><option value={p.id} key={p.id}>{p.identifier} v{p.version} · rascunho #{p.definition.draft_id}</option>)}</select></label></div><button disabled={busy}>Rever prontidão</button></form>{review&&<div className="fiscal-review"><h3>{review.ready?'Pronto para demonstração isolada':'Emissão de teste bloqueada'}</h3><p>{review.notice}</p><p>Cliente: {review.payload.document.client.name} · Data prevista da operação: {review.payload.expected_date}. O instante efectivo será fixado na confirmação.</p>{review.payload.calculation_entry&&<p>Base: {review.payload.calculation_entry.snapshot.result.base_exact} · IVA proposto: {review.payload.calculation_entry.snapshot.result.vat_document_proposed} · Total: {review.payload.calculation_entry.snapshot.result.total_document_proposed} MZN</p>}<ul>{review.payload.blocks.map((b,i)=><li key={i}>{b}</li>)}</ul><button className="primary" disabled={busy||!review.ready} onClick={()=>perform(async()=>{const issued=await api(`/api/service-invoices/drafts/${review.draft_id}/issue`,'POST',{series_id:review.payload.series_id,rule_id:review.payload.rule_id,review_hash:review.review_hash},context.token,key);setDocument(await api(`/api/service-invoices/${issued.id}`));setReview(null);await load();},'Documento de teste fixado, sem emissão real.')}>Confirmar emissão apenas de teste</button></div>}</section>}
|
||||||
|
<section className="panel"><h2>Documentos de teste preservados</h2>{!list.invoices.length?<p>Nenhum documento emitido neste cenário.</p>:list.invoices.map(i=><button key={i.id} disabled={busy} onClick={()=>perform(async()=>{setDocument(await api(`/api/service-invoices/${i.id}`));setReview(null);},'Consulta da fotografia preservada.')}>{i.series_code} / {i.number} · rascunho #{i.draft_id}</button>)}</section>
|
||||||
|
{document&&<article className="preview-sheet invoice-test-document"><div className="fiscal-notice"><strong>{notice}</strong></div>{document.document_lines.map(([kind,text],i)=>kind==='title'?<h2 key={i}>{text}</h2>:kind==='section'?<h3 key={i}>{text}</h3>:<p key={i}>{text}</p>)}<div className="fiscal-notice"><strong>{notice}</strong><span>Fotografia imutável · modelo {document.snapshot.renderer}</span></div><a className="invoice-pdf-link" href={`/api/service-invoices/${document.id}/pdf`}>Descarregar PDF de teste</a><p className="help">O PDF guardado reproduz a mesma fotografia, sem nova numeração.</p>{can('service_invoices.correction_request',unit)&&<form onSubmit={e=>{e.preventDefault();perform(async()=>{await api(`/api/service-invoices/${document.id}/corrections`,'POST',{reason});setDocument(await api(`/api/service-invoices/${document.id}`));setReason('');},'Pedido de correcção registado, sem efeito fiscal ou monetário.');}}><label>Motivo do pedido de correcção<textarea required value={reason} maxLength={2000} onChange={e=>setReason(e.target.value)}/></label><button disabled={busy}>Pedir correcção · sem alterar o original</button></form>}{document.corrections.map(r=><p key={r.id}>Pedido #{r.id}: {r.reason} · Pendente, sem efeito fiscal.</p>)}</article>}
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
|
|||||||
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
|
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
|
||||||
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
|
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
|
||||||
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
|
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
|
||||||
|
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.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' };
|
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' };
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ function App() {
|
|||||||
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
|
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
|
||||||
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
|
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
|
||||||
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
|
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
|
||||||
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
||||||
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
|
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
|
||||||
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
|
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
|
||||||
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
|
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
|
||||||
@@ -60,6 +61,7 @@ function App() {
|
|||||||
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
|
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
|
||||||
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
|
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||||
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||||
|
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||||
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||||
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||||
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
|
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
|
|||||||
Route::post('/api/logout', [IdentityController::class, 'logout']);
|
Route::post('/api/logout', [IdentityController::class, 'logout']);
|
||||||
Route::post('/api/context', [IdentityController::class, 'select']);
|
Route::post('/api/context', [IdentityController::class, 'select']);
|
||||||
Route::middleware(RequireCompanyContext::class)->group(function (): void {
|
Route::middleware(RequireCompanyContext::class)->group(function (): void {
|
||||||
|
Route::get('/api/service-invoices', [\App\Modules\Services\Http\ServiceInvoiceController::class,'index']);
|
||||||
|
Route::post('/api/service-invoices/proposals', [\App\Modules\Services\Http\ServiceInvoiceController::class,'propose']);
|
||||||
|
Route::post('/api/service-invoices/proposals/{id}/decision', [\App\Modules\Services\Http\ServiceInvoiceController::class,'decide'])->whereNumber('id');
|
||||||
|
Route::post('/api/service-invoices/drafts/{id}/review', [\App\Modules\Services\Http\ServiceInvoiceController::class,'review'])->whereNumber('id');
|
||||||
|
Route::post('/api/service-invoices/drafts/{id}/issue', [\App\Modules\Services\Http\ServiceInvoiceController::class,'issue'])->whereNumber('id');
|
||||||
|
Route::get('/api/service-invoices/{id}', [\App\Modules\Services\Http\ServiceInvoiceController::class,'show'])->whereNumber('id');
|
||||||
|
Route::get('/api/service-invoices/{id}/pdf', [\App\Modules\Services\Http\ServiceInvoiceController::class,'pdf'])->whereNumber('id');
|
||||||
|
Route::post('/api/service-invoices/{id}/corrections', [\App\Modules\Services\Http\ServiceInvoiceController::class,'correction'])->whereNumber('id');
|
||||||
Route::get('/api/service-fiscal', [\App\Modules\Services\Http\ServiceFiscalController::class,'index']);
|
Route::get('/api/service-fiscal', [\App\Modules\Services\Http\ServiceFiscalController::class,'index']);
|
||||||
Route::post('/api/service-fiscal/proposals', [\App\Modules\Services\Http\ServiceFiscalController::class,'propose']);
|
Route::post('/api/service-fiscal/proposals', [\App\Modules\Services\Http\ServiceFiscalController::class,'propose']);
|
||||||
Route::post('/api/service-fiscal/proposals/{id}/decision', [\App\Modules\Services\Http\ServiceFiscalController::class,'decide'])->whereNumber('id');
|
Route::post('/api/service-fiscal/proposals/{id}/decision', [\App\Modules\Services\Http\ServiceFiscalController::class,'decide'])->whereNumber('id');
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
\App\Modules\Services\ServiceInvoicing::guard();
|
||||||
|
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
$fixture=$f+$f['invoice']+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])];
|
||||||
|
$fixture=DB::transaction(fn()=>\Tests\Support\InvoiceFixture::additionalDraft($fixture));
|
||||||
|
$review=app(\App\Modules\Services\ServiceInvoicing::class)->review($fixture['draft'],$fixture['series'],$fixture['rule'],$f['manager_id']);
|
||||||
|
if(!$review['ready']) throw new RuntimeException('Synthetic concurrent revision not ready.');
|
||||||
|
$input=['actor'=>$f['manager_id'],'tenant'=>$f['tenant'],'company'=>$f['company'],'unit'=>$f['unit'],'draft'=>$fixture['draft'],'key'=>(string)\Illuminate\Support\Str::uuid(),'input'=>['series_id'=>$fixture['series'],'rule_id'=>$fixture['rule'],'review_hash'=>$review['review_hash']]];
|
||||||
|
$before=DB::table('si_invoices')->count();
|
||||||
|
$a=new Process([PHP_BINARY,'scripts/invoice-worker.php','--env=testing'],base_path(),null,json_encode($input+['pause'=>true]));
|
||||||
|
$b=new Process([PHP_BINARY,'scripts/invoice-worker.php','--env=testing'],base_path(),null,json_encode($input));
|
||||||
|
$a->start();if(!$a->waitUntil(fn($type,$buffer)=>str_contains($buffer,'LOCK_HELD')))throw new RuntimeException('No overlapping lock established.');
|
||||||
|
$b->start();$a->wait();$b->wait();$results=[];
|
||||||
|
foreach([$a,$b] as $p){if(!$p->isSuccessful())throw new RuntimeException('Synthetic worker failed.');$lines=array_values(array_filter(explode("\n",trim($p->getOutput())),fn($s)=>str_starts_with($s,'{')));$results[]=json_decode(end($lines),true,32,JSON_THROW_ON_ERROR);}
|
||||||
|
if($results[0]['id']!==$results[1]['id'] || $results[0]['replayed'] || !$results[1]['replayed'] || DB::table('si_invoices')->count()!==$before+1)throw new RuntimeException('Duplicate or missing concurrent invoice.');
|
||||||
|
if(DB::table('si_invoices')->distinct()->count('number')!==DB::table('si_invoices')->count())throw new RuntimeException('Series number repeated.');
|
||||||
|
file_put_contents(base_path('docs/proofs/invoice-concurrency.json'),json_encode(['database'=>'sgf_tempo_test','overlapping_processes'=>2,'one_invoice_one_replay'=>true,'numbers_distinct_in_shared_series'=>true,'prior_documents_preserved'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||||
|
echo "Concurrent synthetic issuance verified: one invoice, one replay, shared series without duplicate number.\n";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
\App\Modules\Services\ServiceInvoicing::guard();
|
||||||
|
$f=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
$fixture=$f+$f['invoice']+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])];
|
||||||
|
$fixture=\Illuminate\Support\Facades\DB::transaction(fn()=>\Tests\Support\InvoiceFixture::additionalDraft($fixture));
|
||||||
|
$issuer=app(\App\Modules\Services\ServiceInvoicing::class);
|
||||||
|
$review=$issuer->review($fixture['draft'],$fixture['series'],$fixture['rule'],$f['manager_id']);
|
||||||
|
if(!$review['ready'])throw new RuntimeException('Synthetic export revision not ready.');
|
||||||
|
$result=$issuer->issue($fixture['draft'],$f['manager_id'],['series_id'=>$fixture['series'],'rule_id'=>$fixture['rule'],'review_hash'=>$review['review_hash']],(string)\Illuminate\Support\Str::uuid());
|
||||||
|
$pdf=$issuer->pdf($result['id'],$f['manager_id']);
|
||||||
|
$dir=base_path('output/pdf'); if(!is_dir($dir))mkdir($dir,0777,true);
|
||||||
|
file_put_contents($dir.'/sgf-tempo-factura-teste.pdf',$pdf);
|
||||||
|
$view=$issuer->show($result['id'],$f['manager_id']);
|
||||||
|
$long=$view['snapshot']; unset($long['document_lines']); $long['document']['observations']=str_repeat('Conteúdo sintético extenso para verificar paginação e margens. ',80).str_repeat('W',800);
|
||||||
|
file_put_contents(storage_path('app/invoice-long-proof.pdf'),app(\App\Modules\Services\InvoicePdf::class)->render($long));
|
||||||
|
if($pdf!==$issuer->pdf($result['id'],$f['manager_id']))throw new RuntimeException('PDF bytes changed.');
|
||||||
|
file_put_contents(base_path('docs/proofs/invoice-pdf-results.json'),json_encode(['database'=>'sgf_tempo_test','renderer'=>$view['snapshot']['renderer'],'pdf_sha256'=>hash('sha256',$pdf),'repeated_download_identical'=>true,'browser_and_pdf_share_document_lines'=>true,'synthetic_only'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
||||||
|
echo json_encode(['invoice_id'=>$result['id'],'number'=>$result['number'],'pdf_sha256'=>hash('sha256',$pdf),'renderer'=>$view['snapshot']['renderer']],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
\App\Modules\Services\ServiceInvoicing::guard();
|
||||||
|
$file='C:/laragon/data/sgf-tempo/secrets/browser-fixture.json';
|
||||||
|
$f=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
if(\Illuminate\Support\Facades\DB::table('a_services')->exists() || \Illuminate\Support\Facades\DB::table('si_proposals')->exists()) throw new RuntimeException('Synthetic fixture must be empty; nothing deleted.');
|
||||||
|
$fixture=\Illuminate\Support\Facades\DB::transaction(fn()=>\Tests\Support\InvoiceFixture::create($f+['manager'=>\App\Models\User::findOrFail($f['manager_id']),'reader'=>\App\Models\User::findOrFail($f['reader_id'])]));
|
||||||
|
$ids=array_intersect_key($fixture,array_flip(['client','service','source','draft','profile','temporal','calculation','series','rule','today','start','end']));
|
||||||
|
file_put_contents($file,json_encode($f+['invoice'=>$ids],JSON_THROW_ON_ERROR));
|
||||||
|
echo "Synthetic invoice fixture ready only in sgf_tempo_test; no invoice or PDF pre-issued.\n";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
$app=require __DIR__.'/../bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
\App\Modules\Services\ServiceInvoicing::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(1500000);}}); }
|
||||||
|
echo json_encode(app(\App\Modules\Services\ServiceInvoicing::class)->issue($in['draft'],$in['actor'],$in['input'],$in['key']),JSON_THROW_ON_ERROR).PHP_EOL;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
namespace Tests\Feature;
|
||||||
|
use App\Modules\Services\ServiceInvoicing;
|
||||||
|
use App\Shared\Context\CompanyContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\Support\InvoiceFixture;
|
||||||
|
use Tests\Support\IdentityFixture;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class ServiceInvoiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
private array $f; private string $token; private string $key;
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp(); $this->f=InvoiceFixture::create(); $this->key=(string)Str::uuid(); $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 review()
|
||||||
|
{
|
||||||
|
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/review',['series_id'=>$this->f['series'],'rule_id'=>$this->f['rule']]);
|
||||||
|
}
|
||||||
|
private function issue(string $hash,?string $key=null,array $changes=[])
|
||||||
|
{
|
||||||
|
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key ?? $this->key])->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/issue',array_replace(['series_id'=>$this->f['series'],'rule_id'=>$this->f['rule'],'review_hash'=>$hash],$changes));
|
||||||
|
}
|
||||||
|
public function test_atomic_issuance_pdf_snapshot_replay_and_correction_request(): void
|
||||||
|
{
|
||||||
|
$review=$this->review()->assertOk()->assertJsonPath('ready',true); $this->assertDatabaseCount('si_invoices',0);
|
||||||
|
$hash=$review->json('review_hash'); $id=$this->issue($hash)->assertOk()->assertJsonPath('number',1)->json('id');
|
||||||
|
$this->issue($hash)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
||||||
|
$this->issue(str_repeat('a',64))->assertStatus(409);
|
||||||
|
$this->issue($hash,(string)Str::uuid())->assertStatus(409);
|
||||||
|
$show=$this->getJson('/api/service-invoices/'.$id)->assertOk()->assertJsonPath('snapshot.notice',ServiceInvoicing::NOTICE)->assertJsonPath('snapshot.calculation.total_document_proposed','9106.00')->assertJsonPath('snapshot.assessment_period',substr($this->f['today'],0,7));
|
||||||
|
$this->assertSame($show->json('snapshot.document_lines'),$show->json('document_lines'));
|
||||||
|
$pdf=$this->get('/api/service-invoices/'.$id.'/pdf')->assertOk()->assertHeader('Content-Type','application/pdf')->getContent();
|
||||||
|
$this->assertStringStartsWith('%PDF-1.4',$pdf); $this->assertSame($show->json('pdf_sha256'),hash('sha256',$pdf));
|
||||||
|
$before=DB::table('si_invoices')->get()->toJson();
|
||||||
|
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/clients/'.$this->f['client'],['version'=>1,'name'=>'Changed after issue'])->assertOk();
|
||||||
|
$this->assertSame($pdf,$this->get('/api/service-invoices/'.$id.'/pdf')->assertOk()->getContent());
|
||||||
|
$this->getJson('/api/service-invoices/'.$id)->assertOk()->assertJsonPath('snapshot.document.client.name','Cliente sintético Horizonte');
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/'.$id.'/corrections',['reason'=>'Pedido sintético sem efeito monetário'])->assertOk()->assertJsonPath('state','pending_no_fiscal_effect');
|
||||||
|
$this->assertSame($before,DB::table('si_invoices')->get()->toJson());
|
||||||
|
$this->assertDatabaseCount('si_events',1); $this->assertDatabaseCount('si_outbox',1); $this->assertDatabaseCount('si_commands',1);
|
||||||
|
}
|
||||||
|
public function test_real_environment_cannot_issue_or_configure_even_with_grants(): void
|
||||||
|
{
|
||||||
|
$hash=$this->review()->assertOk()->json('review_hash');
|
||||||
|
$this->app->instance('env','local');
|
||||||
|
$this->withHeader('X-CSRF-TOKEN',session()->token());
|
||||||
|
$this->issue($hash)->assertForbidden(); $this->review()->assertForbidden();
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',[])->assertForbidden();
|
||||||
|
$this->getJson('/api/service-invoices')->assertOk()->assertJsonPath('real_blocked',true)->assertJsonPath('invoices',[]);
|
||||||
|
$this->assertDatabaseCount('si_invoices',0);
|
||||||
|
try { ServiceInvoicing::guard(); $this->fail('CLI bypass allowed'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
|
||||||
|
}
|
||||||
|
public function test_reader_separation_revocation_and_cross_company_access(): void
|
||||||
|
{
|
||||||
|
$hash=$this->review()->assertOk()->json('review_hash'); $id=$this->issue($hash)->assertOk()->json('id');
|
||||||
|
$this->login('reader'); $this->getJson('/api/service-invoices/'.$id)->assertOk(); $this->issue($hash)->assertForbidden();
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/'.$id.'/corrections',['reason'=>'No right'])->assertForbidden();
|
||||||
|
$this->login(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_invoices.issue')->delete(); $this->issue($hash)->assertForbidden();
|
||||||
|
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
|
||||||
|
$this->getJson('/api/service-invoices/'.$id)->assertForbidden();
|
||||||
|
}
|
||||||
|
public function test_stale_revision_date_changes_missing_data_and_pending_rule_refuse(): void
|
||||||
|
{
|
||||||
|
$hash=$this->review()->assertOk()->json('review_hash');
|
||||||
|
$this->issue($hash,null,['number'=>999])->assertUnprocessable();
|
||||||
|
$this->travel(1)->days(); $this->issue($hash)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false); $this->travelBack();
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->patchJson('/api/document-preparation/client/'.$this->f['client'],['version'=>1,'nuit'=>null])->assertOk();
|
||||||
|
$this->issue($hash)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false);
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/drafts/'.$this->f['draft'].'/review',['series_id'=>$this->f['series'],'rule_id'=>$this->f['series']])->assertOk()->assertJsonPath('ready',false);
|
||||||
|
$this->assertDatabaseCount('si_invoices',0); $this->assertDatabaseCount('si_counters',0);
|
||||||
|
}
|
||||||
|
public function test_failure_during_outbox_rolls_back_number_invoice_and_audit(): void
|
||||||
|
{
|
||||||
|
$hash=$this->review()->assertOk()->json('review_hash');
|
||||||
|
DB::unprepared("CREATE OR REPLACE FUNCTION si_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'SYNTHETIC_FAILURE'; END; $$; CREATE TRIGGER si_failure BEFORE INSERT ON si_outbox FOR EACH ROW EXECUTE FUNCTION si_test_fail()");
|
||||||
|
$this->issue($hash)->assertStatus(500);
|
||||||
|
$this->assertDatabaseCount('si_invoices',0); $this->assertDatabaseCount('si_counters',0); $this->assertDatabaseCount('si_events',0); $this->assertDatabaseCount('si_commands',0);
|
||||||
|
$this->assertSame(0,DB::table('identity_audit')->where('action','service_invoice.test_issued')->count());
|
||||||
|
DB::unprepared('DROP TRIGGER si_failure ON si_outbox');
|
||||||
|
$this->issue($hash)->assertOk()->assertJsonPath('number',1);
|
||||||
|
}
|
||||||
|
public function test_database_immutability_and_b1_snapshot_are_preserved(): void
|
||||||
|
{
|
||||||
|
$id=$this->issue($this->review()->assertOk()->json('review_hash'))->assertOk()->json('id');
|
||||||
|
foreach(['si_invoices','si_proposals','si_decisions','si_events','si_outbox','si_commands'] as $table) {
|
||||||
|
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('History deleted'); }
|
||||||
|
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
|
||||||
|
}
|
||||||
|
try { DB::transaction(fn()=>DB::table('si_invoices')->where('id',$id)->update(['unit_id'=>$this->f['hidden']])); $this->fail('Mass transfer allowed'); }
|
||||||
|
catch(\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
|
||||||
|
$this->assertDatabaseCount('b_internal_confirmations',0);
|
||||||
|
}
|
||||||
|
public function test_b1_confirmed_draft_is_not_converted_or_changed(): void
|
||||||
|
{
|
||||||
|
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$this->f['draft'].'/confirm-internal',['version'=>1])->assertOk();
|
||||||
|
$before=DB::table('b_internal_confirmations')->get()->toJson();
|
||||||
|
$this->review()->assertStatus(409); $this->assertDatabaseCount('si_invoices',0);
|
||||||
|
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
|
||||||
|
}
|
||||||
|
public function test_closed_series_new_approval_changes_revision_and_has_no_counter_reset(): void
|
||||||
|
{
|
||||||
|
$old=$this->review()->assertOk()->json('review_hash');
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','previous_id'=>$this->f['series'],'data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>1,'state'=>'closed','valid_from'=>$this->f['start'],'evidence'=>'Encerramento sintético aprovado.']])->assertOk();
|
||||||
|
$new=(int)DB::table('si_proposals')->max('id');
|
||||||
|
$this->login('reader'); $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals/'.$new.'/decision',['version'=>2,'decision'=>'approved','reason'=>'Encerrar sem apagar contador.'])->assertOk();
|
||||||
|
$this->login(); $this->issue($old)->assertStatus(409); $this->review()->assertOk()->assertJsonPath('ready',false);
|
||||||
|
$this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-invoices/proposals',['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','previous_id'=>$new,'data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>10,'state'=>'open','valid_from'=>$this->f['start'],'evidence'=>'Illegal reset attempt.']])->assertUnprocessable();
|
||||||
|
}
|
||||||
|
public function test_pdf_encoding_and_long_text_are_not_silently_lost(): void
|
||||||
|
{
|
||||||
|
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/clients/'.$this->f['client'],['version'=>1,'name'=>'测试'])->assertOk();
|
||||||
|
$this->review()->assertOk()->assertJsonPath('ready',false);
|
||||||
|
$this->assertDatabaseCount('si_invoices',0);
|
||||||
|
$pdf=new \App\Modules\Services\InvoicePdf();
|
||||||
|
// Encoding refusal must be explicit, rather than generating replacement glyphs.
|
||||||
|
$this->assertFalse(\App\Modules\Services\InvoicePdf::supports([['text','测试']]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
use App\Modules\Services\DocumentPreparation;
|
||||||
|
use App\Modules\Services\ServiceFiscal;
|
||||||
|
use App\Modules\Services\ServiceInvoicing;
|
||||||
|
use App\Modules\Services\ServiceWorkflow;
|
||||||
|
use App\Shared\Context\CompanyContext;
|
||||||
|
use App\Shared\Testing\TestingDatabaseGuard;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class InvoiceFixture
|
||||||
|
{
|
||||||
|
public static function create(?array $f=null): array
|
||||||
|
{
|
||||||
|
TestingDatabaseGuard::assertSafe(); $f ??= DocumentPreviewFixture::create();
|
||||||
|
$actor=$f['manager']->id; $validator=$f['reader']->id;
|
||||||
|
foreach(['view','propose'] as $a) IdentityFixture::grant($actor,$f['tenant'],$f['company'],'service_fiscal.'.$a);
|
||||||
|
foreach(['view','approve'] as $a) IdentityFixture::grant($validator,$f['tenant'],$f['company'],'service_fiscal.'.$a);
|
||||||
|
IdentityFixture::grant($actor,$f['tenant'],$f['company'],'service_fiscal.calculate',$f['unit']);
|
||||||
|
foreach(['view','propose'] as $a) IdentityFixture::grant($actor,$f['tenant'],$f['company'],'invoice_series.'.$a);
|
||||||
|
foreach(['view','approve'] as $a) IdentityFixture::grant($validator,$f['tenant'],$f['company'],'invoice_series.'.$a);
|
||||||
|
foreach(['view','issue','correction_request'] as $a) IdentityFixture::grant($actor,$f['tenant'],$f['company'],'service_invoices.'.$a,$f['unit']);
|
||||||
|
IdentityFixture::grant($validator,$f['tenant'],$f['company'],'service_invoices.view',$f['unit']);
|
||||||
|
$select=function($id)use($f) { app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']); };
|
||||||
|
$select($actor); $w=app(ServiceWorkflow::class); $prepare=app(DocumentPreparation::class); $fiscal=app(ServiceFiscal::class); $invoices=app(ServiceInvoicing::class);
|
||||||
|
$client=$w->save('clients',null,$actor,['name'=>'Cliente sintético Horizonte'],(string)Str::uuid())['id'];
|
||||||
|
$service=$w->save('services',null,$actor,['name'=>'Alojamento mensal sintético'],(string)Str::uuid())['id'];
|
||||||
|
$today=now(ServiceInvoicing::ZONE)->format('Y-m-d'); $start=now(ServiceInvoicing::ZONE)->subDays(30)->format('Y-m-d'); $end=now(ServiceInvoicing::ZONE)->subDay()->format('Y-m-d');
|
||||||
|
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação SINTÉTICA para emissão de teste','client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'1','unit_price'=>'7850.00','description'=>'Serviço fictício para demonstração isolada.']]],(string)Str::uuid())['id'];
|
||||||
|
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Factura de TESTE — alojamento'],(string)Str::uuid())['id'];
|
||||||
|
$prepare->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$start,'period_end'=>$end,'units'=>['período'],'payment_terms'=>'Condição sintética: vencimento na data de emissão.','proposed_due_date'=>$today,'payment_instruction_ids'=>[]]);
|
||||||
|
foreach(['issuer'=>$f['company'],'client'=>$client] as $kind=>$id) $prepare->save($kind,$id,$actor,['version'=>0,'nuit'=>$kind==='issuer'?'999999991':'999999992','address'=>'Rua de demonstração, 1','locality'=>'Localidade sintética','country'=>'Moçambique']);
|
||||||
|
$profile=$fiscal->propose($actor,['kind'=>'profile','identifier'=>'TEST-PROFILE-HOSTING','data'=>['service_id'=>$service,'service_version'=>1,'currency'=>'MZN','price_mode'=>'added','rate_percent'=>'16','valid_from'=>$start,'treatment'=>'domestic_normal','conditions'=>'Hipótese sintética interna, sem aplicação à empresa real.','evidence'=>'Fixture de teste; pressupostos fictícios, não prova de conformidade.']])['id'];
|
||||||
|
$select($validator); $fiscal->decide($profile,$validator,['version'=>1,'decision'=>'approved','reason'=>'Aprovação sintética do perfil.']); $fiscal->proofActivation($profile,$validator,1,true); $select($actor);
|
||||||
|
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'TEST-CALC-PERIOD','data'=>['profile_proposal_id'=>$profile,'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>$start,'reference_basis'=>'Início explícito sintético para vigência, sem exigibilidade determinada.','period_start'=>$start,'period_end'=>$end,'event'=>'pending','evidence'=>'Pendência deliberada para provar que não autoriza emitir.']])['id'];
|
||||||
|
$select($validator); $fiscal->decide($temporal,$validator,['version'=>1,'decision'=>'approved','reason'=>'Somente cálculo condicionado.']); $select($actor);
|
||||||
|
$calculation=$fiscal->calculate($draft,$actor,['profile_proposal_id'=>$profile,'profile_version'=>1,'temporal_proposal_id'=>$temporal,'temporal_version'=>1,'draft_version'=>1,'preparation_version'=>1])['id'];
|
||||||
|
$series=$invoices->propose($actor,['kind'=>'series','identifier'=>'TEST-SERIE-ISOLADA','data'=>['code'=>'TEST-SERIE-ISOLADA','first_number'=>1,'state'=>'open','valid_from'=>$start,'evidence'=>'Série exclusivamente sintética, sem autorização de uso real.']])['id'];
|
||||||
|
$rule=$invoices->propose($actor,['kind'=>'temporal','identifier'=>'TEST-ISSUE-RULE','data'=>['valid_from'=>$start,'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'profile_proposal_id'=>$profile,'period_start'=>$start,'period_end'=>$end,'issue_window_start'=>$today,'issue_window_end'=>$today,'event'=>'on_issue_within_deadline','readiness_basis'=>'Hipótese sintética: dados documentais, cobertura, representação e tempestividade validados apenas para testar o mecanismo.','evidence'=>'Janela fictícia de emissão validada, não regra legal ou calendário da Tempo.']])['id'];
|
||||||
|
$select($validator);
|
||||||
|
foreach([$series,$rule] as $id) $invoices->decide($id,$validator,['version'=>1,'decision'=>'approved','reason'=>'Validação sintética da versão e pressupostos de teste.']);
|
||||||
|
$select($actor);
|
||||||
|
return $f+compact('client','service','source','draft','profile','temporal','calculation','series','rule','today','start','end');
|
||||||
|
}
|
||||||
|
public static function additionalDraft(array $f): array
|
||||||
|
{
|
||||||
|
TestingDatabaseGuard::assertSafe(); $actor=$f['manager']->id; $validator=$f['reader']->id;
|
||||||
|
$select=function($id)use($f) { app(CompanyContext::class)->selectFor($id,$f['tenant'],$f['company'],$f['unit']); };
|
||||||
|
$select($actor); $w=app(ServiceWorkflow::class); $fiscal=app(ServiceFiscal::class); $invoices=app(ServiceInvoicing::class);
|
||||||
|
$source=$w->save('service_drafts',null,$actor,['name'=>'Prestação sintética concorrente','client_id'=>$f['client'],'currency'=>'MZN','lines'=>[['service_id'=>$f['service'],'quantity'=>'1','unit_price'=>'7850.00']]],(string)Str::uuid())['id'];
|
||||||
|
$draft=$w->save('billing_drafts',null,$actor,['source_id'=>$source,'name'=>'Documento sintético concorrente'],(string)Str::uuid())['id'];
|
||||||
|
app(DocumentPreparation::class)->save('billing_drafts',$draft,$actor,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>$f['start'],'period_end'=>$f['end'],'units'=>['período'],'payment_instruction_ids'=>[]]);
|
||||||
|
$temporal=$fiscal->propose($actor,['kind'=>'temporal','identifier'=>'TEST-CALC-'.$draft,'data'=>['profile_proposal_id'=>$f['profile'],'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>$f['start'],'reference_basis'=>'Referência explícita sintética.','period_start'=>$f['start'],'period_end'=>$f['end'],'event'=>'pending','evidence'=>'Prova concorrente isolada.']])['id'];
|
||||||
|
$select($validator); $fiscal->decide($temporal,$validator,['version'=>1,'decision'=>'approved','reason'=>'Cálculo sintético condicionado.']); $select($actor);
|
||||||
|
$calculation=$fiscal->calculate($draft,$actor,['profile_proposal_id'=>$f['profile'],'profile_version'=>1,'temporal_proposal_id'=>$temporal,'temporal_version'=>1,'draft_version'=>1,'preparation_version'=>1])['id'];
|
||||||
|
$rule=$invoices->propose($actor,['kind'=>'temporal','identifier'=>'TEST-ISSUE-'.$draft,'data'=>['valid_from'=>$f['start'],'draft_id'=>$draft,'draft_version'=>1,'preparation_version'=>1,'profile_proposal_id'=>$f['profile'],'period_start'=>$f['start'],'period_end'=>$f['end'],'issue_window_start'=>$f['today'],'issue_window_end'=>$f['today'],'event'=>'on_issue_within_deadline','readiness_basis'=>'Pressupostos sintéticos validados para prova de concorrência.','evidence'=>'Prova isolada, sem calendário legal presumido.']])['id'];
|
||||||
|
$select($validator); $invoices->decide($rule,$validator,['version'=>1,'decision'=>'approved','reason'=>'Validação sintética da regra.']); $select($actor);
|
||||||
|
return array_replace($f,compact('source','draft','temporal','calculation','rule'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user