v1.9.0: Phase 0 — инфраструктура CRM/лидов, миграции БД, расширение моделей

This commit is contained in:
2026-06-04 14:38:43 +03:00
parent e29e2984da
commit f06ecaabe3
15 changed files with 3315 additions and 10 deletions
+233 -2
View File
@@ -5,7 +5,11 @@ ORM-модели для service portal (SQLAlchemy 2.0).
- users: сотрудники (owner/engineer/technician)
- customers: клиенты
- objects: объекты обслуживания
- tasks/ incidents: задачи и инциденты
- tasks / incidents: задачи и инциденты
- leads: лиды (обращения из бота, звонков, сайта, email)
- call_records: звонки (CDR из Yeastar S20)
- client_notifications: уведомления
- pbx_settings: настройки АТС
- blog/cases/ideas: контент
"""
from datetime import datetime
@@ -28,6 +32,7 @@ class User(Base):
phone = Column(String(50), default="")
email = Column(String(255), default="")
max_user_id = Column(BigInteger, nullable=True)
extension = Column(String(10), default="") # Внутренний номер АТС (1001, 1002...)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
@@ -51,17 +56,33 @@ class LoginAttempt(Base):
class AuditLog(Base):
"""Лог действий пользователей (аудит).
Записывает все изменения в системе: кто, что, когда изменил.
Используется для отслеживания дедупликации и ручных правок.
"""
__tablename__ = "audit_log"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
action = Column(String(255), nullable=False)
entity_type = Column(String(50), default="") # customer, lead, call_record, task
entity_id = Column(Integer, default=0)
field = Column(String(100), default="") # какое поле изменено
old_value = Column(Text, default="") # старое значение
new_value = Column(Text, default="") # новое значение
details = Column(Text)
ip_address = Column(String(45), default="")
created_at = Column(DateTime, nullable=False, server_default="NOW()")
class Customer(Base):
"""Клиент (контрагент).
Хранит контактные данные, ИНН/КПП, статус.
Статус 'draft' — автосоздан системой из лида/звонка, ожидает заполнения.
Статус 'merged' — объединён с другим клиентом.
"""
__tablename__ = "customers"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -74,12 +95,20 @@ class Customer(Base):
contact_email = Column(String(255), default="")
status = Column(String(20), nullable=False, default="active")
notes = Column(Text)
lead_source = Column(String(20), default="") # Откуда пришёл клиент
last_call_at = Column(DateTime) # Последний звонок
total_calls = Column(Integer, default=0) # Количество звонков
total_leads = Column(Integer, default=0) # Количество лидов
merged_into = Column(Integer, nullable=True) # ID клиента, с которым слили
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"),
CheckConstraint(
"status IN ('active','inactive','prospect','draft','merged')",
name="ck_customers_status"
),
)
objects = relationship("Object", back_populates="customer")
@@ -144,12 +173,19 @@ class ObjectAssignment(Base):
class Task(Base):
"""Задача (привязана к объекту).
Owner/engineer создают и назначают на любого.
Technician может создать только себе напоминание.
lead_id — связь задачи с лидом (если задача создана из лида).
"""
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) # Связь с лидом
title = Column(String(500), nullable=False)
description = Column(Text)
priority = Column(String(4), nullable=False, default="P3")
@@ -521,4 +557,199 @@ class RoleMenuPermission(Base):
)
# ─── Модели для лидов, звонков, уведомлений, настроек АТС ──────────────────────
class Lead(Base):
"""Лид — обращение из бота, звонка, сайта или email.
Источники: bot, phone_in, phone_out, site, email, manual.
Автопривязка к клиенту: ИНН → телефон → email → создание draft.
"""
__tablename__ = "leads"
id = Column(Integer, primary_key=True, autoincrement=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
# Источник обращения
source = Column(String(20), nullable=False, default="manual")
# bot | phone_in | phone_out | site | email | manual
# Данные клиента
company_name = Column(String(255), default="")
contact_name = Column(String(255), default="")
contact_phone = Column(String(50), default="")
contact_email = Column(String(255), default="")
inn = Column(String(12), default="")
# Содержание обращения
title = Column(String(500), default="")
service_type = Column(String(255), default="")
description = Column(Text, default="")
urgency = Column(String(10), default="medium")
# critical | high | medium | low
# Статус
status = Column(String(30), default="new")
# new | contact | qualification | proposal | negotiation | approved | won | lost | merged
# Привязка
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
bot_ticket_id = Column(BigInteger) # ID заявки из бота
# Воронка
deal_amount = Column(Numeric(12, 2), default=0)
expected_close_date = Column(Date)
# Мета
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint(
"status IN ('new','contact','qualification','proposal','negotiation','approved','won','lost','merged')",
name="ck_leads_status"
),
CheckConstraint(
"urgency IN ('critical','high','medium','low')",
name="ck_leads_urgency"
),
CheckConstraint(
"source IN ('bot','phone_in','phone_out','site','email','manual')",
name="ck_leads_source"
),
)
customer = relationship("Customer", foreign_keys=[customer_id])
assignee = relationship("User", foreign_keys=[assigned_to])
creator = relationship("User", foreign_keys=[created_by])
class CallRecord(Base):
"""Звонок (CDR из Yeastar S20 через MySQL).
Синхронизируется каждые 5 минут из таблицы cdr.
Автопривязка к клиенту по номеру телефона.
Автопривязка к сотруднику по внутреннему номеру (extension).
"""
__tablename__ = "call_records"
id = Column(Integer, primary_key=True, autoincrement=True)
# Данные из CDR
cdr_uniqueid = Column(String(32), unique=True) # Уникальный ID из CDR
call_datetime = Column(DateTime, nullable=False)
caller_number = Column(String(80), default="") # Номер звонящего (src)
callee_number = Column(String(80), default="") # Номер получателя (dst)
trunk = Column(String(80), default="") # Транк (Mango, Tele2)
duration = Column(Integer, default=0) # Общая длительность (сек)
billable_seconds = Column(Integer, default=0) # Биллируемая (сек)
disposition = Column(String(30), default="") # ANSWERED, NO ANSWER, BUSY, VOICEMAIL
call_type = Column(String(20), default="") # Inbound, Outbound, Internal
queue_name = Column(String(30), default="") # Очередь (6700, 6701, 6702)
# Привязка к клиенту
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL"))
# Инженер/техник кто принял звонок
answered_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
answered_by_name = Column(String(100), default="")
# Запись
has_recording = Column(Boolean, default=False)
recording_path = Column(String(500), default="") # Путь на PBX
recording_filename = Column(String(200), default="") # Имя WAV-файла
# Уведомления
notification_sent = Column(Boolean, default=False)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
customer = relationship("Customer", foreign_keys=[customer_id])
lead = relationship("Lead", foreign_keys=[lead_id])
employee = relationship("User", foreign_keys=[answered_by])
class ClientNotification(Base):
"""Уведомление для сотрудника.
Типы: пропущенный звонок, новый лид, изменение статуса лида,
нарушение SLA, просроченная задача.
"""
__tablename__ = "client_notifications"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL"))
call_record_id = Column(Integer, ForeignKey("call_records.id", ondelete="SET NULL"))
type = Column(String(30), nullable=False)
# missed_call | new_lead | lead_status_change | sla_breach | task_overdue
title = Column(String(500), nullable=False)
message = Column(Text, default="")
is_read = Column(Boolean, default=False)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
user = relationship("User", foreign_keys=[user_id])
customer = relationship("Customer", foreign_keys=[customer_id])
lead = relationship("Lead", foreign_keys=[lead_id])
call_record = relationship("CallRecord", foreign_keys=[call_record_id])
class PbxSettings(Base):
"""Настройки подключения к АТС (Yeastar S20).
Всегда одна запись (id=1). Пароли шифруются через cryptography.fernet.
"""
__tablename__ = "pbx_settings"
id = Column(Integer, primary_key=True, default=1)
# Подключение MySQL (CDR)
mysql_host = Column(String(255), default="192.168.1.150")
mysql_port = Column(Integer, default=3306)
mysql_user = Column(String(100), default="")
mysql_password = Column(String(255), default="") # зашифровано
mysql_database = Column(String(100), default="cdr")
# AMI
ami_host = Column(String(255), default="192.168.1.150")
ami_port = Column(Integer, default=5038)
ami_user = Column(String(100), default="")
ami_password = Column(String(255), default="") # зашифровано
# FTP (записи WAV)
ftp_host = Column(String(255), default="192.168.1.150")
ftp_port = Column(Integer, default=21)
ftp_user = Column(String(100), default="")
ftp_password = Column(String(255), default="") # зашифровано
# Веб-интерфейс
web_host = Column(String(255), default="192.168.1.150")
web_port = Column(Integer, default=8088)
web_user = Column(String(100), default="")
web_password = Column(String(255), default="") # зашифровано
# Информация
serial_number = Column(String(50), default="") # Ручной ввод
actual_serial = Column(String(50), default="") # Из FTP
# Статус подключения
mysql_status = Column(String(20), default="unknown") # ok | error | unknown
ami_status = Column(String(20), default="unknown")
ftp_status = Column(String(20), default="unknown")
serial_match = Column(Boolean, default=False)
# Синхронизация
last_sync_at = Column(DateTime)
sync_interval = Column(Integer, default=5) # минут
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())