v1.9.0: Phase 0 — инфраструктура CRM/лидов, миграции БД, расширение моделей
This commit is contained in:
@@ -153,8 +153,8 @@ async def tech_access_page(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
if user["role"] != "engineer":
|
||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
||||
if user["role"] not in ("engineer", "owner"):
|
||||
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||
|
||||
perms = discover_docs()
|
||||
editable_docs = [
|
||||
@@ -170,8 +170,8 @@ async def tech_access_update(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
if user["role"] != "engineer":
|
||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
||||
if user["role"] not in ("engineer", "owner"):
|
||||
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||
|
||||
form = await request.form()
|
||||
perms = discover_docs()
|
||||
|
||||
@@ -113,8 +113,20 @@ async def seed_initial_data():
|
||||
async def lifespan(app: FastAPI):
|
||||
from app.models.models import Base
|
||||
from app.database import engine
|
||||
|
||||
# Применяем миграции (ALTER TABLE, CREATE TABLE IF NOT EXISTS)
|
||||
from app.migrations_runner import run_migrations_on_startup
|
||||
migration_results = await run_migrations_on_startup()
|
||||
if migration_results:
|
||||
import logging
|
||||
logging.getLogger("migrations").info(
|
||||
"Применены миграции: %s", ", ".join(migration_results)
|
||||
)
|
||||
|
||||
# Создаём таблицы которые ещё не существуют (idempotent)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
await seed_initial_data()
|
||||
async with async_session() as db:
|
||||
from app.calculations.engine import reload_coefficients
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Миграционный раннер для py_service.
|
||||
|
||||
Автоматически применяет SQL-файлы из папки migrations/ при старте приложения.
|
||||
Отслеживает применённые миграции в таблице _applied_migrations.
|
||||
|
||||
Порядок:
|
||||
1. Создаёт таблицу _applied_migrations (если нет)
|
||||
2. Сканирует migrations/*.sql
|
||||
3. Применяет неприменённые по порядку имени файла
|
||||
4. Записывает имя файла и время в _applied_migrations
|
||||
|
||||
Формат SQL-файлов:
|
||||
- Имя: 001_description.sql, 002_description.sql и т.д.
|
||||
- Содержимое: один или несколько SQL-запросов, разделённых точкой с запятой
|
||||
- Каждый запрос разделяется через ; (точка с запятой на отдельной строке или в конце)
|
||||
"""
|
||||
import os
|
||||
import glob
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
MIGRATIONS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "migrations")
|
||||
|
||||
|
||||
async def ensure_tracking_table(db: AsyncSession) -> None:
|
||||
"""Создаёт таблицу _applied_migrations если она ещё не существует."""
|
||||
await db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS _applied_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_applied_migrations(db: AsyncSession) -> set:
|
||||
"""Возвращает множество имён уже применённых миграций."""
|
||||
result = await db.execute(text("SELECT filename FROM _applied_migrations"))
|
||||
return {row[0] for row in result.fetchall()}
|
||||
|
||||
|
||||
def _split_sql(content: str) -> list:
|
||||
"""Разделяет SQL-содержимое на отдельные запросы.
|
||||
|
||||
Использует точку с запятой как разделитель.
|
||||
Пустые запросы и комментарии пропускаются.
|
||||
"""
|
||||
queries = []
|
||||
for part in content.split(";"):
|
||||
# Убираем SQL-комментарии (строки начинающиеся с --)
|
||||
lines = []
|
||||
for line in part.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--"):
|
||||
continue
|
||||
lines.append(line)
|
||||
cleaned = "\n".join(lines).strip()
|
||||
if cleaned:
|
||||
queries.append(cleaned)
|
||||
return queries
|
||||
|
||||
|
||||
async def apply_migrations(db: AsyncSession) -> list:
|
||||
"""Применяет все неприменённые миграции из папки migrations/.
|
||||
|
||||
Возвращает список имён применённых миграций.
|
||||
"""
|
||||
await ensure_tracking_table(db)
|
||||
|
||||
# Получаем уже применённые
|
||||
applied = await get_applied_migrations(db)
|
||||
|
||||
# Сканируем SQL-файлы
|
||||
pattern = os.path.join(MIGRATIONS_DIR, "*.sql")
|
||||
files = sorted(glob.glob(pattern))
|
||||
|
||||
applied_now = []
|
||||
for filepath in files:
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
# Пропускаем уже применённые
|
||||
if filename in applied:
|
||||
continue
|
||||
|
||||
# Читаем SQL
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Разделяем на запросы
|
||||
queries = _split_sql(content)
|
||||
if not queries:
|
||||
continue
|
||||
|
||||
# Применяем каждый запрос
|
||||
try:
|
||||
for query in queries:
|
||||
await db.execute(text(query))
|
||||
|
||||
# Записываем в tracking table
|
||||
await db.execute(
|
||||
text("INSERT INTO _applied_migrations (filename, applied_at) VALUES (:fn, NOW())"),
|
||||
{"fn": filename}
|
||||
)
|
||||
await db.commit()
|
||||
applied_now.append(filename)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise RuntimeError(f"Ошибка при применении миграции {filename}: {e}")
|
||||
|
||||
return applied_now
|
||||
|
||||
|
||||
async def run_migrations_on_startup() -> list:
|
||||
"""Запуск миграций при старте приложения.
|
||||
|
||||
Используется из lifespan в main.py.
|
||||
Возвращает список применённых миграций.
|
||||
"""
|
||||
from app.database import async_session
|
||||
|
||||
async with async_session() as db:
|
||||
return await apply_migrations(db)
|
||||
@@ -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