sienge-ecbiesek-mcp 1.1.5__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sienge-ecbiesek-mcp might be problematic. Click here for more details.

sienge_mcp/server2.py ADDED
@@ -0,0 +1,2679 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SIENGE MCP COMPLETO - FastMCP com Autenticação Flexível
4
+ Suporta Bearer Token e Basic Auth
5
+ CORREÇÕES IMPLEMENTADAS:
6
+ 1. Separação entre camada MCP e camada de serviços
7
+ 2. Alias compatíveis com checklist
8
+ 3. Normalização de parâmetros (camelCase + arrays)
9
+ 4. Bulk-data assíncrono com polling
10
+ 5. Observabilidade mínima (X-Request-ID, cache, logs)
11
+ 6. Ajustes de compatibilidade pontuais
12
+ """
13
+
14
+ from fastmcp import FastMCP
15
+ import httpx
16
+ from typing import Dict, List, Optional, Any, Union
17
+ import os
18
+ from dotenv import load_dotenv
19
+ from datetime import datetime, timedelta
20
+ import uuid
21
+ import asyncio
22
+ import json
23
+ import time
24
+ import logging
25
+ from functools import wraps
26
+
27
+ # Carrega as variáveis de ambiente
28
+ load_dotenv()
29
+
30
+ mcp = FastMCP("Sienge API Integration 🏗️ - ChatGPT Compatible")
31
+
32
+ # Configurações da API do Sienge
33
+ SIENGE_BASE_URL = os.getenv("SIENGE_BASE_URL", "https://api.sienge.com.br")
34
+ SIENGE_SUBDOMAIN = os.getenv("SIENGE_SUBDOMAIN", "")
35
+ SIENGE_USERNAME = os.getenv("SIENGE_USERNAME", "")
36
+ SIENGE_PASSWORD = os.getenv("SIENGE_PASSWORD", "")
37
+ SIENGE_API_KEY = os.getenv("SIENGE_API_KEY", "")
38
+ REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
39
+
40
+ # Cache simples em memória
41
+ _cache = {}
42
+ CACHE_TTL = 300 # 5 minutos
43
+
44
+ # Configurar logging estruturado
45
+ logging.basicConfig(
46
+ level=logging.INFO,
47
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
48
+ )
49
+ logger = logging.getLogger("sienge-mcp")
50
+
51
+
52
+ class SiengeAPIError(Exception):
53
+ """Exceção customizada para erros da API do Sienge"""
54
+ pass
55
+
56
+
57
+ # ============ HELPERS DE NORMALIZAÇÃO E OBSERVABILIDADE ============
58
+
59
+ def _camel(s: str) -> str:
60
+ """Converte snake_case para camelCase"""
61
+ if '_' not in s:
62
+ return s
63
+ parts = s.split('_')
64
+ return parts[0] + ''.join(x.capitalize() for x in parts[1:])
65
+
66
+
67
+ def to_query(params: dict) -> dict:
68
+ """
69
+ Converte parâmetros para query string normalizada:
70
+ - snake_case → camelCase
71
+ - listas/tuplas → CSV string
72
+ - booleanos → 'true'/'false' (minúsculo)
73
+ - remove valores None
74
+ """
75
+ if not params:
76
+ return {}
77
+
78
+ out = {}
79
+ for k, v in params.items():
80
+ if v is None:
81
+ continue
82
+ key = _camel(k)
83
+ if isinstance(v, (list, tuple)):
84
+ out[key] = ','.join(map(str, v))
85
+ elif isinstance(v, bool):
86
+ out[key] = 'true' if v else 'false'
87
+ else:
88
+ out[key] = v
89
+ return out
90
+
91
+
92
+ def to_camel_json(obj: Any) -> Any:
93
+ """
94
+ Normaliza payload JSON recursivamente:
95
+ - snake_case → camelCase nas chaves
96
+ - remove valores None
97
+ - mantém estrutura de listas e objetos aninhados
98
+ """
99
+ if isinstance(obj, dict):
100
+ return {_camel(k): to_camel_json(v) for k, v in obj.items() if v is not None}
101
+ elif isinstance(obj, list):
102
+ return [to_camel_json(x) for x in obj]
103
+ else:
104
+ return obj
105
+
106
+
107
+ def _normalize_url(base_url: str, subdomain: str) -> str:
108
+ """Normaliza URL evitando //public/api quando subdomain está vazio"""
109
+ if not subdomain or subdomain.strip() == "":
110
+ return f"{base_url.rstrip('/')}/public/api"
111
+ return f"{base_url.rstrip('/')}/{subdomain.strip()}/public/api"
112
+
113
+
114
+ def _parse_numeric_value(value: Any) -> float:
115
+ """Sanitiza valores numéricos, lidando com vírgulas decimais"""
116
+ if value is None:
117
+ return 0.0
118
+
119
+ if isinstance(value, (int, float)):
120
+ return float(value)
121
+
122
+ # Se for string, tentar converter
123
+ str_value = str(value).strip()
124
+ if not str_value:
125
+ return 0.0
126
+
127
+ # Trocar vírgula por ponto decimal
128
+ str_value = str_value.replace(',', '.')
129
+
130
+ try:
131
+ return float(str_value)
132
+ except (ValueError, TypeError):
133
+ logger.warning(f"Não foi possível converter '{value}' para número")
134
+ return 0.0
135
+
136
+
137
+ def _get_cache_key(endpoint: str, params: dict = None) -> str:
138
+ """Gera chave de cache baseada no endpoint e parâmetros"""
139
+ cache_params = json.dumps(params or {}, sort_keys=True)
140
+ return f"{endpoint}:{hash(cache_params)}"
141
+
142
+
143
+ def _set_cache(key: str, data: Any) -> None:
144
+ """Armazena dados no cache com TTL"""
145
+ _cache[key] = {
146
+ "data": data,
147
+ "timestamp": time.time(),
148
+ "ttl": CACHE_TTL
149
+ }
150
+
151
+
152
+ def _get_cache(key: str) -> Optional[Dict]:
153
+ """Recupera dados do cache se ainda válidos - CORRIGIDO: mantém shape original"""
154
+ if key not in _cache:
155
+ return None
156
+
157
+ cached = _cache[key]
158
+ if time.time() - cached["timestamp"] > cached["ttl"]:
159
+ del _cache[key] # Remove cache expirado
160
+ return None
161
+
162
+ # cached["data"] já é o "result" completo salvo em _set_cache
163
+ result = dict(cached["data"]) # cópia rasa
164
+ result["cache"] = {
165
+ "hit": True,
166
+ "ttl_s": cached["ttl"] - (time.time() - cached["timestamp"])
167
+ }
168
+ return result
169
+
170
+
171
+ def _log_request(method: str, endpoint: str, status_code: int, latency: float, request_id: str) -> None:
172
+ """Log estruturado das requisições"""
173
+ logger.info(
174
+ f"HTTP {method} {endpoint} - Status: {status_code} - "
175
+ f"Latency: {latency:.3f}s - RequestID: {request_id}"
176
+ )
177
+
178
+
179
+ def _extract_items_and_total(resp_data: Any) -> tuple:
180
+ """
181
+ Extrai items e total count de resposta padronizada da API Sienge
182
+ Retorna: (items_list, total_count)
183
+ """
184
+ items = resp_data.get("results", []) if isinstance(resp_data, dict) else (resp_data or [])
185
+ meta = resp_data.get("resultSetMetadata", {}) if isinstance(resp_data, dict) else {}
186
+ total = meta.get("count", len(items))
187
+ return items, total
188
+
189
+
190
+ async def make_sienge_request(
191
+ method: str, endpoint: str, params: Optional[Dict] = None, json_data: Optional[Dict] = None, use_cache: bool = True
192
+ ) -> Dict:
193
+ """
194
+ Função auxiliar para fazer requisições à API do Sienge (v1)
195
+ Suporta tanto Bearer Token quanto Basic Auth
196
+ MELHORADO: Observabilidade, cache, normalização de parâmetros
197
+ """
198
+ start_time = time.time()
199
+ req_id = str(uuid.uuid4())
200
+
201
+ try:
202
+ # Normalizar parâmetros
203
+ normalized_params = to_query(params) if params else None
204
+
205
+ # Verificar cache para operações GET
206
+ cache_key = None
207
+ if method.upper() == "GET" and use_cache and endpoint in ["/customer-types", "/creditors", "/customers"]:
208
+ cache_key = _get_cache_key(endpoint, normalized_params)
209
+ cached_result = _get_cache(cache_key)
210
+ if cached_result:
211
+ cached_result["request_id"] = req_id
212
+ return cached_result
213
+
214
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
215
+ headers = {
216
+ "Content-Type": "application/json",
217
+ "Accept": "application/json",
218
+ "X-Request-ID": req_id
219
+ }
220
+
221
+ # Configurar autenticação e URL (corrigindo URLs duplas)
222
+ auth = None
223
+ base_normalized = _normalize_url(SIENGE_BASE_URL, SIENGE_SUBDOMAIN)
224
+
225
+ if SIENGE_API_KEY and SIENGE_API_KEY != "sua_api_key_aqui":
226
+ headers["Authorization"] = f"Bearer {SIENGE_API_KEY}"
227
+ url = f"{base_normalized}/v1{endpoint}"
228
+ elif SIENGE_USERNAME and SIENGE_PASSWORD:
229
+ auth = httpx.BasicAuth(SIENGE_USERNAME, SIENGE_PASSWORD)
230
+ url = f"{base_normalized}/v1{endpoint}"
231
+ else:
232
+ return {
233
+ "success": False,
234
+ "error": "No Authentication",
235
+ "message": "Configure SIENGE_API_KEY ou SIENGE_USERNAME/PASSWORD no .env",
236
+ "request_id": req_id
237
+ }
238
+
239
+ response = await client.request(
240
+ method=method,
241
+ url=url,
242
+ headers=headers,
243
+ params=normalized_params,
244
+ json=json_data,
245
+ auth=auth
246
+ )
247
+
248
+ latency = time.time() - start_time
249
+ _log_request(method, endpoint, response.status_code, latency, req_id)
250
+
251
+ if response.status_code in [200, 201]:
252
+ try:
253
+ data = response.json()
254
+ result = {
255
+ "success": True,
256
+ "data": data,
257
+ "status_code": response.status_code,
258
+ "request_id": req_id,
259
+ "latency_ms": round(latency * 1000, 2)
260
+ }
261
+
262
+ # Armazenar no cache se aplicável
263
+ if cache_key and method.upper() == "GET":
264
+ _set_cache(cache_key, result)
265
+ result["cache"] = {"hit": False, "ttl_s": CACHE_TTL}
266
+
267
+ return result
268
+
269
+ except Exception:
270
+ return {
271
+ "success": True,
272
+ "data": {"message": "Success"},
273
+ "status_code": response.status_code,
274
+ "request_id": req_id,
275
+ "latency_ms": round(latency * 1000, 2)
276
+ }
277
+ else:
278
+ return {
279
+ "success": False,
280
+ "error": f"HTTP {response.status_code}",
281
+ "message": response.text,
282
+ "status_code": response.status_code,
283
+ "request_id": req_id,
284
+ "latency_ms": round(latency * 1000, 2)
285
+ }
286
+
287
+ except httpx.TimeoutException:
288
+ latency = time.time() - start_time
289
+ _log_request(method, endpoint, 408, latency, req_id)
290
+ return {
291
+ "success": False,
292
+ "error": "Timeout",
293
+ "message": f"A requisição excedeu o tempo limite de {REQUEST_TIMEOUT}s",
294
+ "request_id": req_id,
295
+ "latency_ms": round(latency * 1000, 2)
296
+ }
297
+ except Exception as e:
298
+ latency = time.time() - start_time
299
+ _log_request(method, endpoint, 500, latency, req_id)
300
+ return {
301
+ "success": False,
302
+ "error": str(e),
303
+ "message": f"Erro na requisição: {str(e)}",
304
+ "request_id": req_id,
305
+ "latency_ms": round(latency * 1000, 2)
306
+ }
307
+
308
+
309
+ async def make_sienge_bulk_request(
310
+ method: str, endpoint: str, params: Optional[Dict] = None, json_data: Optional[Dict] = None
311
+ ) -> Dict:
312
+ """
313
+ Função auxiliar para fazer requisições à API bulk-data do Sienge
314
+ Suporta tanto Bearer Token quanto Basic Auth
315
+ MELHORADO: Observabilidade e normalização de parâmetros
316
+ """
317
+ start_time = time.time()
318
+ req_id = str(uuid.uuid4())
319
+
320
+ try:
321
+ # Normalizar parâmetros
322
+ normalized_params = to_query(params) if params else None
323
+
324
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
325
+ headers = {
326
+ "Content-Type": "application/json",
327
+ "Accept": "application/json",
328
+ "X-Request-ID": req_id
329
+ }
330
+
331
+ # Configurar autenticação e URL para bulk-data (corrigindo URLs duplas)
332
+ auth = None
333
+ base_normalized = _normalize_url(SIENGE_BASE_URL, SIENGE_SUBDOMAIN)
334
+
335
+ if SIENGE_API_KEY and SIENGE_API_KEY != "sua_api_key_aqui":
336
+ headers["Authorization"] = f"Bearer {SIENGE_API_KEY}"
337
+ url = f"{base_normalized}/bulk-data/v1{endpoint}"
338
+ elif SIENGE_USERNAME and SIENGE_PASSWORD:
339
+ auth = httpx.BasicAuth(SIENGE_USERNAME, SIENGE_PASSWORD)
340
+ url = f"{base_normalized}/bulk-data/v1{endpoint}"
341
+ else:
342
+ return {
343
+ "success": False,
344
+ "error": "No Authentication",
345
+ "message": "Configure SIENGE_API_KEY ou SIENGE_USERNAME/PASSWORD no .env",
346
+ "request_id": req_id
347
+ }
348
+
349
+ response = await client.request(
350
+ method=method,
351
+ url=url,
352
+ headers=headers,
353
+ params=normalized_params,
354
+ json=json_data,
355
+ auth=auth
356
+ )
357
+
358
+ latency = time.time() - start_time
359
+ _log_request(method, f"bulk-data{endpoint}", response.status_code, latency, req_id)
360
+
361
+ if response.status_code in [200, 201, 202]:
362
+ try:
363
+ return {
364
+ "success": True,
365
+ "data": response.json(),
366
+ "status_code": response.status_code,
367
+ "request_id": req_id,
368
+ "latency_ms": round(latency * 1000, 2)
369
+ }
370
+ except Exception:
371
+ return {
372
+ "success": True,
373
+ "data": {"message": "Success"},
374
+ "status_code": response.status_code,
375
+ "request_id": req_id,
376
+ "latency_ms": round(latency * 1000, 2)
377
+ }
378
+ else:
379
+ return {
380
+ "success": False,
381
+ "error": f"HTTP {response.status_code}",
382
+ "message": response.text,
383
+ "status_code": response.status_code,
384
+ "request_id": req_id,
385
+ "latency_ms": round(latency * 1000, 2)
386
+ }
387
+
388
+ except httpx.TimeoutException:
389
+ latency = time.time() - start_time
390
+ _log_request(method, f"bulk-data{endpoint}", 408, latency, req_id)
391
+ return {
392
+ "success": False,
393
+ "error": "Timeout",
394
+ "message": f"A requisição excedeu o tempo limite de {REQUEST_TIMEOUT}s",
395
+ "request_id": req_id,
396
+ "latency_ms": round(latency * 1000, 2)
397
+ }
398
+ except Exception as e:
399
+ latency = time.time() - start_time
400
+ _log_request(method, f"bulk-data{endpoint}", 500, latency, req_id)
401
+ return {
402
+ "success": False,
403
+ "error": str(e),
404
+ "message": f"Erro na requisição bulk-data: {str(e)}",
405
+ "request_id": req_id,
406
+ "latency_ms": round(latency * 1000, 2)
407
+ }
408
+
409
+
410
+ async def _fetch_bulk_with_polling(method: str, endpoint: str, params: Optional[Dict] = None, json_data: Optional[Dict] = None) -> Dict:
411
+ """
412
+ Faz requisição bulk com polling automático para requests assíncronos (202)
413
+ """
414
+ correlation_id = str(uuid.uuid4())
415
+
416
+ # Fazer requisição inicial
417
+ result = await make_sienge_bulk_request(method, endpoint, params, json_data)
418
+
419
+ # Se não foi 202 ou não tem identifier, retornar resultado direto
420
+ if result.get("status_code") != 202:
421
+ return result
422
+
423
+ data = result.get("data", {})
424
+ if not isinstance(data, dict) or not data.get("identifier"):
425
+ return result
426
+
427
+ # Processar requisição assíncrona com polling
428
+ identifier = data["identifier"]
429
+ request_id = result.get("request_id")
430
+
431
+ logger.info(f"Iniciando polling para bulk request - Identifier: {identifier} - RequestID: {request_id}")
432
+
433
+ max_attempts = 30 # Máximo 5 minutos (30 * 10s)
434
+ attempt = 0
435
+ backoff_delay = 2 # Começar com 2 segundos
436
+
437
+ while attempt < max_attempts:
438
+ attempt += 1
439
+ await asyncio.sleep(backoff_delay)
440
+
441
+ # Verificar status do processamento
442
+ status_result = await make_sienge_bulk_request("GET", f"/async/{identifier}")
443
+
444
+ if not status_result["success"]:
445
+ logger.error(f"Erro ao verificar status do bulk request {identifier}: {status_result.get('error')}")
446
+ break
447
+
448
+ status_data = status_result.get("data", {})
449
+ status = status_data.get("status", "unknown")
450
+
451
+ logger.info(f"Polling attempt {attempt} - Status: {status} - Identifier: {identifier}")
452
+
453
+ if status == "completed":
454
+ # Buscar resultados finais
455
+ all_chunks = []
456
+ chunk_count = status_data.get("chunk_count", 1)
457
+
458
+ chunks_downloaded = 0
459
+ for chunk_num in range(chunk_count):
460
+ try:
461
+ # CORRIGIDO: endpoint aninhado sob /async
462
+ chunk_result = await make_sienge_bulk_request("GET", f"/async/{identifier}/result/{chunk_num}")
463
+ if chunk_result["success"]:
464
+ chunk_data = chunk_result.get("data", {}).get("data", [])
465
+ if isinstance(chunk_data, list):
466
+ all_chunks.extend(chunk_data)
467
+ chunks_downloaded += 1
468
+ except Exception as e:
469
+ logger.warning(f"Erro ao buscar chunk {chunk_num}: {e}")
470
+
471
+ return {
472
+ "success": True,
473
+ "data": all_chunks,
474
+ "async_identifier": identifier,
475
+ "correlation_id": correlation_id,
476
+ "chunks_downloaded": chunks_downloaded,
477
+ "rows_returned": len(all_chunks),
478
+ "polling_attempts": attempt,
479
+ "request_id": request_id
480
+ }
481
+
482
+ elif status == "failed" or status == "error":
483
+ return {
484
+ "success": False,
485
+ "error": "Bulk processing failed",
486
+ "message": status_data.get("error_message", "Processamento bulk falhou"),
487
+ "async_identifier": identifier,
488
+ "correlation_id": correlation_id,
489
+ "polling_attempts": attempt,
490
+ "request_id": request_id
491
+ }
492
+
493
+ # Aumentar delay progressivamente (backoff exponencial limitado)
494
+ backoff_delay = min(backoff_delay * 1.5, 10) # Máximo 10 segundos
495
+
496
+ # Timeout do polling
497
+ return {
498
+ "success": False,
499
+ "error": "Polling timeout",
500
+ "message": f"Processamento bulk não completou em {max_attempts} tentativas",
501
+ "async_identifier": identifier,
502
+ "correlation_id": correlation_id,
503
+ "polling_attempts": attempt,
504
+ "request_id": request_id
505
+ }
506
+
507
+
508
+ # ============ CAMADA DE SERVIÇOS (FUNÇÕES INTERNAS) ============
509
+
510
+ async def _svc_get_customer_types() -> Dict:
511
+ """Serviço interno: buscar tipos de clientes"""
512
+ return await make_sienge_request("GET", "/customer-types", use_cache=True)
513
+
514
+
515
+ async def _svc_get_customers(*, limit: int = 50, offset: int = 0, search: str = None, customer_type_id: str = None) -> Dict:
516
+ """Serviço interno: buscar clientes"""
517
+ params = {"limit": min(limit or 50, 200), "offset": offset or 0}
518
+ if search:
519
+ params["search"] = search
520
+ if customer_type_id:
521
+ params["customer_type_id"] = customer_type_id
522
+
523
+ return await make_sienge_request("GET", "/customers", params=params)
524
+
525
+
526
+ async def _svc_get_creditors(*, limit: int = 50, offset: int = 0, search: str = None) -> Dict:
527
+ """Serviço interno: buscar credores/fornecedores"""
528
+ params = {"limit": min(limit or 50, 200), "offset": offset or 0}
529
+ if search:
530
+ params["search"] = search
531
+
532
+ return await make_sienge_request("GET", "/creditors", params=params)
533
+
534
+
535
+ async def _svc_get_creditor_bank_info(*, creditor_id: str) -> Dict:
536
+ """Serviço interno: informações bancárias de credor"""
537
+ return await make_sienge_request("GET", f"/creditors/{creditor_id}/bank-informations")
538
+
539
+
540
+ async def _svc_get_projects(*, limit: int = 100, offset: int = 0, company_id: int = None,
541
+ enterprise_type: int = None, receivable_register: str = None,
542
+ only_buildings_enabled: bool = False) -> Dict:
543
+ """Serviço interno: buscar empreendimentos/projetos"""
544
+ params = {"limit": min(limit or 100, 200), "offset": offset or 0}
545
+
546
+ if company_id:
547
+ params["company_id"] = company_id
548
+ if enterprise_type:
549
+ params["type"] = enterprise_type
550
+ if receivable_register:
551
+ params["receivable_register"] = receivable_register
552
+ if only_buildings_enabled:
553
+ params["only_buildings_enabled_for_integration"] = only_buildings_enabled
554
+
555
+ return await make_sienge_request("GET", "/enterprises", params=params)
556
+
557
+
558
+ async def _svc_get_bills(*, start_date: str = None, end_date: str = None, creditor_id: str = None,
559
+ status: str = None, limit: int = 50) -> Dict:
560
+ """Serviço interno: buscar títulos a pagar"""
561
+ # Se start_date não fornecido, usar últimos 30 dias
562
+ if not start_date:
563
+ start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
564
+
565
+ # Se end_date não fornecido, usar hoje
566
+ if not end_date:
567
+ end_date = datetime.now().strftime("%Y-%m-%d")
568
+
569
+ params = {"start_date": start_date, "end_date": end_date, "limit": min(limit or 50, 200)}
570
+
571
+ if creditor_id:
572
+ params["creditor_id"] = creditor_id
573
+ if status:
574
+ params["status"] = status
575
+
576
+ return await make_sienge_request("GET", "/bills", params=params)
577
+
578
+
579
+ async def _svc_get_accounts_receivable(*, start_date: str, end_date: str, selection_type: str = "D",
580
+ company_id: int = None, cost_centers_id: List[int] = None,
581
+ correction_indexer_id: int = None, correction_date: str = None,
582
+ change_start_date: str = None, completed_bills: str = None,
583
+ origins_ids: List[str] = None, bearers_id_in: List[int] = None,
584
+ bearers_id_not_in: List[int] = None) -> Dict:
585
+ """Serviço interno: buscar contas a receber via bulk-data"""
586
+ params = {"start_date": start_date, "end_date": end_date, "selection_type": selection_type}
587
+
588
+ if company_id:
589
+ params["company_id"] = company_id
590
+ if cost_centers_id:
591
+ params["cost_centers_id"] = cost_centers_id
592
+ if correction_indexer_id:
593
+ params["correction_indexer_id"] = correction_indexer_id
594
+ if correction_date:
595
+ params["correction_date"] = correction_date
596
+ if change_start_date:
597
+ params["change_start_date"] = change_start_date
598
+ if completed_bills:
599
+ params["completed_bills"] = completed_bills
600
+ if origins_ids:
601
+ params["origins_ids"] = origins_ids
602
+ if bearers_id_in:
603
+ params["bearers_id_in"] = bearers_id_in
604
+ if bearers_id_not_in:
605
+ params["bearers_id_not_in"] = bearers_id_not_in
606
+
607
+ return await _fetch_bulk_with_polling("GET", "/income", params=params)
608
+
609
+
610
+ async def _svc_get_accounts_receivable_by_bills(*, bills_ids: List[int], correction_indexer_id: int = None,
611
+ correction_date: str = None) -> Dict:
612
+ """Serviço interno: buscar contas a receber por títulos específicos"""
613
+ params = {"bills_ids": bills_ids}
614
+
615
+ if correction_indexer_id:
616
+ params["correction_indexer_id"] = correction_indexer_id
617
+ if correction_date:
618
+ params["correction_date"] = correction_date
619
+
620
+ return await _fetch_bulk_with_polling("GET", "/income/by-bills", params=params)
621
+
622
+
623
+ async def _svc_get_purchase_orders(*, purchase_order_id: str = None, status: str = None,
624
+ date_from: str = None, limit: int = 50) -> Dict:
625
+ """Serviço interno: buscar pedidos de compra"""
626
+ if purchase_order_id:
627
+ return await make_sienge_request("GET", f"/purchase-orders/{purchase_order_id}")
628
+
629
+ params = {"limit": min(limit or 50, 200)}
630
+ if status:
631
+ params["status"] = status
632
+ if date_from:
633
+ params["date_from"] = date_from
634
+
635
+ return await make_sienge_request("GET", "/purchase-orders", params=params)
636
+
637
+
638
+ async def _svc_get_purchase_requests(*, purchase_request_id: str = None, limit: int = 50, status: str = None) -> Dict:
639
+ """Serviço interno: buscar solicitações de compra"""
640
+ if purchase_request_id:
641
+ return await make_sienge_request("GET", f"/purchase-requests/{purchase_request_id}")
642
+
643
+ params = {"limit": min(limit or 50, 200)}
644
+ if status:
645
+ params["status"] = status
646
+
647
+ return await make_sienge_request("GET", "/purchase-requests", params=params)
648
+
649
+
650
+ async def _svc_get_purchase_invoices(*, limit: int = 50, date_from: str = None) -> Dict:
651
+ """Serviço interno: listar notas fiscais de compra"""
652
+ params = {"limit": min(limit or 50, 200)}
653
+ if date_from:
654
+ params["date_from"] = date_from
655
+
656
+ return await make_sienge_request("GET", "/purchase-invoices", params=params)
657
+
658
+
659
+ # ============ CONEXÃO E TESTE ============
660
+
661
+
662
+ @mcp.tool
663
+ async def test_sienge_connection() -> Dict:
664
+ """Testa a conexão com a API do Sienge"""
665
+ try:
666
+ # Usar serviço interno
667
+ result = await _svc_get_customer_types()
668
+
669
+ if result["success"]:
670
+ auth_method = "Bearer Token" if SIENGE_API_KEY else "Basic Auth"
671
+ return {
672
+ "success": True,
673
+ "message": "✅ Conexão com API do Sienge estabelecida com sucesso!",
674
+ "api_status": "Online",
675
+ "auth_method": auth_method,
676
+ "timestamp": datetime.now().isoformat(),
677
+ "request_id": result.get("request_id"),
678
+ "latency_ms": result.get("latency_ms"),
679
+ "cache": result.get("cache")
680
+ }
681
+ else:
682
+ return {
683
+ "success": False,
684
+ "message": "❌ Falha ao conectar com API do Sienge",
685
+ "error": result.get("error"),
686
+ "details": result.get("message"),
687
+ "timestamp": datetime.now().isoformat(),
688
+ "request_id": result.get("request_id")
689
+ }
690
+ except Exception as e:
691
+ return {
692
+ "success": False,
693
+ "message": "❌ Erro ao testar conexão",
694
+ "error": str(e),
695
+ "timestamp": datetime.now().isoformat(),
696
+ }
697
+
698
+
699
+ # ============ CLIENTES ============
700
+
701
+
702
+ @mcp.tool
703
+ async def get_sienge_customers(
704
+ limit: Optional[int] = 50, offset: Optional[int] = 0, search: Optional[str] = None, customer_type_id: Optional[str] = None
705
+ ) -> Dict:
706
+ """
707
+ Busca clientes no Sienge com filtros
708
+
709
+ Args:
710
+ limit: Máximo de registros (padrão: 50)
711
+ offset: Pular registros (padrão: 0)
712
+ search: Buscar por nome ou documento
713
+ customer_type_id: Filtrar por tipo de cliente
714
+ """
715
+ # Usar serviço interno
716
+ result = await _svc_get_customers(
717
+ limit=limit or 50,
718
+ offset=offset or 0,
719
+ search=search,
720
+ customer_type_id=customer_type_id
721
+ )
722
+
723
+ if result["success"]:
724
+ data = result["data"]
725
+ customers = data.get("results", []) if isinstance(data, dict) else data
726
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
727
+ total_count = metadata.get("count", len(customers))
728
+
729
+ return {
730
+ "success": True,
731
+ "message": f"✅ Encontrados {len(customers)} clientes (total: {total_count})",
732
+ "customers": customers,
733
+ "count": len(customers),
734
+ "total_count": total_count,
735
+ "filters_applied": {
736
+ "limit": limit, "offset": offset, "search": search, "customer_type_id": customer_type_id
737
+ },
738
+ "request_id": result.get("request_id"),
739
+ "latency_ms": result.get("latency_ms"),
740
+ "cache": result.get("cache")
741
+ }
742
+
743
+ return {
744
+ "success": False,
745
+ "message": "❌ Erro ao buscar clientes",
746
+ "error": result.get("error"),
747
+ "details": result.get("message"),
748
+ "request_id": result.get("request_id")
749
+ }
750
+
751
+
752
+ @mcp.tool
753
+ async def get_sienge_customer_types() -> Dict:
754
+ """Lista tipos de clientes disponíveis"""
755
+ # Usar serviço interno
756
+ result = await _svc_get_customer_types()
757
+
758
+ if result["success"]:
759
+ data = result["data"]
760
+ customer_types = data.get("results", []) if isinstance(data, dict) else data
761
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
762
+ total_count = metadata.get("count", len(customer_types))
763
+
764
+ return {
765
+ "success": True,
766
+ "message": f"✅ Encontrados {len(customer_types)} tipos de clientes (total: {total_count})",
767
+ "customer_types": customer_types,
768
+ "count": len(customer_types),
769
+ "total_count": total_count,
770
+ "request_id": result.get("request_id"),
771
+ "latency_ms": result.get("latency_ms"),
772
+ "cache": result.get("cache")
773
+ }
774
+
775
+ return {
776
+ "success": False,
777
+ "message": "❌ Erro ao buscar tipos de clientes",
778
+ "error": result.get("error"),
779
+ "details": result.get("message"),
780
+ "request_id": result.get("request_id")
781
+ }
782
+
783
+
784
+ # ============ ALIAS COMPATÍVEIS COM CHECKLIST ============
785
+
786
+ @mcp.tool
787
+ async def get_sienge_enterprises(
788
+ limit: int = 100, offset: int = 0, company_id: int = None, enterprise_type: int = None
789
+ ) -> Dict:
790
+ """
791
+ ALIAS: get_sienge_projects → get_sienge_enterprises
792
+ Busca empreendimentos/obras (compatibilidade com checklist)
793
+ """
794
+ return await get_sienge_projects(
795
+ limit=limit,
796
+ offset=offset,
797
+ company_id=company_id,
798
+ enterprise_type=enterprise_type
799
+ )
800
+
801
+
802
+ @mcp.tool
803
+ async def get_sienge_suppliers(
804
+ limit: int = 50,
805
+ offset: int = 0,
806
+ search: str = None
807
+ ) -> Dict:
808
+ """
809
+ ALIAS: get_sienge_creditors → get_sienge_suppliers
810
+ Busca fornecedores (compatibilidade com checklist)
811
+ """
812
+ return await get_sienge_creditors(limit=limit, offset=offset, search=search)
813
+
814
+
815
+ @mcp.tool
816
+ async def search_sienge_finances(
817
+ period_start: str,
818
+ period_end: str,
819
+ account_type: Optional[str] = None,
820
+ cost_center: Optional[str] = None, # ignorado por enquanto (não suportado na API atual)
821
+ amount_filter: Optional[str] = None,
822
+ customer_creditor: Optional[str] = None
823
+ ) -> Dict:
824
+ """
825
+ ALIAS: search_sienge_financial_data → search_sienge_finances
826
+ - account_type: receivable | payable | both
827
+ - amount_filter: "100..500", ">=1000", "<=500", ">100", "<200", "=750"
828
+ - customer_creditor: termo de busca (cliente/credor)
829
+ """
830
+ # 1) mapear tipo
831
+ search_type = (account_type or "both").lower()
832
+ if search_type not in {"receivable", "payable", "both"}:
833
+ search_type = "both"
834
+
835
+ # 2) parse de faixa de valores
836
+ amount_min = amount_max = None
837
+ if amount_filter:
838
+ s = amount_filter.replace(" ", "")
839
+ try:
840
+ if ".." in s:
841
+ lo, hi = s.split("..", 1)
842
+ amount_min = float(lo) if lo else None
843
+ amount_max = float(hi) if hi else None
844
+ elif s.startswith(">="):
845
+ amount_min = float(s[2:])
846
+ elif s.startswith("<="):
847
+ amount_max = float(s[2:])
848
+ elif s.startswith(">"):
849
+ # >x → min = x (estrito não suportado; aproximamos)
850
+ amount_min = float(s[1:])
851
+ elif s.startswith("<"):
852
+ amount_max = float(s[1:])
853
+ elif s.startswith("="):
854
+ v = float(s[1:])
855
+ amount_min = v
856
+ amount_max = v
857
+ else:
858
+ # número puro → min
859
+ amount_min = float(s)
860
+ except ValueError:
861
+ # filtro inválido → ignora silenciosamente
862
+ amount_min = amount_max = None
863
+
864
+ return await search_sienge_financial_data(
865
+ period_start=period_start,
866
+ period_end=period_end,
867
+ search_type=search_type,
868
+ amount_min=amount_min,
869
+ amount_max=amount_max,
870
+ customer_creditor_search=customer_creditor
871
+ )
872
+
873
+
874
+ @mcp.tool
875
+ async def get_sienge_accounts_payable(
876
+ start_date: str = None, end_date: str = None, creditor_id: str = None,
877
+ status: str = None, limit: int = 50
878
+ ) -> Dict:
879
+ """
880
+ ALIAS: get_sienge_bills → get_sienge_accounts_payable
881
+ Busca contas a pagar (compatibilidade com checklist)
882
+ """
883
+ return await get_sienge_bills(
884
+ start_date=start_date,
885
+ end_date=end_date,
886
+ creditor_id=creditor_id,
887
+ status=status,
888
+ limit=limit
889
+ )
890
+
891
+
892
+ @mcp.tool
893
+ async def list_sienge_purchase_invoices(limit: int = 50, date_from: str = None) -> Dict:
894
+ """
895
+ Lista notas fiscais de compra (versão list/plural esperada pelo checklist)
896
+
897
+ Args:
898
+ limit: Máximo de registros (padrão: 50, máx: 200)
899
+ date_from: Data inicial (YYYY-MM-DD)
900
+ """
901
+ # Usar serviço interno
902
+ result = await _svc_get_purchase_invoices(limit=limit, date_from=date_from)
903
+
904
+ if result["success"]:
905
+ data = result["data"]
906
+ invoices = data.get("results", []) if isinstance(data, dict) else data
907
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
908
+ total_count = metadata.get("count", len(invoices))
909
+
910
+ return {
911
+ "success": True,
912
+ "message": f"✅ Encontradas {len(invoices)} notas fiscais de compra (total: {total_count})",
913
+ "purchase_invoices": invoices,
914
+ "count": len(invoices),
915
+ "total_count": total_count,
916
+ "filters_applied": {"limit": limit, "date_from": date_from},
917
+ "request_id": result.get("request_id"),
918
+ "latency_ms": result.get("latency_ms"),
919
+ "cache": result.get("cache")
920
+ }
921
+
922
+ return {
923
+ "success": False,
924
+ "message": "❌ Erro ao buscar notas fiscais de compra",
925
+ "error": result.get("error"),
926
+ "details": result.get("message"),
927
+ "request_id": result.get("request_id")
928
+ }
929
+
930
+
931
+ @mcp.tool
932
+ async def list_sienge_purchase_requests(limit: int = 50, status: str = None) -> Dict:
933
+ """
934
+ Lista solicitações de compra (versão list/plural esperada pelo checklist)
935
+
936
+ Args:
937
+ limit: Máximo de registros (padrão: 50, máx: 200)
938
+ status: Status da solicitação
939
+ """
940
+ # Usar serviço interno
941
+ result = await _svc_get_purchase_requests(limit=limit, status=status)
942
+
943
+ if result["success"]:
944
+ data = result["data"]
945
+ requests = data.get("results", []) if isinstance(data, dict) else data
946
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
947
+ total_count = metadata.get("count", len(requests))
948
+
949
+ return {
950
+ "success": True,
951
+ "message": f"✅ Encontradas {len(requests)} solicitações de compra (total: {total_count})",
952
+ "purchase_requests": requests,
953
+ "count": len(requests),
954
+ "total_count": total_count,
955
+ "filters_applied": {"limit": limit, "status": status},
956
+ "request_id": result.get("request_id"),
957
+ "latency_ms": result.get("latency_ms"),
958
+ "cache": result.get("cache")
959
+ }
960
+
961
+ return {
962
+ "success": False,
963
+ "message": "❌ Erro ao buscar solicitações de compra",
964
+ "error": result.get("error"),
965
+ "details": result.get("message"),
966
+ "request_id": result.get("request_id")
967
+ }
968
+
969
+
970
+ # ============ CREDORES ============
971
+
972
+
973
+ @mcp.tool
974
+ async def get_sienge_creditors(limit: Optional[int] = 50, offset: Optional[int] = 0, search: Optional[str] = None) -> Dict:
975
+ """
976
+ Busca credores/fornecedores
977
+
978
+ Args:
979
+ limit: Máximo de registros (padrão: 50)
980
+ offset: Pular registros (padrão: 0)
981
+ search: Buscar por nome
982
+ """
983
+ # Usar serviço interno
984
+ result = await _svc_get_creditors(
985
+ limit=limit or 50,
986
+ offset=offset or 0,
987
+ search=search
988
+ )
989
+
990
+ if result["success"]:
991
+ data = result["data"]
992
+ creditors = data.get("results", []) if isinstance(data, dict) else data
993
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
994
+ total_count = metadata.get("count", len(creditors))
995
+
996
+ return {
997
+ "success": True,
998
+ "message": f"✅ Encontrados {len(creditors)} credores (total: {total_count})",
999
+ "creditors": creditors,
1000
+ "count": len(creditors),
1001
+ "total_count": total_count,
1002
+ "filters_applied": {"limit": limit, "offset": offset, "search": search},
1003
+ "request_id": result.get("request_id"),
1004
+ "latency_ms": result.get("latency_ms"),
1005
+ "cache": result.get("cache")
1006
+ }
1007
+
1008
+ return {
1009
+ "success": False,
1010
+ "message": "❌ Erro ao buscar credores",
1011
+ "error": result.get("error"),
1012
+ "details": result.get("message"),
1013
+ "request_id": result.get("request_id")
1014
+ }
1015
+
1016
+
1017
+ @mcp.tool
1018
+ async def get_sienge_creditor_bank_info(creditor_id: str) -> Dict:
1019
+ """
1020
+ Consulta informações bancárias de um credor
1021
+
1022
+ Args:
1023
+ creditor_id: ID do credor (obrigatório)
1024
+ """
1025
+ # Usar serviço interno
1026
+ result = await _svc_get_creditor_bank_info(creditor_id=creditor_id)
1027
+
1028
+ if result["success"]:
1029
+ return {
1030
+ "success": True,
1031
+ "message": f"✅ Informações bancárias do credor {creditor_id}",
1032
+ "creditor_id": creditor_id,
1033
+ "bank_info": result["data"],
1034
+ "request_id": result.get("request_id"),
1035
+ "latency_ms": result.get("latency_ms")
1036
+ }
1037
+
1038
+ return {
1039
+ "success": False,
1040
+ "message": f"❌ Erro ao buscar info bancária do credor {creditor_id}",
1041
+ "error": result.get("error"),
1042
+ "details": result.get("message"),
1043
+ "request_id": result.get("request_id")
1044
+ }
1045
+
1046
+
1047
+ # ============ FINANCEIRO ============
1048
+
1049
+
1050
+ @mcp.tool
1051
+ async def get_sienge_accounts_receivable(
1052
+ start_date: str,
1053
+ end_date: str,
1054
+ selection_type: str = "D",
1055
+ company_id: Optional[int] = None,
1056
+ cost_centers_id: Optional[List[int]] = None,
1057
+ correction_indexer_id: Optional[int] = None,
1058
+ correction_date: Optional[str] = None,
1059
+ change_start_date: Optional[str] = None,
1060
+ completed_bills: Optional[str] = None,
1061
+ origins_ids: Optional[List[str]] = None,
1062
+ bearers_id_in: Optional[List[int]] = None,
1063
+ bearers_id_not_in: Optional[List[int]] = None,
1064
+ ) -> Dict:
1065
+ """
1066
+ Consulta parcelas do contas a receber via API bulk-data
1067
+ MELHORADO: Suporte a polling assíncrono para requests 202
1068
+
1069
+ Args:
1070
+ start_date: Data de início do período (YYYY-MM-DD) - OBRIGATÓRIO
1071
+ end_date: Data do fim do período (YYYY-MM-DD) - OBRIGATÓRIO
1072
+ selection_type: Seleção da data do período (I=emissão, D=vencimento, P=pagamento, B=competência) - padrão: D
1073
+ company_id: Código da empresa
1074
+ cost_centers_id: Lista de códigos de centro de custo
1075
+ correction_indexer_id: Código do indexador de correção
1076
+ correction_date: Data para correção do indexador (YYYY-MM-DD)
1077
+ change_start_date: Data inicial de alteração do título/parcela (YYYY-MM-DD)
1078
+ completed_bills: Filtrar por títulos completos (S)
1079
+ origins_ids: Códigos dos módulos de origem (CR, CO, ME, CA, CI, AR, SC, LO, NE, NS, AC, NF)
1080
+ bearers_id_in: Filtrar parcelas com códigos de portador específicos
1081
+ bearers_id_not_in: Filtrar parcelas excluindo códigos de portador específicos
1082
+ """
1083
+ # Usar serviço interno com polling assíncrono
1084
+ result = await _svc_get_accounts_receivable(
1085
+ start_date=start_date,
1086
+ end_date=end_date,
1087
+ selection_type=selection_type,
1088
+ company_id=company_id,
1089
+ cost_centers_id=cost_centers_id,
1090
+ correction_indexer_id=correction_indexer_id,
1091
+ correction_date=correction_date,
1092
+ change_start_date=change_start_date,
1093
+ completed_bills=completed_bills,
1094
+ origins_ids=origins_ids,
1095
+ bearers_id_in=bearers_id_in,
1096
+ bearers_id_not_in=bearers_id_not_in
1097
+ )
1098
+
1099
+ if result["success"]:
1100
+ # Para requests normais (200) e assíncronos processados
1101
+ income_data = result.get("data", [])
1102
+
1103
+ response = {
1104
+ "success": True,
1105
+ "message": f"✅ Encontradas {len(income_data)} parcelas a receber",
1106
+ "income_data": income_data,
1107
+ "count": len(income_data),
1108
+ "period": f"{start_date} a {end_date}",
1109
+ "selection_type": selection_type,
1110
+ "request_id": result.get("request_id"),
1111
+ "latency_ms": result.get("latency_ms")
1112
+ }
1113
+
1114
+ # Se foi processamento assíncrono, incluir informações extras
1115
+ if result.get("async_identifier"):
1116
+ response.update({
1117
+ "async_processing": {
1118
+ "identifier": result.get("async_identifier"),
1119
+ "correlation_id": result.get("correlation_id"),
1120
+ "chunks_downloaded": result.get("chunks_downloaded"),
1121
+ "rows_returned": result.get("rows_returned"),
1122
+ "polling_attempts": result.get("polling_attempts")
1123
+ }
1124
+ })
1125
+
1126
+ return response
1127
+
1128
+ return {
1129
+ "success": False,
1130
+ "message": "❌ Erro ao buscar parcelas a receber",
1131
+ "error": result.get("error"),
1132
+ "details": result.get("message"),
1133
+ "request_id": result.get("request_id"),
1134
+ "async_info": {
1135
+ "identifier": result.get("async_identifier"),
1136
+ "polling_attempts": result.get("polling_attempts")
1137
+ } if result.get("async_identifier") else None
1138
+ }
1139
+
1140
+
1141
+ @mcp.tool
1142
+ async def get_sienge_accounts_receivable_by_bills(
1143
+ bills_ids: List[int], correction_indexer_id: Optional[int] = None, correction_date: Optional[str] = None
1144
+ ) -> Dict:
1145
+ """
1146
+ Consulta parcelas dos títulos informados via API bulk-data
1147
+ MELHORADO: Suporte a polling assíncrono para requests 202
1148
+
1149
+ Args:
1150
+ bills_ids: Lista de códigos dos títulos - OBRIGATÓRIO
1151
+ correction_indexer_id: Código do indexador de correção
1152
+ correction_date: Data para correção do indexador (YYYY-MM-DD)
1153
+ """
1154
+ # Usar serviço interno com polling assíncrono
1155
+ result = await _svc_get_accounts_receivable_by_bills(
1156
+ bills_ids=bills_ids,
1157
+ correction_indexer_id=correction_indexer_id,
1158
+ correction_date=correction_date
1159
+ )
1160
+
1161
+ if result["success"]:
1162
+ income_data = result.get("data", [])
1163
+
1164
+ response = {
1165
+ "success": True,
1166
+ "message": f"✅ Encontradas {len(income_data)} parcelas dos títulos informados",
1167
+ "income_data": income_data,
1168
+ "count": len(income_data),
1169
+ "bills_consulted": bills_ids,
1170
+ "request_id": result.get("request_id"),
1171
+ "latency_ms": result.get("latency_ms")
1172
+ }
1173
+
1174
+ # Se foi processamento assíncrono, incluir informações extras
1175
+ if result.get("async_identifier"):
1176
+ response.update({
1177
+ "async_processing": {
1178
+ "identifier": result.get("async_identifier"),
1179
+ "correlation_id": result.get("correlation_id"),
1180
+ "chunks_downloaded": result.get("chunks_downloaded"),
1181
+ "rows_returned": result.get("rows_returned"),
1182
+ "polling_attempts": result.get("polling_attempts")
1183
+ }
1184
+ })
1185
+
1186
+ return response
1187
+
1188
+ return {
1189
+ "success": False,
1190
+ "message": "❌ Erro ao buscar parcelas dos títulos informados",
1191
+ "error": result.get("error"),
1192
+ "details": result.get("message"),
1193
+ "request_id": result.get("request_id"),
1194
+ "async_info": {
1195
+ "identifier": result.get("async_identifier"),
1196
+ "polling_attempts": result.get("polling_attempts")
1197
+ } if result.get("async_identifier") else None
1198
+ }
1199
+
1200
+
1201
+ @mcp.tool
1202
+ async def get_sienge_bills(
1203
+ start_date: Optional[str] = None,
1204
+ end_date: Optional[str] = None,
1205
+ creditor_id: Optional[str] = None,
1206
+ status: Optional[str] = None,
1207
+ limit: Optional[int] = 50,
1208
+ ) -> Dict:
1209
+ """
1210
+ Consulta títulos a pagar (contas a pagar) - REQUER startDate obrigatório
1211
+
1212
+ Args:
1213
+ start_date: Data inicial obrigatória (YYYY-MM-DD) - padrão últimos 30 dias
1214
+ end_date: Data final (YYYY-MM-DD) - padrão hoje
1215
+ creditor_id: ID do credor
1216
+ status: Status do título (ex: open, paid, cancelled)
1217
+ limit: Máximo de registros (padrão: 50, máx: 200)
1218
+ """
1219
+ # Usar serviço interno
1220
+ result = await _svc_get_bills(
1221
+ start_date=start_date,
1222
+ end_date=end_date,
1223
+ creditor_id=creditor_id,
1224
+ status=status,
1225
+ limit=limit or 50
1226
+ )
1227
+
1228
+ if result["success"]:
1229
+ data = result["data"]
1230
+ bills = data.get("results", []) if isinstance(data, dict) else data
1231
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1232
+ total_count = metadata.get("count", len(bills))
1233
+
1234
+ # Aplicar parsing numérico nos valores
1235
+ for bill in bills:
1236
+ if "amount" in bill:
1237
+ bill["amount"] = _parse_numeric_value(bill["amount"])
1238
+ if "paid_amount" in bill:
1239
+ bill["paid_amount"] = _parse_numeric_value(bill["paid_amount"])
1240
+ if "remaining_amount" in bill:
1241
+ bill["remaining_amount"] = _parse_numeric_value(bill["remaining_amount"])
1242
+
1243
+ # Usar datas padrão se não fornecidas
1244
+ actual_start = start_date or (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
1245
+ actual_end = end_date or datetime.now().strftime("%Y-%m-%d")
1246
+
1247
+ return {
1248
+ "success": True,
1249
+ "message": f"✅ Encontrados {len(bills)} títulos a pagar (total: {total_count}) - período: {actual_start} a {actual_end}",
1250
+ "bills": bills,
1251
+ "count": len(bills),
1252
+ "total_count": total_count,
1253
+ "period": {"start_date": actual_start, "end_date": actual_end},
1254
+ "filters_applied": {
1255
+ "start_date": actual_start, "end_date": actual_end,
1256
+ "creditor_id": creditor_id, "status": status, "limit": limit
1257
+ },
1258
+ "request_id": result.get("request_id"),
1259
+ "latency_ms": result.get("latency_ms"),
1260
+ "cache": result.get("cache")
1261
+ }
1262
+
1263
+ return {
1264
+ "success": False,
1265
+ "message": "❌ Erro ao buscar títulos a pagar",
1266
+ "error": result.get("error"),
1267
+ "details": result.get("message"),
1268
+ "request_id": result.get("request_id")
1269
+ }
1270
+
1271
+
1272
+ # ============ COMPRAS ============
1273
+
1274
+
1275
+ @mcp.tool
1276
+ async def get_sienge_purchase_orders(
1277
+ purchase_order_id: Optional[str] = None,
1278
+ status: Optional[str] = None,
1279
+ date_from: Optional[str] = None,
1280
+ limit: Optional[int] = 50,
1281
+ ) -> Dict:
1282
+ """
1283
+ Consulta pedidos de compra
1284
+
1285
+ Args:
1286
+ purchase_order_id: ID específico do pedido
1287
+ status: Status do pedido
1288
+ date_from: Data inicial (YYYY-MM-DD)
1289
+ limit: Máximo de registros
1290
+ """
1291
+ if purchase_order_id:
1292
+ result = await make_sienge_request("GET", f"/purchase-orders/{purchase_order_id}")
1293
+ if result["success"]:
1294
+ return {"success": True, "message": f"✅ Pedido {purchase_order_id} encontrado", "purchase_order": result["data"]}
1295
+ return result
1296
+
1297
+ params = {"limit": min(limit or 50, 200)}
1298
+ if status:
1299
+ params["status"] = status
1300
+ if date_from:
1301
+ params["date_from"] = date_from
1302
+
1303
+ result = await make_sienge_request("GET", "/purchase-orders", params=params)
1304
+
1305
+ if result["success"]:
1306
+ data = result["data"]
1307
+ orders = data.get("results", []) if isinstance(data, dict) else data
1308
+
1309
+ return {
1310
+ "success": True,
1311
+ "message": f"✅ Encontrados {len(orders)} pedidos de compra",
1312
+ "purchase_orders": orders,
1313
+ "count": len(orders),
1314
+ "request_id": result.get("request_id"),
1315
+ "latency_ms": result.get("latency_ms")
1316
+ }
1317
+
1318
+ return {
1319
+ "success": False,
1320
+ "message": "❌ Erro ao buscar pedidos de compra",
1321
+ "error": result.get("error"),
1322
+ "details": result.get("message"),
1323
+ "request_id": result.get("request_id"),
1324
+ "latency_ms": result.get("latency_ms")
1325
+ }
1326
+
1327
+
1328
+ @mcp.tool
1329
+ async def get_sienge_purchase_order_items(purchase_order_id: str) -> Dict:
1330
+ """
1331
+ Consulta itens de um pedido de compra específico
1332
+
1333
+ Args:
1334
+ purchase_order_id: ID do pedido (obrigatório)
1335
+ """
1336
+ result = await make_sienge_request("GET", f"/purchase-orders/{purchase_order_id}/items")
1337
+
1338
+ if result["success"]:
1339
+ data = result["data"]
1340
+ items = data.get("results", []) if isinstance(data, dict) else data
1341
+
1342
+ return {
1343
+ "success": True,
1344
+ "message": f"✅ Encontrados {len(items)} itens no pedido {purchase_order_id}",
1345
+ "purchase_order_id": purchase_order_id,
1346
+ "items": items,
1347
+ "count": len(items),
1348
+ }
1349
+
1350
+ return {
1351
+ "success": False,
1352
+ "message": f"❌ Erro ao buscar itens do pedido {purchase_order_id}",
1353
+ "error": result.get("error"),
1354
+ "details": result.get("message"),
1355
+ }
1356
+
1357
+
1358
+ @mcp.tool
1359
+ async def get_sienge_purchase_requests(purchase_request_id: Optional[str] = None, limit: Optional[int] = 50) -> Dict:
1360
+ """
1361
+ Consulta solicitações de compra
1362
+
1363
+ Args:
1364
+ purchase_request_id: ID específico da solicitação
1365
+ limit: Máximo de registros
1366
+ """
1367
+ if purchase_request_id:
1368
+ result = await make_sienge_request("GET", f"/purchase-requests/{purchase_request_id}")
1369
+ if result["success"]:
1370
+ return {
1371
+ "success": True,
1372
+ "message": f"✅ Solicitação {purchase_request_id} encontrada",
1373
+ "purchase_request": result["data"],
1374
+ }
1375
+ return result
1376
+
1377
+ params = {"limit": min(limit or 50, 200)}
1378
+ result = await make_sienge_request("GET", "/purchase-requests", params=params)
1379
+
1380
+ if result["success"]:
1381
+ data = result["data"]
1382
+ requests = data.get("results", []) if isinstance(data, dict) else data
1383
+
1384
+ return {
1385
+ "success": True,
1386
+ "message": f"✅ Encontradas {len(requests)} solicitações de compra",
1387
+ "purchase_requests": requests,
1388
+ "count": len(requests),
1389
+ }
1390
+
1391
+ return {
1392
+ "success": False,
1393
+ "message": "❌ Erro ao buscar solicitações de compra",
1394
+ "error": result.get("error"),
1395
+ "details": result.get("message"),
1396
+ }
1397
+
1398
+
1399
+ @mcp.tool
1400
+ async def create_sienge_purchase_request(description: str, project_id: str, items: List[Dict[str, Any]]) -> Dict:
1401
+ """
1402
+ Cria nova solicitação de compra
1403
+
1404
+ Args:
1405
+ description: Descrição da solicitação
1406
+ project_id: ID do projeto/obra
1407
+ items: Lista de itens da solicitação
1408
+ """
1409
+ request_data = {
1410
+ "description": description,
1411
+ "project_id": project_id,
1412
+ "items": items,
1413
+ "date": datetime.now().strftime("%Y-%m-%d"),
1414
+ }
1415
+
1416
+ # CORRIGIDO: Normalizar JSON payload
1417
+ json_data = to_camel_json(request_data)
1418
+ result = await make_sienge_request("POST", "/purchase-requests", json_data=json_data)
1419
+
1420
+ if result["success"]:
1421
+ return {
1422
+ "success": True,
1423
+ "message": "✅ Solicitação de compra criada com sucesso",
1424
+ "request_id": result.get("request_id"),
1425
+ "purchase_request_id": result["data"].get("id"),
1426
+ "data": result["data"],
1427
+ "latency_ms": result.get("latency_ms")
1428
+ }
1429
+
1430
+ return {
1431
+ "success": False,
1432
+ "message": "❌ Erro ao criar solicitação de compra",
1433
+ "error": result.get("error"),
1434
+ "details": result.get("message"),
1435
+ "request_id": result.get("request_id")
1436
+ }
1437
+
1438
+
1439
+ # ============ NOTAS FISCAIS DE COMPRA ============
1440
+
1441
+
1442
+ @mcp.tool
1443
+ async def get_sienge_purchase_invoice(sequential_number: int) -> Dict:
1444
+ """
1445
+ Consulta nota fiscal de compra por número sequencial
1446
+
1447
+ Args:
1448
+ sequential_number: Número sequencial da nota fiscal
1449
+ """
1450
+ result = await make_sienge_request("GET", f"/purchase-invoices/{sequential_number}")
1451
+
1452
+ if result["success"]:
1453
+ return {"success": True, "message": f"✅ Nota fiscal {sequential_number} encontrada", "invoice": result["data"]}
1454
+
1455
+ return {
1456
+ "success": False,
1457
+ "message": f"❌ Erro ao buscar nota fiscal {sequential_number}",
1458
+ "error": result.get("error"),
1459
+ "details": result.get("message"),
1460
+ }
1461
+
1462
+
1463
+ @mcp.tool
1464
+ async def get_sienge_purchase_invoice_items(sequential_number: int) -> Dict:
1465
+ """
1466
+ Consulta itens de uma nota fiscal de compra
1467
+
1468
+ Args:
1469
+ sequential_number: Número sequencial da nota fiscal
1470
+ """
1471
+ result = await make_sienge_request("GET", f"/purchase-invoices/{sequential_number}/items")
1472
+
1473
+ if result["success"]:
1474
+ data = result["data"]
1475
+ items = data.get("results", []) if isinstance(data, dict) else data
1476
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1477
+
1478
+ return {
1479
+ "success": True,
1480
+ "message": f"✅ Encontrados {len(items)} itens na nota fiscal {sequential_number}",
1481
+ "items": items,
1482
+ "count": len(items),
1483
+ "metadata": metadata,
1484
+ }
1485
+
1486
+ return {
1487
+ "success": False,
1488
+ "message": f"❌ Erro ao buscar itens da nota fiscal {sequential_number}",
1489
+ "error": result.get("error"),
1490
+ "details": result.get("message"),
1491
+ }
1492
+
1493
+
1494
+ @mcp.tool
1495
+ async def create_sienge_purchase_invoice(
1496
+ document_id: str,
1497
+ number: str,
1498
+ supplier_id: int,
1499
+ company_id: int,
1500
+ movement_type_id: int,
1501
+ movement_date: str,
1502
+ issue_date: str,
1503
+ series: Optional[str] = None,
1504
+ notes: Optional[str] = None,
1505
+ ) -> Dict:
1506
+ """
1507
+ Cadastra uma nova nota fiscal de compra
1508
+
1509
+ Args:
1510
+ document_id: ID do documento (ex: "NF")
1511
+ number: Número da nota fiscal
1512
+ supplier_id: ID do fornecedor
1513
+ company_id: ID da empresa
1514
+ movement_type_id: ID do tipo de movimento
1515
+ movement_date: Data do movimento (YYYY-MM-DD)
1516
+ issue_date: Data de emissão (YYYY-MM-DD)
1517
+ series: Série da nota fiscal (opcional)
1518
+ notes: Observações (opcional)
1519
+ """
1520
+ invoice_data = {
1521
+ "document_id": document_id,
1522
+ "number": number,
1523
+ "supplier_id": supplier_id,
1524
+ "company_id": company_id,
1525
+ "movement_type_id": movement_type_id,
1526
+ "movement_date": movement_date,
1527
+ "issue_date": issue_date,
1528
+ }
1529
+
1530
+ if series:
1531
+ invoice_data["series"] = series
1532
+ if notes:
1533
+ invoice_data["notes"] = notes
1534
+
1535
+ result = await make_sienge_request("POST", "/purchase-invoices", json_data=to_camel_json(invoice_data))
1536
+
1537
+ if result["success"]:
1538
+ return {"success": True, "message": f"✅ Nota fiscal {number} criada com sucesso", "invoice": result["data"]}
1539
+
1540
+ return {
1541
+ "success": False,
1542
+ "message": f"❌ Erro ao criar nota fiscal {number}",
1543
+ "error": result.get("error"),
1544
+ "details": result.get("message"),
1545
+ }
1546
+
1547
+
1548
+ @mcp.tool
1549
+ async def add_items_to_purchase_invoice(
1550
+ sequential_number: int,
1551
+ deliveries_order: List[Dict[str, Any]],
1552
+ copy_notes_purchase_orders: bool = True,
1553
+ copy_notes_resources: bool = False,
1554
+ copy_attachments_purchase_orders: bool = True,
1555
+ ) -> Dict:
1556
+ """
1557
+ Insere itens em uma nota fiscal a partir de entregas de pedidos de compra
1558
+
1559
+ Args:
1560
+ sequential_number: Número sequencial da nota fiscal
1561
+ deliveries_order: Lista de entregas com estrutura:
1562
+ - purchaseOrderId: ID do pedido de compra
1563
+ - itemNumber: Número do item no pedido
1564
+ - deliveryScheduleNumber: Número da programação de entrega
1565
+ - deliveredQuantity: Quantidade entregue
1566
+ - keepBalance: Manter saldo (true/false)
1567
+ copy_notes_purchase_orders: Copiar observações dos pedidos de compra
1568
+ copy_notes_resources: Copiar observações dos recursos
1569
+ copy_attachments_purchase_orders: Copiar anexos dos pedidos de compra
1570
+ """
1571
+ item_data = {
1572
+ "deliveries_order": deliveries_order,
1573
+ "copy_notes_purchase_orders": copy_notes_purchase_orders,
1574
+ "copy_notes_resources": copy_notes_resources,
1575
+ "copy_attachments_purchase_orders": copy_attachments_purchase_orders,
1576
+ }
1577
+
1578
+ result = await make_sienge_request(
1579
+ "POST", f"/purchase-invoices/{sequential_number}/items/purchase-orders/delivery-schedules", json_data=to_camel_json(item_data)
1580
+ )
1581
+
1582
+ if result["success"]:
1583
+ return {
1584
+ "success": True,
1585
+ "message": f"✅ Itens adicionados à nota fiscal {sequential_number} com sucesso",
1586
+ "item": result["data"],
1587
+ }
1588
+
1589
+ return {
1590
+ "success": False,
1591
+ "message": f"❌ Erro ao adicionar itens à nota fiscal {sequential_number}",
1592
+ "error": result.get("error"),
1593
+ "details": result.get("message"),
1594
+ }
1595
+
1596
+
1597
+ @mcp.tool
1598
+ async def get_sienge_purchase_invoices_deliveries_attended(
1599
+ bill_id: Optional[int] = None,
1600
+ sequential_number: Optional[int] = None,
1601
+ purchase_order_id: Optional[int] = None,
1602
+ invoice_item_number: Optional[int] = None,
1603
+ purchase_order_item_number: Optional[int] = None,
1604
+ limit: Optional[int] = 100,
1605
+ offset: Optional[int] = 0,
1606
+ ) -> Dict:
1607
+ """
1608
+ Lista entregas atendidas entre pedidos de compra e notas fiscais
1609
+
1610
+ Args:
1611
+ bill_id: ID do título da nota fiscal
1612
+ sequential_number: Número sequencial da nota fiscal
1613
+ purchase_order_id: ID do pedido de compra
1614
+ invoice_item_number: Número do item da nota fiscal
1615
+ purchase_order_item_number: Número do item do pedido de compra
1616
+ limit: Máximo de registros (padrão: 100, máximo: 200)
1617
+ offset: Deslocamento (padrão: 0)
1618
+ """
1619
+ params = {"limit": min(limit or 100, 200), "offset": offset or 0}
1620
+
1621
+ if bill_id:
1622
+ params["billId"] = bill_id
1623
+ if sequential_number:
1624
+ params["sequentialNumber"] = sequential_number
1625
+ if purchase_order_id:
1626
+ params["purchaseOrderId"] = purchase_order_id
1627
+ if invoice_item_number:
1628
+ params["invoiceItemNumber"] = invoice_item_number
1629
+ if purchase_order_item_number:
1630
+ params["purchaseOrderItemNumber"] = purchase_order_item_number
1631
+
1632
+ result = await make_sienge_request("GET", "/purchase-invoices/deliveries-attended", params=params)
1633
+
1634
+ if result["success"]:
1635
+ data = result["data"]
1636
+ deliveries = data.get("results", []) if isinstance(data, dict) else data
1637
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1638
+
1639
+ return {
1640
+ "success": True,
1641
+ "message": f"✅ Encontradas {len(deliveries)} entregas atendidas",
1642
+ "deliveries": deliveries,
1643
+ "count": len(deliveries),
1644
+ "metadata": metadata,
1645
+ "filters": params,
1646
+ }
1647
+
1648
+ return {
1649
+ "success": False,
1650
+ "message": "❌ Erro ao buscar entregas atendidas",
1651
+ "error": result.get("error"),
1652
+ "details": result.get("message"),
1653
+ }
1654
+
1655
+
1656
+ # ============ ESTOQUE ============
1657
+
1658
+
1659
+ @mcp.tool
1660
+ async def get_sienge_stock_inventory(cost_center_id: str, resource_id: Optional[str] = None) -> Dict:
1661
+ """
1662
+ Consulta inventário de estoque por centro de custo
1663
+
1664
+ Args:
1665
+ cost_center_id: ID do centro de custo (obrigatório)
1666
+ resource_id: ID do insumo específico (opcional)
1667
+ """
1668
+ if resource_id:
1669
+ endpoint = f"/stock-inventories/{cost_center_id}/items/{resource_id}"
1670
+ else:
1671
+ endpoint = f"/stock-inventories/{cost_center_id}/items"
1672
+
1673
+ result = await make_sienge_request("GET", endpoint)
1674
+
1675
+ if result["success"]:
1676
+ data = result["data"]
1677
+ items = data.get("results", []) if isinstance(data, dict) else data
1678
+ count = len(items) if isinstance(items, list) else 1
1679
+
1680
+ return {
1681
+ "success": True,
1682
+ "message": f"✅ Inventário do centro de custo {cost_center_id}",
1683
+ "cost_center_id": cost_center_id,
1684
+ "inventory": items,
1685
+ "count": count,
1686
+ }
1687
+
1688
+ return {
1689
+ "success": False,
1690
+ "message": f"❌ Erro ao consultar estoque do centro {cost_center_id}",
1691
+ "error": result.get("error"),
1692
+ "details": result.get("message"),
1693
+ }
1694
+
1695
+
1696
+ @mcp.tool
1697
+ async def get_sienge_stock_reservations(limit: Optional[int] = 50) -> Dict:
1698
+ """
1699
+ Lista reservas de estoque
1700
+
1701
+ Args:
1702
+ limit: Máximo de registros
1703
+ """
1704
+ params = {"limit": min(limit or 50, 200)}
1705
+ result = await make_sienge_request("GET", "/stock-reservations", params=params)
1706
+
1707
+ if result["success"]:
1708
+ data = result["data"]
1709
+ reservations = data.get("results", []) if isinstance(data, dict) else data
1710
+
1711
+ return {
1712
+ "success": True,
1713
+ "message": f"✅ Encontradas {len(reservations)} reservas de estoque",
1714
+ "reservations": reservations,
1715
+ "count": len(reservations),
1716
+ }
1717
+
1718
+ return {
1719
+ "success": False,
1720
+ "message": "❌ Erro ao buscar reservas de estoque",
1721
+ "error": result.get("error"),
1722
+ "details": result.get("message"),
1723
+ }
1724
+
1725
+
1726
+ # ============ PROJETOS/OBRAS ============
1727
+
1728
+
1729
+ @mcp.tool
1730
+ async def get_sienge_projects(
1731
+ limit: Optional[int] = 100,
1732
+ offset: Optional[int] = 0,
1733
+ company_id: Optional[int] = None,
1734
+ enterprise_type: Optional[int] = None,
1735
+ receivable_register: Optional[str] = None,
1736
+ only_buildings_enabled: Optional[bool] = False,
1737
+ ) -> Dict:
1738
+ """
1739
+ Busca empreendimentos/obras no Sienge
1740
+ CORRIGIDO: Mapeamento correto da chave de resposta
1741
+
1742
+ Args:
1743
+ limit: Máximo de registros (padrão: 100, máximo: 200)
1744
+ offset: Pular registros (padrão: 0)
1745
+ company_id: Código da empresa
1746
+ enterprise_type: Tipo do empreendimento (1: Obra e Centro de custo, 2: Obra, 3: Centro de custo, 4: Centro de custo associado a obra)
1747
+ receivable_register: Filtro de registro de recebíveis (B3, CERC)
1748
+ only_buildings_enabled: Retornar apenas obras habilitadas para integração orçamentária
1749
+ """
1750
+ # Usar serviço interno
1751
+ result = await _svc_get_projects(
1752
+ limit=limit or 100,
1753
+ offset=offset or 0,
1754
+ company_id=company_id,
1755
+ enterprise_type=enterprise_type,
1756
+ receivable_register=receivable_register,
1757
+ only_buildings_enabled=only_buildings_enabled or False
1758
+ )
1759
+
1760
+ if result["success"]:
1761
+ data = result["data"]
1762
+ # CORREÇÃO: API retorna em "results", mas paginador espera em "enterprises"
1763
+ enterprises = data.get("results", []) if isinstance(data, dict) else data
1764
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1765
+ total_count = metadata.get("count", len(enterprises))
1766
+
1767
+ return {
1768
+ "success": True,
1769
+ "message": f"✅ Encontrados {len(enterprises)} empreendimentos (total: {total_count})",
1770
+ "enterprises": enterprises, # Manter consistência para paginador
1771
+ "projects": enterprises, # Alias para compatibilidade
1772
+ "count": len(enterprises),
1773
+ "total_count": total_count,
1774
+ "metadata": metadata,
1775
+ "filters_applied": {
1776
+ "limit": limit, "offset": offset, "company_id": company_id,
1777
+ "enterprise_type": enterprise_type, "receivable_register": receivable_register,
1778
+ "only_buildings_enabled": only_buildings_enabled
1779
+ },
1780
+ "request_id": result.get("request_id"),
1781
+ "latency_ms": result.get("latency_ms"),
1782
+ "cache": result.get("cache")
1783
+ }
1784
+
1785
+ return {
1786
+ "success": False,
1787
+ "message": "❌ Erro ao buscar empreendimentos",
1788
+ "error": result.get("error"),
1789
+ "details": result.get("message"),
1790
+ "request_id": result.get("request_id")
1791
+ }
1792
+
1793
+
1794
+ @mcp.tool
1795
+ async def get_sienge_enterprise_by_id(enterprise_id: int) -> Dict:
1796
+ """
1797
+ Busca um empreendimento específico por ID no Sienge
1798
+
1799
+ Args:
1800
+ enterprise_id: ID do empreendimento
1801
+ """
1802
+ result = await make_sienge_request("GET", f"/enterprises/{enterprise_id}")
1803
+
1804
+ if result["success"]:
1805
+ return {"success": True, "message": f"✅ Empreendimento {enterprise_id} encontrado", "enterprise": result["data"]}
1806
+
1807
+ return {
1808
+ "success": False,
1809
+ "message": f"❌ Erro ao buscar empreendimento {enterprise_id}",
1810
+ "error": result.get("error"),
1811
+ "details": result.get("message"),
1812
+ }
1813
+
1814
+
1815
+ @mcp.tool
1816
+ async def get_sienge_enterprise_groupings(enterprise_id: int) -> Dict:
1817
+ """
1818
+ Busca agrupamentos de unidades de um empreendimento específico
1819
+
1820
+ Args:
1821
+ enterprise_id: ID do empreendimento
1822
+ """
1823
+ result = await make_sienge_request("GET", f"/enterprises/{enterprise_id}/groupings")
1824
+
1825
+ if result["success"]:
1826
+ groupings = result["data"]
1827
+ return {
1828
+ "success": True,
1829
+ "message": f"✅ Agrupamentos do empreendimento {enterprise_id} encontrados",
1830
+ "groupings": groupings,
1831
+ "count": len(groupings) if isinstance(groupings, list) else 0,
1832
+ }
1833
+
1834
+ return {
1835
+ "success": False,
1836
+ "message": f"❌ Erro ao buscar agrupamentos do empreendimento {enterprise_id}",
1837
+ "error": result.get("error"),
1838
+ "details": result.get("message"),
1839
+ }
1840
+
1841
+
1842
+ @mcp.tool
1843
+ async def get_sienge_units(limit: Optional[int] = 50, offset: Optional[int] = 0) -> Dict:
1844
+ """
1845
+ Consulta unidades cadastradas no Sienge
1846
+
1847
+ Args:
1848
+ limit: Máximo de registros (padrão: 50)
1849
+ offset: Pular registros (padrão: 0)
1850
+ """
1851
+ params = {"limit": min(limit or 50, 200), "offset": offset or 0}
1852
+ result = await make_sienge_request("GET", "/units", params=params)
1853
+
1854
+ if result["success"]:
1855
+ data = result["data"]
1856
+ units = data.get("results", []) if isinstance(data, dict) else data
1857
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1858
+ total_count = metadata.get("count", len(units))
1859
+
1860
+ return {
1861
+ "success": True,
1862
+ "message": f"✅ Encontradas {len(units)} unidades (total: {total_count})",
1863
+ "units": units,
1864
+ "count": len(units),
1865
+ "total_count": total_count,
1866
+ "request_id": result.get("request_id"),
1867
+ "latency_ms": result.get("latency_ms")
1868
+ }
1869
+
1870
+ return {
1871
+ "success": False,
1872
+ "message": "❌ Erro ao buscar unidades",
1873
+ "error": result.get("error"),
1874
+ "details": result.get("message"),
1875
+ "request_id": result.get("request_id"),
1876
+ "latency_ms": result.get("latency_ms")
1877
+ }
1878
+
1879
+
1880
+ # ============ CUSTOS ============
1881
+
1882
+
1883
+ @mcp.tool
1884
+ async def get_sienge_unit_cost_tables(
1885
+ table_code: Optional[str] = None,
1886
+ description: Optional[str] = None,
1887
+ status: Optional[str] = "Active",
1888
+ integration_enabled: Optional[bool] = None,
1889
+ ) -> Dict:
1890
+ """
1891
+ Consulta tabelas de custos unitários
1892
+
1893
+ Args:
1894
+ table_code: Código da tabela (opcional)
1895
+ description: Descrição da tabela (opcional)
1896
+ status: Status (Active/Inactive)
1897
+ integration_enabled: Se habilitada para integração
1898
+ """
1899
+ params = {"status": status or "Active"}
1900
+
1901
+ if table_code:
1902
+ params["table_code"] = table_code
1903
+ if description:
1904
+ params["description"] = description
1905
+ if integration_enabled is not None:
1906
+ params["integration_enabled"] = integration_enabled
1907
+
1908
+ result = await make_sienge_request("GET", "/unit-cost-tables", params=params)
1909
+
1910
+ if result["success"]:
1911
+ data = result["data"]
1912
+ tables = data.get("results", []) if isinstance(data, dict) else data
1913
+
1914
+ return {
1915
+ "success": True,
1916
+ "message": f"✅ Encontradas {len(tables)} tabelas de custos",
1917
+ "cost_tables": tables,
1918
+ "count": len(tables),
1919
+ }
1920
+
1921
+ return {
1922
+ "success": False,
1923
+ "message": "❌ Erro ao buscar tabelas de custos",
1924
+ "error": result.get("error"),
1925
+ "details": result.get("message"),
1926
+ }
1927
+
1928
+
1929
+ # ============ SEARCH UNIVERSAL (COMPATIBILIDADE CHATGPT) ============
1930
+
1931
+
1932
+ @mcp.tool
1933
+ async def search_sienge_data(
1934
+ query: str,
1935
+ entity_type: Optional[str] = None,
1936
+ limit: Optional[int] = 20,
1937
+ filters: Optional[Dict[str, Any]] = None
1938
+ ) -> Dict:
1939
+ """
1940
+ Busca universal no Sienge - compatível com ChatGPT/OpenAI MCP
1941
+
1942
+ Permite buscar em múltiplas entidades do Sienge de forma unificada.
1943
+
1944
+ Args:
1945
+ query: Termo de busca (nome, código, descrição, etc.)
1946
+ entity_type: Tipo de entidade (customers, creditors, projects, bills, purchase_orders, etc.)
1947
+ limit: Máximo de registros (padrão: 20, máximo: 100)
1948
+ filters: Filtros específicos por tipo de entidade
1949
+ """
1950
+ search_results = []
1951
+ limit = min(limit or 20, 100)
1952
+
1953
+ # Se entity_type específico, buscar apenas nele
1954
+ if entity_type:
1955
+ result = await _search_specific_entity(entity_type, query, limit, filters or {})
1956
+ if result["success"]:
1957
+ return result
1958
+ else:
1959
+ return {
1960
+ "success": False,
1961
+ "message": f"❌ Erro na busca em {entity_type}",
1962
+ "error": result.get("error"),
1963
+ "query": query,
1964
+ "entity_type": entity_type
1965
+ }
1966
+
1967
+ # Busca universal em múltiplas entidades
1968
+ entities_to_search = [
1969
+ ("customers", "clientes"),
1970
+ ("creditors", "credores/fornecedores"),
1971
+ ("projects", "empreendimentos/obras"),
1972
+ ("bills", "títulos a pagar"),
1973
+ ("purchase_orders", "pedidos de compra")
1974
+ ]
1975
+
1976
+ total_found = 0
1977
+
1978
+ for entity_key, entity_name in entities_to_search:
1979
+ try:
1980
+ entity_result = await _search_specific_entity(entity_key, query, min(5, limit), {})
1981
+ if entity_result["success"] and entity_result.get("count", 0) > 0:
1982
+ search_results.append({
1983
+ "entity_type": entity_key,
1984
+ "entity_name": entity_name,
1985
+ "count": entity_result["count"],
1986
+ "results": entity_result["data"][:5], # Limitar a 5 por entidade na busca universal
1987
+ "has_more": entity_result["count"] > 5
1988
+ })
1989
+ total_found += entity_result["count"]
1990
+ except Exception as e:
1991
+ # Continuar com outras entidades se uma falhar
1992
+ continue
1993
+
1994
+ if search_results:
1995
+ return {
1996
+ "success": True,
1997
+ "message": f"✅ Busca '{query}' encontrou resultados em {len(search_results)} entidades (total: {total_found} registros)",
1998
+ "query": query,
1999
+ "total_entities": len(search_results),
2000
+ "total_records": total_found,
2001
+ "results_by_entity": search_results,
2002
+ "suggestion": "Use entity_type para buscar especificamente em uma entidade e obter mais resultados"
2003
+ }
2004
+ else:
2005
+ return {
2006
+ "success": False,
2007
+ "message": f"❌ Nenhum resultado encontrado para '{query}'",
2008
+ "query": query,
2009
+ "searched_entities": [name for _, name in entities_to_search],
2010
+ "suggestion": "Tente termos mais específicos ou use os tools específicos de cada entidade"
2011
+ }
2012
+
2013
+
2014
+ async def _search_specific_entity(entity_type: str, query: str, limit: int, filters: Dict) -> Dict:
2015
+ """
2016
+ Função auxiliar para buscar em uma entidade específica
2017
+ CORRIGIDO: Usa serviços internos, nunca outras tools
2018
+ """
2019
+
2020
+ if entity_type == "customers":
2021
+ result = await _svc_get_customers(limit=limit, search=query)
2022
+ if result["success"]:
2023
+ data = result["data"]
2024
+ customers = data.get("results", []) if isinstance(data, dict) else data
2025
+ return {
2026
+ "success": True,
2027
+ "data": customers,
2028
+ "count": len(customers),
2029
+ "entity_type": "customers"
2030
+ }
2031
+
2032
+ elif entity_type == "creditors":
2033
+ result = await _svc_get_creditors(limit=limit, search=query)
2034
+ if result["success"]:
2035
+ data = result["data"]
2036
+ creditors = data.get("results", []) if isinstance(data, dict) else data
2037
+ return {
2038
+ "success": True,
2039
+ "data": creditors,
2040
+ "count": len(creditors),
2041
+ "entity_type": "creditors"
2042
+ }
2043
+
2044
+ elif entity_type == "projects" or entity_type == "enterprises":
2045
+ # Para projetos, usar filtros mais específicos se disponível
2046
+ company_id = filters.get("company_id")
2047
+ result = await _svc_get_projects(limit=limit, company_id=company_id)
2048
+ if result["success"]:
2049
+ data = result["data"]
2050
+ projects = data.get("results", []) if isinstance(data, dict) else data
2051
+
2052
+ # Filtrar por query se fornecida
2053
+ if query:
2054
+ projects = [
2055
+ p for p in projects
2056
+ if query.lower() in str(p.get("description", "")).lower()
2057
+ or query.lower() in str(p.get("name", "")).lower()
2058
+ or query.lower() in str(p.get("code", "")).lower()
2059
+ ]
2060
+ return {
2061
+ "success": True,
2062
+ "data": projects,
2063
+ "count": len(projects),
2064
+ "entity_type": "projects"
2065
+ }
2066
+
2067
+ elif entity_type == "bills":
2068
+ # Para títulos, usar data padrão se não especificada
2069
+ start_date = filters.get("start_date")
2070
+ end_date = filters.get("end_date")
2071
+ result = await _svc_get_bills(
2072
+ start_date=start_date,
2073
+ end_date=end_date,
2074
+ limit=limit
2075
+ )
2076
+ if result["success"]:
2077
+ data = result["data"]
2078
+ bills = data.get("results", []) if isinstance(data, dict) else data
2079
+ return {
2080
+ "success": True,
2081
+ "data": bills,
2082
+ "count": len(bills),
2083
+ "entity_type": "bills"
2084
+ }
2085
+
2086
+ elif entity_type == "purchase_orders":
2087
+ result = await _svc_get_purchase_orders(limit=limit)
2088
+ if result["success"]:
2089
+ data = result["data"]
2090
+ orders = data.get("results", []) if isinstance(data, dict) else data
2091
+
2092
+ # Filtrar por query se fornecida
2093
+ if query:
2094
+ orders = [
2095
+ o for o in orders
2096
+ if query.lower() in str(o.get("description", "")).lower()
2097
+ or query.lower() in str(o.get("id", "")).lower()
2098
+ ]
2099
+ return {
2100
+ "success": True,
2101
+ "data": orders,
2102
+ "count": len(orders),
2103
+ "entity_type": "purchase_orders"
2104
+ }
2105
+
2106
+ # Se chegou aqui, entidade não suportada ou erro
2107
+ return {
2108
+ "success": False,
2109
+ "error": f"Entidade '{entity_type}' não suportada ou erro na busca",
2110
+ "supported_entities": ["customers", "creditors", "projects", "bills", "purchase_orders"]
2111
+ }
2112
+
2113
+
2114
+ @mcp.tool
2115
+ async def list_sienge_entities() -> Dict:
2116
+ """
2117
+ Lista todas as entidades disponíveis no Sienge MCP para busca
2118
+
2119
+ Retorna informações sobre os tipos de dados que podem ser consultados
2120
+ """
2121
+ entities = [
2122
+ {
2123
+ "type": "customers",
2124
+ "name": "Clientes",
2125
+ "description": "Clientes cadastrados no sistema",
2126
+ "search_fields": ["nome", "documento", "email"],
2127
+ "tools": ["get_sienge_customers", "search_sienge_data"]
2128
+ },
2129
+ {
2130
+ "type": "creditors",
2131
+ "name": "Credores/Fornecedores",
2132
+ "description": "Fornecedores e credores cadastrados",
2133
+ "search_fields": ["nome", "documento"],
2134
+ "tools": ["get_sienge_creditors", "get_sienge_creditor_bank_info", "get_sienge_suppliers"]
2135
+ },
2136
+ {
2137
+ "type": "projects",
2138
+ "name": "Empreendimentos/Obras",
2139
+ "description": "Projetos e obras cadastrados",
2140
+ "search_fields": ["código", "descrição", "nome"],
2141
+ "tools": ["get_sienge_projects", "get_sienge_enterprises", "get_sienge_enterprise_by_id"]
2142
+ },
2143
+ {
2144
+ "type": "bills",
2145
+ "name": "Títulos a Pagar",
2146
+ "description": "Contas a pagar e títulos financeiros",
2147
+ "search_fields": ["número", "credor", "valor"],
2148
+ "tools": ["get_sienge_bills", "get_sienge_accounts_payable"]
2149
+ },
2150
+ {
2151
+ "type": "purchase_orders",
2152
+ "name": "Pedidos de Compra",
2153
+ "description": "Pedidos de compra e solicitações",
2154
+ "search_fields": ["id", "descrição", "status"],
2155
+ "tools": ["get_sienge_purchase_orders", "get_sienge_purchase_requests", "list_sienge_purchase_requests"]
2156
+ },
2157
+ {
2158
+ "type": "invoices",
2159
+ "name": "Notas Fiscais",
2160
+ "description": "Notas fiscais de compra",
2161
+ "search_fields": ["número", "série", "fornecedor"],
2162
+ "tools": ["get_sienge_purchase_invoice"]
2163
+ },
2164
+ {
2165
+ "type": "stock",
2166
+ "name": "Estoque",
2167
+ "description": "Inventário e movimentações de estoque",
2168
+ "search_fields": ["centro_custo", "recurso"],
2169
+ "tools": ["get_sienge_stock_inventory", "get_sienge_stock_reservations"]
2170
+ },
2171
+ {
2172
+ "type": "financial",
2173
+ "name": "Financeiro",
2174
+ "description": "Contas a receber e movimentações financeiras",
2175
+ "search_fields": ["período", "cliente", "valor"],
2176
+ "tools": ["get_sienge_accounts_receivable", "search_sienge_financial_data", "search_sienge_finances"]
2177
+ },
2178
+ {
2179
+ "type": "suppliers",
2180
+ "name": "Fornecedores",
2181
+ "description": "Fornecedores e credores cadastrados",
2182
+ "search_fields": ["código", "nome", "razão social"],
2183
+ "tools": ["get_sienge_suppliers"]
2184
+ }
2185
+ ]
2186
+
2187
+ return {
2188
+ "success": True,
2189
+ "message": f"✅ {len(entities)} tipos de entidades disponíveis no Sienge",
2190
+ "entities": entities,
2191
+ "total_tools": sum(len(e["tools"]) for e in entities),
2192
+ "usage_example": {
2193
+ "search_all": "search_sienge_data('nome_cliente')",
2194
+ "search_specific": "search_sienge_data('nome_cliente', entity_type='customers')",
2195
+ "direct_access": "get_sienge_customers(search='nome_cliente')"
2196
+ }
2197
+ }
2198
+
2199
+
2200
+ # ============ PAGINATION E NAVEGAÇÃO ============
2201
+
2202
+
2203
+ @mcp.tool
2204
+ async def get_sienge_data_paginated(
2205
+ entity_type: str,
2206
+ page: int = 1,
2207
+ page_size: int = 20,
2208
+ filters: Optional[Dict[str, Any]] = None,
2209
+ sort_by: Optional[str] = None
2210
+ ) -> Dict:
2211
+ """
2212
+ Busca dados do Sienge com paginação avançada - compatível com ChatGPT
2213
+
2214
+ Args:
2215
+ entity_type: Tipo de entidade (customers, creditors, projects, bills, etc.)
2216
+ page: Número da página (começando em 1)
2217
+ page_size: Registros por página (máximo 50)
2218
+ filters: Filtros específicos da entidade
2219
+ sort_by: Campo para ordenação (se suportado)
2220
+ """
2221
+ page_size = min(page_size, 50)
2222
+ offset = (page - 1) * page_size
2223
+
2224
+ filters = filters or {}
2225
+
2226
+ # CORRIGIDO: Mapear para serviços internos, não tools
2227
+ if entity_type == "customers":
2228
+ search = filters.get("search")
2229
+ customer_type_id = filters.get("customer_type_id")
2230
+ result = await _svc_get_customers(
2231
+ limit=page_size,
2232
+ offset=offset,
2233
+ search=search,
2234
+ customer_type_id=customer_type_id
2235
+ )
2236
+ # CORRIGIDO: Extrair items e total corretamente
2237
+ if result["success"]:
2238
+ data = result["data"]
2239
+ items, total = _extract_items_and_total(data)
2240
+ result["customers"] = items
2241
+ result["count"] = len(items)
2242
+ result["total_count"] = total
2243
+
2244
+ elif entity_type == "creditors":
2245
+ search = filters.get("search")
2246
+ result = await _svc_get_creditors(
2247
+ limit=page_size,
2248
+ offset=offset,
2249
+ search=search
2250
+ )
2251
+ # CORRIGIDO: Extrair items e total corretamente
2252
+ if result["success"]:
2253
+ data = result["data"]
2254
+ items, total = _extract_items_and_total(data)
2255
+ result["creditors"] = items
2256
+ result["count"] = len(items)
2257
+ result["total_count"] = total
2258
+
2259
+ elif entity_type == "projects":
2260
+ result = await _svc_get_projects(
2261
+ limit=page_size,
2262
+ offset=offset,
2263
+ company_id=filters.get("company_id"),
2264
+ enterprise_type=filters.get("enterprise_type")
2265
+ )
2266
+ # CORRIGIDO: Extrair items e total corretamente
2267
+ if result["success"]:
2268
+ data = result["data"]
2269
+ items, total = _extract_items_and_total(data)
2270
+ result["projects"] = items
2271
+ result["enterprises"] = items # Para compatibilidade
2272
+ result["count"] = len(items)
2273
+ result["total_count"] = total
2274
+
2275
+ elif entity_type == "bills":
2276
+ result = await _svc_get_bills(
2277
+ start_date=filters.get("start_date"),
2278
+ end_date=filters.get("end_date"),
2279
+ creditor_id=filters.get("creditor_id"),
2280
+ status=filters.get("status"),
2281
+ limit=page_size
2282
+ )
2283
+ # CORRIGIDO: Extrair items e total corretamente
2284
+ if result["success"]:
2285
+ data = result["data"]
2286
+ items, total = _extract_items_and_total(data)
2287
+ result["bills"] = items
2288
+ result["count"] = len(items)
2289
+ result["total_count"] = total
2290
+
2291
+ else:
2292
+ return {
2293
+ "success": False,
2294
+ "message": f"❌ Tipo de entidade '{entity_type}' não suportado para paginação",
2295
+ "supported_types": ["customers", "creditors", "projects", "bills"]
2296
+ }
2297
+
2298
+ if result["success"]:
2299
+ # Calcular informações de paginação
2300
+ total_count = result.get("total_count", result.get("count", 0))
2301
+ total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
2302
+
2303
+ return {
2304
+ "success": True,
2305
+ "message": f"✅ Página {page} de {total_pages} - {entity_type}",
2306
+ "data": result.get(entity_type, result.get("data", [])),
2307
+ "pagination": {
2308
+ "current_page": page,
2309
+ "page_size": page_size,
2310
+ "total_pages": total_pages,
2311
+ "total_records": total_count,
2312
+ "has_next": page < total_pages,
2313
+ "has_previous": page > 1,
2314
+ "next_page": page + 1 if page < total_pages else None,
2315
+ "previous_page": page - 1 if page > 1 else None
2316
+ },
2317
+ "entity_type": entity_type,
2318
+ "filters_applied": filters
2319
+ }
2320
+
2321
+ return result
2322
+
2323
+
2324
+ @mcp.tool
2325
+ async def search_sienge_financial_data(
2326
+ period_start: str,
2327
+ period_end: str,
2328
+ search_type: str = "both",
2329
+ amount_min: Optional[float] = None,
2330
+ amount_max: Optional[float] = None,
2331
+ customer_creditor_search: Optional[str] = None
2332
+ ) -> Dict:
2333
+ """
2334
+ Busca avançada em dados financeiros do Sienge - Contas a Pagar e Receber
2335
+
2336
+ Args:
2337
+ period_start: Data inicial do período (YYYY-MM-DD)
2338
+ period_end: Data final do período (YYYY-MM-DD)
2339
+ search_type: Tipo de busca ("receivable", "payable", "both")
2340
+ amount_min: Valor mínimo (opcional)
2341
+ amount_max: Valor máximo (opcional)
2342
+ customer_creditor_search: Buscar por nome de cliente/credor (opcional)
2343
+ """
2344
+
2345
+ financial_results = {
2346
+ "receivable": {"success": False, "data": [], "count": 0, "error": None},
2347
+ "payable": {"success": False, "data": [], "count": 0, "error": None}
2348
+ }
2349
+
2350
+ # Buscar contas a receber
2351
+ if search_type in ["receivable", "both"]:
2352
+ try:
2353
+ # CORRIGIDO: Usar serviço interno
2354
+ receivable_result = await _svc_get_accounts_receivable(
2355
+ start_date=period_start,
2356
+ end_date=period_end,
2357
+ selection_type="D" # Por vencimento
2358
+ )
2359
+
2360
+ if receivable_result["success"]:
2361
+ receivable_data = receivable_result.get("data", [])
2362
+
2363
+ # CORRIGIDO: Aplicar filtros de valor usando _parse_numeric_value
2364
+ if amount_min is not None or amount_max is not None:
2365
+ filtered_data = []
2366
+ for item in receivable_data:
2367
+ amount = _parse_numeric_value(item.get("amount", 0))
2368
+ if amount_min is not None and amount < amount_min:
2369
+ continue
2370
+ if amount_max is not None and amount > amount_max:
2371
+ continue
2372
+ filtered_data.append(item)
2373
+ receivable_data = filtered_data
2374
+
2375
+ # Aplicar filtro de cliente se especificado
2376
+ if customer_creditor_search:
2377
+ search_lower = customer_creditor_search.lower()
2378
+ filtered_data = []
2379
+ for item in receivable_data:
2380
+ customer_name = str(item.get("customer_name", "")).lower()
2381
+ if search_lower in customer_name:
2382
+ filtered_data.append(item)
2383
+ receivable_data = filtered_data
2384
+
2385
+ financial_results["receivable"] = {
2386
+ "success": True,
2387
+ "data": receivable_data,
2388
+ "count": len(receivable_data),
2389
+ "error": None
2390
+ }
2391
+ else:
2392
+ financial_results["receivable"]["error"] = receivable_result.get("error")
2393
+
2394
+ except Exception as e:
2395
+ financial_results["receivable"]["error"] = str(e)
2396
+
2397
+ # Buscar contas a pagar
2398
+ if search_type in ["payable", "both"]:
2399
+ try:
2400
+ # CORRIGIDO: Usar serviço interno
2401
+ payable_result = await _svc_get_bills(
2402
+ start_date=period_start,
2403
+ end_date=period_end,
2404
+ limit=100
2405
+ )
2406
+
2407
+ if payable_result["success"]:
2408
+ data = payable_result["data"]
2409
+ payable_data = data.get("results", []) if isinstance(data, dict) else data
2410
+
2411
+ # CORRIGIDO: Aplicar filtros de valor usando _parse_numeric_value
2412
+ if amount_min is not None or amount_max is not None:
2413
+ filtered_data = []
2414
+ for item in payable_data:
2415
+ amount = _parse_numeric_value(item.get("amount", 0))
2416
+ if amount_min is not None and amount < amount_min:
2417
+ continue
2418
+ if amount_max is not None and amount > amount_max:
2419
+ continue
2420
+ filtered_data.append(item)
2421
+ payable_data = filtered_data
2422
+
2423
+ # Aplicar filtro de credor se especificado
2424
+ if customer_creditor_search:
2425
+ search_lower = customer_creditor_search.lower()
2426
+ filtered_data = []
2427
+ for item in payable_data:
2428
+ creditor_name = str(item.get("creditor_name", "")).lower()
2429
+ if search_lower in creditor_name:
2430
+ filtered_data.append(item)
2431
+ payable_data = filtered_data
2432
+
2433
+ financial_results["payable"] = {
2434
+ "success": True,
2435
+ "data": payable_data,
2436
+ "count": len(payable_data),
2437
+ "error": None
2438
+ }
2439
+ else:
2440
+ financial_results["payable"]["error"] = payable_result.get("error")
2441
+
2442
+ except Exception as e:
2443
+ financial_results["payable"]["error"] = str(e)
2444
+
2445
+ # Compilar resultado final
2446
+ total_records = financial_results["receivable"]["count"] + financial_results["payable"]["count"]
2447
+ has_errors = bool(financial_results["receivable"]["error"] or financial_results["payable"]["error"])
2448
+
2449
+ summary = {
2450
+ "period": f"{period_start} a {period_end}",
2451
+ "search_type": search_type,
2452
+ "total_records": total_records,
2453
+ "receivable_count": financial_results["receivable"]["count"],
2454
+ "payable_count": financial_results["payable"]["count"],
2455
+ "filters_applied": {
2456
+ "amount_range": f"{amount_min or 'sem mín'} - {amount_max or 'sem máx'}",
2457
+ "customer_creditor": customer_creditor_search or "todos"
2458
+ }
2459
+ }
2460
+
2461
+ if total_records > 0:
2462
+ return {
2463
+ "success": True,
2464
+ "message": f"✅ Busca financeira encontrou {total_records} registros no período",
2465
+ "summary": summary,
2466
+ "receivable": financial_results["receivable"],
2467
+ "payable": financial_results["payable"],
2468
+ "has_errors": has_errors
2469
+ }
2470
+ else:
2471
+ return {
2472
+ "success": False,
2473
+ "message": f"❌ Nenhum registro financeiro encontrado no período {period_start} a {period_end}",
2474
+ "summary": summary,
2475
+ "errors": {
2476
+ "receivable": financial_results["receivable"]["error"],
2477
+ "payable": financial_results["payable"]["error"]
2478
+ }
2479
+ }
2480
+
2481
+
2482
+ @mcp.tool
2483
+ async def get_sienge_dashboard_summary() -> Dict:
2484
+ """
2485
+ Obtém um resumo tipo dashboard com informações gerais do Sienge
2486
+ Útil para visão geral rápida do sistema
2487
+ """
2488
+
2489
+ # Data atual e períodos
2490
+ today = datetime.now()
2491
+ current_month_start = today.replace(day=1).strftime("%Y-%m-%d")
2492
+ current_month_end = today.strftime("%Y-%m-%d")
2493
+
2494
+ dashboard_data = {}
2495
+ errors = []
2496
+
2497
+ # 1. Testar conexão
2498
+ try:
2499
+ connection_test = await test_sienge_connection()
2500
+ dashboard_data["connection"] = connection_test
2501
+ except Exception as e:
2502
+ errors.append(f"Teste de conexão: {str(e)}")
2503
+ dashboard_data["connection"] = {"success": False, "error": str(e)}
2504
+
2505
+ # 2. Contar clientes (amostra)
2506
+ try:
2507
+ # CORRIGIDO: Usar serviço interno
2508
+ customers_result = await _svc_get_customers(limit=1)
2509
+ dashboard_data["customers"] = {"available": customers_result["success"]}
2510
+ except Exception as e:
2511
+ errors.append(f"Clientes: {str(e)}")
2512
+ dashboard_data["customers"] = {"available": False}
2513
+
2514
+ # 3. Contar projetos (amostra)
2515
+ try:
2516
+ # CORRIGIDO: Usar serviço interno
2517
+ projects_result = await _svc_get_projects(limit=5)
2518
+ if projects_result["success"]:
2519
+ data = projects_result["data"]
2520
+ enterprises = data.get("results", []) if isinstance(data, dict) else data
2521
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
2522
+
2523
+ dashboard_data["projects"] = {
2524
+ "available": True,
2525
+ "sample_count": len(enterprises),
2526
+ "total_count": metadata.get("count", "N/A")
2527
+ }
2528
+ else:
2529
+ dashboard_data["projects"] = {"available": False}
2530
+ except Exception as e:
2531
+ errors.append(f"Projetos: {str(e)}")
2532
+ dashboard_data["projects"] = {"available": False, "error": str(e)}
2533
+
2534
+ # 4. Títulos a pagar do mês atual
2535
+ try:
2536
+ # CORRIGIDO: Usar serviço interno
2537
+ bills_result = await _svc_get_bills(
2538
+ start_date=current_month_start,
2539
+ end_date=current_month_end,
2540
+ limit=10
2541
+ )
2542
+ if bills_result["success"]:
2543
+ data = bills_result["data"]
2544
+ bills = data.get("results", []) if isinstance(data, dict) else data
2545
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
2546
+
2547
+ dashboard_data["monthly_bills"] = {
2548
+ "available": True,
2549
+ "count": len(bills),
2550
+ "total_count": metadata.get("count", len(bills))
2551
+ }
2552
+ else:
2553
+ dashboard_data["monthly_bills"] = {"available": False}
2554
+ except Exception as e:
2555
+ errors.append(f"Títulos mensais: {str(e)}")
2556
+ dashboard_data["monthly_bills"] = {"available": False, "error": str(e)}
2557
+
2558
+ # 5. Tipos de clientes
2559
+ try:
2560
+ # CORRIGIDO: Usar serviço interno
2561
+ customer_types_result = await _svc_get_customer_types()
2562
+ if customer_types_result["success"]:
2563
+ data = customer_types_result["data"]
2564
+ customer_types = data.get("results", []) if isinstance(data, dict) else data
2565
+
2566
+ dashboard_data["customer_types"] = {
2567
+ "available": True,
2568
+ "count": len(customer_types)
2569
+ }
2570
+ else:
2571
+ dashboard_data["customer_types"] = {"available": False}
2572
+ except Exception as e:
2573
+ dashboard_data["customer_types"] = {"available": False, "error": str(e)}
2574
+
2575
+ # Compilar resultado
2576
+ available_modules = sum(1 for key, value in dashboard_data.items()
2577
+ if key != "connection" and isinstance(value, dict) and value.get("available"))
2578
+
2579
+ return {
2580
+ "success": True,
2581
+ "message": f"✅ Dashboard do Sienge - {available_modules} módulos disponíveis",
2582
+ "timestamp": today.isoformat(),
2583
+ "period_analyzed": f"{current_month_start} a {current_month_end}",
2584
+ "modules_status": dashboard_data,
2585
+ "available_modules": available_modules,
2586
+ "errors": errors if errors else None,
2587
+ "quick_actions": [
2588
+ "search_sienge_data('termo_busca') - Busca universal",
2589
+ "list_sienge_entities() - Listar tipos de dados",
2590
+ "get_sienge_customers(search='nome') - Buscar clientes",
2591
+ "get_sienge_projects() - Listar projetos/obras",
2592
+ "search_sienge_financial_data('2024-01-01', '2024-12-31') - Dados financeiros"
2593
+ ]
2594
+ }
2595
+
2596
+
2597
+ # ============ UTILITÁRIOS ============
2598
+
2599
+
2600
+ @mcp.tool
2601
+ def add(a: int, b: int) -> int:
2602
+ """Soma dois números (função de teste)"""
2603
+ return a + b
2604
+
2605
+
2606
+ def _mask(s: str) -> str:
2607
+ """Mascara dados sensíveis mantendo apenas o início e fim"""
2608
+ if not s:
2609
+ return None
2610
+ if len(s) == 1:
2611
+ return s + "*"
2612
+ if len(s) == 2:
2613
+ return s
2614
+ if len(s) <= 4:
2615
+ return s[:2] + "*" * (len(s) - 2)
2616
+ # Para strings > 4: usar no máximo 4 asteriscos no meio
2617
+ middle_asterisks = min(4, len(s) - 4)
2618
+ return s[:2] + "*" * middle_asterisks + s[-2:]
2619
+
2620
+
2621
+ def _get_auth_info_internal() -> Dict:
2622
+ """Função interna para verificar configuração de autenticação"""
2623
+ if SIENGE_API_KEY and SIENGE_API_KEY != "sua_api_key_aqui":
2624
+ return {"auth_method": "Bearer Token", "configured": True, "base_url": SIENGE_BASE_URL, "api_key_configured": True}
2625
+ elif SIENGE_USERNAME and SIENGE_PASSWORD:
2626
+ return {
2627
+ "auth_method": "Basic Auth",
2628
+ "configured": True,
2629
+ "base_url": SIENGE_BASE_URL,
2630
+ "subdomain": SIENGE_SUBDOMAIN,
2631
+ "username": _mask(SIENGE_USERNAME) if SIENGE_USERNAME else None,
2632
+ }
2633
+ else:
2634
+ return {
2635
+ "auth_method": "None",
2636
+ "configured": False,
2637
+ "message": "Configure SIENGE_API_KEY ou SIENGE_USERNAME/PASSWORD no .env",
2638
+ }
2639
+
2640
+
2641
+ @mcp.tool
2642
+ def get_auth_info() -> Dict:
2643
+ """Retorna informações sobre a configuração de autenticação"""
2644
+ return _get_auth_info_internal()
2645
+
2646
+
2647
+ def main():
2648
+ """Entry point for the Sienge MCP Server"""
2649
+ print("* Iniciando Sienge MCP Server (FastMCP)...")
2650
+
2651
+ # Mostrar info de configuração
2652
+ auth_info = _get_auth_info_internal()
2653
+ print(f"* Autenticacao: {auth_info['auth_method']}")
2654
+ print(f"* Configurado: {auth_info['configured']}")
2655
+
2656
+ if not auth_info["configured"]:
2657
+ print("* ERRO: Autenticacao nao configurada!")
2658
+ print("Configure as variáveis de ambiente:")
2659
+ print("- SIENGE_API_KEY (Bearer Token) OU")
2660
+ print("- SIENGE_USERNAME + SIENGE_PASSWORD + SIENGE_SUBDOMAIN (Basic Auth)")
2661
+ print("- SIENGE_BASE_URL (padrão: https://api.sienge.com.br)")
2662
+ print("")
2663
+ print("Exemplo no Windows PowerShell:")
2664
+ print('$env:SIENGE_USERNAME="seu_usuario"')
2665
+ print('$env:SIENGE_PASSWORD="sua_senha"')
2666
+ print('$env:SIENGE_SUBDOMAIN="sua_empresa"')
2667
+ print("")
2668
+ print("Exemplo no Linux/Mac:")
2669
+ print('export SIENGE_USERNAME="seu_usuario"')
2670
+ print('export SIENGE_PASSWORD="sua_senha"')
2671
+ print('export SIENGE_SUBDOMAIN="sua_empresa"')
2672
+ else:
2673
+ print("* MCP pronto para uso!")
2674
+
2675
+ mcp.run()
2676
+
2677
+
2678
+ if __name__ == "__main__":
2679
+ main()