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