From 425a4a9dfb26c7a852a14f1840960e8289791b37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:16:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B7=9C=EA=B2=A9=20=EC=97=B4=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0):=20summary=5Fitem=20spec=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=EC=9D=B4=20=EA=B3=A0=EB=A5=B8=20=EC=97=B4=20?= =?UTF-8?q?=EA=B5=B3=ED=9E=98=20=C2=B7=20=EA=B0=99=EC=9D=80=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=3D=20=EC=97=B4=EA=B9=8C=EC=A7=80=20=C2=B7=20B07=20?= =?UTF-8?q?=ED=95=9C=20=EC=9E=A5=20=3D=20=ED=95=AD=EB=AA=A9=20+=20?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EC=93=B0=EB=8A=94=20=EC=97=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20409=20(PLAN=20704-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summary_item spec(파이썬 · TS 짝) — 없으면 기본 열 · 모르는 키 거절 그대로 - 사본 = 고른 열 수량을 손으로 굳힘 · manifest 규격 · same_item 에 spec(열 바꿈 = 새 사본 · 덮개 열쇠 S:폴더 그대로) - B07 구조물 도면 키 = 항목 + 열 해시 — 열이 다르면 따로 장 - M02 일위대가 [저장] — 쓰는 사본 있는 열 지움 · 이름 바꿈 409 - 항목 목록 labor.규격(열 · 열마다 합계) — 고르기 창 열 단추 - 시험 test_704_4_spec 7(열 바꿈 뒤 덮개 남음 · 짝 잃음 0) Co-Authored-By: Claude Opus 5.5 --- ...7_DesignDetail_Router_Support_Structure.py | 6 +- M02_MasterTemplete/M02_Item_Snapshot.py | 6 +- M02_MasterTemplete/M02_Item_Source.py | 7 +- M02_MasterTemplete/M02_Labor_Spec.py | 23 ++- .../M02_MasterTemplete_Items.py | 8 + .../M02_MasterTemplete_Store.py | 4 + common_util/common_util_summary_item.py | 17 +- common_util/common_util_summary_item.ts | 9 +- resources/tester/test_704_4_spec.py | 170 ++++++++++++++++++ 9 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_704_4_spec.py diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py index 549d036c0..4a719ffa3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py @@ -1,12 +1,14 @@ """B07 구조물 도면(376 · 요구 241-1) — B05 · B06 배치에 연결된 구조물 조합 도면 목록 · 단건. -조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) — 같은 조합을 여러 배치가 써도 한 장 · +조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) + 규격 열(704-4 · `-s해시`) — 같은 조합 · +같은 열을 여러 배치가 써도 한 장 · 열이 다르면 따로(표제 = 항목 이름 「… (H=2.0)」) · 더한장은 장마다 · id `structure_{키}[_{장id}]` · 차례 = 도번 · 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 도번 · 도면명). 고치기는 M02 · B08 — 여기는 읽기만. """ from __future__ import annotations +import hashlib import logging import re from pathlib import Path @@ -38,6 +40,8 @@ def structure_sheets(project_root: Path) -> list[dict[str, Any]]: if "도면" not in copy["부분"] or not item.get("key"): continue key = snapshot_folder_name(item) + if item.get("spec"): # 규격 열(704-4) — 열 이름은 아무 글이라 id 에는 해시만 + key += f"-s{hashlib.sha1(str(item['spec']).encode()).hexdigest()[:8]}" if key in docs: continue doc = read_json(snapshot.structures_dir(project_root) / copy["폴더"] / "도면.json") diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 629c2cc1d..1f7244744 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -86,10 +86,11 @@ def now_text() -> str: def same_item(manifest: Any, item: dict[str, Any]) -> bool: + """같은 항목 · 같은 규격 열(704-4 · 열이 바뀌면 다른 사본 · 덮개는 폴더 열쇠라 그대로 남음).""" origin = manifest.get("원본") if isinstance(manifest, dict) else None if not isinstance(origin, dict): return False - return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key")) + return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key", "spec")) def origin_of(item: dict[str, Any]) -> dict[str, Any]: @@ -142,6 +143,9 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic atomic_write_json(target / name, content) stamp = now_text() source = version_of(source_files(files)) + spec = ((files.get(LABOR_FILE) or {}).get("줄") or {}).get("규격") # 굳힌 규격 열(704-4) + if isinstance(spec, dict): + extra = {**extra, "규격": {"이름": spec.get("이름"), "열": spec.get("기본")}} manifest = { "원본": origin_of(item), "판": source, diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 597cb086c..ede035547 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -2,7 +2,8 @@ 모으는 것 = `{파일 이름: 내용}`: `집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · - `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄) + `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 · + 규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4) + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) + 참조사본(단가승격 · 사전조합을 값으로 굳힘) · `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) @@ -146,7 +147,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: files[name] = part doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None if isinstance(doc, dict): - labor = labor_doc_rows(doc) + from M02_MasterTemplete import M02_Labor_Spec as labor_spec + + labor = labor_doc_rows(labor_spec.freeze(doc, item.get("spec"))) # 고른 규격 열(704-4) else: labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: diff --git a/M02_MasterTemplete/M02_Labor_Spec.py b/M02_MasterTemplete/M02_Labor_Spec.py index ec5ccbdd8..a7d3b168f 100644 --- a/M02_MasterTemplete/M02_Labor_Spec.py +++ b/M02_MasterTemplete/M02_Labor_Spec.py @@ -132,7 +132,7 @@ def freeze(doc: dict, col: Any = None) -> dict: use = column(doc, col) if use is None: return doc - got = at(doc, use) + got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림 return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}} @@ -161,3 +161,24 @@ def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]: if same: found.append(f"{path.parents[3].name}/{path.parent.name}") return found + + +def guard(path: Path, new: Any, column: str) -> None: + """[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 — + 지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete.M02_Item_Source import read_json + + old = (read_json(path) or {}).get("규격") if path.is_file() else None + gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or []) + if not gone: + return + rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column] + keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k} + used = users(store.root(), keys, gone) + if used: + raise store.StoreError( + 409, + f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 " + f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})", + ) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 317d4698d..ed92edf04 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -119,6 +119,14 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]: "기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2) "단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3) } + spec = doc.get("규격") if isinstance(doc.get("규격"), dict) else None + if spec and spec.get("열"): # 규격 열(704-4) — 고르기 창 열 단추 · 열마다 합계 + sums = calc.get("규격합") or {} + labor["규격"] = { + "이름": spec.get("이름"), + "열": [{"이름": c, "합계": _number(sums.get(c))} for c in spec["열"]], + "기본": spec.get("기본"), + } return {"수정일": stamps, "labor": labor} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 06c289772..99b52145d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -166,6 +166,10 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) doc = _server(kind, doc) + if kind == "labor": # 쓰는 규격 열 지움 막음(704-4) + from M02_MasterTemplete import M02_Labor_Spec as labor_spec + + labor_spec.guard(path, doc, name) fresh: list[dict[str, Any]] = [] with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" diff --git a/common_util/common_util_summary_item.py b/common_util/common_util_summary_item.py index 542681d3c..4aa4068bf 100644 --- a/common_util/common_util_summary_item.py +++ b/common_util/common_util_summary_item.py @@ -1,7 +1,10 @@ """구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1). summary_item = {layer: "system"|"company"|"personal", - owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"} + owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…", + spec?: "2.0"} + +`spec`(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(옛 기록 · 규격 없는 항목). 항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001` (머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새 @@ -28,15 +31,17 @@ _OWNER = { "personal": re.compile(r"^\d+/\d+$"), } _NAME_MAX = 200 +_SPEC_MAX = 20 # 규격 열 이름 상한(`M02_Labor_Spec.NAME_MAX`) def parse_summary_item(value: Any) -> dict[str, Any] | None: - """모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError.""" + """모양이 맞으면 `{layer, owner, key, name, spec?}` 새 dict · 빈 값은 None · + 틀리면 ValueError.""" if value is None: return None if not isinstance(value, dict): raise ValueError("집계 항목(summary_item)은 객체여야 합니다.") - extra = set(value) - {"layer", "owner", "key", "name"} + extra = set(value) - {"layer", "owner", "key", "name", "spec"} if extra: raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}") layer = value.get("layer") @@ -59,7 +64,11 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None: name = value.get("name") if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX: raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.") - return {"layer": layer, "owner": owner, "key": key, "name": name} + spec = value.get("spec") + if spec is not None and (not isinstance(spec, str) or not 0 < len(spec.strip()) <= _SPEC_MAX): + raise ValueError(f"집계 항목 spec(규격 열)은 1~{_SPEC_MAX}자 글입니다.") + out = {"layer": layer, "owner": owner, "key": key, "name": name} + return {**out, "spec": spec.strip()} if spec is not None else out #: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙) diff --git a/common_util/common_util_summary_item.ts b/common_util/common_util_summary_item.ts index d30d08b72..c448daffc 100644 --- a/common_util/common_util_summary_item.ts +++ b/common_util/common_util_summary_item.ts @@ -3,7 +3,9 @@ * 구조물 집계표 항목 참조(PLAN 21-1) — 구조물 · 관 지점이 「어느 집계표 항목」인지 가리키는 값. * * summary_item: { layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}", - * key: "SS000004", name: "돌기슭막이 메쌓기 1.2m" } + * key: "SS000004", name: "돌기슭막이 메쌓기 1.2m", spec?: "2.0" } + * + * spec(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(서버 `parse_summary_item` 과 짝). * * 없으면 null(= 미지정 · 집계표에 안 들어감). 옵션(options) 밖의 칸이다. * import 없음 — 시험이 이 파일만 옮겨 돌림. @@ -16,6 +18,8 @@ export interface SummaryItem { owner: string | null; key: string; name: string; + /** 규격 열 이름(704-4) — 없으면 기본 열 */ + spec?: string; } const LAYERS: readonly string[] = ["system", "company", "personal"]; @@ -30,7 +34,8 @@ export function isSummaryItem(value: unknown): value is SummaryItem { (item.owner === null || typeof item.owner === "string") && typeof item.key === "string" && item.key !== "" && - typeof item.name === "string" + typeof item.name === "string" && + (item.spec === undefined || typeof item.spec === "string") ); } diff --git a/resources/tester/test_704_4_spec.py b/resources/tester/test_704_4_spec.py new file mode 100644 index 000000000..1743d615c --- /dev/null +++ b/resources/tester/test_704_4_spec.py @@ -0,0 +1,170 @@ +"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 · +열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본). +임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만). +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs +from common_util.common_util_summary_item import parse_summary_item +from config import config_system +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Labor_Spec as spec +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE + +MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} +SPEC = { + "이름": "H", + "열": ["1.5", "2.5"], + "기본": "1.5", + "수량": {"GF000219": {"1.5": 2, "2.5": 3}}, +} +LABOR = { + "종류": "일위대가", + "열": "ms", + "단위": "m", + "담은로직": [ + {"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량 + {"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통 + ], + "자재": [], + "규격": SPEC, +} + + +# ── summary_item spec ────────────────────────────────────────────────────── +def test_summary_item_spec_있음_없음(): + assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"} + assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로 + with pytest.raises(ValueError, match="모르는 칸"): + parse_summary_item({**MS, "열": "2.5"}) + for bad in ("", "x" * 21, 2.5): + with pytest.raises(ValueError, match="spec"): + parse_summary_item({**MS, "spec": bad}) + + +# ── 굳히기 ───────────────────────────────────────────────────────────────── +def test_굳히기_고른_열_지운_열_옛_문서(): + got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5") + assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5} + assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}} + assert "계산값" not in got + assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본 + old = {k: v for k, v in LABOR.items() if k != "규격"} + assert spec.freeze(old, "2.5") is old + + +# ── 임시 저장소 사본 ─────────────────────────────────────────────────────── +@pytest.fixture +def world(tmp_path: Path, monkeypatch) -> dict[str, Any]: + system = tmp_path / "master" + system.mkdir() + shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE) + for kind in ("table", "structure", "area"): + shutil.copytree(SAMPLE / kind, system / kind) + (system / "labor").mkdir() + (system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8") + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + root = storage / "3" / "12" / "proj" + (root / "B05_Profile/route").mkdir(parents=True) + (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) + return {"system": system, "root": root} + + +def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]: + rows = [ + { + "structure_id": f"st{n}", + "type_id": "masonry_dry", + "placement": "interval", + "chainage_m": 85 + n * 100, + "start_m": 80 + n * 100, + "end_m": 90 + n * 100, + "options": {"height_m": 2.0, "length_m": 10}, + "summary_item": {**MS, **({"spec": s} if s else {})}, + } + for n, s in enumerate(specs) + ] + (root / "B05_Profile/route/structures.json").write_text( + json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8" + ) + (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( + '{"points": []}', encoding="utf-8" + ) + return snap.sync_snapshots(root) + + +def _block(root: Path, owner: str) -> tuple[dict, dict]: + got = daega_inputs(root) + return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner]) + + +def test_사본이_고른_열을_굳힘_manifest_규격(world): + root = world["root"] + assert _save(root, ["2.5"])["만듦"] == ["st0"] + folder = snap.structures_dir(root) / "st0" + doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"] + assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}] + manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8")) + assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5" + assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로 + + +def test_열_바꿈_뒤_덮개_남음(world): + root = world["root"] + _save(root, ["1.5"]) + _, before = _block(root, "st0") + cids = [ln["cid"] for ln in before["lines"]] + assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5] + cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침 + got = _save(root, ["2.5"]) + assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본 + after_all, after = _block(root, "st0") + assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids + sheets = {"labor": [], "material": [], "machine": [], "gaps": []} + out = compute_labor(after_all, sheets, {}, cover) + lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"] + assert lines[0]["qty"] == 3 # 새 열 수량 + assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로 + assert out["daega_orphans"] == [] + + +def test_B07_한_장_판정_항목_더하기_열(world): + root = world["root"] + _save(root, ["1.5", "1.5", "2.5"]) + ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")] + assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로 + + +def test_열_지움_쓰는_사본_찾기(world): + root = world["root"] + _save(root, ["2.5", None]) + assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"] + assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == [] + assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == [] + + +def test_쓰는_열_지움_409_안_쓰는_열_됨(world): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + root = world["root"] + _save(root, ["2.5"]) + path = world["system"] / "labor" / "ms.json" + with store.at(world["system"]): + with pytest.raises(store.StoreError) as got: + spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms") + assert got.value.status == 409 and "proj/st0" in str(got.value.detail) + spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀 + spec.guard(path, {"규격": SPEC}, "ms")