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.
@@ -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
+ }