sienge-ecbiesek-mcp 1.1.5__py3-none-any.whl → 1.2.1__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,2694 @@
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(_meta: Optional[Dict] = None) -> 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
+ _meta: Optional[Dict] = None
706
+ ) -> Dict:
707
+ """
708
+ Busca clientes no Sienge com filtros
709
+
710
+ Args:
711
+ limit: Máximo de registros (padrão: 50)
712
+ offset: Pular registros (padrão: 0)
713
+ search: Buscar por nome ou documento
714
+ customer_type_id: Filtrar por tipo de cliente
715
+ """
716
+ # Usar serviço interno
717
+ result = await _svc_get_customers(
718
+ limit=limit or 50,
719
+ offset=offset or 0,
720
+ search=search,
721
+ customer_type_id=customer_type_id
722
+ )
723
+
724
+ if result["success"]:
725
+ data = result["data"]
726
+ customers = data.get("results", []) if isinstance(data, dict) else data
727
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
728
+ total_count = metadata.get("count", len(customers))
729
+
730
+ return {
731
+ "success": True,
732
+ "message": f"✅ Encontrados {len(customers)} clientes (total: {total_count})",
733
+ "customers": customers,
734
+ "count": len(customers),
735
+ "total_count": total_count,
736
+ "filters_applied": {
737
+ "limit": limit, "offset": offset, "search": search, "customer_type_id": customer_type_id
738
+ },
739
+ "request_id": result.get("request_id"),
740
+ "latency_ms": result.get("latency_ms"),
741
+ "cache": result.get("cache")
742
+ }
743
+
744
+ return {
745
+ "success": False,
746
+ "message": "❌ Erro ao buscar clientes",
747
+ "error": result.get("error"),
748
+ "details": result.get("message"),
749
+ "request_id": result.get("request_id")
750
+ }
751
+
752
+
753
+ @mcp.tool
754
+ async def get_sienge_customer_types(_meta: Optional[Dict] = None) -> Dict:
755
+ """Lista tipos de clientes disponíveis"""
756
+ # Usar serviço interno
757
+ result = await _svc_get_customer_types()
758
+
759
+ if result["success"]:
760
+ data = result["data"]
761
+ customer_types = data.get("results", []) if isinstance(data, dict) else data
762
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
763
+ total_count = metadata.get("count", len(customer_types))
764
+
765
+ return {
766
+ "success": True,
767
+ "message": f"✅ Encontrados {len(customer_types)} tipos de clientes (total: {total_count})",
768
+ "customer_types": customer_types,
769
+ "count": len(customer_types),
770
+ "total_count": total_count,
771
+ "request_id": result.get("request_id"),
772
+ "latency_ms": result.get("latency_ms"),
773
+ "cache": result.get("cache")
774
+ }
775
+
776
+ return {
777
+ "success": False,
778
+ "message": "❌ Erro ao buscar tipos de clientes",
779
+ "error": result.get("error"),
780
+ "details": result.get("message"),
781
+ "request_id": result.get("request_id")
782
+ }
783
+
784
+
785
+ # ============ ALIAS COMPATÍVEIS COM CHECKLIST ============
786
+
787
+ @mcp.tool
788
+ async def get_sienge_enterprises(
789
+ limit: int = 100, offset: int = 0, company_id: int = None, enterprise_type: int = None,
790
+ _meta: Optional[Dict] = None) -> Dict:
791
+ """
792
+ ALIAS: get_sienge_projects → get_sienge_enterprises
793
+ Busca empreendimentos/obras (compatibilidade com checklist)
794
+ """
795
+ return await get_sienge_projects(
796
+ limit=limit,
797
+ offset=offset,
798
+ company_id=company_id,
799
+ enterprise_type=enterprise_type
800
+ )
801
+
802
+
803
+ @mcp.tool
804
+ async def get_sienge_suppliers(
805
+ limit: int = 50,
806
+ offset: int = 0,
807
+ search: str = None,
808
+ _meta: Optional[Dict] = None) -> Dict:
809
+ """
810
+ ALIAS: get_sienge_creditors → get_sienge_suppliers
811
+ Busca fornecedores (compatibilidade com checklist)
812
+ """
813
+ return await get_sienge_creditors(limit=limit, offset=offset, search=search)
814
+
815
+
816
+ @mcp.tool
817
+ async def search_sienge_finances(
818
+ period_start: str,
819
+ period_end: str,
820
+ account_type: Optional[str] = None,
821
+ cost_center: Optional[str] = None, # ignorado por enquanto (não suportado na API atual)
822
+ amount_filter: Optional[str] = None,
823
+ customer_creditor: Optional[str] = None
824
+ ) -> Dict:
825
+ """
826
+ ALIAS: search_sienge_financial_data → search_sienge_finances
827
+ - account_type: receivable | payable | both
828
+ - amount_filter: "100..500", ">=1000", "<=500", ">100", "<200", "=750"
829
+ - customer_creditor: termo de busca (cliente/credor)
830
+ """
831
+ # 1) mapear tipo
832
+ search_type = (account_type or "both").lower()
833
+ if search_type not in {"receivable", "payable", "both"}:
834
+ search_type = "both"
835
+
836
+ # 2) parse de faixa de valores
837
+ amount_min = amount_max = None
838
+ if amount_filter:
839
+ s = amount_filter.replace(" ", "")
840
+ try:
841
+ if ".." in s:
842
+ lo, hi = s.split("..", 1)
843
+ amount_min = float(lo) if lo else None
844
+ amount_max = float(hi) if hi else None
845
+ elif s.startswith(">="):
846
+ amount_min = float(s[2:])
847
+ elif s.startswith("<="):
848
+ amount_max = float(s[2:])
849
+ elif s.startswith(">"):
850
+ # >x → min = x (estrito não suportado; aproximamos)
851
+ amount_min = float(s[1:])
852
+ elif s.startswith("<"):
853
+ amount_max = float(s[1:])
854
+ elif s.startswith("="):
855
+ v = float(s[1:])
856
+ amount_min = v
857
+ amount_max = v
858
+ else:
859
+ # número puro → min
860
+ amount_min = float(s)
861
+ except ValueError:
862
+ # filtro inválido → ignora silenciosamente
863
+ amount_min = amount_max = None
864
+
865
+ return await search_sienge_financial_data(
866
+ period_start=period_start,
867
+ period_end=period_end,
868
+ search_type=search_type,
869
+ amount_min=amount_min,
870
+ amount_max=amount_max,
871
+ customer_creditor_search=customer_creditor
872
+ )
873
+
874
+
875
+ @mcp.tool
876
+ async def get_sienge_accounts_payable(
877
+ start_date: str = None, end_date: str = None, creditor_id: str = None,
878
+ status: str = None, limit: int = 50,
879
+ _meta: Optional[Dict] = None) -> Dict:
880
+ """
881
+ ALIAS: get_sienge_bills → get_sienge_accounts_payable
882
+ Busca contas a pagar (compatibilidade com checklist)
883
+ """
884
+ return await get_sienge_bills(
885
+ start_date=start_date,
886
+ end_date=end_date,
887
+ creditor_id=creditor_id,
888
+ status=status,
889
+ limit=limit
890
+ )
891
+
892
+
893
+ @mcp.tool
894
+ async def list_sienge_purchase_invoices(limit: int = 50, date_from: str = None,
895
+ _meta: Optional[Dict] = None) -> Dict:
896
+ """
897
+ Lista notas fiscais de compra (versão list/plural esperada pelo checklist)
898
+
899
+ Args:
900
+ limit: Máximo de registros (padrão: 50, máx: 200)
901
+ date_from: Data inicial (YYYY-MM-DD)
902
+ """
903
+ # Usar serviço interno
904
+ result = await _svc_get_purchase_invoices(limit=limit, date_from=date_from)
905
+
906
+ if result["success"]:
907
+ data = result["data"]
908
+ invoices = data.get("results", []) if isinstance(data, dict) else data
909
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
910
+ total_count = metadata.get("count", len(invoices))
911
+
912
+ return {
913
+ "success": True,
914
+ "message": f"✅ Encontradas {len(invoices)} notas fiscais de compra (total: {total_count})",
915
+ "purchase_invoices": invoices,
916
+ "count": len(invoices),
917
+ "total_count": total_count,
918
+ "filters_applied": {"limit": limit, "date_from": date_from},
919
+ "request_id": result.get("request_id"),
920
+ "latency_ms": result.get("latency_ms"),
921
+ "cache": result.get("cache")
922
+ }
923
+
924
+ return {
925
+ "success": False,
926
+ "message": "❌ Erro ao buscar notas fiscais de compra",
927
+ "error": result.get("error"),
928
+ "details": result.get("message"),
929
+ "request_id": result.get("request_id")
930
+ }
931
+
932
+
933
+ @mcp.tool
934
+ async def list_sienge_purchase_requests(limit: int = 50, status: str = None,
935
+ _meta: Optional[Dict] = None) -> Dict:
936
+ """
937
+ Lista solicitações de compra (versão list/plural esperada pelo checklist)
938
+
939
+ Args:
940
+ limit: Máximo de registros (padrão: 50, máx: 200)
941
+ status: Status da solicitação
942
+ """
943
+ # Usar serviço interno
944
+ result = await _svc_get_purchase_requests(limit=limit, status=status)
945
+
946
+ if result["success"]:
947
+ data = result["data"]
948
+ requests = data.get("results", []) if isinstance(data, dict) else data
949
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
950
+ total_count = metadata.get("count", len(requests))
951
+
952
+ return {
953
+ "success": True,
954
+ "message": f"✅ Encontradas {len(requests)} solicitações de compra (total: {total_count})",
955
+ "purchase_requests": requests,
956
+ "count": len(requests),
957
+ "total_count": total_count,
958
+ "filters_applied": {"limit": limit, "status": status},
959
+ "request_id": result.get("request_id"),
960
+ "latency_ms": result.get("latency_ms"),
961
+ "cache": result.get("cache")
962
+ }
963
+
964
+ return {
965
+ "success": False,
966
+ "message": "❌ Erro ao buscar solicitações de compra",
967
+ "error": result.get("error"),
968
+ "details": result.get("message"),
969
+ "request_id": result.get("request_id")
970
+ }
971
+
972
+
973
+ # ============ CREDORES ============
974
+
975
+
976
+ @mcp.tool
977
+ async def get_sienge_creditors(limit: Optional[int] = 50, offset: Optional[int] = 0, search: Optional[str] = None,
978
+ _meta: Optional[Dict] = None) -> Dict:
979
+ """
980
+ Busca credores/fornecedores
981
+
982
+ Args:
983
+ limit: Máximo de registros (padrão: 50)
984
+ offset: Pular registros (padrão: 0)
985
+ search: Buscar por nome
986
+ """
987
+ # Usar serviço interno
988
+ result = await _svc_get_creditors(
989
+ limit=limit or 50,
990
+ offset=offset or 0,
991
+ search=search
992
+ )
993
+
994
+ if result["success"]:
995
+ data = result["data"]
996
+ creditors = data.get("results", []) if isinstance(data, dict) else data
997
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
998
+ total_count = metadata.get("count", len(creditors))
999
+
1000
+ return {
1001
+ "success": True,
1002
+ "message": f"✅ Encontrados {len(creditors)} credores (total: {total_count})",
1003
+ "creditors": creditors,
1004
+ "count": len(creditors),
1005
+ "total_count": total_count,
1006
+ "filters_applied": {"limit": limit, "offset": offset, "search": search},
1007
+ "request_id": result.get("request_id"),
1008
+ "latency_ms": result.get("latency_ms"),
1009
+ "cache": result.get("cache")
1010
+ }
1011
+
1012
+ return {
1013
+ "success": False,
1014
+ "message": "❌ Erro ao buscar credores",
1015
+ "error": result.get("error"),
1016
+ "details": result.get("message"),
1017
+ "request_id": result.get("request_id")
1018
+ }
1019
+
1020
+
1021
+ @mcp.tool
1022
+ async def get_sienge_creditor_bank_info(creditor_id: str,
1023
+ _meta: Optional[Dict] = None) -> Dict:
1024
+ """
1025
+ Consulta informações bancárias de um credor
1026
+
1027
+ Args:
1028
+ creditor_id: ID do credor (obrigatório)
1029
+ """
1030
+ # Usar serviço interno
1031
+ result = await _svc_get_creditor_bank_info(creditor_id=creditor_id)
1032
+
1033
+ if result["success"]:
1034
+ return {
1035
+ "success": True,
1036
+ "message": f"✅ Informações bancárias do credor {creditor_id}",
1037
+ "creditor_id": creditor_id,
1038
+ "bank_info": result["data"],
1039
+ "request_id": result.get("request_id"),
1040
+ "latency_ms": result.get("latency_ms")
1041
+ }
1042
+
1043
+ return {
1044
+ "success": False,
1045
+ "message": f"❌ Erro ao buscar info bancária do credor {creditor_id}",
1046
+ "error": result.get("error"),
1047
+ "details": result.get("message"),
1048
+ "request_id": result.get("request_id")
1049
+ }
1050
+
1051
+
1052
+ # ============ FINANCEIRO ============
1053
+
1054
+
1055
+ @mcp.tool
1056
+ async def get_sienge_accounts_receivable(
1057
+ start_date: str,
1058
+ end_date: str,
1059
+ selection_type: str = "D",
1060
+ company_id: Optional[int] = None,
1061
+ cost_centers_id: Optional[List[int]] = None,
1062
+ correction_indexer_id: Optional[int] = None,
1063
+ correction_date: Optional[str] = None,
1064
+ change_start_date: Optional[str] = None,
1065
+ completed_bills: Optional[str] = None,
1066
+ origins_ids: Optional[List[str]] = None,
1067
+ bearers_id_in: Optional[List[int]] = None,
1068
+ bearers_id_not_in: Optional[List[int]] = None,
1069
+ _meta: Optional[Dict] = None) -> Dict:
1070
+ """
1071
+ Consulta parcelas do contas a receber via API bulk-data
1072
+ MELHORADO: Suporte a polling assíncrono para requests 202
1073
+
1074
+ Args:
1075
+ start_date: Data de início do período (YYYY-MM-DD) - OBRIGATÓRIO
1076
+ end_date: Data do fim do período (YYYY-MM-DD) - OBRIGATÓRIO
1077
+ selection_type: Seleção da data do período (I=emissão, D=vencimento, P=pagamento, B=competência) - padrão: D
1078
+ company_id: Código da empresa
1079
+ cost_centers_id: Lista de códigos de centro de custo
1080
+ correction_indexer_id: Código do indexador de correção
1081
+ correction_date: Data para correção do indexador (YYYY-MM-DD)
1082
+ change_start_date: Data inicial de alteração do título/parcela (YYYY-MM-DD)
1083
+ completed_bills: Filtrar por títulos completos (S)
1084
+ origins_ids: Códigos dos módulos de origem (CR, CO, ME, CA, CI, AR, SC, LO, NE, NS, AC, NF)
1085
+ bearers_id_in: Filtrar parcelas com códigos de portador específicos
1086
+ bearers_id_not_in: Filtrar parcelas excluindo códigos de portador específicos
1087
+ """
1088
+ # Usar serviço interno com polling assíncrono
1089
+ result = await _svc_get_accounts_receivable(
1090
+ start_date=start_date,
1091
+ end_date=end_date,
1092
+ selection_type=selection_type,
1093
+ company_id=company_id,
1094
+ cost_centers_id=cost_centers_id,
1095
+ correction_indexer_id=correction_indexer_id,
1096
+ correction_date=correction_date,
1097
+ change_start_date=change_start_date,
1098
+ completed_bills=completed_bills,
1099
+ origins_ids=origins_ids,
1100
+ bearers_id_in=bearers_id_in,
1101
+ bearers_id_not_in=bearers_id_not_in
1102
+ )
1103
+
1104
+ if result["success"]:
1105
+ # Para requests normais (200) e assíncronos processados
1106
+ income_data = result.get("data", [])
1107
+
1108
+ response = {
1109
+ "success": True,
1110
+ "message": f"✅ Encontradas {len(income_data)} parcelas a receber",
1111
+ "income_data": income_data,
1112
+ "count": len(income_data),
1113
+ "period": f"{start_date} a {end_date}",
1114
+ "selection_type": selection_type,
1115
+ "request_id": result.get("request_id"),
1116
+ "latency_ms": result.get("latency_ms")
1117
+ }
1118
+
1119
+ # Se foi processamento assíncrono, incluir informações extras
1120
+ if result.get("async_identifier"):
1121
+ response.update({
1122
+ "async_processing": {
1123
+ "identifier": result.get("async_identifier"),
1124
+ "correlation_id": result.get("correlation_id"),
1125
+ "chunks_downloaded": result.get("chunks_downloaded"),
1126
+ "rows_returned": result.get("rows_returned"),
1127
+ "polling_attempts": result.get("polling_attempts")
1128
+ }
1129
+ })
1130
+
1131
+ return response
1132
+
1133
+ return {
1134
+ "success": False,
1135
+ "message": "❌ Erro ao buscar parcelas a receber",
1136
+ "error": result.get("error"),
1137
+ "details": result.get("message"),
1138
+ "request_id": result.get("request_id"),
1139
+ "async_info": {
1140
+ "identifier": result.get("async_identifier"),
1141
+ "polling_attempts": result.get("polling_attempts")
1142
+ } if result.get("async_identifier") else None
1143
+ }
1144
+
1145
+
1146
+ @mcp.tool
1147
+ async def get_sienge_accounts_receivable_by_bills(
1148
+ bills_ids: List[int], correction_indexer_id: Optional[int] = None, correction_date: Optional[str] = None,
1149
+ _meta: Optional[Dict] = None) -> Dict:
1150
+ """
1151
+ Consulta parcelas dos títulos informados via API bulk-data
1152
+ MELHORADO: Suporte a polling assíncrono para requests 202
1153
+
1154
+ Args:
1155
+ bills_ids: Lista de códigos dos títulos - OBRIGATÓRIO
1156
+ correction_indexer_id: Código do indexador de correção
1157
+ correction_date: Data para correção do indexador (YYYY-MM-DD)
1158
+ """
1159
+ # Usar serviço interno com polling assíncrono
1160
+ result = await _svc_get_accounts_receivable_by_bills(
1161
+ bills_ids=bills_ids,
1162
+ correction_indexer_id=correction_indexer_id,
1163
+ correction_date=correction_date
1164
+ )
1165
+
1166
+ if result["success"]:
1167
+ income_data = result.get("data", [])
1168
+
1169
+ response = {
1170
+ "success": True,
1171
+ "message": f"✅ Encontradas {len(income_data)} parcelas dos títulos informados",
1172
+ "income_data": income_data,
1173
+ "count": len(income_data),
1174
+ "bills_consulted": bills_ids,
1175
+ "request_id": result.get("request_id"),
1176
+ "latency_ms": result.get("latency_ms")
1177
+ }
1178
+
1179
+ # Se foi processamento assíncrono, incluir informações extras
1180
+ if result.get("async_identifier"):
1181
+ response.update({
1182
+ "async_processing": {
1183
+ "identifier": result.get("async_identifier"),
1184
+ "correlation_id": result.get("correlation_id"),
1185
+ "chunks_downloaded": result.get("chunks_downloaded"),
1186
+ "rows_returned": result.get("rows_returned"),
1187
+ "polling_attempts": result.get("polling_attempts")
1188
+ }
1189
+ })
1190
+
1191
+ return response
1192
+
1193
+ return {
1194
+ "success": False,
1195
+ "message": "❌ Erro ao buscar parcelas dos títulos informados",
1196
+ "error": result.get("error"),
1197
+ "details": result.get("message"),
1198
+ "request_id": result.get("request_id"),
1199
+ "async_info": {
1200
+ "identifier": result.get("async_identifier"),
1201
+ "polling_attempts": result.get("polling_attempts")
1202
+ } if result.get("async_identifier") else None
1203
+ }
1204
+
1205
+
1206
+ @mcp.tool
1207
+ async def get_sienge_bills(
1208
+ start_date: Optional[str] = None,
1209
+ end_date: Optional[str] = None,
1210
+ creditor_id: Optional[str] = None,
1211
+ status: Optional[str] = None,
1212
+ limit: Optional[int] = 50,
1213
+ _meta: Optional[Dict] = None) -> Dict:
1214
+ """
1215
+ Consulta títulos a pagar (contas a pagar) - REQUER startDate obrigatório
1216
+
1217
+ Args:
1218
+ start_date: Data inicial obrigatória (YYYY-MM-DD) - padrão últimos 30 dias
1219
+ end_date: Data final (YYYY-MM-DD) - padrão hoje
1220
+ creditor_id: ID do credor
1221
+ status: Status do título (ex: open, paid, cancelled)
1222
+ limit: Máximo de registros (padrão: 50, máx: 200)
1223
+ """
1224
+ # Usar serviço interno
1225
+ result = await _svc_get_bills(
1226
+ start_date=start_date,
1227
+ end_date=end_date,
1228
+ creditor_id=creditor_id,
1229
+ status=status,
1230
+ limit=limit or 50
1231
+ )
1232
+
1233
+ if result["success"]:
1234
+ data = result["data"]
1235
+ bills = data.get("results", []) if isinstance(data, dict) else data
1236
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1237
+ total_count = metadata.get("count", len(bills))
1238
+
1239
+ # Aplicar parsing numérico nos valores
1240
+ for bill in bills:
1241
+ if "amount" in bill:
1242
+ bill["amount"] = _parse_numeric_value(bill["amount"])
1243
+ if "paid_amount" in bill:
1244
+ bill["paid_amount"] = _parse_numeric_value(bill["paid_amount"])
1245
+ if "remaining_amount" in bill:
1246
+ bill["remaining_amount"] = _parse_numeric_value(bill["remaining_amount"])
1247
+
1248
+ # Usar datas padrão se não fornecidas
1249
+ actual_start = start_date or (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
1250
+ actual_end = end_date or datetime.now().strftime("%Y-%m-%d")
1251
+
1252
+ return {
1253
+ "success": True,
1254
+ "message": f"✅ Encontrados {len(bills)} títulos a pagar (total: {total_count}) - período: {actual_start} a {actual_end}",
1255
+ "bills": bills,
1256
+ "count": len(bills),
1257
+ "total_count": total_count,
1258
+ "period": {"start_date": actual_start, "end_date": actual_end},
1259
+ "filters_applied": {
1260
+ "start_date": actual_start, "end_date": actual_end,
1261
+ "creditor_id": creditor_id, "status": status, "limit": limit
1262
+ },
1263
+ "request_id": result.get("request_id"),
1264
+ "latency_ms": result.get("latency_ms"),
1265
+ "cache": result.get("cache")
1266
+ }
1267
+
1268
+ return {
1269
+ "success": False,
1270
+ "message": "❌ Erro ao buscar títulos a pagar",
1271
+ "error": result.get("error"),
1272
+ "details": result.get("message"),
1273
+ "request_id": result.get("request_id")
1274
+ }
1275
+
1276
+
1277
+ # ============ COMPRAS ============
1278
+
1279
+
1280
+ @mcp.tool
1281
+ async def get_sienge_purchase_orders(
1282
+ purchase_order_id: Optional[str] = None,
1283
+ status: Optional[str] = None,
1284
+ date_from: Optional[str] = None,
1285
+ limit: Optional[int] = 50,
1286
+ _meta: Optional[Dict] = None) -> Dict:
1287
+ """
1288
+ Consulta pedidos de compra
1289
+
1290
+ Args:
1291
+ purchase_order_id: ID específico do pedido
1292
+ status: Status do pedido
1293
+ date_from: Data inicial (YYYY-MM-DD)
1294
+ limit: Máximo de registros
1295
+ """
1296
+ if purchase_order_id:
1297
+ result = await make_sienge_request("GET", f"/purchase-orders/{purchase_order_id}")
1298
+ if result["success"]:
1299
+ return {"success": True, "message": f"✅ Pedido {purchase_order_id} encontrado", "purchase_order": result["data"]}
1300
+ return result
1301
+
1302
+ params = {"limit": min(limit or 50, 200)}
1303
+ if status:
1304
+ params["status"] = status
1305
+ if date_from:
1306
+ params["date_from"] = date_from
1307
+
1308
+ result = await make_sienge_request("GET", "/purchase-orders", params=params)
1309
+
1310
+ if result["success"]:
1311
+ data = result["data"]
1312
+ orders = data.get("results", []) if isinstance(data, dict) else data
1313
+
1314
+ return {
1315
+ "success": True,
1316
+ "message": f"✅ Encontrados {len(orders)} pedidos de compra",
1317
+ "purchase_orders": orders,
1318
+ "count": len(orders),
1319
+ "request_id": result.get("request_id"),
1320
+ "latency_ms": result.get("latency_ms")
1321
+ }
1322
+
1323
+ return {
1324
+ "success": False,
1325
+ "message": "❌ Erro ao buscar pedidos de compra",
1326
+ "error": result.get("error"),
1327
+ "details": result.get("message"),
1328
+ "request_id": result.get("request_id"),
1329
+ "latency_ms": result.get("latency_ms")
1330
+ }
1331
+
1332
+
1333
+ @mcp.tool
1334
+ async def get_sienge_purchase_order_items(purchase_order_id: str,
1335
+ _meta: Optional[Dict] = None) -> Dict:
1336
+ """
1337
+ Consulta itens de um pedido de compra específico
1338
+
1339
+ Args:
1340
+ purchase_order_id: ID do pedido (obrigatório)
1341
+ """
1342
+ result = await make_sienge_request("GET", f"/purchase-orders/{purchase_order_id}/items")
1343
+
1344
+ if result["success"]:
1345
+ data = result["data"]
1346
+ items = data.get("results", []) if isinstance(data, dict) else data
1347
+
1348
+ return {
1349
+ "success": True,
1350
+ "message": f"✅ Encontrados {len(items)} itens no pedido {purchase_order_id}",
1351
+ "purchase_order_id": purchase_order_id,
1352
+ "items": items,
1353
+ "count": len(items),
1354
+ }
1355
+
1356
+ return {
1357
+ "success": False,
1358
+ "message": f"❌ Erro ao buscar itens do pedido {purchase_order_id}",
1359
+ "error": result.get("error"),
1360
+ "details": result.get("message"),
1361
+ }
1362
+
1363
+
1364
+ @mcp.tool
1365
+ async def get_sienge_purchase_requests(purchase_request_id: Optional[str] = None, limit: Optional[int] = 50,
1366
+ _meta: Optional[Dict] = None) -> Dict:
1367
+ """
1368
+ Consulta solicitações de compra
1369
+
1370
+ Args:
1371
+ purchase_request_id: ID específico da solicitação
1372
+ limit: Máximo de registros
1373
+ """
1374
+ if purchase_request_id:
1375
+ result = await make_sienge_request("GET", f"/purchase-requests/{purchase_request_id}")
1376
+ if result["success"]:
1377
+ return {
1378
+ "success": True,
1379
+ "message": f"✅ Solicitação {purchase_request_id} encontrada",
1380
+ "purchase_request": result["data"],
1381
+ }
1382
+ return result
1383
+
1384
+ params = {"limit": min(limit or 50, 200)}
1385
+ result = await make_sienge_request("GET", "/purchase-requests", params=params)
1386
+
1387
+ if result["success"]:
1388
+ data = result["data"]
1389
+ requests = data.get("results", []) if isinstance(data, dict) else data
1390
+
1391
+ return {
1392
+ "success": True,
1393
+ "message": f"✅ Encontradas {len(requests)} solicitações de compra",
1394
+ "purchase_requests": requests,
1395
+ "count": len(requests),
1396
+ }
1397
+
1398
+ return {
1399
+ "success": False,
1400
+ "message": "❌ Erro ao buscar solicitações de compra",
1401
+ "error": result.get("error"),
1402
+ "details": result.get("message"),
1403
+ }
1404
+
1405
+
1406
+ @mcp.tool
1407
+ async def create_sienge_purchase_request(description: str, project_id: str, items: List[Dict[str, Any]],
1408
+ _meta: Optional[Dict] = None) -> Dict:
1409
+ """
1410
+ Cria nova solicitação de compra
1411
+
1412
+ Args:
1413
+ description: Descrição da solicitação
1414
+ project_id: ID do projeto/obra
1415
+ items: Lista de itens da solicitação
1416
+ """
1417
+ request_data = {
1418
+ "description": description,
1419
+ "project_id": project_id,
1420
+ "items": items,
1421
+ "date": datetime.now().strftime("%Y-%m-%d"),
1422
+ }
1423
+
1424
+ # CORRIGIDO: Normalizar JSON payload
1425
+ json_data = to_camel_json(request_data)
1426
+ result = await make_sienge_request("POST", "/purchase-requests", json_data=json_data)
1427
+
1428
+ if result["success"]:
1429
+ return {
1430
+ "success": True,
1431
+ "message": "✅ Solicitação de compra criada com sucesso",
1432
+ "request_id": result.get("request_id"),
1433
+ "purchase_request_id": result["data"].get("id"),
1434
+ "data": result["data"],
1435
+ "latency_ms": result.get("latency_ms")
1436
+ }
1437
+
1438
+ return {
1439
+ "success": False,
1440
+ "message": "❌ Erro ao criar solicitação de compra",
1441
+ "error": result.get("error"),
1442
+ "details": result.get("message"),
1443
+ "request_id": result.get("request_id")
1444
+ }
1445
+
1446
+
1447
+ # ============ NOTAS FISCAIS DE COMPRA ============
1448
+
1449
+
1450
+ @mcp.tool
1451
+ async def get_sienge_purchase_invoice(sequential_number: int,
1452
+ _meta: Optional[Dict] = None) -> Dict:
1453
+ """
1454
+ Consulta nota fiscal de compra por número sequencial
1455
+
1456
+ Args:
1457
+ sequential_number: Número sequencial da nota fiscal
1458
+ """
1459
+ result = await make_sienge_request("GET", f"/purchase-invoices/{sequential_number}")
1460
+
1461
+ if result["success"]:
1462
+ return {"success": True, "message": f"✅ Nota fiscal {sequential_number} encontrada", "invoice": result["data"]}
1463
+
1464
+ return {
1465
+ "success": False,
1466
+ "message": f"❌ Erro ao buscar nota fiscal {sequential_number}",
1467
+ "error": result.get("error"),
1468
+ "details": result.get("message"),
1469
+ }
1470
+
1471
+
1472
+ @mcp.tool
1473
+ async def get_sienge_purchase_invoice_items(sequential_number: int,
1474
+ _meta: Optional[Dict] = None) -> Dict:
1475
+ """
1476
+ Consulta itens de uma nota fiscal de compra
1477
+
1478
+ Args:
1479
+ sequential_number: Número sequencial da nota fiscal
1480
+ """
1481
+ result = await make_sienge_request("GET", f"/purchase-invoices/{sequential_number}/items")
1482
+
1483
+ if result["success"]:
1484
+ data = result["data"]
1485
+ items = data.get("results", []) if isinstance(data, dict) else data
1486
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1487
+
1488
+ return {
1489
+ "success": True,
1490
+ "message": f"✅ Encontrados {len(items)} itens na nota fiscal {sequential_number}",
1491
+ "items": items,
1492
+ "count": len(items),
1493
+ "metadata": metadata,
1494
+ }
1495
+
1496
+ return {
1497
+ "success": False,
1498
+ "message": f"❌ Erro ao buscar itens da nota fiscal {sequential_number}",
1499
+ "error": result.get("error"),
1500
+ "details": result.get("message"),
1501
+ }
1502
+
1503
+
1504
+ @mcp.tool
1505
+ async def create_sienge_purchase_invoice(
1506
+ document_id: str,
1507
+ number: str,
1508
+ supplier_id: int,
1509
+ company_id: int,
1510
+ movement_type_id: int,
1511
+ movement_date: str,
1512
+ issue_date: str,
1513
+ series: Optional[str] = None,
1514
+ notes: Optional[str] = None,
1515
+ _meta: Optional[Dict] = None) -> Dict:
1516
+ """
1517
+ Cadastra uma nova nota fiscal de compra
1518
+
1519
+ Args:
1520
+ document_id: ID do documento (ex: "NF")
1521
+ number: Número da nota fiscal
1522
+ supplier_id: ID do fornecedor
1523
+ company_id: ID da empresa
1524
+ movement_type_id: ID do tipo de movimento
1525
+ movement_date: Data do movimento (YYYY-MM-DD)
1526
+ issue_date: Data de emissão (YYYY-MM-DD)
1527
+ series: Série da nota fiscal (opcional)
1528
+ notes: Observações (opcional)
1529
+ """
1530
+ invoice_data = {
1531
+ "document_id": document_id,
1532
+ "number": number,
1533
+ "supplier_id": supplier_id,
1534
+ "company_id": company_id,
1535
+ "movement_type_id": movement_type_id,
1536
+ "movement_date": movement_date,
1537
+ "issue_date": issue_date,
1538
+ }
1539
+
1540
+ if series:
1541
+ invoice_data["series"] = series
1542
+ if notes:
1543
+ invoice_data["notes"] = notes
1544
+
1545
+ result = await make_sienge_request("POST", "/purchase-invoices", json_data=to_camel_json(invoice_data))
1546
+
1547
+ if result["success"]:
1548
+ return {"success": True, "message": f"✅ Nota fiscal {number} criada com sucesso", "invoice": result["data"]}
1549
+
1550
+ return {
1551
+ "success": False,
1552
+ "message": f"❌ Erro ao criar nota fiscal {number}",
1553
+ "error": result.get("error"),
1554
+ "details": result.get("message"),
1555
+ }
1556
+
1557
+
1558
+ @mcp.tool
1559
+ async def add_items_to_purchase_invoice(
1560
+ sequential_number: int,
1561
+ deliveries_order: List[Dict[str, Any]],
1562
+ copy_notes_purchase_orders: bool = True,
1563
+ copy_notes_resources: bool = False,
1564
+ copy_attachments_purchase_orders: bool = True,
1565
+ _meta: Optional[Dict] = None) -> Dict:
1566
+ """
1567
+ Insere itens em uma nota fiscal a partir de entregas de pedidos de compra
1568
+
1569
+ Args:
1570
+ sequential_number: Número sequencial da nota fiscal
1571
+ deliveries_order: Lista de entregas com estrutura:
1572
+ - purchaseOrderId: ID do pedido de compra
1573
+ - itemNumber: Número do item no pedido
1574
+ - deliveryScheduleNumber: Número da programação de entrega
1575
+ - deliveredQuantity: Quantidade entregue
1576
+ - keepBalance: Manter saldo (true/false)
1577
+ copy_notes_purchase_orders: Copiar observações dos pedidos de compra
1578
+ copy_notes_resources: Copiar observações dos recursos
1579
+ copy_attachments_purchase_orders: Copiar anexos dos pedidos de compra
1580
+ """
1581
+ item_data = {
1582
+ "deliveries_order": deliveries_order,
1583
+ "copy_notes_purchase_orders": copy_notes_purchase_orders,
1584
+ "copy_notes_resources": copy_notes_resources,
1585
+ "copy_attachments_purchase_orders": copy_attachments_purchase_orders,
1586
+ }
1587
+
1588
+ result = await make_sienge_request(
1589
+ "POST", f"/purchase-invoices/{sequential_number}/items/purchase-orders/delivery-schedules", json_data=to_camel_json(item_data)
1590
+ )
1591
+
1592
+ if result["success"]:
1593
+ return {
1594
+ "success": True,
1595
+ "message": f"✅ Itens adicionados à nota fiscal {sequential_number} com sucesso",
1596
+ "item": result["data"],
1597
+ }
1598
+
1599
+ return {
1600
+ "success": False,
1601
+ "message": f"❌ Erro ao adicionar itens à nota fiscal {sequential_number}",
1602
+ "error": result.get("error"),
1603
+ "details": result.get("message"),
1604
+ }
1605
+
1606
+
1607
+ @mcp.tool
1608
+ async def get_sienge_purchase_invoices_deliveries_attended(
1609
+ bill_id: Optional[int] = None,
1610
+ sequential_number: Optional[int] = None,
1611
+ purchase_order_id: Optional[int] = None,
1612
+ invoice_item_number: Optional[int] = None,
1613
+ purchase_order_item_number: Optional[int] = None,
1614
+ limit: Optional[int] = 100,
1615
+ offset: Optional[int] = 0,
1616
+ _meta: Optional[Dict] = None) -> Dict:
1617
+ """
1618
+ Lista entregas atendidas entre pedidos de compra e notas fiscais
1619
+
1620
+ Args:
1621
+ bill_id: ID do título da nota fiscal
1622
+ sequential_number: Número sequencial da nota fiscal
1623
+ purchase_order_id: ID do pedido de compra
1624
+ invoice_item_number: Número do item da nota fiscal
1625
+ purchase_order_item_number: Número do item do pedido de compra
1626
+ limit: Máximo de registros (padrão: 100, máximo: 200)
1627
+ offset: Deslocamento (padrão: 0)
1628
+ """
1629
+ params = {"limit": min(limit or 100, 200), "offset": offset or 0}
1630
+
1631
+ if bill_id:
1632
+ params["billId"] = bill_id
1633
+ if sequential_number:
1634
+ params["sequentialNumber"] = sequential_number
1635
+ if purchase_order_id:
1636
+ params["purchaseOrderId"] = purchase_order_id
1637
+ if invoice_item_number:
1638
+ params["invoiceItemNumber"] = invoice_item_number
1639
+ if purchase_order_item_number:
1640
+ params["purchaseOrderItemNumber"] = purchase_order_item_number
1641
+
1642
+ result = await make_sienge_request("GET", "/purchase-invoices/deliveries-attended", params=params)
1643
+
1644
+ if result["success"]:
1645
+ data = result["data"]
1646
+ deliveries = data.get("results", []) if isinstance(data, dict) else data
1647
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1648
+
1649
+ return {
1650
+ "success": True,
1651
+ "message": f"✅ Encontradas {len(deliveries)} entregas atendidas",
1652
+ "deliveries": deliveries,
1653
+ "count": len(deliveries),
1654
+ "metadata": metadata,
1655
+ "filters": params,
1656
+ }
1657
+
1658
+ return {
1659
+ "success": False,
1660
+ "message": "❌ Erro ao buscar entregas atendidas",
1661
+ "error": result.get("error"),
1662
+ "details": result.get("message"),
1663
+ }
1664
+
1665
+
1666
+ # ============ ESTOQUE ============
1667
+
1668
+
1669
+ @mcp.tool
1670
+ async def get_sienge_stock_inventory(cost_center_id: str, resource_id: Optional[str] = None,
1671
+ _meta: Optional[Dict] = None) -> Dict:
1672
+ """
1673
+ Consulta inventário de estoque por centro de custo
1674
+
1675
+ Args:
1676
+ cost_center_id: ID do centro de custo (obrigatório)
1677
+ resource_id: ID do insumo específico (opcional)
1678
+ """
1679
+ if resource_id:
1680
+ endpoint = f"/stock-inventories/{cost_center_id}/items/{resource_id}"
1681
+ else:
1682
+ endpoint = f"/stock-inventories/{cost_center_id}/items"
1683
+
1684
+ result = await make_sienge_request("GET", endpoint)
1685
+
1686
+ if result["success"]:
1687
+ data = result["data"]
1688
+ items = data.get("results", []) if isinstance(data, dict) else data
1689
+ count = len(items) if isinstance(items, list) else 1
1690
+
1691
+ return {
1692
+ "success": True,
1693
+ "message": f"✅ Inventário do centro de custo {cost_center_id}",
1694
+ "cost_center_id": cost_center_id,
1695
+ "inventory": items,
1696
+ "count": count,
1697
+ }
1698
+
1699
+ return {
1700
+ "success": False,
1701
+ "message": f"❌ Erro ao consultar estoque do centro {cost_center_id}",
1702
+ "error": result.get("error"),
1703
+ "details": result.get("message"),
1704
+ }
1705
+
1706
+
1707
+ @mcp.tool
1708
+ async def get_sienge_stock_reservations(limit: Optional[int] = 50,
1709
+ _meta: Optional[Dict] = None) -> Dict:
1710
+ """
1711
+ Lista reservas de estoque
1712
+
1713
+ Args:
1714
+ limit: Máximo de registros
1715
+ """
1716
+ params = {"limit": min(limit or 50, 200)}
1717
+ result = await make_sienge_request("GET", "/stock-reservations", params=params)
1718
+
1719
+ if result["success"]:
1720
+ data = result["data"]
1721
+ reservations = data.get("results", []) if isinstance(data, dict) else data
1722
+
1723
+ return {
1724
+ "success": True,
1725
+ "message": f"✅ Encontradas {len(reservations)} reservas de estoque",
1726
+ "reservations": reservations,
1727
+ "count": len(reservations),
1728
+ }
1729
+
1730
+ return {
1731
+ "success": False,
1732
+ "message": "❌ Erro ao buscar reservas de estoque",
1733
+ "error": result.get("error"),
1734
+ "details": result.get("message"),
1735
+ }
1736
+
1737
+
1738
+ # ============ PROJETOS/OBRAS ============
1739
+
1740
+
1741
+ @mcp.tool
1742
+ async def get_sienge_projects(
1743
+ limit: Optional[int] = 100,
1744
+ offset: Optional[int] = 0,
1745
+ company_id: Optional[int] = None,
1746
+ enterprise_type: Optional[int] = None,
1747
+ receivable_register: Optional[str] = None,
1748
+ only_buildings_enabled: Optional[bool] = False,
1749
+ _meta: Optional[Dict] = None) -> Dict:
1750
+ """
1751
+ Busca empreendimentos/obras no Sienge
1752
+ CORRIGIDO: Mapeamento correto da chave de resposta
1753
+
1754
+ Args:
1755
+ limit: Máximo de registros (padrão: 100, máximo: 200)
1756
+ offset: Pular registros (padrão: 0)
1757
+ company_id: Código da empresa
1758
+ enterprise_type: Tipo do empreendimento (1: Obra e Centro de custo, 2: Obra, 3: Centro de custo, 4: Centro de custo associado a obra)
1759
+ receivable_register: Filtro de registro de recebíveis (B3, CERC)
1760
+ only_buildings_enabled: Retornar apenas obras habilitadas para integração orçamentária
1761
+ """
1762
+ # Usar serviço interno
1763
+ result = await _svc_get_projects(
1764
+ limit=limit or 100,
1765
+ offset=offset or 0,
1766
+ company_id=company_id,
1767
+ enterprise_type=enterprise_type,
1768
+ receivable_register=receivable_register,
1769
+ only_buildings_enabled=only_buildings_enabled or False
1770
+ )
1771
+
1772
+ if result["success"]:
1773
+ data = result["data"]
1774
+ # CORREÇÃO: API retorna em "results", mas paginador espera em "enterprises"
1775
+ enterprises = data.get("results", []) if isinstance(data, dict) else data
1776
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1777
+ total_count = metadata.get("count", len(enterprises))
1778
+
1779
+ return {
1780
+ "success": True,
1781
+ "message": f"✅ Encontrados {len(enterprises)} empreendimentos (total: {total_count})",
1782
+ "enterprises": enterprises, # Manter consistência para paginador
1783
+ "projects": enterprises, # Alias para compatibilidade
1784
+ "count": len(enterprises),
1785
+ "total_count": total_count,
1786
+ "metadata": metadata,
1787
+ "filters_applied": {
1788
+ "limit": limit, "offset": offset, "company_id": company_id,
1789
+ "enterprise_type": enterprise_type, "receivable_register": receivable_register,
1790
+ "only_buildings_enabled": only_buildings_enabled
1791
+ },
1792
+ "request_id": result.get("request_id"),
1793
+ "latency_ms": result.get("latency_ms"),
1794
+ "cache": result.get("cache")
1795
+ }
1796
+
1797
+ return {
1798
+ "success": False,
1799
+ "message": "❌ Erro ao buscar empreendimentos",
1800
+ "error": result.get("error"),
1801
+ "details": result.get("message"),
1802
+ "request_id": result.get("request_id")
1803
+ }
1804
+
1805
+
1806
+ @mcp.tool
1807
+ async def get_sienge_enterprise_by_id(enterprise_id: int,
1808
+ _meta: Optional[Dict] = None) -> Dict:
1809
+ """
1810
+ Busca um empreendimento específico por ID no Sienge
1811
+
1812
+ Args:
1813
+ enterprise_id: ID do empreendimento
1814
+ """
1815
+ result = await make_sienge_request("GET", f"/enterprises/{enterprise_id}")
1816
+
1817
+ if result["success"]:
1818
+ return {"success": True, "message": f"✅ Empreendimento {enterprise_id} encontrado", "enterprise": result["data"]}
1819
+
1820
+ return {
1821
+ "success": False,
1822
+ "message": f"❌ Erro ao buscar empreendimento {enterprise_id}",
1823
+ "error": result.get("error"),
1824
+ "details": result.get("message"),
1825
+ }
1826
+
1827
+
1828
+ @mcp.tool
1829
+ async def get_sienge_enterprise_groupings(enterprise_id: int,
1830
+ _meta: Optional[Dict] = None) -> Dict:
1831
+ """
1832
+ Busca agrupamentos de unidades de um empreendimento específico
1833
+
1834
+ Args:
1835
+ enterprise_id: ID do empreendimento
1836
+ """
1837
+ result = await make_sienge_request("GET", f"/enterprises/{enterprise_id}/groupings")
1838
+
1839
+ if result["success"]:
1840
+ groupings = result["data"]
1841
+ return {
1842
+ "success": True,
1843
+ "message": f"✅ Agrupamentos do empreendimento {enterprise_id} encontrados",
1844
+ "groupings": groupings,
1845
+ "count": len(groupings) if isinstance(groupings, list) else 0,
1846
+ }
1847
+
1848
+ return {
1849
+ "success": False,
1850
+ "message": f"❌ Erro ao buscar agrupamentos do empreendimento {enterprise_id}",
1851
+ "error": result.get("error"),
1852
+ "details": result.get("message"),
1853
+ }
1854
+
1855
+
1856
+ @mcp.tool
1857
+ async def get_sienge_units(limit: Optional[int] = 50, offset: Optional[int] = 0,
1858
+ _meta: Optional[Dict] = None) -> Dict:
1859
+ """
1860
+ Consulta unidades cadastradas no Sienge
1861
+
1862
+ Args:
1863
+ limit: Máximo de registros (padrão: 50)
1864
+ offset: Pular registros (padrão: 0)
1865
+ """
1866
+ params = {"limit": min(limit or 50, 200), "offset": offset or 0}
1867
+ result = await make_sienge_request("GET", "/units", params=params)
1868
+
1869
+ if result["success"]:
1870
+ data = result["data"]
1871
+ units = data.get("results", []) if isinstance(data, dict) else data
1872
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
1873
+ total_count = metadata.get("count", len(units))
1874
+
1875
+ return {
1876
+ "success": True,
1877
+ "message": f"✅ Encontradas {len(units)} unidades (total: {total_count})",
1878
+ "units": units,
1879
+ "count": len(units),
1880
+ "total_count": total_count,
1881
+ "request_id": result.get("request_id"),
1882
+ "latency_ms": result.get("latency_ms")
1883
+ }
1884
+
1885
+ return {
1886
+ "success": False,
1887
+ "message": "❌ Erro ao buscar unidades",
1888
+ "error": result.get("error"),
1889
+ "details": result.get("message"),
1890
+ "request_id": result.get("request_id"),
1891
+ "latency_ms": result.get("latency_ms")
1892
+ }
1893
+
1894
+
1895
+ # ============ CUSTOS ============
1896
+
1897
+
1898
+ @mcp.tool
1899
+ async def get_sienge_unit_cost_tables(
1900
+ table_code: Optional[str] = None,
1901
+ description: Optional[str] = None,
1902
+ status: Optional[str] = "Active",
1903
+ integration_enabled: Optional[bool] = None,
1904
+ _meta: Optional[Dict] = None) -> Dict:
1905
+ """
1906
+ Consulta tabelas de custos unitários
1907
+
1908
+ Args:
1909
+ table_code: Código da tabela (opcional)
1910
+ description: Descrição da tabela (opcional)
1911
+ status: Status (Active/Inactive)
1912
+ integration_enabled: Se habilitada para integração
1913
+ """
1914
+ params = {"status": status or "Active"}
1915
+
1916
+ if table_code:
1917
+ params["table_code"] = table_code
1918
+ if description:
1919
+ params["description"] = description
1920
+ if integration_enabled is not None:
1921
+ params["integration_enabled"] = integration_enabled
1922
+
1923
+ result = await make_sienge_request("GET", "/unit-cost-tables", params=params)
1924
+
1925
+ if result["success"]:
1926
+ data = result["data"]
1927
+ tables = data.get("results", []) if isinstance(data, dict) else data
1928
+
1929
+ return {
1930
+ "success": True,
1931
+ "message": f"✅ Encontradas {len(tables)} tabelas de custos",
1932
+ "cost_tables": tables,
1933
+ "count": len(tables),
1934
+ }
1935
+
1936
+ return {
1937
+ "success": False,
1938
+ "message": "❌ Erro ao buscar tabelas de custos",
1939
+ "error": result.get("error"),
1940
+ "details": result.get("message"),
1941
+ }
1942
+
1943
+
1944
+ # ============ SEARCH UNIVERSAL (COMPATIBILIDADE CHATGPT) ============
1945
+
1946
+
1947
+ @mcp.tool
1948
+ async def search_sienge_data(
1949
+ query: str,
1950
+ entity_type: Optional[str] = None,
1951
+ limit: Optional[int] = 20,
1952
+ filters: Optional[Dict[str, Any]] = None,
1953
+ _meta: Optional[Dict] = None) -> Dict:
1954
+ """
1955
+ Busca universal no Sienge - compatível com ChatGPT/OpenAI MCP
1956
+
1957
+ Permite buscar em múltiplas entidades do Sienge de forma unificada.
1958
+
1959
+ Args:
1960
+ query: Termo de busca (nome, código, descrição, etc.)
1961
+ entity_type: Tipo de entidade (customers, creditors, projects, bills, purchase_orders, etc.)
1962
+ limit: Máximo de registros (padrão: 20, máximo: 100)
1963
+ filters: Filtros específicos por tipo de entidade
1964
+ """
1965
+ search_results = []
1966
+ limit = min(limit or 20, 100)
1967
+
1968
+ # Se entity_type específico, buscar apenas nele
1969
+ if entity_type:
1970
+ result = await _search_specific_entity(entity_type, query, limit, filters or {})
1971
+ if result["success"]:
1972
+ return result
1973
+ else:
1974
+ return {
1975
+ "success": False,
1976
+ "message": f"❌ Erro na busca em {entity_type}",
1977
+ "error": result.get("error"),
1978
+ "query": query,
1979
+ "entity_type": entity_type
1980
+ }
1981
+
1982
+ # Busca universal em múltiplas entidades
1983
+ entities_to_search = [
1984
+ ("customers", "clientes"),
1985
+ ("creditors", "credores/fornecedores"),
1986
+ ("projects", "empreendimentos/obras"),
1987
+ ("bills", "títulos a pagar"),
1988
+ ("purchase_orders", "pedidos de compra")
1989
+ ]
1990
+
1991
+ total_found = 0
1992
+
1993
+ for entity_key, entity_name in entities_to_search:
1994
+ try:
1995
+ entity_result = await _search_specific_entity(entity_key, query, min(5, limit), {})
1996
+ if entity_result["success"] and entity_result.get("count", 0) > 0:
1997
+ search_results.append({
1998
+ "entity_type": entity_key,
1999
+ "entity_name": entity_name,
2000
+ "count": entity_result["count"],
2001
+ "results": entity_result["data"][:5], # Limitar a 5 por entidade na busca universal
2002
+ "has_more": entity_result["count"] > 5
2003
+ })
2004
+ total_found += entity_result["count"]
2005
+ except Exception as e:
2006
+ # Continuar com outras entidades se uma falhar
2007
+ continue
2008
+
2009
+ if search_results:
2010
+ return {
2011
+ "success": True,
2012
+ "message": f"✅ Busca '{query}' encontrou resultados em {len(search_results)} entidades (total: {total_found} registros)",
2013
+ "query": query,
2014
+ "total_entities": len(search_results),
2015
+ "total_records": total_found,
2016
+ "results_by_entity": search_results,
2017
+ "suggestion": "Use entity_type para buscar especificamente em uma entidade e obter mais resultados"
2018
+ }
2019
+ else:
2020
+ return {
2021
+ "success": False,
2022
+ "message": f"❌ Nenhum resultado encontrado para '{query}'",
2023
+ "query": query,
2024
+ "searched_entities": [name for _, name in entities_to_search],
2025
+ "suggestion": "Tente termos mais específicos ou use os tools específicos de cada entidade"
2026
+ }
2027
+
2028
+
2029
+ async def _search_specific_entity(entity_type: str, query: str, limit: int, filters: Dict) -> Dict:
2030
+ """
2031
+ Função auxiliar para buscar em uma entidade específica
2032
+ CORRIGIDO: Usa serviços internos, nunca outras tools
2033
+ """
2034
+
2035
+ if entity_type == "customers":
2036
+ result = await _svc_get_customers(limit=limit, search=query)
2037
+ if result["success"]:
2038
+ data = result["data"]
2039
+ customers = data.get("results", []) if isinstance(data, dict) else data
2040
+ return {
2041
+ "success": True,
2042
+ "data": customers,
2043
+ "count": len(customers),
2044
+ "entity_type": "customers"
2045
+ }
2046
+
2047
+ elif entity_type == "creditors":
2048
+ result = await _svc_get_creditors(limit=limit, search=query)
2049
+ if result["success"]:
2050
+ data = result["data"]
2051
+ creditors = data.get("results", []) if isinstance(data, dict) else data
2052
+ return {
2053
+ "success": True,
2054
+ "data": creditors,
2055
+ "count": len(creditors),
2056
+ "entity_type": "creditors"
2057
+ }
2058
+
2059
+ elif entity_type == "projects" or entity_type == "enterprises":
2060
+ # Para projetos, usar filtros mais específicos se disponível
2061
+ company_id = filters.get("company_id")
2062
+ result = await _svc_get_projects(limit=limit, company_id=company_id)
2063
+ if result["success"]:
2064
+ data = result["data"]
2065
+ projects = data.get("results", []) if isinstance(data, dict) else data
2066
+
2067
+ # Filtrar por query se fornecida
2068
+ if query:
2069
+ projects = [
2070
+ p for p in projects
2071
+ if query.lower() in str(p.get("description", "")).lower()
2072
+ or query.lower() in str(p.get("name", "")).lower()
2073
+ or query.lower() in str(p.get("code", "")).lower()
2074
+ ]
2075
+ return {
2076
+ "success": True,
2077
+ "data": projects,
2078
+ "count": len(projects),
2079
+ "entity_type": "projects"
2080
+ }
2081
+
2082
+ elif entity_type == "bills":
2083
+ # Para títulos, usar data padrão se não especificada
2084
+ start_date = filters.get("start_date")
2085
+ end_date = filters.get("end_date")
2086
+ result = await _svc_get_bills(
2087
+ start_date=start_date,
2088
+ end_date=end_date,
2089
+ limit=limit
2090
+ )
2091
+ if result["success"]:
2092
+ data = result["data"]
2093
+ bills = data.get("results", []) if isinstance(data, dict) else data
2094
+ return {
2095
+ "success": True,
2096
+ "data": bills,
2097
+ "count": len(bills),
2098
+ "entity_type": "bills"
2099
+ }
2100
+
2101
+ elif entity_type == "purchase_orders":
2102
+ result = await _svc_get_purchase_orders(limit=limit)
2103
+ if result["success"]:
2104
+ data = result["data"]
2105
+ orders = data.get("results", []) if isinstance(data, dict) else data
2106
+
2107
+ # Filtrar por query se fornecida
2108
+ if query:
2109
+ orders = [
2110
+ o for o in orders
2111
+ if query.lower() in str(o.get("description", "")).lower()
2112
+ or query.lower() in str(o.get("id", "")).lower()
2113
+ ]
2114
+ return {
2115
+ "success": True,
2116
+ "data": orders,
2117
+ "count": len(orders),
2118
+ "entity_type": "purchase_orders"
2119
+ }
2120
+
2121
+ # Se chegou aqui, entidade não suportada ou erro
2122
+ return {
2123
+ "success": False,
2124
+ "error": f"Entidade '{entity_type}' não suportada ou erro na busca",
2125
+ "supported_entities": ["customers", "creditors", "projects", "bills", "purchase_orders"]
2126
+ }
2127
+
2128
+
2129
+ @mcp.tool
2130
+ async def list_sienge_entities(_meta: Optional[Dict] = None) -> Dict:
2131
+ """
2132
+ Lista todas as entidades disponíveis no Sienge MCP para busca
2133
+
2134
+ Retorna informações sobre os tipos de dados que podem ser consultados
2135
+ """
2136
+ entities = [
2137
+ {
2138
+ "type": "customers",
2139
+ "name": "Clientes",
2140
+ "description": "Clientes cadastrados no sistema",
2141
+ "search_fields": ["nome", "documento", "email"],
2142
+ "tools": ["get_sienge_customers", "search_sienge_data"]
2143
+ },
2144
+ {
2145
+ "type": "creditors",
2146
+ "name": "Credores/Fornecedores",
2147
+ "description": "Fornecedores e credores cadastrados",
2148
+ "search_fields": ["nome", "documento"],
2149
+ "tools": ["get_sienge_creditors", "get_sienge_creditor_bank_info", "get_sienge_suppliers"]
2150
+ },
2151
+ {
2152
+ "type": "projects",
2153
+ "name": "Empreendimentos/Obras",
2154
+ "description": "Projetos e obras cadastrados",
2155
+ "search_fields": ["código", "descrição", "nome"],
2156
+ "tools": ["get_sienge_projects", "get_sienge_enterprises", "get_sienge_enterprise_by_id"]
2157
+ },
2158
+ {
2159
+ "type": "bills",
2160
+ "name": "Títulos a Pagar",
2161
+ "description": "Contas a pagar e títulos financeiros",
2162
+ "search_fields": ["número", "credor", "valor"],
2163
+ "tools": ["get_sienge_bills", "get_sienge_accounts_payable"]
2164
+ },
2165
+ {
2166
+ "type": "purchase_orders",
2167
+ "name": "Pedidos de Compra",
2168
+ "description": "Pedidos de compra e solicitações",
2169
+ "search_fields": ["id", "descrição", "status"],
2170
+ "tools": ["get_sienge_purchase_orders", "get_sienge_purchase_requests", "list_sienge_purchase_requests"]
2171
+ },
2172
+ {
2173
+ "type": "invoices",
2174
+ "name": "Notas Fiscais",
2175
+ "description": "Notas fiscais de compra",
2176
+ "search_fields": ["número", "série", "fornecedor"],
2177
+ "tools": ["get_sienge_purchase_invoice"]
2178
+ },
2179
+ {
2180
+ "type": "stock",
2181
+ "name": "Estoque",
2182
+ "description": "Inventário e movimentações de estoque",
2183
+ "search_fields": ["centro_custo", "recurso"],
2184
+ "tools": ["get_sienge_stock_inventory", "get_sienge_stock_reservations"]
2185
+ },
2186
+ {
2187
+ "type": "financial",
2188
+ "name": "Financeiro",
2189
+ "description": "Contas a receber e movimentações financeiras",
2190
+ "search_fields": ["período", "cliente", "valor"],
2191
+ "tools": ["get_sienge_accounts_receivable", "search_sienge_financial_data", "search_sienge_finances"]
2192
+ },
2193
+ {
2194
+ "type": "suppliers",
2195
+ "name": "Fornecedores",
2196
+ "description": "Fornecedores e credores cadastrados",
2197
+ "search_fields": ["código", "nome", "razão social"],
2198
+ "tools": ["get_sienge_suppliers"]
2199
+ }
2200
+ ]
2201
+
2202
+ return {
2203
+ "success": True,
2204
+ "message": f"✅ {len(entities)} tipos de entidades disponíveis no Sienge",
2205
+ "entities": entities,
2206
+ "total_tools": sum(len(e["tools"]) for e in entities),
2207
+ "usage_example": {
2208
+ "search_all": "search_sienge_data('nome_cliente')",
2209
+ "search_specific": "search_sienge_data('nome_cliente', entity_type='customers')",
2210
+ "direct_access": "get_sienge_customers(search='nome_cliente')"
2211
+ }
2212
+ }
2213
+
2214
+
2215
+ # ============ PAGINATION E NAVEGAÇÃO ============
2216
+
2217
+
2218
+ @mcp.tool
2219
+ async def get_sienge_data_paginated(
2220
+ entity_type: str,
2221
+ page: int = 1,
2222
+ page_size: int = 20,
2223
+ filters: Optional[Dict[str, Any]] = None,
2224
+ sort_by: Optional[str] = None,
2225
+ _meta: Optional[Dict] = None) -> Dict:
2226
+ """
2227
+ Busca dados do Sienge com paginação avançada - compatível com ChatGPT
2228
+
2229
+ Args:
2230
+ entity_type: Tipo de entidade (customers, creditors, projects, bills, etc.)
2231
+ page: Número da página (começando em 1)
2232
+ page_size: Registros por página (máximo 50)
2233
+ filters: Filtros específicos da entidade
2234
+ sort_by: Campo para ordenação (se suportado)
2235
+ """
2236
+ page_size = min(page_size, 50)
2237
+ offset = (page - 1) * page_size
2238
+
2239
+ filters = filters or {}
2240
+
2241
+ # CORRIGIDO: Mapear para serviços internos, não tools
2242
+ if entity_type == "customers":
2243
+ search = filters.get("search")
2244
+ customer_type_id = filters.get("customer_type_id")
2245
+ result = await _svc_get_customers(
2246
+ limit=page_size,
2247
+ offset=offset,
2248
+ search=search,
2249
+ customer_type_id=customer_type_id
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["customers"] = items
2256
+ result["count"] = len(items)
2257
+ result["total_count"] = total
2258
+
2259
+ elif entity_type == "creditors":
2260
+ search = filters.get("search")
2261
+ result = await _svc_get_creditors(
2262
+ limit=page_size,
2263
+ offset=offset,
2264
+ search=search
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["creditors"] = items
2271
+ result["count"] = len(items)
2272
+ result["total_count"] = total
2273
+
2274
+ elif entity_type == "projects":
2275
+ result = await _svc_get_projects(
2276
+ limit=page_size,
2277
+ offset=offset,
2278
+ company_id=filters.get("company_id"),
2279
+ enterprise_type=filters.get("enterprise_type")
2280
+ )
2281
+ # CORRIGIDO: Extrair items e total corretamente
2282
+ if result["success"]:
2283
+ data = result["data"]
2284
+ items, total = _extract_items_and_total(data)
2285
+ result["projects"] = items
2286
+ result["enterprises"] = items # Para compatibilidade
2287
+ result["count"] = len(items)
2288
+ result["total_count"] = total
2289
+
2290
+ elif entity_type == "bills":
2291
+ result = await _svc_get_bills(
2292
+ start_date=filters.get("start_date"),
2293
+ end_date=filters.get("end_date"),
2294
+ creditor_id=filters.get("creditor_id"),
2295
+ status=filters.get("status"),
2296
+ limit=page_size
2297
+ )
2298
+ # CORRIGIDO: Extrair items e total corretamente
2299
+ if result["success"]:
2300
+ data = result["data"]
2301
+ items, total = _extract_items_and_total(data)
2302
+ result["bills"] = items
2303
+ result["count"] = len(items)
2304
+ result["total_count"] = total
2305
+
2306
+ else:
2307
+ return {
2308
+ "success": False,
2309
+ "message": f"❌ Tipo de entidade '{entity_type}' não suportado para paginação",
2310
+ "supported_types": ["customers", "creditors", "projects", "bills"]
2311
+ }
2312
+
2313
+ if result["success"]:
2314
+ # Calcular informações de paginação
2315
+ total_count = result.get("total_count", result.get("count", 0))
2316
+ total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
2317
+
2318
+ return {
2319
+ "success": True,
2320
+ "message": f"✅ Página {page} de {total_pages} - {entity_type}",
2321
+ "data": result.get(entity_type, result.get("data", [])),
2322
+ "pagination": {
2323
+ "current_page": page,
2324
+ "page_size": page_size,
2325
+ "total_pages": total_pages,
2326
+ "total_records": total_count,
2327
+ "has_next": page < total_pages,
2328
+ "has_previous": page > 1,
2329
+ "next_page": page + 1 if page < total_pages else None,
2330
+ "previous_page": page - 1 if page > 1 else None
2331
+ },
2332
+ "entity_type": entity_type,
2333
+ "filters_applied": filters
2334
+ }
2335
+
2336
+ return result
2337
+
2338
+
2339
+ @mcp.tool
2340
+ async def search_sienge_financial_data(
2341
+ period_start: str,
2342
+ period_end: str,
2343
+ search_type: str = "both",
2344
+ amount_min: Optional[float] = None,
2345
+ amount_max: Optional[float] = None,
2346
+ customer_creditor_search: Optional[str] = None,
2347
+ _meta: Optional[Dict] = None) -> Dict:
2348
+ """
2349
+ Busca avançada em dados financeiros do Sienge - Contas a Pagar e Receber
2350
+
2351
+ Args:
2352
+ period_start: Data inicial do período (YYYY-MM-DD)
2353
+ period_end: Data final do período (YYYY-MM-DD)
2354
+ search_type: Tipo de busca ("receivable", "payable", "both")
2355
+ amount_min: Valor mínimo (opcional)
2356
+ amount_max: Valor máximo (opcional)
2357
+ customer_creditor_search: Buscar por nome de cliente/credor (opcional)
2358
+ """
2359
+
2360
+ financial_results = {
2361
+ "receivable": {"success": False, "data": [], "count": 0, "error": None},
2362
+ "payable": {"success": False, "data": [], "count": 0, "error": None}
2363
+ }
2364
+
2365
+ # Buscar contas a receber
2366
+ if search_type in ["receivable", "both"]:
2367
+ try:
2368
+ # CORRIGIDO: Usar serviço interno
2369
+ receivable_result = await _svc_get_accounts_receivable(
2370
+ start_date=period_start,
2371
+ end_date=period_end,
2372
+ selection_type="D" # Por vencimento
2373
+ )
2374
+
2375
+ if receivable_result["success"]:
2376
+ receivable_data = receivable_result.get("data", [])
2377
+
2378
+ # CORRIGIDO: Aplicar filtros de valor usando _parse_numeric_value
2379
+ if amount_min is not None or amount_max is not None:
2380
+ filtered_data = []
2381
+ for item in receivable_data:
2382
+ amount = _parse_numeric_value(item.get("amount", 0))
2383
+ if amount_min is not None and amount < amount_min:
2384
+ continue
2385
+ if amount_max is not None and amount > amount_max:
2386
+ continue
2387
+ filtered_data.append(item)
2388
+ receivable_data = filtered_data
2389
+
2390
+ # Aplicar filtro de cliente se especificado
2391
+ if customer_creditor_search:
2392
+ search_lower = customer_creditor_search.lower()
2393
+ filtered_data = []
2394
+ for item in receivable_data:
2395
+ customer_name = str(item.get("customer_name", "")).lower()
2396
+ if search_lower in customer_name:
2397
+ filtered_data.append(item)
2398
+ receivable_data = filtered_data
2399
+
2400
+ financial_results["receivable"] = {
2401
+ "success": True,
2402
+ "data": receivable_data,
2403
+ "count": len(receivable_data),
2404
+ "error": None
2405
+ }
2406
+ else:
2407
+ financial_results["receivable"]["error"] = receivable_result.get("error")
2408
+
2409
+ except Exception as e:
2410
+ financial_results["receivable"]["error"] = str(e)
2411
+
2412
+ # Buscar contas a pagar
2413
+ if search_type in ["payable", "both"]:
2414
+ try:
2415
+ # CORRIGIDO: Usar serviço interno
2416
+ payable_result = await _svc_get_bills(
2417
+ start_date=period_start,
2418
+ end_date=period_end,
2419
+ limit=100
2420
+ )
2421
+
2422
+ if payable_result["success"]:
2423
+ data = payable_result["data"]
2424
+ payable_data = data.get("results", []) if isinstance(data, dict) else data
2425
+
2426
+ # CORRIGIDO: Aplicar filtros de valor usando _parse_numeric_value
2427
+ if amount_min is not None or amount_max is not None:
2428
+ filtered_data = []
2429
+ for item in payable_data:
2430
+ amount = _parse_numeric_value(item.get("amount", 0))
2431
+ if amount_min is not None and amount < amount_min:
2432
+ continue
2433
+ if amount_max is not None and amount > amount_max:
2434
+ continue
2435
+ filtered_data.append(item)
2436
+ payable_data = filtered_data
2437
+
2438
+ # Aplicar filtro de credor se especificado
2439
+ if customer_creditor_search:
2440
+ search_lower = customer_creditor_search.lower()
2441
+ filtered_data = []
2442
+ for item in payable_data:
2443
+ creditor_name = str(item.get("creditor_name", "")).lower()
2444
+ if search_lower in creditor_name:
2445
+ filtered_data.append(item)
2446
+ payable_data = filtered_data
2447
+
2448
+ financial_results["payable"] = {
2449
+ "success": True,
2450
+ "data": payable_data,
2451
+ "count": len(payable_data),
2452
+ "error": None
2453
+ }
2454
+ else:
2455
+ financial_results["payable"]["error"] = payable_result.get("error")
2456
+
2457
+ except Exception as e:
2458
+ financial_results["payable"]["error"] = str(e)
2459
+
2460
+ # Compilar resultado final
2461
+ total_records = financial_results["receivable"]["count"] + financial_results["payable"]["count"]
2462
+ has_errors = bool(financial_results["receivable"]["error"] or financial_results["payable"]["error"])
2463
+
2464
+ summary = {
2465
+ "period": f"{period_start} a {period_end}",
2466
+ "search_type": search_type,
2467
+ "total_records": total_records,
2468
+ "receivable_count": financial_results["receivable"]["count"],
2469
+ "payable_count": financial_results["payable"]["count"],
2470
+ "filters_applied": {
2471
+ "amount_range": f"{amount_min or 'sem mín'} - {amount_max or 'sem máx'}",
2472
+ "customer_creditor": customer_creditor_search or "todos"
2473
+ }
2474
+ }
2475
+
2476
+ if total_records > 0:
2477
+ return {
2478
+ "success": True,
2479
+ "message": f"✅ Busca financeira encontrou {total_records} registros no período",
2480
+ "summary": summary,
2481
+ "receivable": financial_results["receivable"],
2482
+ "payable": financial_results["payable"],
2483
+ "has_errors": has_errors
2484
+ }
2485
+ else:
2486
+ return {
2487
+ "success": False,
2488
+ "message": f"❌ Nenhum registro financeiro encontrado no período {period_start} a {period_end}",
2489
+ "summary": summary,
2490
+ "errors": {
2491
+ "receivable": financial_results["receivable"]["error"],
2492
+ "payable": financial_results["payable"]["error"]
2493
+ }
2494
+ }
2495
+
2496
+
2497
+ @mcp.tool
2498
+ async def get_sienge_dashboard_summary(_meta: Optional[Dict] = None) -> Dict:
2499
+ """
2500
+ Obtém um resumo tipo dashboard com informações gerais do Sienge
2501
+ Útil para visão geral rápida do sistema
2502
+ """
2503
+
2504
+ # Data atual e períodos
2505
+ today = datetime.now()
2506
+ current_month_start = today.replace(day=1).strftime("%Y-%m-%d")
2507
+ current_month_end = today.strftime("%Y-%m-%d")
2508
+
2509
+ dashboard_data = {}
2510
+ errors = []
2511
+
2512
+ # 1. Testar conexão
2513
+ try:
2514
+ connection_test = await test_sienge_connection()
2515
+ dashboard_data["connection"] = connection_test
2516
+ except Exception as e:
2517
+ errors.append(f"Teste de conexão: {str(e)}")
2518
+ dashboard_data["connection"] = {"success": False, "error": str(e)}
2519
+
2520
+ # 2. Contar clientes (amostra)
2521
+ try:
2522
+ # CORRIGIDO: Usar serviço interno
2523
+ customers_result = await _svc_get_customers(limit=1)
2524
+ dashboard_data["customers"] = {"available": customers_result["success"]}
2525
+ except Exception as e:
2526
+ errors.append(f"Clientes: {str(e)}")
2527
+ dashboard_data["customers"] = {"available": False}
2528
+
2529
+ # 3. Contar projetos (amostra)
2530
+ try:
2531
+ # CORRIGIDO: Usar serviço interno
2532
+ projects_result = await _svc_get_projects(limit=5)
2533
+ if projects_result["success"]:
2534
+ data = projects_result["data"]
2535
+ enterprises = data.get("results", []) if isinstance(data, dict) else data
2536
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
2537
+
2538
+ dashboard_data["projects"] = {
2539
+ "available": True,
2540
+ "sample_count": len(enterprises),
2541
+ "total_count": metadata.get("count", "N/A")
2542
+ }
2543
+ else:
2544
+ dashboard_data["projects"] = {"available": False}
2545
+ except Exception as e:
2546
+ errors.append(f"Projetos: {str(e)}")
2547
+ dashboard_data["projects"] = {"available": False, "error": str(e)}
2548
+
2549
+ # 4. Títulos a pagar do mês atual
2550
+ try:
2551
+ # CORRIGIDO: Usar serviço interno
2552
+ bills_result = await _svc_get_bills(
2553
+ start_date=current_month_start,
2554
+ end_date=current_month_end,
2555
+ limit=10
2556
+ )
2557
+ if bills_result["success"]:
2558
+ data = bills_result["data"]
2559
+ bills = data.get("results", []) if isinstance(data, dict) else data
2560
+ metadata = data.get("resultSetMetadata", {}) if isinstance(data, dict) else {}
2561
+
2562
+ dashboard_data["monthly_bills"] = {
2563
+ "available": True,
2564
+ "count": len(bills),
2565
+ "total_count": metadata.get("count", len(bills))
2566
+ }
2567
+ else:
2568
+ dashboard_data["monthly_bills"] = {"available": False}
2569
+ except Exception as e:
2570
+ errors.append(f"Títulos mensais: {str(e)}")
2571
+ dashboard_data["monthly_bills"] = {"available": False, "error": str(e)}
2572
+
2573
+ # 5. Tipos de clientes
2574
+ try:
2575
+ # CORRIGIDO: Usar serviço interno
2576
+ customer_types_result = await _svc_get_customer_types()
2577
+ if customer_types_result["success"]:
2578
+ data = customer_types_result["data"]
2579
+ customer_types = data.get("results", []) if isinstance(data, dict) else data
2580
+
2581
+ dashboard_data["customer_types"] = {
2582
+ "available": True,
2583
+ "count": len(customer_types)
2584
+ }
2585
+ else:
2586
+ dashboard_data["customer_types"] = {"available": False}
2587
+ except Exception as e:
2588
+ dashboard_data["customer_types"] = {"available": False, "error": str(e)}
2589
+
2590
+ # Compilar resultado
2591
+ available_modules = sum(1 for key, value in dashboard_data.items()
2592
+ if key != "connection" and isinstance(value, dict) and value.get("available"))
2593
+
2594
+ return {
2595
+ "success": True,
2596
+ "message": f"✅ Dashboard do Sienge - {available_modules} módulos disponíveis",
2597
+ "timestamp": today.isoformat(),
2598
+ "period_analyzed": f"{current_month_start} a {current_month_end}",
2599
+ "modules_status": dashboard_data,
2600
+ "available_modules": available_modules,
2601
+ "errors": errors if errors else None,
2602
+ "quick_actions": [
2603
+ "search_sienge_data('termo_busca') - Busca universal",
2604
+ "list_sienge_entities() - Listar tipos de dados",
2605
+ "get_sienge_customers(search='nome') - Buscar clientes",
2606
+ "get_sienge_projects() - Listar projetos/obras",
2607
+ "search_sienge_financial_data('2024-01-01', '2024-12-31') - Dados financeiros"
2608
+ ]
2609
+ }
2610
+
2611
+
2612
+ # ============ UTILITÁRIOS ============
2613
+
2614
+
2615
+ @mcp.tool
2616
+ def add(a: int, b: int) -> int:
2617
+ """Soma dois números (função de teste)"""
2618
+ return a + b
2619
+
2620
+
2621
+ def _mask(s: str) -> str:
2622
+ """Mascara dados sensíveis mantendo apenas o início e fim"""
2623
+ if not s:
2624
+ return None
2625
+ if len(s) == 1:
2626
+ return s + "*"
2627
+ if len(s) == 2:
2628
+ return s
2629
+ if len(s) <= 4:
2630
+ return s[:2] + "*" * (len(s) - 2)
2631
+ # Para strings > 4: usar no máximo 4 asteriscos no meio
2632
+ middle_asterisks = min(4, len(s) - 4)
2633
+ return s[:2] + "*" * middle_asterisks + s[-2:]
2634
+
2635
+
2636
+ def _get_auth_info_internal() -> Dict:
2637
+ """Função interna para verificar configuração de autenticação"""
2638
+ if SIENGE_API_KEY and SIENGE_API_KEY != "sua_api_key_aqui":
2639
+ return {"auth_method": "Bearer Token", "configured": True, "base_url": SIENGE_BASE_URL, "api_key_configured": True}
2640
+ elif SIENGE_USERNAME and SIENGE_PASSWORD:
2641
+ return {
2642
+ "auth_method": "Basic Auth",
2643
+ "configured": True,
2644
+ "base_url": SIENGE_BASE_URL,
2645
+ "subdomain": SIENGE_SUBDOMAIN,
2646
+ "username": _mask(SIENGE_USERNAME) if SIENGE_USERNAME else None,
2647
+ }
2648
+ else:
2649
+ return {
2650
+ "auth_method": "None",
2651
+ "configured": False,
2652
+ "message": "Configure SIENGE_API_KEY ou SIENGE_USERNAME/PASSWORD no .env",
2653
+ }
2654
+
2655
+
2656
+ @mcp.tool
2657
+ def get_auth_info() -> Dict:
2658
+ """Retorna informações sobre a configuração de autenticação"""
2659
+ return _get_auth_info_internal()
2660
+
2661
+
2662
+ def main():
2663
+ """Entry point for the Sienge MCP Server"""
2664
+ print("* Iniciando Sienge MCP Server (FastMCP)...")
2665
+
2666
+ # Mostrar info de configuração
2667
+ auth_info = _get_auth_info_internal()
2668
+ print(f"* Autenticacao: {auth_info['auth_method']}")
2669
+ print(f"* Configurado: {auth_info['configured']}")
2670
+
2671
+ if not auth_info["configured"]:
2672
+ print("* ERRO: Autenticacao nao configurada!")
2673
+ print("Configure as variáveis de ambiente:")
2674
+ print("- SIENGE_API_KEY (Bearer Token) OU")
2675
+ print("- SIENGE_USERNAME + SIENGE_PASSWORD + SIENGE_SUBDOMAIN (Basic Auth)")
2676
+ print("- SIENGE_BASE_URL (padrão: https://api.sienge.com.br)")
2677
+ print("")
2678
+ print("Exemplo no Windows PowerShell:")
2679
+ print('$env:SIENGE_USERNAME="seu_usuario"')
2680
+ print('$env:SIENGE_PASSWORD="sua_senha"')
2681
+ print('$env:SIENGE_SUBDOMAIN="sua_empresa"')
2682
+ print("")
2683
+ print("Exemplo no Linux/Mac:")
2684
+ print('export SIENGE_USERNAME="seu_usuario"')
2685
+ print('export SIENGE_PASSWORD="sua_senha"')
2686
+ print('export SIENGE_SUBDOMAIN="sua_empresa"')
2687
+ else:
2688
+ print("* MCP pronto para uso!")
2689
+
2690
+ mcp.run()
2691
+
2692
+
2693
+ if __name__ == "__main__":
2694
+ main()