diff --git a/api/scripts/convert_chart_1c.py b/api/scripts/convert_chart_1c.py new file mode 100644 index 0000000..105ea1c --- /dev/null +++ b/api/scripts/convert_chart_1c.py @@ -0,0 +1,110 @@ +"""Одноразовый конвертер плана счетов 1С (xlsx) в `seed/core/chart_of_accounts.csv` (issue #23). + +Источник — `fixtures/directories/chart-of-accounts-1c.xlsx` (данные, не код). Колонки ищутся по заголовкам: +«Код счета», «Наименование счета», «Вид» (А/П/АП), «Заб.» (Да/Нет). Порядок строк — как в выгрузке. + +ASSUMPTION(OQ-113): выгрузка считается полным планом счетов заказчика. + +Запуск (из `arm-core/api`):: + + .venv/bin/python -I -m scripts.convert_chart_1c + +`-I` отключает подхват кода из текущего каталога; пути — абсолютные. +""" + +import csv +import sys +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path + +from openpyxl import load_workbook + +CSV_COLUMNS = ["code", "parent_code", "name", "kind", "row_type", "is_off_balance"] +KIND_BY_1C = {"А": "A", "П": "P", "АП": "AP"} # кириллица, как в выгрузке +ROW_TYPE_BY_DEPTH = ("account", "subaccount", "analytic") +HEADERS = {"code": "Код счета", "name": "Наименование счета", "kind": "Вид", "off_balance": "Заб."} + + +@dataclass(frozen=True) +class ChartRow: + code: str + parent_code: str + name: str + kind: str + row_type: str + is_off_balance: bool + + +def convert(records: Iterable[Mapping[str, object]]) -> list[ChartRow]: + """Строки выгрузки (словари по заголовкам) -> строки seed; иерархия — по структуре кода.""" + raw = [ + ( + str(r[HEADERS["code"]]).strip(), + " ".join(str(r[HEADERS["name"]]).split()), + str(r[HEADERS["kind"]]).strip(), + str(r[HEADERS["off_balance"]]).strip(), + ) + for r in records + ] + codes = [code for code, *_ in raw] + known = set(codes) + if len(known) != len(codes): + raise ValueError("в выгрузке повторяются коды счетов") + rows: list[ChartRow] = [] + for code, name, kind_1c, off_balance in raw: + if kind_1c not in KIND_BY_1C: + raise ValueError(f"{code}: неизвестный вид «{kind_1c}» (ожидались А, П, АП)") + if off_balance not in ("Да", "Нет"): + raise ValueError(f"{code}: «Заб.» должно быть Да или Нет, получено «{off_balance}»") + if not name: + raise ValueError(f"{code}: пустое наименование") + depth = code.count(".") + if depth >= len(ROW_TYPE_BY_DEPTH): + raise ValueError(f"{code}: глубина кода больше {len(ROW_TYPE_BY_DEPTH) - 1} точек") + parent = code.rsplit(".", 1)[0] if depth else "" + if parent and parent not in known: + # Сирота: родителя нет в выгрузке (например, «Я68.01» без «Я68») — родителей не выдумываем, + # но предков выше быть не должно, иначе иерархия в выгрузке неполная + ancestors = {code.rsplit(".", i)[0] for i in range(2, depth + 1)} + if ancestors & known: + raise ValueError(f"{code}: нет родителя «{parent}», но есть более дальний предок") + parent = "" + rows.append(ChartRow(code, parent, name, KIND_BY_1C[kind_1c], ROW_TYPE_BY_DEPTH[depth], off_balance == "Да")) + return rows + + +def read_xlsx(path: Path) -> list[dict[str, object]]: + """Первый лист: заголовки — первая строка, колонки ищутся по названию.""" + workbook = load_workbook(path, read_only=True, data_only=True) + try: + lines = list(workbook.worksheets[0].iter_rows(values_only=True)) + finally: + workbook.close() + header = [str(cell).strip() if cell is not None else "" for cell in lines[0]] + missing = [title for title in HEADERS.values() if title not in header] + if missing: + raise ValueError(f"в выгрузке нет колонок: {', '.join(missing)}") + return [dict(zip(header, line, strict=False)) for line in lines[1:] if any(cell is not None for cell in line)] + + +def write_csv(rows: Iterable[ChartRow], path: Path) -> None: + with path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.writer(stream, lineterminator="\n") + writer.writerow(CSV_COLUMNS) + for r in rows: + writer.writerow( + [r.code, r.parent_code, r.name, r.kind, r.row_type, "true" if r.is_off_balance else "false"] + ) + + +def main(argv: list[str]) -> int: + if len(argv) != 3: # noqa: PLR2004 + print(__doc__, file=sys.stderr) + return 2 + write_csv(convert(read_xlsx(Path(argv[1]))), Path(argv[2])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/api/tests/test_convert_chart_1c.py b/api/tests/test_convert_chart_1c.py new file mode 100644 index 0000000..db7b049 --- /dev/null +++ b/api/tests/test_convert_chart_1c.py @@ -0,0 +1,73 @@ +"""Конвертер плана счетов 1С в csv seed (issue #23): иерархия по структуре кода, kind и забалансовость из выгрузки.""" + +from pathlib import Path + +import pytest + +from scripts.convert_chart_1c import ChartRow, convert, read_xlsx, write_csv +from scripts.seed import load_seed + +API_DIR = Path(__file__).resolve().parent.parent +SEED_DIR = API_DIR / "seed" +CHART_SIZE = 488 +XLSX = API_DIR.parent.parent / "fixtures" / "directories" / "chart-of-accounts-1c.xlsx" + + +def _rec(code: str, name: str = "Счёт", kind: str = "АП", off: str = "Нет") -> dict[str, object]: + return {"Код счета": code, "Наименование счета": name, "Вид": kind, "Заб.": off} + + +def test_convert_builds_hierarchy_kind_and_row_type() -> None: + rows = convert( + [_rec("01", kind="А"), _rec("01.К", kind="А"), _rec("76", kind="АП"), _rec("76.07"), _rec("76.07.1", kind="П")] + ) + assert rows == [ + ChartRow("01", "", "Счёт", "A", "account", False), + ChartRow("01.К", "01", "Счёт", "A", "subaccount", False), + ChartRow("76", "", "Счёт", "AP", "account", False), + ChartRow("76.07", "76", "Счёт", "AP", "subaccount", False), + ChartRow("76.07.1", "76.07", "Счёт", "P", "analytic", False), + ] + + +def test_convert_off_balance_from_column_and_name_whitespace_collapsed() -> None: + (row,) = convert([_rec("МЦ", name=" Материальные ценности ", kind="А", off="Да")]) + assert row.is_off_balance is True + assert row.name == "Материальные ценности" + + +def test_convert_orphan_without_any_ancestor_gets_empty_parent() -> None: + rows = convert([_rec("Я68.01"), _rec("Я69.06.5")]) + assert [(r.code, r.parent_code, r.row_type) for r in rows] == [ + ("Я68.01", "", "subaccount"), + ("Я69.06.5", "", "analytic"), + ] + + +def test_convert_rejects_gap_in_hierarchy() -> None: + with pytest.raises(ValueError, match="более дальний предок"): + convert([_rec("76"), _rec("76.07.1")]) + + +@pytest.mark.parametrize( + ("records", "message"), + [ + ([_rec("01"), _rec("01")], "повторяются"), + ([_rec("01", kind="Х")], "неизвестный вид"), + ([_rec("01", off="да?")], "Да или Нет"), + ([_rec("01", name=" ")], "пустое наименование"), + ([_rec("01.01.1.1")], "глубина"), + ], +) +def test_convert_rejects_bad_rows(records: list[dict[str, object]], message: str) -> None: + with pytest.raises(ValueError, match=message): + convert(records) + + +@pytest.mark.skipif(not XLSX.is_file(), reason="fixtures/directories/chart-of-accounts-1c.xlsx недоступен") +def test_repository_seed_matches_1c_export(tmp_path: Path) -> None: + """Дрейф: seed/core/chart_of_accounts.csv побайтно равен результату конвертера на выгрузке 1С.""" + out = tmp_path / "chart.csv" + write_csv(convert(read_xlsx(XLSX)), out) + assert out.read_text(encoding="utf-8") == (SEED_DIR / "core" / "chart_of_accounts.csv").read_text(encoding="utf-8") + assert len(load_seed(SEED_DIR).accounts) == CHART_SIZE