feat(seed): конвертер плана счетов 1С в csv seed

Одноразовый скрипт api/scripts/convert_chart_1c.py: xlsx выгрузки 1С -> chart_of_accounts.csv
(иерархия по структуре кода, kind из «Вид», is_off_balance из «Заб.»).
ASSUMPTION(OQ-113): выгрузка считается полным планом счетов.

Refs #23

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 12:57:20 +04:00
parent 830c02ab7c
commit 408dcaf3b0
2 changed files with 183 additions and 0 deletions

View File

@ -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 <chart-of-accounts-1c.xlsx> <chart_of_accounts.csv>
`-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))

View File

@ -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