get-sinapi 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
getsinapi/bootstrap.py ADDED
@@ -0,0 +1,422 @@
1
+ """Bootstrap histórico, incremental e idempotente da base oficial SINAPI."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import sqlite3
8
+ import threading
9
+ import time
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ from curl_cffi import requests as browser_requests
14
+
15
+ from getsinapi.collectors.caixa import CaixaDownloadError, download_monthly_xlsx
16
+ from getsinapi.collectors.history import (
17
+ LEGACY_LAYOUT_END,
18
+ UNIFIED_LAYOUT_START,
19
+ discover_historical_xlsx_catalog,
20
+ )
21
+ from getsinapi.config import Settings
22
+ from getsinapi.extractors.workbooks import extract_and_catalog
23
+ from getsinapi.pipeline.normalize import normalize_reference_workbook
24
+ from getsinapi.storage.sqlite import load_silver_reference
25
+
26
+ log = logging.getLogger("getsinapi.bootstrap")
27
+ _BOOTSTRAP_LOCK = threading.Lock()
28
+ DISCOVERY_CACHE_TTL_SECONDS = 6 * 60 * 60
29
+ REQUIRED_SHEETS = {"ISD", "ICD", "ISE", "Analítico"}
30
+
31
+
32
+ def _month_shift(year: int, month: int, delta: int) -> tuple[int, int]:
33
+ """Desloca uma competência mensal sem depender de datas ou fuso horário."""
34
+ absolute_month = year * 12 + (month - 1) + delta
35
+ shifted_year, zero_based_month = divmod(absolute_month, 12)
36
+ return shifted_year, zero_based_month + 1
37
+
38
+
39
+ def _database_path(settings: Settings) -> Path:
40
+ return settings.data_dir / "getsinapi.sqlite3"
41
+
42
+
43
+ def database_is_valid(path: Path) -> bool:
44
+ if not path.is_file() or path.stat().st_size == 0:
45
+ return False
46
+ try:
47
+ with sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5) as db:
48
+ if db.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
49
+ return False
50
+ tables = {r[0] for r in db.execute(
51
+ "SELECT name FROM sqlite_master WHERE type='table'"
52
+ )}
53
+ required = {"source_references", "input_prices", "compositions", "composition_items"}
54
+ if not required <= tables:
55
+ return False
56
+ return db.execute("SELECT COUNT(*) FROM source_references").fetchone()[0] > 0
57
+ except (sqlite3.Error, OSError):
58
+ return False
59
+
60
+
61
+ def _loaded_and_complete(database: Path, reference: str) -> bool:
62
+ """Verifica a presença dos dados sem executar integrity_check em todo o banco.
63
+
64
+ PRAGMA integrity_check é uma varredura integral e não deve ser repetida
65
+ antes/depois de cada competência durante a ingestão histórica.
66
+ A integridade completa é verificada uma vez no início/final do bootstrap.
67
+ """
68
+ if not database.is_file() or database.stat().st_size == 0:
69
+ return False
70
+ try:
71
+ with sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=5) as db:
72
+ tables = {row[0] for row in db.execute(
73
+ "SELECT name FROM sqlite_master WHERE type='table'"
74
+ )}
75
+ required = {"source_references", "input_prices", "compositions", "composition_items"}
76
+ if not required <= tables:
77
+ return False
78
+ source = db.execute(
79
+ "SELECT 1 FROM source_references WHERE reference=?", (reference,)
80
+ ).fetchone()
81
+ if not source:
82
+ return False
83
+ prices, compositions, items = db.execute(
84
+ """SELECT
85
+ (SELECT COUNT(*) FROM input_prices WHERE reference=?),
86
+ (SELECT COUNT(*) FROM compositions WHERE reference=?),
87
+ (SELECT COUNT(*) FROM composition_items WHERE reference=?)""",
88
+ (reference, reference, reference),
89
+ ).fetchone()
90
+ return prices > 0 and compositions > 0 and items > 0
91
+ except sqlite3.Error:
92
+ return False
93
+
94
+
95
+ def _sha256(path: Path) -> str:
96
+ digest = hashlib.sha256()
97
+ with path.open("rb") as stream:
98
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
99
+ digest.update(chunk)
100
+ return digest.hexdigest()
101
+
102
+
103
+ def _find_archive(settings: Settings, reference: str) -> Path | None:
104
+ for path in sorted(settings.raw_dir.glob(f"SINAPI-{reference}-formato-xlsx-*.zip")):
105
+ if zipfile.is_zipfile(path):
106
+ return path
107
+ return None
108
+
109
+
110
+ def _catalog_valid(settings: Settings, reference: str, archive: Path) -> tuple[dict, Path] | None:
111
+ digest = _sha256(archive)
112
+ catalog_path = settings.manifest_dir / reference / f"workbook-catalog-{digest[:12]}.json"
113
+ destination = settings.staging_dir / reference / digest[:12]
114
+ if not catalog_path.is_file():
115
+ return None
116
+ try:
117
+ catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
118
+ if catalog.get("reference") != reference or catalog.get("source_sha256") != digest:
119
+ return None
120
+ for entry in catalog.get("files", []):
121
+ file_path = destination / entry["path"]
122
+ if not file_path.is_file() or file_path.stat().st_size != entry["size_bytes"]:
123
+ return None
124
+ return catalog, destination
125
+ except (OSError, ValueError, KeyError, TypeError):
126
+ return None
127
+
128
+
129
+ def _normalization_valid(settings: Settings, reference: str) -> bool:
130
+ folder = settings.processed_dir / reference
131
+ manifest_path = folder / "normalization-manifest.json"
132
+ try:
133
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
134
+ if manifest.get("reference") != reference:
135
+ return False
136
+ artifacts = {item["file"]: item for item in manifest["artifacts"]}
137
+ expected = {"input_prices.jsonl", "compositions.jsonl", "composition_items.jsonl"}
138
+ if set(artifacts) != expected:
139
+ return False
140
+ for name, metadata in artifacts.items():
141
+ path = folder / name
142
+ if not path.is_file() or path.stat().st_size != metadata["size_bytes"]:
143
+ return False
144
+ if _sha256(path) != metadata["sha256"]:
145
+ return False
146
+ return True
147
+ except (OSError, ValueError, KeyError, TypeError):
148
+ return False
149
+
150
+
151
+ def _write_status(settings: Settings, *, reset: bool = False, **values) -> None:
152
+ path = settings.data_dir / "logs" / "bootstrap-state.json"
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ current = {}
155
+ if not reset and path.is_file():
156
+ try:
157
+ current = json.loads(path.read_text(encoding="utf-8"))
158
+ except (OSError, ValueError):
159
+ pass
160
+ current.update(values)
161
+ current["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
162
+ temporary = path.with_suffix(".json.tmp")
163
+ temporary.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
164
+ temporary.replace(path)
165
+
166
+
167
+ def _process_reference(
168
+ config: Settings,
169
+ database: Path,
170
+ reference: str,
171
+ source_url: str,
172
+ session,
173
+ ) -> dict:
174
+ if _loaded_and_complete(database, reference):
175
+ return {"reference": reference, "status": "already_loaded"}
176
+
177
+ archive = _find_archive(config, reference)
178
+ if archive is None:
179
+ _write_status(
180
+ config, stage="download", current_reference=reference,
181
+ message=f"Baixando pacote oficial {reference}.",
182
+ )
183
+ archive = download_monthly_xlsx(
184
+ reference, settings=config, source_url=source_url, session=session
185
+ ).path
186
+
187
+ catalog_result = _catalog_valid(config, reference, archive)
188
+ if catalog_result is None:
189
+ _write_status(
190
+ config, stage="extraction", current_reference=reference,
191
+ message=f"Extraindo e catalogando {reference}.",
192
+ )
193
+ extracted = extract_and_catalog(archive, reference, settings=config)
194
+ catalog = extracted["catalog"]
195
+ destination = config.staging_dir / reference / _sha256(archive)[:12]
196
+ else:
197
+ catalog, destination = catalog_result
198
+
199
+ workbook_path = None
200
+ for entry in catalog.get("workbooks", []):
201
+ sheet_names = {sheet["name"] for sheet in entry.get("sheets", [])}
202
+ if REQUIRED_SHEETS <= sheet_names:
203
+ candidate = destination / entry["relative_path"]
204
+ if candidate.is_file():
205
+ workbook_path = candidate
206
+ break
207
+ if workbook_path is None:
208
+ raise CaixaDownloadError(
209
+ f"O pacote {reference} não contém XLSX com as abas "
210
+ f"oficiais {sorted(REQUIRED_SHEETS)}."
211
+ )
212
+
213
+ if not _normalization_valid(config, reference):
214
+ _write_status(
215
+ config, stage="normalization", current_reference=reference,
216
+ message=f"Normalizando preços e composições de {reference}.",
217
+ )
218
+ normalized = normalize_reference_workbook(
219
+ workbook_path, reference=reference, settings=config
220
+ )
221
+ else:
222
+ normalized = json.loads(
223
+ (config.processed_dir / reference / "normalization-manifest.json").read_text(
224
+ encoding="utf-8"
225
+ )
226
+ )
227
+
228
+ if not _loaded_and_complete(database, reference):
229
+ _write_status(
230
+ config, stage="database_load", current_reference=reference,
231
+ message=f"Carregando {reference} no SQLite sem remover o histórico existente.",
232
+ )
233
+ def report_load_progress(phase: str, records: int) -> None:
234
+ _write_status(
235
+ config,
236
+ status="running",
237
+ stage="database_load",
238
+ current_reference=reference,
239
+ message=(
240
+ f"Gravando {reference} no SQLite: "
241
+ f"{phase} — {records:,} registros processados."
242
+ ),
243
+ )
244
+
245
+ loaded = load_silver_reference(
246
+ config.processed_dir,
247
+ reference=reference,
248
+ progress_callback=report_load_progress,
249
+ )
250
+ else:
251
+ loaded = {"reference": reference, "database_path": str(database)}
252
+
253
+ if not _loaded_and_complete(database, reference):
254
+ raise RuntimeError(f"A validação pós-carga de {reference} não foi aprovada.")
255
+
256
+ return {
257
+ "reference": reference,
258
+ "status": "loaded",
259
+ "counts": normalized.get("counts", {}),
260
+ "load": loaded,
261
+ }
262
+
263
+
264
+ def _catalog_has_full_monthly_range(catalog: dict) -> bool:
265
+ """Reject stale/partial inventories that would hide earlier competencies."""
266
+ if UNIFIED_LAYOUT_START not in catalog:
267
+ return False
268
+ references = {str(reference) for reference in catalog}
269
+ last_reference = max(references)
270
+ year, month = 2025, 1
271
+ while f"{year:04d}-{month:02d}" <= last_reference:
272
+ if f"{year:04d}-{month:02d}" not in references:
273
+ return False
274
+ year, month = _month_shift(year, month, 1)
275
+ return True
276
+
277
+
278
+ def _load_discovery_cache(config: Settings, *, force: bool) -> dict[str, str] | None:
279
+ path = config.manifest_dir / "historical-catalog.json"
280
+ if force or not path.is_file():
281
+ return None
282
+ try:
283
+ payload = json.loads(path.read_text(encoding="utf-8"))
284
+ age = time.time() - float(payload["checked_at"])
285
+ catalog = payload["catalog"]
286
+ if (
287
+ age <= DISCOVERY_CACHE_TTL_SECONDS
288
+ and isinstance(catalog, dict)
289
+ and catalog
290
+ and _catalog_has_full_monthly_range(catalog)
291
+ ):
292
+ return {str(key): str(value) for key, value in catalog.items()}
293
+ except (OSError, ValueError, KeyError, TypeError):
294
+ return None
295
+ return None
296
+
297
+
298
+ def _save_discovery_cache(config: Settings, catalog: dict[str, str]) -> None:
299
+ path = config.manifest_dir / "historical-catalog.json"
300
+ path.parent.mkdir(parents=True, exist_ok=True)
301
+ temporary = path.with_suffix(".json.tmp")
302
+ temporary.write_text(
303
+ json.dumps({"checked_at": time.time(), "catalog": catalog}, ensure_ascii=False, indent=2),
304
+ encoding="utf-8",
305
+ )
306
+ temporary.replace(path)
307
+
308
+
309
+ def _run_bootstrap(settings: Settings | None = None, *, force: bool = False) -> dict:
310
+ config = settings or Settings.from_env()
311
+ config.create_data_directories()
312
+ database = _database_path(config)
313
+ had_database = database_is_valid(database)
314
+ _write_status(
315
+ config, reset=True, status="checking", stage="discovery", percent=0,
316
+ message="Descobrindo todas as competências XLSX publicadas no catálogo oficial da CAIXA.",
317
+ error=None,
318
+ )
319
+ try:
320
+ with browser_requests.Session(impersonate="chrome") as http_session:
321
+ catalog = _load_discovery_cache(config, force=force)
322
+ if catalog is None:
323
+ catalog = discover_historical_xlsx_catalog(
324
+ settings=config, session=http_session
325
+ )
326
+ _save_discovery_cache(config, catalog)
327
+
328
+ references = sorted(catalog)
329
+ inventory = {
330
+ "schema_version": "1.0",
331
+ "source": "CAIXA",
332
+ "discovered_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
333
+ "layout": "unified-xlsx",
334
+ "covered_from": UNIFIED_LAYOUT_START,
335
+ "legacy_layout_until": LEGACY_LAYOUT_END,
336
+ "references": [
337
+ {"reference": reference, "url": catalog[reference]}
338
+ for reference in references
339
+ ],
340
+ }
341
+ inventory_path = config.manifest_dir / "historical-inventory.json"
342
+ inventory_tmp = inventory_path.with_suffix(".json.tmp")
343
+ inventory_tmp.write_text(
344
+ json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8"
345
+ )
346
+ inventory_tmp.replace(inventory_path)
347
+
348
+ outcomes = []
349
+ failures = []
350
+ for index, reference in enumerate(references, start=1):
351
+ percent = round((index - 1) / max(len(references), 1) * 100)
352
+ _write_status(
353
+ config, status="running", stage="historical_ingestion",
354
+ percent=percent, current_reference=reference,
355
+ completed=len(outcomes), total=len(references),
356
+ message=f"Processando competência {reference} ({index}/{len(references)}).",
357
+ )
358
+ try:
359
+ result = _process_reference(
360
+ config, database, reference, catalog[reference], http_session
361
+ )
362
+ outcomes.append(result)
363
+ except Exception as exc:
364
+ log.exception("Falha ao processar competência histórica %s", reference)
365
+ failures.append({
366
+ "reference": reference,
367
+ "error": f"{type(exc).__name__}: {exc}",
368
+ })
369
+ _write_status(
370
+ config, status="running", stage="historical_ingestion",
371
+ current_reference=reference, failed=len(failures),
372
+ failures=failures,
373
+ message=f"Falha em {reference}; continuando as demais competências.",
374
+ )
375
+
376
+ loaded_references = [
377
+ item["reference"] for item in outcomes
378
+ if item["status"] in {"loaded", "already_loaded"}
379
+ ]
380
+ legacy_pending = True
381
+ result = {
382
+ "status": "partial" if legacy_pending or failures else "complete",
383
+ "database": str(database),
384
+ "discovered": len(references),
385
+ "loaded_or_verified": len(loaded_references),
386
+ "failed": failures,
387
+ "references": loaded_references,
388
+ "covered_from": UNIFIED_LAYOUT_START,
389
+ "legacy_history_pending": legacy_pending,
390
+ "legacy_layout_until": LEGACY_LAYOUT_END,
391
+ "previous_database_preserved": had_database,
392
+ }
393
+ if failures:
394
+ result["status"] = "partial"
395
+ _write_status(
396
+ config, **result, stage="partial" if legacy_pending or failures else "complete",
397
+ percent=100, current_reference=None,
398
+ message=(
399
+ "Pacotes XLSX unificados processados. O histórico legado até 2024 "
400
+ "ainda exige ingestão por UF e parser próprio."
401
+ ),
402
+ error=None if not failures else "Uma ou mais competências não foram processadas.",
403
+ )
404
+ return result
405
+ except Exception as exc:
406
+ log.exception("Falha na descoberta/ingestão histórica SINAPI")
407
+ _write_status(
408
+ config, status="error", stage="error", percent=None,
409
+ message="Ingestão interrompida; os dados históricos já persistidos foram preservados.",
410
+ error=f"{type(exc).__name__}: {exc}",
411
+ )
412
+ raise
413
+
414
+
415
+ def run_bootstrap(settings: Settings | None = None, *, force: bool = False) -> dict:
416
+ """Executa uma única atualização histórica por processo; chamadas concorrentes são coalescidas."""
417
+ if not _BOOTSTRAP_LOCK.acquire(blocking=False):
418
+ return {"status": "already_running", "message": "A atualização SINAPI já está em andamento."}
419
+ try:
420
+ return _run_bootstrap(settings, force=force)
421
+ finally:
422
+ _BOOTSTRAP_LOCK.release()
getsinapi/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ """Interface de linha de comando do GET-SINAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from getsinapi import __version__
10
+ from getsinapi.config import Settings
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="getsinapi",
16
+ description="Coleta, consulta e exporta dados SINAPI/CAIXA.",
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
19
+ commands = parser.add_subparsers(dest="command")
20
+ commands.add_parser("check-config", help="Valida as configurações locais.")
21
+ commands.add_parser("export", help="Abre o menu interativo de exportação em lote.")
22
+
23
+ download = commands.add_parser("download", help="Baixa o pacote XLSX mensal oficial da CAIXA.")
24
+ download.add_argument("--reference", required=True, help="Competência no formato AAAA-MM")
25
+
26
+ extract = commands.add_parser("extract", help="Extrai ZIP e cataloga planilhas XLSX.")
27
+ extract.add_argument("--archive", required=True, type=Path, help="Caminho do ZIP já baixado.")
28
+ extract.add_argument("--reference", required=True, help="Competência no formato AAAA-MM")
29
+
30
+ parse = commands.add_parser("parse", help="Normaliza preços e composições para Silver.")
31
+ parse.add_argument("--workbook", required=True, type=Path, help="XLSX oficial de referência.")
32
+ parse.add_argument("--reference", required=True, help="Competência no formato AAAA-MM")
33
+
34
+ load = commands.add_parser("load", help="Carrega os artefatos Silver no SQLite.")
35
+ load.add_argument("--reference", required=True, help="Competência no formato AAAA-MM")
36
+ return parser
37
+
38
+
39
+ def _ask_choice(prompt: str, options: dict[str, str], default: str | None = None) -> str:
40
+ while True:
41
+ suffix = f" [padrão {default}]" if default else ""
42
+ answer = input(f"{prompt}{suffix}: ").strip() or (default or "")
43
+ if answer in options:
44
+ return answer
45
+ print("Opção inválida. Escolha uma das opções exibidas.")
46
+
47
+
48
+ def _export_menu(settings: Settings) -> int:
49
+ from getsinapi.exporter import available_references, export_catalog
50
+
51
+ desktop_data = Path(os.getenv("LOCALAPPDATA", Path.home())) / "NRTech" / "GET-SINAPI-V3" / "data"
52
+ candidates = [
53
+ Path(os.getenv("GETSINAPI_DATABASE_PATH", "")) if os.getenv("GETSINAPI_DATABASE_PATH") else None,
54
+ desktop_data / "getsinapi.sqlite3",
55
+ settings.data_dir / "getsinapi.sqlite3",
56
+ ]
57
+ database = next((path for path in candidates if path and path.is_file()),
58
+ desktop_data / "getsinapi.sqlite3" if os.name == "nt" else settings.data_dir / "getsinapi.sqlite3")
59
+ if not database.is_file():
60
+ print(f"Banco SINAPI não encontrado: {database}")
61
+ print("Inicialize a base com os comandos download, extract, parse e load.")
62
+ return 2
63
+
64
+ print("\n" + "=" * 62)
65
+ print("GET-SINAPI | CENTRAL DE EXPORTAÇÃO EM LOTE")
66
+ print("=" * 62)
67
+ print("1. Exportar tudo (insumos, composições e componentes)")
68
+ print("2. Escolher categorias")
69
+ mode = _ask_choice("Selecione", {"1": "Tudo", "2": "Personalizado"}, "1")
70
+
71
+ categories = ["INSUMO", "COMPOSICAO", "COMPONENTE"]
72
+ if mode == "2":
73
+ print("\nCategorias disponíveis:")
74
+ print("1. Insumos 2. Composições 3. Componentes")
75
+ selected = input("Informe uma ou mais opções separadas por vírgula (ex.: 1,3): ").strip()
76
+ mapping = {"1": "INSUMO", "2": "COMPOSICAO", "3": "COMPONENTE"}
77
+ chosen = [mapping[item.strip()] for item in selected.split(",") if item.strip() in mapping]
78
+ if not chosen:
79
+ print("Nenhuma categoria válida foi selecionada.")
80
+ return 2
81
+ categories = list(dict.fromkeys(chosen))
82
+
83
+ refs = available_references(database)
84
+ if not refs:
85
+ print("A base SQLite não possui competências carregadas.")
86
+ return 2
87
+ print("\nCompetências disponíveis:")
88
+ for index, reference in enumerate(refs, start=1):
89
+ print(f"{index}. {reference}")
90
+ print("A. Todas as competências")
91
+ reference_choice = input("Competência [A]: ").strip().upper() or "A"
92
+ if reference_choice == "A":
93
+ references = None
94
+ elif reference_choice.isdigit() and 1 <= int(reference_choice) <= len(refs):
95
+ references = [refs[int(reference_choice) - 1]]
96
+ else:
97
+ print("Competência inválida.")
98
+ return 2
99
+
100
+ uf = input("\nUF (duas letras) ou ENTER para todas: ").strip().upper() or None
101
+ if uf and (len(uf) != 2 or not uf.isalpha()):
102
+ print("UF inválida.")
103
+ return 2
104
+
105
+ regime_choice = _ask_choice(
106
+ "Regime: 1 todos | 2 desonerado | 3 não desonerado",
107
+ {"1": "all", "2": "desonerado", "3": "nao_desonerado"},
108
+ "1",
109
+ )
110
+ regime = None if regime_choice == "1" else regime_choice
111
+
112
+ encargo_choice = _ask_choice(
113
+ "Encargos: 1 todos | 2 com encargos | 3 sem encargos",
114
+ {"1": "all", "2": "com_encargos", "3": "sem_encargos"},
115
+ "1",
116
+ )
117
+ encargo_mode = None if encargo_choice == "1" else encargo_choice
118
+
119
+ search = input("Pesquisar por código/descrição (ENTER para sem filtro): ").strip() or None
120
+ file_format = _ask_choice(
121
+ "Formato: 1 Excel (.xlsx) | 2 CSV | 3 Excel + CSV",
122
+ {"1": "xlsx", "2": "csv", "3": "both"},
123
+ "1",
124
+ )
125
+ default_output = database.parent / "exports"
126
+ output_text = input(f"Pasta de destino [{default_output}]: ").strip()
127
+ output_dir = Path(output_text).expanduser() if output_text else default_output
128
+
129
+ print("\nIniciando extração da base local...")
130
+ generated = export_catalog(
131
+ database,
132
+ output_dir,
133
+ file_format=file_format,
134
+ references=references,
135
+ uf=uf,
136
+ categories=categories,
137
+ regime=regime,
138
+ encargo_mode=encargo_mode,
139
+ search=search,
140
+ )
141
+ print(f"\nExportação concluída. Arquivos gerados: {len(generated)}")
142
+ for path in generated:
143
+ print(f" - {path}")
144
+ return 0
145
+
146
+
147
+ def main() -> int:
148
+ parser = build_parser()
149
+ args = parser.parse_args()
150
+ settings = Settings.from_env()
151
+
152
+ if args.command in (None, "export"):
153
+ settings.create_data_directories()
154
+ return _export_menu(settings)
155
+
156
+ if args.command == "check-config":
157
+ settings.create_data_directories()
158
+ print(f"Configuração válida | ambiente={settings.environment}")
159
+ print(f"Diretório de dados: {settings.data_dir}")
160
+ return 0
161
+
162
+ if args.command == "download":
163
+ from getsinapi.collectors.caixa import download_monthly_xlsx
164
+ artifact = download_monthly_xlsx(args.reference, settings=settings)
165
+ print(f"Download validado: {artifact.path}")
166
+ print(f"SHA-256: {artifact.sha256}")
167
+ print(f"Tamanho: {artifact.size_bytes} bytes")
168
+ return 0
169
+
170
+ if args.command == "extract":
171
+ from getsinapi.extractors.workbooks import extract_and_catalog
172
+ result = extract_and_catalog(args.archive, args.reference, settings=settings)
173
+ print(f"Arquivos extraídos: {len(result['files'])}")
174
+ print(f"Planilhas catalogadas: {len(result['workbooks'])}")
175
+ print(f"Catálogo: {result['catalog_path']}")
176
+ for workbook in result["workbooks"]:
177
+ print(f"- {workbook['file']} | abas: {len(workbook['sheets'])}")
178
+ return 0
179
+
180
+ if args.command == "parse":
181
+ from getsinapi.pipeline.normalize import normalize_reference_workbook
182
+ result = normalize_reference_workbook(args.workbook, reference=args.reference, settings=settings)
183
+ print(f"Competência: {result['reference']}")
184
+ print(f"Preços: {result['counts']['input_prices']:,}")
185
+ print(f"Composições: {result['counts']['compositions']:,}")
186
+ print(f"Itens de composição: {result['counts']['composition_items']:,}")
187
+ print(f"Advertências: {result['warning_count']:,}")
188
+ print(f"Manifesto: {result['manifest_path']}")
189
+ return 0
190
+
191
+ if args.command == "load":
192
+ from getsinapi.storage.sqlite import load_silver_reference
193
+ result = load_silver_reference(settings.processed_dir, reference=args.reference)
194
+ print(f"Competência carregada: {result['reference']}")
195
+ print(f"Preços: {result['input_prices']:,}")
196
+ print(f"Composições: {result['compositions']:,}")
197
+ print(f"Itens: {result['composition_items']:,}")
198
+ print(f"Banco: {result['database_path']}")
199
+ return 0
200
+
201
+ parser.error("Comando não reconhecido.")
202
+ return 2
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Coletores de fontes oficiais."""