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.
@@ -1,14 +1,19 @@
1
1
  {
2
- "cli_version": "1.0.23",
3
- "backend_template_version": "1.0.23",
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-09T19:00:00Z"
18
+ "generated_at": "2026-05-09T21:00:00Z"
14
19
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agroplan-ai-cli",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "CLI global para AgroPlan AI - modo local acelerado",
5
5
  "type": "module",
6
6
  "bin": {