@@ -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"}
|
||||||
@@ -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}
|
||||||
@@ -22,7 +22,10 @@ jobs:
|
|||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm install --frozen-lockfile=false
|
run: pnpm install --frozen-lockfile=false
|
||||||
- name: Install backend deps
|
- 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
|
- name: Sync WESP UI parity
|
||||||
run: bash scripts/materialize-wesp-static.sh
|
run: bash scripts/materialize-wesp-static.sh
|
||||||
- name: Lint
|
- name: Lint
|
||||||
|
|||||||
@@ -20,4 +20,3 @@ apps/api/data/secrets/install.meta.json
|
|||||||
data/secrets/
|
data/secrets/
|
||||||
apps/api/data/logs/
|
apps/api/data/logs/
|
||||||
apps/api/data/compton_settings.json
|
apps/api/data/compton_settings.json
|
||||||
apps/web/public/static/
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Конец важной части. Дальше могло бы что-то ещё быть, но в рамках заявленного баланса «минимум смысла — максимум слов» мы на этом, пожалуй, остановимся.*
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Zootech lab module (Neoton tab integration)."""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .engine import calculate_ration
|
||||||
|
from .diff import compare_master_execution
|
||||||
|
|
||||||
|
__all__ = ["calculate_ration", "compare_master_execution"]
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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%CЖ",
|
||||||
|
"уСП>7%CЖ",
|
||||||
|
"уСП/кг СВ формул",
|
||||||
|
"уСП в ОР",
|
||||||
|
"уСП формул",
|
||||||
|
"БРА",
|
||||||
|
"Нераств крохмал",
|
||||||
|
"доля крохмала",
|
||||||
|
"Доля белка",
|
||||||
|
"% перев в кишках",
|
||||||
|
"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
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Российские методики суточных норм (Москва / Петербург)."""
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""NPitV (справочник питательных веществ) → ключи показателей WESP."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# NORMY_MOSKWA_LACTIR / NORM_1_1_CALC: NPitV 1–24 (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)
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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)}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .ration import ExecutionSnapshot, RationSnapshot
|
||||||
|
|
||||||
|
__all__ = ["RationSnapshot", "ExecutionSnapshot"]
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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%CЖ"], aggregation="weighted_avg"),
|
||||||
|
_ind("usp_high_fat", "уСП >7% СЖ", "г", ["уСП>7%CЖ"], 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)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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%CЖ": "usp_low_fat",
|
||||||
|
"уСП>7%CЖ": "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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .api import ration_to_api
|
||||||
|
|
||||||
|
__all__ = ["ration_to_api"]
|
||||||
@@ -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)
|
||||||
@@ -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 {}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Импортируем модели, чтобы метаданные SQLAlchemy были доступны при инициализации.
|
||||||
|
from .component import Component, Ingredient # noqa: F401
|
||||||
|
from .recipe import Recipe, UnloadingGroup, PeriodRecipe # noqa: F401
|
||||||
|
from .report import ( # noqa: F401
|
||||||
|
LoadingReport,
|
||||||
|
LoadingReportComponent,
|
||||||
|
ComponentLoadingTime,
|
||||||
|
UnloadingReport,
|
||||||
|
UnloadingReportGroup,
|
||||||
|
)
|
||||||
|
from .feed_alert import FeedAlert # noqa: F401
|
||||||
|
from .feed_quality_settings import FeedQualitySettings # noqa: F401
|
||||||
|
from .org_settings import OrgSettings # noqa: F401
|
||||||
|
from .daily_component_norm_adjustment import DailyComponentNormAdjustment # noqa: F401
|
||||||
|
from .daily_trip_skip import DailyTripSkip # noqa: F401
|
||||||
|
from .daily_ingredient_skip import DailyIngredientSkip # noqa: F401
|
||||||
|
from .daily_ingredient_replacement import DailyIngredientReplacement # noqa: F401
|
||||||
|
from .daily_unloading_group_skip import DailyUnloadingGroupSkip # noqa: F401
|
||||||
|
from .equipment import ( # noqa: F401
|
||||||
|
FeedMixer,
|
||||||
|
FeedDispenser,
|
||||||
|
FeedingLocation,
|
||||||
|
FeedingPeriod,
|
||||||
|
FeedingPoint,
|
||||||
|
Trip,
|
||||||
|
)
|
||||||
|
from .sklad import ComponentStock # noqa: F401
|
||||||
|
from .hardware_setting import HardwareSetting # noqa: F401
|
||||||
|
from .kiosk import KioskDevice, KioskPairToken # noqa: F401
|
||||||
|
from .user import WebUser # noqa: F401
|
||||||
|
from .auto_update_settings import AutoUpdateSettings # noqa: F401
|
||||||
|
from .zootech_notification import ZootechNotification # noqa: F401
|
||||||
|
from .sync import ( # noqa: F401
|
||||||
|
SyncMetadata,
|
||||||
|
SyncClient,
|
||||||
|
SyncClientDisplayName,
|
||||||
|
SyncEngineState,
|
||||||
|
SyncQueue,
|
||||||
|
SyncDelivery,
|
||||||
|
SyncConflict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .base import TimestampMixin, SoftDeleteMixin # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def _cascade_on_parent_soft_delete(mapper, connection, target) -> None: # type: ignore[override]
|
||||||
|
if not getattr(target, "is_deleted", False):
|
||||||
|
return
|
||||||
|
table = getattr(target, "__tablename__", "")
|
||||||
|
if not table:
|
||||||
|
return
|
||||||
|
from sqlalchemy.orm import object_session
|
||||||
|
|
||||||
|
from app.services.sync_cascade import CASCADE_CHILDREN, cascade_soft_delete
|
||||||
|
|
||||||
|
if table not in CASCADE_CHILDREN:
|
||||||
|
return
|
||||||
|
sess = object_session(target)
|
||||||
|
suppress_enqueue = bool(
|
||||||
|
sess is not None and sess.info.get(WESP_SUPPRESS_SYNC_ENQUEUE)
|
||||||
|
)
|
||||||
|
cascade_soft_delete(
|
||||||
|
table,
|
||||||
|
str(getattr(target, "id", "")),
|
||||||
|
deleted_by=str(getattr(target, "deleted_by", None) or f"cascade:{table}"),
|
||||||
|
enqueue=not suppress_enqueue,
|
||||||
|
defer_enqueue=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
for _parent_table in (
|
||||||
|
"recipe",
|
||||||
|
"component",
|
||||||
|
"feed_dispenser",
|
||||||
|
"feeding_period",
|
||||||
|
"feed_mixer",
|
||||||
|
"loading_report",
|
||||||
|
"unloading_report",
|
||||||
|
):
|
||||||
|
_parent_model = {
|
||||||
|
"recipe": Recipe,
|
||||||
|
"component": Component,
|
||||||
|
"feed_dispenser": FeedDispenser,
|
||||||
|
"feeding_period": FeedingPeriod,
|
||||||
|
"feed_mixer": FeedMixer,
|
||||||
|
"loading_report": LoadingReport,
|
||||||
|
"unloading_report": UnloadingReport,
|
||||||
|
}[_parent_table]
|
||||||
|
event.listen(_parent_model, "after_update", _cascade_on_parent_soft_delete)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_record_id(target) -> str:
|
||||||
|
table = getattr(target, "__tablename__", "")
|
||||||
|
if table == "period_recipes":
|
||||||
|
return f"{target.period_id}:{target.recipe_id}"
|
||||||
|
return target.id
|
||||||
|
|
||||||
|
|
||||||
|
_SYNC_ENQUEUE_INFO_KEY = "_wesp_sync_enqueue_pending"
|
||||||
|
# session.info: применение pull на клиенте (sync_client.apply_batch) — не ставить задачи в sync_queue.
|
||||||
|
WESP_SUPPRESS_SYNC_ENQUEUE = "_wesp_suppress_sync_enqueue"
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_sync_enqueue_from_listener(target, action: str, priority: int) -> None:
|
||||||
|
"""Не вызывать enqueue_sync_queue_task из after_insert/after_update — это внутри flush."""
|
||||||
|
from sqlalchemy.orm import object_session
|
||||||
|
|
||||||
|
sess = object_session(target)
|
||||||
|
if sess is not None and sess.info.get(WESP_SUPPRESS_SYNC_ENQUEUE):
|
||||||
|
return
|
||||||
|
rid = _sync_record_id(target)
|
||||||
|
row = (target.__tablename__, str(rid), action, priority, None)
|
||||||
|
if sess is None:
|
||||||
|
from app.services.sync_manager import enqueue_sync_queue_task
|
||||||
|
|
||||||
|
tname, rec_id, act, pri, tid = row
|
||||||
|
enqueue_sync_queue_task(tname, rec_id, act, priority=pri, target_node_id=tid)
|
||||||
|
return
|
||||||
|
sess.info.setdefault(_SYNC_ENQUEUE_INFO_KEY, []).append(row)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Session, "after_flush")
|
||||||
|
def _flush_pending_sync_enqueues(session, flush_context) -> None: # type: ignore[override]
|
||||||
|
from app.services.sync_cascade import CASCADE_ENQUEUE_INFO_KEY
|
||||||
|
|
||||||
|
pending = session.info.pop(_SYNC_ENQUEUE_INFO_KEY, None)
|
||||||
|
cascade_pending = session.info.pop(CASCADE_ENQUEUE_INFO_KEY, None)
|
||||||
|
from app.services.sync_manager import enqueue_sync_queue_task
|
||||||
|
|
||||||
|
if cascade_pending:
|
||||||
|
merged_cascade: set[tuple[str, str]] = set()
|
||||||
|
for tname, rid in cascade_pending:
|
||||||
|
merged_cascade.add((tname, rid))
|
||||||
|
with session.no_autoflush:
|
||||||
|
for tname, rid in merged_cascade:
|
||||||
|
enqueue_sync_queue_task(tname, rid, "update", priority=1)
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
|
||||||
|
merged: dict[tuple[str, str, str, str | None], tuple] = {}
|
||||||
|
for table_name, record_id, action, priority, target_node_id in pending:
|
||||||
|
key = (table_name, record_id, action, target_node_id)
|
||||||
|
pri = int(priority)
|
||||||
|
prev = merged.get(key)
|
||||||
|
if prev is None or pri > prev[3]:
|
||||||
|
merged[key] = (table_name, record_id, action, pri, target_node_id)
|
||||||
|
|
||||||
|
with session.no_autoflush:
|
||||||
|
for tname, rec_id, act, pri, tid in merged.values():
|
||||||
|
enqueue_sync_queue_task(tname, rec_id, act, priority=pri, target_node_id=tid)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_task_after_insert(mapper, connection, target): # type: ignore[override]
|
||||||
|
"""Универсальная постановка в sync_queue после insert (паритет с legacy sync_models)."""
|
||||||
|
priority_map = {
|
||||||
|
"component": 2,
|
||||||
|
"recipe": 2,
|
||||||
|
"ingredient": 3,
|
||||||
|
"unloading_group": 4,
|
||||||
|
"loading_report": 4,
|
||||||
|
"loading_report_component": 4,
|
||||||
|
"component_loading_time": 4,
|
||||||
|
"unloading_report": 4,
|
||||||
|
"unloading_report_group": 4,
|
||||||
|
"feed_mixer": 3,
|
||||||
|
"feeding_location": 3,
|
||||||
|
"feeding_period": 3,
|
||||||
|
"feeding_point": 3,
|
||||||
|
"trip": 4,
|
||||||
|
"feed_dispenser": 3,
|
||||||
|
"period_recipes": 3,
|
||||||
|
"daily_trip_skip": 2,
|
||||||
|
"daily_ingredient_skip": 2,
|
||||||
|
"daily_unloading_group_skip": 2,
|
||||||
|
"daily_ingredient_replacement": 2,
|
||||||
|
"daily_component_norm_adjustment": 2,
|
||||||
|
}
|
||||||
|
priority = priority_map.get(target.__tablename__, 3)
|
||||||
|
_schedule_sync_enqueue_from_listener(target, "create", priority)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_task_after_update(mapper, connection, target): # type: ignore[override]
|
||||||
|
# soft_delete / каскад: update в очередь не ставим — delete или cascade_soft_delete уже поставили задачи
|
||||||
|
if getattr(target, "is_deleted", False):
|
||||||
|
return
|
||||||
|
priority_map = {
|
||||||
|
"component": 3,
|
||||||
|
"recipe": 3,
|
||||||
|
"ingredient": 4,
|
||||||
|
"unloading_group": 4,
|
||||||
|
"loading_report": 4,
|
||||||
|
"loading_report_component": 4,
|
||||||
|
"component_loading_time": 4,
|
||||||
|
"unloading_report": 4,
|
||||||
|
"unloading_report_group": 4,
|
||||||
|
"feed_mixer": 4,
|
||||||
|
"feeding_location": 4,
|
||||||
|
"feeding_period": 4,
|
||||||
|
"feeding_point": 4,
|
||||||
|
"trip": 4,
|
||||||
|
"feed_dispenser": 4,
|
||||||
|
"period_recipes": 4,
|
||||||
|
"daily_trip_skip": 3,
|
||||||
|
"daily_ingredient_skip": 3,
|
||||||
|
"daily_unloading_group_skip": 3,
|
||||||
|
"daily_ingredient_replacement": 3,
|
||||||
|
"daily_component_norm_adjustment": 3,
|
||||||
|
}
|
||||||
|
priority = priority_map.get(target.__tablename__, 4)
|
||||||
|
_schedule_sync_enqueue_from_listener(target, "update", priority)
|
||||||
|
|
||||||
|
|
||||||
|
_sync_models = (
|
||||||
|
Component,
|
||||||
|
Recipe,
|
||||||
|
Ingredient,
|
||||||
|
UnloadingGroup,
|
||||||
|
LoadingReport,
|
||||||
|
LoadingReportComponent,
|
||||||
|
ComponentLoadingTime,
|
||||||
|
UnloadingReport,
|
||||||
|
UnloadingReportGroup,
|
||||||
|
FeedMixer,
|
||||||
|
FeedingLocation,
|
||||||
|
FeedingPeriod,
|
||||||
|
FeedingPoint,
|
||||||
|
Trip,
|
||||||
|
FeedDispenser,
|
||||||
|
PeriodRecipe,
|
||||||
|
DailyTripSkip,
|
||||||
|
DailyIngredientSkip,
|
||||||
|
DailyUnloadingGroupSkip,
|
||||||
|
DailyIngredientReplacement,
|
||||||
|
DailyComponentNormAdjustment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _model_has_content_hash_column(cls) -> bool:
|
||||||
|
try:
|
||||||
|
return "content_hash" in cls.__table__.columns
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_content_hash_after_insert(mapper, connection, target) -> None: # type: ignore[override]
|
||||||
|
"""После INSERT строка уже содержит значения по умолчанию из БД — хеш совпадает с get_object_data."""
|
||||||
|
from sqlalchemy import and_, update
|
||||||
|
|
||||||
|
from app.services.sync_content_hash import compute_content_hash_for_object
|
||||||
|
|
||||||
|
if not hasattr(target, "content_hash"):
|
||||||
|
return
|
||||||
|
h = compute_content_hash_for_object(target)
|
||||||
|
if not h:
|
||||||
|
return
|
||||||
|
table = mapper.local_table
|
||||||
|
pk_cols = list(table.primary_key.columns)
|
||||||
|
stmt = update(table).values(content_hash=h)
|
||||||
|
if len(pk_cols) == 1:
|
||||||
|
pk = pk_cols[0]
|
||||||
|
stmt = stmt.where(pk == getattr(target, pk.key))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(and_(*(c == getattr(target, c.key) for c in pk_cols)))
|
||||||
|
connection.execute(stmt)
|
||||||
|
target.content_hash = h
|
||||||
|
|
||||||
|
|
||||||
|
for _sm in _sync_models:
|
||||||
|
if _model_has_content_hash_column(_sm):
|
||||||
|
event.listen(_sm, "after_insert", _sync_content_hash_after_insert)
|
||||||
|
|
||||||
|
for _sm in _sync_models:
|
||||||
|
event.listen(_sm, "after_insert", _sync_task_after_insert)
|
||||||
|
event.listen(_sm, "after_update", _sync_task_after_update)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Session, "before_flush")
|
||||||
|
def _wesp_recompute_content_hashes_before_flush(session, flush_context, instances) -> None: # type: ignore[override]
|
||||||
|
from app.services.sync_content_hash import refresh_content_hash_if_applicable
|
||||||
|
|
||||||
|
for obj in list(session.dirty):
|
||||||
|
if hasattr(obj, "content_hash"):
|
||||||
|
refresh_content_hash_if_applicable(obj)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Singleton-настройки автообновления (Gitea) в recipes.db."""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Integer, String, text
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class AutoUpdateSettings(db.Model):
|
||||||
|
__tablename__ = "auto_update_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
enabled = Column(Boolean, nullable=False, default=False, server_default=text("0"))
|
||||||
|
auto_install = Column(Boolean, nullable=False, default=False, server_default=text("0"))
|
||||||
|
gitea_url = Column(String(512), nullable=False, default="", server_default=text("''"))
|
||||||
|
gitea_owner = Column(String(255), nullable=False, default="", server_default=text("''"))
|
||||||
|
gitea_repo = Column(String(255), nullable=False, default="", server_default=text("''"))
|
||||||
|
repository_url = Column(String(512), nullable=False, default="", server_default=text("''"))
|
||||||
|
check_interval_sec = Column(Integer, nullable=False, default=3600, server_default=text("3600"))
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, String
|
||||||
|
|
||||||
|
from app.timeutil import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoftDeleteMixin:
|
||||||
|
is_deleted = Column(Boolean, default=False, nullable=True)
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
|
deleted_by = Column(String(50), nullable=True)
|
||||||
|
|
||||||
|
def soft_delete(self, deleted_by_user: str = "system", **_: Any) -> None:
|
||||||
|
"""Мягкое удаление сущности.
|
||||||
|
|
||||||
|
Дополнительные аргументы игнорируются, чтобы сохранить совместимость
|
||||||
|
с вызовами из легаси-кода (reason, request и т.п.).
|
||||||
|
"""
|
||||||
|
if self.is_deleted:
|
||||||
|
return
|
||||||
|
self.is_deleted = True
|
||||||
|
self.deleted_at = utc_now_naive()
|
||||||
|
self.deleted_by = deleted_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def default_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from .base import SoftDeleteMixin, TimestampMixin, default_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Component(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""Кормовой компонент (ингредиент как сущность склада/рецептов)."""
|
||||||
|
|
||||||
|
__tablename__ = "component"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
type = Column(String(100), nullable=False, default="")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
dry_matter = Column(Float, nullable=False, default=0.0)
|
||||||
|
protein = Column(Float, nullable=False, default=0.0)
|
||||||
|
energy = Column(Float, nullable=False, default=0.0)
|
||||||
|
price = Column(Float, nullable=False, default=0.0)
|
||||||
|
external_no = Column(Integer, nullable=True, index=True)
|
||||||
|
|
||||||
|
# Версионирование и аудит (минимальный набор для синхронизации)
|
||||||
|
version = Column(Integer, default=1, nullable=False)
|
||||||
|
created_by = Column(String(50), nullable=False, default="system")
|
||||||
|
updated_by = Column(String(50), nullable=False, default="system")
|
||||||
|
sync_timestamp = Column(DateTime, nullable=True)
|
||||||
|
sync_status = Column(String(20), default="pending", nullable=False)
|
||||||
|
content_hash = Column(String(64), nullable=False, default="")
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
ingredients = relationship(
|
||||||
|
"Ingredient",
|
||||||
|
back_populates="component",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Ingredient(db.Model, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""Строка рецепта (ингредиент в составе рецепта)."""
|
||||||
|
|
||||||
|
__tablename__ = "ingredient"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=default_uuid)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
# Новое поле в легаси: вес на голову
|
||||||
|
weight_per_head = Column(Float, nullable=True)
|
||||||
|
# Процент сухого вещества
|
||||||
|
dry_matter = Column(Float, nullable=False, default=0.0)
|
||||||
|
# СВ на голову (кг) - константа для режима "замок СВ"
|
||||||
|
dry_matter_per_head = Column(Float, nullable=True)
|
||||||
|
# Порядок ингредиента в рецепте
|
||||||
|
order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
recipe_id = Column(String(36), ForeignKey("recipe.id"), nullable=False, index=True)
|
||||||
|
component_id = Column(
|
||||||
|
String(36), ForeignKey("component.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
version = Column(Integer, default=1, nullable=False)
|
||||||
|
created_by = Column(String(50), nullable=False, default="system")
|
||||||
|
updated_by = Column(String(50), nullable=False, default="system")
|
||||||
|
sync_timestamp = Column(DateTime, nullable=True)
|
||||||
|
sync_status = Column(String(20), default="pending", nullable=False)
|
||||||
|
content_hash = Column(String(64), nullable=False, default="")
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
recipe = relationship("Recipe", back_populates="ingredients")
|
||||||
|
component = relationship("Component", back_populates="ingredients")
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user