feat(osv): контрагенты расчётных счетов без названия вида — по плану счетов (#59)
counterparty_accounts в osv_groups.yaml (R-9: без 71 и 73), is_counterparty_account, OSV_ROWS по счёту; формулировка проверки «Итого» без корня ACCOUNT (T4.1); ожидания s40–s44 в синтетике (T1.1/T1.2), тест испорченной «Итого» плоского списка (T4.2). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
71c4c93db2
commit
cdbe23b9b6
@ -15,5 +15,10 @@ mapping:
|
|||||||
- ['^стать(и|я)\b', OTHER]
|
- ['^стать(и|я)\b', OTHER]
|
||||||
- ['^валют', 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
|
default: OTHER
|
||||||
|
|||||||
@ -220,6 +220,9 @@ def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] =
|
|||||||
if total is None:
|
if total is None:
|
||||||
return TOTAL_NOT_FOUND_WARNING
|
return TOTAL_NOT_FOUND_WARNING
|
||||||
if root is None:
|
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}) не пуста, а корневого счёта нет"
|
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
|
||||||
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
|
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
|
||||||
level_zero = _level_zero(rows)
|
level_zero = _level_zero(rows)
|
||||||
|
|||||||
@ -83,6 +83,8 @@ class ClassifiedRow:
|
|||||||
data_type: DataType | None
|
data_type: DataType | None
|
||||||
extra: dict[str, str | None] | None
|
extra: dict[str, str | None] | None
|
||||||
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
|
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_name: str | None = None
|
||||||
counterparty_inn: str | None = None
|
counterparty_inn: str | None = None
|
||||||
indicator: bool = False
|
indicator: bool = False
|
||||||
|
counterparty_by_account: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -423,6 +426,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
|
|||||||
position = inherited.depth + 1
|
position = inherited.depth + 1
|
||||||
group_type, name = _schema_group(context.schema, position)
|
group_type, name = _schema_group(context.schema, position)
|
||||||
branch = _Branch(code, 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":
|
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
|
||||||
@ -430,7 +443,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
|
|||||||
# ИНН: колонка ИНН, затем строка ИНН рядом (`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(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
|
||||||
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): всё, что нужно классификатору, приходит явно
|
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
||||||
@ -496,6 +518,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
data_type=kind.data_type,
|
data_type=kind.data_type,
|
||||||
extra=_extra(sheet, item.row, extra_keys),
|
extra=_extra(sheet, item.row, extra_keys),
|
||||||
indicator=kind.indicator,
|
indicator=kind.indicator,
|
||||||
|
counterparty_by_account=kind.counterparty_by_account,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
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))
|
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:
|
def _is_ok(checks: OsvChecks) -> bool:
|
||||||
return (
|
return (
|
||||||
bool(checks.total_row_matches_root)
|
bool(checks.total_row_matches_root)
|
||||||
@ -226,6 +236,7 @@ class OsvParser:
|
|||||||
*notes_of(OSV_HEADER, doc.warnings),
|
*notes_of(OSV_HEADER, doc.warnings),
|
||||||
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
|
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
|
||||||
*notes_of(OSV_ROWS, body_warnings),
|
*notes_of(OSV_ROWS, body_warnings),
|
||||||
|
*notes_of(OSV_ROWS, _by_account_warnings(classified)),
|
||||||
*notes_of(OSV_INN_ROWS, glue.warnings),
|
*notes_of(OSV_INN_ROWS, glue.warnings),
|
||||||
]
|
]
|
||||||
if not any(item.group_type != "TOTAL" for item in classified):
|
if not any(item.group_type != "TOTAL" for item in classified):
|
||||||
|
|||||||
@ -184,9 +184,34 @@ class OsvHeaderRules(_Model):
|
|||||||
return self
|
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):
|
class OsvGroupRules(_Model):
|
||||||
mapping: list[tuple[Pattern, GroupType]]
|
mapping: list[tuple[Pattern, GroupType]]
|
||||||
default: GroupType = "OTHER"
|
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:
|
def classify(self, text: str) -> GroupType:
|
||||||
"""Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`."""
|
"""Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`."""
|
||||||
@ -203,17 +228,12 @@ class OsvCheckRules(_Model):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_codes(self) -> "OsvCheckRules":
|
def _check_codes(self) -> "OsvCheckRules":
|
||||||
bad = [code for code in self.accounts_without_analytic_balance if not _ACCOUNT_CODE.fullmatch(code)]
|
_check_account_codes("accounts_without_analytic_balance", self.accounts_without_analytic_balance)
|
||||||
if bad:
|
|
||||||
raise ValueError(f"accounts_without_analytic_balance: ожидаются коды счетов вида «91» или «76.09», {bad}")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def has_no_analytic_balance(self, account_code: str | None) -> bool:
|
def has_no_analytic_balance(self, account_code: str | None) -> bool:
|
||||||
"""Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт."""
|
"""Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт."""
|
||||||
if not account_code:
|
return _is_under_account(account_code, self.accounts_without_analytic_balance)
|
||||||
return False
|
|
||||||
code = account_code.strip()
|
|
||||||
return any(code == root or code.startswith(f"{root}.") for root in self.accounts_without_analytic_balance)
|
|
||||||
|
|
||||||
|
|
||||||
class PeriodRule(_Model):
|
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"
|
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
|
||||||
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
|
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
|
||||||
|
|
||||||
# Плоская форма ОСВ 1С (#59): на этапе снимков текущего поведения ожиданий нет — их добавляют задачи 2–5 плана
|
# Плоская форма ОСВ 1С (#59)
|
||||||
FLAT_FORM_FILES = (
|
FLAT_FORM_FILES = (
|
||||||
"s40_flat_header_account.xlsx",
|
"s40_flat_header_account.xlsx",
|
||||||
"s41_flat_chart_of_accounts.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),
|
"s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False),
|
||||||
"s35_currency_slices.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),
|
"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:
|
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:
|
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
|
||||||
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
|
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
|
||||||
if synthetic_file.name in FLAT_FORM_FILES:
|
|
||||||
pytest.skip("плоская форма (#59): ожидания по файлу — в сценариях задач 2–5, до них поведение фиксирует golden")
|
|
||||||
expect = EXPECTED[synthetic_file.name]
|
expect = EXPECTED[synthetic_file.name]
|
||||||
|
|
||||||
outcome = parse_path(synthetic_file)
|
outcome = parse_path(synthetic_file)
|
||||||
|
|||||||
@ -1142,3 +1142,74 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None:
|
|||||||
secondary = parsed.one("Итого развернутое")
|
secondary = parsed.one("Итого развернутое")
|
||||||
assert parsed.total_row == secondary.body.row
|
assert parsed.total_row == secondary.body.row
|
||||||
assert secondary.group_type == "TOTAL"
|
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` и счёт шапки у строк."""
|
"""Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from gridbuilder import L, M, sheet_from_rows
|
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.deadline import Deadline
|
||||||
from src.parser.osv import OsvParser
|
from src.parser.osv import OsvParser
|
||||||
from src.parser.rules import Rules, load_rules
|
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
|
assert HEADER_ACCOUNT_CODE not in codes
|
||||||
data = [item for item in document.rows if item.group_type != "TOTAL"]
|
data = [item for item in document.rows if item.group_type != "TOTAL"]
|
||||||
assert [item.account_code_raw for item in data] == ["60", "60"]
|
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тели", "Показатели", "ПОКАЗАТЕЛИ"])
|
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
|
||||||
def test_indicator_service_column(rules: Rules, title: str) -> None:
|
def test_indicator_service_column(rules: Rules, title: str) -> None:
|
||||||
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
|
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