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/api/main.py ADDED
@@ -0,0 +1,791 @@
1
+ """Endpoints de leitura para dados SINAPI persistidos localmente."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import os
10
+ import secrets
11
+ import sqlite3
12
+ import threading
13
+ import time
14
+ from contextlib import closing
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+ from typing import Annotated, Any
18
+
19
+ from fastapi import FastAPI, HTTPException, Query, Request, Response
20
+ from fastapi.encoders import jsonable_encoder
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse, StreamingResponse
23
+ from pydantic import BaseModel, Field
24
+
25
+ from getsinapi import __version__
26
+ from getsinapi.domain import CompositionStatus, EncargoMode, PriceStatus, Regime
27
+ from getsinapi.extractors.compositions import CompositionItemRecord, CompositionRecord
28
+ from getsinapi.extractors.price_tables import InputPriceRecord
29
+ from getsinapi.services.costing import BudgetItem, CompositionCostCalculator
30
+
31
+ app = FastAPI(
32
+ title="GET-SINAPI API",
33
+ description="API de consulta dos dados históricos oficiais do SINAPI.",
34
+ version=__version__,
35
+ )
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["http://localhost:5173"],
40
+ allow_credentials=True,
41
+ allow_methods=["GET", "POST"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ class AuthRequest(BaseModel):
46
+ username: str = Field(min_length=3, max_length=80)
47
+ password: str = Field(min_length=12, max_length=256)
48
+
49
+
50
+ _AUTH_SESSIONS: dict[str, tuple[str, float]] = {}
51
+ _AUTH_LOCK = threading.RLock()
52
+ _AUTH_COOKIE = "getsinapi_session"
53
+
54
+
55
+ def _auth_path() -> Path:
56
+ base = Path(os.getenv("GETSINAPI_DATA_DIR", _database_path().parent))
57
+ return base / "security" / "users.json"
58
+
59
+
60
+ def _read_auth() -> dict[str, Any]:
61
+ path = _auth_path()
62
+ if not path.is_file():
63
+ return {"users": []}
64
+ try:
65
+ payload = json.loads(path.read_text(encoding="utf-8"))
66
+ return payload if isinstance(payload, dict) else {"users": []}
67
+ except (OSError, ValueError, TypeError):
68
+ return {"users": []}
69
+
70
+
71
+ def _password_digest(password: str, salt: bytes) -> str:
72
+ return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 310_000).hex()
73
+
74
+
75
+ def _new_session(username: str, response: Response) -> None:
76
+ token = secrets.token_urlsafe(40)
77
+ with _AUTH_LOCK:
78
+ _AUTH_SESSIONS[token] = (username, time.time() + 8 * 60 * 60)
79
+ response.set_cookie(_AUTH_COOKIE, token, httponly=True, samesite="strict",
80
+ secure=False, max_age=8 * 60 * 60, path="/")
81
+
82
+
83
+ def _session_user(request: Request) -> str | None:
84
+ token = request.cookies.get(_AUTH_COOKIE)
85
+ if not token:
86
+ return None
87
+ with _AUTH_LOCK:
88
+ session = _AUTH_SESSIONS.get(token)
89
+ if not session:
90
+ return None
91
+ if session[1] <= time.time():
92
+ _AUTH_SESSIONS.pop(token, None)
93
+ return None
94
+ return session[0]
95
+
96
+
97
+ @app.middleware("http")
98
+ async def require_local_auth(request: Request, call_next):
99
+ path = request.url.path
100
+ if (
101
+ path.startswith("/api/")
102
+ and path not in {
103
+ "/api/health", "/api/auth/status", "/api/auth/setup",
104
+ "/api/auth/login", "/api/auth/logout"
105
+ }
106
+ and _read_auth().get("users")
107
+ and not _session_user(request)
108
+ ):
109
+ return JSONResponse(status_code=401, content={"detail": "Sessão expirada ou não autenticada."})
110
+ return await call_next(request)
111
+
112
+
113
+ @app.get("/api/auth/status", tags=["Autenticação"])
114
+ def auth_status(request: Request) -> dict[str, Any]:
115
+ users = _read_auth().get("users", [])
116
+ username = _session_user(request)
117
+ return {"configured": bool(users), "authenticated": bool(username), "username": username, "role": "admin" if users and username == users[0].get("username") else "user"}
118
+
119
+
120
+ @app.post("/api/auth/setup", tags=["Autenticação"])
121
+ def auth_setup(payload: AuthRequest, response: Response) -> dict[str, str]:
122
+ with _AUTH_LOCK:
123
+ path = _auth_path()
124
+ current = _read_auth()
125
+ if current.get("users"):
126
+ raise HTTPException(status_code=409, detail="A configuração inicial já foi concluída.")
127
+ username = payload.username.strip().lower()
128
+ if not username:
129
+ raise HTTPException(status_code=422, detail="Informe um usuário válido.")
130
+ salt = secrets.token_bytes(24)
131
+ user = {"username": username, "salt": salt.hex(),
132
+ "password_hash": _password_digest(payload.password, salt),
133
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S")}
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ temporary = path.with_suffix(".json.tmp")
136
+ temporary.write_text(json.dumps({"users": [user]}, ensure_ascii=False, indent=2), encoding="utf-8")
137
+ temporary.replace(path)
138
+ _new_session(username, response)
139
+ return {"status": "configured", "username": username, "role": "admin"}
140
+
141
+
142
+ @app.post("/api/auth/login", tags=["Autenticação"])
143
+ def auth_login(payload: AuthRequest, response: Response) -> dict[str, str]:
144
+ username = payload.username.strip().lower()
145
+ users = _read_auth().get("users", [])
146
+ user = next((item for item in users if item.get("username") == username), None)
147
+ if not user:
148
+ raise HTTPException(status_code=401, detail="Usuário ou senha inválidos.")
149
+ try:
150
+ salt = bytes.fromhex(user["salt"])
151
+ expected = str(user["password_hash"])
152
+ except (KeyError, ValueError, TypeError):
153
+ raise HTTPException(status_code=500, detail="Cadastro local de autenticação inválido.")
154
+ if not secrets.compare_digest(_password_digest(payload.password, salt), expected):
155
+ raise HTTPException(status_code=401, detail="Usuário ou senha inválidos.")
156
+ _new_session(username, response)
157
+ return {"status": "authenticated", "username": username, "role": "admin" if users and username == users[0].get("username") else "user"}
158
+
159
+
160
+ @app.post("/api/auth/logout", tags=["Autenticação"])
161
+ def auth_logout(request: Request, response: Response) -> dict[str, str]:
162
+ token = request.cookies.get(_AUTH_COOKIE)
163
+ if token:
164
+ with _AUTH_LOCK:
165
+ _AUTH_SESSIONS.pop(token, None)
166
+ response.delete_cookie(_AUTH_COOKIE, path="/")
167
+ return {"status": "logged_out"}
168
+
169
+
170
+
171
+
172
+ @app.get("/api/auth/users", tags=["Autenticação"])
173
+ def auth_users(request: Request) -> dict[str, Any]:
174
+ users = _read_auth().get("users", [])
175
+ current = _session_user(request)
176
+ if not users or current != users[0].get("username"):
177
+ raise HTTPException(status_code=403, detail="Somente o administrador local pode gerenciar usuários.")
178
+ return {"users": [{"username": item.get("username"), "created_at": item.get("created_at"),
179
+ "role": "admin" if index == 0 else "user"}
180
+ for index, item in enumerate(users)]}
181
+
182
+
183
+ @app.post("/api/auth/users", tags=["Autenticação"])
184
+ def auth_add_user(payload: AuthRequest, request: Request) -> dict[str, str]:
185
+ with _AUTH_LOCK:
186
+ current = _session_user(request)
187
+ path = _auth_path()
188
+ data = _read_auth()
189
+ users = data.get("users", [])
190
+ if not users or current != users[0].get("username"):
191
+ raise HTTPException(status_code=403, detail="Somente o administrador local pode criar usuários.")
192
+ username = payload.username.strip().lower()
193
+ if not username:
194
+ raise HTTPException(status_code=422, detail="Informe um usuário válido.")
195
+ if any(item.get("username") == username for item in users):
196
+ raise HTTPException(status_code=409, detail="Este usuário já está cadastrado.")
197
+ salt = secrets.token_bytes(24)
198
+ users.append({"username": username, "salt": salt.hex(),
199
+ "password_hash": _password_digest(payload.password, salt),
200
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S")})
201
+ temporary = path.with_suffix(".json.tmp")
202
+ temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
203
+ temporary.replace(path)
204
+ return {"status": "created", "username": username}
205
+
206
+
207
+ class BudgetLineRequest(BaseModel):
208
+ code: str = Field(min_length=1, max_length=40)
209
+ item_type: str = Field(pattern="^(INSUMO|COMPOSICAO)$")
210
+ quantity: Decimal = Field(ge=0)
211
+
212
+
213
+ class BudgetRequest(BaseModel):
214
+ reference: str = Field(pattern=r"^\d{4}-(0[1-9]|1[0-2])$")
215
+ uf: str = Field(min_length=2, max_length=2)
216
+ regime: Regime | None = Regime.NAO_DESONERADO
217
+ encargo_mode: EncargoMode = EncargoMode.COM_ENCARGOS
218
+ items: list[BudgetLineRequest] = Field(min_length=1, max_length=500)
219
+
220
+
221
+ def _database_path() -> Path:
222
+ return Path(os.getenv("GETSINAPI_DATABASE_PATH", "data/getsinapi.sqlite3"))
223
+
224
+
225
+ def _connect() -> sqlite3.Connection:
226
+ path = _database_path()
227
+ if not path.is_file():
228
+ raise HTTPException(
229
+ status_code=503,
230
+ detail="Banco SINAPI não encontrado. Execute a normalização e a carga da competência.",
231
+ )
232
+ connection = sqlite3.connect(path)
233
+ connection.row_factory = sqlite3.Row
234
+ return connection
235
+
236
+
237
+ def _rows(sql: str, parameters: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
238
+ try:
239
+ with closing(_connect()) as connection, connection:
240
+ return [dict(row) for row in connection.execute(sql, parameters).fetchall()]
241
+ except sqlite3.OperationalError as exc:
242
+ if "no such table" in str(exc):
243
+ raise HTTPException(
244
+ status_code=503,
245
+ detail="Banco ainda não inicializado. Execute getsinapi load.",
246
+ ) from exc
247
+ raise
248
+
249
+
250
+ @app.get("/api/health", tags=["Sistema"], summary="Verifica a disponibilidade da API")
251
+ def health_check() -> dict[str, str]:
252
+ """Verifica o serviço HTTP sem simular conectividade com o banco."""
253
+ return {"status": "ok", "service": "get-sinapi-api", "version": app.version}
254
+
255
+
256
+ @app.get("/api/references", tags=["Dados"], summary="Lista competências disponíveis")
257
+ def list_references() -> list[dict[str, Any]]:
258
+ return _rows(
259
+ """SELECT reference, source_file, loaded_at
260
+ FROM source_references ORDER BY reference DESC"""
261
+ )
262
+
263
+
264
+ @app.get("/api/summary", tags=["Dados"], summary="Resumo de uma competência")
265
+ def summary(reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$")) -> dict[str, Any]:
266
+ rows = _rows(
267
+ """SELECT
268
+ (SELECT COUNT(*) FROM input_prices WHERE reference = ?) AS input_prices,
269
+ (SELECT COUNT(*) FROM compositions WHERE reference = ?) AS compositions,
270
+ (SELECT COUNT(*) FROM composition_items WHERE reference = ?) AS composition_items,
271
+ (SELECT COUNT(DISTINCT uf) FROM input_prices WHERE reference = ?) AS states,
272
+ (SELECT COUNT(*) FROM input_prices
273
+ WHERE reference = ? AND status IN ('missing', 'not_researched')) AS unavailable_prices""",
274
+ (reference, reference, reference, reference, reference),
275
+ )
276
+ if not rows:
277
+ raise HTTPException(status_code=404, detail="Competência não encontrada.")
278
+ return {"reference": reference, **rows[0]}
279
+
280
+
281
+ def _escape_like(value: str) -> str:
282
+ """Escape SQLite LIKE metacharacters so search terms remain literal."""
283
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
284
+
285
+
286
+ @app.get("/api/insumos", tags=["Insumos"], summary="Consulta preços de insumos")
287
+ def list_input_prices(
288
+ reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
289
+ uf: str = Query(..., min_length=2, max_length=2),
290
+ regime: str | None = Query(None, pattern="^(desonerado|nao_desonerado)$"),
291
+ encargo_mode: str = Query("com_encargos", pattern="^(com_encargos|sem_encargos)$"),
292
+ search: str | None = Query(None, min_length=2, max_length=120),
293
+ limit: int = Query(100, ge=1, le=500),
294
+ offset: int = Query(0, ge=0),
295
+ ) -> list[dict[str, Any]]:
296
+ clauses = ["reference = ?", "uf = ?", "encargo_mode = ?"]
297
+ parameters: list[Any] = [reference, uf.upper(), encargo_mode]
298
+ if regime is not None:
299
+ clauses.append("regime = ?")
300
+ parameters.append(regime)
301
+ if search:
302
+ clauses.append("(code LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')")
303
+ term = f"%{_escape_like(search.strip())}%"
304
+ parameters.extend((term, term))
305
+ parameters.extend((limit, offset))
306
+ return _rows(
307
+ f"""SELECT code, description, unit, uf, regime, encargo_mode,
308
+ table_type, price, status, price_origin
309
+ FROM input_prices WHERE {' AND '.join(clauses)}
310
+ ORDER BY code LIMIT ? OFFSET ?""",
311
+ tuple(parameters),
312
+ )
313
+
314
+
315
+ @app.get("/api/composicoes", tags=["Composições"], summary="Consulta composições")
316
+ def list_compositions(
317
+ reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
318
+ search: str | None = Query(None, min_length=2, max_length=120),
319
+ limit: int = Query(100, ge=1, le=500),
320
+ offset: int = Query(0, ge=0),
321
+ ) -> list[dict[str, Any]]:
322
+ clauses = ["reference = ?"]
323
+ parameters: list[Any] = [reference]
324
+ if search:
325
+ clauses.append("(code LIKE ? OR description LIKE ?)")
326
+ term = f"%{search.strip()}%"
327
+ parameters.extend((term, term))
328
+ parameters.extend((limit, offset))
329
+ return _rows(
330
+ f"""SELECT code, description, unit, status
331
+ FROM compositions WHERE {' AND '.join(clauses)}
332
+ ORDER BY code LIMIT ? OFFSET ?""",
333
+ tuple(parameters),
334
+ )
335
+
336
+
337
+ @app.get(
338
+ "/api/composicoes/{code}/itens",
339
+ tags=["Composições"],
340
+ summary="Lista os componentes de uma composição",
341
+ )
342
+ def composition_items(
343
+ code: str,
344
+ reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
345
+ ) -> list[dict[str, Any]]:
346
+ return _rows(
347
+ """SELECT item_code, item_type, description, unit, coefficient, status, source_row
348
+ FROM composition_items
349
+ WHERE reference = ? AND composition_code = ?
350
+ ORDER BY source_row""",
351
+ (reference, code),
352
+ )
353
+
354
+ def _cost_calculator(
355
+ *, reference: str, uf: str, regime: Regime | None, encargo_mode: EncargoMode
356
+ ) -> CompositionCostCalculator:
357
+ price_rows = _rows(
358
+ """SELECT reference, code, description, unit, uf, regime, encargo_mode,
359
+ price, status, price_origin, source_file, source_sheet, source_row
360
+ FROM input_prices
361
+ WHERE reference = ? AND uf = ? AND regime IS ? AND encargo_mode = ?""",
362
+ (reference, uf.upper(), regime.value if regime else None, encargo_mode.value),
363
+ )
364
+ composition_rows = _rows(
365
+ """SELECT reference, code, description, unit, status,
366
+ source_file, source_sheet, source_row
367
+ FROM compositions WHERE reference = ?""",
368
+ (reference,),
369
+ )
370
+ item_rows = _rows(
371
+ """SELECT reference, composition_code, item_code, item_type,
372
+ description, unit, coefficient, status, source_file, source_sheet, source_row
373
+ FROM composition_items WHERE reference = ? ORDER BY source_row""",
374
+ (reference,),
375
+ )
376
+ prices = [
377
+ InputPriceRecord(
378
+ **{
379
+ **row,
380
+ "regime": Regime(row["regime"]) if row["regime"] else None,
381
+ "encargo_mode": EncargoMode(row["encargo_mode"]),
382
+ "price": Decimal(row["price"]) if row["price"] is not None else None,
383
+ "status": PriceStatus(row["status"]),
384
+ }
385
+ )
386
+ for row in price_rows
387
+ ]
388
+ compositions = [
389
+ CompositionRecord(
390
+ **{
391
+ **row,
392
+ "status": CompositionStatus(row["status"]) if row["status"] else None,
393
+ }
394
+ )
395
+ for row in composition_rows
396
+ ]
397
+ item_records = [
398
+ CompositionItemRecord(
399
+ **{
400
+ **row,
401
+ "coefficient": Decimal(row["coefficient"]),
402
+ "status": CompositionStatus(row["status"]) if row["status"] else None,
403
+ }
404
+ )
405
+ for row in item_rows
406
+ ]
407
+ return CompositionCostCalculator(
408
+ prices=prices,
409
+ compositions=compositions,
410
+ composition_items=item_records,
411
+ reference=reference,
412
+ uf=uf,
413
+ regime=regime,
414
+ encargo_mode=encargo_mode,
415
+ )
416
+
417
+
418
+ @app.get(
419
+ "/api/composicoes/{code}/custo",
420
+ tags=["Orçamento"],
421
+ summary="Calcula o custo unitário recursivo de uma composição",
422
+ )
423
+ def composition_cost(
424
+ code: str,
425
+ reference: Annotated[str, Query(pattern=r"^\d{4}-(0[1-9]|1[0-2])$")],
426
+ uf: Annotated[str, Query(min_length=2, max_length=2)],
427
+ regime: Annotated[Regime | None, Query()] = None,
428
+ encargo_mode: Annotated[EncargoMode, Query()] = EncargoMode.COM_ENCARGOS,
429
+ ) -> dict[str, Any]:
430
+ effective_regime = (
431
+ None if encargo_mode == EncargoMode.SEM_ENCARGOS
432
+ else regime or Regime.NAO_DESONERADO
433
+ )
434
+ calculator = _cost_calculator(
435
+ reference=reference, uf=uf, regime=effective_regime, encargo_mode=encargo_mode
436
+ )
437
+ return jsonable_encoder(calculator.calculate_composition(code))
438
+
439
+
440
+ @app.post(
441
+ "/api/budget",
442
+ tags=["Orçamento"],
443
+ summary="Calcula orçamento com insumos e composições SINAPI",
444
+ )
445
+ def calculate_budget(request: BudgetRequest) -> dict[str, Any]:
446
+ effective_regime = (
447
+ None if request.encargo_mode == EncargoMode.SEM_ENCARGOS
448
+ else request.regime or Regime.NAO_DESONERADO
449
+ )
450
+ calculator = _cost_calculator(
451
+ reference=request.reference,
452
+ uf=request.uf,
453
+ regime=effective_regime,
454
+ encargo_mode=request.encargo_mode,
455
+ )
456
+ items = [
457
+ BudgetItem(code=item.code, item_type=item.item_type, quantity=item.quantity)
458
+ for item in request.items
459
+ ]
460
+ return jsonable_encoder(calculator.calculate_budget(items))
461
+
462
+
463
+
464
+ @app.get("/api/catalog", tags=["Catálogo"], summary="Catálogo unificado e paginado de todos os registros SINAPI")
465
+ def catalog(
466
+ search: str | None = Query(None, min_length=2, max_length=120),
467
+ uf: str | None = Query(None, min_length=2, max_length=2, pattern="^[A-Za-z]{2}$"),
468
+ record_type: str | None = Query(None, pattern="^(INSUMO|COMPOSICAO|COMPONENTE)$"),
469
+ limit: int = Query(100, ge=1, le=500),
470
+ offset: int = Query(0, ge=0),
471
+ ) -> dict[str, Any]:
472
+ """Lista preços, composições e componentes em uma grade única, sem limitar competência."""
473
+ cte = """
474
+ WITH catalog AS (
475
+ SELECT reference, 'INSUMO' AS record_type, code,
476
+ description, unit, uf, regime, encargo_mode, table_type,
477
+ price AS value, 'PREÇO' AS value_type,
478
+ NULL AS composition_code, status, source_file, source_sheet, source_row
479
+ FROM input_prices
480
+ UNION ALL
481
+ SELECT reference, 'COMPOSICAO', code, description, unit,
482
+ NULL, NULL, NULL, 'Analítico', NULL, NULL,
483
+ NULL, status, source_file, source_sheet, source_row
484
+ FROM compositions
485
+ UNION ALL
486
+ SELECT reference, 'COMPONENTE', item_code, description, unit,
487
+ NULL, NULL, NULL, 'Analítico', coefficient, 'COEFICIENTE',
488
+ composition_code, status, source_file, source_sheet, source_row
489
+ FROM composition_items
490
+ )
491
+ """
492
+ clauses: list[str] = []
493
+ parameters: list[Any] = []
494
+ if uf:
495
+ # Composições e componentes são nacionais; permanecem visíveis junto aos preços da UF.
496
+ clauses.append("(uf = ? OR uf IS NULL)")
497
+ parameters.append(uf.upper())
498
+ if record_type:
499
+ clauses.append("record_type = ?")
500
+ parameters.append(record_type)
501
+ if search:
502
+ term = f"%{_escape_like(search.strip())}%"
503
+ clauses.append(
504
+ "(code LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' "
505
+ "OR composition_code LIKE ? ESCAPE '\\' OR source_file LIKE ? ESCAPE '\\' "
506
+ "OR source_sheet LIKE ? ESCAPE '\\')"
507
+ )
508
+ parameters.extend((term, term, term, term, term))
509
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
510
+ try:
511
+ with closing(_connect()) as connection, connection:
512
+ total = int(connection.execute(
513
+ cte + " SELECT COUNT(*) FROM catalog" + where, tuple(parameters)
514
+ ).fetchone()[0])
515
+ rows = [dict(row) for row in connection.execute(
516
+ cte + " SELECT * FROM catalog" + where
517
+ + " ORDER BY reference DESC, record_type, code, uf LIMIT ? OFFSET ?",
518
+ (*parameters, limit, offset),
519
+ ).fetchall()]
520
+ except sqlite3.OperationalError as exc:
521
+ if "no such table" in str(exc):
522
+ raise HTTPException(
523
+ status_code=503, detail="Banco SINAPI ainda não foi inicializado."
524
+ ) from exc
525
+ raise
526
+ return {"items": rows, "total": total, "limit": limit, "offset": offset}
527
+
528
+
529
+ @app.get("/api/analytics", tags=["Análises"], summary="Agregados reais para o painel analítico")
530
+ def analytics(reference: str | None = Query(None, pattern=r"^\d{4}-(0[1-9]|1[0-2])$")) -> dict[str, Any]:
531
+ """Retorna séries e agregações calculadas diretamente da base SINAPI."""
532
+ with closing(_connect()) as connection, connection:
533
+ refs = [dict(row) for row in connection.execute(
534
+ "SELECT reference, source_file, loaded_at FROM source_references ORDER BY reference"
535
+ ).fetchall()]
536
+ selected = reference or (refs[-1]["reference"] if refs else None)
537
+ if not selected:
538
+ return {"reference": None, "references": refs, "totals": {
539
+ "input_prices": 0, "compositions": 0, "composition_items": 0,
540
+ "states": 0, "unavailable_prices": 0
541
+ }, "by_state": [], "by_reference": [], "by_table": [], "status": []}
542
+ totals = connection.execute(
543
+ """SELECT
544
+ (SELECT COUNT(*) FROM input_prices WHERE reference = ?) input_prices,
545
+ (SELECT COUNT(*) FROM compositions WHERE reference = ?) compositions,
546
+ (SELECT COUNT(*) FROM composition_items WHERE reference = ?) composition_items,
547
+ (SELECT COUNT(DISTINCT uf) FROM input_prices WHERE reference = ?) states,
548
+ (SELECT COUNT(*) FROM input_prices WHERE reference = ?
549
+ AND status IN ('missing','not_researched')) unavailable_prices""",
550
+ (selected, selected, selected, selected, selected)
551
+ ).fetchone()
552
+ by_state = [dict(row) for row in connection.execute(
553
+ """SELECT uf, COUNT(*) observations,
554
+ COUNT(CASE WHEN price IS NOT NULL AND TRIM(price) <> '' THEN 1 END) priced,
555
+ ROUND(AVG(CASE WHEN price IS NOT NULL AND TRIM(price) <> ''
556
+ THEN CAST(REPLACE(price, ',', '.') AS REAL) END), 4) average_price
557
+ FROM input_prices WHERE reference = ?
558
+ GROUP BY uf ORDER BY uf""", (selected,)
559
+ ).fetchall()]
560
+ by_table = [dict(row) for row in connection.execute(
561
+ """SELECT table_type, COUNT(*) observations,
562
+ COUNT(CASE WHEN price IS NOT NULL AND TRIM(price) <> '' THEN 1 END) priced
563
+ FROM input_prices WHERE reference = ? GROUP BY table_type ORDER BY table_type""",
564
+ (selected,)
565
+ ).fetchall()]
566
+ status = [dict(row) for row in connection.execute(
567
+ """SELECT status, COUNT(*) quantity FROM input_prices
568
+ WHERE reference = ? GROUP BY status ORDER BY quantity DESC""", (selected,)
569
+ ).fetchall()]
570
+ by_reference = [dict(row) for row in connection.execute(
571
+ """SELECT r.reference,
572
+ (SELECT COUNT(*) FROM input_prices p WHERE p.reference=r.reference) input_prices,
573
+ (SELECT COUNT(*) FROM compositions c WHERE c.reference=r.reference) compositions,
574
+ (SELECT COUNT(DISTINCT uf) FROM input_prices p WHERE p.reference=r.reference) states
575
+ FROM source_references r ORDER BY r.reference""").fetchall()]
576
+ return {
577
+ "reference": selected, "references": refs, "totals": dict(totals),
578
+ "by_state": by_state, "by_reference": by_reference,
579
+ "by_table": by_table, "status": status
580
+ }
581
+
582
+
583
+
584
+
585
+ @app.get("/api/catalog/advanced", tags=["Catálogo"], summary="Consulta avançada com filtros combináveis")
586
+ def catalog_advanced(
587
+ search: str | None = Query(None, min_length=2, max_length=120),
588
+ reference: str | None = Query(None, pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
589
+ uf: str | None = Query(None, min_length=2, max_length=2, pattern="^[A-Za-z]{2}$"),
590
+ record_type: str | None = Query(None, pattern="^(INSUMO|COMPOSICAO|COMPONENTE)$"),
591
+ regime: str | None = Query(None, pattern="^(desonerado|nao_desonerado)$"),
592
+ table_type: str | None = Query(None, max_length=80),
593
+ min_value: float | None = Query(None),
594
+ max_value: float | None = Query(None),
595
+ priced_only: bool | None = Query(None),
596
+ limit: int = Query(100, ge=1, le=500),
597
+ offset: int = Query(0, ge=0),
598
+ ) -> dict[str, Any]:
599
+ cte = """
600
+ WITH catalog AS (
601
+ SELECT reference,'INSUMO' record_type,code,description,unit,uf,regime,
602
+ encargo_mode,table_type,price value,'PREÇO' value_type,NULL composition_code,
603
+ status,source_file,source_sheet,source_row FROM input_prices
604
+ UNION ALL
605
+ SELECT reference,'COMPOSICAO',code,description,unit,NULL,NULL,NULL,'Analítico',
606
+ NULL,NULL,NULL,status,source_file,source_sheet,source_row FROM compositions
607
+ UNION ALL
608
+ SELECT reference,'COMPONENTE',item_code,description,unit,NULL,NULL,NULL,'Analítico',
609
+ coefficient,'COEFICIENTE',composition_code,status,source_file,source_sheet,source_row
610
+ FROM composition_items
611
+ )
612
+ """
613
+ clauses: list[str] = []
614
+ params: list[Any] = []
615
+ for column, value in (("reference", reference), ("record_type", record_type),
616
+ ("regime", regime), ("table_type", table_type)):
617
+ if value:
618
+ clauses.append(column + " = ?")
619
+ params.append(value)
620
+ if uf:
621
+ clauses.append("(uf = ? OR uf IS NULL)")
622
+ params.append(uf.upper())
623
+ if search:
624
+ term = "%" + _escape_like(search.strip()) + "%"
625
+ clauses.append("(code LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR composition_code LIKE ? ESCAPE '\\' OR source_file LIKE ? ESCAPE '\\')")
626
+ params.extend((term, term, term, term))
627
+ if min_value is not None:
628
+ clauses.append("CAST(REPLACE(value, ',', '.') AS REAL) >= ?")
629
+ params.append(min_value)
630
+ if max_value is not None:
631
+ clauses.append("CAST(REPLACE(value, ',', '.') AS REAL) <= ?")
632
+ params.append(max_value)
633
+ if priced_only is True:
634
+ clauses.append("value IS NOT NULL")
635
+ elif priced_only is False:
636
+ clauses.append("value IS NULL")
637
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
638
+ try:
639
+ with closing(_connect()) as connection, connection:
640
+ total = int(connection.execute(cte + " SELECT COUNT(*) FROM catalog" + where, tuple(params)).fetchone()[0])
641
+ rows = [dict(row) for row in connection.execute(
642
+ cte + " SELECT * FROM catalog" + where +
643
+ " ORDER BY reference DESC, record_type, code, uf LIMIT ? OFFSET ?",
644
+ (*params, limit, offset)).fetchall()]
645
+ except sqlite3.OperationalError as exc:
646
+ if "no such table" in str(exc):
647
+ raise HTTPException(status_code=503, detail="Banco SINAPI ainda não foi inicializado.") from exc
648
+ raise
649
+ return {"items": rows, "total": total, "limit": limit, "offset": offset}
650
+
651
+
652
+ @app.get("/api/catalog/export", tags=["Relatórios"], summary="Exporta o catálogo filtrado em CSV ou JSON")
653
+ def export_catalog(
654
+ format: str = Query("csv", pattern="^(csv|json|xlsx|pdf)$"),
655
+ search: str | None = Query(None, min_length=2, max_length=120),
656
+ reference: str | None = Query(None, pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
657
+ uf: str | None = Query(None, min_length=2, max_length=2, pattern="^[A-Za-z]{2}$"),
658
+ record_type: str | None = Query(None, pattern="^(INSUMO|COMPOSICAO|COMPONENTE)$"),
659
+ regime: str | None = Query(None, pattern="^(desonerado|nao_desonerado)$"),
660
+ table_type: str | None = Query(None, max_length=80),
661
+ min_value: float | None = Query(None),
662
+ max_value: float | None = Query(None),
663
+ priced_only: bool | None = Query(None),
664
+ ) -> StreamingResponse:
665
+ rows: list[dict[str, Any]] = []
666
+ offset = 0
667
+ while True:
668
+ result = catalog_advanced(search, reference, uf, record_type, regime, table_type,
669
+ min_value, max_value, priced_only, 500, offset)
670
+ rows.extend(result["items"])
671
+ offset += 500
672
+ if offset >= result["total"] or not result["items"]:
673
+ break
674
+ if format == "json":
675
+ payload = json.dumps({"total": len(rows), "items": rows}, ensure_ascii=False, indent=2).encode("utf-8")
676
+ return StreamingResponse(iter([payload]), media_type="application/json",
677
+ headers={"Content-Disposition": "attachment; filename=get-sinapi-catalogo.json"})
678
+ if format == "xlsx":
679
+ from openpyxl import Workbook
680
+ workbook = Workbook(write_only=True)
681
+ sheet = workbook.create_sheet("Catálogo SINAPI")
682
+ if rows:
683
+ sheet.append(list(rows[0].keys()))
684
+ for item in rows:
685
+ sheet.append([item.get(key) for key in rows[0]])
686
+ buffer = io.BytesIO()
687
+ workbook.save(buffer)
688
+ return StreamingResponse(iter([buffer.getvalue()]),
689
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
690
+ headers={"Content-Disposition": "attachment; filename=get-sinapi-catalogo.xlsx"})
691
+ if format == "pdf":
692
+ from xml.sax.saxutils import escape
693
+
694
+ from reportlab.lib import colors
695
+ from reportlab.lib.enums import TA_LEFT
696
+ from reportlab.lib.pagesizes import A4, landscape
697
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
698
+ from reportlab.lib.units import mm
699
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
700
+ buffer = io.BytesIO()
701
+ document = SimpleDocTemplate(buffer, pagesize=landscape(A4),
702
+ rightMargin=10*mm, leftMargin=10*mm, topMargin=10*mm, bottomMargin=10*mm)
703
+ styles = getSampleStyleSheet()
704
+ small = ParagraphStyle("Small", parent=styles["BodyText"], fontSize=6, leading=7, alignment=TA_LEFT)
705
+ story = [Paragraph("GET-SINAPI | Relatório de consulta", styles["Title"]),
706
+ Paragraph("NR TECH · Fonte: CAIXA Econômica Federal · SINAPI", styles["Normal"]),
707
+ Paragraph("Registros encontrados: " + str(len(rows)), styles["Normal"]),
708
+ Spacer(1, 5*mm)]
709
+ columns = ["Competência", "Tipo", "Código", "Descrição", "UF", "Valor / coef.", "Arquivo"]
710
+ matrix = [columns]
711
+ for item in rows[:150]:
712
+ matrix.append([
713
+ str(item.get("reference") or ""), str(item.get("record_type") or ""),
714
+ str(item.get("code") or ""), Paragraph(escape(str(item.get("description") or "")), small),
715
+ str(item.get("uf") or "Nacional"), str(item.get("value") or ""),
716
+ Paragraph(escape(str(item.get("source_file") or "")), small)
717
+ ])
718
+ if len(rows) > 150:
719
+ story.append(Paragraph("Prévia limitada aos primeiros 150 registros. CSV, XLSX e JSON contêm a extração integral.", styles["Italic"]))
720
+ table = Table(matrix, repeatRows=1, colWidths=[22*mm,22*mm,20*mm,95*mm,18*mm,24*mm,60*mm])
721
+ table.setStyle(TableStyle([
722
+ ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#10243A")),
723
+ ("TEXTCOLOR",(0,0),(-1,0),colors.white),
724
+ ("GRID",(0,0),(-1,-1),0.25,colors.HexColor("#AAB8C5")),
725
+ ("FONTSIZE",(0,0),(-1,0),7),("FONTSIZE",(0,1),(-1,-1),6),
726
+ ("VALIGN",(0,0),(-1,-1),"TOP"),("ROWBACKGROUNDS",(0,1),(-1,-1),[colors.white,colors.HexColor("#EEF4F8")]),
727
+ ("LEFTPADDING",(0,0),(-1,-1),3),("RIGHTPADDING",(0,0),(-1,-1),3),
728
+ ("TOPPADDING",(0,0),(-1,-1),3),("BOTTOMPADDING",(0,0),(-1,-1),3)
729
+ ]))
730
+ story.append(table)
731
+ document.build(story)
732
+ return StreamingResponse(iter([buffer.getvalue()]), media_type="application/pdf",
733
+ headers={"Content-Disposition": "attachment; filename=get-sinapi-relatorio.pdf"})
734
+ output = io.StringIO(newline="")
735
+ if rows:
736
+ writer = csv.DictWriter(output, fieldnames=list(rows[0].keys()), delimiter=";")
737
+ writer.writeheader()
738
+ writer.writerows(rows)
739
+ payload = ("\ufeff" + output.getvalue()).encode("utf-8")
740
+ return StreamingResponse(iter([payload]), media_type="text/csv; charset=utf-8",
741
+ headers={"Content-Disposition": "attachment; filename=get-sinapi-catalogo.csv"})
742
+
743
+
744
+ @app.get("/api/compare/prices", tags=["Análises"], summary="Compara preço de insumo entre UFs")
745
+ def compare_prices(
746
+ code: str = Query(..., min_length=1, max_length=40),
747
+ reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
748
+ regime: str = Query("nao_desonerado", pattern="^(desonerado|nao_desonerado)$"),
749
+ encargo_mode: str = Query("com_encargos", pattern="^(com_encargos|sem_encargos)$"),
750
+ ) -> list[dict[str, Any]]:
751
+ return _rows(
752
+ """SELECT code,description,unit,uf,regime,encargo_mode,table_type,price,status,price_origin
753
+ FROM input_prices WHERE reference=? AND code=? AND regime=? AND encargo_mode=?
754
+ ORDER BY uf,table_type""",
755
+ (reference, code, regime, encargo_mode))
756
+
757
+
758
+ @app.get("/api/insumos/count", tags=["Insumos"], summary="Total de resultados do catálogo de insumos")
759
+ def count_input_prices(
760
+ reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
761
+ uf: str = Query(..., min_length=2, max_length=2),
762
+ regime: str | None = Query(None, pattern="^(desonerado|nao_desonerado)$"),
763
+ encargo_mode: str = Query("com_encargos", pattern="^(com_encargos|sem_encargos)$"),
764
+ search: str | None = Query(None, min_length=2, max_length=120),
765
+ ) -> dict[str, int]:
766
+ clauses = ["reference = ?", "uf = ?", "encargo_mode = ?"]
767
+ parameters: list[Any] = [reference, uf.upper(), encargo_mode]
768
+ if regime is not None:
769
+ clauses.append("regime = ?")
770
+ parameters.append(regime)
771
+ if search:
772
+ clauses.append("(code LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')")
773
+ term = f"%{_escape_like(search.strip())}%"
774
+ parameters.extend((term, term))
775
+ rows = _rows(f"SELECT COUNT(*) AS total FROM input_prices WHERE {' AND '.join(clauses)}", tuple(parameters))
776
+ return {"total": int(rows[0]["total"])}
777
+
778
+
779
+ @app.get("/api/composicoes/count", tags=["Composições"], summary="Total de resultados do catálogo de composições")
780
+ def count_compositions(
781
+ reference: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"),
782
+ search: str | None = Query(None, min_length=2, max_length=120),
783
+ ) -> dict[str, int]:
784
+ clauses = ["reference = ?"]
785
+ parameters: list[Any] = [reference]
786
+ if search:
787
+ clauses.append("(code LIKE ? OR description LIKE ?)")
788
+ term = f"%{search.strip()}%"
789
+ parameters.extend((term, term))
790
+ rows = _rows(f"SELECT COUNT(*) AS total FROM compositions WHERE {' AND '.join(clauses)}", tuple(parameters))
791
+ return {"total": int(rows[0]["total"])}