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/domain.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Modelos de domínio para registros normalizados do SINAPI.
|
|
2
|
+
|
|
3
|
+
Os modelos preservam a diferença entre preço zero informado, preço ausente
|
|
4
|
+
e ausência de pesquisa explicitamente documentada pela fonte.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Regime(StrEnum):
|
|
14
|
+
DESONERADO = "desonerado"
|
|
15
|
+
NAO_DESONERADO = "nao_desonerado"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EncargoMode(StrEnum):
|
|
19
|
+
"""Tratamento de encargos sociais aplicado ao relatório."""
|
|
20
|
+
|
|
21
|
+
COM_ENCARGOS = "com_encargos"
|
|
22
|
+
SEM_ENCARGOS = "sem_encargos"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CompositionStatus(StrEnum):
|
|
26
|
+
"""Situação da composição conforme a classificação da fonte oficial."""
|
|
27
|
+
|
|
28
|
+
COM_CUSTO = "com_custo"
|
|
29
|
+
COM_PRECO = "com_preco"
|
|
30
|
+
SEM_CUSTO = "sem_custo"
|
|
31
|
+
SEM_PRECO = "sem_preco"
|
|
32
|
+
EM_ESTUDO = "em_estudo"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PriceStatus(StrEnum):
|
|
36
|
+
REPORTED = "reported"
|
|
37
|
+
REPORTED_ZERO = "reported_zero"
|
|
38
|
+
MISSING = "missing"
|
|
39
|
+
NOT_RESEARCHED = "not_researched"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class PriceObservation:
|
|
44
|
+
reference: str
|
|
45
|
+
uf: str
|
|
46
|
+
regime: Regime
|
|
47
|
+
code: str
|
|
48
|
+
description: str
|
|
49
|
+
unit: str
|
|
50
|
+
price: Decimal | None
|
|
51
|
+
status: PriceStatus
|
|
52
|
+
source_file: str
|
|
53
|
+
source_sheet: str
|
|
54
|
+
source_row: int
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
if len(self.reference) != 7 or self.reference[4] != "-":
|
|
58
|
+
raise ValueError("Competência deve estar no formato AAAA-MM.")
|
|
59
|
+
if len(self.uf) != 2 or not self.uf.isalpha():
|
|
60
|
+
raise ValueError("UF deve conter a sigla de duas letras.")
|
|
61
|
+
if not self.code.strip():
|
|
62
|
+
raise ValueError("Código SINAPI não pode ser vazio.")
|
|
63
|
+
if self.source_row < 1:
|
|
64
|
+
raise ValueError("Linha de origem deve ser maior ou igual a 1.")
|
|
65
|
+
reported = {PriceStatus.REPORTED, PriceStatus.REPORTED_ZERO}
|
|
66
|
+
absent = {PriceStatus.MISSING, PriceStatus.NOT_RESEARCHED}
|
|
67
|
+
if self.status in reported and self.price is None:
|
|
68
|
+
raise ValueError("Preço informado exige valor numérico.")
|
|
69
|
+
if self.status == PriceStatus.REPORTED_ZERO and self.price != Decimal(0):
|
|
70
|
+
raise ValueError("Status reported_zero exige preço igual a zero.")
|
|
71
|
+
if self.status == PriceStatus.REPORTED and self.price == Decimal(0):
|
|
72
|
+
raise ValueError("Preço zero deve usar status reported_zero.")
|
|
73
|
+
if self.status in absent and self.price is not None:
|
|
74
|
+
raise ValueError("Preço ausente ou não pesquisado não pode conter valor.")
|
getsinapi/exporter.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Exportação em lote do catálogo SINAPI para Excel e CSV."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openpyxl import Workbook
|
|
12
|
+
|
|
13
|
+
CATEGORIES = {
|
|
14
|
+
"INSUMO": "Insumos",
|
|
15
|
+
"COMPOSICAO": "Composicoes",
|
|
16
|
+
"COMPONENTE": "Componentes",
|
|
17
|
+
}
|
|
18
|
+
EXCEL_MAX_DATA_ROWS = 1_048_575
|
|
19
|
+
|
|
20
|
+
CATALOG_CTE = """
|
|
21
|
+
WITH catalog AS (
|
|
22
|
+
SELECT reference, 'INSUMO' AS record_type, code, description, unit, uf,
|
|
23
|
+
regime, encargo_mode, table_type, price AS value, 'PRECO' AS value_type,
|
|
24
|
+
NULL AS composition_code, status, source_file, source_sheet, source_row
|
|
25
|
+
FROM input_prices
|
|
26
|
+
UNION ALL
|
|
27
|
+
SELECT reference, 'COMPOSICAO', code, description, unit,
|
|
28
|
+
NULL, NULL, NULL, 'Analitico', NULL, NULL, NULL,
|
|
29
|
+
status, source_file, source_sheet, source_row
|
|
30
|
+
FROM compositions
|
|
31
|
+
UNION ALL
|
|
32
|
+
SELECT reference, 'COMPONENTE', item_code, description, unit,
|
|
33
|
+
NULL, NULL, NULL, 'Analitico', coefficient, 'COEFICIENTE',
|
|
34
|
+
composition_code, status, source_file, source_sheet, source_row
|
|
35
|
+
FROM composition_items
|
|
36
|
+
)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
COLUMNS = (
|
|
40
|
+
"reference", "record_type", "code", "description", "unit", "uf", "regime",
|
|
41
|
+
"encargo_mode", "table_type", "value", "value_type", "composition_code",
|
|
42
|
+
"status", "source_file", "source_sheet", "source_row",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _query(
|
|
47
|
+
*,
|
|
48
|
+
references: list[str] | None,
|
|
49
|
+
uf: str | None,
|
|
50
|
+
categories: list[str],
|
|
51
|
+
regime: str | None,
|
|
52
|
+
encargo_mode: str | None,
|
|
53
|
+
search: str | None,
|
|
54
|
+
) -> tuple[str, tuple[Any, ...]]:
|
|
55
|
+
clauses: list[str] = []
|
|
56
|
+
params: list[Any] = []
|
|
57
|
+
if references:
|
|
58
|
+
clauses.append("reference IN (" + ",".join("?" for _ in references) + ")")
|
|
59
|
+
params.extend(references)
|
|
60
|
+
if uf:
|
|
61
|
+
clauses.append("(uf = ? OR uf IS NULL)")
|
|
62
|
+
params.append(uf.upper())
|
|
63
|
+
if categories:
|
|
64
|
+
clauses.append("record_type IN (" + ",".join("?" for _ in categories) + ")")
|
|
65
|
+
params.extend(categories)
|
|
66
|
+
if regime:
|
|
67
|
+
clauses.append("(regime = ? OR regime IS NULL)")
|
|
68
|
+
params.append(regime)
|
|
69
|
+
if encargo_mode:
|
|
70
|
+
clauses.append("(encargo_mode = ? OR encargo_mode IS NULL)")
|
|
71
|
+
params.append(encargo_mode)
|
|
72
|
+
if search:
|
|
73
|
+
term = "%" + search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
74
|
+
clauses.append(
|
|
75
|
+
"(code LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' "
|
|
76
|
+
"OR composition_code LIKE ? ESCAPE '\\')"
|
|
77
|
+
)
|
|
78
|
+
params.extend((term, term, term))
|
|
79
|
+
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
|
80
|
+
sql = CATALOG_CTE + " SELECT " + ", ".join(COLUMNS) + " FROM catalog" + where
|
|
81
|
+
sql += " ORDER BY reference DESC, record_type, code, uf"
|
|
82
|
+
return sql, tuple(params)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def available_references(database_path: Path) -> list[str]:
|
|
86
|
+
with sqlite3.connect(database_path) as connection:
|
|
87
|
+
rows = connection.execute(
|
|
88
|
+
"SELECT reference FROM source_references ORDER BY reference DESC"
|
|
89
|
+
).fetchall()
|
|
90
|
+
return [str(row[0]) for row in rows]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def export_catalog(
|
|
94
|
+
database_path: Path,
|
|
95
|
+
output_dir: Path,
|
|
96
|
+
*,
|
|
97
|
+
file_format: str,
|
|
98
|
+
references: list[str] | None = None,
|
|
99
|
+
uf: str | None = None,
|
|
100
|
+
categories: list[str] | None = None,
|
|
101
|
+
regime: str | None = None,
|
|
102
|
+
encargo_mode: str | None = None,
|
|
103
|
+
search: str | None = None,
|
|
104
|
+
) -> list[Path]:
|
|
105
|
+
"""Exporta categorias individualizadas e uma visão consolidada."""
|
|
106
|
+
if file_format not in {"xlsx", "csv", "both"}:
|
|
107
|
+
raise ValueError("Formato deve ser xlsx, csv ou both.")
|
|
108
|
+
selected = categories or list(CATEGORIES)
|
|
109
|
+
invalid = set(selected) - set(CATEGORIES)
|
|
110
|
+
if invalid:
|
|
111
|
+
raise ValueError(f"Categorias inválidas: {', '.join(sorted(invalid))}.")
|
|
112
|
+
if not database_path.is_file():
|
|
113
|
+
raise FileNotFoundError(f"Banco SINAPI não encontrado: {database_path}")
|
|
114
|
+
|
|
115
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
117
|
+
base = output_dir / f"getsinapi_export_{stamp}"
|
|
118
|
+
sql, params = _query(
|
|
119
|
+
references=references, uf=uf, categories=selected, regime=regime,
|
|
120
|
+
encargo_mode=encargo_mode, search=search
|
|
121
|
+
)
|
|
122
|
+
generated: list[Path] = []
|
|
123
|
+
|
|
124
|
+
with sqlite3.connect(database_path) as connection:
|
|
125
|
+
connection.row_factory = sqlite3.Row
|
|
126
|
+
if file_format in {"xlsx", "both"}:
|
|
127
|
+
workbook = Workbook(write_only=True)
|
|
128
|
+
sheets: dict[str, Any] = {}
|
|
129
|
+
|
|
130
|
+
def new_sheet(label: str, index: int = 1):
|
|
131
|
+
suffix = f"_{index:02d}" if index > 1 else ""
|
|
132
|
+
sheet = workbook.create_sheet((label[:25] + suffix)[:31])
|
|
133
|
+
sheet.append(list(COLUMNS))
|
|
134
|
+
return sheet
|
|
135
|
+
|
|
136
|
+
sheets["CONSOLIDADO"] = new_sheet("CONSOLIDADO")
|
|
137
|
+
for category in selected:
|
|
138
|
+
sheets[category] = new_sheet(CATEGORIES[category])
|
|
139
|
+
|
|
140
|
+
cursor = connection.execute(sql, params)
|
|
141
|
+
counts = {key: 0 for key in sheets}
|
|
142
|
+
sheet_parts = {key: 1 for key in sheets}
|
|
143
|
+
while rows := cursor.fetchmany(2000):
|
|
144
|
+
for row in rows:
|
|
145
|
+
values = [row[column] for column in COLUMNS]
|
|
146
|
+
category = str(row["record_type"])
|
|
147
|
+
for key in ("CONSOLIDADO", category):
|
|
148
|
+
if key not in sheets:
|
|
149
|
+
continue
|
|
150
|
+
if counts[key] >= EXCEL_MAX_DATA_ROWS:
|
|
151
|
+
sheet_parts[key] += 1
|
|
152
|
+
label = "CONSOLIDADO" if key == "CONSOLIDADO" else CATEGORIES[key]
|
|
153
|
+
sheets[key] = new_sheet(label, sheet_parts[key])
|
|
154
|
+
counts[key] = 0
|
|
155
|
+
sheets[key].append(values)
|
|
156
|
+
counts[key] += 1
|
|
157
|
+
xlsx_path = base.with_suffix(".xlsx")
|
|
158
|
+
workbook.save(xlsx_path)
|
|
159
|
+
generated.append(xlsx_path)
|
|
160
|
+
|
|
161
|
+
if file_format in {"csv", "both"}:
|
|
162
|
+
csv_targets = {"CONSOLIDADO": base.with_name(base.name + "_consolidado.csv")}
|
|
163
|
+
csv_targets.update({
|
|
164
|
+
category: base.with_name(base.name + "_" + CATEGORIES[category].lower() + ".csv")
|
|
165
|
+
for category in selected
|
|
166
|
+
})
|
|
167
|
+
handles = {}
|
|
168
|
+
writers = {}
|
|
169
|
+
try:
|
|
170
|
+
for key, path in csv_targets.items():
|
|
171
|
+
handle = path.open("w", newline="", encoding="utf-8-sig")
|
|
172
|
+
handles[key] = handle
|
|
173
|
+
writer = csv.writer(handle, delimiter=";", quoting=csv.QUOTE_MINIMAL)
|
|
174
|
+
writer.writerow(COLUMNS)
|
|
175
|
+
writers[key] = writer
|
|
176
|
+
cursor = connection.execute(sql, params)
|
|
177
|
+
while rows := cursor.fetchmany(2000):
|
|
178
|
+
for row in rows:
|
|
179
|
+
values = [row[column] for column in COLUMNS]
|
|
180
|
+
writers["CONSOLIDADO"].writerow(values)
|
|
181
|
+
category = str(row["record_type"])
|
|
182
|
+
if category in writers:
|
|
183
|
+
writers[category].writerow(values)
|
|
184
|
+
finally:
|
|
185
|
+
for handle in handles.values():
|
|
186
|
+
handle.close()
|
|
187
|
+
generated.extend(csv_targets.values())
|
|
188
|
+
|
|
189
|
+
return generated
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Extratores de pacotes oficiais SINAPI."""
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Parser do relatório Analítico oficial de composições SINAPI.
|
|
2
|
+
|
|
3
|
+
Layout auditado na referência 2026-09:
|
|
4
|
+
- Linha 10: cabeçalho das colunas.
|
|
5
|
+
- Linha 11 em diante: linha-pai (C e D vazias), COMPOSICAO e INSUMO.
|
|
6
|
+
- B: código da composição; C: tipo do item; D: código do item;
|
|
7
|
+
E: descrição; F: unidade; G: coeficiente; H: situação.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from openpyxl import load_workbook
|
|
19
|
+
|
|
20
|
+
from getsinapi.domain import CompositionStatus
|
|
21
|
+
from getsinapi.normalizers import parse_decimal
|
|
22
|
+
|
|
23
|
+
REFERENCE_PATTERN = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
|
24
|
+
STATUS_MAP = {
|
|
25
|
+
"com custo": CompositionStatus.COM_CUSTO,
|
|
26
|
+
"com preco": CompositionStatus.COM_PRECO,
|
|
27
|
+
"sem custo": CompositionStatus.SEM_CUSTO,
|
|
28
|
+
"sem preco": CompositionStatus.SEM_PRECO,
|
|
29
|
+
"em estudo": CompositionStatus.EM_ESTUDO,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _text(value: Any) -> str:
|
|
34
|
+
if value is None:
|
|
35
|
+
return ""
|
|
36
|
+
normalized = unicodedata.normalize("NFKD", str(value).strip().casefold())
|
|
37
|
+
normalized = "".join(char for char in normalized if not unicodedata.combining(char))
|
|
38
|
+
return re.sub(r"\s+", " ", normalized)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _code(value: Any) -> str:
|
|
42
|
+
if value is None:
|
|
43
|
+
return ""
|
|
44
|
+
if isinstance(value, float) and value.is_integer():
|
|
45
|
+
value = int(value)
|
|
46
|
+
raw = str(value).strip()
|
|
47
|
+
if raw.endswith(".0") and raw[:-2].isdigit():
|
|
48
|
+
raw = raw[:-2]
|
|
49
|
+
return raw
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class CompositionRecord:
|
|
54
|
+
reference: str
|
|
55
|
+
code: str
|
|
56
|
+
description: str
|
|
57
|
+
unit: str
|
|
58
|
+
status: CompositionStatus | None
|
|
59
|
+
source_file: str
|
|
60
|
+
source_sheet: str
|
|
61
|
+
source_row: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class CompositionItemRecord:
|
|
66
|
+
reference: str
|
|
67
|
+
composition_code: str
|
|
68
|
+
item_code: str
|
|
69
|
+
item_type: str
|
|
70
|
+
description: str
|
|
71
|
+
unit: str
|
|
72
|
+
coefficient: Decimal
|
|
73
|
+
source_file: str
|
|
74
|
+
source_sheet: str
|
|
75
|
+
source_row: int
|
|
76
|
+
status: CompositionStatus | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class CompositionParseResult:
|
|
81
|
+
compositions: tuple[CompositionRecord, ...]
|
|
82
|
+
items: tuple[CompositionItemRecord, ...]
|
|
83
|
+
warnings: tuple[str, ...]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_analytic_compositions(
|
|
87
|
+
workbook_path: str | Path,
|
|
88
|
+
sheet_name: str = "Analítico",
|
|
89
|
+
*,
|
|
90
|
+
reference: str,
|
|
91
|
+
) -> CompositionParseResult:
|
|
92
|
+
"""Extrai composições e seus componentes sem preencher dados ausentes."""
|
|
93
|
+
if not REFERENCE_PATTERN.fullmatch(reference):
|
|
94
|
+
raise ValueError("Competência inválida. Informe AAAA-MM.")
|
|
95
|
+
|
|
96
|
+
path = Path(workbook_path)
|
|
97
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
98
|
+
try:
|
|
99
|
+
if sheet_name not in workbook.sheetnames:
|
|
100
|
+
raise ValueError(f"Aba inexistente: {sheet_name}")
|
|
101
|
+
sheet = workbook[sheet_name]
|
|
102
|
+
|
|
103
|
+
header = [_text(sheet.cell(row=10, column=column).value) for column in range(1, 9)]
|
|
104
|
+
required_headers = {1: "codigo", 2: "tipo", 4: "descricao", 5: "unidade", 6: "coeficiente"}
|
|
105
|
+
if any(not header[index] or expected not in header[index]
|
|
106
|
+
for index, expected in required_headers.items()) or not any(
|
|
107
|
+
"situacao" in value or "status" in value for value in header
|
|
108
|
+
):
|
|
109
|
+
raise ValueError("Cabeçalho do relatório Analítico não reconhecido na linha 10.")
|
|
110
|
+
|
|
111
|
+
compositions: list[CompositionRecord] = []
|
|
112
|
+
items: list[CompositionItemRecord] = []
|
|
113
|
+
warnings: list[str] = []
|
|
114
|
+
known_compositions: set[str] = set()
|
|
115
|
+
|
|
116
|
+
rows = sheet.iter_rows(min_row=11, max_col=8, values_only=True)
|
|
117
|
+
for row_number, row in enumerate(rows, start=11):
|
|
118
|
+
values = tuple(row)
|
|
119
|
+
parent_code = _code(values[1] if len(values) > 1 else None)
|
|
120
|
+
row_type = _text(values[2] if len(values) > 2 else None)
|
|
121
|
+
item_code = _code(values[3] if len(values) > 3 else None)
|
|
122
|
+
description = str(values[4] or "").strip() if len(values) > 4 else ""
|
|
123
|
+
unit = str(values[5] or "").strip() if len(values) > 5 else ""
|
|
124
|
+
coefficient_value = values[6] if len(values) > 6 else None
|
|
125
|
+
status_value = _text(values[7] if len(values) > 7 else None)
|
|
126
|
+
|
|
127
|
+
if not parent_code and not row_type and not description:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Na planilha oficial, a linha-pai não possui tipo nem código de item.
|
|
131
|
+
# A descrição e a unidade identificam a composição; não há rótulo CABECALHO.
|
|
132
|
+
if not row_type and not item_code and parent_code and description and unit:
|
|
133
|
+
if not parent_code or not description or not unit:
|
|
134
|
+
warnings.append(f"Linha {row_number}: cabeçalho de composição incompleto.")
|
|
135
|
+
continue
|
|
136
|
+
status = STATUS_MAP.get(status_value)
|
|
137
|
+
if status_value and status is None:
|
|
138
|
+
warnings.append(
|
|
139
|
+
f"Linha {row_number}: situação não reconhecida: {status_value!r}."
|
|
140
|
+
)
|
|
141
|
+
if parent_code in known_compositions:
|
|
142
|
+
warnings.append(
|
|
143
|
+
f"Linha {row_number}: composição duplicada {parent_code!r}."
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
known_compositions.add(parent_code)
|
|
147
|
+
compositions.append(CompositionRecord(
|
|
148
|
+
reference=reference,
|
|
149
|
+
code=parent_code,
|
|
150
|
+
description=description,
|
|
151
|
+
unit=unit,
|
|
152
|
+
status=status,
|
|
153
|
+
source_file=path.name,
|
|
154
|
+
source_sheet=sheet_name,
|
|
155
|
+
source_row=row_number,
|
|
156
|
+
))
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if row_type not in {"composicao", "insumo"}:
|
|
160
|
+
if row_type:
|
|
161
|
+
warnings.append(
|
|
162
|
+
f"Linha {row_number}: tipo de registro não reconhecido: {row_type!r}."
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if not parent_code or not item_code or not description or not unit:
|
|
167
|
+
warnings.append(f"Linha {row_number}: componente incompleto ignorado.")
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
coefficient = parse_decimal(coefficient_value)
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
warnings.append(f"Linha {row_number}: coeficiente inválido.")
|
|
173
|
+
continue
|
|
174
|
+
if coefficient is None:
|
|
175
|
+
warnings.append(f"Linha {row_number}: coeficiente ausente.")
|
|
176
|
+
continue
|
|
177
|
+
if coefficient < 0:
|
|
178
|
+
warnings.append(f"Linha {row_number}: coeficiente negativo ignorado.")
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
item_status = STATUS_MAP.get(status_value)
|
|
182
|
+
if status_value and item_status is None:
|
|
183
|
+
warnings.append(
|
|
184
|
+
f"Linha {row_number}: situação de componente não reconhecida: "
|
|
185
|
+
f"{status_value!r}."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
items.append(CompositionItemRecord(
|
|
189
|
+
reference=reference,
|
|
190
|
+
composition_code=parent_code,
|
|
191
|
+
item_code=item_code,
|
|
192
|
+
item_type="COMPOSICAO" if row_type == "composicao" else "INSUMO",
|
|
193
|
+
description=description,
|
|
194
|
+
unit=unit,
|
|
195
|
+
coefficient=coefficient,
|
|
196
|
+
source_file=path.name,
|
|
197
|
+
source_sheet=sheet_name,
|
|
198
|
+
source_row=row_number,
|
|
199
|
+
status=item_status,
|
|
200
|
+
))
|
|
201
|
+
|
|
202
|
+
orphan_items = [item for item in items if item.composition_code not in known_compositions]
|
|
203
|
+
for item in orphan_items:
|
|
204
|
+
warnings.append(
|
|
205
|
+
f"Linha {item.source_row}: componente sem composição-pai "
|
|
206
|
+
f"{item.composition_code!r}."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return CompositionParseResult(
|
|
210
|
+
tuple(compositions), tuple(items), tuple(warnings)
|
|
211
|
+
)
|
|
212
|
+
finally:
|
|
213
|
+
workbook.close()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Parser específico para as tabelas matriciais de preços de insumos SINAPI.
|
|
2
|
+
|
|
3
|
+
Layout auditado no XLSX oficial: cabeçalhos dos campos e das UFs na linha 10,
|
|
4
|
+
com registros a partir da linha 11. As colunas F:AF representam as 27 UFs.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from openpyxl import load_workbook
|
|
15
|
+
|
|
16
|
+
from getsinapi.domain import EncargoMode, PriceStatus, Regime
|
|
17
|
+
from getsinapi.normalizers import classify_price
|
|
18
|
+
|
|
19
|
+
REFERENCE_PATTERN = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
|
20
|
+
UF_CODES = {
|
|
21
|
+
"AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA", "MT",
|
|
22
|
+
"MS", "MG", "PA", "PB", "PR", "PE", "PI", "RJ", "RN", "RS", "RO",
|
|
23
|
+
"RR", "SC", "SP", "SE", "TO",
|
|
24
|
+
}
|
|
25
|
+
PRICE_TABLES = {
|
|
26
|
+
"ISD": (Regime.NAO_DESONERADO, EncargoMode.COM_ENCARGOS),
|
|
27
|
+
"ICD": (Regime.DESONERADO, EncargoMode.COM_ENCARGOS),
|
|
28
|
+
"ISE": (None, EncargoMode.SEM_ENCARGOS),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class InputPriceRecord:
|
|
34
|
+
reference: str
|
|
35
|
+
code: str
|
|
36
|
+
description: str
|
|
37
|
+
unit: str
|
|
38
|
+
uf: str
|
|
39
|
+
regime: Regime | None
|
|
40
|
+
encargo_mode: EncargoMode
|
|
41
|
+
price: Decimal | None
|
|
42
|
+
status: PriceStatus
|
|
43
|
+
price_origin: str | None
|
|
44
|
+
source_file: str
|
|
45
|
+
source_sheet: str
|
|
46
|
+
source_row: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class InputPriceParseResult:
|
|
51
|
+
table_type: str
|
|
52
|
+
records: tuple[InputPriceRecord, ...]
|
|
53
|
+
warnings: tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _table_type(path: Path, sheet_name: str) -> str:
|
|
57
|
+
candidates = (path.stem.upper(), sheet_name.upper())
|
|
58
|
+
for candidate in candidates:
|
|
59
|
+
match = re.search(r"(?<![A-Z])(ISD|ICD|ISE)(?![A-Z])", candidate)
|
|
60
|
+
if match:
|
|
61
|
+
return match.group(1)
|
|
62
|
+
raise ValueError("Tipo de tabela não identificado; esperado ISD, ICD ou ISE.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _uf(value: Any) -> str | None:
|
|
66
|
+
if value is None:
|
|
67
|
+
return None
|
|
68
|
+
text = str(value).strip().upper()
|
|
69
|
+
match = re.search(r"\b([A-Z]{2})\b", text)
|
|
70
|
+
if match and match.group(1) in UF_CODES:
|
|
71
|
+
return match.group(1)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_input_price_table(
|
|
76
|
+
workbook_path: str | Path,
|
|
77
|
+
sheet_name: str,
|
|
78
|
+
*,
|
|
79
|
+
reference: str,
|
|
80
|
+
) -> InputPriceParseResult:
|
|
81
|
+
"""Converte uma tabela ISD/ICD/ISE larga em observações por UF."""
|
|
82
|
+
if not REFERENCE_PATTERN.fullmatch(reference):
|
|
83
|
+
raise ValueError("Competência inválida. Informe AAAA-MM.")
|
|
84
|
+
path = Path(workbook_path)
|
|
85
|
+
table_type = _table_type(path, sheet_name)
|
|
86
|
+
regime, encargo_mode = PRICE_TABLES[table_type]
|
|
87
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
88
|
+
try:
|
|
89
|
+
if sheet_name not in workbook.sheetnames:
|
|
90
|
+
raise ValueError(f"Aba inexistente: {sheet_name}")
|
|
91
|
+
sheet = workbook[sheet_name]
|
|
92
|
+
|
|
93
|
+
# No XLSX oficial, os rótulos dos campos e as UFs compartilham a linha 10.
|
|
94
|
+
# Streaming sequencial mantém compatibilidade com read_only=True.
|
|
95
|
+
rows = sheet.iter_rows(min_row=10, max_col=32, values_only=True)
|
|
96
|
+
header_row = next(rows, ())
|
|
97
|
+
uf_columns = [
|
|
98
|
+
(column, state)
|
|
99
|
+
for column, value in enumerate(header_row, start=1)
|
|
100
|
+
if column >= 6 and (state := _uf(value)) is not None
|
|
101
|
+
]
|
|
102
|
+
if len(uf_columns) != 27 or len({state for _, state in uf_columns}) != 27:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Mapa de UFs inválido na linha 10: {len(uf_columns)} colunas reconhecidas."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
headers = [str(value or "").strip().casefold() for value in header_row[:5]]
|
|
108
|
+
if not any("código" in value or "codigo" in value for value in headers):
|
|
109
|
+
raise ValueError("Cabeçalho da tabela não reconhecido na linha 10.")
|
|
110
|
+
|
|
111
|
+
records: list[InputPriceRecord] = []
|
|
112
|
+
warnings: list[str] = []
|
|
113
|
+
for row_number, row in enumerate(rows, start=11):
|
|
114
|
+
values = tuple(row)
|
|
115
|
+
code_value = values[1] if len(values) > 1 else None
|
|
116
|
+
description_value = values[2] if len(values) > 2 else None
|
|
117
|
+
unit_value = values[3] if len(values) > 3 else None
|
|
118
|
+
if code_value is None and description_value is None:
|
|
119
|
+
continue
|
|
120
|
+
if code_value is None or description_value is None or unit_value is None:
|
|
121
|
+
warnings.append(f"Linha {row_number}: registro incompleto ignorado.")
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
code = str(code_value).strip()
|
|
125
|
+
description = str(description_value).strip()
|
|
126
|
+
unit = str(unit_value).strip()
|
|
127
|
+
origin_value = values[4] if len(values) > 4 else None
|
|
128
|
+
origin = str(origin_value).strip() if origin_value is not None else None
|
|
129
|
+
|
|
130
|
+
for column, state in uf_columns:
|
|
131
|
+
raw_price = values[column - 1] if len(values) >= column else None
|
|
132
|
+
price, status = classify_price(raw_price)
|
|
133
|
+
records.append(InputPriceRecord(
|
|
134
|
+
reference=reference,
|
|
135
|
+
code=code,
|
|
136
|
+
description=description,
|
|
137
|
+
unit=unit,
|
|
138
|
+
uf=state,
|
|
139
|
+
regime=regime,
|
|
140
|
+
encargo_mode=encargo_mode,
|
|
141
|
+
price=price,
|
|
142
|
+
status=status,
|
|
143
|
+
price_origin=origin,
|
|
144
|
+
source_file=path.name,
|
|
145
|
+
source_sheet=sheet_name,
|
|
146
|
+
source_row=row_number,
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
return InputPriceParseResult(table_type, tuple(records), tuple(warnings))
|
|
150
|
+
finally:
|
|
151
|
+
workbook.close()
|