"""Книга xlsx сводной ОСВ (spec consolidated-osv «Экспорт xlsx»; design D-14): чистая функция без БД. Лист «Сводная ОСВ» — строки свода в переданном порядке (порядок API, D-16), шесть сумм — числовые ячейки `#,##0.00` в рублях без деления. В ячейки пишется `Decimal` как есть: `float` в нашем коде не используется; `openpyxl` сам сериализует число в XML через `%.16g` (формат xlsx хранит числа как IEEE-754 double): значения до 10^13 с копейками читаются обратно точно, выше ~10^13 младшая копейка может потеряться (`96729051052621.71` → `…621.7`), а `9999999999999999.99` станет `1e+16`. Для сумм ОСВ в рублях вывод прежний: сам Excel держит 15 цифр. Лист «Пробелы» — пробелы свода целиком (`Consolidated.gaps`), фильтры строк на него не влияют; без пробелов — одна ячейка «пробелов нет». Тексты приходят из загруженных внешних файлов (R-12): в книгу они попадают только через `_text` / `_put_text` — запрещённые XML-символы вырезаются (иначе `IllegalCharacterError` → 500), тип ячейки принудительно строковый (`"s"`), поэтому ведущие `=`, `+`, `-`, `@` не превращаются в формулы Excel, а текст читается обратно без изменений. Строки пишутся по явному счётчику (`sheet.max_row` в `openpyxl` — O(n) на вызов, цикл по нему квадратичен). """ from collections.abc import Sequence from decimal import Decimal from io import BytesIO from openpyxl import Workbook from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE from openpyxl.utils import get_column_letter from openpyxl.worksheet.worksheet import Worksheet from src.domain.consolidated_osv import Consolidated, ConsolidatedRow __all__ = ["GAPS_SHEET", "MAIN_SHEET", "MONEY_FORMAT", "NO_GAPS", "build_workbook"] MAIN_SHEET = "Сводная ОСВ" GAPS_SHEET = "Пробелы" MONEY_FORMAT = "#,##0.00" NO_GAPS = "пробелов нет" MAIN_HEADER = ( "Компания ГК", "ИНН компании", "Счёт", "Период", "Контрагент", "ИНН контрагента", "Сальдо начало Дт", "Сальдо начало Кт", "Обороты Дт", "Обороты Кт", "Сальдо конец Дт", "Сальдо конец Кт", "Тип контрагента", "Сопоставлено с", "Способ сопоставления", ) # fmt: skip GAPS_HEADER = ("Компания ГК", "Счёт", "Нет ОСВ за кварталы") FIRST_MONEY_COLUMN, LAST_MONEY_COLUMN = 7, 12 def _widths(sheet: Worksheet, widths: Sequence[int]) -> None: for index, width in enumerate(widths, start=1): sheet.column_dimensions[get_column_letter(index)].width = width def _text(value: str | None) -> str | None: """Текст для ячейки: запрещённые XML-символы вырезаны (`IllegalCharacterError` → 500), `None` остаётся пустым.""" return None if value is None else ILLEGAL_CHARACTERS_RE.sub("", value) def _put_text(sheet: Worksheet, row: int, column: int, value: str | None) -> None: """Строковая ячейка: тип `"s"` принудительно — значение с ведущим `=` не становится формулой (R-12).""" text = _text(value) if text is None: return cell = sheet.cell(row=row, column=column, value=text) cell.data_type = "s" def _put_money(sheet: Worksheet, row: int, column: int, value: Decimal | None) -> None: """Числовая ячейка `#,##0.00`; `None` (сумма не задана) — пустая ячейка, не ноль.""" if value is None: return sheet.cell(row=row, column=column, value=value).number_format = MONEY_FORMAT def _main_sheet(sheet: Worksheet, rows: Sequence[ConsolidatedRow]) -> None: sheet.title = MAIN_SHEET sheet.append(MAIN_HEADER) for line, row in enumerate(rows, start=2): # явный счётчик строк: `sheet.max_row` — O(n) на вызов texts = (row.legal_entity_name, row.legal_entity_inn, row.account_code, row.period, row.counterparty_name, row.counterparty_inn) # fmt: skip for column, text in enumerate(texts, start=1): _put_text(sheet, line, column, text) amounts = (row.opening_debit, row.opening_credit, row.turnover_debit, row.turnover_credit, row.closing_debit, row.closing_credit) # fmt: skip for column, amount in enumerate(amounts, start=FIRST_MONEY_COLUMN): _put_money(sheet, line, column, amount) _put_text(sheet, line, LAST_MONEY_COLUMN + 1, row.counterparty_type.value) _put_text(sheet, line, LAST_MONEY_COLUMN + 2, row.counterparty_entity_name) _put_text(sheet, line, LAST_MONEY_COLUMN + 3, None if row.match_kind is None else row.match_kind.value) sheet.freeze_panes = "A2" _widths(sheet, (32, 14, 8, 10, 36, 14, 16, 16, 16, 16, 16, 16, 12, 32, 14)) def _gaps_sheet(sheet: Worksheet, consolidated: Consolidated) -> None: sheet.title = GAPS_SHEET if not consolidated.gaps: sheet.append([NO_GAPS]) return sheet.append(GAPS_HEADER) for line, gap in enumerate(consolidated.gaps, start=2): _put_text(sheet, line, 1, gap.legal_entity_name) _put_text(sheet, line, 2, gap.account_code) _put_text(sheet, line, 3, ", ".join(quarter.value for quarter in gap.missing_quarters)) _widths(sheet, (32, 8, 24)) def build_workbook(consolidated: Consolidated, rows: Sequence[ConsolidatedRow]) -> bytes: """Книга из двух листов: `rows` (уже отфильтрованные строки свода) и пробелы `consolidated.gaps`. Чистая функция: только память (`BytesIO`), ни БД, ни S3; время линейно по числу строк.""" workbook = Workbook() main = workbook.active assert main is not None # новая книга всегда с активным листом _main_sheet(main, rows) _gaps_sheet(workbook.create_sheet(), consolidated) buffer = BytesIO() workbook.save(buffer) return buffer.getvalue()