Merge pull request 'ОСВ: имена частей xlsx без учёта регистра, «Кол-во, <единица>» и «Кол. доп.» (#66, AURORA-1707, AURORA-1709)' (#67) from feat/66-osv-quantity-sharedstrings into main

This commit is contained in:
aep 2026-10-20 12:09:04 +03:00
commit 6c81199a1a
8 changed files with 188 additions and 11 deletions

View File

@ -119,7 +119,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| Модуль | Что делает | | Модуль | Что делает |
|---|---| |---|---|
| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) | | `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») | | `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl; части архива, отличающиеся от ссылок `[Content_Types].xml`/`*.rels` только регистром (`xl/SharedStrings.xml`), до openpyxl переименовываются по ссылкам, как их находит Excel — AURORA-1707; xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») |
| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте | | `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | | `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
@ -163,7 +163,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
### Строки-показатели и колонка «Показатели» (§1.3, AURORA-1613, AURORA-1661) ### Строки-показатели и колонка «Показатели» (§1.3, AURORA-1613, AURORA-1661)
Колонка «Показатели» (`service_columns.indicator`; заголовок часто «Показа-тели») несёт вид суммы строки: «БУ» — обычная Колонка «Показатели» (`service_columns.indicator`; заголовок часто «Показа-тели») несёт вид суммы строки: «БУ» — обычная
строка данных (`data_type = BU`), «НУ», «Вал.», «Кол.» — показатели (`NU`/`CURRENCY`/`QUANTITY` по словарю строка данных (`data_type = BU`), «НУ», «Вал.», «Кол.», «Кол. доп.» — показатели (`NU`/`CURRENCY`/`QUANTITY` по словарю
`data_types.yaml`: `indicator_column`), «ПР», «ВР» (`indicator_column_untyped`, `data_type = null`) и неизвестная метка — показатель с `data_types.yaml`: `indicator_column`), «ПР», «ВР» (`indicator_column_untyped`, `data_type = null`) и неизвестная метка — показатель с
`data_type = null` (неизвестная — ещё и предупреждение `OSV_ROWS`). Показатель — строка с признаком `indicator`: он `data_type = null` (неизвестная — ещё и предупреждение `OSV_ROWS`). Показатель — строка с признаком `indicator`: он
не входит в суммирование «сумма детей = родитель» и не проверяется как родитель, его родитель в дереве — предыдущая строка не входит в суммирование «сумма детей = родитель» и не проверяется как родитель, его родитель в дереве — предыдущая строка
@ -226,7 +226,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки), Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
`osv_groups.yaml` (группировки → `ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER`), `periods.yaml` (период → квартал и `osv_groups.yaml` (группировки → `ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER`), `periods.yaml` (период → квартал и
нарастающий итог), `data_types.yaml` (БУ/НУ/сумма, строки «Вал.»/«Кол.», коды и написания валют), `osv_checks.yaml` (счета без сальдо нарастающий итог), `data_types.yaml` (БУ/НУ/сумма, строки «Вал.»/«Кол.»/«Кол-во, <единица>», коды и написания валют), `osv_checks.yaml` (счета без сальдо
по аналитике — вне тождества строки). Формат и правила по аналитике — вне тождества строки). Формат и правила
записи паттернов — [`api/rules/README.md`](api/rules/README.md). записи паттернов — [`api/rules/README.md`](api/rules/README.md).

View File

@ -14,7 +14,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (состав `90`, `91`, `99`; OQ-132, 90 добавлен 19.10.2026): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения | | `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (состав `90`, `91`, `99`; OQ-132, 90 добавлен 19.10.2026): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.», «Кол-во», «Кол-во, <единица>» -> `CURRENCY` / `QUANTITY`; метки колонки «Показатели» (`indicator_column`: «Кол.», «Кол. доп.» -> `QUANTITY`); коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
Словари форм ББ/ОФР (`statement_*.yaml` в прежней версии) удалены из сервиса: их держит пакет `arm_statements` Словари форм ББ/ОФР (`statement_*.yaml` в прежней версии) удалены из сервиса: их держит пакет `arm_statements`
(`api/arm_statements/rules/`), сервис их не читает. (`api/arm_statements/rules/`), сервис их не читает.

View File

@ -14,10 +14,10 @@ standalone_header:
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную. «Вал» без точки — не метка: # Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную. «Вал» без точки — не метка:
# так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код валюты из `currency_codes` (AURORA-1663). # так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код валюты из `currency_codes` (AURORA-1663).
# «Кол-во»: нормализация текста превращает дефис в пробел # «Кол-во»: нормализация текста превращает дефис в пробел; после запятой — единица измерения («Кол-во, га», AURORA-1709)
row: row:
- ['^вал\.$', CURRENCY] - ['^вал\.$', CURRENCY]
- ['^кол(ичество|\s?во)?\.?$', QUANTITY] - ['^кол(ичество|\s?во)?\.?(\s*,\s*[^,]+)?$', QUANTITY]
# строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях # строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях
- ['^в\s+валюте$', CURRENCY] - ['^в\s+валюте$', CURRENCY]
@ -26,7 +26,8 @@ row:
indicator_column: indicator_column:
- ['^бу$', BU] - ['^бу$', BU]
- ['^ну$', NU] - ['^ну$', NU]
- ['^кол\.?$', QUANTITY] # «Кол. доп.» — дополнительное количество (сч. 11, животные на выращивании; AURORA-1709)
- ['^кол(\.?|(\.\s*|\s+)доп\.?)$', QUANTITY]
- ['^вал\.?$', CURRENCY] - ['^вал\.?$', CURRENCY]
# Метки-показатели без значения data_type в контракте (постоянные и временные разницы): data_type = null. # Метки-показатели без значения data_type в контракте (постоянные и временные разницы): data_type = null.

View File

@ -1,7 +1,11 @@
"""Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт).""" """Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт)."""
import io import io
import posixpath
import zipfile
from collections import Counter
from typing import Any from typing import Any
from xml.etree import ElementTree
import openpyxl import openpyxl
@ -10,15 +14,79 @@ from src.parser.readers._common import make_cell
__all__ = ["read_xlsx"] __all__ = ["read_xlsx"]
_CONTENT_TYPES_PART = "[Content_Types].xml"
_RELS_SUFFIX = ".rels"
_RELS_DIR = "_rels"
_LOCAL_OVERRIDE = "Override"
_LOCAL_RELATIONSHIP = "Relationship"
_EXTERNAL_TARGET_MODE = "External"
def read_xlsx(data: bytes) -> tuple[Sheet, ...]: def read_xlsx(data: bytes) -> tuple[Sheet, ...]:
book = openpyxl.load_workbook(io.BytesIO(data), data_only=True, read_only=False) book = openpyxl.load_workbook(io.BytesIO(_with_referenced_part_names(data)), data_only=True, read_only=False)
try: try:
return tuple(_sheet(worksheet) for worksheet in book.worksheets) return tuple(_sheet(worksheet) for worksheet in book.worksheets)
finally: finally:
book.close() book.close()
def _with_referenced_part_names(data: bytes) -> bytes:
"""Книга, в которой части архива названы так, как на них ссылаются `[Content_Types].xml` и `*.rels`.
Excel сравнивает имена частей без учёта регистра (OPC), openpyxl — точно: таблица строк `xl/SharedStrings.xml` при
ссылке на `sharedStrings.xml` даёт KeyError, а лист `Sheet1.xml` при ссылке на `sheet1.xml` пропадает молча
(AURORA-1707). Если часть отличается от ссылки только регистром, архив пересобирается в памяти без сжатия
(распакованный размер уже ограничен `_detect`); иначе возвращаются те же байты. Две части, подходящие к одной
ссылке, не переименовываются: какую из них имел в виду автор, неизвестно.
"""
with zipfile.ZipFile(io.BytesIO(data)) as archive:
names = archive.namelist()
present = set(names)
wanted = {name.lower(): name for name in _referenced_part_names(archive, present) if name not in present}
candidates = Counter(name.lower() for name in names if name.lower() in wanted)
renames = {name: wanted[name.lower()] for name in names if candidates[name.lower()] == 1}
if not renames:
return data
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_STORED) as target:
for info in archive.infolist():
target.writestr(renames.get(info.filename, info.filename), archive.read(info))
return buffer.getvalue()
def _referenced_part_names(archive: zipfile.ZipFile, present: set[str]) -> set[str]:
"""Имена частей (без ведущего `/`) из `Override PartName` и из `Target` внутренних связей всех `*.rels`.
Нечитаемый XML пропускается: книгу с ним не откроет и openpyxl, ошибку покажет он."""
referenced: set[str] = set()
if _CONTENT_TYPES_PART in present:
for element in _elements(archive, _CONTENT_TYPES_PART, _LOCAL_OVERRIDE):
if part := element.get("PartName"):
referenced.add(part.lstrip("/"))
for rels in present:
if not rels.endswith(_RELS_SUFFIX):
continue
# `xl/_rels/workbook.xml.rels` описывает связи `xl/workbook.xml`: цели относительно `xl/`
rels_dir = posixpath.dirname(rels)
base = posixpath.dirname(rels_dir) if posixpath.basename(rels_dir) == _RELS_DIR else ""
for element in _elements(archive, rels, _LOCAL_RELATIONSHIP):
target = element.get("Target")
if not target or element.get("TargetMode") == _EXTERNAL_TARGET_MODE:
continue
path = target.lstrip("/") if target.startswith("/") else posixpath.join(base, target)
referenced.add(posixpath.normpath(path))
return referenced
def _elements(archive: zipfile.ZipFile, name: str, local_name: str) -> list[ElementTree.Element]:
"""Элементы части `name` с локальным именем `local_name` (пространство имён не важно)."""
try:
root = ElementTree.fromstring(archive.read(name))
except ElementTree.ParseError:
return []
return [element for element in root.iter() if element.tag.rpartition("}")[2] == local_name]
def _sheet(ws: Any) -> Sheet: def _sheet(ws: Any) -> Sheet:
cells: dict[tuple[int, int], Cell] = {} cells: dict[tuple[int, int], Cell] = {}
# `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь # `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь

View File

@ -420,6 +420,13 @@ def test_currency_and_quantity_rows(rules: Rules) -> None:
("Количество", "QUANTITY"), ("Количество", "QUANTITY"),
("Кол-во", "QUANTITY"), # AURORA-1663, O-5 ("Кол-во", "QUANTITY"), # AURORA-1663, O-5
("кол-во.", "QUANTITY"), ("кол-во.", "QUANTITY"),
# единица измерения после запятой (AURORA-1709, ОСВ 43.1: строка под позицией)
("Кол-во, га", "QUANTITY"),
("Кол-во, кг", "QUANTITY"),
("Количество, шт.", "QUANTITY"),
("Колхоз, ОАО", None),
("Колос", None),
("Кол-во, га, кг", None),
("Вал.", "CURRENCY"), ("Вал.", "CURRENCY"),
("USD", "CURRENCY"), ("USD", "CURRENCY"),
("Вал", None), # AURORA-1663, O-6: «Вал» без точки — наименование материала (ОСВ 10.1), не метка валюты ("Вал", None), # AURORA-1663, O-6: «Вал» без точки — наименование материала (ОСВ 10.1), не метка валюты

View File

@ -53,6 +53,20 @@ def test_untyped_label_has_no_data_type_and_no_warning(rules: Rules) -> None:
assert (result.indicator, result.data_type, result.text, result.warning) == (True, None, "ПР", None) assert (result.indicator, result.data_type, result.text, result.warning) == (True, None, "ПР", None)
@pytest.mark.parametrize("label", ["Кол.", "Кол", "Кол. доп.", "Кол.доп."])
def test_quantity_labels_including_additional_quantity(rules: Rules, label: str) -> None:
"""«Кол. доп.» — дополнительное количество (сч. 11, животные на выращивании; AURORA-1709)."""
result, _ = _read(label, rules)
assert (result.indicator, result.data_type, result.text, result.warning) == (True, "QUANTITY", label, None)
@pytest.mark.parametrize("label", ["Колдоп", "Кол. доп. ед.", "Колос"])
def test_quantity_label_needs_exact_form(rules: Rules, label: str) -> None:
result, _ = _read(label, rules)
assert (result.indicator, result.data_type) == (True, None)
assert result.warning is not None
def test_unknown_label_warns_with_row_number(rules: Rules) -> None: def test_unknown_label_warns_with_row_number(rules: Rules) -> None:
result, row = _read("Ед.", rules) result, row = _read("Ед.", rules)
assert (result.indicator, result.data_type) == (True, None) assert (result.indicator, result.data_type) == (True, None)

View File

@ -195,6 +195,86 @@ def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None:
read_book(_zip_with("hello.txt"), "XLSX") read_book(_zip_with("hello.txt"), "XLSX")
def _renamed_parts(data: bytes, renames: dict[str, str]) -> bytes:
"""Та же книга, у частей из `renames` — другое имя в архиве; ссылки `[Content_Types].xml` и `*.rels` прежние."""
buffer = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target:
for info in source.infolist():
target.writestr(renames.get(info.filename, info.filename), source.read(info))
return buffer.getvalue()
def _xlsx_with_shared_strings(part_name: str) -> bytes:
"""Минимальная книга с таблицей общих строк (openpyxl пишет строки inline) под именем `part_name` в архиве;
`[Content_Types].xml` и `workbook.xml.rels` ссылаются на `xl/sharedStrings.xml`."""
main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
ct = "application/vnd.openxmlformats-officedocument.spreadsheetml"
parts = {
"[Content_Types].xml": (
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
f'<Override PartName="/xl/workbook.xml" ContentType="{ct}.sheet.main+xml"/>'
f'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="{ct}.worksheet+xml"/>'
f'<Override PartName="/xl/sharedStrings.xml" ContentType="{ct}.sharedStrings+xml"/>'
"</Types>"
),
"_rels/.rels": (
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
f'<Relationship Id="rId1" Type="{rel}/officeDocument" Target="xl/workbook.xml"/>'
"</Relationships>"
),
"xl/workbook.xml": (
f'<workbook xmlns="{main}" xmlns:r="{rel}">'
'<sheets><sheet name="ОСВ" sheetId="1" r:id="rId1"/></sheets></workbook>'
),
"xl/_rels/workbook.xml.rels": (
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
f'<Relationship Id="rId1" Type="{rel}/worksheet" Target="worksheets/sheet1.xml"/>'
f'<Relationship Id="rId2" Type="{rel}/sharedStrings" Target="sharedStrings.xml"/>'
"</Relationships>"
),
"xl/worksheets/sheet1.xml": (
f'<worksheet xmlns="{main}"><sheetData>'
'<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>1500</v></c></row>'
"</sheetData></worksheet>"
),
part_name: f'<sst xmlns="{main}" count="1" uniqueCount="1"><si><t>Счет 62</t></si></sst>',
}
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for name, xml in parts.items():
archive.writestr(name, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + xml)
return buffer.getvalue()
def test_shared_strings_part_name_is_case_insensitive_like_in_excel() -> None:
"""Некоторые выгрузки кладут таблицу строк как `xl/SharedStrings.xml` (AURORA-1707); Excel их открывает."""
sheets, warnings = read_book(_xlsx_with_shared_strings("xl/SharedStrings.xml"), "XLSX")
assert (sheets, warnings) == read_book(_xlsx_with_shared_strings("xl/sharedStrings.xml"), "XLSX")
assert sheets[0].cell(1, 1) == Cell("Счет 62", "text")
def test_two_parts_matching_one_reference_are_not_guessed() -> None:
data = _xlsx_with_shared_strings("xl/SharedStrings.xml")
buffer = io.BytesIO(data)
with zipfile.ZipFile(buffer, "a") as archive:
archive.writestr("xl/SHAREDSTRINGS.xml", archive.read("xl/SharedStrings.xml"))
with pytest.raises(UnreadableError, match="sharedStrings.xml"):
read_book(buffer.getvalue(), "XLSX")
def test_part_found_by_relationship_is_case_insensitive_too() -> None:
"""Лист openpyxl находит по ссылке из `workbook.xml.rels`, стили — по `[Content_Types].xml`."""
original = _synthetic_xlsx()
renames = {"xl/worksheets/sheet1.xml": "xl/worksheets/Sheet1.xml", "xl/styles.xml": "XL/Styles.XML"}
assert read_book(_renamed_parts(original, renames), "XLSX") == read_book(original, "XLSX")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("value", "expected_text"), ("value", "expected_text"),
[ [

View File

@ -336,7 +336,9 @@ balance_identity_violations, parent_sum_violations}`. Результат про
### Requirement: Форматы, лимиты, время ### Requirement: Форматы, лимиты, время
Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен — Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение файла `FILE_TYPE_MISMATCH` (`severity = info`) в `metrics.warnings`. каскад начинается с отступа). Имена частей xlsx SHALL сравниваться без учёта регистра, как в Excel (AURORA-1707): часть,
отличающаяся от ссылки `[Content_Types].xml` (`Override PartName`) или `*.rels` (`Target` внутренней связи) только
регистром, читается под именем из ссылки; две части под одну ссылку не угадываются. Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение файла `FILE_TYPE_MISMATCH` (`severity = info`) в `metrics.warnings`.
Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×); объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
@ -364,6 +366,11 @@ balance_identity_violations, parent_sum_violations}`. Результат про
- **WHEN** запрос говорит `XLSX`, а байты — BIFF8 - **WHEN** запрос говорит `XLSX`, а байты — BIFF8
- **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому - **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому
#### Scenario: Имя части xlsx в другом регистре
- **WHEN** таблица строк лежит в архиве как `xl/SharedStrings.xml`, а ссылки ведут на `xl/sharedStrings.xml` (или лист
`Sheet1.xml` при ссылке на `sheet1.xml`)
- **THEN** книга читается так же, как с именами из ссылок; не `UNREADABLE` и без потери листа
#### Scenario: xls без кодовой страницы #### Scenario: xls без кодовой страницы
- **WHEN** разбирается xls без записи CODEPAGE (синтетика, `codepage=None`) - **WHEN** разбирается xls без записи CODEPAGE (синтетика, `codepage=None`)
- **THEN** строки прочитаны как cp1251, в `metrics.warnings` — предупреждение файла `XLS_NO_CODEPAGE` (`info`, без - **THEN** строки прочитаны как cp1251, в `metrics.warnings` — предупреждение файла `XLS_NO_CODEPAGE` (`info`, без
@ -538,11 +545,11 @@ design #14).
Строка-показатель — строка, которая несёт вид сумм предыдущей строки данных, а не уровень аналитики. Парсер SHALL считать Строка-показатель — строка, которая несёт вид сумм предыдущей строки данных, а не уровень аналитики. Парсер SHALL считать
строку показателем, если: строку показателем, если:
1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`, 1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`,
«Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.» → «Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.», «Кол. доп.» →
QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`) QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`)
→ `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в → `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в
колонке «Показатели» не распознана — строка не участвует в проверках»; колонке «Показатели» не распознана — строка не участвует в проверках»;
2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «В валюте» → CURRENCY/QUANTITY) или 2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «Кол-во, <единица>», «В валюте» → CURRENCY/QUANTITY) или
равен коду валюты из `currency_codes`. равен коду валюты из `currency_codes`.
Строка «Валюта <код>» («Валюта USD») MUST NOT считаться показателем: это строка аналитики с рублёвыми суммами; код валюты Строка «Валюта <код>» («Валюта USD») MUST NOT считаться показателем: это строка аналитики с рублёвыми суммами; код валюты