"""Генерация 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()