pyflutter-app 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (107) hide show
  1. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/PKG-INFO +1 -1
  2. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/common.py +7 -0
  3. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/config.py +6 -6
  4. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/controller_forms.py +143 -9
  5. pyflutter_app-0.3.1/converter/page_forms/master_forms.py +113 -0
  6. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/model_forms.py +124 -10
  7. pyflutter_app-0.3.1/converter/page_forms/view_forms.py +164 -0
  8. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/relatorio_ocr.py +11 -0
  9. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/table_ocr.py +12 -2
  10. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyproject.toml +1 -1
  11. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/test.py +1 -0
  12. pyflutter_app-0.3.1/update.sh +5 -0
  13. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/uv.lock +1 -1
  14. pyflutter_app-0.3.0/converter/page_forms/view_forms.py +0 -104
  15. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.gitignore +0 -0
  16. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/.gitignore +0 -0
  17. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/inspectionProfiles/Project_Default.xml +0 -0
  18. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  19. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/misc.xml +0 -0
  20. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/modules.xml +0 -0
  21. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/pyflutter_app.iml +0 -0
  22. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/.idea/vcs.xml +0 -0
  23. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/README.md +0 -0
  24. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/__init__.py +0 -0
  25. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/erros.py +0 -0
  26. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/find_toi.py +0 -0
  27. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/relatorio.py +0 -0
  28. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/table_toi.py +0 -0
  29. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/cartas/util.py +0 -0
  30. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/__init__.py +0 -0
  31. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/__main__.py +0 -0
  32. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/assets/forms_config.json +0 -0
  33. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/config_app.py +0 -0
  34. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/home_page/__init__.py +0 -0
  35. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/home_page/_page_edit.py +0 -0
  36. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/home_page/controller.py +0 -0
  37. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/home_page/model.py +0 -0
  38. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/home_page/view.py +0 -0
  39. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/lib_toi.py +0 -0
  40. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/Diagnorstico.txt +0 -0
  41. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/__init__.py +0 -0
  42. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/common.py +0 -0
  43. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/diagnose_forms.py +0 -0
  44. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/page_forms/service.py +0 -0
  45. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/run_app_convert.py +0 -0
  46. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/table_widget.py +0 -0
  47. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/util.py +0 -0
  48. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/whatsapp/__init__.py +0 -0
  49. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/converter/whatsapp/model.py +0 -0
  50. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/export_app.sh +0 -0
  51. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/local_build/linux.sh +0 -0
  52. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/local_build/send.sh +0 -0
  53. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/main.py +0 -0
  54. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/mediator.py +0 -0
  55. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/mvc/controller.py +0 -0
  56. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/mvc/model.py +0 -0
  57. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/mvc/view.py +0 -0
  58. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/new-export_app.sh +0 -0
  59. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/__init__.py +0 -0
  60. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/_tesseract.py +0 -0
  61. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/common.py +0 -0
  62. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/erros.py +0 -0
  63. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/export_ocr.sh +0 -0
  64. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/ocrlib/util.py +0 -0
  65. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pdflib/__init__.py +0 -0
  66. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pdflib/common.py +0 -0
  67. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pdflib/imagelib.py +0 -0
  68. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pdflib/pdf_convert.py +0 -0
  69. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pdflib/pdf_document.py +0 -0
  70. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/__init__.py +0 -0
  71. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/app.py +0 -0
  72. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/file_picker.py +0 -0
  73. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/navigator.py +0 -0
  74. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/scheduler.py +0 -0
  75. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/theme.py +0 -0
  76. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/__init__.py +0 -0
  77. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/buttons.py +0 -0
  78. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/container.py +0 -0
  79. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/core.py +0 -0
  80. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/list_builder.py +0 -0
  81. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/progress.py +0 -0
  82. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/tables.py +0 -0
  83. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/pyflutter_ui/ui/widgets.py +0 -0
  84. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/__init__.py +0 -0
  85. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/array.py +0 -0
  86. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/erros.py +0 -0
  87. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/hash_map.py +0 -0
  88. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/notifier.py +0 -0
  89. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/pattner/__init__.py +0 -0
  90. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/pattner/adapter.py +0 -0
  91. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/pattner/observer.py +0 -0
  92. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/sheet/__init__.py +0 -0
  93. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/sheet/check_mod.py +0 -0
  94. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/sheet/data_storage.py +0 -0
  95. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/sheet/options.py +0 -0
  96. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/state.py +0 -0
  97. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/sysfiles.py +0 -0
  98. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/table.py +0 -0
  99. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/thread.py +0 -0
  100. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/table_stream/workbook.py +0 -0
  101. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/toi.py +0 -0
  102. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/whatsapp_files/__init__.py +0 -0
  103. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/whatsapp_files/_table_whatsapp.py +0 -0
  104. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/whatsapp_files/common.py +0 -0
  105. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/whatsapp_files/erros.py +0 -0
  106. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/whatsapp_files/stream_files.py +0 -0
  107. {pyflutter_app-0.3.0 → pyflutter_app-0.3.1}/whatsapp_files/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyflutter_app
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Bibliotecas Python para conversão de documentos, interface e processamento de tabelas.
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: opencv-python>=5.0.0.93
@@ -10,6 +10,7 @@ class ConfigTOI:
10
10
  file_relatorio_toi: File
11
11
  file_relatorio_forms: File
12
12
  file_table_toi: File
13
+ file_master_forms: File # NOVO
13
14
  dir_base_toi: Directory
14
15
  dir_log_excel: Directory
15
16
  dir_relatorios: Directory
@@ -66,6 +67,11 @@ class BuilderConfigTOI(object):
66
67
  d = self._builder_ocr.builder().dir_table
67
68
  return d.join_file("cartas-toi-lancadas.xlsx")
68
69
 
70
+ @property
71
+ def file_master_forms(self) -> File:
72
+ d = self._builder_ocr.builder().dir_table
73
+ return d.join_file("master-forms-toi.xlsx")
74
+
69
75
  @property
70
76
  def file_table_toi(self) -> File:
71
77
  d = self._builder_ocr.builder().dir_table
@@ -80,6 +86,7 @@ class BuilderConfigTOI(object):
80
86
  file_relatorio_forms=self.file_relatorio_forms,
81
87
  dir_log_excel=self.dir_log_excel,
82
88
  dir_relatorios=self.dir_relatorios,
89
+ file_master_forms=self.file_master_forms,
83
90
  )
84
91
 
85
92
  def builder(self) -> ConfigTOI:
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
  from dataclasses import dataclass, field
3
-
4
3
  from cartas import LocalEntregaCarta, TipoCarta
5
4
 
6
5
  FORMS_URL = (
@@ -31,11 +30,12 @@ class FormsSubmission:
31
30
  Contrato entre Model e Service: UMA resposta do formulário, já validada
32
31
  e com os textos finais das opções. O Service não conhece pandas nem PyFlutter.
33
32
  """
33
+
34
34
  uc: str
35
35
  toi: str
36
36
  local_label: str
37
37
  tipo_label: str
38
- date: str # dd/MM/yyyy
38
+ date: str # dd/MM/yyyy
39
39
  files: list[str] = field(default_factory=list)
40
40
 
41
41
 
@@ -47,15 +47,15 @@ class FormsConfig:
47
47
  user_data_dir: str = ""
48
48
  screenshot_dir: str = ""
49
49
  headless: bool = False
50
- slow_mo_ms: int = 150 # pausa automática entre CADA ação do Playwright
51
- pause_between_rows_s: float = 1.5 # pausa entre uma carta e outra
52
- browser_channel: str | None = None # ex.: "chrome" para usar o Chrome instalado
50
+ slow_mo_ms: int = 150 # pausa automática entre CADA ação do Playwright
51
+ pause_between_rows_s: float = 1.5 # pausa entre uma carta e outra
52
+ browser_channel: str | None = None # ex.: "chrome" para usar o Chrome instalado
53
53
 
54
54
  login_timeout_s: int = 900
55
55
  action_timeout_ms: int = 15000
56
56
  upload_timeout_ms: int = 120000
57
57
  wait_after_submit_ms: int = 1500
58
- send_receipt: bool = True # marcar "Enviar-me um email de confirmação"
58
+ send_receipt: bool = True # marcar "Enviar-me um email de confirmação"
59
59
 
60
60
  # Regex (case-insensitive) que identifica o TÍTULO de cada pergunta.
61
61
  # O texto do bloco vem colado ("1.UCTexto de linha única."), por isso NÃO
@@ -9,8 +9,15 @@ from pyflutter_ui.ui import ChangeNotifier, Provider
9
9
  from pyflutter_ui.scheduler import MainThreadScheduler
10
10
 
11
11
  from converter.config_app import AppConfigNotifier
12
- from converter.page_forms.common import MapRows, StatusForms
13
- from converter.page_forms.model_forms import ModelForms, FormsCallbacks
12
+ from converter.page_forms.common import MapRows, StatusForms, HeaderForms
13
+
14
+ from converter.page_forms.master_forms import ControllerMasterForms, make_key
15
+ from converter.page_forms.model_forms import (
16
+ ModelForms,
17
+ FormsCallbacks,
18
+ ResultGenerateTable,
19
+ )
20
+ from table_stream import File, Directory, TableData, ArrayList
14
21
 
15
22
  from cartas import HeaderFilesTOI, BuilderCartas
16
23
  from table_stream import File, Directory, TableData
@@ -37,6 +44,9 @@ class ControllerForms(ChangeNotifier):
37
44
  self.progress: tuple[int, int] = (0, 0)
38
45
  self.send_receipt: bool = True
39
46
 
47
+ self.master = ControllerMasterForms()
48
+ self.generating: bool = False
49
+
40
50
  # Log em Excel gravado durante o lançamento (1 arquivo por execução).
41
51
  self.save_every: int = 2 # grava a cada N linhas concluídas (sucesso OU falha)
42
52
  self.result_file: File | None = None
@@ -66,16 +76,119 @@ class ControllerForms(ChangeNotifier):
66
76
  if self.fn_show_error is not None:
67
77
  _ui(lambda: self.fn_show_error(e))
68
78
 
79
+ # ------------------------------------------------------------------ master
80
+
81
+ def _apply_master(self, mp: MapRows) -> int:
82
+ """Marca como SKIPPED o que já está no master (key UC+TOI) ou repetido na tabela."""
83
+ keys = self.master.load_keys()
84
+ seen: set[str] = set()
85
+ skipped = 0
86
+
87
+ for idx in mp.header():
88
+ row = mp[idx]
89
+ if mp.get_status(idx) == StatusForms.SUCCESS:
90
+ seen.add(make_key(row.get(HeaderForms.UC), row.get(HeaderForms.TOI)))
91
+ continue
92
+
93
+ key = make_key(row.get(HeaderForms.UC), row.get(HeaderForms.TOI))
94
+ if key in keys:
95
+ mp.update_status(idx, StatusForms.SKIPPED, "Já lançada (master)")
96
+ skipped += 1
97
+ elif key in seen:
98
+ mp.update_status(idx, StatusForms.SKIPPED, "Duplicada na tabela")
99
+ skipped += 1
100
+ seen.add(key)
101
+ return skipped
102
+
69
103
  # ------------------------------------------------------------------ importação
70
104
  def set_map_rows(self, mp: MapRows) -> None:
71
105
  if mp.is_empty():
72
106
  self.show_error("Nenhuma linha TOI encontrada na tabela.")
73
107
  return
108
+
109
+ try:
110
+ skipped = self._apply_master(mp)
111
+ except Exception as e:
112
+ self.show_error(f"Falha ao ler o arquivo master:\n{e}")
113
+ return
114
+
74
115
  self.map_rows = mp
75
116
  self.progress = (0, 0)
76
- self.status_text = f"{mp.size_header()} cartas importadas. Pronto para lançar."
117
+ pending = mp.size_header() - skipped
118
+ self.status_text = (
119
+ f"{mp.size_header()} cartas importadas | "
120
+ f"{skipped} já lançadas/ignoradas | {pending} a lançar."
121
+ )
122
+ self.notify_listeners()
123
+
124
+ # ------------------------------------------------------------------ gerar tabela (cache)
125
+ def generate_table(self, files: ArrayList[File]) -> None:
126
+ if self.running or self.generating:
127
+ self.show_info("Existe outra operação em andamento.")
128
+ return
129
+ if files.empty():
130
+ return
131
+
132
+ self.generating = True
133
+ self.progress = (0, 0)
134
+ self.status_text = (
135
+ f"Gerando tabela a partir de {files.size()} arquivos (somente cache)..."
136
+ )
137
+ self.notify_listeners()
138
+
139
+ try:
140
+ self.model.start_generate(
141
+ files,
142
+ on_progress=lambda d, t: _ui(lambda: self._on_gen_progress(d, t)),
143
+ on_done=lambda r, e: _ui(lambda: self._on_gen_done(r, e)),
144
+ )
145
+ except Exception as e:
146
+ self.generating = False
147
+ self.show_error(str(e))
148
+
149
+ def _on_gen_progress(self, done: int, total: int) -> None:
150
+ self.progress = (done, total)
151
+ self.status_text = f"Calculando hashes {done}/{total}"
152
+ if done == total or done % 25 == 0: # evita piscar a tela
153
+ self.notify_listeners()
154
+
155
+ def _on_gen_done(
156
+ self, res: ResultGenerateTable | None, err: Exception | None
157
+ ) -> None:
158
+ self.generating = False
159
+ self.progress = (0, 0)
160
+
161
+ if err is not None:
162
+ self.status_text = f"Erro ao gerar tabela: {err}"
163
+ self.notify_listeners()
164
+ self.show_error(str(err))
165
+ return
166
+
167
+ if res is None or res.table is None:
168
+ self.status_text = "Nenhum arquivo encontrado no cache."
169
+ self.notify_listeners()
170
+ self.show_info(
171
+ "Nenhum dos arquivos está na tabela TOI em cache.\n"
172
+ "Aplique o OCR/relatório TOI antes de gerar a tabela."
173
+ )
174
+ return
175
+
176
+ self.status_text = f"Tabela gerada: {res.found}/{res.total} arquivos."
77
177
  self.notify_listeners()
78
178
 
179
+ msg = f"Tabela gerada com {res.found} de {res.total} arquivos.\n\n{res.file.absolute()}"
180
+ if res.not_found:
181
+ sample = "\n".join(res.not_found[:10])
182
+ more = (
183
+ f"\n... e mais {len(res.not_found) - 10}"
184
+ if len(res.not_found) > 10
185
+ else ""
186
+ )
187
+ msg += f"\n\nSem dados no cache ({len(res.not_found)}):\n{sample}{more}"
188
+ if res.invalid:
189
+ msg += f"\n\nIgnorados (não são PDF/imagem): {res.invalid}"
190
+ self.show_info(msg)
191
+
79
192
  def set_table_toi(self, tb: TableData[HeaderFilesTOI]) -> None:
80
193
  if tb.is_empty():
81
194
  self.show_error("Tabela TOI vazia")
@@ -122,17 +235,27 @@ class ControllerForms(ChangeNotifier):
122
235
 
123
236
  # ------------------------------------------------------------------ execução
124
237
  def start_forms(self) -> None:
125
- if self.running:
126
- self.show_info("Já existe um lançamento em andamento.")
238
+
239
+ if self.running or self.generating:
240
+ self.show_info("Já existe uma operação em andamento.")
127
241
  return
128
242
  if self.map_rows.is_empty():
129
243
  self.show_error("Importe uma tabela TOI antes de iniciar.")
130
244
  return
131
245
 
246
+ try:
247
+ self._apply_master(self.map_rows) # o master pode ter mudado
248
+ except Exception as e:
249
+ self.show_error(f"Falha ao ler o arquivo master:\n{e}")
250
+ return
251
+
132
252
  jobs = {}
133
253
  for idx in self.map_rows.header():
134
- if self.map_rows.get_status(idx) == StatusForms.SUCCESS:
135
- continue # já lançada em uma execução anterior desta sessão
254
+ if self.map_rows.get_status(idx) in (
255
+ StatusForms.SUCCESS,
256
+ StatusForms.SKIPPED,
257
+ ):
258
+ continue
136
259
  try:
137
260
  jobs[idx] = self.model.build_submission(self.map_rows[idx])
138
261
  self.map_rows.update_status(idx, StatusForms.PENDING)
@@ -140,10 +263,13 @@ class ControllerForms(ChangeNotifier):
140
263
  self.map_rows.update_status(idx, StatusForms.FAILED, str(e))
141
264
 
142
265
  if not jobs:
143
- self.status_text = "Nenhuma linha válida para lançar."
266
+ self.status_text = (
267
+ "Nenhuma carta nova para lançar (todas já lançadas ou inválidas)."
268
+ )
144
269
  self.notify_listeners()
145
270
  return
146
271
 
272
+ # ... resto idêntico ao atual
147
273
  config = self.model.create_config(
148
274
  self.get_config().value.work_dir, self.send_receipt
149
275
  )
@@ -184,6 +310,12 @@ class ControllerForms(ChangeNotifier):
184
310
  def _on_status(self, idx: int, status: StatusForms, msg: str) -> None:
185
311
  self.map_rows.update_status(idx, status, msg)
186
312
 
313
+ if status == StatusForms.SUCCESS:
314
+ try:
315
+ self.master.register(self.map_rows[idx])
316
+ except Exception as e:
317
+ self.show_error(f"Carta lançada, mas falhou ao gravar no master:\n{e}")
318
+
187
319
  if status in (StatusForms.SUCCESS, StatusForms.FAILED):
188
320
  self._since_save += 1
189
321
  if self._since_save >= self.save_every:
@@ -203,7 +335,9 @@ class ControllerForms(ChangeNotifier):
203
335
  def _on_done(self, err: Exception | None, stopped: bool) -> None:
204
336
  ok = self.map_rows.count_status(StatusForms.SUCCESS)
205
337
  bad = self.map_rows.count_status(StatusForms.FAILED)
206
- summary = f"sucesso: {ok} | falha: {bad}"
338
+ skip = self.map_rows.count_status(StatusForms.SKIPPED)
339
+ summary = f"sucesso: {ok} | falha: {bad} | ignoradas: {skip}"
340
+ # summary = f"sucesso: {ok} | falha: {bad}"
207
341
 
208
342
  if err is not None:
209
343
  self.status_text = f"Erro: {err}"
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import threading
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ from table_stream import RowH, TableData
11
+ from cartas import BuilderConfigTOI
12
+
13
+ from converter.page_forms.common import HeaderForms, clean_number, clean_text
14
+
15
+
16
+ class HeaderMasterForms(StrEnum):
17
+ KEY = "key_uc_toi"
18
+ UC = HeaderForms.UC.value
19
+ TOI = HeaderForms.TOI.value
20
+ DATA_ENTREGA = HeaderForms.DATA_ENTREGA.value
21
+ LOCAL_ENTREGA = HeaderForms.LOCAL_ENTREGA.value
22
+ TIPO = HeaderForms.TIPO.value
23
+ HASH_FILE = HeaderForms.HASH_FILE.value
24
+ FULL_PATH = HeaderForms.FULL_PATH.value
25
+ DATA_LANCAMENTO = "data_lancamento"
26
+
27
+
28
+ def make_key(uc: Any, toi: Any) -> str:
29
+ """Chave de uma carta: UC sozinha não identifica, usa UC+TOI."""
30
+ return f"{clean_number(uc)}_{clean_number(toi)}"
31
+
32
+
33
+ class ControllerMasterForms(object):
34
+ """
35
+ Arquivo master do Forms: tudo que já foi lançado com sucesso.
36
+ Equivalente ao ControllerTOI().load_table_toi(), mas exclusivo do Forms.
37
+ """
38
+
39
+ COLUMNS: list[str] = [h.value for h in HeaderMasterForms]
40
+
41
+ def __init__(self) -> None:
42
+ self._lock = threading.Lock()
43
+
44
+ @property
45
+ def file_master(self):
46
+ return BuilderConfigTOI().builder().file_master_forms
47
+
48
+ # ---------------------------------------------------------------- leitura
49
+ def load_dataframe(self) -> pd.DataFrame:
50
+ f = self.file_master
51
+ if not f.exists():
52
+ return pd.DataFrame(columns=self.COLUMNS)
53
+
54
+ df = pd.read_excel(f.absolute(), dtype=str).fillna("")
55
+ for c in self.COLUMNS:
56
+ if c not in df.columns:
57
+ df[c] = ""
58
+ return df[self.COLUMNS]
59
+
60
+ def load_table_master(self) -> TableData[HeaderMasterForms]:
61
+ return TableData(self.load_dataframe())
62
+
63
+ def load_keys(self) -> set[str]:
64
+ df = self.load_dataframe()
65
+ return set(df[HeaderMasterForms.KEY.value].astype(str).tolist())
66
+
67
+ def contains(self, uc: Any, toi: Any) -> bool:
68
+ return make_key(uc, toi) in self.load_keys()
69
+
70
+ # ---------------------------------------------------------------- escrita
71
+ def register(self, row: RowH[HeaderForms, Any]) -> bool:
72
+ """
73
+ Grava uma carta lançada com sucesso. Retorna False se a chave já existia.
74
+ Escrita atômica (tmp + replace): nunca deixa .xlsx corrompido.
75
+ """
76
+ uc = clean_number(row.get(HeaderForms.UC))
77
+ toi = clean_number(row.get(HeaderForms.TOI))
78
+ key = make_key(uc, toi)
79
+
80
+ with self._lock:
81
+ df = self.load_dataframe()
82
+ if key in set(df[HeaderMasterForms.KEY.value].tolist()):
83
+ return False
84
+
85
+ record = {
86
+ HeaderMasterForms.KEY.value: key,
87
+ HeaderMasterForms.UC.value: uc,
88
+ HeaderMasterForms.TOI.value: toi,
89
+ HeaderMasterForms.DATA_ENTREGA.value: clean_text(
90
+ row.get(HeaderForms.DATA_ENTREGA)
91
+ ),
92
+ HeaderMasterForms.LOCAL_ENTREGA.value: clean_text(
93
+ row.get(HeaderForms.LOCAL_ENTREGA)
94
+ ),
95
+ HeaderMasterForms.TIPO.value: clean_text(row.get(HeaderForms.TIPO)),
96
+ HeaderMasterForms.HASH_FILE.value: clean_text(
97
+ row.get(HeaderForms.HASH_FILE)
98
+ ),
99
+ HeaderMasterForms.FULL_PATH.value: clean_text(
100
+ row.get(HeaderForms.FULL_PATH)
101
+ ),
102
+ HeaderMasterForms.DATA_LANCAMENTO.value: datetime.now().strftime(
103
+ "%d/%m/%Y %H:%M:%S"
104
+ ),
105
+ }
106
+ new = pd.DataFrame([record], columns=self.COLUMNS)
107
+ final = new if df.empty else pd.concat([df, new], ignore_index=True)
108
+
109
+ path = self.file_master.absolute()
110
+ tmp = f"{path}-tmp.xlsx"
111
+ final.to_excel(tmp, index=False)
112
+ os.replace(tmp, path)
113
+ return True
@@ -1,13 +1,29 @@
1
1
  from __future__ import annotations
2
2
  import os
3
3
  import threading
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from cartas import BuilderConfigTOI
5
7
  from typing import Any, Callable
6
8
 
7
9
  import pandas as pd
8
10
 
9
- from table_stream import ArrayList, File, Directory, HashMapDict, RowH, load_file_hash
10
- from cartas import TableTOI, ControllerTOI, HeaderTOI
11
+ from table_stream import (
12
+ ArrayList,
13
+ File,
14
+ Directory,
15
+ HashMapDict,
16
+ RowH,
17
+ TableData,
18
+ load_file_hash,
19
+ )
20
+ from cartas import (
21
+ TableTOI,
22
+ ControllerTOI,
23
+ HeaderEntregaTOI,
24
+ HeaderFilesTOI,
25
+ BuilderCartas,
26
+ )
11
27
 
12
28
  from converter.page_forms.common import (
13
29
  HeaderForms,
@@ -43,10 +59,21 @@ class FormsCallbacks:
43
59
  on_done: Callable[[Exception | None, bool], None] = _noop # (erro, interrompido)
44
60
 
45
61
 
62
+ @dataclass
63
+ class ResultGenerateTable:
64
+ table: TableData[HeaderFilesTOI] | None = None
65
+ file: File | None = None
66
+ total: int = 0
67
+ found: int = 0
68
+ invalid: int = 0
69
+ not_found: list[str] = field(default_factory=list)
70
+
71
+
46
72
  class ModelForms(object):
47
73
 
48
74
  def __init__(self) -> None:
49
75
  self._relatorio_toi: ControllerTOI | None = None
76
+ self.builder_carta = BuilderCartas()
50
77
  self._thread: threading.Thread | None = None
51
78
  self._stop = threading.Event()
52
79
 
@@ -66,21 +93,23 @@ class ModelForms(object):
66
93
  self._relatorio_toi = ControllerTOI()
67
94
  return self._relatorio_toi
68
95
 
69
- def create_relatorio_toi(self, files: ArrayList[File]) -> TableTOI:
96
+ def create_relatorio_toi(self, files: ArrayList[File]) -> TableData[HeaderFilesTOI]:
70
97
  if files.empty():
71
- return TableTOI()
98
+ return self.builder_carta.create_void()
72
99
 
73
100
  mp = HashMapDict()
74
101
  files.for_each(lambda f: mp.set_value(load_file_hash(f), f))
75
102
 
76
- tb = self.relatorio_toi.load_from_map(mp)
103
+ tb: TableData[HeaderFilesTOI] | None = self.relatorio_toi.load_from_map(mp)
77
104
  if tb is None:
78
- return TableTOI()
105
+ return self.builder_carta.create_void()
79
106
 
80
107
  df = tb.to_dataframe().astype(str)
81
- df = df[df[HeaderTOI.UC] != TableTOI.NULL_STRING]
82
- df = df[df[HeaderTOI.DATE] != TableTOI.NULL_STRING]
83
- return TableTOI.create_from_data(df)
108
+
109
+ df = df[df[HeaderFilesTOI.UC] != TableTOI.NULL_STRING]
110
+ df = df[df[HeaderFilesTOI.TOI] != TableTOI.NULL_STRING]
111
+ df = df[df[HeaderFilesTOI.DATE] != TableTOI.NULL_STRING]
112
+ return self.builder_carta.from_data(df)
84
113
 
85
114
  # ------------------------------------------------------------------ validação/conversão
86
115
  @staticmethod
@@ -213,3 +242,88 @@ class ModelForms(object):
213
242
  finally:
214
243
  service.stop()
215
244
  cb.on_done(error, self._stop.is_set())
245
+
246
+ # ------------------------------------------------------------------
247
+ # gerar tabela (SOMENTE cache, sem OCR)
248
+ # ------------------------------------------------------------------
249
+ def is_generating(self) -> bool:
250
+ t = getattr(self, "_gen_thread", None)
251
+ return t is not None and t.is_alive()
252
+
253
+ def generate_table(
254
+ self,
255
+ files: ArrayList[File],
256
+ on_progress: Callable[[int, int], None] = _noop,
257
+ ) -> ResultGenerateTable:
258
+ """Calcula o hash, consulta ControllerTOI.find_on_cache() e NÃO aplica OCR."""
259
+ res = ResultGenerateTable()
260
+ valid = ArrayList[File]()
261
+
262
+ for f in files:
263
+ if f.is_image() or f.is_pdf():
264
+ valid.add(f)
265
+ else:
266
+ res.invalid += 1
267
+
268
+ mp = HashMapDict()
269
+ total = valid.size()
270
+ for n, f in enumerate(valid):
271
+ mp.set_value(load_file_hash(f), f)
272
+ on_progress(n + 1, total)
273
+
274
+ res.total = mp.size_header()
275
+ if mp.is_empty():
276
+ return res
277
+
278
+ tb: TableData[HeaderFilesTOI] = self.relatorio_toi.find_on_cache(mp)
279
+ found_hashes: set[str] = set()
280
+
281
+ if not tb.is_empty():
282
+ df = tb.to_dataframe().astype(str)
283
+ for h in (HeaderFilesTOI.UC, HeaderFilesTOI.TOI, HeaderFilesTOI.DATE):
284
+ df = df[df[h] != TableTOI.NULL_STRING]
285
+
286
+ if not df.empty:
287
+ found_hashes = set(df[HeaderFilesTOI.HASH_FILE].tolist())
288
+ res.table = self.builder_carta.from_data(df)
289
+
290
+ res.found = len(found_hashes)
291
+ res.not_found = [
292
+ mp.get_value(h).basename() for h in mp.header() if h not in found_hashes
293
+ ]
294
+ return res
295
+
296
+ def save_generated_table(self, tb: TableData[HeaderFilesTOI]) -> File:
297
+ """Grava em ConfigTOI.dir_relatorios (padrão do projeto)."""
298
+ cfg = BuilderConfigTOI().builder()
299
+ out = cfg.dir_relatorios.join_file(
300
+ f"tabela-toi-{datetime.now():%Y%m%d-%H%M%S}.xlsx"
301
+ )
302
+ tmp = f"{out.absolute()}-tmp.xlsx"
303
+ tb.to_dataframe().to_excel(tmp, index=False)
304
+ os.replace(tmp, out.absolute())
305
+ return out
306
+
307
+ def start_generate(
308
+ self,
309
+ files: ArrayList[File],
310
+ on_progress: Callable[[int, int], None],
311
+ on_done: Callable[[ResultGenerateTable | None, Exception | None], None],
312
+ ) -> None:
313
+ if self.is_generating():
314
+ raise RuntimeError("Já existe uma geração de tabela em andamento")
315
+
316
+ def work() -> None:
317
+ res: ResultGenerateTable | None = None
318
+ err: Exception | None = None
319
+ try:
320
+ res = self.generate_table(files, on_progress)
321
+ if res.table is not None:
322
+ res.file = self.save_generated_table(res.table)
323
+ except Exception as e:
324
+ err = e
325
+ finally:
326
+ on_done(res, err)
327
+
328
+ self._gen_thread = threading.Thread(target=work, daemon=True, name="gen-table")
329
+ self._gen_thread.start()