Плоская форма ОСВ 1С: счёт из шапки, «Итого развернутое», плоский список, контрагенты расчётных счетов (#59) #63
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user