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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Orquestra a leitura das tabelas oficiais de referência SINAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from openpyxl import load_workbook
|
|
9
|
+
|
|
10
|
+
from getsinapi.extractors.compositions import (
|
|
11
|
+
CompositionParseResult,
|
|
12
|
+
parse_analytic_compositions,
|
|
13
|
+
)
|
|
14
|
+
from getsinapi.extractors.price_tables import (
|
|
15
|
+
InputPriceParseResult,
|
|
16
|
+
parse_input_price_table,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
PRICE_SHEETS = ("ISD", "ICD", "ISE")
|
|
20
|
+
COMPOSITION_SHEET = "Analítico"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class ReferenceWorkbookResult:
|
|
25
|
+
reference: str
|
|
26
|
+
source_file: str
|
|
27
|
+
prices: tuple[InputPriceParseResult, ...]
|
|
28
|
+
compositions: CompositionParseResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_reference_workbook(
|
|
32
|
+
workbook_path: str | Path,
|
|
33
|
+
*,
|
|
34
|
+
reference: str,
|
|
35
|
+
) -> ReferenceWorkbookResult:
|
|
36
|
+
"""Extrai preços ISD/ICD/ISE e estrutura Analítico do mesmo XLSX oficial.
|
|
37
|
+
|
|
38
|
+
A função não persiste nem transforma ausência de preço em zero; entrega
|
|
39
|
+
resultados tipados para a próxima etapa de validação e carga.
|
|
40
|
+
"""
|
|
41
|
+
path = Path(workbook_path)
|
|
42
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
43
|
+
try:
|
|
44
|
+
available = set(workbook.sheetnames)
|
|
45
|
+
finally:
|
|
46
|
+
workbook.close()
|
|
47
|
+
|
|
48
|
+
required = {*PRICE_SHEETS, COMPOSITION_SHEET}
|
|
49
|
+
missing = sorted(required - available)
|
|
50
|
+
if missing:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"Planilha de referência incompleta; abas ausentes: "
|
|
53
|
+
+ ", ".join(missing)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
prices = tuple(
|
|
57
|
+
parse_input_price_table(path, sheet_name, reference=reference)
|
|
58
|
+
for sheet_name in PRICE_SHEETS
|
|
59
|
+
)
|
|
60
|
+
compositions = parse_analytic_compositions(
|
|
61
|
+
path, COMPOSITION_SHEET, reference=reference
|
|
62
|
+
)
|
|
63
|
+
return ReferenceWorkbookResult(
|
|
64
|
+
reference=reference,
|
|
65
|
+
source_file=path.name,
|
|
66
|
+
prices=prices,
|
|
67
|
+
compositions=compositions,
|
|
68
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Parser configurável para planilhas SINAPI catalogadas.
|
|
2
|
+
|
|
3
|
+
A detecção de colunas é conservadora: só mapeia cabeçalhos conhecidos.
|
|
4
|
+
Nenhuma coluna ou regime é inferido apenas pela posição da coluna.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from openpyxl import load_workbook
|
|
16
|
+
|
|
17
|
+
from getsinapi.domain import PriceObservation, Regime
|
|
18
|
+
from getsinapi.normalizers import classify_price
|
|
19
|
+
|
|
20
|
+
HEADER_ALIASES = {
|
|
21
|
+
"code": {"codigo", "cod", "codigo do insumo", "codigo da composicao"},
|
|
22
|
+
"description": {"descricao", "descricao do insumo", "descricao da composicao"},
|
|
23
|
+
"unit": {"unidade", "un"},
|
|
24
|
+
"price": {"preco", "preco mediano", "custo"},
|
|
25
|
+
"status": {"situacao", "observacao", "status", "nota"},
|
|
26
|
+
}
|
|
27
|
+
UF_PATTERN = re.compile(
|
|
28
|
+
r"\b(AC|AL|AP|AM|BA|CE|DF|ES|GO|MA|MT|MS|MG|PA|PB|PR|PE|PI|RJ|RN|RS|RO|RR|SC|SP|SE|TO)\b"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class SheetParseResult:
|
|
34
|
+
sheet_name: str
|
|
35
|
+
regime: Regime | None
|
|
36
|
+
uf: str | None
|
|
37
|
+
header_row: int | None
|
|
38
|
+
records: tuple[PriceObservation, ...]
|
|
39
|
+
warnings: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_header(value: Any) -> str:
|
|
43
|
+
if value is None:
|
|
44
|
+
return ""
|
|
45
|
+
text = unicodedata.normalize("NFKD", str(value))
|
|
46
|
+
text = "".join(char for char in text if not unicodedata.combining(char))
|
|
47
|
+
return re.sub(r"\s+", " ", re.sub(r"[^a-zA-Z0-9 ]", " ", text)).strip().lower()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detect_regime(sheet_name: str) -> Regime | None:
|
|
51
|
+
normalized = normalize_header(sheet_name)
|
|
52
|
+
if "nao desonerado" in normalized or "nao desonerada" in normalized:
|
|
53
|
+
return Regime.NAO_DESONERADO
|
|
54
|
+
if "desonerado" in normalized or "desonerada" in normalized:
|
|
55
|
+
return Regime.DESONERADO
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect_uf(*values: Any) -> str | None:
|
|
60
|
+
text = " ".join(str(value) for value in values if value is not None).upper()
|
|
61
|
+
matches = UF_PATTERN.findall(text)
|
|
62
|
+
return matches[0] if len(set(matches)) == 1 else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _column_map(row: tuple[Any, ...]) -> dict[str, int]:
|
|
66
|
+
normalized = [normalize_header(value) for value in row]
|
|
67
|
+
result: dict[str, int] = {}
|
|
68
|
+
for field, aliases in HEADER_ALIASES.items():
|
|
69
|
+
matches = [index for index, value in enumerate(normalized) if value in aliases]
|
|
70
|
+
if len(matches) == 1:
|
|
71
|
+
result[field] = matches[0]
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cell(row: tuple[Any, ...], columns: dict[str, int], field: str) -> Any:
|
|
76
|
+
index = columns.get(field)
|
|
77
|
+
return row[index] if index is not None and index < len(row) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_worksheet(
|
|
81
|
+
workbook_path: str | Path,
|
|
82
|
+
sheet_name: str,
|
|
83
|
+
*,
|
|
84
|
+
reference: str,
|
|
85
|
+
source_file: str | None = None,
|
|
86
|
+
) -> SheetParseResult:
|
|
87
|
+
path = Path(workbook_path)
|
|
88
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
89
|
+
try:
|
|
90
|
+
if sheet_name not in workbook.sheetnames:
|
|
91
|
+
raise ValueError(f"Aba inexistente: {sheet_name}")
|
|
92
|
+
sheet = workbook[sheet_name]
|
|
93
|
+
regime = detect_regime(sheet_name)
|
|
94
|
+
rows = sheet.iter_rows(values_only=True)
|
|
95
|
+
header_row = None
|
|
96
|
+
columns: dict[str, int] = {}
|
|
97
|
+
uf: str | None = None
|
|
98
|
+
context_rows: list[Any] = []
|
|
99
|
+
|
|
100
|
+
for row_number, row in enumerate(rows, start=1):
|
|
101
|
+
context_rows.extend(row)
|
|
102
|
+
candidate = _column_map(tuple(row))
|
|
103
|
+
if {"code", "description", "unit", "price"}.issubset(candidate):
|
|
104
|
+
header_row = row_number
|
|
105
|
+
columns = candidate
|
|
106
|
+
uf = detect_uf(*context_rows)
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
if header_row is None:
|
|
110
|
+
return SheetParseResult(
|
|
111
|
+
sheet_name,
|
|
112
|
+
regime,
|
|
113
|
+
None,
|
|
114
|
+
None,
|
|
115
|
+
(),
|
|
116
|
+
("Cabeçalho não reconhecido; layout requer mapeamento explícito.",),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
records: list[PriceObservation] = []
|
|
120
|
+
warnings: list[str] = []
|
|
121
|
+
for row_number, row in enumerate(rows, start=header_row + 1):
|
|
122
|
+
values = tuple(row)
|
|
123
|
+
code = _cell(values, columns, "code")
|
|
124
|
+
description = _cell(values, columns, "description")
|
|
125
|
+
unit = _cell(values, columns, "unit")
|
|
126
|
+
raw_price = _cell(values, columns, "price")
|
|
127
|
+
raw_status = _cell(values, columns, "status")
|
|
128
|
+
if code is None and description is None:
|
|
129
|
+
continue
|
|
130
|
+
if code is None or description is None or unit is None:
|
|
131
|
+
warnings.append(f"Linha {row_number}: registro incompleto ignorado.")
|
|
132
|
+
continue
|
|
133
|
+
if uf is None or regime is None:
|
|
134
|
+
warnings.append(
|
|
135
|
+
f"Linha {row_number}: UF ou regime não identificados; registro não emitido."
|
|
136
|
+
)
|
|
137
|
+
continue
|
|
138
|
+
price, status = classify_price(raw_price, raw_status)
|
|
139
|
+
records.append(
|
|
140
|
+
PriceObservation(
|
|
141
|
+
reference=reference,
|
|
142
|
+
uf=uf,
|
|
143
|
+
regime=regime,
|
|
144
|
+
code=str(code).strip(),
|
|
145
|
+
description=str(description).strip(),
|
|
146
|
+
unit=str(unit).strip(),
|
|
147
|
+
price=price,
|
|
148
|
+
status=status,
|
|
149
|
+
source_file=source_file or path.name,
|
|
150
|
+
source_sheet=sheet_name,
|
|
151
|
+
source_row=row_number,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return SheetParseResult(
|
|
155
|
+
sheet_name, regime, uf, header_row, tuple(records), tuple(warnings)
|
|
156
|
+
)
|
|
157
|
+
finally:
|
|
158
|
+
workbook.close()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Extração segura e inventário de planilhas XLSX de pacotes SINAPI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from zipfile import BadZipFile
|
|
10
|
+
|
|
11
|
+
from openpyxl import load_workbook
|
|
12
|
+
|
|
13
|
+
from getsinapi.collectors.caixa import CaixaDownloadError, safe_extract_zip
|
|
14
|
+
from getsinapi.config import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sha256(path: Path) -> str:
|
|
18
|
+
digest = hashlib.sha256()
|
|
19
|
+
with path.open("rb") as stream:
|
|
20
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
21
|
+
digest.update(chunk)
|
|
22
|
+
return digest.hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json_value(value):
|
|
26
|
+
"""Converte valores de células para tipos serializáveis sem alterar seu significado."""
|
|
27
|
+
if hasattr(value, "isoformat"):
|
|
28
|
+
return value.isoformat()
|
|
29
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
30
|
+
return value
|
|
31
|
+
return str(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inspect_workbook(path: Path) -> dict:
|
|
35
|
+
"""Retorna metadados e amostra técnica das abas, sem inferir conteúdo de negócio."""
|
|
36
|
+
try:
|
|
37
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
38
|
+
except (BadZipFile, OSError, ValueError) as exc:
|
|
39
|
+
raise CaixaDownloadError(f"Planilha XLSX inválida: {path.name}: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
sheets = []
|
|
42
|
+
try:
|
|
43
|
+
for worksheet in workbook.worksheets:
|
|
44
|
+
preview = []
|
|
45
|
+
for row in worksheet.iter_rows(
|
|
46
|
+
min_row=1,
|
|
47
|
+
max_row=min(worksheet.max_row or 0, 12),
|
|
48
|
+
min_col=1,
|
|
49
|
+
max_col=min(worksheet.max_column or 0, 20),
|
|
50
|
+
values_only=True,
|
|
51
|
+
):
|
|
52
|
+
values = [_json_value(value) for value in row]
|
|
53
|
+
if any(value is not None and value != "" for value in values):
|
|
54
|
+
preview.append(values)
|
|
55
|
+
sheets.append({
|
|
56
|
+
"name": worksheet.title,
|
|
57
|
+
"max_row": worksheet.max_row,
|
|
58
|
+
"max_column": worksheet.max_column,
|
|
59
|
+
"state": worksheet.sheet_state,
|
|
60
|
+
"preview_rows": preview,
|
|
61
|
+
})
|
|
62
|
+
finally:
|
|
63
|
+
workbook.close()
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"file": path.name,
|
|
67
|
+
"relative_path": path.name,
|
|
68
|
+
"size_bytes": path.stat().st_size,
|
|
69
|
+
"sha256": _sha256(path),
|
|
70
|
+
"sheets": sheets,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_and_catalog(
|
|
75
|
+
archive_path: Path,
|
|
76
|
+
reference: str,
|
|
77
|
+
settings: Settings | None = None,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""Extrai um pacote oficial e gera catálogo JSON reproduzível."""
|
|
80
|
+
if not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", reference):
|
|
81
|
+
raise ValueError("Competência inválida. Informe AAAA-MM.")
|
|
82
|
+
|
|
83
|
+
archive_path = Path(archive_path)
|
|
84
|
+
if not archive_path.is_file():
|
|
85
|
+
raise FileNotFoundError(f"Arquivo ZIP não encontrado: {archive_path}")
|
|
86
|
+
settings = settings or Settings.from_env()
|
|
87
|
+
settings.create_data_directories()
|
|
88
|
+
|
|
89
|
+
digest = _sha256(archive_path)
|
|
90
|
+
destination = settings.staging_dir / reference / digest[:12]
|
|
91
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
extracted = safe_extract_zip(archive_path, destination)
|
|
94
|
+
workbooks = []
|
|
95
|
+
for path in sorted(extracted):
|
|
96
|
+
if path.suffix.lower() == ".xlsx":
|
|
97
|
+
workbooks.append(inspect_workbook(path))
|
|
98
|
+
|
|
99
|
+
catalog = {
|
|
100
|
+
"schema_version": "1.0",
|
|
101
|
+
"source_archive": archive_path.name,
|
|
102
|
+
"source_sha256": digest,
|
|
103
|
+
"reference": reference,
|
|
104
|
+
"extracted_at": datetime.now(UTC).isoformat(),
|
|
105
|
+
"extracted_file_count": len(extracted),
|
|
106
|
+
"workbook_count": len(workbooks),
|
|
107
|
+
"files": [
|
|
108
|
+
{"path": path.relative_to(destination).as_posix(), "size_bytes": path.stat().st_size}
|
|
109
|
+
for path in sorted(extracted)
|
|
110
|
+
],
|
|
111
|
+
"workbooks": workbooks,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
catalog_dir = settings.manifest_dir / reference
|
|
115
|
+
catalog_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
catalog_path = catalog_dir / f"workbook-catalog-{digest[:12]}.json"
|
|
117
|
+
temporary = catalog_path.with_suffix(".json.part")
|
|
118
|
+
temporary.write_text(json.dumps(catalog, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
119
|
+
temporary.replace(catalog_path)
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"files": extracted,
|
|
123
|
+
"workbooks": workbooks,
|
|
124
|
+
"catalog_path": catalog_path,
|
|
125
|
+
"catalog": catalog,
|
|
126
|
+
}
|
getsinapi/normalizers.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
|
|
2
|
+
"""Normalização conservadora de valores e observações de preço SINAPI."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
from decimal import Decimal, InvalidOperation
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from getsinapi.domain import PriceStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize_text(value: Any) -> str:
|
|
15
|
+
"""Normaliza texto para comparação, preservando o conteúdo semântico."""
|
|
16
|
+
text = str(value).strip().casefold()
|
|
17
|
+
text = unicodedata.normalize("NFKD", text)
|
|
18
|
+
text = "".join(
|
|
19
|
+
char for char in text
|
|
20
|
+
if not unicodedata.combining(char)
|
|
21
|
+
)
|
|
22
|
+
return re.sub(r"\s+", " ", text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_NOT_RESEARCHED = re.compile(
|
|
26
|
+
r"\b(?:"
|
|
27
|
+
r"nao pesquisad[oa]s?"
|
|
28
|
+
r"|sem pesquisa de preco"
|
|
29
|
+
r"|preco nao pesquisad[oa]"
|
|
30
|
+
r")\b",
|
|
31
|
+
flags=re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_decimal(value: Any) -> Decimal | None:
|
|
36
|
+
"""Converte valores numéricos sem arredondamento; vazio permanece None.
|
|
37
|
+
|
|
38
|
+
Aceita Decimal, inteiros, floats e strings numéricas brasileiras.
|
|
39
|
+
Não interpreta textos de ausência como zero.
|
|
40
|
+
"""
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
raise TypeError("Booleano não é um valor monetário válido.")
|
|
46
|
+
|
|
47
|
+
if isinstance(value, Decimal):
|
|
48
|
+
result = value
|
|
49
|
+
elif isinstance(value, int):
|
|
50
|
+
result = Decimal(value)
|
|
51
|
+
elif isinstance(value, float):
|
|
52
|
+
result = Decimal(str(value))
|
|
53
|
+
else:
|
|
54
|
+
raw = str(value).strip()
|
|
55
|
+
|
|
56
|
+
if not raw:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
raw = raw.replace("R$", "").replace(" ", "")
|
|
60
|
+
|
|
61
|
+
if "," in raw and "." in raw:
|
|
62
|
+
if raw.rfind(",") > raw.rfind("."):
|
|
63
|
+
raw = raw.replace(".", "").replace(",", ".")
|
|
64
|
+
else:
|
|
65
|
+
raw = raw.replace(",", "")
|
|
66
|
+
elif "," in raw:
|
|
67
|
+
raw = raw.replace(".", "").replace(",", ".")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = Decimal(raw)
|
|
71
|
+
except InvalidOperation as exc:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Valor numérico inválido: {value!r}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
if not result.is_finite():
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Valor numérico não finito: {value!r}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def classify_price(
|
|
85
|
+
value: Any,
|
|
86
|
+
source_status: Any = None,
|
|
87
|
+
) -> tuple[Decimal | None, PriceStatus]:
|
|
88
|
+
"""Classifica preços preservando as distinções da fonte.
|
|
89
|
+
|
|
90
|
+
- NOT_RESEARCHED: indicação textual explícita da fonte.
|
|
91
|
+
- MISSING: valor ausente ou célula vazia.
|
|
92
|
+
- REPORTED_ZERO: preço numérico igual a zero.
|
|
93
|
+
- REPORTED: preço numérico diferente de zero.
|
|
94
|
+
|
|
95
|
+
A ausência de informação nunca é convertida automaticamente em zero.
|
|
96
|
+
"""
|
|
97
|
+
status_text = (
|
|
98
|
+
""
|
|
99
|
+
if source_status is None
|
|
100
|
+
else _normalize_text(source_status)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if _NOT_RESEARCHED.search(status_text):
|
|
104
|
+
return None, PriceStatus.NOT_RESEARCHED
|
|
105
|
+
|
|
106
|
+
parsed = parse_decimal(value)
|
|
107
|
+
|
|
108
|
+
if parsed is None:
|
|
109
|
+
return None, PriceStatus.MISSING
|
|
110
|
+
|
|
111
|
+
if parsed == Decimal(0):
|
|
112
|
+
return parsed, PriceStatus.REPORTED_ZERO
|
|
113
|
+
|
|
114
|
+
return parsed, PriceStatus.REPORTED
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Orquestração das etapas de processamento SINAPI."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Publica os resultados normalizados do XLSX oficial na camada Silver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from getsinapi.config import Settings
|
|
16
|
+
from getsinapi.extractors.reference_workbook import parse_reference_workbook
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _json_default(value: Any) -> str:
|
|
20
|
+
if isinstance(value, Decimal):
|
|
21
|
+
return str(value)
|
|
22
|
+
if isinstance(value, Enum):
|
|
23
|
+
return value.value
|
|
24
|
+
raise TypeError(f"Tipo não serializável: {type(value).__name__}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_jsonl(path: Path, records: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
30
|
+
digest = hashlib.sha256()
|
|
31
|
+
record_count = 0
|
|
32
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
|
|
33
|
+
for record in records:
|
|
34
|
+
line = json.dumps(
|
|
35
|
+
record,
|
|
36
|
+
ensure_ascii=False,
|
|
37
|
+
sort_keys=True,
|
|
38
|
+
default=_json_default,
|
|
39
|
+
separators=(",", ":"),
|
|
40
|
+
) + "\n"
|
|
41
|
+
stream.write(line)
|
|
42
|
+
digest.update(line.encode("utf-8"))
|
|
43
|
+
record_count += 1
|
|
44
|
+
os.replace(temporary, path)
|
|
45
|
+
return {
|
|
46
|
+
"file": path.name,
|
|
47
|
+
"records": record_count,
|
|
48
|
+
"sha256": digest.hexdigest(),
|
|
49
|
+
"size_bytes": path.stat().st_size,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_reference_workbook(
|
|
54
|
+
workbook_path: str | Path,
|
|
55
|
+
*,
|
|
56
|
+
reference: str,
|
|
57
|
+
settings: Settings | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Extrai tabelas e grava JSONL rastreável em data/processed/AAAA-MM."""
|
|
60
|
+
config = settings or Settings.from_env()
|
|
61
|
+
result = parse_reference_workbook(workbook_path, reference=reference)
|
|
62
|
+
output_dir = config.processed_dir / reference
|
|
63
|
+
|
|
64
|
+
# Não materializa cópias adicionais de centenas de milhares de registros
|
|
65
|
+
# como listas de dicionários; serializa cada registro diretamente em JSONL.
|
|
66
|
+
def iter_price_records():
|
|
67
|
+
for table in result.prices:
|
|
68
|
+
for record in table.records:
|
|
69
|
+
yield {**asdict(record), "table_type": table.table_type}
|
|
70
|
+
|
|
71
|
+
artifacts = [
|
|
72
|
+
_write_jsonl(output_dir / "input_prices.jsonl", iter_price_records()),
|
|
73
|
+
_write_jsonl(
|
|
74
|
+
output_dir / "compositions.jsonl",
|
|
75
|
+
(asdict(record) for record in result.compositions.compositions),
|
|
76
|
+
),
|
|
77
|
+
_write_jsonl(
|
|
78
|
+
output_dir / "composition_items.jsonl",
|
|
79
|
+
(asdict(record) for record in result.compositions.items),
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
counts_by_file = {item["file"]: item["records"] for item in artifacts}
|
|
83
|
+
|
|
84
|
+
warnings = [
|
|
85
|
+
{"source": table.table_type, "message": warning}
|
|
86
|
+
for table in result.prices
|
|
87
|
+
for warning in table.warnings
|
|
88
|
+
] + [
|
|
89
|
+
{"source": "Analítico", "message": warning}
|
|
90
|
+
for warning in result.compositions.warnings
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
manifest = {
|
|
94
|
+
"reference": reference,
|
|
95
|
+
"source_file": result.source_file,
|
|
96
|
+
"source_path": str(Path(workbook_path)),
|
|
97
|
+
"artifacts": artifacts,
|
|
98
|
+
"counts": {
|
|
99
|
+
"input_prices": counts_by_file["input_prices.jsonl"],
|
|
100
|
+
"compositions": counts_by_file["compositions.jsonl"],
|
|
101
|
+
"composition_items": counts_by_file["composition_items.jsonl"],
|
|
102
|
+
},
|
|
103
|
+
"warning_count": len(warnings),
|
|
104
|
+
"warnings": warnings,
|
|
105
|
+
}
|
|
106
|
+
manifest_path = output_dir / "normalization-manifest.json"
|
|
107
|
+
temporary_manifest = manifest_path.with_suffix(".json.tmp")
|
|
108
|
+
temporary_manifest.write_text(
|
|
109
|
+
json.dumps(manifest, ensure_ascii=False, indent=2, default=_json_default) + "\n",
|
|
110
|
+
encoding="utf-8",
|
|
111
|
+
)
|
|
112
|
+
os.replace(temporary_manifest, manifest_path)
|
|
113
|
+
manifest["manifest_path"] = str(manifest_path)
|
|
114
|
+
return manifest
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Serviços de domínio e cálculos do GET-SINAPI."""
|