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

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+18
View File
@@ -0,0 +1,18 @@
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203431425,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes.html:doSaveRecipe","message":"mill recipe save response","data":{"status":200,"wasCreate":true,"recipeId":"370f90f2-c0dc-412e-ad74-91979c633cec","dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","mill":true},"timestamp":1784203451214,"hypothesisId":"H2"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203451433,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203490339,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes.html:doSaveRecipe","message":"mill recipe save response","data":{"status":200,"wasCreate":true,"recipeId":"c991f6b7-b5fc-4fe6-9df9-04e9d091353c","dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","mill":true},"timestamp":1784203509560,"hypothesisId":"H2"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203509735,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203544254,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203546039,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":0,"ids":[]},"timestamp":1784203549849,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes.html:doSaveRecipe","message":"mill recipe save response","data":{"status":200,"wasCreate":true,"recipeId":"9f31bffa-b76e-45ce-96f8-ccea91c60397","dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","mill":true},"timestamp":1784203578260,"hypothesisId":"H2"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784203578439,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes.html:doSaveRecipe","message":"mill recipe save response","data":{"status":200,"wasCreate":false,"recipeId":"9f31bffa-b76e-45ce-96f8-ccea91c60397","dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","mill":true},"timestamp":1784203600138,"hypothesisId":"H2"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784203600341,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784203634053,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784203855469,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784203887388,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784204037170,"hypothesisId":"H4"}
{"sessionId":"136860","location":"recipes-data-controller.js:loadMillRecipes","message":"mill recipes loaded","data":{"dispenserId":"b269b7c0-9d67-4b2c-a85f-e8437dcd625d","status":200,"count":1,"ids":["9f31bffa-b76e-45ce-96f8-ccea91c60397"]},"timestamp":1784204072688,"hypothesisId":"H4"}
+36
View File
@@ -0,0 +1,36 @@
{"sessionId":"e7c486","hypothesisId":"H3","location":"wesp-orchestrator-boot.js:patchUrl","message":"feed-quality URL kept under /api (not rewritten to v1)","data":{"original":"/api/feed-quality/settings","patched":"/api/feed-quality/settings"},"timestamp":1784206923808}
{"sessionId":"e7c486","hypothesisId":"H1,H2,H3","location":"feed-quality-settings-modal.js:loadSettings","message":"feed-quality settings fetch result","data":{"requestedUrl":"/api/feed-quality/settings","respUrl":"http://localhost:5173/api/feed-quality/settings?enterprise_id=9e99368b-e72b-49d5-b83f-9717df999235","status":200,"ok":true},"timestamp":1784206923913}
{"sessionId":"e7c486","hypothesisId":"H3","location":"wesp-orchestrator-boot.js:patchUrl","message":"feed-quality URL kept under /api (not rewritten to v1)","data":{"original":"/api/feed-quality/alerts/summary?date_from=2026-07-01&date_to=2026-07-16","patched":"/api/feed-quality/alerts/summary?date_from=2026-07-01&date_to=2026-07-16"},"timestamp":1784207540478}
{"sessionId":"e7c486","hypothesisId":"H3","location":"wesp-orchestrator-boot.js:patchUrl","message":"feed-quality URL kept under /api (not rewritten to v1)","data":{"original":"/api/feed-quality/alerts/summary?date_from=2026-07-01&date_to=2026-07-16","patched":"/api/feed-quality/alerts/summary?date_from=2026-07-01&date_to=2026-07-16"},"timestamp":1784207541497}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "query": "enterprise_id=d6e18152-0ec7-4b77-844b-7985dc991278&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208552799}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "status": 200}, "timestamp": 1784208560888}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "query": "enterprise_id=d6e18152-0ec7-4b77-844b-7985dc991278&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208560892}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "status": 200}, "timestamp": 1784208568951}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "GET", "query": "enterprise_id=d6e18152-0ec7-4b77-844b-7985dc991278", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208568954}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "GET", "status": 200}, "timestamp": 1784208577048}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "query": "enterprise_id=d6e18152-0ec7-4b77-844b-7985dc991278", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208577052}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "status": 200}, "timestamp": 1784208585164}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "query": "enterprise_id=c5ad2c76-1a24-4e72-9f18-f5e0f77cdc68&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208983358}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "status": 200}, "timestamp": 1784208991445}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "query": "enterprise_id=c5ad2c76-1a24-4e72-9f18-f5e0f77cdc68&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208991449}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "status": 200}, "timestamp": 1784208999514}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "GET", "query": "enterprise_id=c5ad2c76-1a24-4e72-9f18-f5e0f77cdc68", "has_auth": true, "enterprise_header": null}, "timestamp": 1784208999518}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "GET", "status": 200}, "timestamp": 1784209007591}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "query": "enterprise_id=c5ad2c76-1a24-4e72-9f18-f5e0f77cdc68", "has_auth": true, "enterprise_header": null}, "timestamp": 1784209007594}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "status": 200}, "timestamp": 1784209015643}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "query": "enterprise_id=26d1bb13-7dac-4982-85f4-18a6735ca570&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784209596440}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "status": 200}, "timestamp": 1784209604517}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "query": "enterprise_id=26d1bb13-7dac-4982-85f4-18a6735ca570&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784209604521}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "status": 200}, "timestamp": 1784209612613}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "GET", "query": "enterprise_id=26d1bb13-7dac-4982-85f4-18a6735ca570", "has_auth": true, "enterprise_header": null}, "timestamp": 1784209612617}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "GET", "status": 200}, "timestamp": 1784209620695}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "query": "enterprise_id=26d1bb13-7dac-4982-85f4-18a6735ca570", "has_auth": true, "enterprise_header": null}, "timestamp": 1784209620699}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "status": 200}, "timestamp": 1784209628804}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "query": "enterprise_id=b743ca10-61ff-47f8-9ea6-4b9c96cd9b22&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784210262553}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts/summary", "method": "GET", "status": 200}, "timestamp": 1784210270624}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "query": "enterprise_id=b743ca10-61ff-47f8-9ea6-4b9c96cd9b22&date_from=2026-07-01&date_to=2026-07-15", "has_auth": true, "enterprise_header": null}, "timestamp": 1784210270628}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/alerts", "method": "GET", "status": 200}, "timestamp": 1784210278720}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "GET", "query": "enterprise_id=b743ca10-61ff-47f8-9ea6-4b9c96cd9b22", "has_auth": true, "enterprise_header": null}, "timestamp": 1784210278724}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "GET", "status": 200}, "timestamp": 1784210286802}
{"sessionId": "e7c486", "hypothesisId": "H1,H2,H4", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality request reached API", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "query": "enterprise_id=b743ca10-61ff-47f8-9ea6-4b9c96cd9b22", "has_auth": true, "enterprise_header": null}, "timestamp": 1784210286806}
{"sessionId": "e7c486", "hypothesisId": "H1,H5", "location": "main.py:debug_feed_quality_middleware", "message": "feed-quality response", "data": {"path": "/api/feed-quality/settings", "method": "PUT", "status": 200}, "timestamp": 1784210294924}
+4 -1
View File
@@ -22,7 +22,10 @@ jobs:
- name: Install frontend deps
run: pnpm install --frozen-lockfile=false
- name: Install backend deps
run: pip install -r apps/api/requirements-dev.txt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends fonts-dejavu-core
pip install -r apps/api/requirements-dev.txt
- name: Sync WESP UI parity
run: bash scripts/materialize-wesp-static.sh
- name: Lint
-1
View File
@@ -20,4 +20,3 @@ apps/api/data/secrets/install.meta.json
data/secrets/
apps/api/data/logs/
apps/api/data/compton_settings.json
apps/web/public/static/
+77
View File
@@ -0,0 +1,77 @@
# Python
__pycache__/
.pytest_cache/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Базы данных
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# Секретные файлы и конфиги
.secret/
credentials.json
# Локальное состояние полевого sync-клиента (client_id, server_url из UI)
sync_client_state.json
wesp_security_settings.json
wesp_network_settings.json
wesp_network_diagnostics.json
wesp_install_state.json
gitea_secrets.json
*.secret
.env
.env.local
# Резервные копии
backups/
*.backup
*.bak
# Временные файлы обновлений
temp_updates/
# Калибровка и настройки оборудования
calibration_factor.json
weight_0.json
# IDE
.idea/
.vscode/
.cursor/rules/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
desktop.ini
# Логи
*.log
logs/
log/
data/logs/*
!data/logs/.gitkeep
# Конфиг с секретами
# config.json
# Развёрнутая документация только локально (не коммитить)
docs/
# Офлайн-колёса pip (кладите сюда .whl / архивы; в репозитории только .gitkeep)
vendor/wheels/*
!vendor/wheels/.gitkeep
# Локальный llama-server.exe (install/assistant/download_llama_server.ps1)
tools/llama-server.exe
+19
View File
@@ -0,0 +1,19 @@
# WESP
Если вы открыли этот файл, вы уже на полпути к пониманию того, что перед вами вообще какой-то проект. Не обязательно хороший и не обязательно плохой — просто проект, и в этом, если задуматься, тоже есть определённая ценность, потому что вообще-то не у каждого есть проект, а у вас вот есть, и это уже неплохо с точки зрения наличия чего-то.
**WESP** — это название. За названием, как водится, стоит какая-то идея, а за идеей — код. Код лежит в папках; папки, в свою очередь, лежат там, где их положили. Отдельные вещи можно поискать в **`app/`**, если вам вдруг понадобится не просто созерцать структуру, а что-то с ней делать, хотя делать не обязательно: иногда достаточно просто знать, что оно где-то есть.
Если уж совсем по-человечески: это софт для кормления на ферме — рецепты и рейсы, миксеры и кормораздатчики, терминалы на планшетах, факт загрузки и отчёты СП-20, синхронизация между сервером и полем. В K-hub есть **план на день** (пропуски, замены, коррекция СВ%), **отклонения** при загрузке и зачатки **аналитики** — всё это не магия, а файлы в репозитории, просто их много, и мы сознательно не превращаем README в каталог достижений.
Запуск, когда дойдут руки и настроение, в общих чертах выглядит так: поставить зависимости как обычно принято в вашем мире (часто **`pip install -r requirements.txt`**), потом **`python run.py`**, потом открыть браузер туда, куда обычно открывают, если не знаете точный адрес — попробуйте **`http://127.0.0.1`**, вдруг совпадёт. Не совпадёт — ну значит не совпадёт, бывает.
**Чистая установка на Linux-сервер:** `sudo bash scripts/install_fresh.sh /opt/wesp /opt/wes` — см. **`docs/README.full.md`** и **`install/wesp.env.example`**.
Настройки в целом живут в **`config.py`**, переменные окружения начинаются с **`WESP_`**, если это важно в вашей вселенной. Тесты, для тех кому это важно, гоняются через **`python3 tests/run_tests.py all`** (или интерактивное меню без аргументов). Остальное — по ситуации, по ощущениям и по тому, что написано в других файлах, которых много, и перечислять их все здесь было бы не в духе минимальной документации, которую мы как раз и стараемся не раздувать, хотя получается с переменным успехом.
Если нужна не вода, а плотнее — **`docs/`**: **[docs/README.md](docs/README.md)** (оглавление), glossary, pages, architecture; дорожная карта — **`docs/ROADMAP.md`**. Папка `docs/` в git не коммитится (локально). Если нет — ничего страшного, мир шире любого README.
---
*Конец важной части. Дальше могло бы что-то ещё быть, но в рамках заявленного баланса «минимум смысла — максимум слов» мы на этом, пожалуй, остановимся.*
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = migrations
sqlalchemy.url = sqlite:///data/recipes.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+532
View File
@@ -0,0 +1,532 @@
import logging
import os
import threading
import time
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from flask import Flask, abort, jsonify, redirect, request
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import event
from sqlalchemy.engine import Engine
from flask_cors import CORS
from config import ProductionConfig, apply_network_settings_to_app, apply_security_settings_to_app, get_config_class
db = SQLAlchemy()
# Flask-SQLAlchemy 3 не экспонирует db.app; env.py берёт приложение отсюда во время upgrade.
_alembic_host_app: Flask | None = None
def _configure_wesp_file_logging(app: Flask, log_level: int, fmt: str) -> None:
"""Пишет логи в WESP_ADMIN_LOG_PATH (по умолчанию <BASE_DIR>/data/logs/wesp.log), ротация в полночь, сутки хранения — WESP_LOG_RETENTION_DAYS."""
if app.config.get("TESTING"):
return
log_path = Path(str(app.config.get("WESP_ADMIN_LOG_PATH") or "")).expanduser()
if not log_path.name:
return
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
logging.getLogger(__name__).warning("Не удалось создать каталог логов: %s", log_path.parent, exc_info=True)
return
root = logging.getLogger()
target_abs = os.path.normcase(os.path.abspath(str(log_path)))
if any(
isinstance(h, TimedRotatingFileHandler)
and os.path.normcase(os.path.abspath(getattr(h, "baseFilename", ""))) == target_abs
for h in root.handlers
):
return
retention = max(1, int(app.config.get("WESP_LOG_RETENTION_DAYS", 3)))
backup_count = max(0, retention - 1)
try:
handler = TimedRotatingFileHandler(
filename=str(log_path),
when="midnight",
interval=1,
backupCount=backup_count,
encoding="utf-8",
delay=True,
)
except OSError:
logging.getLogger(__name__).warning("Не удалось открыть файл лога: %s", log_path, exc_info=True)
return
# В файл — префикс времени (для ленты activity-feed); порог как у LOG_LEVEL / WESP_LOG_LEVEL.
file_fmt = "%(asctime)s %(levelname)s:%(name)s:%(message)s"
handler.setFormatter(logging.Formatter(file_fmt, datefmt="%Y-%m-%d %H:%M:%S"))
handler.setLevel(log_level)
root.addHandler(handler)
logging.getLogger(__name__).info(
"Файловый лог: %s (порог файла %s, хранение %s сут.)",
log_path,
logging.getLevelName(log_level),
retention,
)
def _parse_log_level(value) -> int:
"""Уровень логов из конфига (LOG_LEVEL / WESP_LOG_LEVEL): только имена уровней logging."""
if value is None or value == "":
return logging.INFO
name = str(value).strip().upper()
level = getattr(logging, name, None)
return level if isinstance(level, int) else logging.INFO
def _install_immediate_log_flush(root: logging.Logger) -> None:
"""Сразу сбрасывает буфер после записи (удобно для tail -f и логов из фонового sync_client)."""
for handler in list(root.handlers):
orig_emit = handler.emit
def _emit_with_flush(record, _orig=orig_emit, _h=handler):
_orig(record)
try:
_h.flush()
except Exception:
pass
handler.emit = _emit_with_flush # type: ignore[method-assign]
def _start_sync_requeue_worker(app: Flask) -> None:
"""Периодический requeue_stuck_processing (ТЗ п. 4.6), без зависимости от sync pull."""
interval = int(app.config.get("SYNC_REQUEUE_INTERVAL_SEC", 120))
log = logging.getLogger(__name__)
def _loop() -> None:
while True:
try:
time.sleep(interval)
with app.app_context():
from app.services.sync_manager import requeue_stuck_processing
timeout = int(app.config.get("SYNC_REQUEUE_TIMEOUT_MINUTES", 15))
n = requeue_stuck_processing(timeout_minutes=timeout)
if n:
log.info(
"Фоновый requeue sync_queue: возвращено в pending %s задач (timeout %s мин)",
n,
timeout,
)
except Exception:
log.exception("Фоновый requeue sync_queue: ошибка цикла")
threading.Thread(target=_loop, daemon=True, name="wesp-sync-requeue").start()
def _register_sklad_api(app: Flask) -> None:
"""Регистрирует полные маршруты /api/sklad из корневого sklad.py (остатки + расход из reports.db)."""
from sklad import init_sklad
from app.models import (
Component,
Ingredient,
LoadingReport,
LoadingReportComponent,
Recipe,
)
init_sklad(
app,
db,
Component=Component,
LoadingReport=LoadingReport,
LoadingReportComponent=LoadingReportComponent,
Recipe=Recipe,
Ingredient=Ingredient,
)
def _run_auto_migrations(app: Flask) -> None:
"""Применяет alembic upgrade head (идемпотентно для существующей схемы)."""
from alembic import command
from alembic.config import Config as AlembicConfig
# Чтобы migrations/env.py подключился к тем же URI, что и это приложение
os.environ["WESP_RECIPES_DB_URI"] = app.config["SQLALCHEMY_DATABASE_URI"]
binds = app.config.get("SQLALCHEMY_BINDS") or {}
if binds.get("reports"):
os.environ["WESP_REPORTS_DB_URI"] = binds["reports"]
else:
os.environ.pop("WESP_REPORTS_DB_URI", None)
project_root = Path(__file__).resolve().parent.parent
ini_path = project_root / "alembic.ini"
cfg = AlembicConfig(str(ini_path))
cfg.set_main_option("script_location", str(project_root / "migrations"))
from app.services.migration_pace import (
migration_pause,
migration_pause_bind,
release_sqlalchemy_sqlite_locks,
)
global _alembic_host_app
log = logging.getLogger(__name__)
os.environ.pop("WESP_MIGRATION_BODY_RAN_COUNT", None)
migration_pause("перед alembic upgrade")
release_sqlalchemy_sqlite_locks(app)
log.info(
"База данных: Alembic upgrade head (recipes + reports — две БД, не повторный вызов create_app)..."
)
# env.py выставляет WESP_ALEMBIC=1 через setdefault — снимаем после upgrade, иначе mDNS и др. службы
# навсегда считают процесс «режимом Alembic» (см. mdns_skip_reason_on_boot).
_alembic_flag_present = "WESP_ALEMBIC" in os.environ
_alembic_flag_prev = os.environ.get("WESP_ALEMBIC")
_alembic_host_app = app
try:
command.upgrade(cfg, "head")
finally:
_alembic_host_app = None
if _alembic_flag_present:
if _alembic_flag_prev is not None:
os.environ["WESP_ALEMBIC"] = _alembic_flag_prev
else:
os.environ.pop("WESP_ALEMBIC", None)
migration_pause("после alembic upgrade")
from alembic.script import ScriptDirectory
head_rev = ScriptDirectory.from_config(cfg).get_current_head()
log.info("Миграции Alembic применены (ревизия %s).", head_rev or "")
# Догонка только если upgrade не выполнял тело wesp_2_0 (stamp / частичный прогон).
binds = app.config.get("SQLALCHEMY_BINDS") or {}
expected_bodies = 2 if binds.get("reports") else 1
bodies_ran = int(os.environ.pop("WESP_MIGRATION_BODY_RAN_COUNT", "0") or "0")
if bodies_ran >= expected_bodies:
log.info(
"Идемпотентная догонка схемы пропущена: wesp_2_0 уже выполнен в Alembic (%s/%s БД).",
bodies_ran,
expected_bodies,
)
return
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from app.schema_bootstrap import run_wesp20_idempotent_sync
log.info(
"Идемпотентная догонка схемы 2.0 (Alembic не прогнал тело миграции, bodies=%s/%s)...",
bodies_ran,
expected_bodies,
)
with app.app_context():
def _sync(engine: Engine, kw: dict) -> None:
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
run_wesp20_idempotent_sync(Operations(ctx), kw)
_sync(db.engine, {})
if binds.get("reports") and "reports" in db.engines:
migration_pause_bind("догонка recipes → reports")
_sync(db.engines["reports"], {"tag": "reports"})
migration_pause("после догонки схемы")
log.info("Идемпотентная догонка схемы 2.0 выполнена.")
def create_app(
config_class: type | None = None,
*,
run_migrations: bool = True,
) -> Flask:
"""Application Factory для WESP."""
import wesp_runtime_env
wesp_runtime_env.apply_kiosk_headless_env()
if config_class is None:
config_class = get_config_class()
# env.py внутри command.upgrade: не второй Flask и не повторный db.init_app.
if (
not run_migrations
and os.environ.get("WESP_ALEMBIC", "").strip() == "1"
and _alembic_host_app is not None
):
return _alembic_host_app
project_root = Path(__file__).resolve().parent.parent
static_dir = project_root / "static"
templates_dir = project_root / "templates"
app = Flask(
__name__,
static_folder=str(static_dir),
static_url_path="/static",
template_folder=str(templates_dir),
)
app.config.from_object(config_class)
apply_security_settings_to_app(app)
apply_network_settings_to_app(app)
# env.py: create_app(run_migrations=False) — без повторного upgrade и без лишнего I/O.
_alembic_env_only = (
os.environ.get("WESP_ALEMBIC", "").strip() == "1" and not run_migrations
)
if run_migrations and not app.config.get("TESTING") and config_class is ProductionConfig:
from config import validate_production_config
validate_production_config(app)
if not app.config.get("TESTING") and not _alembic_env_only:
try:
Path(app.config["BASE_DIR"], "data").mkdir(parents=True, exist_ok=True)
except OSError:
logging.getLogger(__name__).warning(
"Не удалось создать каталог data/: %s",
Path(app.config["BASE_DIR"], "data"),
exc_info=True,
)
try:
from app.services.wesp_assistant_paths import ensure_assistant_layout
ensure_assistant_layout(app.config.get("WESP_ASSISTANT_DIR"))
except Exception:
logging.getLogger(__name__).warning(
"Каталог ассистента (WESP_ASSISTANT_DIR) не подготовлен",
exc_info=True,
)
log = logging.getLogger(__name__)
if not _alembic_env_only:
# Логирование (WESP_LOG_LEVEL → Config.LOG_LEVEL)
log_level = _parse_log_level(app.config.get("LOG_LEVEL"))
_fmt = "%(levelname)s:%(name)s:%(message)s"
root = logging.getLogger()
root.setLevel(logging.DEBUG)
if not root.handlers:
logging.basicConfig(level=logging.DEBUG, format=_fmt)
for _h in root.handlers:
_h.setLevel(log_level)
for _name in ("app", "app.routes", "app.services", "sync_client"):
logging.getLogger(_name).setLevel(min(log_level, logging.ERROR))
_configure_wesp_file_logging(app, log_level, _fmt)
_install_immediate_log_flush(root)
try:
# Расширения
db.init_app(app)
CORS(app)
# Настройки SQLite (WAL, synchronous, busy_timeout) на каждом engine (основной + binds).
def _register_sqlite_pragmas(bind_engine: Engine) -> None:
if bind_engine.dialect.name != "sqlite":
return
@event.listens_for(bind_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record): # type: ignore[override]
cursor = dbapi_connection.cursor()
cursor.execute(f"PRAGMA journal_mode={app.config.get('SQLITE_JOURNAL_MODE', 'WAL')}")
cursor.execute(f"PRAGMA synchronous={app.config.get('SQLITE_SYNCHRONOUS', 'NORMAL')}")
busy_ms = int(app.config.get("SQLITE_BUSY_TIMEOUT_MS", 30000))
cursor.execute(f"PRAGMA busy_timeout={busy_ms}")
cursor.close()
with app.app_context():
for _eng in db.engines.values():
_register_sqlite_pragmas(_eng)
# Импортируем модели, чтобы зарегистрировать их в metadata
from app import models # noqa: F401
from importlib import import_module
import_module("app.lab.models")
if not app.config.get("TESTING") and not _alembic_env_only:
try:
from config import repair_sync_client_state_on_disk
repair_sync_client_state_on_disk()
except Exception:
log.debug("repair_sync_client_state_on_disk", exc_info=True)
_defer_heavy_startup = (
run_migrations
and not app.config.get("TESTING")
and not _alembic_env_only
and bool(app.config.get("WESP_BACKGROUND_STARTUP", True))
)
app.config["WESP_DEFERRED_STARTUP"] = _defer_heavy_startup
if run_migrations and not app.config.get("TESTING") and not _defer_heavy_startup:
_run_auto_migrations(app)
if _alembic_env_only:
log.debug(
"create_app: режим WESP_ALEMBIC (только ORM; upgrade в env.py, без повторных миграций)"
)
return app
# Регистрация blueprints (доступны /api/startup/status сразу; остальное — после готовности).
from .routes import register_blueprints
register_blueprints(app)
# Маршруты sklad регистрируются до первого HTTP-запроса (нельзя в фоне после request).
_register_sklad_api(app)
if run_migrations and not app.config.get("TESTING") and not _defer_heavy_startup:
from app.services.startup_background import _finish_app_startup
from app.services.startup_state import mark_startup_ready
_finish_app_startup(app)
mark_startup_ready()
if not _defer_heavy_startup:
if (
run_migrations
and not app.config.get("TESTING")
and app.config.get("SYNC_BACKGROUND_REQUEUE", True)
):
from app.services.startup_defer import schedule_sync_requeue_after_boot
schedule_sync_requeue_after_boot(app)
if not app.config.get("TESTING") and run_migrations:
try:
import psutil # noqa: F401
psutil.cpu_percent(interval=None)
except ImportError:
log.warning(
"Пакет psutil не установлен: кольца CPU/RAM в админке будут пустыми. "
"Установите: pip install psutil"
)
except Exception:
log.debug("psutil: прогрев при старте не удался", exc_info=True)
try:
from app.services.startup_defer import schedule_mdns_after_boot
schedule_mdns_after_boot(app)
except Exception:
log.warning("mDNS не запланирован при старте", exc_info=True)
from app.services.startup_defer import schedule_bootstrap_update_env
schedule_bootstrap_update_env(app)
# Киоск: те же HTML могут запрашиваться по /static/... — не блокировать (отдельная защита на маршрутах киоска).
_STATIC_HTML_ALLOWED = frozenset(
{
"/static/login.html",
"/static/unauthorized.html",
"/static/recipes_selection.html", # !!!! костыль
"/static/unloading.html", # !!!! костыль
"/static/startup-loading.html",
}
)
@app.before_request
def _wesp_startup_readiness_gate():
if app.config.get("TESTING"):
return None
from app.services.startup_background import is_startup_ready_implicit
from app.services.startup_gate import is_path_allowed_during_startup
if is_startup_ready_implicit(app):
return None
path = request.path or ""
if is_path_allowed_during_startup(path):
return None
if path.startswith("/api/"):
from app.services.startup_state import startup_status_payload
body = startup_status_payload()
return (
jsonify(
{
"status": "starting",
"message": "Инициализация WESP…",
**body,
}
),
503,
)
from urllib.parse import quote
from app.services.hardware_settings_service import default_app_landing_path
path_only = request.path or "/"
if path_only in ("/", "/starting"):
next_path = default_app_landing_path()
else:
next_path = request.full_path or path_only
if next_path.endswith("?") and not request.query_string:
next_path = path_only
return redirect(f"/starting?next={quote(next_path, safe='/?:=&')}")
if _defer_heavy_startup:
from app.services.startup_background import schedule_background_startup
schedule_background_startup(app)
@app.before_request
def _restrict_static_html_files() -> None:
"""Не отдавать HTML из static/ напрямую, кроме явного allowlist (обход именованных маршрутов)."""
path = request.path
if not path.startswith("/static/") or not path.endswith(".html"):
return None
if path in _STATIC_HTML_ALLOWED:
return None
abort(404)
@app.after_request
def _html_head_injects(response):
from app.html_head_injects import apply_html_head_injects
return apply_html_head_injects(response, request.path or "")
@app.errorhandler(404)
def _log_404(error): # type: ignore[no-untyped-def]
"""Диагностика киоска: фиксируем неожиданные 404 (в т.ч. для статики)."""
try:
ua = (request.headers.get("User-Agent") or "").strip()
ref = (request.headers.get("Referer") or "").strip()
logging.getLogger(__name__).warning(
"[404] path=%s host=%s remote=%s ua=%s ref=%s",
request.path,
request.host,
request.remote_addr,
ua[:200],
ref[:200],
)
except Exception:
pass
return error.get_response()
return app
except Exception:
log.exception(
"Сбой при инициализации приложения (create_app): миграции, blueprints или склад"
)
_append_startup_crash_dump(app)
raise
def _append_startup_crash_dump(app: Flask) -> None:
"""Дублирует traceback в отдельный файл, если основной файловый лог ещё не пишет ERROR (например, только WARNING+)."""
if app.config.get("TESTING"):
return
try:
import traceback
log_path = Path(str(app.config.get("WESP_ADMIN_LOG_PATH") or "")).expanduser()
if not log_path.name:
log_path = Path(app.config["BASE_DIR"], "data", "logs", "wesp.log")
crash_path = log_path.parent / "wesp-startup-crash.log"
crash_path.parent.mkdir(parents=True, exist_ok=True)
with crash_path.open("a", encoding="utf-8") as f:
f.write("\n--- create_app failure ---\n")
traceback.print_exc(file=f)
except OSError:
logging.getLogger(__name__).debug("Не удалось записать wesp-startup-crash.log", exc_info=True)
+93
View File
@@ -0,0 +1,93 @@
"""Общие вставки в <head> HTML-ответов (favicon и т.д.)."""
from __future__ import annotations
import re
from flask import Response
_FAVICON_TAGS = (
'<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">\n'
' <link rel="icon" href="/static/favicon-32.png" type="image/png" sizes="32x32">\n'
' <link rel="apple-touch-icon" href="/static/apple-touch-icon.png">'
)
_HAS_FAVICON_RE = re.compile(
r'<link[^>]+rel=["\'](?:shortcut\s+)?icon["\']',
re.IGNORECASE,
)
def inject_favicon_html(html: str) -> str:
"""Добавить favicon в <head>, если его ещё нет."""
if _HAS_FAVICON_RE.search(html):
return html
if "<head>" in html.lower():
return re.sub(
r"<head>",
"<head>\n " + _FAVICON_TAGS,
html,
count=1,
flags=re.IGNORECASE,
)
return _FAVICON_TAGS + "\n" + html
def _patch_html_response(response: Response, patch_fn) -> Response:
content_type = (response.content_type or "").lower()
if "text/html" not in content_type:
return response
if getattr(response, "direct_passthrough", False):
return response
try:
html = response.get_data(as_text=True)
except (TypeError, UnicodeDecodeError, RuntimeError):
return response
if not html or "<html" not in html.lower():
return response
patched = patch_fn(html)
if patched == html:
return response
response.set_data(patched)
if response.content_length is not None:
response.headers.pop("Content-Length", None)
return response
def inject_kiosk_cursor_html(html: str, path: str) -> str:
"""Скрыть курсор на страницах киоска (см. static/css/kiosk-cursor.css)."""
from flask import current_app
from app.kiosk_page_hints import is_kiosk_cursor_hidden_path
if not is_kiosk_cursor_hidden_path(path):
return html
if not current_app.config.get("WESP_KIOSK_HIDE_CURSOR", True):
return html
if "kiosk-cursor.css" in html:
return html
tag = (
'<link rel="stylesheet" href="/static/css/kiosk-cursor.css">\n'
' <script>document.documentElement.classList.add("wesp-kiosk-hide-cursor");</script>'
)
if "<head>" in html.lower():
return re.sub(
r"<head>",
"<head>\n " + tag,
html,
count=1,
flags=re.IGNORECASE,
)
return tag + "\n" + html
def _patch_head(html: str, path: str) -> str:
html = inject_favicon_html(html)
return inject_kiosk_cursor_html(html, path)
def apply_html_head_injects(response: Response, path: str) -> Response:
from app.kiosk_page_hints import apply_kiosk_translate_hints
response = apply_kiosk_translate_hints(response, path)
return _patch_html_response(response, lambda html: _patch_head(html, path))
+21
View File
@@ -0,0 +1,21 @@
"""Cookie терминала киоска (wesp_kiosk_device_id)."""
from flask import current_app
COOKIE_NAME = "wesp_kiosk_device_id"
def kiosk_cookie_max_age() -> int:
return int(current_app.config.get("KIOSK_AUTH_COOKIE_DAYS", 30)) * 24 * 3600
def set_kiosk_device_cookie(response, device_id: str):
"""Привязать браузер к конкретному KioskDevice."""
response.set_cookie(
COOKIE_NAME,
device_id,
max_age=kiosk_cookie_max_age(),
httponly=True,
samesite="Lax",
)
return response
+123
View File
@@ -0,0 +1,123 @@
"""Подсказки HTML/HTTP для киоск-Chromium: не предлагать перевод страницы."""
from __future__ import annotations
import re
from typing import Tuple
from flask import Response
# Префиксы страниц киоска (localhost / весы / загрузка).
# Киоск-страницы, где курсор мыши остаётся видимым (остальные киоск-страницы — cursor: none).
_KIOSK_CURSOR_VISIBLE_PATHS: Tuple[str, ...] = (
"/scales",
)
_KIOSK_HTML_PATH_PREFIXES: Tuple[str, ...] = (
"/scales",
"/starting",
"/setup",
"/calibration",
"/calibrate",
"/kiosk/",
"/static/startup-loading.html",
)
_HEAD_INJECT = (
'<meta name="google" content="notranslate">\n'
' <meta name="googlebot" content="notranslate">\n'
' <meta http-equiv="Content-Language" content="ru">\n'
)
_HTML_TAG_RE = re.compile(r"<html(\s[^>]*)?>", re.IGNORECASE)
_HAS_NOTRANSLATE_RE = re.compile(
r'<meta[^>]+name=["\']google["\'][^>]+content=["\']notranslate["\']',
re.IGNORECASE,
)
def is_kiosk_html_path(path: str) -> bool:
if not path:
return False
for prefix in _KIOSK_HTML_PATH_PREFIXES:
if path == prefix or path.startswith(prefix):
return True
return False
def is_kiosk_cursor_hidden_path(path: str) -> bool:
"""Скрывать курсор на киоск-страницах, кроме явных исключений (например /scales)."""
if not is_kiosk_html_path(path):
return False
normalized = path.rstrip("/") or "/"
for visible in _KIOSK_CURSOR_VISIBLE_PATHS:
if normalized == visible.rstrip("/") or normalized.startswith(visible.rstrip("/") + "/"):
return False
return True
def kiosk_translate_response_headers() -> dict[str, str]:
return {
"Content-Language": "ru",
"Google-Translate": "no",
}
def inject_no_translate_html(html: str) -> str:
"""Вставить meta notranslate и translate=no на <html> (обман встроенного переводчика)."""
if _HAS_NOTRANSLATE_RE.search(html):
patched = html
elif "<head>" in html.lower():
patched = re.sub(
r"<head>",
"<head>\n " + _HEAD_INJECT.strip().replace("\n", "\n "),
html,
count=1,
flags=re.IGNORECASE,
)
else:
patched = _HEAD_INJECT + html
def _html_repl(match: re.Match[str]) -> str:
attrs = match.group(1) or ""
if re.search(r'\btranslate\s*=', attrs, re.IGNORECASE):
return match.group(0)
if re.search(r'\bclass\s*=', attrs, re.IGNORECASE):
attrs = re.sub(
r'class\s*=\s*["\']([^"\']*)["\']',
r'class="\1 notranslate"',
attrs,
count=1,
flags=re.IGNORECASE,
)
else:
attrs = f'{attrs} class="notranslate"'
if not re.search(r'\blang\s*=', attrs, re.IGNORECASE):
attrs = f'{attrs} lang="ru"'
return f"<html{attrs} translate=\"no\">"
return _HTML_TAG_RE.sub(_html_repl, patched, count=1)
def apply_kiosk_translate_hints(response: Response, path: str) -> Response:
if not is_kiosk_html_path(path):
return response
for key, value in kiosk_translate_response_headers().items():
response.headers[key] = value
content_type = (response.content_type or "").lower()
if "text/html" not in content_type:
return response
# send_from_directory и др. — direct passthrough; get_data() падает с RuntimeError.
if getattr(response, "direct_passthrough", False):
return response
try:
html = response.get_data(as_text=True)
except (TypeError, UnicodeDecodeError, RuntimeError):
return response
if not html or "<html" not in html.lower():
return response
patched = inject_no_translate_html(html)
response.set_data(patched)
if response.content_length is not None:
response.headers.pop("Content-Length", None)
return response
+31
View File
@@ -0,0 +1,31 @@
"""Локальная генерация QR для привязки киоска (без внешних HTTP и без Pillow)."""
import base64
from io import BytesIO
import qrcode
import qrcode.image.svg
def _svg_image_factory():
"""SvgPathFillImage — белый фон; в старых версиях qrcode может не быть."""
svg = qrcode.image.svg
return getattr(svg, "SvgPathFillImage", svg.SvgPathImage)
def pair_url_to_qr_data_uri(pair_url: str) -> str:
"""QR-код как data URI (SVG) для подстановки в img src. Работает без Pillow."""
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=4,
image_factory=_svg_image_factory(),
)
qr.add_data(pair_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/svg+xml;base64,{b64}"
+1
View File
@@ -0,0 +1 @@
"""Zootech lab module (Neoton tab integration)."""
+4
View File
@@ -0,0 +1,4 @@
from .engine import calculate_ration
from .diff import compare_master_execution
__all__ = ["calculate_ration", "compare_master_execution"]
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Any
from app.lab.calc.derived import apply_content_derived
from app.lab.calc.nutrients import norm_diff, weighted_average
from app.lab.indicators import RATION_ALL_INDICATORS
def _compound_active(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
l
for l in lines
if l.get("in_compound") and (l.get("daily_kg") or 0) > 0
]
def _sum_kg(lines: list[dict[str, Any]]) -> float:
return sum(float(l.get("daily_kg") or 0) for l in lines)
def _compound_indicators(
lines: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
) -> list[dict[str, Any]]:
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
if defn.get("derived"):
continue
content_by_key[defn["key"]] = weighted_average(
lines,
total_kg,
defn["nutrient_keys"],
indicator_key=defn.get("key"),
)
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
content_by_key[defn["key"]] = apply_content_derived(
content_by_key,
defn,
compound_mode=True,
)
rows = []
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
content = content_by_key.get(key)
bounds = norms.get(key, {})
min_v = bounds.get("min")
max_v = bounds.get("max")
diff = norm_diff(content, min_v, max_v)
if content is None and min_v is None and max_v is None:
continue
rows.append(
{
"key": key,
"label": defn["label"],
"unit": defn["unit"],
"min": min_v,
"max": max_v,
"content": content,
"diff": diff,
}
)
return rows
def calculate_compound_feed(
lines: list[dict[str, Any]],
norms: dict[str, dict[str, float | None]] | None = None,
*,
profile_mass_kg: float | None = None,
heads_per_trip: int = 1,
) -> dict[str, Any] | None:
del profile_mass_kg, heads_per_trip
active = _compound_active(lines)
if not active:
return None
total_kg = _sum_kg(active)
cost = None
any_cost = False
for line in active:
kg = line.get("daily_kg")
price = line.get("price_per_kg")
if kg is None or price is None:
continue
cost = (cost or 0) + float(kg) * float(price)
any_cost = True
return {
"totals": [
{"key": "compound_kg", "label": "Масса комбикорма, кг", "value": total_kg},
{
"key": "compound_cost",
"label": "Стоимость комбикорма",
"value": cost if any_cost else None,
},
],
"indicators": _compound_indicators(active, total_kg, norms or {}),
"lines": [
{
"ingredient_name": line.get("ingredient_name") or "",
"daily_kg": line.get("daily_kg"),
"share_pct": (
(float(line["daily_kg"]) / total_kg) * 100
if total_kg > 0 and line.get("daily_kg") is not None
else None
),
}
for line in active
],
}
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Any
from app.lab.calc.nutrients import rnb
def apply_content_derived(
content_by_key: dict[str, float | None],
defn: dict[str, Any],
*,
total_kg: float = 0,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
compound_mode: bool = False,
) -> float | None:
derived = defn.get("derived")
if derived == "alias":
return content_by_key.get(defn.get("alias_of"))
if derived == "pct_of_dm":
src = content_by_key.get(defn.get("from_key"))
dm = content_by_key.get("dry_matter")
if src is None or not dm or dm <= 0:
return None
return src / dm * 100.0
if derived == "g_per_kg_dm":
src = content_by_key.get(defn.get("from_key"))
dm = content_by_key.get("dry_matter")
if src is None or not dm or dm <= 0:
return None
if compound_mode:
return src
return src / (dm / 1000.0)
if derived == "nel_per_kg_dm":
dm = content_by_key.get("dry_matter")
nel = content_by_key.get("nel")
if not dm or dm <= 0 or nel is None:
return None
if compound_mode:
return nel
return nel / (dm / 1000.0)
if derived == "ratio":
num = content_by_key.get(defn.get("ratio_num"))
den = content_by_key.get(defn.get("ratio_den"))
if num is None or den is None or den == 0:
return None
return num / den
if derived == "dm_pct_bw":
dm = content_by_key.get("dry_matter")
if dm is None or not profile_mass_kg or profile_mass_kg <= 0:
return None
return (dm / 1000.0 / profile_mass_kg) * 100.0
if derived == "ration_pct_bw":
if not profile_mass_kg or profile_mass_kg <= 0 or total_kg <= 0:
return None
heads = max(int(heads_per_trip or 1), 1)
kg_per_head = total_kg / heads
return (kg_per_head / profile_mass_kg) * 100.0
if derived in ("rnb", "bra_rnb"):
return rnb(
content_by_key.get("crude_protein"),
content_by_key.get("usp"),
)
return None
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from app.lab.constants import DIFF_TOLERANCE_KG
def compare_master_execution(
master_lines: list[dict[str, Any]],
execution_lines: list[dict[str, Any]],
) -> dict[str, Any]:
master_map: dict[str, float] = {}
for line in master_lines:
if not line.get("in_ration"):
continue
cid = line.get("component_id")
if not cid:
continue
master_map[str(cid)] = float(line.get("daily_kg") or 0)
exec_map: dict[str, float] = {}
for line in execution_lines:
cid = line.get("component_id")
if not cid:
continue
exec_map[str(cid)] = float(line.get("daily_kg_total") or 0)
all_ids = set(master_map) | set(exec_map)
diff_lines = []
has_changes = False
for cid in sorted(all_ids):
m = master_map.get(cid)
e = exec_map.get(cid)
reasons = []
if m is None:
reasons.append("missing_in_master")
has_changes = True
if e is None:
reasons.append("missing_in_execution")
has_changes = True
if m is not None and e is not None and abs(m - e) > DIFF_TOLERANCE_KG:
reasons.append("kg_mismatch")
has_changes = True
if reasons:
diff_lines.append(
{
"component_id": cid,
"master_kg": m,
"execution_kg": e,
"reasons": reasons,
}
)
return {"has_changes": has_changes, "lines": diff_lines}
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from app.lab.calc.compound import calculate_compound_feed
from app.lab.calc.derived import apply_content_derived
from app.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
from app.lab.constants import RATION_TOTAL_KEYS
from app.lab.indicators import RATION_ALL_INDICATORS
def _active_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
l
for l in lines
if l.get("in_ration") and (l.get("daily_kg") or 0) > 0
]
def _sum_kg(lines: list[dict[str, Any]]) -> float:
return sum(float(l.get("daily_kg") or 0) for l in lines)
def _sum_cost(lines: list[dict[str, Any]]) -> float | None:
total = 0.0
any_cost = False
for line in lines:
kg = line.get("daily_kg")
price = line.get("price_per_kg")
if kg is None or price is None:
continue
total += float(kg) * float(price)
any_cost = True
return total if any_cost else None
def _sum_ration_percent(lines: list[dict[str, Any]], total_kg: float) -> float | None:
if total_kg <= 0:
return None
return sum((float(l.get("daily_kg") or 0) / total_kg) * 100 for l in lines)
def _compute_totals(
ration_type: str,
active: list[dict[str, Any]],
total_kg: float,
) -> list[dict[str, Any]]:
defs = RATION_TOTAL_KEYS.get(ration_type, RATION_TOTAL_KEYS["BEEF"])
ration_kg = _sum_kg(active)
values = {
"total_kg": total_kg if total_kg > 0 else None,
"ration_kg": ration_kg if ration_kg > 0 else None,
"ration_pct_sum": _sum_ration_percent(active, total_kg),
"cost_total": _sum_cost(active),
}
return [{"key": d["key"], "label": d["label"], "value": values.get(d["key"])} for d in defs]
def _indicator_content(
defn: dict[str, Any],
active: list[dict[str, Any]],
total_kg: float,
*,
heads_per_trip: int,
) -> float | None:
key = defn.get("key")
if defn.get("aggregation") == "weighted_avg":
return weighted_average(
active,
total_kg,
defn["nutrient_keys"],
indicator_key=key,
)
return daily_intake_total(
active,
defn["nutrient_keys"],
heads_per_trip=heads_per_trip,
indicator_key=key,
)
def _compute_indicators(
active: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> list[dict[str, Any]]:
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
if defn.get("derived"):
continue
content_by_key[defn["key"]] = _indicator_content(
defn, active, total_kg, heads_per_trip=heads_per_trip
)
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
content_by_key[defn["key"]] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=heads_per_trip,
profile_mass_kg=profile_mass_kg,
)
rows = []
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
content = content_by_key.get(key)
bounds = norms.get(key, {})
min_v = bounds.get("min")
max_v = bounds.get("max")
diff = norm_diff(content, min_v, max_v)
if content is None and min_v is None and max_v is None:
continue
rows.append(
{
"key": key,
"label": defn["label"],
"unit": defn["unit"],
"min": min_v,
"max": max_v,
"content": content,
"diff": diff,
}
)
return rows
def _missing_nutrient_warnings(active: list[dict[str, Any]]) -> list[str]:
warnings: list[str] = []
for line in active:
nutrients = line.get("nutrients") or {}
cp = get_nutrient_value(
line.get("dry_matter"),
nutrients,
["Сыр. Протеин"],
indicator_key="crude_protein",
)
if cp is not None:
continue
name = line.get("ingredient_name") or line.get("component_id") or "?"
warnings.append(f"nutrients_missing:{name}")
return warnings
def calculate_ration(
ration_type: str,
lines: list[dict[str, Any]],
norms: dict[str, dict[str, float | None]] | None = None,
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> dict[str, Any]:
norms = norms or {}
errors: list[str] = []
active = _active_lines(lines)
total_kg = _sum_kg(active)
heads = max(int(heads_per_trip or 1), 1)
if not active:
errors.append("Нет строк сырья «в рационе» с дозировкой кг/день")
warnings = _missing_nutrient_warnings(active)
compound = calculate_compound_feed(
lines, norms, profile_mass_kg=profile_mass_kg, heads_per_trip=heads
)
return {
"calculated_at": datetime.now(timezone.utc).isoformat(),
"engine": "native",
"totals": _compute_totals(ration_type, active, total_kg),
"indicators": _compute_indicators(
active,
total_kg,
norms,
heads_per_trip=heads,
profile_mass_kg=profile_mass_kg,
),
"compound": compound,
"errors": errors,
"warnings": warnings,
}
+211
View File
@@ -0,0 +1,211 @@
"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг."""
from __future__ import annotations
from typing import Any
from app.models import Component
FEED_GROUPS: tuple[dict[str, Any], ...] = (
{
"id": "rough",
"label": "База — грубые",
"shortLabel": "База",
"required": True,
"minPick": 1,
"hint": "Без каркаса партия не взлетит. Я не бизнесмен — я специалист.",
"step": 1,
},
{
"id": "succulent",
"label": "Влага — сочные",
"shortLabel": "Влага",
"required": False,
"minPick": 0,
"hint": "Сочное сырьё. Можно не мешать — но чистота пострадает.",
"step": 2,
},
{
"id": "concentrate",
"label": "Энергия — концентраты",
"shortLabel": "Энергия",
"required": False,
"minPick": 0,
"hint": "Концентрат дозируй как реагент — точно. Держись подальше от моей территории.",
"step": 3,
},
{
"id": "other",
"label": "Добавки",
"shortLabel": "Добавки",
"required": False,
"minPick": 0,
"hint": "Минералы и премикс. Необязательно. Но Хайзенберг бы не пропустил.",
"step": 4,
},
)
# Канонические значения component.type (выбор в /components)
FEED_COMPONENT_TYPES: tuple[dict[str, str], ...] = (
{
"value": "Грубые корма",
"feedGroup": "rough",
"description": "Сено, солома.",
},
{
"value": "Сочные корма",
"feedGroup": "succulent",
"description": "Силос, корнеплоды.",
},
{
"value": "Концентрированные",
"feedGroup": "concentrate",
"description": "Зерно, комбикорм, жмых, шрот",
},
{
"value": "Добавки",
"feedGroup": "other",
"description": "Премикс, минералы, витамины, КЖП",
},
)
_GROUP_BY_ID = {g["id"]: g for g in FEED_GROUPS}
_LEGACY_TYPE_TO_GROUP: dict[str, str] = {
"зерновые": "concentrate",
"энергетические": "concentrate",
"белковые": "concentrate",
"минеральные": "other",
"витаминные": "other",
}
# Старый тип «Объемные корма» — уточните до Грубые/Сочные; эвристика по имени
_ROUGH_NAME_KEYS = ("солом", "сено", "hay", "straw")
_SUCCULENT_NAME_KEYS = ("силос", "сенаж", "сочн", "корнеплод", "свекл", "морков", "тыкв", "зелен")
_CONCENTRATE_NAME_KEYS = ("зерн", "концентр", "комбикорм", "комбик", "жмых", "шрот", "дробин", "пивн")
def _norm(text: str | None) -> str:
return (text or "").strip().lower()
def _canonical_type_map() -> dict[str, str]:
return {_norm(t["value"]): t["feedGroup"] for t in FEED_COMPONENT_TYPES}
def _canonical_values() -> list[str]:
return [t["value"] for t in FEED_COMPONENT_TYPES]
def list_component_feed_types() -> list[dict[str, str]]:
return [
{
"value": t["value"],
"feedGroup": t["feedGroup"],
"description": t["description"],
"groupLabel": _GROUP_BY_ID[t["feedGroup"]]["label"],
}
for t in FEED_COMPONENT_TYPES
]
def is_canonical_feed_type(component_type: str | None) -> bool:
return _norm(component_type) in _canonical_type_map()
def feed_group_for_type(component_type: str | None) -> str | None:
"""Группа авторациона по component.type (канон или legacy)."""
ctype = _norm(component_type)
if not ctype:
return None
canonical = _canonical_type_map().get(ctype)
if canonical:
return canonical
if ctype in ("объемные корма", "объёмные корма"):
return None
return _LEGACY_TYPE_TO_GROUP.get(ctype)
def classify_feed_group(comp: Component) -> str:
"""Группа для авторациона: сначала component.type, иначе эвристика по имени (legacy)."""
by_type = feed_group_for_type(comp.type)
if by_type:
return by_type
name = _norm(comp.name)
def has_any(keys: tuple[str, ...]) -> bool:
return any(k in name for k in keys)
if has_any(_ROUGH_NAME_KEYS):
return "rough"
if has_any(_SUCCULENT_NAME_KEYS):
return "succulent"
if has_any(_CONCENTRATE_NAME_KEYS):
return "concentrate"
if _norm(comp.type) in ("объемные корма", "объёмные корма"):
return "succulent"
return "other"
def list_feed_groups_api() -> list[dict[str, Any]]:
return [
{
"id": g["id"],
"label": g["label"],
"shortLabel": g["shortLabel"],
"required": g["required"],
"minPick": g["minPick"],
"hint": g["hint"],
"step": g["step"],
}
for g in FEED_GROUPS
]
def parse_group_selections(raw: Any) -> dict[str, list[str]]:
if not isinstance(raw, dict):
return {}
out: dict[str, list[str]] = {}
for gid in _GROUP_BY_ID:
vals = raw.get(gid) or []
if isinstance(vals, list):
out[gid] = [str(x) for x in vals if x]
return out
def flatten_group_selections(selections: dict[str, list[str]]) -> list[str]:
seen: list[str] = []
for gid in _GROUP_BY_ID:
for cid in selections.get(gid) or []:
if cid not in seen:
seen.append(cid)
return seen
def validate_group_selections(selections: dict[str, list[str]]) -> list[str]:
errors: list[str] = []
for g in FEED_GROUPS:
gid = g["id"]
picked = selections.get(gid) or []
if g["required"] and len(picked) < int(g["minPick"]):
errors.append(f"{gid}:need_{g['minPick']}")
total = len(flatten_group_selections(selections))
if total < 3:
errors.append("pool:need_3")
return errors
def triplet_meets_group_rules(
triplet_ids: set[str],
selections: dict[str, list[str]],
) -> bool:
for g in FEED_GROUPS:
if not g["required"]:
continue
pool = set(selections.get(g["id"]) or [])
if not pool:
continue
if not triplet_ids & pool:
return False
return True
+417
View File
@@ -0,0 +1,417 @@
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
from typing import Any
from app.lab.calc.engine import calculate_ration
from app.lab.calc.feed_groups import (
FEED_GROUPS,
flatten_group_selections,
triplet_meets_group_rules,
validate_group_selections,
)
from app.lab.calc.feed_groups import classify_feed_group as _classify_feed_group
from app.lab.calc.formulate_optimize import optimize_shares_for_triplet
from app.lab.calc.formulate_score import (
build_calc_lines,
build_triplet_score_model,
score_model_shares,
violation_score,
)
from app.lab.calc.formulate_validate import validate_component, validate_components
from app.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
from app.lab.calc.nutrients import get_nutrient_value
from app.lab.indicators import RATION_ALL_INDICATORS
from app.lab.models import LabAnimalProfile
from app.lab.services.component_nutrients import nutrients_calc_dict_batch
from app.lab.services.profile_norms import load_norms_dict
from app.models import Component
@dataclass
class FormulateRequest:
profile_id: str
candidate_ids: list[str] = field(default_factory=list)
group_selections: dict[str, list[str]] = field(default_factory=dict)
main_feed_ids: list[str] = field(default_factory=list) # legacy → rough
mass_kg: float | None = None
milk_yield_kg: float | None = None
heads_per_trip: int | None = None
total_kg_per_head: float = 7.3
optimize_keys: list[str] = field(default_factory=list)
objective: str = "min_cost"
cost_weight: float = 100.0
grid_step: float = 0.1
prefilter_k: int = 18
min_share: float = 0.05
norms_method: str = "wesp"
norms_params: dict[str, Any] = field(default_factory=dict)
_DEFAULT_OPTIMIZE_KEYS = (
"dry_matter",
"usp",
"nel",
"crude_protein",
"rnb",
"nfc_pct_dm_uk",
)
def _indicator_def(key: str) -> dict[str, Any] | None:
for defn in RATION_ALL_INDICATORS:
if defn["key"] == key:
return defn
return None
def _resolve_norms(profile: LabAnimalProfile, req: FormulateRequest) -> tuple[dict, dict]:
stored = load_norms_dict(profile.id)
mass = req.mass_kg if req.mass_kg is not None else profile.mass_kg
milk = req.milk_yield_kg if req.milk_yield_kg is not None else profile.milk_yield_kg
from app.lab.services.norms_params import load_norms_params
method = normalize_norms_method(req.norms_method or profile.norms_method)
params = load_norms_params(profile)
if req.norms_params:
merged = {
"milkFatPct": params.milk_fat_pct,
"lactationNo": params.lactation_no,
"lactationStage": params.lactation_stage,
"bodyCondition": params.body_condition,
"housingSystem": params.housing_system,
"koncOeSv": params.konc_oe_sv,
}
merged.update(req.norms_params)
params = NormsParams.from_dict(merged)
resolved, meta = resolve_norms(
NormsResolveRequest(
method=method,
stored=stored,
mass_kg=mass,
milk_yield_kg=milk,
ration_type=profile.ration_type,
force_dynamic=method == "wesp",
params=params,
)
)
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
return resolved, {"normsMethod": method, "dynamicNorms": dynamic, "normsMeta": meta.get("meta")}
def _rough_component_value(
comp: Component,
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
nutrient_cache: dict[str, dict[str, float]],
) -> float | None:
nutrients = nutrient_cache.get(comp.id, {})
dm_pct = comp.dry_matter
total = 0.0
counted = 0
for key in optimize_keys:
defn = _indicator_def(key)
if defn is None or defn.get("derived"):
continue
bounds = norms.get(key) or {}
target_min = bounds.get("min")
target_max = bounds.get("max")
if target_min is None and target_max is None:
continue
target = None
if target_min is not None and target_max is not None:
target = (float(target_min) + float(target_max)) / 2.0
elif target_max is not None:
target = float(target_max) * 0.9
elif target_min is not None:
target = float(target_min) * 1.1
if target is None or target == 0:
continue
val = get_nutrient_value(
dm_pct,
nutrients,
defn.get("nutrient_keys") or [],
indicator_key=key,
)
if val is None:
continue
rel = (float(val) - target) / abs(target)
total += rel * rel
counted += 1
return total / counted if counted else None
def _prefilter_candidates(
candidates: list[Component],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
nutrient_cache: dict[str, dict[str, float]],
*,
prefilter_k: int,
must_keep_ids: set[str] | None = None,
) -> list[Component]:
must_keep_ids = must_keep_ids or set()
pinned = [c for c in candidates if c.id in must_keep_ids]
rest = [c for c in candidates if c.id not in must_keep_ids]
slots = max(prefilter_k - len(pinned), 0)
if len(candidates) <= prefilter_k:
return candidates
if slots <= 0:
return pinned[:prefilter_k]
prices = [float(c.price) for c in rest if c.price is not None]
median_price = sorted(prices)[len(prices) // 2] if prices else 1.0
if median_price <= 0:
median_price = 1.0
scored: list[tuple[float, Component]] = []
for comp in rest:
price = float(comp.price) if comp.price is not None else median_price
cost_part = price / median_price
nutrient_part = _rough_component_value(comp, optimize_keys, norms, nutrient_cache)
if nutrient_part is None:
score = cost_part
else:
score = 0.4 * cost_part + 0.6 * nutrient_part
scored.append((score, comp))
scored.sort(key=lambda x: x[0])
return pinned + [comp for _, comp in scored[:slots]]
def _load_profile(profile_id: str) -> LabAnimalProfile:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
return profile
def _load_eligible_components(candidate_ids: list[str]) -> tuple[list[Component], list[dict[str, Any]]]:
unique = list(dict.fromkeys(candidate_ids))
validations = validate_components(unique)
ineligible = [v for v in validations if not v["eligible"]]
if ineligible:
raise ValueError("ineligible_components", ineligible)
comps: list[Component] = []
for cid in unique:
comp = Component.query.filter_by(id=cid, is_deleted=False).first()
if comp is None:
raise ValueError("component_not_found", cid)
comps.append(comp)
return comps, validations
def _resolve_group_selections(req: FormulateRequest) -> dict[str, list[str]]:
selections = dict(req.group_selections or {})
if req.main_feed_ids and not selections.get("rough"):
selections["rough"] = list(req.main_feed_ids)
return selections
def formulate(req: FormulateRequest) -> dict[str, Any]:
group_selections = _resolve_group_selections(req)
candidate_ids = list(req.candidate_ids)
if group_selections:
pool_errors = validate_group_selections(group_selections)
if pool_errors:
raise ValueError("group_selection_invalid", pool_errors)
candidate_ids = flatten_group_selections(group_selections)
if len(candidate_ids) < 3:
raise ValueError("candidate_ids_min_3")
if len(set(candidate_ids)) != len(candidate_ids):
raise ValueError("candidate_ids_duplicate")
profile = _load_profile(req.profile_id)
candidates, _ = _load_eligible_components(candidate_ids)
nutrient_cache = nutrients_calc_dict_batch(candidate_ids)
norms, dynamic = _resolve_norms(profile, req)
optimize_keys = req.optimize_keys or list(_DEFAULT_OPTIMIZE_KEYS)
heads = max(int(req.heads_per_trip or 10), 1)
herd_scale = float(req.total_kg_per_head) * heads
profile_mass_kg = req.mass_kg if req.mass_kg is not None else profile.mass_kg
must_keep: set[str] = set()
for g in FEED_GROUPS:
if g["required"]:
must_keep.update(group_selections.get(g["id"]) or [])
prefilter_applied = len(candidates) > req.prefilter_k
shortlist = _prefilter_candidates(
candidates,
optimize_keys,
norms,
nutrient_cache,
prefilter_k=req.prefilter_k,
must_keep_ids=must_keep,
)
started = time.perf_counter()
evaluations = 0
triplets_evaluated = 0
triplet_winners: list[dict[str, Any]] = []
def _eval_triplet(
triplet: tuple[Component, Component, Component],
) -> dict[str, Any] | None:
nonlocal evaluations
model = build_triplet_score_model(
triplet,
nutrient_cache=nutrient_cache,
norms=norms,
optimize_keys=optimize_keys,
herd_scale=herd_scale,
heads=heads,
cost_weight=req.cost_weight,
profile_mass_kg=profile_mass_kg,
)
def score_fn(shares: tuple[float, float, float]) -> float:
_violation, _cost_head, score = score_model_shares(model, shares)
return score
opt = optimize_shares_for_triplet(
triplet,
score_fn,
min_share=req.min_share,
grid_step=req.grid_step,
)
if opt is None:
return None
evaluations += opt.evaluations
violation, cost_head, score = score_model_shares(model, opt.shares)
lines = build_calc_lines(triplet, opt.shares, herd_scale, nutrient_cache)
daily = [opt.shares[i] * herd_scale for i in range(3)]
return {
"score": score,
"violation": violation,
"costHead": cost_head,
"costTotal": cost_head * heads,
"triplet": triplet,
"shares": opt.shares,
"daily": daily,
"lines": lines,
}
for triplet in itertools.combinations(shortlist, 3):
triplet_ids = {c.id for c in triplet}
if not triplet_meets_group_rules(triplet_ids, group_selections):
continue
triplets_evaluated += 1
best_triplet_result = _eval_triplet(triplet)
if best_triplet_result is None:
continue
triplet_winners.append(best_triplet_result)
if not triplet_winners:
if group_selections:
raise ValueError("no_feasible_solution_groups")
raise ValueError("no_feasible_solution")
triplet_winners.sort(key=lambda x: x["score"])
best = triplet_winners[0]
full_calc = calculate_ration(
profile.ration_type or "DAIRY",
best["lines"],
norms,
heads_per_trip=heads,
profile_mass_kg=profile_mass_kg,
)
best["calc"] = full_calc
best["violation"] = violation_score(
full_calc.get("indicators") or [],
optimize_keys,
norms,
)
best["score"] = best["violation"] * req.cost_weight + best["costHead"]
duration_ms = int((time.perf_counter() - started) * 1000)
alternatives = [
{
"componentIds": [c.id for c in item["triplet"]],
"names": [c.name for c in item["triplet"]],
"score": item["score"],
"violation": item["violation"],
"costPerHead": item["costHead"],
}
for item in triplet_winners[:3]
]
alternatives.sort(key=lambda x: x["score"])
alternatives = alternatives[:3]
result_lines = []
for i, comp in enumerate(best["triplet"]):
s1, s2, s3 = best["shares"]
share = (s1, s2, s3)[i]
result_lines.append(
{
"componentId": comp.id,
"name": comp.name,
"dailyKg": round(best["daily"][i], 4),
"sharePct": round(share * 100, 2),
"pricePerKg": comp.price,
"dryMatterPct": comp.dry_matter,
}
)
violation = best["violation"]
return {
"lines": result_lines,
"candidatePoolSize": len(candidates),
"groupSelections": group_selections,
"shortlistedIds": [c.id for c in shortlist],
"costTotal": best["costTotal"],
"costPerHead": best["costHead"],
"score": best["score"],
"violation": violation,
"feasible": violation < 0.01,
"indicators": best["calc"].get("indicators") or [],
"totals": best["calc"].get("totals") or [],
"optimizeKeys": optimize_keys,
"dynamicNorms": dynamic.get("dynamicNorms") or None,
"normsMethod": dynamic.get("normsMethod"),
"normsMeta": dynamic.get("normsMeta"),
"alternatives": alternatives,
"searchStats": {
"evaluations": evaluations,
"tripletsEvaluated": triplets_evaluated,
"durationMs": duration_ms,
"prefilterApplied": prefilter_applied,
"prefilterK": req.prefilter_k,
"scoreEngine": "fast",
"optimizer": "slsqp",
"normsMethod": dynamic.get("normsMethod"),
},
}
def list_formulate_components() -> list[dict[str, Any]]:
rows = (
Component.query.filter_by(is_active=True, is_deleted=False)
.order_by(Component.name)
.all()
)
out: list[dict[str, Any]] = []
for comp in rows:
v = validate_component(comp.id)
feed_group = _classify_feed_group(comp)
out.append(
{
"id": comp.id,
"name": comp.name,
"type": comp.type,
"feedGroup": feed_group,
"eligible": v["eligible"],
"missing": v["missing"],
"warnings": v["warnings"],
"dryMatterPct": v["dryMatterPct"],
"hasPrice": v["hasPrice"],
"price": v["price"],
"mainFeedDmGPerKg": v.get("mainFeedDmGPerKg"),
"isMainFeed": v.get("isMainFeed", False),
}
)
return out
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from scipy.optimize import minimize
from app.models import Component
ShareTuple = tuple[float, float, float]
ScoreFn = Callable[[ShareTuple], float]
@dataclass
class OptimizeSharesResult:
shares: ShareTuple
score: float
evaluations: int
def _normalize_shares(s1: float, s2: float, min_share: float) -> ShareTuple | None:
s3 = 1.0 - s1 - s2
ms = max(min_share, 0.0)
if s1 < ms - 1e-9 or s2 < ms - 1e-9 or s3 < ms - 1e-9:
return None
if abs(s1 + s2 + s3 - 1.0) > 1e-6:
return None
return (round(s1, 8), round(s2, 8), round(s3, 8))
def _start_points(min_share: float, grid_step: float) -> list[tuple[float, float]]:
"""Multi-start seeds: simplex center, corners, and a few grid_step hints."""
ms = max(min_share, 0.0)
max_pair = max(1.0 - 2 * ms, ms)
center = round((1.0 - ms) / 3.0, 6)
points: list[tuple[float, float]] = [
(center, center),
(ms, ms),
(max_pair, ms),
(ms, max_pair),
(max_pair, max_pair),
]
step = max(grid_step, 0.1)
if ms <= step <= max_pair:
points.append((step, ms))
points.append((ms, step))
deduped: list[tuple[float, float]] = []
seen: set[tuple[float, float]] = set()
for s1, s2 in points:
if s1 + s2 > 1.0 - ms + 1e-9:
continue
key = (round(s1, 6), round(s2, 6))
if key in seen:
continue
seen.add(key)
deduped.append(key)
return deduped
def optimize_shares_for_triplet(
triplet: tuple[Component, Component, Component],
score_fn: ScoreFn,
*,
min_share: float,
grid_step: float = 0.1,
) -> OptimizeSharesResult | None:
del triplet
ms = max(min_share, 0.0)
max_s1 = max(1.0 - 2 * ms, ms)
evaluations = 0
def objective(x: Any) -> float:
nonlocal evaluations
evaluations += 1
shares = _normalize_shares(float(x[0]), float(x[1]), ms)
if shares is None:
return 1e18
return score_fn(shares)
bounds = [(ms, max_s1), (ms, max_s1)]
constraints = [{"type": "ineq", "fun": lambda x: 1.0 - ms - float(x[0]) - float(x[1])}]
best_score: float | None = None
best_shares: ShareTuple | None = None
for s1, s2 in _start_points(ms, grid_step):
if s1 + s2 > 1.0 - ms + 1e-9:
continue
try:
res = minimize(
objective,
[s1, s2],
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-8, "maxiter": 40},
)
except Exception:
continue
if not res.success and res.fun >= 1e17:
continue
shares = _normalize_shares(float(res.x[0]), float(res.x[1]), ms)
if shares is None:
continue
score = float(res.fun)
if best_score is None or score < best_score:
best_score = score
best_shares = shares
if best_shares is None or best_score is None:
return None
return OptimizeSharesResult(shares=best_shares, score=best_score, evaluations=evaluations)
+371
View File
@@ -0,0 +1,371 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.lab.calc.derived import apply_content_derived
from app.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
from app.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
from app.models import Component
def resolve_score_closure(optimize_keys: list[str]) -> frozenset[str]:
"""Collect base + derived indicator keys needed to score optimize_keys."""
needed: set[str] = set(optimize_keys)
changed = True
while changed:
changed = False
for key in list(needed):
defn = indicator_by_key(key)
if defn is None:
continue
derived = defn.get("derived")
if derived == "alias":
dep = defn.get("alias_of")
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived in ("pct_of_dm", "g_per_kg_dm", "nel_per_kg_dm"):
dep = defn.get("from_key")
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived in ("rnb", "bra_rnb"):
for dep in ("crude_protein", "usp"):
if dep not in needed:
needed.add(dep)
changed = True
elif derived == "ratio":
for dep in (defn.get("ratio_num"), defn.get("ratio_den")):
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived == "dm_pct_bw":
if "dry_matter" not in needed:
needed.add("dry_matter")
changed = True
return frozenset(needed)
def _indicator_content(
defn: dict[str, Any],
active: list[dict[str, Any]],
total_kg: float,
*,
heads_per_trip: int,
) -> float | None:
key = defn.get("key")
if defn.get("aggregation") == "weighted_avg":
return weighted_average(
active,
total_kg,
defn["nutrient_keys"],
indicator_key=key,
)
return daily_intake_total(
active,
defn["nutrient_keys"],
heads_per_trip=heads_per_trip,
indicator_key=key,
)
def compute_score_indicators(
active: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> list[dict[str, Any]]:
closure = resolve_score_closure(optimize_keys)
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or defn.get("derived"):
continue
content_by_key[key] = _indicator_content(
defn, active, total_kg, heads_per_trip=heads_per_trip
)
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or not defn.get("derived"):
continue
content_by_key[key] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=heads_per_trip,
profile_mass_kg=profile_mass_kg,
)
rows: list[dict[str, Any]] = []
for key in optimize_keys:
if key not in closure:
continue
defn = indicator_by_key(key)
if defn is None:
continue
content = content_by_key.get(key)
bounds = norms.get(key, {})
min_v = bounds.get("min")
max_v = bounds.get("max")
diff = norm_diff(content, min_v, max_v)
if content is None and min_v is None and max_v is None:
continue
rows.append(
{
"key": key,
"label": defn["label"],
"unit": defn["unit"],
"min": min_v,
"max": max_v,
"content": content,
"diff": diff,
}
)
return rows
def violation_score(
indicators: list[dict[str, Any]],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
) -> float:
by_key = {row.get("key"): row for row in indicators if row.get("key")}
total = 0.0
counted = 0
for key in optimize_keys:
bounds = norms.get(key) or {}
if bounds.get("min") is None and bounds.get("max") is None:
continue
row = by_key.get(key)
if row is None:
continue
diff = row.get("diff")
if diff is None:
diff = norm_diff(row.get("content"), bounds.get("min"), bounds.get("max"))
if diff is None:
continue
total += float(diff) ** 2
counted += 1
return total if counted else 0.0
def _violation_from_content(
content_by_key: dict[str, float | None],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
) -> float:
total = 0.0
counted = 0
for key in optimize_keys:
bounds = norms.get(key) or {}
if bounds.get("min") is None and bounds.get("max") is None:
continue
content = content_by_key.get(key)
diff = norm_diff(content, bounds.get("min"), bounds.get("max"))
if diff is None:
continue
total += float(diff) ** 2
counted += 1
return total if counted else 0.0
@dataclass
class TripletScoreModel:
triplet: tuple[Component, Component, Component]
nutrient_cache: dict[str, dict[str, float]]
herd_scale: float
heads: int
cost_weight: float
profile_mass_kg: float | None
optimize_keys: list[str]
norms: dict[str, dict[str, float | None]]
closure: frozenset[str]
intake_unit: dict[str, tuple[float | None, float | None, float | None]]
weighted_vals: dict[str, tuple[float | None, float | None, float | None]]
prices: tuple[float, float, float]
def build_triplet_score_model(
triplet: tuple[Component, Component, Component],
*,
nutrient_cache: dict[str, dict[str, float]],
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
herd_scale: float,
heads: int,
cost_weight: float,
profile_mass_kg: float | None,
) -> TripletScoreModel:
closure = resolve_score_closure(optimize_keys)
kg_per_share = herd_scale / max(heads, 1)
intake_unit: dict[str, tuple[float | None, float | None, float | None]] = {}
weighted_vals: dict[str, tuple[float | None, float | None, float | None]] = {}
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or defn.get("derived"):
continue
vals: list[float | None] = []
for comp in triplet:
nutrients = nutrient_cache.get(comp.id, {})
vals.append(
get_nutrient_value(
comp.dry_matter,
nutrients,
defn["nutrient_keys"],
indicator_key=key,
)
)
tup = (vals[0], vals[1], vals[2])
if defn.get("aggregation") == "weighted_avg":
weighted_vals[key] = tup
else:
intake_unit[key] = tuple(v * kg_per_share if v is not None else None for v in vals)
prices = tuple(
float(comp.price) if comp.price is not None else 0.0 for comp in triplet
)
return TripletScoreModel(
triplet=triplet,
nutrient_cache=nutrient_cache,
herd_scale=herd_scale,
heads=heads,
cost_weight=cost_weight,
profile_mass_kg=profile_mass_kg,
optimize_keys=optimize_keys,
norms=norms,
closure=closure,
intake_unit=intake_unit,
weighted_vals=weighted_vals,
prices=prices,
)
def _content_from_shares(
model: TripletScoreModel,
shares: tuple[float, float, float],
) -> dict[str, float | None]:
content_by_key: dict[str, float | None] = {}
total_kg = model.herd_scale
for key, coeffs in model.intake_unit.items():
parts = [
shares[i] * coeffs[i]
for i in range(3)
if coeffs[i] is not None
]
content_by_key[key] = sum(parts) if parts else None
for key, vals in model.weighted_vals.items():
num = 0.0
den = 0.0
for i in range(3):
if vals[i] is None:
continue
num += shares[i] * vals[i]
den += shares[i]
content_by_key[key] = (num / den) if den > 0 else None
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in model.closure or not defn.get("derived"):
continue
content_by_key[key] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=model.heads,
profile_mass_kg=model.profile_mass_kg,
)
return content_by_key
def score_model_shares(
model: TripletScoreModel,
shares: tuple[float, float, float],
) -> tuple[float, float, float]:
"""Return (violation, cost_head, score) without building line dicts."""
content = _content_from_shares(model, shares)
violation = _violation_from_content(content, model.optimize_keys, model.norms)
cost_total = sum(shares[i] * model.prices[i] * model.herd_scale for i in range(3))
cost_head = cost_total / max(model.heads, 1)
score = violation * model.cost_weight + cost_head
return violation, cost_head, score
def build_calc_lines(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
herd_scale: float,
nutrient_cache: dict[str, dict[str, float]],
) -> list[dict[str, Any]]:
lines: list[dict[str, Any]] = []
for i, comp in enumerate(triplet):
daily_kg = shares[i] * herd_scale
lines.append(
{
"component_id": comp.id,
"ingredient_name": comp.name,
"daily_kg": daily_kg,
"in_ration": True,
"in_compound": False,
"dry_matter": comp.dry_matter,
"price_per_kg": comp.price,
"nutrients": nutrient_cache.get(comp.id, {}),
}
)
return lines
def cost_per_head(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
herd_scale: float,
heads: int,
) -> float:
total = 0.0
any_cost = False
for i, comp in enumerate(triplet):
kg = shares[i] * herd_scale
price = comp.price
if kg is None or price is None:
continue
total += float(kg) * float(price)
any_cost = True
if not any_cost:
return 0.0
return total / max(heads, 1)
def score_triplet_shares(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
*,
nutrient_cache: dict[str, dict[str, float]],
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
herd_scale: float,
heads: int,
cost_weight: float,
profile_mass_kg: float | None,
) -> tuple[float, float, float, list[dict[str, Any]]]:
"""Return (violation, cost_head, score, lines)."""
model = build_triplet_score_model(
triplet,
nutrient_cache=nutrient_cache,
norms=norms,
optimize_keys=optimize_keys,
herd_scale=herd_scale,
heads=heads,
cost_weight=cost_weight,
profile_mass_kg=profile_mass_kg,
)
violation, cost_head, score = score_model_shares(model, shares)
lines = build_calc_lines(triplet, shares, herd_scale, nutrient_cache)
return violation, cost_head, score, lines
@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from app.lab.calc.feed_groups import classify_feed_group
from app.lab.calc.gfe_policies import default_om_digestibility_pct
from app.lab.nutrient_schema import read_from_mapping
from app.lab.services.component_nutrients import derive_context_for_component, nutrients_full_dict, nutrients_is_empty
from app.models import Component
_REQUIRED_EAV_KEYS = ("Сыр. Протеин", "Сырая клетч", "Сырой жир")
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
def main_feed_dm_g_per_kg(component_id: str | None) -> float | None:
if not component_id:
return None
full = nutrients_full_dict(component_id)
val = read_from_mapping(full, _MAIN_FEED_KEYS)
return float(val) if val is not None else None
def validate_component(component_id: str) -> dict[str, Any]:
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is None:
return {
"id": component_id,
"name": None,
"eligible": False,
"missing": ["component_not_found"],
"warnings": [],
"dryMatterPct": None,
"hasPrice": False,
"price": None,
}
missing: list[str] = []
warnings: list[str] = []
dry_matter_pct = comp.dry_matter
if dry_matter_pct is None or float(dry_matter_pct) <= 0:
missing.append("dry_matter")
full = nutrients_full_dict(component_id)
if nutrients_is_empty(component_id):
missing.append("nutrients_empty")
else:
for key in _REQUIRED_EAV_KEYS:
if read_from_mapping(full, (key,)) is None:
missing.append(key)
has_price = comp.price is not None and float(comp.price) >= 0
if not has_price:
warnings.append("price_missing")
main_feed_dm = read_from_mapping(full, _MAIN_FEED_KEYS)
if main_feed_dm is None:
warnings.append("main_feed_unset")
feed_group = classify_feed_group(comp)
omd = read_from_mapping(full, _OMD_KEYS)
if feed_group in ("rough", "succulent") and omd is None:
warnings.append("omd_missing")
elif omd is None and comp.dry_matter and float(comp.dry_matter) > 0:
ctx = derive_context_for_component(component_id, full)
warnings.append(
f"omd_defaulted:{default_om_digestibility_pct(ctx):.0f}"
)
return {
"id": comp.id,
"name": comp.name,
"eligible": len(missing) == 0,
"missing": missing,
"warnings": warnings,
"dryMatterPct": dry_matter_pct,
"hasPrice": has_price,
"price": comp.price,
"mainFeedDmGPerKg": main_feed_dm,
"isMainFeed": main_feed_dm is not None and float(main_feed_dm) > 0,
"feedGroup": feed_group,
}
def validate_components(component_ids: list[str]) -> list[dict[str, Any]]:
return [validate_component(cid) for cid in component_ids]
+94
View File
@@ -0,0 +1,94 @@
"""Динамические нормы по уравнениям GfE (Германия)."""
from __future__ import annotations
from typing import Any
USP_DYNAMIC_KEY = "usp"
NEL_DYNAMIC_KEY = "nel"
# GfE 2001 Milchkühe — Erhaltung + Milch (Standardmilch / FCM)
NEL_MAINTENANCE_COEFF = 0.293 # MJ NEL / (kg LM)^0,75 / Tag
NEL_PER_KG_MILK_MJ = 3.3 # MJ NEL / kg Milch (FCM)
def gfe_usp_min_g(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> float | None:
"""
Минимальная суточная потребность в усвояемом протеине (уСП / nXP), г/сут.
GfE: 0,09 × (масса^0,75) × 6,25 + удой × 85 г.
"""
if mass_kg is None or mass_kg <= 0:
return None
maintenance = 0.09 * (mass_kg**0.75) * 6.25
milk = max(float(milk_yield_kg or 0), 0.0) * 85.0
return maintenance + milk
def gfe_nel_min_mj(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> float | None:
"""
Минимальная суточная потребность в ЧЭЛ (NEL), МДж/сут.
GfE 2001: 0,293 × LM^0,75 + удой × 3,3 (MJ NEL на кг молока).
"""
if mass_kg is None or mass_kg <= 0:
return None
maintenance = NEL_MAINTENANCE_COEFF * (mass_kg**0.75)
milk = max(float(milk_yield_kg or 0), 0.0) * NEL_PER_KG_MILK_MJ
return maintenance + milk
def preview_dynamic_norms(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> dict[str, Any]:
"""Расчётные min по GfE для UI (без учёта сохранённых норм)."""
_, dynamic = apply_dynamic_norms(
{},
mass_kg=mass_kg,
milk_yield_kg=milk_yield_kg,
)
return dynamic
def apply_dynamic_norms(
stored: dict[str, dict[str, float | None]],
*,
mass_kg: float | None,
milk_yield_kg: float | None,
ration_type: str | None = None,
force_dynamic: bool = False,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
Заполняет нормы по GfE, если в БД min не задан.
force_dynamic=True — пересчитать min уСП/ЧЭЛ по массе и удою даже при нормах в БД
(автоготовка с явными mass_kg / milk_yield_kg).
Возвращает (resolved_norms, dynamic_meta).
"""
del ration_type
resolved: dict[str, dict[str, float | None]] = {
key: {"min": bounds.get("min"), "max": bounds.get("max")}
for key, bounds in stored.items()
}
dynamic: dict[str, Any] = {}
for key, compute, formula in (
(USP_DYNAMIC_KEY, gfe_usp_min_g, "0.09×масса^0.75×6.25 + удой×85"),
(NEL_DYNAMIC_KEY, gfe_nel_min_mj, "0.293×масса^0.75 + удой×3.3"),
):
bounds = resolved.get(key, {"min": None, "max": None})
if force_dynamic or bounds.get("min") is None:
computed = compute(mass_kg, milk_yield_kg)
if computed is not None:
entry = dict(bounds)
entry["min"] = computed
resolved[key] = entry
dynamic[key] = {"min": computed, "formula": formula}
return resolved, dynamic
+84
View File
@@ -0,0 +1,84 @@
"""WESP-политики расчёта на базе GfE 2001 (отличия от zootech Excel — осознанные)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
FeedGroup = Literal["rough", "succulent", "concentrate", "other", "unknown"]
# --- GfE 2001 константы (формулы не меняем) ---
NEL_Q_COEFF = 0.004
NEL_Q_REF_PCT = 57.0
NEL_BASE = 0.6
GE_CP = 0.0239
GE_FAT = 0.0398
GE_FIBER = 0.0201
GE_NFE = 0.0175
ME_FAT = 0.0312
ME_FIBER = 0.0136
ME_OR_RESIDUE = 0.0147
ME_CP = 0.00234
USP_FAT_THRESHOLD_G_PER_KG_DM = 70.0 # 7% СЖ/кг СВ
# DCAB (Na+K)-(Cl+S), мэкв при минералах в г/кг СВ
DCAB_NA = 43.5
DCAB_K = 25.6
DCAB_CL = 28.2
DCAB_S = 62.4
# Дефолты переваримости при пустых коэфф. (Excel legacy, кроме ОВ)
DEFAULT_CP_DIGEST_PCT = 86.0
DEFAULT_FAT_DIGEST_PCT = 75.0
DEFAULT_FIBER_DIGEST_PCT = 86.0
DEFAULT_NFE_DIGEST_PCT = 94.0
DEFAULT_INSOLUBLE_PROTEIN_PCT = 15.0
DEFAULT_PROTEIN_FRACTION_PCT = 18.9
# WESP: дефолт ВРХ орг. вещ. при пустом поле — по классу корма
DEFAULT_OMD_ROUGH_PCT = 65.0
DEFAULT_OMD_SUCCULENT_PCT = 72.0
DEFAULT_OMD_CONCENTRATE_PCT = 91.0
@dataclass(frozen=True)
class DeriveContext:
feed_group: FeedGroup = "unknown"
is_main_feed: bool = False
@classmethod
def from_cells(cls, *, main_feed_g: float = 0.0, feed_group: FeedGroup = "unknown") -> DeriveContext:
return cls(
feed_group=feed_group,
is_main_feed=main_feed_g > 0,
)
@classmethod
def infer_from_cells(cls, cells: dict[str, float], feed_group: FeedGroup = "unknown") -> DeriveContext:
main_feed = cells.get("E", 0.0)
return cls.from_cells(main_feed_g=main_feed, feed_group=feed_group)
def default_om_digestibility_pct(ctx: DeriveContext | None) -> float:
"""Дефолт переваримости ОВ (%) при отсутствии ВРХ Орг Вещ."""
if ctx is None:
return DEFAULT_OMD_CONCENTRATE_PCT
if ctx.is_main_feed or ctx.feed_group == "rough":
return DEFAULT_OMD_ROUGH_PCT
if ctx.feed_group == "succulent":
return DEFAULT_OMD_SUCCULENT_PCT
return DEFAULT_OMD_CONCENTRATE_PCT
def default_digestibility_coefficients() -> dict[str, float]:
return {
"cp": DEFAULT_CP_DIGEST_PCT,
"fat": DEFAULT_FAT_DIGEST_PCT,
"fiber": DEFAULT_FIBER_DIGEST_PCT,
"nfe": DEFAULT_NFE_DIGEST_PCT,
"insoluble_protein": DEFAULT_INSOLUBLE_PROTEIN_PCT,
"protein_fraction": DEFAULT_PROTEIN_FRACTION_PCT,
}
+187
View File
@@ -0,0 +1,187 @@
"""Каталог показателей zootech «База сырья» (109 колонок)."""
from __future__ import annotations
from openpyxl.utils import column_index_from_string, get_column_letter
# Заголовки row 3 в xlsx_extracted/data_csv_cleaned/База сырья.csv
INGREDIENT_HEADERS: tuple[str, ...] = (
"",
"Наименование",
"Цена 1 кг",
"СВ",
"Осн.Корм",
"Сыр. Протеин",
"уСП",
"БРА",
"ЧЭЛ- КРС",
"ОЭ-КРС",
"Сырая клетч",
"Структур клетч",
"Сырой жир",
"НДК",
"КДК",
"NFC",
"Ca",
"P",
"Mg",
"Fe",
"Zn",
"Cu",
"Co",
"Mn",
"Se",
"J",
"Na",
"K",
"CL",
"S",
"DCAB Форм",
"Сахар и Крохм",
"Нераств Крохм",
"Сахар",
"Крахмал",
"Нераств крахмал",
"Вит А",
"Вит D",
"Вит Е",
"Вит В1",
"Вит В2",
"Вит В6",
"Вит В12",
"Пант Кальц",
"Никот Ки-та",
"Фол ки-та",
"Холин",
"Биотин",
"Сырая зола",
"БЕР",
"Лизин",
"Метионин",
"Треонин",
"Триптофан",
"Изолейцин",
"Лейцин",
"Валин",
"Каротин",
"b -Каротин",
"Линолевая к-та",
"Линоленовая ки-та",
"Масляная ки-та",
"Арахидоновая ки-та",
"Полиэновая ки-та",
"Мочевина",
"СВ Основной корм",
"ВРХ Орг Вещ",
"Перевар Орг Вещ",
"КРС Протеин",
"Переварим Протеин",
"КРС Сырой жир",
"Переварим Сырой жир",
"КРС Сырая клетч",
"Переварим сырая клетч",
"КРС БЭВ",
"Переварим БЭВ",
"ВЕ",
"OЭ КРС форм",
"ЧЭЛ - КРС Форм",
"НДК Общ",
"НДК Осн. Корм",
"КДК общ",
"% нераствор протеин",
"Нерастворим прот",
"СЖ/кг СВ",
"НСП/кг СВ",
"СП/кг СВ",
"пОВ/кг СВ",
"пСЖ/кг СВ",
"уСП<7%",
"уСП>7%",
"уСП/кг СВ формул",
"уСП в ОР",
"уСП формул",
"БРА",
"Нераств крохмал",
"доля крохмала",
"Доля белка",
"% перев в кишках",
"OEB",
"Синтез Мдж",
"Промеж рез 1",
"Промеж рез 2",
"Метаб ОЕТ",
"Метаб лизин",
"Метабол Треон",
"Метабол Лейцин",
"Метабол Изолейц",
"Метабол Валин",
)
HEADER_TO_LETTER: dict[str, str] = {
header: get_column_letter(i + 1) for i, header in enumerate(INGREDIENT_HEADERS)
}
LETTER_TO_HEADER: dict[str, str] = {
get_column_letter(i + 1): header for i, header in enumerate(INGREDIENT_HEADERS)
}
# Колонки с формулами в шаблонной строке 6 (native port, не Excel runtime).
DERIVED_LETTERS: frozenset[str] = frozenset(
{
"AE",
"AF",
"AG",
"AX",
"BN",
"BP",
"BR",
"BT",
"BV",
"BX",
"BY",
"BZ",
"CA",
"CF",
"CG",
"CH",
"CI",
"CJ",
"CK",
"CL",
"CM",
"CN",
"CP",
"CQ",
"CX",
"CY",
"CZ",
"DA",
"DB",
"DC",
"DD",
"DE",
}
)
# Отображаемые поля G–J синхронизируются с расчётными CP/CQ/CA/BZ.
DISPLAY_SYNC: tuple[tuple[str, str], ...] = (
("G", "CP"), # уСП
("H", "CQ"), # RNB (legacy заголовок «БРА», дубль CQ)
("I", "CA"), # ЧЭЛ- КРС
("J", "BZ"), # ОЭ-КРС
)
DERIVED_HEADERS: frozenset[str] = frozenset(
LETTER_TO_HEADER[letter] for letter in DERIVED_LETTERS
) | frozenset(LETTER_TO_HEADER[g] for g, _ in DISPLAY_SYNC)
INPUT_HEADERS: frozenset[str] = frozenset(INGREDIENT_HEADERS) - DERIVED_HEADERS - frozenset(
("", "Наименование", "Цена 1 кг")
)
# Дублирующий заголовок «БРА»/RNB (H и CQ) — в derive используем CQ.
DUPLICATE_HEADERS: frozenset[str] = frozenset({"БРА"})
def letter_index(letter: str) -> int:
return column_index_from_string(letter) - 1
+216
View File
@@ -0,0 +1,216 @@
"""Native derive формул zootech «База сырья» — WESP GfE 2001 engine."""
from __future__ import annotations
from typing import Any
from app.lab.calc.gfe_policies import (
DCAB_CL,
DCAB_K,
DCAB_NA,
DCAB_S,
DEFAULT_CP_DIGEST_PCT,
DEFAULT_FAT_DIGEST_PCT,
DEFAULT_FIBER_DIGEST_PCT,
DEFAULT_INSOLUBLE_PROTEIN_PCT,
DEFAULT_NFE_DIGEST_PCT,
DEFAULT_PROTEIN_FRACTION_PCT,
DeriveContext,
GE_CP,
GE_FAT,
GE_FIBER,
GE_NFE,
ME_CP,
ME_FAT,
ME_FIBER,
ME_OR_RESIDUE,
NEL_BASE,
NEL_Q_COEFF,
NEL_Q_REF_PCT,
USP_FAT_THRESHOLD_G_PER_KG_DM,
default_om_digestibility_pct,
)
from app.lab.calc.ingredient_catalog import (
DISPLAY_SYNC,
HEADER_TO_LETTER,
INGREDIENT_HEADERS,
LETTER_TO_HEADER,
)
def _parse_num(value: Any) -> 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 _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _v(cells: dict[str, float], letter: str, default: float = 0.0) -> float:
return cells.get(letter, default)
def _if_pos(test: float, when_true, when_false: float = 0.0) -> float:
"""Excel IF(test>0, …) — ветка when_true не вычисляется при test<=0."""
if test > 0:
return when_true() if callable(when_true) else when_true
return when_false
def dict_to_cells(data: dict[str, Any] | None) -> dict[str, float]:
"""Словарь {заголовок: значение} → {буква колонки: значение}."""
cells: dict[str, float] = {}
if not data:
return cells
norm_index = {_normalize_key(h): h for h in INGREDIENT_HEADERS}
for raw_key, raw_val in data.items():
n = _parse_num(raw_val)
if n is None:
continue
nk = _normalize_key(str(raw_key))
header = norm_index.get(nk)
if header is None:
continue
letter = HEADER_TO_LETTER.get(header)
if letter:
cells[letter] = n
return cells
def cells_to_dict(cells: dict[str, float]) -> dict[str, float]:
out: dict[str, float] = {}
for letter, value in cells.items():
header = LETTER_TO_HEADER.get(letter)
if header and header not in ("", "Наименование", "Цена 1 кг"):
out[header] = value
return out
def derive_cells(
cells: dict[str, float],
*,
context: DeriveContext | None = None,
) -> dict[str, float]:
"""Пересчёт derived-колонок по цепочке формул row 6 «База сырья»."""
c = dict(cells)
ctx = context or DeriveContext.infer_from_cells(c)
omd_default = default_om_digestibility_pct(ctx)
c["AE"] = DCAB_NA * _v(c, "AA") + DCAB_K * _v(c, "AB") - DCAB_CL * _v(c, "AC") - DCAB_S * _v(c, "AD")
c["AG"] = _v(c, "AJ") * _v(c, "AI") / 100.0
c["AF"] = _v(c, "AI") - c["AG"] + _v(c, "AH")
c["AX"] = _v(c, "D") - _v(c, "F") - _v(c, "K") - _v(c, "M") - _v(c, "AW")
c["BN"] = _if_pos(_v(c, "E"), lambda: _v(c, "D") / _v(c, "E") * 1000.0)
c["BP"] = _if_pos(
_v(c, "BO"),
lambda: (_v(c, "D") - _v(c, "AW")) * _v(c, "BO") / 100.0,
(_v(c, "D") - _v(c, "AW")) * omd_default / 100.0,
)
c["BR"] = _if_pos(
_v(c, "BQ"), lambda: _v(c, "F") * _v(c, "BQ") / 100.0, _v(c, "F") * DEFAULT_CP_DIGEST_PCT / 100.0
)
c["BT"] = _if_pos(
_v(c, "BS"), lambda: _v(c, "M") * _v(c, "BS") / 100.0, _v(c, "M") * DEFAULT_FAT_DIGEST_PCT / 100.0
)
c["BV"] = _if_pos(
_v(c, "BU"), lambda: _v(c, "K") * _v(c, "BU") / 100.0, _v(c, "K") * DEFAULT_FIBER_DIGEST_PCT / 100.0
)
c["BX"] = _if_pos(
_v(c, "BW"), lambda: c["AX"] * _v(c, "BW") / 100.0, c["AX"] * DEFAULT_NFE_DIGEST_PCT / 100.0
)
c["BY"] = GE_CP * _v(c, "F") + GE_FAT * _v(c, "M") + GE_FIBER * _v(c, "K") + GE_NFE * c["AX"]
c["BZ"] = (
ME_FAT * c["BT"]
+ ME_FIBER * c["BV"]
+ ME_OR_RESIDUE * (c["BP"] - c["BT"] - c["BV"])
+ ME_CP * _v(c, "F")
)
c["CA"] = _if_pos(
c["BY"],
lambda: (NEL_BASE * (1.0 + NEL_Q_COEFF * (c["BZ"] / c["BY"] * 100.0 - NEL_Q_REF_PCT)) * c["BZ"]),
)
c["CF"] = _if_pos(
_v(c, "CE"),
lambda: _v(c, "F") * _v(c, "CE") / 100.0,
_v(c, "F") * DEFAULT_INSOLUBLE_PROTEIN_PCT / 100.0,
)
c["CG"] = _if_pos(_v(c, "D"), lambda: _v(c, "M") * 1000.0 / _v(c, "D"))
c["CH"] = _if_pos(_v(c, "D"), lambda: c["CF"] * 1000.0 / _v(c, "D"))
c["CI"] = _if_pos(_v(c, "D"), lambda: _v(c, "F") * 1000.0 / _v(c, "D"))
c["CJ"] = _if_pos(_v(c, "D"), lambda: c["BP"] / _v(c, "D"))
c["CK"] = _if_pos(_v(c, "D"), lambda: c["BT"] / _v(c, "D"))
c["CL"] = _if_pos(c["CI"], lambda: (187.7 - 115.4 * c["CH"] / c["CI"]) * c["CJ"] + 1.03 * c["CH"])
c["CM"] = _if_pos(c["CI"], lambda: (196.1 - 127.5 * c["CH"] / c["CI"]) * (c["CJ"] - c["CK"]) + 1.03 * c["CH"])
co = _v(c, "CO")
if c["CG"] < USP_FAT_THRESHOLD_G_PER_KG_DM + 0.01:
c["CN"] = c["CL"]
elif c["CG"] > USP_FAT_THRESHOLD_G_PER_KG_DM:
c["CN"] = c["CM"]
else:
c["CN"] = 0.0
if co < 1.01:
c["CP"] = c["CN"] * _v(c, "D") / 1000.0
elif co > 1.0:
c["CP"] = co
else:
c["CP"] = 0.0
c["CQ"] = (_v(c, "F") - c["CP"]) / 6.25
c["CX"] = _if_pos(
_v(c, "CT"),
lambda: _v(c, "F") * _v(c, "CT") / 100.0,
_v(c, "F") * DEFAULT_PROTEIN_FRACTION_PCT / 100.0,
)
nel = c["CA"]
c["CY"] = nel * _v(c, "CW")
f_val = _v(c, "F")
bo = _v(c, "BO")
if f_val == 0.0:
c["CZ"] = 0.0
c["DA"] = 0.0
c["DB"] = 0.0
c["DC"] = 0.0
c["DD"] = 0.0
c["DE"] = 0.0
else:
c["DA"] = c["CX"] * (_v(c, "AY") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.071 * 0.8
c["CZ"] = _if_pos(
bo,
lambda: c["CX"] * (_v(c, "AZ") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.018 * 0.8,
)
c["DB"] = _if_pos(
bo,
lambda: c["CX"] * (_v(c, "BA") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.044 * 0.8,
)
c["DC"] = _if_pos(
_v(c, "BD"),
lambda: c["CX"] * (_v(c, "BD") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.063 * 0.8,
)
c["DD"] = _if_pos(
_v(c, "BC"),
lambda: c["CX"] * (_v(c, "BC") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.049 * 0.8,
)
c["DE"] = _if_pos(
_v(c, "BE"),
lambda: c["CX"] * (_v(c, "BE") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.048 * 0.8,
)
for display, source in DISPLAY_SYNC:
c[display] = c[source]
return c
def derive_ingredient_nutrients(
data: dict[str, Any] | None,
*,
context: DeriveContext | None = None,
) -> dict[str, float]:
"""Полный набор показателей: входные + пересчитанные derived."""
cells = dict_to_cells(data)
return cells_to_dict(derive_cells(cells, context=context))
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
from app.lab.calc.nutrients import parse_num
from app.lab.constants import NORM_COLUMN_ALIASES, RATION_QUALITY_INDICATORS
def merge_norms_from_profile(profile_data: Any, ration_type: str) -> dict[str, dict[str, float | None]]:
del ration_type
if not profile_data or not isinstance(profile_data, dict):
return {}
indicators = profile_data.get("indicators")
if isinstance(indicators, dict):
out: dict[str, dict[str, float | None]] = {}
for key, bounds in indicators.items():
if not isinstance(bounds, dict):
continue
out[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
return out
out = {}
for defn in RATION_QUALITY_INDICATORS:
key = defn["key"]
aliases = NORM_COLUMN_ALIASES.get(key)
if not aliases:
continue
for alias in aliases:
entry = profile_data.get(alias)
if isinstance(entry, dict):
out[key] = {
"min": parse_num(entry.get("min")),
"max": parse_num(entry.get("max")),
}
break
return out
+86
View File
@@ -0,0 +1,86 @@
"""Производные min/max норм из базовых показателей RACION."""
from __future__ import annotations
from typing import Any
from app.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
def _norm_min(bounds: dict[str, float | None] | None) -> float | None:
if not bounds:
return None
v = bounds.get("min")
return float(v) if v is not None else None
def _apply_derived_min(
norms: dict[str, dict[str, float | None]],
defn: dict[str, Any],
*,
mass_kg: float | None = None,
) -> float | None:
derived = defn.get("derived")
key = defn["key"]
if derived == "alias":
src = norms.get(defn.get("alias_of") or "")
if src and src.get("min") is not None:
return src["min"]
return None
if derived == "pct_of_dm":
src = _norm_min(norms.get(defn.get("from_key") or ""))
dm = _norm_min(norms.get("dry_matter"))
if src is None or not dm or dm <= 0:
return None
return src / dm * 100.0
if derived == "g_per_kg_dm":
src = _norm_min(norms.get(defn.get("from_key") or ""))
dm = _norm_min(norms.get("dry_matter"))
if src is None or not dm or dm <= 0:
return None
return src / (dm / 1000.0)
if derived == "nel_per_kg_dm":
dm = _norm_min(norms.get("dry_matter"))
nel = _norm_min(norms.get("nel"))
if not dm or dm <= 0 or nel is None:
return None
return nel / (dm / 1000.0)
if derived == "ratio":
num = _norm_min(norms.get(defn.get("ratio_num") or ""))
den = _norm_min(norms.get(defn.get("ratio_den") or ""))
if num is None or den is None or den == 0:
return None
return num / den
if derived == "dm_pct_bw":
dm = _norm_min(norms.get("dry_matter"))
if dm is None or not mass_kg or mass_kg <= 0:
return None
return (dm / 1000.0 / mass_kg) * 100.0
if derived == "ration_pct_bw":
return None
if derived in ("rnb", "bra_rnb"):
return _norm_min(norms.get(key))
return None
def apply_derived_norms(
norms: dict[str, dict[str, float | None]],
*,
mass_kg: float | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""Дополняет norms производными min; max не трогает."""
out = {k: dict(v) for k, v in norms.items()}
dynamic: dict[str, Any] = {}
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
key = defn["key"]
if _norm_min(out.get(key)) is not None:
continue
val = _apply_derived_min(out, defn, mass_kg=mass_kg)
if val is None:
continue
rounded = round(val, 3)
out[key] = {"min": rounded, "max": out.get(key, {}).get("max")}
dynamic[key] = {"min": rounded, "derived": defn.get("derived")}
return out, dynamic
+192
View File
@@ -0,0 +1,192 @@
"""Роутер методик суточных норм: WESP / Москва / Петербург."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from app.lab.calc.gfe_norms import apply_dynamic_norms
from app.lab.calc.racion.moscow import MoscowDairyParams, resolve_moscow_dairy_norms
from app.lab.calc.racion.piter import PiterDairyParams, PiterPrepError, resolve_piter_dairy_norms
from app.lab.indicators import RATION_ALL_INDICATORS
NormsMethod = Literal["wesp", "racion_moscow", "racion_piter"]
VALID_NORMS_METHODS: frozenset[str] = frozenset({"wesp", "racion_moscow", "racion_piter"})
@dataclass
class NormsParams:
milk_fat_pct: float | None = None
lactation_no: int | None = None
lactation_stage: int | None = None
body_condition: int | None = None
housing_system: int | None = None
konc_oe_sv: float | None = None
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> NormsParams:
if not raw:
return cls()
return cls(
milk_fat_pct=_flt(raw.get("milkFatPct", raw.get("milk_fat_pct"))),
lactation_no=_int(raw.get("lactationNo", raw.get("lactation_no"))),
lactation_stage=_int(raw.get("lactationStage", raw.get("lactation_stage"))),
body_condition=_int(raw.get("bodyCondition", raw.get("body_condition"))),
housing_system=_int(raw.get("housingSystem", raw.get("housing_system"))),
konc_oe_sv=_flt(raw.get("koncOeSv", raw.get("konc_oe_sv"))),
)
def _flt(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _int(v: Any) -> int | None:
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def normalize_norms_method(method: str | None) -> NormsMethod:
m = (method or "wesp").strip().lower()
if m not in VALID_NORMS_METHODS:
return "wesp"
return m # type: ignore[return-value]
@dataclass
class NormsResolveRequest:
method: NormsMethod = "wesp"
stored: dict[str, dict[str, float | None]] = field(default_factory=dict)
mass_kg: float | None = None
milk_yield_kg: float | None = None
ration_type: str | None = None
force_dynamic: bool = False
params: NormsParams = field(default_factory=NormsParams)
def merge_hybrid_norms(
racion_resolved: dict[str, dict[str, float | None]],
stored: dict[str, dict[str, float | None]],
*,
dynamic_meta: dict[str, Any] | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
RACION + derived имеют приоритет по min.
stored дополняет ключи без RACION-min; max всегда из stored, если задан.
"""
out: dict[str, dict[str, float | None]] = {}
coverage: dict[str, list[str]] = {
"racion": [],
"derived": [],
"fallback": [],
"missing": [],
}
dynamic = dynamic_meta or {}
all_keys = {d["key"] for d in RATION_ALL_INDICATORS}
all_keys.update(racion_resolved.keys())
all_keys.update(stored.keys())
for key in sorted(all_keys):
rac = racion_resolved.get(key) or {}
st = stored.get(key) or {}
rac_min = rac.get("min")
st_min = st.get("min")
st_max = st.get("max")
source: str | None = None
min_v: float | None = None
if rac_min is not None:
min_v = rac_min
src = (dynamic.get(key) or {}).get("source")
source = "derived" if src == "derived" else "racion"
elif st_min is not None:
min_v = st_min
source = "fallback"
max_v = st_max if st_max is not None else rac.get("max")
if min_v is not None or max_v is not None:
out[key] = {"min": min_v, "max": max_v}
if source == "racion":
coverage["racion"].append(key)
elif source == "derived":
coverage["derived"].append(key)
elif source == "fallback":
coverage["fallback"].append(key)
elif key in {d["key"] for d in RATION_ALL_INDICATORS}:
coverage["missing"].append(key)
coverage["withMin"] = len([k for k, b in out.items() if b.get("min") is not None])
coverage["total"] = len(RATION_ALL_INDICATORS)
return out, coverage
def _require_dairy_params(req: NormsResolveRequest) -> tuple[float, float]:
if req.mass_kg is None or req.mass_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите живую массу, кг")
if req.milk_yield_kg is None or req.milk_yield_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите суточный удой, кг")
return float(req.mass_kg), float(req.milk_yield_kg)
def _moscow_params(req: NormsResolveRequest) -> MoscowDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
return MoscowDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
)
def _piter_params(req: NormsResolveRequest) -> PiterDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
konc = p.konc_oe_sv
if konc is None or konc <= 0:
raise ValueError("Для методики Петербург укажите концентрацию ОЭ/СВ, МДж/кг СВ")
return PiterDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
konc_oe_sv=float(konc),
)
def resolve_norms(req: NormsResolveRequest) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
method = normalize_norms_method(req.method)
if method == "wesp":
resolved, dynamic = apply_dynamic_norms(
req.stored,
mass_kg=req.mass_kg,
milk_yield_kg=req.milk_yield_kg,
ration_type=req.ration_type,
force_dynamic=req.force_dynamic,
)
return resolved, {"normsMethod": "wesp", "dynamicNorms": dynamic}
try:
if method == "racion_moscow":
racion, pack = resolve_moscow_dairy_norms(_moscow_params(req))
else:
racion, pack = resolve_piter_dairy_norms(_piter_params(req))
except PiterPrepError as exc:
raise ValueError(str(exc)) from exc
resolved, coverage = merge_hybrid_norms(racion, req.stored, dynamic_meta=pack.get("dynamic"))
return resolved, {"normsMethod": method, "coverage": coverage, **pack}
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from typing import Any, Mapping
from app.lab.nutrient_schema import resolve_sv_g_per_kg
def normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def parse_num(value: Any) -> 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 get_nutrient_value(
dry_matter: float | None,
nutrients: Mapping[str, Any] | None,
nutrient_keys: list[str],
*,
indicator_key: str | None = None,
) -> float | None:
"""Только точное совпадение ключа (заголовок или indicator_key slug)."""
nutrients = nutrients or {}
if indicator_key:
for k, v in nutrients.items():
if normalize_key(str(k)) == normalize_key(indicator_key):
n = parse_num(v)
if n is not None:
return n
for search in nutrient_keys:
target = normalize_key(search)
for k, v in nutrients.items():
if normalize_key(str(k)) != target:
continue
n = parse_num(v)
if n is not None:
return n
if any(normalize_key(k) == "св" for k in nutrient_keys):
return resolve_sv_g_per_kg(nutrients, dry_matter)
return None
def weighted_average(
lines: list[dict[str, Any]],
total_kg: float,
nutrient_keys: list[str],
*,
indicator_key: str | None = None,
) -> float | None:
if total_kg <= 0:
return None
total = 0.0
weight = 0.0
for line in lines:
kg = float(line.get("daily_kg") or 0)
if kg <= 0:
continue
v = get_nutrient_value(
line.get("dry_matter"),
line.get("nutrients"),
nutrient_keys,
indicator_key=indicator_key,
)
if v is None:
continue
total += kg * v
weight += kg
if weight <= 0:
return None
return total / weight
def daily_intake_total(
lines: list[dict[str, Any]],
nutrient_keys: list[str],
*,
heads_per_trip: int = 1,
indicator_key: str | None = None,
) -> float | None:
"""Суточная доза на голову (г или МДж): Σ (кг/день/гол × г/кг). Как Excel Рацион КРС."""
heads = max(int(heads_per_trip or 1), 1)
total = 0.0
any_value = False
for line in lines:
herd_kg = float(line.get("daily_kg") or 0)
if herd_kg <= 0:
continue
kg = herd_kg / heads
v = get_nutrient_value(
line.get("dry_matter"),
line.get("nutrients"),
nutrient_keys,
indicator_key=indicator_key,
)
if v is None:
continue
total += kg * v
any_value = True
return total if any_value else None
def rnb(
crude_protein: float | None,
usp: float | None,
) -> float | None:
"""RNB (ruminal nitrogen balance, GfE): (сырой протеин − уСП) / 6,25."""
if crude_protein is None or usp is None:
return None
return (crude_protein - usp) / 6.25
def bra_rnb(crude_protein: float | None, usp: float | None) -> float | None:
"""Deprecated alias for :func:`rnb`."""
return rnb(crude_protein, usp)
def norm_diff(
content: float | None,
min_val: float | None,
max_val: float | None,
) -> float | None:
if content is None:
return None
if min_val is not None and content < min_val:
return content - min_val
if max_val is not None and content > max_val:
return content - max_val
return 0.0
+1
View File
@@ -0,0 +1 @@
"""Российские методики суточных норм (Москва / Петербург)."""
+25
View File
@@ -0,0 +1,25 @@
"""Линейная интерполяция (аналог FRAC в методичке)."""
from __future__ import annotations
def frac(numerator: float, denominator: float) -> float:
if denominator == 0:
return 0.0
return numerator / denominator
def lerp(x: float, x1: float, n1: float, x2: float, n2: float) -> float:
"""Norma = n1 + frac(n2 - n1, x2 - x1) * (x - x1)."""
if x2 == x1:
return n1
return n1 + frac(n2 - n1, x2 - x1) * (x - x1)
def popr_index(udoy: float, boundaries: list[float]) -> int:
"""1-based индекс столбца POPR_K по суточному удою."""
idx = 1
for i, bound in enumerate(boundaries, start=1):
if udoy >= bound:
idx = i + 1
return min(idx, len(boundaries) + 1)
+107
View File
@@ -0,0 +1,107 @@
"""Методика Москва — лактирующие коровы (NORM_1_1_CALC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.lab.calc.racion.interp import popr_index
from app.lab.calc.norms_derived import apply_derived_norms
from app.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.lab.calc.racion.tables import load_moskwa_lactir
@dataclass(frozen=True)
class MoscowDairyParams:
mass_kg: float
milk_yield_kg: float
milk_fat_pct: float = 4.0
lactation_no: int = 2
body_condition: int = 1
housing_system: int = 1
def _row_lookup(rows: list[dict], npitv: int) -> dict | None:
for row in rows:
if row["npitv"] == npitv and row["pom"] == 1:
return row
return None
def _popr_value(row: dict, udoy: float, boundaries: list[float]) -> tuple[float | None, float | None]:
popr = row.get("popr_k") or []
if not popr:
return None, None
idx = popr_index(udoy, boundaries) - 1
idx = max(0, min(idx, len(popr) - 1))
p_k = popr[idx]
koef = float(row.get("koef") or 0)
return p_k, koef
def compute_moscow_norm(npitv: int, params: MoscowDairyParams) -> float | None:
data = load_moskwa_lactir()
boundaries = data.get("udoy_boundaries") or []
row = _row_lookup(data.get("rows") or [], npitv)
if row is None:
return None
mass = params.mass_kg
udoy = params.milk_yield_kg
jir = params.milk_fat_pct
wmassa = mass * 1.02 if params.body_condition > 1 else mass
p_k, koef = _popr_value(row, udoy, boundaries)
if p_k is None:
return None
norma: float | None = None
if npitv == 1:
temp = 0.005 if udoy <= 22 else 0.0025
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 148
elif npitv == 2:
temp = 0.09 if udoy <= 22 else 0.065
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 15
elif npitv == 3:
temp = 0.017 if udoy <= 22 else 0.015
norma = temp * (wmassa - 500) + p_k * udoy
elif 4 <= npitv <= 24:
norma = p_k * udoy + (wmassa - 500) * koef
if npitv == 10:
norma *= 0.393
else:
return None
if norma is None:
return None
if params.lactation_no == 1:
norma *= 0.95
elif params.lactation_no == 3:
norma *= 1.05
if params.housing_system == 2:
norma *= 1.1
return round(norma, 3)
def resolve_moscow_dairy_norms(params: MoscowDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
resolved: dict[str, dict[str, float | None]] = {}
dynamic: dict[str, Any] = {}
for npitv in DAIRY_LACTIR_NPITV:
value = compute_moscow_norm(npitv, params)
if value is None:
continue
key = NPITV_TO_INDICATOR.get(npitv)
if not key:
continue
resolved[key] = {"min": value, "max": None}
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_moscow", "source": "racion"}
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
for k, v in derived_dyn.items():
dynamic[k] = {**v, "method": "racion_moscow", "source": "derived"}
meta = {
"method": "racion_moscow",
"massKg": params.mass_kg,
"milkYieldKg": params.milk_yield_kg,
"milkFatPct": params.milk_fat_pct,
}
return resolved, {"meta": meta, "dynamic": dynamic}
+42
View File
@@ -0,0 +1,42 @@
"""NPitV (справочник питательных веществ) → ключи показателей WESP."""
from __future__ import annotations
# NORMY_MOSKWA_LACTIR / NORM_1_1_CALC: NPitV 124 (pom=1)
NPITV_TO_INDICATOR: dict[int, str] = {
1: "feed_units",
2: "oe",
3: "dry_matter",
4: "crude_protein",
5: "digestible_protein",
6: "crude_fat",
7: "nel",
8: "rnb",
9: "usp",
10: "sodium",
11: "magnesium",
12: "starch",
13: "potassium",
14: "calcium",
15: "phosphorus",
16: "iron",
17: "copper",
18: "zinc",
19: "manganese",
20: "cobalt",
21: "iodine",
22: "carotene",
23: "vitamin_d",
24: "vitamin_e",
}
DAIRY_LACTIR_NPITV: tuple[int, ...] = tuple(range(1, 25))
# Обратная совместимость
DAIRY_CORE_NPITV: tuple[int, ...] = (2, 3, 4, 5, 7, 8, 9, 10, 12, 14, 15)
INDICATOR_TO_NPITV: dict[str, int] = {v: k for k, v in NPITV_TO_INDICATOR.items()}
def indicator_for_npitv(npitv: int) -> str | None:
return NPITV_TO_INDICATOR.get(npitv)
+193
View File
@@ -0,0 +1,193 @@
"""Методика Петербург — лактирующие коровы (NORM_1_2_CALC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.lab.calc.norms_derived import apply_derived_norms
from app.lab.calc.racion.interp import lerp
from app.lab.calc.racion.moscow import MoscowDairyParams
from app.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.lab.calc.racion.piter_prep import PiterPrepError, PiterPrepResult, prepare_piter_calc
from app.lab.calc.racion.tables import load_piter_lactir
@dataclass(frozen=True)
class PiterDairyParams(MoscowDairyParams):
konc_oe_sv: float = 10.3
def _konc_bracket(konc: float, konc_values: list[float]) -> tuple[float, float]:
sorted_k = sorted(set(konc_values))
positive = [k for k in sorted_k if k > 0]
if not positive:
return konc, konc
if konc <= positive[0]:
return positive[0], positive[min(1, len(positive) - 1)]
for i in range(len(positive) - 1):
if positive[i] <= konc <= positive[i + 1]:
return positive[i], positive[i + 1]
return positive[-2], positive[-1]
def _udoy_bracket(udoy_jir: float, udoys: list[float]) -> tuple[float, float]:
sorted_u = sorted(set(udoys))
if len(sorted_u) < 2:
return sorted_u[0], sorted_u[0]
if udoy_jir <= sorted_u[0]:
return sorted_u[0], sorted_u[1]
for i in range(len(sorted_u) - 1):
if sorted_u[i] <= udoy_jir < sorted_u[i + 1]:
return sorted_u[i], sorted_u[i + 1]
return sorted_u[-2], sorted_u[-1]
def _norm_at_mass(
entry_normy: list[float],
mass_ind: int,
wmassa: float,
m_a: float,
m_b: float,
) -> float | None:
i = mass_ind - 1
if i < 0 or i + 1 >= len(entry_normy):
return None
n_a, n_b = entry_normy[i], entry_normy[i + 1]
if n_a <= 0 or n_b <= 0:
return None
return lerp(wmassa, m_a, n_a, m_b, n_b)
def _entries_for(data: dict, npitv: int) -> list[dict]:
npv = 4 if npitv == 5 else npitv
return [e for e in data.get("entries") or [] if e["npitv"] == npv]
def _compute_with_konc(
entries: list[dict],
npitv: int,
params: PiterDairyParams,
prep: PiterPrepResult,
konc_pred: float,
konc_sled: float,
) -> float | None:
by_konc = {k: [e for e in entries if abs(e["konc"] - k) < 1e-6] for k in (konc_pred, konc_sled)}
def _at_konc(konc: float) -> float | None:
rows = by_konc.get(konc) or []
if not rows:
return None
udoys = [e["udoy"] for e in rows]
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
if ud_pred == ud_sled:
return None
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
if not e_pred or not e_sled:
return None
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
if n01 is None or n02 is None:
return None
return lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
norma1 = _at_konc(konc_pred)
norma2 = _at_konc(konc_sled)
if norma1 is None or norma2 is None or konc_pred == konc_sled:
return None
norma = lerp(params.konc_oe_sv, konc_pred, norma1, konc_sled, norma2)
if npitv == 5:
norma *= 0.65
return round(norma, 3)
def _compute_konc_independent(
entries: list[dict],
params: PiterDairyParams,
prep: PiterPrepResult,
) -> float | None:
rows = [e for e in entries if e["konc"] == -10]
if not rows:
return None
udoys = [e["udoy"] for e in rows]
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
if ud_pred == ud_sled:
return None
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
if not e_pred or not e_sled:
return None
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
if n01 is None or n02 is None:
return None
norma = lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
if params.housing_system == 2:
norma *= 1.1
return round(norma, 3)
def compute_piter_norm(npitv: int, params: PiterDairyParams, prep: PiterPrepResult | None = None) -> float | None:
data = load_piter_lactir()
entries = _entries_for(data, npitv)
if not entries:
return None
if prep is None:
prep = prepare_piter_calc(
mass_kg=params.mass_kg,
milk_yield_kg=params.milk_yield_kg,
milk_fat_pct=params.milk_fat_pct,
konc_oe_sv=params.konc_oe_sv,
body_condition=params.body_condition,
)
konc_vals = sorted({e["konc"] for e in entries if e["konc"] > 0})
if konc_vals:
min_k = min(konc_vals)
if min_k > 0:
konc_pred, konc_sled = _konc_bracket(params.konc_oe_sv, konc_vals)
return _compute_with_konc(entries, npitv, params, prep, konc_pred, konc_sled)
return _compute_konc_independent(entries, params, prep)
def resolve_piter_dairy_norms(params: PiterDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
prep = prepare_piter_calc(
mass_kg=params.mass_kg,
milk_yield_kg=params.milk_yield_kg,
milk_fat_pct=params.milk_fat_pct,
konc_oe_sv=params.konc_oe_sv,
body_condition=params.body_condition,
)
resolved: dict[str, dict[str, float | None]] = {}
dynamic: dict[str, Any] = {}
for npitv in DAIRY_LACTIR_NPITV:
value = compute_piter_norm(npitv, params, prep=prep)
if value is None:
continue
key = NPITV_TO_INDICATOR.get(npitv)
if not key:
continue
resolved[key] = {"min": value, "max": None}
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_piter", "source": "racion"}
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
for k, v in derived_dyn.items():
dynamic[k] = {**v, "method": "racion_piter", "source": "derived"}
meta = {
"method": "racion_piter",
"massKg": params.mass_kg,
"milkYieldKg": params.milk_yield_kg,
"koncOeSv": params.konc_oe_sv,
"prep": {
"massInd": prep.mass_ind,
"mA": prep.m_a,
"mB": prep.m_b,
"koncPred": prep.konc_pred,
"koncSled": prep.konc_sled,
},
}
return resolved, {"meta": meta, "dynamic": dynamic}
__all__ = ["PiterDairyParams", "PiterPrepError", "compute_piter_norm", "resolve_piter_dairy_norms"]
+202
View File
@@ -0,0 +1,202 @@
"""Подготовка параметров NORM_1_2_PREP (интервалы массы и концентрации)."""
from __future__ import annotations
from dataclasses import dataclass
from app.lab.calc.racion.interp import lerp
from app.lab.calc.racion.tables import load_normy_info
@dataclass(frozen=True)
class PiterPrepResult:
mass_ind: int
konc_pred: float
konc_sled: float
m_a: float
m_b: float
udoy_jir: float
wmassa: float
class PiterPrepError(ValueError):
def __init__(self, code: int, message: str, *, info: str | None = None) -> None:
super().__init__(message)
self.code = code
self.info = info
def _info_values(nperem: int) -> list[float]:
data = load_normy_info()
for row in data.get("rows") or []:
if row.get("nperem") == nperem:
return list(row.get("znachenie") or [])
return []
def _fl_from_str(values: list[float], index: int) -> float | None:
"""1-based index как FlFromStr в RACION."""
i = index - 1
if i < 0 or i >= len(values):
return None
return float(values[i])
def _wmassa(mass_kg: float, body_condition: int) -> float:
if body_condition > 1:
return mass_kg * 1.02
return mass_kg
def prepare_piter_calc(
*,
mass_kg: float,
milk_yield_kg: float,
milk_fat_pct: float,
konc_oe_sv: float,
body_condition: int = 1,
) -> PiterPrepResult:
"""Порт NORM_1_2_PREP: интервалы для NORM_1_2_CALC."""
if milk_yield_kg <= 0:
raise PiterPrepError(-11, "Укажите суточный удой, кг")
if milk_fat_pct <= 0:
raise PiterPrepError(-12, "Укажите жирность молока, %")
if mass_kg <= 0:
raise PiterPrepError(-14, "Укажите живую массу, кг")
if konc_oe_sv <= 0:
raise PiterPrepError(-15, "Укажите концентрацию ОЭ/СВ, МДж/кг СВ")
wmassa = _wmassa(mass_kg, body_condition)
udoy_jir = milk_yield_kg * milk_fat_pct * 0.25
kol_konc_vals = _info_values(7)
kol_mass_vals = _info_values(7)
if len(kol_konc_vals) < 2 or len(kol_mass_vals) < 2:
raise PiterPrepError(-1, "Справочник NORMY_INFO (NPerem=7) не задан")
kol_konc = int(kol_konc_vals[0])
kol_mass = int(kol_mass_vals[1])
if kol_konc < 2 or kol_mass < 2:
raise PiterPrepError(-1, "Некорректные размеры таблицы концентраций/масс")
masses = _info_values(6) or _info_values(14)
koncss = _info_values(3)
if not masses or not koncss or len(masses) < 2 or len(koncss) < 2:
raise PiterPrepError(-1, "Справочник NORMY_INFO: массы или концентрации не заданы")
# Интервал концентрации
konc_ind = 1
for i in range(2, kol_konc):
v = _fl_from_str(koncss, i)
if v is not None and konc_oe_sv >= v:
konc_ind = i
konc_pred = _fl_from_str(koncss, konc_ind)
konc_sled = _fl_from_str(koncss, konc_ind + 1)
if konc_pred is None or konc_sled is None or konc_pred < 0 or konc_sled < 0 or konc_pred == konc_sled:
raise PiterPrepError(-1, "Не удалось определить интервал концентрации")
# Интервал массы
mass_ind = 1
for i in range(2, kol_mass):
v = _fl_from_str(masses, i)
if v is not None and wmassa >= v:
mass_ind = i
m_a = _fl_from_str(masses, mass_ind)
m_b = _fl_from_str(masses, mass_ind + 1)
if m_a is None or m_b is None or m_a < 0 or m_b < 0 or m_a == m_b:
raise PiterPrepError(-1, "Не удалось определить интервал массы")
# Проверка удоя (NPerem=4,5)
udoy_str_4 = _info_values(4)
udoy_str_5 = _info_values(5)
udoy_min = _fl_from_str(udoy_str_4, 1) if udoy_str_4 else None
udoy_max = _fl_from_str(udoy_str_5, kol_konc) if udoy_str_5 else None
if udoy_min is not None and udoy_max is not None:
if udoy_jir < udoy_min or udoy_jir > udoy_max:
jir_str = _info_values(2)
gr_udoy1 = max(udoy_min * 4 / milk_fat_pct, udoy_min)
gr_udoy2 = min(udoy_max * 4 / milk_fat_pct, udoy_max)
gr_jir1 = udoy_min * 4 / milk_yield_kg
gr_jir2 = udoy_max * 4 / milk_yield_kg
if jir_str:
if len(jir_str) >= 1:
gr_jir1 = max(gr_jir1, jir_str[0])
if len(jir_str) >= 2:
gr_jir2 = min(gr_jir2, jir_str[1])
code = -4 if udoy_jir < udoy_min else -5
info = f"{gr_udoy1:.4f};{gr_udoy2:.4f};{gr_jir1:.3f};{gr_jir2:.3f};"
raise PiterPrepError(code, "Удой вне допустимого диапазона для жирности", info=info)
# Допустимый диапазон концентрации для udoy_jir
i = 1
if udoy_jir >= (_fl_from_str(udoy_str_4, 1) or 0):
while i < kol_konc - 1:
nxt = _fl_from_str(udoy_str_4, i + 1)
if nxt is None or nxt > udoy_jir:
break
i += 1
else:
while i < kol_konc - 1:
nxt = _fl_from_str(udoy_str_4, i + 1)
cur = _fl_from_str(udoy_str_4, i)
if nxt is None or cur is None or nxt != cur:
break
i += 1
ud_a = _fl_from_str(udoy_str_4, i)
ud_b = _fl_from_str(udoy_str_4, i + 1)
konc_a = _fl_from_str(koncss, i)
konc_b = _fl_from_str(koncss, i + 1)
if ud_a is not None and ud_b is not None and konc_a is not None and konc_b is not None:
if ud_a == ud_b:
konc_max = konc_b
else:
konc_max = lerp(udoy_jir, ud_a, konc_a, ud_b, konc_b)
i = kol_konc
if udoy_jir <= (_fl_from_str(udoy_str_5, kol_konc) or udoy_jir):
while i > 2:
prev = _fl_from_str(udoy_str_5, i - 1)
if prev is None or prev < udoy_jir:
break
i -= 1
else:
while i > 2:
prev = _fl_from_str(udoy_str_5, i - 1)
last = _fl_from_str(udoy_str_5, kol_konc)
if prev is None or last is None or prev != last:
break
i -= 1
ud_a2 = _fl_from_str(udoy_str_5, i - 1)
ud_b2 = _fl_from_str(udoy_str_5, i)
konc_a2 = _fl_from_str(koncss, i - 1)
konc_b2 = _fl_from_str(koncss, i)
if ud_a2 is not None and ud_b2 is not None and konc_a2 is not None and konc_b2 is not None:
if ud_a2 == ud_b2:
konc_min = konc_b2
else:
konc_min = lerp(udoy_jir, ud_a2, konc_a2, ud_b2, konc_b2)
konc_lo = _fl_from_str(koncss, 1) or konc_min
konc_hi = _fl_from_str(koncss, kol_konc) or konc_max
if konc_min < konc_lo:
konc_min = konc_lo
if konc_max > konc_hi:
konc_max = konc_hi
if round(konc_oe_sv, 1) < round(konc_min, 1) or round(konc_oe_sv, 1) > round(konc_max, 1):
code = -2 if round(konc_oe_sv, 1) < round(konc_min, 1) else -3
info = f"{round(konc_min, 1)};{round(konc_max, 1)};"
raise PiterPrepError(
code,
f"Концентрация ОЭ/СВ вне допустимого диапазона ({round(konc_min, 1)}{round(konc_max, 1)})",
info=info,
)
return PiterPrepResult(
mass_ind=mass_ind,
konc_pred=float(konc_pred),
konc_sled=float(konc_sled),
m_a=float(m_a),
m_b=float(m_b),
udoy_jir=udoy_jir,
wmassa=wmassa,
)
+75
View File
@@ -0,0 +1,75 @@
"""Загрузка справочников норм RACION (БД → JSON fallback)."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
_SEED_DIR = Path(__file__).resolve().parents[4] / "data" / "seed" / "racion"
def _read_json(name: str) -> dict:
path = _SEED_DIR / name
if not path.exists():
raise FileNotFoundError(f"Справочник не найден: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def _load_from_db(loader_name: str) -> dict | None:
try:
from flask import has_app_context
if not has_app_context():
return None
from app.lab.services.racion_reference import (
load_moskwa_lactir_from_db,
load_normy_info_from_db,
load_piter_lactir_from_db,
)
loaders = {
"moskwa": load_moskwa_lactir_from_db,
"piter": load_piter_lactir_from_db,
"info": load_normy_info_from_db,
}
fn = loaders.get(loader_name)
if fn is None:
return None
data = fn()
return data if data else None
except Exception:
return None
@lru_cache(maxsize=1)
def load_moskwa_lactir() -> dict:
data = _load_from_db("moskwa")
if data:
return data
return _read_json("moskwa_lactir.json")
@lru_cache(maxsize=1)
def load_piter_lactir() -> dict:
data = _load_from_db("piter")
if data:
return data
return _read_json("piter_lactir.json")
@lru_cache(maxsize=1)
def load_normy_info() -> dict:
data = _load_from_db("info")
if data:
return data
try:
return _read_json("normy_info.json")
except FileNotFoundError:
return {"rows": [], "mass_kg_values": [400, 450, 500, 550, 600, 650, 700, 750]}
def clear_tables_cache() -> None:
load_moskwa_lactir.cache_clear()
load_piter_lactir.cache_clear()
load_normy_info.cache_clear()
+21
View File
@@ -0,0 +1,21 @@
from .apply_from_master import apply_from_master
from .ensure_empty_master import ensure_empty_master
from .recalculate import recalculate_ration
from .seed_demo_component_nutrients import seed_demo_component_nutrients_once
from .seed_from_execution import seed_from_execution
from .sync_from_execution import sync_from_execution
from .delete_profile import delete_animal_profile
from .upsert_profile import upsert_animal_profile
from .upsert_ration import upsert_ration
__all__ = [
"upsert_ration",
"recalculate_ration",
"apply_from_master",
"seed_demo_component_nutrients_once",
"seed_from_execution",
"ensure_empty_master",
"sync_from_execution",
"delete_animal_profile",
"upsert_animal_profile",
]
@@ -0,0 +1,76 @@
from __future__ import annotations
from app import db
from app.lab.commands.write_audit import write_audit
from app.lab.db_guard import retry_locked
from app.lab.loaders.execution_loader import load_execution
from app.lab.loaders.ration_loader import load_ration
from app.models import Component, Ingredient, Recipe
from app.models.base import default_uuid
from app.services.recipe_update_service import _enqueue_recipe_children_sync
@retry_locked
def apply_from_master(recipe_id: str, user_id: str = "system") -> dict:
snapshot = load_ration(recipe_id)
if not snapshot.exists:
raise LookupError("Мастер рациона не найден")
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
heads = int(recipe.heads_per_trip or 0)
if heads <= 0:
raise ValueError("Количество голов должно быть > 0")
execution = load_execution(recipe_id)
by_component = {str(l.component_id): l for l in execution.lines if l.component_id}
master_by_component = {
str(l.component_id): l
for l in snapshot.lines
if l.component_id and l.in_ration
}
touched_ingredient_ids: list[str] = []
for comp_id, master_line in master_by_component.items():
daily_kg = float(master_line.daily_kg or 0)
weight_per_head = daily_kg / heads
ing = by_component.get(comp_id)
comp = Component.query.get(comp_id)
dm_pct = float(comp.dry_matter or 0) if comp else float(master_line.dry_matter or 0)
if ing:
row = Ingredient.query.get(ing.ingredient_id)
if row is None:
continue
row.weight_per_head = weight_per_head
row.amount = weight_per_head
row.dry_matter = dm_pct
row.updated_by = user_id
touched_ingredient_ids.append(row.id)
else:
comp_name = master_line.ingredient_name or comp_id
new_ing = Ingredient(
id=default_uuid(),
recipe_id=recipe_id,
component_id=comp_id,
name=comp_name[:100],
amount=weight_per_head,
weight_per_head=weight_per_head,
dry_matter=dm_pct,
order=len(touched_ingredient_ids),
created_by=user_id,
updated_by=user_id,
)
db.session.add(new_ing)
touched_ingredient_ids.append(new_ing.id)
master_ids = set(master_by_component)
for line in execution.lines:
if line.component_id and str(line.component_id) not in master_ids:
row = Ingredient.query.get(line.ingredient_id)
if row and not row.is_deleted:
row.soft_delete(user_id)
_enqueue_recipe_children_sync(recipe_id)
write_audit("APPLY_FROM_MASTER", "lab_recipe_ration", recipe_id, user_id)
db.session.commit()
return {"recipeId": recipe_id, "ingredientsUpdated": len(touched_ingredient_ids)}
+202
View File
@@ -0,0 +1,202 @@
"""Аудит данных lab-модуля (профили, нормы, рационы) — без компонентов."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from app import db
from app.lab.constants import RATION_QUALITY_INDICATORS
from app.lab.models import (
LabAnimalProfile,
LabProfileNorm,
LabRationCalcIndicator,
LabRationLine,
LabRecipeRation,
)
from app.lab.calc.feed_groups import classify_feed_group
from app.lab.nutrient_schema import read_from_mapping
from app.lab.services.component_nutrients import nutrients_full_dict
from app.models import Component, Recipe
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
_log = logging.getLogger(__name__)
CALC_NORM_KEYS = tuple(d["key"] for d in RATION_QUALITY_INDICATORS)
@dataclass
class AuditIssue:
level: str # error | warn | info
area: str
message: str
@dataclass
class LabDataAuditReport:
issues: list[AuditIssue] = field(default_factory=list)
profiles: int = 0
profiles_with_norms: int = 0
profile_norms_beef: int = 0
profile_norms_dairy: int = 0
recipe_rations: int = 0
ration_lines: int = 0
calc_indicators: int = 0
components: int = 0
components_omd_missing: int = 0
def add(self, level: str, area: str, message: str) -> None:
self.issues.append(AuditIssue(level, area, message))
def ok(self) -> bool:
return not any(i.level == "error" for i in self.issues)
def _audit_components(report: LabDataAuditReport) -> None:
components = Component.query.filter_by(is_deleted=False).all()
report.components = len(components)
for comp in components:
full = nutrients_full_dict(comp.id)
if not full:
continue
feed_group = classify_feed_group(comp)
omd = read_from_mapping(full, _OMD_KEYS)
if feed_group in ("rough", "succulent") and omd is None:
report.components_omd_missing += 1
report.add(
"warn",
"component",
f"Компонент {comp.name!r} ({feed_group}): нет ВРХ Орг Вещ",
)
oe = read_from_mapping(full, ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"))
nel = read_from_mapping(full, ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"))
if (oe is not None and oe < 0) or (nel is not None and nel < 0):
report.add(
"warn",
"component",
f"Компонент {comp.name!r}: отрицательная энергия (ОЭ={oe}, ЧЭЛ={nel})",
)
def audit_lab_data(*, include_components: bool = False) -> LabDataAuditReport:
report = LabDataAuditReport()
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
report.profiles = len(profiles)
for profile in profiles:
norms = LabProfileNorm.query.filter_by(profile_id=profile.id).all()
norm_keys = {n.indicator_key for n in norms}
if norms:
report.profiles_with_norms += 1
else:
report.add("warn", "profile", f"Профиль {profile.profile_key!r} без норм (lab_profile_norm пуст)")
if profile.mass_kg is None:
report.add("warn", "profile", f"Профиль {profile.profile_key!r}: mass_kg не задан")
missing = [k for k in CALC_NORM_KEYS if k not in norm_keys]
if norms and missing:
report.add(
"info",
"profile",
f"Профиль {profile.profile_key!r}: нет ключей {', '.join(missing[:5])}"
+ ("" if len(missing) > 5 else ""),
)
for norm in norms:
if norm.min_value is None and norm.max_value is None:
report.add(
"warn",
"profile",
f"Профиль {profile.profile_key!r}: {norm.indicator_key} без min и max",
)
for ration_type in ("BEEF", "DAIRY"):
count = (
db.session.query(LabProfileNorm)
.join(LabAnimalProfile, LabAnimalProfile.id == LabProfileNorm.profile_id)
.filter(
LabAnimalProfile.ration_type == ration_type,
LabAnimalProfile.is_deleted.is_(False),
)
.count()
)
if ration_type == "BEEF":
report.profile_norms_beef = count
else:
report.profile_norms_dairy = count
if report.profile_norms_beef == 0 and report.profile_norms_dairy == 0:
report.add(
"warn",
"profile_norm",
"lab_profile_norm пуст — нормы не импортированы (scripts/import_seed.py --norms)",
)
headers = (
LabRecipeRation.query.filter_by(is_deleted=False)
.join(Recipe, Recipe.id == LabRecipeRation.recipe_id)
.filter(Recipe.is_deleted.is_(False))
.all()
)
report.recipe_rations = len(headers)
for header in headers:
recipe = Recipe.query.get(header.recipe_id)
name = recipe.name if recipe else header.recipe_id
if not header.animal_profile_id:
report.add("warn", "ration", f"Рецепт {name!r}: нет animal_profile_id")
elif LabAnimalProfile.query.get(header.animal_profile_id) is None:
report.add("error", "ration", f"Рецепт {name!r}: профиль {header.animal_profile_id} не найден")
lines = LabRationLine.query.filter_by(
recipe_id=header.recipe_id, is_deleted=False
).all()
active = [ln for ln in lines if ln.in_ration and (ln.daily_kg or 0) > 0]
if not active:
report.add("info", "ration", f"Рецепт {name!r}: нет активных строк рациона")
for line in active:
if not line.component_id:
report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: нет component_id")
if line.daily_kg is None:
report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: daily_kg пуст")
if header.calculated_at and not LabRationCalcIndicator.query.filter_by(
recipe_id=header.recipe_id
).first():
report.add("warn", "calc", f"Рецепт {name!r}: calculated_at есть, lab_ration_calc_indicator пуст")
report.ration_lines = LabRationLine.query.filter_by(is_deleted=False).count()
report.calc_indicators = LabRationCalcIndicator.query.count()
if report.profiles == 0:
report.add("warn", "profile", "Нет профилей стада (lab_animal_profile)")
if include_components:
_audit_components(report)
_log.info(
"lab audit: profiles=%s with_norms=%s rations=%s issues=%s",
report.profiles,
report.profiles_with_norms,
report.recipe_rations,
len(report.issues),
)
return report
def format_audit_report(report: LabDataAuditReport) -> str:
lines = [
"=== Lab data audit ===",
f"Профили: {report.profiles} (с нормами: {report.profiles_with_norms})",
f"lab_profile_norm: BEEF={report.profile_norms_beef}, DAIRY={report.profile_norms_dairy}",
f"lab_recipe_ration: {report.recipe_rations}, строк: {report.ration_lines}",
f"lab_ration_calc_indicator: {report.calc_indicators}",
f"components: {report.components} (без ВРХ rough/succulent: {report.components_omd_missing})",
f"Замечаний: {len(report.issues)}",
]
for issue in report.issues:
lines.append(f" [{issue.level}] {issue.area}: {issue.message}")
return "\n".join(lines)
@@ -0,0 +1,84 @@
"""Удаление legacy/fp_* профилей стада (одноразовая ops-команда)."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from app import db
from app.lab.models import LabAnimalProfile, LabProfileNorm, LabRecipeRation
_log = logging.getLogger(__name__)
_LEGACY_KEY = re.compile(r"^(dairy|beef)-\d+$", re.IGNORECASE)
_FP_KEY = re.compile(r"^fp_", re.IGNORECASE)
@dataclass
class CleanupReport:
dry_run: bool = True
profiles_to_delete: list[str] = field(default_factory=list)
recipe_ids_cleared: list[str] = field(default_factory=list)
norms_deleted: int = 0
profiles_deleted: int = 0
errors: list[str] = field(default_factory=list)
def _should_delete(profile_key: str | None) -> bool:
key = (profile_key or "").strip()
if not key:
return False
if key.startswith("lab_math_"):
return False
if _LEGACY_KEY.match(key):
return True
if _FP_KEY.match(key):
return True
return False
def cleanup_legacy_profiles(*, dry_run: bool = True, user_id: str = "system") -> CleanupReport:
report = CleanupReport(dry_run=dry_run)
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
to_delete = [p for p in profiles if _should_delete(p.profile_key)]
for profile in to_delete:
report.profiles_to_delete.append(profile.profile_key or profile.id)
norms_count = LabProfileNorm.query.filter_by(profile_id=profile.id).count()
report.norms_deleted += norms_count
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
for header in rations:
if header.recipe_id not in report.recipe_ids_cleared:
report.recipe_ids_cleared.append(header.recipe_id)
if dry_run:
continue
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
for header in rations:
header.animal_profile_id = None
header.updated_by = user_id
if hasattr(profile, "soft_delete"):
profile.soft_delete(user_id)
else:
db.session.delete(profile)
report.profiles_deleted += 1
if not dry_run:
db.session.commit()
_log.info(
"cleanup_legacy_profiles user=%s deleted=%s rations_cleared=%s",
user_id,
report.profiles_deleted,
len(report.recipe_ids_cleared),
)
else:
_log.info(
"cleanup_legacy_profiles dry_run profiles=%s rations=%s",
len(report.profiles_to_delete),
len(report.recipe_ids_cleared),
)
return report
@@ -0,0 +1,30 @@
from __future__ import annotations
from app import db
from app.lab.db_guard import retry_locked
from app.lab.models import (
LabAnimalProfile,
LabProfileNorm,
LabRecipeRation,
)
@retry_locked
def delete_animal_profile(profile_id: str, user_id: str = "system") -> dict:
pid = (profile_id or "").strip()
if not pid:
raise LookupError("Профиль не найден")
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
for header in rations:
header.animal_profile_id = None
header.updated_by = user_id
profile.updated_by = user_id
profile.soft_delete(user_id)
db.session.commit()
return {"id": profile.id, "deleted": True}
@@ -0,0 +1,20 @@
from __future__ import annotations
from app import db
from app.lab.db_guard import retry_locked
from app.lab.models import LabRecipeRation
from app.models import Recipe
@retry_locked
def ensure_empty_master(recipe_id: str, user_id: str = "system") -> dict:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
if existing is not None:
return {"recipeId": recipe_id, "created": False}
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
db.session.commit()
return {"recipeId": recipe_id, "created": True}
+299
View File
@@ -0,0 +1,299 @@
"""Generic ETL: CSV из data/seed/ → lab profiles и component nutrients."""
from __future__ import annotations
import csv
import logging
from dataclasses import dataclass, field
from pathlib import Path
from app import db
from app.lab.calc.ingredient_catalog import INGREDIENT_HEADERS
from app.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.lab.models import LabAnimalProfile
from app.lab.norm_catalog import norm_header, norm_title_to_key
from app.lab.seed_paths import norms_dir, nutrients_dir
from app.lab.services.component_nutrients import save_component_nutrients
from app.lab.services.profile_norms import save_norms_from_payload
from app.models.base import default_uuid
from app.models.component import Component
_log = logging.getLogger(__name__)
_SKIP_NUTRIENT_HEADERS = frozenset({"", "Наименование", "Цена 1 кг"})
@dataclass
class NormsImportStats:
profiles_created: int = 0
profiles_updated: int = 0
norm_rows: int = 0
skipped: int = 0
unmapped_headers: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
@dataclass
class NutrientsImportStats:
imported: int = 0
skipped: int = 0
unmatched: int = 0
errors: list[str] = field(default_factory=list)
def _parse_min_max_groups(h1: list[str], h2: list[str]) -> list[tuple[str, int, int | None]]:
groups: list[tuple[str, int, int | None]] = []
current_name: str | None = None
limit = min(len(h1), len(h2))
for i in range(limit):
b = (h2[i] or "").strip().lower().replace("і", "и")
n = (h1[i] or "").strip()
if n and n not in ("", "Наименование", "Масса", "КРС"):
current_name = norm_header(n)
if b.startswith("мин") and current_name:
max_i = i + 1 if i + 1 < limit and "мах" in (h2[i + 1] or "").lower() else None
groups.append((current_name, i, max_i))
return groups
def norm_profile_key(ration_type: str, external_no: int) -> str:
return f"norm_{ration_type.lower()}_{external_no:04d}"
def parse_norm_profile_row(
row: list[str],
groups: list[tuple[str, int, int | None]],
ration_type: str,
*,
unmapped: set[str],
profile_key_fn=norm_profile_key,
) -> dict | None:
if len(row) < 2 or not (row[1] or "").strip():
return None
try:
external_no = int(float((row[0] or "").strip()))
except (ValueError, TypeError):
return None
if external_no <= 0:
return None
indicators: dict[str, dict[str, float | None]] = {}
for header, min_i, max_i in groups:
key = norm_title_to_key(header)
if not key:
unmapped.add(header)
continue
def _num(idx: int | None) -> float | None:
if idx is None or idx >= len(row):
return None
raw = (row[idx] or "").strip()
if not raw:
return None
try:
return float(raw)
except ValueError:
return None
min_v, max_v = _num(min_i), _num(max_i)
if min_v is None and max_v is None:
continue
indicators[key] = {"min": min_v, "max": max_v}
mass_kg = None
if len(row) > 2 and (row[2] or "").strip():
try:
mass_kg = float(row[2])
except ValueError:
pass
return {
"external_no": external_no,
"profile_key": profile_key_fn(ration_type, external_no),
"label": row[1].strip(),
"ration_type": ration_type,
"mass_kg": mass_kg,
"indicators": indicators,
}
_IMPORT_BATCH_SIZE = 50
def _lookup_reference_profile(profile_key: str) -> LabAnimalProfile | None:
"""Поиск по profile_key включая soft-deleted (UNIQUE на всю таблицу)."""
return LabAnimalProfile.query.filter_by(profile_key=profile_key).first()
def _restore_reference_profile(profile: LabAnimalProfile) -> None:
if not profile.is_deleted:
return
profile.is_deleted = False
profile.deleted_at = None
profile.deleted_by = None
def _import_norm_sheet(
csv_path: Path,
ration_type: str,
stats: NormsImportStats,
unmapped: set[str],
*,
replace_reference: bool = True,
) -> None:
if not csv_path.is_file():
stats.errors.append(f"Файл не найден: {csv_path}")
return
with csv_path.open(encoding="utf-8") as f:
rows = list(csv.reader(f))
if len(rows) < 8:
stats.errors.append(f"Слишком мало строк: {csv_path}")
return
groups = _parse_min_max_groups(rows[2], rows[3])
batch_count = 0
for row in rows[6:]:
parsed = parse_norm_profile_row(row, groups, ration_type, unmapped=unmapped)
if parsed is None:
stats.skipped += 1
continue
profile = _lookup_reference_profile(parsed["profile_key"])
if profile is not None:
if not replace_reference and not profile.is_deleted:
stats.skipped += 1
continue
if not replace_reference and profile.is_deleted:
stats.skipped += 1
continue
_restore_reference_profile(profile)
stats.profiles_updated += 1
else:
profile = LabAnimalProfile(
id=default_uuid(),
profile_key=parsed["profile_key"],
created_by="seed-import",
)
db.session.add(profile)
stats.profiles_created += 1
profile.label = parsed["label"]
profile.ration_type = ration_type
profile.external_no = parsed["external_no"]
save_norms_from_payload(
profile,
{
"massKg": parsed["mass_kg"],
"externalNo": parsed["external_no"],
"indicators": parsed["indicators"],
},
)
stats.norm_rows += len(parsed["indicators"])
batch_count += 1
if batch_count >= _IMPORT_BATCH_SIZE:
db.session.commit()
batch_count = 0
def import_norm_profiles(
csv_dir: Path | None = None,
*,
replace_reference: bool = True,
) -> NormsImportStats:
stats = NormsImportStats()
base = csv_dir or norms_dir()
unmapped: set[str] = set()
_import_norm_sheet(
base / "Нормы КРС.csv", "BEEF", stats, unmapped, replace_reference=replace_reference
)
_import_norm_sheet(
base / "Нормы Дойн.csv", "DAIRY", stats, unmapped, replace_reference=replace_reference
)
stats.unmapped_headers = sorted(unmapped)
db.session.commit()
_log.info(
"seed norms import: created=%s updated=%s norm_rows=%s",
stats.profiles_created,
stats.profiles_updated,
stats.norm_rows,
)
return stats
def _parse_nutrient_row(row: list[str]) -> dict[str, float]:
nutrients: dict[str, float] = {}
for i, header in enumerate(INGREDIENT_HEADERS):
if header in _SKIP_NUTRIENT_HEADERS or i >= len(row):
continue
raw = (row[i] or "").strip()
if not raw:
continue
try:
nutrients[header] = float(raw)
except ValueError:
continue
return nutrients
def _find_component(name: str, external_no: int | None) -> Component | None:
if external_no is not None:
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
if hit:
return hit
if name:
hit = Component.query.filter(Component.name == name, Component.is_deleted.is_(False)).first()
if hit:
return hit
return None
def import_component_nutrients(
csv_path: Path | None = None,
*,
derive: bool = True,
dry_run: bool = False,
) -> NutrientsImportStats:
path = csv_path or (nutrients_dir() / "База сырья.csv")
stats = NutrientsImportStats()
if not path.is_file():
stats.errors.append(f"CSV not found: {path}")
return stats
with path.open(encoding="utf-8") as f:
rows = list(csv.reader(f))
for row in rows[5:]:
name = row[1].strip() if len(row) > 1 else ""
if not name:
stats.skipped += 1
continue
try:
external_no = int(float(row[0])) if row[0].strip() else None
except (ValueError, IndexError):
external_no = None
comp = _find_component(name, external_no)
if comp is None:
stats.unmatched += 1
continue
nutrients = _parse_nutrient_row(row)
if derive:
nutrients = derive_ingredient_nutrients(nutrients)
if not dry_run:
save_component_nutrients(comp.id, nutrients, user_id="seed-import")
stats.imported += 1
if not dry_run:
db.session.commit()
_log.info(
"seed nutrients import: imported=%s skipped=%s unmatched=%s",
stats.imported,
stats.skipped,
stats.unmatched,
)
return stats
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import time
from app import db
from app.lab.calc.engine import calculate_ration
from app.lab.commands.write_audit import write_audit, write_calculation_run
from app.lab.db_guard import retry_locked
from app.lab.commands.seed_demo_component_nutrients import (
ensure_component_nutrients_if_empty,
supplement_component_nutrients_if_sparse,
)
from app.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
from app.lab.models import LabAnimalProfile, LabRecipeRation
from app.lab.services.ration_calc_store import save_calc_result
@retry_locked
def recalculate_ration(recipe_id: str, user_id: str = "system") -> dict:
snapshot = load_ration(recipe_id)
if not snapshot.lines:
raise ValueError("Пустой мастер — добавьте строки рациона")
seeded_any = False
for line in snapshot.lines:
if not line.component_id:
continue
if ensure_component_nutrients_if_empty(line.component_id, user_id=user_id):
seeded_any = True
elif supplement_component_nutrients_if_sparse(line.component_id, user_id=user_id):
seeded_any = True
if seeded_any:
db.session.commit()
snapshot = load_ration(recipe_id)
profile_mass_kg = None
if snapshot.animal_profile_id:
prof = LabAnimalProfile.query.get(snapshot.animal_profile_id)
if prof is not None:
profile_mass_kg = prof.mass_kg
started = time.perf_counter()
result = calculate_ration(
snapshot.ration_type,
ration_to_calc_lines(snapshot),
snapshot.norms,
heads_per_trip=snapshot.heads_per_trip,
profile_mass_kg=profile_mass_kg,
)
duration_ms = int((time.perf_counter() - started) * 1000)
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
if header is None:
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
save_calc_result(recipe_id, result, header)
header.updated_by = user_id
status = "FAILED" if result.get("errors") else "COMPLETED"
write_calculation_run(
recipe_id,
status,
{"indicators": result.get("indicators", [])},
duration_ms=duration_ms,
error_message="; ".join(result.get("errors") or []) or None,
)
write_audit("RECALCULATE", "lab_recipe_ration", recipe_id, user_id)
db.session.commit()
return result
@@ -0,0 +1,344 @@
"""Стартовые nutrients для компонентов без EAV (демо-шаблоны, не лабораторный анализ).
При старте и пересчёте рациона заполняет только пустые `lab_component_nutrient_value`.
Маркер в data/ журнал последнего прогона, не блокирует новые компоненты.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from app import db
from config import Config
from app.lab.nutrient_schema import read_from_mapping
from app.lab.services.component_nutrients import nutrients_full_dict, nutrients_is_empty, save_component_nutrients
from app.models.component import Component
_log = logging.getLogger(__name__)
_MARKER_NAME = ".lab_demo_nutrients_seeded"
# Доп. поля для полной матрицы рациона (демо; заменить лабораторными при наличии).
_MINERAL_SUPPLEMENT: dict[str, float] = {
"Ca": 8.0,
"P": 5.5,
"Mg": 3.2,
"Na": 1.2,
"K": 6.5,
"DCAB Форм": 45.0,
"Сырая зола": 55.0,
"Каротин": 5.0,
}
_CARB_SUPPLEMENT: dict[str, float] = {
"Сахар": 80.0,
"Крахмал": 280.0,
"Сахар и Крохм": 420.0,
"Нераств. Крохмал": 35.0,
}
_ROUGHAGE_TYPES = frozenset({"объемные корма", "грубые корма", "сочные корма"})
_SUPPLEMENT_KEYS: tuple[str, ...] = tuple({**_MINERAL_SUPPLEMENT, **_CARB_SUPPLEMENT}.keys())
_NAMED_SEEDS: dict[str, dict[str, Any]] = {
"комбикорм": {
"dry_matter": 88.0,
"nutrients": {
"Сыр. Протеин": 170,
"уСП": 142,
"БРА": 36,
"ОЭ-КРС": 12.5,
"ЧЭЛ- КРС": 7.8,
"Сырая клетчатка": 95,
"Структур. клетчатка": 28,
"Сырой жир": 38,
},
},
"жом свекловичный гранулы": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 105,
"уСП": 88,
"ОЭ-КРС": 11.8,
"ЧЭЛ- КРС": 7.2,
"Сырая клетчатка": 220,
"Структур. клетчатка": 48,
"Сырой жир": 12,
},
},
"жом": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 105,
"уСП": 88,
"ОЭ-КРС": 11.8,
"ЧЭЛ- КРС": 7.2,
"Сырая клетчатка": 220,
"Структур. клетчатка": 48,
"Сырой жир": 12,
},
},
"дробина": {
"dry_matter": 91.0,
"nutrients": {
"Сыр. Протеин": 100,
"уСП": 88,
"ОЭ-КРС": 11.5,
"ЧЭЛ- КРС": 7.0,
"Сырая клетчатка": 200,
"Структур. клетчатка": 40,
"Сырой жир": 12,
},
},
"добавки": {
"dry_matter": 99.0,
"nutrients": {
"Сыр. Протеин": 8,
"ОЭ-КРС": 2.0,
"ЧЭЛ- КРС": 1.2,
"Сырая клетчатка": 5,
"Сырой жир": 3,
},
},
"сено": {
"dry_matter": 85.0,
"nutrients": {
"Сыр. Протеин": 78,
"уСП": 62,
"ОЭ-КРС": 5.9,
"ЧЭЛ- КРС": 3.4,
"Сырая клетчатка": 328,
"Структур. клетчатка": 265,
"Сырой жир": 22,
},
},
"анионовые соли": {
"dry_matter": 95.0,
"nutrients": {
"Сыр. Протеин": 12,
"ОЭ-КРС": 1.2,
"ЧЭЛ- КРС": 0.8,
"Сырая клетчатка": 8,
"Сырой жир": 2,
},
},
}
_TYPE_DEFAULTS: dict[str, dict[str, Any]] = {
"зерновые": {
"dry_matter": 88.0,
"nutrients": {
"Сыр. Протеин": 120,
"уСП": 105,
"БРА": 28,
"ОЭ-КРС": 11.0,
"ЧЭЛ- КРС": 6.8,
"Сырая клетчатка": 85,
"Структур. клетчатка": 22,
"Сырой жир": 35,
},
},
"белковые": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 380,
"уСП": 300,
"БРА": 80,
"ОЭ-КРС": 11.5,
"ЧЭЛ- КРС": 7.0,
"Сырая клетчатка": 120,
"Сырой жир": 45,
},
},
"минеральные": {
"dry_matter": 95.0,
"nutrients": {
"Сыр. Протеин": 12,
"ОЭ-КРС": 1.2,
"ЧЭЛ- КРС": 0.8,
"Сырая клетчатка": 8,
"Сырой жир": 2,
},
},
"витаминные": {
"dry_matter": 99.0,
"nutrients": {
"Сыр. Протеин": 8,
"ОЭ-КРС": 2.0,
"ЧЭЛ- КРС": 1.2,
"Сырая клетчатка": 5,
"Сырой жир": 3,
},
},
"энергетические": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 100,
"уСП": 88,
"ОЭ-КРС": 11.5,
"ЧЭЛ- КРС": 7.0,
"Сырая клетчатка": 200,
"Структур. клетчатка": 40,
"Сырой жир": 12,
},
},
"объемные корма": {
"dry_matter": 85.0,
"nutrients": {
"Сыр. Протеин": 78,
"уСП": 62,
"ОЭ-КРС": 5.9,
"ЧЭЛ- КРС": 3.4,
"Сырая клетчатка": 328,
"Структур. клетчатка": 265,
"Сырой жир": 22,
},
},
}
_FALLBACK = {
"dry_matter": 88.0,
"nutrients": {
"Сыр. Протеин": 100,
"уСП": 85,
"ОЭ-КРС": 10.0,
"ЧЭЛ- КРС": 6.2,
"Сырая клетчатка": 150,
"Сырой жир": 25,
},
}
def _normalize_name(name: str | None) -> str:
return (name or "").strip().lower()
def _merge_supplement(nutrients: dict[str, Any], component: Component) -> dict[str, float]:
merged = dict(_MINERAL_SUPPLEMENT)
type_key = _normalize_name(component.type)
if type_key not in _ROUGHAGE_TYPES:
merged.update(_CARB_SUPPLEMENT)
merged.update(nutrients)
return {k: float(v) for k, v in merged.items() if v is not None}
def _payload_for_component(component: Component) -> dict[str, Any]:
name_key = _normalize_name(component.name)
if name_key in _NAMED_SEEDS:
payload = _NAMED_SEEDS[name_key]
else:
payload = None
for key in sorted(_NAMED_SEEDS, key=len, reverse=True):
if key in name_key:
payload = _NAMED_SEEDS[key]
break
if payload is None:
type_key = _normalize_name(component.type)
payload = _TYPE_DEFAULTS.get(type_key, _FALLBACK)
return {
"dry_matter": payload.get("dry_matter"),
"nutrients": _merge_supplement(payload.get("nutrients") or {}, component),
}
def _apply_demo_payload(component: Component, payload: dict[str, Any], *, user_id: str) -> None:
if payload.get("dry_matter") is not None:
dm = float(payload["dry_matter"])
current_dm = float(component.dry_matter or 0)
if dm > 0 and (current_dm <= 0 or current_dm > 100):
component.dry_matter = dm
save_component_nutrients(
component.id,
payload["nutrients"],
user_id=user_id,
)
component.protein = 0.0
component.energy = 0.0
component.updated_by = "demo-nutrients-seed"
def supplement_component_nutrients_if_sparse(
component_id: str | None,
*,
user_id: str = "system",
) -> bool:
"""Добавляет Ca/P/сахара и т.д., если EAV уже есть, но без минералов."""
if not component_id or nutrients_is_empty(component_id):
return False
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
if component is None:
return False
full = nutrients_full_dict(component_id)
template = _payload_for_component(component)["nutrients"]
allowed = set(_MINERAL_SUPPLEMENT)
if _normalize_name(component.type) not in _ROUGHAGE_TYPES:
allowed.update(_CARB_SUPPLEMENT)
missing = {
key: float(template[key])
for key in allowed
if key in template and read_from_mapping(full, (key,)) is None
}
if not missing:
return False
save_component_nutrients(component_id, missing, user_id=user_id, pin=True)
return True
def ensure_component_nutrients_if_empty(
component_id: str | None,
*,
user_id: str = "system",
) -> bool:
"""Заполняет EAV демо-шаблоном, если у компонента нет nutrients. Возвращает True при записи."""
if not component_id or not nutrients_is_empty(component_id):
return False
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
if component is None:
return False
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
return True
def _marker_path() -> Path:
return Path(Config.DATA_DIR) / _MARKER_NAME
def _already_seeded() -> bool:
return _marker_path().is_file()
def _mark_seeded() -> None:
path = _marker_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("1\n", encoding="utf-8")
def seed_demo_component_nutrients_once(*, user_id: str = "system", force: bool = False) -> dict:
"""Заполняет lab_component_nutrient_value у всех компонентов с пустой матрицей."""
updated = 0
skipped = 0
for component in Component.query.filter_by(is_deleted=False).all():
if not force and not nutrients_is_empty(component.id):
skipped += 1
continue
if force and not nutrients_is_empty(component.id):
skipped += 1
continue
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
updated += 1
if updated:
db.session.commit()
_mark_seeded()
_log.info(
"demo component nutrients seed user=%s updated=%s skipped=%s",
user_id,
updated,
skipped,
)
return {"seeded": True, "updated": updated, "skipped": skipped}
return {"seeded": False, "reason": "nothing_to_update", "updated": 0, "skipped": skipped}
@@ -0,0 +1,40 @@
from __future__ import annotations
from app import db
from app.lab.db_guard import retry_locked
from app.lab.loaders.execution_loader import load_execution
from app.lab.models import LabRationLine, LabRecipeRation
from app.models import Component
from app.models.base import default_uuid
@retry_locked
def seed_from_execution(recipe_id: str, user_id: str = "system") -> dict:
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
if existing is not None:
return {"recipeId": recipe_id, "seeded": False, "reason": "already_exists"}
execution = load_execution(recipe_id)
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
for idx, line in enumerate(execution.lines):
comp = Component.query.get(line.component_id) if line.component_id else None
daily_kg = line.daily_kg_total
db.session.add(
LabRationLine(
id=default_uuid(),
recipe_id=recipe_id,
component_id=line.component_id,
ingredient_name=line.name,
row_index=idx,
daily_kg=daily_kg,
in_ration=True,
in_compound=False,
created_by=user_id,
updated_by=user_id,
)
)
header.seed_source = "execution"
db.session.commit()
return {"recipeId": recipe_id, "seeded": True, "lines": len(execution.lines)}
@@ -0,0 +1,360 @@
"""Тестовые рецепты LAB: полная матрица показателей + рацион «в нормах»."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app import db
from app.lab.commands.recalculate import recalculate_ration
from app.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
from app.lab.services.component_nutrients import save_component_nutrients
from app.models import Component, Ingredient, Recipe
from app.models.base import default_uuid
_log = logging.getLogger(__name__)
PROFILE_KEY = "lab_math_dairy_01"
HEADS = 10
_MARKER = ".lab_math_ration_seeded"
RECIPE_MATRIX = "LAB тест — полная матрица"
RECIPE_IN_NORMS = "LAB тест — в нормах"
# Реалистичные г/кг (и МДж для энергии) — покрывают расширенный каталог indicators.py
_COMPONENTS: tuple[dict[str, Any], ...] = (
{
"name": "LAB тест — сено",
"type": "Грубые корма",
"dry_matter": 85.0,
"price": 12.0,
"nutrients": {
"СВ": 850,
"Осн.Корм": 850,
"Сыр. Протеин": 78,
"уСП": 62,
"ОЭ-КРС": 5.9,
"ЧЭЛ- КРС": 3.4,
"Сырая клетч": 328,
"НДК": 328,
"КДК": 210,
"Структур клетч": 265,
"Сырой жир": 22,
"Ca": 3.5,
"P": 2.1,
"Mg": 1.8,
"Na": 0.5,
"K": 18,
"DCAB Форм": 120,
"Сахар и Крохм": 120,
"Сахар": 40,
"Крахмал": 15,
"Нераств. Крохмал": 5,
"Каротин": 25,
"Сырая зола": 65,
"БЕР": 95,
"Переварим Протеин": 55,
"КРС Протеин": 68,
"Переварим сырая клетч": 195,
"Fe": 40,
"Zn": 22,
"Cu": 6,
"Mn": 35,
"Se": 0.04,
"J": 0.02,
"Вит А": 12000,
"Вит D": 800,
"Вит Е": 30,
"Вит В1": 2.5,
"Вит В2": 4.0,
"Вит В6": 3.0,
"Вит В12": 0.0,
"Лизин": 2.8,
"Метаб лизин": 1.2,
"% уСП/кг СВ": 7.3,
"Нер. СП / кг СВ": 11,
},
},
{
"name": "LAB тест — комбикорм",
"type": "Концентрированные",
"dry_matter": 88.0,
"price": 22.0,
"nutrients": {
"СВ": 880,
"Осн.Корм": 0,
"Сыр. Протеин": 170,
"уСП": 142,
"ОЭ-КРС": 12.5,
"ЧЭЛ- КРС": 7.8,
"Сырая клетч": 95,
"НДК": 95,
"КДК": 42,
"Структур клетч": 28,
"Сырой жир": 38,
"Ca": 8.0,
"P": 5.5,
"Mg": 3.2,
"Na": 1.2,
"K": 6.5,
"DCAB Форм": 45,
"Сахар и Крохм": 420,
"Сахар": 80,
"Крахмал": 280,
"Нераств. Крохмал": 35,
"Каротин": 5,
"Сырая зола": 55,
"БЕР": 520,
"Переварим Протеин": 125,
"КРС Протеин": 155,
"Fe": 85,
"Zn": 55,
"Cu": 12,
"Mn": 28,
"Se": 0.12,
"Вит А": 2500,
"Вит D": 400,
"Вит Е": 45,
"Вит В1": 5.5,
"Вит В2": 6.0,
"Лизин": 8.5,
"Метаб лизин": 4.2,
"% уСП/кг СВ": 16.1,
"Нер. СП / кг СВ": 28,
},
},
{
"name": "LAB тест — силос",
"type": "Сочные корма",
"dry_matter": 34.0,
"price": 4.0,
"nutrients": {
"СВ": 340,
"Осн.Корм": 0,
"Сыр. Протеин": 32,
"уСП": 28,
"ОЭ-КРС": 6.2,
"ЧЭЛ- КРС": 3.8,
"Сырая клетч": 220,
"НДК": 220,
"КДК": 125,
"Структур клетч": 45,
"Сырой жир": 28,
"Ca": 4.5,
"P": 2.8,
"Mg": 1.5,
"Na": 0.3,
"K": 12,
"DCAB Форм": 95,
"Сахар и Крохм": 180,
"Сахар": 60,
"Крахмал": 45,
"Нераств. Крохмал": 12,
"Каротин": 18,
"Сырая зола": 38,
"БЕР": 145,
"Переварим Протеин": 22,
"КРС Протеин": 28,
"Fe": 25,
"Zn": 18,
"Cu": 4,
"Mn": 15,
"Вит А": 3500,
"Вит Е": 18,
"Лизин": 1.1,
"% уСП/кг СВ": 8.2,
},
},
)
_RECIPE_SPECS: tuple[dict[str, Any], ...] = (
{
"name": RECIPE_MATRIX,
"profile_key": PROFILE_KEY,
"description": "перегруз для проверки красных отклонений",
"lines": (
("LAB тест — сено", 5.0),
("LAB тест — комбикорм", 12.0),
("LAB тест — силос", 8.0),
),
},
{
"name": RECIPE_IN_NORMS,
"profile_key": PROFILE_KEY,
"description": "поддержание 300 кг — большинство норм в зелёной зоне",
"lines": (
("LAB тест — сено", 4.5),
("LAB тест — комбикорм", 1.0),
("LAB тест — силос", 1.8),
),
},
)
@dataclass
class LabMathRationStats:
recipe_ids: list[str] = field(default_factory=list)
created: int = 0
updated: int = 0
recalculated: int = 0
errors: list[str] = field(default_factory=list)
@property
def recipe_id(self) -> str | None:
return self.recipe_ids[0] if self.recipe_ids else None
def _marker_path() -> Path:
from config import Config
return Path(Config.DATA_DIR) / _MARKER
def _get_or_create_component(spec: dict[str, Any]) -> Component:
comp = Component.query.filter_by(name=spec["name"], is_deleted=False).first()
if comp is None:
comp = Component(
id=default_uuid(),
name=spec["name"],
type=spec["type"],
dry_matter=float(spec["dry_matter"]),
protein=0.0,
energy=0.0,
price=float(spec.get("price", 0)),
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
db.session.add(comp)
db.session.flush()
else:
comp.dry_matter = float(spec["dry_matter"])
comp.price = float(spec.get("price", comp.price or 0))
comp.updated_by = "lab-math-ration"
nutrients = {k: float(v) for k, v in spec["nutrients"].items()}
save_component_nutrients(comp.id, nutrients, user_id="lab-math-ration", pin=True)
return comp
def _seed_one_recipe(
spec: dict[str, Any],
*,
by_name: dict[str, Component],
profile: LabAnimalProfile,
stats: LabMathRationStats,
) -> str:
recipe = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
if recipe is None:
recipe = Recipe(
id=default_uuid(),
name=spec["name"],
heads_per_trip=HEADS,
ration_type="DAIRY",
mixing_time=0,
trip_percent=100.0,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
db.session.add(recipe)
stats.created += 1
db.session.flush()
else:
recipe.heads_per_trip = HEADS
recipe.ration_type = "DAIRY"
stats.updated += 1
for ing in Ingredient.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
ing.soft_delete("lab-math-ration")
for order, (cname, wph) in enumerate(spec["lines"]):
comp = by_name[cname]
db.session.add(
Ingredient(
id=default_uuid(),
recipe_id=recipe.id,
component_id=comp.id,
name=comp.name,
amount=wph * HEADS,
weight_per_head=wph,
dry_matter=comp.dry_matter,
order=order,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
)
header = LabRecipeRation.query.filter_by(recipe_id=recipe.id, is_deleted=False).first()
if header is None:
header = LabRecipeRation(
recipe_id=recipe.id,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
db.session.add(header)
header.animal_profile_id = profile.id
header.seed_source = "lab_math_test"
for line in LabRationLine.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
line.soft_delete("lab-math-ration")
for idx, (cname, wph) in enumerate(spec["lines"]):
comp = by_name[cname]
db.session.add(
LabRationLine(
id=default_uuid(),
recipe_id=recipe.id,
component_id=comp.id,
ingredient_name=comp.name,
row_index=idx,
daily_kg=wph * HEADS,
in_ration=True,
in_compound=False,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
)
return recipe.id
def seed_lab_math_ration(*, force: bool = False, run_calc: bool = True) -> LabMathRationStats:
stats = LabMathRationStats()
if not force and _marker_path().is_file():
for spec in _RECIPE_SPECS:
existing = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
if existing:
stats.recipe_ids.append(existing.id)
if stats.recipe_ids:
stats.updated = len(stats.recipe_ids)
return stats
profile = LabAnimalProfile.query.filter_by(profile_key=PROFILE_KEY, is_deleted=False).first()
if profile is None:
stats.errors.append(
f"Профиль {PROFILE_KEY} не найден — запустите scripts/seed_lab_math_profiles.py"
)
return stats
components = [_get_or_create_component(spec) for spec in _COMPONENTS]
by_name = {c.name: c for c in components}
for spec in _RECIPE_SPECS:
rid = _seed_one_recipe(spec, by_name=by_name, profile=profile, stats=stats)
stats.recipe_ids.append(rid)
db.session.commit()
if run_calc:
for rid in stats.recipe_ids:
try:
recalculate_ration(rid, "lab-math-ration")
stats.recalculated += 1
except Exception as exc:
stats.errors.append(f"пересчёт {rid}: {exc}")
_marker_path().parent.mkdir(parents=True, exist_ok=True)
_marker_path().write_text("\n".join(stats.recipe_ids) + "\n", encoding="utf-8")
_log.info(
"lab math rations: ids=%s matrix+in_norms heads=%s",
stats.recipe_ids,
HEADS,
)
return stats
@@ -0,0 +1,90 @@
"""5 профилей норм для проверки математики рациона (строки 1–5 из data/seed/norms)."""
from __future__ import annotations
import csv
import logging
from dataclasses import dataclass, field
from pathlib import Path
from app import db
from app.lab.commands.import_seed import _parse_min_max_groups, parse_norm_profile_row
from app.lab.seed_paths import norms_dir
from app.lab.models import LabAnimalProfile
from app.lab.services.profile_norms import save_norms_from_payload
from app.models.base import default_uuid
_log = logging.getLogger(__name__)
_MATH_SPECS: tuple[tuple[str, str, int, str], ...] = (
("DAIRY", "Нормы Дойн.csv", 1, "lab_math_dairy_01"),
("DAIRY", "Нормы Дойн.csv", 2, "lab_math_dairy_02"),
("DAIRY", "Нормы Дойн.csv", 3, "lab_math_dairy_03"),
("DAIRY", "Нормы Дойн.csv", 4, "lab_math_dairy_04"),
("DAIRY", "Нормы Дойн.csv", 5, "lab_math_dairy_05"),
)
@dataclass
class MathTestSeedStats:
created: int = 0
updated: int = 0
errors: list[str] = field(default_factory=list)
def _load_csv_row(csv_path: Path, external_no: int, ration_type: str) -> dict | None:
with csv_path.open(encoding="utf-8") as f:
rows = list(csv.reader(f))
if len(rows) < 8:
return None
groups = _parse_min_max_groups(rows[2], rows[3])
data_row = rows[5 + external_no]
eno = external_no
return parse_norm_profile_row(
data_row,
groups,
ration_type,
unmapped=set(),
profile_key_fn=lambda rt, _ext: f"lab_math_{rt.lower()}_{eno:02d}",
)
def seed_math_test_profiles(*, csv_dir: Path | None = None) -> MathTestSeedStats:
stats = MathTestSeedStats()
base = csv_dir or norms_dir()
for ration_type, filename, external_no, profile_key in _MATH_SPECS:
parsed = _load_csv_row(base / filename, external_no, ration_type)
if parsed is None:
stats.errors.append(f"Строка {external_no} не найдена в {filename}")
continue
profile = LabAnimalProfile.query.filter_by(profile_key=profile_key, is_deleted=False).first()
if profile is None:
profile = LabAnimalProfile(
id=default_uuid(),
profile_key=profile_key,
created_by="lab-math-test-seed",
)
db.session.add(profile)
stats.created += 1
else:
stats.updated += 1
profile.label = f"TEST математика #{external_no}{parsed['label'][:48]}"
profile.ration_type = ration_type
profile.external_no = parsed["external_no"]
parsed["indicators"]["rnb"] = {"min": -10.0, "max": 20.0}
save_norms_from_payload(
profile,
{
"massKg": parsed["mass_kg"],
"externalNo": parsed["external_no"],
"indicators": parsed["indicators"],
},
)
db.session.commit()
_log.info("lab math test profiles: created=%s updated=%s", stats.created, stats.updated)
return stats
@@ -0,0 +1,49 @@
from __future__ import annotations
from app import db
from app.lab.commands.write_audit import write_audit
from app.lab.db_guard import retry_locked
from app.lab.loaders.execution_loader import load_execution
from app.lab.models import LabRationLine, LabRecipeRation
from app.models import Component
from app.models.base import default_uuid
@retry_locked
def sync_from_execution(recipe_id: str, user_id: str = "system") -> dict:
"""Обновить мастер из execution (обратный перенос /recipes → lab)."""
execution = load_execution(recipe_id)
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
if header is None:
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
else:
header.updated_by = user_id
existing = LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
for line in existing:
line.soft_delete(user_id)
created = 0
for idx, line in enumerate(execution.lines):
comp = Component.query.get(line.component_id) if line.component_id else None
db.session.add(
LabRationLine(
id=default_uuid(),
recipe_id=recipe_id,
component_id=line.component_id,
ingredient_name=line.name or (comp.name if comp else None),
row_index=idx,
daily_kg=line.daily_kg_total,
in_ration=True,
in_compound=False,
created_by=user_id,
updated_by=user_id,
)
)
created += 1
header.seed_source = "synced_from"
write_audit("SYNC_FROM_EXECUTION", "lab_recipe_ration", recipe_id, user_id)
db.session.commit()
return {"recipeId": recipe_id, "lines": created}
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from app import db
from app.lab.db_guard import retry_locked
from app.lab.models import LabAnimalProfile
from app.lab.calc.nutrients import parse_num
from app.lab.calc.norms_resolver import normalize_norms_method
from app.lab.services.norms_params import save_norms_params
from app.lab.services.profile_norms import save_norms_from_payload, sync_racion_norms_to_profile
from app.models.base import default_uuid
@retry_locked
def upsert_animal_profile(profile_id: str | None, payload: dict[str, Any], user_id: str = "system") -> dict:
pid = (profile_id or payload.get("id") or "").strip() or default_uuid()
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
is_new = profile is None
if is_new:
profile = LabAnimalProfile(id=pid, created_by=user_id)
db.session.add(profile)
key = (payload.get("profileKey") or payload.get("profile_key") or "").strip()
label = (payload.get("label") or "").strip()
ration_type = (payload.get("rationType") or payload.get("ration_type") or "BEEF").strip().upper()
if not key or not label:
raise ValueError("profileKey и label обязательны")
profile.profile_key = key
profile.label = label
profile.ration_type = ration_type
if "normsMethod" in payload or "norms_method" in payload:
profile.norms_method = normalize_norms_method(
payload.get("normsMethod", payload.get("norms_method"))
)
if "normsParams" in payload or "norms_params" in payload:
raw_params = payload.get("normsParams", payload.get("norms_params"))
save_norms_params(profile, raw_params if isinstance(raw_params, dict) else None)
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
norms_payload: dict[str, Any] = {}
if "normsData" in payload or "norms_data" in payload:
raw = payload.get("normsData") if "normsData" in payload else payload.get("norms_data")
if isinstance(raw, dict):
norms_payload = dict(raw)
if payload.get("massKg") is not None or payload.get("mass_kg") is not None:
norms_payload["massKg"] = payload.get("massKg", payload.get("mass_kg"))
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
norms_payload["milkYieldKg"] = payload.get("milkYieldKg", payload.get("milk_yield_kg"))
if payload.get("externalNo") is not None or payload.get("external_no") is not None:
norms_payload["externalNo"] = payload.get("externalNo", payload.get("external_no"))
if isinstance(payload.get("indicators"), dict):
norms_payload["indicators"] = payload["indicators"]
if norms_payload:
save_norms_from_payload(profile, norms_payload)
profile.updated_by = user_id
sync_racion_norms_to_profile(profile)
db.session.commit()
return {"id": profile.id, "profileKey": profile.profile_key, "label": profile.label}
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any
from app import db
from app.lab.db_guard import retry_locked
from app.lab.models import LabRationLine, LabRecipeRation
from app.models import Recipe
from app.models.base import default_uuid
@retry_locked
def upsert_ration(recipe_id: str, payload: dict[str, Any], user_id: str = "system") -> dict[str, Any]:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
if header is None:
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
header.animal_profile_id = payload.get("animalProfileId") or payload.get("animal_profile_id")
if "params" in payload:
params = payload.get("params") or {}
if isinstance(params, dict):
seeded = params.get("seeded_from") or params.get("synced_from")
if seeded:
header.seed_source = str(seeded)
if "rationType" in payload or "ration_type" in payload:
recipe.ration_type = (payload.get("rationType") or payload.get("ration_type") or "").upper() or None
header.updated_by = user_id
incoming = payload.get("lines") or []
existing = {
str(l.id): l
for l in LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
}
seen_ids: set[str] = set()
for idx, row in enumerate(incoming):
line_id = str(row.get("id") or "")
line = existing.get(line_id) if line_id else None
if line is None:
line = LabRationLine(
id=default_uuid(),
recipe_id=recipe_id,
created_by=user_id,
updated_by=user_id,
)
db.session.add(line)
line.row_index = int(row.get("rowIndex", row.get("row_index", idx)))
line.component_id = row.get("componentId") or row.get("component_id")
line.ingredient_name = row.get("ingredientName") or row.get("ingredient_name")
line.daily_kg = row.get("dailyKg", row.get("daily_kg"))
line.in_ration = bool(row.get("inRation", row.get("in_ration", True)))
line.in_compound = bool(row.get("inCompound", row.get("in_compound", False)))
line.updated_by = user_id
if line.id:
seen_ids.add(str(line.id))
for lid, line in existing.items():
if lid not in seen_ids:
line.soft_delete(user_id)
db.session.commit()
return {"recipeId": recipe_id, "ok": True}
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import json
import logging
from typing import Any
_log = logging.getLogger("app.lab.audit")
def write_audit(
action: str,
entity_type: str,
entity_id: str | None,
user_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
_log.info(
"lab action=%s entity=%s/%s user=%s metadata=%s",
action,
entity_type,
entity_id,
user_id or "system",
json.dumps(metadata or {}, ensure_ascii=False),
)
def write_calculation_run(
recipe_id: str,
status: str,
kpi_results: dict[str, Any],
*,
duration_ms: int | None = None,
error_message: str | None = None,
) -> None:
_log.info(
"lab calculation recipe=%s status=%s duration_ms=%s engine=%s error=%s kpi=%s",
recipe_id,
status,
duration_ms,
"native-1",
error_message or "",
json.dumps(kpi_results, ensure_ascii=False),
)
+28
View File
@@ -0,0 +1,28 @@
"""Lab ration calc constants (ported from tab @neoton/shared)."""
from __future__ import annotations
from app.lab.indicators import RATION_ALL_INDICATORS, RATION_QUALITY_INDICATORS
RATION_TOTAL_KEYS = {
"DAIRY": [
{"key": "total_kg", "label": "Итого кг/день"},
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
],
"BEEF": [
{"key": "total_kg", "label": "Итого кг/день"},
{"key": "ration_kg", "label": "Сумма «из рациона», кг"},
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
{"key": "cost_total", "label": "Стоимость рациона (сумма)"},
],
}
NORM_COLUMN_ALIASES = {
"dry_matter": ["Сухое Вещество", "Сухое вещество"],
"crude_protein": ["Сыр. Протеин"],
"usp": ["уСП"],
"oe": ["ОЭ-КРС", " ОЭ-КРС"],
"nel": ["ЧЭЛ- КРС", " ЧЭЛ- КРС"],
}
DIFF_TOLERANCE_KG = 0.05
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import time
from functools import wraps
from typing import Any, Callable, TypeVar
from sqlalchemy.exc import OperationalError
F = TypeVar("F", bound=Callable[..., Any])
def retry_locked(fn: F, *, attempts: int = 3, delay: float = 0.15) -> F:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
last_exc: Exception | None = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except OperationalError as exc:
if "locked" not in str(exc).lower():
raise
last_exc = exc
time.sleep(delay * (i + 1))
if last_exc:
raise last_exc
return None
return wrapper # type: ignore[return-value]
+3
View File
@@ -0,0 +1,3 @@
from .ration import ExecutionSnapshot, RationSnapshot
__all__ = ["RationSnapshot", "ExecutionSnapshot"]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class RationLineSnapshot:
id: str | None
component_id: str | None
ingredient_name: str | None
row_index: int
daily_kg: float | None
in_ration: bool
in_compound: bool
dry_matter: float | None
price_per_kg: float | None
nutrients: dict[str, Any]
@dataclass(frozen=True)
class RationSnapshot:
recipe_id: str
recipe_name: str
ration_type: str
heads_per_trip: int
animal_profile_id: str | None
params: dict[str, Any]
lines: tuple[RationLineSnapshot, ...] = field(default_factory=tuple)
norms: dict[str, dict[str, float | None]] = field(default_factory=dict)
ration_results: dict[str, Any] = field(default_factory=dict)
compound_results: dict[str, Any] = field(default_factory=dict)
calculated_at: str | None = None
exists: bool = True
norms_profile_key: str | None = None
legacy_norms_remapped: bool = False
@dataclass(frozen=True)
class ExecutionLineSnapshot:
ingredient_id: str
component_id: str | None
name: str
weight_per_head: float
daily_kg_total: float
@dataclass(frozen=True)
class ExecutionSnapshot:
recipe_id: str
heads_per_trip: int
lines: tuple[ExecutionLineSnapshot, ...] = field(default_factory=tuple)
+1
View File
@@ -0,0 +1 @@
"""Offline ETL from tab reference PostgreSQL."""
@@ -0,0 +1,88 @@
"""AgroStar PDF (печатная форма анализа) → AgrostarParseResult."""
from __future__ import annotations
import re
from typing import Any
from app.lab.etl.agrostar_tabular import (
parse_tabular_triplet_lines,
rows_to_label_values,
sample_from_agro_fields,
tabular_rows_to_agro_fields,
)
from app.lab.etl.agrostar_xml_import import AgrostarParseResult
def _pdf_text(data: bytes) -> str:
try:
import fitz # pymupdf
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
parts = [doc[i].get_text() for i in range(doc.page_count)]
doc.close()
return "\n".join(parts)
def _line_after(lines: list[str], marker: str) -> str:
for i, line in enumerate(lines):
if line == marker and i + 1 < len(lines):
return lines[i + 1]
return ""
def _is_agrostar_pdf(text: str) -> bool:
t = text or ""
return "ИНФОРМАЦИЯ ОБ ОБРАЗЦЕ" in t or "РЕЗУЛЬТАТЫ АНАЛИЗА" in t
def parse_agrostar_pdf(data: bytes) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="АгроСтар")
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not _is_agrostar_pdf(text):
result.errors.append("Не похоже на отчёт AgroStar (PDF)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
sample_no = _line_after(lines, "Образец №")
farm_name = _line_after(lines, "Клиент")
date_printed = _line_after(lines, "Дата анализа")
desc = _line_after(lines, "Описание")
label_parts: list[str] = []
if "Образец" in lines:
idx = lines.index("Образец")
if idx + 1 < len(lines) and lines[idx + 1] != "Описание":
label_parts.append(lines[idx + 1])
if idx + 2 < len(lines) and lines[idx + 2] not in ("Описание", "Клиент"):
label_parts.append(lines[idx + 2])
triplets = parse_tabular_triplet_lines(lines)
agro_fields = tabular_rows_to_agro_fields(rows_to_label_values(triplets))
if not agro_fields:
result.errors.append("В PDF не найдены показатели анализа")
return result
sample = sample_from_agro_fields(
sample_no=sample_no,
farm_name=farm_name,
date_printed=date_printed,
desc_1=desc or " ".join(label_parts),
agro_fields=agro_fields,
)
result.samples.append(sample)
return result
def looks_like_agrostar_pdf(data: bytes) -> bool:
try:
text = _pdf_text(data)
except RuntimeError:
return False
return _is_agrostar_pdf(text)
+129
View File
@@ -0,0 +1,129 @@
"""AgroStar табличные форматы (PDF, xlsx) → те же AgrostarSample, что и XML."""
from __future__ import annotations
import re
from typing import Any
from app.lab.etl.agrostar_xml_import import (
AgrostarParseResult,
AgrostarSample,
_parse_float,
_pct_dm_to_g_per_kg_sv,
finalize_agrostar_sample,
)
# Подпись в отчёте → ключ AgroStar XML (дальше — общий _AGROSTAR_MAP)
_TABULAR_LABEL_TO_AGRO: dict[str, str] = {
"Сухое вещество (DM)": "DM",
"Сырой протеин (Crude Protein)": "CP",
"КДК (ADF)": "ADF",
"НДК (aNDF)": "NDF",
"НДК по орг. веществу (aNDFom)": "aNDFom",
"Сырой жир (Fat EE)": "Fat_EE",
"Сырая зола (Ash)": "Ash",
"Кальций (Ca)": "Ca",
"Фосфор (P)": "P",
"Магний (Mg)": "Mg",
"Калий (K)": "K",
"Сера (S)": "S",
"Хлор (Cl)": "Cl",
"Водорастворимый сахар (WSC)": "Sugar_WSC",
"Cпирторастворимый сахар (ESC)": "Sugar_ESC",
"Крахмал (Starch)": "Starch",
"Безволокнистые углеводы (NFC)": "NFC",
"Переваримые питательные вещества (TDN)": "TDN",
"НДК протеин (ND-ICP)": "NDICP_CP",
"Лизин": "Lys",
"Метионин": "Met",
"Лейцин": "Leu",
"Изолейцин": "Ile",
"Валин": "Val",
"Усваиваемость НДК 30 ч (NDFD30)": "NDFDom_IV_30hr",
}
_SECTION_HEADERS = frozenset(
{
"Протеин:",
"Клетчатка:",
"Сахара и крахмал:",
"Жир и жирные кислоты:",
"Минералы:",
"Энергия:",
}
)
def map_tabular_label(label: str) -> str | None:
text = (label or "").strip()
if not text or text in _SECTION_HEADERS:
return None
if text in _TABULAR_LABEL_TO_AGRO:
return _TABULAR_LABEL_TO_AGRO[text]
lowered = text.lower()
for key, ag_key in sorted(_TABULAR_LABEL_TO_AGRO.items(), key=lambda item: len(item[0]), reverse=True):
if key.lower() in lowered:
return ag_key
return None
def tabular_rows_to_agro_fields(rows: list[tuple[str, str]]) -> dict[str, float]:
"""[(label, raw_value), ...] → raw_fields AgroStar."""
out: dict[str, float] = {}
for label, raw in rows:
ag_key = map_tabular_label(label)
if not ag_key:
continue
val = _parse_float(raw)
if val is not None:
out[ag_key] = val
return out
def sample_from_agro_fields(
*,
sample_no: str,
farm_name: str,
farm_id: str = "",
date_printed: str = "",
feed_type: str = "Mixed haylage",
desc_1: str = "",
desc_2: str = "",
desc_3: str = "",
agro_fields: dict[str, float],
) -> AgrostarSample:
sample = AgrostarSample(
sample_no=sample_no,
farm_name=farm_name,
farm_id=farm_id,
date_printed=date_printed,
feed_type=feed_type,
desc_1=desc_1,
desc_2=desc_2,
desc_3=desc_3,
dry_matter_pct=agro_fields.get("DM"),
)
sample.raw_fields = {k: v for k, v in agro_fields.items() if k != "DM"}
return finalize_agrostar_sample(sample)
def parse_tabular_triplet_lines(lines: list[str]) -> list[tuple[str, str, str]]:
"""Строки PDF: name, unit (%DM|%CP|%), value."""
rows: list[tuple[str, str, str]] = []
i = 0
units = frozenset({"%DM", "%CP", "%"})
while i < len(lines):
line = lines[i]
if line in units and i >= 1:
name = lines[i - 1]
val = lines[i + 1] if i + 1 < len(lines) else ""
if val and (val[0].isdigit() or val.startswith("<")):
rows.append((name, line, val))
i += 2
continue
i += 1
return rows
def rows_to_label_values(triplets: list[tuple[str, str, str]]) -> list[tuple[str, str]]:
return [(name, val) for name, _unit, val in triplets]
@@ -0,0 +1,72 @@
"""AgroStar xlsx (сводка проб) → AgrostarParseResult."""
from __future__ import annotations
import io
from typing import Any
from openpyxl import load_workbook
from app.lab.etl.agrostar_tabular import map_tabular_label, sample_from_agro_fields, tabular_rows_to_agro_fields
from app.lab.etl.agrostar_xml_import import AgrostarParseResult, _parse_float
def parse_agrostar_xlsx(data: bytes) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="АгроСтар")
try:
wb = load_workbook(io.BytesIO(data), data_only=True, read_only=False)
except Exception as exc: # noqa: BLE001
result.errors.append(f"Не удалось открыть xlsx: {exc}")
return result
ws = wb.active
if ws.max_row < 4 or ws.max_column < 2:
result.errors.append("Пустой или неузнаваемый xlsx")
return result
sample_nos: list[str] = []
farms: list[str] = []
labels: list[str] = []
for col in range(2, ws.max_column + 1):
no = ws.cell(1, col).value
if no is None:
continue
sample_nos.append(str(no).strip())
farms.append(str(ws.cell(2, col).value or "").strip())
labels.append(str(ws.cell(3, col).value or "").strip())
if not sample_nos:
result.errors.append("В xlsx нет колонок с пробами")
return result
row_values: list[tuple[str, list[str]]] = []
for row in range(4, ws.max_row + 1):
label = ws.cell(row, 1).value
if label is None:
continue
label_str = str(label).strip()
if not label_str or not map_tabular_label(label_str):
continue
vals = []
for col in range(2, 2 + len(sample_nos)):
raw = ws.cell(row, col).value
vals.append("" if raw is None else str(raw))
row_values.append((label_str, vals))
for idx, sample_no in enumerate(sample_nos):
pairs = [(label, vals[idx]) for label, vals in row_values if idx < len(vals)]
agro_fields = tabular_rows_to_agro_fields(pairs)
if "DM" not in agro_fields:
result.errors.append(f"Проба {sample_no}: нет СВ")
continue
sample = sample_from_agro_fields(
sample_no=sample_no,
farm_name=farms[idx] if idx < len(farms) else "",
desc_1=labels[idx] if idx < len(labels) else sample_no,
agro_fields=agro_fields,
)
result.samples.append(sample)
if not result.samples and not result.errors:
result.errors.append("Не удалось разобрать пробы из xlsx")
return result
+787
View File
@@ -0,0 +1,787 @@
"""Импорт лабораторных анализов AgroStar (Standard_XML_Data) → zootech nutrients WESP."""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import Any
from app import db
from app.lab.calc.feed_groups import classify_feed_group
from app.lab.services.component_nutrients import save_component_nutrients
from app.models.component import Component
# AgroStar Name → (WESP заголовок «База сырья», kind)
# pct_dm: %DM → г/кг СВ (×10); pct: процент как есть; extra: дублировать в доп. ключи
_AGROSTAR_MAP: tuple[tuple[str, str, str], ...] = (
("CP", "Сыр. Протеин", "pct_dm"),
("NDF", "Сырая клетч", "pct_dm"),
("ADF", "КДК", "pct_dm"),
("Fat_EE", "Сырой жир", "pct_dm"),
("Ash", "Сырая зола", "pct_dm"),
("Ca", "Ca", "pct_dm"),
("P", "P", "pct_dm"),
("Mg", "Mg", "pct_dm"),
("K", "K", "pct_dm"),
("S", "S", "pct_dm"),
("Cl", "CL", "pct_dm"),
("Starch", "Крахмал", "pct_dm"),
("Sugar_WSC", "Сахар", "pct_dm"),
("NFC", "NFC", "pct_dm"),
("Lys", "Лизин", "pct_dm"),
("Met", "Метионин", "pct_dm"),
("Leu", "Лейцин", "pct_dm"),
("Ile", "Изолейцин", "pct_dm"),
("Val", "Валин", "pct_dm"),
("NDICP_CP", "% нераствор протеин", "pct"),
("TDN", "ВРХ Орг Вещ", "pct"),
)
_AGROSTAR_MAPPED_FIELDS = frozenset(key for key, _, _ in _AGROSTAR_MAP) | frozenset({"DM", "Sugar_ESC", "aNDFom"})
_UNSUPPORTED_AGROSTAR: dict[str, str] = {
"NDFDom_IV_30hr": "нет поля для переваримости NDF",
"NDFDom_IV_12hr": "нет поля для переваримости NDF",
"NDFDom_IV_120hr": "нет поля для переваримости NDF",
"NDFDom_IV_240hr": "нет поля для переваримости NDF",
}
# Подписи AgroStar-полей, которые не пишем в WESP (для блока «Не попадёт»)
_AGROSTAR_FIELD_RU: dict[str, str] = {
"Moisture": "Влажность",
"pH": "pH",
"SP_CP": "Растворимый протеин",
"SP": "Растворимый протеин (абс.)",
"ADICP": "КДК-протеин (AD-ICP)",
"ADICP_CP": "КДК-протеин, %CP",
"NDICP": "НДК-протеин (абс.)",
"NH3CPE": "Небелковый азот (NH₃-CP)",
"Lignin": "Лигнин",
"Lignin_NDF": "Лигнин, %NDF",
"TFA": "Жирные кислоты, всего",
"RFV": "RFV (относит. корм. ценность)",
"RFQ": "RFQ (относит. качество)",
"uNDFom_IV_12hr": "Нерасщепляемая NDF 12 ч",
"uNDFom_IV_30hr": "Нерасщепляемая NDF 30 ч",
"uNDFom_IV_120hr": "Нерасщепляемая NDF 120 ч",
"uNDFom_IV_240hr": "Нерасщепляемая NDF 240 ч",
"NDFDom_IV_12hr": "Переваримость NDF 12 ч",
"NDFDom_IV_30hr": "Переваримость NDF 30 ч",
"NDFDom_IV_120hr": "Переваримость NDF 120 ч",
"NDFDom_IV_240hr": "Переваримость NDF 240 ч",
"NELMcallb": "NEL (Mcalf/lb)",
"NELMcalkg": "NEL (Mcal/kg)",
"NELMJkg": "NEL (MJ/kg)",
"NEMMcallb": "NEM (Mcal/lb)",
"NEMMcalkg": "NEM (Mcal/kg)",
"NEMMJkg": "NEM (MJ/kg)",
"NEGMcallb": "NEG (Mcal/lb)",
"NEGMcalkg": "NEG (Mcal/kg)",
"NEGMJkg": "NEG (MJ/kg)",
"MilktonH": "Молоко/тонна (Holstein)",
"Horse_DE_Mcallb": "DE лошади (Mcal/lb)",
"Acetic": "Уксусная кислота",
"Propionic": "Пропионовая кислота",
"Butyric": "Масляная кислота",
"Lactic": "Молочная кислота",
"Total_acid": "Органические кислоты, всего",
"C160": "C16:0",
"C180": "C18:0",
"C181": "C18:1",
"C182": "C18:2",
"C183": "C18:3",
"C160_TFA": "C16:0, %TFA",
"C180_TFA": "C18:0, %TFA",
"C181_TFA": "C18:1, %TFA",
"C182_TFA": "C18:2, %TFA",
"C183_TFA": "C18:3, %TFA",
"His": "Гистидин",
"TAA": "Сумма аминокислот",
"Lys_CP": "Лизин, %CP",
"Met_CP": "Метионин, %CP",
"Leu_CP": "Лейцин, %CP",
"Ile_CP": "Изолейцин, %CP",
"Val_CP": "Валин, %CP",
"His_CP": "Гистидин, %CP",
"TAA_CP": "Сумма аминокислот, %CP",
}
_UNSUPPORTED_GROUP_ORDER: tuple[str, ...] = (
"переваримость NDF",
"энергия",
"кислоты силоса",
"жирные кислоты",
"дробный протеин",
"аминокислоты (%CP)",
"клетчатка и RF",
"качество силоса",
"прочее",
)
def _unsupported_group_key(ag_key: str) -> str:
if ag_key in {"Moisture", "pH"}:
return "качество силоса"
if ag_key.startswith(("NDFDom", "uNDFom")):
return "переваримость NDF"
if ag_key.startswith(("NEL", "NEM", "NEG", "Milk", "Horse")):
return "энергия"
if ag_key in {"Acetic", "Propionic", "Butyric", "Lactic", "Total_acid"}:
return "кислоты силоса"
if ag_key.startswith("C1") or ag_key == "TFA":
return "жирные кислоты"
if ag_key in {"SP_CP", "SP", "ADICP", "ADICP_CP", "NDICP", "NH3CPE"}:
return "дробный протеин"
if ag_key.endswith("_CP") or ag_key in {"His", "TAA"}:
return "аминокислоты (%CP)"
if ag_key.startswith(("Lignin", "RF")):
return "клетчатка и RF"
return "прочее"
def _summarize_unsupported(
unsupported: list[dict[str, str]],
*,
nutrient_count: int,
total_in_report: int,
) -> tuple[list[dict[str, int | str]], str]:
counts: dict[str, int] = {}
for row in unsupported:
group = _unsupported_group_key(row["agroKey"])
counts[group] = counts.get(group, 0) + 1
groups: list[dict[str, int | str]] = []
for label in _UNSUPPORTED_GROUP_ORDER:
count = counts.pop(label, 0)
if count:
groups.append({"label": label, "count": count})
for label, count in sorted(counts.items()):
groups.append({"label": label, "count": count})
skip = len(unsupported)
if skip == 0:
return groups, ""
parts = [f"{g['label']} ({g['count']})" for g in groups]
group_text = ", ".join(parts)
stored = nutrient_count + 0 # nutrients without DM line in count; DM stored separately
return (
groups,
f"В отчёте {total_in_report} показателей — запишем СВ и {stored} в карточку реагента. "
f"Ещё {skip} останутся только в AgroStar: {group_text}.",
)
# WESP nutrient key → AgroStar raw field(s), первый найденный — для колонки «В документе»
_PREVIEW_AGRO_SOURCE: dict[str, tuple[str, ...]] = {
"Сыр. Протеин": ("CP",),
"% нераствор протеин": ("NDICP_CP",),
"Сырая клетч": ("NDF",),
"КДК": ("ADF",),
"Структур. клетч": ("aNDFom", "ADF"),
"Сырой жир": ("Fat_EE",),
"Сырая зола": ("Ash",),
"NFC": ("NFC",),
"Сахар": ("Sugar_WSC", "Sugar_ESC"),
"Крахмал": ("Starch",),
"Ca": ("Ca",),
"P": ("P",),
"Mg": ("Mg",),
"K": ("K",),
"S": ("S",),
"CL": ("Cl",),
"ВРХ Орг Вещ": ("TDN",),
"Лизин": ("Lys",),
"Метионин": ("Met",),
"Лейцин": ("Leu",),
"Изолейцин": ("Ile",),
"Валин": ("Val",),
}
_AGRO_SOURCE_UNIT: dict[str, str] = {
"CP": "%DM",
"NDF": "%DM",
"ADF": "%DM",
"aNDFom": "%DM",
"Fat_EE": "%DM",
"Ash": "%DM",
"Ca": "%DM",
"P": "%DM",
"Mg": "%DM",
"K": "%DM",
"S": "%DM",
"Cl": "%DM",
"Starch": "%DM",
"Sugar_WSC": "%DM",
"Sugar_ESC": "%DM",
"NFC": "%DM",
"TDN": "%DM",
"Lys": "%DM",
"Met": "%DM",
"Leu": "%DM",
"Ile": "%DM",
"Val": "%DM",
"NDICP_CP": "%CP",
}
_META_FIELDS = frozenset({"Sample_No", "Name", "Farm_ID", "Lot_name", "Date_Printed", "Type", "Desc_1", "Desc_2", "Desc_3", "Product_code"})
_FEED_TYPE_RU: dict[str, str] = {
"Mixed haylage": "Смешанный сенаж",
"Haylage": "Сенаж",
"Corn silage": "Кукурузный силос",
"Grass silage": "Травяной силос",
}
_AGROSTAR_FEED_TO_COMPONENT_TYPE: dict[str, str] = {
"Mixed haylage": "Сочные корма",
"Haylage": "Сочные корма",
"Corn silage": "Сочные корма",
"Grass silage": "Сочные корма",
}
_FEED_GROUP_TO_COMPONENT_TYPE: dict[str, str] = {
"rough": "Грубые корма",
"succulent": "Сочные корма",
"concentrate": "Концентрированные",
"other": "Добавки",
}
_WARNING_RU: dict[str, str] = {
"dm_missing": "В файле нет сухого вещества (DM)",
"omd_missing": "Нет данных для ВРХ — при расчёте подставится дефолт WESP",
"omd_from_tdn": "ВРХ орг. вещ. оценили по TDN, не прямой OMD из лаборатории",
}
# Порядок показа в «понюхали»: (ключ WESP, подпись, единица)
_PREVIEW_WRITE_ORDER: tuple[tuple[str, str, str], ...] = (
("__dry_matter__", "Сухое вещество (в карточку компонента)", "%"),
("Сыр. Протеин", "Сырой протеин", "г/кг СВ"),
("% нераствор протеин", "Нерастворимый протеин, %", "%"),
("Сырая клетч", "Сырая клетчатка (NDF)", "г/кг СВ"),
("КДК", "Кислая детергентная клетчатка (ADF)", "г/кг СВ"),
("Структур. клетч", "Структурная клетчатка", "г/кг СВ"),
("Сырой жир", "Сырой жир", "г/кг СВ"),
("Сырая зола", "Сырая зола", "г/кг СВ"),
("NFC", "Безволокнистые углеводы (NFC)", "г/кг СВ"),
("Сахар", "Сахар (WSC/ESC)", "г/кг СВ"),
("Крахмал", "Крахмал", "г/кг СВ"),
("Ca", "Кальций", "г/кг СВ"),
("P", "Фосфор", "г/кг СВ"),
("Mg", "Магний", "г/кг СВ"),
("K", "Калий", "г/кг СВ"),
("S", "Сера", "г/кг СВ"),
("CL", "Хлор", "г/кг СВ"),
("ВРХ Орг Вещ", "Переваримость ОВ (из TDN)", "%"),
("Лизин", "Лизин", "г/кг СВ"),
("Метионин", "Метионин", "г/кг СВ"),
("Лейцин", "Лейцин", "г/кг СВ"),
("Изолейцин", "Изолейцин", "г/кг СВ"),
("Валин", "Валин", "г/кг СВ"),
)
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
text = str(value).strip().replace(",", ".")
if not text or text.startswith("<"):
return None
try:
n = float(text)
except ValueError:
return None
return None if n != n else n
def _normalize_label(value: str) -> str:
return re.sub(r"\s+", " ", (value or "").strip().lower())
def _pct_dm_to_g_per_kg_sv(pct_dm: float) -> float:
return round(pct_dm * 10.0, 4)
@dataclass
class AgrostarSample:
sample_no: str
farm_name: str
farm_id: str
date_printed: str
feed_type: str
desc_1: str
desc_2: str
desc_3: str
dry_matter_pct: float | None
raw_fields: dict[str, float] = field(default_factory=dict)
nutrients: dict[str, float] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
@property
def label(self) -> str:
parts = [p for p in (self.desc_1, self.desc_2, self.desc_3) if p]
return parts[0] if parts else self.sample_no
def to_api_dict(self) -> dict[str, Any]:
return {
"sampleNo": self.sample_no,
"farmName": self.farm_name,
"farmId": self.farm_id,
"datePrinted": self.date_printed,
"feedType": self.feed_type,
"desc1": self.desc_1,
"desc2": self.desc_2,
"desc3": self.desc_3,
"label": self.label,
"dryMatterPct": self.dry_matter_pct,
"nutrients": self.nutrients,
"warnings": self.warnings,
"rawFieldCount": len(self.raw_fields),
}
@dataclass
class AgrostarParseResult:
lab_name: str
samples: list[AgrostarSample] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"labName": self.lab_name,
"samples": [s.to_api_dict() for s in self.samples],
"errors": self.errors,
"sampleCount": len(self.samples),
}
@dataclass
class ComponentMatch:
component_id: str
name: str
score: float
def to_api_dict(self) -> dict[str, Any]:
return {"componentId": self.component_id, "name": self.name, "score": round(self.score, 3)}
@dataclass
class ApplyAssignmentResult:
sample_no: str
component_id: str | None
component_name: str | None
dry_matter_pct: float | None
nutrient_count: int
warnings: list[str]
skipped: bool = False
reason: str | None = None
def to_api_dict(self) -> dict[str, Any]:
return {
"sampleNo": self.sample_no,
"componentId": self.component_id,
"componentName": self.component_name,
"dryMatterPct": self.dry_matter_pct,
"nutrientCount": self.nutrient_count,
"warnings": self.warnings,
"skipped": self.skipped,
"reason": self.reason,
}
@dataclass
class ApplyResult:
applied: int = 0
skipped: int = 0
results: list[ApplyAssignmentResult] = field(default_factory=list)
dry_run: bool = False
def to_api_dict(self) -> dict[str, Any]:
return {
"applied": self.applied,
"skipped": self.skipped,
"dryRun": self.dry_run,
"results": [r.to_api_dict() for r in self.results],
}
def _apply_nutrients_from_raw(sample: AgrostarSample) -> AgrostarSample:
nutrients: dict[str, float] = {}
for ag_key, wesp_key, kind in _AGROSTAR_MAP:
val = sample.raw_fields.get(ag_key)
if val is None:
continue
if kind == "pct_dm":
nutrients[wesp_key] = _pct_dm_to_g_per_kg_sv(val)
elif kind == "pct":
nutrients[wesp_key] = round(val, 4)
if "Sugar_ESC" in sample.raw_fields and "Sugar_WSC" not in sample.raw_fields:
esc = sample.raw_fields["Sugar_ESC"]
nutrients["Сахар"] = _pct_dm_to_g_per_kg_sv(esc)
struct_raw = sample.raw_fields.get("aNDFom")
if struct_raw is None:
struct_raw = sample.raw_fields.get("ADF")
if struct_raw is not None:
nutrients["Структур. клетч"] = _pct_dm_to_g_per_kg_sv(struct_raw)
if "Структур. клетч" in nutrients:
nutrients["Структур клетч"] = nutrients["Структур. клетч"]
sample.nutrients = nutrients
if sample.dry_matter_pct is None:
sample.warnings.append("dm_missing")
if "ВРХ Орг Вещ" not in nutrients and "TDN" not in sample.raw_fields:
sample.warnings.append("omd_missing")
elif "TDN" in sample.raw_fields:
sample.warnings.append("omd_from_tdn")
ndfd30 = sample.raw_fields.get("NDFDom_IV_30hr")
if ndfd30 is not None:
sample.warnings.append(f"ndfd30={ndfd30}")
return sample
def finalize_agrostar_sample(sample: AgrostarSample) -> AgrostarSample:
"""Общий финал: raw_fields → nutrients + warnings (XML/PDF/xlsx)."""
return _apply_nutrients_from_raw(sample)
def _read_sample_block(block: ET.Element) -> AgrostarSample:
fields: dict[str, str] = {}
for child in block:
name = child.get("Name") or child.tag
fields[name] = child.get("Value") or ""
sample = AgrostarSample(
sample_no=fields.get("Sample_No", ""),
farm_name=fields.get("Name", ""),
farm_id=fields.get("Farm_ID", ""),
date_printed=fields.get("Date_Printed", ""),
feed_type=fields.get("Type", ""),
desc_1=fields.get("Desc_1", ""),
desc_2=fields.get("Desc_2", ""),
desc_3=fields.get("Desc_3", ""),
dry_matter_pct=_parse_float(fields.get("DM")),
)
for key, raw in fields.items():
if key in _META_FIELDS or key == "DM":
continue
val = _parse_float(raw)
if val is not None:
sample.raw_fields[key] = val
return finalize_agrostar_sample(sample)
def parse_agrostar_xml(text: str) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="")
if not (text or "").strip():
result.errors.append("Пустой файл")
return result
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
result.errors.append(f"Некорректный XML: {exc}")
return result
if root.tag != "Standard_XML_Data":
result.errors.append(f"Ожидался Standard_XML_Data, получен {root.tag}")
for child in root:
tag = child.tag
if tag == "Lab_Name":
result.lab_name = child.get("Value") or ""
elif tag == "Sample_Data":
try:
result.samples.append(_read_sample_block(child))
except Exception as exc: # noqa: BLE001 — собрать все пробы
result.errors.append(f"Ошибка пробы: {exc}")
if not result.samples and not result.errors:
result.errors.append("В файле нет блоков Sample_Data")
return result
def _match_score(label: str, component_name: str) -> float:
a = _normalize_label(label)
b = _normalize_label(component_name)
if not a or not b:
return 0.0
if a in b or b in a:
return 0.95
# ключевые токены: яма, номер, силос
tokens_a = set(re.findall(r"[a-zа-яё0-9]+", a, re.I))
tokens_b = set(re.findall(r"[a-zа-яё0-9]+", b, re.I))
if tokens_a and tokens_b:
overlap = len(tokens_a & tokens_b) / max(len(tokens_a), len(tokens_b))
if overlap >= 0.4:
return 0.5 + overlap * 0.4
return SequenceMatcher(None, a, b).ratio()
def suggest_component_matches(
label: str,
*,
limit: int = 8,
min_score: float = 0.25,
) -> list[ComponentMatch]:
components = (
Component.query.filter(Component.is_deleted.is_(False), Component.is_active.is_(True))
.order_by(Component.name)
.all()
)
scored: list[ComponentMatch] = []
for comp in components:
score = _match_score(label, comp.name or "")
if score >= min_score:
scored.append(ComponentMatch(comp.id, comp.name or "", score))
scored.sort(key=lambda m: (-m.score, m.name.lower()))
return scored[:limit]
def _fmt_preview_num(value: float) -> str:
rounded = round(value, 2)
if abs(rounded - round(rounded)) < 0.01:
return f"{round(rounded):g}"
text = f"{rounded:.2f}".rstrip("0").rstrip(".")
return text or "0"
def _preview_source_for_key(sample: AgrostarSample, wesp_key: str) -> tuple[float | None, str]:
for ag_key in _PREVIEW_AGRO_SOURCE.get(wesp_key, ()):
val = sample.raw_fields.get(ag_key)
if val is not None:
return val, _AGRO_SOURCE_UNIT.get(ag_key, "%DM")
return None, ""
def suggest_canonical_feed_type(sample: AgrostarSample) -> str:
"""Канонический component.type для POST /api/components."""
mapped = _AGROSTAR_FEED_TO_COMPONENT_TYPE.get(sample.feed_type)
if mapped:
return mapped
stub = Component(name=sample.label, type="")
group = classify_feed_group(stub)
return _FEED_GROUP_TO_COMPONENT_TYPE.get(group, "Сочные корма")
def _warning_to_text(code: str) -> str:
if code in _WARNING_RU:
return _WARNING_RU[code]
if code.startswith("ndfd30="):
val = code.split("=", 1)[1]
return _UNSUPPORTED_AGROSTAR.get("NDFDom_IV_30hr", f"ndfd30={val}")
return code
def build_storage_report(sample: AgrostarSample) -> dict[str, Any]:
"""Сводка: что сохранится в WESP и что из AgroStar не мапится."""
unsupported: list[dict[str, str]] = []
for ag_key, val in sample.raw_fields.items():
if ag_key in _AGROSTAR_MAPPED_FIELDS:
continue
label = _AGROSTAR_FIELD_RU.get(ag_key, ag_key)
reason = _UNSUPPORTED_AGROSTAR.get(ag_key, "нет поля в WESP")
unsupported.append(
{
"agroKey": ag_key,
"label": label,
"value": _fmt_preview_num(val),
"reason": reason,
}
)
unsupported.sort(key=lambda row: row["label"].lower())
nutrient_keys = set(sample.nutrients)
stored_count = len(nutrient_keys) + (1 if sample.dry_matter_pct is not None else 0)
total_in_report = len(sample.raw_fields) + (1 if sample.dry_matter_pct is not None else 0)
groups, unsupported_message = _summarize_unsupported(
unsupported,
nutrient_count=len(nutrient_keys),
total_in_report=total_in_report,
)
return {
"storedCount": stored_count,
"nutrientCount": len(nutrient_keys),
"unsupportedCount": len(unsupported),
"unsupportedGroups": groups,
"unsupportedMessage": unsupported_message,
"unsupported": unsupported,
}
def build_sample_preview(sample: AgrostarSample) -> dict[str, Any]:
"""Человекочитаемый разбор пробы для UI «Сначала понюхаем»."""
feed_ru = _FEED_TYPE_RU.get(sample.feed_type, sample.feed_type or "")
desc_parts = [p for p in (sample.desc_1, sample.desc_2, sample.desc_3) if p]
description = " · ".join(desc_parts) if desc_parts else sample.label
meta: list[dict[str, str]] = [
{"label": "№ пробы", "value": sample.sample_no or ""},
{"label": "Хозяйство", "value": sample.farm_name or ""},
{"label": "Код хозяйства", "value": sample.farm_id or ""},
{"label": "Дата отчёта", "value": sample.date_printed or ""},
{"label": "Тип корма", "value": feed_ru},
{"label": "Описание", "value": description},
]
will_write: list[dict[str, str]] = []
if sample.dry_matter_pct is not None:
will_write.append(
{
"label": "Сухое вещество (поле компонента)",
"value": _fmt_preview_num(sample.dry_matter_pct),
"unit": "%",
"sourceValue": _fmt_preview_num(sample.dry_matter_pct),
"sourceUnit": "%",
}
)
for key, label, unit in _PREVIEW_WRITE_ORDER:
if key == "__dry_matter__":
continue
val = sample.nutrients.get(key)
if val is None:
continue
row: dict[str, str] = {"label": label, "value": _fmt_preview_num(val), "unit": unit}
source_val, source_unit = _preview_source_for_key(sample, key)
if source_val is not None:
row["sourceValue"] = _fmt_preview_num(source_val)
row["sourceUnit"] = source_unit
will_write.append(row)
notes = [_warning_to_text(w) for w in sample.warnings]
storage = build_storage_report(sample)
return {
"title": sample.label,
"feedTypeRu": feed_ru,
"suggestedName": sample.label,
"suggestedType": suggest_canonical_feed_type(sample),
"meta": meta,
"willWrite": will_write,
"notes": notes,
"recognizedCount": len(will_write),
"storage": storage,
}
def enrich_parse_with_matches(parse: AgrostarParseResult) -> dict[str, Any]:
body = parse.to_api_dict()
for i, sample in enumerate(parse.samples):
matches = suggest_component_matches(sample.label)
body["samples"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
body["samples"][i]["preview"] = build_sample_preview(sample)
return body
def apply_agrostar_import(
assignments: list[dict[str, Any]],
*,
user_id: str = "agrostar-import",
dry_run: bool = False,
) -> ApplyResult:
"""assignments: [{sampleNo, componentId}] — данные проб из предыдущего parse или повторный parse."""
result = ApplyResult(dry_run=dry_run)
by_sample: dict[str, dict[str, Any]] = {
str(a.get("sampleNo") or a.get("sample_no") or ""): a for a in assignments
}
for sample_no, row in by_sample.items():
if not sample_no:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no="",
component_id=None,
component_name=None,
dry_matter_pct=None,
nutrient_count=0,
warnings=[],
skipped=True,
reason="missing_sample_no",
)
)
continue
component_id = row.get("componentId") or row.get("component_id")
nutrients = row.get("nutrients") or {}
dry_matter = row.get("dryMatterPct") or row.get("dry_matter_pct")
if not component_id:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=None,
component_name=None,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=list(row.get("warnings") or []),
skipped=True,
reason="no_component",
)
)
continue
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is None:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=component_id,
component_name=None,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=[],
skipped=True,
reason="component_not_found",
)
)
continue
warnings = list(row.get("warnings") or [])
if dry_run:
result.applied += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=comp.id,
component_name=comp.name,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=warnings,
)
)
continue
if dry_matter is not None:
try:
comp.dry_matter = float(dry_matter)
except (TypeError, ValueError):
warnings.append("invalid_dry_matter")
save_component_nutrients(comp.id, nutrients, user_id=user_id)
comp.updated_by = user_id
db.session.commit()
result.applied += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=comp.id,
component_name=comp.name,
dry_matter_pct=comp.dry_matter,
nutrient_count=len(nutrients),
warnings=warnings,
)
)
return result
+243
View File
@@ -0,0 +1,243 @@
"""Единый роутер импорта lab: XML / PDF / xlsx → preview API."""
from __future__ import annotations
import re
from typing import Any
from app.lab.etl.agrostar_pdf_import import looks_like_agrostar_pdf, parse_agrostar_pdf
from app.lab.etl.agrostar_xlsx_import import parse_agrostar_xlsx
from app.lab.etl.agrostar_xml_import import AgrostarParseResult, enrich_parse_with_matches, parse_agrostar_xml
from app.lab.etl.plinor_pdf_import import (
enrich_plinor_composition,
is_plinor_sos,
is_plinor_zoo,
parse_plinor_sos,
parse_plinor_zoo,
)
SOURCE_LABELS: dict[str, str] = {
"agrostar_xml": "AgroStar XML",
"agrostar_pdf": "AgroStar PDF",
"agrostar_xlsx": "AgroStar Excel",
"plinor_sos": "ПЛИНОР — состав рациона",
"plinor_zoo": "ПЛИНОР — показатели рациона",
}
_PDF_MAGIC = b"%PDF"
_XLSX_MAGIC = b"PK\x03\x04"
_XML_MARKERS = (b"Standard_XML_Data", b"<?xml", b"<Standard_XML_Data")
def _filename_hint_plinor_zoo(name: str) -> bool:
n = (name or "").lower()
return "зоо" in n or re.search(r"(^|[^a-z])zoo([^a-z]|$)", n) is not None
def _filename_hint_plinor_sos(name: str) -> bool:
n = (name or "").lower()
return "сос" in n or re.search(r"(^|[^a-z])sos([^a-z]|$)", n) is not None
def _looks_like_xml_agrostar(data: bytes) -> bool:
head = data[:16384]
return any(marker in head for marker in _XML_MARKERS)
def _looks_like_xlsx(data: bytes) -> bool:
return data.startswith(_XLSX_MAGIC)
def _looks_like_pdf(data: bytes) -> bool:
return data.startswith(_PDF_MAGIC)
def _pdf_import_available() -> bool:
try:
import fitz # noqa: F401
except ImportError:
return False
return True
def detect_source_format(filename: str, data: bytes) -> str:
"""Определение формата по содержимому и имени (расширение — запасной вариант)."""
name = (filename or "").lower()
if _looks_like_pdf(data):
return _detect_pdf_format(data, name)
if _looks_like_xlsx(data):
return "agrostar_xlsx"
if _looks_like_xml_agrostar(data):
return "agrostar_xml"
if name.endswith(".xml"):
return "agrostar_xml"
if name.endswith(".xlsx") or name.endswith(".xls"):
return "agrostar_xlsx"
if name.endswith(".pdf"):
return _detect_pdf_format(data, name)
return "unknown"
def _detect_pdf_format(data: bytes, name: str) -> str:
head = data[:8192]
if b"Standard_XML_Data" in head:
return "agrostar_xml"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
pdf_text = ""
if _pdf_import_available():
try:
pdf_text = _peek_pdf_text(data)
except Exception: # noqa: BLE001
pdf_text = ""
if pdf_text:
if is_plinor_zoo(pdf_text):
return "plinor_zoo"
if is_plinor_sos(pdf_text):
return "plinor_sos"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
if _filename_hint_plinor_zoo(name):
return "plinor_zoo"
if _filename_hint_plinor_sos(name):
return "plinor_sos"
return "unknown_pdf"
def _peek_pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
text = doc[0].get_text() if doc.page_count else ""
doc.close()
return text
def parse_lab_import(filename: str, data: bytes) -> dict[str, Any]:
"""Разбор файла → единый JSON для /lab комбайна."""
if not data:
return {"error": True, "message": "Пустой файл", "kind": "error"}
fmt = detect_source_format(filename, data)
source_label = SOURCE_LABELS.get(fmt, "Неизвестный формат")
if fmt == "agrostar_xml":
text = _decode_text(data)
parsed = parse_agrostar_xml(text)
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "agrostar_pdf":
parsed = parse_agrostar_pdf(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "agrostar_xlsx":
parsed = parse_agrostar_xlsx(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "plinor_sos":
parsed = parse_plinor_sos(data)
if parsed.errors and not parsed.feed_lines:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = enrich_plinor_composition(parsed.to_api_dict())
body["kind"] = "ration_composition"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "plinor_zoo":
parsed = parse_plinor_zoo(data)
if parsed.errors and not parsed.indicators:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = parsed.to_api_dict()
body["kind"] = "ration_indicators"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "unknown_pdf" and _looks_like_pdf(data) and not _pdf_import_available():
return {
"error": True,
"message": "Для PDF нужен pymupdf — выполните: pip install pymupdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
return {
"error": True,
"message": "Формат не распознан. Поддерживаются: AgroStar xml/pdf/xlsx, ПЛИНОР сос/зоо pdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
def _lab_samples_response(
parsed: AgrostarParseResult,
fmt: str,
source_label: str,
filename: str,
) -> dict[str, Any]:
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
def _decode_text(data: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
+215
View File
@@ -0,0 +1,215 @@
"""ПЛИНОР PDF (сос / зоо) → состав рациона или показатели."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from app.lab.etl.agrostar_xml_import import _parse_float, suggest_component_matches
def _pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
parts = [doc[i].get_text() for i in range(doc.page_count)]
doc.close()
return "\n".join(parts)
def _meta_from_lines(lines: list[str]) -> dict[str, str]:
meta: dict[str, str] = {}
for line in lines:
for key, prefix in (
("group", "Группа:"),
("farm", "Хозяйство:"),
("region", "Район:"),
("rationDate", "Дата рациона:"),
("calcDate", "Дата расчетов:"),
):
if line.startswith(prefix):
meta[key] = line.split(":", 1)[1].strip()
if line.startswith("Суточный удой"):
m = re.search(r"(\d+(?:[,\.]\d+)?)", line)
if m:
meta["milkYieldKg"] = m.group(1).replace(",", ".")
for i, line in enumerate(lines):
if line.startswith("Цена (руб.)") and i + 1 < len(lines):
meta["totalCostRub"] = lines[i + 1].replace(",", ".")
if line == "Масса (кг)" and i + 1 < len(lines):
meta["totalMassKg"] = lines[i + 1].replace(",", ".")
return meta
@dataclass
class PlinorFeedLine:
feed_name: str
daily_kg: float
cost_rub: float
def to_api_dict(self) -> dict[str, Any]:
return {"feedName": self.feed_name, "dailyKg": self.daily_kg, "costRub": self.cost_rub}
@dataclass
class PlinorIndicatorRow:
name: str
norm: float | None
current: float | None
def to_api_dict(self) -> dict[str, Any]:
return {"name": self.name, "norm": self.norm, "current": self.current}
@dataclass
class PlinorCompositionResult:
meta: dict[str, str] = field(default_factory=dict)
feed_lines: list[PlinorFeedLine] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"meta": self.meta,
"feedLines": [line.to_api_dict() for line in self.feed_lines],
"feedCount": len(self.feed_lines),
"errors": self.errors,
}
@dataclass
class PlinorIndicatorsResult:
meta: dict[str, str] = field(default_factory=dict)
indicators: list[PlinorIndicatorRow] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"meta": self.meta,
"indicators": [row.to_api_dict() for row in self.indicators],
"indicatorCount": len(self.indicators),
"errors": self.errors,
}
def _is_plinor(text: str) -> bool:
return "ПЛИНОР" in (text or "") or 'ИАС "РАЦИОНЫ"' in (text or "")
def is_plinor_sos(text: str) -> bool:
if not _is_plinor(text):
return False
if "Состав рациона" in text:
return True
return bool(re.search(r"Таблица 1\.1(?!0)", text))
def is_plinor_zoo(text: str) -> bool:
if not _is_plinor(text):
return False
if "Зоотехнические показатели" in text:
return True
return "Таблица 1.10" in text
def parse_plinor_sos(data: bytes) -> PlinorCompositionResult:
result = PlinorCompositionResult()
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not is_plinor_sos(text):
result.errors.append("Не похоже на ПЛИНОР «Состав рациона» (табл. 1.1)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
result.meta = _meta_from_lines(lines)
feeds: list[PlinorFeedLine] = []
i = 0
while i < len(lines):
if lines[i] == "кг" and i >= 1 and i + 2 < len(lines):
name = lines[i - 1]
amt = _parse_float(lines[i + 1])
cost = _parse_float(lines[i + 2])
if (
amt is not None
and cost is not None
and name not in ("Дача", "изм.", "Корма")
and "кг" not in name.lower()
):
feeds.append(PlinorFeedLine(name, amt, cost))
i += 3
continue
i += 1
if not feeds:
result.errors.append("Не найдены строки кормов")
result.feed_lines = feeds
return result
def parse_plinor_zoo(data: bytes) -> PlinorIndicatorsResult:
result = PlinorIndicatorsResult()
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not is_plinor_zoo(text):
result.errors.append("Не похоже на ПЛИНОР «Зоотехнические показатели» (табл. 1.10)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
result.meta = _meta_from_lines(lines)
skip_prefixes = (
"Район:",
"Хозяйство:",
"Ферма:",
"Двор:",
"Подразделение:",
"Группа:",
"Суточный",
"Стадия",
"Живая",
"Система",
"Конц.",
"Кр.опт.",
"Дата",
)
skip_exact = frozenset(
{"1", "2", "3", "По польз.", "норме", "Текущий рацион", "Наименование", "Значение"}
)
rows: list[PlinorIndicatorRow] = []
i = 0
while i < len(lines):
line = lines[i]
if line in skip_exact or any(line.startswith(p) for p in skip_prefixes):
i += 1
continue
if i + 2 < len(lines) and len(line) > 8:
norm = _parse_float(lines[i + 1])
cur = _parse_float(lines[i + 2])
if norm is not None and cur is not None:
rows.append(PlinorIndicatorRow(line, norm, cur))
i += 3
continue
i += 1
if not rows:
result.errors.append("Не найдены показатели рациона")
result.indicators = rows
return result
def enrich_plinor_composition(body: dict[str, Any]) -> dict[str, Any]:
for i, line in enumerate(body.get("feedLines") or []):
name = line.get("feedName") or ""
matches = suggest_component_matches(name, limit=5)
body["feedLines"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
return body
+265
View File
@@ -0,0 +1,265 @@
"""Import tab reference PostgreSQL → WESP SQLite (offline admin ETL)."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import psycopg2
from psycopg2.extras import RealDictCursor
from app import db
from app.lab.models import LabAnimalProfile
from app.models import Component, Recipe, WESP_SUPPRESS_SYNC_ENQUEUE
from app.lab.services.profile_norms import import_norms_from_legacy_text, save_norms_from_payload
DEFAULT_PG_URL = "postgresql://neoton:neoton_secret@localhost:5432/neoton"
@dataclass
class ImportStats:
components_enriched: int = 0
components_unmatched: int = 0
tab_components_purged: int = 0
animal_profiles: int = 0
recipe_links: int = 0
errors: list[str] = field(default_factory=list)
def _pg_url() -> str:
return os.environ.get("TAB_REFERENCE_DATABASE_URL", DEFAULT_PG_URL).strip()
def _json_text(value: Any) -> str:
if value is None:
return "{}"
if isinstance(value, str):
return value if value else "{}"
return json.dumps(value, ensure_ascii=False)
def _dt(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return None
def _fetch_all(conn, sql: str) -> list[dict[str, Any]]:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql)
return list(cur.fetchall())
def _normalize_name(name: str) -> str:
return " ".join((name or "").lower().split())
def _find_wesp_component(row: dict[str, Any]) -> Component | None:
"""Match tab feed_ingredient → existing WESP component (never create)."""
external_no = row.get("external_no")
name = (row.get("name") or "").strip()
norm = _normalize_name(name)
if external_no is not None:
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
if hit is not None:
return hit
if name:
hit = Component.query.filter(
Component.name == name, Component.is_deleted.is_(False)
).first()
if hit is not None:
return hit
if norm:
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
if _normalize_name(comp.name) == norm:
return comp
if len(name) >= 12:
prefix = name[:20].lower()
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
if prefix in (comp.name or "").lower():
return comp
return None
def _enrich_wesp_component(component: Component, row: dict[str, Any]) -> None:
"""Copy zootech nutrients from tab; WESP id/name/dry_matter/price stay canonical."""
from app.lab.services.component_nutrients import save_component_nutrients
raw = row.get("nutrients")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = {}
if not isinstance(raw, dict):
raw = {}
save_component_nutrients(component.id, raw, user_id="reference-db-import")
if row.get("external_no") is not None:
component.external_no = row.get("external_no")
component.updated_by = "tab-enrich"
def _purge_tab_imported_components(stats: ImportStats) -> None:
"""Remove components created from tab feed_ingredients (not used in WESP calc)."""
tab_ids = [
c.id
for c in Component.query.filter(
Component.created_by == "tab-import", Component.is_deleted.is_(False)
).all()
]
if not tab_ids:
return
for comp in Component.query.filter(Component.id.in_(tab_ids)).all():
comp.soft_delete("tab-import-cleanup")
stats.tab_components_purged += 1
def _import_feed_ingredients(conn, stats: ImportStats) -> None:
"""Map tab feed_ingredient → WESP component; enrich nutrients only."""
rows = _fetch_all(
conn,
"""
SELECT id, external_no, name, price_per_kg, dry_matter, nutrients, row_index
FROM feed_ingredients
ORDER BY row_index NULLS LAST, external_no NULLS LAST
""",
)
for row in rows:
component = _find_wesp_component(row)
if component is None:
stats.components_unmatched += 1
continue
_enrich_wesp_component(component, row)
stats.components_enriched += 1
def build_feed_ingredient_mapping(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> dict[str, str | None]:
st = stats or ImportStats()
mapping: dict[str, str | None] = {}
for row in rows:
tab_id = str(row.get("id") or "")
component = _find_wesp_component(row)
if component is None:
st.components_unmatched += 1
mapping[tab_id] = None
continue
_enrich_wesp_component(component, row)
mapping[tab_id] = component.id
st.components_enriched += 1
return mapping
def apply_animal_profile_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
"""Импорт профилей из списка строк (PG-формат или fixtures JSON)."""
st = stats or ImportStats()
for row in rows:
_upsert_animal_profile_row(row, st)
return st
def _upsert_animal_profile_row(row: dict[str, Any], stats: ImportStats) -> None:
profile = LabAnimalProfile.query.get(row["id"])
if profile is None:
profile = LabAnimalProfile(id=row["id"])
db.session.add(profile)
profile.profile_key = row["key"]
profile.label = row["label"]
profile.ration_type = str(row["type"])
norms_raw = row.get("norms_data")
if isinstance(norms_raw, dict):
save_norms_from_payload(profile, norms_raw)
else:
import_norms_from_legacy_text(profile, norms_raw)
profile.created_at = _dt(row.get("created_at")) or profile.created_at
profile.updated_at = _dt(row.get("updated_at")) or profile.updated_at
profile.created_by = "tab-import"
profile.updated_by = "tab-import"
stats.animal_profiles += 1
def apply_feed_ingredient_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
"""Обогащение WESP component из строк feed_ingredients (без PG)."""
st = stats or ImportStats()
for row in rows:
component = _find_wesp_component(row)
if component is None:
st.components_unmatched += 1
continue
_enrich_wesp_component(component, row)
st.components_enriched += 1
return st
def _import_animal_profiles(conn, stats: ImportStats) -> None:
rows = _fetch_all(
conn,
"""
SELECT id, key, label, type, norms_data, created_at, updated_at
FROM animal_profiles
ORDER BY key
""",
)
apply_animal_profile_rows(rows, stats)
def _recipe_by_name(name: str) -> Recipe | None:
if not name:
return None
return Recipe.query.filter(Recipe.name == name, Recipe.is_deleted.is_(False)).first()
def _link_recipes_from_tab_projects(conn, stats: ImportStats) -> None:
"""Только ration_type на recipe по имени — без staging-таблиц."""
rows = _fetch_all(
conn,
"""
SELECT name, type
FROM ration_projects
ORDER BY updated_at DESC NULLS LAST
""",
)
seen: set[str] = set()
for row in rows:
name = (row.get("name") or "").strip()
if not name or name in seen:
continue
linked = _recipe_by_name(name)
if linked is None:
continue
if row.get("type"):
linked.ration_type = str(row["type"])
seen.add(name)
stats.recipe_links += 1
def import_from_reference_db(pg_url: str | None = None) -> ImportStats:
"""Full dump from tab PostgreSQL into WESP SQLite."""
stats = ImportStats()
url = pg_url or _pg_url()
conn = psycopg2.connect(url)
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
_purge_tab_imported_components(stats)
db.session.flush()
_import_feed_ingredients(conn, stats)
db.session.flush()
_import_animal_profiles(conn, stats)
_link_recipes_from_tab_projects(conn, stats)
db.session.commit()
except Exception as exc:
db.session.rollback()
stats.errors.append(str(exc))
raise
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
conn.close()
return stats
+169
View File
@@ -0,0 +1,169 @@
"""Все показатели рациона для расчёта и сравнения с нормами профиля."""
from __future__ import annotations
from typing import Any
def _ind(
key: str,
label: str,
unit: str,
nutrient_keys: list[str] | None = None,
*,
aggregation: str = "daily_total",
derived: str | None = None,
**extra: Any,
) -> dict[str, Any]:
row: dict[str, Any] = {
"key": key,
"label": label,
"unit": unit,
"nutrient_keys": nutrient_keys or [],
"aggregation": aggregation,
}
if derived:
row["derived"] = derived
row.update(extra)
return row
# Порядок: базовые → расширенные zootech → производные
RATION_ALL_INDICATORS: list[dict[str, Any]] = [
_ind("dry_matter", "Сухое вещество", "г", ["СВ"]),
_ind("dm_main", "СВ — основной корм", "г", ["Осн.Корм", "СВ Основной корм"]),
_ind("oe", "ОЭ — КРС / Дойн", "MJ", ["ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"]),
_ind("nel", "ЧЭЛ — КРС / Дойн", "MJ", ["ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"]),
_ind("nel_per_kg_dm", "ЧЭЛ/кг СВ", "MJ", derived="nel_per_kg_dm"),
_ind("crude_protein", "Сырой протеин", "г", ["Сыр. Протеин"]),
_ind("rup_per_kg_dm", "Нер. СП / кг СВ", "г", ["Нер. СП / кг СВ"], aggregation="weighted_avg"),
_ind("insoluble_protein", "Нерастворимый протеин", "г", ["Нераствор протеин ", "Нерастворим прот ", "Нерастворим прот"]),
_ind("insoluble_protein_pct", "% нераств. протеин", "%", ["% нераств. протеин", "% нераствор протеин"], aggregation="weighted_avg"),
_ind("usp", "уСП", "г", ["уСП", "уСП формул"]),
_ind("usp_pct_dm", "% уСП/кг СВ", "%", ["% уСП/кг СВ", "% уСП/кг СР"], aggregation="weighted_avg"),
_ind("usp_per_kg_dm", "уСП/кг СВ", "г", derived="g_per_kg_dm", from_key="usp"),
_ind("usp_in_om", "уСП в ОВ", "г", ["уСП в ОР", "уСП в ОВ"]),
_ind("usp_low_fat", "уСП <7% СЖ", "г", ["уСП<7%"], aggregation="weighted_avg"),
_ind("usp_high_fat", "уСП >7% СЖ", "г", ["уСП>7%"], aggregation="weighted_avg"),
_ind("ndf", "Сырая клетчатка", "г", ["Сырая клетч", "Сырая клетчатка", "НДК"]),
_ind("ndf_total", "НДК общ", "г", ["НДК Общ", "НДК"]),
_ind("ndf_main_feed", "НДК осн. корм", "г", ["НДК Осн. Корм"]),
_ind("adf_total", "КДК общ", "г", ["КДК общ", "КДК"]),
_ind("structural_fiber", "Структур. клетчатка", "г", ["Структур клетч", "Структур. клетчатка"]),
_ind("structural_fiber_pct", "% стр. клетчатки", "%", ["% стр Сыр. Клетчатк.", "% стр Сыр. Клетч."], aggregation="weighted_avg"),
_ind("crude_fat", "Сырой жир", "г", ["Сырой жир"]),
_ind("rnb", "RNB", "г", ["RNB", "БРА", " БРА "], derived="rnb"),
_ind("calcium", "Ca", "г", ["Ca"]),
_ind("phosphorus", "P", "г", ["P"]),
_ind("magnesium", "Mg", "г", ["Mg"]),
_ind("sodium", "Na", "г", ["Na"]),
_ind("potassium", "K", "г", ["K"]),
_ind("dcab", "DCAB", "мэкв", ["DCAB Форм", "DCAB"]),
_ind("sugar_starch", "Сахар и крахмал", "г", ["Сахар и Крохм"]),
_ind("sugar_digestible_starch", "Сахар и усв. крахмал", "г", ["Сахар и усв. крох"]),
_ind("insoluble_starch", "Нераств. крахмал", "г", ["Нераств. Крохмал", "Нераств Крохм", "Нераств крахмал"]),
_ind("insoluble_starch_alt", "Нераств. крахмал (2)", "г", ["Нераств крохмал"]),
_ind("insoluble_starch_pct", "% нераств. крахмала", "%", ["% Нераств крохм"], aggregation="weighted_avg"),
_ind("sugar", "Сахар", "г", ["Сахар"]),
_ind("starch", "Крахмал", "г", ["Крахмал"]),
_ind("starch_pct_dm", "% крахмала СВ", "%", ["% Крохмал. СВ", "доля крохмала"], aggregation="weighted_avg"),
_ind("carotene", "Каротин", "мг", ["Каротин"]),
_ind("beta_carotene", "β-каротин", "мг", ["b -Каротин"]),
_ind("linoleic_acid", "Линолевая к-та", "г", ["Линолевая к-та"]),
_ind("linolenic_acid", "Линоленовая к-та", "г", ["Линоленовая ки-та"]),
_ind("butyric_acid", "Масляная к-та", "г", ["Масляная ки-та"]),
_ind("arachidonic_acid", "Арахидоновая к-та", "г", ["Арахидоновая ки-та"]),
_ind("polyenoic_acid", "Полиэновая к-та", "г", ["Полиэновая ки-та"]),
_ind("urea", "Мочевина", "г", ["Мочевина"]),
_ind("crude_ash", "Сырая зола", "г", ["Сырая зола"]),
_ind("nfe", "БЭВ", "г", ["БЕР", "БЭВ"]),
_ind("tdn_cattle", "КРС орг. вещество", "г", ["КРС Орг Вещ", "ВРХ Орг Вещ"]),
_ind("digestible_organic_matter", "Перевар. орг. вещество", "г", ["Перевар Орг Вещ", "Переварим Орг Вещ"]),
_ind("cp_cattle", "КРС протеин", "г", ["КРС Протеин"]),
_ind("digestible_protein", "Перевар. протеин", "г", ["Перевар Протеин", "Переварим Протеин"]),
_ind("fat_cattle", "КРС сырой жир", "г", ["КРС Сырой жир", "КРС Сирий жир", "КРС Сирой жир"]),
_ind("digestible_fat", "Перевар. сырой жир", "г", ["Перевар Сырой жир", "Перевар Сирой жир", "Переварим Сырой жир"]),
_ind("fiber_cattle", "КРС сырая клетчатка", "г", ["КРС Сырая клетч"]),
_ind("digestible_fiber", "Перевар. сырая клетчатка", "г", ["Перевар сыр клетч", "Переварим сырая клетч"]),
_ind("nfe_cattle", "КРС БЭВ", "г", ["КРС БЭВ"]),
_ind("digestible_nfe", "Перевар. БЭВ", "г", ["Перевар БЭВ", "Переварим БЭВ"]),
_ind("feed_value", "ВЕ", "", ["ВЕ"], aggregation="weighted_avg"),
_ind("fat_per_kg_dm", "СЖ/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_fat"),
_ind("nsp_per_kg_dm", "НСП/кг СВ", "г", ["НСП/кг СВ"], aggregation="weighted_avg"),
_ind("cp_per_kg_dm", "СП/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_protein"),
_ind("dom_per_kg_dm", "пОВ/кг СВ", "г", derived="g_per_kg_dm", from_key="digestible_organic_matter"),
_ind(
"digestible_fat_per_kg_dm",
"пСЖ/кг СВ",
"г",
derived="g_per_kg_dm",
from_key="digestible_fat",
),
_ind("fiber_pct_per_kg_dm", "%СК/кг СВ", "%", derived="pct_of_dm", from_key="ndf"),
_ind("fat_pct_per_kg_dm", "%-СЖ/кг СВ", "%", derived="pct_of_dm", from_key="crude_fat"),
_ind("usp_pct_per_kg_dm", "%-уСП / кг СВ", "%", derived="pct_of_dm", from_key="usp"),
_ind("ca_pct_per_kg_dm", "%-Ca / кг СВ", "%", derived="pct_of_dm", from_key="calcium"),
_ind("p_pct_per_kg_dm", "%-P / кг СВ", "%", derived="pct_of_dm", from_key="phosphorus"),
_ind("k_pct_per_kg_dm", "%-К / кг СВ", "%", derived="pct_of_dm", from_key="potassium"),
_ind("na_pct_per_kg_dm", "%-Na / кг СВ", "%", derived="pct_of_dm", from_key="sodium"),
_ind("mg_pct_per_kg_dm", "%-Mg / кг СВ", "%", derived="pct_of_dm", from_key="magnesium"),
_ind("ndf_pct_dm", "НДК % от СВ", "%", derived="pct_of_dm", from_key="ndf"),
_ind("ndf_main_pct_dm", "НДК-ОК % от СВ", "%", derived="pct_of_dm", from_key="ndf_main_feed"),
_ind("adf_pct_dm", "КДК % от СВ", "%", derived="pct_of_dm", from_key="adf_total"),
_ind("nfc_pct_dm", "НКВ % от СВ", "%", derived="pct_of_dm", from_key="nfe"),
_ind("sw_per_kg_dm", "SW / кг СВ", "", ["SW / кг СВ ()"], aggregation="weighted_avg"),
_ind("metab_oet", "Метаб. ОЕТ", "г", ["Метаб ОЕТ", "OEB"]),
_ind("metab_lysine", "Метаб. лизин", "г", ["Метаб лизин", "Лизин"]),
_ind("metab_threonine", "Метаб. треонин", "г", ["Метабол Треон", "Треонин"]),
_ind("metab_leucine", "Метаб. лейцин", "г", ["Метаб Лейц", "Метабол Лейцин", "Лейцин"]),
_ind("metab_isoleucine", "Метаб. изолейцин", "г", ["Метаб Изол", "Метабол Изолейц", "Изолейцин"]),
_ind("metab_valine", "Метаб. валин", "г", ["Метаб Вал", "Метабол Валин", "Валин"]),
_ind("vitamin_a", "Витамин А", "МЕ", ["Вит А"]),
_ind("vitamin_d", "Витамин D", "МЕ", ["Вит D"]),
_ind("vitamin_e", "Витамин Е", "мг", ["Вит Е"]),
_ind("vitamin_b1", "Витамин В1", "мг", ["Вит В1"]),
_ind("vitamin_b2", "Витамин В2", "мг", ["Вит В2"]),
_ind("vitamin_b6", "Витамин В6", "мг", ["Вит В6"]),
_ind("vitamin_b12", "Витамин В12", "мкг", ["Вит В12"]),
_ind("calcium_pantothenate", "Пантотенат кальция", "мг", ["Пант Кальц", "Пантеонат Кальция"]),
_ind("niacin", "Никотиновая к-та", "мг", ["Никот Ки-та", "Никотиновая кислота"]),
_ind("folic_acid", "Фолиевая к-та", "мг", ["Фол ки-та", "Фолиева кислота"]),
_ind("choline", "Холин", "мг", ["Холин"]),
_ind("biotin", "Биотин", "мкг", ["Биотин"]),
_ind("iron", "Fe", "мг", ["Fe"]),
_ind("zinc", "Zn", "мг", ["Zn"]),
_ind("copper", "Cu", "мг", ["Cu"]),
_ind("cobalt", "Co", "мг", ["Co"]),
_ind("manganese", "Mn", "мг", ["Mn"]),
_ind("selenium", "Se", "мг", ["Se"]),
_ind("iodine", "J", "мг", ["J"]),
_ind("oet", "OET", "", ["OEB", "OET"]),
_ind("synthesis_mj", "Синтез", "МДж", ["Синтез Мдж"]),
_ind("ca_p_ratio", "Са:P", "", derived="ratio", ratio_num="calcium", ratio_den="phosphorus"),
_ind("k_na_ratio", "K:Na", "", derived="ratio", ratio_num="potassium", ratio_den="sodium"),
_ind("ration_dm_pct_bw", "Рацион % СВ от веса", "%", derived="dm_pct_bw"),
_ind("ration_pct_bw", "Рацион % от веса", "%", derived="ration_pct_bw"),
# Дубликаты ключей норм (укр. заголовки) — тот же расчёт
_ind("ndf_pct_dm_uk", "НДК % від СВ", "%", derived="alias", alias_of="ndf_pct_dm"),
_ind("ndf_main_pct_dm_uk", "НДК-ОК % від СВ", "%", derived="alias", alias_of="ndf_main_pct_dm"),
_ind("adf_pct_dm_uk", "КДК % від СВ", "%", derived="alias", alias_of="adf_pct_dm"),
_ind("nfc_pct_dm_uk", "НКВ % від СВ", "%", derived="alias", alias_of="nfc_pct_dm"),
]
# Обратная совместимость
RATION_QUALITY_INDICATORS = RATION_ALL_INDICATORS
_INDICATOR_BY_KEY = {d["key"]: d for d in RATION_ALL_INDICATORS}
_LABEL_TO_KEY = {d["label"]: d["key"] for d in RATION_ALL_INDICATORS}
def indicator_by_key(key: str) -> dict[str, Any] | None:
return _INDICATOR_BY_KEY.get(key)
def label_to_indicator_key(label: str) -> str | None:
return _LABEL_TO_KEY.get(label)
def calc_indicator_keys() -> frozenset[str]:
return frozenset(_INDICATOR_BY_KEY)
+5
View File
@@ -0,0 +1,5 @@
from .execution_loader import load_execution
from .profile_loader import list_animal_profiles, load_animal_profile
from .ration_loader import load_ration
__all__ = ["load_ration", "load_execution", "list_animal_profiles", "load_animal_profile"]
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import json
from typing import Any
def parse_json_text(value: Any, default: Any = None) -> Any:
if default is None:
default = {}
if value is None or value == "":
return default
if isinstance(value, dict):
return value
try:
return json.loads(value)
except (TypeError, json.JSONDecodeError):
return default
@@ -0,0 +1,29 @@
from __future__ import annotations
from app.lab.dto.ration import ExecutionLineSnapshot, ExecutionSnapshot
from app.models import Ingredient, Recipe
def load_execution(recipe_id: str) -> ExecutionSnapshot:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
heads = int(recipe.heads_per_trip or 1)
ingredients = (
Ingredient.query.filter_by(recipe_id=recipe_id, is_deleted=False)
.order_by(Ingredient.order)
.all()
)
lines = []
for ing in ingredients:
wph = float(ing.weight_per_head or 0)
lines.append(
ExecutionLineSnapshot(
ingredient_id=ing.id,
component_id=ing.component_id,
name=ing.name,
weight_per_head=wph,
daily_kg_total=wph * heads,
)
)
return ExecutionSnapshot(recipe_id=recipe_id, heads_per_trip=heads, lines=tuple(lines))
@@ -0,0 +1,58 @@
from __future__ import annotations
from app.lab.models import LabAnimalProfile
from app.lab.services.profile_norms import load_norms_api
def list_animal_profiles(ration_type: str | None = None) -> list[dict]:
q = LabAnimalProfile.query.filter_by(is_deleted=False)
if ration_type:
q = q.filter_by(ration_type=ration_type.upper())
rows = q.order_by(LabAnimalProfile.profile_key).all()
return [_profile_dict(p, include_resolved=False) for p in rows]
def load_animal_profile(profile_id: str) -> dict:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
return _profile_dict(profile, resolve_norms=True, include_resolved=True)
def _profile_dict(
profile: LabAnimalProfile,
*,
resolve_norms: bool = False,
include_resolved: bool = False,
) -> dict:
norms_api = load_norms_api(profile, resolve_dynamic=include_resolved) if resolve_norms else {}
if not resolve_norms:
from app.lab.services.profile_norms import load_norms_dict
indicators = load_norms_dict(profile.id)
norms_api = {"indicators": indicators}
from app.lab.services.norms_params import norms_params_api
out: dict = {
"id": profile.id,
"profileKey": profile.profile_key,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"milkYieldKg": profile.milk_yield_kg,
"externalNo": profile.external_no,
"normsMethod": profile.norms_method or "wesp",
"normsParams": norms_params_api(profile),
"normsData": norms_api,
"norms": norms_api.get("indicators") or {},
"normsProfileKey": profile.profile_key,
"legacyNormsRemapped": False,
}
if include_resolved:
out["resolvedNorms"] = norms_api.get("resolvedIndicators") or {}
out["dynamicNorms"] = norms_api.get("dynamicNorms") or {}
if norms_api.get("coverage"):
out["coverage"] = norms_api["coverage"]
if norms_api.get("normsMeta"):
out["normsMeta"] = norms_api["normsMeta"]
return out
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
from app.lab.calc.nutrients import norm_diff
from app.lab.indicators import label_to_indicator_key
from app.lab.dto.ration import RationLineSnapshot, RationSnapshot
from app.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
from app.lab.services.component_nutrients import nutrients_calc_dict
from app.lab.services.profile_norms import resolve_norms_for_profile
from app.lab.services.ration_calc_store import (
load_compound_results,
load_params,
load_ration_results,
)
from app.models import Component, Recipe
def _refresh_indicator_norms(
indicators: list[dict],
norms: dict[str, dict[str, float | None]],
) -> list[dict]:
"""min/max/diff в сохранённом расчёте — по текущему профилю норм."""
if not indicators or not norms:
return indicators
out: list[dict] = []
for row in indicators:
item = dict(row)
key = item.get("key") or label_to_indicator_key(str(item.get("label") or ""))
if key and key in norms:
bounds = norms[key]
min_v = bounds.get("min")
max_v = bounds.get("max")
item["min"] = min_v
item["max"] = max_v
item["diff"] = norm_diff(item.get("content"), min_v, max_v)
out.append(item)
return out
def load_ration(recipe_id: str) -> RationSnapshot:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
ration_type = recipe.ration_type or "BEEF"
norms: dict = {}
params: dict = {}
ration_results: dict = {}
compound_results: dict = {}
calculated_at = None
animal_profile_id = None
norms_profile_key = None
if header:
animal_profile_id = header.animal_profile_id
params = load_params(recipe_id, header)
ration_results = load_ration_results(recipe_id, header)
compound_results = load_compound_results(recipe_id)
calculated_at = header.calculated_at.isoformat() if header.calculated_at else None
if header.animal_profile_id:
profile = LabAnimalProfile.query.filter_by(
id=header.animal_profile_id, is_deleted=False
).first()
if profile:
norms_profile_key = profile.profile_key
norms = resolve_norms_for_profile(profile)
if ration_results.get("indicators") and norms:
ration_results = {
**ration_results,
"indicators": _refresh_indicator_norms(ration_results["indicators"], norms),
}
lines_q = (
LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False)
.order_by(LabRationLine.row_index)
.all()
)
line_snaps = []
for line in lines_q:
comp = Component.query.get(line.component_id) if line.component_id else None
nutrients = nutrients_calc_dict(line.component_id) if line.component_id else {}
dry_matter_pct = comp.dry_matter if comp else None
line_snaps.append(
RationLineSnapshot(
id=line.id,
component_id=line.component_id,
ingredient_name=line.ingredient_name or (comp.name if comp else None),
row_index=line.row_index,
daily_kg=line.daily_kg,
in_ration=bool(line.in_ration),
in_compound=bool(line.in_compound),
dry_matter=dry_matter_pct,
price_per_kg=comp.price if comp else None,
nutrients=nutrients,
)
)
return RationSnapshot(
recipe_id=recipe_id,
recipe_name=recipe.name,
ration_type=ration_type,
heads_per_trip=int(recipe.heads_per_trip or 1),
animal_profile_id=animal_profile_id,
params=params,
lines=tuple(line_snaps),
norms=norms,
ration_results=ration_results,
compound_results=compound_results,
calculated_at=calculated_at,
exists=header is not None,
norms_profile_key=norms_profile_key,
legacy_norms_remapped=False,
)
def ration_to_calc_lines(snapshot: RationSnapshot) -> list[dict]:
return [
{
"daily_kg": line.daily_kg,
"in_ration": line.in_ration,
"in_compound": line.in_compound,
"ingredient_name": line.ingredient_name,
"price_per_kg": line.price_per_kg,
"dry_matter": line.dry_matter,
"nutrients": line.nutrients,
"component_id": line.component_id,
}
for line in snapshot.lines
]
+17
View File
@@ -0,0 +1,17 @@
from .animal_profile import LabAnimalProfile # noqa: F401
from .component_nutrient_value import LabComponentNutrientValue # noqa: F401
from .profile_norm import LabProfileNorm # noqa: F401
from .racion_normy import ( # noqa: F401
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
)
from .ration_calc import ( # noqa: F401
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
)
from .ration_line import LabRationLine # noqa: F401
from .recipe_ration import LabRecipeRation # noqa: F401
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy import Column, Integer, String
class LabAuditMixin:
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")
# Backward alias during migration cleanup
LabSyncMixin = LabAuditMixin
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Column, Float, Integer, String, Text
from app import db
from app.models.base import SoftDeleteMixin, TimestampMixin, default_uuid
from ._mixins import LabAuditMixin
class LabAnimalProfile(db.Model, TimestampMixin, SoftDeleteMixin, LabAuditMixin):
__tablename__ = "lab_animal_profile"
id = Column(String(36), primary_key=True, default=default_uuid)
profile_key = Column(String(64), nullable=False, unique=True, index=True)
label = Column(String(200), nullable=False)
ration_type = Column(String(10), nullable=False, index=True)
mass_kg = Column(Float, nullable=True)
milk_yield_kg = Column(Float, nullable=True)
external_no = Column(Integer, nullable=True)
norms_method = Column(String(20), nullable=False, default="wesp", server_default="wesp")
norms_params_json = Column(Text, nullable=True)
@@ -0,0 +1,17 @@
from sqlalchemy import Column, Float, ForeignKey, String, UniqueConstraint
from app import db
class LabComponentNutrientValue(db.Model):
"""Полная матрица показателей zootech «База сырья» (EAV, server-only)."""
__tablename__ = "lab_component_nutrient_value"
__table_args__ = (
UniqueConstraint("component_id", "nutrient_key", name="uq_lab_comp_nutrient"),
)
id = Column(String(36), primary_key=True)
component_id = Column(String(36), ForeignKey("component.id"), nullable=False, index=True)
nutrient_key = Column(String(80), nullable=False, index=True)
value = Column(Float, nullable=True)
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import Column, Float, ForeignKey, String, UniqueConstraint
from app import db
from app.models.base import default_uuid
class LabProfileNorm(db.Model):
"""Min/max нормы показателя для профиля стада (server-only)."""
__tablename__ = "lab_profile_norm"
__table_args__ = (
UniqueConstraint("profile_id", "indicator_key", name="uq_lab_profile_norm"),
)
id = Column(String(36), primary_key=True, default=default_uuid)
profile_id = Column(
String(36),
ForeignKey("lab_animal_profile.id"),
nullable=False,
index=True,
)
indicator_key = Column(String(64), nullable=False)
min_value = Column(Float, nullable=True)
max_value = Column(Float, nullable=True)
+56
View File
@@ -0,0 +1,56 @@
"""Справочники норм RACION (NORMY_*)."""
from __future__ import annotations
from sqlalchemy import Column, Float, Integer, String, Text, UniqueConstraint
from app import db
from app.models.base import default_uuid
class LabRacionNormyMoskwa(db.Model):
__tablename__ = "lab_racion_normy_moskwa"
__table_args__ = (UniqueConstraint("npitv", "pom", name="uq_lab_racion_moskwa_npitv_pom"),)
id = Column(String(36), primary_key=True, default=default_uuid)
npitv = Column(Integer, nullable=False, index=True)
pom = Column(Integer, nullable=False, default=1)
koef = Column(Float, nullable=True)
popr_k_json = Column(Text, nullable=True)
class LabRacionNormyMoskwaMeta(db.Model):
__tablename__ = "lab_racion_normy_moskwa_meta"
id = Column(String(36), primary_key=True, default=default_uuid)
meta_key = Column(String(32), nullable=False, unique=True)
meta_json = Column(Text, nullable=True)
class LabRacionNormyPiter(db.Model):
__tablename__ = "lab_racion_normy_piter"
__table_args__ = (
UniqueConstraint("npitv", "konc", "udoy", name="uq_lab_racion_piter_npitv_konc_udoy"),
)
id = Column(String(36), primary_key=True, default=default_uuid)
npitv = Column(Integer, nullable=False, index=True)
konc = Column(Float, nullable=False)
udoy = Column(Float, nullable=False)
normy_json = Column(Text, nullable=True)
class LabRacionNormyPiterMeta(db.Model):
__tablename__ = "lab_racion_normy_piter_meta"
id = Column(String(36), primary_key=True, default=default_uuid)
meta_key = Column(String(32), nullable=False, unique=True)
meta_json = Column(Text, nullable=True)
class LabRacionNormyInfo(db.Model):
__tablename__ = "lab_racion_normy_info"
id = Column(String(36), primary_key=True, default=default_uuid)
nperem = Column(Integer, nullable=False, unique=True, index=True)
znachenie_json = Column(Text, nullable=True)
+58
View File
@@ -0,0 +1,58 @@
from sqlalchemy import Column, Float, ForeignKey, Integer, String
from app import db
from app.models.base import default_uuid
class LabRationCalcTotal(db.Model):
__tablename__ = "lab_ration_calc_total"
id = Column(String(36), primary_key=True, default=default_uuid)
recipe_id = Column(
String(36),
ForeignKey("lab_recipe_ration.recipe_id"),
nullable=False,
index=True,
)
scope = Column(String(16), nullable=False, default="ration")
metric_key = Column(String(32), nullable=False)
label = Column(String(128), nullable=False, default="")
value = Column(Float, nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
class LabRationCalcIndicator(db.Model):
__tablename__ = "lab_ration_calc_indicator"
id = Column(String(36), primary_key=True, default=default_uuid)
recipe_id = Column(
String(36),
ForeignKey("lab_recipe_ration.recipe_id"),
nullable=False,
index=True,
)
scope = Column(String(16), nullable=False, default="ration")
indicator_key = Column(String(64), nullable=True)
label = Column(String(128), nullable=False, default="")
unit = Column(String(32), nullable=False, default="")
min_value = Column(Float, nullable=True)
max_value = Column(Float, nullable=True)
content = Column(Float, nullable=True)
diff = Column(Float, nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
class LabRationCompoundLine(db.Model):
__tablename__ = "lab_ration_compound_line"
id = Column(String(36), primary_key=True, default=default_uuid)
recipe_id = Column(
String(36),
ForeignKey("lab_recipe_ration.recipe_id"),
nullable=False,
index=True,
)
row_index = Column(Integer, nullable=False, default=0)
ingredient_name = Column(String(200), nullable=True)
daily_kg = Column(Float, nullable=True)
share_pct = Column(Float, nullable=True)
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String
from app import db
from app.models.base import SoftDeleteMixin, TimestampMixin, default_uuid
from ._mixins import LabAuditMixin
class LabRationLine(db.Model, TimestampMixin, SoftDeleteMixin, LabAuditMixin):
__tablename__ = "lab_ration_line"
id = Column(String(36), primary_key=True, default=default_uuid)
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
component_id = Column(
String(36), ForeignKey("component.id"), nullable=True, index=True
)
row_index = Column(Integer, nullable=False, default=0)
ingredient_name = Column(String(200), nullable=True)
daily_kg = Column(Float, nullable=True)
in_ration = Column(Boolean, nullable=False, default=True)
in_compound = Column(Boolean, nullable=False, default=False)
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Column, DateTime, ForeignKey, String
from app import db
from app.models.base import SoftDeleteMixin, TimestampMixin
from ._mixins import LabAuditMixin
class LabRecipeRation(db.Model, TimestampMixin, SoftDeleteMixin, LabAuditMixin):
__tablename__ = "lab_recipe_ration"
recipe_id = Column(String(36), ForeignKey("recipe.id"), primary_key=True)
animal_profile_id = Column(
String(36), ForeignKey("lab_animal_profile.id"), nullable=True, index=True
)
calc_engine = Column(String(20), nullable=True)
seed_source = Column(String(32), nullable=True)
calculated_at = Column(DateTime, nullable=True)
+290
View File
@@ -0,0 +1,290 @@
"""Каталог показателей норм профиля стада."""
from __future__ import annotations
import re
import unicodedata
from app.lab.indicators import RATION_ALL_INDICATORS, calc_indicator_keys
_CALC_META = {d["key"]: d for d in RATION_ALL_INDICATORS}
# Заголовок колонки CSV «Нормы Дойн/КРС» (после нормализации) → indicator_key в lab_profile_norm
NORM_HEADER_TO_KEY: dict[str, str] = {
"Сухое Вещество": "dry_matter",
"Сыр. Протеин": "crude_protein",
"уСП": "usp",
"RNB": "rnb",
"БРА": "rnb",
"ЧЭЛ- КРС": "nel",
"ОЭ-КРС": "oe",
"Сырая клетч": "ndf",
"Сырая клетчат": "ndf",
"Сырая клетчатка": "ndf",
"Структур клетчатк": "structural_fiber",
"Сырой жир": "crude_fat",
"Сирой жир": "crude_fat",
"Ca": "calcium",
"P": "phosphorus",
"Mg": "magnesium",
"Na": "sodium",
"DCAB": "dcab",
"Сахар и Крохм": "sugar_starch",
"Нераств. Крохмал": "insoluble_starch",
"Нерозч. Крохмал": "insoluble_starch",
"Сахар": "sugar",
"Крохмал": "starch",
"Каротин": "carotene",
"b -Каротин": "beta_carotene",
"Линолевая к-та": "linoleic_acid",
"Линоленовая ки-та": "linolenic_acid",
"Масляная ки-та": "butyric_acid",
"Мочевина": "urea",
"СВ Основной корм": "dm_main",
"КРС Орг Вещ": "tdn_cattle",
"Перевар Орг Вещ": "digestible_organic_matter",
"КРС Протеин": "cp_cattle",
"Перевар Протеин": "digestible_protein",
"КРС Сирий жир": "fat_cattle",
"КРС Сирой жир": "fat_cattle",
"Перевар Сырой жир": "digestible_fat",
"Перевар Сирой жир": "digestible_fat",
"КРС Сырая клетч": "fiber_cattle",
"Перевар сыр клетч": "digestible_fiber",
"КРС БЭВ": "nfe_cattle",
"Перевар БЭВ": "digestible_nfe",
"ВЕ": "feed_value",
"НДК Общ": "ndf_total",
"НДК Осн. Корм": "ndf_main_feed",
"КДК общ": "adf_total",
"% нераств. протеин": "insoluble_protein_pct",
"Нераствор протеин": "insoluble_protein",
"СЖ/кг СВ": "fat_per_kg_dm",
"НСП/кг СВ": "nsp_per_kg_dm",
"СП/кг СВ": "cp_per_kg_dm",
"пОВ/кг СВ": "dom_per_kg_dm",
"пСЖ/кг СВ": "digestible_fat_per_kg_dm",
"уСП<7%": "usp_low_fat",
"уСП>7%": "usp_high_fat",
"уСП/кг СВ": "usp_per_kg_dm",
"уСП в ОВ": "usp_in_om",
"Нераств крохмал": "insoluble_starch_alt",
"Метаб ОЕТ": "metab_oet",
"Метаб лизин": "metab_lysine",
"Метабол Треон": "metab_threonine",
"Метаб Лейц": "metab_leucine",
"Метаб Изол": "metab_isoleucine",
"Метаб Вал": "metab_valine",
"НДК % від СВ ()": "ndf_pct_dm_uk",
"НДК % от СВ ()": "ndf_pct_dm",
"НДК-ОК % від СВ ()": "ndf_main_pct_dm_uk",
"НДК-ОК % от СВ ()": "ndf_main_pct_dm",
"КДК % від СВ ()": "adf_pct_dm_uk",
"КДК % от СВ ()": "adf_pct_dm",
"НКВ % від СВ ()": "nfc_pct_dm_uk",
"НКВ % от СВ ()": "nfc_pct_dm",
"SW / кг СВ ()": "sw_per_kg_dm",
"ЧЕЛ/кг СВ (МДж)": "nel_per_kg_dm",
"Нер. СП / кг СВ": "rup_per_kg_dm",
"%СК/кг СВ ()": "fiber_pct_per_kg_dm",
"%-СЖ/кг СВ ()": "fat_pct_per_kg_dm",
"%-уСП / кг СВ ()": "usp_pct_per_kg_dm",
"% уСП/кг СВ": "usp_pct_dm",
"%-Ca / кг СВ ()": "ca_pct_per_kg_dm",
"%-P / кг СВ ()": "p_pct_per_kg_dm",
"%-К / кг СВ ()": "k_pct_per_kg_dm",
"%-Na / кг СВ ()": "na_pct_per_kg_dm",
"%-Mg / кг СВ ()": "mg_pct_per_kg_dm",
"% стр Сыр. Клетчатк.": "structural_fiber_pct",
"% стр Сыр. Клетч.": "structural_fiber_pct",
"% Крохмал. СВ": "starch_pct_dm",
"% Нераств крохм": "insoluble_starch_pct",
"Са:P": "ca_p_ratio",
"K:Na": "k_na_ratio",
"Рацион % СВ от Веса КРС": "ration_dm_pct_bw",
"Рацион % от Веса КРС": "ration_pct_bw",
"Витамин А": "vitamin_a",
"Витамин D": "vitamin_d",
"Витамин Е": "vitamin_e",
"Витамин В1": "vitamin_b1",
"Витамин В2": "vitamin_b2",
"Витамин В6": "vitamin_b6",
"Витамин В12": "vitamin_b12",
"Пантеонат Кальция": "calcium_pantothenate",
"Пантеонат Кальцию": "calcium_pantothenate",
"Никотиновая кислота": "niacin",
"Фолиева кислота": "folic_acid",
"Фолиевач кислота": "folic_acid",
"Холин": "choline",
"Биотин": "biotin",
"Fe": "iron",
"Zn": "zinc",
"Cu": "copper",
"Co": "cobalt",
"Mn": "manganese",
"Se": "selenium",
"J": "iodine",
"K": "potassium",
"Сахар и усв. крох": "sugar_digestible_starch",
"Сырая зола": "crude_ash",
"Сірая зола": "crude_ash",
"БЭВ": "nfe",
"БЄВ": "nfe",
"Арахидоновая ки-та": "arachidonic_acid",
"Полиэновая ки-та": "polyenoic_acid",
"OET": "oet",
"Синтез Мдж": "synthesis_mj",
}
# Метаданные для UI (label/unit) — показатели вне расчёта рациона
_EXTRA_NORM_META: dict[str, dict[str, str]] = {
"beta_carotene": {"label": "β-каротин", "unit": "мг"},
"linoleic_acid": {"label": "Линолевая к-та", "unit": "г"},
"linolenic_acid": {"label": "Линоленовая к-та", "unit": "г"},
"butyric_acid": {"label": "Масляная к-та", "unit": "г"},
"urea": {"label": "Мочевина", "unit": "г"},
"tdn_cattle": {"label": "КРС орг. вещество", "unit": "г"},
"digestible_organic_matter": {"label": "Перевар. орг. вещество", "unit": "г"},
"cp_cattle": {"label": "КРС протеин", "unit": "г"},
"digestible_protein": {"label": "Перевар. протеин", "unit": "г"},
"fat_cattle": {"label": "КРС сырой жир", "unit": "г"},
"digestible_fat": {"label": "Перевар. сырой жир", "unit": "г"},
"fiber_cattle": {"label": "КРС сырая клетчатка", "unit": "г"},
"digestible_fiber": {"label": "Перевар. сырая клетчатка", "unit": "г"},
"nfe_cattle": {"label": "КРС БЭВ", "unit": "г"},
"digestible_nfe": {"label": "Перевар. БЭВ", "unit": "г"},
"feed_value": {"label": "ВЕ", "unit": ""},
"ndf_total": {"label": "НДК общ", "unit": "г"},
"ndf_main_feed": {"label": "НДК осн. корм", "unit": "г"},
"adf_total": {"label": "КДК общ", "unit": "г"},
"insoluble_protein_pct": {"label": "% нераств. протеин", "unit": "%"},
"fat_per_kg_dm": {"label": "СЖ/кг СВ", "unit": "г"},
"nsp_per_kg_dm": {"label": "НСП/кг СВ", "unit": "г"},
"cp_per_kg_dm": {"label": "СП/кг СВ", "unit": "г"},
"dom_per_kg_dm": {"label": "пОВ/кг СВ", "unit": "г"},
"digestible_fat_per_kg_dm": {"label": "пСЖ/кг СВ", "unit": "г"},
"usp_low_fat": {"label": "уСП <7% СЖ", "unit": "г"},
"usp_high_fat": {"label": "уСП >7% СЖ", "unit": "г"},
"usp_per_kg_dm": {"label": "уСП/кг СВ", "unit": "г"},
"usp_in_om": {"label": "уСП в ОВ", "unit": "г"},
"insoluble_starch_alt": {"label": "Нераств. крахмал (2)", "unit": "г"},
"metab_oet": {"label": "Метаб. ОЕТ", "unit": "г"},
"metab_lysine": {"label": "Метаб. лизин", "unit": "г"},
"metab_threonine": {"label": "Метаб. треонин", "unit": "г"},
"metab_leucine": {"label": "Метаб. лейцин", "unit": "г"},
"metab_isoleucine": {"label": "Метаб. изолейцин", "unit": "г"},
"metab_valine": {"label": "Метаб. валин", "unit": "г"},
"ndf_pct_dm": {"label": "НДК % от СВ", "unit": "%"},
"ndf_pct_dm_uk": {"label": "НДК % від СВ", "unit": "%"},
"ndf_main_pct_dm": {"label": "НДК-ОК % от СВ", "unit": "%"},
"ndf_main_pct_dm_uk": {"label": "НДК-ОК % від СВ", "unit": "%"},
"adf_pct_dm": {"label": "КДК % от СВ", "unit": "%"},
"adf_pct_dm_uk": {"label": "КДК % від СВ", "unit": "%"},
"nfc_pct_dm": {"label": "НКВ % от СВ", "unit": "%"},
"nfc_pct_dm_uk": {"label": "НКВ % від СВ", "unit": "%"},
"sw_per_kg_dm": {"label": "SW / кг СВ", "unit": ""},
"fiber_pct_per_kg_dm": {"label": "%СК/кг СВ", "unit": "%"},
"fat_pct_per_kg_dm": {"label": "%-СЖ/кг СВ", "unit": "%"},
"usp_pct_per_kg_dm": {"label": "%-уСП / кг СВ", "unit": "%"},
"ca_pct_per_kg_dm": {"label": "%-Ca / кг СВ", "unit": "%"},
"p_pct_per_kg_dm": {"label": "%-P / кг СВ", "unit": "%"},
"k_pct_per_kg_dm": {"label": "%-К / кг СВ", "unit": "%"},
"na_pct_per_kg_dm": {"label": "%-Na / кг СВ", "unit": "%"},
"mg_pct_per_kg_dm": {"label": "%-Mg / кг СВ", "unit": "%"},
"structural_fiber_pct": {"label": "% стр. клетчатки", "unit": "%"},
"starch_pct_dm": {"label": "% крахмала СВ", "unit": "%"},
"insoluble_starch_pct": {"label": "% нераств. крахмала", "unit": "%"},
"ca_p_ratio": {"label": "Са:P", "unit": ""},
"k_na_ratio": {"label": "K:Na", "unit": ""},
"ration_dm_pct_bw": {"label": "Рацион % СВ от веса", "unit": "%"},
"ration_pct_bw": {"label": "Рацион % от веса", "unit": "%"},
"vitamin_a": {"label": "Витамин А", "unit": "МЕ"},
"vitamin_d": {"label": "Витамин D", "unit": "МЕ"},
"vitamin_e": {"label": "Витамин Е", "unit": "мг"},
"vitamin_b1": {"label": "Витамин В1", "unit": "мг"},
"vitamin_b2": {"label": "Витамин В2", "unit": "мг"},
"vitamin_b6": {"label": "Витамин В6", "unit": "мг"},
"vitamin_b12": {"label": "Витамин В12", "unit": "мкг"},
"calcium_pantothenate": {"label": "Пантотенат кальция", "unit": "мг"},
"niacin": {"label": "Никотиновая к-та", "unit": "мг"},
"folic_acid": {"label": "Фолиевая к-та", "unit": "мг"},
"choline": {"label": "Холин", "unit": "мг"},
"biotin": {"label": "Биотин", "unit": "мкг"},
"iron": {"label": "Fe", "unit": "мг"},
"zinc": {"label": "Zn", "unit": "мг"},
"copper": {"label": "Cu", "unit": "мг"},
"cobalt": {"label": "Co", "unit": "мг"},
"manganese": {"label": "Mn", "unit": "мг"},
"selenium": {"label": "Se", "unit": "мг"},
"iodine": {"label": "J", "unit": "мг"},
"potassium": {"label": "K", "unit": "г"},
"sugar_digestible_starch": {"label": "Сахар и усв. крахмал", "unit": "г"},
"crude_ash": {"label": "Сырая зола", "unit": "г"},
"nfe": {"label": "БЭВ", "unit": "г"},
"arachidonic_acid": {"label": "Арахидоновая к-та", "unit": "г"},
"polyenoic_acid": {"label": "Полиэновая к-та", "unit": "г"},
"oet": {"label": "OET", "unit": ""},
"synthesis_mj": {"label": "Синтез", "unit": "МДж"},
}
_CALC_KEYS = calc_indicator_keys()
def norm_header(value: str) -> str:
return " ".join((value or "").split()).strip()
def norm_title_to_key(title: str) -> str | None:
"""Заголовок колонки CSV → indicator_key (или None)."""
key = NORM_HEADER_TO_KEY.get(norm_header(title))
if key:
return key
return _fallback_key(title)
def _fallback_key(title: str) -> str | None:
n = norm_header(title)
if not n:
return None
low = n.lower().replace("і", "и").replace("є", "е")
for header, key in NORM_HEADER_TO_KEY.items():
if header.lower() == low:
return key
slug = re.sub(r"[^a-z0-9]+", "_", unicodedata.normalize("NFKD", low).encode("ascii", "ignore").decode())
slug = slug.strip("_")[:48]
return f"norm_{slug}" if slug else None
def norm_indicator_meta(key: str) -> dict[str, str | bool]:
calc = _CALC_META.get(key)
if calc:
return {
"key": key,
"label": str(calc["label"]),
"unit": str(calc.get("unit") or ""),
"inCalc": True,
}
extra = _EXTRA_NORM_META.get(key, {})
return {
"key": key,
"label": extra.get("label", key),
"unit": extra.get("unit", ""),
"inCalc": key in _CALC_KEYS,
}
def list_norm_indicators() -> list[dict[str, str | bool]]:
keys: list[str] = []
seen: set[str] = set()
for key in NORM_HEADER_TO_KEY.values():
if key not in seen:
seen.add(key)
keys.append(key)
for key in sorted(_EXTRA_NORM_META):
if key not in seen:
keys.append(key)
return [norm_indicator_meta(k) for k in keys]
def calc_norm_keys() -> frozenset[str]:
return _CALC_KEYS
+28
View File
@@ -0,0 +1,28 @@
"""Канонические indicator_key для нутриентов компонента (без fuzzy-match)."""
from __future__ import annotations
from typing import Any
from app.lab.norm_catalog import NORM_HEADER_TO_KEY
def augment_with_indicator_keys(data: dict[str, Any] | None) -> dict[str, float]:
"""Дублирует значения под slug indicator_key."""
if not data:
return {}
out: dict[str, float] = {}
for raw_key, raw_val in data.items():
try:
out[str(raw_key)] = float(raw_val)
except (TypeError, ValueError):
continue
for header, indicator_key in NORM_HEADER_TO_KEY.items():
if header in out and indicator_key not in out:
out[indicator_key] = out[header]
return out
def canonicalize_for_storage(data: dict[str, Any] | None) -> dict[str, float]:
"""При записи в EAV: slug indicator_key + исходный заголовок."""
return augment_with_indicator_keys(data)
+163
View File
@@ -0,0 +1,163 @@
"""Канонические поля zootech-показателей компонента (parity tab INGREDIENT_NUTRIENT_EDIT_ROWS)."""
from __future__ import annotations
from typing import Any
# column_name -> ключи в API/calc (первый — канон для UI)
NUTRIENT_FIELD_SPECS: tuple[tuple[str, tuple[str, ...]], ...] = (
("crude_protein", ("Сыр. Протеин",)),
("usp", ("уСП",)),
("rnb", ("RNB", "БРА", " БРА ")),
("nel_cattle", ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм")),
("oe_cattle", ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм")),
("ndf", ("Сырая клетчатка", "Сырая клетч")),
("structural_fiber", ("Структур. клетчатка", "Структур клетч")),
("crude_fat", ("Сырой жир",)),
)
def _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _parse_num(value: Any) -> 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 dry_matter_g_per_kg(dry_matter_pct: float | None) -> float | None:
"""СВ г/кг из канонического component.dry_matter (%): 88.7% → 887 г/кг."""
dm = _parse_num(dry_matter_pct)
if dm is None:
return None
if 0 < dm <= 100:
return dm * 10.0
if dm > 100:
return dm
return None
def resolve_sv_g_per_kg(
nutrients: dict[str, Any] | None,
dry_matter_pct: float | None,
) -> float | None:
"""Deprecated alias: СВ только из component.dry_matter (%). nutrients игнорируется."""
return dry_matter_g_per_kg(dry_matter_pct)
def read_from_mapping(data: dict[str, Any] | None, keys: tuple[str, ...]) -> float | None:
if not data:
return None
for search in keys:
target = _normalize_key(search)
for k, v in data.items():
if _normalize_key(str(k)) != target:
continue
n = _parse_num(v)
if n is not None:
return n
return None
def api_dict_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
payload = data or {}
out: dict[str, float | None] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
out[col] = read_from_mapping(payload, keys)
return out
def derived_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
return api_dict_to_column_values(data)
def column_values_to_mapping(values: dict[str, float | None]) -> dict[str, float]:
out: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = values.get(col)
n = _parse_num(val)
if n is not None:
out[keys[0]] = n
return out
def mapping_to_calc_dict(data: dict[str, Any] | None) -> dict[str, float]:
if not data:
return {}
from app.lab.indicators import RATION_ALL_INDICATORS
result: dict[str, float] = {}
for _col, keys in NUTRIENT_FIELD_SPECS:
val = read_from_mapping(data, keys)
if val is None:
continue
for key in keys:
result[key] = val
seen: set[str] = set()
for defn in RATION_ALL_INDICATORS:
for key in defn.get("nutrient_keys") or []:
if key in seen:
continue
seen.add(key)
val = read_from_mapping(data, (key,))
if val is not None:
result[key] = val
return result
def row_to_calc_dict(row: Any | None) -> dict[str, float]:
if row is None:
return {}
result: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = getattr(row, col, None)
if val is None:
continue
n = _parse_num(val)
if n is None:
continue
for key in keys:
result[key] = n
return result
def row_to_api_dict(row: Any | None) -> dict[str, float]:
"""API nutrients — канонические ключи tab."""
if row is None:
return {}
out: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = getattr(row, col, None)
n = _parse_num(val)
if n is not None:
out[keys[0]] = n
return out
def row_is_empty(row: Any | None) -> bool:
if row is None:
return True
for col, _keys in NUTRIENT_FIELD_SPECS:
if _parse_num(getattr(row, col, None)) is not None:
return False
return True
def legacy_json_to_column_values(raw: str | None) -> dict[str, float | None]:
if not raw or not str(raw).strip() or str(raw).strip() == "{}":
return {col: None for col, _ in NUTRIENT_FIELD_SPECS}
import json
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
data = {}
if not isinstance(data, dict):
data = {}
return api_dict_to_column_values(data)
+9
View File
@@ -0,0 +1,9 @@
"""Справочные zootech-профили (norm_*) vs пользовательские стандарты."""
from __future__ import annotations
_REFERENCE_PREFIX = "norm_"
def is_reference_profile(profile_key: str | None) -> bool:
return bool(profile_key) and profile_key.startswith(_REFERENCE_PREFIX)
+19
View File
@@ -0,0 +1,19 @@
"""Пути к seed-данным WESP (data/seed/)."""
from __future__ import annotations
from pathlib import Path
_WESP_ROOT = Path(__file__).resolve().parents[2]
def seed_dir() -> Path:
return _WESP_ROOT / "data" / "seed"
def norms_dir() -> Path:
return seed_dir() / "norms"
def nutrients_dir() -> Path:
return seed_dir() / "nutrients"
+3
View File
@@ -0,0 +1,3 @@
from .api import ration_to_api
__all__ = ["ration_to_api"]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any
from app.lab.dto.ration import RationSnapshot
def ration_to_api(snapshot: RationSnapshot) -> dict[str, Any]:
return {
"recipeId": snapshot.recipe_id,
"recipeName": snapshot.recipe_name,
"rationType": snapshot.ration_type,
"headsPerTrip": snapshot.heads_per_trip,
"exists": snapshot.exists,
"animalProfileId": snapshot.animal_profile_id,
"params": snapshot.params,
"norms": snapshot.norms,
"normsProfileKey": snapshot.norms_profile_key,
"legacyNormsRemapped": snapshot.legacy_norms_remapped,
"rationResults": snapshot.ration_results,
"compoundResults": snapshot.compound_results,
"calculatedAt": snapshot.calculated_at,
"lines": [
{
"id": line.id,
"componentId": line.component_id,
"ingredientName": line.ingredient_name,
"rowIndex": line.row_index,
"dailyKg": line.daily_kg,
"inRation": line.in_ration,
"inCompound": line.in_compound,
"dryMatter": line.dry_matter,
"pricePerKg": line.price_per_kg,
}
for line in snapshot.lines
],
}
def diff_to_api(diff: dict[str, Any]) -> dict[str, Any]:
return {
"hasChanges": diff.get("has_changes", False),
"lines": [
{
"componentId": row.get("component_id"),
"masterKg": row.get("master_kg"),
"executionKg": row.get("execution_kg"),
"reasons": row.get("reasons", []),
}
for row in diff.get("lines", [])
],
}
@@ -0,0 +1,249 @@
from __future__ import annotations
import uuid
from typing import Any
from app import db
from app.lab.calc.feed_groups import classify_feed_group
from app.lab.calc.gfe_policies import DeriveContext
from app.lab.calc.ingredient_catalog import DERIVED_HEADERS
from app.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.lab.models import LabComponentNutrientValue
from app.lab.nutrient_keys import augment_with_indicator_keys, canonicalize_for_storage
from app.lab.nutrient_schema import (
dry_matter_g_per_kg,
mapping_to_calc_dict,
read_from_mapping,
)
from app.models.component import Component
_DERIVE_INPUT_KEYS = ("СВ", "Сыр. Протеин", "Сырая клетч", "Сырой жир")
_EAV_SKIP_KEYS = frozenset({"СВ"})
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
def derive_context_for_component(
component_id: str | None,
merged: dict[str, Any] | None = None,
) -> DeriveContext:
"""Контекст derive: тип корма + признак основного корма."""
main_feed = read_from_mapping(merged or {}, _MAIN_FEED_KEYS)
is_main = main_feed is not None and float(main_feed) > 0
feed_group: str = "unknown"
if component_id:
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is not None:
feed_group = classify_feed_group(comp)
return DeriveContext(feed_group=feed_group, is_main_feed=is_main) # type: ignore[arg-type]
def _component_dry_matter_pct(component_id: str | None) -> float | None:
if not component_id:
return None
comp = Component.query.get(component_id)
return comp.dry_matter if comp else None
def _inject_sv_for_derive(merged: dict[str, Any], dry_matter_pct: float | None) -> dict[str, Any]:
out = dict(merged)
sv = dry_matter_g_per_kg(dry_matter_pct)
if sv is not None:
out["СВ"] = sv
return out
def _should_derive(merged: dict[str, Any]) -> bool:
"""Derive при полном базовом вводе (СВ из component.dry_matter + 3 показателя)."""
return all(read_from_mapping(merged, (k,)) is not None for k in _DERIVE_INPUT_KEYS)
def nutrients_full_dict(component_id: str | None) -> dict[str, float]:
if not component_id:
return {}
rows = LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
return {
row.nutrient_key: float(row.value)
for row in rows
if row.value is not None and row.nutrient_key not in _EAV_SKIP_KEYS
}
def nutrients_api_dict(component_id: str | None) -> dict[str, float]:
return nutrients_full_dict(component_id)
def _fill_missing_derived(
merged: dict[str, float],
*,
component_id: str | None,
) -> dict[str, float]:
"""Добавляет derived-поля (пОВ, переваримые фракции), не перезаписывая введённые лаб. значения."""
if not _should_derive(merged):
return merged
ctx = derive_context_for_component(component_id, merged)
derived = derive_ingredient_nutrients(merged, context=ctx)
out = dict(merged)
for header in DERIVED_HEADERS:
if read_from_mapping(out, (header,)) is not None:
continue
val = read_from_mapping(derived, (header,))
if val is not None:
out[header] = float(val)
return out
def _repair_for_ration_calc(
full: dict[str, float],
dry_matter_pct: float | None,
component_id: str | None = None,
) -> dict[str, float]:
merged = _inject_sv_for_derive(full, dry_matter_pct)
oe = read_from_mapping(merged, ("ОЭ-КРС", " ОЭ-КРС"))
nel = read_from_mapping(merged, ("ЧЭЛ- КРС", " ЧЭЛ- КРС"))
energy_bad = (oe is not None and oe < 0) or (nel is not None and nel < 0)
if energy_bad:
minimal = {key: read_from_mapping(merged, (key,)) for key in _DERIVE_INPUT_KEYS}
if all(v is not None for v in minimal.values()):
ctx = derive_context_for_component(component_id, merged)
merged.update(derive_ingredient_nutrients(minimal, context=ctx))
else:
merged = _fill_missing_derived(merged, component_id=component_id)
return merged
def _calc_dict_from_full(
full: dict[str, float],
dry_matter_pct: float | None,
component_id: str | None,
) -> dict[str, float]:
repaired = _repair_for_ration_calc(full, dry_matter_pct, component_id)
return mapping_to_calc_dict(augment_with_indicator_keys(repaired))
def nutrients_calc_dict(component_id: str | None) -> dict[str, float]:
full = nutrients_full_dict(component_id)
dry_matter_pct = _component_dry_matter_pct(component_id)
return _calc_dict_from_full(full, dry_matter_pct, component_id)
def nutrients_calc_dict_batch(component_ids: list[str]) -> dict[str, dict[str, float]]:
"""Batch-load EAV + dry_matter for formulate (one SQL round-trip per table)."""
unique = list(dict.fromkeys(cid for cid in component_ids if cid))
if not unique:
return {}
eav_by_id: dict[str, dict[str, float]] = {cid: {} for cid in unique}
rows = LabComponentNutrientValue.query.filter(
LabComponentNutrientValue.component_id.in_(unique),
).all()
for row in rows:
if row.value is None or row.nutrient_key in _EAV_SKIP_KEYS:
continue
eav_by_id.setdefault(row.component_id, {})[row.nutrient_key] = float(row.value)
dm_by_id: dict[str, float | None] = {cid: None for cid in unique}
for comp in Component.query.filter(
Component.id.in_(unique),
Component.is_deleted.is_(False),
).all():
dm_by_id[comp.id] = comp.dry_matter
return {
cid: _calc_dict_from_full(eav_by_id.get(cid, {}), dm_by_id.get(cid), cid)
for cid in unique
}
def nutrients_is_empty(component_id: str | None) -> bool:
if not component_id:
return True
return (
LabComponentNutrientValue.query.filter(
LabComponentNutrientValue.component_id == component_id,
LabComponentNutrientValue.nutrient_key.notin_(_EAV_SKIP_KEYS),
).first()
is None
)
def _upsert_eav(component_id: str, nutrients: dict[str, float]) -> None:
existing = {
row.nutrient_key: row
for row in LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
}
for key in list(existing):
if key in _EAV_SKIP_KEYS:
db.session.delete(existing[key])
existing = {k: v for k, v in existing.items() if k not in _EAV_SKIP_KEYS}
for key, value in nutrients.items():
if value is None or key in _EAV_SKIP_KEYS:
continue
row = existing.get(key)
if row is None:
row = LabComponentNutrientValue(
id=str(uuid.uuid4()),
component_id=component_id,
nutrient_key=key,
value=float(value),
)
db.session.add(row)
existing[key] = row
else:
row.value = float(value)
def _prepare_payload(
component_id: str,
nutrients: dict[str, Any] | None,
*,
pin: bool = False,
) -> dict[str, float]:
dry_matter_pct = _component_dry_matter_pct(component_id)
merged = _inject_sv_for_derive(
{**nutrients_full_dict(component_id), **(nutrients or {})},
dry_matter_pct,
)
if pin:
payload = merged
elif _should_derive(merged):
ctx = derive_context_for_component(component_id, merged)
derived = derive_ingredient_nutrients(merged, context=ctx)
payload = {**merged, **derived}
else:
payload = merged
stored = canonicalize_for_storage(payload)
return {k: v for k, v in stored.items() if k not in _EAV_SKIP_KEYS}
def save_component_nutrients(
component_id: str,
nutrients: dict[str, Any] | None,
*,
user_id: str = "system",
pin: bool = False,
) -> dict[str, Any]:
"""Единственная точка записи EAV + derive. Возвращает stored dict и affectedRecipeIds."""
stored = _prepare_payload(component_id, nutrients, pin=pin)
_upsert_eav(component_id, stored)
from app.lab.services.ration_recalc import find_rations_by_component
affected = find_rations_by_component(component_id)
return {"stored": stored, "affectedRecipeIds": affected, "userId": user_id}
# Backward-compatible aliases for tests and gradual migration
def upsert_from_api_dict(component_id: str, nutrients: dict[str, Any] | None) -> dict[str, float]:
result = save_component_nutrients(component_id, nutrients)
return result["stored"]
def pin_nutrient_values(component_id: str, values: dict[str, float]) -> None:
save_component_nutrients(component_id, values, pin=True)
def upsert_from_column_values(component_id: str, values: dict[str, float | None]) -> dict[str, float]:
from app.lab.nutrient_schema import column_values_to_mapping
merged = nutrients_full_dict(component_id)
merged.update(column_values_to_mapping(values))
return upsert_from_api_dict(component_id, merged)
+52
View File
@@ -0,0 +1,52 @@
"""Сериализация параметров методики норм на профиле."""
from __future__ import annotations
import json
from typing import Any
from app.lab.calc.norms_resolver import NormsParams
from app.lab.models import LabAnimalProfile
def load_norms_params(profile: LabAnimalProfile) -> NormsParams:
raw = profile.norms_params_json
if not raw:
return NormsParams()
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return NormsParams()
return NormsParams.from_dict(data if isinstance(data, dict) else {})
def save_norms_params(profile: LabAnimalProfile, params: NormsParams | dict[str, Any] | None) -> None:
if params is None:
profile.norms_params_json = None
return
if isinstance(params, NormsParams):
payload = {
k: v
for k, v in (
("milkFatPct", params.milk_fat_pct),
("lactationNo", params.lactation_no),
("lactationStage", params.lactation_stage),
("bodyCondition", params.body_condition),
("housingSystem", params.housing_system),
("koncOeSv", params.konc_oe_sv),
)
if v is not None
}
else:
payload = dict(params)
profile.norms_params_json = json.dumps(payload, ensure_ascii=False) if payload else None
def norms_params_api(profile: LabAnimalProfile) -> dict[str, Any]:
if not profile.norms_params_json:
return {}
try:
data = json.loads(profile.norms_params_json)
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
import json
from typing import Any
from app import db
from app.lab.calc.norms_resolver import NormsResolveRequest, normalize_norms_method, resolve_norms
from app.lab.services.norms_params import load_norms_params
from app.lab.calc.norms import merge_norms_from_profile
from app.lab.calc.nutrients import parse_num
from app.lab.models import LabAnimalProfile, LabProfileNorm
from app.models.base import default_uuid
def _parse_profile_payload(data: Any) -> dict[str, Any]:
if data is None:
return {}
if isinstance(data, str):
try:
data = json.loads(data or "{}")
except (json.JSONDecodeError, TypeError):
return {}
return data if isinstance(data, dict) else {}
def load_norms_dict(profile_id: str | None) -> dict[str, dict[str, float | None]]:
if not profile_id:
return {}
rows = (
LabProfileNorm.query.filter_by(profile_id=profile_id)
.order_by(LabProfileNorm.indicator_key)
.all()
)
return {
row.indicator_key: {
"min": parse_num(row.min_value),
"max": parse_num(row.max_value),
}
for row in rows
}
def resolve_norms_for_profile(
profile: LabAnimalProfile,
*,
method: str | None = None,
params_override: dict | None = None,
force_dynamic: bool = False,
) -> dict[str, dict[str, float | None]]:
"""Нормы для расчёта рациона по выбранной методике."""
stored = load_norms_dict(profile.id)
norms_method = normalize_norms_method(method or profile.norms_method)
params = load_norms_params(profile)
if params_override:
from app.lab.calc.norms_resolver import NormsParams
base = {
"milkFatPct": params.milk_fat_pct,
"lactationNo": params.lactation_no,
"lactationStage": params.lactation_stage,
"bodyCondition": params.body_condition,
"housingSystem": params.housing_system,
"koncOeSv": params.konc_oe_sv,
}
base.update(params_override)
params = NormsParams.from_dict(base)
resolved, _ = resolve_norms(
NormsResolveRequest(
method=norms_method,
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
force_dynamic=force_dynamic,
params=params,
)
)
return resolved
def load_norms_api(profile: LabAnimalProfile, *, resolve_dynamic: bool = True) -> dict[str, Any]:
stored = load_norms_dict(profile.id)
payload: dict[str, Any] = {}
if profile.mass_kg is not None:
payload["massKg"] = profile.mass_kg
if profile.milk_yield_kg is not None:
payload["milkYieldKg"] = profile.milk_yield_kg
if profile.external_no is not None:
payload["externalNo"] = profile.external_no
if stored:
payload["indicators"] = stored
if resolve_dynamic:
resolved, meta = resolve_norms(
NormsResolveRequest(
method=normalize_norms_method(profile.norms_method),
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
params=load_norms_params(profile),
)
)
payload["resolvedIndicators"] = resolved
payload["normsMethod"] = meta.get("normsMethod", profile.norms_method or "wesp")
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
if dynamic:
payload["dynamicNorms"] = dynamic
if meta.get("meta"):
payload["normsMeta"] = meta["meta"]
if meta.get("coverage"):
payload["coverage"] = meta["coverage"]
return payload
def clear_profile_norms(profile_id: str) -> None:
LabProfileNorm.query.filter_by(profile_id=profile_id).delete(synchronize_session=False)
def save_norms_from_payload(profile: LabAnimalProfile, data: Any) -> None:
payload = _parse_profile_payload(data)
profile.mass_kg = parse_num(payload.get("massKg", payload.get("mass_kg")))
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
ext = payload.get("externalNo", payload.get("external_no"))
profile.external_no = int(ext) if ext is not None and str(ext).strip() != "" else None
merged = merge_norms_from_profile(payload, profile.ration_type)
skip_keys = {"massKg", "mass_kg", "externalNo", "external_no", "indicators"}
for key, bounds in payload.items():
if key in skip_keys or not isinstance(bounds, dict):
continue
if "min" in bounds or "max" in bounds:
merged[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
indicators = payload.get("indicators")
if isinstance(indicators, dict):
for key, bounds in indicators.items():
if not isinstance(bounds, dict):
continue
merged[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
clear_profile_norms(profile.id)
for indicator_key, bounds in merged.items():
min_v = bounds.get("min")
max_v = bounds.get("max")
if min_v is None and max_v is None:
continue
db.session.add(
LabProfileNorm(
id=default_uuid(),
profile_id=profile.id,
indicator_key=indicator_key,
min_value=min_v,
max_value=max_v,
)
)
def import_norms_from_legacy_text(profile: LabAnimalProfile, raw: str | None) -> None:
save_norms_from_payload(profile, raw)
def _can_sync_racion(profile: LabAnimalProfile) -> bool:
method = normalize_norms_method(profile.norms_method)
if method not in ("racion_moscow", "racion_piter"):
return False
if not profile.mass_kg or profile.mass_kg <= 0:
return False
if not profile.milk_yield_kg or profile.milk_yield_kg <= 0:
return False
if method == "racion_piter":
from app.lab.services.norms_params import load_norms_params
params = load_norms_params(profile)
if params.konc_oe_sv is None or params.konc_oe_sv <= 0:
return False
return True
def sync_racion_norms_to_profile(profile: LabAnimalProfile) -> int:
"""Вычислить RACION-нормы и upsert min в lab_profile_norm (max сохраняется)."""
if not _can_sync_racion(profile):
return 0
stored = load_norms_dict(profile.id)
resolved, _ = resolve_norms(
NormsResolveRequest(
method=normalize_norms_method(profile.norms_method),
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
params=load_norms_params(profile),
)
)
existing = {
row.indicator_key: row
for row in LabProfileNorm.query.filter_by(profile_id=profile.id).all()
}
updated = 0
for key, bounds in resolved.items():
min_v = bounds.get("min")
if min_v is None:
continue
row = existing.get(key)
if row is None:
db.session.add(
LabProfileNorm(
id=default_uuid(),
profile_id=profile.id,
indicator_key=key,
min_value=min_v,
max_value=bounds.get("max"),
)
)
updated += 1
elif row.min_value != min_v:
row.min_value = min_v
if bounds.get("max") is not None:
row.max_value = bounds.get("max")
updated += 1
return updated
@@ -0,0 +1,155 @@
"""Импорт и загрузка справочников RACION из БД."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app import db
from app.lab.calc.racion.tables import clear_tables_cache
from app.lab.models.racion_normy import (
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
)
from app.models.base import default_uuid
_SEED_DIR = Path(__file__).resolve().parents[3] / "data" / "seed" / "racion"
@dataclass
class RacionReferenceImportStats:
moskwa_rows: int = 0
piter_rows: int = 0
info_rows: int = 0
errors: list[str] = field(default_factory=list)
def _read_seed(name: str) -> dict:
path = _SEED_DIR / name
return json.loads(path.read_text(encoding="utf-8"))
def import_racion_reference(*, replace: bool = True) -> RacionReferenceImportStats:
stats = RacionReferenceImportStats()
try:
moskwa = _read_seed("moskwa_lactir.json")
piter = _read_seed("piter_lactir.json")
info = _read_seed("normy_info.json")
except (OSError, json.JSONDecodeError) as exc:
stats.errors.append(str(exc))
return stats
if replace:
LabRacionNormyMoskwa.query.delete(synchronize_session=False)
LabRacionNormyMoskwaMeta.query.delete(synchronize_session=False)
LabRacionNormyPiter.query.delete(synchronize_session=False)
LabRacionNormyPiterMeta.query.delete(synchronize_session=False)
LabRacionNormyInfo.query.delete(synchronize_session=False)
for row in moskwa.get("rows") or []:
db.session.add(
LabRacionNormyMoskwa(
id=default_uuid(),
npitv=int(row["npitv"]),
pom=int(row.get("pom") or 1),
koef=row.get("koef"),
popr_k_json=json.dumps(row.get("popr_k") or [], ensure_ascii=False),
)
)
stats.moskwa_rows += 1
db.session.add(
LabRacionNormyMoskwaMeta(
id=default_uuid(),
meta_key="udoy_boundaries",
meta_json=json.dumps(moskwa.get("udoy_boundaries") or [], ensure_ascii=False),
)
)
for entry in piter.get("entries") or []:
db.session.add(
LabRacionNormyPiter(
id=default_uuid(),
npitv=int(entry["npitv"]),
konc=float(entry["konc"]),
udoy=float(entry["udoy"]),
normy_json=json.dumps(entry.get("normy") or [], ensure_ascii=False),
)
)
stats.piter_rows += 1
db.session.add(
LabRacionNormyPiterMeta(
id=default_uuid(),
meta_key="mass_kg_values",
meta_json=json.dumps(piter.get("mass_kg_values") or [], ensure_ascii=False),
)
)
for row in info.get("rows") or []:
db.session.add(
LabRacionNormyInfo(
id=default_uuid(),
nperem=int(row["nperem"]),
znachenie_json=json.dumps(row.get("znachenie") or [], ensure_ascii=False),
)
)
stats.info_rows += 1
db.session.commit()
clear_tables_cache()
return stats
def load_moskwa_lactir_from_db() -> dict | None:
if LabRacionNormyMoskwa.query.count() == 0:
return None
rows = []
for r in LabRacionNormyMoskwa.query.order_by(LabRacionNormyMoskwa.npitv, LabRacionNormyMoskwa.pom).all():
rows.append(
{
"npitv": r.npitv,
"pom": r.pom,
"koef": r.koef,
"popr_k": json.loads(r.popr_k_json or "[]"),
}
)
meta = LabRacionNormyMoskwaMeta.query.filter_by(meta_key="udoy_boundaries").first()
boundaries = json.loads(meta.meta_json or "[]") if meta else []
return {"udoy_boundaries": boundaries, "rows": rows}
def load_piter_lactir_from_db() -> dict | None:
if LabRacionNormyPiter.query.count() == 0:
return None
entries = []
for r in LabRacionNormyPiter.query.order_by(
LabRacionNormyPiter.npitv, LabRacionNormyPiter.konc, LabRacionNormyPiter.udoy
).all():
entries.append(
{
"npitv": r.npitv,
"konc": r.konc,
"udoy": r.udoy,
"normy": json.loads(r.normy_json or "[]"),
}
)
meta = LabRacionNormyPiterMeta.query.filter_by(meta_key="mass_kg_values").first()
masses = json.loads(meta.meta_json or "[]") if meta else [400, 450, 500, 550, 600, 650, 700, 750]
return {"mass_kg_values": masses, "entries": entries}
def load_normy_info_from_db() -> dict | None:
if LabRacionNormyInfo.query.count() == 0:
return None
rows = []
for r in LabRacionNormyInfo.query.order_by(LabRacionNormyInfo.nperem).all():
rows.append({"nperem": r.nperem, "znachenie": json.loads(r.znachenie_json or "[]")})
by_nperem = {row["nperem"]: row["znachenie"] for row in rows}
mass_kg_values = by_nperem.get(14) or by_nperem.get(13) or [400, 450, 500, 550, 600, 650, 700, 750]
return {"rows": rows, "mass_kg_values": mass_kg_values}
@@ -0,0 +1,204 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from app import db
from app.lab.calc.nutrients import parse_num
from app.lab.indicators import label_to_indicator_key
from app.lab.models import (
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRecipeRation,
)
from app.models.base import default_uuid
_log = logging.getLogger("app.lab.calc")
def _indicator_key(row: dict[str, Any]) -> str | None:
key = row.get("key")
if key:
return str(key)
label = row.get("label")
if label:
return label_to_indicator_key(str(label))
return None
def _log_calc_errors(recipe_id: str, errors: list[str] | None) -> None:
for message in errors or []:
text = str(message or "").strip()
if text:
_log.warning("ration calc recipe=%s: %s", recipe_id, text)
def clear_calc(recipe_id: str) -> None:
for model in (
LabRationCalcTotal,
LabRationCalcIndicator,
LabRationCompoundLine,
):
model.query.filter_by(recipe_id=recipe_id).delete(synchronize_session=False)
def _save_totals(recipe_id: str, scope: str, totals: list[dict[str, Any]] | None) -> None:
for idx, row in enumerate(totals or []):
db.session.add(
LabRationCalcTotal(
id=default_uuid(),
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,
)
)
def _save_indicators(
recipe_id: str,
scope: str,
indicators: list[dict[str, Any]] | None,
) -> None:
for idx, row in enumerate(indicators or []):
db.session.add(
LabRationCalcIndicator(
id=default_uuid(),
recipe_id=recipe_id,
scope=scope,
indicator_key=_indicator_key(row),
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,
)
)
def _save_compound_lines(recipe_id: str, lines: list[dict[str, Any]] | None) -> None:
for idx, row in enumerate(lines or []):
db.session.add(
LabRationCompoundLine(
id=default_uuid(),
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")),
)
)
def save_calc_result(recipe_id: str, result: dict[str, Any], header: LabRecipeRation) -> None:
clear_calc(recipe_id)
_log_calc_errors(recipe_id, result.get("errors"))
header.calc_engine = str(result.get("engine") or "native")
calculated_at = result.get("calculated_at")
if calculated_at:
try:
header.calculated_at = datetime.fromisoformat(str(calculated_at).replace("Z", "+00:00"))
except ValueError:
header.calculated_at = datetime.utcnow()
else:
header.calculated_at = datetime.utcnow()
_save_totals(recipe_id, "ration", result.get("totals"))
_save_indicators(recipe_id, "ration", result.get("indicators"))
compound = result.get("compound")
if compound:
_save_totals(recipe_id, "compound", compound.get("totals"))
_save_indicators(recipe_id, "compound", compound.get("indicators"))
_save_compound_lines(recipe_id, compound.get("lines"))
def _load_totals(recipe_id: str, scope: str) -> list[dict[str, Any]]:
rows = (
LabRationCalcTotal.query.filter_by(recipe_id=recipe_id, scope=scope)
.order_by(LabRationCalcTotal.sort_order)
.all()
)
return [
{"key": row.metric_key, "label": row.label, "value": row.value}
for row in rows
]
def _load_indicators(recipe_id: str, scope: str) -> list[dict[str, Any]]:
rows = (
LabRationCalcIndicator.query.filter_by(recipe_id=recipe_id, scope=scope)
.order_by(LabRationCalcIndicator.sort_order)
.all()
)
return [
{
"key": row.indicator_key,
"label": row.label,
"unit": row.unit,
"min": row.min_value,
"max": row.max_value,
"content": row.content,
"diff": row.diff,
}
for row in rows
]
def load_compound_results(recipe_id: str) -> dict[str, Any]:
totals = _load_totals(recipe_id, "compound")
indicators = _load_indicators(recipe_id, "compound")
lines = (
LabRationCompoundLine.query.filter_by(recipe_id=recipe_id)
.order_by(LabRationCompoundLine.row_index)
.all()
)
if not totals and not indicators and not lines:
return {}
return {
"totals": totals,
"indicators": indicators,
"lines": [
{
"ingredient_name": row.ingredient_name,
"daily_kg": row.daily_kg,
"share_pct": row.share_pct,
}
for row in lines
],
}
def load_ration_results(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]:
if header is None or header.calculated_at is None:
return {}
totals = _load_totals(recipe_id, "ration")
indicators = _load_indicators(recipe_id, "ration")
if not totals and not indicators:
return {}
compound = load_compound_results(recipe_id)
payload: dict[str, Any] = {
"calculated_at": header.calculated_at.isoformat(),
"engine": header.calc_engine or "native",
"totals": totals,
"indicators": indicators,
}
if compound:
payload["compound"] = compound
return payload
def load_params(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]:
if header is None or not header.seed_source:
return {}
if header.seed_source == "execution":
return {"seeded_from": "execution"}
if header.seed_source == "synced_from":
return {"synced_from": "execution"}
return {"source": header.seed_source}
@@ -0,0 +1,40 @@
from __future__ import annotations
from app.lab.models import LabRationLine
def find_rations_by_component(component_id: str) -> list[str]:
"""Recipe IDs с не удалёнными строками рациона, использующими component."""
if not component_id:
return []
rows = (
LabRationLine.query.filter_by(component_id=component_id, is_deleted=False)
.with_entities(LabRationLine.recipe_id)
.distinct()
.all()
)
return sorted({str(r[0]) for r in rows if r[0]})
def recalculate_rations(recipe_ids: list[str], user_id: str = "system") -> dict:
from app.lab.commands.recalculate import recalculate_ration
ok: list[str] = []
failed: dict[str, str] = {}
results: dict[str, dict] = {}
for recipe_id in recipe_ids:
try:
results[recipe_id] = recalculate_ration(recipe_id, user_id)
ok.append(recipe_id)
except Exception as exc:
failed[recipe_id] = str(exc)
return {"ok": ok, "failed": failed, "results": results}
def on_component_nutrients_changed(component_id: str, user_id: str = "system") -> dict:
"""Найти и пересчитать все рационы с данным компонентом (для будущего UI)."""
recipe_ids = find_rations_by_component(component_id)
if not recipe_ids:
return {"recipeIds": [], "ok": [], "failed": {}}
report = recalculate_rations(recipe_ids, user_id)
return {"recipeIds": recipe_ids, **report}
@@ -0,0 +1,83 @@
"""Справочник zootech-норм из БД (lab_animal_profile norm_* + lab_profile_norm)."""
from __future__ import annotations
from app.lab.commands.import_seed import norm_profile_key
from app.lab.models import LabAnimalProfile
from app.lab.reference_profiles import is_reference_profile
from app.lab.services.profile_norms import load_norms_dict
_VALID_RATIONS = frozenset({"DAIRY", "BEEF"})
class ReferenceNormsCatalogEmptyError(LookupError):
"""Справочник norm_* в БД пуст — нужен import_seed.py --norms."""
def _normalize_ration(ration_type: str) -> str:
ration = (ration_type or "").strip().upper()
if ration not in _VALID_RATIONS:
raise ValueError(f"Неизвестная линейка: {ration_type}")
return ration
def _reference_profiles_query(ration_type: str):
ration = _normalize_ration(ration_type)
return (
LabAnimalProfile.query.filter(
LabAnimalProfile.is_deleted.is_(False),
LabAnimalProfile.ration_type == ration,
LabAnimalProfile.profile_key.like("norm_%"),
)
.order_by(LabAnimalProfile.external_no, LabAnimalProfile.profile_key)
)
def list_seed_norm_catalog(ration_type: str) -> list[dict]:
"""Краткий список строк справочника для выпадающего списка."""
rows = _reference_profiles_query(ration_type).all()
if not rows:
raise ReferenceNormsCatalogEmptyError(
"Справочник пуст — выполните: python3 scripts/import_seed.py --norms"
)
out: list[dict] = []
for profile in rows:
if profile.external_no is None:
continue
indicators = load_norms_dict(profile.id)
out.append(
{
"profileId": profile.id,
"profileKey": profile.profile_key,
"externalNo": profile.external_no,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"indicatorCount": len(indicators),
}
)
out.sort(key=lambda row: row["externalNo"])
return out
def get_seed_norm_entry(ration_type: str, external_no: int) -> dict | None:
"""Полная строка справочника: метаданные + indicators min/max."""
ration = _normalize_ration(ration_type)
profile = _reference_profiles_query(ration).filter_by(external_no=external_no).first()
if profile is None:
profile = LabAnimalProfile.query.filter_by(
profile_key=norm_profile_key(ration, external_no),
is_deleted=False,
).first()
if profile is None or not is_reference_profile(profile.profile_key):
return None
indicators = load_norms_dict(profile.id)
return {
"profileId": profile.id,
"externalNo": profile.external_no if profile.external_no is not None else external_no,
"profileKey": profile.profile_key,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"indicators": indicators,
}
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; margin: 24px; color: #111; }
h1 { font-size: 1.25rem; margin: 0 0 8px; }
.meta { color: #555; margin-bottom: 16px; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; }
.num { text-align: right; }
.indicators { display: flex; flex-wrap: wrap; gap: 8px; }
.badge { border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px; font-size: 0.85rem; }
@media print { body { margin: 12px; } }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<div class="meta">
Рецепт: {{ recipe_name }} · Тип: {{ ration_type }} · Голов/рейс: {{ heads }}
</div>
<table>
<thead>
<tr>
<th>Ингредиент</th>
<th class="num">кг/день</th>
{% if mode == "compound" %}<th>В комб.</th>{% else %}<th>В рац.</th>{% endif %}
</tr>
</thead>
<tbody>
{% for line in lines %}
<tr>
<td>{{ line.name }}</td>
<td class="num">{{ line.daily_kg }}</td>
<td>{{ "да" if line.included else "нет" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if indicators %}
<div class="indicators">
{% for ind in indicators %}
<span class="badge">{{ ind.label }}: {{ ind.content }} {{ ind.unit }}</span>
{% endfor %}
</div>
{% endif %}
<script>window.addEventListener("load", () => window.print());</script>
</body>
</html>
+289
View File
@@ -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"))
+37
View File
@@ -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())
+72
View File
@@ -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")

Some files were not shown because too many files have changed in this diff Show More