Add CRM order queue: live sync, view modal, receipt tabs, delete
Wires the CRM (exoCRM GetOrders) into the dashboard as a locally persisted order queue instead of the previous static mockup: - CrmClient Protocol + ExoCrmClient/StubCrmClient for the CRM's signed JSON-RPC API - Order model + migration, synced from CRM on each queue view; soft-deleted orders stay hidden across re-syncs - GET/DELETE /api/v1/orders with "no receipt"/"receipt issued" tabs (the latter is empty until Checkbox fiscalization lands) - Dashboard: real order list, item-detail modal, tab switcher, one-click delete Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""Миграция 0001 обязана точно соответствовать моделям.
|
||||
"""Миграции обязаны точно соответствовать моделям.
|
||||
|
||||
Расхождение между `Base.metadata` и миграцией обнаруживается только на живой
|
||||
Расхождение между `Base.metadata` и миграциями обнаруживается только на живой
|
||||
БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки,
|
||||
nullability и индексы, которые создаёт миграция, с тем, что описано моделями.
|
||||
nullability и индексы, которые создают миграции, с тем, что описано моделями.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,10 +14,12 @@ import pytest
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py"
|
||||
SOURCE = MIGRATION.read_text(encoding="utf-8")
|
||||
VERSIONS_DIR = Path(__file__).resolve().parents[1] / "alembic" / "versions"
|
||||
MIGRATION_FILES = sorted(VERSIONS_DIR.glob("*.py"))
|
||||
SOURCES = {path.name: path.read_text(encoding="utf-8") for path in MIGRATION_FILES}
|
||||
# Склеенный текст всех миграций — таблица может быть создана в любой из них.
|
||||
COMBINED_SOURCE = "\n".join(SOURCES.values())
|
||||
|
||||
# Миграция 0001 создаёт схему с нуля, поэтому в ней обязаны быть все таблицы.
|
||||
MODEL_TABLES = set(Base.metadata.tables)
|
||||
|
||||
|
||||
@@ -26,24 +28,27 @@ def _migration_block(table: str) -> str:
|
||||
|
||||
Границей служит следующий op.create_table; для последней таблицы — конец файла.
|
||||
"""
|
||||
start = SOURCE.index(f'op.create_table(\n "{table}",')
|
||||
next_table = SOURCE.find("op.create_table(", start + 1)
|
||||
end = next_table if next_table != -1 else len(SOURCE)
|
||||
return SOURCE[start:end]
|
||||
start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",')
|
||||
next_table = COMBINED_SOURCE.find("op.create_table(", start + 1)
|
||||
end = next_table if next_table != -1 else len(COMBINED_SOURCE)
|
||||
return COMBINED_SOURCE[start:end]
|
||||
|
||||
|
||||
def test_migration_creates_every_model_table() -> None:
|
||||
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE))
|
||||
def test_migrations_create_every_model_table() -> None:
|
||||
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', COMBINED_SOURCE))
|
||||
assert created == MODEL_TABLES, (
|
||||
f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
|
||||
f"только в миграции: {created - MODEL_TABLES}"
|
||||
f"Миграции и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
|
||||
f"только в миграциях: {created - MODEL_TABLES}"
|
||||
)
|
||||
|
||||
|
||||
def test_downgrade_drops_every_created_table() -> None:
|
||||
downgrade = SOURCE[SOURCE.index("def downgrade()") :]
|
||||
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
|
||||
assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}"
|
||||
def test_each_migration_downgrade_drops_its_own_tables() -> None:
|
||||
"""Каждая миграция откатывает ровно те таблицы, которые сама создаёт."""
|
||||
for name, source in SOURCES.items():
|
||||
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', source))
|
||||
downgrade = source[source.index("def downgrade()") :]
|
||||
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
|
||||
assert dropped == created, f"{name}: downgrade не удаляет {created - dropped}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||
@@ -77,7 +82,9 @@ def test_nullability_matches(table_name: str) -> None:
|
||||
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||
def test_indexes_match(table_name: str) -> None:
|
||||
model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes}
|
||||
migration_indexes = set(re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', SOURCE))
|
||||
migration_indexes = set(
|
||||
re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', COMBINED_SOURCE)
|
||||
)
|
||||
|
||||
missing = model_indexes - migration_indexes
|
||||
assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}"
|
||||
|
||||
Reference in New Issue
Block a user