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