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,332 @@
|
|
|
1
|
+
"""Cálculo recursivo de composições e orçamentos com precisão decimal.
|
|
2
|
+
|
|
3
|
+
A lógica reaproveita o conceito de composição recursiva do PulseSINAPI, mas
|
|
4
|
+
não converte preço ausente em zero e detecta ciclos na árvore de dependências.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
from getsinapi.domain import EncargoMode, Regime
|
|
16
|
+
from getsinapi.extractors.compositions import (
|
|
17
|
+
CompositionItemRecord,
|
|
18
|
+
CompositionRecord,
|
|
19
|
+
)
|
|
20
|
+
from getsinapi.extractors.price_tables import InputPriceRecord
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CostStatus(StrEnum):
|
|
24
|
+
COMPLETE = "complete"
|
|
25
|
+
INCOMPLETE = "incomplete"
|
|
26
|
+
CYCLE = "cycle"
|
|
27
|
+
NOT_FOUND = "not_found"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class CostLine:
|
|
32
|
+
item_code: str
|
|
33
|
+
item_type: str
|
|
34
|
+
description: str
|
|
35
|
+
unit: str
|
|
36
|
+
coefficient: Decimal
|
|
37
|
+
unit_price: Decimal | None
|
|
38
|
+
total: Decimal | None
|
|
39
|
+
status: CostStatus
|
|
40
|
+
children: tuple[CostLine, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class CompositionCost:
|
|
45
|
+
composition_code: str
|
|
46
|
+
reference: str
|
|
47
|
+
uf: str
|
|
48
|
+
regime: Regime | None
|
|
49
|
+
encargo_mode: EncargoMode
|
|
50
|
+
total: Decimal | None
|
|
51
|
+
status: CostStatus
|
|
52
|
+
lines: tuple[CostLine, ...]
|
|
53
|
+
issues: tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class BudgetItem:
|
|
58
|
+
code: str
|
|
59
|
+
item_type: str
|
|
60
|
+
quantity: Decimal
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class BudgetLine:
|
|
65
|
+
code: str
|
|
66
|
+
item_type: str
|
|
67
|
+
quantity: Decimal
|
|
68
|
+
unit_cost: Decimal | None
|
|
69
|
+
total: Decimal | None
|
|
70
|
+
status: CostStatus
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class BudgetResult:
|
|
75
|
+
reference: str
|
|
76
|
+
uf: str
|
|
77
|
+
regime: Regime | None
|
|
78
|
+
encargo_mode: EncargoMode
|
|
79
|
+
total: Decimal | None
|
|
80
|
+
status: CostStatus
|
|
81
|
+
lines: tuple[BudgetLine, ...]
|
|
82
|
+
issues: tuple[str, ...]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CompositionCostCalculator:
|
|
86
|
+
"""Calcula custos a partir de registros normalizados e rastreáveis."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
prices: Iterable[InputPriceRecord],
|
|
92
|
+
compositions: Iterable[CompositionRecord],
|
|
93
|
+
composition_items: Iterable[CompositionItemRecord],
|
|
94
|
+
reference: str,
|
|
95
|
+
uf: str,
|
|
96
|
+
regime: Regime | None,
|
|
97
|
+
encargo_mode: EncargoMode,
|
|
98
|
+
) -> None:
|
|
99
|
+
if not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", reference):
|
|
100
|
+
raise ValueError("Competência inválida. Informe AAAA-MM.")
|
|
101
|
+
normalized_uf = uf.strip().upper()
|
|
102
|
+
if not re.fullmatch(r"[A-Z]{2}", normalized_uf):
|
|
103
|
+
raise ValueError("UF inválida. Informe uma sigla de duas letras.")
|
|
104
|
+
|
|
105
|
+
self.reference = reference
|
|
106
|
+
self.uf = normalized_uf
|
|
107
|
+
self.regime = regime
|
|
108
|
+
self.encargo_mode = encargo_mode
|
|
109
|
+
self.compositions: dict[str, CompositionRecord] = {}
|
|
110
|
+
for record in compositions:
|
|
111
|
+
if record.reference != reference:
|
|
112
|
+
continue
|
|
113
|
+
if record.code in self.compositions:
|
|
114
|
+
raise ValueError(f"Composição duplicada na competência {reference}: {record.code}.")
|
|
115
|
+
self.compositions[record.code] = record
|
|
116
|
+
self.items: dict[str, list[CompositionItemRecord]] = {}
|
|
117
|
+
for item in composition_items:
|
|
118
|
+
if item.reference != reference:
|
|
119
|
+
continue
|
|
120
|
+
self.items.setdefault(item.composition_code, []).append(item)
|
|
121
|
+
|
|
122
|
+
self.prices: dict[str, InputPriceRecord] = {}
|
|
123
|
+
for price in prices:
|
|
124
|
+
if (
|
|
125
|
+
price.reference != reference
|
|
126
|
+
or price.uf != normalized_uf
|
|
127
|
+
or price.regime != regime
|
|
128
|
+
or price.encargo_mode != encargo_mode
|
|
129
|
+
):
|
|
130
|
+
continue
|
|
131
|
+
if price.code in self.prices:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Preço duplicado para insumo {price.code}, {normalized_uf}, {reference}."
|
|
134
|
+
)
|
|
135
|
+
self.prices[price.code] = price
|
|
136
|
+
|
|
137
|
+
def calculate_composition(self, composition_code: str) -> CompositionCost:
|
|
138
|
+
"""Calcula uma composição e expõe a árvore de componentes."""
|
|
139
|
+
lines, total, status, issues = self._calculate(composition_code, ())
|
|
140
|
+
return CompositionCost(
|
|
141
|
+
composition_code=composition_code,
|
|
142
|
+
reference=self.reference,
|
|
143
|
+
uf=self.uf,
|
|
144
|
+
regime=self.regime,
|
|
145
|
+
encargo_mode=self.encargo_mode,
|
|
146
|
+
total=total,
|
|
147
|
+
status=status,
|
|
148
|
+
lines=lines,
|
|
149
|
+
issues=issues,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _calculate(
|
|
153
|
+
self,
|
|
154
|
+
composition_code: str,
|
|
155
|
+
stack: tuple[str, ...],
|
|
156
|
+
) -> tuple[tuple[CostLine, ...], Decimal | None, CostStatus, tuple[str, ...]]:
|
|
157
|
+
if composition_code in stack:
|
|
158
|
+
cycle = " -> ".join((*stack, composition_code))
|
|
159
|
+
return (), None, CostStatus.CYCLE, (f"Ciclo de composição detectado: {cycle}.",)
|
|
160
|
+
|
|
161
|
+
composition = self.compositions.get(composition_code)
|
|
162
|
+
if composition is None:
|
|
163
|
+
return (), None, CostStatus.NOT_FOUND, (
|
|
164
|
+
f"Composição não encontrada: {composition_code}.",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
component_records = self.items.get(composition_code, [])
|
|
168
|
+
if not component_records:
|
|
169
|
+
return (), None, CostStatus.INCOMPLETE, (
|
|
170
|
+
f"Composição sem componentes: {composition_code}.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
next_stack = (*stack, composition_code)
|
|
174
|
+
lines: list[CostLine] = []
|
|
175
|
+
issues: list[str] = []
|
|
176
|
+
subtotal = Decimal(0)
|
|
177
|
+
complete = True
|
|
178
|
+
cycle_detected = False
|
|
179
|
+
|
|
180
|
+
for item in component_records:
|
|
181
|
+
if item.item_type == "INSUMO":
|
|
182
|
+
price_record = self.prices.get(item.item_code)
|
|
183
|
+
if price_record is None or price_record.price is None:
|
|
184
|
+
price_status = (
|
|
185
|
+
price_record.status.value if price_record is not None else "not_found"
|
|
186
|
+
)
|
|
187
|
+
issues.append(
|
|
188
|
+
f"Preço indisponível para insumo {item.item_code} "
|
|
189
|
+
f"({price_status}) na UF {self.uf}."
|
|
190
|
+
)
|
|
191
|
+
lines.append(CostLine(
|
|
192
|
+
item_code=item.item_code,
|
|
193
|
+
item_type=item.item_type,
|
|
194
|
+
description=item.description,
|
|
195
|
+
unit=item.unit,
|
|
196
|
+
coefficient=item.coefficient,
|
|
197
|
+
unit_price=None,
|
|
198
|
+
total=None,
|
|
199
|
+
status=CostStatus.INCOMPLETE,
|
|
200
|
+
))
|
|
201
|
+
complete = False
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
line_total = item.coefficient * price_record.price
|
|
205
|
+
subtotal += line_total
|
|
206
|
+
lines.append(CostLine(
|
|
207
|
+
item_code=item.item_code,
|
|
208
|
+
item_type=item.item_type,
|
|
209
|
+
description=item.description,
|
|
210
|
+
unit=item.unit,
|
|
211
|
+
coefficient=item.coefficient,
|
|
212
|
+
unit_price=price_record.price,
|
|
213
|
+
total=line_total,
|
|
214
|
+
status=CostStatus.COMPLETE,
|
|
215
|
+
))
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if item.item_type == "COMPOSICAO":
|
|
219
|
+
child_lines, child_total, child_status, child_issues = self._calculate(
|
|
220
|
+
item.item_code, next_stack
|
|
221
|
+
)
|
|
222
|
+
issues.extend(child_issues)
|
|
223
|
+
if child_status == CostStatus.CYCLE:
|
|
224
|
+
cycle_detected = True
|
|
225
|
+
if child_total is None:
|
|
226
|
+
complete = False
|
|
227
|
+
lines.append(CostLine(
|
|
228
|
+
item_code=item.item_code,
|
|
229
|
+
item_type=item.item_type,
|
|
230
|
+
description=item.description,
|
|
231
|
+
unit=item.unit,
|
|
232
|
+
coefficient=item.coefficient,
|
|
233
|
+
unit_price=None,
|
|
234
|
+
total=None,
|
|
235
|
+
status=child_status,
|
|
236
|
+
children=child_lines,
|
|
237
|
+
))
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
line_total = item.coefficient * child_total
|
|
241
|
+
subtotal += line_total
|
|
242
|
+
lines.append(CostLine(
|
|
243
|
+
item_code=item.item_code,
|
|
244
|
+
item_type=item.item_type,
|
|
245
|
+
description=item.description,
|
|
246
|
+
unit=item.unit,
|
|
247
|
+
coefficient=item.coefficient,
|
|
248
|
+
unit_price=child_total,
|
|
249
|
+
total=line_total,
|
|
250
|
+
status=child_status,
|
|
251
|
+
children=child_lines,
|
|
252
|
+
))
|
|
253
|
+
if child_status != CostStatus.COMPLETE:
|
|
254
|
+
complete = False
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
issues.append(
|
|
258
|
+
f"Tipo de componente não reconhecido: {item.item_type!r} "
|
|
259
|
+
f"na composição {composition_code}."
|
|
260
|
+
)
|
|
261
|
+
complete = False
|
|
262
|
+
|
|
263
|
+
if cycle_detected:
|
|
264
|
+
return tuple(lines), None, CostStatus.CYCLE, tuple(issues)
|
|
265
|
+
if not complete:
|
|
266
|
+
return tuple(lines), None, CostStatus.INCOMPLETE, tuple(issues)
|
|
267
|
+
return tuple(lines), subtotal, CostStatus.COMPLETE, tuple(issues)
|
|
268
|
+
|
|
269
|
+
def calculate_budget(self, budget_items: Iterable[BudgetItem]) -> BudgetResult:
|
|
270
|
+
"""Calcula orçamento sem totalizar linhas com custo incompleto."""
|
|
271
|
+
lines: list[BudgetLine] = []
|
|
272
|
+
issues: list[str] = []
|
|
273
|
+
subtotal = Decimal(0)
|
|
274
|
+
complete = True
|
|
275
|
+
cycle_detected = False
|
|
276
|
+
|
|
277
|
+
for item in budget_items:
|
|
278
|
+
if item.quantity < 0:
|
|
279
|
+
raise ValueError(f"Quantidade negativa não permitida: {item.code}.")
|
|
280
|
+
if item.item_type == "INSUMO":
|
|
281
|
+
price = self.prices.get(item.code)
|
|
282
|
+
if price is None or price.price is None:
|
|
283
|
+
lines.append(BudgetLine(
|
|
284
|
+
item.code, item.item_type, item.quantity, None, None,
|
|
285
|
+
CostStatus.INCOMPLETE,
|
|
286
|
+
))
|
|
287
|
+
issues.append(f"Preço indisponível para insumo {item.code}.")
|
|
288
|
+
complete = False
|
|
289
|
+
continue
|
|
290
|
+
line_total = item.quantity * price.price
|
|
291
|
+
subtotal += line_total
|
|
292
|
+
lines.append(BudgetLine(
|
|
293
|
+
item.code, item.item_type, item.quantity, price.price,
|
|
294
|
+
line_total, CostStatus.COMPLETE,
|
|
295
|
+
))
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
if item.item_type == "COMPOSICAO":
|
|
299
|
+
cost = self.calculate_composition(item.code)
|
|
300
|
+
issues.extend(cost.issues)
|
|
301
|
+
if cost.total is None:
|
|
302
|
+
lines.append(BudgetLine(
|
|
303
|
+
item.code, item.item_type, item.quantity, None, None, cost.status
|
|
304
|
+
))
|
|
305
|
+
complete = False
|
|
306
|
+
cycle_detected = cycle_detected or cost.status == CostStatus.CYCLE
|
|
307
|
+
continue
|
|
308
|
+
line_total = item.quantity * cost.total
|
|
309
|
+
subtotal += line_total
|
|
310
|
+
lines.append(BudgetLine(
|
|
311
|
+
item.code, item.item_type, item.quantity, cost.total,
|
|
312
|
+
line_total, cost.status,
|
|
313
|
+
))
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
raise ValueError(f"Tipo de item não suportado: {item.item_type!r}.")
|
|
317
|
+
|
|
318
|
+
status = (
|
|
319
|
+
CostStatus.CYCLE if cycle_detected
|
|
320
|
+
else CostStatus.COMPLETE if complete
|
|
321
|
+
else CostStatus.INCOMPLETE
|
|
322
|
+
)
|
|
323
|
+
return BudgetResult(
|
|
324
|
+
reference=self.reference,
|
|
325
|
+
uf=self.uf,
|
|
326
|
+
regime=self.regime,
|
|
327
|
+
encargo_mode=self.encargo_mode,
|
|
328
|
+
total=subtotal if complete else None,
|
|
329
|
+
status=status,
|
|
330
|
+
lines=tuple(lines),
|
|
331
|
+
issues=tuple(issues),
|
|
332
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Persistência e repositórios do GET-SINAPI."""
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Persistência histórica idempotente em SQLite para desenvolvimento e análise local.
|
|
2
|
+
|
|
3
|
+
O contrato relacional é independente do mecanismo; a migração para PostgreSQL
|
|
4
|
+
pode preservar tabelas, chaves naturais e semântica de carga.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
SCHEMA = """
|
|
18
|
+
PRAGMA foreign_keys = ON;
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS source_references (
|
|
21
|
+
reference TEXT PRIMARY KEY,
|
|
22
|
+
source_file TEXT NOT NULL,
|
|
23
|
+
loaded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS input_prices (
|
|
27
|
+
id INTEGER PRIMARY KEY,
|
|
28
|
+
reference TEXT NOT NULL,
|
|
29
|
+
code TEXT NOT NULL,
|
|
30
|
+
description TEXT NOT NULL,
|
|
31
|
+
unit TEXT NOT NULL,
|
|
32
|
+
uf TEXT NOT NULL,
|
|
33
|
+
regime TEXT,
|
|
34
|
+
encargo_mode TEXT NOT NULL,
|
|
35
|
+
table_type TEXT NOT NULL,
|
|
36
|
+
price TEXT,
|
|
37
|
+
status TEXT NOT NULL,
|
|
38
|
+
price_origin TEXT,
|
|
39
|
+
source_file TEXT NOT NULL,
|
|
40
|
+
source_sheet TEXT NOT NULL,
|
|
41
|
+
source_row INTEGER NOT NULL,
|
|
42
|
+
UNIQUE(reference, code, uf, regime, encargo_mode, table_type),
|
|
43
|
+
FOREIGN KEY(reference) REFERENCES source_references(reference)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_input_prices_lookup
|
|
47
|
+
ON input_prices(reference, uf, regime, encargo_mode, code);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS compositions (
|
|
50
|
+
id INTEGER PRIMARY KEY,
|
|
51
|
+
reference TEXT NOT NULL,
|
|
52
|
+
code TEXT NOT NULL,
|
|
53
|
+
description TEXT NOT NULL,
|
|
54
|
+
unit TEXT NOT NULL,
|
|
55
|
+
status TEXT,
|
|
56
|
+
source_file TEXT NOT NULL,
|
|
57
|
+
source_sheet TEXT NOT NULL,
|
|
58
|
+
source_row INTEGER NOT NULL,
|
|
59
|
+
UNIQUE(reference, code),
|
|
60
|
+
FOREIGN KEY(reference) REFERENCES source_references(reference)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS composition_items (
|
|
64
|
+
id INTEGER PRIMARY KEY,
|
|
65
|
+
reference TEXT NOT NULL,
|
|
66
|
+
composition_code TEXT NOT NULL,
|
|
67
|
+
item_code TEXT NOT NULL,
|
|
68
|
+
item_type TEXT NOT NULL,
|
|
69
|
+
description TEXT NOT NULL,
|
|
70
|
+
unit TEXT NOT NULL,
|
|
71
|
+
coefficient TEXT NOT NULL,
|
|
72
|
+
status TEXT,
|
|
73
|
+
source_file TEXT NOT NULL,
|
|
74
|
+
source_sheet TEXT NOT NULL,
|
|
75
|
+
source_row INTEGER NOT NULL,
|
|
76
|
+
UNIQUE(reference, composition_code, source_row),
|
|
77
|
+
FOREIGN KEY(reference, composition_code)
|
|
78
|
+
REFERENCES compositions(reference, code)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_composition_items_parent
|
|
82
|
+
ON composition_items(reference, composition_code);
|
|
83
|
+
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_composition_items_child
|
|
85
|
+
ON composition_items(reference, item_code, item_type);
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def connect(database_path: str | Path) -> sqlite3.Connection:
|
|
90
|
+
path = Path(database_path)
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
connection = sqlite3.connect(path, timeout=30)
|
|
93
|
+
connection.row_factory = sqlite3.Row
|
|
94
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
95
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
96
|
+
connection.execute("PRAGMA synchronous = NORMAL")
|
|
97
|
+
connection.execute("PRAGMA temp_store = MEMORY")
|
|
98
|
+
return connection
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_schema(connection: sqlite3.Connection) -> None:
|
|
102
|
+
connection.executescript(SCHEMA)
|
|
103
|
+
|
|
104
|
+
item_columns = {
|
|
105
|
+
row["name"] for row in connection.execute("PRAGMA table_info(composition_items)")
|
|
106
|
+
}
|
|
107
|
+
if "status" not in item_columns:
|
|
108
|
+
connection.execute("ALTER TABLE composition_items ADD COLUMN status TEXT")
|
|
109
|
+
|
|
110
|
+
# A auditoria GROUP BY é necessária somente na migração de bancos antigos.
|
|
111
|
+
# Executá-la em toda competência relê a tabela histórica inteira e torna a
|
|
112
|
+
# ingestão progressivamente mais lenta à medida que o banco cresce.
|
|
113
|
+
natural_index = connection.execute(
|
|
114
|
+
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='uq_input_prices_natural_key'"
|
|
115
|
+
).fetchone()
|
|
116
|
+
if natural_index is None:
|
|
117
|
+
duplicate = connection.execute(
|
|
118
|
+
"""SELECT 1
|
|
119
|
+
FROM input_prices
|
|
120
|
+
GROUP BY reference, code, uf, COALESCE(regime, ''), encargo_mode, table_type
|
|
121
|
+
HAVING COUNT(*) > 1 LIMIT 1"""
|
|
122
|
+
).fetchone()
|
|
123
|
+
if duplicate is not None:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"Chave natural de preços duplicada no banco existente; "
|
|
126
|
+
"reconcilie os registros antes de criar a restrição idempotente."
|
|
127
|
+
)
|
|
128
|
+
connection.execute(
|
|
129
|
+
"""CREATE UNIQUE INDEX uq_input_prices_natural_key
|
|
130
|
+
ON input_prices(
|
|
131
|
+
reference, code, uf, COALESCE(regime, ''), encargo_mode, table_type
|
|
132
|
+
)"""
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def initialize_database(database_path: str | Path) -> None:
|
|
137
|
+
with connect(database_path) as connection:
|
|
138
|
+
_ensure_schema(connection)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sha256_file(path: Path) -> str:
|
|
142
|
+
digest = hashlib.sha256()
|
|
143
|
+
with path.open("rb") as stream:
|
|
144
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
145
|
+
digest.update(chunk)
|
|
146
|
+
return digest.hexdigest()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _iter_jsonl(path: Path) -> Iterator[dict[str, Any]]:
|
|
150
|
+
"""Lê JSONL em fluxo, sem materializar a competência inteira na memória."""
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
raise FileNotFoundError(f"Artefato Silver não encontrado: {path}")
|
|
153
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
154
|
+
for line_number, line in enumerate(stream, start=1):
|
|
155
|
+
if not line.strip():
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
record = json.loads(line)
|
|
159
|
+
except json.JSONDecodeError as exc:
|
|
160
|
+
raise ValueError(f"JSONL inválido em {path.name}, linha {line_number}.") from exc
|
|
161
|
+
if not isinstance(record, dict):
|
|
162
|
+
raise TypeError(f"Registro JSONL deve ser objeto: {path.name}:{line_number}.")
|
|
163
|
+
yield record
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _mapped_rows(
|
|
167
|
+
path: Path,
|
|
168
|
+
mapper,
|
|
169
|
+
*,
|
|
170
|
+
phase: str,
|
|
171
|
+
progress_callback: Callable[[str, int], None] | None = None,
|
|
172
|
+
):
|
|
173
|
+
processed = 0
|
|
174
|
+
for record in _iter_jsonl(path):
|
|
175
|
+
yield mapper(record)
|
|
176
|
+
processed += 1
|
|
177
|
+
if progress_callback is not None and processed % 10_000 == 0:
|
|
178
|
+
progress_callback(phase, processed)
|
|
179
|
+
if progress_callback is not None and processed % 10_000:
|
|
180
|
+
progress_callback(phase, processed)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_silver_reference(
|
|
184
|
+
processed_dir: str | Path,
|
|
185
|
+
*,
|
|
186
|
+
reference: str,
|
|
187
|
+
progress_callback: Callable[[str, int], None] | None = None,
|
|
188
|
+
) -> dict[str, int | str]:
|
|
189
|
+
"""Carrega uma competência em transação única; repetir a carga não duplica."""
|
|
190
|
+
if not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", reference):
|
|
191
|
+
raise ValueError("Competência inválida. Informe AAAA-MM.")
|
|
192
|
+
base = Path(processed_dir) / reference
|
|
193
|
+
manifest_path = base / "normalization-manifest.json"
|
|
194
|
+
if not manifest_path.is_file():
|
|
195
|
+
raise FileNotFoundError(f"Manifesto Silver não encontrado: {manifest_path}")
|
|
196
|
+
try:
|
|
197
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
198
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
199
|
+
raise ValueError("Manifesto Silver inválido ou ilegível.") from exc
|
|
200
|
+
if not isinstance(manifest, dict):
|
|
201
|
+
raise TypeError("Manifesto Silver deve ser um objeto JSON.")
|
|
202
|
+
if manifest.get("reference") != reference:
|
|
203
|
+
raise ValueError("Competência do manifesto diverge da solicitada.")
|
|
204
|
+
source_file = manifest.get("source_file")
|
|
205
|
+
if not isinstance(source_file, str) or not source_file.strip():
|
|
206
|
+
raise ValueError("Manifesto Silver deve informar source_file não vazio.")
|
|
207
|
+
|
|
208
|
+
expected_files = {
|
|
209
|
+
"input_prices.jsonl", "compositions.jsonl", "composition_items.jsonl"
|
|
210
|
+
}
|
|
211
|
+
artifacts = manifest.get("artifacts")
|
|
212
|
+
if not isinstance(artifacts, list):
|
|
213
|
+
raise TypeError("Manifesto Silver sem inventário de artefatos válido.")
|
|
214
|
+
metadata = {entry.get("file"): entry for entry in artifacts if isinstance(entry, dict)}
|
|
215
|
+
if set(metadata) != expected_files or len(metadata) != len(artifacts):
|
|
216
|
+
raise ValueError("Inventário de artefatos Silver incompleto ou inconsistente.")
|
|
217
|
+
|
|
218
|
+
artifact_paths: dict[str, Path] = {}
|
|
219
|
+
counts: dict[str, int] = {}
|
|
220
|
+
for filename in sorted(expected_files):
|
|
221
|
+
path = base / filename
|
|
222
|
+
artifact = metadata[filename]
|
|
223
|
+
if not path.is_file():
|
|
224
|
+
raise FileNotFoundError(f"Artefato Silver não encontrado: {path}")
|
|
225
|
+
if path.stat().st_size != artifact.get("size_bytes"):
|
|
226
|
+
raise ValueError(f"Tamanho divergente no artefato Silver: {filename}.")
|
|
227
|
+
|
|
228
|
+
# Uma única passagem valida hash, JSON, competência e contagem.
|
|
229
|
+
# A unicidade da chave natural é garantida pelo índice UNIQUE do SQLite
|
|
230
|
+
# durante executemany, sem manter centenas de milhares de chaves em RAM.
|
|
231
|
+
digest = hashlib.sha256()
|
|
232
|
+
record_count = 0
|
|
233
|
+
with path.open("rb") as stream:
|
|
234
|
+
for line_number, raw_line in enumerate(stream, start=1):
|
|
235
|
+
digest.update(raw_line)
|
|
236
|
+
if not raw_line.strip():
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
record = json.loads(raw_line)
|
|
240
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"JSONL inválido em {filename}, linha {line_number}."
|
|
243
|
+
) from exc
|
|
244
|
+
if not isinstance(record, dict):
|
|
245
|
+
raise TypeError(
|
|
246
|
+
f"Registro JSONL deve ser objeto: {filename}:{line_number}."
|
|
247
|
+
)
|
|
248
|
+
if record.get("reference") != reference:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"Competência divergente em {filename}, linha {line_number}."
|
|
251
|
+
)
|
|
252
|
+
record_count += 1
|
|
253
|
+
|
|
254
|
+
if digest.hexdigest() != artifact.get("sha256"):
|
|
255
|
+
raise ValueError(f"SHA-256 divergente no artefato Silver: {filename}.")
|
|
256
|
+
if record_count != artifact.get("records"):
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"Quantidade de registros divergente no artefato Silver: {filename}."
|
|
259
|
+
)
|
|
260
|
+
artifact_paths[filename] = path
|
|
261
|
+
counts[filename.removesuffix(".jsonl")] = record_count
|
|
262
|
+
|
|
263
|
+
database_path = Path(processed_dir).parent / "getsinapi.sqlite3"
|
|
264
|
+
with connect(database_path) as connection:
|
|
265
|
+
_ensure_schema(connection)
|
|
266
|
+
connection.execute(
|
|
267
|
+
"""INSERT INTO source_references(reference, source_file)
|
|
268
|
+
VALUES (?, ?)
|
|
269
|
+
ON CONFLICT(reference) DO UPDATE SET
|
|
270
|
+
source_file=excluded.source_file, loaded_at=CURRENT_TIMESTAMP""",
|
|
271
|
+
(reference, source_file),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Substitui apenas a competência solicitada, preservando todo o histórico.
|
|
275
|
+
connection.execute("DELETE FROM input_prices WHERE reference = ?", (reference,))
|
|
276
|
+
connection.execute("DELETE FROM composition_items WHERE reference = ?", (reference,))
|
|
277
|
+
connection.execute("DELETE FROM compositions WHERE reference = ?", (reference,))
|
|
278
|
+
|
|
279
|
+
connection.executemany(
|
|
280
|
+
"""INSERT INTO compositions(
|
|
281
|
+
reference, code, description, unit, status,
|
|
282
|
+
source_file, source_sheet, source_row
|
|
283
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
284
|
+
_mapped_rows(
|
|
285
|
+
artifact_paths["compositions.jsonl"],
|
|
286
|
+
lambda row: (
|
|
287
|
+
row["reference"], row["code"], row["description"], row["unit"],
|
|
288
|
+
row.get("status"), row["source_file"], row["source_sheet"], row["source_row"],
|
|
289
|
+
),
|
|
290
|
+
phase="compositions",
|
|
291
|
+
progress_callback=progress_callback,
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
connection.executemany(
|
|
295
|
+
"""INSERT INTO composition_items(
|
|
296
|
+
reference, composition_code, item_code, item_type, description,
|
|
297
|
+
unit, coefficient, status, source_file, source_sheet, source_row
|
|
298
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
299
|
+
_mapped_rows(
|
|
300
|
+
artifact_paths["composition_items.jsonl"],
|
|
301
|
+
lambda row: (
|
|
302
|
+
row["reference"], row["composition_code"], row["item_code"],
|
|
303
|
+
row["item_type"], row["description"], row["unit"], str(row["coefficient"]),
|
|
304
|
+
row.get("status"), row["source_file"], row["source_sheet"], row["source_row"],
|
|
305
|
+
),
|
|
306
|
+
phase="composition_items",
|
|
307
|
+
progress_callback=progress_callback,
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
connection.executemany(
|
|
312
|
+
"""INSERT INTO input_prices(
|
|
313
|
+
reference, code, description, unit, uf, regime, encargo_mode,
|
|
314
|
+
table_type, price, status, price_origin, source_file,
|
|
315
|
+
source_sheet, source_row
|
|
316
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
317
|
+
_mapped_rows(
|
|
318
|
+
artifact_paths["input_prices.jsonl"],
|
|
319
|
+
lambda row: (
|
|
320
|
+
row["reference"], row["code"], row["description"], row["unit"],
|
|
321
|
+
row["uf"], row.get("regime"), row["encargo_mode"], row["table_type"],
|
|
322
|
+
row.get("price"), row["status"], row.get("price_origin"),
|
|
323
|
+
row["source_file"], row["source_sheet"], row["source_row"],
|
|
324
|
+
),
|
|
325
|
+
phase="input_prices",
|
|
326
|
+
progress_callback=progress_callback,
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
except sqlite3.IntegrityError as exc:
|
|
330
|
+
if "uq_input_prices_natural_key" in str(exc):
|
|
331
|
+
raise ValueError(
|
|
332
|
+
"duplicate input-price natural key rejected by SQLite."
|
|
333
|
+
) from exc
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
"reference": reference,
|
|
338
|
+
"database_path": str(database_path),
|
|
339
|
+
"input_prices": counts["input_prices"],
|
|
340
|
+
"compositions": counts["compositions"],
|
|
341
|
+
"composition_items": counts["composition_items"],
|
|
342
|
+
}
|