Плоская форма ОСВ 1С: счёт из шапки, «Итого развернутое», плоский список, контрагенты расчётных счетов (#59) #63

Merged
aep merged 16 commits from feat/59-osv-flat-form into main 2026-10-19 13:21:55 +03:00
9 changed files with 285 additions and 13 deletions
Showing only changes of commit cdbe23b9b6 - Show all commits

View File

@ -15,5 +15,10 @@ mapping:
- ['^стать(и|я)\b', OTHER]
- ['^валют', OTHER]
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам
# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты.
counterparty_accounts: ['58', '60', '62', '66', '67', '75', '76', '80']
# Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка
default: OTHER

View File

@ -220,6 +220,9 @@ def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] =
if total is None:
return TOTAL_NOT_FOUND_WARNING
if root is None:
level_zero = _level_zero(rows)
if level_zero:
return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ сумме {len(level_zero)} строк уровня 0"
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
level_zero = _level_zero(rows)

View File

@ -83,6 +83,8 @@ class ClassifiedRow:
data_type: DataType | None
extra: dict[str, str | None] | None
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-9), а не по схеме группировок
counterparty_by_account: bool = False
# --- Строки таблицы ---
@ -383,6 +385,7 @@ class _Kind:
counterparty_name: str | None = None
counterparty_inn: str | None = None
indicator: bool = False
counterparty_by_account: bool = False
@dataclass(frozen=True)
@ -423,6 +426,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
position = inherited.depth + 1
group_type, name = _schema_group(context.schema, position)
branch = _Branch(code, position)
# Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов (R-9)
by_account = (
group_type == "OTHER"
and name is None
and position == 1
and position >= len(context.schema)
and context.rules.groups.is_counterparty_account(code)
)
if by_account:
group_type = "COUNTERPARTY"
if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, own), branch
inn_col = context.header.inn_col
@ -430,7 +443,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules)
inn = column or context.inns.get(item.row) or in_name
return _Kind(group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn), branch
kind = _Kind(
group_type,
name,
code,
own,
counterparty_name=counterparty,
counterparty_inn=inn,
counterparty_by_account=by_account,
)
return kind, branch
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
@ -496,6 +518,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
data_type=kind.data_type,
extra=_extra(sheet, item.row, extra_keys),
indicator=kind.indicator,
counterparty_by_account=kind.counterparty_by_account,
)
)
return result

View File

@ -112,6 +112,16 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea
return replace(doc, account_code_raw=code, warnings=tuple(warnings))
def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]:
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-9), в порядке листа."""
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
return [
f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — "
"проверьте"
for code in codes
]
def _is_ok(checks: OsvChecks) -> bool:
return (
bool(checks.total_row_matches_root)
@ -226,6 +236,7 @@ class OsvParser:
*notes_of(OSV_HEADER, doc.warnings),
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
*notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_ROWS, _by_account_warnings(classified)),
*notes_of(OSV_INN_ROWS, glue.warnings),
]
if not any(item.group_type != "TOTAL" for item in classified):

View File

@ -184,9 +184,34 @@ class OsvHeaderRules(_Model):
return self
def _check_account_codes(field_name: str, codes: Iterable[str]) -> None:
bad = [code for code in codes if not _ACCOUNT_CODE.fullmatch(code)]
if bad:
raise ValueError(f"{field_name}: ожидаются коды счетов вида «91» или «76.09», {bad}")
def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool:
"""Код счёта из файла («91», «91.01») совпадает с одним из `roots` либо является его субсчётом (не «910»)."""
if not account_code:
return False
code = account_code.strip()
return any(code == root or code.startswith(f"{root}.") for root in roots)
class OsvGroupRules(_Model):
mapping: list[tuple[Pattern, GroupType]]
default: GroupType = "OTHER"
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-9)
counterparty_accounts: list[str] = []
@model_validator(mode="after")
def _check_counterparty_accounts(self) -> "OsvGroupRules":
_check_account_codes("counterparty_accounts", self.counterparty_accounts)
return self
def is_counterparty_account(self, account_code: str | None) -> bool:
"""Счёт из `counterparty_accounts` или его субсчёт («76» -> 76, 76.2, 76.Н.1; не 760)."""
return _is_under_account(account_code, self.counterparty_accounts)
def classify(self, text: str) -> GroupType:
"""Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`."""
@ -203,17 +228,12 @@ class OsvCheckRules(_Model):
@model_validator(mode="after")
def _check_codes(self) -> "OsvCheckRules":
bad = [code for code in self.accounts_without_analytic_balance if not _ACCOUNT_CODE.fullmatch(code)]
if bad:
raise ValueError(f"accounts_without_analytic_balance: ожидаются коды счетов вида «91» или «76.09», {bad}")
_check_account_codes("accounts_without_analytic_balance", self.accounts_without_analytic_balance)
return self
def has_no_analytic_balance(self, account_code: str | None) -> bool:
"""Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт."""
if not account_code:
return False
code = account_code.strip()
return any(code == root or code.startswith(f"{root}.") for root in self.accounts_without_analytic_balance)
return _is_under_account(account_code, self.accounts_without_analytic_balance)
class PeriodRule(_Model):

View File

@ -43,7 +43,7 @@ VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
# Плоская форма ОСВ 1С (#59): на этапе снимков текущего поведения ожиданий нет — их добавляют задачи 2–5 плана
# Плоская форма ОСВ 1С (#59)
FLAT_FORM_FILES = (
"s40_flat_header_account.xlsx",
"s41_flat_chart_of_accounts.xlsx",
@ -162,6 +162,27 @@ EXPECTED: dict[str, Expect] = {
"s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False),
"s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False),
"s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None),
# плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `data_type = None` (умолчание — трек A, AURORA-1613)
# счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.»
"s40_flat_header_account.xlsx": Expect(
"header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"'
),
# вид «Организации», «за 2 Квартал 2026 г. План счетов …»
"s41_flat_chart_of_accounts.xlsx": Expect(
"header_account", 2026, "Q2", False, data_type=None, organization="ООО Хреновская Нива"
),
# субсчета 76.2…76.Н.1 по шаблону кода; «Итого развернутое» перед «Итого»
"s42_subaccounts_totals_reversed.xlsx": Expect(
"account_pattern", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"'
),
# счёт 60 без вида аналитики в шапке, отступы
"s43_subkonto_unnamed_60.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
# то же на счёте 10: материалы остаются OTHER
"s44_subkonto_unnamed_10.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"'
),
}
@ -248,13 +269,11 @@ def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
"""Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора)."""
assert synthetic_file.name in {*EXPECTED, *FLAT_FORM_FILES}, f"добавьте ожидания для {synthetic_file.name}"
assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}"
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
if synthetic_file.name in FLAT_FORM_FILES:
pytest.skip("плоская форма (#59): ожидания по файлу — в сценариях задач 2–5, до них поведение фиксирует golden")
expect = EXPECTED[synthetic_file.name]
outcome = parse_path(synthetic_file)

View File

@ -1142,3 +1142,74 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None:
secondary = parsed.one("Итого развернутое")
assert parsed.total_row == secondary.body.row
assert secondary.group_type == "TOTAL"
# --- Контрагенты расчётных счетов без названия вида (#59, R-9) ---
def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet:
"""Схема из одной ACCOUNT («Субконто»); `label` — вторая группировка в блоке заголовка."""
block: list[list[object]] = [["Субконто", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]]
block.append([label, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"])
return sheet_from_rows([["ООО Ромашка"], [TITLE], [], *block, *rows])
def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "60.01", *S100],
[L(1), "ИП Знаменских А.И.", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("ИП Знаменских А.И.")
assert row.group_type == "COUNTERPARTY"
assert row.group_name is None
assert row.counterparty_name == "ИП Знаменских А.И."
assert row.counterparty_by_account is True
def test_unnamed_second_level_stays_other(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "60.01", *S100],
[L(1), "ИП Знаменских А.И.", *S100],
[L(2), "Договор № 1", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("Договор № 1")
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False
def test_named_other_group_is_not_reclassified(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "76.01", *S100],
[L(1), "Станок", *S100],
[L(0), "Итого", *S100],
label="Основные средства",
)
parsed = parse(sheet, rules)
row = parsed.one("Станок")
assert row.group_type == "OTHER"
assert row.group_name == "Основные средства"
assert row.counterparty_by_account is False
def test_non_settlement_account_stays_other(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "10.01", *S100],
[L(1), "Мука пшеничная", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("Мука пшеничная")
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False

View File

@ -1,9 +1,12 @@
"""Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк."""
from pathlib import Path
import pytest
from gridbuilder import L, M, sheet_from_rows
from src.contract.result import OsvDocument
from src.contract.result import OsvDocument, OsvRow
from src.parser import get_parser
from src.parser.deadline import Deadline
from src.parser.osv import OsvParser
from src.parser.rules import Rules, load_rules
@ -101,3 +104,79 @@ def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Ru
assert HEADER_ACCOUNT_CODE not in codes
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.account_code_raw for item in data] == ["60", "60"]
# --- Контрагенты расчётных счетов без названия вида (Task 5, R-9, R-10) ---
BY_ACCOUNT_WARNING = (
"вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте"
)
def _parse_synthetic(synthetic_dir: Path, name: str) -> tuple[OsvDocument, list[str], str]:
outcome = get_parser().parse((synthetic_dir / name).read_bytes(), "XLSX")
assert outcome.error is None, outcome.error
(document,) = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)]
return document, [note.message for note in document.warnings], document.status
def _data_rows(document: OsvDocument) -> list[OsvRow]:
return [item for item in document.rows if item.group_type != "TOTAL"]
@pytest.mark.fixtures
def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None:
document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx")
kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"}
assert set(kids) == {"ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'}
assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"}
notes = [note for note in document.warnings if note.code == "OSV_ROWS"]
assert [note.message for note in notes] == [BY_ACCOUNT_WARNING]
assert status == "done"
assert messages.count(BY_ACCOUNT_WARNING) == 1
@pytest.mark.fixtures
def test_s44_materials_stay_other(synthetic_dir: Path) -> None:
document, messages, _ = _parse_synthetic(synthetic_dir, "s44_subkonto_unnamed_10.xlsx")
kids = [item for item in _data_rows(document) if item.row_value in {"Мука пшеничная", "Сахар-песок"}]
assert [item.row_value for item in kids] == ["Мука пшеничная", "Сахар-песок"]
assert {item.group_type for item in kids} == {"OTHER"}
assert not any("вид субконто в шапке не назван" in message for message in messages)
@pytest.mark.fixtures
def test_s41_flat_counterparties(synthetic_dir: Path) -> None:
document, messages, _ = _parse_synthetic(synthetic_dir, "s41_flat_chart_of_accounts.xlsx")
data = _data_rows(document)
assert {item.group_type for item in data} == {"COUNTERPARTY"}
assert {item.account_code_raw for item in data} == {"60"}
assert not any("вид субконто в шапке не назван" in message for message in messages)
def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None:
"""T4.2: плоский список под счётом шапки с испорченной суммой «Итого» — needs_check, не done."""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 76.Н.1"],
["Контрагенты"],
["за 2025 г."],
[],
*_table_head(),
["Август - Агро ООО", 1000, None, 500, 300, 1200, None],
["АВИС ООО", None, 200, 100, 400, None, 500],
["Итого", 800, None, 650, 700, 1200, 500],
]
document, _ = _parse(rules, rows)
assert document.status == "needs_check"
assert document.checks is not None
assert document.checks.total_row_matches_root is False
# T4.1: без корня ACCOUNT текст говорит о сумме строк уровня 0, а не о «корневом счёте нет»
messages = [note.message for note in document.warnings]
assert any("≠ сумме 2 строк уровня 0" in message for message in messages), messages
assert not any("корневого счёта нет" in message for message in messages)

View File

@ -706,3 +706,44 @@ def test_v_valyute_row_is_currency(rules: Rules) -> None:
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
def test_indicator_service_column(rules: Rules, title: str) -> None:
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-9) ---
@pytest.mark.parametrize(
("code", "expected"),
[
("76", True),
("76.2", True),
("76.Н.1", True),
("60.01", True),
("60", True),
(" 62.01 ", True),
("760", False),
("600", False),
("10.01", False),
("71", False),
("73.01", False),
("", False),
(None, False),
],
)
def test_counterparty_accounts_match_code_and_subaccounts(rules: Rules, code: str | None, expected: bool) -> None:
assert rules.groups.is_counterparty_account(code) is expected
def test_counterparty_accounts_list_is_data(rules: Rules) -> None:
assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"]
@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"])
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_groups.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["counterparty_accounts"] = [bad]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*counterparty_accounts"):
load_rules(target)