feature: Added Transactions and major Order System Overhaul
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user