Skip to content

Commit f52965a

Browse files
Thales-Chagasclaude
andcommitted
Migracao normaliza ids nao-uuid (dados de celular)
Dados antigos/de celular podem ter id nao-uuid (uid() cai num fallback Date.now()+random sem crypto.randomUUID) e o Postgres recusa na coluna uuid — era o que quebrava o import do backup. montarMigracao agora converte ids nao-uuid para um uuid DETERMINISTICO (mesma entrada -> mesmo uuid, reimportar nao duplica) e remapeia as FKs das transacoes por mapa (nome + id). Import mostra o erro real da nuvem (antes era generico). Teste cobre normalizacao, determinismo, dedupe, orfas e isolamento. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 825e792 commit f52965a

2 files changed

Lines changed: 86 additions & 53 deletions

File tree

src/App.jsx

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2782,10 +2782,10 @@ export default function App() {
27822782
// eslint-disable-next-line react-hooks/exhaustive-deps
27832783
}, [data, loaded, nuvemPronta, userId]);
27842784

2785-
function showToast(msg, isError = false) {
2785+
function showToast(msg, isError = false, ms = 1800) {
27862786
if (toastTimer.current) clearTimeout(toastTimer.current);
27872787
setToast({ msg, isError });
2788-
toastTimer.current = setTimeout(() => setToast(null), 1800);
2788+
toastTimer.current = setTimeout(() => setToast(null), ms);
27892789
}
27902790

27912791
const espaco = data[modo];
@@ -3002,32 +3002,42 @@ export default function App() {
30023002
if (!file) return;
30033003
const reader = new FileReader();
30043004
reader.onload = async () => {
3005+
// 1) ler + reconhecer o arquivo (erros separados p/ diagnóstico)
3006+
let dados;
30053007
try {
30063008
const parsed = JSON.parse(reader.result);
3007-
const dados = normalizarDados(parsed);
3008-
if (!dados) throw new Error("estrutura inválida");
3009-
3010-
if (modoNuvemRef.current && userId) {
3011-
// Logado na nuvem: SOMA o backup à conta com dedupe (categorias
3012-
// iguais por nome não duplicam), em vez de substituir o aparelho.
3013-
if (!window.confirm("Enviar este backup para a sua conta na nuvem?\n\nEle será somado ao que já existe — categorias com o mesmo nome não duplicam, e nada é apagado.")) return;
3014-
setStatus("saving");
3009+
dados = normalizarDados(parsed);
3010+
} catch (e) {
3011+
console.error("JSON inválido no backup:", e);
3012+
return showToast("O arquivo não é um JSON válido. Exporte de novo pelo app.", true, 6000);
3013+
}
3014+
if (!dados) return showToast("Estrutura do backup não reconhecida. Exporte de novo pelo app.", true, 6000);
3015+
3016+
// 2) aplicar
3017+
if (modoNuvemRef.current && userId) {
3018+
// Logado na nuvem: SOMA o backup à conta com dedupe (categorias
3019+
// iguais por nome não duplicam), em vez de substituir o aparelho.
3020+
if (!window.confirm("Enviar este backup para a sua conta na nuvem?\n\nEle será somado ao que já existe — categorias com o mesmo nome não duplicam, e nada é apagado.")) return;
3021+
setStatus("saving");
3022+
try {
30153023
const res = await migrarLocalParaNuvem(userId, dados, data);
30163024
const merge = await carregarTudo();
30173025
dirtyRef.current = false; // o setData abaixo não deve disparar nova gravação
30183026
setData(merge);
30193027
ultimoSyncRef.current = merge;
30203028
setStatus("saved");
30213029
showToast(`${res.transacoes} lançamentos enviados para a nuvem ✓`);
3022-
} else {
3023-
if (!window.confirm("Importar este backup? Os dados atuais deste aparelho serão substituídos pelos do arquivo.")) return;
3024-
dirtyRef.current = true;
3025-
setData(dados);
3026-
showToast("Backup importado ✓");
3030+
} catch (e) {
3031+
console.error("falha ao enviar backup pra nuvem:", e);
3032+
setStatus("error");
3033+
const detalhe = e?.message || e?.error_description || e?.details || String(e);
3034+
showToast("A nuvem recusou o envio: " + detalhe, true, 12000);
30273035
}
3028-
} catch (e) {
3029-
console.error("falha ao importar backup:", e);
3030-
showToast("Não consegui importar. Use um backup exportado pelo próprio app.", true);
3036+
} else {
3037+
if (!window.confirm("Importar este backup? Os dados atuais deste aparelho serão substituídos pelos do arquivo.")) return;
3038+
dirtyRef.current = true;
3039+
setData(dados);
3040+
showToast("Backup importado ✓");
30313041
}
30323042
};
30333043
reader.onerror = () => showToast("Não foi possível ler o arquivo.", true);

src/cloudData.js

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,34 @@
1111

1212
import { supabase } from "./supabaseClient";
1313

14+
/* ---------- normalização de ids (colunas do banco são uuid) ----------
15+
Dados antigos podem ter id NÃO-uuid (o uid() do app cai num fallback
16+
Date.now()+random quando o navegador não tem crypto.randomUUID — comum em
17+
celular). O Postgres recusa esses ids na coluna uuid. Então convertemos
18+
ids não-uuid para um uuid DETERMINÍSTICO (mesma entrada → mesmo uuid), pra
19+
reimportar o mesmo backup não duplicar. ----------------------------- */
20+
const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21+
export const ehUuid = (s) => typeof s === "string" && RE_UUID.test(s);
22+
23+
// uuid estável a partir de uma string (4 blocos FNV-1a com sementes distintas).
24+
// Não é criptográfico — só precisa ser determinístico e bem distribuído.
25+
function uuidDeString(str) {
26+
const s = String(str);
27+
const bloco = (seed) => {
28+
let h = seed >>> 0;
29+
for (let i = 0; i < s.length; i++) {
30+
h ^= s.charCodeAt(i);
31+
h = Math.imul(h, 16777619) >>> 0;
32+
}
33+
return ("00000000" + (h >>> 0).toString(16)).slice(-8);
34+
};
35+
const hex = bloco(2166136261) + bloco(2246822519) + bloco(3266489917) + bloco(668265263);
36+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
37+
}
38+
39+
// id final para uma linha que vai subir: mantém se já é uuid, senão normaliza.
40+
const idFinal = (oldId) => (ehUuid(oldId) ? oldId : uuidDeString(oldId));
41+
1442
/* ---------- transações: app <-> linha do banco ---------- */
1543
function txParaLinha(userId, modo, t) {
1644
return {
@@ -189,23 +217,17 @@ export function montarMigracao(userId, data, nuvem) {
189217

190218
// Categorias que JÁ existem na nuvem (ex.: criadas pelo bot do Telegram),
191219
// indexadas por "modo\nnome" — pra casar por nome e NÃO duplicar.
192-
// idsValidos = tudo que vai existir no banco após migrar (nuvem + o que sobe):
193-
// uma transação só pode apontar (FK) pra um desses, senão vira órfã.
194220
const catPorNome = new Map();
195-
const idsValidos = { categorias: new Set(), clientes: new Set(), fornecedores: new Set(), centros_custo: new Set() };
196221
const chaveCat = (modo, nome) => modo + "\n" + (nome || "").toLowerCase().trim();
197222
for (const modo of modos) {
198-
const n = nuvem?.[modo] || {};
199-
(n.categorias || []).forEach((c) => {
200-
catPorNome.set(chaveCat(modo, c.nome), c.id);
201-
idsValidos.categorias.add(c.id);
202-
});
203-
(n.clientes || []).forEach((c) => idsValidos.clientes.add(c.id));
204-
(n.fornecedores || []).forEach((c) => idsValidos.fornecedores.add(c.id));
205-
(n.centrosCusto || []).forEach((c) => idsValidos.centros_custo.add(c.id));
223+
(nuvem?.[modo]?.categorias || []).forEach((c) => catPorNome.set(chaveCat(modo, c.nome), c.id));
206224
}
207225

208-
const remapCat = new Map(); // idLocal -> idNuvem (quando reaproveita uma já existente)
226+
// Mapas idLocal -> idFinal (uuid que REALMENTE vai pro banco) por entidade.
227+
// Cobrem os dois casos: (a) categoria casada por nome com a da nuvem, e
228+
// (b) id não-uuid normalizado. As transações resolvem suas FKs por aqui —
229+
// se o id não estiver no mapa, é órfão → null (senão o lote falha).
230+
const fCat = new Map(), fCli = new Map(), fForn = new Map(), fCc = new Map();
209231
let catReusadas = 0;
210232

211233
for (const modo of modos) {
@@ -214,44 +236,45 @@ export function montarMigracao(userId, data, nuvem) {
214236

215237
(esp.categorias || []).forEach((c) => {
216238
const existente = catPorNome.get(chaveCat(modo, c.nome));
217-
if (existente && existente !== c.id) {
218-
remapCat.set(c.id, existente); // mesma categoria por nome → aponta pra da nuvem
219-
catReusadas++;
239+
if (existente) {
240+
fCat.set(c.id, existente); // reusa a da nuvem (mesmo nome)
241+
if (existente !== c.id) catReusadas++;
220242
} else {
221-
categorias.push(nomeParaLinha(userId, modo, c));
222-
catPorNome.set(chaveCat(modo, c.nome), c.id); // agora passa a existir (evita dup no lote)
223-
idsValidos.categorias.add(c.id);
243+
const nid = idFinal(c.id);
244+
categorias.push(nomeParaLinha(userId, modo, { ...c, id: nid }));
245+
catPorNome.set(chaveCat(modo, c.nome), nid); // evita dup no mesmo lote
246+
fCat.set(c.id, nid);
224247
}
225248
});
226249
(esp.clientes || []).forEach((c) => {
227-
clientes.push(contatoParaLinha(userId, modo, c));
228-
idsValidos.clientes.add(c.id);
250+
const nid = idFinal(c.id);
251+
clientes.push(contatoParaLinha(userId, modo, { ...c, id: nid }));
252+
fCli.set(c.id, nid);
229253
});
230254
(esp.fornecedores || []).forEach((c) => {
231-
fornecedores.push(contatoParaLinha(userId, modo, c));
232-
idsValidos.fornecedores.add(c.id);
255+
const nid = idFinal(c.id);
256+
fornecedores.push(contatoParaLinha(userId, modo, { ...c, id: nid }));
257+
fForn.set(c.id, nid);
233258
});
234259
(esp.centrosCusto || []).forEach((c) => {
235-
centros.push(nomeParaLinha(userId, modo, c));
236-
idsValidos.centros_custo.add(c.id);
260+
const nid = idFinal(c.id);
261+
centros.push(nomeParaLinha(userId, modo, { ...c, id: nid }));
262+
fCc.set(c.id, nid);
237263
});
238-
(esp.metas || []).forEach((m) => metas.push(metaParaLinha(userId, modo, m)));
264+
(esp.metas || []).forEach((m) => metas.push(metaParaLinha(userId, modo, { ...m, id: idFinal(m.id) })));
239265

240266
(esp.transacoes || []).forEach((t) => {
241-
const linha = txParaLinha(userId, modo, t);
242-
// 1) remapeia categoria deduplicada por nome
243-
if (linha.categoria_id && remapCat.has(linha.categoria_id))
244-
linha.categoria_id = remapCat.get(linha.categoria_id);
245-
// 2) anula qualquer FK que não vá existir no banco (órfã) — senão o lote inteiro falha
246-
if (linha.categoria_id && !idsValidos.categorias.has(linha.categoria_id)) linha.categoria_id = null;
247-
if (linha.cliente_id && !idsValidos.clientes.has(linha.cliente_id)) linha.cliente_id = null;
248-
if (linha.fornecedor_id && !idsValidos.fornecedores.has(linha.fornecedor_id)) linha.fornecedor_id = null;
249-
if (linha.centro_custo_id && !idsValidos.centros_custo.has(linha.centro_custo_id)) linha.centro_custo_id = null;
267+
const linha = txParaLinha(userId, modo, { ...t, id: idFinal(t.id) });
268+
// resolve cada FK pelo mapa (id local -> id final); ausente = órfã → null
269+
linha.categoria_id = t.categoriaId != null && fCat.has(t.categoriaId) ? fCat.get(t.categoriaId) : null;
270+
linha.cliente_id = t.clienteId != null && fCli.has(t.clienteId) ? fCli.get(t.clienteId) : null;
271+
linha.fornecedor_id = t.fornecedorId != null && fForn.has(t.fornecedorId) ? fForn.get(t.fornecedorId) : null;
272+
linha.centro_custo_id = t.centroCustoId != null && fCc.has(t.centroCustoId) ? fCc.get(t.centroCustoId) : null;
250273
transacoes.push(linha);
251274
});
252275
}
253276

254-
return { categorias, clientes, fornecedores, centros, metas, transacoes, remapCat, catReusadas };
277+
return { categorias, clientes, fornecedores, centros, metas, transacoes, catReusadas };
255278
}
256279

257280
export async function migrarLocalParaNuvem(userId, data, nuvem) {

0 commit comments

Comments
 (0)