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.
- get_sinapi-3.1.0.dist-info/METADATA +236 -0
- get_sinapi-3.1.0.dist-info/RECORD +30 -0
- get_sinapi-3.1.0.dist-info/WHEEL +5 -0
- get_sinapi-3.1.0.dist-info/entry_points.txt +2 -0
- get_sinapi-3.1.0.dist-info/licenses/LICENSE +21 -0
- get_sinapi-3.1.0.dist-info/top_level.txt +1 -0
- getsinapi/__init__.py +2 -0
- getsinapi/api/__init__.py +1 -0
- getsinapi/api/main.py +791 -0
- getsinapi/bootstrap.py +422 -0
- getsinapi/cli.py +206 -0
- getsinapi/collectors/__init__.py +1 -0
- getsinapi/collectors/caixa.py +386 -0
- getsinapi/collectors/history.py +238 -0
- getsinapi/config.py +60 -0
- getsinapi/domain.py +74 -0
- getsinapi/exporter.py +189 -0
- getsinapi/extractors/__init__.py +1 -0
- getsinapi/extractors/compositions.py +213 -0
- getsinapi/extractors/price_tables.py +151 -0
- getsinapi/extractors/reference_workbook.py +68 -0
- getsinapi/extractors/sinapi_parser.py +158 -0
- getsinapi/extractors/workbooks.py +126 -0
- getsinapi/normalizers.py +114 -0
- getsinapi/pipeline/__init__.py +1 -0
- getsinapi/pipeline/normalize.py +114 -0
- getsinapi/services/__init__.py +1 -0
- getsinapi/services/costing.py +332 -0
- getsinapi/storage/__init__.py +1 -0
- getsinapi/storage/sqlite.py +342 -0
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."""
|