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

@@ -480,28 +480,29 @@ def _derive_physical_status(sale_order: dict, pickings: list, productions: list)
"label": "Expedida no Odoo",
"reason": "A entrega/picking no Odoo está concluída.",
"ready_to_ship": False,
"next_action": "Confirmar tracking/entrega no ClientFlow.",
"stage": "SHIPMENT_CREATED",
"next_action": "Processo pronto para conclusão no ClientFlow quando fatura enviada e pagamento confirmado.",
"stage": "SHIPPED",
}
if any(state == "assigned" for state in picking_states):
return {
"physical_status": "ready_to_ship",
"label": "Pronta para despacho",
"reason": "O picking/entrega está reservado e disponível no Odoo.",
"ready_to_ship": True,
"next_action": "Emitir fatura se necessário e criar envio Packlink.",
"stage": "READY_TO_SHIP",
"physical_status": "picking_assigned",
"label": "Picking reservado — validação física pendente",
"reason": "Odoo assigned indica stock reservado/disponível, mas não confirma que a encomenda foi fisicamente validada.",
"ready_to_ship": False,
"picking_reserved": True,
"next_action": "Confirmar a preparação física da encomenda antes de criar envio/tracking.",
"stage": "ORDER_PREPARATION",
}
if any(state in {"progress", "to_close", "confirmed"} for state in production_states):
return {
"physical_status": "in_production",
"label": "Em produção/preparação",
"reason": "Existe ordem de produção ativa no Odoo.",
"label": "A aguardar WH/OUT",
"reason": "Ainda não existe entrega/WH-OUT pronta ou concluída; WH/MO é detalhe técnico.",
"ready_to_ship": False,
"next_action": "Aguardar conclusão da produção/preparação no Odoo.",
"stage": "IN_PRODUCTION",
"next_action": "Aguardar encomenda/WH-OUT no Odoo.",
"stage": "ODOO_ORDER_CREATED",
}
if any(state in {"waiting", "confirmed"} for state in picking_states):
@@ -544,6 +545,158 @@ def _derive_physical_status(sale_order: dict, pickings: list, productions: list)
}
def _safe_apply_odoo_derived_stage(opportunity_id: str, stage: str, *, reason: str = "") -> dict:
"""Apply an Odoo-derived stage, including one guarded physical rewind.
``picking assigned`` is stronger evidence than a stale ClientFlow stage.
It may safely move READY_TO_SHIP/SHIPMENT_CREATED back to
ORDER_PREPARATION only when no physical validation, shipment or tracking
exists. No commercial/financial stage is rewound.
"""
stage = str(stage or "").strip().upper()
if not stage:
return {"changed": False, "reason": "no_stage"}
if stage != "ORDER_PREPARATION":
if stage in {"IN_PRODUCTION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED"}:
try:
from app.opportunity_service import set_opportunity_stage
changed = set_opportunity_stage(
opportunity_id, stage, note=reason or "Estado físico atualizado pelo Odoo.", created_by="odoo_sync"
)
return {"changed": bool(changed), "stage": stage, "mode": "normal"}
except Exception as exc:
return {"changed": False, "stage": stage, "reason": str(exc)}
return {"changed": False, "stage": stage, "reason": "stage_not_managed"}
with engine.begin() as conn:
current = conn.execute(text("""
SELECT id::text, stage, status, COALESCE(metadata, '{}'::jsonb) AS metadata
FROM opportunities
WHERE id = CAST(:opportunity_id AS UUID)
FOR UPDATE
"""), {"opportunity_id": opportunity_id}).mappings().first()
if not current:
return {"changed": False, "reason": "opportunity_not_found"}
guards = conn.execute(text("""
SELECT
EXISTS (
SELECT 1 FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'odoo' AND external_type = 'physical_validation'
AND status IN ('validated','ready_to_ship')
) AS physical_validated,
EXISTS (
SELECT 1 FROM shipments
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND (COALESCE(external_reference,'') <> ''
OR COALESCE(tracking_code,'') <> ''
OR status NOT IN ('draft','cancelled','failed'))
) AS shipment_exists,
EXISTS (
SELECT 1 FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND ((system = 'packlink' AND external_type = 'shipment')
OR external_type IN ('shipment','tracking'))
AND status NOT IN ('not_created','cancelled','failed')
) AS shipment_link_exists
"""), {"opportunity_id": opportunity_id}).mappings().first() or {}
if guards.get("physical_validated") or guards.get("shipment_exists") or guards.get("shipment_link_exists"):
return {
"changed": False,
"stage": current.get("stage"),
"reason": "physical_validation_or_shipment_exists",
"guards": dict(guards),
}
old_stage = str(current.get("stage") or "")
allowed_current = {
"ODOO_ORDER_CREATED", "IN_PRODUCTION", "ORDER_PREPARATION",
"READY_TO_SHIP", "SHIPMENT_CREATED",
}
if old_stage not in allowed_current:
return {"changed": False, "stage": old_stage, "reason": "commercial_stage_not_rewindable"}
conn.execute(text("""
UPDATE opportunities
SET stage = 'ORDER_PREPARATION',
status = 'open',
last_action_code = 'VALIDATE_PHYSICAL_ORDER',
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now()
WHERE id = CAST(:opportunity_id AS UUID)
"""), {
"opportunity_id": opportunity_id,
"metadata": _json({
"odoo_physical_stage_corrected": True,
"odoo_physical_stage_from": old_stage,
"odoo_physical_stage_to": "ORDER_PREPARATION",
"odoo_physical_stage_reason": reason or "picking_assigned_without_physical_validation",
"odoo_physical_stage_version": "v4928.1.5.132",
}),
})
superseded = conn.execute(text("""
UPDATE tasks
SET status = 'ignored',
done_at = COALESCE(done_at, now()),
done_by = COALESCE(done_by, 'odoo_sync'),
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
note = COALESCE(note, '') || E'\n\nIgnorada: picking Odoo assigned ainda requer validação física.',
updated_at = now()
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND status = 'pending'
AND action_code = 'CREATE_SHIPMENT'
RETURNING id::text
"""), {
"opportunity_id": opportunity_id,
"metadata": _json({
"superseded_by": "VALIDATE_PHYSICAL_ORDER",
"superseded_reason": "picking_assigned_without_physical_validation",
"superseded_version": "v4928.1.5.132",
}),
}).mappings().all()
conn.execute(text("""
INSERT INTO opportunity_events (
id, opportunity_id, event_type, action_code, from_stage, to_stage,
note, payload, created_by
) VALUES (
CAST(:id AS UUID), CAST(:opportunity_id AS UUID),
'odoo_physical_stage_corrected', 'VALIDATE_PHYSICAL_ORDER',
CAST(:from_stage AS TEXT), 'ORDER_PREPARATION', CAST(:note AS TEXT),
CAST(:payload AS JSONB), 'odoo_sync'
)
"""), {
"id": str(uuid.uuid4()),
"opportunity_id": opportunity_id,
"from_stage": old_stage,
"note": reason or "Picking reservado; validação física ainda pendente.",
"payload": _json({"ignored_create_shipment_task_ids": [row["id"] for row in superseded]}),
})
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
materialization = ensure_pending_task_for_next_action(
opportunity_id,
get_opportunity_next_action(opportunity_id),
source="odoo_physical_stage_correction",
actor="odoo_sync",
)
except Exception as exc:
materialization = {"created": False, "reason": str(exc)}
return {
"changed": old_stage != "ORDER_PREPARATION",
"from_stage": old_stage,
"stage": "ORDER_PREPARATION",
"mode": "guarded_physical_rewind",
"materialization": materialization,
}
def sync_opportunity_odoo_status(opportunity_id: str) -> dict:
"""Consulta Odoo e guarda um resumo físico simples na operation_links.
@@ -627,12 +780,23 @@ def sync_opportunity_odoo_status(opportunity_id: str) -> dict:
)
stage = derived.get("stage")
if stage in {"IN_PRODUCTION", "READY_TO_SHIP", "SHIPMENT_CREATED"}:
try:
from app.opportunity_service import set_opportunity_stage
set_opportunity_stage(opportunity_id, stage, note=derived.get("reason") or "", created_by="odoo_sync")
except Exception:
pass
payload["stage_application"] = _safe_apply_odoo_derived_stage(
opportunity_id,
str(stage or ""),
reason=str(derived.get("reason") or ""),
)
if str(derived.get("physical_status") or "").lower() == "shipped":
from app.odoo_delivery_task_reconciliation import reconcile_odoo_delivery_done
with engine.begin() as conn:
payload["task_reconciliation"] = reconcile_odoo_delivery_done(
conn,
opportunity_id,
evidence=payload,
actor="odoo_sync",
upsert_validation=True,
)
return payload