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

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

View File

@ -3,7 +3,8 @@
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md`
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
другим треком); описание — `osv/synthetic/README.md`
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
@ -861,6 +862,116 @@ def _s32() -> list[SheetSpec]:
return [sheet]
FlatRow = tuple[str, tuple[str | None, ...], int]
def _flat_sheet(texts: dict[int, str], group_row: int, rows: Sequence[FlatRow]) -> SheetSpec:
"""Плоская форма 1С (#59): колонка A — текст, B…G — суммы «начало / обороты / конец» × «Дебет | Кредит»; заголовок
«Субконто» + группы (объединения B:C, D:E, F:G) в `group_row`, подзаголовки под ним, данные сразу ниже. `texts` —
строки шапки над таблицей; в `rows` пустая сумма (None) — нет ячейки, отступ — выравнивание ячейки."""
sheet = SheetSpec(SHEET)
for row, text in texts.items():
sheet.put(row, 1, text)
sheet.put(group_row, 1, "Субконто")
for index, key in enumerate(("opening", "turnover", "closing")):
col = 2 + index * PAIR_WIDTH
sheet.put(group_row, col, GROUP_TITLES[key])
sheet.merge(group_row, col, group_row, col + 1)
sheet.put(group_row + 1, col, DEBIT)
sheet.put(group_row + 1, col + 1, CREDIT)
for offset, (text, sums, indent) in enumerate(rows):
row = group_row + 2 + offset
sheet.put(row, 1, text, indent=indent)
for col, value in enumerate(sums, start=2):
if value is not None:
sheet.put(row, col, D(value))
return sheet
def _s40() -> list[SheetSpec]:
"""Плоская форма (#59): счёт в заголовке «по счету: 76.Н.1» и вид аналитики «Контрагенты» строкой ниже, данные без
outline и отступов; «Итого развернутое» — сумма строк, «Итого» — свёрнутое сальдо."""
texts = {
2: "Оборотно-сальдовая ведомость по счету: 76.Н.1",
3: "Контрагенты",
4: "за 2025 г.",
5: 'ООО "Товарное хозяйство"',
7: "Единица измерения: рубль (код по ОКЕИ 383)",
}
rows: list[FlatRow] = [
("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0),
("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0),
("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0),
("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0),
("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0),
]
return [_flat_sheet(texts, 9, rows)]
def _s41() -> list[SheetSpec]:
"""Плоская форма (#59): вид аналитики «Организации», период «за 2 Квартал 2026 г.» со словами «План счетов …»."""
texts = {
2: "Оборотно-сальдовая ведомость по счету: 60",
3: "Организации",
4: "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива",
}
rows: list[FlatRow] = [
("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0),
("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0),
("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0),
("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0),
]
return [_flat_sheet(texts, 5, rows)]
def _s42() -> list[SheetSpec]:
"""Плоская форма (#59): под счётом 76 идут субсчета «76.2», «76.5», «76.Н», «76.Н.1»; «Итого развернутое» перед
«Итого»."""
texts = {
2: "Оборотно-сальдовая ведомость по счету: 76",
3: "за 2025 г.",
4: 'ООО "Товарное хозяйство"',
6: "Единица измерения: рубль (код по ОКЕИ 383)",
}
rows: list[FlatRow] = [
("76.2", ("1000.00", None, None, "1000.00", None, None), 0),
("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0),
("76.Н", (None, None, "5000.00", "5000.00", None, None), 0),
("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0),
("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0),
("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0),
]
return [_flat_sheet(texts, 8, rows)]
def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]:
"""Счёт с отступами: корень-субсчёт (без отступа) и два элемента аналитики с отступом 1, без вида аналитики в шапке;
«Итого» перед «Итого развернутое»."""
texts = {
1: 'ООО "Октябрьское"',
2: f"Оборотно-сальдовая ведомость по счету {account}",
3: "Период: 9 месяцев 2024 г.",
}
rows: list[FlatRow] = [
(root, ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0),
(kids[0], ("34618000.00", None, None, "34618000.00", None, None), 1),
(kids[1], (None, None, "4236500.00", "4236500.00", None, None), 1),
("Итого", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0),
("Итого развернутое", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0),
]
return [_flat_sheet(texts, 5, rows)]
def _s43() -> list[SheetSpec]:
"""Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом)."""
return _s43_s44("60", "60.01", ("ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'))
def _s44() -> list[SheetSpec]:
"""То же, что s43, но счёт 10: номенклатура вместо контрагентов."""
return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок"))
def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree()
@ -969,6 +1080,11 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx),
"s35_currency_slices.xlsx": (_s35, to_xlsx),
"s36_no_data_type_header.xlsx": (_s36, to_xlsx),
"s40_flat_header_account.xlsx": (_s40, to_xlsx),
"s41_flat_chart_of_accounts.xlsx": (_s41, to_xlsx),
"s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx),
"s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx),
"s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -987,7 +1103,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s36 (xlsx, один xlsb и один xls) в указанный каталог.",
description="Синтетические ОСВ s01…s36, s40…s44 (xlsx, один xlsb и один xls) в указанный каталог.",
)
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv)

View File

@ -31,7 +31,9 @@ def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None:
assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)]
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
numbers = [*range(1, 37), *range(40, 45)]
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 sorted(path.name for path in tmp_path.iterdir()) == sorted(names)

View File

@ -42,6 +42,15 @@ XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls"
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 плана
FLAT_FORM_FILES = (
"s40_flat_header_account.xlsx",
"s41_flat_chart_of_accounts.xlsx",
"s42_subaccounts_totals_reversed.xlsx",
"s43_subkonto_unnamed_60.xlsx",
"s44_subkonto_unnamed_10.xlsx",
)
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
@ -73,6 +82,7 @@ EXPECTED_NAMES = [
"s34_indicator_column_qty_val_diff.xlsx",
"s35_currency_slices.xlsx",
"s36_no_data_type_header.xlsx",
*FLAT_FORM_FILES,
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -229,7 +239,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s36 (s13 — xlsb; допустима деградация: нет только его)."""
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s36, s40…s44 (s13 — xlsb; допустима деградация: нет только его)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present]
@ -238,11 +248,13 @@ 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, f"добавьте ожидания для {synthetic_file.name}"
assert synthetic_file.name in {*EXPECTED, *FLAT_FORM_FILES}, 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)