Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user