"""Feed accounting (СП-20, exports, org requisites) for WESP consumption UI.""" from __future__ import annotations import base64 import hashlib import io import json import re import zipfile from datetime import UTC, datetime from typing import Any from xml.sax.saxutils import escape from sqlalchemy import select from app.core.database import session_scope from app.modules.sync import repository as sync_repo from app.modules.zootech.settings_models import ZootechOrgSettings from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time from app.modules.zootech.pdf_export import build_table_pdf from app.modules.zootech.wesp_sklad_service import list_sklad_items from app.modules.zootech.report_models import ZootechLoadingReport ORG_FIELD_KEYS = ( "organization_name", "okpo", "department", "farm_name", "brigade", "animal_type", "responsible_person", "zootechnician", "warehouse_keeper", "document_number_prefix", ) SIGNATURE_ROLES = ("zootechnician", "warehouse_keeper", "recipient") def _empty_org_fields() -> dict[str, str]: return {key: "" for key in ORG_FIELD_KEYS} def _parse_payload_json(raw: str | None) -> dict[str, Any]: if not raw: return {} try: parsed = json.loads(raw) except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} def _load_org_row(enterprise_id: str) -> tuple[ZootechOrgSettings | None, dict[str, Any]]: with session_scope() as db: row = db.scalar(select(ZootechOrgSettings).where(ZootechOrgSettings.enterprise_id == enterprise_id)) if not row: return None, {} return row, _parse_payload_json(row.payload_json) def _signature_status(payload: dict[str, Any]) -> dict[str, bool]: signatures = payload.get("signatures") if not isinstance(signatures, dict): signatures = {} return {role: bool(signatures.get(role)) for role in SIGNATURE_ROLES} def _has_requisites(fields: dict[str, str]) -> bool: return bool( (fields.get("organization_name") or "").strip() and (fields.get("zootechnician") or "").strip() and (fields.get("warehouse_keeper") or "").strip() ) def get_org_settings(enterprise_id: str) -> dict[str, Any]: _row, payload = _load_org_row(enterprise_id) fields = _empty_org_fields() stored = payload.get("fields") if isinstance(stored, dict): for key in ORG_FIELD_KEYS: fields[key] = str(stored.get(key) or "") farms = [hub.name for hub in sync_repo.list_farm_hubs(enterprise_id) if hub.name] return { **fields, "available_farms": farms, "signatures": _signature_status(payload), } def save_org_settings(enterprise_id: str, body: dict[str, Any]) -> dict[str, Any]: fields = _empty_org_fields() for key in ORG_FIELD_KEYS: if key in body: fields[key] = str(body.get(key) or "").strip() with session_scope() as db: row = db.scalar(select(ZootechOrgSettings).where(ZootechOrgSettings.enterprise_id == enterprise_id)) payload = _parse_payload_json(row.payload_json) if row else {} payload["fields"] = fields payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) content_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest() if row: row.payload_json = payload_json row.version = int(row.version or 0) + 1 row.content_hash = content_hash row.updated_at = datetime.now(UTC) else: db.add( ZootechOrgSettings( enterprise_id=enterprise_id, payload_json=payload_json, version=1, content_hash=content_hash, ) ) return {"success": True, "signatures": _signature_status(payload)} def _decode_png_base64(data_url: str) -> bytes: text = (data_url or "").strip() if "," in text: text = text.split(",", 1)[1] return base64.b64decode(text) def save_signature(enterprise_id: str, role: str, png_base64: str) -> dict[str, Any]: if role not in SIGNATURE_ROLES: return {"success": False, "message": "Неизвестная роль подписи"} try: png_bytes = _decode_png_base64(png_base64) except (ValueError, TypeError): return {"success": False, "message": "Некорректное изображение подписи"} encoded = base64.b64encode(png_bytes).decode("ascii") with session_scope() as db: row = db.scalar(select(ZootechOrgSettings).where(ZootechOrgSettings.enterprise_id == enterprise_id)) payload = _parse_payload_json(row.payload_json) if row else {} signatures = payload.get("signatures") if not isinstance(signatures, dict): signatures = {} signatures[role] = encoded payload["signatures"] = signatures payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) content_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest() if row: row.payload_json = payload_json row.version = int(row.version or 0) + 1 row.content_hash = content_hash row.updated_at = datetime.now(UTC) else: db.add( ZootechOrgSettings( enterprise_id=enterprise_id, payload_json=payload_json, version=1, content_hash=content_hash, ) ) return {"success": True, "roles": _signature_status(payload)} def get_signature_png(enterprise_id: str, role: str) -> bytes | None: if role not in SIGNATURE_ROLES: return None _row, payload = _load_org_row(enterprise_id) signatures = payload.get("signatures") if not isinstance(signatures, dict): return None encoded = signatures.get(role) if not encoded: return None try: return base64.b64decode(str(encoded)) except (ValueError, TypeError): return None def _month_window(month: str) -> tuple[datetime, datetime]: start = datetime.strptime(month.strip() + "-01", "%Y-%m-%d").replace(tzinfo=UTC) if start.month == 12: end = datetime(start.year + 1, 1, 1, tzinfo=UTC) else: end = datetime(start.year, start.month + 1, 1, tzinfo=UTC) return start, end def _reports_in_window(enterprise_id: str, window: tuple[datetime, datetime]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] with session_scope() as db: for row in db.scalars( select(ZootechLoadingReport).where( ZootechLoadingReport.enterprise_id == enterprise_id, ZootechLoadingReport.is_deleted.is_(False), ) ): payload = _parse_payload(row) start_time = _parse_report_time(payload.get("start_time")) if start_time is None or not (window[0] <= start_time < window[1]): continue rows.append(payload) return rows def sp20_preview(enterprise_id: str, month: str) -> dict[str, Any]: window = _month_window(month) reports = _reports_in_window(enterprise_id, window) lines_count = 0 total_kg = 0.0 for payload in reports: components = payload.get("components") if not isinstance(components, list): continue for comp in components: if not isinstance(comp, dict): continue actual = float(comp.get("actual_weight") or 0) if actual <= 0: continue lines_count += 1 total_kg += actual settings = get_org_settings(enterprise_id) fields = {key: settings.get(key, "") for key in ORG_FIELD_KEYS} return { "lines_count": lines_count, "total_kg": round(total_kg, 2), "has_requisites": _has_requisites(fields), "month": month, } def _col_letter(index: int) -> str: result = "" n = index while True: result = chr(n % 26 + ord("A")) + result n = n // 26 - 1 if n < 0: break return result def build_xlsx_bytes(sheet_name: str, headers: list[str], rows: list[list[Any]]) -> bytes: sheet_rows: list[str] = [] all_rows = [headers, *rows] for row_idx, row in enumerate(all_rows, start=1): cells: list[str] = [] for col_idx, value in enumerate(row): ref = f"{_col_letter(col_idx)}{row_idx}" if isinstance(value, (int, float)) and not isinstance(value, bool): cells.append(f'{value}') else: text = escape(str(value if value is not None else "")) cells.append(f'{text}') sheet_rows.append(f'{"".join(cells)}') sheet_xml = ( '' '' f"{''.join(sheet_rows)}" ) workbook_xml = ( '' '' f'' ) content_types = ( '' '' '' '' '' '' "" ) rels_root = ( '' '' '' "" ) rels_workbook = ( '' '' '' "" ) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("[Content_Types].xml", content_types) archive.writestr("_rels/.rels", rels_root) archive.writestr("xl/workbook.xml", workbook_xml) archive.writestr("xl/_rels/workbook.xml.rels", rels_workbook) archive.writestr("xl/worksheets/sheet1.xml", sheet_xml) return buffer.getvalue() def build_consumption_xlsx(enterprise_id: str, date_from: str, date_to: str) -> bytes: data = list_sklad_items(enterprise_id, date_from=date_from, date_to=date_to) rows = [ [ item.get("name", ""), item.get("consumed_total_kg", 0), item.get("remaining_kg", 0), item.get("planned_consumption_per_day_kg", 0), ] for item in data.get("items") or [] ] return build_xlsx_bytes( "Потребление", ["Компонент", "Расход (кг)", "Остаток (кг)", "План кг/сут"], rows, ) def build_stock_balances_xlsx(enterprise_id: str, date_from: str, date_to: str) -> bytes: data = list_sklad_items(enterprise_id, date_from=date_from, date_to=date_to) rows = [ [ item.get("name", ""), item.get("total_kg", 0), item.get("inflow_kg", 0), item.get("remaining_kg", 0), ] for item in data.get("items") or [] if item.get("has_stock") ] return build_xlsx_bytes( "Остатки", ["Компонент", "Всего (кг)", "Приход (кг)", "Остаток (кг)"], rows, ) def _org_subtitle_lines(enterprise_id: str, *, month: str | None = None) -> list[str]: settings = get_org_settings(enterprise_id) lines = [ f"Организация: {settings.get('organization_name') or '—'}", f"ОКПО: {settings.get('okpo') or '—'}", f"Отделение: {settings.get('department') or '—'}", f"Ферма: {settings.get('farm_name') or '—'}", ] if month: lines.append(f"Период: {month}") return lines def _collect_sp20_rows(enterprise_id: str, month: str) -> list[list[Any]]: window = _month_window(month) reports = _reports_in_window(enterprise_id, window) rows: list[list[Any]] = [] for payload in reports: start_time = payload.get("start_time") or "" recipe_name = payload.get("recipe_name") or "" components = payload.get("components") if not isinstance(components, list): continue for comp in components: if not isinstance(comp, dict): continue actual = float(comp.get("actual_weight") or 0) if actual <= 0: continue rows.append( [ str(start_time)[:10], recipe_name, comp.get("component_name") or comp.get("component_id") or "", actual, ] ) return rows def build_sp20_xlsx(enterprise_id: str, month: str) -> bytes: rows = _collect_sp20_rows(enterprise_id, month) return build_xlsx_bytes( "СП-20", ["Дата", "Рецепт", "Компонент", "Расход (кг)"], rows, ) def build_sp20_pdf(enterprise_id: str, month: str) -> bytes: rows = _collect_sp20_rows(enterprise_id, month) total_kg = round(sum(float(row[3]) for row in rows), 2) settings = get_org_settings(enterprise_id) footer = [ f"Итого расход: {total_kg} кг", f"Зоотехник: {settings.get('zootechnician') or '—'}", f"Кладовщик: {settings.get('warehouse_keeper') or '—'}", ] return build_table_pdf( title="СП-20 (ОКУД 0325020)", subtitle_lines=_org_subtitle_lines(enterprise_id, month=month), headers=["Дата", "Рецепт", "Компонент", "Расход (кг)"], rows=rows, footer_lines=footer, ) def build_consumption_pdf(enterprise_id: str, date_from: str, date_to: str) -> bytes: data = list_sklad_items(enterprise_id, date_from=date_from, date_to=date_to) rows = [ [ item.get("name", ""), item.get("consumed_total_kg", 0), item.get("remaining_kg", 0), item.get("planned_consumption_per_day_kg", 0), ] for item in data.get("items") or [] ] return build_table_pdf( title="Потребление кормов", subtitle_lines=_org_subtitle_lines(enterprise_id) + [f"Период: {date_from} — {date_to}"], headers=["Компонент", "Расход (кг)", "Остаток (кг)", "План кг/сут"], rows=rows, ) def _collect_journal_rows(enterprise_id: str, month: str) -> list[list[Any]]: window = _month_window(month) reports = _reports_in_window(enterprise_id, window) return [ [ str(payload.get("start_time") or "")[:19], payload.get("recipe_name") or "", payload.get("total_weight") or 0, payload.get("dispenser_type") or "", ] for payload in reports ] def build_journal_xlsx(enterprise_id: str, month: str) -> bytes: rows = _collect_journal_rows(enterprise_id, month) return build_xlsx_bytes( "Журнал", ["Дата/время", "Рецепт", "Вес (кг)", "Тип"], rows, ) def build_journal_pdf(enterprise_id: str, month: str) -> bytes: rows = _collect_journal_rows(enterprise_id, month) return build_table_pdf( title="Журнал учёта кормов", subtitle_lines=_org_subtitle_lines(enterprise_id, month=month), headers=["Дата/время", "Рецепт", "Вес (кг)", "Тип"], rows=rows, ) def build_documents_zip(enterprise_id: str, date_from: str, date_to: str, month: str) -> bytes: files = { f"consumption_{date_from}_{date_to}.xlsx": build_consumption_xlsx(enterprise_id, date_from, date_to), f"stock_balances_{date_from}_{date_to}.xlsx": build_stock_balances_xlsx(enterprise_id, date_from, date_to), f"sp20_{month}.xlsx": build_sp20_xlsx(enterprise_id, month), f"journal_{month}.xlsx": build_journal_xlsx(enterprise_id, month), } buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: for name, content in files.items(): archive.writestr(name, content) return buffer.getvalue() def safe_filename_part(value: str) -> str: cleaned = re.sub(r"[^\w\-.]+", "_", value.strip()) return cleaned or "export"