"""External API sync for operational reconciliation. This module is deliberately conservative: it fetches loose evidence from Jasmin/Odoo/Packlink and creates reconciliation candidates. It does not create opportunities, confirm payments, issue invoices or close processes without an operator decision. """ from __future__ import annotations import asyncio import json import re from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation from typing import Any, Dict, Iterable, List, Optional from sqlalchemy import text from app.config import settings from app.db import engine from app.commercial_service import upsert_customer from app.reconciliation_service import upsert_reconciliation_item JASMIN_QUOTATION_TYPE = "jasmin_quotation" JASMIN_INVOICE_TYPE = "jasmin_invoice" JASMIN_PROFORMA_TYPE = "jasmin_proforma" ODOO_SALE_ORDER_TYPE = "odoo_sale_order" PACKLINK_SHIPMENT_TYPE = "packlink_shipment" def _clean(value: Any) -> str: return str(value or "").strip() def _normalize_tax_id(value: Any) -> str: raw = _clean(value).upper().replace(" ", "").replace("-", "").replace(".", "") if raw.startswith("PT"): raw = raw[2:] return raw def _first(record: Dict[str, Any], *keys: str) -> Any: for key in keys: if key in record and record.get(key) not in (None, ""): return record.get(key) return None def _as_list(data: Any) -> List[Dict[str, Any]]: """Normalize common REST/OData payload shapes into a list of records.""" if data is None: return [] if isinstance(data, list): return [x for x in data if isinstance(x, dict)] if isinstance(data, dict): for key in ("value", "items", "data", "results", "shipments"): value = data.get(key) if isinstance(value, list): return [x for x in value if isinstance(x, dict)] return [] def _decimal_or_none(value: Any) -> Optional[str]: raw = _clean(value).replace("€", "").replace(" ", "").replace(",", ".") if not raw: return None try: return str(Decimal(raw).quantize(Decimal("0.01"))) except (InvalidOperation, ValueError): return None def _date_or_none(value: Any) -> Optional[str]: if not value: return None if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() text_value = _clean(value) if not text_value: return None # Jasmin/Odoo commonly return ISO timestamps. PostgreSQL DATE accepts the # YYYY-MM-DD prefix and rejecting unclear values keeps the sync safe. if len(text_value) >= 10 and text_value[4:5] == "-" and text_value[7:8] == "-": return text_value[:10] return None def _date_sort_key(value: Optional[str]) -> str: # Keep undated records last when sorting newest first. return value or "0000-00-00" def _recent_window_start(days: int, *, today: Optional[date] = None) -> date: """Return the inclusive start date for a human "last N days" window. Operators read "últimos 3 dias" as today plus the previous two days. For example, on 2026-06-05, days=3 starts at 2026-06-03, not 2026-06-02. """ days = max(int(days or 1), 1) return (today or datetime.now(timezone.utc).date()) - timedelta(days=days - 1) def _record_date(record: Dict[str, Any]) -> Optional[str]: return _date_or_none(_first(record, "documentDate", "date", "creationDate", "postingDate")) def _within_days(record: Dict[str, Any], *, days: int, today: Optional[date] = None) -> bool: days = max(int(days or 0), 1) date_value = _record_date(record) if not date_value: return False start = _recent_window_start(days, today=today) return date_value >= start.isoformat() def _currency(record: Dict[str, Any]) -> str: value = _first(record, "currency", "currencyKey", "currency_id", "currencyCode") if isinstance(value, (list, tuple)) and value: value = value[1] if len(value) > 1 else value[0] return _clean(value) or "EUR" def _external_id(record: Dict[str, Any], *, fallback_prefix: str) -> str: value = _first( record, "id", "key", "naturalKey", "documentKey", "documentId", "externalId", "name", "reference", "shipmentReference", "tracking_number", ) if value: return _clean(value) digest = json.dumps(record, ensure_ascii=False, default=str, sort_keys=True)[:120] return f"{fallback_prefix}:{abs(hash(digest))}" def _document_number(record: Dict[str, Any]) -> str: composed = _first(record, "documentNumber", "number", "naturalKey", "name", "reference") if composed: return _clean(composed) doc_type = _clean(_first(record, "documentType", "documentTypeKey")) serie = _clean(_first(record, "serie", "serieKey", "series")) number = _clean(_first(record, "seriesNumber", "sequenceNumber")) return " ".join(part for part in [doc_type, serie, number] if part) def _customer_name(record: Dict[str, Any]) -> str: value = _first( record, "customerName", "partyName", "buyerCustomerPartyName", "sellerSupplierPartyName", "name", "partner_name", "recipient_name", ) if isinstance(value, (list, tuple)) and value: value = value[1] if len(value) > 1 else value[0] return _clean(value) def _customer_email(record: Dict[str, Any]) -> str: return _clean(_first(record, "customerEmail", "email", "electronicMail", "recipient_email", "partner_email")) def _customer_tax_id(record: Dict[str, Any]) -> str: """Extract NIF/VAT from common Jasmin/Odoo/Packlink payload shapes.""" direct = _first( record, "customerTaxId", "customerTaxID", "customer_tax_id", "companyTaxID", "companyTaxId", "partyTaxId", "partyTaxID", "buyerCustomerPartyTaxId", "buyerCustomerPartyTaxID", "taxId", "taxID", "vat", "partner_vat", "nif", ) if direct: return _normalize_tax_id(direct) for container_key in ("customer", "customerParty", "buyerCustomerParty", "party", "partner", "recipient"): nested = record.get(container_key) if isinstance(nested, dict): nested_value = _first(nested, "tax_id", "taxId", "taxID", "companyTaxID", "vat", "nif") if nested_value: return _normalize_tax_id(nested_value) return "" COMPANY_NAME_MARKERS = ( "lda", "limitada", "unipessoal", "s.a", " sa", "sociedade anónima", "sociedade anonima", "sgps", "ace", "crl", "sucursal", "empresa", "construções", "construcoes", "soluções", "solucoes", "indústria", "industria", "comércio", "comercio", "portugal", ) def _looks_like_company_name(value: Any) -> bool: text_value = f" {_clean(value).casefold()} " if not text_value.strip(): return False return any(marker in text_value for marker in COMPANY_NAME_MARKERS) def _external_customer_key(record: Dict[str, Any], *, source_system: str) -> str: if source_system == "jasmin": return _clean(_first(record, "partyKey", "customerPartyKey", "naturalKey", "key", "id")) if source_system == "odoo": return _clean(_first(record, "partner_external_id", "id")) return _clean(_first(record, "id", "key", "externalId")) def _looks_like_odoo_sale_order_reference(value: Any) -> bool: """Return True for Odoo sale order numbers such as S00265. Sale order references identify the commercial process, not the fiscal customer. They must never be stored in customers.name. """ return bool(re.fullmatch(r"S\d{4,}", _clean(value).upper())) def _odoo_fiscal_customer_name(record: Dict[str, Any]) -> str: """Prefer Odoo partner legal name over sale.order.name. Recent Odoo sale orders have ``name`` equal to S00xxx and the real fiscal entity in ``partner_name`` or in the many2one ``partner_id`` display name. """ name = _clean(record.get("partner_name")) if not name: partner_id = record.get("partner_id") if isinstance(partner_id, (list, tuple)) and partner_id: name = _clean(partner_id[1] if len(partner_id) > 1 else partner_id[0]) if name and not _looks_like_odoo_sale_order_reference(name): return name # res.partner customer seeding records use ``name`` as the partner name. raw_name = _customer_name(record) if raw_name and not _looks_like_odoo_sale_order_reference(raw_name): return raw_name return "" def _country_code(value: Any) -> str: if isinstance(value, (list, tuple)) and value: return _clean(value[1] if len(value) > 1 else value[0]) or "PT" return _clean(value) or "PT" def _customer_seed_data_from_record(record: Dict[str, Any], *, source_system: str) -> Optional[Dict[str, Any]]: if source_system == "odoo": name = _odoo_fiscal_customer_name(record) else: name = _customer_name(record) or _clean(record.get("partner_name")) tax_id = _customer_tax_id(record) if not name: return None # Never create/update a fiscal customer with an Odoo sale order reference # (S00265, S00277, ...). That reference belongs to the purchase process. if source_system == "odoo" and _looks_like_odoo_sale_order_reference(name): return None # Jasmin customer parties are fiscal/commercial customers by definition. # Odoo res.partner can also contain people/contacts, so require VAT, an # explicit company flag, or a clear legal-company name before creating a # fiscal customer in ClientFlow. is_company = bool(record.get("is_company")) or _clean(record.get("company_type")) == "company" if source_system == "odoo" and not (tax_id or is_company or _looks_like_company_name(name)): return None external_key = _external_customer_key(record, source_system=source_system) metadata = { "seeded_by_reconciliation": True, "source_system": source_system, "external_customer_key": external_key, "raw_customer_payload": record, } data = { "name": name, "tax_id": tax_id, "email": _customer_email(record), "phone": _clean(_first(record, "phone", "telephone", "mobile", "phoneNumber")), "street_name": _clean(_first(record, "streetName", "street", "address", "street_name")), "postal_zone": _clean(_first(record, "postalZone", "zip", "postal_code", "postalCode")), "city_name": _clean(_first(record, "cityName", "city")), "country": _country_code(_first(record, "country", "country_id", "countryCode")), "metadata": metadata, } if source_system == "jasmin": data["jasmin_customer_party_key"] = external_key data["jasmin_customer_id"] = _clean(_first(record, "id", "uuid")) or None return data def _upsert_fiscal_customer_from_external_record(record: Dict[str, Any], *, source_system: str) -> Optional[str]: data = _customer_seed_data_from_record(record, source_system=source_system) if not data: return None try: customer = upsert_customer(data) return str(customer.get("id") or "") or None except Exception: # Customer seeding must never block candidate creation. A document can # still be reviewed manually if the fiscal customer import fails. return None def _already_linked_to_opportunity(*, source_system: str, external_id: str) -> bool: if not external_id: return False try: with engine.begin() as conn: value = conn.execute(text(""" SELECT EXISTS ( SELECT 1 FROM commercial_documents WHERE system = :source_system AND external_id = :external_id AND opportunity_id IS NOT NULL ) """), {"source_system": source_system, "external_id": external_id}).scalar() return bool(value) except Exception: # commercial_documents may not exist yet on very old deployments. The # sync must not fail because of the optional local cache. return False def _jasmin_external_type(record: Dict[str, Any], default_type: str) -> str: text_blob = " ".join( _clean(_first(record, key)) for key in ("documentType", "documentTypeKey", "documentKind", "naturalKey", "documentNumber") ).lower() if "proforma" in text_blob or "pró-forma" in text_blob or "pro-forma" in text_blob: return JASMIN_PROFORMA_TYPE if "invoice" in text_blob or "fatura" in text_blob or "factura" in text_blob or "ft" in text_blob.split(): return JASMIN_INVOICE_TYPE return default_type def _jasmin_amount(record: Dict[str, Any]) -> Optional[str]: return _decimal_or_none(_first(record, "payableAmount", "totalAmount", "total", "grossAmount", "amount")) def _jasmin_candidate_from_record(record: Dict[str, Any], *, default_type: str) -> Optional[Dict[str, Any]]: external_id = _external_id(record, fallback_prefix="jasmin") if _already_linked_to_opportunity(source_system="jasmin", external_id=external_id): return None external_type = _jasmin_external_type(record, default_type) document_number = _document_number(record) or external_id customer_name = _customer_name(record) amount = _jasmin_amount(record) date_value = _date_or_none(_first(record, "documentDate", "date", "creationDate", "postingDate")) label = { JASMIN_QUOTATION_TYPE: "Orçamento Jasmin sem oportunidade", JASMIN_PROFORMA_TYPE: "Pró-forma Jasmin sem oportunidade", JASMIN_INVOICE_TYPE: "Fatura Jasmin sem oportunidade", }.get(external_type, "Documento Jasmin sem oportunidade") suggested_action = { JASMIN_QUOTATION_TYPE: "SEND_PROFORMA", JASMIN_PROFORMA_TYPE: "CONFIRM_PAYMENT", JASMIN_INVOICE_TYPE: "CONFIRM_PAYMENT", }.get(external_type, "REVIEW_MANUALLY") priority = "alta" if external_type in {JASMIN_PROFORMA_TYPE, JASMIN_INVOICE_TYPE} else "normal" customer_id = _upsert_fiscal_customer_from_external_record(record, source_system="jasmin") return { "source_system": "jasmin", "external_type": external_type, "external_id": external_id, "title": f"{label} · {document_number}", "description": "Documento encontrado via API Jasmin. Operador deve ligar a oportunidade existente, criar oportunidade ou ignorar.", "priority": priority, "suggested_action": suggested_action, "confidence": 0.75 if customer_name else 0.55, "customer_id": customer_id, "customer_name": customer_name, "customer_email": _customer_email(record), "customer_tax_id": _customer_tax_id(record), "document_number": document_number, "document_date": date_value, "amount": amount, "currency": _currency(record), "payload": {"source": "jasmin_api", "record": record}, "idempotency_key": f"api-sync:jasmin:{external_type}:{external_id}", } async def sync_jasmin_reconciliation_candidates(*, limit: int = 100, days: int = 3) -> Dict[str, Any]: """Fetch recent Jasmin quotations/invoices and stage unlinked candidates. `days` is intentionally enforced locally after the API call. Some Jasmin tenants accept OData `$filter`, while others return broader result sets. The reconciliation queue must only show the recent window requested by the operator, never historic invoices just because they are first in the API default order. """ if not bool(settings.jasmin_enabled): return {"source": "jasmin", "enabled": False, "seen": 0, "created_or_updated": 0, "skipped": "JASMIN_ENABLED=false"} from app.jasmin_client import JasminClient client = JasminClient() seen = 0 created = 0 errors: List[str] = [] limit = min(max(int(limit or 100), 1), 500) days = max(int(days or 3), 1) since = _recent_window_start(days).isoformat() async def _fetch(kind: str) -> List[Dict[str, Any]]: # Fetch a wider first page per document family because final `limit` is # global after merging quotations and invoices. Ask Jasmin for recent # documents first, then still validate the date locally. top = min(max(limit * 2, 20), 100) odata_filter = f"documentDate ge {since}" try: if kind == "quotation": data = await client.list_quotations(top=top, skip=0, filter=odata_filter, orderby="documentDate desc") else: data = await client.list_invoices(top=top, skip=0, filter=odata_filter, orderby="documentDate desc") return _as_list(data) except Exception as exc: errors.append(f"{kind} filtered: {exc}") try: # Compatibility fallback for tenants where OData filtering over # documentDate is not supported. Local filtering still applies. if kind == "quotation": data = await client.list_quotations(top=top, skip=0, orderby="documentDate desc") else: data = await client.list_invoices(top=top, skip=0, orderby="documentDate desc") return _as_list(data) except Exception as fallback_exc: errors.append(f"{kind}: {fallback_exc}") return [] staged: List[tuple[str, Dict[str, Any]]] = [] for default_type, records in [ (JASMIN_QUOTATION_TYPE, await _fetch("quotation")), (JASMIN_INVOICE_TYPE, await _fetch("invoice")), ]: for record in records: if not _within_days(record, days=days): continue staged.append((default_type, record)) staged.sort(key=lambda item: _date_sort_key(_record_date(item[1])), reverse=True) for default_type, record in staged[:limit]: seen += 1 candidate = _jasmin_candidate_from_record(record, default_type=default_type) if not candidate: continue upsert_reconciliation_item(**candidate) created += 1 return { "source": "jasmin", "enabled": True, "seen": seen, "created_or_updated": created, "days": days, "since": since, "errors": errors, } def _odoo_partner_name(value: Any) -> str: if isinstance(value, (list, tuple)) and value: return _clean(value[1] if len(value) > 1 else value[0]) return _clean(value) def _odoo_currency(value: Any) -> str: if isinstance(value, (list, tuple)) and value: return _clean(value[1] if len(value) > 1 else value[0]) or "EUR" return _clean(value) or "EUR" def _odoo_m2o_id(value: Any) -> Optional[int]: if isinstance(value, (list, tuple)) and value: try: return int(value[0]) except (TypeError, ValueError): return None try: return int(value) except (TypeError, ValueError): return None def _odoo_m2o_name(value: Any) -> str: if isinstance(value, (list, tuple)) and value: return _clean(value[1] if len(value) > 1 else value[0]) return _clean(value) def _odoo_search_read_safe(client: Any, model: str, domain: List[Any], fields: List[str], *, limit: int = 100, order: str = "id asc") -> List[Dict[str, Any]]: try: rows = client.search_read(model, domain=domain, fields=fields, limit=limit, order=order) return [dict(row) for row in rows] except Exception: return [] def _odoo_fetch_sale_lines(client: Any, sale_id: int) -> List[Dict[str, Any]]: if not sale_id: return [] base_fields = ["id", "order_id", "product_id", "name", "product_uom_qty", "price_unit", "price_total"] extended_fields = base_fields + ["qty_delivered", "qty_invoiced"] rows = _odoo_search_read_safe(client, "sale.order.line", [["order_id", "=", sale_id]], extended_fields, limit=200, order="id asc") if not rows: rows = _odoo_search_read_safe(client, "sale.order.line", [["order_id", "=", sale_id]], base_fields, limit=200, order="id asc") normalized = [] for row in rows: normalized.append({ "id": row.get("id"), "product_id": _odoo_m2o_id(row.get("product_id")), "product_name": _odoo_m2o_name(row.get("product_id")) or _clean(row.get("name")), "description": _clean(row.get("name")), "qty_ordered": row.get("product_uom_qty"), "qty_delivered": row.get("qty_delivered"), "qty_invoiced": row.get("qty_invoiced"), "price_unit": row.get("price_unit"), "price_total": row.get("price_total"), }) return normalized def _odoo_fetch_pickings(client: Any, sale_name: str) -> List[Dict[str, Any]]: if not sale_name: return [] fields = ["id", "name", "state", "origin", "picking_type_id", "scheduled_date", "date_done"] rows = _odoo_search_read_safe(client, "stock.picking", [["origin", "ilike", sale_name]], fields, limit=100, order="id desc") return [ { "id": row.get("id"), "name": row.get("name"), "state": row.get("state"), "origin": row.get("origin"), "type": _odoo_m2o_name(row.get("picking_type_id")), "scheduled_date": row.get("scheduled_date"), "date_done": row.get("date_done"), } for row in rows ] def _odoo_fetch_productions(client: Any, sale_name: str) -> List[Dict[str, Any]]: if not sale_name: return [] fields = ["id", "name", "state", "origin", "product_id", "product_qty", "date_start", "date_finished"] rows = _odoo_search_read_safe(client, "mrp.production", [["origin", "ilike", sale_name]], fields, limit=100, order="id desc") return [ { "id": row.get("id"), "name": row.get("name"), "state": row.get("state"), "origin": row.get("origin"), "product_name": _odoo_m2o_name(row.get("product_id")), "qty": row.get("product_qty"), "date_start": row.get("date_start"), "date_finished": row.get("date_finished"), } for row in rows ] def _odoo_is_outgoing_picking(picking: Dict[str, Any]) -> bool: name = _clean(picking.get("name")).upper() picking_type = _clean(picking.get("type")).lower() if "/OUT/" in name or name.startswith("WH/OUT"): return True return any(keyword in picking_type for keyword in ["delivery", "outgoing", "entrega", "expedi", "saída", "saida"]) def _odoo_derive_fulfilment(record: Dict[str, Any]) -> Dict[str, Any]: lines = record.get("order_lines") if isinstance(record.get("order_lines"), list) else [] pickings = record.get("pickings") if isinstance(record.get("pickings"), list) else [] productions = record.get("productions") if isinstance(record.get("productions"), list) else [] outgoing = [p for p in pickings if _odoo_is_outgoing_picking(p)] or pickings delivery_done = bool(outgoing) and all(_clean(p.get("state")) == "done" for p in outgoing) delivery_ready = any(_clean(p.get("state")) == "assigned" for p in outgoing) production_active = any(_clean(p.get("state")) in {"progress", "to_close", "confirmed"} for p in productions) invoice_status = _clean(record.get("invoice_status")) has_uninvoiced_lines = False for line in lines: ordered = line.get("qty_ordered") or 0 invoiced = line.get("qty_invoiced") try: if invoiced is not None and float(invoiced) < float(ordered or 0): has_uninvoiced_lines = True except (TypeError, ValueError): pass invoice_pending = invoice_status in {"to invoice", "no"} or has_uninvoiced_lines physical_status = "order_created" label = "Venda criada" stage = "ODOO_ORDER_CREATED" if delivery_done: physical_status = "shipped" label = "Entrega concluída no Odoo" stage = "SHIPMENT_CREATED" elif delivery_ready: physical_status = "ready_to_ship" label = "Pronta para despacho" stage = "READY_TO_SHIP" elif production_active: physical_status = "in_production" label = "Em produção/preparação" stage = "IN_PRODUCTION" return { "physical_status": physical_status, "label": label, "stage": stage, "delivery_done": delivery_done, "delivery_ready": delivery_ready, "invoice_pending": invoice_pending, "invoice_status": invoice_status, "next_action": "SEND_INVOICE" if invoice_pending else "REVIEW_MANUALLY", "outgoing_pickings": outgoing, "lines": lines, "productions": productions, } def _odoo_candidate_from_record(record: Dict[str, Any]) -> Dict[str, Any]: external_id = _clean(_first(record, "id", "name")) order_name = _clean(_first(record, "name", "client_order_ref")) or f"Odoo #{external_id}" customer_name = _clean(record.get("partner_name")) or _odoo_partner_name(record.get("partner_id")) state = _clean(record.get("state")) invoice_status = _clean(record.get("invoice_status")) amount = _decimal_or_none(record.get("amount_total")) fulfilment = record.get("fulfilment") if isinstance(record.get("fulfilment"), dict) else _odoo_derive_fulfilment(record) invoice_pending = bool(fulfilment.get("invoice_pending")) or invoice_status in {"to invoice", "no"} customer_id = _upsert_fiscal_customer_from_external_record(record, source_system="odoo") return { "source_system": "odoo", "external_type": ODOO_SALE_ORDER_TYPE, "external_id": external_id or order_name, "title": f"Venda Odoo sem oportunidade · {order_name}", "description": "Venda/encomenda encontrada via API Odoo. Operador deve ligar a oportunidade existente, criar oportunidade ou ignorar.", "priority": "alta" if state in {"sale", "done"} and invoice_pending else "normal", "suggested_action": "SEND_INVOICE" if invoice_pending else "REVIEW_MANUALLY", "confidence": 0.70 if customer_name else 0.50, "customer_id": customer_id, "customer_name": customer_name, "customer_email": _customer_email(record), "customer_tax_id": _customer_tax_id(record), "document_number": order_name, "document_date": _date_or_none(record.get("date_order") or record.get("create_date")), "amount": amount, "currency": _odoo_currency(record.get("currency_id")), "payload": {"source": "odoo_api", "record": record}, "idempotency_key": f"api-sync:odoo:sale_order:{external_id or order_name}", } 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): return {"source": "odoo", "enabled": False, "seen": 0, "created_or_updated": 0, "skipped": "ODOO_ENABLED=false"} from app.odoo_client import OdooClient client = OdooClient() days = max(int(days or 3), 1) since = _recent_window_start(days).strftime("%Y-%m-%d") domain = [["date_order", ">=", since], ["state", "in", ["sale", "done"]]] fields = [ "id", "name", "partner_id", "amount_total", "currency_id", "date_order", "state", "invoice_status", "client_order_ref", "create_date", "order_line", ] records = client.search_read("sale.order", domain=domain, fields=fields, limit=limit, order="date_order desc") # sale.order does not reliably expose partner VAT/NIF in all Odoo editions. # Fetch it from res.partner and enrich the staging record before matching. partner_ids: List[int] = [] for record in records: partner = record.get("partner_id") if isinstance(partner, (list, tuple)) and partner: try: partner_ids.append(int(partner[0])) except (TypeError, ValueError): pass partner_vat_by_id: Dict[int, str] = {} partner_email_by_id: Dict[int, str] = {} partner_name_by_id: Dict[int, str] = {} if partner_ids: partners = client.search_read( "res.partner", domain=[["id", "in", sorted(set(partner_ids))]], fields=["id", "vat", "email", "name"], limit=len(set(partner_ids)), order="id asc", ) for partner in partners: try: partner_id = int(partner.get("id")) except (TypeError, ValueError): continue partner_vat_by_id[partner_id] = _normalize_tax_id(partner.get("vat")) partner_email_by_id[partner_id] = _clean(partner.get("email")) partner_name_by_id[partner_id] = _clean(partner.get("name")) seen = 0 created = 0 for record in records: partner = record.get("partner_id") if isinstance(partner, (list, tuple)) and partner: try: partner_id = int(partner[0]) record["partner_vat"] = partner_vat_by_id.get(partner_id, "") record["partner_email"] = partner_email_by_id.get(partner_id, "") record["partner_name"] = partner_name_by_id.get(partner_id, "") record["partner_external_id"] = partner_id except (TypeError, ValueError): pass try: sale_id = int(record.get("id")) except (TypeError, ValueError): sale_id = 0 sale_name = _clean(record.get("name")) record["order_lines"] = _odoo_fetch_sale_lines(client, sale_id) record["pickings"] = _odoo_fetch_pickings(client, sale_name) record["productions"] = _odoo_fetch_productions(client, sale_name) record["fulfilment"] = _odoo_derive_fulfilment(record) seen += 1 candidate = _odoo_candidate_from_record(record) upsert_reconciliation_item(**candidate) created += 1 return {"source": "odoo", "enabled": True, "seen": seen, "created_or_updated": created, "days": max(int(days), 1), "since": since} def _packlink_candidate_from_record(record: Dict[str, Any]) -> Dict[str, Any]: external_id = _external_id(record, fallback_prefix="packlink") reference = _clean(_first(record, "reference", "shipmentReference", "id", "tracking_number")) or external_id recipient = record.get("to") if isinstance(record.get("to"), dict) else {} customer_name = _customer_name(record) or _clean(_first(recipient, "name", "contactName", "company")) email = _customer_email(record) or _clean(_first(recipient, "email")) amount = _decimal_or_none(_first(record, "price", "totalPrice", "amount")) customer_id = _upsert_fiscal_customer_from_external_record(record, source_system="packlink") return { "source_system": "packlink", "external_type": PACKLINK_SHIPMENT_TYPE, "external_id": external_id, "title": f"Envio Packlink sem oportunidade · {reference}", "description": "Envio/tracking encontrado via API Packlink. Operador deve ligar a oportunidade existente ou ignorar.", "priority": "normal", "suggested_action": "REVIEW_MANUALLY", "confidence": 0.55, "customer_id": customer_id, "customer_name": customer_name, "customer_email": email, "customer_tax_id": _customer_tax_id(record), "document_number": reference, "document_date": _date_or_none(_first(record, "createdAt", "created_at", "date")), "amount": amount, "currency": _currency(record), "payload": {"source": "packlink_api", "record": record}, "idempotency_key": f"api-sync:packlink:shipment:{external_id}", } async def sync_packlink_reconciliation_candidates(*, limit: int = 100, days: int = 3) -> Dict[str, Any]: """Fetch recent Packlink shipments if the API/list endpoint is available.""" if not bool(settings.packlink_enabled): return {"source": "packlink", "enabled": False, "seen": 0, "created_or_updated": 0, "skipped": "PACKLINK_ENABLED=false"} from app.packlink_client import PacklinkClient client = PacklinkClient() try: if hasattr(client, "list_shipments"): data = await client.list_shipments(limit=limit) else: data = await client._request("GET", "/shipments", params={"limit": limit}) # type: ignore[attr-defined] except Exception as exc: return {"source": "packlink", "enabled": True, "seen": 0, "created_or_updated": 0, "errors": [str(exc)]} days = max(int(days or 3), 1) records = [] for record in _as_list(data): if _within_days(record, days=days): records.append(record) if len(records) >= int(limit): break created = 0 for record in records: upsert_reconciliation_item(**_packlink_candidate_from_record(record)) created += 1 return {"source": "packlink", "enabled": True, "seen": len(records), "created_or_updated": created, "days": days} async def sync_jasmin_fiscal_customers_for_reconciliation(*, limit: int = 200) -> Dict[str, Any]: """Seed ClientFlow fiscal customers from Jasmin before document matching.""" if not bool(settings.jasmin_enabled): return {"source": "jasmin_customers", "enabled": False, "seen": 0, "created_or_updated": 0, "skipped": "JASMIN_ENABLED=false"} from app.jasmin_client import JasminClient client = JasminClient() seen = 0 created = 0 errors: List[str] = [] try: data = await client.list_customers_odata(top=min(max(int(limit or 200), 1), 100), skip=0) records = _as_list(data) except Exception as exc: errors.append(str(exc)) records = [] for record in records[: max(int(limit or 200), 1)]: seen += 1 if _upsert_fiscal_customer_from_external_record(record, source_system="jasmin"): created += 1 return {"source": "jasmin_customers", "enabled": True, "seen": seen, "created_or_updated": created, "errors": errors} def _odoo_model_field_names(client: Any, model: str, errors: List[str]) -> Optional[set]: """Return available Odoo model fields, or None if discovery is unavailable. Odoo installations can differ by edition/module set. In the production instance that triggered v4.9.25.2, res.partner did not expose ``mobile``; asking search_read for that optional field made the entire customer seeding phase fail even though quotations/sales could still sync. """ try: result = client.execute_kw(model, "fields_get", [], {"attributes": ["type"]}) except Exception as exc: errors.append(f"Odoo {model}.fields_get falhou; a usar campos compatíveis por defeito: {exc}") return None if isinstance(result, dict): return set(result.keys()) return None def _odoo_partner_fields_for_available_schema(available_fields: Optional[set]) -> List[str]: requested = [ "id", "name", "vat", "email", "phone", "mobile", "street", "zip", "city", "country_id", "is_company", "company_type", ] if available_fields is None: # Most compatible fallback: do not request mobile unless we confirmed it # exists. This prevents "Invalid field 'mobile' on 'res.partner'". return [field for field in requested if field != "mobile"] return [field for field in requested if field in available_fields] def _odoo_partner_domains_for_available_schema(available_fields: Optional[set]) -> List[List[Any]]: domains: List[List[Any]] = [] if available_fields is None or {"is_company", "vat"}.issubset(available_fields): domains.append(["|", ["is_company", "=", True], ["vat", "!=", False]]) elif "vat" in available_fields: domains.append([["vat", "!=", False]]) if available_fields is None or "customer_rank" in available_fields: domains.append([["customer_rank", ">", 0]]) # Final fallback: fetch recent partners and let _customer_seed_data... filter # people/contacts out before creating fiscal customers. domains.append([]) return domains def sync_odoo_fiscal_customers_for_reconciliation(*, limit: int = 200) -> Dict[str, Any]: """Seed ClientFlow fiscal customers from Odoo companies/partners. v4.9.25.2 makes this phase schema-compatible with Odoo installs that do not have optional partner fields such as ``mobile``. """ if not bool(settings.odoo_enabled): return {"source": "odoo_customers", "enabled": False, "seen": 0, "created_or_updated": 0, "skipped": "ODOO_ENABLED=false"} from app.odoo_client import OdooClient client = OdooClient() errors: List[str] = [] available_fields = _odoo_model_field_names(client, "res.partner", errors) fields = _odoo_partner_fields_for_available_schema(available_fields) domains = _odoo_partner_domains_for_available_schema(available_fields) ignored_optional_fields = [] if available_fields is not None: ignored_optional_fields = [field for field in ("mobile",) if field not in available_fields] records: List[Dict[str, Any]] = [] for domain in domains: try: records = client.search_read("res.partner", domain=domain, fields=fields, limit=limit, order="write_date desc") break except Exception as exc: errors.append(str(exc)) records = [] seen = 0 created = 0 for record in records[: max(int(limit or 200), 1)]: seen += 1 record["partner_external_id"] = record.get("id") if _upsert_fiscal_customer_from_external_record(record, source_system="odoo"): created += 1 return { "source": "odoo_customers", "enabled": True, "seen": seen, "created_or_updated": created, "errors": errors, "ignored_optional_fields": ignored_optional_fields, } async def sync_external_fiscal_customers_for_reconciliation(*, limit: int = 200) -> Dict[str, Any]: """Phase 1 of reconciliation: create/update fiscal customers first.""" jasmin_result = await sync_jasmin_fiscal_customers_for_reconciliation(limit=limit) odoo_result = await asyncio.to_thread(sync_odoo_fiscal_customers_for_reconciliation, limit=limit) results = [jasmin_result, odoo_result] return { "seen": sum(int(r.get("seen") or 0) for r in results), "created_or_updated": sum(int(r.get("created_or_updated") or 0) for r in results), "results": results, } async def sync_all_external_reconciliation_candidates(*, limit: int = 100, days: int = 3) -> Dict[str, Any]: """Run the ordered reconciliation pipeline. v4.9.25 adds Phase 0: enrich open ClientFlow opportunities with fiscal customers before trying to match Jasmin/Odoo documents. This makes the rest of the pipeline use stronger keys (NIF/fiscal name/customer id) instead of relying on contact names. Phase 1 seeds fiscal customers from Jasmin/Odoo. Phase 2 stages Jasmin quotations/proformas/invoices. Phase 3 stages Odoo sales/orders. Phase 4 stages shipment evidence. Process cards are then reconstructed by the reconciliation service from customer identity + purchase-operation anchors. """ try: from app.fiscal_enrichment_service import enrich_open_opportunities enrichment_result = await asyncio.to_thread( enrich_open_opportunities, limit=max(min(limit, 200), 50), apply_safe=True, mode="pre_reconciliation", ) except Exception as exc: enrichment_result = {"seen": 0, "enriched": 0, "suggested": 0, "auto_applied": 0, "errors": [str(exc)]} customer_result = await sync_external_fiscal_customers_for_reconciliation(limit=max(limit, 200)) jasmin_result = await sync_jasmin_reconciliation_candidates(limit=limit, days=days) # Odoo client is synchronous XML-RPC; run it in a thread to keep async routes responsive. odoo_result = await asyncio.to_thread(sync_odoo_reconciliation_candidates, limit=limit, days=days) packlink_result = await sync_packlink_reconciliation_candidates(limit=limit, days=days) document_results = [jasmin_result, odoo_result, packlink_result] return { "seen": sum(int(r.get("seen") or 0) for r in document_results), "created_or_updated": sum(int(r.get("created_or_updated") or 0) for r in document_results), "enrichment_seen": int(enrichment_result.get("seen") or 0), "enrichment_suggested": int(enrichment_result.get("suggested") or 0), "enrichment_auto_applied": int(enrichment_result.get("auto_applied") or 0), "enrichment_result": enrichment_result, "customer_seen": int(customer_result.get("seen") or 0), "customers_created_or_updated": int(customer_result.get("created_or_updated") or 0), "customer_results": customer_result.get("results") or [], "results": document_results, }