"""API учётных документов (СП-20, остатки, журнал).""" from __future__ import annotations import base64 import calendar import re from datetime import date from urllib.parse import quote from flask import Blueprint, Response, current_app, jsonify, request from app.routes.auth_decorators import require_auth from app.services.feed_accounting.consumption_pdf import build_consumption_pdf from app.services.feed_accounting.consumption_report_builder import ( consumption_export_filename, consumption_export_filename_ascii, ) from app.services.feed_accounting.consumption_xlsx import build_consumption_xlsx from app.services.feed_accounting.pdf_feed_accounting import build_journal_pdf, build_sp20_pdf from app.services.feed_accounting.sp20_report_builder import parse_month, sp20_report_summary from app.services.feed_accounting.xlsx_feed_accounting import ( build_documents_zip, build_journal_xlsx, build_sp20_xlsx, build_stock_balances_xlsx, ) from app.services.org_settings import ( SIGNATURE_ROLES, delete_signature, get_org_settings, list_signature_status, save_signature_png, signature_path, validate_org_settings_payload, write_org_settings, list_farms_from_db, ) bp = Blueprint("feed_accounting", __name__, url_prefix="/api/feed-accounting") _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def _bad(message: str, code: int = 400): return jsonify({"error": True, "message": message}), code def _parse_dates(): date_from = (request.args.get("date_from") or "").strip() date_to = (request.args.get("date_to") or "").strip() if not date_from or not date_to: today = date.today() start = date(today.year, today.month, 1) last = calendar.monthrange(today.year, today.month)[1] end = date(today.year, today.month, last) return start.isoformat(), end.isoformat(), None if not _DATE_RE.match(date_from) or not _DATE_RE.match(date_to): return None, None, _bad("date_from/date_to: формат YYYY-MM-DD") return date_from, date_to, None def _parse_month_arg(): month = (request.args.get("month") or "").strip() if not month: today = date.today() month = f"{today.year}-{today.month:02d}" try: year, mon = parse_month(month) return year, mon, month, None except ValueError as e: return None, None, None, _bad(str(e)) def _content_disposition(filename: str, *, ascii_filename: str | None = None) -> str: """RFC 5987: кириллица в filename*, latin-1 fallback в filename.""" try: filename.encode("ascii") return f'attachment; filename="{filename}"' except UnicodeEncodeError: fallback = ascii_filename or filename quoted = quote(filename, safe="!#$&+-.^_`|~") return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quoted}" def _xlsx_response(data: bytes, filename: str, *, ascii_filename: str | None = None) -> Response: return Response( data, mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": _content_disposition(filename, ascii_filename=ascii_filename)}, ) def _pdf_response(data: bytes, filename: str, *, ascii_filename: str | None = None) -> Response: return Response( data, mimetype="application/pdf", headers={"Content-Disposition": _content_disposition(filename, ascii_filename=ascii_filename)}, ) @bp.get("/org-settings") @require_auth def api_get_org_settings(): settings = get_org_settings(current_app) return jsonify( { **settings, "available_farms": list_farms_from_db(), "signatures": list_signature_status(), } ) @bp.put("/org-settings") @require_auth def api_put_org_settings(): data = request.get_json(silent=True) or {} ok, err, partial = validate_org_settings_payload(data) if not ok: return _bad(err) write_org_settings(partial) return jsonify({"success": True, "settings": get_org_settings(current_app)}) @bp.get("/signatures") @require_auth def api_list_signatures(): return jsonify({"roles": list_signature_status()}) @bp.get("/signatures/.png") @require_auth def api_get_signature_png(role: str): if role not in SIGNATURE_ROLES: return _bad("Неизвестная роль подписи") path = signature_path(role) if not path.is_file(): return _bad("Подпись не найдена", 404) return Response(path.read_bytes(), mimetype="image/png") @bp.post("/signatures/") @require_auth def api_save_signature(role: str): if role not in SIGNATURE_ROLES: return _bad("Неизвестная роль подписи") png_bytes = None if request.content_type and "application/json" in request.content_type: body = request.get_json(silent=True) or {} raw = body.get("png_base64") or body.get("data") or "" if isinstance(raw, str) and "," in raw: raw = raw.split(",", 1)[1] try: png_bytes = base64.b64decode(raw) except Exception: return _bad("Некорректный base64") elif "file" in request.files: f = request.files["file"] png_bytes = f.read() else: png_bytes = request.get_data() try: save_signature_png(role, png_bytes or b"") except ValueError as e: return _bad(str(e)) return jsonify({"success": True, "roles": list_signature_status()}) @bp.delete("/signatures/") @require_auth def api_delete_signature(role: str): if role not in SIGNATURE_ROLES: return _bad("Неизвестная роль подписи") try: delete_signature(role) except ValueError as e: return _bad(str(e)) return jsonify({"success": True, "roles": list_signature_status()}) @bp.get("/stock-balances.xlsx") @require_auth def api_stock_balances_xlsx(): date_from, date_to, err = _parse_dates() if err: return err data = build_stock_balances_xlsx(current_app, date_from, date_to) return _xlsx_response(data, f"WESP_ostatki_{date_from}_{date_to}.xlsx") @bp.get("/consumption.xlsx") @require_auth def api_consumption_xlsx(): date_from, date_to, err = _parse_dates() if err: return err data = build_consumption_xlsx(current_app, date_from, date_to) return _xlsx_response( data, consumption_export_filename(date_from, date_to, "xlsx"), ascii_filename=consumption_export_filename_ascii(date_from, date_to, "xlsx"), ) @bp.get("/consumption.pdf") @require_auth def api_consumption_pdf(): date_from, date_to, err = _parse_dates() if err: return err data = build_consumption_pdf(current_app, date_from, date_to) return _pdf_response( data, consumption_export_filename(date_from, date_to, "pdf"), ascii_filename=consumption_export_filename_ascii(date_from, date_to, "pdf"), ) @bp.get("/sp20-preview") @require_auth def api_sp20_preview(): year, month, month_s, err = _parse_month_arg() if err: return err return jsonify(sp20_report_summary(current_app, year, month)) @bp.get("/sp20.xlsx") @require_auth def api_sp20_xlsx(): year, month, month_s, err = _parse_month_arg() if err: return err include_journal = request.args.get("include_journal", "1") != "0" data = build_sp20_xlsx(current_app, year, month, include_journal=include_journal) return _xlsx_response(data, f"WESP_SP20_{month_s}.xlsx") @bp.get("/sp20.pdf") @require_auth def api_sp20_pdf(): year, month, month_s, err = _parse_month_arg() if err: return err data = build_sp20_pdf(current_app, year, month) return _pdf_response(data, f"WESP_SP20_{month_s}.pdf") @bp.get("/journal.xlsx") @require_auth def api_journal_xlsx(): year, month, month_s, err = _parse_month_arg() if err: return err data = build_journal_xlsx(current_app, year, month) return _xlsx_response(data, f"WESP_zhurnal_{month_s}.xlsx") @bp.get("/journal.pdf") @require_auth def api_journal_pdf(): year, month, month_s, err = _parse_month_arg() if err: return err data = build_journal_pdf(current_app, year, month) return _pdf_response(data, f"WESP_zhurnal_{month_s}.pdf") @bp.get("/documents.zip") @require_auth def api_documents_zip(): date_from, date_to, err = _parse_dates() if err: return err year, month, month_s, err2 = _parse_month_arg() if err2: return err2 data = build_documents_zip(current_app, year, month, date_from, date_to) return Response( data, mimetype="application/zip", headers={"Content-Disposition": f'attachment; filename="WESP_documents_{month_s}.zip"'}, )