feature: Added Transactions and major Order System Overhaul
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse
|
from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse, TransactionEntry
|
||||||
from crm import service, nextcloud
|
from crm import service, nextcloud
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
@@ -105,26 +105,141 @@ async def delete_customer(
|
|||||||
logger.warning("Could not rename NC folder for customer %s: %s", customer_id, e)
|
logger.warning("Could not rename NC folder for customer %s: %s", customer_id, e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{customer_id}/toggle-negotiating", response_model=CustomerInDB)
|
|
||||||
async def toggle_negotiating(
|
|
||||||
customer_id: str,
|
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
|
||||||
):
|
|
||||||
return await service.toggle_negotiating(customer_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{customer_id}/toggle-problem", response_model=CustomerInDB)
|
|
||||||
async def toggle_problem(
|
|
||||||
customer_id: str,
|
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
|
||||||
):
|
|
||||||
return await service.toggle_problem(customer_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{customer_id}/last-comm-direction")
|
@router.get("/{customer_id}/last-comm-direction")
|
||||||
async def get_last_comm_direction(
|
async def get_last_comm_direction(
|
||||||
customer_id: str,
|
customer_id: str,
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
):
|
):
|
||||||
direction = await service.get_last_comm_direction(customer_id)
|
result = await service.get_last_comm_direction(customer_id)
|
||||||
return {"direction": direction}
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Relationship Status ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}/relationship-status", response_model=CustomerInDB)
|
||||||
|
def update_relationship_status(
|
||||||
|
customer_id: str,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_relationship_status(customer_id, body.get("status", ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Technical Issues ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{customer_id}/technical-issues", response_model=CustomerInDB)
|
||||||
|
def add_technical_issue(
|
||||||
|
customer_id: str,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.add_technical_issue(
|
||||||
|
customer_id,
|
||||||
|
note=body.get("note", ""),
|
||||||
|
opened_by=body.get("opened_by", ""),
|
||||||
|
date=body.get("date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}/technical-issues/{index}/resolve", response_model=CustomerInDB)
|
||||||
|
def resolve_technical_issue(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.resolve_technical_issue(customer_id, index, body.get("resolved_by", ""))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}/technical-issues/{index}", response_model=CustomerInDB)
|
||||||
|
def edit_technical_issue(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.edit_technical_issue(customer_id, index, body.get("note", ""), body.get("opened_date"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}/technical-issues/{index}", response_model=CustomerInDB)
|
||||||
|
def delete_technical_issue(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.delete_technical_issue(customer_id, index)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Install Support ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{customer_id}/install-support", response_model=CustomerInDB)
|
||||||
|
def add_install_support(
|
||||||
|
customer_id: str,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.add_install_support(
|
||||||
|
customer_id,
|
||||||
|
note=body.get("note", ""),
|
||||||
|
opened_by=body.get("opened_by", ""),
|
||||||
|
date=body.get("date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}/install-support/{index}/resolve", response_model=CustomerInDB)
|
||||||
|
def resolve_install_support(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.resolve_install_support(customer_id, index, body.get("resolved_by", ""))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}/install-support/{index}", response_model=CustomerInDB)
|
||||||
|
def edit_install_support(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
body: dict = Body(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.edit_install_support(customer_id, index, body.get("note", ""), body.get("opened_date"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}/install-support/{index}", response_model=CustomerInDB)
|
||||||
|
def delete_install_support(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.delete_install_support(customer_id, index)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transactions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{customer_id}/transactions", response_model=CustomerInDB)
|
||||||
|
def add_transaction(
|
||||||
|
customer_id: str,
|
||||||
|
body: TransactionEntry,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.add_transaction(customer_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}/transactions/{index}", response_model=CustomerInDB)
|
||||||
|
def update_transaction(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
body: TransactionEntry,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_transaction(customer_id, index, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}/transactions/{index}", response_model=CustomerInDB)
|
||||||
|
def delete_transaction(
|
||||||
|
customer_id: str,
|
||||||
|
index: int,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.delete_transaction(customer_id, index)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +129,50 @@ class CustomerLocation(BaseModel):
|
|||||||
country: 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):
|
class CustomerCreate(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
@@ -143,9 +187,12 @@ class CustomerCreate(BaseModel):
|
|||||||
owned_items: List[OwnedItem] = []
|
owned_items: List[OwnedItem] = []
|
||||||
linked_user_ids: List[str] = []
|
linked_user_ids: List[str] = []
|
||||||
nextcloud_folder: Optional[str] = None
|
nextcloud_folder: Optional[str] = None
|
||||||
folder_id: Optional[str] = None # Human-readable Nextcloud folder name, e.g. "saint-john-corfu"
|
folder_id: Optional[str] = None
|
||||||
negotiating: bool = False
|
relationship_status: str = "lead"
|
||||||
has_problem: bool = False
|
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):
|
class CustomerUpdate(BaseModel):
|
||||||
@@ -162,8 +209,7 @@ class CustomerUpdate(BaseModel):
|
|||||||
owned_items: Optional[List[OwnedItem]] = None
|
owned_items: Optional[List[OwnedItem]] = None
|
||||||
linked_user_ids: Optional[List[str]] = None
|
linked_user_ids: Optional[List[str]] = None
|
||||||
nextcloud_folder: Optional[str] = None
|
nextcloud_folder: Optional[str] = None
|
||||||
negotiating: Optional[bool] = None
|
relationship_status: Optional[str] = None
|
||||||
has_problem: Optional[bool] = None
|
|
||||||
# folder_id intentionally excluded from update — set once at creation
|
# folder_id intentionally excluded from update — set once at creation
|
||||||
|
|
||||||
|
|
||||||
@@ -181,18 +227,34 @@ class CustomerListResponse(BaseModel):
|
|||||||
# ── Orders ───────────────────────────────────────────────────────────────────
|
# ── Orders ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class OrderStatus(str, Enum):
|
class OrderStatus(str, Enum):
|
||||||
draft = "draft"
|
negotiating = "negotiating"
|
||||||
confirmed = "confirmed"
|
awaiting_quotation = "awaiting_quotation"
|
||||||
in_production = "in_production"
|
awaiting_customer_confirmation = "awaiting_customer_confirmation"
|
||||||
|
awaiting_fulfilment = "awaiting_fulfilment"
|
||||||
|
awaiting_payment = "awaiting_payment"
|
||||||
|
manufacturing = "manufacturing"
|
||||||
shipped = "shipped"
|
shipped = "shipped"
|
||||||
delivered = "delivered"
|
installed = "installed"
|
||||||
cancelled = "cancelled"
|
declined = "declined"
|
||||||
|
complete = "complete"
|
||||||
|
|
||||||
|
|
||||||
class PaymentStatus(str, Enum):
|
class OrderPaymentStatus(BaseModel):
|
||||||
pending = "pending"
|
required_amount: float = 0
|
||||||
partial = "partial"
|
received_amount: float = 0
|
||||||
paid = "paid"
|
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):
|
class OrderDiscount(BaseModel):
|
||||||
@@ -223,29 +285,36 @@ class OrderItem(BaseModel):
|
|||||||
class OrderCreate(BaseModel):
|
class OrderCreate(BaseModel):
|
||||||
customer_id: str
|
customer_id: str
|
||||||
order_number: Optional[str] = None
|
order_number: Optional[str] = None
|
||||||
status: OrderStatus = OrderStatus.draft
|
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] = []
|
items: List[OrderItem] = []
|
||||||
subtotal: float = 0
|
subtotal: float = 0
|
||||||
discount: Optional[OrderDiscount] = None
|
discount: Optional[OrderDiscount] = None
|
||||||
total_price: float = 0
|
total_price: float = 0
|
||||||
currency: str = "EUR"
|
currency: str = "EUR"
|
||||||
shipping: Optional[OrderShipping] = None
|
shipping: Optional[OrderShipping] = None
|
||||||
payment_status: PaymentStatus = PaymentStatus.pending
|
payment_status: Optional[Dict[str, Any]] = None
|
||||||
invoice_path: Optional[str] = None
|
invoice_path: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
timeline: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
class OrderUpdate(BaseModel):
|
class OrderUpdate(BaseModel):
|
||||||
customer_id: Optional[str] = None
|
|
||||||
order_number: Optional[str] = None
|
order_number: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
status: Optional[OrderStatus] = None
|
status: Optional[OrderStatus] = None
|
||||||
|
status_updated_date: Optional[str] = None
|
||||||
|
status_updated_by: Optional[str] = None
|
||||||
items: Optional[List[OrderItem]] = None
|
items: Optional[List[OrderItem]] = None
|
||||||
subtotal: Optional[float] = None
|
subtotal: Optional[float] = None
|
||||||
discount: Optional[OrderDiscount] = None
|
discount: Optional[OrderDiscount] = None
|
||||||
total_price: Optional[float] = None
|
total_price: Optional[float] = None
|
||||||
currency: Optional[str] = None
|
currency: Optional[str] = None
|
||||||
shipping: Optional[OrderShipping] = None
|
shipping: Optional[OrderShipping] = None
|
||||||
payment_status: Optional[PaymentStatus] = None
|
payment_status: Optional[Dict[str, Any]] = None
|
||||||
invoice_path: Optional[str] = None
|
invoice_path: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@@ -6,52 +6,146 @@ from auth.dependencies import require_permission
|
|||||||
from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse
|
from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse
|
||||||
from crm import service
|
from crm import service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/crm/orders", tags=["crm-orders"])
|
router = APIRouter(prefix="/api/crm/customers/{customer_id}/orders", tags=["crm-orders"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=OrderListResponse)
|
@router.get("", response_model=OrderListResponse)
|
||||||
def list_orders(
|
def list_orders(
|
||||||
customer_id: Optional[str] = Query(None),
|
customer_id: str,
|
||||||
status: Optional[str] = Query(None),
|
|
||||||
payment_status: Optional[str] = Query(None),
|
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
):
|
):
|
||||||
orders = service.list_orders(
|
orders = service.list_orders(customer_id)
|
||||||
customer_id=customer_id,
|
|
||||||
status=status,
|
|
||||||
payment_status=payment_status,
|
|
||||||
)
|
|
||||||
return OrderListResponse(orders=orders, total=len(orders))
|
return OrderListResponse(orders=orders, total=len(orders))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{order_id}", response_model=OrderInDB)
|
# IMPORTANT: specific sub-paths must come before /{order_id}
|
||||||
def get_order(
|
@router.get("/next-order-number")
|
||||||
order_id: str,
|
def get_next_order_number(
|
||||||
|
customer_id: str,
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
):
|
):
|
||||||
return service.get_order(order_id)
|
"""Return the next globally unique order number (ORD-DDMMYY-NNN across all customers)."""
|
||||||
|
return {"order_number": service._generate_order_number(customer_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/init-negotiations", response_model=OrderInDB, status_code=201)
|
||||||
|
def init_negotiations(
|
||||||
|
customer_id: str,
|
||||||
|
body: dict,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.init_negotiations(
|
||||||
|
customer_id=customer_id,
|
||||||
|
title=body.get("title", ""),
|
||||||
|
note=body.get("note", ""),
|
||||||
|
date=body.get("date"),
|
||||||
|
created_by=body.get("created_by", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=OrderInDB, status_code=201)
|
@router.post("", response_model=OrderInDB, status_code=201)
|
||||||
def create_order(
|
def create_order(
|
||||||
|
customer_id: str,
|
||||||
body: OrderCreate,
|
body: OrderCreate,
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
):
|
):
|
||||||
return service.create_order(body)
|
return service.create_order(customer_id, body)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{order_id}", response_model=OrderInDB)
|
@router.get("/{order_id}", response_model=OrderInDB)
|
||||||
|
def get_order(
|
||||||
|
customer_id: str,
|
||||||
|
order_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return service.get_order(customer_id, order_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{order_id}", response_model=OrderInDB)
|
||||||
def update_order(
|
def update_order(
|
||||||
|
customer_id: str,
|
||||||
order_id: str,
|
order_id: str,
|
||||||
body: OrderUpdate,
|
body: OrderUpdate,
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_order(order_id, body)
|
return service.update_order(customer_id, order_id, body)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{order_id}", status_code=204)
|
@router.delete("/{order_id}", status_code=204)
|
||||||
def delete_order(
|
def delete_order(
|
||||||
|
customer_id: str,
|
||||||
order_id: str,
|
order_id: str,
|
||||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
):
|
):
|
||||||
service.delete_order(order_id)
|
service.delete_order(customer_id, order_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/timeline", response_model=OrderInDB)
|
||||||
|
def append_timeline_event(
|
||||||
|
customer_id: str,
|
||||||
|
order_id: str,
|
||||||
|
body: dict,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.append_timeline_event(customer_id, order_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{order_id}/timeline/{index}", response_model=OrderInDB)
|
||||||
|
def update_timeline_event(
|
||||||
|
customer_id: str,
|
||||||
|
order_id: str,
|
||||||
|
index: int,
|
||||||
|
body: dict,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_timeline_event(customer_id, order_id, index, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{order_id}/timeline/{index}", response_model=OrderInDB)
|
||||||
|
def delete_timeline_event(
|
||||||
|
customer_id: str,
|
||||||
|
order_id: str,
|
||||||
|
index: int,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.delete_timeline_event(customer_id, order_id, index)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{order_id}/payment-status", response_model=OrderInDB)
|
||||||
|
def update_payment_status(
|
||||||
|
customer_id: str,
|
||||||
|
order_id: str,
|
||||||
|
body: dict,
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_order_payment_status(customer_id, order_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Global order list (collection group) ─────────────────────────────────────
|
||||||
|
# Separate router registered at /api/crm/orders for the global OrderList page
|
||||||
|
|
||||||
|
global_router = APIRouter(prefix="/api/crm/orders", tags=["crm-orders-global"])
|
||||||
|
|
||||||
|
|
||||||
|
@global_router.get("")
|
||||||
|
def list_all_orders(
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
orders = service.list_all_orders(status=status)
|
||||||
|
# Enrich with customer names
|
||||||
|
customer_ids = list({o.customer_id for o in orders if o.customer_id})
|
||||||
|
customer_names: dict[str, str] = {}
|
||||||
|
for cid in customer_ids:
|
||||||
|
try:
|
||||||
|
c = service.get_customer(cid)
|
||||||
|
parts = [c.name, c.organization] if c.organization else [c.name]
|
||||||
|
customer_names[cid] = " / ".join(filter(None, parts))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
enriched = []
|
||||||
|
for o in orders:
|
||||||
|
d = o.model_dump()
|
||||||
|
d["customer_name"] = customer_names.get(o.customer_id)
|
||||||
|
enriched.append(d)
|
||||||
|
return {"orders": enriched, "total": len(enriched)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from crm.models import (
|
|||||||
OrderCreate, OrderUpdate, OrderInDB,
|
OrderCreate, OrderUpdate, OrderInDB,
|
||||||
CommCreate, CommUpdate, CommInDB,
|
CommCreate, CommUpdate, CommInDB,
|
||||||
MediaCreate, MediaInDB,
|
MediaCreate, MediaInDB,
|
||||||
|
TechnicalIssue, InstallSupportEntry, TransactionEntry,
|
||||||
)
|
)
|
||||||
|
|
||||||
COLLECTION = "crm_products"
|
COLLECTION = "crm_products"
|
||||||
@@ -126,8 +127,12 @@ def delete_product(product_id: str) -> None:
|
|||||||
CUSTOMERS_COLLECTION = "crm_customers"
|
CUSTOMERS_COLLECTION = "crm_customers"
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_CUSTOMER_FIELDS = {"negotiating", "has_problem"}
|
||||||
|
|
||||||
def _doc_to_customer(doc) -> CustomerInDB:
|
def _doc_to_customer(doc) -> CustomerInDB:
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
|
for f in _LEGACY_CUSTOMER_FIELDS:
|
||||||
|
data.pop(f, None)
|
||||||
return CustomerInDB(id=doc.id, **data)
|
return CustomerInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
@@ -272,42 +277,19 @@ def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
|||||||
return _doc_to_customer(updated_doc)
|
return _doc_to_customer(updated_doc)
|
||||||
|
|
||||||
|
|
||||||
async def toggle_negotiating(customer_id: str) -> CustomerInDB:
|
|
||||||
db_fs = get_db()
|
|
||||||
doc_ref = db_fs.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
|
||||||
doc = doc_ref.get()
|
|
||||||
if not doc.exists:
|
|
||||||
raise NotFoundError("Customer")
|
|
||||||
current = doc.to_dict().get("negotiating", False)
|
|
||||||
update_data = {"negotiating": not current, "updated_at": datetime.utcnow().isoformat()}
|
|
||||||
doc_ref.update(update_data)
|
|
||||||
return _doc_to_customer(doc_ref.get())
|
|
||||||
|
|
||||||
|
async def get_last_comm_direction(customer_id: str) -> dict:
|
||||||
async def toggle_problem(customer_id: str) -> CustomerInDB:
|
"""Return direction ('inbound'/'outbound') and timestamp of the most recent comm, or None."""
|
||||||
db_fs = get_db()
|
|
||||||
doc_ref = db_fs.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
|
||||||
doc = doc_ref.get()
|
|
||||||
if not doc.exists:
|
|
||||||
raise NotFoundError("Customer")
|
|
||||||
current = doc.to_dict().get("has_problem", False)
|
|
||||||
update_data = {"has_problem": not current, "updated_at": datetime.utcnow().isoformat()}
|
|
||||||
doc_ref.update(update_data)
|
|
||||||
return _doc_to_customer(doc_ref.get())
|
|
||||||
|
|
||||||
|
|
||||||
async def get_last_comm_direction(customer_id: str) -> str | None:
|
|
||||||
"""Return 'inbound' or 'outbound' of the most recent comm for this customer, or None."""
|
|
||||||
db = await mqtt_db.get_db()
|
db = await mqtt_db.get_db()
|
||||||
rows = await db.execute_fetchall(
|
rows = await db.execute_fetchall(
|
||||||
"SELECT direction FROM crm_comms_log WHERE customer_id = ? "
|
"SELECT direction, COALESCE(occurred_at, created_at) as ts FROM crm_comms_log WHERE customer_id = ? "
|
||||||
"AND direction IN ('inbound', 'outbound') "
|
"AND direction IN ('inbound', 'outbound') "
|
||||||
"ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT 1",
|
"ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT 1",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
)
|
)
|
||||||
if rows:
|
if rows:
|
||||||
return rows[0][0]
|
return {"direction": rows[0][0], "occurred_at": rows[0][1]}
|
||||||
return None
|
return {"direction": None, "occurred_at": None}
|
||||||
|
|
||||||
|
|
||||||
async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
||||||
@@ -365,21 +347,25 @@ async def delete_customer_media_entries(customer_id: str) -> int:
|
|||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
# ── Orders ───────────────────────────────────────────────────────────────────
|
# ── Orders (subcollection under customers/{id}/orders) ────────────────────────
|
||||||
|
|
||||||
ORDERS_COLLECTION = "crm_orders"
|
|
||||||
|
|
||||||
|
|
||||||
def _doc_to_order(doc) -> OrderInDB:
|
def _doc_to_order(doc) -> OrderInDB:
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
return OrderInDB(id=doc.id, **data)
|
return OrderInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
def _generate_order_number(db) -> str:
|
def _order_collection(customer_id: str):
|
||||||
year = datetime.utcnow().year
|
db = get_db()
|
||||||
prefix = f"ORD-{year}-"
|
return db.collection(CUSTOMERS_COLLECTION).document(customer_id).collection("orders")
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_order_number(customer_id: str) -> str:
|
||||||
|
"""Generate next ORD-DDMMYY-NNN across all customers using collection group query."""
|
||||||
|
db = get_db()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
prefix = f"ORD-{now.strftime('%d%m%y')}-"
|
||||||
max_n = 0
|
max_n = 0
|
||||||
for doc in db.collection(ORDERS_COLLECTION).stream():
|
for doc in db.collection_group("orders").stream():
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
num = data.get("order_number", "")
|
num = data.get("order_number", "")
|
||||||
if num and num.startswith(prefix):
|
if num and num.startswith(prefix):
|
||||||
@@ -392,50 +378,150 @@ def _generate_order_number(db) -> str:
|
|||||||
return f"{prefix}{max_n + 1:03d}"
|
return f"{prefix}{max_n + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
def list_orders(
|
def _default_payment_status() -> dict:
|
||||||
customer_id: str | None = None,
|
return {
|
||||||
status: str | None = None,
|
"required_amount": 0,
|
||||||
payment_status: str | None = None,
|
"received_amount": 0,
|
||||||
) -> list[OrderInDB]:
|
"balance_due": 0,
|
||||||
db = get_db()
|
"advance_required": False,
|
||||||
query = db.collection(ORDERS_COLLECTION)
|
"advance_amount": None,
|
||||||
|
"payment_complete": False,
|
||||||
|
}
|
||||||
|
|
||||||
if customer_id:
|
|
||||||
query = query.where("customer_id", "==", customer_id)
|
def _recalculate_order_payment_status(customer_id: str, order_id: str) -> None:
|
||||||
|
"""Recompute an order's payment_status from transaction_history on the customer."""
|
||||||
|
db = get_db()
|
||||||
|
cust_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
cust_data = (cust_ref.get().to_dict()) or {}
|
||||||
|
txns = cust_data.get("transaction_history") or []
|
||||||
|
required = sum(float(t.get("amount") or 0) for t in txns
|
||||||
|
if t.get("order_ref") == order_id and t.get("flow") == "invoice")
|
||||||
|
received = sum(float(t.get("amount") or 0) for t in txns
|
||||||
|
if t.get("order_ref") == order_id and t.get("flow") == "payment")
|
||||||
|
balance_due = required - received
|
||||||
|
payment_complete = (required > 0 and balance_due <= 0)
|
||||||
|
order_ref = _order_collection(customer_id).document(order_id)
|
||||||
|
if not order_ref.get().exists:
|
||||||
|
return
|
||||||
|
order_ref.update({
|
||||||
|
"payment_status": {
|
||||||
|
"required_amount": required,
|
||||||
|
"received_amount": received,
|
||||||
|
"balance_due": balance_due,
|
||||||
|
"advance_required": False,
|
||||||
|
"advance_amount": None,
|
||||||
|
"payment_complete": payment_complete,
|
||||||
|
},
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _update_crm_summary(customer_id: str) -> None:
|
||||||
|
"""Recompute and store the crm_summary field on the customer document."""
|
||||||
|
db = get_db()
|
||||||
|
customer_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
|
||||||
|
# Load customer for issue/support arrays
|
||||||
|
customer_doc = customer_ref.get()
|
||||||
|
if not customer_doc.exists:
|
||||||
|
return
|
||||||
|
customer_data = customer_doc.to_dict() or {}
|
||||||
|
|
||||||
|
# Active issues
|
||||||
|
issues = customer_data.get("technical_issues") or []
|
||||||
|
active_issues = [i for i in issues if i.get("active")]
|
||||||
|
active_issues_count = len(active_issues)
|
||||||
|
latest_issue_date = None
|
||||||
|
if active_issues:
|
||||||
|
latest_issue_date = max((i.get("opened_date") or "") for i in active_issues) or None
|
||||||
|
|
||||||
|
# Active support
|
||||||
|
support = customer_data.get("install_support") or []
|
||||||
|
active_support = [s for s in support if s.get("active")]
|
||||||
|
active_support_count = len(active_support)
|
||||||
|
latest_support_date = None
|
||||||
|
if active_support:
|
||||||
|
latest_support_date = max((s.get("opened_date") or "") for s in active_support) or None
|
||||||
|
|
||||||
|
# Active order (most recent non-terminal status)
|
||||||
|
TERMINAL_STATUSES = {"declined", "complete"}
|
||||||
|
active_order_status = None
|
||||||
|
active_order_status_date = None
|
||||||
|
active_order_title = None
|
||||||
|
active_order_number = None
|
||||||
|
latest_order_date = ""
|
||||||
|
all_order_statuses = []
|
||||||
|
for doc in _order_collection(customer_id).stream():
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
status = data.get("status", "")
|
||||||
|
all_order_statuses.append(status)
|
||||||
|
if status not in TERMINAL_STATUSES:
|
||||||
|
upd = data.get("status_updated_date") or data.get("created_at") or ""
|
||||||
|
if upd > latest_order_date:
|
||||||
|
latest_order_date = upd
|
||||||
|
active_order_status = status
|
||||||
|
active_order_status_date = upd
|
||||||
|
active_order_title = data.get("title")
|
||||||
|
active_order_number = data.get("order_number")
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"active_order_status": active_order_status,
|
||||||
|
"active_order_status_date": active_order_status_date,
|
||||||
|
"active_order_title": active_order_title,
|
||||||
|
"active_order_number": active_order_number,
|
||||||
|
"all_orders_statuses": all_order_statuses,
|
||||||
|
"active_issues_count": active_issues_count,
|
||||||
|
"latest_issue_date": latest_issue_date,
|
||||||
|
"active_support_count": active_support_count,
|
||||||
|
"latest_support_date": latest_support_date,
|
||||||
|
}
|
||||||
|
customer_ref.update({"crm_summary": summary, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
|
||||||
|
|
||||||
|
def list_orders(customer_id: str) -> list[OrderInDB]:
|
||||||
|
return [_doc_to_order(doc) for doc in _order_collection(customer_id).stream()]
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_orders(status: str | None = None) -> list[OrderInDB]:
|
||||||
|
"""Query across all customers using Firestore collection group."""
|
||||||
|
db = get_db()
|
||||||
|
query = db.collection_group("orders")
|
||||||
if status:
|
if status:
|
||||||
query = query.where("status", "==", status)
|
query = query.where("status", "==", status)
|
||||||
if payment_status:
|
|
||||||
query = query.where("payment_status", "==", payment_status)
|
|
||||||
|
|
||||||
return [_doc_to_order(doc) for doc in query.stream()]
|
return [_doc_to_order(doc) for doc in query.stream()]
|
||||||
|
|
||||||
|
|
||||||
def get_order(order_id: str) -> OrderInDB:
|
def get_order(customer_id: str, order_id: str) -> OrderInDB:
|
||||||
db = get_db()
|
doc = _order_collection(customer_id).document(order_id).get()
|
||||||
doc = db.collection(ORDERS_COLLECTION).document(order_id).get()
|
|
||||||
if not doc.exists:
|
if not doc.exists:
|
||||||
raise NotFoundError("Order")
|
raise NotFoundError("Order")
|
||||||
return _doc_to_order(doc)
|
return _doc_to_order(doc)
|
||||||
|
|
||||||
|
|
||||||
def create_order(data: OrderCreate) -> OrderInDB:
|
def create_order(customer_id: str, data: OrderCreate) -> OrderInDB:
|
||||||
db = get_db()
|
col = _order_collection(customer_id)
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
order_id = str(uuid.uuid4())
|
order_id = str(uuid.uuid4())
|
||||||
|
|
||||||
doc_data = data.model_dump()
|
doc_data = data.model_dump()
|
||||||
|
doc_data["customer_id"] = customer_id
|
||||||
if not doc_data.get("order_number"):
|
if not doc_data.get("order_number"):
|
||||||
doc_data["order_number"] = _generate_order_number(db)
|
doc_data["order_number"] = _generate_order_number(customer_id)
|
||||||
|
if not doc_data.get("payment_status"):
|
||||||
|
doc_data["payment_status"] = _default_payment_status()
|
||||||
|
if not doc_data.get("status_updated_date"):
|
||||||
|
doc_data["status_updated_date"] = now
|
||||||
doc_data["created_at"] = now
|
doc_data["created_at"] = now
|
||||||
doc_data["updated_at"] = now
|
doc_data["updated_at"] = now
|
||||||
|
|
||||||
db.collection(ORDERS_COLLECTION).document(order_id).set(doc_data)
|
col.document(order_id).set(doc_data)
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
return OrderInDB(id=order_id, **doc_data)
|
return OrderInDB(id=order_id, **doc_data)
|
||||||
|
|
||||||
|
|
||||||
def update_order(order_id: str, data: OrderUpdate) -> OrderInDB:
|
def update_order(customer_id: str, order_id: str, data: OrderUpdate) -> OrderInDB:
|
||||||
db = get_db()
|
doc_ref = _order_collection(customer_id).document(order_id)
|
||||||
doc_ref = db.collection(ORDERS_COLLECTION).document(order_id)
|
|
||||||
doc = doc_ref.get()
|
doc = doc_ref.get()
|
||||||
if not doc.exists:
|
if not doc.exists:
|
||||||
raise NotFoundError("Order")
|
raise NotFoundError("Order")
|
||||||
@@ -444,17 +530,362 @@ def update_order(order_id: str, data: OrderUpdate) -> OrderInDB:
|
|||||||
update_data["updated_at"] = datetime.utcnow().isoformat()
|
update_data["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
doc_ref.update(update_data)
|
doc_ref.update(update_data)
|
||||||
updated_doc = doc_ref.get()
|
_update_crm_summary(customer_id)
|
||||||
return _doc_to_order(updated_doc)
|
result = _doc_to_order(doc_ref.get())
|
||||||
|
|
||||||
|
# Auto-mark customer as inactive when all orders are complete
|
||||||
|
if update_data.get("status") == "complete":
|
||||||
|
all_orders = list_orders(customer_id)
|
||||||
|
if all_orders and all(o.status == "complete" for o in all_orders):
|
||||||
|
db = get_db()
|
||||||
|
db.collection(CUSTOMERS_COLLECTION).document(customer_id).update({
|
||||||
|
"relationship_status": "inactive",
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def delete_order(order_id: str) -> None:
|
def delete_order(customer_id: str, order_id: str) -> None:
|
||||||
db = get_db()
|
doc_ref = _order_collection(customer_id).document(order_id)
|
||||||
doc_ref = db.collection(ORDERS_COLLECTION).document(order_id)
|
if not doc_ref.get().exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
doc_ref.delete()
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
def append_timeline_event(customer_id: str, order_id: str, event: dict) -> OrderInDB:
|
||||||
|
from google.cloud.firestore_v1 import ArrayUnion
|
||||||
|
doc_ref = _order_collection(customer_id).document(order_id)
|
||||||
|
if not doc_ref.get().exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
doc_ref.update({
|
||||||
|
"timeline": ArrayUnion([event]),
|
||||||
|
"status_updated_date": event.get("date", now),
|
||||||
|
"status_updated_by": event.get("updated_by", ""),
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
return _doc_to_order(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def update_timeline_event(customer_id: str, order_id: str, index: int, data: dict) -> OrderInDB:
|
||||||
|
doc_ref = _order_collection(customer_id).document(order_id)
|
||||||
doc = doc_ref.get()
|
doc = doc_ref.get()
|
||||||
if not doc.exists:
|
if not doc.exists:
|
||||||
raise NotFoundError("Order")
|
raise NotFoundError("Order")
|
||||||
doc_ref.delete()
|
timeline = list(doc.to_dict().get("timeline") or [])
|
||||||
|
if index < 0 or index >= len(timeline):
|
||||||
|
raise HTTPException(status_code=404, detail="Timeline index out of range")
|
||||||
|
timeline[index] = {**timeline[index], **data}
|
||||||
|
doc_ref.update({"timeline": timeline, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
return _doc_to_order(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_timeline_event(customer_id: str, order_id: str, index: int) -> OrderInDB:
|
||||||
|
doc_ref = _order_collection(customer_id).document(order_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
timeline = list(doc.to_dict().get("timeline") or [])
|
||||||
|
if index < 0 or index >= len(timeline):
|
||||||
|
raise HTTPException(status_code=404, detail="Timeline index out of range")
|
||||||
|
timeline.pop(index)
|
||||||
|
doc_ref.update({"timeline": timeline, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
return _doc_to_order(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def update_order_payment_status(customer_id: str, order_id: str, payment_data: dict) -> OrderInDB:
|
||||||
|
doc_ref = _order_collection(customer_id).document(order_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Order")
|
||||||
|
existing = doc.to_dict().get("payment_status") or _default_payment_status()
|
||||||
|
existing.update({k: v for k, v in payment_data.items() if v is not None})
|
||||||
|
doc_ref.update({
|
||||||
|
"payment_status": existing,
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
|
return _doc_to_order(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def init_negotiations(customer_id: str, title: str, note: str, date: str, created_by: str) -> OrderInDB:
|
||||||
|
"""Create a new order with status=negotiating and bump customer relationship_status if needed."""
|
||||||
|
db = get_db()
|
||||||
|
customer_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
customer_doc = customer_ref.get()
|
||||||
|
if not customer_doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
order_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
timeline_event = {
|
||||||
|
"date": date or now,
|
||||||
|
"type": "note",
|
||||||
|
"note": note or "",
|
||||||
|
"updated_by": created_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
doc_data = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"order_number": _generate_order_number(customer_id),
|
||||||
|
"title": title,
|
||||||
|
"created_by": created_by,
|
||||||
|
"status": "negotiating",
|
||||||
|
"status_updated_date": date or now,
|
||||||
|
"status_updated_by": created_by,
|
||||||
|
"items": [],
|
||||||
|
"subtotal": 0,
|
||||||
|
"discount": None,
|
||||||
|
"total_price": 0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"shipping": None,
|
||||||
|
"payment_status": _default_payment_status(),
|
||||||
|
"invoice_path": None,
|
||||||
|
"notes": note or "",
|
||||||
|
"timeline": [timeline_event],
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_order_collection(customer_id).document(order_id).set(doc_data)
|
||||||
|
|
||||||
|
# Upgrade relationship_status only if currently lead or prospect
|
||||||
|
current_data = customer_doc.to_dict() or {}
|
||||||
|
current_rel = current_data.get("relationship_status", "lead")
|
||||||
|
if current_rel in ("lead", "prospect"):
|
||||||
|
customer_ref.update({"relationship_status": "active", "updated_at": now})
|
||||||
|
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return OrderInDB(id=order_id, **doc_data)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Technical Issues & Install Support ────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_technical_issue(customer_id: str, note: str, opened_by: str, date: str | None = None) -> CustomerInDB:
|
||||||
|
from google.cloud.firestore_v1 import ArrayUnion
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
if not doc_ref.get().exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
issue = {
|
||||||
|
"active": True,
|
||||||
|
"opened_date": date or now,
|
||||||
|
"resolved_date": None,
|
||||||
|
"note": note,
|
||||||
|
"opened_by": opened_by,
|
||||||
|
"resolved_by": None,
|
||||||
|
}
|
||||||
|
doc_ref.update({"technical_issues": ArrayUnion([issue]), "updated_at": now})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_technical_issue(customer_id: str, index: int, resolved_by: str) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
issues = list(data.get("technical_issues") or [])
|
||||||
|
if index < 0 or index >= len(issues):
|
||||||
|
raise HTTPException(status_code=404, detail="Issue index out of range")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
issues[index] = {**issues[index], "active": False, "resolved_date": now, "resolved_by": resolved_by}
|
||||||
|
doc_ref.update({"technical_issues": issues, "updated_at": now})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def add_install_support(customer_id: str, note: str, opened_by: str, date: str | None = None) -> CustomerInDB:
|
||||||
|
from google.cloud.firestore_v1 import ArrayUnion
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
if not doc_ref.get().exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
entry = {
|
||||||
|
"active": True,
|
||||||
|
"opened_date": date or now,
|
||||||
|
"resolved_date": None,
|
||||||
|
"note": note,
|
||||||
|
"opened_by": opened_by,
|
||||||
|
"resolved_by": None,
|
||||||
|
}
|
||||||
|
doc_ref.update({"install_support": ArrayUnion([entry]), "updated_at": now})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_install_support(customer_id: str, index: int, resolved_by: str) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
entries = list(data.get("install_support") or [])
|
||||||
|
if index < 0 or index >= len(entries):
|
||||||
|
raise HTTPException(status_code=404, detail="Support index out of range")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
entries[index] = {**entries[index], "active": False, "resolved_date": now, "resolved_by": resolved_by}
|
||||||
|
doc_ref.update({"install_support": entries, "updated_at": now})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def edit_technical_issue(customer_id: str, index: int, note: str, opened_date: str | None = None) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
issues = list(data.get("technical_issues") or [])
|
||||||
|
if index < 0 or index >= len(issues):
|
||||||
|
raise HTTPException(status_code=404, detail="Issue index out of range")
|
||||||
|
issues[index] = {**issues[index], "note": note}
|
||||||
|
if opened_date:
|
||||||
|
issues[index]["opened_date"] = opened_date
|
||||||
|
doc_ref.update({"technical_issues": issues, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_technical_issue(customer_id: str, index: int) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
issues = list(data.get("technical_issues") or [])
|
||||||
|
if index < 0 or index >= len(issues):
|
||||||
|
raise HTTPException(status_code=404, detail="Issue index out of range")
|
||||||
|
issues.pop(index)
|
||||||
|
doc_ref.update({"technical_issues": issues, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def edit_install_support(customer_id: str, index: int, note: str, opened_date: str | None = None) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
entries = list(data.get("install_support") or [])
|
||||||
|
if index < 0 or index >= len(entries):
|
||||||
|
raise HTTPException(status_code=404, detail="Support index out of range")
|
||||||
|
entries[index] = {**entries[index], "note": note}
|
||||||
|
if opened_date:
|
||||||
|
entries[index]["opened_date"] = opened_date
|
||||||
|
doc_ref.update({"install_support": entries, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_install_support(customer_id: str, index: int) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
entries = list(data.get("install_support") or [])
|
||||||
|
if index < 0 or index >= len(entries):
|
||||||
|
raise HTTPException(status_code=404, detail="Support index out of range")
|
||||||
|
entries.pop(index)
|
||||||
|
doc_ref.update({"install_support": entries, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
_update_crm_summary(customer_id)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transactions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_transaction(customer_id: str, entry: TransactionEntry) -> CustomerInDB:
|
||||||
|
from google.cloud.firestore_v1 import ArrayUnion
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
if not doc_ref.get().exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
doc_ref.update({"transaction_history": ArrayUnion([entry.model_dump()]), "updated_at": now})
|
||||||
|
if entry.order_ref:
|
||||||
|
_recalculate_order_payment_status(customer_id, entry.order_ref)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def update_transaction(customer_id: str, index: int, entry: TransactionEntry) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
txns = list(data.get("transaction_history") or [])
|
||||||
|
if index < 0 or index >= len(txns):
|
||||||
|
raise HTTPException(status_code=404, detail="Transaction index out of range")
|
||||||
|
txns[index] = entry.model_dump()
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
doc_ref.update({"transaction_history": txns, "updated_at": now})
|
||||||
|
if entry.order_ref:
|
||||||
|
_recalculate_order_payment_status(customer_id, entry.order_ref)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_transaction(customer_id: str, index: int) -> CustomerInDB:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
txns = list(data.get("transaction_history") or [])
|
||||||
|
if index < 0 or index >= len(txns):
|
||||||
|
raise HTTPException(status_code=404, detail="Transaction index out of range")
|
||||||
|
deleted_order_ref = txns[index].get("order_ref")
|
||||||
|
txns.pop(index)
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
doc_ref.update({"transaction_history": txns, "updated_at": now})
|
||||||
|
if deleted_order_ref:
|
||||||
|
_recalculate_order_payment_status(customer_id, deleted_order_ref)
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Relationship Status ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def update_relationship_status(customer_id: str, status: str) -> CustomerInDB:
|
||||||
|
VALID = {"lead", "prospect", "active", "inactive", "churned"}
|
||||||
|
if status not in VALID:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Invalid relationship_status: {status}")
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||||
|
if not doc_ref.get().exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
|
||||||
|
# Failsafe: cannot manually mark inactive if open (non-terminal) orders exist
|
||||||
|
if status == "inactive":
|
||||||
|
TERMINAL = {"declined", "complete"}
|
||||||
|
open_orders = [
|
||||||
|
doc for doc in _order_collection(customer_id).stream()
|
||||||
|
if (doc.to_dict() or {}).get("status", "") not in TERMINAL
|
||||||
|
]
|
||||||
|
if open_orders:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Cannot mark as inactive: {len(open_orders)} open order(s) still exist. "
|
||||||
|
"Please resolve all orders before changing the status."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_ref.update({"relationship_status": status, "updated_at": datetime.utcnow().isoformat()})
|
||||||
|
return _doc_to_customer(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
# ── Comms Log (SQLite, async) ─────────────────────────────────────────────────
|
# ── Comms Log (SQLite, async) ─────────────────────────────────────────────────
|
||||||
@@ -753,3 +1184,65 @@ async def delete_media(media_id: str) -> None:
|
|||||||
raise HTTPException(status_code=404, detail="Media entry not found")
|
raise HTTPException(status_code=404, detail="Media entry not found")
|
||||||
await db.execute("DELETE FROM crm_media WHERE id = ?", (media_id,))
|
await db.execute("DELETE FROM crm_media WHERE id = ?", (media_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Background polling ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PRE_MFG_STATUSES = {"negotiating", "awaiting_quotation", "awaiting_customer_confirmation", "awaiting_fulfilment", "awaiting_payment"}
|
||||||
|
TERMINAL_STATUSES = {"declined", "complete"}
|
||||||
|
|
||||||
|
|
||||||
|
def poll_crm_customer_statuses() -> None:
|
||||||
|
"""
|
||||||
|
Two checks run daily:
|
||||||
|
|
||||||
|
1. Active + open pre-mfg order + 12+ months since last comm → churn.
|
||||||
|
2. Inactive + has any open (non-terminal) order → flip back to active.
|
||||||
|
"""
|
||||||
|
db = get_db()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
for doc in db.collection(CUSTOMERS_COLLECTION).stream():
|
||||||
|
try:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
rel_status = data.get("relationship_status", "lead")
|
||||||
|
summary = data.get("crm_summary") or {}
|
||||||
|
all_statuses = summary.get("all_orders_statuses") or []
|
||||||
|
|
||||||
|
# ── Check 1: active + silent 12 months on a pre-mfg order → churned ──
|
||||||
|
if rel_status == "active":
|
||||||
|
has_open_pre_mfg = any(s in PRE_MFG_STATUSES for s in all_statuses)
|
||||||
|
if not has_open_pre_mfg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find last comm date from SQLite comms table
|
||||||
|
# (comms are stored in SQLite, keyed by customer_id)
|
||||||
|
# We rely on crm_summary not having this; use Firestore comms subcollection as fallback
|
||||||
|
# The last_comm_date is passed from the frontend; here we use the comms subcollection
|
||||||
|
comms = list(db.collection(CUSTOMERS_COLLECTION).document(doc.id).collection("comms").stream())
|
||||||
|
if not comms:
|
||||||
|
continue
|
||||||
|
latest_date_str = max((c.to_dict().get("date") or "") for c in comms)
|
||||||
|
if not latest_date_str:
|
||||||
|
continue
|
||||||
|
last_contact = datetime.fromisoformat(latest_date_str.rstrip("Z").split("+")[0])
|
||||||
|
days_since = (now - last_contact).days
|
||||||
|
if days_since >= 365:
|
||||||
|
db.collection(CUSTOMERS_COLLECTION).document(doc.id).update({
|
||||||
|
"relationship_status": "churned",
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
})
|
||||||
|
print(f"[CRM POLL] {doc.id} → churned ({days_since}d silent, open pre-mfg order)")
|
||||||
|
|
||||||
|
# ── Check 2: inactive + open orders exist → flip back to active ──
|
||||||
|
elif rel_status == "inactive":
|
||||||
|
has_open = any(s not in TERMINAL_STATUSES for s in all_statuses)
|
||||||
|
if has_open:
|
||||||
|
db.collection(CUSTOMERS_COLLECTION).document(doc.id).update({
|
||||||
|
"relationship_status": "active",
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
})
|
||||||
|
print(f"[CRM POLL] {doc.id} → active (inactive but has open orders)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CRM POLL] Error processing customer {doc.id}: {e}")
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class DeviceCreate(BaseModel):
|
|||||||
device_subscription: DeviceSubInformation = DeviceSubInformation()
|
device_subscription: DeviceSubInformation = DeviceSubInformation()
|
||||||
device_stats: DeviceStatistics = DeviceStatistics()
|
device_stats: DeviceStatistics = DeviceStatistics()
|
||||||
events_on: bool = False
|
events_on: bool = False
|
||||||
device_location_coordinates: str = ""
|
device_location_coordinates: Any = None # GeoPoint dict {lat, lng} or legacy str
|
||||||
device_melodies_all: List[MelodyMainItem] = []
|
device_melodies_all: List[MelodyMainItem] = []
|
||||||
device_melodies_favorites: List[str] = []
|
device_melodies_favorites: List[str] = []
|
||||||
user_list: List[str] = []
|
user_list: List[str] = []
|
||||||
@@ -144,7 +144,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
device_subscription: Optional[Dict[str, Any]] = None
|
device_subscription: Optional[Dict[str, Any]] = None
|
||||||
device_stats: Optional[Dict[str, Any]] = None
|
device_stats: Optional[Dict[str, Any]] = None
|
||||||
events_on: Optional[bool] = None
|
events_on: Optional[bool] = None
|
||||||
device_location_coordinates: Optional[str] = None
|
device_location_coordinates: Optional[Any] = None # dict {lat, lng} or legacy str
|
||||||
device_melodies_all: Optional[List[MelodyMainItem]] = None
|
device_melodies_all: Optional[List[MelodyMainItem]] = None
|
||||||
device_melodies_favorites: Optional[List[str]] = None
|
device_melodies_favorites: Optional[List[str]] = None
|
||||||
user_list: Optional[List[str]] = None
|
user_list: Optional[List[str]] = None
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ def _convert_firestore_value(val):
|
|||||||
# Firestore DatetimeWithNanoseconds is a datetime subclass
|
# Firestore DatetimeWithNanoseconds is a datetime subclass
|
||||||
return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||||
if isinstance(val, GeoPoint):
|
if isinstance(val, GeoPoint):
|
||||||
return f"{val.latitude}° N, {val.longitude}° E"
|
return {"lat": val.latitude, "lng": val.longitude}
|
||||||
if isinstance(val, DocumentReference):
|
if isinstance(val, DocumentReference):
|
||||||
# Store the document path (e.g. "users/abc123")
|
# Store the document path (e.g. "users/abc123")
|
||||||
return val.path
|
return val.path
|
||||||
@@ -213,6 +213,11 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_none=True)
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Convert {lat, lng} dict to a Firestore GeoPoint
|
||||||
|
coords = update_data.get("device_location_coordinates")
|
||||||
|
if isinstance(coords, dict) and "lat" in coords and "lng" in coords:
|
||||||
|
update_data["device_location_coordinates"] = GeoPoint(coords["lat"], coords["lng"])
|
||||||
|
|
||||||
# Deep-merge nested structs so unmentioned sub-fields are preserved
|
# Deep-merge nested structs so unmentioned sub-fields are preserved
|
||||||
existing = doc.to_dict()
|
existing = doc.to_dict()
|
||||||
nested_keys = (
|
nested_keys = (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from firmware.router import router as firmware_router, ota_router
|
|||||||
from admin.router import router as admin_router
|
from admin.router import router as admin_router
|
||||||
from crm.router import router as crm_products_router
|
from crm.router import router as crm_products_router
|
||||||
from crm.customers_router import router as crm_customers_router
|
from crm.customers_router import router as crm_customers_router
|
||||||
from crm.orders_router import router as crm_orders_router
|
from crm.orders_router import router as crm_orders_router, global_router as crm_orders_global_router
|
||||||
from crm.comms_router import router as crm_comms_router
|
from crm.comms_router import router as crm_comms_router
|
||||||
from crm.media_router import router as crm_media_router
|
from crm.media_router import router as crm_media_router
|
||||||
from crm.nextcloud_router import router as crm_nextcloud_router
|
from crm.nextcloud_router import router as crm_nextcloud_router
|
||||||
@@ -64,6 +64,7 @@ app.include_router(admin_router)
|
|||||||
app.include_router(crm_products_router)
|
app.include_router(crm_products_router)
|
||||||
app.include_router(crm_customers_router)
|
app.include_router(crm_customers_router)
|
||||||
app.include_router(crm_orders_router)
|
app.include_router(crm_orders_router)
|
||||||
|
app.include_router(crm_orders_global_router)
|
||||||
app.include_router(crm_comms_router)
|
app.include_router(crm_comms_router)
|
||||||
app.include_router(crm_media_router)
|
app.include_router(crm_media_router)
|
||||||
app.include_router(crm_nextcloud_router)
|
app.include_router(crm_nextcloud_router)
|
||||||
@@ -88,6 +89,16 @@ async def email_sync_loop():
|
|||||||
print(f"[EMAIL SYNC] Error: {e}")
|
print(f"[EMAIL SYNC] Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def crm_poll_loop():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(24 * 60 * 60) # once per day
|
||||||
|
try:
|
||||||
|
from crm.service import poll_crm_customer_statuses
|
||||||
|
poll_crm_customer_statuses()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CRM POLL] Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
init_firebase()
|
init_firebase()
|
||||||
@@ -96,6 +107,7 @@ async def startup():
|
|||||||
mqtt_manager.start(asyncio.get_event_loop())
|
mqtt_manager.start(asyncio.get_event_loop())
|
||||||
asyncio.create_task(db.purge_loop())
|
asyncio.create_task(db.purge_loop())
|
||||||
asyncio.create_task(nextcloud_keepalive_loop())
|
asyncio.create_task(nextcloud_keepalive_loop())
|
||||||
|
asyncio.create_task(crm_poll_loop())
|
||||||
sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")]
|
sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")]
|
||||||
if sync_accounts:
|
if sync_accounts:
|
||||||
print(f"[EMAIL SYNC] IMAP configured for {len(sync_accounts)} account(s) - starting sync loop")
|
print(f"[EMAIL SYNC] IMAP configured for {len(sync_accounts)} account(s) - starting sync loop")
|
||||||
|
|||||||
178
backend/migrate_customer_flags.py
Normal file
178
backend/migrate_customer_flags.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
One-time migration script: convert legacy negotiating/has_problem flags to new structure.
|
||||||
|
|
||||||
|
Run AFTER deploying the new backend code:
|
||||||
|
cd backend && python migrate_customer_flags.py
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. For each customer with negotiating=True:
|
||||||
|
- Creates an order subcollection document with status="negotiating"
|
||||||
|
- Sets relationship_status="active" (only if currently "lead" or "prospect")
|
||||||
|
2. For each customer with has_problem=True:
|
||||||
|
- Appends one entry to technical_issues with active=True
|
||||||
|
3. Removes negotiating and has_problem fields from every customer document
|
||||||
|
4. Initialises relationship_status="lead" on any customer missing it
|
||||||
|
5. Recomputes crm_summary for each affected customer
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Make sure we can import backend modules
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from shared.firebase import init_firebase, get_db
|
||||||
|
|
||||||
|
init_firebase()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db = get_db()
|
||||||
|
customers_ref = db.collection("crm_customers")
|
||||||
|
docs = list(customers_ref.stream())
|
||||||
|
print(f"Found {len(docs)} customer documents.")
|
||||||
|
|
||||||
|
migrated_neg = 0
|
||||||
|
migrated_prob = 0
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
customer_id = doc.id
|
||||||
|
updates = {}
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# ── 1. Initialise new fields if missing ──────────────────────────────
|
||||||
|
if "relationship_status" not in data:
|
||||||
|
updates["relationship_status"] = "lead"
|
||||||
|
changed = True
|
||||||
|
if "technical_issues" not in data:
|
||||||
|
updates["technical_issues"] = []
|
||||||
|
changed = True
|
||||||
|
if "install_support" not in data:
|
||||||
|
updates["install_support"] = []
|
||||||
|
changed = True
|
||||||
|
if "transaction_history" not in data:
|
||||||
|
updates["transaction_history"] = []
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# ── 2. Migrate negotiating flag ───────────────────────────────────────
|
||||||
|
if data.get("negotiating"):
|
||||||
|
order_id = str(uuid.uuid4())
|
||||||
|
order_data = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"order_number": f"ORD-{datetime.utcnow().year}-001-migrated",
|
||||||
|
"title": "Migrated from legacy negotiating flag",
|
||||||
|
"created_by": "system",
|
||||||
|
"status": "negotiating",
|
||||||
|
"status_updated_date": now,
|
||||||
|
"status_updated_by": "system",
|
||||||
|
"items": [],
|
||||||
|
"subtotal": 0,
|
||||||
|
"discount": None,
|
||||||
|
"total_price": 0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"shipping": None,
|
||||||
|
"payment_status": {
|
||||||
|
"required_amount": 0,
|
||||||
|
"received_amount": 0,
|
||||||
|
"balance_due": 0,
|
||||||
|
"advance_required": False,
|
||||||
|
"advance_amount": None,
|
||||||
|
"payment_complete": False,
|
||||||
|
},
|
||||||
|
"invoice_path": None,
|
||||||
|
"notes": "Migrated from legacy negotiating flag",
|
||||||
|
"timeline": [{
|
||||||
|
"date": now,
|
||||||
|
"type": "note",
|
||||||
|
"note": "Migrated from legacy negotiating flag",
|
||||||
|
"updated_by": "system",
|
||||||
|
}],
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
customers_ref.document(customer_id).collection("orders").document(order_id).set(order_data)
|
||||||
|
|
||||||
|
current_rel = updates.get("relationship_status") or data.get("relationship_status", "lead")
|
||||||
|
if current_rel in ("lead", "prospect"):
|
||||||
|
updates["relationship_status"] = "active"
|
||||||
|
|
||||||
|
migrated_neg += 1
|
||||||
|
print(f" [{customer_id}] Created negotiating order, set relationship_status=active")
|
||||||
|
|
||||||
|
# ── 3. Migrate has_problem flag ───────────────────────────────────────
|
||||||
|
if data.get("has_problem"):
|
||||||
|
existing_issues = list(updates.get("technical_issues") or data.get("technical_issues") or [])
|
||||||
|
existing_issues.append({
|
||||||
|
"active": True,
|
||||||
|
"opened_date": data.get("updated_at") or now,
|
||||||
|
"resolved_date": None,
|
||||||
|
"note": "Migrated from legacy has_problem flag",
|
||||||
|
"opened_by": "system",
|
||||||
|
"resolved_by": None,
|
||||||
|
})
|
||||||
|
updates["technical_issues"] = existing_issues
|
||||||
|
migrated_prob += 1
|
||||||
|
changed = True
|
||||||
|
print(f" [{customer_id}] Appended technical issue from has_problem flag")
|
||||||
|
|
||||||
|
# ── 4. Remove legacy fields ───────────────────────────────────────────
|
||||||
|
from google.cloud.firestore_v1 import DELETE_FIELD
|
||||||
|
if "negotiating" in data:
|
||||||
|
updates["negotiating"] = DELETE_FIELD
|
||||||
|
changed = True
|
||||||
|
if "has_problem" in data:
|
||||||
|
updates["has_problem"] = DELETE_FIELD
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed or data.get("negotiating") or data.get("has_problem"):
|
||||||
|
updates["updated_at"] = now
|
||||||
|
customers_ref.document(customer_id).update(updates)
|
||||||
|
|
||||||
|
# ── 5. Recompute crm_summary ──────────────────────────────────────────
|
||||||
|
# Re-read updated doc to compute summary
|
||||||
|
updated_doc = customers_ref.document(customer_id).get()
|
||||||
|
updated_data = updated_doc.to_dict() or {}
|
||||||
|
|
||||||
|
issues = updated_data.get("technical_issues") or []
|
||||||
|
active_issues = [i for i in issues if i.get("active")]
|
||||||
|
support = updated_data.get("install_support") or []
|
||||||
|
active_support = [s for s in support if s.get("active")]
|
||||||
|
|
||||||
|
TERMINAL = {"declined", "complete"}
|
||||||
|
active_order_status = None
|
||||||
|
active_order_status_date = None
|
||||||
|
active_order_title = None
|
||||||
|
latest_date = ""
|
||||||
|
for odoc in customers_ref.document(customer_id).collection("orders").stream():
|
||||||
|
odata = odoc.to_dict() or {}
|
||||||
|
if odata.get("status") not in TERMINAL:
|
||||||
|
upd = odata.get("status_updated_date") or odata.get("created_at") or ""
|
||||||
|
if upd > latest_date:
|
||||||
|
latest_date = upd
|
||||||
|
active_order_status = odata.get("status")
|
||||||
|
active_order_status_date = upd
|
||||||
|
active_order_title = odata.get("title")
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"active_order_status": active_order_status,
|
||||||
|
"active_order_status_date": active_order_status_date,
|
||||||
|
"active_order_title": active_order_title,
|
||||||
|
"active_issues_count": len(active_issues),
|
||||||
|
"latest_issue_date": max((i.get("opened_date") or "") for i in active_issues) if active_issues else None,
|
||||||
|
"active_support_count": len(active_support),
|
||||||
|
"latest_support_date": max((s.get("opened_date") or "") for s in active_support) if active_support else None,
|
||||||
|
}
|
||||||
|
customers_ref.document(customer_id).update({"crm_summary": summary})
|
||||||
|
|
||||||
|
print(f"\nMigration complete.")
|
||||||
|
print(f" Negotiating orders created: {migrated_neg}")
|
||||||
|
print(f" Technical issues created: {migrated_prob}")
|
||||||
|
print(f" Total customers processed: {len(docs)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
Reference in New Issue
Block a user