Accept conditional service calculation with versioned fiscal profile

This commit is contained in:
2026-10-26 16:48:44 +02:00
parent bc2707bb65
commit 9f480045fa
25 changed files with 801 additions and 3 deletions
+1
View File
@@ -52,6 +52,7 @@ export default function DocumentPreview({ id, historical, context, api, onClose
<p>Base aritmética exacta: <strong>{entry.snapshot.result.base_exact}</strong></p><p>IVA exacto: <strong>{entry.snapshot.result.vat_exact}</strong></p><p>IVA truncado proposto: <strong>{entry.snapshot.result.vat_document_proposed}</strong></p><p>Diferença descartada: <strong>{entry.snapshot.result.discarded_difference}</strong></p><p>Total documental proposto: <strong>{entry.snapshot.result.total_document_proposed ?? 'Pendente: representação da base/total'}</strong> {entry.snapshot.result.currency}</p>{entry.snapshot.result.pending.map((text,i)=><p key={i}>{text}</p>)}<p>Sem validação fiscal. {notice}</p></div>)}
</>}
</section>}
{!historical && view.fiscal_history?.length>0 && <section><h2>Cálculo proposto do serviço — condicionado</h2>{view.fiscal_history.map(entry=><div className="preview-values" key={entry.id}><h3>Resultado #{entry.id} · {entry.current?'Actual para as versões validadas':'Desactualizado — histórico'}</h3><p>Perfil {entry.snapshot.input.profile_identifier} v{entry.snapshot.input.profile_version} · decisão temporal v{entry.snapshot.input.temporal_version}</p><p>Base exacta: {entry.snapshot.result.base_exact} {entry.snapshot.result.currency}</p><p>IVA exacto: {entry.snapshot.result.vat_exact}</p><p>IVA truncado proposto: {entry.snapshot.result.vat_document_proposed}</p><p>Diferença preservada: {entry.snapshot.result.discarded_difference}</p><p>Total proposto: {entry.snapshot.result.total_document_proposed ?? 'Pendente'}</p><p>Exigibilidade: {entry.snapshot.result.exigibility_date ?? 'Não determinada'}</p><p>Período de apuramento: {entry.snapshot.result.assessment_period ?? 'Não determinado'}</p>{entry.snapshot.result.pending.map((p,i)=><p key={i}>{p}</p>)}<p>{notice} · Não representa emissão ou apuramento líquido do imposto.</p></div>)}</section>}
{!!view.payments.length && <section><h2>Instruções do emitente para facilitar o pagamento</h2><p className="help">Informação introduzida pelo utilizador, sem verificação bancária. Não regista recebimento nem inicia operação bancária.</p>{view.payments.map(payment => <div key={payment.id} className="preview-payment"><h3>{payment.data.label} · versão {payment.version}</h3>{Object.entries({ bank:'Banco', holder:'Titular', account_number:'Conta', nib:'NIB', iban:'IBAN', swift:'SWIFT', bank_code:'Código bancário', currency:'Moeda' }).filter(([key]) => payment.data[key]).map(([key,label]) => <p key={key}>{label}: <strong>{payment.data[key]}</strong></p>)}{payment.data.instructions && <p className="preview-text">{payment.data.instructions}</p>}</div>)}</section>}
{view.observations && <section><h2>Observações</h2><p className="preview-text">{view.observations}</p></section>}
<section className="preview-pending" aria-label="Pendências obrigatórias"><h2>Dados em falta e validações pendentes</h2><p>Preenchido não significa validado. Imposto e total definitivo continuam por apurar.</p><ul>{Object.entries(view.pending).map(([code,text]) => <li key={code}>{text}</li>)}</ul></section><p className="preview-end-notice">{notice}</p>
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react';
const fieldLabels = {currency:'Moeda',treatment:'Tratamento fiscal',price_mode:'Modo de preço',service_id:'Serviço',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até',rate_percent:'Taxa de IVA',profile_proposal_id:'Proposta do perfil',draft_id:'Rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data de referência',reference_basis:'Fundamento da referência',period_start:'Início do período',period_end:'Fim do período',next_billing:'Próxima facturação prevista',event:'Acontecimento',event_date:'Data do acontecimento'};
const valueLabels = {domestic_normal:'Operação interna · regime normal',added:'Preço sem IVA · imposto acrescido',included:'Imposto incluído',pending:'Por determinar',invoice_in_time:'Emissão dentro do prazo fundamentado',invoice_deadline:'Termo do prazo de emissão fundamentado'};
function displayValue(key,value) {
if(value===null || value===undefined || value==='') return 'Não indicado';
if(['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'].includes(key) && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value.split('-').reverse().join('/');
if(key==='rate_percent') return `${value}%`;
if(['service_id','draft_id','profile_proposal_id'].includes(key)) return `#${value}`;
return valueLabels[value] || String(value);
}
function FiscalProposal({p,can,busy,reasons,setReasons,perform,api,load,listing,context}) {
const d=p.definition, status=p.decision?.decision;
const stateLabel=status==='approved'?'Validada':status==='refused'?'Recusada':'Aguarda validação';
const summary=p.kind==='profile'?['rate_percent','currency','price_mode','valid_from']:['period_start','period_end','event','event_date'];
return <article className="fiscal-proposal">
<div className="fiscal-card-heading"><div><p className="eyebrow">{p.kind==='profile'?'PERFIL FISCAL':'DECISÃO TEMPORAL'} · PROPOSTA #{p.id}</p><h3>{p.kind==='profile'?`Enquadramento de IVA · serviço #${d.service_id}`:`Período e exigibilidade · rascunho #${d.draft_id}`}</h3><p className="fiscal-reference">{p.identifier} · Versão {p.version}</p></div><div className="fiscal-statuses"><span className={`fiscal-status ${status || 'pending'}`}>{stateLabel}</span>{p.kind==='profile' && <span className={`fiscal-status ${p.active?'approved':'inactive'}`}>{p.active?'Activo para cálculo proposto':'Sem activação'}</span>}</div></div>
<dl className="fiscal-summary">{summary.map(key=><div key={key}><dt>{fieldLabels[key]}</dt><dd>{displayValue(key,d[key])}</dd></div>)}</dl>
<dl className="fiscal-facts">{Object.entries(d).filter(([key])=>!summary.includes(key) && !['evidence','conditions','reference_basis'].includes(key)).map(([key,value])=><div key={key}><dt>{fieldLabels[key] || key}</dt><dd>{displayValue(key,value)}</dd></div>)}</dl>
{d.conditions && <section className="fiscal-conditions"><h4>Condições de aplicação</h4><p>{d.conditions}</p></section>}
{d.reference_basis && <section className="fiscal-conditions"><h4>Fundamento da data de referência</h4><p>{d.reference_basis}</p></section>}
{d.evidence && <details className="fiscal-evidence"><summary>Fontes e elementos de suporte</summary><p>{d.evidence}</p></details>}
{p.decision && <div className="fiscal-recorded"><strong>Fundamento da decisão</strong><p>{p.decision.reason}</p></div>}
{!p.decision && can('service_fiscal.approve') && <div className="fiscal-review"><h4>Validar esta versão</h4><p>A decisão refere-se à versão {p.version}, na unidade seleccionada. Validar não activa o perfil nem emite uma factura.</p><label htmlFor={`fiscal-reason-${p.id}`}>Fundamento da decisão<textarea id={`fiscal-reason-${p.id}`} rows={4} placeholder="Indique o fundamento da validação ou recusa." value={reasons[p.id] || ''} onChange={e=>setReasons({...reasons,[p.id]:e.target.value})}/></label><div className="actions">{['approved','refused'].map(decision=><button className={decision==='approved'?'primary':'fiscal-refuse'} key={decision} disabled={busy || !reasons[p.id]} onClick={()=>perform(async()=>{await api(`/api/service-fiscal/proposals/${p.id}/decision`,'POST',{version:p.version,decision,reason:reasons[p.id]});await load();},'Decisão registada para a versão exacta; sem activação.')}>{decision==='approved'?'Validar versão':'Recusar versão'}</button>)}</div></div>}
{p.kind==='temporal' && status==='approved' && can('service_fiscal.calculate',context.unit.id) && <div className="fiscal-review"><button disabled={busy} onClick={()=>perform(async()=>{const profile=listing.proposals.find(x=>x.id===d.profile_proposal_id); if(!profile) throw new Error('Perfil exacto não disponível; actualize a consulta.'); await api(`/api/service-fiscal/drafts/${d.draft_id}/calculate`,'POST',{profile_proposal_id:profile.id,profile_version:profile.version,temporal_proposal_id:p.id,temporal_version:p.version,draft_version:d.draft_version,preparation_version:d.preparation_version});},'Resultado proposto guardado; consulte a pré-visualização do rascunho.')}>Calcular com perfil activo e decisão exacta</button></div>}
</article>;
}
export default function ServiceFiscalWorkspace({ api, context, can, perform, busy }) {
const [listing,setListing]=useState(null), [error,setError]=useState(''), [kind,setKind]=useState('profile');
const [form,setForm]=useState({identifier:'',previous_id:'',service_id:'',service_version:'',valid_from:'',valid_until:'',conditions:'',evidence:'',profile_proposal_id:'',draft_id:'',draft_version:'',preparation_version:'',reference_date:'',reference_basis:'',period_start:'',period_end:'',next_billing:'',event:'pending',event_date:''});
const [reasons,setReasons]=useState({});
async function load() { setListing(await api('/api/service-fiscal')); }
useEffect(()=>{setListing(null);setError(''); if(context.unit) load().catch(e=>setError(e.message));},[context.token]);
if (!context.unit) return <section className="panel"><h1>Enquadramento fiscal do serviço</h1><p>Seleccione uma unidade autorizada.</p></section>;
const fields=kind==='profile' ? {service_id:'ID do serviço existente',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',conditions:'Condições de aplicabilidade',evidence:'Referências de validação e fontes'} : {profile_proposal_id:'ID da proposta exacta do perfil',draft_id:'ID do rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data explícita para verificar vigência',reference_basis:'Fundamento dessa referência (não presumido)',period_start:'Início do período na preparação',period_end:'Fim do período (opcional)',next_billing:'Próxima facturação prevista (opcional)',event_date:'Data do acontecimento (apenas se determinado)',evidence:'Evidência e fundamento do acontecimento/pêndencia'};
const dates=['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'];
async function propose(e) { e.preventDefault(); await perform(async()=>{
const data={}; Object.keys(fields).forEach(key=>{ if(form[key]!=='' && !(key==='event_date' && form.event==='pending')) data[key]=key.endsWith('_id') || key.endsWith('_version') ? Number(form[key]) : form[key]; });
if(kind==='profile') Object.assign(data,{currency:'MZN',price_mode:'added',rate_percent:'16',treatment:'domestic_normal'}); else data.event=form.event;
await api('/api/service-fiscal/proposals','POST',{kind,identifier:form.identifier,...(form.previous_id ? {previous_id:Number(form.previous_id)} : {}),data}); await load();
},'Proposta guardada; não activa o perfil.'); }
return <section className="fiscal-workspace"><div className="fiscal-page-heading"><p className="eyebrow">ENQUADRAMENTO DO SERVIÇO</p><h1>Perfis e validação fiscal</h1><p>Consulte as condições propostas e acompanhe a validação de cada versão.</p></div><div className="fiscal-notice"><strong>RASCUNHO — SEM VALIDADE FISCAL</strong><span>Aprovar não activa o perfil. Este ecrã não emite facturas; calcular não altera uma decisão temporal validada.</span></div>{error && <p className="alert error" role="alert">{error}</p>}
{listing?.migration_pending ? <p>Preparação técnica pendente de migração autorizada; não há perfil activo.</p> : <>
{can('service_fiscal.propose') && <details className="fiscal-new panel"><summary>Preparar nova proposta</summary><form onSubmit={propose}><h2>Preparar proposta para validação</h2><label>Tipo<select value={kind} onChange={e=>setKind(e.target.value)}><option value="profile">Perfil deste serviço · MZN · IVA geral 16% · preço sem IVA</option><option value="temporal">Decisão temporal de um rascunho</option></select></label>
<label>Identificador da proposta<input value={form.identifier} required onChange={e=>setForm({...form,identifier:e.target.value})}/></label><label>ID da versão anterior (apenas correcção)<input type="number" min="1" value={form.previous_id} onChange={e=>setForm({...form,previous_id:e.target.value})}/></label>
{kind==='temporal' && <label>Acontecimento<select value={form.event} onChange={e=>setForm({...form,event:e.target.value,event_date:''})}><option value="pending">Pendente — não determinar exigibilidade</option><option value="invoice_in_time">Emissão dentro do prazo fundamentado</option><option value="invoice_deadline">Termo do prazo de emissão fundamentado</option></select><small>Adiantamentos e afectações parciais ficam fora deste primeiro caso.</small></label>}
{Object.entries(fields).filter(([key])=>key!=='event_date' || form.event!=='pending').map(([key,label])=><label key={key}>{label}{['conditions','evidence','reference_basis'].includes(key)?<textarea rows={4} value={form[key]} required onChange={e=>setForm({...form,[key]:e.target.value})}/>:<input type={dates.includes(key)?'date':'text'} value={form[key]} required={!['valid_until','period_end','next_billing'].includes(key)} onChange={e=>setForm({...form,[key]:e.target.value})}/>}</label>)}
<p className="help">Fontes e condições são referências de validação; não escreva segredos. Correcção material gera nova versão. Facturação prevista não é emissão efectiva.</p><button className="primary" disabled={busy}>Submeter proposta</button></form></details>}
<div className="fiscal-list-heading"><h2>Propostas na unidade</h2><span>{context.unit.name} · {listing?.proposals.length ?? '…'} proposta(s)</span></div>{listing?.proposals.map(p=><FiscalProposal key={p.id} {...{p,can,busy,reasons,setReasons,perform,api,load,listing,context}}/>)}{!listing && <p role="status">A carregar propostas…</p>}{listing && !listing.proposals.length && <div className="panel"><h3>Ainda não há propostas nesta unidade</h3><p>As propostas e respectivas decisões serão apresentadas aqui.</p></div>}</>}
</section>;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.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' };
@@ -49,7 +50,7 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('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_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
@@ -59,6 +60,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); }} />}
{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 === '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 === '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>)}
</div>}