Release v4928.1.4.2 stable
This commit is contained in:
669
app/odoo_service.py
Normal file
669
app/odoo_service.py
Normal file
@@ -0,0 +1,669 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.odoo_client import OdooClient
|
||||
|
||||
|
||||
_SCHEMA_READY = False
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _money(value: Any, default: str = "0") -> str:
|
||||
try:
|
||||
if value is None or str(value).strip() == "":
|
||||
return f"{Decimal(default):.2f}"
|
||||
return f"{Decimal(str(value).replace(',', '.')).quantize(Decimal('0.01'))}"
|
||||
except (InvalidOperation, ValueError):
|
||||
return f"{Decimal(default):.2f}"
|
||||
|
||||
|
||||
def _m2o_id(value: Any) -> Optional[int]:
|
||||
if isinstance(value, (list, tuple)) and value:
|
||||
try:
|
||||
return int(value[0])
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _m2o_name(value: Any) -> str:
|
||||
if isinstance(value, (list, tuple)) and len(value) > 1:
|
||||
return str(value[1] or "")
|
||||
return ""
|
||||
|
||||
|
||||
def ensure_odoo_schema() -> None:
|
||||
global _SCHEMA_READY
|
||||
if _SCHEMA_READY:
|
||||
return
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS odoo_sync_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sync_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
total_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_changed INTEGER NOT NULL DEFAULT 0,
|
||||
total_errors INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
)
|
||||
"""))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_odoo_sync_runs_type_started ON odoo_sync_runs(sync_type, started_at DESC)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_products_metadata_odoo_id ON products ((metadata->'odoo'->>'product_id'))"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_products_metadata_odoo_sync ON products ((metadata->'odoo'->>'last_synced_at'))"))
|
||||
|
||||
_SCHEMA_READY = True
|
||||
|
||||
|
||||
def test_odoo_connection() -> Dict[str, Any]:
|
||||
client = OdooClient()
|
||||
version = client.version()
|
||||
uid = client.authenticate()
|
||||
counts = {}
|
||||
for model in ["product.product", "stock.quant", "mrp.bom", "mrp.production", "sale.order"]:
|
||||
try:
|
||||
counts[model] = client.count(model, [])
|
||||
except Exception as exc:
|
||||
counts[model] = f"erro: {exc}"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"base_url": settings.odoo_base_url,
|
||||
"db": settings.odoo_db,
|
||||
"username": settings.odoo_username,
|
||||
"uid": uid,
|
||||
"version": version,
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
|
||||
def _start_sync(sync_type: str, payload: Optional[Dict[str, Any]] = None) -> str:
|
||||
ensure_odoo_schema()
|
||||
sync_id = str(uuid.uuid4())
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO odoo_sync_runs (id, sync_type, status, payload)
|
||||
VALUES (CAST(:id AS UUID), :sync_type, 'running', CAST(:payload AS JSONB))
|
||||
"""), {"id": sync_id, "sync_type": sync_type, "payload": _json(payload or {})})
|
||||
return sync_id
|
||||
|
||||
|
||||
def _finish_sync(sync_id: str, *, status: str, total_seen: int = 0, total_changed: int = 0, total_errors: int = 0, message: str = "", payload: Optional[Dict[str, Any]] = None) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE odoo_sync_runs
|
||||
SET status = :status,
|
||||
finished_at = now(),
|
||||
total_seen = :total_seen,
|
||||
total_changed = :total_changed,
|
||||
total_errors = :total_errors,
|
||||
message = :message,
|
||||
payload = payload || CAST(:payload AS JSONB)
|
||||
WHERE id = CAST(:id AS UUID)
|
||||
"""), {
|
||||
"id": sync_id,
|
||||
"status": status,
|
||||
"total_seen": int(total_seen),
|
||||
"total_changed": int(total_changed),
|
||||
"total_errors": int(total_errors),
|
||||
"message": message,
|
||||
"payload": _json(payload or {}),
|
||||
})
|
||||
|
||||
|
||||
def list_odoo_sync_runs(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
ensure_odoo_schema()
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
SELECT id::text, sync_type, status, started_at, finished_at,
|
||||
total_seen, total_changed, total_errors, message, payload
|
||||
FROM odoo_sync_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT :limit
|
||||
"""), {"limit": int(limit)}).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _fetch_stock_by_product(client: OdooClient, *, limit: int = 10000) -> Dict[int, Dict[str, float]]:
|
||||
fields = ["product_id", "location_id", "quantity", "reserved_quantity"]
|
||||
try:
|
||||
quants = client.search_read(
|
||||
"stock.quant",
|
||||
[["location_id.usage", "=", "internal"]],
|
||||
fields,
|
||||
limit=limit,
|
||||
context={"active_test": False},
|
||||
)
|
||||
except Exception:
|
||||
fields = ["product_id", "location_id", "quantity"]
|
||||
quants = client.search_read(
|
||||
"stock.quant",
|
||||
[["location_id.usage", "=", "internal"]],
|
||||
fields,
|
||||
limit=limit,
|
||||
context={"active_test": False},
|
||||
)
|
||||
|
||||
by_product: Dict[int, Dict[str, float]] = defaultdict(lambda: {"quantity": 0.0, "reserved": 0.0})
|
||||
for q in quants:
|
||||
pid = _m2o_id(q.get("product_id"))
|
||||
if not pid:
|
||||
continue
|
||||
by_product[pid]["quantity"] += float(q.get("quantity") or 0)
|
||||
by_product[pid]["reserved"] += float(q.get("reserved_quantity") or 0)
|
||||
|
||||
for vals in by_product.values():
|
||||
vals["available"] = vals["quantity"] - vals["reserved"]
|
||||
|
||||
return by_product
|
||||
|
||||
|
||||
def _fetch_bom_index(client: OdooClient, *, limit: int = 5000) -> Tuple[Dict[int, int], Dict[int, int]]:
|
||||
boms = client.search_read(
|
||||
"mrp.bom",
|
||||
[],
|
||||
["id", "product_id", "product_tmpl_id", "type", "active"],
|
||||
limit=limit,
|
||||
context={"active_test": False},
|
||||
)
|
||||
|
||||
by_product: Dict[int, int] = defaultdict(int)
|
||||
by_template: Dict[int, int] = defaultdict(int)
|
||||
|
||||
for bom in boms:
|
||||
product_id = _m2o_id(bom.get("product_id"))
|
||||
template_id = _m2o_id(bom.get("product_tmpl_id"))
|
||||
if product_id:
|
||||
by_product[product_id] += 1
|
||||
if template_id:
|
||||
by_template[template_id] += 1
|
||||
|
||||
return dict(by_product), dict(by_template)
|
||||
|
||||
|
||||
def _upsert_product(row: Dict[str, Any], metadata: Dict[str, Any]) -> bool:
|
||||
odoo_product_id = int(row["id"])
|
||||
sku = str(row.get("default_code") or "").strip()
|
||||
if not sku:
|
||||
sku = f"ODOO-{odoo_product_id}"
|
||||
|
||||
name = str(row.get("display_name") or row.get("name") or sku).strip()
|
||||
category = _m2o_name(row.get("categ_id")) or "Odoo"
|
||||
price = _money(row.get("lst_price") if "lst_price" in row else row.get("list_price"))
|
||||
|
||||
with engine.begin() as conn:
|
||||
result = conn.execute(text("""
|
||||
INSERT INTO products (
|
||||
id, sku, name, category, description,
|
||||
default_unit_price, vat_rate, active, metadata, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
CAST(:id AS UUID), :sku, :name, :category, :description,
|
||||
:default_unit_price, 23, :active, CAST(:metadata AS JSONB), now(), now()
|
||||
)
|
||||
ON CONFLICT (sku)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
category = EXCLUDED.category,
|
||||
default_unit_price = EXCLUDED.default_unit_price,
|
||||
active = EXCLUDED.active,
|
||||
metadata = products.metadata || EXCLUDED.metadata,
|
||||
updated_at = now()
|
||||
RETURNING id
|
||||
"""), {
|
||||
"id": str(uuid.uuid4()),
|
||||
"sku": sku,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"description": str(row.get("description_sale") or ""),
|
||||
"default_unit_price": price,
|
||||
"active": bool(row.get("active", True)),
|
||||
"metadata": _json({"odoo": metadata}),
|
||||
})
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
def sync_odoo_products(*, limit: int = 500, include_inactive: bool = True) -> Dict[str, Any]:
|
||||
ensure_odoo_schema()
|
||||
sync_id = _start_sync("products", {"limit": limit, "include_inactive": include_inactive})
|
||||
|
||||
total_seen = 0
|
||||
total_changed = 0
|
||||
total_errors = 0
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
client = OdooClient()
|
||||
client.authenticate()
|
||||
|
||||
stock_by_product = _fetch_stock_by_product(client)
|
||||
bom_by_product, bom_by_template = _fetch_bom_index(client)
|
||||
|
||||
fields = [
|
||||
"id",
|
||||
"display_name",
|
||||
"default_code",
|
||||
"active",
|
||||
"lst_price",
|
||||
"standard_price",
|
||||
"categ_id",
|
||||
"product_tmpl_id",
|
||||
"sale_ok",
|
||||
"purchase_ok",
|
||||
"type",
|
||||
"description_sale",
|
||||
]
|
||||
|
||||
try:
|
||||
products = client.search_read(
|
||||
"product.product",
|
||||
[["sale_ok", "=", True]],
|
||||
fields,
|
||||
limit=int(limit),
|
||||
context={"active_test": not include_inactive},
|
||||
)
|
||||
except Exception:
|
||||
fallback_fields = ["id", "display_name", "default_code", "active", "lst_price", "categ_id", "product_tmpl_id", "sale_ok", "purchase_ok"]
|
||||
products = client.search_read(
|
||||
"product.product",
|
||||
[["sale_ok", "=", True]],
|
||||
fallback_fields,
|
||||
limit=int(limit),
|
||||
context={"active_test": not include_inactive},
|
||||
)
|
||||
|
||||
synced_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
for product in products:
|
||||
total_seen += 1
|
||||
try:
|
||||
product_id = int(product["id"])
|
||||
template_id = _m2o_id(product.get("product_tmpl_id"))
|
||||
stock = stock_by_product.get(product_id, {"quantity": 0.0, "reserved": 0.0, "available": 0.0})
|
||||
bom_count = int(bom_by_product.get(product_id, 0) + (bom_by_template.get(template_id, 0) if template_id else 0))
|
||||
|
||||
metadata = {
|
||||
"source": "odoo",
|
||||
"product_id": product_id,
|
||||
"template_id": template_id,
|
||||
"last_synced_at": synced_at,
|
||||
"sale_ok": bool(product.get("sale_ok")),
|
||||
"purchase_ok": bool(product.get("purchase_ok")),
|
||||
"type": product.get("type"),
|
||||
"cost": product.get("standard_price"),
|
||||
"stock": {
|
||||
"quantity_on_hand": stock["quantity"],
|
||||
"reserved": stock["reserved"],
|
||||
"available": stock["available"],
|
||||
},
|
||||
"has_bom": bom_count > 0,
|
||||
"bom_count": bom_count,
|
||||
}
|
||||
|
||||
if _upsert_product(product, metadata):
|
||||
total_changed += 1
|
||||
except Exception as exc:
|
||||
total_errors += 1
|
||||
errors.append(f"Produto {product.get('id')}: {exc}")
|
||||
|
||||
status = "success" if total_errors == 0 else "partial"
|
||||
_finish_sync(sync_id, status=status, total_seen=total_seen, total_changed=total_changed, total_errors=total_errors, message="sync produtos concluído", payload={"errors": errors[:20]})
|
||||
|
||||
return {
|
||||
"ok": total_errors == 0,
|
||||
"sync_id": sync_id,
|
||||
"status": status,
|
||||
"total_seen": total_seen,
|
||||
"total_changed": total_changed,
|
||||
"total_errors": total_errors,
|
||||
"errors": errors[:20],
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
_finish_sync(sync_id, status="failed", total_seen=total_seen, total_changed=total_changed, total_errors=total_errors + 1, message=str(exc), payload={"errors": errors[:20]})
|
||||
raise
|
||||
|
||||
|
||||
def get_odoo_product_snapshot(limit: int = 20) -> Dict[str, Any]:
|
||||
ensure_odoo_schema()
|
||||
with engine.begin() as conn:
|
||||
stats = conn.execute(text("""
|
||||
SELECT
|
||||
count(*) FILTER (WHERE metadata ? 'odoo') AS synced_products,
|
||||
count(*) FILTER (WHERE COALESCE((metadata->'odoo'->>'has_bom')::boolean, false)) AS products_with_bom,
|
||||
count(*) FILTER (WHERE COALESCE((metadata->'odoo'->'stock'->>'available')::numeric, 0) > 0) AS products_with_available_stock,
|
||||
max(metadata->'odoo'->>'last_synced_at') AS last_synced_at
|
||||
FROM products
|
||||
""")).mappings().first()
|
||||
|
||||
rows = conn.execute(text("""
|
||||
SELECT
|
||||
id::text,
|
||||
sku,
|
||||
name,
|
||||
category,
|
||||
default_unit_price,
|
||||
active,
|
||||
metadata->'odoo' AS odoo
|
||||
FROM products
|
||||
WHERE metadata ? 'odoo'
|
||||
ORDER BY name
|
||||
LIMIT :limit
|
||||
"""), {"limit": int(limit)}).mappings().all()
|
||||
|
||||
return {
|
||||
"stats": dict(stats or {}),
|
||||
"products": [dict(row) for row in rows],
|
||||
"sync_runs": list_odoo_sync_runs(limit=10),
|
||||
}
|
||||
|
||||
# === ClientFlow Odoo physical status integration ===
|
||||
|
||||
def _compact_m2o(value):
|
||||
if isinstance(value, (list, tuple)) and len(value) >= 2:
|
||||
return {"id": value[0], "name": value[1]}
|
||||
if isinstance(value, int):
|
||||
return {"id": value, "name": ""}
|
||||
return {"id": None, "name": ""}
|
||||
|
||||
|
||||
def _find_odoo_sale_order_link(opportunity_id: str) -> dict:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
SELECT
|
||||
opportunity_id::text,
|
||||
external_id,
|
||||
external_name,
|
||||
external_url,
|
||||
status,
|
||||
payload
|
||||
FROM operation_links
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND system = 'odoo'
|
||||
AND external_type = 'sale_order'
|
||||
LIMIT 1
|
||||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||||
return dict(row or {})
|
||||
|
||||
|
||||
def _find_sale_order(client: OdooClient, external_id: str = "", external_name: str = "") -> dict:
|
||||
fields = ["id", "name", "state", "partner_id", "amount_total", "date_order"]
|
||||
|
||||
if external_id and str(external_id).isdigit():
|
||||
rows = client.search_read("sale.order", [["id", "=", int(external_id)]], fields, limit=1)
|
||||
if rows:
|
||||
return dict(rows[0])
|
||||
|
||||
for ref in [external_name, external_id]:
|
||||
ref = str(ref or "").strip()
|
||||
if not ref:
|
||||
continue
|
||||
rows = client.search_read("sale.order", [["name", "=", ref]], fields, limit=1)
|
||||
if rows:
|
||||
return dict(rows[0])
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _search_odoo_pickings(client: OdooClient, sale_name: str) -> list:
|
||||
if not sale_name:
|
||||
return []
|
||||
fields = ["id", "name", "state", "origin", "picking_type_id", "scheduled_date", "date_done"]
|
||||
try:
|
||||
rows = client.search_read("stock.picking", [["origin", "ilike", sale_name]], fields, limit=100, order="id desc")
|
||||
except Exception:
|
||||
rows = []
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _search_odoo_productions(client: OdooClient, sale_name: str) -> list:
|
||||
if not sale_name:
|
||||
return []
|
||||
fields = ["id", "name", "state", "origin", "product_id", "product_qty", "date_start", "date_finished"]
|
||||
try:
|
||||
rows = client.search_read("mrp.production", [["origin", "ilike", sale_name]], fields, limit=100, order="id desc")
|
||||
except Exception:
|
||||
rows = []
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _is_outgoing_picking(picking: dict) -> bool:
|
||||
name = str(picking.get("name") or "").upper()
|
||||
picking_type = _m2o_name(picking.get("picking_type_id")).lower()
|
||||
|
||||
if "/OUT/" in name or name.startswith("WH/OUT"):
|
||||
return True
|
||||
|
||||
keywords = ["delivery", "outgoing", "entrega", "expedição", "expedicao", "saída", "saida"]
|
||||
return any(k in picking_type for k in keywords)
|
||||
|
||||
|
||||
def _derive_physical_status(sale_order: dict, pickings: list, productions: list) -> dict:
|
||||
sale_state = str(sale_order.get("state") or "")
|
||||
outgoing = [p for p in pickings if _is_outgoing_picking(p)] or pickings
|
||||
|
||||
picking_states = {str(p.get("state") or "") for p in outgoing}
|
||||
production_states = {str(mo.get("state") or "") for mo in productions}
|
||||
|
||||
if sale_state in {"cancel", "cancelled"}:
|
||||
return {
|
||||
"physical_status": "cancelled",
|
||||
"label": "Cancelada",
|
||||
"reason": "A venda no Odoo está cancelada.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Rever oportunidade no ClientFlow.",
|
||||
"stage": None,
|
||||
}
|
||||
|
||||
if outgoing and all(str(p.get("state") or "") == "done" for p in outgoing):
|
||||
return {
|
||||
"physical_status": "shipped",
|
||||
"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",
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Aguardar conclusão da produção/preparação no Odoo.",
|
||||
"stage": "IN_PRODUCTION",
|
||||
}
|
||||
|
||||
if any(state in {"waiting", "confirmed"} for state in picking_states):
|
||||
return {
|
||||
"physical_status": "waiting_stock",
|
||||
"label": "A aguardar stock/preparação",
|
||||
"reason": "A entrega ainda não está disponível para despacho.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Aguardar stock, compra ou produção no Odoo.",
|
||||
"stage": None,
|
||||
}
|
||||
|
||||
if sale_state in {"draft", "sent"}:
|
||||
return {
|
||||
"physical_status": "quote_only",
|
||||
"label": "Cotação no Odoo",
|
||||
"reason": "A venda ainda não está confirmada no Odoo.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Confirmar venda/pagamento antes de preparar.",
|
||||
"stage": None,
|
||||
}
|
||||
|
||||
if sale_state in {"sale", "done"}:
|
||||
return {
|
||||
"physical_status": "order_created",
|
||||
"label": "Venda criada",
|
||||
"reason": "Venda confirmada, mas sem picking pronto identificado.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Verificar preparação física no Odoo.",
|
||||
"stage": "ODOO_ORDER_CREATED",
|
||||
}
|
||||
|
||||
return {
|
||||
"physical_status": "unknown",
|
||||
"label": "Estado desconhecido",
|
||||
"reason": "Não foi possível interpretar o estado físico a partir do Odoo.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Rever venda diretamente no Odoo.",
|
||||
"stage": None,
|
||||
}
|
||||
|
||||
|
||||
def sync_opportunity_odoo_status(opportunity_id: str) -> dict:
|
||||
"""Consulta Odoo e guarda um resumo físico simples na operation_links.
|
||||
|
||||
Não altera o Odoo. Apenas lê sale.order, stock.picking e mrp.production,
|
||||
e guarda em operation_links external_type='physical_status'.
|
||||
"""
|
||||
link = _find_odoo_sale_order_link(opportunity_id)
|
||||
if not link:
|
||||
payload = {
|
||||
"physical_status": "no_order",
|
||||
"label": "Sem venda Odoo",
|
||||
"reason": "A oportunidade ainda não tem venda Odoo ligada.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Criar ou associar venda Odoo.",
|
||||
}
|
||||
_upsert_odoo_physical_status_link(opportunity_id, "", "Sem venda Odoo", "", "no_order", payload)
|
||||
return payload
|
||||
|
||||
client = OdooClient()
|
||||
sale = _find_sale_order(client, str(link.get("external_id") or ""), str(link.get("external_name") or ""))
|
||||
|
||||
if not sale:
|
||||
payload = {
|
||||
"physical_status": "not_found",
|
||||
"label": "Venda não encontrada",
|
||||
"reason": "A referência guardada no ClientFlow não foi encontrada no Odoo.",
|
||||
"ready_to_ship": False,
|
||||
"next_action": "Confirmar o número/id da venda Odoo.",
|
||||
"linked_sale_order": link,
|
||||
}
|
||||
_upsert_odoo_physical_status_link(opportunity_id, str(link.get("external_id") or ""), str(link.get("external_name") or ""), str(link.get("external_url") or ""), "not_found", payload)
|
||||
return payload
|
||||
|
||||
sale_name = str(sale.get("name") or "")
|
||||
pickings = _search_odoo_pickings(client, sale_name)
|
||||
productions = _search_odoo_productions(client, sale_name)
|
||||
derived = _derive_physical_status(sale, pickings, productions)
|
||||
|
||||
payload = {
|
||||
**derived,
|
||||
"sale_order": {
|
||||
"id": sale.get("id"),
|
||||
"name": sale.get("name"),
|
||||
"state": sale.get("state"),
|
||||
"partner": _compact_m2o(sale.get("partner_id")),
|
||||
"amount_total": sale.get("amount_total"),
|
||||
"date_order": sale.get("date_order"),
|
||||
},
|
||||
"pickings": [
|
||||
{
|
||||
"id": p.get("id"),
|
||||
"name": p.get("name"),
|
||||
"state": p.get("state"),
|
||||
"type": _compact_m2o(p.get("picking_type_id")),
|
||||
"scheduled_date": p.get("scheduled_date"),
|
||||
"date_done": p.get("date_done"),
|
||||
}
|
||||
for p in pickings
|
||||
],
|
||||
"productions": [
|
||||
{
|
||||
"id": mo.get("id"),
|
||||
"name": mo.get("name"),
|
||||
"state": mo.get("state"),
|
||||
"product": _compact_m2o(mo.get("product_id")),
|
||||
"qty": mo.get("product_qty"),
|
||||
"date_start": mo.get("date_start"),
|
||||
"date_finished": mo.get("date_finished"),
|
||||
}
|
||||
for mo in productions
|
||||
],
|
||||
}
|
||||
|
||||
_upsert_odoo_physical_status_link(
|
||||
opportunity_id,
|
||||
str(sale.get("id") or link.get("external_id") or ""),
|
||||
sale_name,
|
||||
str(link.get("external_url") or ""),
|
||||
derived["physical_status"],
|
||||
payload,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _upsert_odoo_physical_status_link(opportunity_id: str, external_id: str, external_name: str, external_url: str, status: str, payload: dict) -> None:
|
||||
with engine.begin() as conn:
|
||||
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_status',
|
||||
:external_id, :external_name, :external_url,
|
||||
:status, 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,
|
||||
external_url = EXCLUDED.external_url,
|
||||
status = EXCLUDED.status,
|
||||
payload = EXCLUDED.payload,
|
||||
last_synced_at = now(),
|
||||
updated_at = now()
|
||||
"""), {
|
||||
"opportunity_id": opportunity_id,
|
||||
"external_id": external_id,
|
||||
"external_name": external_name,
|
||||
"external_url": external_url,
|
||||
"status": status,
|
||||
"payload": _json(payload),
|
||||
})
|
||||
Reference in New Issue
Block a user