From b2d1e2bdc4894479ed7e54235fb355bc0b225c7a Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 25 Mar 2026 10:32:47 +0200 Subject: [PATCH] feature: Added Transactions and major Order System Overhaul --- backend/crm/customers_router.py | 153 +++++++- backend/crm/models.py | 107 ++++- backend/crm/orders_router.py | 128 +++++- backend/crm/service.py | 621 +++++++++++++++++++++++++++--- backend/devices/models.py | 4 +- backend/devices/service.py | 7 +- backend/main.py | 14 +- backend/migrate_customer_flags.py | 178 +++++++++ 8 files changed, 1089 insertions(+), 123 deletions(-) create mode 100644 backend/migrate_customer_flags.py diff --git a/backend/crm/customers_router.py b/backend/crm/customers_router.py index 100a2fd..5c6bfdf 100644 --- a/backend/crm/customers_router.py +++ b/backend/crm/customers_router.py @@ -5,7 +5,7 @@ from typing import Optional from auth.models import TokenPayload 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 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) -@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") async def get_last_comm_direction( customer_id: str, _user: TokenPayload = Depends(require_permission("crm", "view")), ): - direction = await service.get_last_comm_direction(customer_id) - return {"direction": direction} + result = await service.get_last_comm_direction(customer_id) + 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) diff --git a/backend/crm/models.py b/backend/crm/models.py index 72ae30d..525e2a0 100644 --- a/backend/crm/models.py +++ b/backend/crm/models.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel @@ -129,6 +129,50 @@ class CustomerLocation(BaseModel): country: Optional[str] = None +# ── New customer status models ──────────────────────────────────────────────── + +class TechnicalIssue(BaseModel): + active: bool = True + opened_date: str # ISO string + resolved_date: Optional[str] = None + note: str + opened_by: str + resolved_by: Optional[str] = None + + +class InstallSupportEntry(BaseModel): + active: bool = True + opened_date: str # ISO string + resolved_date: Optional[str] = None + note: str + opened_by: str + resolved_by: Optional[str] = None + + +class TransactionEntry(BaseModel): + date: str # ISO string + flow: str # "invoice" | "payment" | "refund" | "credit" + payment_type: Optional[str] = None # "cash" | "bank_transfer" | "card" | "paypal" — null for invoices + category: str # "full_payment" | "advance" | "installment" + amount: float + currency: str = "EUR" + invoice_ref: Optional[str] = None + order_ref: Optional[str] = None + recorded_by: str + note: str = "" + + +# Lightweight summary stored on customer doc for fast CustomerList expanded view +class CrmSummary(BaseModel): + active_order_status: Optional[str] = None + active_order_status_date: Optional[str] = None + active_order_title: Optional[str] = None + active_issues_count: int = 0 + latest_issue_date: Optional[str] = None + active_support_count: int = 0 + latest_support_date: Optional[str] = None + + class CustomerCreate(BaseModel): title: Optional[str] = None name: str @@ -143,9 +187,12 @@ class CustomerCreate(BaseModel): owned_items: List[OwnedItem] = [] linked_user_ids: List[str] = [] nextcloud_folder: Optional[str] = None - folder_id: Optional[str] = None # Human-readable Nextcloud folder name, e.g. "saint-john-corfu" - negotiating: bool = False - has_problem: bool = False + folder_id: Optional[str] = None + relationship_status: str = "lead" + technical_issues: List[Dict[str, Any]] = [] + install_support: List[Dict[str, Any]] = [] + transaction_history: List[Dict[str, Any]] = [] + crm_summary: Optional[Dict[str, Any]] = None class CustomerUpdate(BaseModel): @@ -162,8 +209,7 @@ class CustomerUpdate(BaseModel): owned_items: Optional[List[OwnedItem]] = None linked_user_ids: Optional[List[str]] = None nextcloud_folder: Optional[str] = None - negotiating: Optional[bool] = None - has_problem: Optional[bool] = None + relationship_status: Optional[str] = None # folder_id intentionally excluded from update — set once at creation @@ -181,18 +227,34 @@ class CustomerListResponse(BaseModel): # ── Orders ─────────────────────────────────────────────────────────────────── class OrderStatus(str, Enum): - draft = "draft" - confirmed = "confirmed" - in_production = "in_production" + negotiating = "negotiating" + awaiting_quotation = "awaiting_quotation" + awaiting_customer_confirmation = "awaiting_customer_confirmation" + awaiting_fulfilment = "awaiting_fulfilment" + awaiting_payment = "awaiting_payment" + manufacturing = "manufacturing" shipped = "shipped" - delivered = "delivered" - cancelled = "cancelled" + installed = "installed" + declined = "declined" + complete = "complete" -class PaymentStatus(str, Enum): - pending = "pending" - partial = "partial" - paid = "paid" +class OrderPaymentStatus(BaseModel): + required_amount: float = 0 + received_amount: float = 0 + balance_due: float = 0 + advance_required: bool = False + advance_amount: Optional[float] = None + payment_complete: bool = False + + +class OrderTimelineEvent(BaseModel): + date: str # ISO string + type: str # "quote_request" | "quote_sent" | "quote_accepted" | "quote_declined" + # | "mfg_started" | "mfg_complete" | "order_shipped" | "installed" + # | "payment_received" | "invoice_sent" | "note" + note: str = "" + updated_by: str class OrderDiscount(BaseModel): @@ -223,29 +285,36 @@ class OrderItem(BaseModel): class OrderCreate(BaseModel): customer_id: str 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] = [] subtotal: float = 0 discount: Optional[OrderDiscount] = None total_price: float = 0 currency: str = "EUR" shipping: Optional[OrderShipping] = None - payment_status: PaymentStatus = PaymentStatus.pending + payment_status: Optional[Dict[str, Any]] = None invoice_path: Optional[str] = None notes: Optional[str] = None + timeline: List[Dict[str, Any]] = [] class OrderUpdate(BaseModel): - customer_id: Optional[str] = None order_number: Optional[str] = None + title: Optional[str] = None status: Optional[OrderStatus] = None + status_updated_date: Optional[str] = None + status_updated_by: Optional[str] = None items: Optional[List[OrderItem]] = None subtotal: Optional[float] = None discount: Optional[OrderDiscount] = None total_price: Optional[float] = None currency: Optional[str] = None shipping: Optional[OrderShipping] = None - payment_status: Optional[PaymentStatus] = None + payment_status: Optional[Dict[str, Any]] = None invoice_path: Optional[str] = None notes: Optional[str] = None diff --git a/backend/crm/orders_router.py b/backend/crm/orders_router.py index a7e95ea..d38f851 100644 --- a/backend/crm/orders_router.py +++ b/backend/crm/orders_router.py @@ -6,52 +6,146 @@ from auth.dependencies import require_permission from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse 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) def list_orders( - customer_id: Optional[str] = Query(None), - status: Optional[str] = Query(None), - payment_status: Optional[str] = Query(None), + customer_id: str, _user: TokenPayload = Depends(require_permission("crm", "view")), ): - orders = service.list_orders( - customer_id=customer_id, - status=status, - payment_status=payment_status, - ) + orders = service.list_orders(customer_id) return OrderListResponse(orders=orders, total=len(orders)) -@router.get("/{order_id}", response_model=OrderInDB) -def get_order( - order_id: str, +# IMPORTANT: specific sub-paths must come before /{order_id} +@router.get("/next-order-number") +def get_next_order_number( + customer_id: str, _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) def create_order( + customer_id: str, body: OrderCreate, _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( + customer_id: str, order_id: str, body: OrderUpdate, _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) def delete_order( + customer_id: str, order_id: str, _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)} diff --git a/backend/crm/service.py b/backend/crm/service.py index 5e7c5be..2b68bfa 100644 --- a/backend/crm/service.py +++ b/backend/crm/service.py @@ -14,6 +14,7 @@ from crm.models import ( OrderCreate, OrderUpdate, OrderInDB, CommCreate, CommUpdate, CommInDB, MediaCreate, MediaInDB, + TechnicalIssue, InstallSupportEntry, TransactionEntry, ) COLLECTION = "crm_products" @@ -126,8 +127,12 @@ def delete_product(product_id: str) -> None: CUSTOMERS_COLLECTION = "crm_customers" +_LEGACY_CUSTOMER_FIELDS = {"negotiating", "has_problem"} + def _doc_to_customer(doc) -> CustomerInDB: data = doc.to_dict() + for f in _LEGACY_CUSTOMER_FIELDS: + data.pop(f, None) 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) -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 toggle_problem(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("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.""" +async def get_last_comm_direction(customer_id: str) -> dict: + """Return direction ('inbound'/'outbound') and timestamp of the most recent comm, or None.""" db = await mqtt_db.get_db() 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') " "ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT 1", (customer_id,), ) if rows: - return rows[0][0] - return None + return {"direction": rows[0][0], "occurred_at": rows[0][1]} + return {"direction": None, "occurred_at": 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 -# ── Orders ─────────────────────────────────────────────────────────────────── - -ORDERS_COLLECTION = "crm_orders" - +# ── Orders (subcollection under customers/{id}/orders) ──────────────────────── def _doc_to_order(doc) -> OrderInDB: data = doc.to_dict() return OrderInDB(id=doc.id, **data) -def _generate_order_number(db) -> str: - year = datetime.utcnow().year - prefix = f"ORD-{year}-" +def _order_collection(customer_id: str): + db = get_db() + 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 - for doc in db.collection(ORDERS_COLLECTION).stream(): + for doc in db.collection_group("orders").stream(): data = doc.to_dict() num = data.get("order_number", "") if num and num.startswith(prefix): @@ -392,50 +378,150 @@ def _generate_order_number(db) -> str: return f"{prefix}{max_n + 1:03d}" -def list_orders( - customer_id: str | None = None, - status: str | None = None, - payment_status: str | None = None, -) -> list[OrderInDB]: - db = get_db() - query = db.collection(ORDERS_COLLECTION) +def _default_payment_status() -> dict: + return { + "required_amount": 0, + "received_amount": 0, + "balance_due": 0, + "advance_required": False, + "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: 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()] -def get_order(order_id: str) -> OrderInDB: - db = get_db() - doc = db.collection(ORDERS_COLLECTION).document(order_id).get() +def get_order(customer_id: str, order_id: str) -> OrderInDB: + doc = _order_collection(customer_id).document(order_id).get() if not doc.exists: raise NotFoundError("Order") return _doc_to_order(doc) -def create_order(data: OrderCreate) -> OrderInDB: - db = get_db() +def create_order(customer_id: str, data: OrderCreate) -> OrderInDB: + col = _order_collection(customer_id) now = datetime.utcnow().isoformat() order_id = str(uuid.uuid4()) doc_data = data.model_dump() + doc_data["customer_id"] = customer_id 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["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) -def update_order(order_id: str, data: OrderUpdate) -> OrderInDB: - db = get_db() - doc_ref = db.collection(ORDERS_COLLECTION).document(order_id) +def update_order(customer_id: str, order_id: str, data: OrderUpdate) -> OrderInDB: + doc_ref = _order_collection(customer_id).document(order_id) doc = doc_ref.get() if not doc.exists: raise NotFoundError("Order") @@ -444,17 +530,362 @@ def update_order(order_id: str, data: OrderUpdate) -> OrderInDB: update_data["updated_at"] = datetime.utcnow().isoformat() doc_ref.update(update_data) - updated_doc = doc_ref.get() - return _doc_to_order(updated_doc) + _update_crm_summary(customer_id) + 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: - db = get_db() - doc_ref = db.collection(ORDERS_COLLECTION).document(order_id) +def delete_order(customer_id: str, order_id: str) -> None: + doc_ref = _order_collection(customer_id).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() if not doc.exists: 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) ───────────────────────────────────────────────── @@ -753,3 +1184,65 @@ async def delete_media(media_id: str) -> None: raise HTTPException(status_code=404, detail="Media entry not found") await db.execute("DELETE FROM crm_media WHERE id = ?", (media_id,)) 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}") diff --git a/backend/devices/models.py b/backend/devices/models.py index 9c3acec..032124a 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -119,7 +119,7 @@ class DeviceCreate(BaseModel): device_subscription: DeviceSubInformation = DeviceSubInformation() device_stats: DeviceStatistics = DeviceStatistics() 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_favorites: List[str] = [] user_list: List[str] = [] @@ -144,7 +144,7 @@ class DeviceUpdate(BaseModel): device_subscription: Optional[Dict[str, Any]] = None device_stats: Optional[Dict[str, Any]] = 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_favorites: Optional[List[str]] = None user_list: Optional[List[str]] = None diff --git a/backend/devices/service.py b/backend/devices/service.py index ff514d9..81b623d 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -72,7 +72,7 @@ def _convert_firestore_value(val): # Firestore DatetimeWithNanoseconds is a datetime subclass return val.strftime("%d %B %Y at %H:%M:%S UTC%z") if isinstance(val, GeoPoint): - return f"{val.latitude}° N, {val.longitude}° E" + return {"lat": val.latitude, "lng": val.longitude} if isinstance(val, DocumentReference): # Store the document path (e.g. "users/abc123") 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) + # 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 existing = doc.to_dict() nested_keys = ( diff --git a/backend/main.py b/backend/main.py index b62f64a..f4ebbc9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,7 +19,7 @@ from firmware.router import router as firmware_router, ota_router from admin.router import router as admin_router from crm.router import router as crm_products_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.media_router import router as crm_media_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_customers_router) app.include_router(crm_orders_router) +app.include_router(crm_orders_global_router) app.include_router(crm_comms_router) app.include_router(crm_media_router) app.include_router(crm_nextcloud_router) @@ -88,6 +89,16 @@ async def email_sync_loop(): 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") async def startup(): init_firebase() @@ -96,6 +107,7 @@ async def startup(): mqtt_manager.start(asyncio.get_event_loop()) asyncio.create_task(db.purge_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")] if sync_accounts: print(f"[EMAIL SYNC] IMAP configured for {len(sync_accounts)} account(s) - starting sync loop") diff --git a/backend/migrate_customer_flags.py b/backend/migrate_customer_flags.py new file mode 100644 index 0000000..9354fab --- /dev/null +++ b/backend/migrate_customer_flags.py @@ -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()