Интегрирован 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
@@ -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()