kotsu-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ """Parser da linha rápida (D10, D11). Puro: recebe texto e a data de hoje.
2
+
3
+ Sintaxe: o valor vem primeiro; o resto é livre.
4
+
5
+ 89,90 mercado #alimentação @nubank despesa (sem sinal ou com -)
6
+ +3500 salário @itaú receita
7
+ 120 geladeira #casa @nubank 10x 10 parcelas de 120,00, uma por mês
8
+ ... ontem | 15/10 | 15/10/2025 data (padrão: hoje)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ from money_cli.dominio import dinheiro
18
+ from money_cli.dominio.tipos import Tipo
19
+
20
+ MAX_PARCELAS = 120
21
+
22
+ _PARCELAS = re.compile(r"(\d+)[xX]")
23
+ _DATA = re.compile(r"(\d{1,2})/(\d{1,2})(?:/(\d{2}|\d{4}))?")
24
+ _VALOR = re.compile(r"([+-]?)(.+)")
25
+ _DIGITOS = re.compile(r"\d+")
26
+ _MILHAR = re.compile(r"\d{1,3}(\.\d{3})+")
27
+
28
+
29
+ class ErroDeLinha(ValueError):
30
+ """Linha que não dá para salvar. A mensagem é mostrada ao usuário como está."""
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class LinhaInterpretada:
35
+ """O que a linha diz, antes de resolver nomes de conta e categoria em ids."""
36
+
37
+ valor: int # centavos, positivo; de cada parcela quando há parcelas (D11)
38
+ tipo: Tipo
39
+ descricao: str
40
+ data: dt.date
41
+ categoria: str | None = None
42
+ conta: str | None = None
43
+ parcelas: int = 1
44
+ com_data: bool = False # a data foi escrita na linha (senão é hoje)
45
+
46
+ @property
47
+ def total(self) -> int:
48
+ return self.valor * self.parcelas
49
+
50
+
51
+ def interpretar(linha: str, hoje: dt.date) -> LinhaInterpretada:
52
+ """Lê uma linha rápida. Levanta `ErroDeLinha` com mensagem para o usuário."""
53
+ partes = linha.split()
54
+ if not partes:
55
+ raise ErroDeLinha("digite o valor primeiro, ex.: 89,90 mercado @nubank")
56
+
57
+ tipo, valor = _ler_valor(partes[0])
58
+ descricao: list[str] = []
59
+ categoria = conta = None
60
+ data: dt.date | None = None
61
+ parcelas: int | None = None
62
+
63
+ for parte in partes[1:]:
64
+ if parte.startswith("#"):
65
+ if categoria is not None:
66
+ raise ErroDeLinha("só uma #categoria por lançamento")
67
+ categoria = _nome(parte, "categoria")
68
+ elif parte.startswith("@"):
69
+ if conta is not None:
70
+ raise ErroDeLinha("só uma @conta por lançamento")
71
+ conta = _nome(parte, "conta")
72
+ elif m := _PARCELAS.fullmatch(parte):
73
+ if parcelas is not None:
74
+ raise ErroDeLinha("só um número de parcelas (ex.: 10x)")
75
+ parcelas = int(m.group(1))
76
+ if not 2 <= parcelas <= MAX_PARCELAS:
77
+ raise ErroDeLinha(f"parcelas vão de 2x a {MAX_PARCELAS}x")
78
+ elif (d := _ler_data(parte, hoje)) is not None:
79
+ if data is not None:
80
+ raise ErroDeLinha("só uma data por lançamento")
81
+ data = d
82
+ else:
83
+ descricao.append(parte)
84
+
85
+ return LinhaInterpretada(
86
+ valor=valor,
87
+ tipo=tipo,
88
+ descricao=" ".join(descricao),
89
+ data=data or hoje,
90
+ categoria=categoria,
91
+ conta=conta,
92
+ parcelas=parcelas or 1,
93
+ com_data=data is not None,
94
+ )
95
+
96
+
97
+ def _nome(parte: str, oque: str) -> str:
98
+ nome = parte[1:]
99
+ if not nome:
100
+ raise ErroDeLinha(f"falta o nome da {oque} depois de {parte[0]}")
101
+ return nome
102
+
103
+
104
+ def _ler_valor(texto: str) -> tuple[Tipo, int]:
105
+ m = _VALOR.fullmatch(texto)
106
+ if not m:
107
+ raise ErroDeLinha(f"valor ilegível: {texto} (use 89,90 ou 1.234,56)")
108
+ sinal, numero = m.groups()
109
+ tipo = Tipo.RECEITA if sinal == "+" else Tipo.DESPESA
110
+ centavos = _centavos(numero)
111
+ if centavos is None:
112
+ raise ErroDeLinha(f"valor ilegível: {texto} (use 89,90 ou 1.234,56)")
113
+ if centavos == 0:
114
+ raise ErroDeLinha("o valor precisa ser maior que zero")
115
+ return tipo, centavos
116
+
117
+
118
+ def _centavos(numero: str) -> int | None:
119
+ """'89,90' '89.90' '89' '1.234,56' '1234,5' -> centavos. Ilegível -> None.
120
+
121
+ Vírgula é sempre decimal e ponto, milhar. Sem vírgula, um ponto seguido de 1 ou 2
122
+ dígitos no fim é decimal (89.90); em grupos de 3 é milhar (1.234).
123
+ """
124
+ if "," in numero:
125
+ inteiro, _, fracao = numero.partition(",")
126
+ elif re.fullmatch(r"\d+\.\d{1,2}", numero):
127
+ inteiro, _, fracao = numero.partition(".")
128
+ else:
129
+ inteiro, fracao = numero, ""
130
+ if "." in inteiro:
131
+ if not _MILHAR.fullmatch(inteiro):
132
+ return None
133
+ inteiro = inteiro.replace(".", "")
134
+ if not _DIGITOS.fullmatch(inteiro) or (fracao and not _DIGITOS.fullmatch(fracao)):
135
+ return None
136
+ if len(fracao) > 2:
137
+ return None
138
+ return int(inteiro) * 100 + int(fracao.ljust(2, "0") or 0)
139
+
140
+
141
+ def _ler_data(texto: str, hoje: dt.date) -> dt.date | None:
142
+ """'hoje', 'ontem', 'dd/mm', 'dd/mm/aa', 'dd/mm/aaaa'. Não é data -> None."""
143
+ minusculo = texto.casefold()
144
+ if minusculo == "hoje":
145
+ return hoje
146
+ if minusculo == "ontem":
147
+ return hoje - dt.timedelta(days=1)
148
+ m = _DATA.fullmatch(texto)
149
+ if not m:
150
+ return None
151
+ dia, mes, ano = m.groups()
152
+ ano_num = hoje.year if ano is None else int(ano) + (2000 if len(ano) == 2 else 0)
153
+ try:
154
+ return dt.date(ano_num, int(mes), int(dia))
155
+ except ValueError:
156
+ raise ErroDeLinha(f"data impossível: {texto}") from None
157
+
158
+
159
+ def montar(
160
+ valor: int,
161
+ tipo: Tipo,
162
+ descricao: str,
163
+ conta: str,
164
+ categoria: str | None = None,
165
+ data: dt.date | None = None,
166
+ ) -> str:
167
+ """O caminho inverso de `interpretar`: a linha que copiar e editar mostram (D27, D28).
168
+
169
+ Sem `data`, a linha não traz data (fica hoje ao salvar). Nunca traz parcelas.
170
+ """
171
+ partes = [("+" if tipo is Tipo.RECEITA else "") + dinheiro.formatar(valor)]
172
+ if descricao:
173
+ partes.append(descricao)
174
+ if categoria:
175
+ partes.append(f"#{categoria}")
176
+ partes.append(f"@{conta}")
177
+ if data is not None:
178
+ partes.append(data.strftime("%d/%m/%Y"))
179
+ return " ".join(partes)
@@ -0,0 +1,57 @@
1
+ """Mês civil, sem fuso. Cada mês é fechado em si (D9)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from dataclasses import dataclass
7
+
8
+ NOMES = (
9
+ "janeiro",
10
+ "fevereiro",
11
+ "março",
12
+ "abril",
13
+ "maio",
14
+ "junho",
15
+ "julho",
16
+ "agosto",
17
+ "setembro",
18
+ "outubro",
19
+ "novembro",
20
+ "dezembro",
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True, order=True)
25
+ class Mes:
26
+ ano: int
27
+ mes: int
28
+
29
+ def __post_init__(self) -> None:
30
+ if not 1 <= self.mes <= 12:
31
+ raise ValueError(f"mês inválido: {self.mes}")
32
+
33
+ @classmethod
34
+ def de(cls, data: dt.date) -> Mes:
35
+ return cls(data.year, data.month)
36
+
37
+ def somar(self, meses: int) -> Mes:
38
+ indice = self.ano * 12 + (self.mes - 1) + meses
39
+ return Mes(indice // 12, indice % 12 + 1)
40
+
41
+ def anterior(self) -> Mes:
42
+ return self.somar(-1)
43
+
44
+ def seguinte(self) -> Mes:
45
+ return self.somar(1)
46
+
47
+ @property
48
+ def inicio(self) -> dt.date:
49
+ return dt.date(self.ano, self.mes, 1)
50
+
51
+ @property
52
+ def fim_exclusivo(self) -> dt.date:
53
+ return self.seguinte().inicio
54
+
55
+ @property
56
+ def nome(self) -> str:
57
+ return f"{NOMES[self.mes - 1]} de {self.ano}"
@@ -0,0 +1,440 @@
1
+ """Mesclagem de dados entre aparelhos (D39). Puro: recebe e devolve estados.
2
+
3
+ Um *estado* é o conteúdo inteiro de um banco, como dicionário (o mesmo formato do arquivo
4
+ de troca): `contas`, `categorias`, `recorrencias`, `lancamentos` e `lapides`. Os registros
5
+ se referem uns aos outros por `uid`, nunca pelo `id` local.
6
+
7
+ Regras (as mesmas no app Android; os casos em `vetores/` valem para os dois):
8
+
9
+ - Cada registro tem `alterado_em`, um carimbo `milissegundos-contador-réplica` comparável
10
+ como texto. No mesmo `uid`, vale o carimbo maior, com o registro inteiro.
11
+ - Lançamento excluído deixa uma lápide. Ela vence o lançamento se for mais nova que ele.
12
+ - Contas e categorias com o mesmo nome (sem diferenciar maiúsculas) viram uma só: fica o
13
+ menor `uid` e o conteúdo mais novo.
14
+ - Séries: vale o modelo mais novo; `gerada_ate` é sempre o maior dos dois.
15
+
16
+ Mesclar é comutativo e pode repetir: A+B dá o mesmo que B+A, e juntar duas vezes o mesmo
17
+ arquivo não muda nada.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime as dt
23
+ import hashlib
24
+ import re
25
+ from collections.abc import Iterable
26
+ from dataclasses import dataclass, field
27
+
28
+ from money_cli.dominio import dinheiro
29
+
30
+ FORMATO = 1
31
+ CARIMBO_ZERO = "0000000000000-0000-00000000"
32
+ SECOES = ("contas", "categorias", "recorrencias", "lancamentos", "lapides")
33
+
34
+ _CARIMBO = re.compile(r"\d{13}-\d{4}-[0-9a-f]{8}")
35
+ _TIPOS = ("receita", "despesa")
36
+
37
+
38
+ class ErroDeTroca(ValueError):
39
+ """Arquivo de troca que não dá para usar. A mensagem é mostrada ao usuário como está."""
40
+
41
+
42
+ # --- carimbos e identidade ---------------------------------------------------
43
+
44
+
45
+ def carimbo(agora_ms: int, ultimo: str, replica: str) -> str:
46
+ """Próximo carimbo: nunca menor nem igual a `ultimo`, mesmo com o relógio atrasado."""
47
+ ms, contador = int(ultimo[:13]), int(ultimo[14:18])
48
+ if agora_ms > ms:
49
+ ms, contador = agora_ms, 0
50
+ elif contador < 9999:
51
+ contador += 1
52
+ else:
53
+ ms, contador = ms + 1, 0
54
+ return f"{ms:013d}-{contador:04d}-{replica}"
55
+
56
+
57
+ def uid_do_mes(grupo: str, ano: int, mes: int) -> str:
58
+ """`uid` do mês gerado por uma série: igual em qualquer aparelho."""
59
+ return f"{grupo}-{ano:04d}-{mes:02d}"
60
+
61
+
62
+ def chave_do_nome(nome: str) -> str:
63
+ """Como dois nomes de conta ou categoria são comparados (Itaú = ITAÚ)."""
64
+ return nome.strip().casefold()
65
+
66
+
67
+ # --- forma canônica e código de conferência -----------------------------------
68
+
69
+
70
+ def _texto(valor: object) -> str:
71
+ if valor is None:
72
+ return ""
73
+ if isinstance(valor, bool):
74
+ return "1" if valor else "0"
75
+ return str(valor).replace("\\", "\\\\").replace("\n", "\\n").replace("\x1f", "\\u")
76
+
77
+
78
+ def _linha(prefixo: str, campos: Iterable[object]) -> str:
79
+ return "\x1f".join([prefixo, *(_texto(c) for c in campos)])
80
+
81
+
82
+ def _linha_conta(c: dict) -> str:
83
+ return _linha("C", (c["uid"], c["nome"], c["cor"]))
84
+
85
+
86
+ def _linha_categoria(c: dict) -> str:
87
+ return _linha("G", (c["uid"], c["nome"], c["recorrente"]))
88
+
89
+
90
+ def _linha_recorrencia(r: dict) -> str:
91
+ campos = ("grupo", "dia", "descricao", "valor", "tipo", "conta", "categoria", "encerrada")
92
+ return _linha("R", (r[c] for c in campos))
93
+
94
+
95
+ def _linha_lancamento(lanc: dict) -> str:
96
+ p = lanc["parcela"] or {}
97
+ return _linha(
98
+ "L",
99
+ (
100
+ lanc["uid"],
101
+ lanc["data"],
102
+ lanc["descricao"],
103
+ lanc["valor"],
104
+ lanc["tipo"],
105
+ lanc["conta"],
106
+ lanc["categoria"],
107
+ p.get("grupo"),
108
+ p.get("numero"),
109
+ p.get("total"),
110
+ lanc["recorrencia"],
111
+ ),
112
+ )
113
+
114
+
115
+ def forma_canonica(estado: dict) -> str:
116
+ """Texto que resume o conteúdo, igual em qualquer aparelho com os mesmos dados.
117
+
118
+ Uma linha por registro, campos separados por U+001F, cada seção em ordem de `uid`.
119
+ Ficam de fora os carimbos, `gerada_ate` e os meses gerados por série e nunca mexidos
120
+ (`gerado`): cada aparelho gera meses futuros até onde a pessoa navegou.
121
+ """
122
+ linhas = sorted(_linha_conta(c) for c in estado["contas"])
123
+ linhas += sorted(_linha_categoria(c) for c in estado["categorias"])
124
+ linhas += sorted(_linha_recorrencia(r) for r in estado["recorrencias"])
125
+ linhas += sorted(_linha_lancamento(x) for x in estado["lancamentos"] if not x["gerado"])
126
+ linhas += sorted(_linha("X", (x["uid"],)) for x in estado["lapides"])
127
+ return "\n".join(linhas)
128
+
129
+
130
+ def codigo(estado: dict) -> str:
131
+ """Código de conferência: 12 dígitos hexadecimais do SHA-256 da forma canônica."""
132
+ h = hashlib.sha256(forma_canonica(estado).encode("utf-8")).hexdigest()
133
+ return f"{h[0:4]}-{h[4:8]}-{h[8:12]}"
134
+
135
+
136
+ # --- validação ---------------------------------------------------------------
137
+
138
+
139
+ def validar(estado: object) -> dict:
140
+ """Confere um estado vindo de arquivo. Levanta `ErroDeTroca`; devolve o próprio estado."""
141
+ if not isinstance(estado, dict) or estado.get("app") != "kotsu":
142
+ raise ErroDeTroca("este arquivo não é uma exportação do Kotsu")
143
+ formato = estado.get("formato")
144
+ if not isinstance(formato, int) or isinstance(formato, bool) or formato < 1:
145
+ raise ErroDeTroca("este arquivo não é uma exportação do Kotsu")
146
+ if formato > FORMATO:
147
+ raise ErroDeTroca(
148
+ f"arquivo no formato {formato}, mais novo que este Kotsu (formato {FORMATO}):"
149
+ " atualize o Kotsu neste aparelho"
150
+ )
151
+ try:
152
+ _validar_conteudo(estado)
153
+ except (KeyError, TypeError, AttributeError) as erro:
154
+ raise ErroDeTroca(f"arquivo de troca incompleto ou estragado ({erro})") from None
155
+ return estado
156
+
157
+
158
+ def _exigir(condicao: bool, mensagem: str) -> None:
159
+ if not condicao:
160
+ raise ErroDeTroca(f"arquivo de troca inválido: {mensagem}")
161
+
162
+
163
+ def _inteiro(valor: object) -> bool:
164
+ return isinstance(valor, int) and not isinstance(valor, bool)
165
+
166
+
167
+ def _texto_cheio(valor: object) -> bool:
168
+ return isinstance(valor, str) and bool(valor.strip())
169
+
170
+
171
+ def _data(valor: object) -> bool:
172
+ try:
173
+ return isinstance(valor, str) and dt.date.fromisoformat(valor).isoformat() == valor
174
+ except ValueError:
175
+ return False
176
+
177
+
178
+ def _carimbo_valido(valor: object) -> bool:
179
+ return isinstance(valor, str) and _CARIMBO.fullmatch(valor) is not None
180
+
181
+
182
+ def _validar_conteudo(estado: dict) -> None:
183
+ for secao in SECOES:
184
+ _exigir(isinstance(estado[secao], list), f"falta a lista de {secao}")
185
+
186
+ for secao, rotulo in (("contas", "conta"), ("categorias", "categoria")):
187
+ uids, nomes = set(), set()
188
+ for item in estado[secao]:
189
+ _exigir(_texto_cheio(item["uid"]), f"{rotulo} sem uid")
190
+ _exigir(_texto_cheio(item["nome"]), f"{rotulo} sem nome")
191
+ _exigir(_carimbo_valido(item["alterado_em"]), f"carimbo da {rotulo} {item['nome']}")
192
+ _exigir(item["uid"] not in uids, f"{rotulo} repetida ({item['nome']})")
193
+ _exigir(chave_do_nome(item["nome"]) not in nomes, f"{rotulo} repetida ({item['nome']})")
194
+ uids.add(item["uid"])
195
+ nomes.add(chave_do_nome(item["nome"]))
196
+ for conta in estado["contas"]:
197
+ _exigir(isinstance(conta["cor"], str), f"cor da conta {conta['nome']}")
198
+ for categoria in estado["categorias"]:
199
+ _exigir(isinstance(categoria["recorrente"], bool), f"categoria {categoria['nome']}")
200
+ contas = {c["uid"] for c in estado["contas"]}
201
+ categorias = {c["uid"] for c in estado["categorias"]}
202
+
203
+ def movimento(item: dict, oque: str) -> None:
204
+ _exigir(isinstance(item["descricao"], str), f"descrição de {oque}")
205
+ _exigir(_inteiro(item["valor"]) and item["valor"] > 0, f"valor de {oque}")
206
+ _exigir(item["tipo"] in _TIPOS, f"tipo de {oque}")
207
+ _exigir(item["conta"] in contas, f"conta desconhecida em {oque}")
208
+ _exigir(
209
+ item["categoria"] is None or item["categoria"] in categorias,
210
+ f"categoria desconhecida em {oque}",
211
+ )
212
+ _exigir(_carimbo_valido(item["alterado_em"]), f"carimbo de {oque}")
213
+
214
+ series = set()
215
+ for serie in estado["recorrencias"]:
216
+ _exigir(_texto_cheio(serie["grupo"]), "série sem grupo")
217
+ oque = f"série {serie['grupo']}"
218
+ _exigir(serie["grupo"] not in series, f"{oque} repetida")
219
+ series.add(serie["grupo"])
220
+ _exigir(_inteiro(serie["dia"]) and 1 <= serie["dia"] <= 31, f"dia da {oque}")
221
+ _exigir(_data(serie["gerada_ate"]), f"gerada_ate da {oque}")
222
+ _exigir(isinstance(serie["encerrada"], bool), f"encerrada da {oque}")
223
+ movimento(serie, oque)
224
+
225
+ uids = set()
226
+ for lanc in estado["lancamentos"]:
227
+ _exigir(_texto_cheio(lanc["uid"]), "lançamento sem uid")
228
+ oque = f"lançamento {lanc['uid']}"
229
+ _exigir(lanc["uid"] not in uids, f"{oque} repetido")
230
+ uids.add(lanc["uid"])
231
+ _exigir(_data(lanc["data"]), f"data do {oque}")
232
+ _exigir(isinstance(lanc["gerado"], bool), f"gerado do {oque}")
233
+ _exigir(
234
+ lanc["recorrencia"] is None or lanc["recorrencia"] in series,
235
+ f"série desconhecida no {oque}",
236
+ )
237
+ parcela = lanc["parcela"]
238
+ if parcela is not None:
239
+ _exigir(_texto_cheio(parcela["grupo"]), f"parcela do {oque}")
240
+ _exigir(
241
+ _inteiro(parcela["numero"])
242
+ and _inteiro(parcela["total"])
243
+ and 1 <= parcela["numero"] <= parcela["total"],
244
+ f"parcela do {oque}",
245
+ )
246
+ movimento(lanc, oque)
247
+
248
+ lapides = set()
249
+ for lapide in estado["lapides"]:
250
+ _exigir(_texto_cheio(lapide["uid"]), "exclusão sem uid")
251
+ _exigir(lapide["uid"] not in lapides, f"exclusão repetida ({lapide['uid']})")
252
+ lapides.add(lapide["uid"])
253
+ _exigir(_carimbo_valido(lapide["excluido_em"]), f"carimbo da exclusão {lapide['uid']}")
254
+
255
+
256
+ # --- mesclagem ---------------------------------------------------------------
257
+
258
+
259
+ def _mais_novo(a: dict | None, b: dict, linha) -> dict:
260
+ """O registro de carimbo maior; no empate (não deveria haver), a maior linha canônica."""
261
+ if a is None:
262
+ return b
263
+ return max(a, b, key=lambda x: (x["alterado_em"], linha(x)))
264
+
265
+
266
+ def _unir_por_nome(a: list[dict], b: list[dict], linha) -> tuple[list[dict], dict[str, str]]:
267
+ """Junta contas (ou categorias) dos dois lados. Devolve a lista e `uid antigo -> novo`."""
268
+ por_uid: dict[str, dict] = {}
269
+ for item in (*a, *b):
270
+ por_uid[item["uid"]] = _mais_novo(por_uid.get(item["uid"]), item, linha)
271
+ grupos: dict[str, list[dict]] = {}
272
+ for uid in sorted(por_uid):
273
+ grupos.setdefault(chave_do_nome(por_uid[uid]["nome"]), []).append(por_uid[uid])
274
+ unidos, troca = [], {}
275
+ for grupo in grupos.values(): # em ordem de uid: o primeiro é o menor
276
+ conteudo = max(grupo, key=lambda x: x["alterado_em"]) # empate: o de menor uid
277
+ unidos.append({**conteudo, "uid": grupo[0]["uid"]})
278
+ for item in grupo:
279
+ troca[item["uid"]] = grupo[0]["uid"]
280
+ return sorted(unidos, key=lambda x: x["uid"]), troca
281
+
282
+
283
+ def mesclar(a: dict, b: dict) -> dict:
284
+ """Junta dois estados válidos num só (só as seções de dados, cada uma em ordem de uid)."""
285
+ contas, troca_conta = _unir_por_nome(a["contas"], b["contas"], _linha_conta)
286
+ categorias, troca_categoria = _unir_por_nome(a["categorias"], b["categorias"], _linha_categoria)
287
+
288
+ def apontar(item: dict) -> dict:
289
+ categoria = item["categoria"]
290
+ return {
291
+ **item,
292
+ "conta": troca_conta[item["conta"]],
293
+ "categoria": None if categoria is None else troca_categoria[categoria],
294
+ }
295
+
296
+ series: dict[str, dict] = {}
297
+ for serie in (*a["recorrencias"], *b["recorrencias"]):
298
+ serie = apontar(serie)
299
+ outra = series.get(serie["grupo"])
300
+ vence = _mais_novo(outra, serie, _linha_recorrencia)
301
+ gerada = (
302
+ serie["gerada_ate"] if outra is None else max(outra["gerada_ate"], serie["gerada_ate"])
303
+ )
304
+ series[serie["grupo"]] = {**vence, "gerada_ate": gerada}
305
+
306
+ lapides: dict[str, str] = {}
307
+ for lapide in (*a["lapides"], *b["lapides"]):
308
+ lapides[lapide["uid"]] = max(lapides.get(lapide["uid"], ""), lapide["excluido_em"])
309
+
310
+ lancamentos: dict[str, dict] = {}
311
+ for lanc in (*a["lancamentos"], *b["lancamentos"]):
312
+ lanc = apontar(lanc)
313
+ lancamentos[lanc["uid"]] = _mais_novo(
314
+ lancamentos.get(lanc["uid"]),
315
+ lanc,
316
+ lambda x: _linha_lancamento(x) + _texto(x["gerado"]),
317
+ )
318
+ vivos = [x for x in lancamentos.values() if lapides.get(x["uid"], "") < x["alterado_em"]]
319
+
320
+ return {
321
+ "contas": contas,
322
+ "categorias": categorias,
323
+ "recorrencias": sorted(series.values(), key=lambda x: x["grupo"]),
324
+ "lancamentos": sorted(vivos, key=lambda x: x["uid"]),
325
+ "lapides": [{"uid": uid, "excluido_em": lapides[uid]} for uid in sorted(lapides)],
326
+ }
327
+
328
+
329
+ def maior_carimbo(estado: dict) -> str:
330
+ """O maior carimbo que aparece no estado (para o relógio local passar dele)."""
331
+ carimbos = [CARIMBO_ZERO]
332
+ for secao in ("contas", "categorias", "recorrencias", "lancamentos"):
333
+ carimbos += [x["alterado_em"] for x in estado[secao]]
334
+ carimbos += [x["excluido_em"] for x in estado["lapides"]]
335
+ return max(carimbos)
336
+
337
+
338
+ # --- comparação (para conferir) -----------------------------------------------
339
+
340
+
341
+ @dataclass(frozen=True, slots=True)
342
+ class Resumo:
343
+ """O que a mesclagem mudou neste aparelho, em lançamentos."""
344
+
345
+ entraram: int = 0
346
+ mudaram: int = 0
347
+ sairam: int = 0
348
+
349
+ @property
350
+ def nada(self) -> bool:
351
+ return not (self.entraram or self.mudaram or self.sairam)
352
+
353
+
354
+ def resumir(antes: dict, depois: dict) -> Resumo:
355
+ """Compara os lançamentos de antes e depois de mesclar."""
356
+ velhos = {x["uid"]: _visivel(x, antes) for x in antes["lancamentos"]}
357
+ novos = {x["uid"]: _visivel(x, depois) for x in depois["lancamentos"]}
358
+ return Resumo(
359
+ entraram=len(novos.keys() - velhos.keys()),
360
+ mudaram=sum(1 for uid in novos.keys() & velhos.keys() if novos[uid] != velhos[uid]),
361
+ sairam=len(velhos.keys() - novos.keys()),
362
+ )
363
+
364
+
365
+ def _nomes(estado: dict, secao: str) -> dict[str, str]:
366
+ return {x["uid"]: x["nome"] for x in estado[secao]}
367
+
368
+
369
+ def _visivel(lanc: dict, estado: dict, nomes: tuple[dict, dict] | None = None) -> tuple:
370
+ """O que a pessoa vê do lançamento, com nomes no lugar de uids."""
371
+ contas, categorias = nomes or (_nomes(estado, "contas"), _nomes(estado, "categorias"))
372
+ p = lanc["parcela"] or {}
373
+ return (
374
+ lanc["data"],
375
+ lanc["descricao"],
376
+ lanc["valor"],
377
+ lanc["tipo"],
378
+ chave_do_nome(contas[lanc["conta"]]),
379
+ chave_do_nome(categorias[lanc["categoria"]]) if lanc["categoria"] else None,
380
+ p.get("numero"),
381
+ p.get("total"),
382
+ )
383
+
384
+
385
+ @dataclass(frozen=True, slots=True)
386
+ class Comparacao:
387
+ """Diferenças entre os dados daqui e os de um arquivo de outro aparelho."""
388
+
389
+ codigo_aqui: str
390
+ codigo_la: str
391
+ so_aqui: list[dict] = field(default_factory=list)
392
+ so_la: list[dict] = field(default_factory=list)
393
+ diferentes: list[tuple[dict, dict]] = field(default_factory=list) # (aqui, lá)
394
+
395
+ @property
396
+ def iguais(self) -> bool:
397
+ return self.codigo_aqui == self.codigo_la
398
+
399
+
400
+ def comparar(aqui: dict, la: dict) -> Comparacao:
401
+ """Lista os lançamentos que só existem de um lado ou que diferem.
402
+
403
+ Mês gerado por série e nunca mexido não conta como diferença, a não ser que o outro
404
+ lado o tenha excluído.
405
+ """
406
+ nomes_aqui = (_nomes(aqui, "contas"), _nomes(aqui, "categorias"))
407
+ nomes_la = (_nomes(la, "contas"), _nomes(la, "categorias"))
408
+ daqui = {x["uid"]: x for x in aqui["lancamentos"]}
409
+ de_la = {x["uid"]: x for x in la["lancamentos"]}
410
+ lapides_aqui = {x["uid"] for x in aqui["lapides"]}
411
+ lapides_la = {x["uid"] for x in la["lapides"]}
412
+ so_aqui, so_la, diferentes = [], [], []
413
+ for uid in sorted(daqui.keys() | de_la.keys()):
414
+ meu, dele = daqui.get(uid), de_la.get(uid)
415
+ if meu and dele:
416
+ if (not meu["gerado"] or not dele["gerado"]) and _visivel(
417
+ meu, aqui, nomes_aqui
418
+ ) != _visivel(dele, la, nomes_la):
419
+ diferentes.append((meu, dele))
420
+ elif meu and (not meu["gerado"] or uid in lapides_la):
421
+ so_aqui.append(meu)
422
+ elif dele and (not dele["gerado"] or uid in lapides_aqui):
423
+ so_la.append(dele)
424
+ return Comparacao(codigo(aqui), codigo(la), so_aqui, so_la, diferentes)
425
+
426
+
427
+ def descrever(lanc: dict, estado: dict) -> str:
428
+ """Uma linha para mostrar o lançamento numa lista de diferenças."""
429
+ contas, categorias = _nomes(estado, "contas"), _nomes(estado, "categorias")
430
+ dia = dt.date.fromisoformat(lanc["data"]).strftime("%d/%m/%Y")
431
+ sinal = "+" if lanc["tipo"] == "receita" else "-"
432
+ partes = [dia, f"{sinal}{dinheiro.formatar(lanc['valor'])}"]
433
+ if lanc["descricao"]:
434
+ partes.append(lanc["descricao"])
435
+ if lanc["parcela"]:
436
+ partes.append(f"{lanc['parcela']['numero']}/{lanc['parcela']['total']}")
437
+ if lanc["categoria"]:
438
+ partes.append(f"#{categorias[lanc['categoria']]}")
439
+ partes.append(f"@{contas[lanc['conta']]}")
440
+ return " ".join(partes)