Files
bellsystems-cp/backend/crm/models.py

444 lines
12 KiB
Python

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