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/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()