"""Validated, auditable context resolution for a single blocked product.""" from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import select from .audit import record_audit from .context_keys import validate_entry from .models import Agent, BlockedProduct, FiscalContextEntry, Installation, Tenant, Job WORKFLOW_SCOPES = {"PRODUCT", "GROUP", "COMPANY"} ENTRY_FIELDS = {"scope", "scope_key", "key", "value", "reason"} MAX_VALUE_LENGTH = 512 MAX_REASON_LENGTH = 2000 def validate_resolution_input(payload): """Validate only the structured context accepted by the blocker workflow.""" if not isinstance(payload, dict): return None, "resolution input must be an object" unexpected = set(payload) - ENTRY_FIELDS if unexpected: return None, f"unsupported resolution fields: {sorted(unexpected)}" if not isinstance(payload.get("value"), str): return None, "value must be a string" if not isinstance(payload.get("reason"), str): return None, "reason must be a string" if len(payload["value"]) > MAX_VALUE_LENGTH: return None, f"value must be at most {MAX_VALUE_LENGTH} characters" if len(payload["reason"]) > MAX_REASON_LENGTH: return None, f"reason must be at most {MAX_REASON_LENGTH} characters" normalized, error = validate_entry(payload) if error: return None, error if normalized["scope"] not in WORKFLOW_SCOPES: return None, "scope must be one of ('PRODUCT', 'GROUP', 'COMPANY')" if not normalized["reason"]: return None, "reason is required" return normalized, None def _company_key(blocker, installation, tenant): if installation is not None: return (installation.company_code or installation.company_name or installation.customer_code or "").strip() return (blocker.company or (tenant.name if tenant else "")).strip() def _entry_view(row): if row is None: return None return { "company_key": row.company_key, "scope": row.scope, "scope_key": row.scope_key, "key": row.key, "value": row.value, "source": row.source, "reason": row.reason, } def resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=None): """Persist context, enqueue one product reprocess, and audit atomically.""" normalized, error = validate_resolution_input(payload) if error: raise ValueError(error) try: blocker = session.get(BlockedProduct, blocker_id) if blocker is None: raise LookupError("blocker not found") installation = None if blocker.installation_id is not None: installation = session.get(Installation, blocker.installation_id) if installation is None: raise LookupError("blocker installation not found") if installation.tenant_id != blocker.tenant_id: raise LookupError("blocker installation tenant mismatch") agent = session.get(Agent, installation.agent_id) if agent is None: raise LookupError("blocker installation agent not found") if agent.tenant_id != blocker.tenant_id: raise LookupError("blocker agent tenant mismatch") else: raise LookupError("blocker installation is required") tenant = session.get(Tenant, blocker.tenant_id) company_key = _company_key(blocker, installation, tenant) if not company_key: raise ValueError("company context is unavailable") if normalized["scope"] == "PRODUCT" and normalized["scope_key"] != blocker.product_code: raise ValueError("PRODUCT scope_key must match the blocker product code") query = select(FiscalContextEntry).where( FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key, FiscalContextEntry.scope == normalized["scope"], FiscalContextEntry.scope_key == normalized["scope_key"], FiscalContextEntry.key == normalized["key"], ) entry = session.scalar(query) before = _entry_view(entry) now = datetime.now(timezone.utc) if entry is None: entry = FiscalContextEntry( tenant_id=blocker.tenant_id, company_key=company_key, source="OPERATOR", updated_at=now, **normalized, ) session.add(entry) else: entry.value = normalized["value"] entry.reason = normalized["reason"] entry.source = "OPERATOR" entry.updated_at = now job = Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="PENDING", payload={"codigos": [blocker.product_code]}) session.add(job) session.flush() audit = record_audit( session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER_WITH_CONTEXT", target_type="blocker", target_id=str(blocker.id), context={ "admin_user_id": admin.id, "tenant_id": blocker.tenant_id, "client": tenant.name if tenant else None, "company_key": company_key, "installation_id": installation.installation_id if installation else None, "installation_db_id": installation.id if installation else None, "product_code": blocker.product_code, "blocker_id": blocker.id, "key": normalized["key"], "scope": normalized["scope"], "scope_key": normalized["scope_key"], "value": normalized["value"], "reason": normalized["reason"], "before": before, "after": _entry_view(entry), "reprocess_job_id": job.id, "request_ip": request_ip, }, ) session.flush() session.commit() return {"blocker_id": blocker.id, "job_id": job.id, "audit_id": audit.id} except Exception: session.rollback() raise