Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -28,6 +28,16 @@ class MailListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/latest-batch", response_model=dict)
|
||||
async def latest_comm_batch(
|
||||
ids: str = Query(..., description="Comma-separated customer IDs"),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""Return the latest comm summary (id, type, occurred_at) keyed by customer_id."""
|
||||
customer_ids = [i.strip() for i in ids.split(",") if i.strip()]
|
||||
return await service.get_latest_comm_batch(customer_ids)
|
||||
|
||||
|
||||
@router.get("/all", response_model=CommListResponse)
|
||||
async def list_all_comms(
|
||||
type: Optional[str] = Query(None),
|
||||
|
||||
239
backend/crm/orm.py
Normal file
239
backend/crm/orm.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer,
|
||||
Numeric, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class CrmProduct(Base):
|
||||
__tablename__ = "crm_products"
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||
firestore_id = Column(String(128), unique=True) # same as id during transition
|
||||
name = Column(String(500), nullable=False)
|
||||
sku = Column(String(128))
|
||||
category = Column(String(128))
|
||||
description = Column(Text)
|
||||
unit_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
currency = Column(String(10), nullable=False, default="EUR")
|
||||
unit_type = Column(String(32), nullable=False, default="pcs")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class CrmCustomer(Base):
|
||||
__tablename__ = "crm_customers"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_customers_rel_status", "relationship_status"),
|
||||
Index("idx_crm_customers_name", "name", "surname"),
|
||||
Index("idx_crm_customers_tags", "tags", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||
firestore_id = Column(String(128), unique=True)
|
||||
title = Column(String(32))
|
||||
name = Column(String(255), nullable=False)
|
||||
surname = Column(String(255))
|
||||
organization = Column(String(500))
|
||||
religion = Column(String(64))
|
||||
language = Column(String(10), nullable=False, default="el")
|
||||
folder_id = Column(String(128), unique=True, nullable=False)
|
||||
relationship_status = Column(String(64), nullable=False, default="lead")
|
||||
nextcloud_folder = Column(String(500))
|
||||
contacts = Column(JSONB, nullable=False, default=list)
|
||||
notes = Column(JSONB, nullable=False, default=list)
|
||||
location = Column(JSONB)
|
||||
tags = Column(ARRAY(String), nullable=False, default=list)
|
||||
owned_items = Column(JSONB, nullable=False, default=list)
|
||||
linked_user_ids = Column(ARRAY(String), nullable=False, default=list)
|
||||
technical_issues = Column(JSONB, nullable=False, default=list)
|
||||
install_support = Column(JSONB, nullable=False, default=list)
|
||||
transaction_history = Column(JSONB, nullable=False, default=list)
|
||||
crm_summary = Column(JSONB)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
orders = relationship("CrmOrder", back_populates="customer",
|
||||
cascade="all, delete-orphan", lazy="noload")
|
||||
quotations = relationship("CrmQuotation", back_populates="customer",
|
||||
cascade="all, delete-orphan", lazy="noload")
|
||||
comms = relationship("CrmCommsLog", back_populates="customer",
|
||||
cascade="all, delete-orphan", lazy="noload")
|
||||
media = relationship("CrmMedia", back_populates="customer", lazy="noload")
|
||||
|
||||
|
||||
class CrmOrder(Base):
|
||||
__tablename__ = "crm_orders"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_orders_customer", "customer_id"),
|
||||
Index("idx_crm_orders_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
order_number = Column(String(64), unique=True, nullable=False)
|
||||
title = Column(String(500))
|
||||
created_by = Column(String(128))
|
||||
status = Column(String(64), nullable=False, default="negotiating")
|
||||
status_updated_date = Column(DateTime(timezone=True))
|
||||
status_updated_by = Column(String(128))
|
||||
items = Column(JSONB, nullable=False, default=list)
|
||||
subtotal = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
discount = Column(JSONB)
|
||||
total_price = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
currency = Column(String(10), nullable=False, default="EUR")
|
||||
shipping = Column(JSONB)
|
||||
payment_status = Column(JSONB, nullable=False, default=dict)
|
||||
invoice_path = Column(String(500))
|
||||
notes = Column(Text)
|
||||
timeline = Column(JSONB, nullable=False, default=list)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="orders")
|
||||
|
||||
|
||||
class CrmCommsLog(Base):
|
||||
__tablename__ = "crm_comms_log"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_comms_customer", "customer_id", "occurred_at"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="SET NULL"),
|
||||
nullable=True)
|
||||
type = Column(String(32), nullable=False) # email | sms | call | note | ...
|
||||
mail_account = Column(String(256))
|
||||
direction = Column(String(16), nullable=False) # inbound | outbound
|
||||
subject = Column(String(500))
|
||||
body = Column(Text)
|
||||
body_html = Column(Text)
|
||||
attachments = Column(JSONB, nullable=False, default=list)
|
||||
ext_message_id = Column(String(500))
|
||||
from_addr = Column(String(500))
|
||||
to_addrs = Column(Text) # JSON array as text or comma-sep
|
||||
logged_by = Column(String(128))
|
||||
is_important = Column(Boolean, nullable=False, default=False)
|
||||
is_read = Column(Boolean, nullable=False, default=True)
|
||||
occurred_at = Column(DateTime(timezone=True), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="comms")
|
||||
|
||||
|
||||
class CrmMedia(Base):
|
||||
__tablename__ = "crm_media"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_media_customer", "customer_id"),
|
||||
Index("idx_crm_media_order", "order_id"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="SET NULL"),
|
||||
nullable=True)
|
||||
order_id = Column(String(128))
|
||||
filename = Column(String(500), nullable=False)
|
||||
nextcloud_path = Column(String(1000), nullable=False)
|
||||
thumbnail_path = Column(String(1000))
|
||||
mime_type = Column(String(128))
|
||||
direction = Column(String(16))
|
||||
tags = Column(JSONB, nullable=False, default=list)
|
||||
uploaded_by = Column(String(128))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="media")
|
||||
|
||||
|
||||
class CrmSyncState(Base):
|
||||
__tablename__ = "crm_sync_state"
|
||||
|
||||
key = Column(String(128), primary_key=True)
|
||||
value = Column(Text)
|
||||
|
||||
|
||||
class CrmQuotation(Base):
|
||||
__tablename__ = "crm_quotations"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_quotations_customer", "customer_id"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
quotation_number = Column(String(64), unique=True, nullable=False)
|
||||
title = Column(String(500))
|
||||
subtitle = Column(String(500))
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
language = Column(String(10), nullable=False, default="en")
|
||||
status = Column(String(32), nullable=False, default="draft")
|
||||
order_type = Column(String(64))
|
||||
shipping_method = Column(String(64))
|
||||
estimated_shipping_date = Column(String(32)) # stored as DATE string
|
||||
global_discount_label = Column(String(128))
|
||||
global_discount_percent = Column(Numeric(8, 4), nullable=False, default=0)
|
||||
vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||
global_vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||
shipping_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
shipping_cost_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
install_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
install_cost_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
extras_label = Column(String(256))
|
||||
extras_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
comments = Column(JSONB, nullable=False, default=list)
|
||||
quick_notes = Column(JSONB, nullable=False, default=dict)
|
||||
subtotal_before_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
global_discount_amount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
new_subtotal = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
vat_amount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
final_total = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
nextcloud_pdf_path = Column(String(1000))
|
||||
nextcloud_pdf_url = Column(String(1000))
|
||||
# Client snapshot fields (denormalised for PDF generation)
|
||||
client_org = Column(String(500))
|
||||
client_name = Column(String(500))
|
||||
client_location = Column(String(500))
|
||||
client_phone = Column(String(64))
|
||||
client_email = Column(String(256))
|
||||
# Legacy quotation fields
|
||||
is_legacy = Column(Boolean, nullable=False, default=False)
|
||||
legacy_date = Column(String(32))
|
||||
legacy_pdf_path = Column(String(1000))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="quotations")
|
||||
items = relationship("CrmQuotationItem", back_populates="quotation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="CrmQuotationItem.sort_order", lazy="noload")
|
||||
|
||||
|
||||
class CrmQuotationItem(Base):
|
||||
__tablename__ = "crm_quotation_items"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_quotation_items_quotation", "quotation_id", "sort_order"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
quotation_id = Column(String(128), ForeignKey("crm_quotations.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
product_id = Column(String(128))
|
||||
description = Column(Text)
|
||||
description_en = Column(Text)
|
||||
description_gr = Column(Text)
|
||||
unit_type = Column(String(32), nullable=False, default="pcs")
|
||||
unit_cost = Column(Numeric(12, 4), nullable=False, default=0)
|
||||
discount_percent = Column(Numeric(8, 4), nullable=False, default=0)
|
||||
vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||
quantity = Column(Numeric(12, 4), nullable=False, default=1)
|
||||
line_total = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
quotation = relationship("CrmQuotation", back_populates="items")
|
||||
@@ -5,9 +5,10 @@ from pydantic import BaseModel
|
||||
|
||||
class QuotationStatus(str, Enum):
|
||||
draft = "draft"
|
||||
built = "built"
|
||||
sent = "sent"
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
declined = "declined"
|
||||
|
||||
|
||||
class QuotationItemCreate(BaseModel):
|
||||
@@ -39,6 +40,7 @@ class QuotationCreate(BaseModel):
|
||||
estimated_shipping_date: Optional[str] = None
|
||||
global_discount_label: Optional[str] = None
|
||||
global_discount_percent: float = 0.0
|
||||
global_vat_percent: float = 24.0
|
||||
shipping_cost: float = 0.0
|
||||
shipping_cost_discount: float = 0.0
|
||||
install_cost: float = 0.0
|
||||
@@ -70,6 +72,7 @@ class QuotationUpdate(BaseModel):
|
||||
estimated_shipping_date: Optional[str] = None
|
||||
global_discount_label: Optional[str] = None
|
||||
global_discount_percent: Optional[float] = None
|
||||
global_vat_percent: Optional[float] = None
|
||||
shipping_cost: Optional[float] = None
|
||||
shipping_cost_discount: Optional[float] = None
|
||||
install_cost: Optional[float] = None
|
||||
@@ -104,6 +107,7 @@ class QuotationInDB(BaseModel):
|
||||
estimated_shipping_date: Optional[str] = None
|
||||
global_discount_label: Optional[str] = None
|
||||
global_discount_percent: float = 0.0
|
||||
global_vat_percent: float = 24.0
|
||||
shipping_cost: float = 0.0
|
||||
shipping_cost_discount: float = 0.0
|
||||
install_cost: float = 0.0
|
||||
|
||||
@@ -42,6 +42,7 @@ def _float(d: Decimal) -> float:
|
||||
def _calculate_totals(
|
||||
items: list,
|
||||
global_discount_percent: float,
|
||||
global_vat_percent: float,
|
||||
shipping_cost: float,
|
||||
shipping_cost_discount: float,
|
||||
install_cost: float,
|
||||
@@ -50,21 +51,20 @@ def _calculate_totals(
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
|
||||
VAT is computed per-item from each item's vat_percent field.
|
||||
VAT is a single global rate applied to items only (not shipping or install).
|
||||
Shipping and install costs carry 0% VAT.
|
||||
Returns a dict of floats ready for DB storage.
|
||||
"""
|
||||
# Per-line totals and per-item VAT
|
||||
# Per-line totals (items only)
|
||||
item_totals = []
|
||||
item_vat = Decimal(0)
|
||||
for item in items:
|
||||
cost = _d(item.get("unit_cost", 0))
|
||||
qty = _d(item.get("quantity", 1))
|
||||
disc = _d(item.get("discount_percent", 0))
|
||||
net = cost * qty * (1 - disc / 100)
|
||||
item_totals.append(net)
|
||||
vat_pct = _d(item.get("vat_percent", 24))
|
||||
item_vat += net * (vat_pct / 100)
|
||||
|
||||
items_net = sum(item_totals, Decimal(0))
|
||||
|
||||
# Shipping net (VAT = 0%)
|
||||
ship_gross = _d(shipping_cost)
|
||||
@@ -76,16 +76,17 @@ def _calculate_totals(
|
||||
install_disc = _d(install_cost_discount)
|
||||
install_net = install_gross * (1 - install_disc / 100)
|
||||
|
||||
subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net
|
||||
subtotal = items_net + ship_net + install_net
|
||||
|
||||
global_disc_pct = _d(global_discount_percent)
|
||||
global_disc_amount = subtotal * (global_disc_pct / 100)
|
||||
new_subtotal = subtotal - global_disc_amount
|
||||
|
||||
# Global discount proportionally reduces VAT too
|
||||
if subtotal > 0:
|
||||
disc_ratio = new_subtotal / subtotal
|
||||
vat_amount = item_vat * disc_ratio
|
||||
# VAT applies only to items portion, scaled by the global discount ratio
|
||||
vat_pct = _d(global_vat_percent)
|
||||
if subtotal > 0 and items_net > 0:
|
||||
items_ratio = items_net / subtotal
|
||||
vat_amount = new_subtotal * items_ratio * (vat_pct / 100)
|
||||
else:
|
||||
vat_amount = Decimal(0)
|
||||
|
||||
@@ -109,14 +110,16 @@ def _calc_line_total(item) -> float:
|
||||
|
||||
|
||||
async def _generate_quotation_number(db) -> str:
|
||||
year = datetime.utcnow().year
|
||||
prefix = f"QT-{year}-"
|
||||
now = datetime.utcnow()
|
||||
yy = now.strftime("%y")
|
||||
mm = now.strftime("%m")
|
||||
prefix = f"QT-{yy}-{mm}-"
|
||||
rows = await db.execute_fetchall(
|
||||
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
|
||||
(f"{prefix}%",),
|
||||
)
|
||||
if rows:
|
||||
last_num = rows[0][0] # e.g. "QT-2026-012"
|
||||
last_num = rows[0][0] # e.g. "QT-26-04-012"
|
||||
try:
|
||||
seq = int(last_num[len(prefix):]) + 1
|
||||
except ValueError:
|
||||
@@ -174,13 +177,16 @@ async def list_all_quotations() -> list[dict]:
|
||||
doc = fstore.collection("crm_customers").document(cid).get()
|
||||
if doc.exists:
|
||||
d = doc.to_dict()
|
||||
parts = [d.get("name", ""), d.get("surname", ""), d.get("organization", "")]
|
||||
label = " ".join(p for p in parts if p).strip()
|
||||
customer_names[cid] = label or cid
|
||||
name_parts = [d.get("name", ""), d.get("surname", "")]
|
||||
full_name = " ".join(p for p in name_parts if p).strip()
|
||||
org = (d.get("organization", "") or "").strip()
|
||||
customer_names[cid] = {"name": full_name or cid, "org": org}
|
||||
except Exception:
|
||||
customer_names[cid] = cid
|
||||
customer_names[cid] = {"name": cid, "org": ""}
|
||||
for item in items:
|
||||
item["customer_name"] = customer_names.get(item["customer_id"], "")
|
||||
info = customer_names.get(item["customer_id"], {"name": "", "org": ""})
|
||||
item["customer_name"] = info["name"]
|
||||
item["customer_org"] = info["org"]
|
||||
return items
|
||||
|
||||
|
||||
@@ -222,6 +228,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
totals = _calculate_totals(
|
||||
items_raw,
|
||||
data.global_discount_percent,
|
||||
data.global_vat_percent,
|
||||
data.shipping_cost,
|
||||
data.shipping_cost_discount,
|
||||
data.install_cost,
|
||||
@@ -236,7 +243,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
"""INSERT INTO crm_quotations (
|
||||
id, quotation_number, title, subtitle, customer_id,
|
||||
language, status, order_type, shipping_method, estimated_shipping_date,
|
||||
global_discount_label, global_discount_percent,
|
||||
global_discount_label, global_discount_percent, global_vat_percent,
|
||||
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
|
||||
extras_label, extras_cost, comments, quick_notes,
|
||||
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
||||
@@ -247,7 +254,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?,
|
||||
?, 'draft', ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
@@ -259,7 +266,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
(
|
||||
qid, quotation_number, data.title, data.subtitle, data.customer_id,
|
||||
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
|
||||
data.global_discount_label, data.global_discount_percent,
|
||||
data.global_discount_label, data.global_discount_percent, data.global_vat_percent,
|
||||
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
|
||||
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
|
||||
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
||||
@@ -317,7 +324,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
||||
|
||||
scalar_fields = [
|
||||
"title", "subtitle", "language", "status", "order_type", "shipping_method",
|
||||
"estimated_shipping_date", "global_discount_label", "global_discount_percent",
|
||||
"estimated_shipping_date", "global_discount_label", "global_discount_percent", "global_vat_percent",
|
||||
"shipping_cost", "shipping_cost_discount", "install_cost",
|
||||
"install_cost_discount", "extras_label", "extras_cost",
|
||||
"client_org", "client_name", "client_location", "client_phone", "client_email",
|
||||
@@ -352,6 +359,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
||||
totals = _calculate_totals(
|
||||
items_raw,
|
||||
float(merged.get("global_discount_percent", 0)),
|
||||
float(merged.get("global_vat_percent", 24)),
|
||||
float(merged.get("shipping_cost", 0)),
|
||||
float(merged.get("shipping_cost_discount", 0)),
|
||||
float(merged.get("install_cost", 0)),
|
||||
|
||||
@@ -305,6 +305,33 @@ async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def get_latest_comm_batch(customer_ids: list[str]) -> dict[str, dict]:
|
||||
"""Return a dict of customer_id → {id, type, occurred_at} for the latest comm per customer.
|
||||
Uses a single SQL query — no N+1 regardless of list size.
|
||||
"""
|
||||
if not customer_ids:
|
||||
return {}
|
||||
db = await mqtt_db.get_db()
|
||||
placeholders = ",".join("?" * len(customer_ids))
|
||||
rows = await db.execute_fetchall(
|
||||
f"""
|
||||
SELECT customer_id, id, type, COALESCE(occurred_at, created_at) AS ts
|
||||
FROM crm_comms_log
|
||||
WHERE customer_id IN ({placeholders})
|
||||
AND customer_id IS NOT NULL AND customer_id != ''
|
||||
ORDER BY ts DESC
|
||||
""",
|
||||
customer_ids,
|
||||
)
|
||||
# Keep only the first (latest) row per customer
|
||||
result: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
cid = row[0]
|
||||
if cid not in result:
|
||||
result[cid] = {"id": row[1], "type": row[2], "occurred_at": row[3]}
|
||||
return result
|
||||
|
||||
|
||||
async def list_customers_sorted_by_latest_comm(customers: list[CustomerInDB]) -> list[CustomerInDB]:
|
||||
"""Re-sort a list of customers so those with the most recent comm come first."""
|
||||
timestamps = await asyncio.gather(
|
||||
|
||||
Reference in New Issue
Block a user