responds-forms 0.4__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.
cartas/__init__.py ADDED
@@ -0,0 +1,2060 @@
1
+ # arquivo cartas/__init__.py
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ import os
5
+ import re
6
+ from enum import StrEnum
7
+ from typing import Any, Iterable, Mapping
8
+ from abc import ABC, abstractmethod
9
+ from os import rename as rename_file
10
+ from os import replace as replace_file
11
+ from datetime import datetime
12
+
13
+ import pandas as pd
14
+
15
+ from table_stream import (
16
+ # Array
17
+ HashMapDict,
18
+ HashMap,
19
+ ArrayList,
20
+ Set,
21
+ # Tabelas
22
+ TableData,
23
+ ArrayTables,
24
+ ColumnV,
25
+ RowH,
26
+ HeadersSizeNotIqualError,
27
+ # Arquivos.
28
+ HeaderMetaData,
29
+ hashFile,
30
+ File,
31
+ Directory,
32
+ ContentFiles,
33
+ load_file_hash,
34
+ )
35
+
36
+ from ocr_stream import HeaderOcr, BuilderConfigOcr, CreateRelatorioOcr
37
+
38
+ from whatsapp_files import (
39
+ RowWhatsApp,
40
+ TableWhatsApp,
41
+ HeaderWhatsApp,
42
+ BuilderTableWhatsApp,
43
+ ControllerWhatsApp,
44
+ move_path,
45
+ )
46
+
47
+ __version__ = "0.2"
48
+
49
+
50
+ @dataclass
51
+ class ConfigTOI:
52
+
53
+ file_relatorio_toi: File
54
+ file_relatorio_forms: File
55
+ file_table_toi: File
56
+ file_master_forms: File # NOVO
57
+ dir_base_toi: Directory
58
+ dir_log_excel: Directory
59
+ dir_relatorios: Directory
60
+ work_dir: Directory
61
+
62
+
63
+ class BuilderConfigTOI(object):
64
+
65
+ _instance = None # Atributo de classe para armazenar a instância singleton
66
+
67
+ def __new__(cls, *args, **kwargs):
68
+ if cls._instance is None:
69
+ cls._instance = super(BuilderConfigTOI, cls).__new__(cls)
70
+ return cls._instance
71
+
72
+ def __init__(self) -> None:
73
+
74
+ # Garante que __init__ não será executado mais de uma vez
75
+ if hasattr(self, "_initialized") and self._initialized:
76
+ return
77
+
78
+ super().__init__()
79
+ self._initialized: bool = True
80
+ self._builder_ocr = BuilderConfigOcr.get_instance()
81
+ self._config = self._create()
82
+
83
+ @property
84
+ def dir_base_work(self) -> Directory:
85
+ return self._builder_ocr.builder().dir_base_work
86
+
87
+ @property
88
+ def dir_retorno_de_campo(self) -> Directory:
89
+ return self.dir_base_work.concat("Cartas-TOI", True).concat(
90
+ "Retorno-de-campo", True
91
+ )
92
+
93
+ @property
94
+ def dir_log_excel(self) -> Directory:
95
+ return self.dir_base_work.concat("Cartas-TOI", True).concat("Logs", True)
96
+
97
+ @property
98
+ def dir_relatorios(self) -> Directory:
99
+ return self.dir_base_work.concat("Cartas-TOI", True).concat(
100
+ "Relatorios-toi", True
101
+ )
102
+
103
+ @property
104
+ def file_relatorio_toi(self) -> File:
105
+ d = self._builder_ocr.builder().dir_table
106
+ return d.join_file("Relatorio-tois.xlsx")
107
+
108
+ @property
109
+ def file_relatorio_forms(self) -> File:
110
+ d = self._builder_ocr.builder().dir_table
111
+ return d.join_file("cartas-toi-lancadas.xlsx")
112
+
113
+ @property
114
+ def file_master_forms(self) -> File:
115
+ d = self._builder_ocr.builder().dir_table
116
+ return d.join_file("master-forms-toi.xlsx")
117
+
118
+ @property
119
+ def file_table_toi(self) -> File:
120
+ d = self._builder_ocr.builder().dir_table
121
+ return d.join_file("tabela-entregas-tois.xlsx")
122
+
123
+ def _create(self) -> ConfigTOI:
124
+ return ConfigTOI(
125
+ work_dir=self.dir_base_work,
126
+ dir_base_toi=self.dir_retorno_de_campo,
127
+ file_relatorio_toi=self.file_relatorio_toi,
128
+ file_table_toi=self.file_table_toi,
129
+ file_relatorio_forms=self.file_relatorio_forms,
130
+ dir_log_excel=self.dir_log_excel,
131
+ dir_relatorios=self.dir_relatorios,
132
+ file_master_forms=self.file_master_forms,
133
+ )
134
+
135
+ def builder(self) -> ConfigTOI:
136
+ if self._config is None:
137
+ self._config = self._create()
138
+ return self._config
139
+
140
+
141
+ # ============================================================#
142
+ # Extração de UC/TOI tolerante a falhas de OCR
143
+ # ============================================================#
144
+ #
145
+ # UC: 1305934 TOI: 193166923 ...
146
+ # UC: 1305934 TO! 193166923 ... (TOI virou TO!, ":" some)
147
+ # UC| 1305934 T0I: 193166923 ... (0 no lugar do O)
148
+ #
149
+
150
+ UC_TOI_PATTERN = re.compile(
151
+ r"U[C0]\s*[:;|!]?\s*(\d{4,10})" r".{0,60}?" r"T[O0][I1L!l]?\s*[:;|!]?\s*(\d{4,10})",
152
+ re.IGNORECASE | re.DOTALL,
153
+ )
154
+
155
+
156
+ def extrair_uc_toi(texto: str) -> tuple[str, str] | None:
157
+ """
158
+ Retorna (uc, toi) a partir do texto OCR completo de um arquivo,
159
+ ou None se o padrão não for encontrado.
160
+ """
161
+ m = UC_TOI_PATTERN.search(texto)
162
+ if not m:
163
+ return None
164
+ return m.group(1), m.group(2)
165
+
166
+
167
+ # Nova API: identifica cada campo de forma independente. A função antiga acima
168
+ # permanece intacta para não quebrar consumidores existentes.
169
+ UC_PATTERN_PARTIAL = re.compile(
170
+ r"(?<![A-Z0-9])U[CGE0]\s*[:;|!,}]?\s*(\d{4,10})(?!\d)",
171
+ re.IGNORECASE,
172
+ )
173
+
174
+ TOI_PATTERN_PARTIAL = re.compile(
175
+ r"(?<![A-Z0-9])T[O0Q][I1L!|}K]?\s*[:;|!,}]?\s*(\d{4,10})(?!\d)",
176
+ re.IGNORECASE,
177
+ )
178
+
179
+
180
+ def extrair_identificadores_parciais(
181
+ texto: str | None,
182
+ ) -> tuple[str | None, str | None]:
183
+ """Retorna (UC, TOI), permitindo que qualquer um dos campos seja None.
184
+
185
+ Exemplos tolerados: 'Ue, 78534', 'UG; 1462905', 'TOI: 217678510',
186
+ 'Tol: 210768466', 'TO}: 218108980'.
187
+ """
188
+ if not texto:
189
+ return None, None
190
+
191
+ uc_match = UC_PATTERN_PARTIAL.search(texto)
192
+ toi_match = TOI_PATTERN_PARTIAL.search(texto)
193
+ uc = uc_match.group(1) if uc_match else None
194
+ toi = toi_match.group(1) if toi_match else None
195
+ return uc, toi
196
+
197
+
198
+ # ========================================================#
199
+ # Tabelas TOI.
200
+ # ========================================================#
201
+
202
+
203
+ class HeaderEntregaTOI(StrEnum):
204
+ """
205
+ Enumerador que representa a relação entre um documento e os dados
206
+ de entrega:
207
+ UC, TOI, DAY, MON, YEAR, DATE E HASH_FILE.
208
+
209
+ a relação/key de uma entrega e a hash do documento.
210
+ """
211
+
212
+ UC = "uc"
213
+ TOI = "toi"
214
+ DAY = "day"
215
+ MON = "mon"
216
+ YEAR = "year"
217
+ DATE = "data_entrega"
218
+ HASH_FILE = HeaderMetaData.HASH_FILE.value
219
+
220
+
221
+ class HeaderFilesTOI(StrEnum):
222
+ """
223
+ Dados completos de um arquivo TOI, incluindo os metadados e
224
+ caminho absoluto.
225
+ """
226
+
227
+ UC = HeaderEntregaTOI.UC
228
+ TOI = HeaderEntregaTOI.TOI
229
+ DAY = HeaderEntregaTOI.DAY
230
+ MON = HeaderEntregaTOI.MON
231
+ YEAR = HeaderEntregaTOI.YEAR
232
+ DATE = HeaderEntregaTOI.DATE
233
+ HASH_FILE = HeaderEntregaTOI.HASH_FILE
234
+
235
+ LOCAL_ENTREGA = "local_entrega"
236
+ TIPO_DOC = "tipo_carta"
237
+
238
+ IS_FILE = "arquivo?"
239
+ FULL_PATH = HeaderMetaData.FILE_PATH.value
240
+ SRC = HeaderMetaData.PARENT_DIR.value
241
+ FILE_EXTENSION = HeaderMetaData.FILE_EXTENSION.value
242
+ FILE_BASENAME = "basename"
243
+
244
+
245
+ class TipoCarta(StrEnum):
246
+
247
+ CARTA_CALCULO = "CARTA-CALCULO"
248
+
249
+
250
+ class LocalEntregaCarta(StrEnum):
251
+
252
+ OUTRO = "OUTRO"
253
+ MAOS = "MÃOS"
254
+ CORREIOS = "CORREIOS"
255
+ PORTAO = "PORTÃO"
256
+
257
+
258
+ # ========================================================#
259
+ # Criar Tabelas.
260
+ # ========================================================#
261
+
262
+
263
+ class BuilderTOI(ABC):
264
+ """
265
+ Criar dados de uma entrega TOI apartir de um arquivo ou do texto
266
+ de um documento.
267
+ """
268
+
269
+ def __init__(self) -> None:
270
+ super().__init__()
271
+
272
+ @abstractmethod
273
+ def create_void(self) -> TableData[Any]:
274
+ pass
275
+
276
+ @abstractmethod
277
+ def from_data(self, df: pd.DataFrame) -> TableData[Any]:
278
+ pass
279
+
280
+ @abstractmethod
281
+ def from_path_name(self, path: File, md5: hashFile = None) -> TableData[Any] | None:
282
+ pass
283
+
284
+ @abstractmethod
285
+ def from_hash_files(
286
+ self, data: HashMap[hashFile, File], *, ignore_null: bool = True
287
+ ) -> TableData[Any] | None:
288
+ pass
289
+
290
+
291
+ class BuilderEntregaTOI(BuilderTOI):
292
+ """
293
+ Criar dados de uma entrega TOI apartir de um arquivo ou do texto
294
+ de um documento.
295
+ """
296
+
297
+ def __init__(self) -> None:
298
+ super().__init__()
299
+ self.relatorio_ocr = CreateRelatorioOcr()
300
+ self.relatorio_wp = ControllerWhatsApp()
301
+
302
+ self.default_cols_toi = ArrayList[HeaderEntregaTOI].from_list(
303
+ [
304
+ HeaderEntregaTOI.UC,
305
+ HeaderEntregaTOI.TOI,
306
+ HeaderEntregaTOI.DATE,
307
+ HeaderEntregaTOI.YEAR,
308
+ HeaderEntregaTOI.MON,
309
+ HeaderEntregaTOI.DAY,
310
+ HeaderEntregaTOI.HASH_FILE,
311
+ ]
312
+ )
313
+
314
+ def create_void(self) -> TableData[HeaderEntregaTOI]:
315
+ """
316
+ Cria uma tabela TOI vazia com todos os itens do cabeçalho HeaderTOI
317
+ """
318
+ tb = TableData[HeaderEntregaTOI]()
319
+ self.default_cols_toi.for_each(lambda c: tb.add(ColumnV(c)))
320
+ return tb
321
+
322
+ def from_data(self, df: pd.DataFrame) -> TableData[HeaderEntregaTOI]:
323
+ """
324
+ Criar tabela TOI apartir de um DataFrame().
325
+ """
326
+ df = df.astype(str)
327
+ df_cols = ArrayList(df.columns.tolist())
328
+ tb = TableData[HeaderEntregaTOI]()
329
+
330
+ c: HeaderEntregaTOI
331
+ for c in self.default_cols_toi:
332
+ if df_cols.contains(c.value):
333
+ tb.add(
334
+ ColumnV.create_from_list(c, df[c.value].astype(str).values.tolist())
335
+ )
336
+
337
+ if tb.header().size() != self.default_cols_toi.size():
338
+ raise HeadersSizeNotIqualError(
339
+ f"DataFrame() de entrega toi com colunas diferente do padrão: \n{tb.header()}"
340
+ )
341
+ return tb
342
+
343
+ def from_path_name(
344
+ self, path: File, md5: hashFile = None
345
+ ) -> TableData[HeaderEntregaTOI] | None:
346
+ """
347
+ Criar tabela apartir do nome de uma arquivo que esteja no padrão TOI.
348
+ """
349
+
350
+ if not path.exists():
351
+ return None
352
+
353
+ mp = self.create_map_from_filename(path.name())
354
+ if mp is None:
355
+ return None
356
+ if md5 is None:
357
+ md5 = load_file_hash(path)
358
+
359
+ mp.set_value(HeaderEntregaTOI.HASH_FILE, md5)
360
+ tb = TableData[HeaderEntregaTOI]()
361
+ c: HeaderEntregaTOI
362
+
363
+ for c in self.default_cols_toi:
364
+ tb.add(ColumnV.create_from_list(c, [mp.get_value(c)]))
365
+ return tb
366
+
367
+ def create_map_from_filename(
368
+ self,
369
+ filename: str,
370
+ ) -> HashMapDict[HeaderEntregaTOI, str] | None:
371
+ """
372
+ Retorna um Map de chave/valor com as seguintes chaves:
373
+
374
+ UC, TOI, DAY, MON, YEAR e DATE.
375
+ """
376
+
377
+ try:
378
+ values = filename.split(" ")
379
+ if len(values) < 5:
380
+ # Nome incompleto ou inválido.
381
+ return None
382
+ except:
383
+ return None
384
+ else:
385
+ for x in values:
386
+ if not x.isnumeric():
387
+ return None
388
+
389
+ data = HashMapDict()
390
+ data[HeaderEntregaTOI.UC] = values[0] # UC
391
+ data[HeaderEntregaTOI.TOI] = values[1] # TOI
392
+ data[HeaderEntregaTOI.DAY] = values[2] # DIA
393
+ data[HeaderEntregaTOI.MON] = values[3] # MÊS
394
+ data[HeaderEntregaTOI.YEAR] = values[4] # ANO
395
+ data[HeaderEntregaTOI.DATE] = f"{values[2]}/{values[3]}/{values[4]}"
396
+ return data
397
+
398
+ def from_hash_files(
399
+ self, data: HashMap[hashFile, File], *, ignore_null: bool = True
400
+ ) -> TableData[HeaderEntregaTOI] | None:
401
+
402
+ if data.is_empty():
403
+ return None
404
+
405
+ header_hashs = data.header().to_set()
406
+ table_ocr: TableData[HeaderOcr] | None = self.relatorio_ocr.create_from_hashs(
407
+ header_hashs
408
+ )
409
+
410
+ if table_ocr is None:
411
+ return None
412
+ if table_ocr.is_empty():
413
+ return None
414
+
415
+ # builder_wp = BuilderTableWhatsApp()
416
+
417
+ table_whatsapp: TableData[HeaderWhatsApp] = self.relatorio_wp.load_cache_table()
418
+ if table_whatsapp.is_empty():
419
+ if ignore_null:
420
+ return None
421
+
422
+ key_hash: hashFile
423
+ sequence_tables = ArrayTables[TableData[HeaderEntregaTOI]]()
424
+
425
+ for key_hash in header_hashs:
426
+ date_chat_file: str = TableData.NULL_STRING
427
+ day = TableData.NULL_STRING
428
+ mon = TableData.NULL_STRING
429
+ year = TableData.NULL_STRING
430
+ uc = TableData.NULL_STRING
431
+ toi = TableData.NULL_STRING
432
+
433
+ # =========================================================#
434
+ # Data de envio do arquivo no chat
435
+ # =========================================================#
436
+ if not table_whatsapp.is_empty():
437
+ tb_chat = table_whatsapp.search(HeaderWhatsApp.hash_md5, key_hash)
438
+
439
+ if tb_chat.is_empty() and ignore_null:
440
+ continue
441
+
442
+ if not tb_chat.is_empty():
443
+ # date_chat_file: datetime = tb_chat.get_row_at(0).get_date_hour()
444
+ # day = str(date_chat_file.day)
445
+ # mon = str(date_chat_file.month)
446
+ # year = str(date_chat_file.year)
447
+ date_chat_file: str = tb_chat.get_row_at(0).get_date().get_first()
448
+ day = date_chat_file.split(" ")[0]
449
+ mon = date_chat_file.split(" ")[1]
450
+ year = date_chat_file.split(" ")[2]
451
+
452
+ # =========================================================#
453
+ # Extrair UC e TOI
454
+ # =========================================================#
455
+ tb_ocr = table_ocr.search(HeaderOcr.HASH_FILE, key_hash)
456
+ if tb_ocr.is_empty() and ignore_null:
457
+ continue
458
+
459
+ if not tb_ocr.is_empty():
460
+ txt_ocr: str = tb_ocr.to_string(HeaderOcr.TEXT)
461
+ value_uc_toi: tuple[str, str] | None = extrair_uc_toi(txt_ocr)
462
+
463
+ if value_uc_toi is None and ignore_null:
464
+ continue
465
+
466
+ if value_uc_toi is not None:
467
+ uc = value_uc_toi[0]
468
+ toi = value_uc_toi[1]
469
+
470
+ row_toi = RowH[HeaderEntregaTOI, Any](0)
471
+ row_toi.set(HeaderEntregaTOI.UC, uc)
472
+ row_toi.set(HeaderEntregaTOI.TOI, toi)
473
+ row_toi.set(HeaderEntregaTOI.DAY, day)
474
+ row_toi.set(HeaderEntregaTOI.MON, mon)
475
+ row_toi.set(HeaderEntregaTOI.YEAR, year)
476
+ row_toi.set(HeaderEntregaTOI.HASH_FILE, key_hash)
477
+ row_toi.set(HeaderEntregaTOI.DATE, date_chat_file)
478
+
479
+ t = self.create_void()
480
+ t.add_row(row_toi)
481
+ sequence_tables.add(t)
482
+
483
+ if sequence_tables.empty():
484
+ return None
485
+
486
+ return sequence_tables.concat(TableData).drop_duplicates()
487
+
488
+
489
+ class BuilderTableFilesTOI(BuilderTOI):
490
+
491
+ def __init__(self) -> None:
492
+ super().__init__()
493
+
494
+ self.default_cols_cartas = ArrayList[HeaderFilesTOI].from_list(
495
+ [
496
+ HeaderFilesTOI.UC,
497
+ HeaderFilesTOI.TOI,
498
+ HeaderFilesTOI.DATE,
499
+ HeaderFilesTOI.YEAR,
500
+ HeaderFilesTOI.MON,
501
+ HeaderFilesTOI.DAY,
502
+ HeaderFilesTOI.LOCAL_ENTREGA,
503
+ HeaderFilesTOI.TIPO_DOC,
504
+ HeaderFilesTOI.HASH_FILE,
505
+ HeaderFilesTOI.IS_FILE,
506
+ HeaderFilesTOI.FILE_BASENAME,
507
+ HeaderFilesTOI.FILE_EXTENSION,
508
+ HeaderFilesTOI.FULL_PATH,
509
+ HeaderFilesTOI.SRC,
510
+ ]
511
+ )
512
+
513
+ self.factory_toi: BuilderEntregaTOI = BuilderEntregaTOI()
514
+
515
+ def get_factory_toi(self) -> BuilderEntregaTOI:
516
+ return self.factory_toi
517
+
518
+ def create_void(self) -> TableData[HeaderFilesTOI]:
519
+ """
520
+ Cria uma tabela com os cabeçalhos de HeaderFilesTOI.
521
+ """
522
+ tb = TableData()
523
+ self.default_cols_cartas.for_each(lambda c: tb.add(ColumnV(c)))
524
+ return tb
525
+
526
+ def from_data(self, df: pd.DataFrame) -> TableData[HeaderFilesTOI]:
527
+ df = df.astype(str)
528
+ df_cols = ArrayList(df.columns.tolist())
529
+ final_table = TableData()
530
+
531
+ c: HeaderFilesTOI
532
+ for c in self.default_cols_cartas:
533
+
534
+ if df_cols.contains(c.value):
535
+ final_table.add(
536
+ ColumnV.create_from_list(c, df[c.value].astype(str).values.tolist())
537
+ )
538
+
539
+ if final_table.header().size() != self.default_cols_cartas.size():
540
+ msg = "DataFrame() FORMS toi com colunas diferente do padrão:"
541
+ msg_header = f"Cabeçalho FORMS {final_table.header()}"
542
+ raise HeadersSizeNotIqualError(
543
+ f"{msg} \n\n{msg_header}\nCabeçalho externo {df.columns}"
544
+ )
545
+ return final_table
546
+
547
+ def decode_table_toi(
548
+ self, tb: TableData[HeaderEntregaTOI], data: HashMap[hashFile, File]
549
+ ) -> TableData[HeaderFilesTOI] | None:
550
+
551
+ if data.is_empty():
552
+ return None
553
+ if tb.is_empty():
554
+ return None
555
+
556
+ _df_toi = tb.to_dataframe().drop_duplicates(
557
+ subset=[HeaderEntregaTOI.HASH_FILE.value]
558
+ )
559
+
560
+ tb = self.get_factory_toi().from_data(_df_toi)
561
+ final_tb = TableData[HeaderFilesTOI].create_void(self.default_cols_cartas)
562
+
563
+ rows = tb.rows()
564
+ r: RowH[HeaderEntregaTOI, Any]
565
+ row_tb: RowH[HeaderFilesTOI, Any]
566
+
567
+ for r in rows:
568
+ row_tb = RowH(0)
569
+
570
+ row_tb.set(HeaderFilesTOI.UC, r.get(HeaderEntregaTOI.UC))
571
+ row_tb.set(HeaderFilesTOI.TOI, r.get(HeaderEntregaTOI.TOI))
572
+ row_tb.set(HeaderFilesTOI.DATE, r.get(HeaderEntregaTOI.DATE))
573
+ row_tb.set(HeaderFilesTOI.DAY, r.get(HeaderEntregaTOI.DAY))
574
+ row_tb.set(HeaderFilesTOI.MON, r.get(HeaderEntregaTOI.MON))
575
+ row_tb.set(HeaderFilesTOI.YEAR, r.get(HeaderEntregaTOI.YEAR))
576
+ row_tb.set(HeaderFilesTOI.HASH_FILE, r.get(HeaderEntregaTOI.HASH_FILE))
577
+
578
+ row_tb.set(HeaderFilesTOI.LOCAL_ENTREGA, LocalEntregaCarta.OUTRO)
579
+ row_tb.set(HeaderFilesTOI.TIPO_DOC, TipoCarta.CARTA_CALCULO)
580
+
581
+ # Incluir colunas adicionais.
582
+ _hash_file = r.get(HeaderEntregaTOI.HASH_FILE)
583
+ path = data.get_value(_hash_file)
584
+
585
+ row_tb.set(HeaderFilesTOI.FILE_BASENAME, path.basename())
586
+ row_tb.set(
587
+ HeaderFilesTOI.FILE_EXTENSION,
588
+ path.extension() or ".nan",
589
+ )
590
+ row_tb.set(HeaderFilesTOI.FULL_PATH, path.absolute())
591
+
592
+ row_tb.set(HeaderFilesTOI.SRC, path.parent().absolute())
593
+ row_tb.set(HeaderFilesTOI.IS_FILE, "S" if path.is_file() else "N")
594
+
595
+ final_tb.add_row(row_tb)
596
+
597
+ if final_tb.is_empty():
598
+ return None
599
+
600
+ _final_df = final_tb.to_dataframe().drop_duplicates(
601
+ subset=[HeaderFilesTOI.HASH_FILE.value]
602
+ )
603
+ return self.from_data(_final_df)
604
+
605
+ def from_path_name(
606
+ self, path: File, md5: hashFile = None
607
+ ) -> TableData[HeaderFilesTOI] | None:
608
+
609
+ if md5 is None:
610
+ md5 = load_file_hash(path)
611
+
612
+ tb_toi = self.get_factory_toi().from_path_name(path, md5)
613
+ if tb_toi is None:
614
+ return None
615
+
616
+ return self.decode_table_toi(tb_toi, HashMapDict({md5: path}))
617
+
618
+ def from_hash_files(
619
+ self, data: HashMap[hashFile, File], *, ignore_null: bool = True
620
+ ) -> TableData[HeaderFilesTOI] | None:
621
+ tb_toi = self.get_factory_toi().from_hash_files(data, ignore_null=ignore_null)
622
+ if tb_toi is None:
623
+ return None
624
+
625
+ return self.decode_table_toi(tb_toi, data)
626
+
627
+
628
+ @dataclass
629
+ class IdNames:
630
+ """
631
+ Identificar os índices de cada elemento do nome de uma carta.
632
+
633
+ 2026 3 03 1488594 193258476 e 1488594 193258476 03 03 2026 são
634
+
635
+ a mesma coisa porém nomeado com datas diferentes.
636
+ """
637
+
638
+ year: int = 0
639
+ mon: int = 1
640
+ day: int = 2
641
+ uc: int = 3
642
+ toi: int = 4
643
+
644
+
645
+ def create_table_from_ids(
646
+ filename: str, md5: hashFile, *, ids: IdNames = IdNames()
647
+ ) -> TableData[HeaderEntregaTOI]:
648
+
649
+ # Separar os nomes em uma lista
650
+ list_name: list[str] = filename.split(" ")
651
+
652
+ # Gerar a data no formato dia mês ano.
653
+ dt = f"{list_name[ids.day]} {list_name[ids.mon]} {list_name[ids.year]}"
654
+
655
+ row = RowH[HeaderEntregaTOI, str](0)
656
+ row.set(HeaderEntregaTOI.HASH_FILE, md5)
657
+ row.set(HeaderEntregaTOI.UC, list_name[ids.uc])
658
+ row.set(HeaderEntregaTOI.TOI, list_name[ids.toi])
659
+ row.set(HeaderEntregaTOI.YEAR, list_name[ids.year])
660
+ row.set(HeaderEntregaTOI.MON, list_name[ids.mon])
661
+ row.set(HeaderEntregaTOI.DAY, list_name[ids.day])
662
+ row.set(HeaderEntregaTOI.DATE, dt)
663
+ tb = BuilderEntregaTOI().create_void()
664
+ tb.add_row(row)
665
+ return tb
666
+
667
+
668
+ # ============================================================#
669
+ # IDENTIFICAÇÃO v2: UC/TOI parcial, OCR tolerante, nome manual
670
+ # ============================================================#
671
+ #
672
+ # Tudo desta seção é NOVO e independente do fluxo antigo
673
+ # (extrair_uc_toi, add_hashs_on_table, ...). A UI pode migrar aos poucos.
674
+ #
675
+ # Ideia central: cada arquivo (HASH) gera um ResultadoUcToi que pode estar:
676
+ # COMPLETO (uc+toi) | SO_UC | SO_TOI | NAO_IDENTIFICADO
677
+ #
678
+ # Fontes, em ordem de prioridade:
679
+ # 1) NOME do arquivo (identificação manual feita por uma pessoa);
680
+ # 2) CACHE (tabela-entregas-tois.xlsx, já processado antes);
681
+ # 3) OCR (extração tolerante, também aceita só UC ou só TOI).
682
+ # A data de entrega vem do nome, do cache ou da data de envio no WhatsApp.
683
+ #
684
+ # Regra de ouro do cache: um campo preenchido nunca é apagado por um
685
+ # resultado pior (merge_smart). Só o valor "manual" pode substituir outro.
686
+ #
687
+ # Formatos observados no OCR:
688
+ # UC: 7 dígitos (ex.: 1305934) TOI: 9 dígitos (ex.: 221673216)
689
+ # Aceitamos UC 6-8 e TOI 8-10 dígitos no OCR; números fora disso são lixo
690
+ # (ex.: "78534" ou "2936" são UCs truncadas pelo OCR e são descartadas).
691
+
692
+ _K_UC = HeaderEntregaTOI.UC.value
693
+ _K_TOI = HeaderEntregaTOI.TOI.value
694
+ _K_DAY = HeaderEntregaTOI.DAY.value
695
+ _K_MON = HeaderEntregaTOI.MON.value
696
+ _K_YEAR = HeaderEntregaTOI.YEAR.value
697
+ _K_DATE = HeaderEntregaTOI.DATE.value
698
+ _K_HASH = HeaderEntregaTOI.HASH_FILE.value
699
+
700
+ # Mesma ordem de BuilderEntregaTOI.default_cols_toi (sem instanciar o builder).
701
+ _COLS_ENTREGA: tuple[HeaderEntregaTOI, ...] = (
702
+ HeaderEntregaTOI.UC,
703
+ HeaderEntregaTOI.TOI,
704
+ HeaderEntregaTOI.DATE,
705
+ HeaderEntregaTOI.YEAR,
706
+ HeaderEntregaTOI.MON,
707
+ HeaderEntregaTOI.DAY,
708
+ HeaderEntregaTOI.HASH_FILE,
709
+ )
710
+
711
+ # Grupos atualizados "em bloco" no merge.
712
+ _GRUPOS_MERGE: tuple[tuple[str, ...], ...] = (
713
+ (_K_UC,),
714
+ (_K_TOI,),
715
+ (_K_DAY, _K_MON, _K_YEAR, _K_DATE),
716
+ )
717
+
718
+ _NULOS = {"", "nan", "none", "nat", "null", TableData.NULL_STRING.lower()}
719
+
720
+
721
+ def valor_nulo(v: Any) -> bool:
722
+ """True para None, NaN, '', 'nan', 'NULL_TEXT' etc."""
723
+ if v is None:
724
+ return True
725
+ try:
726
+ if pd.isna(v):
727
+ return True
728
+ except (TypeError, ValueError):
729
+ pass
730
+ return str(v).strip().lower() in _NULOS
731
+
732
+
733
+ def limpar_numero(v: Any) -> str:
734
+ """'1305934.0' -> '1305934'; nulo -> ''."""
735
+ if valor_nulo(v):
736
+ return ""
737
+ s = str(v).strip()
738
+ return s[:-2] if s.endswith(".0") else s
739
+
740
+
741
+ def uc_valida(v: str | None) -> bool:
742
+ return bool(v) and re.fullmatch(r"[0-9]{6,8}", v) is not None
743
+
744
+
745
+ def toi_valido(v: str | None) -> bool:
746
+ return bool(v) and re.fullmatch(r"[0-9]{8,10}", v) is not None
747
+
748
+
749
+ class StatusUcToi(StrEnum):
750
+ COMPLETO = "COMPLETO"
751
+ SO_UC = "SO_UC"
752
+ SO_TOI = "SO_TOI"
753
+ NAO_IDENTIFICADO = "NAO_IDENTIFICADO"
754
+
755
+
756
+ class OrigemUcToi(StrEnum):
757
+ NOME_ARQUIVO = "nome_arquivo"
758
+ CACHE = "cache"
759
+ OCR = "ocr"
760
+ MANUAL = "manual"
761
+ NENHUMA = "nenhuma"
762
+
763
+
764
+ @dataclass
765
+ class ResultadoUcToi:
766
+ """
767
+ Resultado da identificação de UMA carta. Campo ausente = None
768
+ (nunca 'NULL_TEXT'; essa conversão acontece só ao gravar a tabela).
769
+
770
+ A data é guardada como day/mon/year (strings com zero à esquerda,
771
+ ex.: '03', '03', '2026'), igual ao padrão do fluxo antigo.
772
+ """
773
+
774
+ uc: str | None = None
775
+ toi: str | None = None
776
+ day: str | None = None
777
+ mon: str | None = None
778
+ year: str | None = None
779
+ origem: OrigemUcToi = OrigemUcToi.NENHUMA
780
+
781
+ @property
782
+ def data(self) -> str | None:
783
+ """Data no formato DateValues.DATE_TOI ('%d %m %Y') ou None."""
784
+ if self.day and self.mon and self.year:
785
+ return f"{self.day} {self.mon} {self.year}"
786
+ return None
787
+
788
+ @property
789
+ def completo(self) -> bool:
790
+ return bool(self.uc and self.toi)
791
+
792
+ @property
793
+ def tem_dados(self) -> bool:
794
+ return bool(self.uc or self.toi)
795
+
796
+ @property
797
+ def status(self) -> StatusUcToi:
798
+ if self.uc and self.toi:
799
+ return StatusUcToi.COMPLETO
800
+ if self.uc:
801
+ return StatusUcToi.SO_UC
802
+ if self.toi:
803
+ return StatusUcToi.SO_TOI
804
+ return StatusUcToi.NAO_IDENTIFICADO
805
+
806
+ def completar_com(self, outro: ResultadoUcToi | None) -> ResultadoUcToi:
807
+ """Preenche SOMENTE o que está faltando aqui, usando 'outro'."""
808
+ if outro is None:
809
+ return self
810
+
811
+ contribuiu = False
812
+ if not self.uc and outro.uc:
813
+ self.uc = outro.uc
814
+ contribuiu = True
815
+ if not self.toi and outro.toi:
816
+ self.toi = outro.toi
817
+ contribuiu = True
818
+ if self.data is None and outro.data is not None:
819
+ self.day, self.mon, self.year = outro.day, outro.mon, outro.year
820
+ contribuiu = True
821
+
822
+ if contribuiu and self.origem == OrigemUcToi.NENHUMA:
823
+ self.origem = outro.origem
824
+ return self
825
+
826
+ def como_registro(self, hash_file: str) -> dict[str, str]:
827
+ """Linha no formato da tabela TOI (HeaderEntregaTOI), nulos = NULL_TEXT."""
828
+ nulo = TableData.NULL_STRING
829
+ return {
830
+ _K_UC: self.uc or nulo,
831
+ _K_TOI: self.toi or nulo,
832
+ _K_DATE: self.data or nulo,
833
+ _K_YEAR: self.year or nulo,
834
+ _K_MON: self.mon or nulo,
835
+ _K_DAY: self.day or nulo,
836
+ _K_HASH: str(hash_file),
837
+ }
838
+
839
+ @classmethod
840
+ def from_record(
841
+ cls, rec: Mapping[Any, Any], origem: OrigemUcToi = OrigemUcToi.CACHE
842
+ ) -> ResultadoUcToi:
843
+ def g(k: str) -> str | None:
844
+ v = limpar_numero(rec.get(k))
845
+ return v or None
846
+
847
+ return cls(
848
+ uc=g(_K_UC),
849
+ toi=g(_K_TOI),
850
+ day=g(_K_DAY),
851
+ mon=g(_K_MON),
852
+ year=g(_K_YEAR),
853
+ origem=origem,
854
+ )
855
+
856
+
857
+ # ------------------------------------------------------------#
858
+ # Extração tolerante no texto OCR (aceita só UC ou só TOI)
859
+ # ------------------------------------------------------------#
860
+ # UC : "UC", "Uc", "UG", "U0" + separador opcional + 6 a 8 dígitos
861
+ # TOI: "TOI", "TO!", "T0I", "TOK", "Tol", "To}" ... + separador + 8 a 10 dígitos
862
+ # O prefixo não pode estar colado em outra letra/número (evita "...AUC123").
863
+
864
+ _UC_OCR_PATTERN = re.compile(
865
+ r"(?<![A-Za-z0-9])U[CG0O]\s*[:;|!.,]?\s*(?<![0-9])([0-9]{6,8})(?![0-9])",
866
+ re.IGNORECASE,
867
+ )
868
+
869
+ _TOI_OCR_PATTERN = re.compile(
870
+ r"(?<![A-Za-z0-9])T[O0][IK1Ll!|}\]]?\s*[:;|!.,]?\s*(?<![0-9])([0-9]{8,10})(?![0-9])",
871
+ re.IGNORECASE,
872
+ )
873
+
874
+
875
+ def extrair_uc(texto: str) -> str | None:
876
+ """Só a UC (tolerante a OCR). None se não encontrar uma UC plausível."""
877
+ if not texto:
878
+ return None
879
+ m = _UC_OCR_PATTERN.search(texto)
880
+ return m.group(1) if m else None
881
+
882
+
883
+ def extrair_toi(texto: str) -> str | None:
884
+ """Só o TOI (tolerante a OCR). None se não encontrar um TOI plausível."""
885
+ if not texto:
886
+ return None
887
+ m = _TOI_OCR_PATTERN.search(texto)
888
+ return m.group(1) if m else None
889
+
890
+
891
+ def extrair_uc_toi_tolerante(texto: str) -> ResultadoUcToi:
892
+ """
893
+ Versão tolerante de extrair_uc_toi(): devolve o que for possível.
894
+
895
+ 'UC: 1462905 TOI: 210768466' -> completo
896
+ 'pr 2936 TOI: 217678510' -> só TOI (a UC truncada é descartada)
897
+ 'Uc: 2489141 To}: 21.' -> só UC
898
+
899
+ Primeiro usa extrair_uc_toi() (que já funciona bem) e valida o
900
+ tamanho dos números; o que faltar é procurado de forma independente.
901
+ """
902
+ res = ResultadoUcToi(origem=OrigemUcToi.OCR)
903
+ if not texto:
904
+ return res
905
+
906
+ antigo = extrair_uc_toi(texto)
907
+ if antigo is not None:
908
+ if uc_valida(antigo[0]):
909
+ res.uc = antigo[0]
910
+ if toi_valido(antigo[1]):
911
+ res.toi = antigo[1]
912
+
913
+ if res.uc is None:
914
+ res.uc = extrair_uc(texto)
915
+ if res.toi is None:
916
+ res.toi = extrair_toi(texto)
917
+ return res
918
+
919
+
920
+ # ------------------------------------------------------------#
921
+ # Identificação MANUAL pelo nome do arquivo
922
+ # ------------------------------------------------------------#
923
+ # Formatos aceitos (qualquer extensão pdf/imagem):
924
+ # '2026 3 03 1488594 193258476' ano mês dia uc toi
925
+ # '1488594 193258476 03 03 2026' uc toi dia mês ano
926
+ # 'UC 2489141' só UC
927
+ # 'TOI 217678510' só TOI
928
+ # 'UC 2489141 TOI 217678510' os dois (sem data; a data vem do chat)
929
+ # Sufixos de colisão '-1', '-2' são ignorados.
930
+
931
+ _SEP_NOME = re.compile(r"[\s_]+")
932
+ _EXT_NOME = re.compile(r"\.(pdf|png|jpe?g|svg)$", re.IGNORECASE)
933
+ _COLISAO_NOME = re.compile(r"-\d{1,2}$")
934
+
935
+ _UC_NOME_PATTERN = re.compile(
936
+ r"(?<![A-Za-z0-9])UC[\s:._-]*([0-9]{4,10})(?![0-9])", re.IGNORECASE
937
+ )
938
+ _TOI_NOME_PATTERN = re.compile(
939
+ r"(?<![A-Za-z0-9])TOI[\s:._-]*([0-9]{4,12})(?![0-9])", re.IGNORECASE
940
+ )
941
+
942
+ # (year, mon, day, uc, toi) -> índices nos 5 números do nome
943
+ _LAYOUTS_NOME: tuple[tuple[int, int, int, int, int], ...] = (
944
+ (0, 1, 2, 3, 4), # ano mês dia uc toi
945
+ (4, 3, 2, 0, 1), # uc toi dia mês ano
946
+ )
947
+
948
+
949
+ def normalizar_data(year: Any, mon: Any, day: Any) -> tuple[str, str, str] | None:
950
+ """Valida e devolve (day, mon, year) com zero à esquerda, ou None."""
951
+ try:
952
+ y, m, d = int(str(year).strip()), int(str(mon).strip()), int(str(day).strip())
953
+ datetime(y, m, d)
954
+ except (ValueError, TypeError, OverflowError):
955
+ return None
956
+ if not (2000 <= y <= 2100):
957
+ return None
958
+ return f"{d:02d}", f"{m:02d}", str(y)
959
+
960
+
961
+ def parse_data_entrega(valor: Any) -> tuple[str, str, str] | None:
962
+ """Converte 'dd mm yyyy', 'dd/mm/yyyy', 'yyyy-mm-dd'... em (day, mon, year)."""
963
+ if valor_nulo(valor):
964
+ return None
965
+ s = str(valor).strip()
966
+ for fmt in ("%d %m %Y", "%d/%m/%Y", "%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%d-%m-%Y"):
967
+ try:
968
+ dt = datetime.strptime(s, fmt)
969
+ except ValueError:
970
+ continue
971
+ return normalizar_data(dt.year, dt.month, dt.day)
972
+ return None
973
+
974
+
975
+ def identificar_por_nome(filename: str) -> ResultadoUcToi | None:
976
+ """
977
+ Identifica UC/TOI/data pelo NOME do arquivo (trabalho manual de uma
978
+ pessoa). Retorna None se o nome não segue nenhum formato conhecido.
979
+ """
980
+ if not filename:
981
+ return None
982
+
983
+ nome = os.path.basename(str(filename)).strip()
984
+ nome = _EXT_NOME.sub("", nome)
985
+ nome = _COLISAO_NOME.sub("", nome).strip()
986
+
987
+ # 1) Cinco números: data + UC + TOI (nos dois formatos).
988
+ tokens = [t for t in _SEP_NOME.split(nome) if t]
989
+ if len(tokens) == 5 and all(t.isascii() and t.isdigit() for t in tokens):
990
+ for iy, im, idy, iuc, itoi in _LAYOUTS_NOME:
991
+ data = normalizar_data(tokens[iy], tokens[im], tokens[idy])
992
+ if data is None:
993
+ continue
994
+ uc, toi = tokens[iuc], tokens[itoi]
995
+ if not (4 <= len(uc) <= 10 and 4 <= len(toi) <= 12):
996
+ continue
997
+ return ResultadoUcToi(
998
+ uc=uc,
999
+ toi=toi,
1000
+ day=data[0],
1001
+ mon=data[1],
1002
+ year=data[2],
1003
+ origem=OrigemUcToi.NOME_ARQUIVO,
1004
+ )
1005
+
1006
+ # 2) Rótulos explícitos: 'UC 123...' e/ou 'TOI 123...'.
1007
+ m_uc = _UC_NOME_PATTERN.search(nome)
1008
+ m_toi = _TOI_NOME_PATTERN.search(nome)
1009
+ if m_uc or m_toi:
1010
+ return ResultadoUcToi(
1011
+ uc=m_uc.group(1) if m_uc else None,
1012
+ toi=m_toi.group(1) if m_toi else None,
1013
+ origem=OrigemUcToi.NOME_ARQUIVO,
1014
+ )
1015
+ return None
1016
+
1017
+
1018
+ # ------------------------------------------------------------#
1019
+ # Resultados <-> tabelas
1020
+ # ------------------------------------------------------------#
1021
+
1022
+
1023
+ def _df_para_tabela_entrega(df: pd.DataFrame) -> TableData[HeaderEntregaTOI]:
1024
+ """DataFrame (colunas str ou enum) -> TableData com cabeçalho padrão."""
1025
+ df = df[[c.value for c in _COLS_ENTREGA]].astype(str).reset_index(drop=True)
1026
+ df.columns = pd.Index(list(_COLS_ENTREGA), dtype=object)
1027
+ return TableData[HeaderEntregaTOI](df)
1028
+
1029
+
1030
+ def tabela_entrega_de_resultados(
1031
+ resultados: Mapping[str, ResultadoUcToi], *, somente_com_dados: bool = True
1032
+ ) -> TableData[HeaderEntregaTOI] | None:
1033
+ """
1034
+ Converte {hash: ResultadoUcToi} em TableData[HeaderEntregaTOI].
1035
+ Com somente_com_dados=True ignora quem não tem UC nem TOI.
1036
+ """
1037
+ registros = [
1038
+ r.como_registro(h)
1039
+ for h, r in resultados.items()
1040
+ if (r.tem_dados or not somente_com_dados)
1041
+ ]
1042
+ if not registros:
1043
+ return None
1044
+ return _df_para_tabela_entrega(pd.DataFrame(registros))
1045
+
1046
+
1047
+ def create_table_from_name(
1048
+ filename: str, md5: hashFile
1049
+ ) -> TableData[HeaderEntregaTOI] | None:
1050
+ """
1051
+ Versão segura de create_table_from_ids(): usa identificar_por_nome(),
1052
+ aceita os dois formatos de nome, UC/TOI parciais e nunca levanta
1053
+ IndexError. Retorna None se o nome não identifica nada.
1054
+ """
1055
+ r = identificar_por_nome(filename)
1056
+ if r is None or not r.tem_dados:
1057
+ return None
1058
+ return tabela_entrega_de_resultados({md5: r})
1059
+
1060
+
1061
+ # ========================================================#
1062
+ # Relatórios TOI.
1063
+ # ========================================================#
1064
+
1065
+
1066
+ class ReadWriteTableTOI(object):
1067
+ """
1068
+ Leitura e escrita de dados na TOI em cache. A tabela em cache contém
1069
+ o cabeçalho HeaderEntregaTOI:
1070
+ UC, TOI, DAY, MON, YEAR, DATE E HASH_FILE.
1071
+
1072
+ Campos ausentes são gravados como TableData.NULL_STRING.
1073
+ """
1074
+
1075
+ _instance = None # Atributo de classe para armazenar a instância singleton
1076
+
1077
+ def __new__(cls, *args, **kwargs):
1078
+ if cls._instance is None:
1079
+ cls._instance = super(ReadWriteTableTOI, cls).__new__(cls)
1080
+ return cls._instance
1081
+
1082
+ def __init__(self) -> None:
1083
+
1084
+ # Garante que __init__ não será executado mais de uma vez
1085
+ if hasattr(self, "_initialized") and self._initialized:
1086
+ return
1087
+ super().__init__()
1088
+
1089
+ self._initialized: bool = True
1090
+ self.builder: BuilderConfigTOI = BuilderConfigTOI()
1091
+ self._tb_toi: TableData[HeaderEntregaTOI] = None
1092
+
1093
+ @property
1094
+ def file_table_toi(self) -> File:
1095
+ return BuilderConfigTOI().builder().file_table_toi
1096
+
1097
+ def set_table_toi(self, tb: TableData[HeaderEntregaTOI]):
1098
+ self._tb_toi = tb
1099
+
1100
+ def get_table_toi(self) -> TableData[HeaderEntregaTOI]:
1101
+ if self._tb_toi is not None:
1102
+ return self._tb_toi
1103
+
1104
+ f = self.file_table_toi
1105
+ if not f.exists():
1106
+ self._tb_toi = BuilderTableFilesTOI().get_factory_toi().create_void()
1107
+ return self._tb_toi
1108
+
1109
+ print(f"Lendo tabela TOI em cache ... {f.absolute()}")
1110
+ # dtype=str preserva UC/TOI como texto (sem virar float/int).
1111
+ data = pd.read_excel(f.absolute(), dtype=str).fillna(TableData.NULL_STRING)
1112
+ data = data.drop_duplicates(subset=[HeaderFilesTOI.HASH_FILE.value])
1113
+
1114
+ # self._tb_toi = TableTOI.create_from_data(data)
1115
+ self._tb_toi = BuilderEntregaTOI().from_data(data)
1116
+ return self._tb_toi
1117
+
1118
+ # ---------------------------------------------------------- consulta
1119
+ def get_resultados(self, hashes: Iterable[str]) -> dict[str, ResultadoUcToi]:
1120
+ """{hash: ResultadoUcToi} das hashs que já estão no cache TOI."""
1121
+ wanted = {str(h) for h in hashes}
1122
+ if not wanted:
1123
+ return {}
1124
+
1125
+ df = self.get_table_toi().to_dataframe()
1126
+ if df.empty:
1127
+ return {}
1128
+
1129
+ sub = df[df[_K_HASH].astype(str).isin(wanted)]
1130
+ out: dict[str, ResultadoUcToi] = {}
1131
+ for rec in sub.to_dict("records"):
1132
+ h = str(rec.get(_K_HASH))
1133
+ if h not in out: # em duplicatas vale a primeira
1134
+ out[h] = ResultadoUcToi.from_record(rec, OrigemUcToi.CACHE)
1135
+ return out
1136
+
1137
+ # ---------------------------------------------------------- escrita
1138
+ def merge_smart(
1139
+ self, tb: TableData[HeaderEntregaTOI], *, override: bool = False
1140
+ ) -> int:
1141
+ """
1142
+ Incorpora tb ao cache SEM piorar o que já existe, por HASH:
1143
+
1144
+ * hash nova -> entra;
1145
+ * campo vazio no cache -> é preenchido (UC, TOI e data,
1146
+ a data é tratada em bloco);
1147
+ * campo já preenchido no cache -> só muda se override=True
1148
+ (use para valores manuais);
1149
+ * valor nulo em tb -> nunca apaga o cache.
1150
+
1151
+ Só grava o arquivo se algo mudou. Retorna quantas linhas mudaram.
1152
+ """
1153
+ if tb is None or tb.is_empty():
1154
+ return 0
1155
+
1156
+ cols = [c.value for c in _COLS_ENTREGA]
1157
+ nulo = TableData.NULL_STRING
1158
+
1159
+ def normalizar(rec: Mapping[Any, Any]) -> dict[str, str]:
1160
+ return {
1161
+ c: (nulo if valor_nulo(rec.get(c)) else limpar_numero(rec.get(c)))
1162
+ for c in cols
1163
+ }
1164
+
1165
+ mapa: dict[str, dict[str, str]] = {}
1166
+ ordem: list[str] = []
1167
+
1168
+ atual = self.get_table_toi().to_dataframe()
1169
+ for rec in atual.to_dict("records"):
1170
+ r = normalizar(rec)
1171
+ h = r[_K_HASH]
1172
+ if h == nulo or h in mapa:
1173
+ continue
1174
+ mapa[h] = r
1175
+ ordem.append(h)
1176
+
1177
+ alterados = 0
1178
+ for rec in tb.to_dataframe().to_dict("records"):
1179
+ n = normalizar(rec)
1180
+ h = n[_K_HASH]
1181
+ if h == nulo:
1182
+ continue
1183
+
1184
+ r = mapa.get(h)
1185
+ if r is None:
1186
+ mapa[h] = n
1187
+ ordem.append(h)
1188
+ alterados += 1
1189
+ continue
1190
+
1191
+ mudou = False
1192
+ for grupo in _GRUPOS_MERGE:
1193
+ if any(n[c] == nulo for c in grupo):
1194
+ continue # novo valor incompleto: ignora
1195
+ atual_ok = all(r[c] != nulo for c in grupo)
1196
+ diferente = any(r[c] != n[c] for c in grupo)
1197
+ if (not atual_ok) or (override and diferente):
1198
+ for c in grupo:
1199
+ r[c] = n[c]
1200
+ mudou = True
1201
+ if mudou:
1202
+ alterados += 1
1203
+
1204
+ if alterados == 0:
1205
+ return 0
1206
+
1207
+ df = pd.DataFrame([mapa[h] for h in ordem], columns=cols)
1208
+ self._tb_toi = _df_para_tabela_entrega(df)
1209
+ self.save()
1210
+ return alterados
1211
+
1212
+ def merge(self, tb: TableData[HeaderEntregaTOI], *, override: bool = False) -> None:
1213
+ """
1214
+ Compatível com a API antiga, mas agora usa merge_smart(): um
1215
+ resultado novo NUNCA apaga/piora dados já existentes no cache
1216
+ (ex.: valores corrigidos manualmente). Use override=True para
1217
+ forçar a substituição dos campos preenchidos.
1218
+ """
1219
+ if tb.is_empty():
1220
+ return None
1221
+
1222
+ print(f"Mergeando tabela TOI ... {self.file_table_toi.absolute()}")
1223
+ self.merge_smart(tb, override=override)
1224
+
1225
+ def save(self) -> None:
1226
+ data = self.get_table_toi().to_dataframe()
1227
+ path = self.file_table_toi.absolute()
1228
+ tmp_file = f"{path}-tmp.xlsx"
1229
+
1230
+ print(f"Salvando tabela TOI ... {path}")
1231
+ self.file_table_toi.parent().mkdir()
1232
+ data.to_excel(tmp_file, index=False)
1233
+ replace_file(tmp_file, path) # atômico e portável (rename falha no Windows)
1234
+
1235
+
1236
+ class ControllerTOI(object):
1237
+ """
1238
+ Cria uma tabela de Cartas TOI apartir dos chats do WhatsApp.
1239
+ Conectando os arquivos das conversas aos textos extraídos via OCR.
1240
+
1241
+ TableWhatsApp() é usado para obter DATA e HORA do envio de um arquivo
1242
+ no chat, pois a data de envio no chat e a data de entrega de uma carta em campo.
1243
+
1244
+ Fluxo antigo : add_hashs_on_table() / find_on_cache() / load_from_map()
1245
+ Fluxo novo : resolver_hashes() / identificar_arquivos() / load_from_map_v2()
1246
+ (aceita UC/TOI parcial, nome de arquivo e usa o cache antes
1247
+ de qualquer reprocessamento).
1248
+ """
1249
+
1250
+ def __init__(self) -> None:
1251
+ self.builder_toi_files: BuilderTableFilesTOI = BuilderTableFilesTOI()
1252
+ self.builder_config: BuilderConfigTOI = BuilderConfigTOI()
1253
+ self.rw: ReadWriteTableTOI = ReadWriteTableTOI()
1254
+
1255
+ @property
1256
+ def relatorio_whatsapp(self) -> ControllerWhatsApp:
1257
+ return self.get_factory_toi().relatorio_wp
1258
+
1259
+ @property
1260
+ def relatorio_ocr(self) -> CreateRelatorioOcr:
1261
+ return self.get_factory_toi().relatorio_ocr
1262
+
1263
+ def get_factory_toi(self) -> BuilderEntregaTOI:
1264
+ return self.builder_toi_files.get_factory_toi()
1265
+
1266
+ # --------------------------------------------------#
1267
+ # Consultar dados - READ
1268
+ # --------------------------------------------------#
1269
+ def load_table_whatsapp(self) -> TableWhatsApp:
1270
+ return self.relatorio_whatsapp.load_cache_table()
1271
+
1272
+ def load_table_toi(self) -> TableData[HeaderEntregaTOI]:
1273
+ return self.rw.get_table_toi()
1274
+
1275
+ def find_on_cache(
1276
+ self, data: HashMap[hashFile, File], *, apenas_completos: bool = False
1277
+ ) -> TableData[HeaderFilesTOI]:
1278
+ """
1279
+ Procura os textos de cada arquivo na tabela OCR apartir da hash e retorna
1280
+ os dados no formato TableData()
1281
+
1282
+ apenas_completos=True devolve só linhas com UC, TOI e data.
1283
+ """
1284
+
1285
+ if data.is_empty():
1286
+ return self.builder_toi_files.create_void()
1287
+
1288
+ tb_cache: TableData[HeaderEntregaTOI] = self.load_table_toi()
1289
+ if tb_cache.is_empty():
1290
+ return self.builder_toi_files.create_void()
1291
+
1292
+ content_cache: TableData | None = tb_cache.search_all(
1293
+ HeaderEntregaTOI.HASH_FILE, data.header()
1294
+ )
1295
+
1296
+ if content_cache is None:
1297
+ return self.builder_toi_files.create_void()
1298
+
1299
+ df_cache = content_cache.to_dataframe()
1300
+
1301
+ if apenas_completos and not df_cache.empty:
1302
+ ok = ~(
1303
+ df_cache[_K_UC].map(valor_nulo)
1304
+ | df_cache[_K_TOI].map(valor_nulo)
1305
+ | df_cache[_K_DATE].map(valor_nulo)
1306
+ )
1307
+ df_cache = df_cache[ok]
1308
+ if df_cache.empty:
1309
+ return self.builder_toi_files.create_void()
1310
+
1311
+ builder = BuilderEntregaTOI()
1312
+
1313
+ final = self.builder_toi_files.decode_table_toi(
1314
+ builder.from_data(df_cache), data
1315
+ )
1316
+
1317
+ if final is None:
1318
+ return self.builder_toi_files.create_void()
1319
+ return final
1320
+
1321
+ def load_from_map(
1322
+ self, data_map: HashMapDict[hashFile, File], *, ignore_null: bool = True
1323
+ ) -> TableData[HeaderFilesTOI] | None:
1324
+ """
1325
+ I) Usa a HASH MD5 dos arquivos para buscar a data de envio do arquivo na
1326
+ tabela whatsapp;
1327
+
1328
+ II) Gera um relatório OCR com os metadados básicos
1329
+ de cada arquivo;
1330
+
1331
+ III) Obtem a UC e TOI de cada arquivo.
1332
+
1333
+ IV) Retorna uma tabela apartir dos dados gerados.
1334
+
1335
+ Observação:
1336
+ Se o parametro ignore_null for True as linhas que contém UC e TOI inválidos
1337
+ serão ignorados.
1338
+ """
1339
+
1340
+ if data_map.is_empty():
1341
+ return None
1342
+
1343
+ final_table: TableData[HeaderFilesTOI] | None = (
1344
+ self.builder_toi_files.from_hash_files(data_map, ignore_null=ignore_null)
1345
+ )
1346
+ if final_table is None:
1347
+ return None
1348
+
1349
+ df = final_table.to_dataframe().drop_duplicates(
1350
+ subset=[HeaderFilesTOI.HASH_FILE.value]
1351
+ )
1352
+
1353
+ if ignore_null:
1354
+ df = df[df[HeaderFilesTOI.UC] != TableData.NULL_STRING]
1355
+
1356
+ return self.builder_toi_files.from_data(df)
1357
+
1358
+ # --------------------------------------------------#
1359
+ # Criar dados - CREATE
1360
+ # --------------------------------------------------#
1361
+ def add_hashs_on_table(
1362
+ self, hash_files: Set[hashFile], *, ignore_null: bool = True
1363
+ ) -> None:
1364
+ """
1365
+ I) Analisa arquivos TOI apartir da HASH MD5, pesquisa o texto de cada
1366
+ hash na tabela OCR e extrai UC e TOI;
1367
+
1368
+ II) Na sequência, usa a hash para encontrar a data de envio do arquivo no chat
1369
+ do whatsapp;
1370
+
1371
+ III) Inclui os dados TOI de cada arquivo na tabela TOI em cache.
1372
+ """
1373
+ if hash_files.empty():
1374
+ print("Lista de arquivos vazia, nada a fazer!")
1375
+ return None
1376
+
1377
+ # Filtrar
1378
+ tb_cache_ocr: TableData[HeaderOcr] = self.relatorio_ocr.load_table_image()
1379
+ tb_cache_ocr.merge(self.relatorio_ocr.load_table_pdf())
1380
+
1381
+ cache_ocr: TableData[HeaderOcr] | None = tb_cache_ocr.search_all(
1382
+ HeaderOcr.HASH_FILE, hash_files.tolist()
1383
+ )
1384
+
1385
+ if cache_ocr is None:
1386
+ return None
1387
+
1388
+ # cache_ocr = cache_ocr.drop_duplicates()
1389
+ table_whatsapp: TableWhatsApp = self.load_table_whatsapp()
1390
+
1391
+ # Cada arquivo gera/extrai a sua própria tabela, ao final essa
1392
+ # lista de tabelas serão concatenadas em uma tabela única.
1393
+ list_tables_toi = ArrayTables[TableData[HeaderEntregaTOI]]()
1394
+ total = hash_files.size()
1395
+ k: hashFile
1396
+
1397
+ print(f"[Pesquisando texto OCR] em {total} arquivos")
1398
+ for n, k in enumerate(hash_files):
1399
+
1400
+ current_table_ocr: TableData[HeaderOcr] = cache_ocr.search(
1401
+ HeaderOcr.HASH_FILE, k
1402
+ )
1403
+
1404
+ text_ocr: str = None
1405
+
1406
+ if not current_table_ocr.is_empty():
1407
+ text_ocr = current_table_ocr.to_string(HeaderOcr.TEXT)
1408
+
1409
+ txt_uc_toi: tuple | None = (
1410
+ extrair_uc_toi(text_ocr) if text_ocr is not None else None
1411
+ )
1412
+
1413
+ if txt_uc_toi is None:
1414
+ if ignore_null:
1415
+ continue
1416
+
1417
+ uc = TableData.NULL_STRING
1418
+ toi = TableData.NULL_STRING
1419
+ year = TableData.NULL_STRING
1420
+ mon = TableData.NULL_STRING
1421
+ day = TableData.NULL_STRING
1422
+ dt: str = TableData.NULL_STRING
1423
+
1424
+ if txt_uc_toi is not None:
1425
+ uc = txt_uc_toi[0]
1426
+ toi = txt_uc_toi[1]
1427
+
1428
+ # Obter os dados no arquivo no chat whatsapp apartir da hash md5
1429
+ current_table_whatsapp = table_whatsapp.search(HeaderWhatsApp.hash_md5, k)
1430
+
1431
+ if not current_table_whatsapp.is_empty():
1432
+ # Arquivo encontrado no chat. Extrair dados da data de envio.
1433
+ dt = current_table_whatsapp.get_row_at(0).get_date().get_first()
1434
+ day = dt.split(" ")[0]
1435
+ mon = dt.split(" ")[1]
1436
+ year = dt.split(" ")[2]
1437
+
1438
+ if ignore_null:
1439
+ if dt == TableData.NULL_STRING:
1440
+ continue
1441
+
1442
+ tb_toi: TableData[HeaderEntregaTOI] = self.get_factory_toi().create_void()
1443
+ row_forms = RowH[HeaderEntregaTOI, Any](0)
1444
+
1445
+ row_forms.set(HeaderEntregaTOI.UC, uc)
1446
+ row_forms.set(HeaderEntregaTOI.TOI, toi)
1447
+ row_forms.set(HeaderEntregaTOI.YEAR, year)
1448
+ row_forms.set(HeaderEntregaTOI.MON, mon)
1449
+ row_forms.set(HeaderEntregaTOI.DAY, day)
1450
+ row_forms.set(HeaderEntregaTOI.DATE, dt)
1451
+ row_forms.set(HeaderEntregaTOI.HASH_FILE, k)
1452
+
1453
+ tb_toi.add_row(row_forms)
1454
+ list_tables_toi.add(tb_toi)
1455
+
1456
+ if list_tables_toi.empty():
1457
+ return None
1458
+
1459
+ final = list_tables_toi.concat(TableData).drop_duplicates()
1460
+ self.rw.merge(final)
1461
+
1462
+ @staticmethod
1463
+ def filter_uc_toi(
1464
+ hf: hashFile, table_ocr_file: TableData[HeaderOcr]
1465
+ ) -> tuple[str, str] | None:
1466
+ """
1467
+ Filtra UC e TOI de uma carta apartir do texto OCR extraído.
1468
+ """
1469
+ tb_current_hash = table_ocr_file.search(HeaderOcr.HASH_FILE, hf)
1470
+ if tb_current_hash.is_empty():
1471
+ return None
1472
+
1473
+ values = tb_current_hash.get(HeaderOcr.TEXT)._elements
1474
+
1475
+ for v in values:
1476
+ txt = extrair_uc_toi(v)
1477
+ if txt is not None:
1478
+ return txt
1479
+ return None
1480
+
1481
+ # ==================================================#
1482
+ # FLUXO NOVO (v2)
1483
+ # ==================================================#
1484
+ def _textos_ocr(self, hashes: Iterable[str]) -> dict[str, str]:
1485
+ """{hash: texto OCR completo} somente do cache (nunca aplica OCR)."""
1486
+ alvo = Set(str(h) for h in hashes)
1487
+ if alvo.empty():
1488
+ return {}
1489
+
1490
+ tb = self.relatorio_ocr.create_from_hashs(alvo)
1491
+ if tb is None or tb.is_empty():
1492
+ return {}
1493
+
1494
+ c_hash = HeaderOcr.HASH_FILE.value
1495
+ c_pag = HeaderOcr.NUM_PAG.value
1496
+ c_lin = HeaderOcr.NUM_LINHA.value
1497
+ c_txt = HeaderOcr.TEXT.value
1498
+
1499
+ df = tb.to_dataframe().astype(str)
1500
+ df = df[df[c_txt] != TableData.NULL_STRING].copy()
1501
+ if df.empty:
1502
+ return {}
1503
+
1504
+ df["_p"] = pd.to_numeric(df[c_pag], errors="coerce").fillna(0)
1505
+ df["_l"] = pd.to_numeric(df[c_lin], errors="coerce").fillna(0)
1506
+ df = df.sort_values(["_p", "_l"], kind="stable")
1507
+
1508
+ return {
1509
+ str(h): " ".join(g[c_txt].tolist())
1510
+ for h, g in df.groupby(c_hash, sort=False)
1511
+ }
1512
+
1513
+ def _datas_whatsapp(self, hashes: Iterable[str]) -> dict[str, tuple[str, str, str]]:
1514
+ """{hash: (day, mon, year)} da data de envio no chat (primeira ocorrência)."""
1515
+ alvo = {str(h) for h in hashes}
1516
+ if not alvo:
1517
+ return {}
1518
+
1519
+ tb = self.load_table_whatsapp()
1520
+ if tb.is_empty():
1521
+ return {}
1522
+
1523
+ df = tb.to_dataframe()
1524
+ df = df[df[HeaderWhatsApp.hash_md5.value].isin(alvo)]
1525
+ if df.empty:
1526
+ return {}
1527
+
1528
+ df = df.drop_duplicates(subset=[HeaderWhatsApp.hash_md5.value], keep="first")
1529
+ datas = pd.to_datetime(df[HeaderWhatsApp.date_hour.value], errors="coerce")
1530
+
1531
+ out: dict[str, tuple[str, str, str]] = {}
1532
+ for h, dt in zip(df[HeaderWhatsApp.hash_md5.value], datas):
1533
+ if pd.isna(dt):
1534
+ continue
1535
+ out[str(h)] = (f"{dt.day:02d}", f"{dt.month:02d}", str(dt.year))
1536
+ return out
1537
+
1538
+ def resolver_hashes(
1539
+ self,
1540
+ data_map: HashMap[hashFile, File],
1541
+ *,
1542
+ usar_nome: bool = True,
1543
+ usar_ocr: bool = True,
1544
+ usar_whatsapp: bool = True,
1545
+ gravar_cache: bool = True,
1546
+ ) -> HashMapDict[hashFile, ResultadoUcToi]:
1547
+ """
1548
+ Identifica UC/TOI/data de cada arquivo de data_map ({hash: File}).
1549
+
1550
+ Ordem de prioridade (cada fonte só preenche o que ainda falta):
1551
+ 1) nome do arquivo (identificação manual);
1552
+ 2) cache TOI (já processado, nada é refeito);
1553
+ 3) texto OCR em cache (UC/TOI, aceita só um dos dois);
1554
+ 4) data de envio no chat do WhatsApp.
1555
+
1556
+ Se gravar_cache=True, tudo que tem UC ou TOI é gravado na tabela TOI
1557
+ com merge_smart(): nunca piora o que já existia, e o nome manual
1558
+ corrige valores antigos. Arquivos sem nenhum número NÃO são gravados
1559
+ (o texto OCR já está em cache, reextrair é barato e melhora sozinho
1560
+ quando o extrator evolui).
1561
+
1562
+ Retorna {hash: ResultadoUcToi} para TODOS os hashes de data_map.
1563
+ """
1564
+ if data_map.is_empty():
1565
+ return HashMapDict()
1566
+
1567
+ hashes: ArrayList[hashFile] = data_map.header()
1568
+ cache = self.rw.get_resultados(hashes)
1569
+ res: dict[str, ResultadoUcToi] = {}
1570
+
1571
+ for h in hashes:
1572
+ r = ResultadoUcToi()
1573
+ if usar_nome:
1574
+ r.completar_com(identificar_por_nome(data_map.get_value(h).name()))
1575
+ r.completar_com(cache.get(h))
1576
+ res[h] = r
1577
+
1578
+ # --- OCR: só para quem ainda não tem UC e TOI
1579
+ if usar_ocr:
1580
+ faltam = [h for h in hashes if not res[h].completo]
1581
+ if faltam:
1582
+ for h, txt in self._textos_ocr(faltam).items():
1583
+ if h in res:
1584
+ res[h].completar_com(extrair_uc_toi_tolerante(txt))
1585
+
1586
+ # --- Data de envio no chat: só para quem ainda não tem data
1587
+ if usar_whatsapp:
1588
+ sem_data = [h for h in hashes if res[h].data is None]
1589
+ if sem_data:
1590
+ for h, (d, m, y) in self._datas_whatsapp(sem_data).items():
1591
+ if h in res:
1592
+ res[h].day, res[h].mon, res[h].year = d, m, y
1593
+
1594
+ if gravar_cache:
1595
+ tb = tabela_entrega_de_resultados(res)
1596
+ if tb is not None:
1597
+ # 'res' já respeita a prioridade nome > cache > ocr.
1598
+ self.rw.merge_smart(tb, override=True)
1599
+
1600
+ return HashMapDict(res)
1601
+
1602
+ def identificar_arquivos(
1603
+ self, files: Iterable[File], **opcoes: Any
1604
+ ) -> HashMapDict[hashFile, ResultadoUcToi]:
1605
+ """Atalho: calcula as hashs dos arquivos e chama resolver_hashes()."""
1606
+ data_map = HashMapDict[hashFile, File]()
1607
+ for f in files:
1608
+ data_map.set_value(load_file_hash(f), f)
1609
+ return self.resolver_hashes(data_map, **opcoes)
1610
+
1611
+ def load_from_map_v2(
1612
+ self,
1613
+ data_map: HashMapDict[hashFile, File],
1614
+ *,
1615
+ permitir_parcial: bool = False,
1616
+ **opcoes: Any,
1617
+ ) -> TableData[HeaderFilesTOI] | None:
1618
+ """
1619
+ Equivalente novo de load_from_map() (mesmo tipo de retorno).
1620
+
1621
+ permitir_parcial=False: só linhas com UC, TOI e data (úteis p/ Forms).
1622
+ permitir_parcial=True : também linhas com só UC ou só TOI (organização).
1623
+ """
1624
+ if data_map.is_empty():
1625
+ return None
1626
+
1627
+ res = self.resolver_hashes(data_map, **opcoes).map
1628
+ if permitir_parcial:
1629
+ validos = {h: r for h, r in res.items() if r.tem_dados}
1630
+ else:
1631
+ validos = {h: r for h, r in res.items() if r.completo and r.data}
1632
+
1633
+ tb = tabela_entrega_de_resultados(validos)
1634
+ if tb is None:
1635
+ return None
1636
+ return self.builder_toi_files.decode_table_toi(tb, data_map)
1637
+
1638
+ def registrar_manual(
1639
+ self,
1640
+ hash_file: hashFile,
1641
+ *,
1642
+ uc: str | None = None,
1643
+ toi: str | None = None,
1644
+ data: str | None = None,
1645
+ ) -> bool:
1646
+ """
1647
+ Grava no cache dados digitados por uma pessoa (têm prioridade sobre
1648
+ qualquer valor existente). 'data' aceita 'dd/mm/yyyy', 'dd mm yyyy'
1649
+ ou 'yyyy-mm-dd'. Retorna True se algo mudou no cache.
1650
+ """
1651
+ r = ResultadoUcToi(
1652
+ uc=limpar_numero(uc) or None,
1653
+ toi=limpar_numero(toi) or None,
1654
+ origem=OrigemUcToi.MANUAL,
1655
+ )
1656
+ d = parse_data_entrega(data)
1657
+ if d is not None:
1658
+ r.day, r.mon, r.year = d
1659
+
1660
+ tb = tabela_entrega_de_resultados({str(hash_file): r})
1661
+ if tb is None:
1662
+ return False
1663
+ return self.rw.merge_smart(tb, override=True) > 0
1664
+
1665
+ def buscar_numero(self, numero: str, *, incluir_ocr: bool = True) -> pd.DataFrame:
1666
+ """
1667
+ Procura um número (UC ou TOI, inclusive parcial/incompleto) e responde
1668
+ "essa carta já foi entregue?":
1669
+
1670
+ * no cache TOI (colunas uc/toi);
1671
+ * no texto OCR (hashs que ainda não estão no cache TOI).
1672
+
1673
+ Colunas: HASH_FILE, uc, toi, data_entrega, origem ('cache' | 'ocr').
1674
+ """
1675
+ cols = [_K_HASH, _K_UC, _K_TOI, _K_DATE, "origem"]
1676
+ alvo = limpar_numero(numero)
1677
+ if not alvo:
1678
+ return pd.DataFrame(columns=cols)
1679
+
1680
+ frames: list[pd.DataFrame] = []
1681
+ hashes_cache: set[str] = set()
1682
+
1683
+ df = self.rw.get_table_toi().to_dataframe()
1684
+ if not df.empty:
1685
+ df = df.astype(str)
1686
+ achados = df[(df[_K_UC] == alvo) | (df[_K_TOI] == alvo)].copy()
1687
+ if not achados.empty:
1688
+ achados["origem"] = "cache"
1689
+ hashes_cache = set(achados[_K_HASH].tolist())
1690
+ frames.append(achados[cols])
1691
+
1692
+ if incluir_ocr:
1693
+ nulo = TableData.NULL_STRING
1694
+ c_hash, c_txt = HeaderOcr.HASH_FILE.value, HeaderOcr.TEXT.value
1695
+ for tb in (
1696
+ self.relatorio_ocr.load_table_image(),
1697
+ self.relatorio_ocr.load_table_pdf(),
1698
+ ):
1699
+ dfo = tb.get_data()
1700
+ if dfo.empty:
1701
+ continue
1702
+ m = dfo[
1703
+ dfo[c_txt].astype(str).str.contains(alvo, regex=False, na=False)
1704
+ ]
1705
+ hs = {
1706
+ h for h in m[c_hash].astype(str).tolist() if h not in hashes_cache
1707
+ }
1708
+ if hs:
1709
+ frames.append(
1710
+ pd.DataFrame(
1711
+ {
1712
+ _K_HASH: sorted(hs),
1713
+ _K_UC: nulo,
1714
+ _K_TOI: nulo,
1715
+ _K_DATE: nulo,
1716
+ "origem": "ocr",
1717
+ }
1718
+ )[cols]
1719
+ )
1720
+ hashes_cache |= hs
1721
+
1722
+ if not frames:
1723
+ return pd.DataFrame(columns=cols)
1724
+ return pd.concat(frames, ignore_index=True)
1725
+
1726
+
1727
+ def load_files_toi(files: Set[File]) -> TableData[HeaderFilesTOI] | None:
1728
+ """
1729
+ I) Analisa arquivos TOI apartir da HASH MD5;
1730
+ II) Compara o texto na tabela OCR e inclui, UC e TOI na tabela TOI principal;
1731
+ III) Retorna os dados no formato de tabela TOI.
1732
+ """
1733
+
1734
+ if files.empty():
1735
+ print(f"[DEBUG]: load_files_toi() lista de arquivos vazia!")
1736
+ return None
1737
+
1738
+ f: File
1739
+ for f in files:
1740
+ if (not f.is_image()) and (not f.is_pdf()):
1741
+ raise ValueError(
1742
+ f"Tipo de documento TOI inválido, use PDF ou Imagem não: {f.extension()}"
1743
+ )
1744
+
1745
+ data_map = HashMapDict[hashFile, File]()
1746
+ files.for_each(lambda f: data_map.set_value(load_file_hash(f), f))
1747
+
1748
+ controller = ControllerTOI()
1749
+ heder_hash = data_map.header()
1750
+ controller.add_hashs_on_table(heder_hash.to_set())
1751
+ return controller.find_on_cache(data_map)
1752
+
1753
+
1754
+ def load_files_toi_v2(
1755
+ files: Set[File], *, permitir_parcial: bool = False, **opcoes: Any
1756
+ ) -> TableData[HeaderFilesTOI] | None:
1757
+ """
1758
+ Equivalente novo de load_files_toi(): usa nome do arquivo + cache + OCR
1759
+ tolerante + data do chat, e grava o que descobriu no cache TOI.
1760
+ """
1761
+ if files.empty():
1762
+ print("[DEBUG]: load_files_toi_v2() lista de arquivos vazia!")
1763
+ return None
1764
+
1765
+ for f in files:
1766
+ if (not f.is_image()) and (not f.is_pdf()):
1767
+ raise ValueError(
1768
+ f"Tipo de documento TOI inválido, use PDF ou Imagem não: {f.extension()}"
1769
+ )
1770
+
1771
+ data_map = HashMapDict[hashFile, File]()
1772
+ files.for_each(lambda f: data_map.set_value(load_file_hash(f), f))
1773
+
1774
+ return ControllerTOI().load_from_map_v2(
1775
+ data_map, permitir_parcial=permitir_parcial, **opcoes
1776
+ )
1777
+
1778
+
1779
+ def get_file_log(base_dir: Directory) -> File:
1780
+ _dt = datetime.now()
1781
+ y = str(_dt.year)
1782
+ m = str(_dt.month)
1783
+ day = str(_dt.day)
1784
+ current_date = f"{y}-{m}-{day}"
1785
+ dir_log: Directory = base_dir.concat(y, True).concat(f"{y}-{m}", True)
1786
+ return dir_log.join_file(f"{current_date}.xlsx")
1787
+
1788
+
1789
+ def get_date_from_row(r: RowH[HeaderFilesTOI, Any]) -> tuple[str, str, str] | None:
1790
+ """
1791
+ Retorna uma tupla de data no formato (ANO, MÊS, DIA)
1792
+ """
1793
+
1794
+ y = r.get(HeaderFilesTOI.YEAR)
1795
+ m = r.get(HeaderFilesTOI.MON)
1796
+ d = r.get(HeaderFilesTOI.DAY)
1797
+
1798
+ if len(m) > 1:
1799
+ if m[0] == "0":
1800
+ m = m[1]
1801
+
1802
+ values = (y, m, d)
1803
+ i: str
1804
+ for i in values:
1805
+ if not i.isnumeric():
1806
+ return None
1807
+ return values
1808
+
1809
+
1810
+ def _resolver_colisao_arquivo(dest: File) -> File:
1811
+ """Se dest existe, devolve 'nome-1.ext', 'nome-2.ext'... (primeiro livre)."""
1812
+ if not dest.exists():
1813
+ return dest
1814
+ base, ext = os.path.splitext(dest.absolute())
1815
+ n = 1
1816
+ novo = File(f"{base}-{n}{ext}")
1817
+ while novo.exists():
1818
+ n += 1
1819
+ novo = File(f"{base}-{n}{ext}")
1820
+ return novo
1821
+
1822
+
1823
+ class RenameFilesTOI:
1824
+ """
1825
+ Organiza cartas TOI e registra o resultado de cada movimentação.
1826
+
1827
+ Destinos:
1828
+ UC + TOI + data -> <base>/ano/ano-mês/ano-mês-dia/'ano mês dia uc toi'
1829
+ UC + TOI s/ data -> CARTAS_DATA_NAO_ENCONTRADA/'uc toi'
1830
+ só UC -> CARTAS_SO_UC/'UC <uc> [data] <hash8>'
1831
+ só TOI -> CARTAS_SO_TOI/'TOI <toi> [data] <hash8>'
1832
+ nada -> CARTAS_UC_NAO_IDENTIFICADAS/<hash>
1833
+
1834
+ modo_v2=True usa resolver_hashes() (nome do arquivo + cache + OCR
1835
+ tolerante + chat) e nunca sobrescreve um arquivo diferente com o mesmo
1836
+ nome de destino (acrescenta -1, -2...). modo_v2=False mantém o fluxo antigo.
1837
+ """
1838
+
1839
+ def __init__(self, files: Set[File], *, modo_v2: bool = False) -> None:
1840
+ self.config: ConfigTOI = BuilderConfigTOI().builder()
1841
+ self.files = files
1842
+ self.modo_v2: bool = modo_v2
1843
+ self.file_log: File = get_file_log(self.config.dir_log_excel)
1844
+
1845
+ self.base_cartas: Directory = self.config.dir_base_toi
1846
+ self.error_uc_dir: Directory = self.base_cartas.concat(
1847
+ "CARTAS_UC_NAO_IDENTIFICADAS", True
1848
+ )
1849
+ self.error_date_dir: Directory = self.base_cartas.concat(
1850
+ "CARTAS_DATA_NAO_ENCONTRADA", True
1851
+ )
1852
+
1853
+ self.map_files: HashMapDict[hashFile, File] = HashMapDict()
1854
+ self.table_status = TableData.create_void(
1855
+ [HeaderFilesTOI.HASH_FILE.value, "src", "dest", "status", "exception"]
1856
+ )
1857
+
1858
+ def process_error_ucs(self):
1859
+ tb_wp = ControllerWhatsApp().load_cache_table()
1860
+ content = ContentFiles(self.error_uc_dir)
1861
+ files = content.get_files()
1862
+
1863
+ content_map = HashMapDict[hashFile, File]()
1864
+ files.for_each(lambda f: content_map.set_value(load_file_hash(f), f))
1865
+
1866
+ content_chat = tb_wp.search_all(HeaderWhatsApp.hash_md5, content_map.header())
1867
+
1868
+ if content_chat is None:
1869
+ return
1870
+ if content_chat.is_empty():
1871
+ return
1872
+
1873
+ rows = (
1874
+ BuilderTableWhatsApp().create_from_data(content_chat.to_dataframe()).rows()
1875
+ )
1876
+
1877
+ r: RowWhatsApp
1878
+ for r in rows:
1879
+ h = r.get(HeaderWhatsApp.hash_md5)
1880
+ file_path: File = content_map.get_value(h)
1881
+
1882
+ dt: datetime = r.get_date_hour()
1883
+ y = str(dt.year)
1884
+ m = str(dt.month)
1885
+ d = str(dt.day)
1886
+ _date = f"{y}-{m}-{d}"
1887
+
1888
+ current_dir = self.error_uc_dir.concat(y).concat(m).concat(_date, True)
1889
+ final_path: File = current_dir.join_file(file_path.basename())
1890
+
1891
+ try:
1892
+ move_path(file_path, final_path)
1893
+ except Exception as e:
1894
+ print(f"{__class__.__name__}(): {e}")
1895
+
1896
+ def _index_files(self) -> None:
1897
+ """Indexa os arquivos originais por hash."""
1898
+ for file in self.files:
1899
+ self.map_files.set_value(load_file_hash(file), file)
1900
+
1901
+ def _create_status_row(self, file_hash: hashFile, src: File, dest: File) -> RowH:
1902
+ row = RowH(0)
1903
+ row.set(HeaderFilesTOI.HASH_FILE.value, file_hash)
1904
+ row.set("src", src.absolute())
1905
+ row.set("dest", dest.absolute())
1906
+ return row
1907
+
1908
+ def _move(self, file_hash: hashFile, src: File, dest: File) -> bool:
1909
+ """Move o arquivo e registra o resultado real da operação."""
1910
+ row = self._create_status_row(file_hash, src, dest)
1911
+
1912
+ try:
1913
+ if not move_path(src, dest):
1914
+ raise OSError(
1915
+ f"move_path retornou False: {src.absolute()} -> {dest.absolute()}"
1916
+ )
1917
+ except Exception as exc:
1918
+ row.set("status", "FALHA")
1919
+ row.set("exception", str(exc))
1920
+ success = False
1921
+ else:
1922
+ row.set("status", "SUCESSO")
1923
+ row.set("exception", "")
1924
+ success = True
1925
+
1926
+ self.table_status.add_row(row)
1927
+ return success
1928
+
1929
+ @staticmethod
1930
+ def _is_missing(value: Any) -> bool:
1931
+ return (
1932
+ value is None
1933
+ or pd.isna(value)
1934
+ or value == TableData.NULL_STRING
1935
+ or str(value).strip() == ""
1936
+ )
1937
+
1938
+ def _get_destination(self, row: RowH) -> tuple[Directory, str]:
1939
+ """Define o destino com prioridade para a ausência de UC."""
1940
+ uc = row.get(HeaderFilesTOI.UC)
1941
+ toi = row.get(HeaderFilesTOI.TOI)
1942
+ date = get_date_from_row(row)
1943
+ file_hash = str(row.get(HeaderFilesTOI.HASH_FILE))
1944
+
1945
+ has_uc = not self._is_missing(uc)
1946
+ has_toi = not self._is_missing(toi)
1947
+
1948
+ if not has_uc and not has_toi:
1949
+ return self.error_uc_dir, file_hash
1950
+
1951
+ # Identificação parcial: o número fica no nome para poder ser pesquisado.
1952
+ if not (has_uc and has_toi):
1953
+ if has_uc:
1954
+ folder, label, number = "CARTAS_SO_UC", "UC", uc
1955
+ else:
1956
+ folder, label, number = "CARTAS_SO_TOI", "TOI", toi
1957
+
1958
+ sufixo = f" {date[0]} {date[1]} {date[2]}" if date is not None else ""
1959
+ name = f"{label} {number}{sufixo} {file_hash[:8]}"
1960
+ return self.base_cartas.concat(folder, True), name
1961
+
1962
+ if date is None:
1963
+ return self.error_date_dir, f"{uc} {toi}"
1964
+
1965
+ year, month, day = date
1966
+ dest_dir = (
1967
+ self.base_cartas.concat(year)
1968
+ .concat(f"{year}-{month}")
1969
+ .concat(f"{year}-{month}-{day}", True)
1970
+ )
1971
+ return dest_dir, f"{year} {month} {day} {uc} {toi}"
1972
+
1973
+ def _destino_livre(self, dest: File, src: File) -> File:
1974
+ """Evita sobrescrever outro arquivo com o mesmo nome de destino."""
1975
+ if dest.absolute() == src.absolute():
1976
+ return dest
1977
+ return _resolver_colisao_arquivo(dest)
1978
+
1979
+ def _process_identified_files(self) -> Set[hashFile]:
1980
+ """Processa os arquivos que possuem registro na tabela TOI."""
1981
+ controller = ControllerTOI()
1982
+
1983
+ if self.modo_v2:
1984
+ table_files = controller.load_from_map_v2(
1985
+ self.map_files, permitir_parcial=True
1986
+ )
1987
+ if table_files is None:
1988
+ return Set[hashFile]()
1989
+ else:
1990
+ controller.add_hashs_on_table(self.map_files.header().to_set())
1991
+ table_files = controller.find_on_cache(self.map_files)
1992
+
1993
+ processed = Set[hashFile]()
1994
+
1995
+ for row in table_files.rows():
1996
+ file_hash: hashFile = row.get(HeaderFilesTOI.HASH_FILE)
1997
+ src: File = self.map_files.get_value(file_hash)
1998
+
1999
+ if src is None:
2000
+ continue
2001
+
2002
+ processed.add(file_hash)
2003
+ dest_dir, output_name = self._get_destination(row)
2004
+ dest = dest_dir.join_file(f"{output_name}{src.extension()}")
2005
+
2006
+ if self.modo_v2:
2007
+ dest = self._destino_livre(dest, src)
2008
+
2009
+ self._move(file_hash, src, dest)
2010
+
2011
+ return processed
2012
+
2013
+ def _process_unidentified_files(self, processed: Set[hashFile]) -> None:
2014
+ """Encaminha para a pasta de UC os arquivos sem registro TOI."""
2015
+ for file_hash in self.map_files.header():
2016
+ if processed.contains(file_hash):
2017
+ continue
2018
+
2019
+ src: File = self.map_files.get_value(file_hash)
2020
+ extension = src.extension() or ".nan"
2021
+ dest = self.error_uc_dir.join_file(f"{file_hash}{extension}")
2022
+
2023
+ if self.modo_v2:
2024
+ dest = self._destino_livre(dest, src)
2025
+
2026
+ self._move(file_hash, src, dest)
2027
+
2028
+ def _save_log(self) -> pd.DataFrame:
2029
+ """Acumula os resultados no relatório existente."""
2030
+ sequence = ArrayTables[TableData]()
2031
+ sequence.add(self.table_status)
2032
+
2033
+ if self.file_log.exists():
2034
+ df_previous = pd.read_excel(self.file_log.absolute())
2035
+ sequence.add(TableData(df_previous))
2036
+
2037
+ df_final = sequence.concat(TableData).to_dataframe()
2038
+ df_final.drop_duplicates(inplace=True)
2039
+
2040
+ print(f"Salvando: {self.file_log}")
2041
+ df_final.to_excel(self.file_log.absolute(), index=False)
2042
+ return df_final
2043
+
2044
+ def execute(self) -> pd.DataFrame:
2045
+ if self.files.empty():
2046
+ return self.table_status.to_dataframe()
2047
+
2048
+ self._index_files()
2049
+ processed = self._process_identified_files()
2050
+ self._process_unidentified_files(processed)
2051
+ return self._save_log()
2052
+
2053
+
2054
+ def rename_files_toi(files: Set[File], *, modo_v2: bool = False) -> RenameFilesTOI:
2055
+ """Renomear arquivos TOI (modo_v2=True usa o fluxo novo de identificação)."""
2056
+
2057
+ rn = RenameFilesTOI(files, modo_v2=modo_v2)
2058
+ rn.execute()
2059
+ rn.process_error_ucs()
2060
+ return rn