arm-core/api/src/services/osv_export.py
Andrey Epifancev 5b3b1011cb fix(consolidation): линейная сборка xlsx, защита от формул и управляющих символов в экспорте
Строки пишутся по явному счётчику (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
2026-10-06 07:12:45 +04:00

113 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Книга 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()