Влил main в ветку ревью AURORA-1677

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 16:33:17 +04:00
commit 22a65ee67f
22 changed files with 852 additions and 43 deletions

View File

@ -10,7 +10,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
| Файл | Ключи | Что описывает |
|---|---|---|
| `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
| `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account` — «по счету», «Счет Поставщика/Клиента», `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
| `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`: записи — коды счетов, а не регулярные выражения |
| `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` — конец текста периода в строке шапки («г.», «год») |

View File

@ -4,6 +4,9 @@ mapping:
- ['^(счета?|субконто):?$', ACCOUNT]
- ['^подразделени', DEPARTMENT]
- ['^контрагент', COUNTERPARTY]
# вид субконто «Поставщики» (выгрузка 1С «ОСВ по счету 60 по контрагентам», строка внутри таблицы) — контрагенты;
# только целиком: название контрагента «Поставщики ООО» этой записью не узнаётся
- ['^поставщики$', COUNTERPARTY]
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число
# «Организация» — собственная организация учёта (измерение шапки), контрагентом не является
- ['^организации\b', COUNTERPARTY]

View File

@ -50,6 +50,13 @@ total_row:
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
total_row_exact:
- '^итого$'
# выгрузка 1С «по контрагентам»: итог листа — «ИТОГ» (без «О»); «ИТОГ по 60.111» — итог субсчёта (subaccount_sums)
- '^итог$'
# Итог субсчёта в выгрузке 1С «по контрагентам»: «ИТОГ по 60.111» — суммы строки-счёта с тем же кодом (группа account);
# отдельной строкой в результат не попадает (05-domain-rules.md §1.3, AURORA-1714)
subaccount_sums:
- '^итог\s+по\s+(?P<account>\S+)$'
# Вторая итоговая строка («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная. Стоит сразу после основной
# «Итого» или ПЕРЕД ней (плоская форма 1С, #59); сверяется основная
@ -77,10 +84,15 @@ inn_kpp_cell:
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
header_markers:
# «по счету 60 за …»: код счёта — сразу после совпадения
# выгрузка 1С «ОСВ по счёту 60 по контрагентам»: «Счет Поставщика» | «60*» — метка и код в соседних ячейках строки
account:
- '\bпо\s+счету\b'
- '\bсчет\s+(поставщика|клиента)\b'
# вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.»
period:
# выгрузка 1С «по контрагентам»: «За период» | «01.01.25..30.06.25» — значение в соседней ячейке строки; запись
# стоит первой: «\bза\s+» ниже совпала бы с «За период» и оставила бы хвостом слово «период»
- '^за\s+период\b\s*:?\s*'
- '\bза\s+'
- '^период\s*:\s*'
# «Выводимые данные: БУ (данные бухгалтерского учета)»

View File

@ -10,10 +10,10 @@ rules:
- {pattern: '\b(3|iii)\s*(й|я|ый|ой)?\s*кв', quarter: Q3, is_cumulative: false}
- {pattern: '\b(4|iv)\s*(й|я|ый|ой)?\s*кв', quarter: Q4, is_cumulative: false}
# Нарастающие периоды: месяцы, диапазон месяцев, диапазон дат «01.01 - 31.03», «с 01.01.2025 по 31.03.2025»
- {pattern: '\b3\s*мес|январ\w*\s+(по\s+)?март|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.03', quarter: Q1, is_cumulative: true}
- {pattern: 'полугоди|\b6\s*мес|январ\w*\s+(по\s+)?июн|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.06', quarter: Q2, is_cumulative: true}
- {pattern: '\b9\s*мес|январ\w*\s+(по\s+)?сентябр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.09', quarter: Q3, is_cumulative: true}
- {pattern: '\b12\s*мес|январ\w*\s+(по\s+)?декабр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.12|годов|\bгод\b', quarter: Q4, is_cumulative: true}
- {pattern: '\b3\s*мес|январ\w*\s+(по\s+)?март|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)31\.03', quarter: Q1, is_cumulative: true}
- {pattern: 'полугоди|\b6\s*мес|январ\w*\s+(по\s+)?июн|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)30\.06', quarter: Q2, is_cumulative: true}
- {pattern: '\b9\s*мес|январ\w*\s+(по\s+)?сентябр|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)30\.09', quarter: Q3, is_cumulative: true}
- {pattern: '\b12\s*мес|январ\w*\s+(по\s+)?декабр|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)31\.12|годов|\bгод\b', quarter: Q4, is_cumulative: true}
# Год: «за 2024 г.», «2024 год»; год в конце даты («31.03.2025 г.») годом периода не считается
- {pattern: '(?<![\d.])(19|20)\d{2}\s*(г\b|год)', quarter: Q4, is_cumulative: true}
# В тексте периода только год: «2024», «за 2024»
@ -22,6 +22,10 @@ rules:
# Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер)
year: '(?<!\d)(19|20)\d{2}(?!\d)'
# Год из двух цифр в дате «01.01.25..30.06.25» (группа yy): 20yy; берётся последняя дата текста — конец диапазона.
# Используется, только если года из четырёх цифр в тексте нет (§1.5, AURORA-1714)
short_year_date: '(?<!\d)\d{2}\.\d{2}\.(?P<yy>\d{2})(?![\d.]*\d)'
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
# без совпадения период идёт до конца строки
end: '\bг\.|\bгода?\b'

View File

@ -12,7 +12,8 @@
разными сторонами сальдо);
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда
корень и «Итого развернутое» развёрнуты, а «Итого» свёрнута, AURORA-1708); корня нет
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы.
@ -225,14 +226,51 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence
if roots:
return _sums_match(_values(total), _sum_of(roots))
return all(abs(value) <= TOLERANCE for value in _values(total))
if not _differs(_values(total), _values(root)):
if _matches_single_root(rows, total, root, secondary_totals):
return True
roots = _level_zero(rows)
# Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски, что у родителя и детей (R-21): 1С показывает «Итого»
# свёрнутым сальдо, а субсчета — развёрнутым. Обороты по-прежнему по колонкам.
# Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски (R-21)
return len(roots) > 1 and _sums_match(_values(total), _sum_of(roots))
def _matches_single_root(
rows: Sequence[ClassifiedRow], total: ClassifiedRow, root: ClassifiedRow, secondary: Sequence[Pattern]
) -> bool:
"""«Итого» = корень: по колонкам; корень — «ребёнок» итоговой строки, те же допуски, что у родителя и детей
(R-21, AURORA-1708): 1С показывает «Итого» свёрнутым сальдо, а счёт (и «Итого развернутое») — развёрнутым, поэтому
при свёрнутом сальдо «Итого» оно сверяется по нетто. «Итого развернутое» = корню по колонкам — основная «Итого»
сверяется с корнем по нетто сальдо и оборотам по колонкам."""
if not _differs(_values(total), _values(root)) or _sums_match(_values(total), _values(root)):
return True
expanded = _expanded_total(rows, secondary)
return (
expanded is not None
and not _differs(_values(expanded), _values(root))
and _net_matches(_values(total), _values(root))
)
def _expanded_total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern]) -> ClassifiedRow | None:
"""Вторая итоговая строка («Итого развернутое»): первая TOTAL не-показатель, чей текст матчит `secondary`."""
return next(
(
item
for item in rows
if item.group_type == "TOTAL" and not item.indicator and any(p.search(item.body.text) for p in secondary)
),
None,
)
def _net_matches(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
"""Обороты по колонкам, сальдо начала и конца по нетто «дебет − кредит» (±0.01)."""
return (
abs(_net(left[_OPENING]) - _net(right[_OPENING])) <= TOLERANCE
and not _differs(left[_TURNOVER], right[_TURNOVER])
and abs(_net(left[_CLOSING]) - _net(right[_CLOSING])) <= TOLERANCE
)
def _rows_text(rows: Iterable[int]) -> str:
numbers = list(rows)
shown = ", ".join(str(number) for number in numbers[:WARNING_ROWS_SHOWN])

View File

@ -21,14 +21,21 @@
import re
from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal
from src.contract.common import DataType
from src.contract.result import OsvGroup
from src.parser.deadline import Deadline
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
from src.parser.detect import (
GROUP_ORDER,
ColumnMap,
TableHeader,
first_data_row,
is_header_repeat,
table_kind_row,
)
from src.parser.grid import Sheet, column_letter
from src.parser.hierarchy import BodyRow, Levels
from src.parser.indicators import is_indicator_total, read_label
@ -37,6 +44,7 @@ from src.parser.rules import GroupType, Rules
__all__ = [
"ACCOUNT_RE",
"match_account",
"DEADLINE_CHECK_EVERY_ROWS",
"SUM_FIELDS",
"ClassifiedRow",
@ -53,7 +61,20 @@ __all__ = [
# `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки
# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3,
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
_SEGMENT_SEPARATOR = "."
def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None:
"""Код счёта в тексте строки: `ACCOUNT_RE`, а при счёте шапки — и его субсчета с 1–3 цифрами в сегменте."""
found = ACCOUNT_RE.match(text)
if found is not None or not header_account or not text.startswith(f"{header_account}{_SEGMENT_SEPARATOR}"):
return found
return _WIDE_ACCOUNT_RE.match(text)
# Сдвиг на одну колонку текста в «индентах» строки: больше любого отступа ячейки
LABEL_COLUMN_INDENT_STEP = 1000
# Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных
@ -101,7 +122,10 @@ def _table_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadl
Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные.
"""
found: list[int] = []
kind_row = table_kind_row(sheet, header, rules) # вид субконто строкой в таблице — не данные
for index, row in enumerate(range(first_data_row(sheet, header), sheet.n_rows + 1), start=1):
if kind_row is not None and row == kind_row[0]:
continue
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check("строки таблицы")
if sheet.is_row_empty(row) or not _in_table(sheet, row, header):
@ -402,6 +426,8 @@ class _Context:
currency_codes: frozenset[str]
inns: Mapping[int, str]
unpaired: AbstractSet[int]
header_account: str | None = None
names: Mapping[int, str] = field(default_factory=dict) # названия контрагентов, склеенные с кодом (`code_blocks`)
def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]:
@ -419,7 +445,7 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки
own = item.label_data_type or context.doc_data_type
account = ACCOUNT_RE.match(item.text) if item.is_account else None
account = match_account(item.text, context.header_account) if item.is_account else None
if account is not None:
code = account.group(1)
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
@ -443,12 +469,14 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
)
if by_account:
group_type = "COUNTERPARTY"
if group_type == "OTHER" and name is None and item.role == "contract":
group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`)
if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, own), branch
inn_col = context.header.inn_col
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules)
counterparty, in_name = split_inn_suffix(context.names.get(item.row, item.text), context.rules)
inn = column or context.inns.get(item.row) or in_name
kind = _Kind(
group_type,
@ -475,6 +503,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
inns: Mapping[int, str] | None = None,
unpaired: AbstractSet[int] = frozenset(),
header_account: str | None = None,
names: Mapping[int, str] | None = None,
) -> list[ClassifiedRow]:
"""Классификация строк по дереву `levels` и схеме группировок.
@ -494,7 +523,16 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
получают его код.
"""
context = _Context(
schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired
schema,
header,
sheet,
doc_data_type,
rules,
frozenset(rules.data_types.currency_codes),
inns or {},
unpaired,
header_account,
names or {},
)
extra_keys = _extra_keys(sheet, header, rows)
root_branch = _Branch(header_account, 0)

View File

@ -0,0 +1,127 @@
"""Блоки субсчетов с кодами контрагентов (`05-domain-rules.md` §1.3, AURORA-1714, решение владельца 27.10.2026).
Выгрузка 1С «ОСВ по счету 60 по контрагентам»: строка-субсчёт без сумм (`60.111`), внутри — код контрагента с суммами,
строка названия без сумм и договоры с суммами, в конце блока — «ИТОГ по 60.111». Шаг работает над строками тела между
сбором строк (`collect_body_rows`) и иерархией:
1. «ИТОГ по <субсчёт>» (`subaccount_sums`, словарь) отдаёт шесть сумм строке-счёту с тем же кодом, у которой сумм нет, и
сама из тела исчезает (в `rows` контракта отдельной строкой не выдаётся);
2. в блоке такого субсчёта строка с суммами, за которой идёт строка без сумм, — контрагент (`role = counterparty`);
следующая за ней строка без сумм — его название: склеивается с ним (`names`) и из тела исчезает; прочие строки с
суммами после контрагента — его договоры (`role = contract`).
Без «ИТОГ по …», сопоставленного субсчёту, шаг ничего не меняет: раскладка узнаётся по ним. Координат и имён нет —
только суммы, порядок строк и словарь.
"""
from collections.abc import Sequence
from dataclasses import dataclass, field, replace
from src.parser.classify import match_account
from src.parser.hierarchy import BodyRow, Role
from src.parser.rules import Rules, normalize_text
__all__ = ["CodeBlocks", "apply_code_blocks"]
@dataclass(frozen=True)
class CodeBlocks:
"""Тело таблицы после шага: строки, названия контрагентов по номеру строки с кодом, признак «раскладка найдена»."""
rows: list[BodyRow]
names: dict[int, str] = field(default_factory=dict)
applied: bool = False
def _has_sums(item: BodyRow) -> bool:
return any(value is not None for value in item.sums)
def _subtotal_code(item: BodyRow, rules: Rules) -> str | None:
"""Код субсчёта из «ИТОГ по 60.111» (в нормализованном виде) или `None`, если строка — не итог субсчёта."""
for pattern in rules.headers.subaccount_sums:
match = pattern.search(item.text)
if match is not None:
return match.group("account")
return None
def _mark_wide_subaccounts(body: Sequence[BodyRow], header_account: str) -> list[BodyRow]:
"""Субсчёт с тремя цифрами в сегменте («60.111» при шапке «60») — строка-счёт (§1.3, правило 1)."""
result: list[BodyRow] = []
for item in body:
under_header = bool(header_account) and item.text.startswith(f"{header_account}.")
found = match_account(item.text, header_account) if under_header and not item.is_account else None
if found is None:
result.append(item)
else:
result.append(replace(item, is_account=True, account_rank=found.group(1).count(".") + 1))
return result
def _block_roles(block: Sequence[BodyRow]) -> tuple[dict[int, Role], dict[int, str], set[int]]:
"""Роли строк блока: `{строка: роль}`, названия `{строка кода: текст}` и номера склеенных (убираемых) строк."""
roles: dict[int, Role] = {}
names: dict[int, str] = {}
glued: set[int] = set()
has_counterparty = False
index = 0
while index < len(block):
item = block[index]
if item.indicator or not _has_sums(item):
index += 1
continue
following = block[index + 1] if index + 1 < len(block) else None
if following is not None and not following.indicator and not _has_sums(following) and following.text.strip():
roles[item.row] = "counterparty"
names[item.row] = following.text
glued.add(following.row)
has_counterparty = True
index += 2
continue
if has_counterparty:
roles[item.row] = "contract"
index += 1
return roles, names, glued
def apply_code_blocks(body: Sequence[BodyRow], total_row: int | None, rules: Rules, header_account: str) -> CodeBlocks:
"""Итоги субсчетов -> суммы строк-счетов; роли и названия в блоках (см. модуль). Без счёта шапки и без пар
«субсчёт — ИТОГ по субсчёту» тело возвращается как есть."""
rows = _mark_wide_subaccounts(body, header_account)
if not header_account or not rules.headers.subaccount_sums:
return CodeBlocks(rows)
data = [item for item in rows if total_row is None or item.row < total_row]
accounts = {normalize_text(item.text): item for item in data if item.is_account and not _has_sums(item)}
sums_of: dict[int, tuple] = {}
dropped: set[int] = set()
for item in data:
code = _subtotal_code(item, rules)
account = None if code is None else accounts.get(code)
if account is not None and account.row < item.row and account.row not in sums_of:
sums_of[account.row] = item.sums
dropped.add(item.row)
if not sums_of:
return CodeBlocks(rows)
roles: dict[int, Role] = {}
names: dict[int, str] = {}
block: list[BodyRow] = []
block_open = False
for current in [*(item for item in data if item.row not in dropped), None]:
if current is None or current.is_account:
if block_open:
block_roles, block_names, glued = _block_roles(block)
roles.update(block_roles)
names.update(block_names)
dropped |= glued
block, block_open = [], current is not None and current.row in sums_of
continue
block.append(current)
result: list[BodyRow] = []
for source in rows:
if source.row in dropped:
continue
sums = sums_of.get(source.row, source.sums)
role = roles.get(source.row)
result.append(replace(source, sums=sums, role=role) if (sums is not source.sums or role) else source)
return CodeBlocks(result, names, applied=True)

View File

@ -34,6 +34,7 @@ __all__ = [
"inn_from_cell",
"is_header_repeat",
"is_osv_sheet",
"table_kind_row",
]
CollapsedGroup = Literal["opening", "closing"]
@ -505,6 +506,26 @@ def first_data_row(sheet: Sheet, header: TableHeader) -> int:
return next(_data_rows(sheet, header.last_row), sheet.n_rows + 1)
def table_kind_row(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[int, str] | None:
"""Вид субконто строкой внутри таблицы (выгрузка 1С «по контрагентам», §1.1): первая строка данных — одна текстовая
ячейка вне колонки текста, а текст — вид субконто, который словарь групп знает явной записью («Поставщики»).
Возвращает номер строки и текст. Строка данных не является: суммы ей не читаются. Текст в колонке текста
(«Поставщики» как название контрагента) под правило не попадает.
"""
row = first_data_row(sheet, header)
if row > sheet.n_rows:
return None
cells = list(sheet.row_cells(row))
if len(cells) != 1:
return None
col, cell = cells[0]
if col == header.text_col or cell.kind != "text":
return None
known = rules.groups.known(cell.text)
return (row, cell.text) if known is not None and known != "ACCOUNT" else None
def _signature(sheet: Sheet, row: int, right_edge: int) -> tuple[tuple[int, str], ...]:
return tuple((col, text) for col, text in _texts(sheet, row) if col <= right_edge)

View File

@ -15,7 +15,7 @@ from typing import Any, Literal, get_args
from src.contract.common import DataType
from src.contract.result import OsvGroup
from src.parser.detect import TableHeader
from src.parser.detect import TableHeader, table_kind_row
from src.parser.grid import Sheet
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset
@ -42,7 +42,7 @@ _LIST_SEPARATOR = ","
# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН»
_ATTRIBUTE_SEPARATOR = "."
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
_CODE_TRAILING = ".,;:"
_CODE_TRAILING = ".,;:*"
@dataclass(frozen=True)
@ -149,6 +149,7 @@ def _currency_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]:
class _PeriodHit:
cell: _HeadCell
text: str
value_cell: _HeadCell | None = None # значение в соседней ячейке («За период» | «01.01.25..30.06.25»)
def _cut_month_range(tail: str, rules: Rules) -> str | None:
@ -190,9 +191,17 @@ def _cut_period(tail: str, rules: Rules) -> str:
return tail
def _next_cell(cells: list[_HeadCell], cell: _HeadCell) -> _HeadCell | None:
"""Ближайшая справа текстовая ячейка той же строки шапки (значение метки «За период» | «01.01.25..30.06.25»)."""
right = [other for other in cells if other.row == cell.row and other.col > cell.col]
return min(right, key=lambda other: other.col, default=None)
def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]:
"""Ячейки с меткой периода («за …», «Период: …») и текст после метки; пустой текст — не кандидат.
Метка без текста («За период» | значение в соседней ячейке) берёт значение справа: ячейка значения запоминается,
организацией она не считается.
Какой из кандидатов — период, решает `_find_period`: «за» в названии организации не должно его перехватывать.
"""
hits: list[_PeriodHit] = []
@ -203,11 +212,15 @@ def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]
tail = _tail(cell.text, match)
if tail:
hits.append(_PeriodHit(cell, _cut_period(tail, rules)))
continue
value = _next_cell(cells, cell)
if value is not None:
hits.append(_PeriodHit(cell, _cut_period(value.text, rules), value))
return hits
def _is_recognised(text: str, rules: Rules) -> bool:
return rules.periods.match(text) is not None or rules.periods.year.search(text) is not None
return rules.periods.match(text) is not None or rules.periods.year_of(text) is not None
def _standalone_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None:
@ -280,6 +293,19 @@ def _chart_of_accounts_tail(cells: list[_HeadCell], rules: Rules) -> str | None:
return None
def _account_value_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]:
"""Ячейки со значением метки счёта в соседней ячейке («Счет Поставщика» | «60*»): организацией не считаются."""
found: list[_HeadCell] = []
for cell in cells:
match = _find(rules.headers.header_markers["account"], cell.text)
if match is None or _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": ")) is not None:
continue
value = _next_cell(cells, cell)
if value is not None:
found.append(value)
return found
def _organization(
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
) -> str | None:
@ -293,9 +319,11 @@ def _organization(
cell
for cell in (
None if period is None else period.cell,
None if period is None else period.value_cell,
data_type,
*_currency_cells(cells, rules),
*_kind_row_cells(cells, rules),
*_account_value_cells(cells, rules),
)
if cell
]
@ -306,12 +334,19 @@ def _organization(
return _title_tail_organization(cells, rules) if found is None else found.text
def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
def _account_code(sheet: Sheet, cells: list[_HeadCell], rules: Rules) -> str | None:
"""Код счёта после метки: в той же ячейке («по счету 60»), иначе в следующей ячейке («Счет Поставщика» | «60*»).
Метка без кода («Счет Клиента» с пустой ячейкой справа) пропускается — берётся следующая метка.
"""
for cell in cells:
match = _find(rules.headers.header_markers["account"], cell.text)
if match is None:
continue
code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": "))
if code is None:
neighbours = [other.text.strip() for col, other in sheet.row_cells(cell.row) if col > cell.col]
code = _ACCOUNT_CODE.match(neighbours[0]) if neighbours else None
if code is not None:
return code.group(0).rstrip(_CODE_TRAILING)
return None
@ -417,14 +452,13 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
if span is not None:
return _month_range_period(span, text, warnings)
rule = rules.periods.match(text)
year = rules.periods.year.search(text)
year_value = rules.periods.year_of(text)
if rule is None:
warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов")
if year is None:
if year_value is None:
warnings.append(f"период «{text}»: год не найден")
# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить
# по данным (opening Q3 == opening Q1 -> нарастающая)
year_value = None if year is None else int(year.group(0))
quarter = None if rule is None else rule.quarter
is_cumulative = None if rule is None else rule.is_cumulative
start, end = _bounds(year_value, quarter, is_cumulative)
@ -473,7 +507,7 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead
organization = _organization(cells, rules, period_hit, None if standalone_type is None else standalone_type[0])
if organization is None:
warnings.append("наименование организации в шапке не найдено")
account = _account_code(cells, rules)
account = _account_code(sheet, cells, rules)
if account is None:
warnings.append(ACCOUNT_NOT_FOUND_WARNING)
period_text = None if period_hit is None else period_hit.text
@ -577,6 +611,10 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSc
groups = [_group("ACCOUNT")]
if len(groups) == 1:
groups.extend(_kind_row_groups(sheet, header, rules))
if len(groups) == 1:
table_kind = table_kind_row(sheet, header, rules)
if table_kind is not None:
groups.append(_group(rules.groups.classify(table_kind[1]), table_kind[1]))
return GroupSchema(groups, 1, hint)

View File

@ -12,7 +12,7 @@
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
from typing import Literal, Protocol
from src.contract.common import DataType
from src.parser.deadline import Deadline
@ -47,6 +47,9 @@ DEADLINE_CHECK_EVERY = 500
_ZERO = Decimal(0)
Role = Literal["counterparty", "contract"]
@dataclass(frozen=True)
class BodyRow:
"""Строка данных до классификации: текст, признак счёта, оформление и шесть сумм (по `ColumnMap`)."""
@ -62,6 +65,8 @@ class BodyRow:
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
role: Role | None = None
@dataclass(frozen=True)
@ -174,6 +179,28 @@ def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None
return by_row
def strategy_code_blocks(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
"""Блоки субсчетов с кодами контрагентов (§1.3): субсчёт -> контрагент -> договор.
Применима, только если `code_blocks.apply_code_blocks` назначил строкам роли. Субсчёт — по рангу кода (как в
`account_pattern`), контрагент — на уровень глубже субсчёта, договор — ещё глубже, прочие строки — дети субсчёта.
"""
if not any(item.role for item in rows):
return None
ranks = [item.account_rank for item in rows if item.is_account and item.account_rank is not None]
base = min(ranks) if ranks else 0
by_row: dict[int, int] = {}
account_level: int | None = None
for item in _paced(rows, deadline, "иерархия (code_blocks)"):
if item.is_account and item.account_rank is not None:
account_level = item.account_rank - base
by_row[item.row] = account_level
continue
child_level = ROOT_LEVEL if account_level is None else account_level + 1
by_row[item.row] = child_level + 1 if item.role == "contract" else child_level
return by_row
def _vector(item: BodyRow) -> tuple[Decimal, ...]:
return tuple(_ZERO if value is None else value for value in item.sums)
@ -247,6 +274,7 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) ->
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
("outline", strategy_outline),
("indent", strategy_indent),
("code_blocks", strategy_code_blocks),
("account_pattern", strategy_account_pattern),
("sums", strategy_sums),
)

View File

@ -8,6 +8,7 @@ from src.parser.deadline import Deadline
from src.parser.detect import TableHeader
from src.parser.grid import Sheet
from src.parser.hierarchy import ROOT_LEVEL, BodyRow, Levels, resolve_levels
from src.parser.osv_warnings import unknown_label_warning
from src.parser.rules import Rules, first_match, normalize_text
__all__ = ["RowLabel", "column_label", "is_indicator_total", "levels_with_indicators", "read_label", "row_data_type"]
@ -46,10 +47,7 @@ def read_label(sheet: Sheet, row: int, text: str, header: TableHeader, rules: Ru
return RowLabel(indicator=False, data_type="BU")
if data_type is not None or any(p.search(label) for p in rules.data_types.indicator_column_untyped):
return RowLabel(indicator=True, data_type=data_type, text=label)
warning = (
f"строка {row}: метка «{label}» в колонке «Показатели» не распознана — строка не участвует в проверках"
)
return RowLabel(indicator=True, data_type=None, text=label, warning=warning)
return RowLabel(indicator=True, data_type=None, text=label, warning=unknown_label_warning(row, label))
data_type = row_data_type(text, rules, frozenset(rules.data_types.currency_codes))
return RowLabel(indicator=data_type is not None, data_type=data_type)

View File

@ -15,11 +15,13 @@ from src.contract.common import DataType
from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.code_blocks import apply_code_blocks
from src.parser.deadline import Deadline
from src.parser.detect import TableHeader, find_table_header
from src.parser.errors import InvalidFormatError
from src.parser.grid import Sheet, column_letter
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
from src.parser.hierarchy import BodyRow
from src.parser.indicators import levels_with_indicators
from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.osv_warnings import (
@ -28,6 +30,7 @@ from src.parser.osv_warnings import (
OSV_NO_DATA_ROWS,
OSV_ROWS,
OsvNote,
group_row_warnings,
hierarchy_note,
notes_of,
)
@ -112,6 +115,23 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea
return replace(doc, account_code_raw=code, warnings=tuple(warnings))
def _without_foreign_bare_codes(body: list[BodyRow], header_account: str) -> list[BodyRow]:
"""Голый числовой код («441», «199») в таблице ОСВ с известным счётом шапки — код контрагента, а не счёт (#71).
Счётом остаётся голый код, совпадающий со счётом шапки («60» при «по счету 60.01»); коды с точкой и названием
(«60.01», «60, Расчеты») под фильтр не попадают. Без счёта шапки список не меняется.
"""
if not header_account:
return body
root = header_account.split(".", maxsplit=1)[0]
return [
replace(item, is_account=False, account_rank=None)
if item.is_account and item.text.isdigit() and item.text != root
else item
for item in body
]
def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]:
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа."""
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
@ -193,6 +213,9 @@ class OsvParser:
deadline.check(f"лист «{sheet.name}»: строки таблицы")
step = "строки таблицы"
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
body = _without_foreign_bare_codes(body, doc.account_code_raw)
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
body = blocks.rows
step = "иерархия строк"
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
step = "строки ИНН"
@ -224,6 +247,7 @@ class OsvParser:
inns=glue.inns,
unpaired=glue.unpaired,
header_account=doc.account_code_raw or None,
names=blocks.names,
)
except InvalidFormatError as exc:
raise _invalid_format(sheet, step, exc) from exc
@ -235,7 +259,7 @@ class OsvParser:
hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings),
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
*notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_ROWS, group_row_warnings(body_warnings)),
*notes_of(OSV_ROWS, _by_account_warnings(classified)),
*notes_of(OSV_INN_ROWS, glue.warnings),
]

View File

@ -4,6 +4,7 @@
страницы» — не код ОСВ, а предупреждение файла `XLS_NO_CODEPAGE` пакета (P4-13, R-2); здесь её нет.
"""
import re
from collections.abc import Iterable
from dataclasses import dataclass
@ -19,7 +20,9 @@ __all__ = [
"OSV_XLSB_NO_OUTLINE",
"HIERARCHY_TEXTS",
"OsvNote",
"group_row_warnings",
"hierarchy_note",
"unknown_label_warning",
"notes_of",
]
@ -31,6 +34,10 @@ OSV_NO_DATA_ROWS = "OSV_NO_DATA_ROWS"
OSV_BALANCE_IDENTITY = "OSV_BALANCE_IDENTITY"
OSV_PARENT_SUM = "OSV_PARENT_SUM"
OSV_TOTAL = "OSV_TOTAL"
WARNING_ROWS_SHOWN = 20 # сколько номеров строк показывать в сгруппированном предупреждении (как у `checks.py`)
_ROW_WARNING = re.compile(r"^строка (\d+): (.*)$", re.DOTALL)
_UNKNOWN_LABEL_TAIL = " в колонке «Показатели» не распознана — "
_UNKNOWN_LABEL = re.compile(r"^метка «(.*)»" + _UNKNOWN_LABEL_TAIL + r"строка не участвует в проверках$", re.DOTALL)
OSV_XLSB_NO_OUTLINE = "OSV_XLSB_NO_OUTLINE" # заметка читателя xlsb: книга дописывает её в каждый документ ОСВ
@ -44,6 +51,7 @@ HIERARCHY_TEXTS = {
"outline": "по группировке листа (outline)",
"indent": "по отступам (indent)",
"account_pattern": "по кодам счетов (account_pattern)",
"code_blocks": "блоки субсчетов с кодами контрагентов (code_blocks)",
"header_account": "плоский список под счётом шапки (header_account)",
"sums": "по суммам (sums)",
"trivial": "плоский список (trivial)",
@ -58,3 +66,64 @@ def hierarchy_note(strategy: str) -> OsvNote:
def notes_of(code: str, messages: Iterable[str]) -> list[OsvNote]:
return [OsvNote(code, message) for message in messages]
def unknown_label_warning(row: int, label: str) -> str:
"""Предупреждение об одной строке с меткой колонки «Показатели», которой нет в словарях."""
return f"строка {row}: метка «{label}»{_UNKNOWN_LABEL_TAIL}строка не участвует в проверках"
def _count(number: int) -> str:
return f"{number:,}".replace(",", " ")
def _rows_shown(rows: list[int]) -> str:
shown = ", ".join(str(number) for number in rows[:WARNING_ROWS_SHOWN])
return f"{shown}, …" if len(rows) > WARNING_ROWS_SHOWN else shown
def _plural_rows(number: int) -> str:
last_two = number % 100
if number % 10 == 1 and last_two != 11: # noqa: PLR2004 - склонение «строка / строки / строк»
return "строка"
if 2 <= number % 10 <= 4 and not 12 <= last_two <= 14: # noqa: PLR2004
return "строки"
return "строк"
def group_row_warnings(messages: Iterable[str]) -> list[str]:
"""Построчные предупреждения «строка N: <текст>» с одним текстом — в одно с числом и номерами строк (AURORA-1724).
Одиночное предупреждение остаётся как есть. Нераспознанная метка «Показатели»: «метка «X» в колонке «Показатели»
не распознана — 2 128 строк (11, 14, …) не участвуют в проверках»; прочие — «<текст> — строк: N (…)». Номера
ограничены `WARNING_ROWS_SHOWN`, порядок групп — по первому появлению; сообщения без номера строки не меняются.
"""
items = list(messages)
groups: dict[str, list[int]] = {}
for message in items:
found = _ROW_WARNING.match(message)
if found:
groups.setdefault(found.group(2), []).append(int(found.group(1)))
result: list[str] = []
done: set[str] = set()
for message in items:
found = _ROW_WARNING.match(message)
if not found:
result.append(message)
continue
text = found.group(2)
rows = groups[text]
if len(rows) == 1:
result.append(message)
elif text not in done:
done.add(text)
result.append(_grouped(text, rows))
return result
def _grouped(text: str, rows: list[int]) -> str:
label = _UNKNOWN_LABEL.match(text)
count = f"{_count(len(rows))} {_plural_rows(len(rows))} ({_rows_shown(rows)})"
if label:
return f"метка «{label.group(1)}»{_UNKNOWN_LABEL_TAIL}{count} не участвуют в проверках"
return f"{text} — строк: {_count(len(rows))} ({_rows_shown(rows)})"

View File

@ -155,6 +155,8 @@ class OsvHeaderRules(_Model):
total_row: list[Pattern]
total_row_exact: list[Pattern]
total_row_secondary: list[Pattern]
# Итог субсчёта «ИТОГ по 60.111» (группа account — код субсчёта); пусто — правило не действует
subaccount_sums: list[Pattern] = []
column_captions: list[Pattern]
counterparty_inn_suffix: list[Pattern]
inn_only_row: list[Pattern]
@ -176,6 +178,7 @@ class OsvHeaderRules(_Model):
("counterparty_inn_suffix", {"tail", "inn"}),
("inn_only_row", {"inn"}),
("inn_kpp_cell", {"inn"}),
("subaccount_sums", {"account"}),
):
for pattern in getattr(self, field_name):
missing_groups = groups - pattern.group_names
@ -243,6 +246,7 @@ class PeriodRule(_Model):
_MONTHS_IN_YEAR = 12
_CENTURY = 2000 # двузначный год даты «…25» — 2025
_RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"})
_MONTH_GROUPS = frozenset(f"m{number:02d}" for number in range(1, _MONTHS_IN_YEAR + 1))
@ -265,6 +269,8 @@ class MonthRange:
class PeriodRules(_Model):
rules: list[PeriodRule]
year: Pattern
# Двузначный год даты (группа yy), если года из четырёх цифр в тексте нет
short_year_date: Pattern
end: Pattern
# Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2
months: Pattern
@ -329,6 +335,14 @@ class PeriodRules(_Model):
return None
return MonthRange(month, int(year.group(0)), month, int(year.group(0)))
def year_of(self, text: str) -> int | None:
"""Год периода: четыре цифры (`year`), иначе двузначный год даты конца диапазона «01.01.25..30.06.25» (20yy)."""
found = self.year.search(text)
if found is not None:
return int(found.group(0))
short = self.short_year_date.search(text)
return None if short is None else _CENTURY + int(short.group("yy"))
def match(self, text: str) -> PeriodRule | None:
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
for rule in self.rules:

View File

@ -4,9 +4,10 @@
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
другим треком); описание — `osv/synthetic/README.md`
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
и zip постоянны, порядок записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
@ -972,6 +973,70 @@ def _s44() -> list[SheetSpec]:
return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок"))
def _put_by_counterparty_head(sheet: SheetSpec) -> None:
"""Первые строки выгрузки 1С «по контрагентам»: организация, название, «За период | диапазон» (s45, s46)."""
sheet.put(1, 1, 'ООО "Тестовая организация"')
sheet.put(1, 2, "Оборотно-сальдовая ведомость")
sheet.put(2, 1, "15 июля 2025(18:40)")
sheet.put(2, 2, "За период")
sheet.put(2, 3, "01.01.25..30.06.25")
def _s45() -> list[SheetSpec]:
"""Выгрузка 1С «ОСВ по счёту 60 по контрагентам» (#71, AURORA-1714): счёт в шапке — «Счет Поставщика» | «60*» в
соседних ячейках («Счет Клиента» без значения), без слов «по счету»; в первой колонке голые числовые коды
контрагентов."""
rows: list[FlatRow] = [
("441", (None, "108000.00", "108000.00", None, None, None), 0),
("431", (None, "200.00", "200.00", None, None, None), 0),
("199", (None, "50.00", "50.00", None, None, None), 0),
("Итого", (None, "108250.00", "108250.00", None, None, None), 0),
]
sheet = _flat_sheet({}, 6, rows)
_put_by_counterparty_head(sheet)
sheet.put(3, 2, "Счет Поставщика")
sheet.put(3, 3, "60*")
sheet.put(4, 2, "Счет Клиента")
return [sheet]
def _s46() -> list[SheetSpec]:
"""Выгрузка 1С «ОСВ по счёту 60 по контрагентам» целиком (#71, AURORA-1714): «За период | 01.01.25..30.06.25»,
«Счет Поставщика | 60*», вид субконто «Поставщики» в колонке суммы первой строки данных; блоки субсчетов 60.111 и
60.112 — код контрагента с суммами, строка названия без сумм, договоры (в том числе «441»), «ИТОГ по <субсчёт>»,
в конце «ИТОГ»."""
table: list[tuple[str, tuple[str | None, ...]]] = [
("60.111", (None,) * 6),
("П005482", (None, "100.00", "100.00", None, None, None)),
("ВЕСТ БИР ООО", (None,) * 6),
("ДП87825;З-139/21-1", (None, "100.00", "100.00", None, None, None)),
("П002913", (None, "50.00", "70.00", "20.00", None, None)),
("М ДИСТРИБЬЮШН", (None,) * 6),
("ДП60469;З-46/20", (None, "20.00", "30.00", "10.00", None, None)),
("441", (None, "30.00", "40.00", "10.00", None, None)),
("ИТОГ по 60.111", (None, "150.00", "170.00", "20.00", None, None)),
("60.112", (None,) * 6),
("П003258", (None, "10.00", "10.00", None, None, None)),
('ООО "РОМАШКА"', (None,) * 6),
("ДП1;1", (None, "10.00", "10.00", None, None, None)),
("ИТОГ по 60.112", (None, "10.00", "10.00", None, None, None)),
("ИТОГ", (None, "160.00", "180.00", "20.00", None, None)),
]
sheet = _flat_sheet({}, 6, [])
_put_by_counterparty_head(sheet)
sheet.put(3, 2, "Счет Поставщика")
sheet.put(3, 3, "60*")
sheet.put(4, 2, "Счет Клиента")
sheet.put(8, 3, "Поставщики")
for offset, (text, sums) in enumerate(table):
row = 9 + offset
sheet.put(row, 1, text)
for col, value in enumerate(sums, start=2):
if value is not None:
sheet.put(row, col, D(value))
return [sheet]
def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree()
@ -1085,6 +1150,8 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx),
"s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx),
"s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
}
FILE_NAMES = tuple(FILES)

View File

@ -26,13 +26,13 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s36_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None:
def test_generated_set_is_s01_to_s36_and_s40_to_s46_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
numbers = [*range(1, 37), *range(40, 45)]
numbers = [*range(1, 37), *range(40, 47)]
assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers]
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)

View File

@ -50,6 +50,8 @@ FLAT_FORM_FILES = (
"s42_subaccounts_totals_reversed.xlsx",
"s43_subkonto_unnamed_60.xlsx",
"s44_subkonto_unnamed_10.xlsx",
"s45_account_label_value_cells.xlsx",
"s46_counterparty_code_blocks.xlsx",
)
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
@ -101,9 +103,9 @@ class Expect:
"""Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
hierarchy: str
year: int
quarter: str
cumulative: bool
year: int | None
quarter: str | None
cumulative: bool | None
status: ResultStatus = ResultStatus.DONE
documents: int = 1
variant: int = 1
@ -183,6 +185,10 @@ EXPECTED: dict[str, Expect] = {
"s44_subkonto_unnamed_10.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
# #71: «Счет Поставщика | 60*», «За период | 01.01.25..30.06.25» -> 2025, Q2, нарастающий
"s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None),
# #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ»
"s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None),
}

View File

@ -635,3 +635,52 @@ def test_total_skips_leading_indicator_total() -> None:
rows.insert(0, row(2, ("9", None, None, None, "9", None), group_type="TOTAL", data_type="NU", indicator=True))
assert total_matches_root(rows) is True
# AURORA-1708 (#73): корень счёта развёрнут (Дт и Кт), основная «Итого» свёрнута, «Итого развернутое» = корню
_EXPANDED_ROOT: Sums = ("10", "4", "100", "70", "50", "20")
_COLLAPSED_TOTAL: Sums = ("6", None, "100", "70", "30", None)
_SECONDARY = [Pattern.compile(r"^итого\s+развернут")]
def test_collapsed_total_matches_expanded_root_by_net() -> None:
"""(б) Только свёрнутая «Итого»: обороты по колонкам, сальдо по нетто — корень из одной строки тоже сходится."""
rows = [account(7, _EXPANDED_ROOT), total(8, _COLLAPSED_TOTAL)]
assert total_matches_root(rows) is True
def test_expanded_secondary_total_equal_to_root_with_collapsed_total() -> None:
"""(а) «Итого развернутое» = корню по колонкам, основная «Итого» свёрнута: предупреждения нет."""
rows = [account(7, _EXPANDED_ROOT), total(8, _COLLAPSED_TOTAL), _secondary(9, _EXPANDED_ROOT)]
_, warnings = run_checks(rows, secondary_totals=_SECONDARY)
assert total_matches_root(rows, _SECONDARY) is True
assert not [w for w in warnings if "Итого" in w.message]
def test_expanded_secondary_total_before_collapsed_total() -> None:
"""(а) То же в плоской форме: «Итого развернутое» стоит перед «Итого»."""
rows = [account(7, _EXPANDED_ROOT), _secondary(8, _EXPANDED_ROOT), total(9, _COLLAPSED_TOTAL)]
assert total_matches_root(rows, _SECONDARY) is True
def test_collapsed_total_with_real_net_difference_still_fails() -> None:
"""(в) Нетто «Итого» не совпало с корнем — расхождение остаётся, даже если «Итого развернутое» = корню."""
wrong_net: Sums = ("7", None, "100", "70", "30", None)
wrong_turnover: Sums = ("6", None, "101", "70", "30", None)
assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, wrong_net)]) is False
assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, wrong_turnover)]) is False
rows = [account(7, _EXPANDED_ROOT), total(8, wrong_net), _secondary(9, _EXPANDED_ROOT)]
assert total_matches_root(rows, _SECONDARY) is False
_, warnings = run_checks(rows, secondary_totals=_SECONDARY)
assert any("≠ корневому счёту" in w.message for w in warnings)
def test_expanded_total_differing_from_expanded_root_still_fails() -> None:
"""Развёрнутая «Итого» (обе стороны) с другим распределением Дт/Кт нетто не спасает: свёрнутость — у «Итого»."""
shifted: Sums = ("16", "10", "100", "70", "50", "20")
assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, shifted)]) is False

View File

@ -78,8 +78,8 @@ def never_expires() -> Deadline:
# --- Каскад: какая стратегия выбирается ---
def test_strategies_are_ordered_outline_indent_account_pattern_sums() -> None:
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "account_pattern", "sums"]
def test_strategies_are_ordered_outline_indent_code_blocks_account_pattern_sums() -> None:
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "code_blocks", "account_pattern", "sums"]
def test_outline_levels_win_first() -> None:

View File

@ -0,0 +1,184 @@
"""Счёт шапки «Счет Поставщика | 60*» (метка и значение в разных ячейках; AURORA-1714, #71).
Книга собирается openpyxl в памяти: шапка 1С «ОСВ по счёту 60 по контрагентам» без слов «по счету», голые числовые коды
контрагентов (441, 431) в первой колонке. Счёт документа — из шапки, строки — контрагенты по плану счетов.
"""
import io
import openpyxl
import pytest
from arm_statements import default_rules
from src.contract import OsvDocument
from src.parser.book import BookParser
from src.parser.osv import OsvParser
from src.parser.rules import Rules, load_rules
from src.parser.statements.pipeline import StatementPipeline
TIMEOUT_SECONDS = 60
MAX_BYTES = 50 * 1024 * 1024
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _book(label: str, empty_label: str) -> bytes:
rows: list[list[object]] = [
["МВС2009", "Оборотно-сальдовая ведомость"],
["15 июля 2025(18:40)", "За период", "01.01.25..30.06.25"],
[None, label, "60*"],
[None, empty_label, None],
[],
["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["441", 0, 100, 100, 0, 0, 0],
["431", 0, 200, 200, 0, 0, 0],
["199", 0, 50, 50, 0, 0, 0],
["Итого", 0, 350, 350, 0, 0, 0],
]
return _xlsx(rows)
def _xlsx(rows: list[list[object]]) -> bytes:
workbook = openpyxl.Workbook()
sheet = workbook.active
assert sheet is not None
for index, cells in enumerate(rows, start=1):
for col, value in enumerate(cells, start=1):
if value is not None:
sheet.cell(index, col, value)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _blocks_book() -> bytes:
"""Выгрузка 1С «ОСВ по счёту 60 по контрагентам»: субсчета 60.111/60.112, код контрагента с суммами, строка названия
без сумм, договоры с суммами (в том числе с числовым названием «441»), «ИТОГ по <субсчёт>» и «ИТОГ»."""
rows: list[list[object]] = [
["МВС2009", "Оборотно-сальдовая ведомость"],
["15 июля 2025(18:40)", "За период", "01.01.25..30.06.25"],
[None, "Счет Поставщика", "60*"],
[None, "Счет Клиента", None],
[],
["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[None, None, "Поставщики"],
["60.111"],
["П005482", 0, 100, 100, 0, 0, 0],
["ВЕСТ БИР ООО"],
["ДП87825;З-139/21-1", 0, 100, 100, 0, 0, 0],
["П002913", 0, 50, 70, 20, 0, 0],
["М ДИСТРИБЬЮШН"],
["ДП60469;З-46/20", 0, 20, 30, 10, 0, 0],
["441", 0, 30, 40, 10, 0, 0],
["ИТОГ по 60.111", 0, 150, 170, 20, 0, 0],
["60.112"],
["П003258", 0, 10, 10, 0, 0, 0],
['ООО "РОМАШКА"'],
["ДП1;1", 0, 10, 10, 0, 0, 0],
["ИТОГ по 60.112", 0, 10, 10, 0, 0, 0],
["ИТОГ", 0, 160, 180, 20, 0, 0],
]
return _xlsx(rows)
def _document(rules: Rules, data: bytes) -> OsvDocument:
statement_rules = default_rules()
parser = BookParser(
rules,
osv=OsvParser(rules),
statements=StatementPipeline(statement_rules),
statement_rules=statement_rules,
max_bytes=MAX_BYTES,
timeout_seconds=TIMEOUT_SECONDS,
)
outcome = parser.parse(data, "xlsx")
documents = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)]
assert len(documents) == 1
return documents[0]
@pytest.mark.parametrize(
("label", "empty_label"),
[("Счет Поставщика", "Счет Клиента"), ("Счет Клиента", "Счет Поставщика"), ("Счёт поставщика:", "Счёт клиента:")],
)
def test_account_from_label_and_value_in_separate_cells(rules: Rules, label: str, empty_label: str) -> None:
document = _document(rules, _book(label, empty_label))
assert document.header.account_code_raw == "60"
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.row_value for item in data] == ["441", "431", "199"]
assert {item.group_type for item in data} == {"COUNTERPARTY"}
assert {item.account_code_raw for item in data} == {"60"}
def test_period_from_label_cell_and_double_dot_range(rules: Rules) -> None:
"""«За период | 01.01.25..30.06.25»: 2025, Q2, нарастающий; метка и значение не становятся организацией."""
header = _document(rules, _blocks_book()).header
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q2", True)
assert (str(header.period_start), str(header.period_end)) == ("2025-01-01", "2025-06-30")
assert header.organization_name == "МВС2009"
def test_blocks_of_subaccounts_with_counterparty_codes(rules: Rules) -> None:
document = _document(rules, _blocks_book())
assert document.status == "done", [w.message for w in document.warnings]
assert [group.type for group in document.group_schema] == ["ACCOUNT", "COUNTERPARTY"]
assert [(row.row_value, row.group_type, row.level) for row in document.rows] == [
("60.111", "ACCOUNT", 0),
("П005482", "COUNTERPARTY", 1),
("ДП87825;З-139/21-1", "CONTRACT", 2),
("П002913", "COUNTERPARTY", 1),
("ДП60469;З-46/20", "CONTRACT", 2),
("441", "CONTRACT", 2),
("60.112", "ACCOUNT", 0),
("П003258", "COUNTERPARTY", 1),
("ДП1;1", "CONTRACT", 2),
("ИТОГ", "TOTAL", 0),
]
by_value = {row.row_value: row for row in document.rows}
# суммы субсчёта — из «ИТОГ по …», строка «ИТОГ по …» отдельно не выдаётся
assert str(by_value["60.111"].opening_credit) == "150.00"
assert by_value["П005482"].counterparty_name == "ВЕСТ БИР ООО"
assert by_value["П002913"].counterparty_name == "М ДИСТРИБЬЮШН"
assert by_value["П005482"].counterparty_inn is None
assert {row.account_code_raw for row in document.rows if row.group_type != "TOTAL"} == {"60.111", "60.112"}
assert not any("нечисловое" in warning.message or "Требует" in warning.message for warning in document.warnings)
def test_bare_three_digit_subaccount_needs_header_account(rules: Rules) -> None:
"""Без счёта шапки «60.111» — прежний шаблон (сегмент 1–2 цифры): строка-счёт только при шапке «60»."""
rows: list[list[object]] = [
["Оборотно-сальдовая ведомость"],
[],
["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["60.111", 0, 10, 10, 0, 0, 0],
["Итого", 0, 10, 10, 0, 0, 0],
]
document = _document(rules, _xlsx(rows))
assert [row.group_type for row in document.rows if row.row_value == "60.111"] != ["ACCOUNT"]
def test_kind_word_in_text_column_is_a_data_row(rules: Rules) -> None:
"""«Поставщики» в колонке текста — название контрагента, а не вид субконто строкой таблицы."""
rows: list[list[object]] = [
["Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."],
[],
["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Поставщики", 0, 10, 10, 0, 0, 0],
["Итого", 0, 10, 10, 0, 0, 0],
]
document = _document(rules, _xlsx(rows))
assert [row.row_value for row in document.rows] == ["Поставщики", "Итого"]

View File

@ -8,7 +8,7 @@ from src.parser.checks import run_checks
from src.parser.deadline import Deadline
from src.parser.grid import Sheet
from src.parser.osv import OsvParser
from src.parser.osv_warnings import OsvNote, hierarchy_note
from src.parser.osv_warnings import OsvNote, group_row_warnings, hierarchy_note
from src.parser.rules import Rules, load_rules
SUMS = (None, None, 100, 100, None, None)
@ -92,3 +92,68 @@ def test_reader_notes_are_appended_last(rules: Rules) -> None:
assert result.notes[-1] == note
assert result.document.warnings[-1].code == "OSV_XLSB_NO_OUTLINE"
def _indicator_rows(label: str, count: int) -> list[list[object]]:
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 76"],
["Контрагенты"],
["за 2025 г."],
['ООО "Тест"'],
[],
[
"Субконто",
"Показатели",
M("Сальдо на начало периода", 2),
M("Обороты за период", 2),
M("Сальдо на конец периода", 2),
],
[None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
for number in range(count):
rows.append([f"Контрагент {number}", "БУ", 10, None, None, None, 10, None])
rows.append([None, label, 5, None, None, None, 5, None])
rows.append(["Итого", "БУ", 10 * count, None, None, None, 10 * count, None])
return rows
def test_unknown_indicator_label_gives_one_warning_for_all_rows(rules: Rules) -> None:
"""AURORA-1724: неизвестная метка «Показатели» в 30 строках — одно предупреждение с числом и номерами строк."""
count = 30
result = OsvParser(rules).parse_sheet(sheet_from_rows(_indicator_rows("Неизв.", count)), Deadline(60))
texts = [note.message for note in result.notes if note.code == "OSV_ROWS" and "«Неизв.»" in note.message]
assert len(texts) == 1, texts
assert "30 строк (10, 12, 14," in texts[0]
assert "не участвуют в проверках" in texts[0]
assert "…" in texts[0] # номера строк ограничены, как у OSV_PARENT_SUM
def test_single_unknown_indicator_label_keeps_row_wording(rules: Rules) -> None:
result = OsvParser(rules).parse_sheet(sheet_from_rows(_indicator_rows("Неизв.", 1)), Deadline(60))
texts = [note.message for note in result.notes if "«Неизв.»" in note.message]
assert texts == ["строка 10: метка «Неизв.» в колонке «Показатели» не распознана — строка не участвует в проверках"]
def test_different_unknown_labels_are_grouped_separately() -> None:
messages = [
"строка 3: метка «А» в колонке «Показатели» не распознана — строка не участвует в проверках",
"строка 4: метка «Б» в колонке «Показатели» не распознана — строка не участвует в проверках",
"строка 5: метка «А» в колонке «Показатели» не распознана — строка не участвует в проверках",
]
assert group_row_warnings(messages) == [
"метка «А» в колонке «Показатели» не распознана — 2 строки (3, 5) не участвуют в проверках",
"строка 4: метка «Б» в колонке «Показатели» не распознана — строка не участвует в проверках",
]
def test_same_text_row_warnings_are_grouped() -> None:
messages = [
"строка 3: нечисловое значение в колонке сумм: «х»",
"строка 4: другое",
"строка 5: нечисловое значение в колонке сумм: «х»",
]
assert group_row_warnings(messages) == [
"нечисловое значение в колонке сумм: «х» — строк: 2 (3, 5)",
"строка 4: другое",
]

View File

@ -61,7 +61,7 @@ SHALL пропускаться с предупреждением файла `SHE
### Requirement: Шапка документа и схема группировок
Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (хвост метки «План счетов <организация>»; иначе первая непустая текстовая строка, не являющаяся меткой шапки и не лежащая между строкой названия ведомости и строкой периода; иначе хвост названия ведомости; trim),
`account_code_raw` (после «по счету»/«по счёту», в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца),
`account_code_raw` (после «по счету»/«по счёту» либо в соседней ячейке строки после метки «Счет Поставщика»/«Счет Клиента» («60*» -> «60»; метка без значения пропускается), в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца),
`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
@ -110,6 +110,23 @@ SHALL пропускаться с предупреждением файла `SHE
- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`,
`group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` (множественное «Организации» — синоним COUNTERPARTY в словаре; единственное «Организация» — собственная организация учёта, R-17)
#### Scenario: Счёт шапки в соседней ячейке от метки
- **WHEN** разбирается синтетическая ОСВ `s45` (выгрузка 1С «ОСВ по счёту 60 по контрагентам»: шапка «Счет Поставщика» | «60*»
в соседних ячейках, «Счет Клиента» без значения, слов «по счету» нет; в первой колонке голые числовые коды 441, 431, 199)
- **THEN** `account_code_raw = "60"`; голый числовой код, не совпадающий со счётом шапки, счётом не считается (это код
контрагента); строки — `COUNTERPARTY` по плану счетов, `done`
#### Scenario: Выгрузка «по контрагентам»: блоки субсчетов с кодами контрагентов
- **WHEN** разбирается синтетическая ОСВ `s46` (шапка «За период | 01.01.25..30.06.25», «Счет Поставщика | 60*», вид
субконто «Поставщики» в колонке суммы первой строки данных; субсчета 60.111/60.112 без сумм; код контрагента с суммами,
строка названия без сумм, договоры с суммами; «ИТОГ по 60.111»; «ИТОГ»)
- **THEN** период 2025, Q2, нарастающий (метка «За период» со значением в соседней ячейке, разделитель «..», год из двух
цифр); `group_schema = [ACCOUNT, COUNTERPARTY(Поставщики)]`; `hierarchy=code_blocks` (субсчёт → контрагент → договор);
субсчета — `ACCOUNT` с суммами из «ИТОГ по …» (строк «ИТОГ по …», названий и «Поставщики» в `rows` нет); коды —
`COUNTERPARTY` с `counterparty_name` = строка названия, `counterparty_inn = null`; договоры (в том числе «441») —
`CONTRACT`; «ИТОГ» — `TOTAL`, таблица кончается на нём; `done`. Субсчёт с тремя цифрами в сегменте — строка-счёт, только
если код начинается со счёта шапки; голый числовой код, не совпадающий со счётом шапки, счётом не считается.
#### Scenario: Счёт шапки важнее строки таблицы
- **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2»)
- **THEN** `account_code_raw = "76"`
@ -231,7 +248,7 @@ turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по
`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка
«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL без признака показателя, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли; итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL без признака показателя, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли; итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если «Итого» свёрнута (не больше одной ненулевой стороны сальдо), а корневая строка развёрнута (в 1С так бывает у счёта с развёрнутым сальдо; «Итого развернутое» равна корню), «Итого» SHALL сверяться с единственным корнем так же, как с родителем и детьми (R-21): обороты по колонкам, сальдо по нетто «дебет − кредит» (AURORA-1708, #73); расхождение по нетто или оборотам остаётся нарушением. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы
§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое
@ -314,6 +331,10 @@ balance_identity_violations, parent_sum_violations}`. Результат про
- **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»;
`total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done`
#### Scenario: «Итого» свёрнута, корень и «Итого развернутое» развёрнуты
- **WHEN** корневая строка счёта несёт развёрнутое сальдо (Дт и Кт), «Итого» свёрнута, «Итого развернутое» равна корню по колонкам, а обороты и нетто сальдо «Итого» совпадают с корнем (либо «Итого развернутое» нет, «Итого» свёрнута и совпадает с корнем по нетто)
- **THEN** `total_row_matches_root = true`, предупреждения `OSV_TOTAL` нет; при расхождении нетто сальдо или оборотов `total_row_matches_root = false` (AURORA-1708, #73)
#### Scenario: «Итого развернутое» без оборотов
- **WHEN** у «Итого развернутое» заполнены только сальдо (обороты пусты), а у основной «Итого» тождество строки выполняется
- **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной
@ -547,8 +568,11 @@ design #14).
1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`,
«Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.», «Кол. доп.» →
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`: одно на каждую
неизвестную метку, а не на каждую строку — «метка «X» в колонке «Показатели» не распознана — 2 128 строк (11, 14, …)
не участвуют в проверках» (показаны первые 20 номеров; для единственной строки текст прежний: «строка N: метка «X» в
колонке «Показатели» не распознана — строка не участвует в проверках»); так же группируются прочие построчные
предупреждения `OSV_ROWS` с одинаковым текстом («строка N: <текст>» → «<текст> — строк: K (…)»; AURORA-1724);
2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «Кол-во, <единица>», «В валюте» → CURRENCY/QUANTITY) или
равен коду валюты из `currency_codes`.