Merge pull request 'AURORA-1714: счёт шапки «Счет Поставщика | 60*» и коды контрагентов в ОСВ' (#74) from feat/71-osv-account-marker into main
This commit is contained in:
commit
c77e496f59
@ -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_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` — конец текста периода в строке шапки («г.», «год») |
|
||||||
|
|||||||
@ -4,6 +4,9 @@ mapping:
|
|||||||
- ['^(счета?|субконто):?$', ACCOUNT]
|
- ['^(счета?|субконто):?$', ACCOUNT]
|
||||||
- ['^подразделени', DEPARTMENT]
|
- ['^подразделени', DEPARTMENT]
|
||||||
- ['^контрагент', COUNTERPARTY]
|
- ['^контрагент', COUNTERPARTY]
|
||||||
|
# вид субконто «Поставщики» (выгрузка 1С «ОСВ по счету 60 по контрагентам», строка внутри таблицы) — контрагенты;
|
||||||
|
# только целиком: название контрагента «Поставщики ООО» этой записью не узнаётся
|
||||||
|
- ['^поставщики$', COUNTERPARTY]
|
||||||
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число
|
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число
|
||||||
# «Организация» — собственная организация учёта (измерение шапки), контрагентом не является
|
# «Организация» — собственная организация учёта (измерение шапки), контрагентом не является
|
||||||
- ['^организации\b', COUNTERPARTY]
|
- ['^организации\b', COUNTERPARTY]
|
||||||
|
|||||||
@ -50,6 +50,13 @@ total_row:
|
|||||||
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
|
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
|
||||||
total_row_exact:
|
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С): тоже TOTAL, но «Итого» листа — основная. Стоит сразу после основной
|
||||||
# «Итого» или ПЕРЕД ней (плоская форма 1С, #59); сверяется основная
|
# «Итого» или ПЕРЕД ней (плоская форма 1С, #59); сверяется основная
|
||||||
@ -77,10 +84,15 @@ inn_kpp_cell:
|
|||||||
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
|
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
|
||||||
header_markers:
|
header_markers:
|
||||||
# «по счету 60 за …»: код счёта — сразу после совпадения
|
# «по счету 60 за …»: код счёта — сразу после совпадения
|
||||||
|
# выгрузка 1С «ОСВ по счёту 60 по контрагентам»: «Счет Поставщика» | «60*» — метка и код в соседних ячейках строки
|
||||||
account:
|
account:
|
||||||
- '\bпо\s+счету\b'
|
- '\bпо\s+счету\b'
|
||||||
|
- '\bсчет\s+(поставщика|клиента)\b'
|
||||||
# вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.»
|
# вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.»
|
||||||
period:
|
period:
|
||||||
|
# выгрузка 1С «по контрагентам»: «За период» | «01.01.25..30.06.25» — значение в соседней ячейке строки; запись
|
||||||
|
# стоит первой: «\bза\s+» ниже совпала бы с «За период» и оставила бы хвостом слово «период»
|
||||||
|
- '^за\s+период\b\s*:?\s*'
|
||||||
- '\bза\s+'
|
- '\bза\s+'
|
||||||
- '^период\s*:\s*'
|
- '^период\s*:\s*'
|
||||||
# «Выводимые данные: БУ (данные бухгалтерского учета)»
|
# «Выводимые данные: БУ (данные бухгалтерского учета)»
|
||||||
|
|||||||
@ -10,10 +10,10 @@ rules:
|
|||||||
- {pattern: '\b(3|iii)\s*(й|я|ый|ой)?\s*кв', quarter: Q3, is_cumulative: false}
|
- {pattern: '\b(3|iii)\s*(й|я|ый|ой)?\s*кв', quarter: Q3, is_cumulative: false}
|
||||||
- {pattern: '\b(4|iv)\s*(й|я|ый|ой)?\s*кв', quarter: Q4, is_cumulative: false}
|
- {pattern: '\b(4|iv)\s*(й|я|ый|ой)?\s*кв', quarter: Q4, is_cumulative: false}
|
||||||
# Нарастающие периоды: месяцы, диапазон месяцев, диапазон дат «01.01 - 31.03», «с 01.01.2025 по 31.03.2025»
|
# Нарастающие периоды: месяцы, диапазон месяцев, диапазон дат «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: '\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: 'полугоди|\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: '\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: '\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 г.») годом периода не считается
|
# Год: «за 2024 г.», «2024 год»; год в конце даты («31.03.2025 г.») годом периода не считается
|
||||||
- {pattern: '(?<![\d.])(19|20)\d{2}\s*(г\b|год)', quarter: Q4, is_cumulative: true}
|
- {pattern: '(?<![\d.])(19|20)\d{2}\s*(г\b|год)', quarter: Q4, is_cumulative: true}
|
||||||
# В тексте периода только год: «2024», «за 2024»
|
# В тексте периода только год: «2024», «за 2024»
|
||||||
@ -22,6 +22,10 @@ rules:
|
|||||||
# Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер)
|
# Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер)
|
||||||
year: '(?<!\d)(19|20)\d{2}(?!\d)'
|
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 г.»;
|
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
|
||||||
# без совпадения период идёт до конца строки
|
# без совпадения период идёт до конца строки
|
||||||
end: '\bг\.|\bгода?\b'
|
end: '\bг\.|\bгода?\b'
|
||||||
|
|||||||
@ -21,14 +21,21 @@
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from collections.abc import Set as AbstractSet
|
from collections.abc import Set as AbstractSet
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from src.contract.common import DataType
|
from src.contract.common import DataType
|
||||||
from src.contract.result import OsvGroup
|
from src.contract.result import OsvGroup
|
||||||
from src.parser.deadline import Deadline
|
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.grid import Sheet, column_letter
|
||||||
from src.parser.hierarchy import BodyRow, Levels
|
from src.parser.hierarchy import BodyRow, Levels
|
||||||
from src.parser.indicators import is_indicator_total, read_label
|
from src.parser.indicators import is_indicator_total, read_label
|
||||||
@ -37,6 +44,7 @@ from src.parser.rules import GroupType, Rules
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ACCOUNT_RE",
|
"ACCOUNT_RE",
|
||||||
|
"match_account",
|
||||||
"DEADLINE_CHECK_EVERY_ROWS",
|
"DEADLINE_CHECK_EVERY_ROWS",
|
||||||
"SUM_FIELDS",
|
"SUM_FIELDS",
|
||||||
"ClassifiedRow",
|
"ClassifiedRow",
|
||||||
@ -53,7 +61,20 @@ __all__ = [
|
|||||||
# `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки
|
# `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки
|
||||||
# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
|
# («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)
|
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 = "."
|
_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
|
LABEL_COLUMN_INDENT_STEP = 1000
|
||||||
# Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных
|
# Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных
|
||||||
@ -101,7 +122,10 @@ def _table_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadl
|
|||||||
Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные.
|
Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные.
|
||||||
"""
|
"""
|
||||||
found: list[int] = []
|
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):
|
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:
|
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||||
deadline.check("строки таблицы")
|
deadline.check("строки таблицы")
|
||||||
if sheet.is_row_empty(row) or not _in_table(sheet, row, header):
|
if sheet.is_row_empty(row) or not _in_table(sheet, row, header):
|
||||||
@ -402,6 +426,8 @@ class _Context:
|
|||||||
currency_codes: frozenset[str]
|
currency_codes: frozenset[str]
|
||||||
inns: Mapping[int, str]
|
inns: Mapping[int, str]
|
||||||
unpaired: AbstractSet[int]
|
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]:
|
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
|
return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
|
||||||
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки
|
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки
|
||||||
own = item.label_data_type or context.doc_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:
|
if account is not None:
|
||||||
code = account.group(1)
|
code = account.group(1)
|
||||||
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
|
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
|
||||||
@ -443,12 +469,14 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
|
|||||||
)
|
)
|
||||||
if by_account:
|
if by_account:
|
||||||
group_type = "COUNTERPARTY"
|
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":
|
if group_type != "COUNTERPARTY":
|
||||||
return _Kind(group_type, name, code, own), branch
|
return _Kind(group_type, name, code, own), branch
|
||||||
inn_col = context.header.inn_col
|
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)
|
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
|
||||||
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
|
# ИНН: колонка ИНН, затем строка ИНН рядом (`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
|
inn = column or context.inns.get(item.row) or in_name
|
||||||
kind = _Kind(
|
kind = _Kind(
|
||||||
group_type,
|
group_type,
|
||||||
@ -475,6 +503,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
inns: Mapping[int, str] | None = None,
|
inns: Mapping[int, str] | None = None,
|
||||||
unpaired: AbstractSet[int] = frozenset(),
|
unpaired: AbstractSet[int] = frozenset(),
|
||||||
header_account: str | None = None,
|
header_account: str | None = None,
|
||||||
|
names: Mapping[int, str] | None = None,
|
||||||
) -> list[ClassifiedRow]:
|
) -> list[ClassifiedRow]:
|
||||||
"""Классификация строк по дереву `levels` и схеме группировок.
|
"""Классификация строк по дереву `levels` и схеме группировок.
|
||||||
|
|
||||||
@ -494,7 +523,16 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
получают его код.
|
получают его код.
|
||||||
"""
|
"""
|
||||||
context = _Context(
|
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)
|
extra_keys = _extra_keys(sheet, header, rows)
|
||||||
root_branch = _Branch(header_account, 0)
|
root_branch = _Branch(header_account, 0)
|
||||||
|
|||||||
127
api/src/parser/code_blocks.py
Normal file
127
api/src/parser/code_blocks.py
Normal 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)
|
||||||
@ -33,6 +33,7 @@ __all__ = [
|
|||||||
"inn_from_cell",
|
"inn_from_cell",
|
||||||
"is_header_repeat",
|
"is_header_repeat",
|
||||||
"is_osv_sheet",
|
"is_osv_sheet",
|
||||||
|
"table_kind_row",
|
||||||
]
|
]
|
||||||
|
|
||||||
CollapsedGroup = Literal["opening", "closing"]
|
CollapsedGroup = Literal["opening", "closing"]
|
||||||
@ -504,6 +505,26 @@ def first_data_row(sheet: Sheet, header: TableHeader) -> int:
|
|||||||
return next(_data_rows(sheet, header.last_row), sheet.n_rows + 1)
|
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], ...]:
|
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)
|
return tuple((col, text) for col, text in _texts(sheet, row) if col <= right_edge)
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from typing import Any, Literal, get_args
|
|||||||
|
|
||||||
from src.contract.common import DataType
|
from src.contract.common import DataType
|
||||||
from src.contract.result import OsvGroup
|
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.grid import Sheet
|
||||||
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset
|
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ _LIST_SEPARATOR = ","
|
|||||||
# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН»
|
# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН»
|
||||||
_ATTRIBUTE_SEPARATOR = "."
|
_ATTRIBUTE_SEPARATOR = "."
|
||||||
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
||||||
_CODE_TRAILING = ".,;:"
|
_CODE_TRAILING = ".,;:*"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -149,6 +149,7 @@ def _currency_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]:
|
|||||||
class _PeriodHit:
|
class _PeriodHit:
|
||||||
cell: _HeadCell
|
cell: _HeadCell
|
||||||
text: str
|
text: str
|
||||||
|
value_cell: _HeadCell | None = None # значение в соседней ячейке («За период» | «01.01.25..30.06.25»)
|
||||||
|
|
||||||
|
|
||||||
def _cut_month_range(tail: str, rules: Rules) -> str | None:
|
def _cut_month_range(tail: str, rules: Rules) -> str | None:
|
||||||
@ -190,9 +191,17 @@ def _cut_period(tail: str, rules: Rules) -> str:
|
|||||||
return tail
|
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]:
|
def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]:
|
||||||
"""Ячейки с меткой периода («за …», «Период: …») и текст после метки; пустой текст — не кандидат.
|
"""Ячейки с меткой периода («за …», «Период: …») и текст после метки; пустой текст — не кандидат.
|
||||||
|
|
||||||
|
Метка без текста («За период» | значение в соседней ячейке) берёт значение справа: ячейка значения запоминается,
|
||||||
|
организацией она не считается.
|
||||||
Какой из кандидатов — период, решает `_find_period`: «за» в названии организации не должно его перехватывать.
|
Какой из кандидатов — период, решает `_find_period`: «за» в названии организации не должно его перехватывать.
|
||||||
"""
|
"""
|
||||||
hits: list[_PeriodHit] = []
|
hits: list[_PeriodHit] = []
|
||||||
@ -203,11 +212,15 @@ def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]
|
|||||||
tail = _tail(cell.text, match)
|
tail = _tail(cell.text, match)
|
||||||
if tail:
|
if tail:
|
||||||
hits.append(_PeriodHit(cell, _cut_period(tail, rules)))
|
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
|
return hits
|
||||||
|
|
||||||
|
|
||||||
def _is_recognised(text: str, rules: Rules) -> bool:
|
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:
|
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
|
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(
|
def _organization(
|
||||||
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
|
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@ -293,9 +319,11 @@ def _organization(
|
|||||||
cell
|
cell
|
||||||
for cell in (
|
for cell in (
|
||||||
None if period is None else period.cell,
|
None if period is None else period.cell,
|
||||||
|
None if period is None else period.value_cell,
|
||||||
data_type,
|
data_type,
|
||||||
*_currency_cells(cells, rules),
|
*_currency_cells(cells, rules),
|
||||||
*_kind_row_cells(cells, rules),
|
*_kind_row_cells(cells, rules),
|
||||||
|
*_account_value_cells(cells, rules),
|
||||||
)
|
)
|
||||||
if cell
|
if cell
|
||||||
]
|
]
|
||||||
@ -306,12 +334,19 @@ def _organization(
|
|||||||
return _title_tail_organization(cells, rules) if found is None else found.text
|
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:
|
for cell in cells:
|
||||||
match = _find(rules.headers.header_markers["account"], cell.text)
|
match = _find(rules.headers.header_markers["account"], cell.text)
|
||||||
if match is None:
|
if match is None:
|
||||||
continue
|
continue
|
||||||
code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": "))
|
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:
|
if code is not None:
|
||||||
return code.group(0).rstrip(_CODE_TRAILING)
|
return code.group(0).rstrip(_CODE_TRAILING)
|
||||||
return None
|
return None
|
||||||
@ -417,14 +452,13 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
|
|||||||
if span is not None:
|
if span is not None:
|
||||||
return _month_range_period(span, text, warnings)
|
return _month_range_period(span, text, warnings)
|
||||||
rule = rules.periods.match(text)
|
rule = rules.periods.match(text)
|
||||||
year = rules.periods.year.search(text)
|
year_value = rules.periods.year_of(text)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов")
|
warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов")
|
||||||
if year is None:
|
if year_value is None:
|
||||||
warnings.append(f"период «{text}»: год не найден")
|
warnings.append(f"период «{text}»: год не найден")
|
||||||
# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить
|
# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить
|
||||||
# по данным (opening Q3 == opening Q1 -> нарастающая)
|
# по данным (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
|
quarter = None if rule is None else rule.quarter
|
||||||
is_cumulative = None if rule is None else rule.is_cumulative
|
is_cumulative = None if rule is None else rule.is_cumulative
|
||||||
start, end = _bounds(year_value, quarter, 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])
|
organization = _organization(cells, rules, period_hit, None if standalone_type is None else standalone_type[0])
|
||||||
if organization is None:
|
if organization is None:
|
||||||
warnings.append("наименование организации в шапке не найдено")
|
warnings.append("наименование организации в шапке не найдено")
|
||||||
account = _account_code(cells, rules)
|
account = _account_code(sheet, cells, rules)
|
||||||
if account is None:
|
if account is None:
|
||||||
warnings.append(ACCOUNT_NOT_FOUND_WARNING)
|
warnings.append(ACCOUNT_NOT_FOUND_WARNING)
|
||||||
period_text = None if period_hit is None else period_hit.text
|
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")]
|
groups = [_group("ACCOUNT")]
|
||||||
if len(groups) == 1:
|
if len(groups) == 1:
|
||||||
groups.extend(_kind_row_groups(sheet, header, rules))
|
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)
|
return GroupSchema(groups, 1, hint)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
from collections.abc import Iterator, Mapping, Sequence
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Protocol
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
from src.contract.common import DataType
|
from src.contract.common import DataType
|
||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
@ -47,6 +47,9 @@ DEADLINE_CHECK_EVERY = 500
|
|||||||
_ZERO = Decimal(0)
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
|
||||||
|
Role = Literal["counterparty", "contract"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BodyRow:
|
class BodyRow:
|
||||||
"""Строка данных до классификации: текст, признак счёта, оформление и шесть сумм (по `ColumnMap`)."""
|
"""Строка данных до классификации: текст, признак счёта, оформление и шесть сумм (по `ColumnMap`)."""
|
||||||
@ -62,6 +65,8 @@ class BodyRow:
|
|||||||
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
|
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
|
||||||
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
|
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
|
||||||
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
|
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
|
||||||
|
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
|
||||||
|
role: Role | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -174,6 +179,28 @@ def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None
|
|||||||
return by_row
|
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, ...]:
|
def _vector(item: BodyRow) -> tuple[Decimal, ...]:
|
||||||
return tuple(_ZERO if value is None else value for value in item.sums)
|
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], ...] = (
|
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
|
||||||
("outline", strategy_outline),
|
("outline", strategy_outline),
|
||||||
("indent", strategy_indent),
|
("indent", strategy_indent),
|
||||||
|
("code_blocks", strategy_code_blocks),
|
||||||
("account_pattern", strategy_account_pattern),
|
("account_pattern", strategy_account_pattern),
|
||||||
("sums", strategy_sums),
|
("sums", strategy_sums),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,11 +15,13 @@ from src.contract.common import DataType
|
|||||||
from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow
|
from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||||
from src.parser.checks import run_checks
|
from src.parser.checks import run_checks
|
||||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
|
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.deadline import Deadline
|
||||||
from src.parser.detect import TableHeader, find_table_header
|
from src.parser.detect import TableHeader, find_table_header
|
||||||
from src.parser.errors import InvalidFormatError
|
from src.parser.errors import InvalidFormatError
|
||||||
from src.parser.grid import Sheet, column_letter
|
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.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.indicators import levels_with_indicators
|
||||||
from src.parser.inn_rows import InnGlue, glue_inn_rows
|
from src.parser.inn_rows import InnGlue, glue_inn_rows
|
||||||
from src.parser.osv_warnings import (
|
from src.parser.osv_warnings import (
|
||||||
@ -113,6 +115,23 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea
|
|||||||
return replace(doc, account_code_raw=code, warnings=tuple(warnings))
|
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]:
|
def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]:
|
||||||
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа."""
|
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа."""
|
||||||
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
|
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
|
||||||
@ -194,6 +213,9 @@ class OsvParser:
|
|||||||
deadline.check(f"лист «{sheet.name}»: строки таблицы")
|
deadline.check(f"лист «{sheet.name}»: строки таблицы")
|
||||||
step = "строки таблицы"
|
step = "строки таблицы"
|
||||||
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
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 = "иерархия строк"
|
step = "иерархия строк"
|
||||||
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
|
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
|
||||||
step = "строки ИНН"
|
step = "строки ИНН"
|
||||||
@ -225,6 +247,7 @@ class OsvParser:
|
|||||||
inns=glue.inns,
|
inns=glue.inns,
|
||||||
unpaired=glue.unpaired,
|
unpaired=glue.unpaired,
|
||||||
header_account=doc.account_code_raw or None,
|
header_account=doc.account_code_raw or None,
|
||||||
|
names=blocks.names,
|
||||||
)
|
)
|
||||||
except InvalidFormatError as exc:
|
except InvalidFormatError as exc:
|
||||||
raise _invalid_format(sheet, step, exc) from exc
|
raise _invalid_format(sheet, step, exc) from exc
|
||||||
|
|||||||
@ -51,6 +51,7 @@ HIERARCHY_TEXTS = {
|
|||||||
"outline": "по группировке листа (outline)",
|
"outline": "по группировке листа (outline)",
|
||||||
"indent": "по отступам (indent)",
|
"indent": "по отступам (indent)",
|
||||||
"account_pattern": "по кодам счетов (account_pattern)",
|
"account_pattern": "по кодам счетов (account_pattern)",
|
||||||
|
"code_blocks": "блоки субсчетов с кодами контрагентов (code_blocks)",
|
||||||
"header_account": "плоский список под счётом шапки (header_account)",
|
"header_account": "плоский список под счётом шапки (header_account)",
|
||||||
"sums": "по суммам (sums)",
|
"sums": "по суммам (sums)",
|
||||||
"trivial": "плоский список (trivial)",
|
"trivial": "плоский список (trivial)",
|
||||||
|
|||||||
@ -155,6 +155,8 @@ class OsvHeaderRules(_Model):
|
|||||||
total_row: list[Pattern]
|
total_row: list[Pattern]
|
||||||
total_row_exact: list[Pattern]
|
total_row_exact: list[Pattern]
|
||||||
total_row_secondary: list[Pattern]
|
total_row_secondary: list[Pattern]
|
||||||
|
# Итог субсчёта «ИТОГ по 60.111» (группа account — код субсчёта); пусто — правило не действует
|
||||||
|
subaccount_sums: list[Pattern] = []
|
||||||
column_captions: list[Pattern]
|
column_captions: list[Pattern]
|
||||||
counterparty_inn_suffix: list[Pattern]
|
counterparty_inn_suffix: list[Pattern]
|
||||||
inn_only_row: list[Pattern]
|
inn_only_row: list[Pattern]
|
||||||
@ -176,6 +178,7 @@ class OsvHeaderRules(_Model):
|
|||||||
("counterparty_inn_suffix", {"tail", "inn"}),
|
("counterparty_inn_suffix", {"tail", "inn"}),
|
||||||
("inn_only_row", {"inn"}),
|
("inn_only_row", {"inn"}),
|
||||||
("inn_kpp_cell", {"inn"}),
|
("inn_kpp_cell", {"inn"}),
|
||||||
|
("subaccount_sums", {"account"}),
|
||||||
):
|
):
|
||||||
for pattern in getattr(self, field_name):
|
for pattern in getattr(self, field_name):
|
||||||
missing_groups = groups - pattern.group_names
|
missing_groups = groups - pattern.group_names
|
||||||
@ -243,6 +246,7 @@ class PeriodRule(_Model):
|
|||||||
|
|
||||||
|
|
||||||
_MONTHS_IN_YEAR = 12
|
_MONTHS_IN_YEAR = 12
|
||||||
|
_CENTURY = 2000 # двузначный год даты «…25» — 2025
|
||||||
_RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"})
|
_RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"})
|
||||||
_MONTH_GROUPS = frozenset(f"m{number:02d}" for number in range(1, _MONTHS_IN_YEAR + 1))
|
_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):
|
class PeriodRules(_Model):
|
||||||
rules: list[PeriodRule]
|
rules: list[PeriodRule]
|
||||||
year: Pattern
|
year: Pattern
|
||||||
|
# Двузначный год даты (группа yy), если года из четырёх цифр в тексте нет
|
||||||
|
short_year_date: Pattern
|
||||||
end: Pattern
|
end: Pattern
|
||||||
# Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2
|
# Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2
|
||||||
months: Pattern
|
months: Pattern
|
||||||
@ -329,6 +335,14 @@ class PeriodRules(_Model):
|
|||||||
return None
|
return None
|
||||||
return MonthRange(month, int(year.group(0)), month, int(year.group(0)))
|
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:
|
def match(self, text: str) -> PeriodRule | None:
|
||||||
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
|
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
|
||||||
for rule in self.rules:
|
for rule in self.rules:
|
||||||
|
|||||||
@ -4,9 +4,10 @@
|
|||||||
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
||||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
|
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
|
||||||
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
|
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
|
||||||
другим треком); описание — `osv/synthetic/README.md`
|
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
|
||||||
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
|
блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
||||||
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
|
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
|
||||||
|
и zip постоянны, порядок записи фиксирован.
|
||||||
|
|
||||||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||||||
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
||||||
@ -972,6 +973,70 @@ def _s44() -> list[SheetSpec]:
|
|||||||
return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок"))
|
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]:
|
def _s28() -> list[SheetSpec]:
|
||||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||||
tree = _inn_layout_tree()
|
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),
|
"s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx),
|
||||||
"s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx),
|
"s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx),
|
||||||
"s44_subkonto_unnamed_10.xlsx": (_s44, 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)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
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)]
|
names = [path.name for path in generate_all(tmp_path)]
|
||||||
|
|
||||||
assert names == list(FILE_NAMES)
|
assert names == list(FILE_NAMES)
|
||||||
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(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
|
# 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[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 [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)
|
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)
|
||||||
|
|||||||
@ -50,6 +50,8 @@ FLAT_FORM_FILES = (
|
|||||||
"s42_subaccounts_totals_reversed.xlsx",
|
"s42_subaccounts_totals_reversed.xlsx",
|
||||||
"s43_subkonto_unnamed_60.xlsx",
|
"s43_subkonto_unnamed_60.xlsx",
|
||||||
"s44_subkonto_unnamed_10.xlsx",
|
"s44_subkonto_unnamed_10.xlsx",
|
||||||
|
"s45_account_label_value_cells.xlsx",
|
||||||
|
"s46_counterparty_code_blocks.xlsx",
|
||||||
)
|
)
|
||||||
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
||||||
EXPECTED_NAMES = [
|
EXPECTED_NAMES = [
|
||||||
@ -101,9 +103,9 @@ class Expect:
|
|||||||
"""Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
|
"""Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
|
||||||
|
|
||||||
hierarchy: str
|
hierarchy: str
|
||||||
year: int
|
year: int | None
|
||||||
quarter: str
|
quarter: str | None
|
||||||
cumulative: bool
|
cumulative: bool | None
|
||||||
status: ResultStatus = ResultStatus.DONE
|
status: ResultStatus = ResultStatus.DONE
|
||||||
documents: int = 1
|
documents: int = 1
|
||||||
variant: int = 1
|
variant: int = 1
|
||||||
@ -183,6 +185,10 @@ EXPECTED: dict[str, Expect] = {
|
|||||||
"s44_subkonto_unnamed_10.xlsx": Expect(
|
"s44_subkonto_unnamed_10.xlsx": Expect(
|
||||||
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
|
"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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -78,8 +78,8 @@ def never_expires() -> Deadline:
|
|||||||
# --- Каскад: какая стратегия выбирается ---
|
# --- Каскад: какая стратегия выбирается ---
|
||||||
|
|
||||||
|
|
||||||
def test_strategies_are_ordered_outline_indent_account_pattern_sums() -> None:
|
def test_strategies_are_ordered_outline_indent_code_blocks_account_pattern_sums() -> None:
|
||||||
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "account_pattern", "sums"]
|
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "code_blocks", "account_pattern", "sums"]
|
||||||
|
|
||||||
|
|
||||||
def test_outline_levels_win_first() -> None:
|
def test_outline_levels_win_first() -> None:
|
||||||
|
|||||||
184
api/tests/unit/parser/test_osv_account_marker.py
Normal file
184
api/tests/unit/parser/test_osv_account_marker.py
Normal 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] == ["Поставщики", "Итого"]
|
||||||
@ -61,7 +61,7 @@ SHALL пропускаться с предупреждением файла `SHE
|
|||||||
|
|
||||||
### Requirement: Шапка документа и схема группировок
|
### Requirement: Шапка документа и схема группировок
|
||||||
Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (хвост метки «План счетов <организация>»; иначе первая непустая текстовая строка, не являющаяся меткой шапки и не лежащая между строкой названия ведомости и строкой периода; иначе хвост названия ведомости; trim),
|
Парсер 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 — группировки
|
`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
|
||||||
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
|
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
|
||||||
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
|
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
|
||||||
@ -110,6 +110,23 @@ SHALL пропускаться с предупреждением файла `SHE
|
|||||||
- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`,
|
- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`,
|
||||||
`group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` (множественное «Организации» — синоним COUNTERPARTY в словаре; единственное «Организация» — собственная организация учёта, R-17)
|
`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: Счёт шапки важнее строки таблицы
|
#### Scenario: Счёт шапки важнее строки таблицы
|
||||||
- **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2»)
|
- **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2»)
|
||||||
- **THEN** `account_code_raw = "76"`
|
- **THEN** `account_code_raw = "76"`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user