v1.9.0: Phase 0 — инфраструктура CRM/лидов, миграции БД, расширение моделей
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user