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 +2060 -0
- cartas/origin.py +1196 -0
- pyflutter_ui/__init__.py +30 -0
- pyflutter_ui/app.py +289 -0
- pyflutter_ui/file_picker.py +274 -0
- pyflutter_ui/navigator.py +93 -0
- pyflutter_ui/scheduler.py +154 -0
- pyflutter_ui/theme.py +1058 -0
- pyflutter_ui/ui/__init__.py +27 -0
- pyflutter_ui/ui/buttons.py +219 -0
- pyflutter_ui/ui/container.py +119 -0
- pyflutter_ui/ui/core.py +820 -0
- pyflutter_ui/ui/list_builder.py +396 -0
- pyflutter_ui/ui/progress.py +80 -0
- pyflutter_ui/ui/tables.py +109 -0
- pyflutter_ui/ui/widgets.py +1418 -0
- responds_forms-0.4.dist-info/METADATA +15 -0
- responds_forms-0.4.dist-info/RECORD +38 -0
- responds_forms-0.4.dist-info/WHEEL +4 -0
- responds_ui/__init__.py +1 -0
- responds_ui/__main__.py +25 -0
- responds_ui/assets/forms_config.json +57 -0
- responds_ui/config_app.py +109 -0
- responds_ui/lib_toi.py +864 -0
- responds_ui/page_forms/Diagnorstico.txt +108 -0
- responds_ui/page_forms/__init__.py +5 -0
- responds_ui/page_forms/common.py +192 -0
- responds_ui/page_forms/config.py +68 -0
- responds_ui/page_forms/controller_forms.py +390 -0
- responds_ui/page_forms/diagnose_forms.py +64 -0
- responds_ui/page_forms/master_forms.py +113 -0
- responds_ui/page_forms/model_forms.py +329 -0
- responds_ui/page_forms/service.py +347 -0
- responds_ui/page_forms/view_forms.py +164 -0
- responds_ui/table_widget.py +140 -0
- responds_ui/util.py +74 -0
- responds_ui/version.py +2 -0
- whatsapp_files/__init__.py +1092 -0
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
|