@@ -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")
|
||||
Reference in New Issue
Block a user