feat(integrations): add SaleOrder service for Odoo sales
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
13
services/integrations/app/schemas/__init__.py
Normal file
13
services/integrations/app/schemas/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from app.schemas.sale import (
|
||||||
|
SaleOrderLine,
|
||||||
|
SaleOrderResponse,
|
||||||
|
SaleOrderSearchResult,
|
||||||
|
QuotationCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SaleOrderLine",
|
||||||
|
"SaleOrderResponse",
|
||||||
|
"SaleOrderSearchResult",
|
||||||
|
"QuotationCreate",
|
||||||
|
]
|
||||||
38
services/integrations/app/schemas/crm.py
Normal file
38
services/integrations/app/schemas/crm.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class LeadCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
partner_id: Optional[int] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
|
email_from: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
expected_revenue: Optional[float] = None
|
||||||
|
source: Optional[str] = "WhatsApp"
|
||||||
|
|
||||||
|
|
||||||
|
class LeadResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
stage_id: int
|
||||||
|
stage_name: str
|
||||||
|
partner_id: Optional[int] = None
|
||||||
|
partner_name: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email_from: Optional[str] = None
|
||||||
|
expected_revenue: float
|
||||||
|
probability: float
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LeadSearchResult(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
stage_name: str
|
||||||
|
expected_revenue: float
|
||||||
|
probability: float
|
||||||
43
services/integrations/app/schemas/partner.py
Normal file
43
services/integrations/app/schemas/partner.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
street: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
country_id: Optional[int] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerCreate(PartnerBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
street: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerResponse(PartnerBase):
|
||||||
|
id: int
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
credit: Optional[float] = None
|
||||||
|
debit: Optional[float] = None
|
||||||
|
credit_limit: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerSearchResult(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
31
services/integrations/app/schemas/product.py
Normal file
31
services/integrations/app/schemas/product.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ProductResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
default_code: Optional[str] = None
|
||||||
|
list_price: float
|
||||||
|
qty_available: float
|
||||||
|
virtual_available: float
|
||||||
|
description: Optional[str] = None
|
||||||
|
categ_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSearchResult(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
default_code: Optional[str] = None
|
||||||
|
list_price: float
|
||||||
|
qty_available: float
|
||||||
|
|
||||||
|
|
||||||
|
class StockInfo(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
product_name: str
|
||||||
|
qty_available: float
|
||||||
|
qty_reserved: float
|
||||||
|
qty_incoming: float
|
||||||
|
qty_outgoing: float
|
||||||
|
virtual_available: float
|
||||||
40
services/integrations/app/schemas/sale.py
Normal file
40
services/integrations/app/schemas/sale.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderLine(BaseModel):
|
||||||
|
id: int
|
||||||
|
product_id: int
|
||||||
|
product_name: str
|
||||||
|
quantity: float
|
||||||
|
price_unit: float
|
||||||
|
price_subtotal: float
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
state: str
|
||||||
|
state_display: str
|
||||||
|
partner_id: int
|
||||||
|
partner_name: str
|
||||||
|
date_order: Optional[str] = None
|
||||||
|
amount_total: float
|
||||||
|
amount_untaxed: float
|
||||||
|
amount_tax: float
|
||||||
|
currency: str
|
||||||
|
order_lines: List[SaleOrderLine] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderSearchResult(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
state: str
|
||||||
|
date_order: Optional[str] = None
|
||||||
|
amount_total: float
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationCreate(BaseModel):
|
||||||
|
partner_id: int
|
||||||
|
lines: List[dict]
|
||||||
|
note: Optional[str] = None
|
||||||
5
services/integrations/app/services/__init__.py
Normal file
5
services/integrations/app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.services.sale import SaleOrderService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SaleOrderService",
|
||||||
|
]
|
||||||
108
services/integrations/app/services/crm.py
Normal file
108
services/integrations/app/services/crm.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from app.odoo import get_odoo_client, OdooNotFoundError
|
||||||
|
from app.schemas.crm import LeadCreate, LeadResponse, LeadSearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class CRMService:
|
||||||
|
"""Service for Odoo CRM operations"""
|
||||||
|
|
||||||
|
MODEL = "crm.lead"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = get_odoo_client()
|
||||||
|
|
||||||
|
def create_lead(self, data: LeadCreate) -> int:
|
||||||
|
"""Create a new lead/opportunity"""
|
||||||
|
values = data.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if data.source:
|
||||||
|
source_ids = self.client.search(
|
||||||
|
"utm.source",
|
||||||
|
[("name", "=", data.source)],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if source_ids:
|
||||||
|
values["source_id"] = source_ids[0]
|
||||||
|
if "source" in values:
|
||||||
|
del values["source"]
|
||||||
|
|
||||||
|
return self.client.create(self.MODEL, values)
|
||||||
|
|
||||||
|
def get_by_id(self, lead_id: int) -> LeadResponse:
|
||||||
|
"""Get lead details"""
|
||||||
|
results = self.client.read(
|
||||||
|
self.MODEL,
|
||||||
|
[lead_id],
|
||||||
|
[
|
||||||
|
"id", "name", "stage_id", "partner_id", "contact_name",
|
||||||
|
"phone", "email_from", "expected_revenue", "probability",
|
||||||
|
"user_id",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise OdooNotFoundError(f"Lead {lead_id} not found")
|
||||||
|
|
||||||
|
lead = results[0]
|
||||||
|
return LeadResponse(
|
||||||
|
id=lead["id"],
|
||||||
|
name=lead["name"],
|
||||||
|
stage_id=lead["stage_id"][0] if lead.get("stage_id") else 0,
|
||||||
|
stage_name=lead["stage_id"][1] if lead.get("stage_id") else "",
|
||||||
|
partner_id=lead["partner_id"][0] if lead.get("partner_id") else None,
|
||||||
|
partner_name=lead["partner_id"][1] if lead.get("partner_id") else None,
|
||||||
|
contact_name=lead.get("contact_name"),
|
||||||
|
phone=lead.get("phone"),
|
||||||
|
email_from=lead.get("email_from"),
|
||||||
|
expected_revenue=lead.get("expected_revenue", 0),
|
||||||
|
probability=lead.get("probability", 0),
|
||||||
|
user_id=lead["user_id"][0] if lead.get("user_id") else None,
|
||||||
|
user_name=lead["user_id"][1] if lead.get("user_id") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_by_partner(
|
||||||
|
self,
|
||||||
|
partner_id: int,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> List[LeadSearchResult]:
|
||||||
|
"""Search leads by partner"""
|
||||||
|
results = self.client.search_read(
|
||||||
|
self.MODEL,
|
||||||
|
[("partner_id", "=", partner_id)],
|
||||||
|
fields=["id", "name", "stage_id", "expected_revenue", "probability"],
|
||||||
|
limit=limit,
|
||||||
|
order="create_date desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
LeadSearchResult(
|
||||||
|
id=r["id"],
|
||||||
|
name=r["name"],
|
||||||
|
stage_name=r["stage_id"][1] if r.get("stage_id") else "",
|
||||||
|
expected_revenue=r.get("expected_revenue", 0),
|
||||||
|
probability=r.get("probability", 0),
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
def update_stage(self, lead_id: int, stage_id: int) -> bool:
|
||||||
|
"""Move lead to different stage"""
|
||||||
|
return self.client.write(self.MODEL, [lead_id], {"stage_id": stage_id})
|
||||||
|
|
||||||
|
def add_note(self, lead_id: int, note: str) -> int:
|
||||||
|
"""Add internal note to lead"""
|
||||||
|
return self.client.create("mail.message", {
|
||||||
|
"model": self.MODEL,
|
||||||
|
"res_id": lead_id,
|
||||||
|
"body": note,
|
||||||
|
"message_type": "comment",
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_stages(self) -> List[dict]:
|
||||||
|
"""Get all CRM stages"""
|
||||||
|
return self.client.search_read(
|
||||||
|
"crm.stage",
|
||||||
|
[],
|
||||||
|
fields=["id", "name", "sequence"],
|
||||||
|
order="sequence",
|
||||||
|
)
|
||||||
97
services/integrations/app/services/partner.py
Normal file
97
services/integrations/app/services/partner.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from app.odoo import get_odoo_client, OdooNotFoundError
|
||||||
|
from app.schemas.partner import (
|
||||||
|
PartnerCreate,
|
||||||
|
PartnerUpdate,
|
||||||
|
PartnerResponse,
|
||||||
|
PartnerSearchResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerService:
|
||||||
|
"""Service for Odoo res.partner operations"""
|
||||||
|
|
||||||
|
MODEL = "res.partner"
|
||||||
|
FIELDS = [
|
||||||
|
"id", "name", "display_name", "phone", "mobile", "email",
|
||||||
|
"street", "city", "country_id", "comment",
|
||||||
|
"credit", "debit", "credit_limit",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = get_odoo_client()
|
||||||
|
|
||||||
|
def search_by_phone(self, phone: str) -> Optional[PartnerSearchResult]:
|
||||||
|
"""Search partner by phone number"""
|
||||||
|
normalized = phone.replace(" ", "").replace("-", "").replace("+", "")
|
||||||
|
|
||||||
|
domain = [
|
||||||
|
"|",
|
||||||
|
("phone", "ilike", normalized[-10:]),
|
||||||
|
("mobile", "ilike", normalized[-10:]),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = self.client.search_read(
|
||||||
|
self.MODEL,
|
||||||
|
domain,
|
||||||
|
fields=["id", "name", "phone", "mobile", "email"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
return PartnerSearchResult(**results[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def search_by_email(self, email: str) -> Optional[PartnerSearchResult]:
|
||||||
|
"""Search partner by email"""
|
||||||
|
results = self.client.search_read(
|
||||||
|
self.MODEL,
|
||||||
|
[("email", "=ilike", email)],
|
||||||
|
fields=["id", "name", "phone", "mobile", "email"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
return PartnerSearchResult(**results[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_by_id(self, partner_id: int) -> PartnerResponse:
|
||||||
|
"""Get partner by ID"""
|
||||||
|
results = self.client.read(self.MODEL, [partner_id], self.FIELDS)
|
||||||
|
if not results:
|
||||||
|
raise OdooNotFoundError(f"Partner {partner_id} not found")
|
||||||
|
|
||||||
|
data = results[0]
|
||||||
|
if data.get("country_id") and isinstance(data["country_id"], (list, tuple)):
|
||||||
|
data["country_id"] = data["country_id"][0]
|
||||||
|
return PartnerResponse(**data)
|
||||||
|
|
||||||
|
def create(self, data: PartnerCreate) -> int:
|
||||||
|
"""Create a new partner"""
|
||||||
|
values = data.model_dump(exclude_none=True)
|
||||||
|
return self.client.create(self.MODEL, values)
|
||||||
|
|
||||||
|
def update(self, partner_id: int, data: PartnerUpdate) -> bool:
|
||||||
|
"""Update a partner"""
|
||||||
|
values = data.model_dump(exclude_none=True)
|
||||||
|
if not values:
|
||||||
|
return True
|
||||||
|
return self.client.write(self.MODEL, [partner_id], values)
|
||||||
|
|
||||||
|
def get_balance(self, partner_id: int) -> dict:
|
||||||
|
"""Get partner balance (credit/debit)"""
|
||||||
|
results = self.client.read(
|
||||||
|
self.MODEL,
|
||||||
|
[partner_id],
|
||||||
|
["credit", "debit", "credit_limit"],
|
||||||
|
)
|
||||||
|
if not results:
|
||||||
|
raise OdooNotFoundError(f"Partner {partner_id} not found")
|
||||||
|
|
||||||
|
data = results[0]
|
||||||
|
return {
|
||||||
|
"credit": data.get("credit", 0),
|
||||||
|
"debit": data.get("debit", 0),
|
||||||
|
"balance": data.get("debit", 0) - data.get("credit", 0),
|
||||||
|
"credit_limit": data.get("credit_limit", 0),
|
||||||
|
}
|
||||||
117
services/integrations/app/services/product.py
Normal file
117
services/integrations/app/services/product.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from app.odoo import get_odoo_client, OdooNotFoundError
|
||||||
|
from app.schemas.product import ProductResponse, ProductSearchResult, StockInfo
|
||||||
|
|
||||||
|
|
||||||
|
class ProductService:
|
||||||
|
"""Service for Odoo product operations"""
|
||||||
|
|
||||||
|
MODEL = "product.product"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = get_odoo_client()
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str = None,
|
||||||
|
category_id: int = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[ProductSearchResult]:
|
||||||
|
"""Search products"""
|
||||||
|
domain = [("sale_ok", "=", True)]
|
||||||
|
|
||||||
|
if query:
|
||||||
|
domain.append("|")
|
||||||
|
domain.append(("name", "ilike", query))
|
||||||
|
domain.append(("default_code", "ilike", query))
|
||||||
|
|
||||||
|
if category_id:
|
||||||
|
domain.append(("categ_id", "=", category_id))
|
||||||
|
|
||||||
|
results = self.client.search_read(
|
||||||
|
self.MODEL,
|
||||||
|
domain,
|
||||||
|
fields=["id", "name", "default_code", "list_price", "qty_available"],
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [ProductSearchResult(**r) for r in results]
|
||||||
|
|
||||||
|
def get_by_id(self, product_id: int) -> ProductResponse:
|
||||||
|
"""Get product details"""
|
||||||
|
results = self.client.read(
|
||||||
|
self.MODEL,
|
||||||
|
[product_id],
|
||||||
|
[
|
||||||
|
"id", "name", "default_code", "list_price",
|
||||||
|
"qty_available", "virtual_available",
|
||||||
|
"description_sale", "categ_id",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise OdooNotFoundError(f"Product {product_id} not found")
|
||||||
|
|
||||||
|
p = results[0]
|
||||||
|
return ProductResponse(
|
||||||
|
id=p["id"],
|
||||||
|
name=p["name"],
|
||||||
|
default_code=p.get("default_code"),
|
||||||
|
list_price=p.get("list_price", 0),
|
||||||
|
qty_available=p.get("qty_available", 0),
|
||||||
|
virtual_available=p.get("virtual_available", 0),
|
||||||
|
description=p.get("description_sale"),
|
||||||
|
categ_name=p["categ_id"][1] if p.get("categ_id") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_by_sku(self, sku: str) -> Optional[ProductResponse]:
|
||||||
|
"""Get product by SKU (default_code)"""
|
||||||
|
ids = self.client.search(
|
||||||
|
self.MODEL,
|
||||||
|
[("default_code", "=", sku)],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
return self.get_by_id(ids[0])
|
||||||
|
|
||||||
|
def check_stock(self, product_id: int) -> StockInfo:
|
||||||
|
"""Get stock info for a product"""
|
||||||
|
results = self.client.read(
|
||||||
|
self.MODEL,
|
||||||
|
[product_id],
|
||||||
|
[
|
||||||
|
"id", "name", "qty_available", "virtual_available",
|
||||||
|
"incoming_qty", "outgoing_qty",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise OdooNotFoundError(f"Product {product_id} not found")
|
||||||
|
|
||||||
|
p = results[0]
|
||||||
|
qty_available = p.get("qty_available", 0)
|
||||||
|
virtual = p.get("virtual_available", 0)
|
||||||
|
|
||||||
|
return StockInfo(
|
||||||
|
product_id=p["id"],
|
||||||
|
product_name=p["name"],
|
||||||
|
qty_available=qty_available,
|
||||||
|
qty_reserved=max(0, qty_available - virtual),
|
||||||
|
qty_incoming=p.get("incoming_qty", 0),
|
||||||
|
qty_outgoing=p.get("outgoing_qty", 0),
|
||||||
|
virtual_available=virtual,
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_availability(self, product_id: int, quantity: float) -> dict:
|
||||||
|
"""Check if quantity is available"""
|
||||||
|
stock = self.check_stock(product_id)
|
||||||
|
available = stock.virtual_available >= quantity
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": available,
|
||||||
|
"requested": quantity,
|
||||||
|
"in_stock": stock.qty_available,
|
||||||
|
"virtual_available": stock.virtual_available,
|
||||||
|
"shortage": max(0, quantity - stock.virtual_available),
|
||||||
|
}
|
||||||
128
services/integrations/app/services/sale.py
Normal file
128
services/integrations/app/services/sale.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from app.odoo import get_odoo_client, OdooNotFoundError
|
||||||
|
from app.schemas.sale import (
|
||||||
|
SaleOrderResponse,
|
||||||
|
SaleOrderSearchResult,
|
||||||
|
SaleOrderLine,
|
||||||
|
QuotationCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
STATE_DISPLAY = {
|
||||||
|
"draft": "Presupuesto",
|
||||||
|
"sent": "Presupuesto Enviado",
|
||||||
|
"sale": "Pedido de Venta",
|
||||||
|
"done": "Bloqueado",
|
||||||
|
"cancel": "Cancelado",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrderService:
|
||||||
|
"""Service for Odoo sale.order operations"""
|
||||||
|
|
||||||
|
MODEL = "sale.order"
|
||||||
|
LINE_MODEL = "sale.order.line"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = get_odoo_client()
|
||||||
|
|
||||||
|
def search_by_partner(
|
||||||
|
self,
|
||||||
|
partner_id: int,
|
||||||
|
state: str = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> List[SaleOrderSearchResult]:
|
||||||
|
"""Search orders by partner"""
|
||||||
|
domain = [("partner_id", "=", partner_id)]
|
||||||
|
if state:
|
||||||
|
domain.append(("state", "=", state))
|
||||||
|
|
||||||
|
results = self.client.search_read(
|
||||||
|
self.MODEL,
|
||||||
|
domain,
|
||||||
|
fields=["id", "name", "state", "date_order", "amount_total"],
|
||||||
|
limit=limit,
|
||||||
|
order="date_order desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
return [SaleOrderSearchResult(**r) for r in results]
|
||||||
|
|
||||||
|
def get_by_id(self, order_id: int) -> SaleOrderResponse:
|
||||||
|
"""Get order details"""
|
||||||
|
results = self.client.read(
|
||||||
|
self.MODEL,
|
||||||
|
[order_id],
|
||||||
|
[
|
||||||
|
"id", "name", "state", "partner_id", "date_order",
|
||||||
|
"amount_total", "amount_untaxed", "amount_tax",
|
||||||
|
"currency_id", "order_line",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise OdooNotFoundError(f"Sale order {order_id} not found")
|
||||||
|
|
||||||
|
order = results[0]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if order.get("order_line"):
|
||||||
|
line_data = self.client.read(
|
||||||
|
self.LINE_MODEL,
|
||||||
|
order["order_line"],
|
||||||
|
["id", "product_id", "product_uom_qty", "price_unit", "price_subtotal"],
|
||||||
|
)
|
||||||
|
for line in line_data:
|
||||||
|
lines.append(SaleOrderLine(
|
||||||
|
id=line["id"],
|
||||||
|
product_id=line["product_id"][0] if line.get("product_id") else 0,
|
||||||
|
product_name=line["product_id"][1] if line.get("product_id") else "",
|
||||||
|
quantity=line.get("product_uom_qty", 0),
|
||||||
|
price_unit=line.get("price_unit", 0),
|
||||||
|
price_subtotal=line.get("price_subtotal", 0),
|
||||||
|
))
|
||||||
|
|
||||||
|
return SaleOrderResponse(
|
||||||
|
id=order["id"],
|
||||||
|
name=order["name"],
|
||||||
|
state=order["state"],
|
||||||
|
state_display=STATE_DISPLAY.get(order["state"], order["state"]),
|
||||||
|
partner_id=order["partner_id"][0] if order.get("partner_id") else 0,
|
||||||
|
partner_name=order["partner_id"][1] if order.get("partner_id") else "",
|
||||||
|
date_order=order.get("date_order"),
|
||||||
|
amount_total=order.get("amount_total", 0),
|
||||||
|
amount_untaxed=order.get("amount_untaxed", 0),
|
||||||
|
amount_tax=order.get("amount_tax", 0),
|
||||||
|
currency=order["currency_id"][1] if order.get("currency_id") else "USD",
|
||||||
|
order_lines=lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> Optional[SaleOrderResponse]:
|
||||||
|
"""Get order by name (SO001)"""
|
||||||
|
ids = self.client.search(self.MODEL, [("name", "=", name)], limit=1)
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
return self.get_by_id(ids[0])
|
||||||
|
|
||||||
|
def create_quotation(self, data: QuotationCreate) -> int:
|
||||||
|
"""Create a quotation"""
|
||||||
|
order_id = self.client.create(self.MODEL, {
|
||||||
|
"partner_id": data.partner_id,
|
||||||
|
"note": data.note,
|
||||||
|
})
|
||||||
|
|
||||||
|
for line in data.lines:
|
||||||
|
self.client.create(self.LINE_MODEL, {
|
||||||
|
"order_id": order_id,
|
||||||
|
"product_id": line["product_id"],
|
||||||
|
"product_uom_qty": line.get("quantity", 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
return order_id
|
||||||
|
|
||||||
|
def confirm_order(self, order_id: int) -> bool:
|
||||||
|
"""Confirm quotation to sale order"""
|
||||||
|
return self.client.execute(self.MODEL, "action_confirm", [order_id])
|
||||||
|
|
||||||
|
def get_pdf_url(self, order_id: int) -> str:
|
||||||
|
"""Get URL to download order PDF"""
|
||||||
|
return f"{self.client.url}/report/pdf/sale.report_saleorder/{order_id}"
|
||||||
Reference in New Issue
Block a user