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
+20
View File
@@ -1,5 +1,25 @@
# Changelog — AegisOne Service Portal
## 1.9.0 (04.06.2026)
### Новые функции
- **Phase 0 — Инфраструктура CRM/лидов:**
- Новые таблицы: `leads` (20 полей), `call_records` (20 полей CDR), `client_notifications` (10 полей), `pbx_settings` (27 полей)
- Расширены модели: `User` (extension — внутренний номер PBX), `Customer` (lead_source, last_call_at, total_calls, total_leads, merged_into, статусы draft/merged), `Task` (lead_id — FK → leads), `AuditLog` (entity_type, entity_id, field, old_value, new_value)
- Миграции БД: система `migrations_runner.py` с авто-применением при старте, отслеживание в `_applied_migrations`
- SQL-миграция `001_phase0_infrastructure.sql`: 31 операция (CREATE TABLE → ALTER TABLE в правильном порядке)
- Шифрование паролей PBX через `cryptography.fernet` (PBXSettings)
### Исправления
- **documents_router.py:** доступ «Владелец/Инженер» для tech-access — `!= "engineer"``not in ("engineer", "owner")`
### Документация
- **AGENTS.md:** раздел 0.4 — обязательные файлы для чтения; раздел 1 — ссылка на архитектуру сервера
- **SERVICE_STYLE_GUIDE.md:** единый гайд UI
- **other/Yeastar_S20.md:** полная документация по PBX (AMI, MySQL CDR, FTP, SSH)
- **other/LEAD_IMPLEMENTATION_PLAN.md:** 8-фазный план (0-7) реализации CRM/лидов
- **other/1C_UNF_INTEGRATION.md:** документация по интеграции с 1С:УНФ 3.0 через OData API
- **other/voronka.md:** документ жизненного цикла лидов
## 1.8.6 (02.06.2026)
### Новые функции
- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
+4 -4
View File
@@ -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()
+12
View File
@@ -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
+128
View File
@@ -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)
+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())
@@ -0,0 +1,144 @@
-- Миграция 001: Инфраструктура для фазы лидов и звонков.
-- Дата: 04.06.2026
-- Описание: Расширение существующих таблиц + создание новых.
-- ═══════════════════════════════════════════════════════════════════════════════
-- 1. Новые таблицы (создаём ПЕРВЫМИ, чтобы FK ссылались)
-- ═══════════════════════════════════════════════════════════════════════════════
-- 1.1 Таблица лидов
CREATE TABLE IF NOT EXISTS leads (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
company_name VARCHAR(255) DEFAULT '',
contact_name VARCHAR(255) DEFAULT '',
contact_phone VARCHAR(50) DEFAULT '',
contact_email VARCHAR(255) DEFAULT '',
inn VARCHAR(12) DEFAULT '',
title VARCHAR(500) DEFAULT '',
service_type VARCHAR(255) DEFAULT '',
description TEXT DEFAULT '',
urgency VARCHAR(10) DEFAULT 'medium',
status VARCHAR(30) DEFAULT 'new',
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
bot_ticket_id BIGINT,
deal_amount NUMERIC(12, 2) DEFAULT 0,
expected_close_date DATE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_leads_customer ON leads(customer_id);
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source);
CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads(assigned_to);
CREATE INDEX IF NOT EXISTS idx_leads_created ON leads(created_at);
-- 1.2 Таблица звонков (CDR из Yeastar S20)
CREATE TABLE IF NOT EXISTS call_records (
id SERIAL PRIMARY KEY,
cdr_uniqueid VARCHAR(32) UNIQUE,
call_datetime TIMESTAMP NOT NULL,
caller_number VARCHAR(80) DEFAULT '',
callee_number VARCHAR(80) DEFAULT '',
trunk VARCHAR(80) DEFAULT '',
duration INTEGER DEFAULT 0,
billable_seconds INTEGER DEFAULT 0,
disposition VARCHAR(30) DEFAULT '',
call_type VARCHAR(20) DEFAULT '',
queue_name VARCHAR(30) DEFAULT '',
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
answered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
answered_by_name VARCHAR(100) DEFAULT '',
has_recording BOOLEAN DEFAULT FALSE,
recording_path VARCHAR(500) DEFAULT '',
recording_filename VARCHAR(200) DEFAULT '',
notification_sent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_call_records_customer ON call_records(customer_id);
CREATE INDEX IF NOT EXISTS idx_call_records_datetime ON call_records(call_datetime);
CREATE INDEX IF NOT EXISTS idx_call_records_caller ON call_records(caller_number);
CREATE INDEX IF NOT EXISTS idx_call_records_disposition ON call_records(disposition);
CREATE INDEX IF NOT EXISTS idx_call_records_uniqueid ON call_records(cdr_uniqueid);
-- 1.3 Таблица уведомлений
CREATE TABLE IF NOT EXISTS client_notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
call_record_id INTEGER REFERENCES call_records(id) ON DELETE SET NULL,
type VARCHAR(30) NOT NULL,
title VARCHAR(500) NOT NULL,
message TEXT DEFAULT '',
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON client_notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON client_notifications(is_read, created_at);
-- 1.4 Таблица настроек АТС (всегда одна запись)
CREATE TABLE IF NOT EXISTS pbx_settings (
id SERIAL PRIMARY KEY,
mysql_host VARCHAR(255) DEFAULT '192.168.1.150',
mysql_port INTEGER DEFAULT 3306,
mysql_user VARCHAR(100) DEFAULT '',
mysql_password VARCHAR(255) DEFAULT '',
mysql_database VARCHAR(100) DEFAULT 'cdr',
ami_host VARCHAR(255) DEFAULT '192.168.1.150',
ami_port INTEGER DEFAULT 5038,
ami_user VARCHAR(100) DEFAULT '',
ami_password VARCHAR(255) DEFAULT '',
ftp_host VARCHAR(255) DEFAULT '192.168.1.150',
ftp_port INTEGER DEFAULT 21,
ftp_user VARCHAR(100) DEFAULT '',
ftp_password VARCHAR(255) DEFAULT '',
web_host VARCHAR(255) DEFAULT '192.168.1.150',
web_port INTEGER DEFAULT 8088,
web_user VARCHAR(100) DEFAULT '',
web_password VARCHAR(255) DEFAULT '',
serial_number VARCHAR(50) DEFAULT '',
actual_serial VARCHAR(50) DEFAULT '',
mysql_status VARCHAR(20) DEFAULT 'unknown',
ami_status VARCHAR(20) DEFAULT 'unknown',
ftp_status VARCHAR(20) DEFAULT 'unknown',
serial_match BOOLEAN DEFAULT FALSE,
last_sync_at TIMESTAMP,
sync_interval INTEGER DEFAULT 5,
updated_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO pbx_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════════
-- 2. Изменения существующих таблиц
-- ═══════════════════════════════════════════════════════════════════════════════
-- 2.1 Users: внутренний номер АТС
ALTER TABLE users ADD COLUMN IF NOT EXISTS extension VARCHAR(10) DEFAULT '';
-- 2.2 Customers: поля CRM + расширенный статус
ALTER TABLE customers ADD COLUMN IF NOT EXISTS lead_source VARCHAR(20) DEFAULT '';
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_call_at TIMESTAMP;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_calls INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_leads INTEGER DEFAULT 0;
ALTER TABLE customers DROP CONSTRAINT IF EXISTS ck_customers_status;
ALTER TABLE customers ADD CONSTRAINT ck_customers_status
CHECK (status IN ('active','inactive','prospect','draft','merged'));
-- 2.3 Tasks: связь с лидом
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL;
-- 2.4 AuditLog: расширенное аудирование
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT '';
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_id INTEGER DEFAULT 0;
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS field VARCHAR(100) DEFAULT '';
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS old_value TEXT DEFAULT '';
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS new_value TEXT DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
View File
+1
View File
@@ -11,6 +11,7 @@ itsdangerous==2.2.0
markdown==3.7
pydantic==2.10.3
pydantic-settings==2.7.0
cryptography>=44.0.0
# Test
pytest>=9.0.0
+1 -1
View File
@@ -1 +1 @@
1.8.6
1.9.0