"""Одноразовый конвертер плана счетов 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))