compraspublica-shared 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. compraspublica_shared/__init__.py +37 -0
  2. compraspublica_shared/_config.py +144 -0
  3. compraspublica_shared/_log.py +39 -0
  4. compraspublica_shared/amm_diario/__init__.py +42 -0
  5. compraspublica_shared/amm_diario/amm.py +128 -0
  6. compraspublica_shared/analise_risco/__init__.py +60 -0
  7. compraspublica_shared/analise_risco/_canon.py +168 -0
  8. compraspublica_shared/analise_risco/_util.py +52 -0
  9. compraspublica_shared/analise_risco/aditivos.py +58 -0
  10. compraspublica_shared/analise_risco/benford.py +63 -0
  11. compraspublica_shared/analise_risco/direcionamento.py +63 -0
  12. compraspublica_shared/analise_risco/fracionamento.py +99 -0
  13. compraspublica_shared/analise_risco/grafo.py +110 -0
  14. compraspublica_shared/analise_risco/laranjas.py +105 -0
  15. compraspublica_shared/analise_risco/models.py +120 -0
  16. compraspublica_shared/analise_risco/nepotismo.py +140 -0
  17. compraspublica_shared/analise_risco/ordem_pagamento.py +54 -0
  18. compraspublica_shared/analise_risco/pagamentos_duplicados.py +133 -0
  19. compraspublica_shared/analise_risco/regras.py +88 -0
  20. compraspublica_shared/analise_risco/restos_a_pagar.py +77 -0
  21. compraspublica_shared/analise_risco/score.py +228 -0
  22. compraspublica_shared/api/__init__.py +4 -0
  23. compraspublica_shared/api/main.py +252 -0
  24. compraspublica_shared/brasilapi/__init__.py +41 -0
  25. compraspublica_shared/brasilapi/_minha_receita.py +37 -0
  26. compraspublica_shared/brasilapi/cli.py +131 -0
  27. compraspublica_shared/brasilapi/cliente.py +277 -0
  28. compraspublica_shared/cadprev/__init__.py +60 -0
  29. compraspublica_shared/cadprev/api.py +346 -0
  30. compraspublica_shared/cnes/__init__.py +44 -0
  31. compraspublica_shared/cnes/datasus.py +176 -0
  32. compraspublica_shared/emendas/__init__.py +28 -0
  33. compraspublica_shared/emendas/cgu_api.py +65 -0
  34. compraspublica_shared/erros.py +34 -0
  35. compraspublica_shared/geocode/__init__.py +47 -0
  36. compraspublica_shared/geocode/nominatim.py +229 -0
  37. compraspublica_shared/geocode/scoring.py +194 -0
  38. compraspublica_shared/http/__init__.py +40 -0
  39. compraspublica_shared/http/escalonador.py +262 -0
  40. compraspublica_shared/portal_transparencia/__init__.py +38 -0
  41. compraspublica_shared/portal_transparencia/cliente.py +220 -0
  42. compraspublica_shared/precos_cascata/__init__.py +60 -0
  43. compraspublica_shared/precos_cascata/cascata.py +308 -0
  44. compraspublica_shared/precos_cascata/data/baseline_precos.json +179 -0
  45. compraspublica_shared/precos_cascata/data/catmat_map.json +19 -0
  46. compraspublica_shared/py.typed +1 -0
  47. compraspublica_shared/sancoes/__init__.py +37 -0
  48. compraspublica_shared/sancoes/base_local.py +332 -0
  49. compraspublica_shared/sancoes/cli.py +129 -0
  50. compraspublica_shared/tse/__init__.py +46 -0
  51. compraspublica_shared/tse/cruzamento.py +267 -0
  52. compraspublica_shared/tse/parquet_loader.py +117 -0
  53. compraspublica_shared-0.9.0.dist-info/METADATA +231 -0
  54. compraspublica_shared-0.9.0.dist-info/RECORD +58 -0
  55. compraspublica_shared-0.9.0.dist-info/WHEEL +5 -0
  56. compraspublica_shared-0.9.0.dist-info/entry_points.txt +3 -0
  57. compraspublica_shared-0.9.0.dist-info/licenses/LICENSE +21 -0
  58. compraspublica_shared-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,37 @@
1
+ """compraspublica-shared — forense pública de contratações.
2
+
3
+ Camada compartilhada entre o projeto de auditoria independente
4
+ [`EnemyDisclosure`](https://github.com/renato0503/EnemyDisclosure) e o painel
5
+ de gestão de contratações
6
+ [`CompraPublica.AI`](https://github.com/renato0503/compraspublica.ai).
7
+
8
+ Roadmap completo no `README.md` e em
9
+ `https://github.com/renato0503/EnemyDisclosure/blob/main/sprint.md` (PARTE E).
10
+ """
11
+
12
+ __version__ = "0.9.0"
13
+ __author__ = "Renato Rosa"
14
+ __email__ = "renato0503@gmail.com"
15
+ __license__ = "MIT"
16
+
17
+ from . import _config # noqa: F401 (configures env vars on import)
18
+ from . import brasilapi, erros, _log, sancoes, tse
19
+
20
+ logger = _log.logger
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "__author__",
25
+ "__email__",
26
+ "__license__",
27
+ "brasilapi",
28
+ "erros",
29
+ "logger",
30
+ "sancoes",
31
+ "tse",
32
+ ]
33
+
34
+
35
+
36
+
37
+
@@ -0,0 +1,144 @@
1
+ """Configuração central do pacote `compraspublica-shared`.
2
+
3
+ Todas as variáveis são lidas de env vars (ou de defaults seguros). Não
4
+ persistimos configuração fora do ambiente — o diretório de cache é
5
+ determinado uma vez no import.
6
+
7
+ Variáveis reconhecidas:
8
+ - `COMPPUBL_CACHE_DIR` — diretório raiz de cache (default `~/.compraspubl/cache`)
9
+ - `COMPPUBL_HTTP_UA` — User-Agent para chamadas HTTP
10
+ - `COMPPUBL_RATE_LIMIT` — segundos entre chamadas a APIs externas
11
+ - `COMPPUBL_LOG_LEVEL` — nível do logger (DEBUG/INFO/WARNING/ERROR)
12
+ - `COMPPUBL_SANCOES_DIR` — path do cache de CEIS/CNEP/CEPIM/Leniência
13
+ - `COMPPUBL_BRASILAPI_CACHE` — path do cache de BrasilAPI
14
+ - `COMPPUBL_USE_SCRAPFLY` — habilita fallback SaaS (precisa `SCRAPFLY_KEY`)
15
+ - `COMPPUBL_USE_ZENROWS` — habilita fallback SaaS alternativo
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+ from urllib.parse import urlparse
22
+
23
+
24
+ def _env_or(name: str, default: str) -> str:
25
+ val = os.environ.get(name)
26
+ if val is None or val == "":
27
+ return default
28
+ return val
29
+
30
+
31
+ def _env_float(name: str, default: float) -> float:
32
+ try:
33
+ return float(_env_or(name, str(default)))
34
+ except ValueError:
35
+ return default
36
+
37
+
38
+ def _env_bool(name: str, default: bool) -> bool:
39
+ return _env_or(name, "1" if default else "0").lower() in ("1", "true", "yes", "on")
40
+
41
+
42
+ # --- cache dir ---
43
+ CACHE_DIR: Path = Path(
44
+ _env_or(
45
+ "COMPPUBL_CACHE_DIR",
46
+ str(Path.home() / ".compraspubl" / "cache"),
47
+ )
48
+ ).expanduser().resolve()
49
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
50
+
51
+
52
+ # --- HTTP ---
53
+ USER_AGENT: str = _env_or(
54
+ "COMPPUBL_HTTP_UA",
55
+ "compraspublica-bot/1.0 (auditoria publica; contato: dev@compraspublica.ai)",
56
+ )
57
+ RATE_LIMIT: float = _env_float("COMPPUBL_RATE_LIMIT", 0.35)
58
+ TIMEOUT: int = int(_env_float("COMPPUBL_TIMEOUT", 30.0))
59
+ RETRIES: int = int(_env_float("COMPPUBL_RETRIES", 3.0))
60
+
61
+
62
+ # --- log ---
63
+ LOG_LEVEL: str = _env_or("COMPPUBL_LOG_LEVEL", "INFO").upper()
64
+
65
+
66
+ # --- paths específicos ---
67
+ SANCOES_DIR: Path = Path(
68
+ _env_or("COMPPUBL_SANCOES_DIR", str(CACHE_DIR / "sancoes"))
69
+ ).expanduser().resolve()
70
+ BRASILAPI_CACHE: Path = Path(
71
+ _env_or("COMPPUBL_BRASILAPI_CACHE", str(CACHE_DIR / "brasilapi.json"))
72
+ ).expanduser().resolve()
73
+ PNCP_CACHE_DIR: Path = Path(
74
+ _env_or("COMPPUBL_PNCP_CACHE_DIR", str(CACHE_DIR / "pncp"))
75
+ ).expanduser().resolve()
76
+ RADARTCE_CACHE: Path = Path(
77
+ _env_or("COMPPUBL_RADARTCE_CACHE", str(CACHE_DIR / "radartce.db"))
78
+ ).expanduser().resolve()
79
+ TSE_CACHE_DIR: Path = Path(
80
+ _env_or("COMPPUBL_TSE_CACHE_DIR", str(CACHE_DIR / "tse"))
81
+ ).expanduser().resolve()
82
+ CNES_CACHE_DIR: Path = Path(
83
+ _env_or("COMPPUBL_CNES_CACHE_DIR", str(CACHE_DIR / "cnes"))
84
+ ).expanduser().resolve()
85
+ HTTP_CACHE_DIR: Path = Path(
86
+ _env_or("COMPPUBL_HTTP_CACHE_DIR", str(CACHE_DIR / "http"))
87
+ ).expanduser().resolve()
88
+ GEOCODE_CACHE: Path = Path(
89
+ _env_or("COMPPUBL_GEOCODE_CACHE", str(CACHE_DIR / "geocode.json"))
90
+ ).expanduser().resolve()
91
+ AMM_CACHE_DIR: Path = Path(
92
+ _env_or("COMPPUBL_AMM_DIR", str(CACHE_DIR / "amm_diario"))
93
+ ).expanduser().resolve()
94
+ PRECO_CACHE_DIR: Path = Path(
95
+ _env_or("COMPPUBL_PRECO_CACHE_DIR", str(CACHE_DIR / "precos"))
96
+ ).expanduser().resolve()
97
+
98
+ # --- APIs externas (URLs) ---
99
+ BRASILAPI_BASE: str = "https://brasilapi.com.br/api/cnpj/v1/"
100
+ MINHARECEITA_BASE: str = "https://minhareceita.org/{cnpj}"
101
+ CEIS_BASE: str = "https://portaldatransparencia.gov.br/download-de-dados"
102
+ TSE_BASE: str = "https://dadosabertos.tse.jus.br"
103
+ TSE_BASE_OLD: str = "https://cdn.tse.jus.br/estatistica/sead/odsele"
104
+ PNCP_BASE: str = "https://pncp.gov.br/api"
105
+ RADARTCE_BASE: str = "https://radardeprecos.tce.mt.gov.br"
106
+ PORTAL_TRANSPARENCIA_BASE: str = "https://api.portaldatransparencia.gov.br/api-de-dados"
107
+ CADPREV_BASE: str = "https://apicadprev.trabalho.gov.br"
108
+ DATASUS_FTP: str = "ftp.datasus.gov.br"
109
+ DATASUS_CNES_BASE: str = "/dissemin/publicos/CNES/200508_/Dados"
110
+
111
+ # cria diretórios lazy na primeira escrita — ver helpers abaixo
112
+ for _p in (SANCOES_DIR, PNCP_CACHE_DIR, CNES_CACHE_DIR, TSE_CACHE_DIR,
113
+ HTTP_CACHE_DIR, PRECO_CACHE_DIR, AMM_CACHE_DIR):
114
+ _p.mkdir(parents=True, exist_ok=True)
115
+
116
+
117
+ # --- fallbacks SaaS ---
118
+ USE_SCRAPFLY: bool = _env_bool("COMPPUBL_USE_SCRAPFLY", False)
119
+ USE_ZENROWS: bool = _env_bool("COMPPUBL_USE_ZENROWS", False)
120
+
121
+
122
+ # --- validação ---
123
+ def _validate_url(url: str, env_name: str) -> None:
124
+ try:
125
+ u = urlparse(url)
126
+ if u.scheme not in ("http", "https"):
127
+ raise ValueError(f"scheme inválido: {u.scheme!r}")
128
+ except Exception as e: # noqa: BLE001
129
+ from .erros import ConfiguracaoInvalida
130
+ raise ConfiguracaoInvalida(
131
+ f"{env_name}={url!r} não é uma URL válida ({e})"
132
+ ) from e
133
+
134
+
135
+ for _name, _val in [
136
+ ("BRASILAPI_BASE", BRASILAPI_BASE),
137
+ ("CEIS_BASE", CEIS_BASE),
138
+ ("PNCP_BASE", PNCP_BASE),
139
+ ("RADARTCE_BASE", RADARTCE_BASE),
140
+ ("TSE_BASE_OLD", TSE_BASE_OLD),
141
+ ("PORTAL_TRANSPARENCIA_BASE", PORTAL_TRANSPARENCIA_BASE),
142
+ ("CADPREV_BASE", CADPREV_BASE),
143
+ ]:
144
+ _validate_url(_val, _name)
@@ -0,0 +1,39 @@
1
+ """Logger central do pacote `compraspublica-shared`."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import sys
6
+
7
+ from . import _config
8
+
9
+ _LOGGER_NAME = "compraspublica_shared"
10
+
11
+ _LEVELS = {
12
+ "DEBUG": logging.DEBUG,
13
+ "INFO": logging.INFO,
14
+ "WARNING": logging.WARNING,
15
+ "WARN": logging.WARNING,
16
+ "ERROR": logging.ERROR,
17
+ "CRITICAL": logging.CRITICAL,
18
+ }
19
+
20
+
21
+ def _build_logger() -> logging.Logger:
22
+ logger = logging.getLogger(_LOGGER_NAME)
23
+ if logger.handlers:
24
+ return logger # já configurado
25
+
26
+ logger.setLevel(_LEVELS.get(_config.LOG_LEVEL, logging.INFO))
27
+
28
+ # Handler que escreve no stderr (sem dependência de root logger)
29
+ handler = logging.StreamHandler(sys.stderr)
30
+ handler.setFormatter(logging.Formatter(
31
+ fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
32
+ datefmt="%Y-%m-%d %H:%M:%S",
33
+ ))
34
+ logger.addHandler(handler)
35
+ logger.propagate = False # não propaga para root
36
+ return logger
37
+
38
+
39
+ logger = _build_logger()
@@ -0,0 +1,42 @@
1
+ """AMM — Diário Oficial dos Municípios de Mato Grosso (amm.diariomunicipal.org).
2
+
3
+ Publica um PDF por edição (todos os municípios juntos). Serve para extrair:
4
+ - **portarias de nomeação** de cargos comissionados e contratos temporários
5
+ (para cruzar com o QSA dos fornecedores — servidor/comissionado que é sócio de
6
+ contratada = conflito de interesse);
7
+ - aditivos contratuais, atas de registro de preços, avisos de dispensa.
8
+
9
+ Não há API pública: raspa-se `/edicoes/?p=N` (paginado) e baixa-se o PDF do CDN.
10
+ Texto via `pdfplumber` (cacheado em `.txt` ao lado do PDF — a extração de ~500
11
+ páginas leva minutos, então é um **job em lote**, não interativo).
12
+ Varredura por regex de CPF num raio de ~250 caracteres das palavras-gatilho.
13
+
14
+ Obs.: o AMM agrega TODOS os municípios de MT numa edição. Várzea Grande também
15
+ mantém diário próprio em `diario.varzeagrande.mt.gov.br` (fora do ar em nuvem no
16
+ teste de 01/09) — se voltar, é a fonte mais direta para as portarias de VG.
17
+
18
+ >>> from compraspublica_shared import amm_diario as amm
19
+ >>> for ed in amm.editions(ate_pagina=3):
20
+ ... pdf = amm.baixar(ed["url"])
21
+ ... for n in amm.nomeacoes(pdf, municipio="VARZEA GRANDE"):
22
+ ... ... # {cpf, nome, tipo, trecho, edicao}
23
+ """
24
+ from .amm import (
25
+ BASE,
26
+ CACHE,
27
+ UA,
28
+ editions,
29
+ baixar,
30
+ nomeacoes,
31
+ cruzar_com_socios,
32
+ )
33
+
34
+ __all__ = [
35
+ "BASE",
36
+ "CACHE",
37
+ "UA",
38
+ "editions",
39
+ "baixar",
40
+ "nomeacoes",
41
+ "cruzar_com_socios",
42
+ ]
@@ -0,0 +1,128 @@
1
+ """Cliente AMM — Diário Oficial dos Municípios de MT.
2
+
3
+ Refatorado de `D:\\Dev\\EnemyDisclosure\\core\\fontes\\amm_diario.py`.
4
+ Independente do pacote Enemy. Sem rede nas importações (urllib/request só é
5
+ importado nas funções que precisam).
6
+
7
+ Dependência opcional: `pdfplumber` (instale com `pip install
8
+ compraspublica-shared[amm]`).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+ import logging
14
+ import re
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ BASE = "https://amm.diariomunicipal.org"
21
+ # path local pode ser sobrescrito via env `COMPPUBL_AMM_DIR` (via _config)
22
+ from .. import _config
23
+ CACHE = _config.AMM_CACHE_DIR
24
+ UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
25
+
26
+ _GATILHOS = re.compile(
27
+ r"(nomear|nomea[çc][ãa]o|design(?:ar|a[çc][ãa]o)|contrata[çc][ãa]o tempor[áa]ria|"
28
+ r"contrato tempor[áa]rio|cargo em comiss[ãa]o|comissionad[oa]|admitir|admiss[ãa]o)",
29
+ re.I)
30
+ _CPF = re.compile(r"\b(\d{3}\.?\d{3}\.?\d{3}-?\d{2})\b")
31
+ _CNPJ = re.compile(r"\b(\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2})\b")
32
+
33
+
34
+ def _get(url: str, timeout: int = 60) -> str:
35
+ """GET simples via urllib (sem deps)."""
36
+ with urllib.request.urlopen(urllib.request.Request(url, headers=UA),
37
+ timeout=timeout) as r:
38
+ return r.read().decode("utf-8", "replace")
39
+
40
+
41
+ def editions(ate_pagina: int = 1, desde: dt.date | None = None) -> list[dict]:
42
+ """Lista edições recentes: [{numero, data, url}]."""
43
+ out, vistos = [], set()
44
+ for p in range(1, ate_pagina + 1):
45
+ html = _get(f"{BASE}/edicoes/?p={p}")
46
+ for m in re.finditer(
47
+ r"(https://cdn-amm\.diariomunicipal\.org/edicoes/(\d{4})/(\d{2})/(\d{2})/"
48
+ r"edicao-(\d+)-[\w-]+\.pdf)", html):
49
+ url, y, mo, d, num = m.groups()
50
+ data = dt.date(int(y), int(mo), int(d))
51
+ if desde and data < desde:
52
+ return out
53
+ key = (num, url)
54
+ if key in vistos:
55
+ continue
56
+ vistos.add(key)
57
+ out.append({"numero": num, "data": data.isoformat(), "url": url})
58
+ return out
59
+
60
+
61
+ def baixar(url: str) -> Path:
62
+ """Baixa o PDF (com cache local)."""
63
+ CACHE.mkdir(parents=True, exist_ok=True)
64
+ destino = CACHE / url.rsplit("/", 1)[-1]
65
+ if not destino.exists():
66
+ logger.info("AMM: baixando %s", destino.name)
67
+ with urllib.request.urlopen(urllib.request.Request(url, headers=UA),
68
+ timeout=180) as r:
69
+ destino.write_bytes(r.read())
70
+ return destino
71
+
72
+
73
+ def _texto(pdf: Path) -> str:
74
+ """Texto do PDF, com cache em .txt ao lado (a extração é lenta)."""
75
+ cache = pdf.with_suffix(".txt")
76
+ if cache.exists():
77
+ return cache.read_text(encoding="utf-8")
78
+ try:
79
+ import pdfplumber # type: ignore
80
+ except ImportError as e:
81
+ raise ImportError(
82
+ "amm_diario precisa de `pdfplumber`. Rode: "
83
+ "`pip install compraspublica-shared[amm]`"
84
+ ) from e
85
+ with pdfplumber.open(str(pdf)) as doc:
86
+ t = "\n\f\n".join((pg.extract_text() or "") for pg in doc.pages)
87
+ cache.write_text(t, encoding="utf-8")
88
+ return t
89
+
90
+
91
+ def nomeacoes(pdf: Path, municipio: str | None = None) -> list[dict]:
92
+ """Trechos com palavra-gatilho de nomeação/contratação + um CPF por perto."""
93
+ txt = _texto(pdf)
94
+ if municipio:
95
+ # restringe às páginas/blocos que citam o município
96
+ mun = municipio.upper()
97
+ blocos = [b for b in re.split(r"\f|\n{2,}", txt) if mun in b.upper()]
98
+ txt = "\n\n".join(blocos)
99
+ achados = []
100
+ for g in _GATILHOS.finditer(txt):
101
+ janela = txt[max(0, g.start() - 250): g.end() + 250]
102
+ for c in _CPF.finditer(janela):
103
+ achados.append({
104
+ "cpf": re.sub(r"\D", "", c.group(1)),
105
+ "tipo": g.group(1).lower(),
106
+ "trecho": re.sub(r"\s+", " ", janela).strip()[:300],
107
+ })
108
+ # dedup por (cpf, tipo)
109
+ vistos, uniq = set(), []
110
+ for a in achados:
111
+ k = (a["cpf"], a["tipo"])
112
+ if k not in vistos:
113
+ vistos.add(k)
114
+ uniq.append(a)
115
+ return uniq
116
+
117
+
118
+ def cruzar_com_socios(nomeados: list[dict], socios_por_cpf: dict) -> list[dict]:
119
+ """Cruza nomeados (com CPF) × QSA das contratadas.
120
+
121
+ `socios_por_cpf`: dict cpf → [(cnpj, empresa, nome), ...].
122
+ """
123
+ hits = []
124
+ for n in nomeados:
125
+ for cnpj, empresa, nome in socios_por_cpf.get(n["cpf"], []):
126
+ hits.append({**n, "cnpj_empresa": cnpj,
127
+ "empresa": empresa, "socio": nome})
128
+ return hits
@@ -0,0 +1,60 @@
1
+ """Motor de análise de risco de fornecedor — B-suite empacotada.
2
+
3
+ Empacota os 9 módulos B forenses relevantes para o CompraPublica.AI F61
4
+ (due diligence do vencedor) e para auditoria:
5
+
6
+ B2 fracionamento de dispensa
7
+ B3 direcionamento de edital
8
+ B5 empresas de fachada (score)
9
+ B6 nepotismo / servidor-sócio
10
+ B9 aditivos contratuais >25%
11
+ B11 restos a pagar como caixa 2
12
+ B17 Lei de Benford
13
+ B18 ordem cronológica de pagamento
14
+ B19 pagamentos duplicados
15
+
16
+ `run_b_suite(cnpj, valor_adjudicado, objeto, uf, data_disputa)` combina os
17
+ módulos + regras (`regras.py`) + fontes (sancoes, brasilapi, cnes, tse) num
18
+ `score_fornecedor` consolidado (0-100).
19
+
20
+ Uso:
21
+ >>> from compraspublica_shared import analise_risco
22
+ >>> r = analise_risco.run_b_suite(
23
+ ... cnpj="27814736000150", valor_adjudicado=5_000_000,
24
+ ... objeto="Limpeza urbana", uf="MT")
25
+ >>> r.score, r.categoria
26
+ (87, 'alto')
27
+ """
28
+ from . import _util, _canon, benford, aditivos, ordem_pagamento
29
+ from . import pagamentos_duplicados, restos_a_pagar, direcionamento
30
+ from . import fracionamento, laranjas, nepotismo, regras, grafo, models
31
+ from .score import (
32
+ FlagRisco as _FlagRiscoDC,
33
+ RiscoFornecedor as _RiscoFornecedorDC,
34
+ run_b_suite,
35
+ score_fornecedor,
36
+ )
37
+
38
+ # re-exporta dataclasses (compat) + Pydantic models
39
+ from .score import FlagRisco, RiscoFornecedor
40
+ from .models import (
41
+ RequestAnalise,
42
+ PrecoReferencia,
43
+ EnderecoGeocodificado,
44
+ SnapshotOrgao,
45
+ )
46
+
47
+ __all__ = [
48
+ "_util", "_canon",
49
+ "benford", "aditivos", "ordem_pagamento",
50
+ "pagamentos_duplicados", "restos_a_pagar", "direcionamento",
51
+ "fracionamento", "laranjas", "nepotismo", "regras", "grafo", "models",
52
+ "FlagRisco",
53
+ "RiscoFornecedor",
54
+ "RequestAnalise",
55
+ "PrecoReferencia",
56
+ "EnderecoGeocodificado",
57
+ "SnapshotOrgao",
58
+ "run_b_suite",
59
+ "score_fornecedor",
60
+ ]
@@ -0,0 +1,168 @@
1
+ # core/analises/_canon.py
2
+ """Camada de normalização: schema bruto de cada portal → modelo canônico.
3
+
4
+ Cada portal (Fiorilli, Elotech, GeneXus-Cuiabá, PNCP...) nomeia os campos de
5
+ um jeito. Aqui um registro bruto vira um dict com chaves canônicas
6
+ (ver PARTE 0 do sprint.md). Os módulos de análise trabalham só no canônico.
7
+
8
+ e = canon("empenho", registro_bruto)
9
+ e["valor_empenhado"], e["credor_doc"], e["data"]
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime
14
+ from typing import List, Optional
15
+
16
+ from ._util import data as _data
17
+ from ._util import doc_limpo, norm, num
18
+
19
+ # canônico → lista de nomes possíveis no bruto (1º que existir vence)
20
+ MAPAS = {
21
+ "empenho": {
22
+ "numero": ("CODIGO", "EmpenhoNumero", "Empenho", "numeroEmpenho", "numero", "empenho",
23
+ "numeroExercicioEmpenho", "numeroAno", "Empenho Numero", "Número/Ano"),
24
+ "data": ("DATAE", "EmpenhoData", "dataEmpenho", "data", "Data"),
25
+ "credor_doc": ("CPFFORMATADO", "EmpenhoCredorDoc", "cnpjCpf", "cpfCnpj",
26
+ "niFornecedor", "documentoFornecedor", "CPFCNPJ",
27
+ "cpfcnpjFavorecido", "cpfCnpjCredor", "Empenho Credor CPFCNPJ"),
28
+ "credor_nome": ("NOMEFOR", "EmpenhoCredorNome", "EmpenhoCredorRazao",
29
+ "NOMEEMPRESA", "nomeFornecedor", "fornecedor", "credor",
30
+ "nomeRazaoSocialFornecedor", "nomeRazaoSocialCredor", "nomeFavorecido",
31
+ "razao_social", "Empenho Credor Nome"),
32
+ "valor_empenhado": ("EMPENHADO", "EmpenhoValor", "valor_empenhado",
33
+ "valorEmpenhado", "valor", "valorEmpenho",
34
+ "valorTotalEmpenhado", "valorEmpenho", "Valor"),
35
+ "valor_liquidado": ("LIQUIDADO", "EmpenhoValorLiq", "EmpenhoLiquidacaoValorLiq",
36
+ "valor_liquidado", "valorLiquidado", "valorTotalLiquidado",
37
+ "Valor Liquidado"),
38
+ "valor_pago": ("PAGO", "EmpenhoPagamentoValorLiq", "valor_pago", "valorPago",
39
+ "valorTotalPago", "valorPagoSemAnulacao", "Valor Pago"),
40
+ "elemento": ("ELEMENTO", "EmpenhoElementoNome", "elementoDespesa",
41
+ "descricaoElemento", "NATUREZA", "dotacao",
42
+ "dota_o_fonte_de_recurso"),
43
+ "funcao": ("FUNCAONOME", "EmpenhoFuncaoNome", "FUNCAO", "funcao",
44
+ "Empenho Orgao Nome"),
45
+ # ATENÇÃO: modalidade de LICITAÇÃO (não a modalidade de aplicação da
46
+ # despesa — em Cuiabá `EmpenhoModalidadeNome`="APLICACOES DIRETAS" é
47
+ # categoria contábil, não licitatória).
48
+ "modalidade": ("MODALIDADE", "EmpenhoLicModalidadeNome", "EmpenhoModLicitacao",
49
+ "modalidadeLicitacao", "modalidade", "MODALI", "modalidadeNome"),
50
+ "processo": ("PROC", "EmpenhoProcesso", "EmpenhoNumeroProcessoCompra",
51
+ "processo", "PROCLIC"),
52
+ "licitacao": ("NUMLICIT", "EmpenhoContratoNumero", "LICIT", "licitacao",
53
+ "numeroLicitacao"),
54
+ "descricao": ("descricao_item", "_item_descricao", "EmpenhoDsc",
55
+ "DESCLICIT_DETALHESEMPENHO", "descricao", "objeto", "historico", "motivo",
56
+ "Histórico"),
57
+ "valor_unitario": ("_item_valor_unitario", "valorUnitario", "valorUnitarioEstimado"),
58
+ },
59
+ "contrato": {
60
+ "numero": ("CONTRATONUM", "ContratoNumero", "Contrato", "numeroContrato",
61
+ "numero", "contrato", "CODIGO"),
62
+ "fornecedor_doc": ("CPFCNPJ", "ContratoFornecedorDoc", "cnpjCpf",
63
+ "niFornecedor", "documentoFornecedor"),
64
+ "fornecedor_nome": ("FORNECEDOR", "ContratoFornecedorNome", "nome",
65
+ "nomeRazaoSocialFornecedor", "fornecedor_nome", "razaoSocial"),
66
+ "objeto": ("OBJETO", "OBJETO_COMPLETO", "ContratoDsc", "objeto", "objetoContrato"),
67
+ "valor_inicial": ("VALCON", "ContratoValor", "valorContratado", "valorInicial", "valor"),
68
+ "valor_atual": ("ContratoValorAtual", "valorGlobal", "valorAtualizado",
69
+ "valor_atual", "valorAcumulado"),
70
+ "valor_aditivos": ("valorAditivo", "valorAditivos"),
71
+ "assinatura": ("DTASSI", "ContratoDataIni", "dataAssinatura", "assinatura"),
72
+ "vigencia_inicio": ("VIGENI", "inicioVigencia", "dataVigenciaInicio", "vigencia_inicio"),
73
+ "vigencia_fim": ("VIGENF", "terminoVigencia", "dataVigenciaFim", "vigencia_fim"),
74
+ "modalidade": ("MODALI", "descricaoTipoLicitacao", "modalidade"),
75
+ "aditado_flag": ("ADITADO", "aditado"),
76
+ },
77
+ "servidor": {
78
+ "nome": ("NOME", "nome", "nomeServidor", "nomeRazaoSocial", "FolhaNome"),
79
+ "cpf": ("CPF", "cpf", "documento", "cpfFormatado", "FolhaCPF"),
80
+ "cargo": ("CARGO", "cargo", "descricaoCargo", "DESCCARGO", "FolhaCargoPriNome",
81
+ "FolhaCargo"),
82
+ "lotacao": ("LOTACAO", "descricaoLotacao", "localTrabalho", "lotacao", "SETOR",
83
+ "estrutAdministrativa", "secretaria", "departamento",
84
+ "FolhaLotacaoNome", "FolhaOrgaoNome"),
85
+ "vinculo": ("VINCULO", "vinculo", "formaInvestidura", "TIPOVINCULO", "situacao",
86
+ "formaIngressoFunc", "tipoSituacaoVincTrab", "FolhaSituacaoFuncDsc",
87
+ "FolhaTipoIngressoDsc"),
88
+ "admissao": ("ADMISSAO", "dataAdmissao", "DATA_ADMISSAO", "dataAdmissaoAtivo"),
89
+ "demissao": ("DESLIGAMENTO", "dataDemissao", "dataFimContrato", "DATA_DESLIGAMENTO",
90
+ "dataExoneracao", "FolhaExoneracaoData"),
91
+ "remuneracao": ("PROVENTOS", "remuneracao", "salarioBase", "REMUNERACAO",
92
+ "salario_base", "salarioBruto", "FolhaVlrBruta"),
93
+ "descontos": ("DESCONTOS", "descontos", "FolhaVlrDescTotal"),
94
+ "competencia": ("FolhaCompetencia", "competencia", "COMPETENCIA"),
95
+ "folha_tipo": ("FolhaTipoDsc",),
96
+ },
97
+ "pagamento": {
98
+ "credor": ("NOMEFOR", "credor", "fornecedor", "nomeCredor", "PagamentoEmpCredorNome"),
99
+ "credor_doc": ("CPFCNPJ", "cnpjCpf", "documentoCredor", "PagamentoEmpCredorCPFCNPJ"),
100
+ "data_liquidacao": ("DATA_LIQUIDACAO", "dataLiquidacao", "data", "LiquidacaoData"),
101
+ "data_pagamento": ("DATA_PAGAMENTO", "dataPagamento", "PagamentoData"),
102
+ "data_vencimento": ("DATA_VENCIMENTO", "dataVencimento"),
103
+ "valor": ("VALOR", "valor", "valorPago", "PagamentoValor"),
104
+ "fonte": ("FONTE", "fonteRecurso", "descFonteRecurso"),
105
+ "empenho": ("EMPENHO", "empenho", "numeroEmpenho", "EmpenhoNumero"),
106
+ },
107
+ "licitacao": {
108
+ "numero": ("NLICITACAO", "LicitacaoNumero", "Licitacao", "NUMLIC",
109
+ "numeroLicitacao", "numero", "LICITACAO", "numeroAnoLicitacao"),
110
+ "ano": ("ANO", "LicitacaoAno", "exercicio", "anoLicitacao"),
111
+ "modalidade": ("DISCR", "LicitacaoModalidadeNome", "modalidadeNome",
112
+ "descricaoTipoLicitacao", "modalidade", "ESPECIETCE",
113
+ "descricaoModalidade"),
114
+ "objeto": ("DISCR10", "LicitacaoObjeto", "objeto", "objetoLicitacao", "OBJETO",
115
+ "descricaoObjeto"),
116
+ "data_publicacao": ("DTENV", "LicitacaoData", "dataPublicacao", "DATAPUBLICACAO"),
117
+ "data_abertura": ("DATAE", "LicitacaoDataSessao", "dataAbertura",
118
+ "DTPROPOSTAINI", "dataSessao"),
119
+ "data_encerramento": ("DTENC", "DTPROPOSTAFIM", "dataEncerramento"),
120
+ "valor_estimado": ("VALOR", "valorEstimado", "valor_estimado"),
121
+ "valor_homologado": ("VALOR1", "valorHomologado", "valor_homologado"),
122
+ "situacao": ("SITUACAO", "LicitacaoSituacao", "situacao", "descricaoSituacao"),
123
+ "n_participantes": ("PARTICIPANTES", "numeroParticipantes", "qtdParticipantes"),
124
+ "vencedor_doc": ("CPFCNPJ", "cnpjVencedor", "documentoVencedor"),
125
+ "registro_preco": ("REGISTROPRECO", "REGISTRO_PRECO", "srp", "registroPreco",
126
+ "flGeraRegistroPrecosExibicao"),
127
+ },
128
+ }
129
+
130
+ _DATAS = {"data", "assinatura", "vigencia_inicio", "vigencia_fim", "admissao",
131
+ "demissao", "data_liquidacao", "data_pagamento", "data_vencimento",
132
+ "data_publicacao", "data_abertura", "data_encerramento"}
133
+ _VALORES = {"valor_empenhado", "valor_liquidado", "valor_pago", "valor_unitario",
134
+ "valor_inicial", "valor_atual", "valor_aditivos", "remuneracao",
135
+ "descontos", "valor", "valor_estimado", "valor_homologado"}
136
+ _DOCS = {"credor_doc", "fornecedor_doc", "cpf", "vencedor_doc"}
137
+
138
+
139
+ def _primeiro(rec: dict, nomes) -> Optional[object]:
140
+ for n in nomes:
141
+ if n in rec and rec[n] not in (None, "", "null"):
142
+ return rec[n]
143
+ # busca case-insensitive
144
+ for k in rec:
145
+ if k.lower() == n.lower() and rec[k] not in (None, "", "null"):
146
+ return rec[k]
147
+ return None
148
+
149
+
150
+ def canon(tipo: str, rec: dict) -> dict:
151
+ m = MAPAS[tipo]
152
+ out = {"_raw": rec}
153
+ for campo, nomes in m.items():
154
+ v = _primeiro(rec, nomes)
155
+ if campo in _DATAS:
156
+ out[campo] = _data(v)
157
+ elif campo in _VALORES:
158
+ out[campo] = num(v)
159
+ elif campo in _DOCS:
160
+ out[campo] = doc_limpo(v)
161
+ else:
162
+ out[campo] = v
163
+ return out
164
+
165
+
166
+ def canon_lista(tipo: str, regs: List[dict]) -> List[dict]:
167
+ return [canon(tipo, r) for r in (regs or [])]
168
+
@@ -0,0 +1,52 @@
1
+ """Helpers comuns às análises B (refatorados de `core/analises/_util.py`)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import unicodedata
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+
10
+ def num(v) -> float:
11
+ """'1.234,56' | '1234.56' | 1234 → float."""
12
+ if v is None or v == "":
13
+ return 0.0
14
+ if isinstance(v, (int, float)):
15
+ return float(v)
16
+ s = str(v).strip().replace("R$", "").replace(" ", "")
17
+ if "," in s and "." in s:
18
+ s = s.replace(".", "").replace(",", ".")
19
+ elif "," in s:
20
+ s = s.replace(",", ".")
21
+ try:
22
+ return float(re.sub(r"[^\d.\-]", "", s) or 0)
23
+ except ValueError:
24
+ return 0.0
25
+
26
+
27
+ def data(v) -> Optional[datetime]:
28
+ if not v:
29
+ return None
30
+ s = str(v)[:10]
31
+ for f in ("%Y-%m-%d", "%d/%m/%Y", "%Y%m%d"):
32
+ try:
33
+ return datetime.strptime(s, f)
34
+ except ValueError:
35
+ pass
36
+ return None
37
+
38
+
39
+ def norm(s: str) -> str:
40
+ s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode()
41
+ return re.sub(r"\s+", " ", s).strip().upper()
42
+
43
+
44
+ def doc_limpo(v) -> str:
45
+ return re.sub(r"\D", "", str(v or ""))
46
+
47
+
48
+ def sobrenomes(nome: str, min_len: int = 4) -> set:
49
+ """Sobrenomes 'úteis' (ignora conectivos e nomes curtos)."""
50
+ stop = {"DE", "DA", "DO", "DActionResult", "DOS", "DAS", "E"}
51
+ toks = norm(nome).split()
52
+ return {t for t in toks[1:] if len(t) >= min_len and t not in stop}