Строки пишутся по явному счётчику (sheet.max_row делал сборку квадратичной: 6 000 строк — 8.4 с → 0.5 с), сборка вынесена в run_in_threadpool. Тексты проходят _put_text: запрещённые XML-символы вырезаются, тип ячейки строковый — ведущие =, +, -, @ не становятся формулами (R-12, design #77). Параметр entity_names удалён (R-13, #78), порог точности double в #73 исправлен, тесты пустых сумм и 6 000 строк. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
113 lines
6.6 KiB
Python
113 lines
6.6 KiB
Python
"""Книга 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()
|