Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+284
View File
@@ -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"'},
)