Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script. Co-authored-by: Cursor <cursoragent@cursor.com>
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Phase 6.1 supporting CRM entities — lookups, tags, addresses, notes, attachments, audit."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from sqlalchemy.types import JSON
|
|
|
|
from app.core.database import Base
|
|
from app.models.base import (
|
|
ActorAuditMixin,
|
|
SoftDeleteMixin,
|
|
TenantMixin,
|
|
TimestampMixin,
|
|
UUIDPrimaryKeyMixin,
|
|
)
|
|
from app.models.types import (
|
|
AddressType,
|
|
AttachmentKind,
|
|
AuditAction,
|
|
CrmEntityType,
|
|
CustomFieldType,
|
|
GUID,
|
|
TagScope,
|
|
)
|
|
|
|
|
|
class LeadSource(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "lead_sources"
|
|
|
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "code", name="uq_lead_sources_tenant_code"),
|
|
)
|
|
|
|
|
|
class LeadStatusDefinition(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "lead_statuses"
|
|
|
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
|
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
is_converted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "code", name="uq_lead_statuses_tenant_code"),
|
|
)
|
|
|
|
|
|
class ContactType(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "contact_types"
|
|
|
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "code", name="uq_contact_types_tenant_code"),
|
|
)
|
|
|
|
|
|
class OrganizationType(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "organization_types"
|
|
|
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id", "code", name="uq_organization_types_tenant_code"
|
|
),
|
|
)
|
|
|
|
|
|
class OrganizationIndustry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "organization_industries"
|
|
|
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id", "code", name="uq_organization_industries_tenant_code"
|
|
),
|
|
)
|
|
|
|
|
|
class LeadAssignment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "lead_assignments"
|
|
|
|
lead_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
assigned_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
assigned_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
assigned_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False
|
|
)
|
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
is_current: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
Index("ix_lead_assignments_lead", "tenant_id", "lead_id"),
|
|
Index("ix_lead_assignments_user", "tenant_id", "assigned_user_id"),
|
|
)
|
|
|
|
|
|
class CrmTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "crm_tags"
|
|
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
group_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
scope: Mapped[TagScope] = mapped_column(default=TagScope.ANY, nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "name", name="uq_crm_tags_tenant_name"),
|
|
)
|
|
|
|
|
|
class LeadTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "lead_tags"
|
|
|
|
lead_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
tag_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "lead_id", "tag_id", name="uq_lead_tags"),
|
|
Index("ix_lead_tags_lead", "tenant_id", "lead_id"),
|
|
)
|
|
|
|
|
|
class ContactTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "contact_tags"
|
|
|
|
contact_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
tag_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("tenant_id", "contact_id", "tag_id", name="uq_contact_tags"),
|
|
Index("ix_contact_tags_contact", "tenant_id", "contact_id"),
|
|
)
|
|
|
|
|
|
class OrganizationTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "organization_tags"
|
|
|
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
tag_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id", "organization_id", "tag_id", name="uq_organization_tags"
|
|
),
|
|
Index("ix_organization_tags_org", "tenant_id", "organization_id"),
|
|
)
|
|
|
|
|
|
class CrmCustomField(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "crm_custom_fields"
|
|
|
|
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
|
|
field_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
label: Mapped[str] = mapped_column(String(150), nullable=False)
|
|
field_type: Mapped[CustomFieldType] = mapped_column(nullable=False)
|
|
is_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
options: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
|
validation_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"entity_type",
|
|
"field_key",
|
|
name="uq_crm_custom_fields_key",
|
|
),
|
|
Index("ix_crm_custom_fields_entity", "tenant_id", "entity_type"),
|
|
)
|
|
|
|
|
|
class CrmCustomFieldValue(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "crm_custom_field_values"
|
|
|
|
custom_field_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
|
|
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
value_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
value_number: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
|
value_boolean: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
value_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
value_json: Mapped[list | dict | None] = mapped_column(JSON, nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id",
|
|
"custom_field_id",
|
|
"entity_id",
|
|
name="uq_crm_custom_field_values",
|
|
),
|
|
Index(
|
|
"ix_crm_custom_field_values_entity",
|
|
"tenant_id",
|
|
"entity_type",
|
|
"entity_id",
|
|
),
|
|
)
|
|
|
|
|
|
class CrmAddress(
|
|
Base,
|
|
UUIDPrimaryKeyMixin,
|
|
TenantMixin,
|
|
TimestampMixin,
|
|
SoftDeleteMixin,
|
|
ActorAuditMixin,
|
|
):
|
|
__tablename__ = "crm_addresses"
|
|
|
|
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
|
|
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
address_type: Mapped[AddressType] = mapped_column(nullable=False)
|
|
label: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
line1: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
postal_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
|
country: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
latitude: Mapped[Decimal | None] = mapped_column(Numeric(10, 7), nullable=True)
|
|
longitude: Mapped[Decimal | None] = mapped_column(Numeric(10, 7), nullable=True)
|
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_crm_addresses_entity",
|
|
"tenant_id",
|
|
"entity_type",
|
|
"entity_id",
|
|
),
|
|
)
|
|
|
|
|
|
class CrmNote(
|
|
Base,
|
|
UUIDPrimaryKeyMixin,
|
|
TenantMixin,
|
|
TimestampMixin,
|
|
SoftDeleteMixin,
|
|
ActorAuditMixin,
|
|
):
|
|
__tablename__ = "crm_notes"
|
|
|
|
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
|
|
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
body_format: Mapped[str] = mapped_column(String(20), default="richtext", nullable=False)
|
|
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
|
is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_crm_notes_entity",
|
|
"tenant_id",
|
|
"entity_type",
|
|
"entity_id",
|
|
),
|
|
)
|
|
|
|
|
|
class CrmNoteVersion(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "crm_note_versions"
|
|
|
|
note_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"tenant_id", "note_id", "version", name="uq_crm_note_versions"
|
|
),
|
|
Index("ix_crm_note_versions_note", "tenant_id", "note_id"),
|
|
)
|
|
|
|
|
|
class CrmAttachment(
|
|
Base,
|
|
UUIDPrimaryKeyMixin,
|
|
TenantMixin,
|
|
TimestampMixin,
|
|
SoftDeleteMixin,
|
|
ActorAuditMixin,
|
|
):
|
|
"""Reference to File Storage service — no binary bytes stored in CRM."""
|
|
|
|
__tablename__ = "crm_attachments"
|
|
|
|
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
|
|
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
file_storage_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
content_type: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
|
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
kind: Mapped[AttachmentKind] = mapped_column(
|
|
default=AttachmentKind.DOCUMENT, nullable=False
|
|
)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_crm_attachments_entity",
|
|
"tenant_id",
|
|
"entity_type",
|
|
"entity_id",
|
|
),
|
|
Index("ix_crm_attachments_file", "tenant_id", "file_storage_id"),
|
|
)
|
|
|
|
|
|
class CrmAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
|
__tablename__ = "crm_audit"
|
|
|
|
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
action: Mapped[AuditAction] = mapped_column(nullable=False)
|
|
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_crm_audit_entity",
|
|
"tenant_id",
|
|
"entity_type",
|
|
"entity_id",
|
|
),
|
|
Index("ix_crm_audit_action", "tenant_id", "action"),
|
|
)
|