Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+151
View File
@@ -0,0 +1,151 @@
import logging
import os
import sys
from logging.config import fileConfig
from typing import Optional
from alembic import context
from sqlalchemy import engine_from_config, event, pool
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
# Подсказка create_app: не поднимать bootstrap/фон до upgrade.
os.environ.setdefault("WESP_ALEMBIC", "1")
def _configure_alembic_logging() -> None:
"""Логи Alembic без второго console handler (иначе каждая строка ×2).
При upgrade из create_app root уже настроен — fileConfig из alembic.ini дублировал вывод.
Прямой ``alembic upgrade`` из shell (без handlers) — читаем ini как раньше.
"""
mig = logging.getLogger("alembic.runtime.migration")
if logging.getLogger().handlers:
for name in ("alembic", "alembic.runtime", "alembic.runtime.migration"):
logging.getLogger(name).setLevel(logging.INFO)
mig.propagate = True
return
ini = context.config.config_file_name
if ini:
fileConfig(ini)
mig.propagate = bool(mig.handlers)
from app import _alembic_host_app, create_app, db # noqa: E402
from config import get_config_class # noqa: E402
config = context.config
_configure_alembic_logging()
# Повторный create_app открывал бы SQLite поверх пулов Flask → database is locked.
if _alembic_host_app is not None:
app = _alembic_host_app
else:
app = create_app(get_config_class(), run_migrations=False)
target_metadata = db.metadata
def _recipes_db_url() -> str:
"""URI основной БД: из окружения (выставляет create_app при автомиграции) или из конфига."""
return os.getenv("WESP_RECIPES_DB_URI") or app.config["SQLALCHEMY_DATABASE_URI"]
def _reports_db_url() -> Optional[str]:
"""URI отчётов; None — второй проход Alembic не выполняется."""
return os.getenv("WESP_REPORTS_DB_URI") or app.config.get("SQLALCHEMY_BINDS", {}).get(
"reports"
)
def run_migrations_offline() -> None:
url = _recipes_db_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def _migration_engine(url: str):
from app.services.migration_pace import apply_sqlite_migration_pragmas
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
url=url,
)
if not str(url).lower().startswith("sqlite:"):
return engine
@event.listens_for(engine, "connect")
def _sqlite_migration_pragmas(dbapi_connection, connection_record): # noqa: ARG001
apply_sqlite_migration_pragmas(dbapi_connection)
return engine
def run_migrations_online() -> None:
from app.services.migration_pace import migration_pause, migration_pause_bind
migration_pause("перед recipes.db")
recipes_url = _recipes_db_url()
connectable = _migration_engine(recipes_url)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table="alembic_version",
compare_type=True,
)
with context.begin_transaction():
context.run_migrations() # wesp_2_0 на recipes.db (1-я строка Running upgrade)
if str(recipes_url).lower().startswith("sqlite:"):
from app.services.migration_pace import sqlite_flush_after_migration
raw = connection.connection.dbapi_connection # type: ignore[attr-defined]
if raw is not None:
sqlite_flush_after_migration(raw)
reports_url = _reports_db_url()
if not reports_url:
migration_pause("после recipes.db")
return
migration_pause_bind("recipes.db → reports.db")
reports_engine = _migration_engine(reports_url)
with reports_engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=None,
version_table="alembic_version_reports",
)
with context.begin_transaction():
context.run_migrations(tag="reports") # та же ревизия на reports.db (2-я строка)
if str(reports_url).lower().startswith("sqlite:"):
from app.services.migration_pace import sqlite_flush_after_migration
raw_rep = connection.connection.dbapi_connection # type: ignore[attr-defined]
if raw_rep is not None:
sqlite_flush_after_migration(raw_rep)
migration_pause("после reports.db")
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,188 @@
"""
Добавление в SQLite недостающих колонок по ORM-моделям (легаси → рефактор).
Вызывается из единой миграции wesp_2_0 на каждом bind отдельно (recipes / reports).
Не создаёт таблицы и не меняет типы существующих колонок.
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.sql import sqltypes
def _bool_server_default(col: sa.Column) -> sa.TextClause:
if col.default is None:
return sa.text("0")
arg = getattr(col.default, "arg", False)
if callable(arg):
return sa.text("0")
return sa.text("1" if arg else "0")
def _int_server_default(col: sa.Column) -> str:
if col.default is None:
return "0"
arg = getattr(col.default, "arg", None)
if arg is None or callable(arg):
return "0"
try:
return str(int(arg))
except (TypeError, ValueError):
return "0"
def _float_server_default(col: sa.Column) -> str:
if col.default is None:
return "0"
arg = getattr(col.default, "arg", None)
if arg is None or callable(arg):
return "0"
try:
return str(float(arg))
except (TypeError, ValueError):
return "0"
def _is_bool_type(t: sa.types.TypeEngine) -> bool:
return isinstance(t, sqltypes.Boolean)
def _is_int_type(t: sa.types.TypeEngine) -> bool:
return isinstance(t, sqltypes.Integer)
def _is_float_type(t: sa.types.TypeEngine) -> bool:
return isinstance(t, (sqltypes.Float, sqltypes.REAL))
def _is_stringy_type(t: sa.types.TypeEngine) -> bool:
return isinstance(t, (sqltypes.String, sqltypes.Text, sqltypes.Unicode, sqltypes.UnicodeText))
def _is_datetime_type(t: sa.types.TypeEngine) -> bool:
return isinstance(t, sqltypes.DateTime)
def _add_column_sqlite_safe(op, table: str, col: sa.Column) -> None:
typ = col.type
name = col.name
# В SQLAlchemy по умолчанию nullable=None/True; только явный False — NOT NULL в DDL.
if col.nullable is not False:
op.add_column(table, sa.Column(name, typ, nullable=True))
return
if col.server_default is not None:
op.add_column(
table,
sa.Column(name, typ, nullable=False, server_default=col.server_default),
)
return
if _is_bool_type(typ):
op.add_column(
table,
sa.Column(name, typ, nullable=False, server_default=_bool_server_default(col)),
)
return
if _is_stringy_type(typ):
op.add_column(
table,
sa.Column(name, typ, nullable=False, server_default=""),
)
return
if _is_int_type(typ):
op.add_column(
table,
sa.Column(
name,
typ,
nullable=False,
server_default=_int_server_default(col),
),
)
return
if _is_float_type(typ):
op.add_column(
table,
sa.Column(
name,
typ,
nullable=False,
server_default=_float_server_default(col),
),
)
return
if _is_datetime_type(typ):
op.add_column(table, sa.Column(name, typ, nullable=True))
bind = op.get_bind()
insp = sa_inspect(bind)
cols_now = {c["name"] for c in insp.get_columns(table)}
parts: list[str] = []
for key in ("created_at", "updated_at"):
if key in cols_now and key != name:
parts.append(f'"{key}"')
if parts:
expr = "COALESCE(" + ", ".join(parts) + ", CURRENT_TIMESTAMP)"
else:
expr = "CURRENT_TIMESTAMP"
op.execute(
sa.text(f'UPDATE "{table}" SET "{name}" = {expr} WHERE "{name}" IS NULL')
)
with op.batch_alter_table(table) as batch:
batch.alter_column(name, existing_type=typ, nullable=False)
return
op.add_column(table, sa.Column(name, typ, nullable=True))
def apply_legacy_orm_gaps(op, kw: dict) -> None:
tag = kw.get("tag")
reports_only = tag == "reports"
from app import db # noqa: WPS433 — внутри миграции, после загрузки приложения в env
import app.models # noqa: F401, WPS433
bind = op.get_bind()
insp = sa_inspect(bind)
for mapper in db.Model.registry.mappers:
cls = mapper.class_
bk = getattr(cls, "__bind_key__", None)
if reports_only:
if bk != "reports":
continue
else:
if bk == "reports":
continue
# db.Model (Flask-SQLAlchemy) задаёт __abstract__ = True; наследники остаются конкретными.
if cls.__dict__.get("__abstract__", False):
continue
tab = getattr(cls, "__tablename__", None)
if not tab:
continue
if not insp.has_table(tab):
continue
existing = {c["name"] for c in insp.get_columns(tab)}
for col in cls.__table__.columns:
if col.primary_key:
continue
if col.key in existing:
continue
_add_column_sqlite_safe(op, tab, col)
existing.add(col.key)
from app.services.migration_pace import migration_pause_step
migration_pause_step(f"column:{tab}.{col.key}")
@@ -0,0 +1,33 @@
"""WESP 2.0: одна миграция для recipes.db и reports.db (переход с легаси 1.3).
Логика в app.schema_bootstrap.run_wesp20_idempotent_sync — та же выполняется после upgrade
в create_app (идемпотентно), чтобы схема догналась даже после alembic stamp без прогона.
Revision ID: wesp_2_0
Revises:
Create Date: 2026-04-19
"""
from __future__ import annotations
import os
from alembic import op
from app.schema_bootstrap import run_wesp20_idempotent_sync
revision = "wesp_2_0"
down_revision = None
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
run_wesp20_idempotent_sync(op, kw)
n = int(os.environ.get("WESP_MIGRATION_BODY_RAN_COUNT", "0") or "0")
os.environ["WESP_MIGRATION_BODY_RAN_COUNT"] = str(n + 1)
def downgrade(**kw) -> None:
"""Не поддерживается: откат к 1.3 только из бэкапа БД."""
@@ -0,0 +1,30 @@
"""Таблица feed_quality_settings + импорт из data/wesp_feed_quality_settings.json.
Revision ID: feed_quality_settings
Revises: wesp_2_0
Create Date: 2026-06-07
"""
from __future__ import annotations
from alembic import op
from app.services.feed_quality.settings_store import ensure_feed_quality_settings_table
revision = "feed_quality_settings"
down_revision = "wesp_2_0"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
ensure_feed_quality_settings_table(op.get_bind(), import_json=True)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
op.drop_table("feed_quality_settings")
@@ -0,0 +1,30 @@
"""Таблица daily_trip_skip — исключение рейса из плана на день.
Revision ID: daily_trip_skip
Revises: feed_quality_settings
Create Date: 2026-06-07
"""
from __future__ import annotations
from alembic import op
from app.models.daily_trip_skip import DailyTripSkip
revision = "daily_trip_skip"
down_revision = "feed_quality_settings"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
DailyTripSkip.__table__.create(op.get_bind(), checkfirst=True)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
op.drop_table("daily_trip_skip")
@@ -0,0 +1,33 @@
"""Таблицы daily_ingredient_skip и daily_unloading_group_skip.
Revision ID: daily_plan_part_skips
Revises: daily_trip_skip
Create Date: 2026-06-07
"""
from __future__ import annotations
from alembic import op
from app.models.daily_ingredient_skip import DailyIngredientSkip
from app.models.daily_unloading_group_skip import DailyUnloadingGroupSkip
revision = "daily_plan_part_skips"
down_revision = "daily_trip_skip"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
DailyIngredientSkip.__table__.create(op.get_bind(), checkfirst=True)
DailyUnloadingGroupSkip.__table__.create(op.get_bind(), checkfirst=True)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
op.drop_table("daily_unloading_group_skip")
op.drop_table("daily_ingredient_skip")
@@ -0,0 +1,49 @@
"""Колонка valid_until для skip-таблиц (диапазон дат).
Revision ID: daily_skip_valid_until
Revises: daily_plan_part_skips
Create Date: 2026-06-07
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "daily_skip_valid_until"
down_revision = "daily_plan_part_skips"
branch_labels = None
depends_on = None
_TABLES = ("daily_trip_skip", "daily_ingredient_skip", "daily_unloading_group_skip")
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
for table in _TABLES:
if table not in insp.get_table_names():
continue
cols = {c["name"] for c in insp.get_columns(table)}
if "valid_until" not in cols:
with op.batch_alter_table(table) as batch:
batch.add_column(sa.Column("valid_until", sa.Date(), nullable=True))
op.create_index(f"ix_{table}_valid_until", table, ["valid_until"], unique=False)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
for table in _TABLES:
if table not in insp.get_table_names():
continue
cols = {c["name"] for c in insp.get_columns(table)}
if "valid_until" in cols:
op.drop_index(f"ix_{table}_valid_until", table_name=table)
with op.batch_alter_table(table) as batch:
batch.drop_column("valid_until")
@@ -0,0 +1,30 @@
"""Таблица daily_ingredient_replacement.
Revision ID: daily_ingredient_replacement
Revises: daily_skip_valid_until
Create Date: 2026-06-07
"""
from __future__ import annotations
from alembic import op
from app.models.daily_ingredient_replacement import DailyIngredientReplacement
revision = "daily_ingredient_replacement"
down_revision = "daily_skip_valid_until"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
DailyIngredientReplacement.__table__.create(op.get_bind(), checkfirst=True)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
op.drop_table("daily_ingredient_replacement")
@@ -0,0 +1,30 @@
"""Таблица org_settings + импорт из data/wesp_org_settings.json.
Revision ID: org_settings
Revises: daily_ingredient_replacement
Create Date: 2026-06-07
"""
from __future__ import annotations
from alembic import op
from app.services.org_settings import ensure_org_settings_table
revision = "org_settings"
down_revision = "daily_ingredient_replacement"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
ensure_org_settings_table(op.get_bind(), import_json=True)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
op.drop_table("org_settings")
@@ -0,0 +1,30 @@
"""Таблица daily_component_norm_adjustment.
Revision ID: daily_component_norm_adjustment
Revises: org_settings
Create Date: 2026-06-07
"""
from __future__ import annotations
from alembic import op
from app.models.daily_component_norm_adjustment import DailyComponentNormAdjustment
revision = "daily_component_norm_adjustment"
down_revision = "org_settings"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
DailyComponentNormAdjustment.__table__.create(op.get_bind(), checkfirst=True)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
op.drop_table("daily_component_norm_adjustment")
@@ -0,0 +1,45 @@
"""Колонка dry_matter (СВ%) в daily_component_norm_adjustment.
Revision ID: daily_component_norm_dry_matter
Revises: daily_component_norm_adjustment
Create Date: 2026-06-07
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "daily_component_norm_dry_matter"
down_revision = "daily_component_norm_adjustment"
branch_labels = None
depends_on = None
_TABLE = "daily_component_norm_adjustment"
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(_TABLE)}
if "dry_matter" not in cols:
with op.batch_alter_table(_TABLE) as batch:
batch.add_column(sa.Column("dry_matter", sa.Float(), nullable=True))
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(_TABLE)}
if "dry_matter" in cols:
with op.batch_alter_table(_TABLE) as batch:
batch.drop_column("dry_matter")
@@ -0,0 +1,47 @@
"""Колонка dry_matter_locked в daily_component_norm_adjustment.
Revision ID: daily_component_norm_dm_locked
Revises: daily_component_norm_dry_matter
Create Date: 2026-06-07
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "daily_component_norm_dm_locked"
down_revision = "daily_component_norm_dry_matter"
branch_labels = None
depends_on = None
_TABLE = "daily_component_norm_adjustment"
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(_TABLE)}
if "dry_matter_locked" not in cols:
with op.batch_alter_table(_TABLE) as batch:
batch.add_column(
sa.Column("dry_matter_locked", sa.Boolean(), nullable=False, server_default=sa.false())
)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(_TABLE)}
if "dry_matter_locked" in cols:
with op.batch_alter_table(_TABLE) as batch:
batch.drop_column("dry_matter_locked")
@@ -0,0 +1,78 @@
"""Lab module tables + component nutrients/external_no + recipe.ration_type.
Revision ID: lab_module
Revises: daily_component_norm_dm_locked
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from app.lab.models import (
LabAnimalProfile,
LabRationLine,
LabRecipeRation,
)
revision = "lab_module"
down_revision = "daily_component_norm_dm_locked"
branch_labels = None
depends_on = None
def _add_column_if_missing(table: str, column: sa.Column) -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if table not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(table)}
if column.name not in cols:
with op.batch_alter_table(table) as batch:
batch.add_column(column)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
for model in (
LabAnimalProfile,
LabRecipeRation,
LabRationLine,
):
model.__table__.create(bind, checkfirst=True)
_add_column_if_missing("component", sa.Column("external_no", sa.Integer(), nullable=True))
_add_column_if_missing("recipe", sa.Column("ration_type", sa.String(length=10), nullable=True))
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
for table in (
"lab_ration_line",
"lab_recipe_ration",
"lab_animal_profile",
):
if table in insp.get_table_names():
op.drop_table(table)
if "component" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("component")}
with op.batch_alter_table("component") as batch:
if "external_no" in cols:
batch.drop_column("external_no")
if "recipe" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("recipe")}
if "ration_type" in cols:
with op.batch_alter_table("recipe") as batch:
batch.drop_column("ration_type")
@@ -0,0 +1,138 @@
"""lab_component_nutrients: zootech-показатели в колонках (не JSON на component).
Revision ID: lab_component_nutrients
Revises: lab_module
Create Date: 2026-06-08
"""
from __future__ import annotations
import json
import sqlalchemy as sa
from alembic import op
revision = "lab_component_nutrients"
down_revision = "lab_module"
branch_labels = None
depends_on = None
def _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _read_num(data: dict, keys: tuple[str, ...]) -> float | None:
for search in keys:
target = _normalize_key(search)
for k, v in data.items():
nk = _normalize_key(str(k))
if nk == target or target in nk or nk in target:
try:
n = float(v)
if n == n:
return n
except (TypeError, ValueError):
pass
return None
_FIELD_KEYS = {
"crude_protein": ("Сыр. Протеин",),
"usp": ("уСП",),
"bra": ("БРА", " БРА "),
"nel_cattle": ("ЧЭЛ- КРС", " ЧЭЛ- КРС"),
"oe_cattle": ("ОЭ-КРС", " ОЭ-КРС"),
"ndf": ("Сырая клетчатка", "Сырая клетч"),
"structural_fiber": ("Структур. клетчатка", "Структур клетч"),
"crude_fat": ("Сырой жир",),
}
def _migrate_json_nutrients(bind) -> None:
insp = sa.inspect(bind)
if "component" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("component")}
if "nutrients" not in cols:
return
rows = bind.execute(sa.text("SELECT id, nutrients FROM component WHERE is_deleted = 0")).fetchall()
for comp_id, raw in rows:
try:
data = json.loads(raw or "{}")
except (json.JSONDecodeError, TypeError):
data = {}
if not isinstance(data, dict) or not data:
continue
payload = {col: _read_num(data, keys) for col, keys in _FIELD_KEYS.items()}
if not any(v is not None for v in payload.values()):
continue
bind.execute(
sa.text(
"""
INSERT INTO lab_component_nutrients (
component_id, crude_protein, usp, bra, nel_cattle,
oe_cattle, ndf, structural_fiber, crude_fat
) VALUES (
:component_id, :crude_protein, :usp, :bra, :nel_cattle,
:oe_cattle, :ndf, :structural_fiber, :crude_fat
)
ON CONFLICT(component_id) DO UPDATE SET
crude_protein = excluded.crude_protein,
usp = excluded.usp,
bra = excluded.bra,
nel_cattle = excluded.nel_cattle,
oe_cattle = excluded.oe_cattle,
ndf = excluded.ndf,
structural_fiber = excluded.structural_fiber,
crude_fat = excluded.crude_fat
"""
),
{"component_id": comp_id, **payload},
)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "lab_component_nutrients" not in insp.get_table_names():
op.create_table(
"lab_component_nutrients",
sa.Column("component_id", sa.String(length=36), primary_key=True),
sa.Column("crude_protein", sa.Float(), nullable=True),
sa.Column("usp", sa.Float(), nullable=True),
sa.Column("bra", sa.Float(), nullable=True),
sa.Column("nel_cattle", sa.Float(), nullable=True),
sa.Column("oe_cattle", sa.Float(), nullable=True),
sa.Column("ndf", sa.Float(), nullable=True),
sa.Column("structural_fiber", sa.Float(), nullable=True),
sa.Column("crude_fat", sa.Float(), nullable=True),
)
_migrate_json_nutrients(bind)
if "component" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("component")}
if "nutrients" in cols:
with op.batch_alter_table("component") as batch:
batch.drop_column("nutrients")
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "component" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("component")}
if "nutrients" not in cols:
with op.batch_alter_table("component") as batch:
batch.add_column(
sa.Column("nutrients", sa.Text(), nullable=False, server_default="{}")
)
if "lab_component_nutrients" in insp.get_table_names():
op.drop_table("lab_component_nutrients")
@@ -0,0 +1,42 @@
"""Удаление lab_audit_log — аудит lab только в application log.
Revision ID: drop_lab_audit_log
Revises: lab_component_nutrients
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "drop_lab_audit_log"
down_revision = "lab_component_nutrients"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
if "lab_audit_log" in sa.inspect(bind).get_table_names():
op.drop_table("lab_audit_log")
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
if "lab_audit_log" not in sa.inspect(bind).get_table_names():
op.create_table(
"lab_audit_log",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=50), nullable=True),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("entity_type", sa.String(length=50), nullable=False),
sa.Column("entity_id", sa.String(length=36), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
@@ -0,0 +1,44 @@
"""Удаление lab_calculation_run — история пересчётов только в application log.
Revision ID: drop_lab_calculation_run
Revises: drop_lab_audit_log
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "drop_lab_calculation_run"
down_revision = "drop_lab_audit_log"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
if "lab_calculation_run" in sa.inspect(bind).get_table_names():
op.drop_table("lab_calculation_run")
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
if "lab_calculation_run" not in sa.inspect(bind).get_table_names():
op.create_table(
"lab_calculation_run",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("recipe_id", sa.String(length=36), nullable=True),
sa.Column("tab_project_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="COMPLETED"),
sa.Column("engine_version", sa.String(length=20), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("kpi_results", sa.Text(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
@@ -0,0 +1,86 @@
"""lab_component_nutrient_value: полная матрица показателей (EAV).
Revision ID: lab_component_nutrient_value
Revises: drop_lab_calculation_run
Create Date: 2026-06-08
"""
from __future__ import annotations
import uuid
import sqlalchemy as sa
from alembic import op
from app.lab.models import LabComponentNutrientValue
revision = "lab_component_nutrient_value"
down_revision = "drop_lab_calculation_run"
branch_labels = None
depends_on = None
# 8 колонок lab_component_nutrients → заголовки FUTTERPLUS
_SUMMARY_TO_HEADER = {
"crude_protein": "Сыр. Протеин",
"usp": "уСП",
"bra": "БРА",
"nel_cattle": "ЧЭЛ- КРС",
"oe_cattle": "ОЭ-КРС",
"ndf": "Сырая клетч",
"structural_fiber": "Структур клетч",
"crude_fat": "Сырой жир",
}
def _migrate_summary_to_eav(bind) -> None:
insp = sa.inspect(bind)
if "lab_component_nutrients" not in insp.get_table_names():
return
rows = bind.execute(
sa.text(
"""
SELECT component_id, crude_protein, usp, bra, nel_cattle,
oe_cattle, ndf, structural_fiber, crude_fat
FROM lab_component_nutrients
"""
)
).fetchall()
col_order = list(_SUMMARY_TO_HEADER.keys())
for row in rows:
comp_id = row[0]
for i, header in enumerate(_SUMMARY_TO_HEADER.values()):
val = row[i + 1]
if val is None:
continue
bind.execute(
sa.text(
"""
INSERT INTO lab_component_nutrient_value
(id, component_id, nutrient_key, value)
VALUES (:id, :component_id, :nutrient_key, :value)
ON CONFLICT(component_id, nutrient_key) DO UPDATE SET value = excluded.value
"""
),
{
"id": str(uuid.uuid4()),
"component_id": comp_id,
"nutrient_key": header,
"value": val,
},
)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
LabComponentNutrientValue.__table__.create(bind, checkfirst=True)
_migrate_summary_to_eav(bind)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
if "lab_component_nutrient_value" in sa.inspect(bind).get_table_names():
op.drop_table("lab_component_nutrient_value")
@@ -0,0 +1,377 @@
"""Normalize lab runtime storage: drop JSON blobs, use relational tables.
Revision ID: lab_normalize
Revises: lab_component_nutrient_value
Create Date: 2026-06-08
"""
from __future__ import annotations
import json
import uuid
import sqlalchemy as sa
from alembic import op
from app.lab.models import (
LabAnimalProfile,
LabProfileNorm,
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRecipeRation,
)
revision = "lab_normalize"
down_revision = "lab_component_nutrient_value"
branch_labels = None
depends_on = None
def _parse_num(value) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def _norms_from_payload(raw: str | None) -> tuple[float | None, int | None, dict]:
if not raw or not str(raw).strip() or str(raw).strip() == "{}":
return None, None, {}
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None, None, {}
if not isinstance(data, dict):
return None, None, {}
mass = _parse_num(data.get("massKg", data.get("mass_kg")))
ext = data.get("externalNo", data.get("external_no"))
external_no = int(ext) if ext is not None and str(ext).strip() != "" else None
indicators = data.get("indicators")
out: dict = {}
if isinstance(indicators, dict):
for key, bounds in indicators.items():
if isinstance(bounds, dict):
out[str(key)] = {
"min": _parse_num(bounds.get("min")),
"max": _parse_num(bounds.get("max")),
}
alias_map = {
"dry_matter": ["Сухое Вещество", "Сухое вещество"],
"crude_protein": ["Сыр. Протеин"],
"usp": ["уСП"],
"oe": ["ОЭ-КРС", " ОЭ-КРС"],
"nel": ["ЧЭЛ- КРС", " ЧЭЛ- КРС"],
}
for key, aliases in alias_map.items():
if key in out:
continue
for alias in aliases:
entry = data.get(alias)
if isinstance(entry, dict):
out[key] = {
"min": _parse_num(entry.get("min")),
"max": _parse_num(entry.get("max")),
}
break
return mass, external_no, out
def _indicator_key(label: str) -> str | None:
mapping = {
"Сухое вещество": "dry_matter",
"СВ — основной корм": "dm_main",
"ОЭ — КРС / Дойн": "oe",
"ЧЭЛ — КРС / Дойн": "nel",
"ЧЭЛ/кг СВ": "nel_per_kg_dm",
"Сырой протеин": "crude_protein",
"Нер. СП / кг СВ": "rup_per_kg_dm",
"Нерастворимый протеин": "insoluble_protein",
"уСП": "usp",
"% уСП/кг СВ": "usp_pct_dm",
}
return mapping.get(label)
def _migrate_profile_norms(bind) -> None:
insp = sa.inspect(bind)
if "lab_animal_profile" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("lab_animal_profile")}
if "norms_data" not in cols:
return
rows = bind.execute(sa.text("SELECT id, norms_data FROM lab_animal_profile")).fetchall()
for profile_id, raw in rows:
mass, external_no, norms = _norms_from_payload(raw)
bind.execute(
sa.text(
"UPDATE lab_animal_profile SET mass_kg = :mass, external_no = :external_no WHERE id = :id"
),
{"id": profile_id, "mass": mass, "external_no": external_no},
)
for indicator_key, bounds in norms.items():
min_v = bounds.get("min")
max_v = bounds.get("max")
if min_v is None and max_v is None:
continue
bind.execute(
sa.text(
"""
INSERT INTO lab_profile_norm
(id, profile_id, indicator_key, min_value, max_value)
VALUES (:id, :profile_id, :indicator_key, :min_value, :max_value)
"""
),
{
"id": str(uuid.uuid4()),
"profile_id": profile_id,
"indicator_key": indicator_key,
"min_value": min_v,
"max_value": max_v,
},
)
def _save_calc(bind, recipe_id: str, result: dict, calculated_at) -> None:
engine = str(result.get("engine") or "native")
bind.execute(
sa.text(
"UPDATE lab_recipe_ration SET calc_engine = :engine, calculated_at = :calculated_at WHERE recipe_id = :recipe_id"
),
{"engine": engine, "calculated_at": calculated_at, "recipe_id": recipe_id},
)
for scope, block_key in (("ration", None), ("compound", "compound")):
block = result if block_key is None else result.get(block_key) or {}
if not isinstance(block, dict):
continue
for idx, row in enumerate(block.get("totals") or []):
bind.execute(
sa.text(
"""
INSERT INTO lab_ration_calc_total
(id, recipe_id, scope, metric_key, label, value, sort_order)
VALUES (:id, :recipe_id, :scope, :metric_key, :label, :value, :sort_order)
"""
),
{
"id": str(uuid.uuid4()),
"recipe_id": recipe_id,
"scope": scope,
"metric_key": str(row.get("key") or f"metric_{idx}"),
"label": str(row.get("label") or ""),
"value": _parse_num(row.get("value")),
"sort_order": idx,
},
)
for idx, row in enumerate(block.get("indicators") or []):
bind.execute(
sa.text(
"""
INSERT INTO lab_ration_calc_indicator
(id, recipe_id, scope, indicator_key, label, unit,
min_value, max_value, content, diff, sort_order)
VALUES (:id, :recipe_id, :scope, :indicator_key, :label, :unit,
:min_value, :max_value, :content, :diff, :sort_order)
"""
),
{
"id": str(uuid.uuid4()),
"recipe_id": recipe_id,
"scope": scope,
"indicator_key": _indicator_key(str(row.get("label") or "")),
"label": str(row.get("label") or ""),
"unit": str(row.get("unit") or ""),
"min_value": _parse_num(row.get("min")),
"max_value": _parse_num(row.get("max")),
"content": _parse_num(row.get("content")),
"diff": _parse_num(row.get("diff")),
"sort_order": idx,
},
)
if scope == "compound":
for idx, row in enumerate(block.get("lines") or []):
bind.execute(
sa.text(
"""
INSERT INTO lab_ration_compound_line
(id, recipe_id, row_index, ingredient_name, daily_kg, share_pct)
VALUES (:id, :recipe_id, :row_index, :ingredient_name, :daily_kg, :share_pct)
"""
),
{
"id": str(uuid.uuid4()),
"recipe_id": recipe_id,
"row_index": idx,
"ingredient_name": row.get("ingredient_name"),
"daily_kg": _parse_num(row.get("daily_kg")),
"share_pct": _parse_num(row.get("share_pct")),
},
)
for idx, message in enumerate(result.get("errors") or []):
text = str(message or "").strip()
if not text:
continue
bind.execute(
sa.text(
"""
INSERT INTO lab_ration_calc_error (id, recipe_id, sort_order, message)
VALUES (:id, :recipe_id, :sort_order, :message)
"""
),
{
"id": str(uuid.uuid4()),
"recipe_id": recipe_id,
"sort_order": idx,
"message": text,
},
)
def _migrate_calc_results(bind) -> None:
insp = sa.inspect(bind)
if "lab_recipe_ration" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("lab_recipe_ration")}
if "ration_results" not in cols:
return
rows = bind.execute(
sa.text(
"SELECT recipe_id, params_json, ration_results, compound_results, calculated_at FROM lab_recipe_ration"
)
).fetchall()
for recipe_id, params_raw, ration_raw, compound_raw, calculated_at in rows:
if params_raw:
try:
params = json.loads(params_raw or "{}")
except (json.JSONDecodeError, TypeError):
params = {}
if isinstance(params, dict):
seeded = params.get("seeded_from") or params.get("synced_from")
if seeded:
bind.execute(
sa.text(
"UPDATE lab_recipe_ration SET seed_source = :seed WHERE recipe_id = :recipe_id"
),
{"seed": str(seeded), "recipe_id": recipe_id},
)
result: dict = {}
if ration_raw:
try:
parsed = json.loads(ration_raw or "{}")
if isinstance(parsed, dict):
result = parsed
except (json.JSONDecodeError, TypeError):
result = {}
if compound_raw:
try:
compound = json.loads(compound_raw or "{}")
if isinstance(compound, dict) and compound:
result["compound"] = compound
except (json.JSONDecodeError, TypeError):
pass
if result:
_save_calc(bind, recipe_id, result, calculated_at)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "lab_animal_profile" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_animal_profile")}
if "mass_kg" not in cols:
with op.batch_alter_table("lab_animal_profile") as batch:
batch.add_column(sa.Column("mass_kg", sa.Float(), nullable=True))
batch.add_column(sa.Column("external_no", sa.Integer(), nullable=True))
if "lab_recipe_ration" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_recipe_ration")}
if "calc_engine" not in cols:
with op.batch_alter_table("lab_recipe_ration") as batch:
batch.add_column(sa.Column("calc_engine", sa.String(length=20), nullable=True))
batch.add_column(sa.Column("seed_source", sa.String(length=32), nullable=True))
for model in (
LabProfileNorm,
LabRationCalcTotal,
LabRationCalcIndicator,
LabRationCompoundLine,
):
model.__table__.create(bind, checkfirst=True)
_migrate_profile_norms(bind)
_migrate_calc_results(bind)
insp = sa.inspect(bind)
if "lab_animal_profile" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_animal_profile")}
if "norms_data" in cols:
with op.batch_alter_table("lab_animal_profile") as batch:
batch.drop_column("norms_data")
if "lab_recipe_ration" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_recipe_ration")}
with op.batch_alter_table("lab_recipe_ration") as batch:
for col in ("params_json", "ration_results", "compound_results"):
if col in cols:
batch.drop_column(col)
if "lab_tab_ration_project" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_tab_ration_project")}
with op.batch_alter_table("lab_tab_ration_project") as batch:
for col in ("settings_json", "results_json", "compound_json"):
if col in cols:
batch.drop_column(col)
if "lab_tab_ration_line" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_tab_ration_line")}
if "raw_data_json" in cols:
with op.batch_alter_table("lab_tab_ration_line") as batch:
batch.drop_column("raw_data_json")
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "lab_animal_profile" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_animal_profile")}
if "norms_data" not in cols:
with op.batch_alter_table("lab_animal_profile") as batch:
batch.add_column(sa.Column("norms_data", sa.Text(), nullable=False, server_default="{}"))
for col in ("mass_kg", "external_no"):
if col in cols:
with op.batch_alter_table("lab_animal_profile") as batch:
batch.drop_column(col)
if "lab_recipe_ration" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_recipe_ration")}
with op.batch_alter_table("lab_recipe_ration") as batch:
for col, default in (
("params_json", "{}"),
("ration_results", "{}"),
("compound_results", "{}"),
):
if col not in cols:
batch.add_column(sa.Column(col, sa.Text(), nullable=False, server_default=default))
for col in ("calc_engine", "seed_source"):
if col in cols:
batch.drop_column(col)
for table in (
"lab_ration_calc_error",
"lab_ration_compound_line",
"lab_ration_calc_indicator",
"lab_ration_calc_total",
"lab_profile_norm",
):
if table in insp.get_table_names():
op.drop_table(table)
@@ -0,0 +1,108 @@
"""org_settings: typed columns вместо payload JSON.
Revision ID: org_settings_normalize
Revises: lab_normalize
Create Date: 2026-06-08
"""
from __future__ import annotations
import json
import sqlalchemy as sa
from alembic import op
from app.services.org_settings import ORG_SETTINGS_FIELDS, DEFAULT_ORG_SETTINGS
revision = "org_settings_normalize"
down_revision = "lab_normalize"
branch_labels = None
depends_on = None
def _normalize_payload(raw: str | None) -> dict[str, str]:
merged = dict(DEFAULT_ORG_SETTINGS)
if not raw or not str(raw).strip() or str(raw).strip() == "{}":
return merged
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return merged
if not isinstance(data, dict):
return merged
for field in ORG_SETTINGS_FIELDS:
if field in data:
merged[field] = str(data[field] or "").strip()
return merged
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "org_settings" not in insp.get_table_names():
from app.services.org_settings import ensure_org_settings_table
ensure_org_settings_table(bind, import_json=True)
return
cols = {c["name"] for c in insp.get_columns("org_settings")}
missing = [field for field in ORG_SETTINGS_FIELDS if field not in cols]
if missing:
with op.batch_alter_table("org_settings") as batch:
for field in missing:
default = DEFAULT_ORG_SETTINGS[field]
batch.add_column(
sa.Column(field, sa.String(length=300 if field == "organization_name" else 200), nullable=False, server_default=default)
)
if "payload" in cols:
rows = bind.execute(sa.text("SELECT id, payload FROM org_settings")).fetchall()
for row_id, payload in rows:
settings = _normalize_payload(payload)
bind.execute(
sa.text(
"UPDATE org_settings SET "
+ ", ".join(f"{field} = :{field}" for field in ORG_SETTINGS_FIELDS)
+ " WHERE id = :id"
),
{"id": row_id, **settings},
)
with op.batch_alter_table("org_settings") as batch:
batch.drop_column("payload")
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "org_settings" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("org_settings")}
if "payload" not in cols:
with op.batch_alter_table("org_settings") as batch:
batch.add_column(sa.Column("payload", sa.Text(), nullable=False, server_default="{}"))
rows = bind.execute(sa.text("SELECT id FROM org_settings")).fetchall()
for (row_id,) in rows:
row = bind.execute(
sa.text(
"SELECT "
+ ", ".join(ORG_SETTINGS_FIELDS)
+ " FROM org_settings WHERE id = :id"
),
{"id": row_id},
).fetchone()
if row is None:
continue
payload = {field: row[idx] for idx, field in enumerate(ORG_SETTINGS_FIELDS)}
bind.execute(
sa.text("UPDATE org_settings SET payload = :payload WHERE id = :id"),
{"id": row_id, "payload": json.dumps(payload, ensure_ascii=False)},
)
with op.batch_alter_table("org_settings") as batch:
for field in ORG_SETTINGS_FIELDS:
if field in cols:
batch.drop_column(field)
@@ -0,0 +1,97 @@
"""Удаление lab_component_nutrients — только EAV lab_component_nutrient_value.
Revision ID: drop_lab_component_nutrients
Revises: org_settings_normalize
Create Date: 2026-06-08
"""
from __future__ import annotations
import uuid
import sqlalchemy as sa
from alembic import op
revision = "drop_lab_component_nutrients"
down_revision = "org_settings_normalize"
branch_labels = None
depends_on = None
_SUMMARY_TO_HEADER = {
"crude_protein": "Сыр. Протеин",
"usp": "уСП",
"bra": "БРА",
"nel_cattle": "ЧЭЛ- КРС",
"oe_cattle": "ОЭ-КРС",
"ndf": "Сырая клетч",
"structural_fiber": "Структур клетч",
"crude_fat": "Сырой жир",
}
def _migrate_summary_to_eav(bind) -> None:
insp = sa.inspect(bind)
if "lab_component_nutrients" not in insp.get_table_names():
return
if "lab_component_nutrient_value" not in insp.get_table_names():
return
rows = bind.execute(
sa.text(
"""
SELECT component_id, crude_protein, usp, bra, nel_cattle,
oe_cattle, ndf, structural_fiber, crude_fat
FROM lab_component_nutrients
"""
)
).fetchall()
for row in rows:
comp_id = row[0]
for i, header in enumerate(_SUMMARY_TO_HEADER.values()):
val = row[i + 1]
if val is None:
continue
bind.execute(
sa.text(
"""
INSERT INTO lab_component_nutrient_value
(id, component_id, nutrient_key, value)
VALUES (:id, :component_id, :nutrient_key, :value)
ON CONFLICT(component_id, nutrient_key) DO NOTHING
"""
),
{
"id": str(uuid.uuid4()),
"component_id": comp_id,
"nutrient_key": header,
"value": val,
},
)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
_migrate_summary_to_eav(bind)
insp = sa.inspect(bind)
if "lab_component_nutrients" in insp.get_table_names():
op.drop_table("lab_component_nutrients")
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
if "lab_component_nutrients" not in sa.inspect(bind).get_table_names():
op.create_table(
"lab_component_nutrients",
sa.Column("component_id", sa.String(length=36), primary_key=True),
sa.Column("crude_protein", sa.Float(), nullable=True),
sa.Column("usp", sa.Float(), nullable=True),
sa.Column("bra", sa.Float(), nullable=True),
sa.Column("nel_cattle", sa.Float(), nullable=True),
sa.Column("oe_cattle", sa.Float(), nullable=True),
sa.Column("ndf", sa.Float(), nullable=True),
sa.Column("structural_fiber", sa.Float(), nullable=True),
sa.Column("crude_fat", sa.Float(), nullable=True),
)
@@ -0,0 +1,44 @@
"""Добавить удой (кг/сут) в lab_animal_profile для динамической нормы уСП GfE.
Revision ID: lab_animal_profile_milk_yield
Revises: drop_lab_component_nutrients
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "lab_animal_profile_milk_yield"
down_revision = "drop_lab_component_nutrients"
branch_labels = None
depends_on = None
_TABLE = "lab_animal_profile"
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(_TABLE)}
if "milk_yield_kg" not in cols:
with op.batch_alter_table(_TABLE, schema=None) as batch_op:
batch_op.add_column(sa.Column("milk_yield_kg", sa.Float(), nullable=True))
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns(_TABLE)}
if "milk_yield_kg" in cols:
with op.batch_alter_table(_TABLE, schema=None) as batch_op:
batch_op.drop_column("milk_yield_kg")
@@ -0,0 +1,85 @@
"""Drop unused sync columns from lab tables (server-only module).
Revision ID: lab_drop_sync_columns
Revises: lab_animal_profile_milk_yield
Create Date: 2026-06-09
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "lab_drop_sync_columns"
down_revision = "lab_animal_profile_milk_yield"
branch_labels = None
depends_on = None
_LAB_TABLES = (
"lab_animal_profile",
"lab_recipe_ration",
"lab_ration_line",
)
_CUSTOMER_DROP_TABLES = ("lab_customer_address", "lab_customer")
_CUSTOMER_ID_COLUMNS = (("lab_recipe_ration", "customer_id"),)
_SYNC_COLS = ("sync_timestamp", "sync_status", "content_hash")
def _drop_customer_layer(insp: sa.Inspector) -> None:
for table, column in _CUSTOMER_ID_COLUMNS:
idx = f"ix_{table}_{column}"
if table in insp.get_table_names():
op.execute(sa.text(f'DROP INDEX IF EXISTS "{idx}"'))
for table, column in _CUSTOMER_ID_COLUMNS:
if table not in insp.get_table_names():
continue
cols = {c["name"] for c in insp.get_columns(table)}
if column in cols:
with op.batch_alter_table(table) as batch:
batch.drop_column(column)
for table in _CUSTOMER_DROP_TABLES:
if table in insp.get_table_names():
op.drop_table(table)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
_drop_customer_layer(insp)
insp = sa.inspect(bind)
for table in _LAB_TABLES:
if table not in insp.get_table_names():
continue
cols = {c["name"] for c in insp.get_columns(table)}
drop = [c for c in _SYNC_COLS if c in cols]
if not drop:
continue
with op.batch_alter_table(table) as batch:
for col in drop:
batch.drop_column(col)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
for table in _LAB_TABLES:
if table not in insp.get_table_names():
continue
cols = {c["name"] for c in insp.get_columns(table)}
with op.batch_alter_table(table) as batch:
if "sync_timestamp" not in cols:
batch.add_column(sa.Column("sync_timestamp", sa.DateTime(), nullable=True))
if "sync_status" not in cols:
batch.add_column(
sa.Column("sync_status", sa.String(length=20), nullable=False, server_default="pending")
)
if "content_hash" not in cols:
batch.add_column(
sa.Column("content_hash", sa.String(length=64), nullable=False, server_default="")
)
@@ -0,0 +1,40 @@
"""Drop legacy lab tables: tab staging, norm dump, calc errors in DB.
Revision ID: lab_drop_legacy_artifacts
Revises: lab_drop_sync_columns
Create Date: 2026-06-09
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "lab_drop_legacy_artifacts"
down_revision = "lab_drop_sync_columns"
branch_labels = None
depends_on = None
_LEGACY_TABLES = (
"lab_tab_ration_line",
"lab_tab_ration_project",
"lab_norm_entry",
"lab_ration_calc_error",
)
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if bind.dialect.name == "sqlite":
op.execute(sa.text("PRAGMA foreign_keys=OFF"))
for table in _LEGACY_TABLES:
if table in insp.get_table_names():
op.drop_table(table)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
@@ -0,0 +1,44 @@
"""Rename indicator_key bra → rnb (GfE ruminal nitrogen balance).
Revision ID: lab_rnb_rename
Revises: lab_drop_legacy_artifacts
Create Date: 2026-06-09
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "lab_rnb_rename"
down_revision = "lab_drop_legacy_artifacts"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
bind.execute(
sa.text("UPDATE lab_profile_norm SET indicator_key = 'rnb' WHERE indicator_key = 'bra'")
)
bind.execute(
sa.text(
"UPDATE lab_ration_calc_indicator SET indicator_key = 'rnb' WHERE indicator_key = 'bra'"
)
)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
bind.execute(
sa.text("UPDATE lab_profile_norm SET indicator_key = 'bra' WHERE indicator_key = 'rnb'")
)
bind.execute(
sa.text(
"UPDATE lab_ration_calc_indicator SET indicator_key = 'bra' WHERE indicator_key = 'rnb'"
)
)
@@ -0,0 +1,101 @@
"""Справочники норм RACION (NORMY_*).
Revision ID: lab_racion_normy
Revises: lab_rnb_rename
Create Date: 2026-06-10
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "lab_racion_normy"
down_revision = "lab_rnb_rename"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "lab_animal_profile" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("lab_animal_profile")}
with op.batch_alter_table("lab_animal_profile", schema=None) as batch_op:
if "norms_method" not in cols:
batch_op.add_column(
sa.Column("norms_method", sa.String(20), nullable=False, server_default="wesp")
)
if "norms_params_json" not in cols:
batch_op.add_column(sa.Column("norms_params_json", sa.Text(), nullable=True))
if "lab_racion_normy_moskwa" not in insp.get_table_names():
op.create_table(
"lab_racion_normy_moskwa",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("npitv", sa.Integer(), nullable=False),
sa.Column("pom", sa.Integer(), nullable=False, server_default="1"),
sa.Column("koef", sa.Float(), nullable=True),
sa.Column("popr_k_json", sa.Text(), nullable=True),
sa.UniqueConstraint("npitv", "pom", name="uq_lab_racion_moskwa_npitv_pom"),
)
op.create_index("ix_lab_racion_normy_moskwa_npitv", "lab_racion_normy_moskwa", ["npitv"])
if "lab_racion_normy_moskwa_meta" not in insp.get_table_names():
op.create_table(
"lab_racion_normy_moskwa_meta",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("meta_key", sa.String(32), nullable=False),
sa.Column("meta_json", sa.Text(), nullable=True),
sa.UniqueConstraint("meta_key", name="uq_lab_racion_moskwa_meta_key"),
)
if "lab_racion_normy_piter" not in insp.get_table_names():
op.create_table(
"lab_racion_normy_piter",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("npitv", sa.Integer(), nullable=False),
sa.Column("konc", sa.Float(), nullable=False),
sa.Column("udoy", sa.Float(), nullable=False),
sa.Column("normy_json", sa.Text(), nullable=True),
sa.UniqueConstraint("npitv", "konc", "udoy", name="uq_lab_racion_piter_npitv_konc_udoy"),
)
op.create_index("ix_lab_racion_normy_piter_npitv", "lab_racion_normy_piter", ["npitv"])
if "lab_racion_normy_piter_meta" not in insp.get_table_names():
op.create_table(
"lab_racion_normy_piter_meta",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("meta_key", sa.String(32), nullable=False),
sa.Column("meta_json", sa.Text(), nullable=True),
sa.UniqueConstraint("meta_key", name="uq_lab_racion_piter_meta_key"),
)
if "lab_racion_normy_info" not in insp.get_table_names():
op.create_table(
"lab_racion_normy_info",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("nperem", sa.Integer(), nullable=False),
sa.Column("znachenie_json", sa.Text(), nullable=True),
sa.UniqueConstraint("nperem", name="uq_lab_racion_normy_info_nperem"),
)
op.create_index("ix_lab_racion_normy_info_nperem", "lab_racion_normy_info", ["nperem"])
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
for table in (
"lab_racion_normy_info",
"lab_racion_normy_piter_meta",
"lab_racion_normy_piter",
"lab_racion_normy_moskwa_meta",
"lab_racion_normy_moskwa",
):
if table in insp.get_table_names():
op.drop_table(table)
@@ -0,0 +1,44 @@
"""web_user.lab_access — per-user доступ к модулю Lab.
Revision ID: web_user_lab_access
Revises: lab_racion_normy
Create Date: 2026-07-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "web_user_lab_access"
down_revision = "lab_racion_normy"
branch_labels = None
depends_on = None
def upgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "web_user" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("web_user")}
if "lab_access" not in cols:
with op.batch_alter_table("web_user", schema=None) as batch_op:
batch_op.add_column(
sa.Column("lab_access", sa.Boolean(), nullable=False, server_default=sa.false())
)
def downgrade(**kw) -> None:
if kw.get("tag") == "reports":
return
bind = op.get_bind()
insp = sa.inspect(bind)
if "web_user" not in insp.get_table_names():
return
cols = {c["name"] for c in insp.get_columns("web_user")}
if "lab_access" in cols:
with op.batch_alter_table("web_user", schema=None) as batch_op:
batch_op.drop_column("lab_access")