@@ -0,0 +1,130 @@
|
||||
"""PDF: отчёт «Потребление корма»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import Flask
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.services.feed_accounting.consumption_report_builder import build_consumption_report
|
||||
from app.services.feed_accounting.pdf_feed_accounting import _styles
|
||||
|
||||
|
||||
def _table(data: list, font: str, *, font_size: int = 8, repeat_rows: int = 1) -> Table:
|
||||
tbl = Table(data, repeatRows=repeat_rows)
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), font_size),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]
|
||||
)
|
||||
)
|
||||
return tbl
|
||||
|
||||
|
||||
def build_consumption_pdf(app: Flask, date_from: str, date_to: str) -> bytes:
|
||||
report = build_consumption_report(app, date_from, date_to)
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=landscape(A4),
|
||||
leftMargin=10 * mm,
|
||||
rightMargin=10 * mm,
|
||||
topMargin=10 * mm,
|
||||
bottomMargin=10 * mm,
|
||||
)
|
||||
styles, font = _styles()
|
||||
story = [
|
||||
Paragraph("Потребление корма", styles["Title"]),
|
||||
Paragraph(f"Период: {date_from} — {date_to}", styles["Normal"]),
|
||||
Spacer(1, 6),
|
||||
Paragraph("Сводка", styles["Normal"]),
|
||||
Spacer(1, 4),
|
||||
]
|
||||
|
||||
sum_data = [
|
||||
[
|
||||
"Компонент",
|
||||
"Осталось, кг",
|
||||
"Расход за период, кг",
|
||||
"План кг/сут",
|
||||
"Осталось дней",
|
||||
"Поступление, кг",
|
||||
"Всего, кг",
|
||||
]
|
||||
]
|
||||
for row in report.summary:
|
||||
sum_data.append(
|
||||
[
|
||||
row["name"],
|
||||
row["remaining_kg"],
|
||||
row["consumed_kg"],
|
||||
row["planned_per_day_kg"] or "",
|
||||
row["days_left"] if row["days_left"] is not None else "",
|
||||
row["inflow_kg"],
|
||||
row["total_kg"],
|
||||
]
|
||||
)
|
||||
if len(sum_data) == 1:
|
||||
sum_data.append(["—", "", "", "", "", "", ""])
|
||||
story.append(_table(sum_data, font))
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(Paragraph("Расход по дням", styles["Normal"]))
|
||||
story.append(Spacer(1, 4))
|
||||
day_labels = [d.strftime("%d.%m") for d in report.days]
|
||||
daily_data = [["Компонент"] + day_labels + ["Итого"]]
|
||||
for row in report.daily:
|
||||
daily_data.append(
|
||||
[row["name"]]
|
||||
+ [row["days"].get(d, 0) or "" for d in report.days]
|
||||
+ [row["total_kg"]]
|
||||
)
|
||||
if len(daily_data) == 1:
|
||||
daily_data.append(["—"] + [""] * (len(report.days) + 1))
|
||||
story.append(_table(daily_data, font, font_size=6))
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(Paragraph("Детализация загрузок", styles["Normal"]))
|
||||
story.append(Spacer(1, 4))
|
||||
det_data = [
|
||||
[
|
||||
"Начало",
|
||||
"Окончание",
|
||||
"Рецепт",
|
||||
"Кормораздатчик",
|
||||
"Компонент",
|
||||
"План, кг",
|
||||
"Факт, кг",
|
||||
"Перегруз, кг",
|
||||
"№",
|
||||
]
|
||||
]
|
||||
for row in report.detail:
|
||||
det_data.append(
|
||||
[
|
||||
row["start"],
|
||||
row["end"],
|
||||
row["recipe"],
|
||||
row["dispenser"],
|
||||
row["component"],
|
||||
row["target_kg"],
|
||||
row["actual_kg"],
|
||||
row["overload_kg"],
|
||||
row["loading_order"],
|
||||
]
|
||||
)
|
||||
if len(det_data) == 1:
|
||||
det_data.append(["—", "", "", "", "", "", "", "", ""])
|
||||
story.append(_table(det_data, font, font_size=7))
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Данные для отчёта «Потребление корма» (сводка, детализация, расход по дням)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, Ingredient, LoadingReport, LoadingReportComponent, Recipe
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
from app.services.sklad_metrics import (
|
||||
_parse_date_range,
|
||||
_to_date,
|
||||
effective_consumed_kg,
|
||||
list_stock_balance_items,
|
||||
)
|
||||
|
||||
|
||||
def _fmt_dt(val: datetime | None) -> str:
|
||||
if not val:
|
||||
return ""
|
||||
return val.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
def _iter_dates(date_from: str, date_to: str) -> List[date]:
|
||||
start = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||||
days: List[date] = []
|
||||
cur = start
|
||||
while cur <= end:
|
||||
days.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def _period_daily_consumption(
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
) -> Tuple[List[date], Dict[str, Dict[date, float]]]:
|
||||
from_dt, to_dt = _parse_date_range(date_from, date_to)
|
||||
if from_dt is None or to_dt is None:
|
||||
return [], {}
|
||||
q = (
|
||||
db.session.query(
|
||||
LoadingReportComponent.component_name,
|
||||
LoadingReport.start_time,
|
||||
LoadingReportComponent.actual_weight,
|
||||
LoadingReportComponent.overload,
|
||||
LoadingReportComponent.target_weight,
|
||||
)
|
||||
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
|
||||
.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
|
||||
)
|
||||
if hasattr(LoadingReport, "is_deleted"):
|
||||
q = q.filter(LoadingReport.is_deleted.is_(False))
|
||||
if hasattr(LoadingReportComponent, "is_deleted"):
|
||||
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
|
||||
|
||||
daily: Dict[str, Dict[date, float]] = {}
|
||||
for cname, start_time, actual, overload, target in q.all():
|
||||
kg = effective_consumed_kg(actual, overload, target)
|
||||
if kg <= 0:
|
||||
continue
|
||||
d = _to_date(start_time)
|
||||
if d is None or d < from_dt.date() or d > to_dt.date():
|
||||
continue
|
||||
name = str(cname or "").strip() or "—"
|
||||
daily.setdefault(name, {})
|
||||
daily[name][d] = daily[name].get(d, 0.0) + kg
|
||||
return _iter_dates(date_from, date_to), daily
|
||||
|
||||
|
||||
def build_consumption_summary_rows(
|
||||
app,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
items = list_stock_balance_items(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
Component=Component,
|
||||
)
|
||||
rows = []
|
||||
for item in sorted(items, key=lambda r: (r.get("name") or "").lower()):
|
||||
rows.append(
|
||||
{
|
||||
"name": item.get("name") or "",
|
||||
"remaining_kg": item.get("remaining_kg") or 0,
|
||||
"consumed_kg": item.get("consumed_total_kg") or item.get("consumed_kg") or 0,
|
||||
"planned_per_day_kg": item.get("planned_consumption_per_day_kg") or 0,
|
||||
"days_left": item.get("days_left_plan"),
|
||||
"inflow_kg": item.get("inflow_kg") or 0,
|
||||
"total_kg": item.get("total_kg") or 0,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build_consumption_detail_rows(date_from: str, date_to: str) -> List[Dict[str, Any]]:
|
||||
from_dt, to_dt = _parse_date_range(date_from, date_to)
|
||||
if from_dt is None or to_dt is None:
|
||||
return []
|
||||
reports = list(
|
||||
db.session.execute(
|
||||
select(LoadingReport)
|
||||
.where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= from_dt,
|
||||
LoadingReport.start_time <= to_dt,
|
||||
)
|
||||
.order_by(LoadingReport.start_time.desc())
|
||||
).scalars()
|
||||
)
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
recipe_ids = {r.recipe_id for r in reports if r.recipe_id}
|
||||
_, dispenser_by_recipe = build_recipe_location_maps(recipe_ids)
|
||||
report_ids = [r.id for r in reports]
|
||||
components = list(
|
||||
db.session.execute(
|
||||
select(LoadingReportComponent)
|
||||
.where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(LoadingReportComponent.loading_order.asc())
|
||||
).scalars()
|
||||
)
|
||||
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
|
||||
for comp in components:
|
||||
comps_by_report.setdefault(comp.report_id, []).append(comp)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for report in reports:
|
||||
dispenser = dispenser_by_recipe.get(report.recipe_id) or report.dispenser_type or "—"
|
||||
for comp in comps_by_report.get(report.id, []):
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
rows.append(
|
||||
{
|
||||
"start": _fmt_dt(report.start_time),
|
||||
"end": _fmt_dt(report.end_time),
|
||||
"recipe": report.recipe_name or "",
|
||||
"dispenser": dispenser,
|
||||
"component": comp.component_name or "",
|
||||
"target_kg": round(target, 2),
|
||||
"actual_kg": round(actual, 2),
|
||||
"overload_kg": round(actual - target, 2),
|
||||
"loading_order": comp.loading_order,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build_consumption_daily_rows(
|
||||
summary_rows: List[Dict[str, Any]],
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
) -> Tuple[List[date], List[Dict[str, Any]]]:
|
||||
days, daily = _period_daily_consumption(date_from, date_to)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for item in summary_rows:
|
||||
name = item.get("name") or ""
|
||||
by_day = daily.get(name, {})
|
||||
total = 0.0
|
||||
day_values: Dict[date, float] = {}
|
||||
for d in days:
|
||||
v = round(by_day.get(d, 0.0), 2)
|
||||
day_values[d] = v
|
||||
total += v
|
||||
rows.append({"name": name, "days": day_values, "total_kg": round(total, 2)})
|
||||
return days, rows
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsumptionReport:
|
||||
date_from: str
|
||||
date_to: str
|
||||
summary: List[Dict[str, Any]]
|
||||
detail: List[Dict[str, Any]]
|
||||
days: List[date]
|
||||
daily: List[Dict[str, Any]]
|
||||
|
||||
|
||||
def build_consumption_report(app, date_from: str, date_to: str) -> ConsumptionReport:
|
||||
summary = build_consumption_summary_rows(app, date_from, date_to)
|
||||
detail = build_consumption_detail_rows(date_from, date_to)
|
||||
days, daily = build_consumption_daily_rows(summary, date_from, date_to)
|
||||
return ConsumptionReport(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
summary=summary,
|
||||
detail=detail,
|
||||
days=days,
|
||||
daily=daily,
|
||||
)
|
||||
|
||||
|
||||
def consumption_export_filename(date_from: str, date_to: str, ext: str) -> str:
|
||||
return f"Потребление_корма_{date_from}_{date_to}.{ext}"
|
||||
|
||||
|
||||
def consumption_export_filename_ascii(date_from: str, date_to: str, ext: str) -> str:
|
||||
return f"Potreblenie_korma_{date_from}_{date_to}.{ext}"
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Excel: отчёт «Потребление корма» (3 листа)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import Flask
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from app.services.feed_accounting.consumption_report_builder import build_consumption_report
|
||||
|
||||
|
||||
def _header_font() -> Font:
|
||||
return Font(bold=True)
|
||||
|
||||
|
||||
def build_consumption_xlsx(app: Flask, date_from: str, date_to: str) -> bytes:
|
||||
report = build_consumption_report(app, date_from, date_to)
|
||||
wb = Workbook()
|
||||
|
||||
ws_sum = wb.active
|
||||
ws_sum.title = "Сводка"
|
||||
ws_sum.append([f"Потребление корма · {date_from} — {date_to}"])
|
||||
ws_sum.append([])
|
||||
sum_headers = [
|
||||
"Компонент",
|
||||
"Осталось, кг",
|
||||
"Расход за период (факт), кг",
|
||||
"Расход /24ч (план), кг",
|
||||
"Осталось дней",
|
||||
"Поступление, кг",
|
||||
"Всего на складе, кг",
|
||||
]
|
||||
ws_sum.append(sum_headers)
|
||||
header_row = ws_sum.max_row
|
||||
for col in range(1, len(sum_headers) + 1):
|
||||
ws_sum.cell(row=header_row, column=col).font = _header_font()
|
||||
for row in report.summary:
|
||||
ws_sum.append(
|
||||
[
|
||||
row["name"],
|
||||
row["remaining_kg"],
|
||||
row["consumed_kg"],
|
||||
row["planned_per_day_kg"] or "",
|
||||
row["days_left"] if row["days_left"] is not None else "",
|
||||
row["inflow_kg"],
|
||||
row["total_kg"],
|
||||
]
|
||||
)
|
||||
for col in range(1, len(sum_headers) + 1):
|
||||
ws_sum.column_dimensions[get_column_letter(col)].width = 18
|
||||
|
||||
ws_det = wb.create_sheet("Детализация")
|
||||
det_headers = [
|
||||
"Дата/время начала",
|
||||
"Дата/время окончания",
|
||||
"Рецепт",
|
||||
"Кормораздатчик",
|
||||
"Компонент",
|
||||
"Целевой вес, кг",
|
||||
"Фактический вес, кг",
|
||||
"Перегруз, кг",
|
||||
"Порядок загрузки",
|
||||
]
|
||||
ws_det.append(det_headers)
|
||||
for col in range(1, len(det_headers) + 1):
|
||||
ws_det.cell(row=1, column=col).font = _header_font()
|
||||
for row in report.detail:
|
||||
ws_det.append(
|
||||
[
|
||||
row["start"],
|
||||
row["end"],
|
||||
row["recipe"],
|
||||
row["dispenser"],
|
||||
row["component"],
|
||||
row["target_kg"],
|
||||
row["actual_kg"],
|
||||
row["overload_kg"],
|
||||
row["loading_order"],
|
||||
]
|
||||
)
|
||||
for col in range(1, len(det_headers) + 1):
|
||||
ws_det.column_dimensions[get_column_letter(col)].width = 16
|
||||
|
||||
ws_daily = wb.create_sheet("Расход по дням")
|
||||
day_headers = ["Компонент"] + [d.strftime("%d.%m.%Y") for d in report.days] + ["Итого за период"]
|
||||
ws_daily.append(day_headers)
|
||||
for col in range(1, len(day_headers) + 1):
|
||||
ws_daily.cell(row=1, column=col).font = _header_font()
|
||||
for row in report.daily:
|
||||
values = [row["name"]]
|
||||
for d in report.days:
|
||||
v = row["days"].get(d, 0)
|
||||
values.append(v if v else 0)
|
||||
values.append(row["total_kg"])
|
||||
ws_daily.append(values)
|
||||
ws_daily.column_dimensions["A"].width = 22
|
||||
for col in range(2, len(day_headers) + 1):
|
||||
ws_daily.column_dimensions[get_column_letter(col)].width = 11
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""PDF-экспорт учётных документов (reportlab)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from flask import Flask
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.services.feed_accounting.sp20_report_builder import build_journal_rows, build_sp20_report
|
||||
from app.services.org_settings import get_org_settings, signature_path
|
||||
from app.services.pdf_cyrillic_fonts import (
|
||||
register_cyrillic_font as _register_cyrillic_font,
|
||||
register_cyrillic_font_bold as _register_cyrillic_font_bold,
|
||||
)
|
||||
|
||||
|
||||
def _styles():
|
||||
font = _register_cyrillic_font()
|
||||
styles = getSampleStyleSheet()
|
||||
styles["Title"].fontName = font
|
||||
styles["Normal"].fontName = font
|
||||
return styles, font
|
||||
|
||||
|
||||
def _signature_image_flowable(role: str, width=40 * mm, height=15 * mm):
|
||||
try:
|
||||
from reportlab.platypus import Image
|
||||
|
||||
p = signature_path(role)
|
||||
if p.is_file():
|
||||
return Image(str(p), width=width, height=height)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def build_sp20_pdf(app: Flask, year: int, month: int) -> bytes:
|
||||
report = build_sp20_report(app, year, month)
|
||||
org = report.org
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=landscape(A4), leftMargin=12 * mm, rightMargin=12 * mm)
|
||||
styles, font = _styles()
|
||||
story = []
|
||||
story.append(Paragraph("Ведомость учёта расхода кормов (форма СП-20)", styles["Title"]))
|
||||
story.append(Paragraph(f"ОКУД 0325020 · {month:02d}.{year}", styles["Normal"]))
|
||||
story.append(Spacer(1, 6))
|
||||
meta = [
|
||||
["Организация", org.get("organization_name") or "—", "ОКПО", org.get("okpo") or "—"],
|
||||
["Ферма", org.get("farm_name") or "—", "Бригада", org.get("brigade") or "—"],
|
||||
["Вид скота", report.animal_type, "Ответственный", org.get("responsible_person") or "—"],
|
||||
]
|
||||
mt = Table(meta, colWidths=[35 * mm, 70 * mm, 35 * mm, 70 * mm])
|
||||
mt.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(mt)
|
||||
story.append(Spacer(1, 8))
|
||||
data = [["Дата", "Корм", "Голов", "Кг"]]
|
||||
for line in report.lines[:120]:
|
||||
data.append(
|
||||
[
|
||||
f"{line.day:02d}.{month:02d}.{year}",
|
||||
line.feed_name,
|
||||
str(line.heads or ""),
|
||||
str(line.quantity_kg),
|
||||
]
|
||||
)
|
||||
if len(data) == 1:
|
||||
data.append(["—", "Нет данных за период", "", ""])
|
||||
tbl = Table(data, repeatRows=1)
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(tbl)
|
||||
story.append(Spacer(1, 12))
|
||||
sig_row = []
|
||||
for role, label in (
|
||||
("zootechnician", org.get("zootechnician") or "Зоотехник"),
|
||||
("warehouse_keeper", org.get("warehouse_keeper") or "Кладовщик"),
|
||||
):
|
||||
img = _signature_image_flowable(role)
|
||||
sig_row.append([label, img if img else ""])
|
||||
sig_tbl = Table(sig_row, colWidths=[100 * mm, 100 * mm])
|
||||
sig_tbl.setStyle(TableStyle([("FONTNAME", (0, 0), (-1, -1), font), ("FONTSIZE", (0, 0), (-1, -1), 9)]))
|
||||
story.append(sig_tbl)
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build_journal_pdf(app: Flask, year: int, month: int) -> bytes:
|
||||
org = get_org_settings(app)
|
||||
rows = build_journal_rows(app, year, month)
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4)
|
||||
styles, font = _styles()
|
||||
story = [
|
||||
Paragraph("Журнал учёта расхода кормов", styles["Title"]),
|
||||
Paragraph(org.get("organization_name") or "", styles["Normal"]),
|
||||
Paragraph(f"Период: {month:02d}.{year}", styles["Normal"]),
|
||||
Spacer(1, 8),
|
||||
]
|
||||
data = [["Вид корма", "Ед.", "За месяц, кг", "С начала года, кг"]]
|
||||
for r in rows:
|
||||
data.append([r["name"], r["unit"], r["month_kg"], r["ytd_kg"]])
|
||||
if len(data) == 1:
|
||||
data.append(["—", "кг", 0, 0])
|
||||
tbl = Table(data, repeatRows=1)
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(tbl)
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Сбор данных для отчёта СП-20."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Ingredient,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Recipe,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.services.org_settings import get_org_settings
|
||||
from app.services.sklad_metrics import (
|
||||
daily_unloading_by_group,
|
||||
month_consumption_by_component,
|
||||
norm_per_head_for_component,
|
||||
recipe_heads_for_component,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sp20Line:
|
||||
day: int
|
||||
feed_name: str
|
||||
quantity_kg: float
|
||||
heads: int
|
||||
norm_per_head: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sp20Report:
|
||||
year: int
|
||||
month: int
|
||||
org: Dict[str, str]
|
||||
animal_type: str
|
||||
lines: List[Sp20Line] = field(default_factory=list)
|
||||
groups: List[Dict[str, Any]] = field(default_factory=list)
|
||||
days_in_month: int = 31
|
||||
|
||||
|
||||
def parse_month(month_str: str) -> tuple[int, int]:
|
||||
parts = month_str.strip().split("-")
|
||||
if len(parts) != 2:
|
||||
raise ValueError("month должен быть YYYY-MM")
|
||||
year, month = int(parts[0]), int(parts[1])
|
||||
if month < 1 or month > 12:
|
||||
raise ValueError("month вне диапазона")
|
||||
return year, month
|
||||
|
||||
|
||||
def build_sp20_report(app: Flask, year: int, month: int) -> Sp20Report:
|
||||
org = get_org_settings(app)
|
||||
days_in_month = calendar.monthrange(year, month)[1]
|
||||
lines: List[Sp20Line] = []
|
||||
by_component = month_consumption_by_component(
|
||||
db, LoadingReport, LoadingReportComponent, year, month
|
||||
)
|
||||
for _key in sorted(by_component.keys(), key=lambda k: (by_component[k].get("name") or k).lower()):
|
||||
meta = by_component[_key]
|
||||
cid = str(meta.get("component_id") or "").strip()
|
||||
name = str(meta.get("name") or "—")
|
||||
daily = meta.get("daily") or {}
|
||||
if not daily:
|
||||
continue
|
||||
heads = recipe_heads_for_component(db, Ingredient, Recipe, cid) if cid else 0
|
||||
norm = norm_per_head_for_component(db, Ingredient, Recipe, cid) if cid else None
|
||||
for day, kg in sorted(daily.items()):
|
||||
if kg <= 0:
|
||||
continue
|
||||
lines.append(
|
||||
Sp20Line(
|
||||
day=day,
|
||||
feed_name=name,
|
||||
quantity_kg=round(kg, 2),
|
||||
heads=heads,
|
||||
norm_per_head=norm,
|
||||
)
|
||||
)
|
||||
groups = daily_unloading_by_group(db, UnloadingReport, UnloadingReportGroup, year, month)
|
||||
return Sp20Report(
|
||||
year=year,
|
||||
month=month,
|
||||
org=org,
|
||||
animal_type=org.get("animal_type") or "КРС",
|
||||
lines=lines,
|
||||
groups=groups,
|
||||
days_in_month=days_in_month,
|
||||
)
|
||||
|
||||
|
||||
def sp20_report_summary(app: Flask, year: int, month: int) -> Dict[str, Any]:
|
||||
report = build_sp20_report(app, year, month)
|
||||
total_kg = sum(line.quantity_kg for line in report.lines)
|
||||
org = report.org
|
||||
return {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"lines_count": len(report.lines),
|
||||
"groups_count": len(report.groups),
|
||||
"total_kg": round(total_kg, 2),
|
||||
"has_org": bool((org.get("organization_name") or "").strip()),
|
||||
"has_requisites": bool(
|
||||
(org.get("organization_name") or "").strip()
|
||||
and (org.get("zootechnician") or "").strip()
|
||||
and (org.get("warehouse_keeper") or "").strip()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_journal_rows(app: Flask, year: int, month: int) -> List[Dict[str, Any]]:
|
||||
"""Журнал учёта расхода кормов: месяц + накопительно с начала года."""
|
||||
from app.services.sklad_metrics import month_date_bounds, period_consumption_rows
|
||||
|
||||
start_m, end_m = month_date_bounds(year, month)
|
||||
ytd_from = date(year, 1, 1).isoformat()
|
||||
month_from = start_m.isoformat()
|
||||
month_to = end_m.isoformat()
|
||||
month_rows = period_consumption_rows(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=month_from, date_to=month_to
|
||||
)
|
||||
ytd_rows = period_consumption_rows(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=ytd_from, date_to=month_to
|
||||
)
|
||||
ytd_map = {r["name_key"]: r["consumed_kg"] for r in ytd_rows}
|
||||
rows = []
|
||||
for item in month_rows:
|
||||
key = item["name_key"]
|
||||
rows.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"unit": "кг",
|
||||
"month_kg": item["consumed_kg"],
|
||||
"ytd_kg": ytd_map.get(key, item["consumed_kg"]),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Генерация Excel для учётных документов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import xlrd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from flask import Flask
|
||||
|
||||
from app import db
|
||||
from app.models import Ingredient, LoadingReport, LoadingReportComponent, Recipe
|
||||
from app.services.feed_accounting.sp20_report_builder import Sp20Report, build_journal_rows, build_sp20_report
|
||||
from app.services.sklad_metrics import list_stock_balance_items
|
||||
|
||||
|
||||
def _template_path(app: Flask) -> Path:
|
||||
p = Path(app.root_path).parent / "static" / "templates" / "forma-sp-20.xls"
|
||||
if not p.is_file():
|
||||
p = Path(app.static_folder or "") / "templates" / "forma-sp-20.xls"
|
||||
return p
|
||||
|
||||
|
||||
def _header_font():
|
||||
return Font(bold=True)
|
||||
|
||||
|
||||
def build_stock_balances_xlsx(app: Flask, date_from: str, date_to: str) -> bytes:
|
||||
items = list_stock_balance_items(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Остатки"
|
||||
ws.append(["Учёт кормов — остатки на складе"])
|
||||
ws.append([f"Период: {date_from} — {date_to}"])
|
||||
ws.append([])
|
||||
headers = [
|
||||
"Компонент",
|
||||
"Всего, кг",
|
||||
"Поступление, кг",
|
||||
"Израсходовано, кг",
|
||||
"Осталось, кг",
|
||||
"План кг/сут",
|
||||
"Осталось дней",
|
||||
]
|
||||
ws.append(headers)
|
||||
for h in range(1, len(headers) + 1):
|
||||
ws.cell(row=4, column=h).font = _header_font()
|
||||
for item in items:
|
||||
ws.append(
|
||||
[
|
||||
item["name"],
|
||||
item["total_kg"],
|
||||
item["inflow_kg"],
|
||||
item["consumed_total_kg"],
|
||||
item["remaining_kg"],
|
||||
item.get("planned_consumption_per_day_kg") or "",
|
||||
item.get("days_left_plan") if item.get("days_left_plan") is not None else "",
|
||||
]
|
||||
)
|
||||
for col in range(1, 8):
|
||||
ws.column_dimensions[get_column_letter(col)].width = 16
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
_XLRD_LINE_STYLE = {
|
||||
0: None,
|
||||
1: "thin",
|
||||
2: "medium",
|
||||
3: "dashed",
|
||||
4: "dotted",
|
||||
5: "thick",
|
||||
6: "double",
|
||||
7: "hair",
|
||||
8: "mediumDashed",
|
||||
9: "dashDot",
|
||||
10: "mediumDashDot",
|
||||
11: "dashDotDot",
|
||||
12: "mediumDashDotDot",
|
||||
13: "slantDashDot",
|
||||
}
|
||||
|
||||
_XLRD_HOR_ALIGN = {
|
||||
0: "general",
|
||||
1: "left",
|
||||
2: "center",
|
||||
3: "right",
|
||||
4: "fill",
|
||||
5: "justify",
|
||||
6: "centerContinuous",
|
||||
}
|
||||
|
||||
_XLRD_VERT_ALIGN = {
|
||||
0: "bottom",
|
||||
1: "top",
|
||||
2: "center",
|
||||
3: "justify",
|
||||
}
|
||||
|
||||
|
||||
def _xlrd_side(line_style: int) -> Side:
|
||||
style = _XLRD_LINE_STYLE.get(line_style)
|
||||
if not style:
|
||||
return Side()
|
||||
return Side(style=style)
|
||||
|
||||
|
||||
def _xlrd_border(xf_border) -> Border:
|
||||
return Border(
|
||||
left=_xlrd_side(xf_border.left_line_style),
|
||||
right=_xlrd_side(xf_border.right_line_style),
|
||||
top=_xlrd_side(xf_border.top_line_style),
|
||||
bottom=_xlrd_side(xf_border.bottom_line_style),
|
||||
)
|
||||
|
||||
|
||||
def _xlrd_font(xls_book, xf) -> Font:
|
||||
f = xls_book.font_list[xf.font_index]
|
||||
kwargs: dict = {}
|
||||
if f.bold:
|
||||
kwargs["bold"] = True
|
||||
if f.italic:
|
||||
kwargs["italic"] = True
|
||||
if f.underline_type:
|
||||
kwargs["underline"] = "single"
|
||||
if f.height:
|
||||
kwargs["size"] = max(6.0, f.height / 20.0)
|
||||
if f.name:
|
||||
kwargs["name"] = f.name
|
||||
return Font(**kwargs) if kwargs else Font()
|
||||
|
||||
|
||||
def _xlrd_alignment(xf) -> Alignment | None:
|
||||
a = xf.alignment
|
||||
kwargs: dict = {}
|
||||
if a.hor_align in _XLRD_HOR_ALIGN:
|
||||
kwargs["horizontal"] = _XLRD_HOR_ALIGN[a.hor_align]
|
||||
if a.vert_align in _XLRD_VERT_ALIGN:
|
||||
kwargs["vertical"] = _XLRD_VERT_ALIGN[a.vert_align]
|
||||
if a.text_wrapped:
|
||||
kwargs["wrap_text"] = True
|
||||
if a.rotation:
|
||||
kwargs["text_rotation"] = a.rotation
|
||||
return Alignment(**kwargs) if kwargs else None
|
||||
|
||||
|
||||
def _copy_cell_styles(xls_sheet, xls_book, ws) -> None:
|
||||
"""Границы, шрифты, выравнивание — без них форма не похожа на бланк."""
|
||||
for r in range(xls_sheet.nrows):
|
||||
for c in range(xls_sheet.ncols):
|
||||
xf_idx = xls_sheet.cell_xf_index(r, c)
|
||||
xf = xls_book.xf_list[xf_idx]
|
||||
cell = ws.cell(row=r + 1, column=c + 1)
|
||||
cell.border = _xlrd_border(xf.border)
|
||||
font = _xlrd_font(xls_book, xf)
|
||||
if font != Font():
|
||||
cell.font = font
|
||||
alignment = _xlrd_alignment(xf)
|
||||
if alignment is not None:
|
||||
cell.alignment = alignment
|
||||
|
||||
|
||||
def _copy_row_heights(xls_sheet, ws) -> None:
|
||||
for row_idx, info in xls_sheet.rowinfo_map.items():
|
||||
if info.height:
|
||||
ws.row_dimensions[row_idx + 1].height = info.height / 20.0
|
||||
|
||||
|
||||
def _copy_merged_cells(xls_sheet, ws) -> None:
|
||||
"""Перенос объединённых ячеек из xlrd в openpyxl (без этого форма «разъезжается»)."""
|
||||
for rlo, rhi, clo, chi in xls_sheet.merged_cells:
|
||||
ws.merge_cells(
|
||||
start_row=rlo + 1,
|
||||
end_row=rhi,
|
||||
start_column=clo + 1,
|
||||
end_column=chi,
|
||||
)
|
||||
|
||||
|
||||
def _copy_column_widths(xls_sheet, ws) -> None:
|
||||
for col_idx, colinfo in xls_sheet.colinfo_map.items():
|
||||
letter = get_column_letter(col_idx + 1)
|
||||
# xlrd: ширина в 1/256 символа; openpyxl — приблизительно в символах
|
||||
ws.column_dimensions[letter].width = max(2.0, colinfo.width / 256.0)
|
||||
|
||||
|
||||
def _copy_xls_sheet_to_ws(xls_sheet, xls_book, ws) -> None:
|
||||
for r in range(xls_sheet.nrows):
|
||||
for c in range(xls_sheet.ncols):
|
||||
val = xls_sheet.cell_value(r, c)
|
||||
if val != "" and val is not None:
|
||||
ws.cell(row=r + 1, column=c + 1, value=val)
|
||||
_copy_cell_styles(xls_sheet, xls_book, ws)
|
||||
_copy_merged_cells(xls_sheet, ws)
|
||||
_copy_column_widths(xls_sheet, ws)
|
||||
_copy_row_heights(xls_sheet, ws)
|
||||
|
||||
|
||||
def _fill_sp20_data_rows(
|
||||
ws,
|
||||
lines,
|
||||
year: int,
|
||||
month: int,
|
||||
*,
|
||||
start_row: int,
|
||||
end_row: int,
|
||||
) -> int:
|
||||
"""Заполняет строки таблицы (дата, головы, корм, кг). Возвращает индекс следующей строки."""
|
||||
row_idx = start_row
|
||||
for line in lines:
|
||||
if row_idx > end_row:
|
||||
break
|
||||
ws.cell(row=row_idx, column=1, value=f"{line.day:02d}.{month:02d}.{year}")
|
||||
ws.cell(row=row_idx, column=3, value=line.heads or "")
|
||||
ws.cell(row=row_idx, column=7, value=line.feed_name)
|
||||
ws.cell(row=row_idx, column=10, value=line.quantity_kg)
|
||||
row_idx += 1
|
||||
return row_idx
|
||||
|
||||
|
||||
def _fill_sp20_sheet1(ws, report: Sp20Report) -> None:
|
||||
org = report.org
|
||||
ws.cell(row=8, column=4, value=org.get("organization_name") or "")
|
||||
ws.cell(row=8, column=27, value=org.get("okpo") or "")
|
||||
ws.cell(row=9, column=5, value=org.get("department") or "")
|
||||
ws.cell(row=10, column=2, value=org.get("farm_name") or "")
|
||||
ws.cell(row=11, column=2, value=org.get("brigade") or "")
|
||||
ws.cell(row=7, column=27, value=date(report.year, report.month, 1).strftime("%d.%m.%Y"))
|
||||
ws.cell(row=14, column=1, value=report.animal_type)
|
||||
ws.cell(row=13, column=13, value=org.get("responsible_person") or org.get("zootechnician") or "")
|
||||
if report.lines:
|
||||
norm = report.lines[0].norm_per_head
|
||||
if norm is not None:
|
||||
ws.cell(row=15, column=7, value=norm)
|
||||
_fill_sp20_data_rows(ws, report.lines, report.year, report.month, start_row=20, end_row=31)
|
||||
|
||||
|
||||
def _fill_sp20_sheet2(ws, report: Sp20Report) -> None:
|
||||
"""Подписи и продолжение таблицы на стр. 2 формы."""
|
||||
org = report.org
|
||||
remaining = list(report.lines[12:]) if len(report.lines) > 12 else []
|
||||
if remaining:
|
||||
_fill_sp20_data_rows(ws, remaining, report.year, report.month, start_row=5, end_row=15)
|
||||
ws.cell(row=23, column=9, value=org.get("zootechnician") or "")
|
||||
ws.cell(row=23, column=26, value=org.get("brigade") or org.get("warehouse_keeper") or "")
|
||||
ws.cell(row=26, column=18, value=org.get("responsible_person") or "")
|
||||
|
||||
|
||||
def _add_groups_sheet(wb: Workbook, report: Sp20Report) -> None:
|
||||
ws = wb.create_sheet("Группы выгрузки")
|
||||
days = report.days_in_month
|
||||
header = ["Группа", "Рецепт", "Тип", "Значение"] + [str(d) for d in range(1, days + 1)] + ["Итого, кг"]
|
||||
ws.append(header)
|
||||
for cell in ws[1]:
|
||||
cell.font = _header_font()
|
||||
for g in report.groups:
|
||||
row = [
|
||||
g.get("group_name"),
|
||||
g.get("recipe_name"),
|
||||
g.get("distribution_type"),
|
||||
g.get("distribution_value"),
|
||||
]
|
||||
daily = g.get("daily") or {}
|
||||
total = 0.0
|
||||
for d in range(1, days + 1):
|
||||
v = float(daily.get(d, 0) or 0)
|
||||
row.append(round(v, 2) if v else "")
|
||||
total += v
|
||||
row.append(round(total, 2))
|
||||
ws.append(row)
|
||||
|
||||
|
||||
def _fill_journal_sheet(ws, journal_rows, org, year, month) -> None:
|
||||
ws.append(["Журнал учёта расхода кормов"])
|
||||
ws.append([org.get("organization_name") or ""])
|
||||
ws.append([f"Период: {month:02d}.{year}"])
|
||||
ws.append([])
|
||||
headers = ["Вид корма", "Ед.изм.", "Расход за месяц, кг", "С начала года, кг"]
|
||||
ws.append(headers)
|
||||
header_row = ws.max_row
|
||||
for cell in ws[header_row]:
|
||||
cell.font = _header_font()
|
||||
for row in journal_rows:
|
||||
ws.append([row["name"], row["unit"], row["month_kg"], row["ytd_kg"]])
|
||||
|
||||
|
||||
def build_groups_xlsx(app: Flask, year: int, month: int) -> bytes:
|
||||
report = build_sp20_report(app, year, month)
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
_add_groups_sheet(wb, report)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build_sp20_xlsx(app: Flask, year: int, month: int, *, include_journal: bool = False) -> bytes:
|
||||
"""Только официальные листы стр1 + стр2 (как forma-sp-20). Журнал и группы — отдельные файлы."""
|
||||
_ = include_journal # legacy query param, больше не добавляет листы в СП-20
|
||||
report = build_sp20_report(app, year, month)
|
||||
template = _template_path(app)
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
if template.is_file():
|
||||
book = xlrd.open_workbook(str(template), formatting_info=True)
|
||||
for si, name in enumerate(book.sheet_names()[:2]):
|
||||
xsh = book.sheet_by_index(si)
|
||||
ws = wb.create_sheet(title=name[:31])
|
||||
_copy_xls_sheet_to_ws(xsh, book, ws)
|
||||
_fill_sp20_sheet1(wb.worksheets[0], report)
|
||||
if len(wb.worksheets) > 1:
|
||||
_fill_sp20_sheet2(wb.worksheets[1], report)
|
||||
else:
|
||||
ws = wb.create_sheet("СП-20")
|
||||
ws.append(["Ведомость учёта расхода кормов (форма СП-20)"])
|
||||
_fill_sp20_sheet1(ws, report)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build_journal_xlsx(app: Flask, year: int, month: int) -> bytes:
|
||||
from app.services.org_settings import get_org_settings
|
||||
|
||||
org = get_org_settings(app)
|
||||
journal = build_journal_rows(app, year, month)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Журнал расхода"
|
||||
_fill_journal_sheet(ws, journal, org, year, month)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build_documents_zip(app: Flask, year: int, month: int, date_from: str, date_to: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(
|
||||
f"WESP_ostatki_{date_from}_{date_to}.xlsx",
|
||||
build_stock_balances_xlsx(app, date_from, date_to),
|
||||
)
|
||||
zf.writestr(f"WESP_SP20_{year}-{month:02d}.xlsx", build_sp20_xlsx(app, year, month))
|
||||
zf.writestr(f"WESP_gruppy_{year}-{month:02d}.xlsx", build_groups_xlsx(app, year, month))
|
||||
zf.writestr(f"WESP_zhurnal_{year}-{month:02d}.xlsx", build_journal_xlsx(app, year, month))
|
||||
try:
|
||||
from app.services.feed_accounting.pdf_feed_accounting import build_journal_pdf, build_sp20_pdf
|
||||
|
||||
zf.writestr(f"WESP_SP20_{year}-{month:02d}.pdf", build_sp20_pdf(app, year, month))
|
||||
zf.writestr(f"WESP_zhurnal_{year}-{month:02d}.pdf", build_journal_pdf(app, year, month))
|
||||
except Exception:
|
||||
pass
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user