agroplan-ai-cli 1.0.23 → 1.0.25
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 +9 -4
- package/backend-template/api.py +130 -2
- package/backend-template/core/price_adapter.py +365 -0
- package/backend-template/core/price_normalizer.py +182 -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,19 @@
|
|
|
1
1
|
{
|
|
2
|
-
"cli_version": "1.0.
|
|
3
|
-
"backend_template_version": "1.0.
|
|
2
|
+
"cli_version": "1.0.25",
|
|
3
|
+
"backend_template_version": "1.0.25",
|
|
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",
|
|
15
|
+
"price_unit_normalization",
|
|
16
|
+
"market_profit_estimate"
|
|
12
17
|
],
|
|
13
|
-
"generated_at": "2026-05-
|
|
18
|
+
"generated_at": "2026-05-09T21:00:00Z"
|
|
14
19
|
}
|
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,104 @@ 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
|
+
|
|
493
|
+
@app.get("/dados/precos/comparar")
|
|
494
|
+
def get_precos_comparar(
|
|
495
|
+
uf: Optional[str] = Query(None, description="Unidade Federativa (ex: SP, PR)")
|
|
496
|
+
):
|
|
497
|
+
"""Compara preços originais com preços normalizados para tonelada"""
|
|
498
|
+
try:
|
|
499
|
+
from providers.price_provider import buscar_precos_lote
|
|
500
|
+
from core.price_normalizer import normalizar_precos_lote, obter_estatisticas_normalizacao
|
|
501
|
+
|
|
502
|
+
# Culturas do AgroPlan
|
|
503
|
+
culturas = ["soja", "milho", "feijao", "trigo", "algodao", "cafe", "cana", "arroz", "sorgo", "mandioca"]
|
|
504
|
+
|
|
505
|
+
# Buscar preços em lote
|
|
506
|
+
precos_data = buscar_precos_lote(culturas=culturas, uf=uf)
|
|
507
|
+
|
|
508
|
+
# Converter para dict por cultura
|
|
509
|
+
precos_dict = {p["cultura"]: p for p in precos_data}
|
|
510
|
+
|
|
511
|
+
# Normalizar preços
|
|
512
|
+
precos_normalizados = normalizar_precos_lote(precos_dict)
|
|
513
|
+
|
|
514
|
+
# Obter estatísticas
|
|
515
|
+
stats = obter_estatisticas_normalizacao(precos_normalizados)
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
"uf": uf,
|
|
519
|
+
"precos_normalizados": precos_normalizados,
|
|
520
|
+
"estatisticas": stats
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
except Exception as e:
|
|
524
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
525
|
+
|
|
423
526
|
@app.get("/dashboard")
|
|
424
527
|
def get_dashboard(
|
|
425
528
|
lat: Optional[float] = None,
|
|
@@ -552,6 +655,10 @@ def get_dashboard(
|
|
|
552
655
|
else:
|
|
553
656
|
resultado_base["zarc"] = {"ativo": False}
|
|
554
657
|
|
|
658
|
+
# Enriquecer com preços agrícolas
|
|
659
|
+
from core.price_adapter import aplicar_precos_no_plano
|
|
660
|
+
resultado_base = aplicar_precos_no_plano(resultado_base, uf=uf)
|
|
661
|
+
|
|
555
662
|
return resultado_base
|
|
556
663
|
|
|
557
664
|
# Usa cache para dashboard com contexto climático
|
|
@@ -619,6 +726,13 @@ def get_recomendacoes(
|
|
|
619
726
|
else:
|
|
620
727
|
resultado["zarc"] = {"ativo": False}
|
|
621
728
|
|
|
729
|
+
# Enriquecer com preços agrícolas
|
|
730
|
+
from core.price_adapter import aplicar_precos_no_plano
|
|
731
|
+
resultado_temp = {"plano": resultado["recomendacoes"]}
|
|
732
|
+
resultado_temp = aplicar_precos_no_plano(resultado_temp, uf=uf)
|
|
733
|
+
resultado["recomendacoes"] = resultado_temp["plano"]
|
|
734
|
+
resultado["precos"] = resultado_temp.get("precos", {"ativo": False})
|
|
735
|
+
|
|
622
736
|
return resultado
|
|
623
737
|
except Exception as e:
|
|
624
738
|
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -872,6 +986,10 @@ def otimizar(request: OtimizarRequest):
|
|
|
872
986
|
else:
|
|
873
987
|
resultado_convertido["zarc"] = {"ativo": False}
|
|
874
988
|
|
|
989
|
+
# Enriquecer com preços agrícolas
|
|
990
|
+
from core.price_adapter import aplicar_precos_no_plano
|
|
991
|
+
resultado_convertido = aplicar_precos_no_plano(resultado_convertido, uf=request.uf)
|
|
992
|
+
|
|
875
993
|
return resultado_convertido
|
|
876
994
|
except HTTPException:
|
|
877
995
|
raise
|
|
@@ -1041,13 +1159,23 @@ def limpar_cache(request: Request):
|
|
|
1041
1159
|
except Exception:
|
|
1042
1160
|
pass
|
|
1043
1161
|
|
|
1162
|
+
# Limpa cache de preços em memória
|
|
1163
|
+
price_cache_cleared = False
|
|
1164
|
+
try:
|
|
1165
|
+
from providers.price_provider import clear_price_cache
|
|
1166
|
+
clear_price_cache()
|
|
1167
|
+
price_cache_cleared = True
|
|
1168
|
+
except Exception:
|
|
1169
|
+
pass
|
|
1170
|
+
|
|
1044
1171
|
return {
|
|
1045
1172
|
"status": "ok",
|
|
1046
1173
|
"message": f"Cache limpo completamente.",
|
|
1047
1174
|
"details": {
|
|
1048
1175
|
"resultados_cache": items_removidos,
|
|
1049
1176
|
"provider_cache": provider_items_removidos,
|
|
1050
|
-
"zarc_index_cache": "cleared" if zarc_cache_cleared else "not_found"
|
|
1177
|
+
"zarc_index_cache": "cleared" if zarc_cache_cleared else "not_found",
|
|
1178
|
+
"price_cache": "cleared" if price_cache_cleared else "not_found"
|
|
1051
1179
|
},
|
|
1052
1180
|
"protected": bool(cache_admin_token)
|
|
1053
1181
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adaptador de Preços Agrícolas
|
|
3
|
+
Integra preços reais no planejamento com normalização de unidades
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
from providers.price_provider import buscar_preco, buscar_precos_lote
|
|
10
|
+
from core.price_normalizer import (
|
|
11
|
+
normalizar_preco_para_tonelada,
|
|
12
|
+
calcular_lucro_com_preco_normalizado,
|
|
13
|
+
obter_estatisticas_normalizacao
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Configuração
|
|
17
|
+
PRICE_APPLY_TO_PROFIT = os.getenv("PRICE_APPLY_TO_PROFIT", "false").lower() == "true"
|
|
18
|
+
|
|
19
|
+
# Cache de dados de culturas
|
|
20
|
+
_culturas_cache = None
|
|
21
|
+
|
|
22
|
+
def _carregar_culturas():
|
|
23
|
+
"""Carrega dados de culturas do CSV (com cache)"""
|
|
24
|
+
global _culturas_cache
|
|
25
|
+
if _culturas_cache is None:
|
|
26
|
+
_culturas_cache = pd.read_csv("data/culturas.csv")
|
|
27
|
+
return _culturas_cache
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def aplicar_precos_no_plano(
|
|
31
|
+
resultado: Dict,
|
|
32
|
+
uf: Optional[str] = None,
|
|
33
|
+
aplicar_no_lucro: bool = None
|
|
34
|
+
) -> Dict:
|
|
35
|
+
"""
|
|
36
|
+
Aplica informações de preços no plano com normalização de unidades
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
resultado: Resultado do AG ou cenário
|
|
40
|
+
uf: Unidade Federativa (opcional)
|
|
41
|
+
aplicar_no_lucro: Se True, recalcula lucro com preços. Se None, usa PRICE_APPLY_TO_PROFIT
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Resultado enriquecido com informações de preços e normalização
|
|
45
|
+
"""
|
|
46
|
+
if "plano" not in resultado:
|
|
47
|
+
return resultado
|
|
48
|
+
|
|
49
|
+
# Determinar se deve aplicar no lucro
|
|
50
|
+
if aplicar_no_lucro is None:
|
|
51
|
+
aplicar_no_lucro = PRICE_APPLY_TO_PROFIT
|
|
52
|
+
|
|
53
|
+
# Coletar culturas únicas
|
|
54
|
+
culturas = list(set([item.get("cultura") for item in resultado["plano"]]))
|
|
55
|
+
|
|
56
|
+
# Buscar preços em lote
|
|
57
|
+
precos_data = buscar_precos_lote(culturas, uf)
|
|
58
|
+
|
|
59
|
+
# Criar mapa de preços por cultura
|
|
60
|
+
precos_map = {p["cultura"]: p for p in precos_data}
|
|
61
|
+
|
|
62
|
+
# Estatísticas
|
|
63
|
+
culturas_com_preco = 0
|
|
64
|
+
culturas_fallback = 0
|
|
65
|
+
culturas_sem_preco = 0
|
|
66
|
+
culturas_normalizadas = 0
|
|
67
|
+
|
|
68
|
+
# Aplicar preços no plano
|
|
69
|
+
for item in resultado["plano"]:
|
|
70
|
+
cultura = item.get("cultura")
|
|
71
|
+
preco_info = precos_map.get(cultura, {})
|
|
72
|
+
|
|
73
|
+
# Adicionar informações de preço
|
|
74
|
+
item["preco_real"] = preco_info
|
|
75
|
+
|
|
76
|
+
# Normalizar preço se disponível
|
|
77
|
+
if preco_info.get("ativo") and preco_info.get("preco") and preco_info.get("unidade"):
|
|
78
|
+
normalizacao = normalizar_preco_para_tonelada(
|
|
79
|
+
preco_info["preco"],
|
|
80
|
+
preco_info["unidade"]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
item["preco_normalizado"] = normalizacao
|
|
84
|
+
|
|
85
|
+
if normalizacao.get("normalizado"):
|
|
86
|
+
culturas_normalizadas += 1
|
|
87
|
+
|
|
88
|
+
# Buscar produtividade e custo da cultura
|
|
89
|
+
culturas_df = _carregar_culturas()
|
|
90
|
+
cultura_data = culturas_df[culturas_df['nome'] == cultura]
|
|
91
|
+
|
|
92
|
+
if not cultura_data.empty:
|
|
93
|
+
produtividade = float(cultura_data.iloc[0]['produtividade']) # toneladas/ha
|
|
94
|
+
custo = float(cultura_data.iloc[0]['custo']) # R$/ha
|
|
95
|
+
area = item.get("area", 0) # ha
|
|
96
|
+
|
|
97
|
+
# Adicionar campos ao item se não existirem
|
|
98
|
+
if "produtividade" not in item:
|
|
99
|
+
item["produtividade"] = produtividade
|
|
100
|
+
if "custo" not in item:
|
|
101
|
+
item["custo"] = custo
|
|
102
|
+
|
|
103
|
+
# Calcular lucro de mercado estimado (sempre, independente de aplicar_no_lucro)
|
|
104
|
+
if produtividade > 0 and area > 0:
|
|
105
|
+
calculo_mercado = calcular_lucro_com_preco_normalizado(
|
|
106
|
+
preco_por_tonelada=normalizacao["preco_por_tonelada"],
|
|
107
|
+
produtividade=produtividade,
|
|
108
|
+
custo_por_hectare=custo,
|
|
109
|
+
area=area
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
item["lucro_mercado_estimado"] = calculo_mercado["lucro_mercado"]
|
|
113
|
+
item["lucro_mercado_detalhes"] = calculo_mercado
|
|
114
|
+
|
|
115
|
+
# Se aplicar_no_lucro, substituir lucro_estimado
|
|
116
|
+
if aplicar_no_lucro:
|
|
117
|
+
item["lucro_original"] = item.get("lucro_estimado", 0)
|
|
118
|
+
item["lucro_estimado"] = calculo_mercado["lucro_mercado"]
|
|
119
|
+
item["lucro_mercado_aplicado"] = True
|
|
120
|
+
else:
|
|
121
|
+
item["lucro_mercado_aplicado"] = False
|
|
122
|
+
else:
|
|
123
|
+
item["lucro_mercado_estimado"] = None
|
|
124
|
+
item["lucro_mercado_aplicado"] = False
|
|
125
|
+
else:
|
|
126
|
+
item["lucro_mercado_estimado"] = None
|
|
127
|
+
item["lucro_mercado_aplicado"] = False
|
|
128
|
+
else:
|
|
129
|
+
item["lucro_mercado_estimado"] = None
|
|
130
|
+
item["lucro_mercado_aplicado"] = False
|
|
131
|
+
else:
|
|
132
|
+
item["preco_normalizado"] = {"normalizado": False}
|
|
133
|
+
item["lucro_mercado_estimado"] = None
|
|
134
|
+
item["lucro_mercado_aplicado"] = False
|
|
135
|
+
|
|
136
|
+
# Contabilizar estatísticas
|
|
137
|
+
if preco_info.get("ativo"):
|
|
138
|
+
if preco_info.get("fallback"):
|
|
139
|
+
culturas_fallback += 1
|
|
140
|
+
else:
|
|
141
|
+
culturas_com_preco += 1
|
|
142
|
+
else:
|
|
143
|
+
culturas_sem_preco += 1
|
|
144
|
+
|
|
145
|
+
# Recalcular lucro_total se aplicar_no_lucro
|
|
146
|
+
if aplicar_no_lucro:
|
|
147
|
+
resultado["lucro_total_original"] = resultado.get("lucro_total", 0)
|
|
148
|
+
resultado["lucro_total"] = sum(
|
|
149
|
+
item.get("lucro_estimado", 0) for item in resultado["plano"]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Adicionar resumo de preços ao resultado
|
|
153
|
+
resultado["precos"] = {
|
|
154
|
+
"ativo": True,
|
|
155
|
+
"source": "price-local-index" if culturas_com_preco > 0 else "price-fallback",
|
|
156
|
+
"fallback_count": culturas_fallback,
|
|
157
|
+
"culturas_com_preco": culturas_com_preco,
|
|
158
|
+
"culturas_sem_preco": culturas_sem_preco,
|
|
159
|
+
"total_culturas": len(culturas),
|
|
160
|
+
"aplicado_no_lucro": aplicar_no_lucro,
|
|
161
|
+
"lucro_recalculado_disponivel": any(
|
|
162
|
+
item.get("lucro_mercado_estimado") is not None
|
|
163
|
+
for item in resultado["plano"]
|
|
164
|
+
),
|
|
165
|
+
"uf": uf,
|
|
166
|
+
"normalizacao": {
|
|
167
|
+
"ativa": True,
|
|
168
|
+
"unidade_base": "tonelada",
|
|
169
|
+
"culturas_normalizadas": culturas_normalizadas,
|
|
170
|
+
"culturas_nao_normalizadas": len(culturas) - culturas_normalizadas
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return resultado
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def gerar_secao_precos_relatorio(plano: List[Dict], uf: Optional[str], formato: str = "md") -> str:
|
|
178
|
+
"""
|
|
179
|
+
Gera seção de preços para o relatório com informações de normalização
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
plano: Lista de itens do plano com informações de preços
|
|
183
|
+
uf: Unidade Federativa
|
|
184
|
+
formato: 'md' ou 'txt'
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
String com seção formatada
|
|
188
|
+
"""
|
|
189
|
+
if formato == "md":
|
|
190
|
+
secao = "## 💰 Preços Agrícolas Utilizados\n\n"
|
|
191
|
+
|
|
192
|
+
if uf:
|
|
193
|
+
secao += f"**Região:** {uf}\n\n"
|
|
194
|
+
|
|
195
|
+
secao += "### Preços por Cultura\n\n"
|
|
196
|
+
secao += "| Cultura | Preço Original | Unidade | Preço/Tonelada | Normalizado | Fonte |\n"
|
|
197
|
+
secao += "|---------|----------------|---------|----------------|-------------|-------|\n"
|
|
198
|
+
|
|
199
|
+
# Coletar culturas únicas
|
|
200
|
+
culturas_vistas = set()
|
|
201
|
+
|
|
202
|
+
for item in plano:
|
|
203
|
+
cultura = item.get("cultura", "").upper()
|
|
204
|
+
|
|
205
|
+
if cultura in culturas_vistas:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
culturas_vistas.add(cultura)
|
|
209
|
+
|
|
210
|
+
preco_info = item.get("preco_real", {})
|
|
211
|
+
preco_norm = item.get("preco_normalizado", {})
|
|
212
|
+
|
|
213
|
+
if preco_info.get("ativo"):
|
|
214
|
+
preco = preco_info.get("preco")
|
|
215
|
+
unidade = preco_info.get("unidade", "N/A")
|
|
216
|
+
fonte = preco_info.get("source", "N/A")
|
|
217
|
+
fallback_icon = "⚠️ " if preco_info.get("fallback") else ""
|
|
218
|
+
|
|
219
|
+
preco_fmt = f"R$ {preco:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") if preco else "N/A"
|
|
220
|
+
|
|
221
|
+
# Informações de normalização
|
|
222
|
+
if preco_norm.get("normalizado"):
|
|
223
|
+
preco_ton = preco_norm.get("preco_por_tonelada", 0)
|
|
224
|
+
preco_ton_fmt = f"R$ {preco_ton:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
225
|
+
norm_status = "✅ Sim"
|
|
226
|
+
else:
|
|
227
|
+
preco_ton_fmt = "N/A"
|
|
228
|
+
norm_status = "❌ Não"
|
|
229
|
+
|
|
230
|
+
secao += f"| {cultura} | {preco_fmt} | {unidade} | {preco_ton_fmt} | {norm_status} | {fallback_icon}{fonte} |\n"
|
|
231
|
+
else:
|
|
232
|
+
secao += f"| {cultura} | N/A | N/A | N/A | ❌ Não | price-unavailable |\n"
|
|
233
|
+
|
|
234
|
+
secao += "\n"
|
|
235
|
+
|
|
236
|
+
# Adicionar informações de lucro de mercado se disponível
|
|
237
|
+
tem_lucro_mercado = any(item.get("lucro_mercado_estimado") is not None for item in plano)
|
|
238
|
+
|
|
239
|
+
if tem_lucro_mercado:
|
|
240
|
+
secao += "### Comparação de Lucro\n\n"
|
|
241
|
+
secao += "| Talhão | Cultura | Lucro Sistema | Lucro Mercado | Diferença |\n"
|
|
242
|
+
secao += "|--------|---------|---------------|---------------|----------|\n"
|
|
243
|
+
|
|
244
|
+
for item in plano:
|
|
245
|
+
if item.get("lucro_mercado_estimado") is not None:
|
|
246
|
+
talhao = item.get("talhao", "N/A")
|
|
247
|
+
cultura = item.get("cultura", "").upper()
|
|
248
|
+
lucro_sistema = item.get("lucro_original", item.get("lucro_estimado", 0))
|
|
249
|
+
lucro_mercado = item.get("lucro_mercado_estimado", 0)
|
|
250
|
+
diferenca = lucro_mercado - lucro_sistema
|
|
251
|
+
|
|
252
|
+
lucro_sistema_fmt = f"R$ {lucro_sistema:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
253
|
+
lucro_mercado_fmt = f"R$ {lucro_mercado:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
254
|
+
diferenca_fmt = f"R$ {diferenca:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
255
|
+
diferenca_icon = "📈" if diferenca > 0 else "📉" if diferenca < 0 else "➡️"
|
|
256
|
+
|
|
257
|
+
secao += f"| {talhao} | {cultura} | {lucro_sistema_fmt} | {lucro_mercado_fmt} | {diferenca_icon} {diferenca_fmt} |\n"
|
|
258
|
+
|
|
259
|
+
secao += "\n"
|
|
260
|
+
|
|
261
|
+
secao += "### Observações\n\n"
|
|
262
|
+
|
|
263
|
+
if PRICE_APPLY_TO_PROFIT:
|
|
264
|
+
secao += "✅ **Os preços de mercado normalizados foram aplicados ao cálculo de lucro.**\n\n"
|
|
265
|
+
else:
|
|
266
|
+
secao += "ℹ️ **Os preços foram normalizados para R$/tonelada. Nesta versão, o lucro principal ainda usa a base interna do sistema, mas o lucro de mercado estimado é exibido para comparação.**\n\n"
|
|
267
|
+
|
|
268
|
+
secao += "**Normalização de Unidades:**\n"
|
|
269
|
+
secao += "- Todos os preços foram convertidos para R$/tonelada para permitir comparação consistente\n"
|
|
270
|
+
secao += "- Fatores de conversão: saca_60kg (×16.67), saca_50kg (×20), arroba_15kg (×66.67)\n\n"
|
|
271
|
+
|
|
272
|
+
secao += "**Fontes:**\n"
|
|
273
|
+
secao += "- `price-local-index`: Índice local de preços\n"
|
|
274
|
+
secao += "- `price-fallback`: Preço de referência (fallback)\n"
|
|
275
|
+
secao += "- `price-unavailable`: Preço não disponível\n"
|
|
276
|
+
|
|
277
|
+
else: # txt
|
|
278
|
+
secao = "PREÇOS AGRÍCOLAS UTILIZADOS\n\n"
|
|
279
|
+
|
|
280
|
+
if uf:
|
|
281
|
+
secao += f"Região: {uf}\n\n"
|
|
282
|
+
|
|
283
|
+
secao += "Preços por Cultura:\n\n"
|
|
284
|
+
|
|
285
|
+
# Coletar culturas únicas
|
|
286
|
+
culturas_vistas = set()
|
|
287
|
+
|
|
288
|
+
for item in plano:
|
|
289
|
+
cultura = item.get("cultura", "").upper()
|
|
290
|
+
|
|
291
|
+
if cultura in culturas_vistas:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
culturas_vistas.add(cultura)
|
|
295
|
+
|
|
296
|
+
preco_info = item.get("preco_real", {})
|
|
297
|
+
preco_norm = item.get("preco_normalizado", {})
|
|
298
|
+
|
|
299
|
+
if preco_info.get("ativo"):
|
|
300
|
+
preco = preco_info.get("preco")
|
|
301
|
+
unidade = preco_info.get("unidade", "N/A")
|
|
302
|
+
fonte = preco_info.get("source", "N/A")
|
|
303
|
+
fallback_text = " (fallback)" if preco_info.get("fallback") else ""
|
|
304
|
+
|
|
305
|
+
preco_fmt = f"R$ {preco:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") if preco else "N/A"
|
|
306
|
+
|
|
307
|
+
secao += f" {cultura}:\n"
|
|
308
|
+
secao += f" Preço Original: {preco_fmt}\n"
|
|
309
|
+
secao += f" Unidade: {unidade}\n"
|
|
310
|
+
|
|
311
|
+
if preco_norm.get("normalizado"):
|
|
312
|
+
preco_ton = preco_norm.get("preco_por_tonelada", 0)
|
|
313
|
+
preco_ton_fmt = f"R$ {preco_ton:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
314
|
+
secao += f" Preço/Tonelada: {preco_ton_fmt}\n"
|
|
315
|
+
secao += f" Normalizado: Sim\n"
|
|
316
|
+
else:
|
|
317
|
+
secao += f" Normalizado: Não\n"
|
|
318
|
+
|
|
319
|
+
secao += f" Fonte: {fonte}{fallback_text}\n\n"
|
|
320
|
+
else:
|
|
321
|
+
secao += f" {cultura}:\n"
|
|
322
|
+
secao += f" Preço: N/A\n"
|
|
323
|
+
secao += f" Normalizado: Não\n"
|
|
324
|
+
secao += f" Observação: Preço não disponível\n\n"
|
|
325
|
+
|
|
326
|
+
# Adicionar comparação de lucro se disponível
|
|
327
|
+
tem_lucro_mercado = any(item.get("lucro_mercado_estimado") is not None for item in plano)
|
|
328
|
+
|
|
329
|
+
if tem_lucro_mercado:
|
|
330
|
+
secao += "Comparação de Lucro:\n\n"
|
|
331
|
+
|
|
332
|
+
for item in plano:
|
|
333
|
+
if item.get("lucro_mercado_estimado") is not None:
|
|
334
|
+
talhao = item.get("talhao", "N/A")
|
|
335
|
+
cultura = item.get("cultura", "").upper()
|
|
336
|
+
lucro_sistema = item.get("lucro_original", item.get("lucro_estimado", 0))
|
|
337
|
+
lucro_mercado = item.get("lucro_mercado_estimado", 0)
|
|
338
|
+
diferenca = lucro_mercado - lucro_sistema
|
|
339
|
+
|
|
340
|
+
lucro_sistema_fmt = f"R$ {lucro_sistema:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
341
|
+
lucro_mercado_fmt = f"R$ {lucro_mercado:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
342
|
+
diferenca_fmt = f"R$ {diferenca:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
343
|
+
|
|
344
|
+
secao += f" Talhão {talhao} ({cultura}):\n"
|
|
345
|
+
secao += f" Lucro Sistema: {lucro_sistema_fmt}\n"
|
|
346
|
+
secao += f" Lucro Mercado: {lucro_mercado_fmt}\n"
|
|
347
|
+
secao += f" Diferença: {diferenca_fmt}\n\n"
|
|
348
|
+
|
|
349
|
+
secao += "Observações:\n\n"
|
|
350
|
+
|
|
351
|
+
if PRICE_APPLY_TO_PROFIT:
|
|
352
|
+
secao += "Os preços de mercado normalizados foram aplicados ao cálculo de lucro.\n\n"
|
|
353
|
+
else:
|
|
354
|
+
secao += "Os preços foram normalizados para R$/tonelada. Nesta versão, o lucro principal ainda usa a base interna do sistema, mas o lucro de mercado estimado é exibido para comparação.\n\n"
|
|
355
|
+
|
|
356
|
+
secao += "Normalização de Unidades:\n"
|
|
357
|
+
secao += "- Todos os preços foram convertidos para R$/tonelada\n"
|
|
358
|
+
secao += "- Fatores: saca_60kg (x16.67), saca_50kg (x20), arroba_15kg (x66.67)\n\n"
|
|
359
|
+
|
|
360
|
+
secao += "Fontes:\n"
|
|
361
|
+
secao += "- price-local-index: Índice local de preços\n"
|
|
362
|
+
secao += "- price-fallback: Preço de referência (fallback)\n"
|
|
363
|
+
secao += "- price-unavailable: Preço não disponível\n"
|
|
364
|
+
|
|
365
|
+
return secao
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Normalizador de Unidades de Preços Agrícolas
|
|
3
|
+
|
|
4
|
+
Converte preços de diferentes unidades para uma base comum (R$/tonelada)
|
|
5
|
+
para permitir comparação e cálculo consistente de lucro.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Fatores de conversão para tonelada
|
|
12
|
+
CONVERSAO_UNIDADES = {
|
|
13
|
+
"tonelada": 1.0,
|
|
14
|
+
"saca_60kg": 1000.0 / 60.0, # 16.6667 sacas por tonelada
|
|
15
|
+
"saca_50kg": 1000.0 / 50.0, # 20 sacas por tonelada
|
|
16
|
+
"arroba_15kg": 1000.0 / 15.0, # 66.6667 arrobas por tonelada
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalizar_preco_para_tonelada(preco: float, unidade: str) -> Dict:
|
|
21
|
+
"""
|
|
22
|
+
Converte preço de qualquer unidade para R$/tonelada.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
preco: Preço na unidade original
|
|
26
|
+
unidade: Unidade do preço (tonelada, saca_60kg, saca_50kg, arroba_15kg)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Dict com informações de normalização:
|
|
30
|
+
- preco_original: Preço original
|
|
31
|
+
- unidade_original: Unidade original
|
|
32
|
+
- preco_por_tonelada: Preço convertido para R$/tonelada
|
|
33
|
+
- unidade_normalizada: "tonelada"
|
|
34
|
+
- fator_conversao: Fator usado na conversão
|
|
35
|
+
- normalizado: True se conversão foi bem-sucedida
|
|
36
|
+
- error: Mensagem de erro se conversão falhou
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Normalizar nome da unidade (remover espaços, lowercase)
|
|
40
|
+
unidade_normalizada = unidade.lower().strip().replace(" ", "_")
|
|
41
|
+
|
|
42
|
+
# Verificar se unidade é suportada
|
|
43
|
+
if unidade_normalizada not in CONVERSAO_UNIDADES:
|
|
44
|
+
return {
|
|
45
|
+
"preco_original": preco,
|
|
46
|
+
"unidade_original": unidade,
|
|
47
|
+
"normalizado": False,
|
|
48
|
+
"error": f"Unidade '{unidade}' não suportada. Unidades válidas: {', '.join(CONVERSAO_UNIDADES.keys())}"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Obter fator de conversão
|
|
52
|
+
fator = CONVERSAO_UNIDADES[unidade_normalizada]
|
|
53
|
+
|
|
54
|
+
# Calcular preço por tonelada
|
|
55
|
+
preco_por_tonelada = preco * fator
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"preco_original": preco,
|
|
59
|
+
"unidade_original": unidade,
|
|
60
|
+
"preco_por_tonelada": round(preco_por_tonelada, 2),
|
|
61
|
+
"unidade_normalizada": "tonelada",
|
|
62
|
+
"fator_conversao": round(fator, 4),
|
|
63
|
+
"normalizado": True
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalizar_precos_lote(precos: Dict[str, Dict]) -> Dict[str, Dict]:
|
|
68
|
+
"""
|
|
69
|
+
Normaliza preços de múltiplas culturas em lote.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
precos: Dict com culturas como chave e dados de preço como valor
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Dict com culturas como chave e dados normalizados como valor
|
|
76
|
+
"""
|
|
77
|
+
resultado = {}
|
|
78
|
+
|
|
79
|
+
for cultura, dados_preco in precos.items():
|
|
80
|
+
if not dados_preco.get("ativo", False):
|
|
81
|
+
resultado[cultura] = {
|
|
82
|
+
**dados_preco,
|
|
83
|
+
"normalizado": False,
|
|
84
|
+
"error": "Preço não disponível"
|
|
85
|
+
}
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
preco = dados_preco.get("preco")
|
|
89
|
+
unidade = dados_preco.get("unidade")
|
|
90
|
+
|
|
91
|
+
if preco is None or unidade is None:
|
|
92
|
+
resultado[cultura] = {
|
|
93
|
+
**dados_preco,
|
|
94
|
+
"normalizado": False,
|
|
95
|
+
"error": "Dados de preço incompletos"
|
|
96
|
+
}
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Normalizar preço
|
|
100
|
+
normalizacao = normalizar_preco_para_tonelada(preco, unidade)
|
|
101
|
+
|
|
102
|
+
# Combinar dados originais com normalização
|
|
103
|
+
resultado[cultura] = {
|
|
104
|
+
**dados_preco,
|
|
105
|
+
**normalizacao
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return resultado
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def calcular_lucro_com_preco_normalizado(
|
|
112
|
+
preco_por_tonelada: float,
|
|
113
|
+
produtividade: float, # toneladas por hectare
|
|
114
|
+
custo_por_hectare: float,
|
|
115
|
+
area: float
|
|
116
|
+
) -> Dict:
|
|
117
|
+
"""
|
|
118
|
+
Calcula lucro usando preço normalizado de mercado.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
preco_por_tonelada: Preço normalizado em R$/tonelada
|
|
122
|
+
produtividade: Produtividade em toneladas por hectare
|
|
123
|
+
custo_por_hectare: Custo de produção por hectare
|
|
124
|
+
area: Área em hectares
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Dict com cálculos detalhados:
|
|
128
|
+
- receita_total: Receita bruta
|
|
129
|
+
- custo_total: Custo total de produção
|
|
130
|
+
- lucro_mercado: Lucro líquido com preço de mercado
|
|
131
|
+
- lucro_por_hectare: Lucro por hectare
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
# Calcular receita
|
|
135
|
+
producao_total = produtividade * area # toneladas
|
|
136
|
+
receita_total = preco_por_tonelada * producao_total
|
|
137
|
+
|
|
138
|
+
# Calcular custo
|
|
139
|
+
custo_total = custo_por_hectare * area
|
|
140
|
+
|
|
141
|
+
# Calcular lucro
|
|
142
|
+
lucro_mercado = receita_total - custo_total
|
|
143
|
+
lucro_por_hectare = lucro_mercado / area if area > 0 else 0
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"receita_total": round(receita_total, 2),
|
|
147
|
+
"custo_total": round(custo_total, 2),
|
|
148
|
+
"lucro_mercado": round(lucro_mercado, 2),
|
|
149
|
+
"lucro_por_hectare": round(lucro_por_hectare, 2),
|
|
150
|
+
"producao_total_toneladas": round(producao_total, 2)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def obter_estatisticas_normalizacao(precos_normalizados: Dict[str, Dict]) -> Dict:
|
|
155
|
+
"""
|
|
156
|
+
Gera estatísticas sobre normalização de preços.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
precos_normalizados: Dict com preços normalizados
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dict com estatísticas de normalização
|
|
163
|
+
"""
|
|
164
|
+
total = len(precos_normalizados)
|
|
165
|
+
normalizados = sum(1 for p in precos_normalizados.values() if p.get("normalizado", False))
|
|
166
|
+
nao_normalizados = total - normalizados
|
|
167
|
+
|
|
168
|
+
# Agrupar por unidade original
|
|
169
|
+
unidades = {}
|
|
170
|
+
for cultura, dados in precos_normalizados.items():
|
|
171
|
+
if dados.get("normalizado", False):
|
|
172
|
+
unidade = dados.get("unidade_original", "desconhecida")
|
|
173
|
+
unidades[unidade] = unidades.get(unidade, 0) + 1
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"ativa": True,
|
|
177
|
+
"unidade_base": "tonelada",
|
|
178
|
+
"total_culturas": total,
|
|
179
|
+
"culturas_normalizadas": normalizados,
|
|
180
|
+
"culturas_nao_normalizadas": nao_normalizados,
|
|
181
|
+
"unidades_originais": unidades
|
|
182
|
+
}
|
|
@@ -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
|