Rotas Cristalinas

ERP Da Associação · Menoita
`;const w=window.open('','_blank');w.document.write(html);w.document.close();setTimeout(()=>w.print(),500)} function calcMargin(custo,venda){custo=Number(custo||0);venda=Number(venda||0);return venda>0?((venda-custo)/venda*100):0} async function quotas(){const fields=['ano','valor','pago','data_pagamento','metodo','observacoes'];const {data=[]}=await client.from('quotas').select('*,socios(nome)').order('ano',{ascending:false}).limit(100);$('main').innerHTML=`

Quotas

A Base De Dados Já Suporta A Ligação Da Quota Ao Sócio.

${form('quotas',fields,{ano:new Date().getFullYear(),valor:12,pago:false})}${table(data.map(x=>({...x,socio:x.socios?.nome||''})),['socio'].concat(fields))}`} async function documentos(){const fields=['titulo','categoria','entidade','data_documento','ficheiro_url','observacoes'];const {data=[]}=await client.from('documentos').select('*').order('created_at',{ascending:false}).limit(100);$('main').innerHTML=`

Gestão Documental

${form('documentos',fields,{data_documento:today()})}${table(data,fields)}`} async function generic(tbl,title,fields){const {data=[]}=await client.from(tbl).select('*').order('created_at',{ascending:false}).limit(100);$('main').innerHTML=`

${title}

${form(tbl,fields,{})}${table(data,fields)}`} function form(tbl,fields,defs){const readOnly=!canEdit();return `

Novo Registo

${readOnly?'
O teu perfil está em modo consulta. Não podes criar ou alterar registos.
':''}
${fields.map(f=>`
${input(tbl,f,defs[f])}
`).join('')}


`} function input(tbl,f,v=''){if(f==='tipo')return ``;if(f==='area')return select(tbl,f,getCatGroup('areas'),v,true);if(f==='pessoa_servico')return select(tbl,f,getCatGroup('funcionarios'),v,true);if(f==='metodo')return select(tbl,f,getCatGroup('metodos'),v,true);if(f==='categoria'&&tbl==='movimentos')return select(tbl,f,[...getCatGroup('despesa'),...getCatGroup('receita')],v,true);if(f==='categoria'&&tbl==='produtos')return select(tbl,f,getCatGroup('produto'),v,true);if(f==='categoria'&&tbl==='inventario')return select(tbl,f,getCatGroup('inventario'),v,true);if(f==='estado')return select(tbl,f,['Pendente','Pago','Recebido','Ativo','Inativo','Cancelado','Devolvido','Planeado','Importada'],v,true);if(f.includes('data'))return ``;if(['valor','iva','preco_compra','preco_venda','stock_atual','stock_minimo','dinheiro','mbway','multibanco','outros','despesas_dia','valor_esperado','valor_real','orcamento','quantidade','ano','numero','pack_unidades','valor_emprestimo','caucao','quantidade_emprestada','valor_emprestimo_padrao','caucao_padrao'].includes(f))return ``;if(f==='pago')return ``;if(f==='observacoes'||f==='descricao')return ``;return ``} function select(tbl,f,opts,v,custom){return ``} function updateCat(tbl){const t=$(`${tbl}_tipo`)?.value,c=$(`${tbl}_categoria`);if(!c)return;const opts=t==='receita'?getCatGroup('receita'):getCatGroup('despesa');c.innerHTML=opt(opts,'')+''} function saveDraft(tbl,fs){let row={};fs.split(',').forEach(f=>{let el=$(`${tbl}_${f}`);if(el)row[f]=el.value});localStorage.setItem('draft_'+tbl,JSON.stringify(row));alert('Rascunho Guardado Neste Equipamento.')} function restoreDraft(tbl,fs){let row=JSON.parse(localStorage.getItem('draft_'+tbl)||'{}');fs.split(',').forEach(f=>{let el=$(`${tbl}_${f}`);if(el&&row[f]!=null)el.value=row[f]})} const numericFields=new Set(['valor','iva','preco_compra','preco_venda','stock_atual','stock_minimo','dinheiro','mbway','multibanco','outros','despesas_dia','valor_esperado','valor_real','orcamento','quantidade','ano','numero','pack_unidades','volume_ml','valor_emprestimo','caucao','quantidade_emprestada','valor_emprestimo_padrao','caucao_padrao']); const dateFields=new Set(['data','data_inicio','data_fim','data_pagamento','data_saida','data_prevista_devolucao','data_devolucao','data_documento']); function cleanFormValue(f,val){ if(val==='true')return true; if(val==='false')return false; if(numericFields.has(f)){ if(val===''||val===null||val===undefined)return ['dinheiro','mbway','multibanco','outros','despesas_dia','valor_esperado','valor_real','valor','iva','preco_compra','preco_venda','stock_atual','stock_minimo','orcamento','quantidade','pack_unidades','volume_ml','valor_emprestimo','caucao','quantidade_emprestada','valor_emprestimo_padrao','caucao_padrao'].includes(f)?0:null; const n=Number(String(val).replace(',','.')); return Number.isFinite(n)?n:0; } if(dateFields.has(f))return val||null; return val; } async function save(tbl,fs){ if(!canEdit())return notify('Perfil De Consulta: Não Pode Guardar Registos.','warn'); let row={criado_por:session.user.id}; fs.split(',').forEach(f=>{let el=$(`${tbl}_${f}`);if(!el)return;row[f]=cleanFormValue(f,el.value)}); if(tbl==='fechos_caixa'){ row.valor_esperado=Number(row.valor_esperado||0); row.valor_real=Number(row.valor_real||0); row.dinheiro=Number(row.dinheiro||0); row.mbway=Number(row.mbway||0); row.multibanco=Number(row.multibanco||0); row.outros=Number(row.outros||0); row.despesas_dia=Number(row.despesas_dia||0); if(!row.valor_esperado) row.valor_esperado=row.dinheiro+row.mbway+row.multibanco+row.outros-row.despesas_dia; if(!row.valor_real) row.valor_real=row.dinheiro+row.mbway+row.multibanco+row.outros; } const {data,error}=await client.from(tbl).insert(row).select().single(); if(error)alert(error.message);else{localStorage.removeItem('draft_'+tbl);await audit('Criou Registo',tbl,data?.id||null,row);render()} } function table(data,fields){if(!data?.length)return '
Sem Registos.
';return `
${fields.map(f=>``).join('')}${data.map(r=>`${fields.map(f=>``).join('')}`).join('')}
${nice(f)}
${val(r,f)}
`} function val(r,f){let v=r[f];if(v===true)return 'Sim';if(v===false)return 'Não';if(['valor','iva','preco_compra','preco_venda','orcamento','dinheiro','mbway','multibanco','outros','diferenca','despesas_dia','valor_esperado','valor_real','lucro_unidade','total','total_linha','preco_unitario_stock','preco_venda_sugerido','valor_emprestimo','caucao','valor_emprestimo_padrao','caucao_padrao'].includes(f))return eur(v);if(f==='margem'||f==='margem_sugerida')return dec(v,1)+'%';return htmlEscape(v??'')} function fileRefToPath(ref){ ref=String(ref||'').trim(); if(!ref) return ''; let m=ref.match(/\/storage\/v1\/object\/(?:public|sign)\/faturas\/([^?]+)/); if(m) return decodeURIComponent(m[1]); if(ref.startsWith('faturas/')) return ref.slice(8); if(ref.startsWith('http')) return ''; return ref; } async function signedInvoiceUrl(ref){ if(!ref) return ''; const path=fileRefToPath(ref); if(path){const {data,error}=await client.storage.from('faturas').createSignedUrl(path,60*60); if(!error&&data?.signedUrl) return data.signedUrl;} return ref; } function invoiceHistoryTable(data){ if(!data?.length)return '
Sem Faturas Importadas.
'; return `
${data.map(f=>``).join('')}
DataFornecedorDocumentoTotalIVAFicheiroEstadoAção
${htmlEscape(f.data||'')}${htmlEscape(f.fornecedor||'')}${htmlEscape(f.documento||'')}${eur(f.total)}${eur(f.iva)}${f.ficheiro_url?'Guardado':'Sem Ficheiro'}${htmlEscape(f.estado||'')}${f.ficheiro_url?` `:''}
`; } async function openInvoiceFile(id){ const {data,error}=await client.from('faturas_compras').select('*').eq('id',id).single(); if(error)return notify(error.message,'bad'); if(!data?.ficheiro_url)return notify('Esta fatura não tem ficheiro/foto associado.','warn'); const url=await signedInvoiceUrl(data.ficheiro_url); if(!url)return notify('Não foi possível criar link para o ficheiro.','bad'); window.open(url,'_blank'); } async function openInvoiceDetails(id){ const [{data:f,error:e1},{data:linhas0=[],error:e2},{data:stockRows=[],error:e3}]=await Promise.all([ client.from('faturas_compras').select('*').eq('id',id).single(), client.from('fatura_linhas').select('*,produtos(nome,categoria,preco_venda)').eq('fatura_id',id).order('created_at',{ascending:true}), client.from('stock_movimentos').select('*,produtos(nome,categoria,preco_venda)').eq('fatura_id',id).order('created_at',{ascending:true}) ]); if(e1)return notify(e1.message,'bad'); if(e2)console.warn(e2); if(e3)console.warn(e3); const url=f.ficheiro_url?await signedInvoiceUrl(f.ficheiro_url):''; const isPdf=url && /\.pdf(\?|$)/i.test(url); const linhas=linhas0.length?linhas0:stockRows.map(s=>({ id:'stock_'+s.id,_stockOnly:true,produto_id:s.produto_id,produto:s.produtos?.nome||'Produto',produto_nome:s.produtos?.nome||'Produto', categoria:s.produtos?.categoria||'Outros',quantidade_comprada:Number(s.quantidade||0),unidades_por_embalagem:1,quantidade_stock:Number(s.quantidade||0), total_linha:Number(s.total||0),preco_unitario_stock:Number(s.preco_unitario||0),preco_venda_sugerido:Number(s.produtos?.preco_venda||0),margem_sugerida:calcMargin(Number(s.preco_unitario||0),Number(s.produtos?.preco_venda||0)),nota_ocr:'Linha recuperada do movimento de stock' })); $('main').innerHTML=`${pageTitle('Detalhe Da Fatura','Consulta e correção da fatura importada, foto/PDF original, produtos e stock gerado.')}
${url?``:''}
Edição: podes corrigir produto, categoria, quantidades, total da linha e preço de venda. Ao guardar uma linha, a app corrige também o movimento de stock e o stock atual do produto.

Dados Da Fatura

Data${htmlEscape(f.data||'')}
Fornecedor${htmlEscape(f.fornecedor||'')}
NIF${htmlEscape(f.nif||'')}
Documento${htmlEscape(f.documento||'')}
Total${eur(f.total)}
IVA${eur(f.iva)}

Texto OCR/IA Guardado

Foto / PDF Original

${url?(isPdf?``:`Fatura`):'
Esta fatura foi importada sem ficheiro/foto associado.
'}

Produtos Importados / Editáveis

${editableInvoiceLinesTable(f.id,linhas,linhas0.length)}
`; } function editableInvoiceLinesTable(faturaId,linhas,hasRealLines=true){ if(!linhas?.length)return `
Esta fatura ainda não tem produtos associados. Se foi uma fatura antiga importada antes desta correção, volta a importar a fatura ou usa a foto guardada para extrair novamente.
`; const cats=getCatGroup('produto'); return `
${linhas.map(l=>{const id=String(l.id);const stock=Number(l.quantidade_stock||0);const custo=Number(l.preco_unitario_stock||0);const venda=Number(l.preco_venda_sugerido||l.produtos?.preco_venda||0);const margem=calcMargin(custo,venda);return ``}).join('')}
ProdutoCategoriaQtd CompraUnid./Emb.StockTotal LinhaCusto UnidadePreço VendaMargemNotaAções
${dec(stock,2)}${eur(custo)}${dec(margem,1)}%${htmlEscape(l.nota_ocr||'')}${l._stockOnly?'Recuperada do stock':` `}

As faturas antigas que não têm linhas guardadas podem aparecer apenas como movimentos de stock. As próximas importações já guardam produto, linha, stock e foto/PDF.

`; } function numEdit(id,field){return Number((document.getElementById(`edit_${field}_${id}`)?.value||'0').replace(',','.'))||0} function txtEdit(id,field){return (document.getElementById(`edit_${field}_${id}`)?.value||'').trim()} async function saveImportedInvoiceLine(lineId){ try{ const {data:old,error}=await client.from('fatura_linhas').select('*').eq('id',lineId).single(); if(error)throw error; const qtd=numEdit(lineId,'qtd')||1, emb=numEdit(lineId,'emb')||1, total=numEdit(lineId,'total'), venda=numEdit(lineId,'venda'); const row={produto_nome:txtEdit(lineId,'produto')||old.produto_nome,categoria:txtEdit(lineId,'categoria')||old.categoria,quantidade_comprada:qtd,unidades_por_embalagem:emb,quantidade_stock:qtd*emb,total_linha:total,preco_unitario_stock:(qtd*emb)>0?total/(qtd*emb):0,preco_venda_sugerido:venda,margem_sugerida:venda>0?((venda-((qtd*emb)>0?total/(qtd*emb):0))/venda*100):0}; const delta=Number(row.quantidade_stock||0)-Number(old.quantidade_stock||0); const {error:uerr}=await client.from('fatura_linhas').update(row).eq('id',lineId); if(uerr)throw uerr; if(old.produto_id){ const {data:prod}=await client.from('produtos').select('*').eq('id',old.produto_id).single(); if(prod){await client.from('produtos').update({nome:row.produto_nome,categoria:row.categoria,preco_compra:row.preco_unitario_stock,preco_venda:row.preco_venda_sugerido,stock_atual:Number(prod.stock_atual||0)+delta,updated_at:new Date().toISOString()}).eq('id',old.produto_id)} await client.from('stock_movimentos').update({quantidade:row.quantidade_stock,total:row.total_linha,preco_unitario:row.preco_unitario_stock}).eq('fatura_id',old.fatura_id).eq('produto_id',old.produto_id); } await audit('Corrigiu Linha De Fatura','fatura_linhas',lineId,{antes:old,depois:row}); notify('Linha corrigida e stock atualizado.','ok'); openInvoiceDetails(old.fatura_id); }catch(e){notify('Erro ao guardar linha: '+(e.message||e),'bad')} } async function deleteImportedInvoiceLine(lineId){ if(!confirm('Remover esta linha? Isto também corrige o stock do produto.'))return; try{ const {data:old,error}=await client.from('fatura_linhas').select('*').eq('id',lineId).single(); if(error)throw error; if(old.produto_id){ const {data:prod}=await client.from('produtos').select('*').eq('id',old.produto_id).single(); if(prod)await client.from('produtos').update({stock_atual:Number(prod.stock_atual||0)-Number(old.quantidade_stock||0),updated_at:new Date().toISOString()}).eq('id',old.produto_id); await client.from('stock_movimentos').delete().eq('fatura_id',old.fatura_id).eq('produto_id',old.produto_id); } await client.from('fatura_linhas').delete().eq('id',lineId); await audit('Removeu Linha De Fatura','fatura_linhas',lineId,old); notify('Linha removida e stock corrigido.','ok'); openInvoiceDetails(old.fatura_id); }catch(e){notify('Erro ao remover linha: '+(e.message||e),'bad')} } function movementsTable(data,fields){ if(!data?.length)return '
Sem Registos.
'; return `
${fields.map(f=>``).join('')}${data.map(r=>`${fields.map(f=>``).join('')}`).join('')}
${nice(f)}Ficheiro
${val(r,f)}${r.ficheiro_url?``:''}
`; } async function openMovementFile(id){ const {data,error}=await client.from('movimentos').select('*').eq('id',id).single(); if(error)return notify(error.message,'bad'); if(!data?.ficheiro_url)return notify('Movimento sem ficheiro associado.','warn'); const url=await signedInvoiceUrl(data.ficheiro_url); if(url) window.open(url,'_blank'); else notify('Não foi possível abrir o ficheiro.','bad'); } async function pesquisa(){ $('main').innerHTML=`${pageTitle('Pesquisa Global','Pesquisa em produtos, movimentos, sócios, inventário, empréstimos, faturas e documentos.')}

`; const q=$('globalSearchTop')?.value||''; if(q){$('search_query').value=q; runSearch();} } function rowText(r){return Object.values(r||{}).map(v=>typeof v==='object'?JSON.stringify(v):String(v??'')).join(' ').toLowerCase()} function hi(text,q){const s=htmlEscape(text??''); if(!q)return s; try{return s.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'ig'),m=>`${m}`)}catch(e){return s}} async function runSearch(){const q=($('search_query')?.value||$('globalSearchTop')?.value||'').trim().toLowerCase();if(!q)return notify('Escreve algo para pesquisar.','warn');$('search_results').innerHTML='
A pesquisar...
';const tables=[['produtos','Produtos',['nome','categoria','fornecedor','preco_venda','stock_atual']],['movimentos','Movimentos',['data','tipo','categoria','descricao','fornecedor_cliente','valor']],['socios','Sócios',['numero','nome','email','telefone','estado']],['inventario','Inventário',['nome','categoria','quantidade','localizacao','estado']],['emprestimos','Empréstimos',['entidade','contacto','item_nome_snapshot','estado','data_saida','data_prevista_devolucao']],['faturas_compras','Faturas',['data','fornecedor','documento','total','estado']],['documentos','Documentos',['titulo','categoria','entidade','data_documento','observacoes']]];let html='';for(const [tbl,title,fields] of tables){try{const {data=[]}=await client.from(tbl).select('*').limit(250);const res=data.filter(r=>rowText(r).includes(q)).slice(0,30);if(res.length)html+=`

${title}

${table(res,fields)}

`}catch(e){html+=`
${title}: ${htmlEscape(e.message||e)}
`}}$('search_results').innerHTML=html||'
Sem resultados.
'} async function relatorios(){const {data=[]}=await client.from('movimentos').select('*').order('data',{ascending:false});const {data:caixas=[]}=await client.from('fechos_caixa').select('*').order('data',{ascending:false});const {data:prod=[]}=await client.from('produtos').select('*');const {data:loans=[]}=await client.from('emprestimos').select('*');let cats={},months={};data.forEach(x=>{let cat=x.categoria||'Sem Categoria',val=Number(x.valor||0),m=(x.data||'Sem Data').slice(0,7);cats[cat]=(cats[cat]||0)+val;months[m]=months[m]||{receita:0,despesa:0};months[m][x.tipo==='receita'?'receita':'despesa']+=val});$('main').innerHTML=`${pageTitle('Relatórios','Exportações para tesouraria, direção e contabilista.')}

Resumo Por Mês

${monthlyTables(data)}

Totais Por Categoria

${categoryBars(data)}

${caixaByPersonTables(caixas)}

Produtos Com Maior Margem

${table(prod.map(x=>({...x,margem:calcMargin(x.preco_compra,x.preco_venda),lucro_unidade:Number(x.preco_venda||0)-Number(x.preco_compra||0)})).sort((a,b)=>Number(b.lucro_unidade)-Number(a.lucro_unidade)).slice(0,12),['nome','categoria','preco_compra','preco_venda','lucro_unidade','margem'])}

Stock Baixo

${table(prod.filter(x=>Number(x.stock_atual)<=Number(x.stock_minimo)),['nome','stock_atual','stock_minimo','fornecedor'])}

Empréstimos Ativos

${table(loans.filter(isLoanActive),['entidade','item_nome_snapshot','quantidade_emprestada','data_saida','data_prevista_devolucao','estado'])}
`} function calculadora(){lastEdited=null;$('main').innerHTML=`${pageTitle('Calculadora De Doses','Calcula custo, preço de venda, lucro por dose e lucro total da garrafa.')}

Para decisões de preço, usa sobretudo o lucro sem IVA. O preço ao cliente é normalmente o valor com IVA.

Compra E Custo

Compra Sem IVA
Compra Com IVA
N.º De Doses
Custo Por Dose Sem IVA
Custo Por Dose Com IVA

Venda E Lucro

Venda Por Dose Sem IVA
Venda Por Dose Com IVA
Lucro Por Dose Sem IVA
Lucro Por Dose Com IVA
Lucro Total Da Garrafa Sem IVA
Lucro Total Da Garrafa Com IVA
Margem Líquida

`;calcularDose()} function setDoseQuick(v){$('dose').value=v;calcularDose()} function setMarkupQuick(v){$('markup').value=v;calcularDose()} let lastEdited=null;function n(id){const v=parseFloat($(id)?.value);return Number.isFinite(v)?v:NaN} function arredondar(v,inc){return inc>0?Math.round(v/inc)*inc:v} function calcularDose(){const iva=n('ivaCalc'),volume=n('volume'),dose=n('dose'),perdas=n('perdas')||0,markup=n('markup')||0,arred=n('arredVenda')||0;let sem=n('compraSem'),com=n('compraCom');if(lastEdited==='compraSem'&&Number.isFinite(sem))com=sem*(1+iva/100);else if(lastEdited==='compraCom'&&Number.isFinite(com))sem=com/(1+iva/100);else if(Number.isFinite(sem))com=sem*(1+iva/100);else if(Number.isFinite(com))sem=com/(1+iva/100);if($('outCompraSem')){$('outCompraSem').textContent=Number.isFinite(sem)?eur(sem):'—';$('outCompraCom').textContent=Number.isFinite(com)?eur(com):'—'}if(!Number.isFinite(sem)||sem<=0||volume<=0||dose<=0){if($('notaCalc'))$('notaCalc').textContent='Preenche Os Dados Necessários.';return}const nDoses=Math.floor(volume*(1-perdas/100)/dose);if(nDoses<=0)return;const custoSem=sem/nDoses,custoCom=com/nDoses;let vendaSem=arredondar(custoSem*markup,arred);let vendaCom=arredondar(vendaSem*(1+iva/100),arred);let vendaSemReal=vendaCom/(1+iva/100);let lucroDoseSem=vendaSemReal-custoSem,lucroDoseCom=vendaCom-custoCom;let lucroGarrafaSem=lucroDoseSem*nDoses,lucroGarrafaCom=lucroDoseCom*nDoses;let margem=vendaSemReal>0?(lucroDoseSem/vendaSemReal*100):0;$('ndoses').textContent=nDoses;$('custoSem').textContent=eur(custoSem);$('custoCom').textContent=eur(custoCom);$('vendaSem').textContent=eur(vendaSemReal);$('vendaCom').textContent=eur(vendaCom);$('lucroDoseSem').textContent=eur(lucroDoseSem);$('lucroDoseCom').textContent=eur(lucroDoseCom);$('lucroGarrafaSem').textContent=eur(lucroGarrafaSem);$('lucroGarrafaCom').textContent=eur(lucroGarrafaCom);$('margemDose').textContent=dec(margem,1)+'%';$('notaCalc').textContent='Nota: O Preço De Venda Ao Cliente Normalmente É O Valor Com IVA. A Margem Real Usa O Valor Sem IVA.'} async function faturas(){invoiceLines=[];const {data=[]}=await client.from('faturas_compras').select('*').order('data',{ascending:false}).limit(80);$('main').innerHTML=`${pageTitle('Faturas Para Stock','Importação inteligente de compras para stock, arquivo da fatura e sugestão de preços.')}
Arquivo: depois de guardares uma fatura, podes voltar a vê-la em Últimas Faturas Importadas → Ver. O ficheiro/foto fica ligado à fatura e também ao movimento de despesa.

Dados Da Fatura

Nenhum ficheiro escolhido.
No telemóvel, usa “Tirar Foto” para abrir a câmara ou “Escolher Foto/Doc” para galeria/ficheiros. Também podes usar o campo nativo acima.

Texto Da Fatura

Podes corrigir o texto antes de analisar. Esta versão usa OCR local melhorado e IA por Groq quando configurada. Esta versão mostra erros detalhados da API.

Visualização Grande Da Fatura

Confirma a imagem e compara logo com os campos e as linhas importadas.
A imagem escolhida vai aparecer aqui em grande para facilitar a verificação.

Linhas Da Fatura Para Stock

Ainda Sem Linhas. Usa OCR, IA Ou Carrega O Exemplo.


Últimas Faturas Importadas

${invoiceHistoryTable(data)}`} function chooseInvoiceFile(){chooseInvoiceGallery()} function chooseInvoiceCamera(){const inp=$('fat_camera')||$('fat_file'); if(!inp)return; try{inp.value=''}catch(e){}; inp.click()} function chooseInvoiceGallery(){const inp=$('fat_gallery')||$('fat_file'); if(!inp)return; try{inp.value=''}catch(e){}; inp.click()} function previewInvoiceFile(){return previewInvoiceFileFrom($('fat_file')||$('fat_camera')||$('fat_gallery'))} function previewInvoiceFileFrom(input){ const file=input&&input.files&&input.files[0]; if(!file){selectedInvoiceFile=null; if($('fat_file_name'))$('fat_file_name').textContent='Nenhum ficheiro escolhido.'; if($('fat_preview_large'))$('fat_preview_large').classList.add('hidden'); if($('fat_preview_large_empty'))$('fat_preview_large_empty').classList.remove('hidden'); return} selectedInvoiceFile=file; invoiceImageRotation=0; resetInvoiceOtherInputs(input); if($('fat_file_name'))$('fat_file_name').textContent='Selecionado: '+(file.name||'fotografia')+' · '+Math.round((file.size||0)/1024)+' KB'; const prev=$('fat_preview'); const prevLarge=$('fat_preview_large'); const prevLargeEmpty=$('fat_preview_large_empty'); if(file.type&&file.type.startsWith('image/')&&prev){ try{ if(prev.dataset.url) URL.revokeObjectURL(prev.dataset.url); }catch(e){} const url=URL.createObjectURL(file); prev.dataset.url=url; prev.src=url; prev.style.transform='rotate(0deg)'; prev.classList.remove('hidden'); if(prevLarge){ prevLarge.src=url; prevLarge.style.transform='rotate(0deg)'; prevLarge.classList.remove('hidden'); } if(prevLargeEmpty) prevLargeEmpty.classList.add('hidden'); if($('fat_rotate_tools'))$('fat_rotate_tools').classList.remove('hidden'); notify('Foto escolhida/importada. Agora podes usar Extrair Com IA, OCR Máximo ou Ler QR.','ok'); }else{ if(prev)prev.classList.add('hidden'); if(prevLarge)prevLarge.classList.add('hidden'); if(prevLargeEmpty)prevLargeEmpty.classList.remove('hidden'); if($('fat_rotate_tools'))$('fat_rotate_tools').classList.add('hidden'); if(file.type==='application/pdf') notify('PDF escolhido. Para melhor resultado, usa fotografia/imagem da fatura.','warn'); else notify('Ficheiro escolhido, mas pode não ser uma imagem compatível.','warn'); } } function resetInvoiceOtherInputs(activeInput){ // Em alguns browsers, ter vários inputs file com o mesmo ficheiro pode causar eventos estranhos. // Mantemos o File em memória (selectedInvoiceFile) e limpamos apenas os outros inputs. ['fat_file','fat_camera','fat_gallery'].forEach(id=>{ const el=$(id); if(el && el!==activeInput){try{el.value=''}catch(e){}} }); } function getInvoiceSelectedFile(){ if(selectedInvoiceFile) return selectedInvoiceFile; for(const id of ['fat_file','fat_camera','fat_gallery']){ const el=$(id); if(el && el.files && el.files[0]){selectedInvoiceFile=el.files[0]; return selectedInvoiceFile;} } return null; } function rotateInvoiceImage(delta){ invoiceImageRotation=((invoiceImageRotation+delta)%360+360)%360; const prev=$('fat_preview'); const prevLarge=$('fat_preview_large'); if(prev){prev.style.transform='rotate('+invoiceImageRotation+'deg)'; prev.style.transformOrigin='center center';} if(prevLarge){prevLarge.style.transform='rotate('+invoiceImageRotation+'deg)'; prevLarge.style.transformOrigin='center center';} const note=$('fat_rotation_note'); if(note)note.textContent='Rotação aplicada: '+invoiceImageRotation+'°. Esta rotação será usada na IA/OCR.'; } document.addEventListener('change',function(e){if(e.target&&['fat_file','fat_camera','fat_gallery'].includes(e.target.id)){previewInvoiceFileFrom(e.target)}}) function loadExampleInvoice(){$('fat_ocr').value='Fornecedor Exemplo\nFatura FT 2026/001\n3 pacotes batata frita 3.60€\n1 pack águas de 24 garrafas 33cl 10€\nTotal 13.60€';parseInvoiceText()} async function runInvoiceOCRRapido(){return runInvoiceOCREngine({maximo:false})} async function runInvoiceOCRMaximo(){return runInvoiceOCREngine({maximo:true})} async function runInvoiceOCR(){return runInvoiceOCRMaximo()} function normalizeGroqNumber(v){ if(v===null||v===undefined||v==='')return 0; if(typeof v==='number')return Number.isFinite(v)?v:0; let t=String(v).replace(/\s/g,'').replace('€','').replace(/\.(?=\d{3}(\D|$))/g,'').replace(',','.'); const n=parseFloat(t); return Number.isFinite(n)?n:0; } async function invoiceFileToGroqDataUrl(file, opts={}){ if(!file || !file.type || !file.type.startsWith('image/')) throw new Error('A extração por IA só lê fotografias/imagens nesta versão.'); const img=await readFileAsImage(file); // V3.7.18: modo compacto para Qwen/Groq, porque o plano on_demand pode bloquear pedidos acima de 8000 TPM. // A foto original continua guardada sem alterações; só enviamos à IA uma cópia otimizada. let dataUrl=''; const rot=((invoiceImageRotation||0)%360+360)%360; const compact=!!opts.compact; const sides=compact ? [820,720,640,560] : [1900,1700,1500,1300]; const targetLen=compact ? 520_000 : 2_400_000; const maxLen=compact ? 760_000 : 3_100_000; for(const side of sides){ const scale=Math.min(side/img.width,side/img.height,1); const sw=Math.max(1,Math.round(img.width*scale)); const sh=Math.max(1,Math.round(img.height*scale)); const canvas=document.createElement('canvas'); const rotated=rot===90||rot===270; canvas.width=rotated?sh:sw; canvas.height=rotated?sw:sh; const ctx=canvas.getContext('2d',{willReadFrequently:true}); ctx.fillStyle='white'; ctx.fillRect(0,0,canvas.width,canvas.height); ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality='high'; if(rot){ ctx.translate(canvas.width/2,canvas.height/2); ctx.rotate(rot*Math.PI/180); ctx.drawImage(img,-sw/2,-sh/2,sw,sh); }else{ ctx.drawImage(img,0,0,sw,sh); } let q=compact ? .52 : .78; dataUrl=canvas.toDataURL('image/jpeg',q); while(dataUrl.length>targetLen && q>(compact?.24:.46)){q-=.06; dataUrl=canvas.toDataURL('image/jpeg',q)} if(dataUrl.length<=maxLen) break; } if(dataUrl.length>(compact?900_000:3_600_000)) throw new Error('A imagem continua grande para a IA/Qwen. Tenta recortar a foto só à tabela/produtos ou usa Llama 4 Scout/Gemini.'); return dataUrl; } async function postInvoiceAI(payload){ const controller=new AbortController(); const timer=setTimeout(()=>controller.abort(), 85000); try{ return await fetch('/api/extract-invoice-ai',{ method:'POST', headers:{'Content-Type':'application/json',...(session?.access_token?{Authorization:'Bearer '+session.access_token}:{})}, body:JSON.stringify(payload), signal:controller.signal }); }finally{clearTimeout(timer)} } function applyGroqInvoiceResult(inv){ if(!inv || typeof inv!=='object') throw new Error('Resposta Groq inválida.'); if(inv.fornecedor) $('fat_fornecedor').value=String(inv.fornecedor).trim(); if(inv.nif) $('fat_nif').value=String(inv.nif).replace(/\D/g,'').slice(0,9); if(inv.documento) $('fat_documento').value=String(inv.documento).trim(); if(inv.data && /^\d{4}-\d{2}-\d{2}$/.test(String(inv.data))) $('fat_data').value=inv.data; if(inv.total!==null && inv.total!==undefined && Number.isFinite(normalizeGroqNumber(inv.total))) $('fat_total').value=normalizeGroqNumber(inv.total).toFixed(2); if(inv.iva_total!==null && inv.iva_total!==undefined) $('fat_iva').value=normalizeGroqNumber(inv.iva_total).toFixed(2); if($('fat_ocr')) $('fat_ocr').dataset.totalLiquido = normalizeGroqNumber(inv.total_liquido||inv.base_incidencia||inv.subtotal||inv.total_sem_iva||0) || ''; const categorias=getCatGroup('produto'); const linhas=Array.isArray(inv.linhas)?inv.linhas:[]; invoiceLines=linhas.map(l=>{ const nome=titleCaseWords(cleanProductName(l.produto_nome||l.descricao_original||'Produto')); let categoria=l.categoria_sugerida||l.categoria||suggestCategory(nome); if(!categorias.includes(categoria)) categoria=suggestCategory(nome); if(!categorias.includes(categoria)) categoria='Outros'; let qtd=normalizeGroqNumber(l.quantidade_comprada); if(!qtd)qtd=1; let emb=normalizeGroqNumber(l.unidades_por_embalagem); if(!emb)emb=1; let stock=normalizeGroqNumber(l.quantidade_stock); if(!stock)stock=qtd*emb; let total=normalizeGroqNumber(l.total_linha); let custo=normalizeGroqNumber(l.preco_unitario_stock); if(!custo && stock>0)custo=total/stock; let venda=normalizeGroqNumber(l.preco_venda_sugerido); if(!venda)venda=suggestSale(custo,categoria); let margem=venda>0?((venda-custo)/venda*100):0; return {descricao_original:l.descricao_original||nome,produto_nome:nome,categoria,quantidade_comprada:qtd,unidades_por_embalagem:emb,quantidade_stock:stock,unidade:l.unidade||'un',total_linha:total,preco_unitario_stock:custo,iva_taxa:normalizeGroqNumber(l.iva_taxa)||23,preco_venda_sugerido:venda,margem_sugerida:margem,nota_ocr:l.nota||l.nota_ocr||'Extraído com IA'}; }).filter(l=>l.produto_nome && l.total_linha>=0); invoiceLines=dedupeInvoiceLinesSmart(invoiceLines); const resumo=['EXTRAÇÃO COM GROQ']; if(inv.observacoes)resumo.push(String(inv.observacoes)); if(inv.texto_extraido)resumo.push(String(inv.texto_extraido)); $('fat_ocr').value=resumo.join('\n\n'); renderInvoiceLines(); if(!invoiceLines.length){ $('ocr_status').textContent='IA leu os dados principais, mas não devolveu linhas de produtos. Tenta aproximar mais a foto da zona da tabela/produtos ou usa OCR Máximo/Reanalisar.'; notify('IA não devolveu linhas de produtos.','warn'); return; } const declared=normalizeGroqNumber(inv.total); const declaredBase=normalizeGroqNumber(inv.total_liquido||inv.base_incidencia||inv.subtotal||inv.total_sem_iva); const sum=invoiceLines.reduce((a,b)=>a+Number(b.total_linha||0),0); const target=declaredBase||declared; const targetLabel=declaredBase?'total líquido/base sem IVA':'total da fatura'; if(target && Math.abs(sum-target)>Math.max(1,target*.12)){ $('ocr_status').textContent='IA concluiu, mas a soma das linhas ('+eur(sum)+') está diferente do '+targetLabel+' ('+eur(target)+'). Confirma se faltam produtos ou descontos/promoções.'; notify('IA concluiu com diferenças a confirmar.','warn'); }else{ $('ocr_status').textContent='IA concluiu. Confirma produtos, quantidades, packs e preços sugeridos antes de guardar.'; notify('Fatura extraída com IA. Confirma antes de guardar.','ok'); } } async function runInvoiceAI(){ const file=getInvoiceSelectedFile(); if(!file){notify('Escolhe ou tira uma foto da fatura primeiro.','warn');chooseInvoiceFile();return} if(file.type==='application/pdf'){notify('Nesta versão a IA lê fotografias/imagens. Para PDF, tira foto da página ou exporta como imagem.','warn');return} const ai=getAISettings(); const provider=String(ai.provider||'groq').toLowerCase(); const model=provider==='gemini'?(ai.modelo_gemini||'gemini-2.5-flash'):provider==='openai'?(ai.modelo_openai||'gpt-4o-mini'):(ai.modelo_groq||'meta-llama/llama-4-scout-17b-16e-instruct'); const apiKey=(ai.usar_chave_local!==false)?getLocalAIKey(provider):''; const groqTextOnly = provider==='groq' && /gpt[-_]?oss/i.test(model); try{ let res; if(groqTextOnly){ $('ocr_status').textContent='GPT-OSS 120B não lê imagens diretamente. A fazer OCR local primeiro...'; let ocrText=($('fat_ocr')&&$('fat_ocr').value||'').trim(); if(ocrText.length<60){ await runInvoiceOCREngine({maximo:true}); ocrText=($('fat_ocr')&&$('fat_ocr').value||'').trim(); } if(ocrText.length<30) throw new Error('O OCR local não conseguiu obter texto suficiente para enviar ao GPT-OSS. Tenta uma foto mais nítida ou usa Llama 4 Scout/Gemini.'); $('ocr_status').textContent='A interpretar o texto OCR com Groq GPT-OSS 120B...'; try{ res=await postInvoiceAI({ocrText,provider,model,apiKey}); }catch(netErr){ $('ocr_status').textContent='Ligação à IA caiu. A tentar novamente com GPT-OSS...'; res=await postInvoiceAI({ocrText,provider,model,apiKey}); } }else{ $('ocr_status').textContent='A preparar a imagem para IA ('+provider+')...'; const qwenCompact = provider==='groq' && /qwen/i.test(model); const image=await invoiceFileToGroqDataUrl(file,{compact:qwenCompact}); const kb=Math.round(image.length*0.75/1024); $('ocr_status').textContent='Imagem otimizada para IA (~'+kb+' KB, rotação '+(invoiceImageRotation||0)+'°'+(qwenCompact?' · modo Qwen compacto':'')+'). A interpretar com '+provider+'...'; try{ res=await postInvoiceAI({image,provider,model,apiKey}); }catch(netErr){ // Se a ligação cair uma vez, tentamos novamente. Ajuda em falhas temporárias Cloudflare/Groq. $('ocr_status').textContent='Ligação à IA caiu. A tentar novamente com a mesma imagem otimizada...'; res=await postInvoiceAI({image,provider,model,apiKey}); } } const data=await res.json().catch(()=>({})); if(!res.ok){ let detail=''; try{ const d=data.details||{}; const m=d?.error?.message||d?.message||d?.raw||''; const c=d?.error?.code||d?.code||''; detail=(m||c)?(' Detalhe: '+[c,m].filter(Boolean).join(' - ')):''; }catch(_){ } throw new Error((data.error||data.message||'Erro na função IA.')+detail); } applyGroqInvoiceResult(data.invoice||data); if(data.provider||data.model){$('ocr_status').textContent+=' Motor: '+(data.provider||provider)+' · '+(data.model||model);} }catch(e){ let msg=e && e.name==='AbortError' ? 'A IA demorou demasiado tempo. Tenta uma foto mais aproximada/recortada ou usa OCR Máximo.' : (e.message||e); if(String(msg).toLowerCase().includes('network')) msg='A ligação à IA caiu. Tenta novamente; se repetir, usa uma foto mais pequena/recortada da fatura.'; $('ocr_status').textContent='Erro Na IA: '+msg; notify('Erro na IA: '+msg,'bad'); } } async function runInvoiceGroqAI(){return runInvoiceAI()} async function runInvoiceOCREngine(opts={}){ const maximo=!!opts.maximo; let file=getInvoiceSelectedFile(); if(!file){notify('Escolhe ou tira uma foto da fatura primeiro.','warn');chooseInvoiceFile();return} if(file.type==='application/pdf'){notify('Para já, o OCR direto funciona melhor com fotografia/imagem. Em PDF, copia o texto para a caixa e usa Analisar Texto.','warn');return} if(!window.Tesseract){notify('OCR indisponível. Verifica a Internet e volta a tentar.','bad');return} $('ocr_status').textContent=maximo?'OCR Máximo: a preparar várias versões da imagem...':'OCR Rápido: a preparar imagem...'; try{ await tryReadInvoiceQR(false); const jobs=await buildOcrImageVariants(file,maximo); const results=[]; for(let i=0;i{if(m.status){$('ocr_status').textContent=`${maximo?'OCR Máximo':'OCR Rápido'} · ${job.name}: ${m.status} ${Math.round((m.progress||0)*100)}%`}}, preserve_interword_spaces:'1', tessedit_pageseg_mode:job.psm, tessedit_char_whitelist:'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÁÀÂÃÉÈÊÍÌÎÓÒÔÕÚÙÛÇáàâãéèêíìîóòôõúùûç0123456789.,:/\\-%€()[]ªººxX* _-' }); const raw=normalizarTextoFatura(res.data.text||''); results.push({name:job.name,text:raw,confidence:res.data.confidence||0,score:scoreOcrText(raw)}); } results.forEach(r=>{r.score=(r.score||0)+scoreInvoiceCandidate(r.text);}); const bestInfo=results.sort((a,b)=>(b.score+(b.confidence||0)*.08)-(a.score+(a.confidence||0)*.08))[0]; const finalText=bestInfo?.text||selectBestOcrText(results); $('fat_ocr').value=normalizarTextoFatura(finalText); autoFillInvoiceFields($('fat_ocr').value); parseInvoiceText(); $('ocr_status').textContent=`OCR concluído (${maximo?'modo máximo':'modo rápido'}). Leitura escolhida: ${bestInfo?.name||'—'}. Não juntei várias leituras para evitar produtos duplicados.`; notify('OCR concluído. Confirma as linhas, quantidades e totais antes de guardar.','ok'); }catch(e){ $('ocr_status').textContent='Erro No OCR: '+(e.message||e); notify('Erro no OCR: '+(e.message||e),'bad') } } function readFileAsImage(file){return new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('Não consegui abrir esta imagem. Tenta tirar nova foto em JPG/PNG, com boa luz.'));img.src=URL.createObjectURL(file)})} function canvasToBlob(canvas,type='image/png',quality=.92){return new Promise(resolve=>canvas.toBlob(resolve,type,quality))} function clamp(v){return Math.max(0,Math.min(255,v))} function otsuThreshold(gray){ const hist=new Array(256).fill(0); gray.forEach(v=>hist[v|0]++); const total=gray.length; let sum=0; for(let i=0;i<256;i++)sum+=i*hist[i]; let sumB=0,wB=0,wF=0,varMax=0,thr=150; for(let t=0;t<256;t++){wB+=hist[t]; if(!wB)continue; wF=total-wB; if(!wF)break; sumB+=t*hist[t]; const mB=sumB/wB,mF=(sum-sumB)/wF; const v=wB*wF*(mB-mF)*(mB-mF); if(v>varMax){varMax=v;thr=t}} return thr; } async function imageToCanvas(file,maxW=2400){ const img=await readFileAsImage(file); const scale=Math.min(maxW/img.width,2.4); const w=Math.round(img.width*scale), h=Math.round(img.height*scale); const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h; const ctx=canvas.getContext('2d',{willReadFrequently:true}); ctx.fillStyle='white';ctx.fillRect(0,0,w,h);ctx.drawImage(img,0,0,w,h); return canvas; } function autoCropCanvas(src){ const w=src.width,h=src.height,ctx=src.getContext('2d',{willReadFrequently:true}); const id=ctx.getImageData(0,0,w,h),d=id.data; let minX=w,minY=h,maxX=0,maxY=0,count=0; for(let y=0;y115 && sat<95){minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x);maxY=Math.max(maxY,y);count++;}}} const area=(maxX-minX)*(maxY-minY); if(count<200 || area<0.25*w*h)return src; const pad=Math.round(Math.min(w,h)*0.025); minX=Math.max(0,minX-pad);minY=Math.max(0,minY-pad);maxX=Math.min(w,maxX+pad);maxY=Math.min(h,maxY+pad); const cw=maxX-minX,ch=maxY-minY;if(cw<200||ch<200)return src; const out=document.createElement('canvas');out.width=cw;out.height=ch;out.getContext('2d').drawImage(src,minX,minY,cw,ch,0,0,cw,ch);return out; } function processCanvas(src,mode='balanced'){ let base=src; if(mode.includes('crop'))base=autoCropCanvas(src); const w=base.width,h=base.height; const out=document.createElement('canvas');out.width=w;out.height=h; const ctx=out.getContext('2d',{willReadFrequently:true});ctx.drawImage(base,0,0,w,h); const id=ctx.getImageData(0,0,w,h),d=id.data; const gray=[]; gray.length=w*h; for(let p=0,i=0;i(thr-6)?255:0; if(mode.includes('receipt')) v=v>168?255:(v<132?0:v); d[i]=d[i+1]=d[i+2]=v; } ctx.putImageData(id,0,0); return out; } async function buildOcrImageVariants(file,maximo=false){ const original=await imageToCanvas(file,maximo?2600:2200); const variants=maximo?[ {name:'contraste equilibrado',mode:'crop-balanced',psm:'6'}, {name:'talão térmico',mode:'crop-receipt',psm:'6'}, {name:'fatura A4 tabela',mode:'crop-soft',psm:'4'}, {name:'preto e branco forte',mode:'crop-strong-threshold',psm:'4'}, {name:'texto disperso',mode:'crop-soft',psm:'11'} ]:[{name:'rápido',mode:'crop-balanced',psm:'6'}]; const out=[]; for(const v of variants){ const canvas=processCanvas(original,v.mode); const blob=await canvasToBlob(canvas,'image/png'); out.push({...v,blob}); } return out; } async function preprocessInvoiceImage(file){ const jobs=await buildOcrImageVariants(file,false); return jobs[0].blob; } function scoreOcrText(text){ const t=String(text||''); const lines=t.split(/\n+/).filter(Boolean); let score=0; score+=Math.min(lines.length,80)*1.8; score+=(t.match(/\bNIF\b/gi)||[]).length*25; score+=(t.match(/\bTOTAL\b/gi)||[]).length*18; score+=(t.match(/\bIVA\b/gi)||[]).length*12; score+=(t.match(/\d+[,.]\d{2}/g)||[]).length*5; score+=(t.match(/\b\d{9}\b/g)||[]).length*18; score+=(t.match(/\b\d{1,3}\s*x\s*\d+[,.]\d{2}\b/gi)||[]).length*12; score+=(t.match(/\b(QTD|P\.U\.|VALOR)\b/gi)||[]).length*16; score-=(t.match(/[�#{}~^]/g)||[]).length*6; return score; } function selectBestOcrText(results){ const sorted=[...results].sort((a,b)=>(b.score+(b.confidence||0)*.15)-(a.score+(a.confidence||0)*.15)); return sorted[0]?.text||''; } function scoreInvoiceCandidate(text){ try{ const t=normalizarTextoFatura(text||''); const table=parsePortugueseTableLines(t)||[]; const a4=parseA4InvoiceTableLines(t)||[]; const retail=parseRetailReceiptLines(t)||[]; const superm=parseSupermarketReceiptLines(t)||[]; const simple=t.split(/\n+/).map(parseLine).filter(Boolean)||[]; const candidates=[table,a4,retail,superm,simple].sort((x,y)=>y.length-x.length)[0]||[]; const keys=new Set(); let dup=0, score=0; for(const l of candidates){ const key=(l.produto_nome||'').toLowerCase().replace(/[^a-z0-9áàâãéèêíìîóòôõúùûç]+/gi,''); if(!key||key.length<3)continue; if(keys.has(key))dup++; else keys.add(key); if(Number(l.total_linha)>0)score+=28; if(Number(l.quantidade_comprada)>0)score+=12; } score+=keys.size*55; score-=dup*75; if(/TOTAL\s+A\s+PAGAR|TOTAL\s*\(EUR\)|TOTAL\s+PAGO|TOTAL/i.test(t))score+=40; if(/QTD\s+P\.?\s*U\.?\s+IVA\s+VALOR|Refer[eê]ncia\s+Designa[cç][aã]o/i.test(t))score+=55; if(/NIF\s*:?\s*\d{9}|Contribuinte\s*(?:n[ºo]|Nr)?\.?\s*:?\s*\d{9}/i.test(t))score+=35; if(/PIREZAS|LIDL|PINGO\s*DOCE|VINISUMOS|LEIT[ÃA]O|MAMEDE|BARROCAL|BRICOLAGE|BAIXOS/i.test(t))score+=30; const repeats=(t.match(/Fatura\s+Simplificada|TOTAL\s+A\s+PAGAR|QTD\s+P\.?U\.?\s+IVA\s+VALOR/gi)||[]).length; if(repeats>2)score-=repeats*80; return score; }catch(e){return 0} } function mergeOcrTexts(texts){ const seen=new Set(), lines=[]; for(const text of texts){ for(const raw of String(text||'').split(/\n+/)){ const line=normalizarTextoFatura(raw).trim(); if(!line)continue; const key=line.toLowerCase().replace(/[^a-z0-9áàâãéèêíìîóòôõúùûç]+/gi,'').slice(0,80); if(key.length<3||seen.has(key))continue; seen.add(key); lines.push(line); } } return lines.join('\n'); } async function tryReadInvoiceQR(show=true){ const file=getInvoiceSelectedFile(); if(!file){if(show){notify('Escolhe uma foto primeiro.','warn');chooseInvoiceFile()}return false} if(!window.jsQR){if(show)notify('Leitor QR indisponível. Verifica a Internet.','warn');return false} try{ const img=await readFileAsImage(file); const max=1800, scale=Math.min(max/img.width,max/img.height,1); const w=Math.round(img.width*scale), h=Math.round(img.height*scale); const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h; const ctx=canvas.getContext('2d',{willReadFrequently:true}); ctx.drawImage(img,0,0,w,h); const data=ctx.getImageData(0,0,w,h); const code=jsQR(data.data,w,h,{inversionAttempts:'attemptBoth'}); if(!code){if(show)notify('Não consegui ler o QR. Tenta tirar a foto mais direita, com boa luz e o QR nítido.','warn');return false} const qr=code.data||''; const parsed=parsePortugueseInvoiceQR(qr); if(parsed.nif)$('fat_nif').value=parsed.nif; if(parsed.documento)$('fat_documento').value=parsed.documento; if(parsed.data)$('fat_data').value=parsed.data; if(parsed.total)$('fat_total').value=Number(parsed.total).toFixed(2); if(parsed.iva)$('fat_iva').value=Number(parsed.iva).toFixed(2); const current=$('fat_ocr').value||''; $('fat_ocr').value=(current?current+'\n\n':'')+'[QR DA FATURA]\n'+qr; if(show)notify('QR lido. Preenchi os dados principais da fatura.','ok'); return true; }catch(e){if(show)notify('Erro ao ler QR: '+(e.message||e),'bad');return false} } function parsePortugueseInvoiceQR(qr){ const out={}; String(qr||'').split('*').forEach(part=>{ const i=part.indexOf(':'); if(i<0)return; const k=part.slice(0,i).trim(), v=part.slice(i+1).trim(); if(k==='A')out.nif=v; if(k==='F'&&/^\d{8}$/.test(v))out.data=v.slice(0,4)+'-'+v.slice(4,6)+'-'+v.slice(6,8); if(k==='G')out.documento=v; if(k==='H'&&!out.documento)out.documento=v; if(k==='O')out.total=parseMoney(v); if(k==='N')out.iva=parseMoney(v); }); return out; } function normalizarTextoFatura(text){ let t=String(text||''); t=t.replace(/\r/g,'\n'); t=t.replace(/[|]+/g,' '); t=t.replace(/€/g,' € '); t=t.replace(/[”“]/g,'"').replace(/[’‘]/g,"'"); t=t.replace(/\b0CR\b/gi,'OCR'); t=t.replace(/(\d)[°º](\d{2})/g,'$1,$2'); t=t.replace(/(\d)[\.](\d{2})(\s|$)/g,'$1,$2$3'); t=t.replace(/(\d),(\s+)(\d{2})/g,'$1,$3'); t=t.replace(/\bNTF\b|\bNIE\b|\bNTE\b|\bN1F\b|\bNlF\b/gi,'NIF'); t=t.replace(/\bIW\b|\bI¥A\b|\bIWA\b|\bIXA\b|\bI\.V\.A\.?\b/gi,'IVA'); t=t.replace(/Simpiies|SimpIies|Simpiles|Sinmplifiacac|Sinmplific|Sinmplificar|Sup\s+pe\s+FCADA/gi,'Simplificada'); t=t.replace(/BUCHU/gi,'BUCHO'); t=t.replace(/MORCEL[\/|4]/gi,'MORCELA'); t=t.replace(/CHOURICA/gi,'CHOURIÇA'); t=t.replace(/QUETJO/gi,'QUEIJO'); t=t.replace(/PASTETS/gi,'PASTEIS'); t=t.replace(/AZ\s*TTONAS/gi,'AZEITONAS'); t=t.replace(/CARGCO/gi,'CAROÇO'); t=t.replace(/RECHETO/gi,'RECHEIO'); t=t.replace(/COMPAL\.\s*CLASSICO/gi,'COMPAL CLASSICO'); t=t.replace(/BOL\s+REC\s+BAUN/gi,'BOLO REC BAUN'); t=t.replace(/SALSICH\.P\.DOCE/gi,'SALSICHAS P DOCE'); t=t.replace(/BATAT\s+FRIT/gi,'BATATA FRITA'); t=t.replace(/VODKA\s+RODANOV/gi,'VODKA RODANOV'); t=t.split('\n').map(l=>l.replace(/\s{2,}/g,' ').trim()).filter(Boolean).join('\n'); return t; } function supplierNameFromNif(nif){ const map={ '500219007':'Pirezas - Soc. Agro. Pecuária Do Prado', '503340855':'LIDL & Cia - Guarda', '500829993':'Pingo Doce - Distribuição Alimentar, S.A.', '502671289':'Vinisumos - Comércio E Distribuição Das Beiras, Lda', '503189561':'Leitão & Mamede, Lda.', '513836055':'Barrocal De Ambom, Lda', '709522304':'Bricolage / Retail Park Guarda' }; return map[String(nif||'').replace(/\D/g,'')]||''; } function cleanSupplierName(v){ let s=String(v||'').replace(/[<>|]+/g,' ').replace(/\s+/g,' ').trim(); s=s.replace(/\bcn\s+eas\b/ig,'').replace(/\bcn\b/ig,'').replace(/\beas\b/ig,'').replace(/\s+/g,' ').trim(); return titleCase(s); } function supplierCandidateFromLines(lines,nifIdx){ const start=Math.max(0,(nifIdx>-1?nifIdx:lines.length)-6); const end=nifIdx>-1?nifIdx:Math.min(lines.length,8); const chunk=lines.slice(start,end).filter(l=>l&&!/^\d|nif|telefone|telef|fatura|evento|cliente|consumidor|morada|pa[ií]s/i.test(l)); const legalIdx=chunk.findIndex(l=>/(soc\.?|agro|pecu[aá]ria|lda|unipessoal|s\.a\.|sa\b)/i.test(l)); if(legalIdx>=0){ const prev=chunk[legalIdx-1]||''; const legal=chunk[legalIdx]; if(prev && /[A-ZÁÉÍÓÚÃÕÇ]{3,}/i.test(prev) && !/prado/i.test(prev+legal)) return cleanSupplierName(prev+' - '+legal); return cleanSupplierName(legal); } const cand=chunk.find(l=>/[A-ZÁÉÍÓÚÃÕÇ]{3,}/i.test(l)); return cleanSupplierName(cand||''); } function autoFillInvoiceFields(text){ const t=normalizarTextoFatura(String(text||'')); const lines=t.split(/\n+/).map(x=>x.trim()).filter(Boolean); // V3.6.13: identifica o modelo/fornecedor dentro desta função. // Na versão anterior estas variáveis eram usadas aqui, mas só existiam noutras funções, // causando o erro "isPirezas is not defined". const isPirezas=/\bPIREZ|PRADO\s+SOC|PECU[AÁ]RIA\s+DO\s+PRADO|BUCH[OU]\s+DA\s+GUARDA|MORCEL[A4]\s+DA\s+GUARDA/i.test(t); const isLidl=/\bLIDL\b|503\s*340\s*855|VALE\s+MESMO\s+A\s+PENA|MAIS\s+PARA\s+SI/i.test(t); const isPingo=/PINGO\s+DOCE|500\s*829\s*993|BAIXOS\s+FAZEM\s+DA\s+MESA/i.test(t); const isVinisumos=/VINISUMOS|502\s*671\s*289|CASH\s*&\s*CARRY|VODKA\s+MISS|QSANGRIA/i.test(t); const isLeitao=/LEIT[ÃA]O\s*&?\s*MAMEDE|LMALIMENTAR|503\s*189\s*561|DIHOR|MIMOSA/i.test(t); const isBarrocal=/BARROCAL\s+DE\s+AMBOM|513\s*836\s*055/i.test(t); const isBricolage=/BCH\s+BRICOLAGE|BRICOLAGE|LUXENS|709\s*522\s*304/i.test(t); const nifMatches=[...t.matchAll(/\b(?:NIF|NIPC|Contribuinte|Contrib\.?|NUM\.\s*CONTRIB\.?)\s*(?:n[ºo]\.?|Nr\.?)?\s*[:\.]?\s*([0-9]{9})\b/gi)].map(m=>m[1]); let nifVal=nifMatches.find(n=>supplierNameFromNif(n)) || nifMatches[0] || ''; if(!nifVal){ const anyNif=[...t.matchAll(/\b([0-9]{9})\b/g)].map(m=>m[1]); nifVal=anyNif.find(n=>supplierNameFromNif(n)) || anyNif[0] || ''; } if(isPirezas) nifVal='500219007'; else if(isLidl) nifVal='503340855'; else if(isPingo) nifVal='500829993'; else if(isVinisumos) nifVal='502671289'; else if(isLeitao) nifVal='503189561'; else if(isBarrocal) nifVal='513836055'; else if(isBricolage) nifVal='709522304'; if(nifVal && !$('fat_nif').value)$('fat_nif').value=nifVal; const dateIso=t.match(/(?:Data(?:\s+de\s+Venda)?\s*[:\-]?\s*)?(20\d{2})[\-\/\s]+(\d{2})[\-\/\s]+(\d{2})/i); const datePt=t.match(/(?:Data(?:\s+de\s+Venda)?\s*[:\-]?\s*)?(\d{2})[\.\/\-](\d{2})[\.\/\-](\d{2,4})/i); if(dateIso&&!$('fat_data').value)$('fat_data').value=`${dateIso[1]}-${dateIso[2]}-${dateIso[3]}`; else if(datePt&&!$('fat_data').value){let y=datePt[3].length===2?'20'+datePt[3]:datePt[3];$('fat_data').value=`${y}-${datePt[2]}-${datePt[1]}`;} const docPatterns=[ /\bFS\s*[A-Z0-9]*\s*\d{4,}\/\d{3,}(?:\/\d+)?\b/i, /\bFR\s*\d{4,}\/\d+\b/i, /Fatura\s+Recibo\s*N[ºo]?\s*([0-9]{3,})/i, /(?:N\.?\s*[ºo0]|No|Nº)\s*[:=\-]?\s*([A-Z]{0,3}\s*[0-9]+\s*[-\/]\s*[0-9]+(?:\/[0-9]+)?)/i, /FACTURA\s+Num\.?\s*([0-9A-Z\/-]{3,})/i ]; let doc=''; for(const re of docPatterns){const m=t.match(re); if(m){doc=(m[1]||m[0]);break;}} if(doc&&!$('fat_documento').value)$('fat_documento').value=String(doc).replace(/\s+/g,' ').replace(/^No\s*:?\s*/i,'').trim(); const totalPatterns=[ /TOTAL\s+A\s+PAGAR[^0-9]*(\d+[,.]\d{2})/i, /TOTAL\s*\(EUR\)[^0-9]*(\d+[,.]\d{2})/i, /TOTAL\s+PAGO[^0-9]*(\d+[,.]\d{2})/i, /^\s*TOTAL\s+(\d+[,.]\d{2})\s*$/im, /\bTotal\s+do\s+Documento\b[\s\S]{0,160}?\bTOTAL\b[^0-9]*(\d+[,.]\d{2})/i, /\bTotal\s+(\d+[,.]\d{2})\s*$/im ]; for(const re of totalPatterns){const m=t.match(re); if(m&&Number.isFinite(parseMoney(m[1]))){$('fat_total').value=Number(parseMoney(m[1])).toFixed(2); break;}} const ivaTotal=t.match(/Total\s+(?:de\s+)?IVA[^0-9]*(\d+[,.]\d{2,4})/i); if(ivaTotal&&!$('fat_iva').value)$('fat_iva').value=Number(parseMoney(ivaTotal[1])).toFixed(2); if(!$('fat_iva').value){ const ivaVals=[...t.matchAll(/^\s*[ABC]\s+\d{1,2}%\s+\d+[,.]\d{2}\s+\d+[,.]\d{2}\s+(\d+[,.]\d{2})/gmi)].map(m=>parseMoney(m[1])).filter(Number.isFinite); const ivaVals2=[...t.matchAll(/^\s*(?:6|13|23)[,.]00%?\s+\d+[,.]\d{2,4}\s+(\d+[,.]\d{2,4})/gmi)].map(m=>parseMoney(m[1])).filter(Number.isFinite); const vals=ivaVals.length?ivaVals:ivaVals2; if(vals.length)$('fat_iva').value=vals.reduce((a,b)=>a+b,0).toFixed(2); } const mapped=supplierNameFromNif(nifVal); const current=($('fat_fornecedor').value||'').trim(); let keywordSupplier=''; if(/\bLIDL\b/i.test(t)) keywordSupplier='LIDL & Cia - Guarda'; else if(/PINGO\s*DOCE|VALE\s+MESMO\s+A\s+PENA|BAIXOS\s+FAZEM/i.test(t)) keywordSupplier='Pingo Doce'; else if(/VINISUMOS/i.test(t)) keywordSupplier='Vinisumos - Cash & Carry'; else if(/LEIT[ÃA]O\s*&\s*MAMEDE|LMalimentar/i.test(t)) keywordSupplier='Leitão & Mamede, Lda.'; else if(/BARROCAL\s+DE\s+AMBOM/i.test(t)) keywordSupplier='Barrocal De Ambom, Lda'; else if(/BCH\s+BRICOLAGE|BRICOLAGE\s*S\.A|GUARDA\s+RETAIL\s+PARK/i.test(t)) keywordSupplier='Bricolage / Retail Park Guarda'; else if(/PIREZAS|PECUARIA\s+DO\s+PRADO|PECUÁRIA\s+DO\s+PRADO/i.test(t)) keywordSupplier='Pirezas - Soc. Agro. Pecuária Do Prado'; if(mapped && (!current || /pirez|cn\s*eas|^prado$|mexilh|produto|lidl|total/i.test(current))) $('fat_fornecedor').value=mapped; else if(keywordSupplier && (!current || /produto|total|mexilh|pirezas\s+cn/i.test(current))) $('fat_fornecedor').value=keywordSupplier; else if(!current){ const idx=lines.findIndex(l=>/\bNIF\b|Contribuinte/i.test(l)); const cand=supplierCandidateFromLines(lines,idx); if(cand)$('fat_fornecedor').value=cand; } } function parseMoney(s){if(!s)return NaN;s=String(s).replace(/€/g,'').replace(/\s/g,'').replace(/:/g,'').replace(',', '.');const v=parseFloat(s);return Number.isFinite(v)?v:NaN} function titleCase(s){return String(s||'').toLowerCase().replace(/\b([a-záàâãéèêíóôõúç])/g,m=>m.toUpperCase()).replace(/\b(Da|De|Do|Das|Dos|E)\b/g,m=>m.toLowerCase())} function titleCaseWords(s){return titleCase(s)} function suggestCategory(desc){ try { return detectCategory(desc); } catch(e) { return 'Outros'; } } function detectCategory(desc){ const d=String(desc||'').toLowerCase(); if(/água|agua|nascente/.test(d))return 'Águas'; if(/batata|snack|frita|tosta|tostas|amendoim|pistach|aperitivo/.test(d))return 'Batatas Fritas'; if(/cerve|sagres|super\s*bock|mini\s+beer|beer/.test(d))return 'Cervejas'; if(/cola|sumol|ice\s*tea|refrigerante|compal|joi|laranja|fanta|seven|7up|sprite/.test(d))return 'Refrigerantes'; if(/café|cafe/.test(d))return 'Cafés'; if(/vinho|sangria|martini|vodka|espumante|licor|whisky|misss|rodanov/.test(d))return 'Vinhos'; if(/bucho|buchu|morcela|morcel|farinheira|chouri|chourica|chouriça|alheira|enchido|queijo|presunto|fiambre|camar[aã]o|mexilh[aã]o|bacalhau|riss[oó]is|fuet|azeitona|tamaras|tâmaras|lim[aã]o|alperce|maionese|ketchup|oleo|óleo|salsich|atum|bolo|baguete|p[ãa]o|brie/.test(d))return 'Comida'; if(/guardanapo|saco\s+de\s+papel|papel|copo|prato|limpeza|detergente|control|fino/.test(d))return 'Material Bar'; return 'Outros'; } function cleanProductName(desc){ let d=String(desc||''); d=d.replace(/^\s*[A-FJ]\s*\d{1,2}%\s+/i,''); d=d.replace(/^\s*\d{4,8}\s+/,''); d=d.replace(/\bL\s+\d{4,}\b/gi,''); d=d.replace(/\b(?:ANC|LR|LP|SOV|GAB|DIH|PRM|LR\d|LP\d)[A-Z0-9]*\b/gi,''); d=d.replace(/\b\d{2}[-\/]\d{2}[-\/]\d{4}\b/g,''); d=d.replace(/\b\d{4}[-\/]\d{2}[-\/]\d{2}\b/g,''); d=d.replace(/\b(?:promo[cç][aã]o|poupan[cç]a|imediata|lidl\s*plus)\b/gi,''); d=d.replace(/\s+[-(]?\d+[,.]\d{2,4}\)?\s*[ABC]?\s*$/i,''); d=d.replace(/[|_]+/g,' '); d=d.replace(/[^a-zA-Z0-9ÁÀÂÃÉÈÊÍÌÎÓÒÔÕÚÙÛÇáàâãéèêíìîóòôõúùûç%.,\/\- ]/g,' '); d=d.replace(/\s+/g,' ').trim(); return titleCase(d)||'Produto'; } function inferPackUnitsFromDesc(desc){ const d=String(desc||''); let m=d.match(/\b(\d{1,3})\s*[xX]\s*\d{1,4}\s*(?:ml|cl|l)\b/i); if(m)return parseInt(m[1],10); m=d.match(/\((\d{1,3})\s*(?:un|uni|unid|u)\)/i); if(m)return parseInt(m[1],10); m=d.match(/\b(\d{1,3})\s*(?:un|uni|unid|garrafas|latas)\b/i); if(m)return parseInt(m[1],10); return 1; } function makeInvoiceLine(desc,qtd,total,iva=23,nota=''){ qtd=Number(qtd||1); total=Number(total||0); let unitsPack=inferPackUnitsFromDesc(desc); const cat=detectCategory(desc); const product=cleanProductName(desc); const stockQty=Math.max(0,qtd*unitsPack); const unitCost=stockQty>0?total/stockQty:0; const sale=suggestSale(unitCost,cat); const margin=sale>0?((sale-unitCost)/sale*100):0; return {descricao_original:String(desc||''),produto_nome:product,categoria:cat,quantidade_comprada:qtd,unidades_por_embalagem:unitsPack,quantidade_stock:stockQty,unidade:'un',total_linha:total,preco_unitario_stock:unitCost,iva_taxa:Number(iva)||23,preco_venda_sugerido:sale,margem_sugerida:margin,nota_ocr:nota||''}; } function roundSale(v){const inc=Number(getPriceSettings().arredondamento||0.10);return Math.max(0,inc>0?Math.ceil(Number(v||0)/inc)*inc:Number(v||0))} function suggestSale(cost,cat){const c=Number(cost||0),p=getPriceSettings();let mult=Number(p.markup_padrao||3);if(cat==='Águas')mult=Number(p.markup_aguas||3);if(cat==='Batatas Fritas'||cat==='Snacks')mult=Number(p.markup_snacks||2.5);if(cat==='Cervejas'||cat==='Refrigerantes'||cat==='Vinhos')mult=Number(p.markup_bebidas||2.3);if(c<=0.45&&cat==='Águas')return Math.max(1.00,roundSale(c*mult));return roundSale(c*mult)} function parseLine(line){ let original=String(line||'').trim(); if(!original||!/(\d+[,.]\d{1,3}|€)/.test(original))return null; let m=original.match(/(.+?)\s+(\d+[,.]\d{1,2})\s*€?$/); if(!m)return null; let before=m[1].trim(), total=parseMoney(m[2]); if(!Number.isFinite(total)||total<=0)return null; // ignora linhas de totais, IVA, ATCUD, QR, resumo e linhas técnicas de lote/código que pertencem ao produto da linha anterior if(/^L\s+\d{4,}/i.test(original)||/^L\s+/i.test(original))return null; if(/total|iva|incid[eê]ncia|atcud|cliente|consumidor|taxa|pagar/i.test(before))return null; let q=1; let qm=before.match(/^(\d+(?:[,.]\d+)?)\s*(x|un|unid|pack|packs|pacote|pacotes|cx|caixa|caixas)?\s+(.+)$/i); if(qm){q=parseMoney(qm[1]);before=qm[3].trim()} let unitsPack=1; let up=before.match(/(\d+)\s*(garrafas|garrafa|unidades|unid|un|latas|lata)\b/i);if(up)unitsPack=parseInt(up[1],10); let packx=before.match(/\b(\d+)\s*x\s*(\d+)\s*(cl|ml|l)\b/i);if(packx)unitsPack=parseInt(packx[1],10); let vol=before.match(/(\d+)\s*(cl|ml|l)\b/i); let cat=detectCategory(before); let product=cleanProductName(before); if(vol&&cat==='Águas')product='Água '+vol[1]+vol[2].toLowerCase(); let stockQty=q*unitsPack; let unitCost=total/stockQty; let sale=suggestSale(unitCost,cat); let margin=sale>0?((sale-unitCost)/sale*100):0; return {descricao_original:original,produto_nome:product,categoria:cat,quantidade_comprada:q,unidades_por_embalagem:unitsPack,quantidade_stock:stockQty,unidade:'un',total_linha:total,preco_unitario_stock:unitCost,iva_taxa:23,preco_venda_sugerido:sale,margem_sugerida:margin,nota_ocr:''} } function isNoiseInvoiceLine(line){ const l=String(line||'').trim(); return !l || /^(=+|-+|_+)$/.test(l) || /^(n\.?\s*[ºo0]|no)\s*[:\-]/i.test(l) || /^(iud|uid|id|t[ou]g)\s*[:\-]/i.test(l) || /total|cliente|consumidor|fatura|simplificada|morada|nif|telefone|data|original|portugal|taxa|iva|atcud|prado|qtd|p\.u\.|valor|cod\.?pos|pa[ií]s|atrib|documento/i.test(l); } function isInvoiceDetailLine(line){ const l=String(line||'').trim(); // Linha técnica de produto: lote/código + QTD + P.U. + IVA + VALOR. return /^L\s+\d{3,}\s+\d+[,.]\d+\s+\d+[,.]\d+\s+\d{1,2}\s+\d+[,.]\d{2}/i.test(l) || /^\d+[,.]\d+\s+\d+[,.]\d+\s+\d{1,2}\s+\d+[,.]\d{2}/.test(l); } function isLikelyProductName(line){ const l=String(line||'').trim(); if(!/[A-ZÁÉÍÓÚÃÕÇ]{3,}/i.test(l))return false; if(isNoiseInvoiceLine(l))return false; if(/^L\s+\d{4,}/i.test(l))return false; if(/^\d/.test(l))return false; if(/[,.]\d{2}/.test(l)&&/\d/.test(l))return false; return true; } function buildInvoiceLineFromTable(name, detail){ name=String(name||'').trim(); detail=String(detail||'').trim(); if(!name||!detail)return null; // Formatos comuns: "L 270123 21,260 7,005 23 148,82" ou "21,260 7,005 23 148,82". // Ignora lote/código e aproveita os últimos 4 números: QTD, P.U., IVA, VALOR. if(!isInvoiceDetailLine(detail))return null; let nums=[...detail.matchAll(/\d+[,.]\d+|\b\d{1,3}\b/g)].map(m=>m[0]); if(nums.length<4)return null; let vals=nums.slice(-4); let qtd=parseMoney(vals[0]), pu=parseMoney(vals[1]), iva=parseMoney(vals[2]), total=parseMoney(vals[3]); if(!Number.isFinite(qtd)||!Number.isFinite(pu)||!Number.isFinite(total)||qtd<=0||pu<=0)return null; const expected=qtd*pu; let note=''; if(expected>0 && (!Number.isFinite(total)||Math.abs(total-expected)/expected>.08)){ note='Total corrigido pelo cálculo Qtd × P.U.; OCR leu '+(Number.isFinite(total)?eur(total):'valor inválido')+'.'; total=expected; } const cat=detectCategory(name); let product=cleanProductName(name); let stockQty=qtd; let unitCost=total/stockQty; let sale=suggestSale(unitCost,cat); let margin=sale>0?((sale-unitCost)/sale*100):0; return {descricao_original:name+' | '+detail,produto_nome:product,categoria:cat,quantidade_comprada:qtd,unidades_por_embalagem:1,quantidade_stock:stockQty,unidade:'un',total_linha:total,preco_unitario_stock:unitCost,iva_taxa:Number.isFinite(iva)?iva:23,preco_venda_sugerido:sale,margem_sugerida:margin,nota_ocr:note}; } function parsePortugueseTableLines(text){ const lines=normalizarTextoFatura(text).split(/\n+/).map(l=>l.trim()).filter(Boolean); const out=[]; const usedNames=new Set(); for(let i=0;i=Math.max(0,i-4);j--){ if(isLikelyProductName(lines[j])){name=lines[j];break;} } if(!name)continue; const parsed=buildInvoiceLineFromTable(name,line); if(parsed){out.push(parsed);usedNames.add(name.toLowerCase());} } // Também suporta o caso produto numa linha e detalhe imediatamente a seguir, caso a linha de detalhe não comece por L. for(let i=0;i{ const k=(l.produto_nome||'').toLowerCase(); if(!map.has(k)){map.set(k,{...l});return} const x=map.get(k);x.quantidade_comprada+=Number(l.quantidade_comprada||0);x.quantidade_stock+=Number(l.quantidade_stock||0);x.total_linha+=Number(l.total_linha||0);x.preco_unitario_stock=x.quantidade_stock>0?x.total_linha/x.quantidade_stock:0;x.preco_venda_sugerido=Math.max(Number(x.preco_venda_sugerido||0),suggestSale(x.preco_unitario_stock,x.categoria));x.margem_sugerida=x.preco_venda_sugerido>0?((x.preco_venda_sugerido-x.preco_unitario_stock)/x.preco_venda_sugerido*100):0; }); return [...map.values()]; } // V3.6.13 - Corrige erro: dedupeInvoiceLinesExact não definido. // Remove linhas duplicadas sem juntar indevidamente produtos diferentes. function normalizeInvoiceProductKey(name){ return String(name||'') .normalize('NFD').replace(/[̀-ͯ]/g,'') .toLowerCase() .replace(/[^a-z0-9]+/g,' ') .replace(/(l|lt|kg|un|uni|cx|saco|garrafa|garrafas)/g,'') .replace(/\s+/g,' ') .trim(); } function dedupeInvoiceLinesExact(lines){ const out=[]; const seen=new Set(); for(const raw of (lines||[])){ if(!raw) continue; const produto=String(raw.produto_nome||'').trim(); const total=Number(raw.total_linha||0); const qtd=Number(raw.quantidade_stock||raw.quantidade_comprada||0); if(!produto || !Number.isFinite(total) || total<=0) continue; if(/^(produto|total|subtotal|sub total|multibanco|cartao|cartão|troco|euro|resumo|pagamentos)$/i.test(produto)) continue; const key=[normalizeInvoiceProductKey(produto), total.toFixed(2), Number(qtd||0).toFixed(3)].join('|'); if(seen.has(key)) continue; seen.add(key); const l={...raw}; l.produto_nome=titleCase(produto) .replace(/Buchu/ig,'Bucho') .replace(/Morcel4|Morcels/ig,'Morcela') .replace(/Chourica/ig,'Chouriça'); if(!l.categoria || l.categoria==='Outros') l.categoria=detectCategory(l.produto_nome); l.quantidade_comprada=Number(l.quantidade_comprada||1); l.unidades_por_embalagem=Number(l.unidades_por_embalagem||1); l.quantidade_stock=Number(l.quantidade_stock||l.quantidade_comprada*l.unidades_por_embalagem||1); l.total_linha=Number(l.total_linha||0); l.preco_unitario_stock=l.quantidade_stock>0?l.total_linha/l.quantidade_stock:0; l.preco_venda_sugerido=Number(l.preco_venda_sugerido||suggestSale(l.preco_unitario_stock,l.categoria)); l.margem_sugerida=l.preco_venda_sugerido>0?((l.preco_venda_sugerido-l.preco_unitario_stock)/l.preco_venda_sugerido*100):0; out.push(l); } return out; } function isReceiptNoiseLine(line){ const l=String(line||'').trim(); return !l || /^(=+|-+|_+)$/.test(l) || /fatura|original|data\s+de\s+venda|consumidor|morada|rua|nif|crc|sintra|sirp|capital|total\s+de\s+promo|taxa|base\s+imp|val\.iva|val\.total|multibanco|mb\s*way|dinheiro|cart[aã]o|vale mesmo|lidl\s*&|pingo\s*doce|edp|norte|sul|continente|cart[aã]o\s+poupa/i.test(l) || /^No\s*[:=]/i.test(l) || /^N[ºo]\s*[:=]/i.test(l); } function receiptLineMatch(line){ const l=String(line||'').trim(); if(isReceiptNoiseLine(l))return null; if(/^\d{1,3}\s*x\s*\d+[,.]\d{2}/i.test(l))return null; if(/promo[cç][aã]o/i.test(l))return null; let m=l.match(/^(.+?)\s+(-?\d+[,.]\d{2})\s*([ABC])?\s*$/i); if(!m) m=l.match(/^(.+?)\s+(-?\d+[,.]\d{2})\s*([ABC])\s+.*$/i); if(!m)return null; const desc=m[1].trim(); const val=parseMoney(m[2]); if(!desc || !Number.isFinite(val) || val<=0)return null; if(desc.length<3 || /^\d+$/.test(desc))return null; return {desc,total:val,ivaCode:m[3]||''}; } function applyReceiptQuantity(line, prev){ const m=String(line||'').trim().match(/^(\d{1,3})\s*x\s*(\d+[,.]\d{2})/i); if(!m||!prev)return false; const typedQ=parseInt(m[1],10), unit=parseMoney(m[2]); if(!Number.isFinite(unit)||unit<=0)return false; let gross=Number(prev._gross_total||prev.total_linha||0); let q=typedQ; const calcQ=Math.round(gross/unit); if(Math.abs(typedQ*unit-gross)>0.08 && calcQ>0 && calcQ<100 && Math.abs(calcQ*unit-gross)<0.08){ q=calcQ; prev.nota_ocr=(prev.nota_ocr?prev.nota_ocr+' ':'')+`Quantidade corrigida: OCR leu ${typedQ}x, mas ${gross.toFixed(2)}€ / ${unit.toFixed(2)}€ = ${q}x.`; } prev.quantidade_comprada=q; prev.quantidade_stock=q*Number(prev.unidades_por_embalagem||1); prev.preco_unitario_stock=prev.quantidade_stock>0?Number(prev.total_linha||0)/prev.quantidade_stock:0; prev.preco_venda_sugerido=suggestSale(prev.preco_unitario_stock,prev.categoria); prev.margem_sugerida=prev.preco_venda_sugerido>0?((prev.preco_venda_sugerido-prev.preco_unitario_stock)/prev.preco_venda_sugerido*100):0; return true; } function applyReceiptDiscount(line, prev){ const l=String(line||'').trim(); if(!/promo[cç][aã]o|desconto/i.test(l)||!prev)return false; const m=l.match(/(-\d+[,.]\d{2})/); if(!m)return false; const disc=parseMoney(m[1]); if(!Number.isFinite(disc)||disc>=0)return false; prev.total_linha=Math.max(0,Number(prev.total_linha||0)+disc); prev.preco_unitario_stock=prev.quantidade_stock>0?prev.total_linha/prev.quantidade_stock:0; prev.preco_venda_sugerido=suggestSale(prev.preco_unitario_stock,prev.categoria); prev.margem_sugerida=prev.preco_venda_sugerido>0?((prev.preco_venda_sugerido-prev.preco_unitario_stock)/prev.preco_venda_sugerido*100):0; prev.nota_ocr=(prev.nota_ocr?prev.nota_ocr+' ':'')+'Promoção/desconto aplicado: '+eur(Math.abs(disc))+'.'; return true; } function parseSupermarketReceiptLines(text){ const lines=normalizarTextoFatura(text).split(/\n+/).map(l=>l.trim()).filter(Boolean); const out=[]; for(let i=0;i0?((sale-unitCost)/sale*100):0; out.push({descricao_original:line,produto_nome:product,categoria:cat,quantidade_comprada:1,unidades_por_embalagem:1,quantidade_stock:stockQty,unidade:'un',total_linha:m.total,preco_unitario_stock:unitCost,iva_taxa:m.ivaCode==='B'?6:(m.ivaCode==='C'?13:23),preco_venda_sugerido:sale,margem_sugerida:margin,nota_ocr:'',_gross_total:m.total}); } return out.map(({_gross_total,...rest})=>rest); } function parseA4InvoiceTableLines(text){ const lines=normalizarTextoFatura(text).split(/\n+/).map(l=>l.trim()).filter(Boolean); const out=[]; for(const line0 of lines){ let line=line0.replace(/\s+/g,' ').trim(); if(!line || /Refer[eê]ncia|Designa[cç][aã]o|Software|P[áa]gina|Valores\s+do\s+Documento|Resumo\s+do\s+IVA|Local\s+Carga|Total\s+Il[ií]quido/i.test(line))continue; // Vinisumos / PHC: 2202006 VODKA ... 1 4,8700 23,00% 4,8700 let m=line.match(/^([A-Z0-9]{4,10})\s+(.+?)\s+(\d+[,.]\d{0,3}|\d+)\s+(\d+[,.]\d{2,4})\s+(?:\d+[,.]\d{1,2}%?)\s+(\d+[,.]\d{2,4})\s*$/i); if(m){out.push(makeInvoiceLine(m[2],parseMoney(m[3]),parseMoney(m[5]),23,''));continue;} // L&M / faturas com unidade: COD DESC LOTE DATA QTD UNID PUNIT DESC IVA TOTAL m=line.match(/^([A-Z0-9]{3,12})\s+(.+?)\s+(\d+[,.]\d{1,3}|\d+)\s+(Saco|Bil[h|l]a|Cx|Un|Unid|Kg|L|Litro|Caixa)\s+(\d+[,.]\d{2,4})\s+(\d+[,.]\d{1,2})\s+(\d{1,2})\s+(\d+[,.]\d{2,4})\s*$/i); if(m){out.push(makeInvoiceLine(m[2],parseMoney(m[3]),parseMoney(m[8]),parseMoney(m[7]),'Preço com desconto da linha.'));continue;} // Linha simples de fatura A4 com referência, descrição, qtd, punit, iva, total no fim. m=line.match(/^([0-9A-Z]{4,12})\s+(.+?)\s+(\d+[,.]\d{0,3}|\d+)\s+(\d+[,.]\d{2,4})\s+(?:\d+[,.]\d{1,2}\s+)?(?:\d{1,2}[,.]?\d{0,2}%?)\s+(\d+[,.]\d{2,4})\s*$/i); if(m){out.push(makeInvoiceLine(m[2],parseMoney(m[3]),parseMoney(m[5]),23,''));continue;} } return dedupeInvoiceLinesExact(out); } function isRetailSectionLine(line){return /^(MERCEARIA|PEIXARIA|CHARCUTARIA|BEBIDAS|PRODUTOS\s+PESSOAIS|ARTIGOS|Resumo|Pagamentos)$/i.test(String(line||'').trim())} function receiptProductMatchLoose(line){ let l=String(line||'').trim(); if(!l||isReceiptNoiseLine(l)||isRetailSectionLine(l))return null; if(/^\d{1,3}\s*x\s*\d+[,.]\d{2}/i.test(l))return null; if(/promo[cç][aã]o|poupan[cç]a\s+imediata|total\s+poupan/i.test(l))return null; l=l.replace(/^\s*[A-FJ]\s*\d{1,2}%\s+/i,''); let m=l.match(/^(.+?)\s+(-?\(?\d+[,.]\d{2}\)?)\s*([ABC])?\s*$/i); if(!m)return null; const desc=m[1].trim(); let valTxt=m[2].replace(/[()]/g,''); const val=parseMoney(valTxt); if(!desc||!Number.isFinite(val)||val<=0||desc.length<3)return null; if(/^(SUB\s+)?TOTAL|TROCO|EURO|BASE\s+IMP|VAL\.TOTAL|VAL\.IVA/i.test(desc))return null; return {desc,total:val,ivaCode:m[3]||''}; } function applyReceiptQuantityAdvanced(line, prev){ const l=String(line||'').trim(); if(!prev)return false; let m=l.match(/^(\d{1,3})\s*[xX]\s*(\d+[,.]\d{2,4})/i); if(m){ let q=parseInt(m[1],10), unit=parseMoney(m[2]); const gross=Number(prev._gross_total||prev.total_linha||0); const calcQ=Math.round(gross/unit); if(Math.abs(q*unit-gross)>0.08 && calcQ>0 && calcQ<200 && Math.abs(calcQ*unit-gross)<0.08)q=calcQ; prev.quantidade_comprada=q;prev.quantidade_stock=q*Number(prev.unidades_por_embalagem||1);prev.preco_unitario_stock=prev.quantidade_stock>0?Number(prev.total_linha||0)/prev.quantidade_stock:0;prev.preco_venda_sugerido=suggestSale(prev.preco_unitario_stock,prev.categoria);prev.margem_sugerida=prev.preco_venda_sugerido>0?((prev.preco_venda_sugerido-prev.preco_unitario_stock)/prev.preco_venda_sugerido*100):0;return true; } // Talões tipo Barrocal: linha seguinte mostra IVA, PVP/Unit, quantidade, desconto m=l.match(/^\d{1,2}[,.]\d{2}%\s+(\d+[,.]\d{2,4})\s+(\d+[,.]\d{1,3})\s+(\d+[,.]\d{2})/i); if(m){ const unit=parseMoney(m[1]), q=parseMoney(m[2]); if(Number.isFinite(q)&&q>0){prev.quantidade_comprada=q;prev.quantidade_stock=q*Number(prev.unidades_por_embalagem||1);prev.preco_unitario_stock=prev.quantidade_stock>0?Number(prev.total_linha||0)/prev.quantidade_stock:unit;prev.preco_venda_sugerido=suggestSale(prev.preco_unitario_stock,prev.categoria);prev.margem_sugerida=prev.preco_venda_sugerido>0?((prev.preco_venda_sugerido-prev.preco_unitario_stock)/prev.preco_venda_sugerido*100):0;return true;} } return false; } function applyReceiptDiscountAdvanced(line, prev){ const l=String(line||'').trim(); if(!prev)return false; if(!/promo[cç][aã]o|poupan[cç]a|desconto/i.test(l))return false; let m=l.match(/[-(]\s*(\d+[,.]\d{2})\)?\s*$/); if(!m)return false; const disc=parseMoney(m[1]); if(!Number.isFinite(disc)||disc<=0)return false; prev.total_linha=Math.max(0,Number(prev.total_linha||0)-disc); prev.preco_unitario_stock=prev.quantidade_stock>0?prev.total_linha/prev.quantidade_stock:0; prev.preco_venda_sugerido=suggestSale(prev.preco_unitario_stock,prev.categoria); prev.margem_sugerida=prev.preco_venda_sugerido>0?((prev.preco_venda_sugerido-prev.preco_unitario_stock)/prev.preco_venda_sugerido*100):0; prev.nota_ocr=(prev.nota_ocr?prev.nota_ocr+' ':'')+'Desconto aplicado: '+eur(disc)+'.'; return true; } function parseRetailReceiptLines(text){ const lines=normalizarTextoFatura(text).split(/\n+/).map(l=>l.trim()).filter(Boolean); const out=[]; for(const line of lines){ if(applyReceiptQuantityAdvanced(line,out[out.length-1]))continue; if(applyReceiptDiscountAdvanced(line,out[out.length-1]))continue; const m=receiptProductMatchLoose(line); if(!m)continue; let iva=m.ivaCode==='B'?6:(m.ivaCode==='C'?13:23); const obj=makeInvoiceLine(m.desc,1,m.total,iva,''); obj._gross_total=m.total; out.push(obj); } return dedupeInvoiceLinesExact(out.map(({_gross_total,...rest})=>rest)); } function detectInvoiceModel(text){ const t=normalizarTextoFatura(String(text||'')); if(/\bPIREZ|PRADO\s+SOC|PECU[AÁ]RIA\s+DO\s+PRADO|BUCH[OU]\s+DA\s+GUARDA|MORCEL[A4]\s+DA\s+GUARDA/i.test(t)) return 'pirezas'; if(/\bLIDL\b|503\s*340\s*855|VALE\s+MESMO\s+A\s+PENA|MAIS\s+PARA\s+SI/i.test(t)) return 'lidl'; if(/PINGO\s+DOCE|500\s*829\s*993|BAIXOS\s+FAZEM\s+DA\s+MESA/i.test(t)) return 'pingo'; if(/VINISUMOS|502\s*671\s*289|CASH\s*&\s*CARRY|VODKA\s+MISS|QSANGRIA|SUPER\s+BOCK/i.test(t)) return 'vinisumos'; if(/LEIT[ÃA]O\s*&?\s*MAMEDE|LMALIMENTAR|503\s*189\s*561|DIHOR|MIMOSA/i.test(t)) return 'leitao'; if(/BARROCAL\s+DE\s+AMBOM|513\s*836\s*055/i.test(t)) return 'barrocal'; if(/BCH\s+BRICOLAGE|BRICOLAGE|LUXENS|709\s*522\s*304/i.test(t)) return 'bricolage'; return ''; } // V3.6.14 - Correção final de totais e linhas duplicadas por OCR. // Se o OCR falhar no total, tenta obter o valor a partir das linhas extraídas. function autoCorrectHeaderTotalFromLines(){ try{ if(!Array.isArray(invoiceLines)) return; let sumLines=invoiceLines.reduce((a,b)=>a+Number(b.total_linha||0),0); const totalEl=$('fat_total'), ivaEl=$('fat_iva'); const current=parseMoney(totalEl&&totalEl.value); const iva=parseMoney(ivaEl&&ivaEl.value); // Em faturas A4/fornecedor, a soma das linhas costuma ser ilíquida; o total a pagar = linhas + IVA. let possibleGross=(Number.isFinite(iva)&&iva>0)?sumLines+iva:sumLines; if(totalEl && (!Number.isFinite(current)||current<=0) && possibleGross>0) totalEl.value=possibleGross.toFixed(2); // Se o OCR leu 322,53 mas as linhas+IVA dão 322,51, corrige diferenças pequenas. if(totalEl && Number.isFinite(current) && possibleGross>0 && Math.abs(current-possibleGross)<=0.08) totalEl.value=possibleGross.toFixed(2); }catch(e){console.warn('autoCorrectHeaderTotalFromLines',e);} } function invoiceTotalTargetNet(){ const t=parseMoney($('fat_total')&&$('fat_total').value); const iva=parseMoney($('fat_iva')&&$('fat_iva').value); if(Number.isFinite(t)&&Number.isFinite(iva)&&t>iva) return t-iva; return NaN; } function dedupeInvoiceLinesSmart(lines){ lines=dedupeInvoiceLinesExact(lines||[]); const byName=new Map(); for(const l of lines){ const k=normalizeInvoiceProductKey(l.produto_nome); if(!k) continue; if(!byName.has(k)){byName.set(k,l); continue;} const old=byName.get(k); // Quando o mesmo produto aparece repetido por OCR, fica com a linha mais plausível. const score=(x)=>{ let s=0; if(Number(x.total_linha)>0) s+=10; if(Number(x.quantidade_stock)>0) s+=5; if(!/produto|total|subtotal|sub total/i.test(x.produto_nome||'')) s+=5; if((x.nota_ocr||'').length<80) s+=1; return s; }; if(score(l)>score(old)) byName.set(k,l); } let out=[...byName.values()]; const target=invoiceTotalTargetNet(); const sum=out.reduce((a,b)=>a+Number(b.total_linha||0),0); // Se a soma ultrapassar muito o total ilíquido esperado, remove duplicados mais óbvios até aproximar. if(Number.isFinite(target)&&target>0&&sum>target*1.15){ out=out.sort((a,b)=>Number(b.total_linha||0)-Number(a.total_linha||0)); const kept=[]; let cur=0; for(const l of out){ if(cur+Number(l.total_linha||0)<=target*1.08){kept.push(l);cur+=Number(l.total_linha||0);} } if(kept.length) out=kept; } return out; } function parseVinisumosAndA4Rows(text){ const lines=normalizarTextoFatura(text).split(/\n+/).map(l=>l.replace(/\s+/g,' ').trim()).filter(Boolean); const out=[]; for(let i=0;il.replace(/\s+/g,' ').trim()).filter(Boolean); const out=[]; for(const line of lines){ if(/TOTAL|SUB\s*TOTAL|CARTAO|CARTÃO|PONTOS|ATCUD|IVA|TROCO|CONSUMIDOR|FATURA/i.test(line)) continue; let m=line.match(/^(.+?)\s+(\d+[,.]\d{2})\s*$/i); if(m && /[A-ZÁÉÍÓÚÃÕÇ]{3,}/i.test(m[1])) out.push(makeInvoiceLine(m[1],1,parseMoney(m[2]),23,'')); } return dedupeInvoiceLinesSmart(out); } function parseInvoiceText(){ const text=$('fat_ocr').value||''; const normalized=normalizarTextoFatura(text); if(normalized!==text)$('fat_ocr').value=normalized; autoFillInvoiceFields(normalized); const model=detectInvoiceModel(normalized); const a4Extra=parseVinisumosAndA4Rows(normalized); const a4Lines=parseA4InvoiceTableLines(normalized); const tableLines=parsePortugueseTableLines(normalized); const retailLines=parseRetailReceiptLines(normalized); const supermarketLines=parseSupermarketReceiptLines(normalized); const bricoLines=parseBricolageSimpleReceipt(normalized); const simpleLines=normalized.split(/\n+/).map(parseLine).filter(Boolean); // Escolhe o parser mais adequado ao modelo detetado. Isto evita que um talão Lidl seja tratado como A4 ou vice-versa. if((model==='vinisumos'||model==='leitao') && (a4Extra.length||a4Lines.length)) invoiceLines=dedupeInvoiceLinesSmart([...(a4Extra||[]),...(a4Lines||[])]); else if(model==='pirezas' && tableLines.length) invoiceLines=dedupeInvoiceLinesSmart(tableLines); else if((model==='lidl'||model==='pingo') && (retailLines.length||supermarketLines.length)) invoiceLines=dedupeInvoiceLinesSmart([...(retailLines||[]),...(supermarketLines||[])]); else if((model==='bricolage'||model==='barrocal') && (bricoLines.length||retailLines.length)) invoiceLines=dedupeInvoiceLinesSmart([...(bricoLines||[]),...(retailLines||[])]); else if((a4Extra.length+a4Lines.length)>=2) invoiceLines=dedupeInvoiceLinesSmart([...(a4Extra||[]),...(a4Lines||[])]); else if(tableLines.length>=2) invoiceLines=dedupeInvoiceLinesSmart(tableLines); else if(retailLines.length>=2) invoiceLines=dedupeInvoiceLinesSmart(mergeInvoiceLines(retailLines)); else if(supermarketLines.length>=2) invoiceLines=dedupeInvoiceLinesSmart(mergeInvoiceLines(supermarketLines)); else if(bricoLines.length>=1) invoiceLines=dedupeInvoiceLinesSmart(bricoLines); else invoiceLines=dedupeInvoiceLinesSmart(mergeInvoiceLines(simpleLines)); autoCorrectHeaderTotalFromLines(); let tot=invoiceLines.reduce((a,b)=>a+Number(b.total_linha||0),0); if(!$('fat_total').value&&tot)$('fat_total').value=tot.toFixed(2); const declared=parseMoney($('fat_total').value); const iva=parseMoney($('fat_iva').value); const expectedNet=Number.isFinite(declared)&&Number.isFinite(iva)?declared-iva:declared; if(Number.isFinite(expectedNet)&&tot>0&&Math.abs(expectedNet-tot)>0.20){ $('ocr_status').textContent='Atenção: a soma das linhas ('+eur(tot)+') não bate com o total ilíquido esperado ('+eur(expectedNet)+'). Confirma descontos, promoções e produtos antes de guardar.'; }else if(model){ $('ocr_status').textContent='Modelo detetado: '+model.toUpperCase()+'. Dados extraídos por modelo; confirma antes de guardar.'; }else{ $('ocr_status').textContent='Texto analisado. Confirma as linhas antes de guardar.'; } renderInvoiceLines(); } function renderInvoiceLines(){const box=$('invoice_lines_box');if(!invoiceLines.length){box.innerHTML='

Não Foram Detetadas Linhas. Podes Escrever Linhas Como: 3 Pacotes Batata Frita 3.60€

';return}box.innerHTML=`
${invoiceLines.map((l,i)=>``).join('')}
ProdutoCategoriaQtd CompraUnid./Emb.Stock A EntrarTotal LinhaCusto UnidadePreço SugeridoLucro UnidadeMargemNotaAção
${dec(l.quantidade_stock,2)}${eur(l.preco_unitario_stock)}${eur(Number(l.preco_venda_sugerido)-Number(l.preco_unitario_stock))}${dec(l.margem_sugerida,1)}%${htmlEscape(l.nota_ocr||"")}

Confirma Sempre As Quantidades. Se A Fatura Tiver Pack De 24, O Stock A Entrar Deve Ser 24 Unidades.

`} function recalcInvoiceLine(i,manualPrice=false){let l=invoiceLines[i];l.quantidade_stock=(Number(l.quantidade_comprada)||0)*(Number(l.unidades_por_embalagem)||1);l.preco_unitario_stock=l.quantidade_stock>0?Number(l.total_linha||0)/l.quantidade_stock:0;if(!manualPrice)l.preco_venda_sugerido=suggestSale(l.preco_unitario_stock,l.categoria);l.margem_sugerida=l.preco_venda_sugerido>0?((l.preco_venda_sugerido-l.preco_unitario_stock)/l.preco_venda_sugerido*100):0;renderInvoiceLines()} async function uploadInvoiceFile(faturaId){const file=getInvoiceSelectedFile();if(!file)return '';const ext=(file.name.split('.').pop()||'jpg').toLowerCase();const path=`${session.user.id}/${faturaId}.${ext}`;const {error}=await client.storage.from('faturas').upload(path,file,{upsert:true});if(error){console.warn(error);notify('A fatura foi guardada, mas a foto/PDF não foi enviado: '+error.message,'warn');return ''}return path} function invoiceLinesSum(){ return (invoiceLines||[]).reduce((a,l)=>a+Number(l.total_linha||0),0); } function invoiceTotalValue(){ return parseMoney($('fat_total')&&$('fat_total').value); } function invoiceLineTargetValue(){ const raw=$('fat_ocr')&&$('fat_ocr').dataset&&$('fat_ocr').dataset.totalLiquido; const base=parseMoney(raw||''); return base||invoiceTotalValue(); } function addManualInvoiceLineFromDifference(){ const total = invoiceLineTargetValue(); const sum = invoiceLinesSum(); const diff = Math.round((total - sum) * 100) / 100; if(!diff || Math.abs(diff) < 0.01){ notify('A soma das linhas já bate com o total da fatura.','ok'); return; } invoiceLines.push({ produto:'Linha Em Falta A Confirmar', categoria:'Outros', qtd_compra:1, unidades_embalagem:1, total_linha:Math.abs(diff), preco_venda:suggestSalePrice(Math.abs(diff),'Outros'), nota:'Criada automaticamente pela diferença entre total e linhas. Corrige o nome/quantidade antes de guardar.' }); renderInvoiceLines(); notify('Foi criada uma linha em falta de '+eur(Math.abs(diff))+'. Corrige o produto antes de guardar.','warn'); } async function runInvoiceMissingLinesFix(){ const file=getInvoiceSelectedFile(); if(!file){notify('Escolhe primeiro a foto/PDF da fatura.','warn');return} const total=invoiceLineTargetValue(); const sum=invoiceLinesSum(); const diff=Math.round((total-sum)*100)/100; if(!total || Math.abs(diff)<0.01){ notify('Não há diferença relevante para procurar.','ok'); return; } const status=$('ocr_status'); if(status)status.textContent='A procurar linhas em falta com IA. Diferença: '+eur(diff)+'...'; try{ const ai=getAISettings(); const model=String(ai.modelo_groq||''); const dataUrl=await invoiceFileToGroqDataUrl(file,{compact:/qwen/i.test(model)}); const payload={ image:dataUrl, mode:'missing_lines', provider:'groq', invoice_total:total, current_lines_total:sum, missing_difference:diff, current_lines:(invoiceLines||[]).map(l=>({produto:l.produto,total_linha:Number(l.total_linha||0),qtd_compra:Number(l.qtd_compra||1)})), prompt:'A soma das linhas atuais é '+sum.toFixed(2)+' mas o total da fatura é '+total.toFixed(2)+'. Procura na imagem apenas produtos/linhas que estejam em falta e que expliquem uma diferença de '+diff.toFixed(2)+'. Não devolvas linhas já importadas.' }; const res=await fetch('/api/extract-invoice-ai',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const out=await res.json().catch(()=>({})); if(!res.ok || out.error) throw new Error(out.error||out.detail||('Erro HTTP '+res.status)); const arr=(out.lines||out.items||out.produtos||out.products||[]); const before=invoiceLines.length; if(arr.length){ const mapped=arr.map(normalizeAILine).filter(x=>x && Number(x.total_linha||0)>0); const existingKeys=new Set((invoiceLines||[]).map(l=>(String(l.produto||'').toLowerCase().replace(/\s+/g,' ').trim()+'|'+Number(l.total_linha||0).toFixed(2)))); mapped.forEach(l=>{ const key=String(l.produto||'').toLowerCase().replace(/\s+/g,' ').trim()+'|'+Number(l.total_linha||0).toFixed(2); if(!existingKeys.has(key)){invoiceLines.push(l); existingKeys.add(key);} }); renderInvoiceLines(); } const afterSum=invoiceLinesSum(); const afterDiff=Math.round((total-afterSum)*100)/100; if(invoiceLines.length>before){ notify('Foram adicionadas linhas em falta. Confirma antes de guardar.','ok'); if(status)status.textContent='Linhas em falta adicionadas. Nova diferença: '+eur(afterDiff)+'.'; }else{ if(status)status.textContent='A IA não encontrou novas linhas. Podes criar uma linha manual pela diferença.'; addManualInvoiceLineFromDifference(); } }catch(e){ if(status)status.textContent='Erro ao procurar linhas em falta: '+(e.message||e); notify('Não consegui procurar automaticamente. Vou criar uma linha de diferença para corrigires manualmente.','warn'); addManualInvoiceLineFromDifference(); } } async function saveInvoiceToStock(){if(!canEdit())return notify('Perfil De Consulta: Não Pode Importar Faturas.','warn');if(!invoiceLines.length)return alert('Não Há Linhas Para Guardar.');const total=Number($('fat_total').value||invoiceLines.reduce((a,b)=>a+Number(b.total_linha||0),0));const fatura={data:$('fat_data').value||today(),fornecedor:$('fat_fornecedor').value, nif:$('fat_nif').value, documento:$('fat_documento').value,total,iva:Number($('fat_iva').value||0),ocr_text:$('fat_ocr').value,estado:'importada',criado_por:session.user.id};let {data:fat,error}=await client.from('faturas_compras').insert(fatura).select().single();if(error)return alert(error.message);const fileUrl=await uploadInvoiceFile(fat.id);if(fileUrl)await client.from('faturas_compras').update({ficheiro_url:fileUrl}).eq('id',fat.id);for(const l of invoiceLines){let {data:existing=[]}=await client.from('produtos').select('*').ilike('nome',l.produto_nome).limit(1);let produto=existing[0];if(produto){await client.from('produtos').update({categoria:l.categoria,preco_compra:l.preco_unitario_stock,preco_venda:l.preco_venda_sugerido,stock_atual:Number(produto.stock_atual||0)+Number(l.quantidade_stock||0),fornecedor:fatura.fornecedor,pack_unidades:l.unidades_por_embalagem,updated_at:new Date().toISOString()}).eq('id',produto.id)}else{const ins={nome:l.produto_nome,categoria:l.categoria,preco_compra:l.preco_unitario_stock,preco_venda:l.preco_venda_sugerido,stock_atual:l.quantidade_stock,stock_minimo:getPriceSettings().stock_minimo_default||5,unidade:l.unidade,fornecedor:fatura.fornecedor,pack_unidades:l.unidades_por_embalagem,criado_por:session.user.id};let res=await client.from('produtos').insert(ins).select().single();if(res.error)return alert(res.error.message);produto=res.data}await client.from('stock_movimentos').insert({produto_id:produto.id,data:fatura.data,tipo:'entrada',quantidade:l.quantidade_stock,motivo:'Compra Por Fatura',preco_unitario:l.preco_unitario_stock,total:l.total_linha,documento:fatura.documento,fornecedor:fatura.fornecedor,fatura_id:fat.id,criado_por:session.user.id});await client.from('fatura_linhas').insert({...l,fatura_id:fat.id,produto_id:produto.id,criado_por:session.user.id})}await client.from('movimentos').insert({data:fatura.data,tipo:'despesa',area:'Bar',categoria:'Compras Bar',descricao:'Compra De Stock Por Fatura',fornecedor_cliente:fatura.fornecedor,nif:fatura.nif,documento:fatura.documento,valor:fatura.total,iva:fatura.iva,metodo:'Outro',estado:'pago',ficheiro_url:fileUrl,criado_por:session.user.id});await audit('Importou Fatura Para Stock','faturas_compras',fat.id,{fatura,linhas:invoiceLines});alert('Fatura Guardada, Despesa Registada E Stock Atualizado.');openInvoiceDetails(fat.id)} async function definicoes(){ const {data:profiles=[]}=await client.from('profiles').select('*').order('created_at',{ascending:false}).limit(50); const id=appSettings.identidade||DEFAULT_SETTINGS.identidade, cats=appSettings.categorias||DEFAULT_SETTINGS.categorias, p=getPriceSettings(), b=appSettings.backups||DEFAULT_SETTINGS.backups, ai=getAISettings(); $('main').innerHTML=`

Definições

Aqui podes mudar o nome da plataforma, categorias, regras de preços, backups e utilizadores. Depois de guardar, as alterações ficam sincronizadas para todos os utilizadores.

Identidade Da Plataforma

Podes manter assets/logo-rcam.png ou colocar uma imagem online.

Regras De Preços E Stock


Categorias

Escreve uma categoria por linha.


IA / OCR De Faturas

Segurança: as chaves API não ficam guardadas na base de dados. Ficam só neste navegador/telemóvel. Para partilhar por todos, usa variáveis de ambiente na Cloudflare.

Se deixares uma chave em branco, mantém a chave local atual. Se não existir chave local, a função tenta usar a variável da Cloudflare: GROQ_API_KEY, GEMINI_API_KEY ou OPENAI_API_KEY.

Backup Automático

Utilizadores

A criação pela plataforma usa o registo normal do Supabase. Se o Supabase bloquear registos novos, cria o utilizador em Authentication → Users.



Perfis Registados

${profiles.length?table(profiles,['nome','role','created_at']):'

Ainda sem perfis. Podes clicar em Registar/Atualizar O Meu Perfil.

'}

`; } async function saveSetting(key,value){const {error}=await client.from('app_settings').upsert({key,value,updated_by:session.user.id,updated_at:new Date().toISOString()},{onConflict:'key'});if(error)throw error} async function saveSettings(){ try{ const identidade={nome_plataforma:$('set_nome').value,subtitulo:$('set_subtitulo').value,organizacao:$('set_org').value,nif:$('set_nif').value,logo_url:$('set_logo').value||'assets/logo-rcam.png'}; const categorias={areas:linesToArray($('set_cat_areas').value),despesa:linesToArray($('set_cat_despesa').value),receita:linesToArray($('set_cat_receita').value),produto:linesToArray($('set_cat_produto').value),inventario:linesToArray($('set_cat_inventario').value),metodos:linesToArray($('set_cat_metodos').value),funcionarios:linesToArray($('set_cat_funcionarios').value)}; const precos={markup_padrao:Number($('set_markup').value||3),markup_aguas:Number($('set_markup_aguas').value||3),markup_snacks:Number($('set_markup_snacks').value||2.5),markup_bebidas:Number($('set_markup_bebidas').value||2.3),margem_minima:Number($('set_margem').value||45),arredondamento:Number($('set_arred').value||0.05),stock_minimo_default:Number($('set_stock_min').value||5)}; const backups={email:$('set_bk_email').value,apps_script_url:$('set_bk_url').value,token:$('set_bk_token').value,hora:$('set_bk_hora').value||'03:00',manter_dias:Number($('set_bk_dias').value||90)}; const ai={provider:$('set_ai_provider')?.value||'groq',modelo_groq:$('set_ai_model_groq')?.value||'meta-llama/llama-4-scout-17b-16e-instruct',modelo_gemini:$('set_ai_model_gemini')?.value||'gemini-2.5-flash',modelo_openai:$('set_ai_model_openai')?.value||'gpt-4o-mini',usar_chave_local:true}; if($('set_ai_key_groq')?.value)setLocalAIKey('groq',$('set_ai_key_groq').value.trim()); if($('set_ai_key_gemini')?.value)setLocalAIKey('gemini',$('set_ai_key_gemini').value.trim()); if($('set_ai_key_openai')?.value)setLocalAIKey('openai',$('set_ai_key_openai').value.trim()); await saveSetting('identidade',identidade);await saveSetting('categorias',categorias);await saveSetting('precos',precos);await saveSetting('backups',backups);await saveSetting('ai',ai); localStorage.setItem('bk_url',backups.apps_script_url||'');localStorage.setItem('bk_token',backups.token||''); await loadSettings();await audit('Alterou Definições','app_settings',null,{identidade,categorias,precos,backups:{...backups,token:backups.token?'***':''},ai:{...ai,chaves:'guardadas localmente'}});alert('Definições Guardadas.');renderMenu();render(); }catch(e){alert('Erro Ao Guardar Definições: '+(e.message||e))} } async function upsertMyProfile(){try{const nome=($('new_user_nome')?.value)||session.user.email;const role=($('new_user_role')?.value)||'admin';const {error}=await client.from('profiles').upsert({id:session.user.id,nome,role},{onConflict:'id'});if(error)throw error;await audit('Atualizou Perfil','profiles',session.user.id,{nome,role});alert('Perfil Atualizado.');definicoes()}catch(e){alert(e.message||e)}} async function createAppUser(){ const email=$('new_user_email').value.trim(),password=$('new_user_pass').value,nome=$('new_user_nome').value.trim(),role=$('new_user_role').value; if(!email||!password)return alert('Preenche Email E Password.'); const signupClient=window.supabase.createClient(cfg.SUPABASE_URL,cfg.SUPABASE_ANON_KEY,{auth:{persistSession:false,autoRefreshToken:false,detectSessionInUrl:false}}); try{const {data,error}=await signupClient.auth.signUp({email,password,options:{data:{nome,role}}});if(error)throw error;if(data.user?.id)await client.from('profiles').upsert({id:data.user.id,nome:nome||email,role},{onConflict:'id'});await audit('Criou/Convidou Utilizador','profiles',data.user?.id||null,{email,nome,role});alert('Utilizador Criado/Convidado. Se o Supabase exigir confirmação, verifica o email.');definicoes()}catch(e){alert('Não Foi Possível Criar Pela Plataforma: '+(e.message||e)+'\n\nSolução: cria em Supabase → Authentication → Users → Add User.')} } async function backup(){$('main').innerHTML=`

Backup E Segurança

Backup Manual

Descarrega Uma Cópia JSON Da Base De Dados. Recomendo Antes De Grandes Alterações.



Backup Para Gmail E Google Drive

Para enviar automaticamente para o Gmail/Drive da associação, usa o ficheiro backup-gmail-drive-appsscript.gs incluído no ZIP.



Recuperação

Se A App Der Erro Ou Fechar Sem Querer, Ela Volta A Abrir No Último Menu Usado. Nos Formulários Podes Usar Guardar Rascunho E Recuperar Rascunho.

Para Backup Automático Diário Sem Teres A App Aberta, Configura O Trigger Diário No Google Apps Script.

`} function saveBackupConfig(){localStorage.setItem('bk_url',$('bk_url').value);localStorage.setItem('bk_token',$('bk_token').value);alert('Configuração Guardada Neste Equipamento.')} async function buildBackup(){let out={created_at:new Date().toISOString(),app:'ERP RCAM V3.6.2 Funcionarios Caixa',tables:{}};for(const t of backupTables){const {data,error}=await client.from(t).select('*');if(error)throw new Error(t+': '+error.message);out.tables[t]=data||[]}return out} async function exportBackup(){try{let out=await buildBackup();const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(out,null,2)],{type:'application/json'}));a.download='backup-rotas-cristalinas-'+today()+'.json';a.click();await client.from('backup_logs').insert({tipo:'manual',destino:'ficheiro',estado:'ok',detalhe:'Backup JSON descarregado',criado_por:session.user.id});await audit('Exportou Backup','backup_logs',null,{destino:'ficheiro'})}catch(e){alert(e.message||e)}} async function sendBackupToGmail(){saveBackupConfig();const url=$('bk_url').value,token=$('bk_token').value;if(!url||!token)return alert('Preenche O URL Do Apps Script E O Token.');try{const backup=await buildBackup();const res=await fetch(url,{method:'POST',body:JSON.stringify({token,backup})});const txt=await res.text();await client.from('backup_logs').insert({tipo:'manual',destino:'gmail/drive',estado:'enviado',detalhe:txt,criado_por:session.user.id});alert('Pedido De Backup Enviado. Confirma No Gmail/Drive.')}catch(e){await client.from('backup_logs').insert({tipo:'manual',destino:'gmail/drive',estado:'erro',detalhe:String(e.message||e),criado_por:session.user.id});alert('Erro: '+(e.message||e))}} async function importBackup(){const file=$('backupFile').files[0];if(!file)return alert('Escolhe Um Ficheiro JSON.');if(!confirm('Importar Pode Duplicar Dados. Queres Continuar?'))return;const json=JSON.parse(await file.text());for(const t of backupTables.filter(x=>!['audit_logs','backup_logs'].includes(x))){const rows=(json.tables&&json.tables[t])||[];if(rows.length){const clean=rows.map(r=>{let c={...r};delete c.id;delete c.created_at;return c});const {error}=await client.from(t).insert(clean);if(error)return alert('Erro Em '+t+': '+error.message)}}await audit('Importou Backup','backup_logs',null,{ficheiro:file.name});alert('Backup Importado.')} async function exportCSV(tbl){const {data=[]}=await client.from(tbl).select('*');if(!data.length)return alert('Sem Dados');const cols=Object.keys(data[0]);const csv=[cols.join(';')].concat(data.map(r=>cols.map(c=>String(r[c]??'').replaceAll(';',',')).join(';'))).join('\n');const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=tbl+'.csv';a.click()} async function historico(){const {data=[]}=await client.from('audit_logs').select('*').order('data',{ascending:false}).limit(120);$('main').innerHTML=`

Histórico De Alterações

Aqui Fica Registado Quem Criou Registos, Importou Faturas Ou Exportou Backups.

${table(data,['data','acao','tabela','registo_id','detalhe'])}`} init(); function val(id){const el=$(id);return el?el.value:''} function numVal(id){const n=parseFloat(String(val(id)).replace(',','.'));return Number.isFinite(n)?n:0} function escAttr(v){return htmlEscape(v??'').replace(/"/g,'"')} function closeModal(){const m=document.getElementById('modalRoot');if(m)m.remove()} function openModal(title,body,actions=''){closeModal();const div=document.createElement('div');div.id='modalRoot';div.className='modalOverlay';div.innerHTML=``;document.body.appendChild(div)} // ================= RCAM POS V5.0 ================= let posCart=[], posButtons=[], posMesaAtual='BALCÃO CENTRO', posCategoriaAtual='Todos'; const POS_DEFAULT_CATS=['Todos','Cervejas','Águas','Refrigerantes','Cafés','Vinhos','Shots','Cocktails','Snacks','Comida','Outros']; function posMoney(n){return Number(n||0).toLocaleString('pt-PT',{style:'currency',currency:'EUR'})} async function posLoadButtons(){ let {data,error}=await client.from('pos_botoes').select('*').eq('ativo',true).order('ordem',{ascending:true}); if(error){ // fallback: produtos existentes como botões POS const r=await client.from('produtos').select('*').order('nome').limit(120); data=(r.data||[]).filter(p=>p.ativo!==false).map((p,i)=>({id:'prod_'+p.id,nome:p.nome,categoria:p.categoria||'Outros',preco:Number(p.preco_venda||0),produto_id:p.id,tipo:'produto',imagem_url:p.foto_url||'',ordem:i,ativo:true})); } posButtons=data||[]; } async function pos(){document.body.classList.add('posMode');await posLoadButtons();renderPOS();} function posCats(){const cats=[...new Set(['Todos',...posButtons.map(b=>b.categoria||'Outros')])];return cats.length?cats:POS_DEFAULT_CATS} function renderPOS(){ const search=(window.posSearch||'').toLowerCase(); const cats=posCats(); const list=posButtons.filter(b=>(posCategoriaAtual==='Todos'||(b.categoria||'Outros')===posCategoriaAtual)&&(!search||String(b.nome||'').toLowerCase().includes(search))); $('main').innerHTML=`
🧾 ${htmlEscape(posMesaAtual||'BALCÃO CENTRO')}
${posCart.length?posCart.map((l,i)=>`
${htmlEscape(l.nome)}
${l.qtd} x ${posMoney(l.preco)}
${posMoney(l.qtd*l.preco)}
`).join(''):'
Sem artigos
'}
Subtotal${posMoney(posTotal())}
Imposto${posMoney(posIVA())}
${posMoney(posTotal())}
${cats.map(c=>``).join('')}
${list.map(b=>``).join('')}
`; } function posIconForCat(c){c=String(c||'').toLowerCase(); if(c.includes('cervej'))return '
🍺
'; if(c.includes('café')||c.includes('cafe'))return '
'; if(c.includes('vinho'))return '
🍷
'; if(c.includes('shot'))return '
🥃
'; if(c.includes('cock'))return '
🍸
'; if(c.includes('snack'))return '
🍟
'; return '
🥤
'} function posTotal(){return posCart.reduce((a,l)=>a+l.qtd*l.preco,0)} function posIVA(){return 0} function posRemove(i){posCart.splice(i,1);renderPOS()} function posClear(){if(!posCart.length||confirm('Anular venda atual?')){posCart=[];renderPOS()}} async function posButtonClick(id){const b=posButtons.find(x=>String(x.id)===String(id)); if(!b)return; if(b.tipo==='opcoes'||/shot/i.test(b.nome||''))return posEscolherOpcao(b); if(b.tipo==='receita'||b.receita_texto)return posReceitaOuVenda(b); posAddLine(b.nome,Number(b.preco||0),1,b)} function posAddLine(nome,preco,qtd=1,meta={}){const key=nome+'|'+preco+'|'+(meta.produto_id||meta.id||'');const ex=posCart.find(l=>l.key===key); if(ex)ex.qtd+=qtd; else posCart.push({key,nome,preco,qtd,meta}); renderPOS();} async function posEscolherOpcao(b){let {data:ops=[]}=await client.from('pos_opcoes').select('*').eq('botao_id',b.id).eq('ativo',true).order('ordem'); if(!ops.length){ops=['Beirão','Whisky','1920','Macieira','Safari','Ginja','Vodka','Tequila'].map((n,i)=>({id:'manual_'+i,nome:n,preco:b.preco,cl_descontar:5}))} openModal('Escolher Bebida Do Shot',`
${ops.map(o=>``).join('')}
`,'');} async function posSelecionarOpcao(botaoId,opId){const b=posButtons.find(x=>String(x.id)===String(botaoId)); let opc=null; const r=await client.from('pos_opcoes').select('*').eq('id',opId).maybeSingle(); opc=r.data||{nome:opId.replace('manual_',''),preco:b.preco,cl_descontar:5}; closeModal(); posAddLine((b.nome||'Shot')+' '+(opc.nome||''),Number(opc.preco||b.preco||0),1,{...b,opcao:opc});} function posReceitaOuVenda(b){openModal(b.nome,`

${htmlEscape(b.nome)}

${b.imagem_url?``:''}

Preço: ${posMoney(b.preco)}

Ficha / Receita

${htmlEscape(b.receita_texto||'Sem receita definida.')}
`,``)} async function posPay(metodo){if(!posCart.length){notify('Sem artigos na venda.','warn');return} const total=posTotal(); const venda={data:today(),mesa:posMesaAtual,total,metodo,estado:'pago',criado_por:session?.user?.id}; const {data:v,error}=await client.from('pos_vendas').insert(venda).select().single(); if(error){notify('Erro ao guardar venda: '+error.message,'bad');return} const linhas=posCart.map(l=>({venda_id:v.id,produto_nome:l.nome,produto_id:l.meta?.produto_id||null,quantidade:l.qtd,preco_unitario:l.preco,total_linha:l.qtd*l.preco,meta:l.meta||{}})); await client.from('pos_venda_linhas').insert(linhas); await posBaixarStock(linhas); await client.from('movimentos').insert({data:today(),tipo:'receita',area:'Bar',categoria:'Vendas Bar',descricao:'Venda POS - '+(posMesaAtual||'Balcão'),valor:total,iva:0,metodo,estado:'pago',fornecedor_cliente:'POS'}); notify('Venda finalizada: '+posMoney(total),'ok'); const cfg=await posGetConfig(); if(cfg.imprimir_auto)posPrintReceipt(v,linhas); posCart=[]; renderPOS();} async function posBaixarStock(linhas){for(const l of linhas){if(l.produto_id){const {data:p}=await client.from('produtos').select('stock_atual,preco_compra').eq('id',l.produto_id).single(); const novo=Number(p?.stock_atual||0)-Number(l.quantidade||0); await client.from('produtos').update({stock_atual:novo}).eq('id',l.produto_id); await client.from('stock_movimentos').insert({produto_id:l.produto_id,tipo:'saida',quantidade:-Number(l.quantidade||0),motivo:'Venda POS',preco_unitario:Number(p?.preco_compra||0),total:Number(l.quantidade||0)*Number(p?.preco_compra||0),criado_por:session?.user?.id});}}} async function posGetConfig(){const {data=[]}=await client.from('pos_config').select('*'); const o={imprimir_auto:false,abrir_gaveta_auto:true}; data.forEach(r=>o[r.key]=r.value); return o} function posOpenCashDrawer(){notify('Comando para abrir gaveta enviado. Se ainda não abrir, configura a impressora/gaveta no Windows.', 'ok')} function posPrintReceipt(v,linhas){const w=window.open('','talão','width=360,height=600');w.document.write(`
ROTAS CRISTALINAS\nBAR\n------------------------------\n${linhas.map(l=>`${l.quantidade} x ${l.produto_nome}  ${posMoney(l.total_linha)}`).join('\n')}\n------------------------------\nTOTAL: ${posMoney(v.total)}\nObrigado!\n