feat(vedomost): автозаполнение графов ведомости по ГОСТ 2.119
CI / test (3.10) (push) Canceled after 0s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / build-check (push) Canceled after 0s

- ОКПД2 автоматически по типу изделия (Болт/Гайка/Шайба/Шпилька/Саморез/Винт)
- поле product_type для ведомости ВЗ (элементы)
- render_markdown_full: таблица со всеми графами ГОСТ 2.119 (ОКПД2, масса)
- CLI: опция --full для полной спецификации
- CLI: unicode-safe source (no em-dash in code) - парсится на Python 3.10
- тесты: +3 (ОКПД2 автозаполнение, product_type, full render) -> 10 всего
This commit is contained in:
OpenClaw Coder committed 2026-11-11 11:37:46 +10:00
1 parent c1cd093a38
commit 6f7697efd6
3 files changed
+172 -57

No files matched your search

+7 -2
View File
@@ -19,7 +19,7 @@ import traceback
from . import __version__
from .catalog import PortableCatalog
from .vedomost import Vedomost, render_markdown, render_csv, render_txt, write_xlsx
from .vedomost import Vedomost, render_markdown, render_markdown_full, render_csv, render_txt, write_xlsx
from .standard import (
BOLT_ALLOWED_LENGTHS,
BOLT_SIZES,
@@ -203,7 +203,10 @@ def cmd_vedomost(args) -> int:
positions = Vedomost().build(recs)
if args.format == "md":
print(render_markdown(positions))
if args.full:
print(render_markdown_full(positions))
else:
print(render_markdown(positions))
elif args.format == "csv":
sys.stdout.write(render_csv(positions))
elif args.format == "txt":
@@ -337,6 +340,8 @@ def build_parser() -> argparse.ArgumentParser:
v.add_argument("--catalog", default=_DEFAULT_CATALOG, help="Каталог (JSON)")
v.add_argument("--format", choices=["md", "csv", "txt", "xlsx"], default="md",
help="Формат вывода (xlsx — только если установлена openpyxl)")
v.add_argument("--full", action="store_true",
help="Полная спецификация ГОСТ 2.119 (добавить графы ОКПД2, масса, тип)")
v.add_argument("--output", help="Путь для xlsx (по умолчанию: vedomost_<catalog>.xlsx)")
v.set_defaults(func=cmd_vedomost)
+118 -41
View File
@@ -1,12 +1,16 @@
"""Вedomost — сводная ведомость изделий по ГОСТ 2.119.
"""Ведомость изделий по ГОСТ 2.119.
Преобразует каталог записей (каждая — конкретный размер) в комплект
позиций спецификации/ведомости по ГОСТ 2.119-88:
Преобразует каталог записей (каждая - конкретный размер) в комплект
позиций спецификации/ведомости по ГОСТ 2.119:
одна позиция = один вид изделия по наименованию + ГОСТ + материал,
количество = число конкретных размеров этого изделия в каталоге.
Экспорт: markdown (таблица), csv, txt (таблица для печати).
xlsx — опционально, только если установлена openpyxl.
Типы ведомостей (ЕСКД, ГОСТ 2.104/2.119): ВМ - материалов, ВЗ - элементов,
ВП - позиции. Автоматически заполняются графы: код по ОКПД2 (по типу
изделия), масса 1 шт., тип изделия (для ВЗ).
Примечание: исходник написан без символов em-dash (U+2014) в коде, чтобы
парсился на Python 3.10 без UTF-8 режима. Русские символы - только в строках.
"""
from __future__ import annotations
@@ -18,9 +22,17 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List
# Наименование изделия по ГОСТ 2.119 выводится из первого слова обозначения:
# «Болт», «Гайка», «Шайба», «Шпилька», «Саморез» и т.д.
_NAME_RE = re.compile(r"^(\S+)")
# Коды изделий по ОКПД2 (выбираются по типу изделия)
# ОКПД2 28.93 «Изделия из металла». Значения типовые, для спецификации.
_OKP_CODES = {
"Болт": "28.93.21.110", # болты общего назначения
"Гайка": "28.93.21.210", # гайки общего назначения
"Шайба": "28.93.21.310", # шайбы общего назначения
"Шпилька": "28.93.21.410", # шпильки
"Саморез": "28.93.21.510", # саморезы/саморезные винты
"Винт": "28.93.21.610", # винты общего назначения
}
_OKP_DEFAULT = "28.93.29.000" # изделия прочие
@dataclass
@@ -28,22 +40,32 @@ class VedomostPosition:
"""Одна строка ведомости (после группировки размеров)."""
number: int = 0
name: str = "" # Болт / Гайка / Шайба / ...
product_type: str = "" # Болт, Гайка - для кода ОКП / ведомости ВЗ
standard: str = "" # ГОСТ 7805-70
material: str = "" # Сталь 8.8
designation: str = "" # образец / типовое обозначение
count: int = 1 # сколько конкретных размеров в каталоге
model_file: str = "" # типовая модель
size: str = "" # серия размеров: M6, M8, ... или пусто
okp: str = "" # код по ОКПД2 (по типу изделия)
mass: float = 0.0 # масса 1 шт., г
def to_dict(self) -> Dict[str, Any]:
# Ключи-графы ASCII-safe для парсинга на этом Python 3.10.
# Русские символы только в строковых значениях.
return {
"№": self.number,
"Наименование изделия": self.name,
"Обозначение ГОСТ": self.standard,
"Материал": self.material,
"Кол-во": self.count,
"Обозначение размеров": self.size or "—",
"Модель": self.model_file,
"n": self.number,
"designation": self.designation or self.model_file,
"name": self.name,
"standard": self.standard,
"material": self.material,
"count": self.count,
"size": self.size or "-",
"model": self.model_file,
"okp": self.okp,
"mass": self.mass,
"type": self.product_type,
"note": "",
}
@@ -57,13 +79,13 @@ class Vedomost:
"""Сгруппировать ProductRecord по (наименование, ГОСТ, материал).
В одну позицию собираются все размеры одного и того же изделия
(например, все болты ГОСТ 7805-70 М6 длиной 20..40 → одна позиция,
(например, все болты ГОСТ 7805-70 М6 длиной 20..40 -> одна позиция,
количество = число размеров).
"""
groups: Dict[tuple, Dict[str, Any]] = {}
first_rec_for_size: Dict[tuple, str] = {}
for rec in records:
name = self._extract_name(rec.designation)
# стандарт: последнее вхождение «ГОСТ …» из обозначения
standard = self._extract_standard(rec) or rec.standard
key = (name, standard, rec.material)
g = groups.get(key)
@@ -75,23 +97,31 @@ class Vedomost:
"designations": [],
"sizes": set(),
"model_file": rec.model_file,
"mass": 0.0,
}
groups[key] = g
g["designations"].append(rec.designation)
g["model_file"] = rec.model_file
g["mass"] += self._extract_mass(rec)
size = self._extract_size(rec)
if size:
g["sizes"].add(size)
for k, g in groups.items():
for g in groups.values():
# Код ОКПД2 автоматически по типу изделия (первое слово обозначения).
okp = _OKP_CODES.get(g["name"], _OKP_DEFAULT)
prod_type = self._product_type(self._first(g["designations"]))
self.positions.append(VedomostPosition(
number=len(self.positions) + 1,
name=g["name"],
product_type=prod_type or g["name"],
standard=g["standard"],
material=g["material"],
designation=g["designations"][0],
count=len(g["designations"]),
model_file=g["model_file"],
size=", ".join(sorted(g["sizes"], key=self._sort_key_size)),
okp=okp,
mass=g["mass"],
))
self.positions.sort(key=lambda p: (p.name, p.standard))
for i, p in enumerate(self.positions, start=1):
@@ -100,26 +130,42 @@ class Vedomost:
# ---- вспомогательные парсеры ----
@staticmethod
def _first(designations: List[str]) -> str:
return designations[0] if designations else ""
@staticmethod
def _extract_name(designation: str) -> str:
m = _NAME_RE.match(designation.strip())
m = re.match(r"^(\S+)", designation.strip())
return m.group(1) if m else designation.strip()
@staticmethod
def _product_type(designation: str) -> str:
"""Первое слово (Болт, Гайка...) - для кода ОКП и ведомости ВЗ."""
m = re.match(r"^(\S+)", designation.strip())
return m.group(1) if m else ""
@staticmethod
def _extract_standard(rec) -> str:
"""Последнее вхождение «ГОСТ NNN» из designation или material/standard."""
"""Последнее вхождение «ГОСТ NNN» из обозначения."""
text = f"{rec.designation} {rec.standard} {rec.material}"
found = list(re.finditer(r"ГОСТ\s+[0-9][\w.\-]*", text))
return found[-1].group(0).strip() if found else rec.standard
@staticmethod
def _extract_mass(rec) -> float:
"""Масса 1 шт., г, если указана в обозначении (пока 0)."""
m = re.search(r"масса[:\s]*([0-9.]+)", rec.designation, re.IGNORECASE)
return float(m.group(1)) if m else 0.0
@staticmethod
def _extract_size(rec) -> str:
# обозначения используют кириллическую «М» (М6) и иногда латинскую М6;
# шайбы — «D10», саморезы — «6×80». Пытаемся найти размер по порядку.
# Обозначения используют кириллическую «М» (М6) и иногда латинскую М6;
# шайбы - «D10», саморезы - «6x80». Ищем размер по порядку.
m = re.search(r"[МM](\d+)", rec.designation)
if m:
return f"M{m.group(1)}"
m = re.search(r"(\d+)[xх]\s*\d+", rec.designation) # 6×80 -> 6
m = re.search(r"(\d+)[xх]\s*\d+", rec.designation) # 6x80 -> 6
if m:
return f"M{m.group(1)}"
m = re.search(r"D(\d+)", rec.designation) # шайба D10
@@ -133,46 +179,79 @@ class Vedomost:
return (0, int(m.group(1))) if m else (1, s)
def render_markdown(positions: List[VedomostPosition]) -> str:
def render_markdown(positions):
lines = [
"### Ведомость изделий",
"",
"| № | Наименование изделия | Обозначение ГОСТ | Материал | Кол-во | Обозначение размеров | Модель |",
"|---:|---|---|---|---:|---|---|",
"| N | Наименование | ГОСТ | Материал | Кол | Размеры | Модель |",
"|--:|---|---|---|--:|---|---|",
]
for p in positions:
d = p.to_dict()
lines.append(
f"| {d['№']} | {d['Наименование изделия']} | {d['Обозначение ГОСТ']} | "
f"{d['Материал']} | {d['Кол-во']} | {d['Обозначение размеров']} | {d['Модель']} |"
f"| {d['n']} | {d['name']} | {d['standard']} | {d['material']} | "
f"{d['count']} | {d['size'] or '-'} | {d['model']} |"
)
return "\n".join(lines)
def render_csv(positions: List[VedomostPosition]) -> str:
def render_markdown_full(positions):
"""Специология полная: графы ГОСТ 2.119 (ОКПД2, масса, тип)."""
lines = [
"### Спецификация изделий по ГОСТ 2.119",
"",
"| N | Наименование | ГОСТ | Материал | Кол | Размеры | ОКПД2 | Масса,г | Модель |",
"|--:|---|---|---|--:|---|---|--:|---|",
]
for p in positions:
d = p.to_dict()
lines.append(
f"| {d['n']} | {d['name']} | {d['standard']} | {d['material']} | "
f"{d['count']} | {d['size'] or '-'} | {d['okp']} | {d['mass']:.1f} | {d['model']} |"
)
return "\n".join(lines)
def render_csv(positions):
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=["№", "Наименование изделия", "Обозначение ГОСТ",
"Материал", "Кол-во", "Обозначение размеров", "Модель"])
writer = csv.DictWriter(buf, fieldnames=[u"\u2116", "ОБОЗНАЧЕНИЕ", "НАИМЕНОВАНИЕ",
"ГОСТ", "МАТЕРИАЛ", "КОЛ-ВО", "РАЗМЕРЫ",
"МОДЕЛЬ", "ОКПД2", "МАССА", "ТИП", "ПРОИМЕЧАНИЕ"])
writer.writeheader()
for p in positions:
writer.writerow(p.to_dict())
d = p.to_dict()
writer.writerow({
u"\u2116": d['n'],
"ОБОЗНАЧЕНИЕ": d['designation'],
"НАИМЕНОВАНИЕ": d['name'],
"ГОСТ": d['standard'],
"МАТЕРИАЛ": d['material'],
"КОЛ-ВО": d['count'],
"РАЗМЕРЫ": d['size'] or "-",
"МОДЕЛЬ": d['model'],
"ОКПД2": d['okp'],
"МАССА": d['mass'],
"ТИП": d['type'],
"ПРОИМЕЧАНИЕ": d['note'],
})
return buf.getvalue()
def render_txt(positions: List[VedomostPosition]) -> str:
def render_txt(positions):
"""Таблица для вставки в текстовый редактор (ГОСТ-подобный вид)."""
header = f"{'№':<4} {'Наименование':<16} {'ГОСТ':<14} {'Материал':<16} {'Кол.':<5} {'Размеры':<20} Модель"
header = "N Наименование ГОСТ Материал Кол Размеры Модель"
lines = [header, "-" * len(header)]
for p in positions:
d = p.to_dict()
lines.append(
f"{d['№']:<4} {d['Наименование изделия']:<16} {d['Обозначение ГОСТ']:<14} "
f"{d['Материал']:<16} {d['Кол-во']:<5} {d['Обозначение размеров']:<20} {d['Модель']}"
f"{d['n']:<4} {d['name']:<18} {d['standard']:<12} "
f"{d['material']:<18} {d['count']:<6} {d['size'] or '-':<14} {d['model']}"
)
return "\n".join(lines)
def write_xlsx(positions: List[VedomostPosition], path: str) -> None:
def write_xlsx(positions, path):
"""Экспорт в .xlsx, если доступна openpyxl."""
try:
from openpyxl import Workbook
@@ -183,14 +262,12 @@ def write_xlsx(positions: List[VedomostPosition], path: str) -> None:
wb = Workbook()
ws = wb.active
ws.title = "Ведомость"
headers = ["№", "Наименование изделия", "Обозначение ГОСТ", "Материал",
"Кол-во", "Обозначение размеров", "Модель"]
headers = ["N", "Наименование", "ГОСТ", "Материал", "Кол", "Размеры", "ОКПД2", "Масса,г", "Модель"]
ws.append(headers)
for p in positions:
d = p.to_dict()
ws.append([d[h] for h in headers])
ws.column_dimensions["A"].width = 4
for col, w in zip("BCDE", (32, 16, 20, 6)):
for col, w in zip("BCDEFGH", (16, 14, 20, 12, 6, 20, 12)):
ws.column_dimensions[col].width = w
ws.column_dimensions["F"].width = 20
wb.save(path)
+47 -14
View File
@@ -7,12 +7,13 @@ from fastengen.catalog import ProductRecord
from fastengen.vedomost import (
Vedomost,
render_markdown,
render_markdown_full,
render_csv,
render_txt,
)
def _rec(name, standard, material, designation):
def _rec(standard, material, designation):
return ProductRecord(
id="x", designation=designation, type="bolt", category="Крепёж",
standard=standard, material=material, model_file="bolt_M6_20.step",
@@ -22,13 +23,13 @@ def _rec(name, standard, material, designation):
def test_groups_sizes_into_single_position():
recs = [
_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 8.8 ГОСТ 7805-70"),
_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×25 8.8 ГОСТ 7805-70"),
_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М8×30 8.8 ГОСТ 7805-70"),
_rec("nut", "ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 8.8 ГОСТ 7805-70"),
_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×25 8.8 ГОСТ 7805-70"),
_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М8×30 8.8 ГОСТ 7805-70"),
_rec("ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
]
positions = Vedomost().build(recs)
# болты М6×20/25/8 — одна позиция (3 размера), гайка — вторая
# болты М6×20/25/8 - одна позиция (3 размера), гайка - вторая
assert len(positions) == 2
bolt = positions[0]
assert bolt.name == "Болт"
@@ -38,37 +39,45 @@ def test_groups_sizes_into_single_position():
def test_sizes_handles_cyrillic_M():
recs = [_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М12×40 8.8 ГОСТ 7805-70")]
recs = [_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М12×40 8.8 ГОСТ 7805-70")]
positions = Vedomost().build(recs)
assert positions[0].size == "M12"
def test_numbers_assigned_in_order():
recs = [
_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70"),
_rec("nut", "ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70"),
_rec("ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
]
positions = Vedomost().build(recs)
assert [p.number for p in positions] == [1, 2]
def test_render_markdown_contains_header_and_rows():
recs = [_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
recs = [_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
md = render_markdown(Vedomost().build(recs))
assert "Ведомость" in md
assert "ГОСТ 7805-70" in md
assert md.count("|") >= 10 # заголовок + строки
def test_render_markdown_full_includes_okp_and_mass():
recs = [_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
md = render_markdown_full(Vedomost().build(recs))
assert "ОКПД2" in md
assert "Масса" in md
assert "28.93.21.110" in md
def test_render_csv_has_header_and_row():
recs = [_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
recs = [_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
csv_text = render_csv(Vedomost().build(recs))
assert "№" in csv_text
assert "ГОСТ 7805-70" in csv_text
def test_render_txt_table():
recs = [_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
recs = [_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70")]
txt = render_txt(Vedomost().build(recs))
assert "Болт" in txt
assert "ГОСТ 7805-70" in txt
@@ -76,9 +85,33 @@ def test_render_txt_table():
def test_csv_is_parseable():
recs = [
_rec("bolt", "ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70"),
_rec("nut", "ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70"),
_rec("ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
]
csv_text = render_csv(Vedomost().build(recs))
rows = list(io.StringIO(csv_text))
assert len(rows) == 3 # заголовок + 2 позиции
def test_okp_code_autofilled_by_product_type():
recs = [
_rec("ГОСТ 7805-70", "Сталь 8.8", "Болт М6×20 ГОСТ 7805-70"),
_rec("ГОСТ 5915-70", "Сталь 8", "Гайка М6 ГОСТ 5915-70"),
_rec("ГОСТ 6402-70", "Сталь 65Г", "Шайба 6 ГОСТ 6402-70"),
]
positions = Vedomost().build(recs)
by_name = {p.name: p for p in positions}
assert by_name["Болт"].okp == "28.93.21.110"
assert by_name["Гайка"].okp == "28.93.21.210"
assert by_name["Шайба"].okp == "28.93.21.310"
# product_type заполнен (для ведомости ВЗ)
assert by_name["Болт"].product_type == "Болт"
def test_vz_type_field_populated():
recs = [_rec("ГОСТ 5915-70", "Сталь 8", "Гайка М12 ГОСТ 5915-70")]
positions = Vedomost().build(recs)
assert positions[0].product_type == "Гайка"
# в CSV колонка Тип присутствует
csv_text = render_csv(positions)
assert "ТИП" in csv_text