from enum import Enum from typing import Any, Dict, List, Optional from pydantic import BaseModel class ProductCategory(str, Enum): controller = "controller" striker = "striker" clock = "clock" part = "part" repair_service = "repair_service" class CostLineItem(BaseModel): name: str quantity: float = 1 price: float = 0.0 class ProductCosts(BaseModel): labor_hours: Optional[float] = None labor_rate: Optional[float] = None items: List[CostLineItem] = [] total: Optional[float] = None class ProductStock(BaseModel): on_hand: int = 0 reserved: int = 0 available: int = 0 class ProductCreate(BaseModel): name: str sku: Optional[str] = None category: ProductCategory description: Optional[str] = None name_en: Optional[str] = None name_gr: Optional[str] = None description_en: Optional[str] = None description_gr: Optional[str] = None price: float currency: str = "EUR" costs: Optional[ProductCosts] = None stock: Optional[ProductStock] = None active: bool = True status: str = "active" # active | discontinued | planned photo_url: Optional[str] = None class ProductUpdate(BaseModel): name: Optional[str] = None sku: Optional[str] = None category: Optional[ProductCategory] = None description: Optional[str] = None name_en: Optional[str] = None name_gr: Optional[str] = None description_en: Optional[str] = None description_gr: Optional[str] = None price: Optional[float] = None currency: Optional[str] = None costs: Optional[ProductCosts] = None stock: Optional[ProductStock] = None active: Optional[bool] = None status: Optional[str] = None photo_url: Optional[str] = None class ProductInDB(ProductCreate): id: str created_at: str updated_at: str class ProductListResponse(BaseModel): products: List[ProductInDB] total: int # ── Customers ──────────────────────────────────────────────────────────────── class ContactType(str, Enum): email = "email" phone = "phone" whatsapp = "whatsapp" other = "other" class CustomerContact(BaseModel): type: ContactType label: str value: str primary: bool = False class CustomerNote(BaseModel): text: str by: str at: str class OwnedItemType(str, Enum): console_device = "console_device" product = "product" freetext = "freetext" class OwnedItem(BaseModel): type: OwnedItemType # console_device fields device_id: Optional[str] = None label: Optional[str] = None # product fields product_id: Optional[str] = None product_name: Optional[str] = None quantity: Optional[int] = None serial_numbers: Optional[List[str]] = None # freetext fields description: Optional[str] = None serial_number: Optional[str] = None notes: Optional[str] = None class CustomerLocation(BaseModel): address: Optional[str] = None city: Optional[str] = None postal_code: Optional[str] = None region: Optional[str] = None country: Optional[str] = None # ── New customer status models ──────────────────────────────────────────────── class TechnicalIssue(BaseModel): active: bool = True opened_date: str # ISO string resolved_date: Optional[str] = None note: str opened_by: str resolved_by: Optional[str] = None class InstallSupportEntry(BaseModel): active: bool = True opened_date: str # ISO string resolved_date: Optional[str] = None note: str opened_by: str resolved_by: Optional[str] = None class TransactionEntry(BaseModel): date: str # ISO string flow: str # "invoice" | "payment" | "refund" | "credit" payment_type: Optional[str] = None # "cash" | "bank_transfer" | "card" | "paypal" — null for invoices category: str # "full_payment" | "advance" | "installment" amount: float currency: str = "EUR" invoice_ref: Optional[str] = None order_ref: Optional[str] = None recorded_by: str note: str = "" # Lightweight summary stored on customer doc for fast CustomerList expanded view class CrmSummary(BaseModel): active_order_status: Optional[str] = None active_order_status_date: Optional[str] = None active_order_title: Optional[str] = None active_issues_count: int = 0 latest_issue_date: Optional[str] = None active_support_count: int = 0 latest_support_date: Optional[str] = None class CustomerCreate(BaseModel): title: Optional[str] = None name: str surname: Optional[str] = None organization: Optional[str] = None religion: Optional[str] = None contacts: List[CustomerContact] = [] notes: List[CustomerNote] = [] location: Optional[CustomerLocation] = None language: str = "el" tags: List[str] = [] owned_items: List[OwnedItem] = [] linked_user_ids: List[str] = [] nextcloud_folder: Optional[str] = None folder_id: Optional[str] = None relationship_status: str = "lead" technical_issues: List[Dict[str, Any]] = [] install_support: List[Dict[str, Any]] = [] transaction_history: List[Dict[str, Any]] = [] crm_summary: Optional[Dict[str, Any]] = None class CustomerUpdate(BaseModel): title: Optional[str] = None name: Optional[str] = None surname: Optional[str] = None organization: Optional[str] = None religion: Optional[str] = None contacts: Optional[List[CustomerContact]] = None notes: Optional[List[CustomerNote]] = None location: Optional[CustomerLocation] = None language: Optional[str] = None tags: Optional[List[str]] = None owned_items: Optional[List[OwnedItem]] = None linked_user_ids: Optional[List[str]] = None nextcloud_folder: Optional[str] = None relationship_status: Optional[str] = None # folder_id intentionally excluded from update — set once at creation class CustomerInDB(CustomerCreate): id: str created_at: str updated_at: str class CustomerListResponse(BaseModel): customers: List[CustomerInDB] total: int # ── Orders ─────────────────────────────────────────────────────────────────── class OrderStatus(str, Enum): negotiating = "negotiating" awaiting_quotation = "awaiting_quotation" awaiting_customer_confirmation = "awaiting_customer_confirmation" awaiting_fulfilment = "awaiting_fulfilment" awaiting_payment = "awaiting_payment" manufacturing = "manufacturing" shipped = "shipped" installed = "installed" declined = "declined" complete = "complete" class OrderPaymentStatus(BaseModel): required_amount: float = 0 received_amount: float = 0 balance_due: float = 0 advance_required: bool = False advance_amount: Optional[float] = None payment_complete: bool = False class OrderTimelineEvent(BaseModel): date: str # ISO string type: str # "quote_request" | "quote_sent" | "quote_accepted" | "quote_declined" # | "mfg_started" | "mfg_complete" | "order_shipped" | "installed" # | "payment_received" | "invoice_sent" | "note" note: str = "" updated_by: str class OrderDiscount(BaseModel): type: str # "percentage" | "fixed" value: float = 0 reason: Optional[str] = None class OrderShipping(BaseModel): method: Optional[str] = None tracking_number: Optional[str] = None carrier: Optional[str] = None shipped_at: Optional[str] = None delivered_at: Optional[str] = None destination: Optional[str] = None class OrderItem(BaseModel): type: str # console_device | product | freetext product_id: Optional[str] = None product_name: Optional[str] = None description: Optional[str] = None quantity: int = 1 unit_price: float = 0.0 serial_numbers: List[str] = [] class OrderCreate(BaseModel): customer_id: str order_number: Optional[str] = None title: Optional[str] = None created_by: Optional[str] = None status: OrderStatus = OrderStatus.negotiating status_updated_date: Optional[str] = None status_updated_by: Optional[str] = None items: List[OrderItem] = [] subtotal: float = 0 discount: Optional[OrderDiscount] = None total_price: float = 0 currency: str = "EUR" shipping: Optional[OrderShipping] = None payment_status: Optional[Dict[str, Any]] = None invoice_path: Optional[str] = None notes: Optional[str] = None timeline: List[Dict[str, Any]] = [] class OrderUpdate(BaseModel): order_number: Optional[str] = None title: Optional[str] = None status: Optional[OrderStatus] = None status_updated_date: Optional[str] = None status_updated_by: Optional[str] = None items: Optional[List[OrderItem]] = None subtotal: Optional[float] = None discount: Optional[OrderDiscount] = None total_price: Optional[float] = None currency: Optional[str] = None shipping: Optional[OrderShipping] = None payment_status: Optional[Dict[str, Any]] = None invoice_path: Optional[str] = None notes: Optional[str] = None class OrderInDB(OrderCreate): id: str created_at: str updated_at: str class OrderListResponse(BaseModel): orders: List[OrderInDB] total: int # ── Comms Log ───────────────────────────────────────────────────────────────── class CommType(str, Enum): email = "email" whatsapp = "whatsapp" call = "call" sms = "sms" note = "note" in_person = "in_person" class CommDirection(str, Enum): inbound = "inbound" outbound = "outbound" internal = "internal" class CommAttachment(BaseModel): filename: str nextcloud_path: Optional[str] = None content_type: Optional[str] = None size: Optional[int] = None class CommCreate(BaseModel): customer_id: Optional[str] = None type: CommType mail_account: Optional[str] = None direction: CommDirection subject: Optional[str] = None body: Optional[str] = None body_html: Optional[str] = None attachments: List[CommAttachment] = [] ext_message_id: Optional[str] = None from_addr: Optional[str] = None to_addrs: Optional[List[str]] = None logged_by: Optional[str] = None occurred_at: Optional[str] = None # defaults to now if not provided class CommUpdate(BaseModel): type: Optional[CommType] = None direction: Optional[CommDirection] = None subject: Optional[str] = None body: Optional[str] = None logged_by: Optional[str] = None occurred_at: Optional[str] = None class CommInDB(BaseModel): id: str customer_id: Optional[str] = None type: CommType mail_account: Optional[str] = None direction: CommDirection subject: Optional[str] = None body: Optional[str] = None body_html: Optional[str] = None attachments: List[CommAttachment] = [] ext_message_id: Optional[str] = None from_addr: Optional[str] = None to_addrs: Optional[List[str]] = None logged_by: Optional[str] = None occurred_at: str created_at: str is_important: bool = False is_read: bool = False class CommListResponse(BaseModel): entries: List[CommInDB] total: int # ── Media ───────────────────────────────────────────────────────────────────── class MediaDirection(str, Enum): received = "received" sent = "sent" internal = "internal" class MediaCreate(BaseModel): customer_id: Optional[str] = None order_id: Optional[str] = None filename: str nextcloud_path: str mime_type: Optional[str] = None direction: Optional[MediaDirection] = None tags: List[str] = [] uploaded_by: Optional[str] = None thumbnail_path: Optional[str] = None class MediaInDB(BaseModel): id: str customer_id: Optional[str] = None order_id: Optional[str] = None filename: str nextcloud_path: str mime_type: Optional[str] = None direction: Optional[MediaDirection] = None tags: List[str] = [] uploaded_by: Optional[str] = None created_at: str thumbnail_path: Optional[str] = None class MediaListResponse(BaseModel): items: List[MediaInDB] total: int