diff --git a/src/fastengen/cli.py b/src/fastengen/cli.py index 5607491..2e1b569 100644 --- a/src/fastengen/cli.py +++ b/src/fastengen/cli.py @@ -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_.xlsx)") v.set_defaults(func=cmd_vedomost) diff --git a/src/fastengen/vedomost.py b/src/fastengen/vedomost.py index 37f24d4..089092b 100644 --- a/src/fastengen/vedomost.py +++ b/src/fastengen/vedomost.py @@ -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) diff --git a/tests/test_vedomost.py b/tests/test_vedomost.py index 716a46b..195f420 100644 --- a/tests/test_vedomost.py +++ b/tests/test_vedomost.py @@ -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