get-sinapi 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- get_sinapi-3.1.0.dist-info/METADATA +236 -0
- get_sinapi-3.1.0.dist-info/RECORD +30 -0
- get_sinapi-3.1.0.dist-info/WHEEL +5 -0
- get_sinapi-3.1.0.dist-info/entry_points.txt +2 -0
- get_sinapi-3.1.0.dist-info/licenses/LICENSE +21 -0
- get_sinapi-3.1.0.dist-info/top_level.txt +1 -0
- getsinapi/__init__.py +2 -0
- getsinapi/api/__init__.py +1 -0
- getsinapi/api/main.py +791 -0
- getsinapi/bootstrap.py +422 -0
- getsinapi/cli.py +206 -0
- getsinapi/collectors/__init__.py +1 -0
- getsinapi/collectors/caixa.py +386 -0
- getsinapi/collectors/history.py +238 -0
- getsinapi/config.py +60 -0
- getsinapi/domain.py +74 -0
- getsinapi/exporter.py +189 -0
- getsinapi/extractors/__init__.py +1 -0
- getsinapi/extractors/compositions.py +213 -0
- getsinapi/extractors/price_tables.py +151 -0
- getsinapi/extractors/reference_workbook.py +68 -0
- getsinapi/extractors/sinapi_parser.py +158 -0
- getsinapi/extractors/workbooks.py +126 -0
- getsinapi/normalizers.py +114 -0
- getsinapi/pipeline/__init__.py +1 -0
- getsinapi/pipeline/normalize.py +114 -0
- getsinapi/services/__init__.py +1 -0
- getsinapi/services/costing.py +332 -0
- getsinapi/storage/__init__.py +1 -0
- getsinapi/storage/sqlite.py +342 -0
getsinapi/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"])}
|