@@ -0,0 +1,284 @@
|
||||
"""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/<role>.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/<role>")
|
||||
@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/<role>")
|
||||
@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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user