feat(bloco-c): motor fiscal com legal_basis obrigatorio, classificacao NCM, seguranca CEST/IPI e validador GTIN

This commit is contained in:
gti
2026-09-28 16:28:15 -03:00
parent cf5bb64d9b
commit d383332150
5 changed files with 322 additions and 12 deletions
@@ -0,0 +1,190 @@
"""Missão 6 — Bloco C: Testes do Motor com Base Legal (NCM, CEST, IPI, Domínios e GTIN)."""
import pytest
from central.app.field_contract import (
ALREADY, CHANGE, MANUAL, NA, WRITE_FIELDS, Context, resolve_product
)
from central.app.classification import Classifier
from central.app.gtin_validator import validate_gtin, calculate_gs1_check_digit
REFS = {
"cfop_valid": {"5101", "5102", "5401", "5405", "1102", "1101", "1403", "2102"},
"csosn": {"101", "102", "103", "201", "202", "203", "300", "400", "500", "900"},
"cst_icms": {"00", "10", "20", "30", "40", "41", "50", "51", "60", "70", "90"},
"cst_pis": {"01", "02", "03", "04", "05", "06", "07", "08", "09", "49", "98", "99"},
"cst_cofins": {"01", "02", "03", "04", "05", "06", "07", "08", "09", "49", "98", "99"},
"origem": set("012345678"),
"fcp_uf": {},
"cbs_rate_2026": None,
"cclasstrib_count": 0,
"ncm_version": "Siscomex-2026-09-28",
"tipi_version": "tipi-xlsx-v1",
"cest_version": "CV142_18",
}
CO_SIMPLES_NAO_IPI = {"uf": "BA", "crt": "1", "contribuinte_ipi": "N", "company_code": "000001"}
CO_NORMAL_COM_IPI = {"uf": "BA", "crt": "3", "contribuinte_ipi": "S", "company_code": "000001"}
EV = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {"000001": True}}
def make_product(**kw):
p = {f: None for f in WRITE_FIELDS}
p.update({
"CODIGO": "000001", "PRODUTO": "CERVEJA LATA", "CODGRUPO": "000019", "TIPO": "VENDA",
"PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": "22030000",
"CFOP": "5102", "CSOSN": "102", "CST": "000", "SITUACAO_TRIBUTARIA": "T",
"IAT": "A", "IPPT": "T", "INCIDENCIA_PISCOFINS": "NENHUMA", "PISCOFINS": "N"
})
p.update(kw)
return p
def make_decision(**kw):
d = {
"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ipi_status": "IPI_RESOLVED",
"ba_st_mva_status": "NO_MVA_MAPPING_FOUND", "dataset_versions": {},
"classification": {
"ncm": "22030000",
"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9, "rate": "3.9"},
"ipi_candidates": [], "cest": None, "cest_candidates": [], "ba_st_mva": None, "ba_st_mva_candidates": []
}
}
cls = kw.pop("classification", {})
d.update(kw)
d["classification"] = {**d["classification"], **cls}
return d
# ---------------- C.1: Legal Basis ----------------
def test_c1_every_decision_contains_legal_basis():
"""C.1: field_contract grava legal_basis estruturado em cada decisão."""
res = resolve_product(make_product(), make_decision(), CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
for field in WRITE_FIELDS:
decision = res[field]
assert "legal_basis" in decision, f"Campo {field} sem legal_basis"
lb = decision["legal_basis"]
assert isinstance(lb, dict)
assert "dataset" in lb or "source" in lb
assert "act" in lb
# ---------------- C.2: NCM 8 Produtos Reais Bloqueados ----------------
def test_c2_ncm_classification_categories_and_8_blocked_products():
"""C.2: Classificar placeholder, inválido, fora da tabela e vencido.
Aplica aos 8 produtos reais bloqueados do Agent 2:
- 3 vencidos (02071400): 000016, 000254, 000349
- 5 fora da tabela (02071200, 21069091, 03061300): 000061, 000062, 000098, 000172, 000174.
"""
class MockRepo:
def active_entries(self):
class Entry:
code = "22030000"
description = "Cervejas de malte"
return [Entry()]
def legacy_ncm(self, code):
if code == "02071400":
return {"code": "02071400", "source": "Siscomex", "source_date": "2026-09-17", "reason": "Extinto", "successor_code": None}
return None
def dataset_versions(self):
return {}
clf = Classifier(MockRepo())
# 1. Placeholder
assert clf.classify({"current_ncm": "00000000"})["status"] == "PLACEHOLDER_NCM"
# 2. Formato Inválido
assert clf.classify({"current_ncm": "12345678"})["status"] == "INVALID_FORMAT_NCM"
# 3. Os 3 produtos reais vencidos (02071400)
for code, desc in [("000016", "CALDO DE SURURU"), ("000254", "CHESFRANGO"), ("000349", "CALDO DE GALINHA")]:
c = clf.classify({"current_ncm": "02071400", "description": desc})
assert c["status"] == "LEGACY_NCM"
# No field contract, isso gera MANUAL com razão de ausência de sucessor unívoco
p = make_product(CODIGO=code, PRODUTO=desc, CLASSIFICACAO_FISCAL="02071400")
res = resolve_product(p, c, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
assert res["CLASSIFICACAO_FISCAL"]["state"] == MANUAL
assert res["CLASSIFICACAO_FISCAL"]["reason"] == "NCM_LEGACY_NO_UNAMBIGUOUS_SUCCESSOR"
# 4. Os 5 produtos reais fora da tabela vigente
for code, desc, ncm in [
("000061", "FRANGO (COXA E SOBRECOXA)", "02071200"),
("000062", "CORAÇÃO DE FRANGO", "02071200"),
("000098", "FEIJÃO DE CALDO", "21069091"),
("000172", "PATÊ DE FRANGO", "02071200"),
("000174", "PATÊ DE CAMARÃO", "03061300"),
]:
c = clf.classify({"current_ncm": ncm, "description": desc})
assert c["status"] == "OUT_OF_TABLE_NCM"
p = make_product(CODIGO=code, PRODUTO=desc, CLASSIFICACAO_FISCAL=ncm)
res = resolve_product(p, c, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
assert res["CLASSIFICACAO_FISCAL"]["state"] == MANUAL
assert res["CLASSIFICACAO_FISCAL"]["reason"] == "NCM_OUT_OF_OFFICIAL_TABLE"
# ---------------- C.3: CEST ----------------
def test_c3_cest_auto_fill_only_when_single_candidate():
"""C.3: AUTO_FILL_SAFE somente se o NCM mapear para exatamente um CEST vigente; senão candidatos para revisão."""
off_single = [{"code": "03.021.03", "official_description": "Cerveja em lata", "description_score": 1.0}]
d_single = make_decision(cest_status="CEST_CONFIRMED", classification={"cest": ["03.021.03"], "cest_candidates": off_single})
res_single = resolve_product(make_product(CEST=None), d_single, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
assert res_single["CEST"]["state"] == CHANGE
assert res_single["CEST"]["proposed"] == "0302103"
assert res_single["CEST"]["reason"] == "SINGLE_DEFENSIBLE_OFFICIAL_CEST"
# Múltiplos candidatos: NÃO PODE virar CHANGE automático
off_multi = [
{"code": "03.021.03", "official_description": "Cerveja lata", "description_score": 0.8},
{"code": "03.021.05", "official_description": "Cerveja garrafa", "description_score": 0.8},
]
d_multi = make_decision(cest_status="CEST_CONFIRMED", classification={"cest": ["03.021.03", "03.021.05"], "cest_candidates": off_multi})
res_multi = resolve_product(make_product(CEST=None), d_multi, CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
assert res_multi["CEST"]["state"] == MANUAL
assert res_multi["CEST"]["reason"] == "SEVERAL_OFFICIAL_CEST_FOR_NCM"
assert len(res_multi["CEST"]["choices"]) == 2
# ---------------- C.4: IPI ----------------
def test_c4_ipi_taxpayer_gate():
"""C.4: valor da TIPI só onde CONTRIBUINTE_IPI aplicar; senão NOT_APPLICABLE."""
# Empresa não contribuinte sem store_ipi_profile
res_na = resolve_product(make_product(IPI=None), make_decision(), CO_SIMPLES_NAO_IPI, REFS, EV, Context([]))
assert res_na["IPI"]["state"] == NA
assert res_na["IPI"]["reason"] == "COMPANY_IS_NOT_AN_IPI_TAXPAYER_C000004.CONTRIBUINTE_IPI"
assert res_na["CST_IPI"]["state"] == NA
# Empresa contribuinte de IPI (CO_NORMAL_COM_IPI)
res_taxpayer = resolve_product(make_product(IPI=None), make_decision(), CO_NORMAL_COM_IPI, REFS, EV, Context([]))
assert res_taxpayer["IPI"]["state"] == CHANGE
assert res_taxpayer["IPI"]["proposed"] == 3.9
# ---------------- C.6: GTIN Validation ----------------
def test_c6_gtin_validation_algorithm():
"""C.6: Validação de DV GS1 e prefixos válidos/restritos."""
# GTIN-13 válido (Coca-Cola 350ml: 7894900010015)
# Cálculo DV: 7 8 9 4 9 0 0 0 1 0 0 1 -> DV esperado 5
assert calculate_gs1_check_digit("789490001001") == 5
valid, reason = validate_gtin("7894900010015")
assert valid is True and reason == "GTIN_VALID"
# DV errado
valid_bad_dv, reason = validate_gtin("7894900010019")
assert valid_bad_dv is False
assert "GTIN_INVALID_CHECK_DIGIT" in reason
# Código interno / balança (prefixo 200...)
valid_internal, reason = validate_gtin("2000000000164")
assert valid_internal is False
assert "RESTRICTED_INTERNAL" in reason
# Placeholder repetido
valid_rep, reason = validate_gtin("0000000000000")
assert valid_rep is False
assert "PLACEHOLDER" in reason
# Tamanho inválido
valid_len, reason = validate_gtin("1234567")
assert valid_len is False