2026-07-19 21:12:49 +02:00
|
|
|
"""Company service — now operates on Contact with type='company'.
|
|
|
|
|
|
|
|
|
|
Backward-compat layer: all company operations are now contact operations
|
|
|
|
|
filtered by type='company'. CompanyContact is replaced by ContactPerson 1:N.
|
|
|
|
|
"""
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
from sqlalchemy import select, func, delete
|
2026-06-29 00:44:34 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-07-19 21:12:49 +02:00
|
|
|
from sqlalchemy.orm import selectinload
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
from app.models.contact import Contact, ContactPerson
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
def _company_to_dict(c: Contact) -> dict:
|
|
|
|
|
"""Serialize a company (Contact with type=company) to dict."""
|
|
|
|
|
return {
|
2026-06-29 00:44:34 +02:00
|
|
|
"id": str(c.id),
|
2026-07-19 21:12:49 +02:00
|
|
|
"name": c.name or "",
|
|
|
|
|
"account_number": c.code or "",
|
|
|
|
|
"industry": (c.custom or {}).get("industry") if c.custom else None,
|
|
|
|
|
"phone": c.phone_1,
|
|
|
|
|
"email": c.email_1,
|
2026-06-29 00:44:34 +02:00
|
|
|
"website": c.website,
|
2026-07-19 21:12:49 +02:00
|
|
|
"description": (c.custom or {}).get("description") if c.custom else None,
|
2026-06-29 00:44:34 +02:00
|
|
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
|
|
|
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
|
|
|
|
}
|
2026-07-15 23:02:30 +02:00
|
|
|
|
|
|
|
|
|
2026-06-29 00:44:34 +02:00
|
|
|
async def list_companies(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
page: int = 1,
|
|
|
|
|
page_size: int = 20,
|
|
|
|
|
search: str | None = None,
|
|
|
|
|
industry: str | None = None,
|
|
|
|
|
sort_by: str = "name",
|
|
|
|
|
sort_order: str = "asc",
|
2026-07-15 23:02:30 +02:00
|
|
|
resolved_perms: dict | None = None,
|
2026-07-19 21:12:49 +02:00
|
|
|
) -> dict:
|
|
|
|
|
"""List companies (contacts with type='company')."""
|
|
|
|
|
base = select(Contact).where(
|
|
|
|
|
Contact.tenant_id == tenant_id,
|
|
|
|
|
Contact.type == "company",
|
|
|
|
|
Contact.deleted_at.is_(None),
|
2026-06-29 00:44:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if search:
|
2026-07-19 21:12:49 +02:00
|
|
|
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
count_q = select(func.count()).select_from(base.subquery())
|
2026-07-19 21:12:49 +02:00
|
|
|
total = (await db.execute(count_q)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
sort_col = getattr(Contact, sort_by if sort_by != "name" else "name", Contact.name)
|
|
|
|
|
if sort_order == "desc":
|
|
|
|
|
sort_col = sort_col.desc()
|
|
|
|
|
base = base.order_by(sort_col)
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
offset = (page - 1) * page_size
|
2026-07-19 21:12:49 +02:00
|
|
|
base = base.offset(offset).limit(page_size)
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
result = await db.execute(base)
|
|
|
|
|
companies = result.scalars().all()
|
2026-07-15 23:02:30 +02:00
|
|
|
|
2026-06-29 00:44:34 +02:00
|
|
|
return {
|
2026-07-19 21:12:49 +02:00
|
|
|
"items": [_company_to_dict(c) for c in companies],
|
2026-06-29 00:44:34 +02:00
|
|
|
"total": total,
|
|
|
|
|
"page": page,
|
|
|
|
|
"page_size": page_size,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
async def create_company(
|
|
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Create a company (contact with type='company')."""
|
|
|
|
|
# Map old fields to new
|
|
|
|
|
contact_data = {
|
|
|
|
|
"type": "company",
|
|
|
|
|
"name": data.get("name", ""),
|
|
|
|
|
"code": data.get("account_number"),
|
|
|
|
|
"phone_1": data.get("phone"),
|
|
|
|
|
"email_1": data.get("email"),
|
|
|
|
|
"website": data.get("website"),
|
|
|
|
|
"displayname": data.get("name", ""),
|
|
|
|
|
}
|
|
|
|
|
# Store industry/description in custom
|
|
|
|
|
custom = {}
|
|
|
|
|
if data.get("industry"):
|
|
|
|
|
custom["industry"] = data["industry"]
|
|
|
|
|
if data.get("description"):
|
|
|
|
|
custom["description"] = data["description"]
|
|
|
|
|
if custom:
|
|
|
|
|
contact_data["custom"] = custom
|
|
|
|
|
|
|
|
|
|
contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data)
|
|
|
|
|
db.add(contact)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return _company_to_dict(contact)
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:44:34 +02:00
|
|
|
async def get_company_detail(
|
2026-07-19 21:12:49 +02:00
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, resolved_perms: dict | None = None
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Get company detail with contact persons."""
|
|
|
|
|
q = (
|
|
|
|
|
select(Contact)
|
|
|
|
|
.options(selectinload(Contact.contact_persons))
|
2026-06-29 00:44:34 +02:00
|
|
|
.where(
|
2026-07-19 21:12:49 +02:00
|
|
|
Contact.id == uuid.UUID(company_id),
|
|
|
|
|
Contact.tenant_id == tenant_id,
|
|
|
|
|
Contact.type == "company",
|
2026-06-29 00:44:34 +02:00
|
|
|
Contact.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-07-19 21:12:49 +02:00
|
|
|
result = await db.execute(q)
|
|
|
|
|
contact = result.scalar_one_or_none()
|
|
|
|
|
if not contact:
|
|
|
|
|
raise ValueError("Company not found")
|
|
|
|
|
|
|
|
|
|
data = _company_to_dict(contact)
|
|
|
|
|
data["contacts"] = [
|
|
|
|
|
{
|
|
|
|
|
"id": str(cp.id),
|
|
|
|
|
"first_name": cp.firstname or "",
|
|
|
|
|
"last_name": cp.lastname or "",
|
|
|
|
|
"email": cp.email,
|
|
|
|
|
"phone": cp.phone,
|
|
|
|
|
"position": cp.function,
|
|
|
|
|
}
|
|
|
|
|
for cp in (contact.contact_persons or [])
|
|
|
|
|
if cp.deleted_at is None
|
|
|
|
|
]
|
2026-06-29 00:44:34 +02:00
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_company(
|
2026-07-19 21:12:49 +02:00
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Update a company."""
|
|
|
|
|
q = select(Contact).where(
|
|
|
|
|
Contact.id == uuid.UUID(company_id),
|
|
|
|
|
Contact.tenant_id == tenant_id,
|
|
|
|
|
Contact.type == "company",
|
|
|
|
|
Contact.deleted_at.is_(None),
|
2026-06-29 00:44:34 +02:00
|
|
|
)
|
|
|
|
|
result = await db.execute(q)
|
2026-07-19 21:12:49 +02:00
|
|
|
contact = result.scalar_one_or_none()
|
|
|
|
|
if not contact:
|
|
|
|
|
raise ValueError("Company not found")
|
|
|
|
|
|
|
|
|
|
if "name" in data:
|
|
|
|
|
contact.name = data["name"]
|
|
|
|
|
contact.displayname = data["name"]
|
|
|
|
|
if "account_number" in data:
|
|
|
|
|
contact.code = data["account_number"]
|
|
|
|
|
if "phone" in data:
|
|
|
|
|
contact.phone_1 = data["phone"]
|
|
|
|
|
if "email" in data:
|
|
|
|
|
contact.email_1 = data["email"]
|
|
|
|
|
if "website" in data:
|
|
|
|
|
contact.website = data["website"]
|
|
|
|
|
|
|
|
|
|
# Store industry/description in custom
|
|
|
|
|
custom = dict(contact.custom or {})
|
|
|
|
|
if "industry" in data:
|
|
|
|
|
custom["industry"] = data["industry"]
|
|
|
|
|
if "description" in data:
|
|
|
|
|
custom["description"] = data["description"]
|
|
|
|
|
if custom:
|
|
|
|
|
contact.custom = custom
|
|
|
|
|
|
|
|
|
|
contact.updated_by = user_id
|
2026-06-29 00:44:34 +02:00
|
|
|
await db.flush()
|
2026-07-19 21:12:49 +02:00
|
|
|
return _company_to_dict(contact)
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def soft_delete_company(
|
2026-07-19 21:12:49 +02:00
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True
|
2026-06-29 00:44:34 +02:00
|
|
|
) -> bool:
|
2026-07-19 21:12:49 +02:00
|
|
|
"""Soft-delete a company."""
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
q = select(Contact).where(
|
|
|
|
|
Contact.id == uuid.UUID(company_id),
|
2026-06-29 00:44:34 +02:00
|
|
|
Contact.tenant_id == tenant_id,
|
2026-07-19 21:12:49 +02:00
|
|
|
Contact.type == "company",
|
2026-06-29 00:44:34 +02:00
|
|
|
Contact.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
result = await db.execute(q)
|
2026-07-19 21:12:49 +02:00
|
|
|
contact = result.scalar_one_or_none()
|
|
|
|
|
if not contact:
|
2026-06-29 00:44:34 +02:00
|
|
|
return False
|
2026-07-19 21:12:49 +02:00
|
|
|
contact.deleted_at = datetime.now(timezone.utc)
|
2026-06-29 00:44:34 +02:00
|
|
|
await db.flush()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def export_companies_xlsx(
|
2026-07-19 21:12:49 +02:00
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None
|
2026-06-29 00:44:34 +02:00
|
|
|
) -> bytes:
|
2026-07-19 21:12:49 +02:00
|
|
|
"""Export companies as XLSX."""
|
2026-06-29 00:44:34 +02:00
|
|
|
from openpyxl import Workbook
|
|
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
base = select(Contact).where(
|
|
|
|
|
Contact.tenant_id == tenant_id,
|
|
|
|
|
Contact.type == "company",
|
|
|
|
|
Contact.deleted_at.is_(None),
|
2026-06-29 00:44:34 +02:00
|
|
|
)
|
|
|
|
|
if search:
|
2026-07-19 21:12:49 +02:00
|
|
|
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
|
|
|
|
base = base.order_by(Contact.name)
|
|
|
|
|
|
2026-06-29 00:44:34 +02:00
|
|
|
result = await db.execute(base)
|
|
|
|
|
companies = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
wb = Workbook()
|
|
|
|
|
ws = wb.active
|
|
|
|
|
ws.title = "Companies"
|
2026-07-19 21:12:49 +02:00
|
|
|
ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"])
|
2026-06-29 00:44:34 +02:00
|
|
|
for c in companies:
|
2026-07-19 21:12:49 +02:00
|
|
|
custom = c.custom or {}
|
|
|
|
|
ws.append([
|
|
|
|
|
c.name or "", c.code or "", custom.get("industry", ""),
|
|
|
|
|
c.phone_1 or "", c.email_1 or "", c.website or "",
|
|
|
|
|
])
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
import io
|
2026-06-29 00:44:34 +02:00
|
|
|
buf = io.BytesIO()
|
|
|
|
|
wb.save(buf)
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 21:12:49 +02:00
|
|
|
async def link_contact(
|
|
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Link a contact person to a company (create ContactPerson)."""
|
|
|
|
|
# In new model, contact_id is used to create a ContactPerson under the company contact
|
|
|
|
|
# This is a compat shim — the old API expected a separate Contact entity
|
|
|
|
|
# Now we just create a ContactPerson linked to the company
|
|
|
|
|
cp = ContactPerson(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
contact_id=uuid.UUID(company_id),
|
|
|
|
|
created_by=user_id,
|
|
|
|
|
updated_by=user_id,
|
|
|
|
|
)
|
|
|
|
|
db.add(cp)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return {"id": str(cp.id), "company_id": company_id, "contact_id": contact_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def unlink_contact(
|
|
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Unlink a contact person from a company (soft-delete ContactPerson)."""
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
q = select(ContactPerson).where(
|
|
|
|
|
ContactPerson.contact_id == uuid.UUID(company_id),
|
|
|
|
|
ContactPerson.id == uuid.UUID(contact_id),
|
|
|
|
|
ContactPerson.tenant_id == tenant_id,
|
|
|
|
|
ContactPerson.deleted_at.is_(None),
|
2026-06-29 00:44:34 +02:00
|
|
|
)
|
|
|
|
|
result = await db.execute(q)
|
2026-07-19 21:12:49 +02:00
|
|
|
cp = result.scalar_one_or_none()
|
|
|
|
|
if not cp:
|
|
|
|
|
return False
|
|
|
|
|
cp.deleted_at = datetime.now(timezone.utc)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return True
|