fix(osv): счёт шапки «Счет Поставщика | 60*» и голые коды контрагентов (AURORA-1714, #71)
Метки «Счет Поставщика»/«Счет Клиента» добавлены в header_markers.account; код счёта читается из соседней ячейки строки, «*» отбрасывается. При известном счёте шапки голый числовой код, не совпадающий с ним, не считается счётом. Синтетика s45, тест на книге openpyxl, сценарий в спеке osv-parsing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
242e11681a
commit
ef14747f49
@ -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` — конец текста периода в строке шапки («г.», «год») |
|
||||||
|
|||||||
@ -77,8 +77,10 @@ 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:
|
||||||
- '\bза\s+'
|
- '\bза\s+'
|
||||||
|
|||||||
@ -42,7 +42,7 @@ _LIST_SEPARATOR = ","
|
|||||||
# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН»
|
# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН»
|
||||||
_ATTRIBUTE_SEPARATOR = "."
|
_ATTRIBUTE_SEPARATOR = "."
|
||||||
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
||||||
_CODE_TRAILING = ".,;:"
|
_CODE_TRAILING = ".,;:*"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -306,12 +306,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
|
||||||
@ -473,7 +480,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
|
||||||
|
|||||||
@ -20,6 +20,7 @@ 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 (
|
||||||
@ -112,6 +113,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)
|
||||||
@ -193,6 +211,7 @@ 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)
|
||||||
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 = "строки ИНН"
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
ОСВ (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*» в соседних ячейках (#71); описание —
|
||||||
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
|
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
|
||||||
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
|
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
|
||||||
|
|
||||||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||||||
@ -972,6 +972,24 @@ def _s44() -> list[SheetSpec]:
|
|||||||
return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок"))
|
return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок"))
|
||||||
|
|
||||||
|
|
||||||
|
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({1: "Оборотно-сальдовая ведомость", 2: "За период"}, 6, rows)
|
||||||
|
sheet.put(2, 3, "01.01.25..30.06.25")
|
||||||
|
sheet.put(3, 2, "Счет Поставщика")
|
||||||
|
sheet.put(3, 3, "60*")
|
||||||
|
sheet.put(4, 2, "Счет Клиента")
|
||||||
|
return [sheet]
|
||||||
|
|
||||||
|
|
||||||
def _s28() -> list[SheetSpec]:
|
def _s28() -> list[SheetSpec]:
|
||||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||||
tree = _inn_layout_tree()
|
tree = _inn_layout_tree()
|
||||||
@ -1085,6 +1103,7 @@ 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),
|
||||||
}
|
}
|
||||||
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_s45_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, 46)]
|
||||||
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)
|
||||||
|
|||||||
81
api/tests/unit/parser/test_osv_account_marker.py
Normal file
81
api/tests/unit/parser/test_osv_account_marker.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""Счёт шапки «Счет Поставщика | 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],
|
||||||
|
]
|
||||||
|
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 _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"}
|
||||||
@ -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,12 @@ 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: Счёт шапки важнее строки таблицы
|
#### 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