Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -20,6 +20,12 @@ from sqlalchemy import text
from app.action_catalog import get_action_config
from app.db import engine
from app.work_center_action_policy import (
RECONSTRUCTED_SENSITIVE_ACTIONS,
canonical_action_code,
reconstructed_review_metadata_patch,
reconstructed_review_status,
)
_SCHEMA_READY = False
logger = logging.getLogger(__name__)
@@ -193,7 +199,7 @@ def recent_window_start(days: int = 3) -> str:
def _priority_for_action(action_code: str, *, fallback: str = "normal") -> str:
code = str(action_code or "").upper()
if code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "CREATE_SHIPMENT"}:
if code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}:
return "alta"
if code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "IGNORE_SPAM", "NO_ACTION", "IGNORE_BOUNCE"}:
return "baixa"
@@ -1134,19 +1140,38 @@ def _upsert_odoo_operation_links_from_item(conn: Any, item: Dict[str, Any], oppo
"payload": _json({"sale_order": sale_name, "productions": productions}),
})
if fulfilment.get("delivery_done") or fulfilment.get("delivery_ready"):
# v132: Odoo ``assigned`` is only a reserved picking. Never create a
# physical_validation link from it. That evidence is created exclusively
# by completing VALIDATE_PHYSICAL_ORDER (or by an unequivocal Odoo done).
if fulfilment.get("delivery_done"):
conn.execute(text("""
INSERT INTO operation_links (opportunity_id, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at)
VALUES (CAST(:opportunity_id AS UUID), 'odoo', 'physical_validation', :external_id, :external_name, NULL, :status, CAST(:payload AS JSONB), now(), now())
VALUES (CAST(:opportunity_id AS UUID), 'odoo', 'physical_validation', :external_id, :external_name, NULL, 'validated', CAST(:payload AS JSONB), now(), now())
ON CONFLICT (opportunity_id, system, external_type)
DO UPDATE SET external_id = EXCLUDED.external_id, external_name = EXCLUDED.external_name, status = EXCLUDED.status, payload = operation_links.payload || EXCLUDED.payload, last_synced_at = now(), updated_at = now()
"""), {
"opportunity_id": opportunity_id,
"external_id": sale_id,
"external_name": "Entrega concluída" if fulfilment.get("delivery_done") else "Entrega pronta",
"status": "validated" if fulfilment.get("delivery_done") else "ready_to_ship",
"external_name": "Entrega concluída",
"payload": _json({"sale_order": sale_name, **fulfilment}),
})
from app.odoo_delivery_task_reconciliation import reconcile_odoo_delivery_done
reconcile_odoo_delivery_done(
conn,
opportunity_id,
evidence={"sale_order": {"id": sale_id, "name": sale_name}, **fulfilment},
actor="reconciliation_odoo_sync",
upsert_validation=False,
)
elif fulfilment.get("delivery_ready"):
conn.execute(text("""
DELETE FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'odoo'
AND external_type = 'physical_validation'
AND status IN ('ready_to_ship','pending')
"""), {"opportunity_id": opportunity_id})
def _odoo_importable_lines(item: Dict[str, Any]) -> List[Dict[str, Any]]:
@@ -1887,13 +1912,49 @@ def _apply_reconstructed_process_to_opportunity(conn: Any, items: List[Dict[str,
_upsert_opportunity_items_from_odoo_item(conn, item, opportunity_id)
jasmin_import = _apply_jasmin_documents_to_opportunity(conn, items, opportunity_id, actor=actor)
current = conn.execute(text("""
SELECT stage, last_action_code, COALESCE(metadata, '{}'::jsonb) AS metadata
FROM opportunities
WHERE id = CAST(:opportunity_id AS UUID)
FOR UPDATE
"""), {"opportunity_id": opportunity_id}).mappings().first() or {}
try:
from app.opportunity_service import OPPORTUNITY_STAGE_RANK
current_rank = int(OPPORTUNITY_STAGE_RANK.get(_clean(current.get("stage")), 0))
suggested_rank = int(OPPORTUNITY_STAGE_RANK.get(stage, 0))
except Exception:
current_rank = suggested_rank = 0
preserve_current = bool(_clean(current.get("stage"))) and current_rank >= suggested_rank
effective_stage = _clean(current.get("stage")) if preserve_current else stage
effective_action_code = _clean(current.get("last_action_code")) if preserve_current else action_code
effective_action_code = effective_action_code or action_code
current_review_status = reconstructed_review_status(current.get("metadata"))
requires_review = (
canonical_action_code(action_code) in {canonical_action_code(code) for code in RECONSTRUCTED_SENSITIVE_ACTIONS}
and current_review_status not in {"validated", "waived"}
)
review_patch = (
reconstructed_review_metadata_patch(
"required", actor=actor,
reason="Processo reconstruído com ação sensível pendente.",
blocked_action_code=action_code,
)
if requires_review else {}
)
metadata_payload = {
"reconstruction_applied": True,
"actor": actor,
"suggested_stage": stage,
"suggested_action": action_code,
"effective_stage": effective_stage,
"effective_action": effective_action_code,
"stage_regression_prevented": preserve_current and effective_stage != stage,
"item_ids": [str(item.get("id")) for item in items if item.get("id")],
"jasmin_import": jasmin_import,
"clientflow_record_mode": "reconstructed_invoice_review",
**review_patch,
}
conn.execute(text("""
UPDATE opportunities
@@ -1908,8 +1969,8 @@ def _apply_reconstructed_process_to_opportunity(conn: Any, items: List[Dict[str,
WHERE id = CAST(:opportunity_id AS UUID)
"""), {
"opportunity_id": opportunity_id,
"stage": stage,
"action_code": action_code,
"stage": effective_stage,
"action_code": effective_action_code,
"amount": amount_value,
"metadata": _json(metadata_payload),
})
@@ -1931,21 +1992,40 @@ def _apply_reconstructed_process_to_opportunity(conn: Any, items: List[Dict[str,
AND EXISTS (SELECT 1 FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID))
"""), {"opportunity_id": opportunity_id})
if odoo_items and action_code == "SEND_INVOICE":
has_invoice = bool(conn.execute(text("""
SELECT EXISTS (
SELECT 1
FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND document_kind = 'invoice'
AND COALESCE(is_active, TRUE) = TRUE
AND COALESCE(role, 'current') IN ('current', 'accepted', 'historical', 'history')
)
"""), {"opportunity_id": opportunity_id}).scalar())
if odoo_items and effective_action_code == "SEND_INVOICE" and not has_invoice:
_ensure_pending_task_for_reconstruction(
conn,
opportunity_id,
action_code,
note="Processo Odoo reconstruído: encomenda/entrega encontrada e fatura por emitir.",
"REVIEW_RECONSTRUCTED_PROCESS" if requires_review else effective_action_code,
note=(
"Validar cliente, documentos e valor do processo reconstruído antes de enviar fatura."
if requires_review
else "Processo reconstruído: validar se falta emitir/enviar fatura antes de avançar."
),
actor=actor,
)
return {"stage": stage, "action_code": action_code, "amount": amount_value}
return {
"stage": effective_stage,
"action_code": effective_action_code,
"amount": amount_value,
"stage_regression_prevented": preserve_current and effective_stage != stage,
}
PROCESS_STEP_BY_EXTERNAL_TYPE = {
"manual_request": (5, "Pedido externo registado", "QUOTE_REQUESTED", "SEND_QUOTE"),
"jasmin_quotation": (20, "Orçamento encontrado no Jasmin", "QUOTE_SENT", "SEND_PROFORMA"),
"jasmin_proforma": (30, "Pró-forma encontrada no Jasmin", "WAITING_PAYMENT", "CONFIRM_PAYMENT"),
"jasmin_proforma": (30, "Orçamento para pagamento encontrado no Jasmin", "WAITING_PAYMENT", "CONFIRM_PAYMENT"),
"payment_proof": (40, "Comprovativo de pagamento recebido", "WAITING_PAYMENT", "CONFIRM_PAYMENT"),
"jasmin_invoice": (50, "Fatura encontrada no Jasmin", "INVOICE_SENT", "CONFIRM_PAYMENT"),
"odoo_sale_order": (60, "Venda/encomenda encontrada no Odoo", "ODOO_ORDER_CREATED", "SEND_INVOICE"),
@@ -2033,10 +2113,10 @@ def _process_steps_for_item(item: Dict[str, Any]) -> List[Dict[str, Any]]:
ready_pickings = [p for p in outgoing if _clean(p.get("state")) == "assigned"]
if done_pickings:
for picking in done_pickings:
steps.append({**base, "rank": 75, "label": "Entrega Odoo concluída", "stage": "SHIPMENT_CREATED", "action_code": "SEND_INVOICE", "external_type": "odoo_delivery", "document_number": picking.get("name"), "document_date": _clean(picking.get("date_done"))[:10] or base.get("document_date"), "picking_state": picking.get("state")})
steps.append({**base, "rank": 75, "label": "Entrega Odoo concluída", "stage": "SHIPPED", "action_code": "SEND_INVOICE", "external_type": "odoo_delivery", "document_number": picking.get("name"), "document_date": _clean(picking.get("date_done"))[:10] or base.get("document_date"), "picking_state": picking.get("state")})
elif ready_pickings:
for picking in ready_pickings:
steps.append({**base, "rank": 70, "label": "Entrega Odoo pronta para despacho", "stage": "READY_TO_SHIP", "action_code": "SEND_INVOICE", "external_type": "odoo_delivery", "document_number": picking.get("name"), "document_date": _clean(picking.get("scheduled_date"))[:10] or base.get("document_date"), "picking_state": picking.get("state")})
steps.append({**base, "rank": 70, "label": "Picking Odoo reservado — validação física pendente", "stage": "ORDER_PREPARATION", "action_code": "VALIDATE_PHYSICAL_ORDER", "external_type": "odoo_delivery", "document_number": picking.get("name"), "document_date": _clean(picking.get("scheduled_date"))[:10] or base.get("document_date"), "picking_state": picking.get("state")})
if fulfilment.get("invoice_pending"):
steps.append({**base, "rank": 85, "label": "Fatura por emitir", "stage": "SHIPMENT_CREATED" if done_pickings else "ODOO_ORDER_CREATED", "action_code": "SEND_INVOICE", "external_type": "odoo_invoice_pending", "document_number": base.get("document_number")})
return steps
@@ -2289,7 +2369,7 @@ def infer_reconciliation_process_state(items: List[Dict[str, Any]]) -> Dict[str,
if "odoo_sale_order" in external_types and "jasmin_invoice" not in external_types:
action_code = "SEND_INVOICE"
if any(step.get("external_type") == "odoo_delivery" for step in steps):
stage = "SHIPMENT_CREATED"
stage = "SHIPPED"
if "payment_proof" in external_types:
action_code = "CONFIRM_PAYMENT"
if "packlink_shipment" in external_types and "jasmin_invoice" not in external_types:
@@ -2341,14 +2421,20 @@ def _candidate_reasons_and_risks(group: Dict[str, Any], items: List[Dict[str, An
reasons.append("existe sugestão de oportunidade aberta")
amounts = _process_amount_values(items)
amount_conflict = False
amount_conflict_values: List[str] = []
if len(amounts) >= 2:
min_amount, max_amount = min(amounts), max(amounts)
if min_amount == max_amount:
unique_amounts = sorted(set(amounts))
amount_conflict_values = [str(value.quantize(Decimal("0.01"))) for value in unique_amounts]
min_amount, max_amount = min(unique_amounts), max(unique_amounts)
if len(unique_amounts) == 1:
reasons.append("valor igual entre documentos")
elif min_amount and max_amount <= (min_amount * Decimal("1.10")):
reasons.append("valor aproximado entre documentos")
else:
risks.append("valores diferentes entre documentos")
amount_conflict = True
risks.append("conflito financeiro: valores divergentes entre documentos")
risks.append("bloquear auto-associação financeira até revisão")
dated_items = [item for item in items if item.get("document_date")]
if len(dated_items) >= 2:
@@ -2378,13 +2464,19 @@ def _candidate_reasons_and_risks(group: Dict[str, Any], items: List[Dict[str, An
# Deterministic de-duplication with stable order.
reasons = list(dict.fromkeys([r for r in reasons if r]))
risks = list(dict.fromkeys([r for r in risks if r]))
if risks and any("valores diferentes" in risk or "compra diferente" in risk for risk in risks):
if amount_conflict or (risks and any("valores diferentes" in risk or "valor divergente" in risk or "compra diferente" in risk for risk in risks)):
review_status = "conflict"
elif risks:
review_status = "needs_review"
else:
review_status = "ready"
return {"reasons": reasons, "risks": risks, "review_status": review_status}
return {
"reasons": reasons,
"risks": risks,
"review_status": review_status,
"financial_conflict": amount_conflict,
"amount_conflict_values": amount_conflict_values if amount_conflict else [],
}
def _record_reconciliation_decision(
@@ -2591,6 +2683,8 @@ def list_reconciliation_process_candidates(*, status: str = "open", days: int =
"reasons": explanation.get("reasons") or [],
"risks": explanation.get("risks") or [],
"review_status": explanation.get("review_status") or "needs_review",
"financial_conflict": bool(explanation.get("financial_conflict")),
"amount_conflict_values": explanation.get("amount_conflict_values") or [],
})
candidates.sort(key=lambda x: (0 if x.get("confidence") == "alta" else 1, -len(x.get("items") or []), str(x.get("customer_name") or "")))
return candidates[: max(int(limit or 20), 1)]
@@ -2616,6 +2710,25 @@ def _get_reconciliation_items_by_ids(item_ids: List[str]) -> List[Dict[str, Any]
return [dict(row) for row in rows]
def _materialize_current_opportunity_action(opportunity_id: str, *, actor: str) -> Dict[str, Any]:
"""Materialize only the central, post-commit decision for the opportunity."""
try:
from app.opportunity_next_action_service import get_opportunity_next_action
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
next_action = get_opportunity_next_action(opportunity_id)
result = ensure_pending_task_for_next_action(
opportunity_id,
next_action,
source="reconciliation_v129",
actor=actor,
)
return {"next_action": next_action, "materialization": result}
except Exception as exc: # pragma: no cover - production safety guard
logger.warning("failed to materialize post-reconciliation action for %s: %s", opportunity_id, exc)
return {"error": str(exc)}
def link_reconciliation_process_to_opportunity(item_ids: List[str], opportunity_id: str, *, actor: str = "operator") -> int:
"""Link several reconciliation items to the same opportunity as one process."""
items = _get_reconciliation_items_by_ids(item_ids)
@@ -2669,6 +2782,7 @@ def link_reconciliation_process_to_opportunity(item_ids: List[str], opportunity_
actor=actor,
payload={"suggested_stage": state.get("stage"), "suggested_action": state.get("action_code")},
)
_materialize_current_opportunity_action(opportunity_id, actor=actor)
return len(items)
@@ -2696,12 +2810,20 @@ def create_opportunity_from_reconciliation_process(item_ids: List[str], *, actor
"amount": item.get("amount"),
} for item in items]
opportunity_id = str(uuid.uuid4())
review_patch = reconstructed_review_metadata_patch(
"required",
actor=actor,
reason="Oportunidade reconstruída a partir de evidências externas.",
blocked_action_code=action_code,
)
metadata = {
"created_from_reconciliation_process": True,
"source_system": "reconciliation",
"evidence": evidence,
"suggested_stage": stage,
"suggested_action": action_code,
"clientflow_record_mode": "reconstructed_invoice_review",
**review_patch,
}
title = f"Processo reconstruído · {customer_name}"
with engine.begin() as conn:
@@ -2793,11 +2915,11 @@ def create_opportunity_from_reconciliation_process(item_ids: List[str], *, actor
)
task_id = _create_task_for_opportunity(
opportunity_id=opportunity_id,
action_code=action_code,
note="Continuar processo reconstruído a partir de documentos/vendas/comprovativos externos. Validar antes de executar ações fiscais ou financeiras.",
action_code="REVIEW_RECONSTRUCTED_PROCESS",
note="Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível sugerida.",
source_system="reconciliation_process",
source_event_id=opportunity_id,
metadata=metadata,
metadata={**metadata, "blocked_action_code": canonical_action_code(action_code), "review_type": "reconstructed_process"},
)
if task_id:
with engine.begin() as conn: