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:
parent
830c02ab7c
commit
408dcaf3b0
110
api/scripts/convert_chart_1c.py
Normal file
110
api/scripts/convert_chart_1c.py
Normal 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))
|
||||
73
api/tests/test_convert_chart_1c.py
Normal file
73
api/tests/test_convert_chart_1c.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user