feat: close autonomous operations for blocked products
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
from central.app.operational_state import classify_product_state, summarize_operational_states
|
||||
|
||||
|
||||
def test_twenty_thousand_products_keep_agent_healthy_with_safe_blocks():
|
||||
states = [{"state": "COMPLETE"}] * 19900 + [{"state": "BLOCKED_MISSING_EVIDENCE"}] * 100
|
||||
summary = summarize_operational_states(states)
|
||||
|
||||
assert summary["AUTONOMOUSLY_PROCESSABLE_PRODUCTS"] == 20000
|
||||
assert summary["SAFE_BLOCKED"] == 100
|
||||
assert summary["AGENT_HEALTH"] == "HEALTHY"
|
||||
|
||||
|
||||
def test_new_evidence_changes_product_state_without_global_failure():
|
||||
blocked = classify_product_state({"CODIGO": "P1"}, {"status": "INVALID_NCM"})
|
||||
resolved = classify_product_state({"CODIGO": "P1"}, {"status": "EXACT_NCM"})
|
||||
|
||||
assert blocked["state"] == "BLOCKED_MISSING_EVIDENCE"
|
||||
assert resolved["state"] == "COMPLETE"
|
||||
@@ -0,0 +1,50 @@
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.blocked_products import list_blockers, resolve_product_blockers, retry_due, upsert_blocker
|
||||
from central.app.models import Agent, Base, BlockedProduct, Tenant
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
tenant = Tenant(name="tenant-blocked")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
return session, tenant
|
||||
|
||||
|
||||
def test_same_blocker_is_upserted_not_duplicated():
|
||||
session, tenant = _session()
|
||||
payload = {"tenant": "t", "company": "c", "product_code": "P1", "field": "CFOP", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "reason_code": "MISSING_EVIDENCE", "input_fingerprint": "v1", "evidence_summary": {"source": "ERP"}}
|
||||
|
||||
first = upsert_blocker(session, tenant.id, payload)
|
||||
second = upsert_blocker(session, tenant.id, payload)
|
||||
|
||||
assert first.id == second.id
|
||||
assert session.query(BlockedProduct).count() == 1
|
||||
|
||||
|
||||
def test_new_input_fingerprint_is_due_for_retry():
|
||||
session, tenant = _session()
|
||||
payload = {"tenant": "t", "company": "c", "product_code": "P1", "field": "CFOP", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "reason_code": "MISSING_EVIDENCE", "input_fingerprint": "v1", "evidence_summary": {}}
|
||||
row = upsert_blocker(session, tenant.id, payload)
|
||||
|
||||
assert list_blockers(session, tenant.id, input_fingerprint="v2") == [row]
|
||||
|
||||
|
||||
def test_product_re_evaluation_closes_previous_blocker():
|
||||
session, tenant = _session()
|
||||
row = upsert_blocker(session, tenant.id, {"company": "c", "product_code": "P1", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1"})
|
||||
|
||||
assert resolve_product_blockers(session, tenant.id, "P1") == 1
|
||||
assert session.get(BlockedProduct, row.id).resolved_at is not None
|
||||
|
||||
|
||||
def test_same_input_without_retry_schedule_is_cache_hit():
|
||||
session, tenant = _session()
|
||||
row = upsert_blocker(session, tenant.id, {"company": "c", "product_code": "P1", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1"})
|
||||
|
||||
assert retry_due(row, "v1") is False
|
||||
assert retry_due(row, "v2") is True
|
||||
@@ -130,3 +130,20 @@ def test_reprocess_products_requires_codigos():
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
response = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {}}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_blocker_registry_api_deduplicates_and_resolves():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
summary = {"blocked_products": [{"company": "C", "product_code": "P1", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1", "evidence_summary": {"source": "ERP"}}]}
|
||||
created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["P1"]}}, headers=headers)
|
||||
job_id = created.json()["job_id"]
|
||||
client.get("/v1/jobs/next", headers=headers)
|
||||
client.post(f"/v1/jobs/{job_id}/complete", json={"result_summary": summary}, headers=headers)
|
||||
listed = client.get("/v1/blockers?field=CFOP", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()["blockers"]) == 1
|
||||
blocker_id = listed.json()["blockers"][0]["id"]
|
||||
resolved = client.post(f"/v1/blockers/{blocker_id}/resolve", json={"resolution_source": "TEST"}, headers=headers)
|
||||
assert resolved.json()["resolved"] is True
|
||||
assert client.get("/v1/blockers", headers=headers).json()["blockers"] == []
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from central.app.operational_state import blocker_fingerprint, classify_product_state, should_retry_blocker, summarize_operational_states
|
||||
|
||||
|
||||
def test_blocked_product_does_not_make_other_products_fail():
|
||||
states = [
|
||||
classify_product_state({"CODIGO": "1"}, {"status": "EXACT_NCM"}),
|
||||
classify_product_state({"CODIGO": "2"}, {"status": "AMBIGUOUS"}),
|
||||
]
|
||||
|
||||
summary = summarize_operational_states(states)
|
||||
|
||||
assert summary["COMPLETE"] == 1
|
||||
assert summary["BLOCKED_CONFLICT"] == 1
|
||||
assert summary["AGENT_HEALTH"] == "HEALTHY"
|
||||
|
||||
|
||||
def test_blocker_fingerprint_deduplicates_same_input():
|
||||
first = blocker_fingerprint("tenant", "company", "P1", "CFOP", "MISSING_EVIDENCE", "engine-v1")
|
||||
second = blocker_fingerprint("tenant", "company", "P1", "CFOP", "MISSING_EVIDENCE", "engine-v1")
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_retry_only_when_input_fingerprint_changes():
|
||||
assert should_retry_blocker("old", "new") is True
|
||||
assert should_retry_blocker("old", "old") is False
|
||||
|
||||
|
||||
def test_technical_failure_is_not_healthy_when_present():
|
||||
summary = summarize_operational_states([{"state": "FAILED_TECHNICAL"}])
|
||||
|
||||
assert summary["FAILED_TECHNICAL"] == 1
|
||||
assert summary["AGENT_HEALTH"] == "DEGRADED"
|
||||
Reference in New Issue
Block a user