agroplan-ai-cli 1.0.23 → 1.0.24
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.
- package/backend-template/VERSION.json +7 -4
- package/backend-template/api.py +97 -2
- package/backend-template/core/price_adapter.py +191 -0
- package/backend-template/core/report_generator.py +7 -0
- package/backend-template/data/precos/precos_fallback.json +87 -0
- package/backend-template/data/precos/precos_index.json +58 -0
- package/backend-template/providers/price_provider.py +219 -0
- package/package.json +1 -1
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"cli_version": "1.0.
|
|
3
|
-
"backend_template_version": "1.0.
|
|
2
|
+
"cli_version": "1.0.24",
|
|
3
|
+
"backend_template_version": "1.0.24",
|
|
4
4
|
"zarc_index_version": "2025-2026-fast-index-v2",
|
|
5
|
+
"price_index_version": "2025-05-reference-v1",
|
|
5
6
|
"features": [
|
|
6
7
|
"zarc_fast_index",
|
|
7
8
|
"zarc_fallback_sorgo_mandioca",
|
|
8
9
|
"soil_normalization_misto_siltoso",
|
|
9
10
|
"climate_real_data",
|
|
10
11
|
"hybrid_mode",
|
|
11
|
-
"report_generator_zarc_support"
|
|
12
|
+
"report_generator_zarc_support",
|
|
13
|
+
"price_provider_index_fallback",
|
|
14
|
+
"price_display_only"
|
|
12
15
|
],
|
|
13
|
-
"generated_at": "2026-05-
|
|
16
|
+
"generated_at": "2026-05-09T20:00:00Z"
|
|
14
17
|
}
|
package/backend-template/api.py
CHANGED
|
@@ -151,6 +151,10 @@ def health():
|
|
|
151
151
|
from providers.zarc_provider import get_zarc_status
|
|
152
152
|
zarc_status = get_zarc_status()
|
|
153
153
|
|
|
154
|
+
# Verificar status de preços
|
|
155
|
+
from providers.price_provider import get_price_status
|
|
156
|
+
price_status = get_price_status()
|
|
157
|
+
|
|
154
158
|
# Carregar VERSION.json se existir
|
|
155
159
|
version_info = {}
|
|
156
160
|
version_path = os.path.join(os.path.dirname(__file__), "VERSION.json")
|
|
@@ -168,7 +172,8 @@ def health():
|
|
|
168
172
|
"data_mode": DATA_MODE,
|
|
169
173
|
"providers": {
|
|
170
174
|
"weather": "available" if WEATHER_PROVIDER else "disabled",
|
|
171
|
-
"zarc": zarc_status
|
|
175
|
+
"zarc": zarc_status,
|
|
176
|
+
"prices": price_status
|
|
172
177
|
},
|
|
173
178
|
"provider_cache": provider_cache_stats
|
|
174
179
|
}
|
|
@@ -420,6 +425,71 @@ def get_zarc(
|
|
|
420
425
|
except Exception as e:
|
|
421
426
|
raise HTTPException(status_code=500, detail=str(e))
|
|
422
427
|
|
|
428
|
+
@app.get("/dados/precos")
|
|
429
|
+
def get_precos(
|
|
430
|
+
cultura: Optional[str] = Query(None, description="Nome da cultura"),
|
|
431
|
+
uf: Optional[str] = Query(None, description="Unidade Federativa (ex: SP, PR)")
|
|
432
|
+
):
|
|
433
|
+
"""Obtém preços agrícolas"""
|
|
434
|
+
try:
|
|
435
|
+
# Importar provider de preços
|
|
436
|
+
from providers.price_provider import buscar_preco
|
|
437
|
+
|
|
438
|
+
# Se cultura não foi fornecida, retornar mensagem amigável
|
|
439
|
+
if not cultura:
|
|
440
|
+
return {
|
|
441
|
+
"message": "Informe a cultura para consultar preços agrícolas.",
|
|
442
|
+
"exemplo_soja_sp": "/dados/precos?cultura=soja&uf=SP",
|
|
443
|
+
"exemplo_milho_pr": "/dados/precos?cultura=milho&uf=PR",
|
|
444
|
+
"parametros": {
|
|
445
|
+
"cultura": "Nome da cultura (obrigatório)",
|
|
446
|
+
"uf": "Unidade Federativa (opcional)"
|
|
447
|
+
},
|
|
448
|
+
"culturas_disponiveis": [
|
|
449
|
+
"soja", "milho", "feijao", "trigo", "algodao",
|
|
450
|
+
"cafe", "cana", "arroz", "sorgo", "mandioca"
|
|
451
|
+
]
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# Buscar preço
|
|
455
|
+
preco_data = buscar_preco(cultura=cultura, uf=uf)
|
|
456
|
+
|
|
457
|
+
return preco_data
|
|
458
|
+
|
|
459
|
+
except Exception as e:
|
|
460
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
461
|
+
|
|
462
|
+
@app.get("/dados/precos/lote")
|
|
463
|
+
def get_precos_lote(
|
|
464
|
+
uf: Optional[str] = Query(None, description="Unidade Federativa (ex: SP, PR)")
|
|
465
|
+
):
|
|
466
|
+
"""Obtém preços agrícolas para todas as culturas do AgroPlan"""
|
|
467
|
+
try:
|
|
468
|
+
# Importar provider de preços
|
|
469
|
+
from providers.price_provider import buscar_precos_lote
|
|
470
|
+
|
|
471
|
+
# Culturas do AgroPlan
|
|
472
|
+
culturas = ["soja", "milho", "feijao", "trigo", "algodao", "cafe", "cana", "arroz", "sorgo", "mandioca"]
|
|
473
|
+
|
|
474
|
+
# Buscar preços em lote
|
|
475
|
+
precos_data = buscar_precos_lote(culturas=culturas, uf=uf)
|
|
476
|
+
|
|
477
|
+
# Estatísticas
|
|
478
|
+
total = len(precos_data)
|
|
479
|
+
com_preco = sum(1 for p in precos_data if p.get("ativo"))
|
|
480
|
+
fallback = sum(1 for p in precos_data if p.get("fallback"))
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
"uf": uf,
|
|
484
|
+
"total_culturas": total,
|
|
485
|
+
"culturas_com_preco": com_preco,
|
|
486
|
+
"culturas_fallback": fallback,
|
|
487
|
+
"precos": precos_data
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
except Exception as e:
|
|
491
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
492
|
+
|
|
423
493
|
@app.get("/dashboard")
|
|
424
494
|
def get_dashboard(
|
|
425
495
|
lat: Optional[float] = None,
|
|
@@ -552,6 +622,10 @@ def get_dashboard(
|
|
|
552
622
|
else:
|
|
553
623
|
resultado_base["zarc"] = {"ativo": False}
|
|
554
624
|
|
|
625
|
+
# Enriquecer com preços agrícolas
|
|
626
|
+
from core.price_adapter import aplicar_precos_no_plano
|
|
627
|
+
resultado_base = aplicar_precos_no_plano(resultado_base, uf=uf)
|
|
628
|
+
|
|
555
629
|
return resultado_base
|
|
556
630
|
|
|
557
631
|
# Usa cache para dashboard com contexto climático
|
|
@@ -619,6 +693,13 @@ def get_recomendacoes(
|
|
|
619
693
|
else:
|
|
620
694
|
resultado["zarc"] = {"ativo": False}
|
|
621
695
|
|
|
696
|
+
# Enriquecer com preços agrícolas
|
|
697
|
+
from core.price_adapter import aplicar_precos_no_plano
|
|
698
|
+
resultado_temp = {"plano": resultado["recomendacoes"]}
|
|
699
|
+
resultado_temp = aplicar_precos_no_plano(resultado_temp, uf=uf)
|
|
700
|
+
resultado["recomendacoes"] = resultado_temp["plano"]
|
|
701
|
+
resultado["precos"] = resultado_temp.get("precos", {"ativo": False})
|
|
702
|
+
|
|
622
703
|
return resultado
|
|
623
704
|
except Exception as e:
|
|
624
705
|
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -872,6 +953,10 @@ def otimizar(request: OtimizarRequest):
|
|
|
872
953
|
else:
|
|
873
954
|
resultado_convertido["zarc"] = {"ativo": False}
|
|
874
955
|
|
|
956
|
+
# Enriquecer com preços agrícolas
|
|
957
|
+
from core.price_adapter import aplicar_precos_no_plano
|
|
958
|
+
resultado_convertido = aplicar_precos_no_plano(resultado_convertido, uf=request.uf)
|
|
959
|
+
|
|
875
960
|
return resultado_convertido
|
|
876
961
|
except HTTPException:
|
|
877
962
|
raise
|
|
@@ -1041,13 +1126,23 @@ def limpar_cache(request: Request):
|
|
|
1041
1126
|
except Exception:
|
|
1042
1127
|
pass
|
|
1043
1128
|
|
|
1129
|
+
# Limpa cache de preços em memória
|
|
1130
|
+
price_cache_cleared = False
|
|
1131
|
+
try:
|
|
1132
|
+
from providers.price_provider import clear_price_cache
|
|
1133
|
+
clear_price_cache()
|
|
1134
|
+
price_cache_cleared = True
|
|
1135
|
+
except Exception:
|
|
1136
|
+
pass
|
|
1137
|
+
|
|
1044
1138
|
return {
|
|
1045
1139
|
"status": "ok",
|
|
1046
1140
|
"message": f"Cache limpo completamente.",
|
|
1047
1141
|
"details": {
|
|
1048
1142
|
"resultados_cache": items_removidos,
|
|
1049
1143
|
"provider_cache": provider_items_removidos,
|
|
1050
|
-
"zarc_index_cache": "cleared" if zarc_cache_cleared else "not_found"
|
|
1144
|
+
"zarc_index_cache": "cleared" if zarc_cache_cleared else "not_found",
|
|
1145
|
+
"price_cache": "cleared" if price_cache_cleared else "not_found"
|
|
1051
1146
|
},
|
|
1052
1147
|
"protected": bool(cache_admin_token)
|
|
1053
1148
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adaptador de Preços Agrícolas
|
|
3
|
+
Integra preços reais no planejamento
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
from providers.price_provider import buscar_preco, buscar_precos_lote
|
|
9
|
+
|
|
10
|
+
# Configuração
|
|
11
|
+
PRICE_APPLY_TO_PROFIT = os.getenv("PRICE_APPLY_TO_PROFIT", "false").lower() == "true"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def aplicar_precos_no_plano(resultado: Dict, uf: Optional[str] = None) -> Dict:
|
|
15
|
+
"""
|
|
16
|
+
Aplica informações de preços no plano
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
resultado: Resultado do AG ou cenário
|
|
20
|
+
uf: Unidade Federativa (opcional)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Resultado enriquecido com informações de preços
|
|
24
|
+
"""
|
|
25
|
+
if "plano" not in resultado:
|
|
26
|
+
return resultado
|
|
27
|
+
|
|
28
|
+
# Coletar culturas únicas
|
|
29
|
+
culturas = list(set([item.get("cultura") for item in resultado["plano"]]))
|
|
30
|
+
|
|
31
|
+
# Buscar preços em lote
|
|
32
|
+
precos_data = buscar_precos_lote(culturas, uf)
|
|
33
|
+
|
|
34
|
+
# Criar mapa de preços por cultura
|
|
35
|
+
precos_map = {p["cultura"]: p for p in precos_data}
|
|
36
|
+
|
|
37
|
+
# Estatísticas
|
|
38
|
+
culturas_com_preco = 0
|
|
39
|
+
culturas_fallback = 0
|
|
40
|
+
culturas_sem_preco = 0
|
|
41
|
+
|
|
42
|
+
# Aplicar preços no plano
|
|
43
|
+
for item in resultado["plano"]:
|
|
44
|
+
cultura = item.get("cultura")
|
|
45
|
+
preco_info = precos_map.get(cultura, {})
|
|
46
|
+
|
|
47
|
+
# Adicionar informações de preço
|
|
48
|
+
item["preco_real"] = preco_info
|
|
49
|
+
|
|
50
|
+
# Contabilizar estatísticas
|
|
51
|
+
if preco_info.get("ativo"):
|
|
52
|
+
if preco_info.get("fallback"):
|
|
53
|
+
culturas_fallback += 1
|
|
54
|
+
else:
|
|
55
|
+
culturas_com_preco += 1
|
|
56
|
+
else:
|
|
57
|
+
culturas_sem_preco += 1
|
|
58
|
+
|
|
59
|
+
# Se PRICE_APPLY_TO_PROFIT estiver ativo, recalcular lucro
|
|
60
|
+
# NOTA: Por enquanto, apenas adiciona informação sem recalcular
|
|
61
|
+
# Recálculo será implementado após validação de unidades
|
|
62
|
+
|
|
63
|
+
# Adicionar resumo de preços ao resultado
|
|
64
|
+
resultado["precos"] = {
|
|
65
|
+
"ativo": True,
|
|
66
|
+
"source": "price-local-index",
|
|
67
|
+
"fallback_count": culturas_fallback,
|
|
68
|
+
"culturas_com_preco": culturas_com_preco,
|
|
69
|
+
"culturas_sem_preco": culturas_sem_preco,
|
|
70
|
+
"total_culturas": len(culturas),
|
|
71
|
+
"aplicado_no_lucro": PRICE_APPLY_TO_PROFIT,
|
|
72
|
+
"uf": uf
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return resultado
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def gerar_secao_precos_relatorio(plano: List[Dict], uf: Optional[str], formato: str = "md") -> str:
|
|
79
|
+
"""
|
|
80
|
+
Gera seção de preços para o relatório
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
plano: Lista de itens do plano com informações de preços
|
|
84
|
+
uf: Unidade Federativa
|
|
85
|
+
formato: 'md' ou 'txt'
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
String com seção formatada
|
|
89
|
+
"""
|
|
90
|
+
if formato == "md":
|
|
91
|
+
secao = "## 💰 Preços Agrícolas Utilizados\n\n"
|
|
92
|
+
|
|
93
|
+
if uf:
|
|
94
|
+
secao += f"**Região:** {uf}\n\n"
|
|
95
|
+
|
|
96
|
+
secao += "### Preços por Cultura\n\n"
|
|
97
|
+
secao += "| Cultura | Preço | Unidade | Fonte | Observação |\n"
|
|
98
|
+
secao += "|---------|-------|---------|-------|------------|\n"
|
|
99
|
+
|
|
100
|
+
# Coletar culturas únicas
|
|
101
|
+
culturas_vistas = set()
|
|
102
|
+
|
|
103
|
+
for item in plano:
|
|
104
|
+
cultura = item.get("cultura", "").upper()
|
|
105
|
+
|
|
106
|
+
if cultura in culturas_vistas:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
culturas_vistas.add(cultura)
|
|
110
|
+
|
|
111
|
+
preco_info = item.get("preco_real", {})
|
|
112
|
+
|
|
113
|
+
if preco_info.get("ativo"):
|
|
114
|
+
preco = preco_info.get("preco")
|
|
115
|
+
unidade = preco_info.get("unidade", "N/A")
|
|
116
|
+
fonte = preco_info.get("source", "N/A")
|
|
117
|
+
fallback_icon = "⚠️ " if preco_info.get("fallback") else ""
|
|
118
|
+
observacao = preco_info.get("observacao", "")
|
|
119
|
+
|
|
120
|
+
preco_fmt = f"R$ {preco:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") if preco else "N/A"
|
|
121
|
+
|
|
122
|
+
secao += f"| {cultura} | {preco_fmt} | {unidade} | {fallback_icon}{fonte} | {observacao} |\n"
|
|
123
|
+
else:
|
|
124
|
+
secao += f"| {cultura} | N/A | N/A | price-unavailable | Preço não disponível |\n"
|
|
125
|
+
|
|
126
|
+
secao += "\n"
|
|
127
|
+
secao += "### Observações\n\n"
|
|
128
|
+
|
|
129
|
+
if PRICE_APPLY_TO_PROFIT:
|
|
130
|
+
secao += "✅ **Os preços foram aplicados ao cálculo de lucro estimado.**\n\n"
|
|
131
|
+
else:
|
|
132
|
+
secao += "ℹ️ **Os preços são exibidos como referência, mas o cálculo de lucro ainda utiliza a base interna simulada.**\n\n"
|
|
133
|
+
|
|
134
|
+
secao += "**Fontes:**\n"
|
|
135
|
+
secao += "- `price-local-index`: Índice local de preços\n"
|
|
136
|
+
secao += "- `price-fallback`: Preço de referência (fallback)\n"
|
|
137
|
+
secao += "- `price-unavailable`: Preço não disponível\n"
|
|
138
|
+
|
|
139
|
+
else: # txt
|
|
140
|
+
secao = "PREÇOS AGRÍCOLAS UTILIZADOS\n\n"
|
|
141
|
+
|
|
142
|
+
if uf:
|
|
143
|
+
secao += f"Região: {uf}\n\n"
|
|
144
|
+
|
|
145
|
+
secao += "Preços por Cultura:\n\n"
|
|
146
|
+
|
|
147
|
+
# Coletar culturas únicas
|
|
148
|
+
culturas_vistas = set()
|
|
149
|
+
|
|
150
|
+
for item in plano:
|
|
151
|
+
cultura = item.get("cultura", "").upper()
|
|
152
|
+
|
|
153
|
+
if cultura in culturas_vistas:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
culturas_vistas.add(cultura)
|
|
157
|
+
|
|
158
|
+
preco_info = item.get("preco_real", {})
|
|
159
|
+
|
|
160
|
+
if preco_info.get("ativo"):
|
|
161
|
+
preco = preco_info.get("preco")
|
|
162
|
+
unidade = preco_info.get("unidade", "N/A")
|
|
163
|
+
fonte = preco_info.get("source", "N/A")
|
|
164
|
+
fallback_text = " (fallback)" if preco_info.get("fallback") else ""
|
|
165
|
+
observacao = preco_info.get("observacao", "")
|
|
166
|
+
|
|
167
|
+
preco_fmt = f"R$ {preco:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") if preco else "N/A"
|
|
168
|
+
|
|
169
|
+
secao += f" {cultura}:\n"
|
|
170
|
+
secao += f" Preço: {preco_fmt}\n"
|
|
171
|
+
secao += f" Unidade: {unidade}\n"
|
|
172
|
+
secao += f" Fonte: {fonte}{fallback_text}\n"
|
|
173
|
+
secao += f" Observação: {observacao}\n\n"
|
|
174
|
+
else:
|
|
175
|
+
secao += f" {cultura}:\n"
|
|
176
|
+
secao += f" Preço: N/A\n"
|
|
177
|
+
secao += f" Observação: Preço não disponível\n\n"
|
|
178
|
+
|
|
179
|
+
secao += "Observações:\n\n"
|
|
180
|
+
|
|
181
|
+
if PRICE_APPLY_TO_PROFIT:
|
|
182
|
+
secao += "Os preços foram aplicados ao cálculo de lucro estimado.\n\n"
|
|
183
|
+
else:
|
|
184
|
+
secao += "Os preços são exibidos como referência, mas o cálculo de lucro ainda utiliza a base interna simulada.\n\n"
|
|
185
|
+
|
|
186
|
+
secao += "Fontes:\n"
|
|
187
|
+
secao += "- price-local-index: Índice local de preços\n"
|
|
188
|
+
secao += "- price-fallback: Preço de referência (fallback)\n"
|
|
189
|
+
secao += "- price-unavailable: Preço não disponível\n"
|
|
190
|
+
|
|
191
|
+
return secao
|
|
@@ -162,6 +162,13 @@ def gerar_relatorio_completo(culturas, talhoes, regras, objetivo='equilibrado',
|
|
|
162
162
|
secao_zarc = gerar_secao_zarc_relatorio(resultado_temp["plano"], uf, municipio, safra, formato)
|
|
163
163
|
conteudo += "\n\n" + secao_zarc
|
|
164
164
|
|
|
165
|
+
# Adicionar seção de preços agrícolas
|
|
166
|
+
from core.price_adapter import aplicar_precos_no_plano, gerar_secao_precos_relatorio
|
|
167
|
+
resultado_temp = {"plano": resultado_ag["plano"]}
|
|
168
|
+
resultado_temp = aplicar_precos_no_plano(resultado_temp, uf=uf)
|
|
169
|
+
secao_precos = gerar_secao_precos_relatorio(resultado_temp["plano"], uf, formato)
|
|
170
|
+
conteudo += "\n\n" + secao_precos
|
|
171
|
+
|
|
165
172
|
# Salva arquivo
|
|
166
173
|
os.makedirs('reports', exist_ok=True)
|
|
167
174
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generated_at": "2026-05-09T19:30:00Z",
|
|
3
|
+
"source": "price-fallback",
|
|
4
|
+
"description": "Preços de referência para fallback quando dados reais não estão disponíveis",
|
|
5
|
+
"precos": [
|
|
6
|
+
{
|
|
7
|
+
"cultura": "soja",
|
|
8
|
+
"preco": 128.50,
|
|
9
|
+
"unidade": "saca_60kg",
|
|
10
|
+
"fonte": "fallback",
|
|
11
|
+
"fallback": true,
|
|
12
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"cultura": "milho",
|
|
16
|
+
"preco": 65.00,
|
|
17
|
+
"unidade": "saca_60kg",
|
|
18
|
+
"fonte": "fallback",
|
|
19
|
+
"fallback": true,
|
|
20
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"cultura": "feijao",
|
|
24
|
+
"preco": 180.00,
|
|
25
|
+
"unidade": "saca_60kg",
|
|
26
|
+
"fonte": "fallback",
|
|
27
|
+
"fallback": true,
|
|
28
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"cultura": "trigo",
|
|
32
|
+
"preco": 75.00,
|
|
33
|
+
"unidade": "saca_60kg",
|
|
34
|
+
"fonte": "fallback",
|
|
35
|
+
"fallback": true,
|
|
36
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"cultura": "algodao",
|
|
40
|
+
"preco": 3200.00,
|
|
41
|
+
"unidade": "arroba_15kg",
|
|
42
|
+
"fonte": "fallback",
|
|
43
|
+
"fallback": true,
|
|
44
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"cultura": "cafe",
|
|
48
|
+
"preco": 1250.00,
|
|
49
|
+
"unidade": "saca_60kg",
|
|
50
|
+
"fonte": "fallback",
|
|
51
|
+
"fallback": true,
|
|
52
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"cultura": "cana",
|
|
56
|
+
"preco": 95.00,
|
|
57
|
+
"unidade": "tonelada",
|
|
58
|
+
"fonte": "fallback",
|
|
59
|
+
"fallback": true,
|
|
60
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"cultura": "arroz",
|
|
64
|
+
"preco": 85.00,
|
|
65
|
+
"unidade": "saca_50kg",
|
|
66
|
+
"fonte": "fallback",
|
|
67
|
+
"fallback": true,
|
|
68
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"cultura": "sorgo",
|
|
72
|
+
"preco": 55.00,
|
|
73
|
+
"unidade": "saca_60kg",
|
|
74
|
+
"fonte": "fallback",
|
|
75
|
+
"fallback": true,
|
|
76
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"cultura": "mandioca",
|
|
80
|
+
"preco": 450.00,
|
|
81
|
+
"unidade": "tonelada",
|
|
82
|
+
"fonte": "fallback",
|
|
83
|
+
"fallback": true,
|
|
84
|
+
"observacao": "Preço de referência baseado em média histórica"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generated_at": "2026-05-09T19:30:00Z",
|
|
3
|
+
"source": "price-local-index",
|
|
4
|
+
"description": "Índice local de preços agrícolas - será substituído por dados reais futuramente",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"records": {
|
|
7
|
+
"soja_SP": {
|
|
8
|
+
"cultura": "soja",
|
|
9
|
+
"uf": "SP",
|
|
10
|
+
"preco": 130.00,
|
|
11
|
+
"unidade": "saca_60kg",
|
|
12
|
+
"fonte": "price-local-index",
|
|
13
|
+
"fallback": false,
|
|
14
|
+
"data_referencia": "2026-05-01",
|
|
15
|
+
"observacao": "Preço de referência para SP"
|
|
16
|
+
},
|
|
17
|
+
"milho_SP": {
|
|
18
|
+
"cultura": "milho",
|
|
19
|
+
"uf": "SP",
|
|
20
|
+
"preco": 67.00,
|
|
21
|
+
"unidade": "saca_60kg",
|
|
22
|
+
"fonte": "price-local-index",
|
|
23
|
+
"fallback": false,
|
|
24
|
+
"data_referencia": "2026-05-01",
|
|
25
|
+
"observacao": "Preço de referência para SP"
|
|
26
|
+
},
|
|
27
|
+
"feijao_SP": {
|
|
28
|
+
"cultura": "feijao",
|
|
29
|
+
"uf": "SP",
|
|
30
|
+
"preco": 185.00,
|
|
31
|
+
"unidade": "saca_60kg",
|
|
32
|
+
"fonte": "price-local-index",
|
|
33
|
+
"fallback": false,
|
|
34
|
+
"data_referencia": "2026-05-01",
|
|
35
|
+
"observacao": "Preço de referência para SP"
|
|
36
|
+
},
|
|
37
|
+
"cafe_SP": {
|
|
38
|
+
"cultura": "cafe",
|
|
39
|
+
"uf": "SP",
|
|
40
|
+
"preco": 1280.00,
|
|
41
|
+
"unidade": "saca_60kg",
|
|
42
|
+
"fonte": "price-local-index",
|
|
43
|
+
"fallback": false,
|
|
44
|
+
"data_referencia": "2026-05-01",
|
|
45
|
+
"observacao": "Preço de referência para SP"
|
|
46
|
+
},
|
|
47
|
+
"cana_SP": {
|
|
48
|
+
"cultura": "cana",
|
|
49
|
+
"uf": "SP",
|
|
50
|
+
"preco": 98.00,
|
|
51
|
+
"unidade": "tonelada",
|
|
52
|
+
"fonte": "price-local-index",
|
|
53
|
+
"fallback": false,
|
|
54
|
+
"data_referencia": "2026-05-01",
|
|
55
|
+
"observacao": "Preço de referência para SP"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider de Preços Agrícolas
|
|
3
|
+
Fornece preços reais ou de referência para culturas
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
from typing import Optional, Dict, List
|
|
9
|
+
|
|
10
|
+
# Configurações
|
|
11
|
+
PRICE_PROVIDER = os.getenv("PRICE_PROVIDER", "local") # local, conab, disabled
|
|
12
|
+
PRICE_INDEX_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "precos", "precos_index.json")
|
|
13
|
+
PRICE_FALLBACK_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "precos", "precos_fallback.json")
|
|
14
|
+
|
|
15
|
+
# Cache em memória para índice de preços
|
|
16
|
+
_price_index_cache = None
|
|
17
|
+
_price_fallback_cache = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalizar_cultura_preco(cultura: str) -> str:
|
|
21
|
+
"""Normaliza nome da cultura para busca de preços"""
|
|
22
|
+
mapping = {
|
|
23
|
+
"café": "cafe",
|
|
24
|
+
"feijão": "feijao",
|
|
25
|
+
"algodão": "algodao",
|
|
26
|
+
"CAFÉ": "cafe",
|
|
27
|
+
"FEIJÃO": "feijao",
|
|
28
|
+
"ALGODÃO": "algodao",
|
|
29
|
+
"Café": "cafe",
|
|
30
|
+
"Feijão": "feijao",
|
|
31
|
+
"Algodão": "algodao"
|
|
32
|
+
}
|
|
33
|
+
cultura_normalizada = mapping.get(cultura, cultura)
|
|
34
|
+
return cultura_normalizada.lower().strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_price_index() -> Optional[Dict]:
|
|
38
|
+
"""Carrega índice de preços do arquivo JSON"""
|
|
39
|
+
global _price_index_cache
|
|
40
|
+
|
|
41
|
+
if _price_index_cache is not None:
|
|
42
|
+
return _price_index_cache
|
|
43
|
+
|
|
44
|
+
if not os.path.exists(PRICE_INDEX_PATH):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with open(PRICE_INDEX_PATH, 'r', encoding='utf-8') as f:
|
|
49
|
+
_price_index_cache = json.load(f)
|
|
50
|
+
return _price_index_cache
|
|
51
|
+
except Exception as e:
|
|
52
|
+
print(f"Erro ao carregar índice de preços: {e}")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_price_fallback() -> List[Dict]:
|
|
57
|
+
"""Carrega preços de fallback do arquivo JSON"""
|
|
58
|
+
global _price_fallback_cache
|
|
59
|
+
|
|
60
|
+
if _price_fallback_cache is not None:
|
|
61
|
+
return _price_fallback_cache
|
|
62
|
+
|
|
63
|
+
if not os.path.exists(PRICE_FALLBACK_PATH):
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with open(PRICE_FALLBACK_PATH, 'r', encoding='utf-8') as f:
|
|
68
|
+
data = json.load(f)
|
|
69
|
+
_price_fallback_cache = data.get("precos", [])
|
|
70
|
+
return _price_fallback_cache
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"Erro ao carregar preços de fallback: {e}")
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def buscar_preco_no_indice(cultura: str, uf: Optional[str] = None) -> Optional[Dict]:
|
|
77
|
+
"""Busca preço no índice local"""
|
|
78
|
+
price_index = load_price_index()
|
|
79
|
+
|
|
80
|
+
if not price_index:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
records = price_index.get("records", {})
|
|
84
|
+
|
|
85
|
+
# Tentar buscar com UF específica
|
|
86
|
+
if uf:
|
|
87
|
+
key = f"{cultura}_{uf.upper()}"
|
|
88
|
+
if key in records:
|
|
89
|
+
return records[key]
|
|
90
|
+
|
|
91
|
+
# Tentar buscar sem UF (genérico)
|
|
92
|
+
for key, record in records.items():
|
|
93
|
+
if record.get("cultura") == cultura and not uf:
|
|
94
|
+
return record
|
|
95
|
+
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def buscar_preco_no_fallback(cultura: str) -> Optional[Dict]:
|
|
100
|
+
"""Busca preço no fallback"""
|
|
101
|
+
fallback_data = load_price_fallback()
|
|
102
|
+
|
|
103
|
+
for item in fallback_data:
|
|
104
|
+
if item.get("cultura") == cultura:
|
|
105
|
+
return item
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def buscar_preco(cultura: str, uf: Optional[str] = None) -> Dict:
|
|
111
|
+
"""
|
|
112
|
+
Busca preço de uma cultura
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
cultura: Nome da cultura
|
|
116
|
+
uf: Unidade Federativa (opcional)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Dicionário com informações de preço (sempre retorna, nunca None)
|
|
120
|
+
"""
|
|
121
|
+
# Normalizar cultura
|
|
122
|
+
cultura_normalizada = normalizar_cultura_preco(cultura)
|
|
123
|
+
|
|
124
|
+
# Se provider desabilitado, retornar inativo
|
|
125
|
+
if PRICE_PROVIDER == "disabled":
|
|
126
|
+
return {
|
|
127
|
+
"ativo": False,
|
|
128
|
+
"source": "price-disabled",
|
|
129
|
+
"fallback": False,
|
|
130
|
+
"cultura": cultura,
|
|
131
|
+
"uf": uf,
|
|
132
|
+
"preco": None,
|
|
133
|
+
"unidade": None,
|
|
134
|
+
"data_referencia": None,
|
|
135
|
+
"observacao": "Provider de preços desabilitado"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Tentar buscar no índice local
|
|
139
|
+
preco_data = buscar_preco_no_indice(cultura_normalizada, uf)
|
|
140
|
+
|
|
141
|
+
if preco_data:
|
|
142
|
+
return {
|
|
143
|
+
"ativo": True,
|
|
144
|
+
"source": preco_data.get("fonte", "price-local-index"),
|
|
145
|
+
"fallback": preco_data.get("fallback", False),
|
|
146
|
+
"cultura": cultura,
|
|
147
|
+
"uf": uf or preco_data.get("uf"),
|
|
148
|
+
"preco": preco_data.get("preco"),
|
|
149
|
+
"unidade": preco_data.get("unidade"),
|
|
150
|
+
"data_referencia": preco_data.get("data_referencia"),
|
|
151
|
+
"observacao": preco_data.get("observacao", "Preço obtido do índice local")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Tentar fallback
|
|
155
|
+
preco_data = buscar_preco_no_fallback(cultura_normalizada)
|
|
156
|
+
|
|
157
|
+
if preco_data:
|
|
158
|
+
return {
|
|
159
|
+
"ativo": True,
|
|
160
|
+
"source": "price-fallback",
|
|
161
|
+
"fallback": True,
|
|
162
|
+
"cultura": cultura,
|
|
163
|
+
"uf": uf,
|
|
164
|
+
"preco": preco_data.get("preco"),
|
|
165
|
+
"unidade": preco_data.get("unidade"),
|
|
166
|
+
"data_referencia": None,
|
|
167
|
+
"observacao": preco_data.get("observacao", "Preço de referência (fallback)")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Nenhum preço encontrado
|
|
171
|
+
return {
|
|
172
|
+
"ativo": False,
|
|
173
|
+
"source": "price-unavailable",
|
|
174
|
+
"fallback": False,
|
|
175
|
+
"cultura": cultura,
|
|
176
|
+
"uf": uf,
|
|
177
|
+
"preco": None,
|
|
178
|
+
"unidade": None,
|
|
179
|
+
"data_referencia": None,
|
|
180
|
+
"observacao": "Preço não disponível para esta cultura"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def buscar_precos_lote(culturas: List[str], uf: Optional[str] = None) -> List[Dict]:
|
|
185
|
+
"""
|
|
186
|
+
Busca preços para múltiplas culturas
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
culturas: Lista de nomes de culturas
|
|
190
|
+
uf: Unidade Federativa (opcional)
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Lista de dicionários com informações de preço
|
|
194
|
+
"""
|
|
195
|
+
return [buscar_preco(cultura, uf) for cultura in culturas]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_price_status() -> Dict:
|
|
199
|
+
"""Retorna status do provider de preços"""
|
|
200
|
+
price_index = load_price_index()
|
|
201
|
+
fallback_data = load_price_fallback()
|
|
202
|
+
|
|
203
|
+
index_count = len(price_index.get("records", {})) if price_index else 0
|
|
204
|
+
fallback_count = len(fallback_data)
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"provider": PRICE_PROVIDER,
|
|
208
|
+
"index_available": price_index is not None,
|
|
209
|
+
"index_records": index_count,
|
|
210
|
+
"fallback_records": fallback_count,
|
|
211
|
+
"total_culturas_cobertas": index_count + fallback_count
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def clear_price_cache():
|
|
216
|
+
"""Limpa cache de preços em memória"""
|
|
217
|
+
global _price_index_cache, _price_fallback_cache
|
|
218
|
+
_price_index_cache = None
|
|
219
|
+
_price_fallback_cache = None
|