@@ -0,0 +1,289 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Импортируем модели, чтобы метаданные SQLAlchemy были доступны при инициализации.
|
||||
from .component import Component, Ingredient # noqa: F401
|
||||
from .recipe import Recipe, UnloadingGroup, PeriodRecipe # noqa: F401
|
||||
from .report import ( # noqa: F401
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
ComponentLoadingTime,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from .feed_alert import FeedAlert # noqa: F401
|
||||
from .feed_quality_settings import FeedQualitySettings # noqa: F401
|
||||
from .org_settings import OrgSettings # noqa: F401
|
||||
from .daily_component_norm_adjustment import DailyComponentNormAdjustment # noqa: F401
|
||||
from .daily_trip_skip import DailyTripSkip # noqa: F401
|
||||
from .daily_ingredient_skip import DailyIngredientSkip # noqa: F401
|
||||
from .daily_ingredient_replacement import DailyIngredientReplacement # noqa: F401
|
||||
from .daily_unloading_group_skip import DailyUnloadingGroupSkip # noqa: F401
|
||||
from .equipment import ( # noqa: F401
|
||||
FeedMixer,
|
||||
FeedDispenser,
|
||||
FeedingLocation,
|
||||
FeedingPeriod,
|
||||
FeedingPoint,
|
||||
Trip,
|
||||
)
|
||||
from .sklad import ComponentStock # noqa: F401
|
||||
from .hardware_setting import HardwareSetting # noqa: F401
|
||||
from .kiosk import KioskDevice, KioskPairToken # noqa: F401
|
||||
from .user import WebUser # noqa: F401
|
||||
from .auto_update_settings import AutoUpdateSettings # noqa: F401
|
||||
from .zootech_notification import ZootechNotification # noqa: F401
|
||||
from .sync import ( # noqa: F401
|
||||
SyncMetadata,
|
||||
SyncClient,
|
||||
SyncClientDisplayName,
|
||||
SyncEngineState,
|
||||
SyncQueue,
|
||||
SyncDelivery,
|
||||
SyncConflict,
|
||||
)
|
||||
|
||||
|
||||
from .base import TimestampMixin, SoftDeleteMixin # noqa: F401
|
||||
|
||||
|
||||
def _cascade_on_parent_soft_delete(mapper, connection, target) -> None: # type: ignore[override]
|
||||
if not getattr(target, "is_deleted", False):
|
||||
return
|
||||
table = getattr(target, "__tablename__", "")
|
||||
if not table:
|
||||
return
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from app.services.sync_cascade import CASCADE_CHILDREN, cascade_soft_delete
|
||||
|
||||
if table not in CASCADE_CHILDREN:
|
||||
return
|
||||
sess = object_session(target)
|
||||
suppress_enqueue = bool(
|
||||
sess is not None and sess.info.get(WESP_SUPPRESS_SYNC_ENQUEUE)
|
||||
)
|
||||
cascade_soft_delete(
|
||||
table,
|
||||
str(getattr(target, "id", "")),
|
||||
deleted_by=str(getattr(target, "deleted_by", None) or f"cascade:{table}"),
|
||||
enqueue=not suppress_enqueue,
|
||||
defer_enqueue=True,
|
||||
)
|
||||
|
||||
|
||||
for _parent_table in (
|
||||
"recipe",
|
||||
"component",
|
||||
"feed_dispenser",
|
||||
"feeding_period",
|
||||
"feed_mixer",
|
||||
"loading_report",
|
||||
"unloading_report",
|
||||
):
|
||||
_parent_model = {
|
||||
"recipe": Recipe,
|
||||
"component": Component,
|
||||
"feed_dispenser": FeedDispenser,
|
||||
"feeding_period": FeedingPeriod,
|
||||
"feed_mixer": FeedMixer,
|
||||
"loading_report": LoadingReport,
|
||||
"unloading_report": UnloadingReport,
|
||||
}[_parent_table]
|
||||
event.listen(_parent_model, "after_update", _cascade_on_parent_soft_delete)
|
||||
|
||||
|
||||
def _sync_record_id(target) -> str:
|
||||
table = getattr(target, "__tablename__", "")
|
||||
if table == "period_recipes":
|
||||
return f"{target.period_id}:{target.recipe_id}"
|
||||
return target.id
|
||||
|
||||
|
||||
_SYNC_ENQUEUE_INFO_KEY = "_wesp_sync_enqueue_pending"
|
||||
# session.info: применение pull на клиенте (sync_client.apply_batch) — не ставить задачи в sync_queue.
|
||||
WESP_SUPPRESS_SYNC_ENQUEUE = "_wesp_suppress_sync_enqueue"
|
||||
|
||||
|
||||
def _schedule_sync_enqueue_from_listener(target, action: str, priority: int) -> None:
|
||||
"""Не вызывать enqueue_sync_queue_task из after_insert/after_update — это внутри flush."""
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
sess = object_session(target)
|
||||
if sess is not None and sess.info.get(WESP_SUPPRESS_SYNC_ENQUEUE):
|
||||
return
|
||||
rid = _sync_record_id(target)
|
||||
row = (target.__tablename__, str(rid), action, priority, None)
|
||||
if sess is None:
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
tname, rec_id, act, pri, tid = row
|
||||
enqueue_sync_queue_task(tname, rec_id, act, priority=pri, target_node_id=tid)
|
||||
return
|
||||
sess.info.setdefault(_SYNC_ENQUEUE_INFO_KEY, []).append(row)
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_flush")
|
||||
def _flush_pending_sync_enqueues(session, flush_context) -> None: # type: ignore[override]
|
||||
from app.services.sync_cascade import CASCADE_ENQUEUE_INFO_KEY
|
||||
|
||||
pending = session.info.pop(_SYNC_ENQUEUE_INFO_KEY, None)
|
||||
cascade_pending = session.info.pop(CASCADE_ENQUEUE_INFO_KEY, None)
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
if cascade_pending:
|
||||
merged_cascade: set[tuple[str, str]] = set()
|
||||
for tname, rid in cascade_pending:
|
||||
merged_cascade.add((tname, rid))
|
||||
with session.no_autoflush:
|
||||
for tname, rid in merged_cascade:
|
||||
enqueue_sync_queue_task(tname, rid, "update", priority=1)
|
||||
|
||||
if not pending:
|
||||
return
|
||||
|
||||
merged: dict[tuple[str, str, str, str | None], tuple] = {}
|
||||
for table_name, record_id, action, priority, target_node_id in pending:
|
||||
key = (table_name, record_id, action, target_node_id)
|
||||
pri = int(priority)
|
||||
prev = merged.get(key)
|
||||
if prev is None or pri > prev[3]:
|
||||
merged[key] = (table_name, record_id, action, pri, target_node_id)
|
||||
|
||||
with session.no_autoflush:
|
||||
for tname, rec_id, act, pri, tid in merged.values():
|
||||
enqueue_sync_queue_task(tname, rec_id, act, priority=pri, target_node_id=tid)
|
||||
|
||||
|
||||
def _sync_task_after_insert(mapper, connection, target): # type: ignore[override]
|
||||
"""Универсальная постановка в sync_queue после insert (паритет с legacy sync_models)."""
|
||||
priority_map = {
|
||||
"component": 2,
|
||||
"recipe": 2,
|
||||
"ingredient": 3,
|
||||
"unloading_group": 4,
|
||||
"loading_report": 4,
|
||||
"loading_report_component": 4,
|
||||
"component_loading_time": 4,
|
||||
"unloading_report": 4,
|
||||
"unloading_report_group": 4,
|
||||
"feed_mixer": 3,
|
||||
"feeding_location": 3,
|
||||
"feeding_period": 3,
|
||||
"feeding_point": 3,
|
||||
"trip": 4,
|
||||
"feed_dispenser": 3,
|
||||
"period_recipes": 3,
|
||||
"daily_trip_skip": 2,
|
||||
"daily_ingredient_skip": 2,
|
||||
"daily_unloading_group_skip": 2,
|
||||
"daily_ingredient_replacement": 2,
|
||||
"daily_component_norm_adjustment": 2,
|
||||
}
|
||||
priority = priority_map.get(target.__tablename__, 3)
|
||||
_schedule_sync_enqueue_from_listener(target, "create", priority)
|
||||
|
||||
|
||||
def _sync_task_after_update(mapper, connection, target): # type: ignore[override]
|
||||
# soft_delete / каскад: update в очередь не ставим — delete или cascade_soft_delete уже поставили задачи
|
||||
if getattr(target, "is_deleted", False):
|
||||
return
|
||||
priority_map = {
|
||||
"component": 3,
|
||||
"recipe": 3,
|
||||
"ingredient": 4,
|
||||
"unloading_group": 4,
|
||||
"loading_report": 4,
|
||||
"loading_report_component": 4,
|
||||
"component_loading_time": 4,
|
||||
"unloading_report": 4,
|
||||
"unloading_report_group": 4,
|
||||
"feed_mixer": 4,
|
||||
"feeding_location": 4,
|
||||
"feeding_period": 4,
|
||||
"feeding_point": 4,
|
||||
"trip": 4,
|
||||
"feed_dispenser": 4,
|
||||
"period_recipes": 4,
|
||||
"daily_trip_skip": 3,
|
||||
"daily_ingredient_skip": 3,
|
||||
"daily_unloading_group_skip": 3,
|
||||
"daily_ingredient_replacement": 3,
|
||||
"daily_component_norm_adjustment": 3,
|
||||
}
|
||||
priority = priority_map.get(target.__tablename__, 4)
|
||||
_schedule_sync_enqueue_from_listener(target, "update", priority)
|
||||
|
||||
|
||||
_sync_models = (
|
||||
Component,
|
||||
Recipe,
|
||||
Ingredient,
|
||||
UnloadingGroup,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
ComponentLoadingTime,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
FeedMixer,
|
||||
FeedingLocation,
|
||||
FeedingPeriod,
|
||||
FeedingPoint,
|
||||
Trip,
|
||||
FeedDispenser,
|
||||
PeriodRecipe,
|
||||
DailyTripSkip,
|
||||
DailyIngredientSkip,
|
||||
DailyUnloadingGroupSkip,
|
||||
DailyIngredientReplacement,
|
||||
DailyComponentNormAdjustment,
|
||||
)
|
||||
|
||||
def _model_has_content_hash_column(cls) -> bool:
|
||||
try:
|
||||
return "content_hash" in cls.__table__.columns
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sync_content_hash_after_insert(mapper, connection, target) -> None: # type: ignore[override]
|
||||
"""После INSERT строка уже содержит значения по умолчанию из БД — хеш совпадает с get_object_data."""
|
||||
from sqlalchemy import and_, update
|
||||
|
||||
from app.services.sync_content_hash import compute_content_hash_for_object
|
||||
|
||||
if not hasattr(target, "content_hash"):
|
||||
return
|
||||
h = compute_content_hash_for_object(target)
|
||||
if not h:
|
||||
return
|
||||
table = mapper.local_table
|
||||
pk_cols = list(table.primary_key.columns)
|
||||
stmt = update(table).values(content_hash=h)
|
||||
if len(pk_cols) == 1:
|
||||
pk = pk_cols[0]
|
||||
stmt = stmt.where(pk == getattr(target, pk.key))
|
||||
else:
|
||||
stmt = stmt.where(and_(*(c == getattr(target, c.key) for c in pk_cols)))
|
||||
connection.execute(stmt)
|
||||
target.content_hash = h
|
||||
|
||||
|
||||
for _sm in _sync_models:
|
||||
if _model_has_content_hash_column(_sm):
|
||||
event.listen(_sm, "after_insert", _sync_content_hash_after_insert)
|
||||
|
||||
for _sm in _sync_models:
|
||||
event.listen(_sm, "after_insert", _sync_task_after_insert)
|
||||
event.listen(_sm, "after_update", _sync_task_after_update)
|
||||
|
||||
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _wesp_recompute_content_hashes_before_flush(session, flush_context, instances) -> None: # type: ignore[override]
|
||||
from app.services.sync_content_hash import refresh_content_hash_if_applicable
|
||||
|
||||
for obj in list(session.dirty):
|
||||
if hasattr(obj, "content_hash"):
|
||||
refresh_content_hash_if_applicable(obj)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Singleton-настройки автообновления (Gitea) в recipes.db."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer, String, text
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class AutoUpdateSettings(db.Model):
|
||||
__tablename__ = "auto_update_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
enabled = Column(Boolean, nullable=False, default=False, server_default=text("0"))
|
||||
auto_install = Column(Boolean, nullable=False, default=False, server_default=text("0"))
|
||||
gitea_url = Column(String(512), nullable=False, default="", server_default=text("''"))
|
||||
gitea_owner = Column(String(255), nullable=False, default="", server_default=text("''"))
|
||||
gitea_repo = Column(String(255), nullable=False, default="", server_default=text("''"))
|
||||
repository_url = Column(String(512), nullable=False, default="", server_default=text("''"))
|
||||
check_interval_sec = Column(Integer, nullable=False, default=3600, server_default=text("3600"))
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, String
|
||||
|
||||
from app.timeutil import utc_now_naive
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
is_deleted = Column(Boolean, default=False, nullable=True)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
deleted_by = Column(String(50), nullable=True)
|
||||
|
||||
def soft_delete(self, deleted_by_user: str = "system", **_: Any) -> None:
|
||||
"""Мягкое удаление сущности.
|
||||
|
||||
Дополнительные аргументы игнорируются, чтобы сохранить совместимость
|
||||
с вызовами из легаси-кода (reason, request и т.п.).
|
||||
"""
|
||||
if self.is_deleted:
|
||||
return
|
||||
self.is_deleted = True
|
||||
self.deleted_at = utc_now_naive()
|
||||
self.deleted_by = deleted_by_user
|
||||
|
||||
|
||||
def default_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class Component(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Кормовой компонент (ингредиент как сущность склада/рецептов)."""
|
||||
|
||||
__tablename__ = "component"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
type = Column(String(100), nullable=False, default="")
|
||||
is_active = Column(Boolean, default=True)
|
||||
dry_matter = Column(Float, nullable=False, default=0.0)
|
||||
protein = Column(Float, nullable=False, default=0.0)
|
||||
energy = Column(Float, nullable=False, default=0.0)
|
||||
price = Column(Float, nullable=False, default=0.0)
|
||||
external_no = Column(Integer, nullable=True, index=True)
|
||||
|
||||
# Версионирование и аудит (минимальный набор для синхронизации)
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
# Связи
|
||||
ingredients = relationship(
|
||||
"Ingredient",
|
||||
back_populates="component",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class Ingredient(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Строка рецепта (ингредиент в составе рецепта)."""
|
||||
|
||||
__tablename__ = "ingredient"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
# Новое поле в легаси: вес на голову
|
||||
weight_per_head = Column(Float, nullable=True)
|
||||
# Процент сухого вещества
|
||||
dry_matter = Column(Float, nullable=False, default=0.0)
|
||||
# СВ на голову (кг) - константа для режима "замок СВ"
|
||||
dry_matter_per_head = Column(Float, nullable=True)
|
||||
# Порядок ингредиента в рецепте
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
|
||||
component_id = Column(
|
||||
String(36), ForeignKey("component.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
# Связи
|
||||
recipe = relationship("Recipe", back_populates="ingredients")
|
||||
component = relationship("Component", back_populates="ingredients")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Временная правка СВ% компонента для плана на день (вес/гол в рецепте не меняется)."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class DailyComponentNormAdjustment(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Глобальная правка нормы component_id на период (plan_date … valid_until)."""
|
||||
|
||||
__tablename__ = "daily_component_norm_adjustment"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
component_id = Column(String(36), ForeignKey("component.id"), nullable=False, index=True)
|
||||
plan_date = Column(Date, nullable=False, index=True)
|
||||
valid_until = Column(Date, nullable=True, index=True)
|
||||
dry_matter = Column(Float, nullable=True)
|
||||
dry_matter_locked = Column(Boolean, nullable=False, default=False)
|
||||
weight_per_head = Column(Float, nullable=True)
|
||||
dry_matter_per_head = Column(Float, nullable=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Временная замена компонента в рейсе для плана на день."""
|
||||
|
||||
from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class DailyIngredientReplacement(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Глобальная замена component_id строки рейса на период (plan_date … valid_until)."""
|
||||
|
||||
__tablename__ = "daily_ingredient_replacement"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
|
||||
ingredient_id = Column(String(36), ForeignKey("ingredient.id"), nullable=False, index=True)
|
||||
replacement_component_id = Column(
|
||||
String(36), ForeignKey("component.id"), nullable=False, index=True
|
||||
)
|
||||
plan_date = Column(Date, nullable=False, index=True)
|
||||
valid_until = Column(Date, nullable=True, index=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Исключение ингредиента рейса из плана на конкретную дату."""
|
||||
|
||||
from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class DailyIngredientSkip(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Глобальное исключение компонента рейса на период (plan_date … valid_until)."""
|
||||
|
||||
__tablename__ = "daily_ingredient_skip"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
|
||||
ingredient_id = Column(String(36), ForeignKey("ingredient.id"), nullable=False, index=True)
|
||||
plan_date = Column(Date, nullable=False, index=True)
|
||||
valid_until = Column(Date, nullable=True, index=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Исключение рейса (recipe) из плана на конкретную дату."""
|
||||
|
||||
from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class DailyTripSkip(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Глобальное исключение рейса на дату (все кормораздатчики / кормоцеха)."""
|
||||
|
||||
__tablename__ = "daily_trip_skip"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
|
||||
plan_date = Column(Date, nullable=False, index=True)
|
||||
valid_until = Column(Date, nullable=True, index=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Исключение группы выгрузки рейса из плана на конкретную дату."""
|
||||
|
||||
from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class DailyUnloadingGroupSkip(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Глобальное исключение группы выгрузки на дату."""
|
||||
|
||||
__tablename__ = "daily_unloading_group_skip"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
|
||||
unloading_group_id = Column(
|
||||
String(36), ForeignKey("unloading_group.id"), nullable=False, index=True
|
||||
)
|
||||
plan_date = Column(Date, nullable=False, index=True)
|
||||
valid_until = Column(Date, nullable=True, index=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
@@ -0,0 +1,167 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class FeedMixer(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Кормосмеситель."""
|
||||
|
||||
__tablename__ = "feed_mixer"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
model = Column(String(100))
|
||||
gosnomer = Column(String(20))
|
||||
operator = Column(String(100))
|
||||
ip_address = Column(String(15))
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
trips = relationship(
|
||||
"Trip",
|
||||
back_populates="mixer",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class FeedDispenser(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Кормораздатчик (шкаф управления)."""
|
||||
|
||||
__tablename__ = "feed_dispenser"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
farm = Column(String(100), nullable=False)
|
||||
operator = Column(String(100), nullable=False)
|
||||
type = Column(String(20), nullable=False, default="dispenser")
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
periods = relationship(
|
||||
"FeedingPeriod",
|
||||
back_populates="dispenser",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class FeedingLocation(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Место кормления (группа точек выгрузки)."""
|
||||
|
||||
__tablename__ = "feeding_location"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
mixer_id = Column(String(36), ForeignKey("feed_mixer.id"), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
|
||||
class FeedingPeriod(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Период кормления (расписание для кормораздатчика)."""
|
||||
|
||||
__tablename__ = "feeding_period"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
dispenser_id = Column(
|
||||
String(36), ForeignKey("feed_dispenser.id"), nullable=False
|
||||
)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
recipes = relationship(
|
||||
"Recipe",
|
||||
secondary="period_recipes",
|
||||
backref="feeding_periods",
|
||||
lazy="joined",
|
||||
overlaps="period_recipes_rel",
|
||||
)
|
||||
period_recipes_rel = relationship(
|
||||
"PeriodRecipe",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
overlaps="feeding_periods,recipes",
|
||||
)
|
||||
dispenser = relationship("FeedDispenser", back_populates="periods")
|
||||
|
||||
|
||||
class FeedingPoint(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Точка кормления (конкретное место выгрузки)."""
|
||||
|
||||
__tablename__ = "feeding_point"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
mixer_id = Column(String(36), ForeignKey("feed_mixer.id"), nullable=True)
|
||||
location_id = Column(
|
||||
String(36), ForeignKey("feeding_location.id"), nullable=True
|
||||
)
|
||||
period_id = Column(
|
||||
String(36), ForeignKey("feeding_period.id"), nullable=True
|
||||
)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
|
||||
class Trip(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Рейс кормораздатчика."""
|
||||
|
||||
__tablename__ = "trip"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
mixer_id = Column(String(36), ForeignKey("feed_mixer.id"), nullable=False)
|
||||
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False)
|
||||
daily_ration_percent = Column(Float, nullable=False)
|
||||
heads_count = Column(Integer, nullable=False)
|
||||
mixing_time = Column(Integer, nullable=False)
|
||||
correction_percent = Column(Float, nullable=False)
|
||||
status = Column(String(20), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False)
|
||||
started_at = Column(DateTime)
|
||||
completed_at = Column(DateTime)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
updated_at = Column(DateTime, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
mixer = relationship("FeedMixer", back_populates="trips")
|
||||
recipe = relationship("Recipe", back_populates="trips")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Алерты контроля качества кормления (feed_quality)."""
|
||||
|
||||
from sqlalchemy import Column, DateTime, Float, String, Text
|
||||
|
||||
from app import db
|
||||
from .base import default_uuid
|
||||
|
||||
|
||||
class FeedAlert(db.Model):
|
||||
"""Событие отклонения по отчёту загрузки/выгрузки."""
|
||||
|
||||
__bind_key__ = "reports"
|
||||
__tablename__ = "feed_alert"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
event_type = Column(String(20), nullable=False)
|
||||
severity = Column(String(10), nullable=False, default="warning")
|
||||
loading_report_id = Column(String(36), nullable=False, index=True)
|
||||
unloading_report_id = Column(String(36), nullable=True, index=True)
|
||||
recipe_id = Column(String(36), nullable=False)
|
||||
recipe_name = Column(String(100), nullable=False, default="")
|
||||
component_name = Column(String(100), nullable=True)
|
||||
group_name = Column(String(100), nullable=True)
|
||||
detail = Column(Text, nullable=False, default="")
|
||||
deviation_kg = Column(Float, nullable=True)
|
||||
deviation_pct = Column(Float, nullable=True)
|
||||
cost_deviation_rub = Column(Float, nullable=True)
|
||||
client_id = Column(String(50), nullable=True)
|
||||
created_at = Column(DateTime, nullable=False)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Singleton глобальных настроек контроля отклонений (recipes.db)."""
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, Text
|
||||
|
||||
from app import db
|
||||
from app.timeutil import utc_now_naive
|
||||
|
||||
|
||||
class FeedQualitySettings(db.Model):
|
||||
__tablename__ = "feed_quality_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
payload = Column(Text, nullable=False, default="{}")
|
||||
updated_at = Column(DateTime, nullable=False, default=utc_now_naive)
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Boolean, Column, Float, Integer
|
||||
|
||||
from app import db
|
||||
from .base import TimestampMixin
|
||||
|
||||
|
||||
class HardwareSetting(db.Model, TimestampMixin):
|
||||
"""Persistent runtime settings for scale calibration and simulation."""
|
||||
|
||||
__tablename__ = "hardware_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
counts_per_kg = Column(Float, nullable=False, default=1.0)
|
||||
tare_raw = Column(Float, nullable=False, default=0.0)
|
||||
simulation_mode = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
simulation_weight_kg = Column(Float, nullable=False, default=0.0, server_default="0")
|
||||
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, DateTime, String, Text
|
||||
|
||||
from app import db
|
||||
from .base import TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class KioskDevice(db.Model, TimestampMixin):
|
||||
"""Persistent registry of kiosk terminals."""
|
||||
|
||||
__tablename__ = "kiosk_device"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
device_fingerprint = Column(String(255), nullable=True)
|
||||
display_name = Column(String(120), nullable=False, default="terminal")
|
||||
status = Column(String(20), nullable=False, default="pending") # pending|active|revoked
|
||||
access_slug = Column(String(64), nullable=True, unique=True, index=True)
|
||||
last_seen_at = Column(DateTime, nullable=True)
|
||||
last_ip = Column(String(45), nullable=True)
|
||||
|
||||
|
||||
class KioskPairToken(db.Model):
|
||||
"""One-time short-lived token for QR pairing."""
|
||||
|
||||
__tablename__ = "kiosk_pair_token"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
token = Column(String(255), nullable=False, unique=True, index=True)
|
||||
device_id = Column(String(36), nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
issued_for = Column(Text, nullable=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Singleton реквизитов организации для СП-20 (recipes.db)."""
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String
|
||||
|
||||
from app import db
|
||||
from app.timeutil import utc_now_naive
|
||||
|
||||
|
||||
class OrgSettings(db.Model):
|
||||
__tablename__ = "org_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
organization_name = Column(String(300), nullable=False, default="")
|
||||
okpo = Column(String(32), nullable=False, default="")
|
||||
farm_name = Column(String(200), nullable=False, default="")
|
||||
department = Column(String(200), nullable=False, default="")
|
||||
brigade = Column(String(200), nullable=False, default="")
|
||||
animal_type = Column(String(100), nullable=False, default="КРС")
|
||||
responsible_person = Column(String(200), nullable=False, default="")
|
||||
warehouse_keeper = Column(String(200), nullable=False, default="")
|
||||
zootechnician = Column(String(200), nullable=False, default="")
|
||||
document_number_prefix = Column(String(32), nullable=False, default="СП20")
|
||||
updated_at = Column(DateTime, nullable=False, default=utc_now_naive)
|
||||
@@ -0,0 +1,108 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class Recipe(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Рацион (рецепт) кормления."""
|
||||
|
||||
__tablename__ = "recipe"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
heads_per_trip = Column(Integer, default=1)
|
||||
mixing_time = Column(Integer, default=0)
|
||||
trip_percent = Column(Float, default=100.0)
|
||||
dry_matter_locked = Column(Boolean, nullable=False, default=False)
|
||||
unloading_link_broken = Column(Boolean, nullable=False, default=False)
|
||||
target_component_id = Column(
|
||||
String(36), ForeignKey("component.id"), nullable=True, index=True
|
||||
)
|
||||
ration_type = Column(String(10), nullable=True)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
ingredients = relationship(
|
||||
"Ingredient",
|
||||
back_populates="recipe",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
unloading_groups = relationship(
|
||||
"UnloadingGroup",
|
||||
back_populates="recipe",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
trips = relationship(
|
||||
"Trip",
|
||||
back_populates="recipe",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
period_recipes_rel = relationship(
|
||||
"PeriodRecipe",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
overlaps="feeding_periods",
|
||||
)
|
||||
|
||||
|
||||
class UnloadingGroup(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Группа выгрузки для рецепта."""
|
||||
|
||||
__tablename__ = "unloading_group"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
# 'percent' или 'heads'
|
||||
distribution_type = Column(String(20), nullable=False)
|
||||
# процент или количество голов
|
||||
value = Column(Float, nullable=False)
|
||||
# вес в кг
|
||||
weight = Column(Float, nullable=True)
|
||||
# порядок выгрузки
|
||||
order = Column(Integer, nullable=False)
|
||||
|
||||
recipe_id = Column(
|
||||
String(36), ForeignKey("recipe.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
recipe = relationship("Recipe", back_populates="unloading_groups")
|
||||
|
||||
|
||||
class PeriodRecipe(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Связка периода кормления и рецепта."""
|
||||
|
||||
__tablename__ = "period_recipes"
|
||||
|
||||
period_id = Column(
|
||||
String(36), ForeignKey("feeding_period.id"), primary_key=True
|
||||
)
|
||||
recipe_id = Column(
|
||||
String(36), ForeignKey("recipe.id"), primary_key=True, index=True
|
||||
)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
# created_at / updated_at — из TimestampMixin (БД: NOT NULL у обоих)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class LoadingReport(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Отчёт о загрузке кормосмесителя."""
|
||||
|
||||
__bind_key__ = "reports"
|
||||
__tablename__ = "loading_report"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
recipe_id = Column(String(36), nullable=False)
|
||||
recipe_name = Column(String(100), nullable=False)
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime)
|
||||
target_mixing_time = Column(Integer)
|
||||
actual_mixing_time = Column(Integer)
|
||||
total_weight = Column(Float)
|
||||
dispenser_type = Column(String(20), nullable=False, default="dispenser")
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
client_id = Column(String(50), nullable=True)
|
||||
server_synced = Column(db.Boolean, default=False, nullable=False)
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
components = relationship(
|
||||
"LoadingReportComponent",
|
||||
back_populates="report",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
component_loading_times = relationship(
|
||||
"ComponentLoadingTime",
|
||||
back_populates="report",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class LoadingReportComponent(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Строка отчёта о загрузке (по компоненту)."""
|
||||
|
||||
__bind_key__ = "reports"
|
||||
__tablename__ = "loading_report_component"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
report_id = Column(
|
||||
String(36), ForeignKey("loading_report.id"), nullable=False, index=True
|
||||
)
|
||||
component_id = Column(String(36), nullable=True, index=True)
|
||||
component_name = Column(String(100), nullable=False)
|
||||
target_weight = Column(Float, nullable=False)
|
||||
actual_weight = Column(Float, nullable=False)
|
||||
overload = Column(Float)
|
||||
loading_order = Column(Integer, nullable=False)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
report = relationship("LoadingReport", back_populates="components")
|
||||
|
||||
|
||||
class ComponentLoadingTime(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Временные метки начала/конца загрузки компонента."""
|
||||
|
||||
__bind_key__ = "reports"
|
||||
__tablename__ = "component_loading_time"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
report_id = Column(
|
||||
String(36), ForeignKey("loading_report.id"), nullable=False, index=True
|
||||
)
|
||||
component_name = Column(String(100), nullable=False)
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime, nullable=False)
|
||||
loading_duration = Column(Float, nullable=False)
|
||||
loading_order = Column(Integer, nullable=False)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
report = relationship("LoadingReport", back_populates="component_loading_times")
|
||||
|
||||
|
||||
class UnloadingReport(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Отчёт о выгрузке."""
|
||||
|
||||
__bind_key__ = "reports"
|
||||
__tablename__ = "unloading_report"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
recipe_id = Column(String(36), nullable=False)
|
||||
recipe_name = Column(String(100), nullable=False)
|
||||
loading_report_id = Column(String(36), nullable=False)
|
||||
start_time = Column(DateTime, nullable=False)
|
||||
end_time = Column(DateTime)
|
||||
total_weight = Column(Float, nullable=False)
|
||||
total_unloaded_weight = Column(Float, nullable=False)
|
||||
remaining_weight = Column(Float, nullable=False)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
client_id = Column(String(50), nullable=True)
|
||||
server_synced = Column(db.Boolean, default=False, nullable=False)
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
groups = relationship(
|
||||
"UnloadingReportGroup",
|
||||
back_populates="report",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class UnloadingReportGroup(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Строка отчёта о выгрузке по группе."""
|
||||
|
||||
__bind_key__ = "reports"
|
||||
__tablename__ = "unloading_report_group"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
report_id = Column(
|
||||
String(36), ForeignKey("unloading_report.id"), nullable=False, index=True
|
||||
)
|
||||
name = Column(String(100), nullable=False)
|
||||
target_weight = Column(Float, nullable=False)
|
||||
unloaded_weight = Column(Float, nullable=False)
|
||||
remaining_weight = Column(Float, nullable=False)
|
||||
distribution_type = Column(String(20), nullable=False)
|
||||
distribution_value = Column(Float, nullable=False)
|
||||
order = Column(Integer, nullable=False)
|
||||
|
||||
version = Column(Integer, default=1, nullable=False)
|
||||
created_by = Column(String(50), nullable=False, default="system")
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sync_timestamp = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="pending", nullable=False)
|
||||
content_hash = Column(String(64), nullable=False, default="")
|
||||
|
||||
report = relationship("UnloadingReport", back_populates="groups")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String
|
||||
|
||||
from app import db
|
||||
from .base import default_uuid
|
||||
|
||||
|
||||
class ComponentStock(db.Model):
|
||||
"""Остатки компонентов на сервере (не синхронизируется).
|
||||
|
||||
Без TimestampMixin: в БД (Alembic 0001) нет колонки created_at, только updated_at/stocktake_at.
|
||||
"""
|
||||
|
||||
__tablename__ = "component_stock"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
component_id = Column(String(36), nullable=False, unique=True, index=True)
|
||||
component_name = Column(String(100), nullable=False, default="")
|
||||
total_kg = Column(Float, nullable=False, default=0.0)
|
||||
inflow_kg = Column(Float, nullable=False, default=0.0)
|
||||
baseline_consumed_kg = Column(Float, nullable=False, default=0.0)
|
||||
stocktake_at = Column(DateTime, nullable=False)
|
||||
updated_at = Column(DateTime, nullable=False)
|
||||
updated_by = Column(String(50), nullable=False, default="system")
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
is_deleted = Column(Boolean, default=False, nullable=True)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
deleted_by = Column(String(50), nullable=True)
|
||||
|
||||
def soft_delete(self, deleted_by_user: str = "system", **_: Any) -> None:
|
||||
"""Мягкое удаление строки склада (ожидается из sklad.init_sklad)."""
|
||||
if self.is_deleted:
|
||||
return
|
||||
self.is_deleted = True
|
||||
self.deleted_at = datetime.now()
|
||||
self.deleted_by = deleted_by_user
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, UniqueConstraint
|
||||
|
||||
from app import db
|
||||
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class SyncMetadata(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Метаданные синхронизации (паспорт узла)."""
|
||||
|
||||
__tablename__ = "sync_metadata"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
node_id = Column(String(36), nullable=False, unique=True)
|
||||
node_type = Column(String(20), nullable=False) # 'server' | 'client'
|
||||
node_name = Column(String(100), nullable=True)
|
||||
node_status = Column(String(20), default="active")
|
||||
last_heartbeat = Column(DateTime, nullable=True)
|
||||
last_sync = Column(DateTime, nullable=True)
|
||||
sync_status = Column(String(20), default="idle")
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
|
||||
|
||||
class SyncClient(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Реестр клиентов синхронизации."""
|
||||
|
||||
__tablename__ = "sync_clients"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
node_id = Column(String(36), nullable=False, unique=True)
|
||||
client_name = Column(String(100), nullable=False)
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
port = Column(Integer, default=80)
|
||||
status = Column(String(20), default="active")
|
||||
last_seen = Column(DateTime, nullable=True)
|
||||
total_syncs = Column(Integer, default=0)
|
||||
last_error = Column(Text, nullable=True)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
personal_snapshot_completed_at = Column(DateTime, nullable=True)
|
||||
personal_snapshot_cursor = Column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class SyncEngineState(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Durable flags/cursors for sync engine bootstrap."""
|
||||
|
||||
__tablename__ = "sync_engine_state"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
universal_bootstrap_completed_at = Column(DateTime, nullable=True)
|
||||
universal_bootstrap_cursor = Column(Integer, nullable=False, default=0)
|
||||
universal_bootstrap_last_error = Column(Text, nullable=True)
|
||||
|
||||
|
||||
class SyncClientDisplayName(db.Model):
|
||||
"""Привязка клиента к отображаемому имени (для UI)."""
|
||||
|
||||
__tablename__ = "sync_client_display_name"
|
||||
|
||||
node_id = Column(String(36), primary_key=True)
|
||||
display_name = Column(String(100), nullable=False)
|
||||
updated_at = Column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class SyncQueue(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Очередь задач синхронизации."""
|
||||
|
||||
__tablename__ = "sync_queue"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"table_name",
|
||||
"record_id",
|
||||
"action",
|
||||
"target_node_id",
|
||||
name="uq_sync_queue_task",
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
table_name = Column(String(50), nullable=False)
|
||||
record_id = Column(String(36), nullable=False)
|
||||
action = Column(String(20), nullable=False) # 'create' | 'update' | 'delete'
|
||||
status = Column(String(20), default="pending") # pending|processing|completed|failed
|
||||
target_node_id = Column(String(36), nullable=True)
|
||||
source_node_id = Column(String(36), nullable=True)
|
||||
priority = Column(Integer, default=1)
|
||||
retry_count = Column(Integer, default=0)
|
||||
max_retries = Column(Integer, default=3)
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False)
|
||||
processed_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class SyncDelivery(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Фиксация доставки универсальных задач до клиентов."""
|
||||
|
||||
__tablename__ = "sync_delivery"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
client_id = Column(String(36), nullable=False, index=True)
|
||||
task_id = Column(String(36), nullable=False, index=True)
|
||||
delivered_at = Column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class SyncConflict(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||
"""Конфликты синхронизации."""
|
||||
|
||||
__tablename__ = "sync_conflicts"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
table_name = Column(String(50), nullable=False)
|
||||
record_id = Column(String(36), nullable=False)
|
||||
conflict_type = Column(String(20), nullable=False)
|
||||
local_data = Column(Text, nullable=True)
|
||||
remote_data = Column(Text, nullable=True)
|
||||
resolution = Column(String(20), nullable=True)
|
||||
resolved_by = Column(String(50), nullable=True)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Boolean, Column, String
|
||||
|
||||
from app import db
|
||||
from .base import TimestampMixin, default_uuid
|
||||
|
||||
|
||||
class WebUser(db.Model, TimestampMixin):
|
||||
"""Web users for zootech/admin authentication."""
|
||||
|
||||
__tablename__ = "web_user"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
login = Column(String(64), nullable=False, unique=True, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_superuser = Column(Boolean, nullable=False, default=False)
|
||||
lab_access = Column(Boolean, nullable=False, default=False)
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, DateTime, String, Text
|
||||
|
||||
from app import db
|
||||
from .base import default_uuid
|
||||
|
||||
|
||||
class ZootechNotification(db.Model):
|
||||
"""Журнал UI-уведомлений зоотехника (локальный терминал, без sync)."""
|
||||
|
||||
__tablename__ = "zootech_notification"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||
title = Column(String(200), nullable=False, default="")
|
||||
detail = Column(Text, nullable=False, default="")
|
||||
kind = Column(String(20), nullable=False, default="info")
|
||||
category = Column(String(40), nullable=False, default="general")
|
||||
page = Column(String(40), nullable=True)
|
||||
link_kind = Column(String(30), nullable=True)
|
||||
link_id = Column(String(36), nullable=True)
|
||||
user_login = Column(String(120), nullable=True)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False)
|
||||
Reference in New Issue
Block a user