285 lines
8.7 KiB
Python
285 lines
8.7 KiB
Python
"""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"'},
|
|
)
|