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

@@ -614,11 +614,11 @@ def _odoo_derive_fulfilment(record: Dict[str, Any]) -> Dict[str, Any]:
if delivery_done:
physical_status = "shipped"
label = "Entrega concluída no Odoo"
stage = "SHIPMENT_CREATED"
stage = "SHIPPED"
elif delivery_ready:
physical_status = "ready_to_ship"
label = "Pronta para despacho"
stage = "READY_TO_SHIP"
physical_status = "picking_assigned"
label = "Picking reservado — validação física pendente"
stage = "ORDER_PREPARATION"
elif production_active:
physical_status = "in_production"
label = "Em produção/preparação"
@@ -670,6 +670,86 @@ def _odoo_candidate_from_record(record: Dict[str, Any]) -> Dict[str, Any]:
}
def _existing_odoo_sale_links(external_id: Any, order_name: Any) -> List[Dict[str, Any]]:
"""Return exact open opportunity links for one Odoo sale order.
Matching is deliberately strict: numeric Odoo id or exact sale name. Name,
customer and amount suggestions belong to the operator review path and must
never auto-resolve a reconciliation item.
"""
external_id = _clean(external_id)
order_name = _clean(order_name)
if not external_id and not order_name:
return []
try:
begin = engine.begin
except Exception:
return []
try:
with begin() as conn:
rows = conn.execute(text("""
SELECT DISTINCT
ol.opportunity_id::text AS opportunity_id,
o.title,
o.stage,
o.status,
ol.external_id,
ol.external_name
FROM operation_links ol
JOIN opportunities o ON o.id = ol.opportunity_id
WHERE ol.system = 'odoo'
AND ol.external_type = 'sale_order'
AND o.status = 'open'
AND (
(NULLIF(:external_id, '') IS NOT NULL AND ol.external_id = :external_id)
OR (NULLIF(:order_name, '') IS NOT NULL AND UPPER(COALESCE(ol.external_name, '')) = UPPER(:order_name))
)
ORDER BY ol.opportunity_id
"""), {"external_id": external_id, "order_name": order_name}).mappings().all()
except Exception:
# Reconciliation sync must remain conservative when local schema access
# is unavailable: keep staging the candidate rather than auto-resolving.
return []
return [dict(row) for row in rows]
def _resolve_existing_odoo_reconciliation_item(
*,
external_id: Any,
order_name: Any,
opportunity_id: str,
) -> int:
"""Close stale open candidates without changing opportunity stage or tasks."""
external_id = _clean(external_id)
order_name = _clean(order_name)
with engine.begin() as conn:
result = conn.execute(text("""
UPDATE reconciliation_items ri
SET opportunity_id = CAST(:opportunity_id AS UUID),
status = 'linked',
resolution_note = 'Resolvido automaticamente: venda Odoo já ligada à oportunidade',
resolved_at = now(),
updated_at = now(),
payload = COALESCE(ri.payload, '{}'::jsonb) || jsonb_build_object(
'resolved_as_existing_operation_link', TRUE,
'resolved_by', 'odoo_reconciliation_sync_v129',
'resolved_sale_order', COALESCE(NULLIF(:order_name, ''), NULLIF(:external_id, ''))
)
WHERE ri.source_system = 'odoo'
AND ri.external_type = 'odoo_sale_order'
AND ri.status IN ('open', 'needs_review', 'conflict')
AND (
(NULLIF(:external_id, '') IS NOT NULL AND ri.external_id = :external_id)
OR (NULLIF(:order_name, '') IS NOT NULL AND UPPER(COALESCE(ri.document_number, '')) = UPPER(:order_name))
)
"""), {
"external_id": external_id,
"order_name": order_name,
"opportunity_id": opportunity_id,
})
return int(result.rowcount or 0)
def sync_odoo_reconciliation_candidates(*, limit: int = 100, days: int = 3) -> Dict[str, Any]:
"""Fetch recent Odoo sale orders and stage unlinked candidates."""
if not bool(settings.odoo_enabled):
@@ -728,6 +808,9 @@ def sync_odoo_reconciliation_candidates(*, limit: int = 100, days: int = 3) -> D
seen = 0
created = 0
already_linked = 0
resolved_existing = 0
link_conflicts = 0
for record in records:
partner = record.get("partner_id")
if isinstance(partner, (list, tuple)) and partner:
@@ -750,9 +833,40 @@ def sync_odoo_reconciliation_candidates(*, limit: int = 100, days: int = 3) -> D
record["fulfilment"] = _odoo_derive_fulfilment(record)
seen += 1
candidate = _odoo_candidate_from_record(record)
existing_links = _existing_odoo_sale_links(candidate.get("external_id"), candidate.get("document_number"))
if len(existing_links) == 1:
already_linked += 1
resolved_existing += _resolve_existing_odoo_reconciliation_item(
external_id=candidate.get("external_id"),
order_name=candidate.get("document_number"),
opportunity_id=existing_links[0]["opportunity_id"],
)
continue
if len(existing_links) > 1:
link_conflicts += 1
candidate["status"] = "conflict"
candidate["priority"] = "alta"
candidate["description"] = (
"A venda Odoo aparece ligada a mais de uma oportunidade. "
"Requer correção manual das ligações antes de reconciliar."
)
candidate["payload"] = {
**(candidate.get("payload") or {}),
"existing_operation_link_conflict": existing_links,
}
upsert_reconciliation_item(**candidate)
created += 1
return {"source": "odoo", "enabled": True, "seen": seen, "created_or_updated": created, "days": max(int(days), 1), "since": since}
return {
"source": "odoo",
"enabled": True,
"seen": seen,
"created_or_updated": created,
"already_linked": already_linked,
"resolved_existing": resolved_existing,
"link_conflicts": link_conflicts,
"days": max(int(days), 1),
"since": since,
}
def _packlink_candidate_from_record(record: Dict[str, Any]) -> Dict[str, Any]: