feature: Added Transactions and major Order System Overhaul

This commit is contained in:
2026-03-25 10:32:47 +02:00
parent 2d57c75d2f
commit b2d1e2bdc4
8 changed files with 1089 additions and 123 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)}

View File

@@ -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}")

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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")

View 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()