From 2286031266de512a6921059d23cc521aa8862187 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:14:23 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(M02=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80):=20338-2a=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EB=B8=94=EB=A1=9D=20=EA=B8=80=20=C2=B7=20=EC=B6=9C=EC=B2=98=20?= =?UTF-8?q?=C2=B7=20=EC=A7=81=EC=A2=85=20HP=20=EC=A0=80=EC=9E=A5=20=C2=B7?= =?UTF-8?q?=20=EA=BC=B4=20=EC=B9=B8=20=C2=B7=20Basis.clause=20=EC=9C=97?= =?UTF-8?q?=EB=A7=88=EB=94=94=200.9=20=C2=B7=20=EC=A1=B0=ED=95=AD=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=2013-114?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단산 블록마다 글(머리 뺀 몸 줄 · 200 줄 · 300 자 안) · 출처 · 직종 → HP 단산 · 꼴 「엑셀꼴」 - Basis.clause 공개 — 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5 · 근거 맞음에 윗마디 포함 - Basis.sources 조항 오타 「13-114」 → 13-11-4 - 북부청 단산 268 · 사방 139 이음 그대로 · 시험 basis · excel · grid · match 51 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- M02_MasterTemplete/M02_Hopyo_Basis.py | 31 +++++++++++++++------ M02_MasterTemplete/M02_Hopyo_Excel.py | 7 +++-- resources/tester/test_m02_hopyo_basis.py | 35 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index 7d615ee34..7a7705054 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -5,8 +5,10 @@ 다음 머리 앞까지 한 블록 · 출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 = 건설품셈 · -직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로). -견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) · +직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · +글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료). +견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · +앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · 같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이 하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 · @@ -31,6 +33,8 @@ BONUS_PARTS = ("공통", "토목") _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") +_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4 +GLINES, GCHARS = 200, 300 _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} @@ -66,8 +70,10 @@ def sources(text: str) -> list[list[str]]: continue before = text[max(0, hit.start() - 12) : hit.start()] book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈" - if [book, num.group()] not in out: - out.append([book, num.group()]) + typo = _TYPO_CLAUSE.match(num.group()) + number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group() + if [book, number] not in out: + out.append([book, number]) return out @@ -82,7 +88,7 @@ def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: - """단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}` + """단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액}}` (행 = 엑셀 1부터).""" if not rows: return {} @@ -105,6 +111,7 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: "행": [at, at], "출처": [], "직종": [], + "글": [], "금액": { key: v for key, c in money.items() @@ -117,6 +124,8 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: if block is None: continue block["행"][1] = at + if len(block["글"]) < GLINES: + block["글"].append(text.rstrip()[:GCHARS]) block["출처"] += [one for one in sources(text) if one not in block["출처"]] trade = _TRADE.match(text) name = names.get(_flat(trade.group(1))) if trade else None @@ -163,12 +172,16 @@ def _head(number: str) -> str: return number.rsplit("-", 1)[0] -def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: - """(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만.""" +def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: + """엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글, + 꼬리표) — 같은 책만 · 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5.""" nums = [n for book, n in theirs if book == our["책"]] same = sorted(n for n in nums if n in our["조항"]) if same: return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음" + kids = sorted({c for c in our["조항"] for n in nums if c.startswith(n + "-")}) + if kids: + return 0.9, [f"{our['구분']} {n}" for n in kids], "윗마디 같음" heads = {_head(n) for n in nums} near = sorted(n for n in our["조항"] if _head(n) in heads) if near: @@ -206,7 +219,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any] our = table.get(one["로직"]) if not our: continue - score, clauses, tag = _clause(theirs, our) + score, clauses, tag = clause(theirs, our) if not score: continue shared = sorted(trades & our["직종"]) @@ -227,7 +240,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any] why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} theirs_loose = _loose_words(one["이름"]) overlap = any(a in b or b in a for a in loose for b in theirs_loose) - full = tag == "조항 같음" and unit and (bool(shared) or not trades) # 근거 맞음 + full = tag != "앞 마디 같음" and unit and (bool(shared) or not trades) # 근거 맞음 why["까닭"] = " · ".join(tags) extra = {"_겹침": dice, "_낱말": overlap or full} scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra})) diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index 9995de2b4..c55a1b89c 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -13,7 +13,8 @@ 어긋남은 글 목록(막지 않음). 행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) · 근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) · -`단산` = 블록마다 `{번, 이름, 단위, 행}`. +`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) · +`꼴` = 「엑셀꼴」. """ from __future__ import annotations @@ -270,8 +271,10 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: {**_json(hopyo), "줄": [_json(line) for line in lines]} for hopyo, lines in zip(hopyos, raw, strict=True) ] - basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()] + keep = ("번", "이름", "단위", "행", "출처", "직종", "글") + basis = [{k: b[k] for k in keep} for b in blocks.values()] return { + "꼴": "엑셀꼴", "시트": SHEET, "호표": out, "단산": basis, diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 94d21bf83..8bc0a880b 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -3,6 +3,7 @@ 블록 북부청 95 · 사방 67 · 단산93 근거 [건설 3-2-1] · [특별인부] · 후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · 잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · +블록 글 HP(338-2a · 268 · 139 이음 그대로) · `clause` 윗마디 0.9 · 조항 오타 13-114 · master_data 그대로. """ @@ -117,3 +118,37 @@ def test_길_응답_근거_칸(client): assert dig["맞춤"]["후보"][0]["근거"]["까닭"].startswith("조항 같음") assert "근거" not in lines["임도표지석(받침,글새김포함)"] # 자재 줄 assert master_hash() == before + + +def test_블록_글_HP(north): + """338-2a — 단산마다 출처 · 직종 · 글(머리 뺀 몸 줄 · 200 줄 · 300 자 안) · 꼴.""" + assert north["꼴"] == "엑셀꼴" + b93 = north["단산"][92] + assert b93["번"] == 93 and b93["출처"] == [["건설품셈", "3-2-1"]] + assert b93["직종"] == ["특별인부"] + assert b93["글"][0] == " [건설공사표준품셈 3-2-1 인력굴착]" + assert " 특별인부 : 226,122 × (3.6+2.7+2.2)/3 ÷ 8 = 80,084.8" in b93["글"] + assert all(b["글"] and len(b["글"]) <= hb.GLINES for b in north["단산"]) + assert all(len(t) <= hb.GCHARS for b in north["단산"] for t in b["글"]) + lines = [x for h in north["호표"] for x in h["줄"]] + assert sum(1 for x in lines if x.get("근거")) == 268 + sabang = hx.read_book(SABANG.name, SABANG.read_bytes()) + assert sum(1 for h in sabang["호표"] for x in h["줄"] if x.get("근거")) == 139 + assert all(b["글"] for b in sabang["단산"]) + + +def test_조항_윗마디_오타(north): + our = {"책": "건설품셈", "구분": "건설품셈 토목", "조항": {"6-2-2", "6-2-3"}} + assert hb.clause([["건설품셈", "6-2-2"]], our) == (1.0, ["건설품셈 토목 6-2-2"], "조항 같음") + assert hb.clause([["건설품셈", "6-2"]], our) == ( + 0.9, + ["건설품셈 토목 6-2-2", "건설품셈 토목 6-2-3"], + "윗마디 같음", + ) + assert hb.clause([["건설품셈", "6-2-9"]], our)[0] == 0.5 + assert hb.clause([["산림품셈", "6-2"]], our) == (0.0, [], "") # 다른 책 + assert hb.clause([["건설품셈", "6-22"]], our)[0] == 0.0 # 6-2 윗마디 아님 + assert hb.sources("[산림사업표준품셈 13-114]") == [["산림품셈", "13-11-4"]] + assert hb.sources("[산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] + stone = next(b for b in north["단산"] if b["번"] == 63) + assert stone["출처"] == [["산림품셈", "13-11-4"]] From ea21006352601a9b97689e2ca54f824a5eb670a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:15:47 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(M02):=20338-3a=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=EB=BC=88=EB=8C=80=20=E2=80=94=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=20=C2=B7=20=EC=B8=B5=20=EC=B0=A8=EB=A1=80=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8A=90=EC=8A=A8=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EA=BC=B4=20=EB=B0=94=EA=BF=94=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=9E=AC=EA=B2=80=EC=82=AC=20=C2=B7=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Ledger.py — key · hopyo_key · recall(같은 층 → 위 층 · 품명|단위 느슨 = 규격다름) · books · rows · check · write · put - 옛 기억 줄(로직|자재|빼기 · 환산 · 확정) = 새 꼴(갈래 짝 · 짝 [{… 배}] · 빼기 = 안 씀)로 읽음 · 쓸 때 새 꼴 - 시험 test_m02_hopyo_ledger.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Ledger.py | 261 ++++++++++++++++++++++ resources/tester/test_m02_hopyo_ledger.py | 149 ++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 M02_MasterTemplete/M02_Hopyo_Ledger.py create mode 100644 resources/tester/test_m02_hopyo_ledger.py diff --git a/M02_MasterTemplete/M02_Hopyo_Ledger.py b/M02_MasterTemplete/M02_Hopyo_Ledger.py new file mode 100644 index 000000000..0b97c4b1c --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Ledger.py @@ -0,0 +1,261 @@ +"""M02 일위대가 실무자료 별칭 장부(PLAN 338-3 · 설계 3-5) — 층 `실무자료/맞춤.json` 을 키움. + +줄 = {열쇠(품명|규격|단위 정규화): {원래 {품명, 규격, 단위}, + 갈래 「짝」 · 「우리에 없음」 · 「안 씀」, 짝 [{로직|자재, 입력값, 배, 몫}], + 누가, 때, 처음파일, 엑셀조항, 판}} · +직접 줄 호표 통째 = 열쇠 「호표|품명|규격|단위」. +옛 줄 {로직|자재|빼기, 입력값, 환산, 확정} = 읽을 때 새 꼴로 바꿔 읽음(빼기 = 「안 씀」 · +파일은 다음 쓰기 때 새 꼴). +읽는 차례 = 같은 층 → 위 층(개인 → 회사 → 마스터) · 아래 층이 이김 · 정확 열쇠가 먼저 · 없으면 +품명|단위 느슨 열쇠(`규격다름`). +쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장]만 · 쓰기 = 그 층만(권한은 길 몫) · +짝은 서버가 다시 검사(돈 로직 · 그 로직 입력 이름 · M01 자재품목 · 배 0 넘음 · 몫 (0, 1]). +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mm = m01.cm.mf.mp.mm +KINDS = ("짝", "우리에 없음", "안 씀") +HOPYO = "호표|" + + +# ── 열쇠 ───────────────────────────────────────────────────────────── +def key(line: dict[str, Any]) -> str: + """줄 열쇠 = 품명 | 규격 | 단위(정규화 · `M02_Hopyo_Match.line_key` 와 같음).""" + return "|".join( + ( + mm.plain(line.get("이름")), + mm.plain_spec(line.get("규격")), + mm.plain_unit(line.get("단위")), + ) + ) + + +def hopyo_key(hopyo: dict[str, Any]) -> str: + """직접 줄 호표 통째 열쇠 — 「호표|품명|규격|단위」.""" + return HOPYO + key(hopyo) + + +def _loose(full: str) -> str: + """느슨 열쇠 = 품명 | 단위(규격 뺌).""" + head, _, rest = full.rpartition("|") + return f"{head.rsplit('|', 1)[0]}|{rest}" + + +# ── 옛 꼴 → 새 꼴 ───────────────────────────────────────────────────── +def _plain_of(full: str) -> dict[str, str]: + name, spec, unit = full.removeprefix(HOPYO).split("|") + return {"품명": name, "규격": spec, "단위": unit} + + +def upgrade(full: str, raw: Any) -> dict[str, Any] | None: + """장부 한 줄을 새 꼴로 — 옛 기억 줄 · 새 꼴 · 모르는 꼴(None).""" + if not isinstance(raw, dict): + return None + if raw.get("갈래") in KINDS: + return {"원래": _plain_of(full), "짝": [], **raw} + head = {"원래": _plain_of(full), "때": raw.get("확정"), "누가": None, "처음파일": None} + if raw.get("빼기"): + return {**head, "갈래": "안 씀", "짝": []} + pair = {"로직": raw["로직"]} if raw.get("로직") else {"자재": raw.get("자재")} + if not any(pair.values()): + return None + pair.update(입력값=raw.get("입력값") or {}, 배=raw.get("환산"), 몫=None) + return {**head, "갈래": "짝", "짝": [pair]} + + +def read(folder: str | Path) -> dict[str, dict[str, Any]]: + """한 층 장부 `{열쇠: 새 꼴 줄}`(옛 꼴은 바꿔 읽음 · 모르는 줄 뺌).""" + out = {} + for full, raw in hopyo_store.memory(folder).items(): + if str(full).removeprefix(HOPYO).count("|") == 2 and (got := upgrade(str(full), raw)): + out[str(full)] = got + return out + + +# ── 층 ─────────────────────────────────────────────────────────────── +def chain(folder: str | Path) -> list[Path]: + """장부를 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" + layer, owner = refs.where(folder) + out = [Path(folder)] + if layer == "personal": + out.append(refs.folder_of("company", str(owner).split("/")[0])) + if layer in ("personal", "company"): + out.append(Path(store.FOLDER)) + return out + + +def books(folder: str | Path) -> list[tuple[str, str | None, dict[str, dict[str, Any]]]]: + """층마다 `(층, 주인, 장부)` — 같은 층부터(줄 여럿을 찾을 때 한 번 읽어 넘김).""" + return [(*refs.where(one), read(one)) for one in chain(folder)] + + +def recall( + line: dict[str, Any], folder: str | Path, got: list | None = None, whole: bool = False +) -> dict[str, Any] | None: + """줄(`whole` = 호표 통째)의 장부 짝 — `{…줄, 층, 주인, 규격다름}` · 없으면 None. + 정확 열쇠(아래 층 먼저) → 품명|단위 느슨(규격다름 True · 체크 안 함).""" + full = hopyo_key(line) if whole else key(line) + got = books(folder) if got is None else got + for layer, owner, book in got: + if full in book: + return {**book[full], "층": layer, "주인": owner, "규격다름": False} + loose = _loose(full) + for layer, owner, book in got: + for one, row in book.items(): + if one.startswith(HOPYO) == whole and _loose(one) == loose: + return {**row, "층": layer, "주인": owner, "규격다름": True} + return None + + +def rows(folder: str | Path, writable: bool) -> list[dict[str, Any]]: + """장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침}`(고침 = 이 층 · 쓰기 권한).""" + out = [] + for at, (layer, owner, book) in enumerate(books(folder)): + for full, row in sorted(book.items()): + out.append( + {"열쇠": full, **row, "층": layer, "주인": owner, "고침": writable and not at} + ) + return out + + +# ── 검사 · 쓰기 ─────────────────────────────────────────────────────── +def _bad(text: str) -> store.StoreError: + return store.StoreError(400, text) + + +def _number(raw: Any) -> Decimal | None: + if isinstance(raw, bool): + return None + try: + got = Decimal(str(raw).replace(",", "").strip()) + except ArithmeticError: + return None + return got if got.is_finite() else None + + +def _json(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def _known() -> tuple[set[str], set[str]]: + """(돈 로직 키, M01 자재품목 키) — 맞춤 목록 한 벌(`M02_Hopyo_Match.targets`).""" + from M02_MasterTemplete import M02_Hopyo_Match as match # 맞춤이 장부를 부름 — 늦게 읽음 + + got = match.targets() + return {x["로직"] for x in got["로직"]}, {x["자재"] for x in got["자재"]} + + +def input_names(code: str) -> set[str]: + got = labor_inputs.logic_inputs([code])["로직"] + return {str(x.get("이름")) for x in (got[0]["입력"] if got else [])} + + +def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[str, Any]: + """짝 하나 재검사 → `{로직|자재, 입력값, 배, 몫}` — 틀리면 400.""" + if not isinstance(raw, dict): + raise _bad(f"짝 꼴이 틀림 「{where}」") + extra = set(raw) - {"로직", "자재", "입력값", "배", "몫", "이름", "단위"} + if extra: + raise _bad(f"모르는 칸 {sorted(extra)} 「{where}」") + code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "") + if bool(code) == bool(mat): + raise _bad(f"돈 로직 · 우리 자재 하나만 「{where}」") + if (code and code not in known[0]) or (mat and mat not in known[1]): + raise _bad(f"없는 {'로직' if code else '자재'} 「{code or mat}」 「{where}」") + values = raw.get("입력값") or {} + if not isinstance(values, dict) or (mat and values): + raise _bad(f"입력값이 틀림 「{where}」") + unknown = set(values) - input_names(code) if code else set() + if unknown: + raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」") + times = _number(raw.get("배", 1)) + if times is None or times <= 0: + raise _bad(f"배는 0 넘는 수 「{where}」") + share = raw.get("몫") + part = None if share is None else _number(share) + if share is not None and (part is None or not 0 < part <= 1): + raise _bad(f"몫은 0 넘고 1 이하 「{where}」") + head = {"로직": code} if code else {"자재": mat} + return { + **head, + "입력값": values, + "배": _json(times), + "몫": None if part is None else _json(part), + } + + +def check(full: str, raw: Any, known: tuple[set[str], set[str]] | None = None) -> dict[str, Any]: + """장부 한 줄 재검사(장부 화면 [저장]) → 쓸 꼴(누가 · 때 빼고).""" + if str(full).removeprefix(HOPYO).count("|") != 2 or not isinstance(raw, dict): + raise _bad(f"장부 꼴이 틀림 「{full}」") + kind = raw.get("갈래") + if kind not in KINDS: + raise _bad(f"갈래는 {' · '.join(KINDS)} 「{full}」") + pairs = raw.get("짝") or [] + if kind != "짝" and pairs: + raise _bad(f"「{kind}」 줄은 짝 없음 「{full}」") + if kind == "짝" and (not isinstance(pairs, list) or not pairs or len(pairs) > 20): + raise _bad(f"짝은 하나 넘고 스물 이하 「{full}」") + known = known or _known() + origin = raw.get("원래") if isinstance(raw.get("원래"), dict) else _plain_of(full) + return { + "원래": {k: str(origin.get(k) or "") for k in ("품명", "규격", "단위")}, + "갈래": kind, + "짝": [check_pair(p, full, known) for p in pairs], + "엑셀조항": raw.get("엑셀조항") if isinstance(raw.get("엑셀조항"), list) else [], + } + + +def _version() -> str | None: + """마스터 판 열쇠(가 창 `M02_Hopyo_Ours.version` — 아직 없으면 None).""" + try: + from M02_MasterTemplete import M02_Hopyo_Ours as ours + except ImportError: + return None + return ours.version() if hasattr(ours, "version") else None + + +def write( + folder: str | Path, + entries: dict[str, dict[str, Any] | None], + who: Any, + file: str | None = None, +) -> int: + """검사 끝난 줄을 층 장부에 씀(None = 지움) — 같은 열쇠는 덮음 · 처음파일은 있던 값 둠 · + 옛 꼴 줄은 이때 새 꼴로 → 쓴 줄 수.""" + if not entries: + return 0 + have = read(folder) + stamp = datetime.now().isoformat(timespec="seconds") + version = _version() + out: dict[str, dict[str, Any] | None] = dict(have) # 옛 꼴도 새 꼴로 다시 씀 + for full, row in entries.items(): + if row is None: + out[full] = None + continue + first = (have.get(full) or {}).get("처음파일") or file + out[full] = {**row, "누가": who, "때": stamp, "처음파일": first, "판": version} + hopyo_store.remember(folder, out) + return len(entries) + + +def put(folder: str | Path, entries: Any, who: Any) -> dict[str, Any]: + """장부 화면 [저장] — `{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀, 줄}`.""" + if not isinstance(entries, dict) or not entries or len(entries) > 2000: + raise _bad("저장할 줄이 없음") + known = _known() + clean = {str(k): None if v is None else check(str(k), v, known) for k, v in entries.items()} + return {"씀": write(folder, clean, who), "줄": rows(folder, True)} diff --git a/resources/tester/test_m02_hopyo_ledger.py b/resources/tester/test_m02_hopyo_ledger.py new file mode 100644 index 000000000..d60b5b239 --- /dev/null +++ b/resources/tester/test_m02_hopyo_ledger.py @@ -0,0 +1,149 @@ +"""일위대가 실무자료 별칭 장부(PLAN 338-3 · `M02_Hopyo_Ledger`) — 옛 꼴 바꿔 읽음 · 새 꼴 · +층 차례(아래 층이 이김) · 느슨 찾기(규격다름) · 우리에 없음 · 안 씀 · 호표 통째 열쇠 · +위 층 읽기만 · 재검사 · 쓰기(처음파일 둠 · 옛 꼴 새로 씀) · master_data 원본 그대로. 층은 임시 폴더. +""" + +import json + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401 + +DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"} +DIG_KEY = "구조물터파기(인력100%)|토사|m3" +WHO = {"id": 42, "이름": "갑"} + + +def _put_raw(folder, rows): + path = folder / hs.DIRNAME / f"{hs.MEMORY}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"줄": rows}, ensure_ascii=False), encoding="utf-8") + + +def _raw(folder): + return json.loads((folder / hs.DIRNAME / f"{hs.MEMORY}.json").read_bytes())["줄"] + + +def test_열쇠는_맞춤과_같음(): + assert ledger.key(DIG) == hm.line_key(DIG) == DIG_KEY + assert ledger.hopyo_key(DIG) == "호표|" + DIG_KEY + assert ledger._loose(DIG_KEY) == "구조물터파기(인력100%)|m3" + assert ledger._loose("호표|" + DIG_KEY) == "호표|구조물터파기(인력100%)|m3" + + +def test_옛_꼴_바꿔_읽음(client): + mine = layers.personal_dir(7, 42) + _put_raw( + mine, + { + DIG_KEY: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 2, "확정": "t1"}, + "합판거푸집|4회|m2": {"빼기": True, "확정": "2026-10-02T10:00:00"}, + "표지석||개": {"자재": "MT004258", "입력값": {}, "환산": 1, "확정": "t"}, + "깨진|줄": {"로직": "X"}, + "빈|짝|m": {"입력값": {}}, + }, + ) # fmt: skip + got = ledger.read(mine) + assert set(got) == {DIG_KEY, "합판거푸집|4회|m2", "표지석||개"} # 모르는 꼴 뺌 + assert got[DIG_KEY] == { + "원래": {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "m3"}, + "때": "t1", + "누가": None, + "처음파일": None, + "갈래": "짝", + "짝": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 2, "몫": None}], + } + assert got["합판거푸집|4회|m2"]["갈래"] == "안 씀" and got["합판거푸집|4회|m2"]["짝"] == [] + assert got["표지석||개"]["짝"] == [{"자재": "MT004258", "입력값": {}, "배": 1, "몫": None}] + + +def test_층_차례_아래_층이_이김_느슨_찾기(client): + mine, company, system = ledger.chain(layers.personal_dir(7, 42)) + assert (company, system) == (layers.company_dir(7), store.FOLDER) + new = {"갈래": "짝", "짝": [{"로직": "GF000103", "입력값": {}, "배": 1, "몫": None}]} + _put_raw(system, {DIG_KEY: new, "잔토처리(인력100%)||m3": {"갈래": "우리에 없음"}}) + _put_raw(company, {DIG_KEY: {**new, "짝": [{"로직": "GF000102", "배": 1}]}}) + got = ledger.recall(DIG, mine) + assert (got["층"], got["주인"], got["짝"][0]["로직"], got["규격다름"]) == ( + "company", + "7", + "GF000102", + False, + ) + _put_raw(mine, {DIG_KEY: {"로직": "GF000101", "환산": 1}}) + assert ledger.recall(DIG, mine)["짝"][0]["로직"] == "GF000101" # 같은 층 먼저 + rock = {**DIG, "규격": "경암"} + loose = ledger.recall(rock, mine) + assert loose["규격다름"] is True and loose["층"] == "personal" # 품명|단위 느슨 + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "M3"} + assert ledger.recall(soil, mine)["갈래"] == "우리에 없음" + assert ledger.recall({"이름": "없는 줄", "단위": "m"}, mine) is None + assert ledger.recall(DIG, mine, whole=True) is None # 호표 통째 열쇠는 따로 + books = ledger.books(mine) + assert ledger.recall(DIG, mine, books)["짝"][0]["로직"] == "GF000101" # 한 번 읽어 넘김 + rows = ledger.rows(mine, True) + assert [(r["층"], r["고침"]) for r in rows] == [ + ("personal", True), + ("company", False), + ("system", False), + ("system", False), + ] # 위 층 = 읽기만 + assert not any(r["고침"] for r in ledger.rows(mine, False)) + + +def test_재검사(client): + known = ledger._known() + ok = { + "갈래": "짝", + "짝": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": "2", "몫": 0.5}], + } + got = ledger.check(DIG_KEY, ok, known) + assert got["짝"] == [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 2, "몫": 0.5}] + assert got["원래"] == {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "m3"} + assert ledger.check(DIG_KEY, {"갈래": "안 씀"}, known)["짝"] == [] + pair = ok["짝"][0] + bad = [ + ("a|b", ok), + (DIG_KEY, {"갈래": "같음", "짝": ok["짝"]}), + (DIG_KEY, {"갈래": "짝", "짝": []}), + (DIG_KEY, {"갈래": "안 씀", "짝": ok["짝"]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "로직": "GC000422"}]}), # 돈 아닌 로직 + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "입력값": {"없는칸": 1}}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "배": 0}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "배": "nan"}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "몫": 1.5}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "자재": "MT004258"}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "금액": 100}]}), # 금액 칸 받지 않음 + (DIG_KEY, {"갈래": "짝", "짝": [{"자재": "MT000000", "배": 1}]}), + ] + for full, raw in bad: + with pytest.raises(store.StoreError) as error: + ledger.check(full, raw, known) + assert error.value.status == 400, raw + + +def test_쓰기_처음파일_옛_꼴_새로(client): + before = master_hash() + mine = layers.personal_dir(7, 42) + _put_raw(mine, {"합판거푸집|4회|m2": {"빼기": True, "확정": "t"}}) + row = ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": "GF000101", "배": 1}]}) + assert ledger.write(mine, {DIG_KEY: row}, WHO, "가.xls") == 1 + raw = _raw(mine) + assert raw["합판거푸집|4회|m2"]["갈래"] == "안 씀" # 옛 꼴 = 쓸 때 새 꼴 + assert raw[DIG_KEY]["누가"] == WHO and raw[DIG_KEY]["처음파일"] == "가.xls" + assert raw[DIG_KEY]["때"] + ledger.write(mine, {DIG_KEY: row}, WHO, "나.xls") + assert _raw(mine)[DIG_KEY]["처음파일"] == "가.xls" # 처음파일은 있던 값 + got = ledger.put(mine, {DIG_KEY: None}, WHO) + assert got["씀"] == 1 and DIG_KEY not in _raw(mine) + with pytest.raises(store.StoreError): + ledger.put(mine, {}, WHO) + assert master_hash() == before From bf5d130e32d6cff34d4c53a853cee42fd30f6c7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:21:09 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(M02):=20338-3b=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=EB=A7=8C=20=EC=82=AC=EC=A0=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=E2=80=94=20Build=20=EC=83=88=20=EB=AA=B8=20=C2=B7?= =?UTF-8?q?=20=EC=9E=AC=EA=B2=80=EC=82=AC=20=C2=B7=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=EC=94=80=20=C2=B7=20=EC=9E=A5=EB=B6=80=20?= =?UTF-8?q?=EA=B8=B8=20=C2=B7=20=EC=98=9B=20=EC=A6=89=EC=8B=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B8=20=EC=97=86=EC=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Build — 몸 줄만(없으면 400) · 대상 여럿 같은 별칭 · 배 · 몫 · 통째 · 하위 호표 확인 전 409 · 본뜬 원본 · 확인 · 같은 본뜬 덮음 · confirmed(folder, source) - M02_Labor_Router — hopyo-build 엄격 몸(extra forbid) · hopyo-ledger GET · PUT · hopyo-memory 없앰 · picks 확인 · 올린 뒤 Fit.ensure · 시동 Ours.warm(있을 때만) · ?to= 만들 층 - M02_Hopyo_Ledger — times_of 나눔 - 시험 test_m02_hopyo_build.py 새로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Build.py | 328 +++++++++++++++-------- M02_MasterTemplete/M02_Hopyo_Ledger.py | 22 +- M02_MasterTemplete/M02_Labor_Router.py | 144 +++++++--- resources/tester/test_m02_hopyo_build.py | 237 ++++++++++------ 4 files changed, 508 insertions(+), 223 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index c6997996a..41309a762 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -1,39 +1,44 @@ -"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3). +"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7). +[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 — +`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`. +몸이 비면 400(확인 없이 들어가는 길 없음). +서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = +돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · +호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · -상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} · -만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만: - 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값, - 수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조 - (같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 · - 아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조 - (순환 = 빠진 줄). -같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도). -빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가. +상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], +확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). + 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값, + 수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄 + 사전조합으로 감싸 참조(같은 로직 두 번 금지) · + 자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조. +같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 +(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). +빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). """ from __future__ import annotations import json +from datetime import datetime from decimal import Decimal from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Match as match +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store ORIGIN = "실무자료" -WHY = { - "%": "% 줄 — 우리 로직 덧줄이 셈", - "빼기": "뺌(확정)", - "후보": "못 맞춤 — 후보 확정 필요", - "없음": "못 맞춤", -} +LINES_MAX = 500 +UNCHECKED = "확인 안 함" +PERCENT = "% 줄 — 우리 로직 덧줄이 셈" +WHOLE = "호표 통째로 대신" def mark(key: str, no: int, at: int | None = None) -> str: @@ -42,25 +47,46 @@ def mark(key: str, no: int, at: int | None = None) -> str: return head if at is None else f"{head} {at + 1}째 줄" -def _found(origin: str) -> tuple[str, str] | None: - """지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판).""" - for path in sorted((store.root() / "precombo").glob("SC-*.json")): +def _origins(folder: Path): + """층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판).""" + for path in sorted((Path(folder) / "precombo").glob("SC-*.json")): try: - doc = json.loads(path.read_bytes()) + raw = path.read_bytes() + doc = json.loads(raw) except (OSError, ValueError): continue got = doc.get("본뜬") if isinstance(doc, dict) else None - if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin: - return path.stem, store.version_of(path.read_bytes()) - return None + if isinstance(got, dict) and got.get("층") == ORIGIN: + yield path.stem, got, raw -def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]: - """(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키.""" +def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool: + """본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음.""" + have = got.get("원본") + return source is None or not have or tuple(have) == tuple(source) + + +def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]: + """층에 확인된 실무 사전조합 `{본뜬 키: SC 키}`(`source` = 파일 층 (층, 주인)으로 거름).""" + return { + got["키"]: key + for key, got, _ in _origins(Path(folder)) + if got.get("확인") and _same_source(got, source) + } + + +def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키.""" at = refs.here() - have = _found(doc["본뜬"]["키"]) - if have and not fresh: - return {"층": at[0], "주인": at[1], "키": have[0]}, True + want = doc["본뜬"] + have = next( + ( + (key, store.version_of(raw)) + for key, got, raw in _origins(store.root()) + if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"])) + ), + None, + ) if have: store.write("precombo", have[0], have[1], doc) key = have[0] @@ -74,10 +100,11 @@ def _num(value: Decimal) -> int | float: return int(value) if value == value.to_integral_value() else float(value) -def _memo(line: dict[str, Any], times: Any) -> str: +def _memo(line: dict[str, Any], one: dict[str, Any]) -> str: qty = line.get("수량") spec = str(line.get("규격") or "").strip() - head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}" + head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {one['배']}" + head += f"(몫 {one['몫']})" if one.get("몫") is not None else "" return f"{head} · {spec}" if spec else head @@ -90,96 +117,178 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: } +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + class Builder: - """한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억.""" + """한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀.""" - def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None: + def __init__(self, folder: Path, source: Path, key: str, no: int, who: Any) -> None: + self.folder = Path(folder) self.doc = hopyo_store.read(source, key) - self.key, self.fresh = key, fresh - self.memo, self.units = match.memories(folder), match.units_of(self.doc) - self.got = match.targets() - self.made: dict[int, dict[str, Any]] = {} - self.missing: dict[int, list[dict[str, Any]]] = {} + self.key, self.no, self.who = key, int(no), who + self.source = refs.where(source) + self.done = confirmed(folder, self.source) + self.known = ledger._known() + self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) + if self.hopyo is None: + raise _bad(f"없는 호표 「제{no}호표」", 404) - def hopyo(self, no: int) -> dict[str, Any]: - found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None) - if found is None: - raise store.StoreError(404, f"없는 호표 「제{no}호표」") - return found + # ── 재검사 ─────────────────────────────────────────────────────── + def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]: + if raw.get("호표") is None: + return ledger.check_pair( + {k: v for k, v in raw.items() if k != "호표"}, where, self.known + ) + if raw.get("로직") or raw.get("자재") or raw.get("입력값"): + raise _bad(f"호표 대상은 호표 · 배만 「{where}」") + child = int(raw["호표"]) + if child == self.no: + raise _bad(f"호표가 자기를 담음 「{where}」") + if mark(self.key, child) not in self.done: + raise _bad(f"제{child}호표 먼저 확인", 409) + times, part = ledger.times_of(raw, where) + return {"호표": child, "배": times, "몫": part} - def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]: - """호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄).""" - if no in self.made: - return self.made[no] - hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got) - lines, mats, missing = [], {}, [] - used: set[str] = set() - for line in hopyo["줄"]: - got = line["맞춤"] - if not got["들어감"]: - conv = got.get("환산") or {} - why = WHY.get(got["상태"]) or ( - "추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요" - ) - missing.append(_missing(line, why)) - continue - times = got["환산"]["배"] - qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times)) - target = got["대상"] - alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None - memo = _memo(line, times) - if "자재" in target: - code = target["자재"] + def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]: + """몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409.""" + if whole is not None and lines: + raise _bad("통째 · 줄 중 하나만") + if not lines and not (whole or {}).get("대상"): + raise _bad("확인한 줄이 없음") + if len(lines) > LINES_MAX: + raise _bad(f"줄은 {LINES_MAX} 개 안") + have = self.hopyo.get("줄") or [] + out: dict[int, dict[str, Any]] = {} + for raw in lines: + at = int(raw.get("차례", -1)) + if not 0 <= at < len(have) or at in out: + raise _bad(f"없는 줄 · 겹친 줄 「제{self.no}호표 {at}」") + where = f"제{self.no}호표 {at + 1}째 줄" + targets = raw.get("대상") or [] + if bool(raw.get("뺌")) == bool(targets): + raise _bad(f"대상 · 뺌 중 하나만 「{where}」") + if raw.get("뺌") and raw["뺌"] not in ledger.KINDS[1:]: + raise _bad(f"뺌 까닭이 틀림 「{where}」") + if len(targets) > 20: + raise _bad(f"대상은 스물 이하 「{where}」") + out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else { + "대상": [self._target(t, where) for t in targets] + } # fmt: skip + pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []] + if any("호표" in p for p in pairs) or len(pairs) > 20: + raise _bad("통째 대상은 로직 · 자재만") + return out, pairs + + # ── 쓰기 ───────────────────────────────────────────────────────── + def _origin(self, origin: str, stamp: str) -> dict[str, Any]: + sure = {"누가": self.who, "때": stamp} + file = self.doc.get("파일") + return { + "층": ORIGIN, + "주인": None, + "키": origin, + "파일": file, + "원본": list(self.source), + "확인": sure, + } + + def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: + stamp = datetime.now().isoformat(timespec="seconds") + hopyo, file = self.hopyo, self.doc.get("파일") or "" + lines, mats, missing, used = [], {}, [], set() + + def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: + qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) + memo = _memo(line, one) + if "자재" in one: apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" - have = mats.get(code) + have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty - mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart} - continue - if "호표" in target: - child = int(target["호표"]) - if child in (*trail, no): - missing.append(_missing(line, "순환 — 호표가 서로 담음")) - continue - ref = self.build(child, (*trail, no)) + mats[one["자재"]] = { + "코드": one["자재"], + "수량": {"손": _num(hand)}, + "별도계상": apart, + } + return + if "호표" in one: + ref = { + "층": refs.here()[0], + "주인": refs.here()[1], + "키": self.done[mark(self.key, one["호표"])], + } lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) - continue - one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo} - if target["로직"] not in used: - used.add(target["로직"]) - lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}}) - continue + return + row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} + if one["로직"] not in used: + used.add(one["로직"]) + lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}}) + return + at = int(line.get("차례", 0)) wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 "이름": f"{hopyo['이름']} — {line.get('이름')}", - "단위": target.get("단위"), + "단위": line.get("단위") or None, "구분": ORIGIN, - "상세구분": self.doc.get("파일") or "", - "담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}], + "상세구분": file, + "담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}], "자재": [], - "본뜬": self._origin(mark(self.key, no, line["차례"])), + "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } - ref, _ = _save(wrap, self.fresh) + ref, _ = _save(wrap) lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + + book: dict[str, dict[str, Any]] = {} + if whole: + for one in whole: + put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None) + book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole) + for at, line in enumerate(hopyo.get("줄") or []): + got = checked.get(at) + if line.get("갈래") == "%": + missing.append(_missing(line, PERCENT)) + continue + if whole or got is None: + missing.append(_missing(line, WHOLE if whole else UNCHECKED)) + continue + if got.get("뺌"): + missing.append(_missing(line, f"뺌 — {got['뺌']}")) + book[ledger.key(line)] = self._row(line, got["뺌"], []) + continue + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + for one in got["대상"]: + put(line, one, alias) + pairs = [p for p in got["대상"] if "호표" not in p] + if pairs: + book[ledger.key(line)] = self._row(line, "짝", pairs) name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) doc = { "이름": name, "단위": hopyo.get("단위") or None, "구분": ORIGIN, - "상세구분": self.doc.get("파일") or "", + "상세구분": file, "담은로직": lines, "자재": list(mats.values()), - "본뜬": self._origin(mark(self.key, no)), + "본뜬": self._origin(mark(self.key, self.no), stamp), } - ref, again = _save(doc, self.fresh) - self.made[no] = {**ref, "다시씀": again} - self.missing[no] = missing - return self.made[no] + ref, again = _save(doc) + wrote = ledger.write(self.folder, book, self.who, file or None) + return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} - def _origin(self, origin: str) -> dict[str, Any]: - return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")} + def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]: + """장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면).""" + origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")} + clauses = (line.get("근거") or {}).get("출처") or [] + return { + "원래": {k: str(v or "") for k, v in origin.items()}, + "갈래": kind, + "짝": pairs, + "엑셀조항": clauses if isinstance(clauses, list) else [], + } def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: - """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가.""" + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님).""" got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) base = got.get("기준") or {} ours = float(base["계"]) if "계" in base else None @@ -188,17 +297,18 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")} -def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]: - """실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 — - `{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`).""" - maker = Builder(source, folder, key, fresh) - made = maker.build(int(no)) - hopyo = maker.hopyo(int(no)) - ref = {k: made[k] for k in ("층", "주인", "키")} - return { - "참조": ref, - "이름": hopyo.get("이름"), - "다시씀": made["다시씀"], - "빠진줄": maker.missing.get(int(no), []), - "비교": compare(ref, hopyo), - } +def build( + folder: Path, + source: Path, + key: str, + no: int, + lines: list[dict[str, Any]] | None = None, + whole: dict[str, Any] | None = None, + who: Any = None, +) -> dict[str, Any]: + """실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 · + 별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`).""" + maker = Builder(folder, source, key, no, who) + checked, pairs = maker.check(list(lines or []), whole) + made = maker.build(checked, pairs) + return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)} diff --git a/M02_MasterTemplete/M02_Hopyo_Ledger.py b/M02_MasterTemplete/M02_Hopyo_Ledger.py index 0b97c4b1c..560292915 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ledger.py +++ b/M02_MasterTemplete/M02_Hopyo_Ledger.py @@ -178,9 +178,21 @@ def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[s values = raw.get("입력값") or {} if not isinstance(values, dict) or (mat and values): raise _bad(f"입력값이 틀림 「{where}」") - unknown = set(values) - input_names(code) if code else set() + unknown = set(values) - input_names(code) if code and values else set() if unknown: raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」") + times, part = times_of(raw, where) + head = {"로직": code} if code else {"자재": mat} + return { + **head, + "입력값": values, + "배": times, + "몫": part, + } + + +def times_of(raw: dict[str, Any], where: str) -> tuple[int | float, int | float | None]: + """(배, 몫) 재검사 — 배 0 넘는 수 · 몫 없음 또는 (0, 1].""" times = _number(raw.get("배", 1)) if times is None or times <= 0: raise _bad(f"배는 0 넘는 수 「{where}」") @@ -188,13 +200,7 @@ def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[s part = None if share is None else _number(share) if share is not None and (part is None or not 0 < part <= 1): raise _bad(f"몫은 0 넘고 1 이하 「{where}」") - head = {"로직": code} if code else {"자재": mat} - return { - **head, - "입력값": values, - "배": _json(times), - "몫": None if part is None else _json(part), - } + return _json(times), None if part is None else _json(part) def check(full: str, raw: Any, known: tuple[set[str], set[str]] | None = None) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 55739aa3a..f528dedf1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -4,23 +4,28 @@ 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + 사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + -일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기). +일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · +338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · +올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations +import importlib +import inspect from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid +from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -168,10 +173,27 @@ def new_material( return _call(folder, own.add, folder, body) -# ── 일위대가 실무자료(324) ───────────────────────────────────────────── +# ── 일위대가 실무자료(324 · 338) ─────────────────────────────────────── HOPYO_MAX_BYTES = 20 * 1024 * 1024 +def _later(module: str, name: str): + """맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로).""" + try: + got = importlib.import_module(f"M02_MasterTemplete.{module}") + except ImportError: + return None + return getattr(got, name, None) + + +if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None: + _warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 + + +def _who(session: Session) -> dict[str, Any]: + return {"id": session.get("user_id"), "이름": session.get("name")} + + def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: try: parsed = hopyo_excel.read_book(name, data) @@ -181,7 +203,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = hopyo_grid.read(name, data, parsed) except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None - return hopyo_store.save(store.root(), name, parsed, who, grid) + row = hopyo_store.save(store.root(), name, parsed, who, grid) + if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: + ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) + return row @router.get("/layers/{layer}/hopyo") @@ -198,9 +223,20 @@ def hopyo_picks( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 - `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억).""" + `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합).""" folder = _root(session, layer, owner) - return {"줄": _call(folder, hopyo_match.picks, folder)} + return {"줄": _call(folder, _picks, folder)} + + +def _picks(folder: Path) -> list[dict[str, Any]]: + rows, here, done = hopyo_match.picks(folder), refs.where(folder), {} + for row in rows: + src = (row["층"], row.get("주인")) + if src not in done: + done[src] = hopyo_build.confirmed(folder, src) + key = done[src].get(hopyo_build.mark(row["키"], row["번"])) + row["확인"] = {"층": here[0], "주인": here[1], "키": key} if key else None + return rows @router.post("/layers/{layer}/hopyo") @@ -217,8 +253,7 @@ def hopyo_upload( if len(data) > HOPYO_MAX_BYTES: raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)") name = Path(file.filename or "실무자료").name[:120] - who = {"id": session.get("user_id"), "이름": session.get("name")} - return _call(folder, _upload, name, data, who) + return _call(folder, _upload, name, data, _who(session)) @router.get("/layers/{layer}/hopyo/{key}") @@ -246,11 +281,17 @@ def hopyo_one( key: str, no: int, owner: str | None = None, + to: Layer | None = None, + to_owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5).""" + """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · + 호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만).""" folder = _root(session, layer, owner) - return _call(folder, hopyo_match.view, folder, key, no) + if to is None or "to" not in inspect.signature(hopyo_match.view).parameters: + return _call(folder, hopyo_match.view, folder, key, no) + target = _root(session, to, to_owner) + return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target)) class GuessBody(BaseModel): @@ -274,33 +315,44 @@ def hopyo_guess( return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상) -class MemoryBody(BaseModel): - """`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}.""" - - 줄: dict[str, Any] +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈) -@router.post("/layers/{layer}/hopyo-memory") -def hopyo_memory( - layer: Layer, - body: MemoryBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동.""" - folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_match.confirm, folder, body.줄) +class BuildTarget(_Strict): + """대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫.""" + + 로직: str | None = None + 자재: str | None = None + 호표: int | None = None + 입력값: dict[str, Any] = Field(default_factory=dict) + 배: float = 1 + 몫: float | None = None -class BuildBody(BaseModel): - """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · - `새로` = 있던 것을 덮음.""" +class BuildLine(_Strict): + """확인한 줄 — `차례` = 호표 안 줄(0부터) · `대상` 목록 또는 `뺌` 까닭.""" + + 차례: int + 대상: list[BuildTarget] = Field(default_factory=list) + 뺌: Literal["우리에 없음", "안 씀"] | None = None + + +class BuildWhole(_Strict): + 대상: list[BuildTarget] + + +class BuildBody(_Strict): + """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` · + 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀).""" 층: Layer 주인: str | None = None 키: str 번: int 새로: bool = False + 줄: list[BuildLine] = Field(default_factory=list, max_length=500) + 통째: BuildWhole | None = None @router.post("/layers/{layer}/hopyo-build") @@ -310,11 +362,43 @@ def hopyo_make( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) — - `{참조, 이름, 다시씀, 빠진줄, 비교}`.""" + """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → + `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409.""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) - return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole, _who(session)) + return _call(folder, hopyo_build.build, *args) + + +@router.get("/layers/{layer}/hopyo-ledger") +def hopyo_ledger_read( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}` + (고침 = 이 층 · 쓰기 권한).""" + folder = _root(session, layer, owner) + writable = access.grant(session, layer, owner).write + return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)} + + +class LedgerBody(_Strict): + """`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사.""" + + 줄: dict[str, Any] + + +@router.put("/layers/{layer}/hopyo-ledger") +def hopyo_ledger_write( + layer: Layer, + body: LedgerBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session)) @router.delete("/layers/{layer}/hopyo/{key}") diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 1284a544c..06b15d287 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -1,11 +1,13 @@ -"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 · -수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 · -같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 · -빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더. +"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 · +몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 · +재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 · +`본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 · +master_data 원본 그대로. 층은 임시 폴더. """ import json +from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_Hopyo_Store as hs from M02_MasterTemplete import M02_Template_Layers as layers @@ -16,18 +18,14 @@ from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_has DIG = "구조물터파기(인력100%)|토사|m3" FILL = "구조물되메우기(인력100%)||m3" SOIL = "잔토처리(인력100%)||m3" -STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3" -MEMORY = "/api/m02/layers/{}/hopyo-memory" BUILD = "/api/m02/layers/{}/hopyo-build" +LEDGER = "/api/m02/layers/{}/hopyo-ledger" -def _remember(client, layer="personal", **lines): - res = client.post(MEMORY.format(layer), json={"줄": lines}) - assert res.status_code == 200, res.text - - -def _build(client, no, layer="personal", want=200, **extra): +def _build(client, no, lines=None, layer="personal", want=200, **extra): body = {"층": "personal", "키": "HP-000001", "번": no, **extra} + if lines is not None: + body["줄"] = lines res = client.post(BUILD.format(layer), json=body) assert res.status_code == want, res.text return res.json() @@ -37,22 +35,30 @@ def _doc(layer_dir, key): return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes()) -def test_만들기_별칭_수량_메모_빠진_줄_비교(client): +def _book(layer_dir): + return json.loads((layer_dir / hs.DIRNAME / f"{hs.MEMORY}.json").read_bytes())["줄"] + + +def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): before = master_hash() assert _post(client).status_code == 200 - _remember( - client, - **{ - DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, - FILL: {"로직": "GF000113", "환산": 2}, - }, - ) - got = _build(client, 3) + lines = [ + {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]}, + {"차례": 2, "대상": [{"로직": "GF000113", "배": 2}]}, + {"차례": 3, "뺌": "우리에 없음"}, + ] + got = _build(client, 3, lines) mine = layers.personal_dir(7, 42) doc = _doc(mine, got["참조"]["키"]) assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료") assert doc["상세구분"].endswith("(공통단가).xls") - assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표" + origin = doc["본뜬"] + assert (origin["층"], origin["키"], origin["원본"]) == ( + "실무자료", + "HP-000001 제3호표", + ["personal", "7/42"], + ) + assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"] assert doc["담은로직"] == [ { "로직": "GF000101", @@ -65,88 +71,167 @@ def test_만들기_별칭_수량_메모_빠진_줄_비교(client): "로직": "GF000113", "입력값": {}, "별칭": "구조물되메우기(인력100%)", - "수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2 + "수량": {"손": 0.1}, # 엑셀 0.05 × 배 2 "메모": "실무 0.05㎥ × 2", }, ] - assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [ - ("임도표지석(받침,글새김포함)", 1400000), - ("잔토처리(인력100%)", 3097.1), + assert doc["자재"] == [] # 표지석 = 몸에 없음 → 안 들어감 + assert [(x["이름"], x["금액"], x["까닭"]) for x in got["빠진줄"]] == [ + ("임도표지석(받침,글새김포함)", 1400000, "확인 안 함"), + ("잔토처리(인력100%)", 3097.1, "뺌 — 우리에 없음"), ] assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 - assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1) + assert got["다시씀"] is False and got["장부"] == {"씀": 3} + book = _book(mine) + assert book[DIG]["갈래"] == "짝" and book[DIG]["짝"] == [ + {"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1, "몫": None} + ] + assert book[DIG]["처음파일"].endswith("(공통단가).xls") + assert book[DIG]["원래"] == {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"} + assert book[SOIL]["갈래"] == "우리에 없음" and book[FILL]["짝"][0]["배"] == 2 + assert hb.confirmed(mine) == {"HP-000001 제3호표": got["참조"]["키"]} + assert hb.confirmed(mine, ("system", None)) == {} # 다른 파일 층 = 따로 assert master_hash() == before -def test_자재_같은_로직_감쌈_다시씀_새로(client): +def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): assert _post(client).status_code == 200 - _remember( - client, - **{ - DIG: {"로직": "GF000101", "환산": 1}, - SOIL: {"로직": "GF000101", "환산": 1}, - STONE: {"자재": "MT004258", "환산": 1}, - }, - ) - got = _build(client, 3) + lines = [ + {"차례": 0, "대상": [{"자재": "MT004258", "배": 1}]}, + {"차례": 1, "대상": [{"로직": "GF000101"}, {"로직": "GF000113", "배": 0.1, "몫": 0.1}]}, + {"차례": 3, "대상": [{"로직": "GF000101"}]}, + ] + got = _build(client, 3, lines) mine = layers.personal_dir(7, 42) doc = _doc(mine, got["참조"]["키"]) - first, wrapped = doc["담은로직"] - assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)" + first, second, wrapped = doc["담은로직"] + assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭 + assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사" assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09} inner = _doc(mine, wrapped["참조"]["키"]) - assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄" + assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄 GF000101" assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1} assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [ ("MT004258", {"손": 1}, "직접계산") ] + stone = next(v for k, v in _book(mine).items() if k.startswith("임도표지석(받침")) + assert stone["짝"][0]["자재"] == "MT004258" files = sorted(p.name for p in (mine / "precombo").glob("*.json")) - again = _build(client, 3) + again = _build(client, 3, lines[:1]) # 다시 확인 = 같은 본뜬 덮음 assert again["다시씀"] is True and again["참조"] == got["참조"] - assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files - _remember(client, **{SOIL: {"빼기": True}}) - fresh = _build(client, 3, 새로=True) - assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True - assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음 + assert _doc(mine, got["참조"]["키"])["담은로직"] == [] assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files -def test_일위N_참조_먼저_만듦(client): +def test_몸_없음_재검사_400(client): assert _post(client).status_code == 200 - got = _build(client, 45) + one = {"로직": "GF000101", "배": 1} + _build(client, 3, want=400) # 옛 몸(줄 없음) = 확인한 줄이 없음 + _build(client, 3, [], want=400) + bad = [ + [{"차례": 9, "대상": [one]}], # 호표 밖 줄 + [{"차례": 1, "대상": [one]}, {"차례": 1, "대상": [one]}], # 겹친 줄 + [{"차례": 1, "대상": []}], # 대상 · 뺌 없음 + [{"차례": 1, "대상": [one], "뺌": "안 씀"}], + [{"차례": 1, "대상": [{"로직": "XX"}]}], + [{"차례": 1, "대상": [{"로직": "GC000422"}]}], # 돈 아닌 로직 + [{"차례": 1, "대상": [{**one, "입력값": {"없는칸": 1}}]}], + [{"차례": 1, "대상": [{**one, "배": 0}]}], + [{"차례": 1, "대상": [{**one, "배": -1}]}], + [{"차례": 1, "대상": [{**one, "몫": 1.5}]}], + [{"차례": 1, "대상": [{**one, "자재": "MT004258"}]}], + [{"차례": 1, "대상": [{"자재": "MT000000"}]}], + [{"차례": 1, "대상": [{"호표": 3}]}], # 자기를 담음 + ] + for lines in bad: + _build(client, 3, lines, want=400) + for extra in ({"금액": 100}, {"품": {"보통인부": 1}}): # 금액 · 품 칸 = 받지 않음 + assert ( + client.post( + BUILD.format("personal"), + json={ + "층": "personal", + "키": "HP-000001", + "번": 3, + "줄": [{"차례": 1, "대상": [{**one, **extra}]}], + }, + ).status_code + == 422 + ) + assert client.post(BUILD.format("personal"), json={ + "층": "personal", "키": "HP-000001", "번": 3, "금액": 1, + "줄": [{"차례": 1, "대상": [one]}]}).status_code == 422 # fmt: skip + assert not (layers.personal_dir(7, 42) / "precombo").exists() or not list( + (layers.personal_dir(7, 42) / "precombo").glob("SC-*.json") + ) # 틀린 몸은 아무것도 안 씀 + assert client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {}}).status_code in ( + 404, + 405, + ) # 옛 즉시 확정 길 없음 + + +def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): + assert _post(client).status_code == 200 + ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}] + assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"] + child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}]) + assert any(x["까닭"].startswith("% 줄") for x in child["빠진줄"]) + got = _build(client, 45, ref) mine = layers.personal_dir(7, 42) - lines = _doc(mine, got["참조"]["키"])["담은로직"] - ref = lines[0] - assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3} - child = _doc(mine, ref["참조"]["키"]) - assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표" - assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"]) + line = _doc(mine, got["참조"]["키"])["담은로직"][0] + assert line["참조"] == child["참조"] and line["별칭"] == "고화매트재료비" + assert line["수량"] == {"손": 2.3} + assert "고화매트재료비||m2" not in _book(mine) # 호표 참조 줄 = 장부 안 씀 + whole = _build(client, 3, 통째={"대상": [{"로직": "GF000101", "배": 1}]}) + doc = _doc(mine, whole["참조"]["키"]) + assert [x["로직"] for x in doc["담은로직"]] == ["GF000101"] + assert doc["담은로직"][0]["수량"] == {"손": 1} + assert {x["까닭"] for x in whole["빠진줄"]} == {"호표 통째로 대신"} + head = next(v for k, v in _book(mine).items() if k.startswith("호표|임도표지석|")) + assert head["짝"][0]["로직"] == "GF000101" and head["원래"]["품명"] == "임도표지석" + _build(client, 3, [ref[0] | {"차례": 1}], 통째={"대상": [{"로직": "GF000101"}]}, want=400) -def test_순환은_빠진_줄(client): - folder = layers.personal_dir(7, 42) - _post(client) # 층 뼈대 - line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"} - money = {"계": 1, "노무비": 1, "재료비": None, "경비": None} - hopyos = [ - {"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [ - {**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]}, - {"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [ - {**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]}, - ] # fmt: skip - key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"] - got = _build(client, 1, 키=key) - assert got["빠진줄"] == [] - inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"]) - assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐 - - -def test_길_권한과_위_층_파일(client): +def test_길_권한과_위_층_파일_picks_확인(client): client.who.update(role="SYSTEM_ADMIN") assert _post(client, "system").status_code == 200 client.who.update(role="USER") - _build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자 - got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합 + lines = [{"차례": 1, "대상": [{"로직": "GF000101"}]}] + _build(client, 3, lines, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자 + got = _build(client, 3, lines, 층="system") # 마스터 파일 → 내 층 사전조합 assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42" - assert _build(client, 99, 층="system", want=404) + _build(client, 99, lines, 층="system", want=404) assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만 + rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] + three = next(r for r in rows if r["번"] == 3) + assert three["확인"] == {"층": "personal", "주인": "7/42", "키": got["참조"]["키"]} + assert next(r for r in rows if r["번"] == 4)["확인"] is None + + +def test_장부_길_보기_고치기_권한(client): + before = master_hash() + assert _post(client).status_code == 200 + _build(client, 3, [{"차례": 1, "대상": [{"로직": "GF000101"}]}]) + rows = client.get(LEDGER.format("personal")).json()["줄"] + assert [(r["열쇠"], r["층"], r["고침"]) for r in rows] == [(DIG, "personal", True)] + put = { + "줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}} + } + res = client.put(LEDGER.format("personal"), json=put) + assert res.status_code == 200, res.text + assert res.json()["씀"] == 2 + book = _book(layers.personal_dir(7, 42)) + assert book[DIG]["짝"][0] == {"로직": "GF000103", "입력값": {}, "배": 2, "몫": None} + assert book[DIG]["처음파일"].endswith("(공통단가).xls") # 처음파일 둠 + bad = {"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "XX"}]}}} + assert client.put(LEDGER.format("personal"), json=bad).status_code == 400 + assert client.put(LEDGER.format("personal"), json={"줄": {DIG: None}}).status_code == 200 + assert DIG not in _book(layers.personal_dir(7, 42)) + assert client.put(LEDGER.format("system"), json=put).status_code == 403 # 위 층 = 읽기만 + client.who.update(role="SYSTEM_ADMIN") + assert client.put(LEDGER.format("system"), json=put).status_code == 200 + client.who.update(role="USER") + rows = client.get(LEDGER.format("personal")).json()["줄"] + assert {(r["층"], r["고침"]) for r in rows} == {("personal", True), ("system", False)} + assert client.get(LEDGER.format("system")).json()["줄"][0]["고침"] is False + assert master_hash() == before From 33207a450ce5da7d867291f7c3a9232884554665 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:25:05 +0900 Subject: [PATCH 4/6] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20337=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=20CAD=20=EC=98=A4=ED=86=A0=EC=BA=90=EB=93=9C?= =?UTF-8?q?=20=EC=A1=B0=EC=9E=91=20=EC=84=A4=EA=B3=84=20=E2=80=94=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=ED=8B=80=20=EC=95=BD=EC=86=8D=20=C2=B7=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=2031=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8?= =?UTF-8?q?=20=EC=82=AC=EC=A0=84=20=C2=B7=20=ED=9D=A0=2023=20=C2=B7=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=C2=B7=20=ED=9C=A0=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BD=20=EB=8B=A4=EC=84=AF=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../2026-10-15_337_CAD오토캐드조작_설계.md | 413 ++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md diff --git a/docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md b/docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md new file mode 100644 index 000000000..159a7f7f1 --- /dev/null +++ b/docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md @@ -0,0 +1,413 @@ +# 요구 229-3 내장 CAD — 오토캐드와 같은 명령 · 옵션 조작 설계 (PA3) + +- 기준 = `main_laptop_1` 8a4598c05(CAD 코드는 335 조사 기준 9c0ef7f82 와 바뀐 줄 0 · 줄 번호 그대로) · 코드 읽기 + 오토캐드 한국어 도움말(2025 KOR) 대조 · 저장소 고침 없음. +- 재료 = `docs/raw/조사/2026-10-15_335_다시조사_오토캐드마우스키보드.md`(대조 146 · 흠 30) · 부록4(K1 공식 · K2 우리 코드) · `2026-10-15_335_CAD입력보강_검토.md`(H1~H12). +- 줄임 — `src/` = `B07_DesignDetail/openwebcad/src/` · `ic/` = `src/inputController/` · `tl/` = `src/tools/` · `T/` = `resources/tester/cad/`. +- 근거 표 — [공] 한국어 도움말에 그 줄 그대로 · [이] 한국어 도움말 옵션 이름 + 영문 공식 줄 꼴로 엮음 · [옮] 영문 공식 줄 옮김(한국어판 이름 못 봄) · [기] 기억(공식 쪽 없음 · 브레인 화면 확인 거리). + +## 0. 요약 + +1. 바닥 셋을 새로 깖 — ① 친 글 처리 한 곳(`ic/prompt-input.ts` 사다리) ② 물음 선언 한 꼴(`src/commands/prompt.ts` 의 PromptSpec · 옵션 사건 `OPTION.<키>`) ③ 명령 끝 = 도구 머신 final 상태 `END` → 쉬는 상태(새 선택 도구)로 바꿔 끼움. +2. 따라 나오는 동작 — 점 · 옵션 묻는 중 명령 이름 거절(「점 또는 옵션 키워드를 요구함.」) · 앞글자 실행 없음 · 대소문자 무관 · Esc 한 번 = 명령 끝 · 빈 Enter = 명령별 뜻(기본값 · 기본 옵션 · 변위 · 끝) · 우클릭 = Enter(쉴 때 반복) · 기본값 세션 기억 · 한 번 그리는 명령(원 · 사각형 · 호 …)은 하나 뒤 끝(오토캐드처럼 Space 로 다시). +3. 결론으로 바뀌는 지금 동작 하나 — 지금은 선 · 원 · 사각형 등이 끝나도 같은 명령 첫 물음으로 돌아감 → 「점 묻는 중 명령 이름 거절」과 맞부딪힘(원 그린 뒤 L 이 안 먹음) → 오토캐드 「끝난 뒤」 열(1 장)대로 끝냄. +4. 명령 31 사전(1 장) — 물음 차례 · 옵션 글자 · 기본값 · 빈 Enter · Esc · 끝난 뒤 · 지금과 다른 곳 · 이번 넣을 옵션 / 뺄 옵션(뺀 옵션은 [ ] 에 안 그림 · 치면 「이 CAD 에 없음」 안내). +5. 흠 30 중 이번 고침 23(H1 · H2 일부 · H3 · H4 · H5 · H7 · H8 · H9 · H11 · N1~N11 · N14 · N17 · N18) + 새 흠 하나(3 번) · 뺌 7(H6 · H10 · H12 · N12 · N13 · N15 · N16) + H2 나머지. +6. 마우스 · 키 넣음 — 고르기 더함 · Shift 뺌 · 명령 중 끌기 상자 · 그립 누르고-옮기고-누르기 · 그립 숫자 · 직교 강제 + Shift 임시 직교 · 휠 밂 = 확대(기본) · 휠 두 번 = 범위 · 우클릭 반복 · Shift+우클릭 스냅 메뉴 · Ctrl+C/X/Shift+C/Shift+V/1 · ↑↓ 최근 값 · ZOOM 옵션(E · A · W · P · nX). +7. 창 다섯 — 가(틀 · 키보드) 먼저 → 나(그리기 12) · 다(고치기 · 재기 · 치수 12) · 라(옮기기 무리 7) 동시 · 마(마우스 · 고르기 · 그립 · 휠 · 보기)는 처음부터 동시 · 겹치는 파일은 줄 몫으로 나눔(5 장). +8. 시험 — 공용 조작 도우미 `T/helpers/cad-drive.ts` + 창마다 새 시험 하나 + 계약 시험(옵션 선언 ↔ 처리 · ESC 맞대기) · 완료 = 시험 · 타입 검사 둘 · CAD 빌드. +9. 브레인 화면 확인 거리 — PLINE Esc 남김 · SPLINE Esc 버림 · ARRAY · TEXT Esc · 한국어판 줄 꼴 · 휠 · 그립 · 스냅 메뉴([기] 줄). + +브레인 정함 → 이 설계 자리: + +| 정함 | 자리 | +|---|---| +| 앞글자 실행 없앰 · 별칭 acad.pgp | 2-5 ⑨ · 2-17 · N18 | +| 대소문자 무관(글 물음만 원문) | 2-4 · N6 | +| Esc = 명령 끝 | 2-7 · 2-8 · 1 장 Esc 열 · N2 · N3 · N4 | +| 점 · 옵션 묻는 중 명령 이름 거절 | 2-5 ⑩ · 2-10 · N1 · H7 | +| 옵션 글자 = 오토캐드 글자 · 빈 Enter 명령별 | 1 장 · 2-6 | +| 기본값 기억(세션) | 2-9 | +| 우클릭 · 고르기 · 그립 · 직교 · 휠 두 번 · 휠 밂 | 4 장 · H3 · H4 · H5 · H8 | +| 식 = 숫자 칸 거절('CAL 밖) | N5 · 7 장 | + +## 1. 프롬프트 사전(명령마다) + +- 줄 꼴 = 「글 또는 [이름(키)/…] <기본값>:」 한 꼴(`renderPrompt` 가 붙임) · 한국어판은 「호의 시작점 또는 [중심(C)] 지정:」처럼 「지정」이 뒤로 가는 줄도 있음[기] → 우리 한 꼴로 맞춤. +- 옵션 적는 꼴 = `닫기(C)·CLOSE` = 한국어 이름(키 글자)·영문 키워드(사건 `OPTION.CLOSE` 의 키도 이 영문) · 「뺌:」 뒤 = 오토캐드엔 있고 이번엔 안 넣음(`absent` 에 둠). +- 기억 = 앞에 쓴 값이 다음 `<기본값>`(세션) · 괄호 안 = 열쇠 이름(오토캐드 변수) · 처음 값. +- 끝난 뒤 — 한 번 = 하나 뒤 명령 끝(쉬는 상태 · Space · Enter · 우클릭 = 같은 명령 다시) · 되풀이 = 빈 Enter · Esc 까지 그 물음 되풀이. +- 지금 = K2(부록4 E 표) 우리 물음 · 동작. + +### 1-1. 그리기 · 글자 · 해치(나) + +| 명령 | 물음 차례 | 옵션(넣음 · 뺌) | 빈 Enter · 기본값 | Esc · 끝난 뒤 | 지금과 다른 곳 | +|---|---|---|---|---|---| +| LINE(L) | 「첫 번째 점 지정:」 → 「다음 점 지정 또는 [명령 취소(U)]:」 → 둘째 토막부터 「다음 점 지정 또는 [닫기(C)/명령 취소(U)]:」 되풀이 [이] | 명령 취소(U)·UNDO = 마지막 토막 지움(되돌림 한 칸 `undo()` · 새 되돌림 안 쌓음) · 닫기(C)·CLOSE = 첫 점으로 잇고 끝 | 첫 점 = 마지막 선 · 폴리선 · 호 끝점에서 시작[공](기억 LINE.END) · 다음 점 = 끝 | 끝(그린 토막 남음) · 되풀이 | Enter · Esc = 새 첫 점(안 끝남) · 첫 토막에도 [닫기] 보임 · 첫 점 Enter 없음 · 글 「첫 점 지정 (x,y):」 | +| PLINE(PL) | 「시작점 지정:」 → 「다음 점 지정 또는 [호(A)/길이(L)/명령 취소(U)]:」 → 둘째 토막부터 [호(A)/닫기(C)/길이(L)/명령 취소(U)] 되풀이 · 호 모드 「호의 끝점 지정 또는 [닫기(CL)/선(L)/명령 취소(U)]:」 되풀이 [이] | 호(A)·ARC = 접선 호 모드(첫 토막이 호면 접선 동쪽 0° · 앞 토막 있으면 그 끝 방향) · 닫기(C)·CLOSE · 길이(L)·LENGTH → 「선의 길이 지정:」[옮](앞 토막 방향 · 호 뒤면 접선) · 명령 취소(U)·UNDO = 마지막 토막 · 호 모드 닫기(CL)·CLOSE · 선(L)·LINE · 명령 취소(U) · 뺌: 반폭(H) · 폭(W) · 호 각도(A) · 중심(CE) · 방향(D) · 반지름(R) · 두 번째 점(S) | 시작점 = 마지막 끝점에서[공](LINE.END) · 다음 점 = 끝(확정) | 끝(그린 데까지 폴리선 남김[기]) · 되풀이 | 옵션 0 · C = 원으로 바뀌며 버림 · U = UNDO · Esc = 다 버리고 다시 · 끝나면 다시 시작점 | +| RECTANG(REC) | 「첫 번째 구석점 지정:」(오토캐드 = 「…또는 [모따기(C)/고도(E)/모깎기(F)/두께(T)/폭(W)]:」[공]) → 「다른 구석점 지정 또는 [치수(D)]:」 [이] | 치수(D)·DIMENSIONS → 「직사각형의 길이 지정 <10.0000>:」 → 「직사각형의 폭 지정 <10.0000>:」[옮] → 다시 「다른 구석점 지정 또는 [치수(D)]:」(누른 쪽 사분면으로 놓음) · 뺌: 모따기(C) · 고도(E) · 모깎기(F) · 두께(T) · 폭(W) · 면적(A) · 회전(R) | 길이 · 폭 기억(RECT.L · RECT.W · 10) | 끝 · 한 번 | 하나 뒤 다시 첫 구석 · 첫 구석 Esc 없음(N3) · D 없음 | +| CIRCLE(C) | 「원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:」 → 「원의 반지름 지정 또는 [지름(D)] :」 [이 · 첫 줄 한국어 꼴 기] | 3점(3P)·3P → 「원 위의 첫 번째 점 지정:」 → 「…두 번째 점 지정:」 → 「…세 번째 점 지정:」 · 2점(2P)·2P → 「원 지름의 첫 번째 끝점 지정:」 → 「…두 번째 끝점 지정:」[옮] · 지름(D)·DIAMETER → 「원의 지름 지정 <2r>:」 · 뺌: Ttr - 접선 접선 반지름(T) | 반지름 기억(CIRCLERAD · 처음 없음 → `<>` 안 그림 · Enter = 아무 일 없음) · 누른 점 반지름도 기억 | 끝 · 한 번 | 하나 뒤 다시 중심 · 3P · 2P · D · 기억 없음 · 글 「원 위의 점 지정 또는 반지름 입력:」 | +| ARC(A) | 「호의 시작점 지정:」 → 「호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:」 → 「호의 끝점 지정:」 [이] | 중심(C)·CENTER → 「호의 중심점 지정:」 → 「호의 끝점 지정 또는 [각도(A)]:」 · 각도(A)·ANGLE → 「사이각 지정:」(+ 반시계) · 끝(E)·END → 「호의 끝점 지정:」 → 「호의 중심점 지정 또는 [반지름(R)]:」 · 반지름(R)·RADIUS → 「호의 반지름 지정:」 · 뺌: 시작 단계 중심(C) · 현의 길이(L) · 방향(D) · E 뒤 각도(A) | 시작점 Enter(마지막 선 · 호 끝 접선 이음[공]) = 뺌 → 아무 일 없음 | 끝 · 한 번 | 세 점만 · 하나 뒤 다시 | +| POLYGON(POL) | 「변의 수 입력 <4>:」 → 「폴리곤의 중심 지정 또는 [모서리(E)]:」 → 「옵션 입력 [원에 내접(I)/원에 외접(C)] :」 → 「원의 반지름 지정:」 [이] | 모서리(E)·EDGE → 「모서리의 첫 번째 끝점 지정:」 → 「모서리의 두 번째 끝점 지정:」[옮] · 원에 내접(I)·INSCRIBED · 원에 외접(C)·CIRCUMSCRIBED | 변 수 기억(POLYSIDES · 4 · 3~1024 정수) · 내접/외접 기억(POLYGON.IC · I) · 반지름 숫자 = 아래 변 수평 · 점 = 크기 + 돌림 | 끝 · 한 번 | 면 수 <6> 상수 · 내접만 · E 없음 · 글 「면의 수를 입력하십시오 <6>.」 | +| ELLIPSE(EL) | 「타원의 축 끝점 지정 또는 [중심(C)]:」 → 「축의 다른 끝점 지정:」 → 「다른 축으로 거리를 지정 또는 [회전(R)]:」 [이] | 중심(C)·CENTER → 「타원의 중심 지정:」 → 「축의 끝점 지정:」 → 같은 거리 물음 · 회전(R)·ROTATION → 「주축 둘레로 회전 지정:」[옮] · 뺌: 호(A) | — | 끝 · 한 번 | 중심 방식만 · 셋째 숫자 ≠ 단축 반지름(N10) · 하나 뒤 다시 | +| SPLINE(SPL) | 「첫 번째 점 지정:」 → 「다음 점 입력:」 → 「다음 점 입력 또는 [명령 취소(U)]:」 → 셋째 점부터 [명령 취소(U)/닫기(C)] 되풀이 [이] | 명령 취소(U)·UNDO = 마지막 점 · 닫기(C)·CLOSE = 닫고 끝 · 뺌: 방법(M) · 매듭(K) · 객체(O) · 시작/끝 접촉부(T) · 공차(L) | 다음 점 = 끝(확정) | 버림[기] · 끝 | 점만 · 끝나면 다시 | +| XLINE(XL) | 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 [이] | 수평(H)·HOR · 수직(V)·VER → 「통과점을 지정:」 되풀이 · 각도(A)·ANG → 「X선의 각도 입력 <0>:」 → 「통과점을 지정:」 되풀이 · 뺌: 이등분(B) · 간격띄우기(O) · 각도 참조(R) | 통과점 = 끝 · 각도 기억(XLINE.ANGLE · 0) | 끝(그은 선 남음) · 되풀이 | 한 줄 뒤 처음부터 · 옵션 없음 | +| TEXT(DT) | 「문자의 시작점 지정:」 → 「높이 지정 :」 → 「문자의 회전 각도 지정 <0>:」 → 글 줄(커서 곁 글 칸 · Enter = 아래 새 줄 · 빈 줄 Enter = 끝) [이] | 뺌: 자리맞추기(J) · 스타일(S) | 시작점 = 직전 명령도 TEXT 면 앞 글 아래 · 높이 · 각 안 물음[공] · 높이 기억(TEXTSIZE · 처음 지금 글자 크기) · 각 기억(TEXT.ANGLE · 0) | 끝(마친 줄 남음 · 치던 줄 버림[기]) | 시작점 → 한 줄 · 높이 · 각 안 물음 · 줄 이어 치기 없음 | +| MTEXT(T · MT) | 「첫 번째 구석 지정:」 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 글(편집기 대신 커서 곁 글 칸 · `\P` 줄바꿈 그대로) [이] | 높이(H)·HEIGHT → 「높이 지정 :」 → 다시 반대 구석 · 폭(W)·WIDTH → 「폭 지정:」(점 · 숫자) → 글 · 뺌: 자리맞추기(J) · 행 간격(L) · 회전(R) · 스타일(S) · 열(C) | 높이 = TEXTSIZE | 끝(저장 없이[공]) | 반대 구석 없음 | +| HATCH(H · BH) | 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 [이] | 내부 점 = 그 점을 품은 닫힌 객체(닫힌 폴리선 · 사각형 · 원) 중 가장 작은 하나 · 객체 선택(S)·SELECT → 「객체 선택:」(지금 방식) · 명령 취소(U)·UNDO = 마지막 경계 · 뺌: 경계 제거(B) · 설정(T) · 그리기(W) | 빈 Enter = 확정 · 끝 | 끝(안 만듦) | 경계 객체 고름만 | + +- POINT · DONUT · RAY 등 31 밖 그리기 = 엔진 바뀜만 따름(한 번 뒤 끝) · 오토캐드 되풀이 명령은 나가 `loopFrom` 둠 — DONUT(중심 되풀이) · RAY(통과점 되풀이) · MLINE · WIPEOUT(되풀이 단계 그대로 · Enter 확정 뒤 끝). + +### 1-2. 고치기 · 재기 · 치수(다) + +| 명령 | 물음 차례 | 옵션(넣음 · 뺌) | 빈 Enter · 기본값 | Esc · 끝난 뒤 | 지금과 다른 곳 | +|---|---|---|---|---|---| +| OFFSET(O) | 「간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:」 → 「간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:」 → 「간격띄우기할 면의 점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:」 → 객체 선택으로 되풀이 [이] | 통과점(T)·THROUGH → 객체 → 「통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:」 · 지우기(E)·ERASE → 「간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:」[옮] · 종료(E)·EXIT · 다중(M)·MULTIPLE = 같은 객체로 쪽 되풀이 · 명령 취소(U)·UNDO = 이 명령 안 마지막 하나 · 뺌: 도면층(L) · 두 점으로 거리 | 거리 기억(OFFSETDIST · 처음 <통과점> = 숫자 대신 통과점 방식) · 객체 · 면 = <종료> | 끝(만든 것 남음) · 되풀이 | 거리 먼저 <1> 상수 · 한 번 뒤 끝 · 옵션 없음 | +| FILLET(F) | 「첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:」 → 「두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [반지름(R)]:」 [이 · Shift 문구 공] | 반지름(R)·RADIUS → 「모깎기 반지름 지정 :」[옮] → 같은 물음 · 자르기(T)·TRIM → 「자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르기>:」[이] · 다중(M)·MULTIPLE = 쌍 되풀이 · 명령 취소(U)·UNDO = 다중 중 앞 쌍 · Shift+둘째 누름 = 반지름 0 · 뺌: 폴리선(P) | 반지름 기억(FILLETRAD · 0) · 자르기 기억(TRIMMODE · 자르기) | 끝 · 한 번(다중이면 되풀이 · 빈 Enter 끝) | 반지름 먼저 <0> 상수 · 옵션 · Shift 없음 | +| CHAMFER(CHA) | 「첫 번째 선 선택 또는 [명령 취소(U)/거리(D)/자르기(T)/다중(M)]:」 → 「두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [거리(D)]:」 [이] | 거리(D)·DISTANCE → 「첫 번째 모따기 거리 지정 :」 → 「두 번째 모따기 거리 지정 :」[옮] · 자르기(T) · 다중(M) · 명령 취소(U) · Shift = 거리 0 · 뺌: 폴리선(P) · 각도(A) · 방법(E) | 거리 기억(CHAMFERA · CHAMFERB · 0 · 둘째 기본 = 첫째) | 끝 · 한 번(다중 되풀이) | 거리 둘 먼저 <1> | +| TRIM(TR) | 「자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:」 되풀이 [이 · 빠른 모드] | 지우기(R)·ERASE → 「지울 객체 선택:」(고르기 · Enter = 지우고 돌아옴)[옮] · 명령 취소(U)·UNDO(숨김) · Shift+누름 = 연장 · 뺌: 절단 모서리(T) · 걸치기(C) · 모드(O) · 투영(P) · 끌기 경로 | 빈 Enter = 끝 | 끝(자른 것 남음) · 되풀이 | Enter · Esc 처리 없음(N3) · 멀리 눌러도 자름(N9) · Shift 없음 | +| EXTEND(EX) | 「연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기:」 되풀이 [이 · 빠른 모드] | 누른 쪽 끝을 가장 가까운 경계(모든 객체)까지 · Shift+누름 = 자르기 · 명령 취소(U)(숨김) · 뺌: 경계 모서리(B) · 걸치기(C) · 모드(O) · 투영(P) | 빈 Enter = 끝 | 끝 · 되풀이 | 경계 먼저 고름 → 선 하나 뒤 끝 | +| BREAK(BR) | 「객체 선택:」(누른 점 = 첫 끊기점) → 「두 번째 끊기점 지정 또는 [첫 번째 점(F)]:」 [이] | 첫 번째 점(F)·FIRST → 「첫 번째 끊기점 지정:」 → 「두 번째 끊기점 지정:」[옮] · `@` = 같은 점(틈 없이 둘로) | — | 끝 · 한 번 | 객체 → 첫 점 → 둘째 점(세 번 누름) | +| JOIN(J) | 「한 번에 결합할 원본 객체 또는 여러 객체 선택:」[공](고르기 물음) | — | 빈 Enter = 결합 | 끝 · 한 번 | 글만 다름 | +| LENGTHEN(LEN) | 「측정할 객체 또는 [증분(DE)/퍼센트(P)/전체(T)] 선택 <전체(T)>:」 → (값) → 「변경할 객체 선택 또는 [명령 취소(U)]:」 되풀이 [이] | 객체 누름 = 「현재 길이: 12.3456」 알림 뒤 같은 물음 · 증분(DE)·DELTA → 「증분 길이 입력 <0.0000>:」 · 퍼센트(P)·PERCENT → 「퍼센트 길이 입력 <100.0000>:」 · 전체(T)·TOTAL → 「전체 길이 지정 <1.0000>:」[옮] · 명령 취소(U) · 뺌: 동적(DY) · 각도(A) | 첫 물음 = 마지막 방식(LENGTHEN.MODE · 전체) · 값 = 방식별 기억 | 끝 · 되풀이 | 객체 먼저 → 증분 <10> · 한 번 뒤 끝 · 선만 | +| DIVIDE(DIV) | 「등분할 객체 선택:」 → 「세그먼트 수 입력:」 [이] | 뺌: 블록(B) | 기본 없음(2~32767 정수) | 끝 · 한 번 | <4> 기본 · 정수 검사 없음 | +| MEASURE(ME) | 「길이 분할 객체 선택:」 → 「세그먼트의 길이 지정:」 [이] | 누른 자리 가까운 끝부터[공] · 뺌: 블록(B) | 기본 없음(> 0) | 끝 · 한 번 | <10> 기본 | +| DIST(DI) | 「첫 번째 점 지정:」 → 「두 번째 점 지정:」 → 기록 · 알림 「거리 = d, XY 평면에서의 각도 = a, XY 평면으로부터의 각도 = 0 / X 증분 = dx, Y 증분 = dy, Z 증분 = 0.0000」[옮] | 뺌: 다중 점(M) | — | 끝 · 한 번 | 결과 글 꼴 · 끝나면 다시 | +| DIMLINEAR(DLI) · DIMALIGNED(DAL) | 「첫 번째 치수보조선 원점 지정 또는 <객체 선택>:」 → 「두 번째 치수보조선 원점 지정:」 → 「치수선의 위치 지정 또는 [문자(T)/수평(H)/수직(V)]:」(정렬 = [문자(T)]) → 기록 「치수 문자 = 100」 [이] | 첫 물음 빈 Enter → 「치수 기입할 객체 선택:」(선 · 폴리선 토막 양 끝) · 문자(T)·TEXT → 「치수 문자 입력 <100>:」(`<>` = 잰 값)[옮] → 같은 물음 · 수평(H)·HORIZONTAL · 수직(V)·VERTICAL = 그 방향 고정 → 같은 물음 · 뺌: 여러 줄 문자(M) · 각도(A) · 회전됨(R) | 첫 물음 <객체 선택> | 끝 · 한 번 | 점만 · 객체 고름 · T · H · V 없음 · 하나 뒤 다시 | +| DIMCONTINUE(DCO) · DIMBASELINE(DBA) | 「두 번째 치수보조선 원점 지정 또는 [명령 취소(U)]:」 되풀이 [옮] | 명령 취소(U) = 이 명령 안 마지막 치수 · 뺌: 선택(S) | 빈 Enter = 끝 | 끝 · 되풀이 | 한 번 | +| DIMRADIUS(DRA) · DIMDIAMETER(DDI) | 「호 또는 원 선택:」 → 「치수선의 위치 지정 또는 [문자(T)]:」 [이] | 문자(T)·TEXT · 뺌: 여러 줄 문자(M) · 각도(A) | — | 끝 · 한 번 | 글 | + +- DIM · DIMANGULAR · DIMORDINATE · DIMARC · QDIM = 이번엔 물음 글 · 끝 · Esc 규칙만(옵션 뺌). + +### 1-3. 옮기기 무리(라) + +| 명령 | 물음 차례 | 옵션(넣음 · 뺌) | 빈 Enter · 기본값 | Esc · 끝난 뒤 | 지금과 다른 곳 | +|---|---|---|---|---|---| +| MOVE(M) | 「객체 선택:」(고른 것 있으면 건너뜀) → 「기준점 지정 또는 [변위(D)] <변위>:」 → 「두 번째 점 지정 또는 <첫 번째 점을 변위로 사용>:」 [이 · 둘째 줄 공] | 변위(D)·DISPLACEMENT → 「변위 지정 <0.0000, 0.0000>:」[옮](좌표 = 변위) | 기준점 = <변위> · 두 번째 점 = 첫 점 좌표가 변위[공] · 변위 기억(MOVE.DISP) | 제자리 · 끝 · 한 번 | D · Enter 변위 없음 · Esc 안 끝남(N2) · 한 번 뒤 고르기로 · 딴 명령 = 사라짐(N1) | +| COPY(CO · CP) | 「객체 선택:」 → 「기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:」[공] → 「두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:」[공] → 첫 사본 뒤 「두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:」 되풀이[옮] | 모드(O)·MODE → 「복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:」[이] · 다중(M)·MULTIPLE = 이번만 되풀이(모드 단일 때만 보임) · 배열(A)·ARRAY → 「배열할 항목 수 입력:」 → 「두 번째 점 지정 또는 [맞춤(F)]:」[이] · 종료(E)·EXIT · 명령 취소(U)·UNDO | 첫 둘째 점 = 변위 · 다음 = <종료> · 모드 기억(COPYMODE · 다중) | 끝(만든 사본 남음) · 되풀이 | Enter 로 못 끝냄 · D · O · A · U 없음 | +| ROTATE(RO) | 「객체 선택:」 → 「기준점 지정:」 → 「회전 각도 지정 또는 [복사(C)/참조(R)] <0>:」 [이] | 누름 = 기준점 → 누른 점 각(한 번) · 숫자 = 반시계 + · 복사(C)·COPY → 기록 「선택한 객체의 사본을 회전함.」(오토캐드 글 [기] · 명사형으로) → 같은 물음 · 참조(R)·REFERENCE → 「참조 각도를 지정 <0>:」(숫자 · 두 점 · `@` = 기준점) → 「새 각도 지정 또는 [점(P)] <0>:」[옮] · 점(P)·POINTS → 두 점 | 각 기억(ROTATE.ANGLE · 0) | 제자리 · 끝 · 한 번 | 누름 = 기준선 끝 → 방향 점(두 번 · 차례 다름) · C · R 없음 | +| SCALE(SC) | 「객체 선택:」 → 「기준점 지정:」 → 「축척 비율 지정 또는 [복사(C)/참조(R)] <1.0000>:」 [이] | 누름 = 기준점~점 거리 · 숫자 > 0 · 복사(C) · 참조(R) → 「참조 길이 지정 <1.0000>:」(숫자 · 두 점) → 「새 길이 지정 또는 [점(P)] <1.0000>:」[옮] | 비율 기억(SCALE.FACTOR · 1) | 제자리 · 끝 · 한 번 | 누름 = 기준선 끝 → 새 길이 끝(두 번) · C 없음 · 0 이하 조용히 버림 | +| STRETCH(S) | 기록 「걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...」 → 「객체 선택:」 → 「기준점 지정 또는 [변위(D)] <변위>:」 → 「두 번째 점 지정 또는 <첫 번째 점을 변위로 사용>:」 [이] | 변위(D) = MOVE 와 같음 · 걸침 창 안 꼭짓점만 옮김 · 창 안에 통째 든 것 = 이동[공 뜻] | MOVE 와 같음(STRETCH.DISP) | 제자리 · 끝 · 한 번 | 선 = 기준점 가까운 끝만 · 그 밖 통째 | +| MIRROR(MI) | 「객체 선택:」 → 「대칭선의 첫 번째 점 지정:」 → 「대칭선의 두 번째 점 지정:」 → 「원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:」 [이] | 예(Y)·YES · 아니오(N)·NO | <아니오>(늘) | 끝(안 만듦) · 한 번 | Y/N 물음 없음 | +| ARRAY(AR) | 「객체 선택:」 → 「배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:」 [이] → 직사각형: 미리보기 4열 × 3행 → 「그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:」 되풀이 · 원형: 「배열의 중심점 지정:」 → 미리보기 6개 360° → 「그립을 선택하여 배열을 편집하거나 [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:」 되풀이 [이] | 개수(COU)·COUNT → 「열 수 입력 <4>:」 → 「행 수 입력 <3>:」 · 간격두기(S)·SPACING → 「열 사이의 거리 지정 :」 → 「행 사이의 거리 지정 :」 · 열(COL)·COLUMNS → 열 수 → 열 거리 · 행(R)·ROWS → 행 수 → 행 거리 · 항목(I)·ITEMS → 「배열의 항목 수 입력 <6>:」 · 사이의 각도(A)·ANGLE → 「항목 사이의 각도 지정 <60>:」 · 채울 각도(F)·FILL → 「채울 각도 지정(+=ccw, -=cw) <360>:」 · 항목 회전(ROT)·ROTATE → 「배열된 항목을 회전하시겠습니까? [예(Y)/아니오(N)] <예>:」[옮] · 종료(X)·EXIT · 뺌: 경로(PA) · 연관(AS) · 기준점(B) · 수준(L) · 회전축(A) · 표현식 | 유형 기억(ARRAYTYPE · 직사각형) · 처음 간격 = 고른 것 범위 폭 · 높이의 1.5 배[기] · 편집 = <종료> → 만듦 | 안 만듦[기] · 끝 · 한 번 | [직선(L)/원형(R)](R = 원형 · 오토캐드 R = 직사각형) · 간격 두 점 · 한 칸 각 · Esc 일부 없음 · `x,y` 면 복사 0(N17) | + +- 고른 것 소비 때 `rememberSelection(ids)`(고르기 이전(P) 열쇠) · 고른 것 없이 고르기 Enter = 명령 끝(오토캐드 같음). + +### 1-4. 고르기 · 그립 · 보기 · 점 물음 보조(마 · 가-2) + +| 물음 | 차례 | 옵션 · 손짓 | 빈 Enter | Esc | 지금과 다른 곳 | +|---|---|---|---|---|---| +| 고르기(모든 명령) | 「객체 선택:」 되풀이 [공] | 숨은 옵션 전체(ALL)·ALL · 최종(L)·LAST · 이전(P)·PREVIOUS · 누름 = 더함 · Shift+누름 = 뺌 · 빈 곳 = 창(왼→오) · 걸침(오→왼) · 클릭-클릭 · 끌기 둘 다 · 「1개를 찾음, 총 3개」 알림 · 뺌: W · C · F · WP · CP · BOX · G · A · R · M · U · AU · SI | 고르기 끝(없으면 명령 끝) | 명령 끝 | 누름 = 바꿈 · 명령 안 끌기 상자 ○ · P = 화면 이동 안내 · L = LINE | +| 그립 | 그립 누름(뜨거움) → 「** 신축 ** 신축점 지정 또는 [종료(X)]:」[기] | 점 · 좌표 · 거리 숫자 · Tab 칸 · 종료(X)·EXIT · 뺌: 기준점(B) · 복사(C) · 명령 취소(U) · Enter/Space 모드 돌림 | — | 그립 놓음 | 누른 채 끌기만 · 안 끌고 떼도 적용(H3) · 숫자 무시(N8) | +| ZOOM(Z) | 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」[이 · 기] | 전체(A)·ALL = 범위 · 범위(E)·EXTENTS · 이전(P)·PREVIOUS = 앞 화면(ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만 기억) · 윈도우(W)·WINDOW → 「첫 번째 구석 지정:」 → 「반대 구석 지정:」 · 점 누름 = 윈도우 첫 구석 · `2X` = 지금 배율 × 2 · 뺌: 중심(C) · 동적(D) · 축척(S) · 객체(O) · 실시간 | <실시간> = 「실시간 줌은 휠로」 알림 · 닫음 | 닫음 | Z = 바로 범위(Z E 손버릇 = 범위 뒤 E → ERASE 로 이어짐) | +| 점 물음 보조(모든 점 물음 · 가-2) | 스냅 이름 → 「끝점(END) 한 번 — 점 지정」(지금 꼴) · FROM → 「기준점:」 → 「<간격띄우기>:」 · M2P · MTP → 「중간점의 첫 번째 점:」 → 「중간점의 두 번째 점:」[옮] | 스냅 이름 = 앞 세 글자~영문 전체(END · ENDP · ENDPOINT …) · 넣음: END MID CEN QUA INT PER NEA NOD NON(NONE) · 「이 CAD 에 없는 스냅」 안내: TAN EXT INS APP PAR GCE · `@` = 마지막 점(LASTPOINT) · 첫 점 `@dx,dy` = 마지막 점 기준 · `#x,y` · `*x,y` = 절대 | — | 보조 풂 + 명령 끝 | 일곱만 · EXT · INS = 명령이 돎(N14) · FROM · M2P · `@` · `#` 버림 | + +## 2. 옵션 틀 약속(다른 창이 이대로 고침) + +### 2-1. 흐름 + +키 · 마우스 → `InputController`(키 쪽 = 가 · 마우스 쪽 = 마) → `prompt-input.routeTyped`(사다리 한 곳) → 사건(`MOUSE_CLICK` · `ABSOLUTE_POINT_INPUT` · `RELATIVE_POINT_INPUT` · `NUMBER_INPUT` · `TEXT_INPUT` · `OPTION.<키>` · `ENTER` · `ESC` · `DRAW`) → 도구 머신(상태 `meta` 에 물음 선언) → `END`(final) → `end-command.ts` 가 쉬는 상태로. + +### 2-2. 파일 · 내보내는 이름 + +| 파일 | 몫 | 내보냄 | +|---|---|---| +| `src/commands/prompt.ts`(새 · 가) | 물음 꼴 · 그림 · 옵션 맞추기 · 기억 · 공용 옵션 | `OptionSpec` · `DefaultSpec` · `PromptSpec` · `PromptLike` · `renderPrompt` · `matchOption` · `remember` · `recall` · `rememberSelection` · `previousSelection` · `rememberPoint` · `lastPoint` · `OPT` · `SELECT_OBJECTS` | +| `ic/prompt-input.ts`(새 · 가) | 친 글 사다리 · 거절 · 알림 줄 · 덧물음 · 쉼 판정 · 명령 끊기 | `currentPrompt` · `routeTyped` · `isIdle` · `cancelActiveCommand` · `undoCommand` · `redoCommand` · `notice` · `currentNotice` · `pushSubPrompt` · `subPrompt` · `closeSubPrompt` · `SubPrompt` · `SubResult` | +| `tl/factories/end-command.ts`(새 · 가) | `END` 받으면 정리 → 새 선택 도구(동기) | 없음(불러오면 갈고리 붙음 · `registry.ts` 가 불러옴) | +| `tl/tool.types.ts`(고침 · 가) | 사건 · 이름 | `OptionEvent` · `optionKey` · `END_STATE` · `StepKind` · `MouseClickEvent.snapped?` | +| `tl/factories/sequence-tool.ts`(고침 · 가) | 순서 엔진 v2 | 2-13 | +| `ic/input-history.ts`(새 · 가-2) | ↑↓ 명령 기록 · 물음별 최근 값 20 | `recallInput(dir, text)` · `pushInput(text)` | +| `ic/point-modifiers.ts`(새 · 가-2) | FROM · M2P · `@` · `#` · `*` · 스냅 이름 늘림 | `tryPointModifier(T)` | +| `T/helpers/cad-drive.ts`(새 · 가) | 시험 공용 조작 | 6-2 | + +### 2-3. 물음 꼴(타입 · 가가 이대로 만듦) + +```ts +type XY = { x: number; y: number }; +interface OptionSpec { key: string; kw: string; en: string; label: string; hidden?: true } +type DefaultSpec = + | { kind: 'value'; remember: string; initial: number | XY | null; format?: 'len' | 'int' | 'ang' } + | { kind: 'option'; key: string; label?: string } // <변위> · <전체(T)>(label 로 바꿈) + | { kind: 'enter'; label: string }; // <첫 번째 점을 변위로 사용> · <종료> · <객체 선택> +interface PromptSpec { + text: string; // '다음 점 지정' — ' 또는 [..]' · ' <..>' · ':' 는 renderPrompt 가 붙임 + options?: OptionSpec[]; // 넣은 옵션(숨김 포함) + absent?: OptionSpec[]; // 오토캐드엔 있고 우리엔 없음 — 치면 안내 · 안 그림 + default?: DefaultSpec; + number?: { int?: true; min?: number; max?: number; positive?: true }; + reject?: string; // 거절 글 바꿈(없으면 2-10) + history?: string; // ↑↓ 최근 값 묶음(없으면 text) +} +type PromptLike = PromptSpec | ((context: any) => PromptSpec); // 지금 상태 context 로 그림 +// 상태 meta 꼴: { kind?: StepKind; prompt?: PromptLike; instructions?: string /* 옛 꼴 */ } +// StepKind = 'point' | 'number' | 'text' | 'entity' | 'selection' | 'keyword' +interface OptionEvent extends EventObject { type: `OPTION.${string}` } // StateEvent 에 더함 +``` + +- 그림 = `글` + (보이는 옵션 있으면 ` 또는 [이름(키)/…]`) + (기본 있으면 ` <기본>`) + `:` · 「두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용」 같은 덧글은 `text` 에 그대로. +- 기본 그림 — 길이 = 소수 넷(LUPREC 4 · `10.0000`) · int = 정수 · ang = 끝 0 뗌 · XY = `x, y` 소수 넷 · 값이 null = `<>` 안 그림. +- 보이는 곳 — 커서 곁 입력 상자 첫 줄 · 명령행(B07) · B06 상세 · M02 는 커서 곁만(같은 코드). + +### 2-4. 옵션 맞추기(`matchOption`) + +- 친 글 → 앞뒤 빈칸 뗌 · 대문자 · 앞 `_` 하나 뗌(영문 공용 키워드 꼴). +- 맞음 = 글 = kw · 글 = en · 또는 en 이 글로 시작하고 글이 kw 로 시작(kw 보다 짧으면 안 맞음 — 공식 initget 「LType = LT · LTY · LTYP · LTYPE · L 은 모자람」). +- 둘 이상 맞으면 kw 딱 맞는 것 · 숨은 옵션도 맞춤 · `absent` 맞으면 「「고도(E)」 옵션은 이 CAD 에 없음.」. +- 한 물음 안 kw 겹침 없음(계약 시험) · 점 물음에선 스냅 이름 · FROM · M2P 를 옵션보다 먼저 봄(PLINE 호 `CE` 대 스냅 `CEN` — 오토캐드도 CEN = 스냅). +- 대소문자 무관은 이 한 곳 — 캔버스 대문자 바꿈(`input-controller.ts:481`)은 보이는 글만 · 명령행 칸 소문자(N6)도 같은 길. + +### 2-5. 친 글 사다리(`routeTyped` · 한 곳) + +① 덧물음 있음 → 덧물음만(그 물음 꼴로 ④~⑦). +② 글 빔 → 동적 입력 길이 잠금 + 점 물음 = 그 길이 점(지금) → 물음 기본(값 숫자 = `NUMBER_INPUT` · 값 XY = `ABSOLUTE_POINT_INPUT` · 옵션 = `OPTION.키` · enter = `ENTER`) → 쉼 = 앞 명령 반복 → 그 밖 = `ENTER`. +③ 글 물음(`kind: 'text'`) → `TEXT_INPUT`(원문 그대로) · 그 밖 물음에서 `'` 로 시작하면 바로 ⑧. +④ 점 물음 → 동적 각도 칸 숫자 · ` 내 별칭 > 기본 별칭 **딱 맞음만** `runCommand` · 아니면 「알 수 없는 명령 「R」.」(N18 — `registry.ts:132` 앞글자 첫 후보 줄 뺌 · 후보는 목록 · 2-12 고름). +⑩ 거절 → 2-10 글을 알림 줄 · 기록 줄에 · 도구엔 사건 안 감 · 친 글 지움. + +- 쉼 = 맨 위 도구가 SELECT 이고 `WAITING_FOR_FIRST_SELECT_POINT`(상자 그리는 중은 쉼 아님). +- 물음 종류 = 덧물음 → 쉼 → `meta.kind` → 없으면 `on` 으로 짐작(`ABSOLUTE_POINT_INPUT` = 점 · `NUMBER_INPUT` = 숫자 · `MOUSE_CLICK` 만 = 객체 · `OPTION.*` 만 = 키워드) · 평평한 상태만 읽음(지금 `ime-sink.ts:13-16` 과 같음). +- 한글 받개 초점 = `kind: 'text'` 일 때만(`activeToolWantsIme` 를 이것으로) — 숫자 · 키워드 물음은 영문 자리 글자. +- 옛 꼴 통과(전환 중 안 깨지게) — `prompt` 없는 상태: `meta.options`(글 배열)에 맞음 → `TEXT_INPUT(대문자)` · `TEXT_INPUT` 받고 `NUMBER_INPUT` 없는 상태 → `TEXT_INPUT(대문자)` · 나 · 다 · 라 전환 끝나면 가가 지움. + +### 2-6. 빈 Enter · Space · 우클릭 + +- 셋 다 ② 길 · Space 는 글 물음에서만 빈칸(지금 `ime-sink.ts:48-51` 그대로) · 우클릭 = `handleEnterKey(true)`(쉴 때도 반복 · 브레인 정함) · 친 글 있으면 그 글 제출. + +### 2-7. Esc(한 번 = 명령 끝) + +- 친 글 · 한글 조합 · 각도 잠금 · 동적 입력 잠금 · 한 번 스냅 · FROM · 덧물음 모두 풂 → 쉼 아니면 도구에 `ESC`(도구 = 정리 뒤 `END`) → 쉼이면 상자 그리는 중 = 상자 취소 · 아니면 고름 풂 · 기록 줄 「*취소*」. +- 지금(`input-controller.ts:485-500`)은 고른 것 있으면 고름만 · 친 글 있으면 글만 → 오토캐드처럼 한 번에. + +### 2-8. 명령 끝 · 명령 바꿈 · 되돌림 + +- 도구 머신이 `END`(`type: 'final'`)에 들면 `state.ts` 갈고리(`setActiveToolActor` 구독 `status === 'done'`) → `end-command.ts` 가 그림자 · 기준점 · 도움선 끔 · 덧물음 닫음 → 새 선택 도구(같은 틱) · 고른 것 풂은 도구 몫(소비한 도구가 풂 · QSELECT 같은 고르기 명령은 남김). +- 선택 도구 자체의 final(Enter)도 같은 갈고리 → `input-controller.ts:531-537` 지움 · `sequence-tool.ts:240-243` setTimeout 바꿔 끼우기 지움(같은 틱이 xstate 에서 탈 나면 queueMicrotask · 시험 `await tick()`). +- `runCommand`(도구형) = `cancelActiveCommand()`(쉼 아니면 `ESC` · 오토캐드 리본 `^C^C`) 뒤 켬 · 즉시형 = 도구 둠(N1 ②). +- `undoCommand` · `redoCommand` = 끊고(`ESC`) 뒤 되돌림 · 고름 풂(N1 ③ · N11 · 오토캐드 `^C^C_u`) · Ctrl+Z · Ctrl+Y · ↶ ↷ · 손가락 단추 모두 이 길. +- 쉴 때 `U` = UNDO(오토캐드 U 와 같음) · 명령 중 `U` = 그 물음 옵션일 때만(아니면 거절). + +### 2-9. 기본값 기억(세션 = 탭 수명 Map) + +- 쓰는 쪽 = 도구(`remember(열쇠, 값)` — 값이 정해진 때 · 누름으로 정한 값도) · 읽는 쪽 = 물음 그림 · 빈 Enter(`recall`). +- 엔진 숫자 단계 = `prompt.default.remember` 열쇠로 엔진이 저절로 기억. +- 열쇠 — OFFSETDIST · FILLETRAD · TRIMMODE · CHAMFERA · CHAMFERB · CIRCLERAD · POLYSIDES · POLYGON.IC · RECT.L · RECT.W · TEXTSIZE · TEXT.ANGLE · XLINE.ANGLE · LINE.END · ROTATE.ANGLE · ROTATE.REF · SCALE.FACTOR · SCALE.REF · MOVE.DISP · STRETCH.DISP · COPYMODE · ARRAYTYPE · ARRAY.COLS · ARRAY.ROWS · ARRAY.ITEMS · ARRAY.FILL · LENGTHEN.MODE · LENGTHEN.DE · LENGTHEN.P · LENGTHEN.T · LASTPOINT(`rememberPoint` — `state.setAngleGuideOriginPoint` 에 null 아닌 점이 오면) · 앞 고름(`rememberSelection`). + +### 2-10. 거절 · 안내 글(커서 곁 둘째 줄 3 초 또는 다음 키까지 + 기록 줄) + +| 때 | 글 | 근거 | +|---|---|---| +| 점 물음 | 점 또는 옵션 키워드를 요구함. | 오토캐드 한국어판(사용자 글 2차) · 브레인 정함 꼴 | +| 점 물음 + `number`(반지름 · 거리) | 숫자 거리, 두 번째 점 또는 옵션 키워드를 요구함. | [옮] | +| 각 물음(`reject` 로) | 유효한 숫자 각도, 두 번째 점 또는 옵션 키워드를 요구함. | [옮] | +| 숫자 · 정수 · 범위 · 양수 | 숫자 값을 요구함. · 정수 값을 요구함. · 3에서 1024 사이의 정수를 요구함. · 값은 양수이고 0이 아니어야 함. | [옮] | +| 키워드 물음 | 옵션 키워드가 잘못됨. | [옮] | +| 고르기 · 객체 물음 | *선택이 잘못됨* | [옮] | +| 없는 옵션 · 스냅 | 「고도(E)」 옵션은 이 CAD 에 없음. · 「TAN」 스냅은 이 CAD 에 없음. | 우리 | +| 쉴 때 | 알 수 없는 명령 「R」. | [옮] | +| 투명 | 「LINE」 명령은 투명하게 쓸 수 없음. | [옮] | + +- 명령 시작 때 「현재 설정: …」 기록 줄(FILLET · TRIM · OFFSET · COPY · TEXT …) = 넣으면 좋음 · 이번 필수 아님(`run-command.ts` 가 `logLine` 내보냄 · 거절 · 알림 기록도 이 길). + +### 2-11. 덧물음(sub-prompt) + +```ts +interface SubPrompt { prompt: PromptSpec; kind: StepKind; + onPoint?(p: Point): SubResult; onNumber?(n: number): SubResult; + onOption?(key: string): SubResult; onEnter?(): SubResult } +type SubResult = 'stay' | 'done' | { point: Point }; // {point} = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 +``` + +- 쓰는 곳 — ZOOM(마 · `ic/zoom-prompt.ts`) · FROM · M2P(가-2) · 도구 머신을 안 바꿔 투명하게 됨(`'Z` 도 같은 길). +- 마우스 누름도 덧물음 먼저(`sendMouseClick` 이 `subPrompt()?.onPoint` 를 먼저 부름 · 마 몫 줄) · 그림 = 덧물음 줄이 도구 물음 대신 · Esc = 2-7(덧물음 닫고 명령도 끝 · 투명 ZOOM Esc 뒤 원래 명령 이어 감 여부[기]). + +### 2-12. ↑↓ · Tab · 후보(가-2) + +- ↑↓ — 쉼 = 친 명령 기록(H11 · 알림 줄 안 섞음) · 명령 중 = 그 물음(`history`) 최근 값 20 · 후보 목록 보일 땐 후보 고름(Enter = 고른 후보 · 안 고르면 친 글 그대로 ⑨) · Shift+↑↓ = 위아래 화면 이동(옛 동작 남김) · ← → = 화면 이동 그대로. +- Tab — 점 물음 = 동적 입력 칸(지금 그대로) · 쉼 + 친 글 = 후보 돌림(글을 후보 이름으로 · 오토캐드 명령행 Tab). +- 명령행(B07 `CommandLine.tsx`)도 같은 함수(`recallInput`) · Tab 첫 후보 채움(`:78-81`) → 돌림. + +### 2-13. 순서 엔진 v2(`sequence-tool.ts` · 가) + +| 칸 | 뜻 | +|---|---| +| `SequenceStep.kind` 에 `'keyword'` | 옵션만 받는 단계 · 고른 옵션 key 가 값(MIRROR 예/아니오 · 배열 유형) | +| `id?` | 값 이름 — `input.get(id)` · `input.has(id)` | +| `prompt?: PromptLike` | 새 꼴 물음(함수면 `SequenceInput` 받음) · 있으면 `instructions` 안 씀 | +| `when?(input)` | 거짓이면 그 단계 건너뜀(갈래 · `mode` 와 같이) | +| `next?` | 값을 받은 뒤 갈 단계 id(없으면 다음 칸 · 되돌아가기 = FILLET 반지름 뒤 첫 물음) | +| `options?: Record` | `OPTION.key` 처리 — `{ mode, next? }` = `input.mode` 세우고 다음(또는 next) · `{ run }` = 설정만 바꾸고 같은 물음 · `'undo'` = 마지막 값 하나 뺌 · `'close'` = `input.closed` 세우고 확정 · `'end'` = 확정 없이 끝 | +| `loopFrom?` | 확정 뒤 그 단계(id · 번호)부터 다시 · 앞 값 둠 — 없으면 확정 뒤 `END` | +| `escCommits?` | Esc 때 모은 값으로 확정 뒤 끝(PLINE) · 없으면 `onCancel` 뒤 끝(값 없어도 있어도 늘 끝 — N4) | +| `pickfirst?`(기본 참) | 첫 단계가 selection 이고 고른 것 있으면 그대로 받고 넘어감(오토캐드 PICKFIRST 1 · 지금 「Enter 한 번 더」) | +| `restart` · `chainFromLastPoint` | 옛 꼴 남김(`restart: true` = `loopFrom: 0`) | +| `SequenceInput` 더함 | `get(id)` · `has(id)` · `mode` · `closed` · 옛 `point(i)` 등 그대로 | + +- 엔진 몫 — 숫자 단계 TEXT_INPUT→숫자 길 지움(`:296-301` · N5) · selection 단계 meta = `{ kind: 'selection', prompt: step.prompt ?? SELECT_OBJECTS }` + `'OPTION.*'` 자식에 넘김 · 빈 고르기 = 끝 · 고른 것 받을 때 `rememberSelection` · 취소 때 selection 단계 있던 명령이면 고름 풂. +- 기본 바뀜이 103 순서 도구 모두에 미침 — 점만 받는 명령도 확정 뒤 끝 · 오토캐드 되풀이 명령만 `loopFrom` 둠(1 장 「되풀이」 줄 · 31 밖은 DONUT · RAY · MLINE · WIPEOUT · 지시선 · 표 놓기(이미 `restart: false`)). + +### 2-14. 손으로 짠 도구 규칙(LINE · CIRCLE · RECTANG · TRIM · MOVE · COPY · ROTATE · SCALE · ARRAY · 그립 · PEDIT) + +1. 물음 상태마다 `meta: { kind, prompt }` · 옛 `instructions` · `options: ['C','U']` 지움. +2. 옵션 처리 = `on: { 'OPTION.CLOSE': {...} }` — `prompt.options` key 마다 하나(숨김 포함 · 계약 시험이 맞댐) · 옵션 글을 `TEXT_INPUT` 으로 받지 않음. +3. `ESC` → 정리(그림자 · 원본 되살림) 뒤 `target: END_STATE` · 한 번 끝나는 명령은 마지막에 `END` · 되풀이는 그 상태로. +4. 고르기 상태 = `meta: { kind: 'selection', prompt: SELECT_OBJECTS }` + `'OPTION.*': { actions: sendTo(자식id, ({ event }) => event) }` + 고른 것 없이 끝 = `END` + 소비 때 `rememberSelection`. +5. 값 기억 = 정해진 때 `remember()` · 기본은 `prompt` 함수에서 `recall()`. +6. 물음 상태는 평평하게(겹 상태 안 · prompt-input 이 평평한 것만 읽음) · 파일 700 줄 넘으면 `*.helpers.ts` 로 가름. + +### 2-15. 고르기 물음 공용 · 앞 고름 + +- `SELECT_OBJECTS` = `{ text: '객체 선택', options: [전체(ALL)·ALL · 최종(L)·LAST · 이전(P)·PREVIOUS](모두 hidden) }` · 선언 = 가 · 처리(`OPTION.ALL` · `.LAST` · `.PREVIOUS`) = 마(선택 도구 첫 점 대기 상태 · 쉴 때 · 자식 둘 다). +- ALL = Ctrl+A 와 같은 목록(`getPickableEntities` · 오토캐드 ALL = 동결 · 잠금 층 뺌 · 끈 층 고름[기] — 다른 곳 표시만) · LAST = 집을 수 있는 마지막 객체 · PREVIOUS = `previousSelection()`. + +### 2-16. 계약 시험(`T/prompt-contract.test.ts` · 가) + +- `TOOL_STATE_MACHINES` 모든 평평한 상태 — `prompt.options` key 마다 `on['OPTION.key']` 또는 `on['OPTION.*']` 있음 · 같은 물음 kw 안 겹침 · `meta.kind` 있는 상태마다 `ESC` 있음 · `prompt` 와 옛 `instructions` 같이 없음 · 옵션 함수 꼴은 빈 context 로 불러 봄. + +### 2-17. 별칭(가) + +- 뺌: `SE`(SELECT · `commands.view.ts:35` · acad SE = DSETTINGS 없음) · 더함: `ED` → TEXTEDIT(`commands.annotate.ts:84` · acad 2018~) · `command-catalog.json` 거울 같이. +- 앞글자 실행 없앰으로 `R`(REDRAW) · `RA` · `W` · `LE`(QLEADER) · `UN` · `TO` · `TA` · `HI` · `IN` · `EXT` · `Q` · `LAY` · `DD` · `OS` · `DS` · `SN` · `V` · `PU` · `LTS` · `OP` = 「알 수 없는 명령」(acad 별칭이 가리키는 명령이 우리에 없음 → 비움). +- 우리 덧 별칭(`TEDIT` · `SAVE` · `UNHIDE` · `UNISOLATE` · `U` = UNDO)은 둠 — acad 와 안 부딪힘 · ☰ 내 별칭 우선 그대로. + +## 3. 흠 고침(이번 목표에 드는 것) + +| 흠 | 까닭(지금) | 자리 | 고침 방향 | 시험 | 창 | +|---|---|---|---|---|---| +| H1 | Ctrl · Alt+글자가 명령 글자로 쌓임(Ctrl+C → 원) | `ic/input-controller.ts:476-482` · `A00_Common/key_shortcut.ts:31-49` | 글자 쌓기에 Ctrl · Alt · ⌘ 거름 · CAD 쪽 `ic/input-shortcut.ts` 에 `cadCtrlCommand`(C=COPYCLIP · X=CUTCLIP · Shift+C=COPYBASE · Shift+V=PASTEBLOCK · 1=PROPERTIES) · 공용 key_shortcut 안 고침 | prompt-frame | 가 | +| H2 일부 | 보기 전용에서 그립 · Ctrl+그립 · Delete 먹음 | `tl/select-tool.helpers.ts:55-72` · `tl/select-tool.ts:73-76` · `src/helpers/draw.ts:56` | `isDrawingReadOnly() \|\| isViewOnly()` 면 그립 시작 · 꼭짓점 지움 · DELETE 안 함 · 그립 안 그림 | mouse-acad | 마 | +| H3 | 그립 누르기만 해도 밀림 | `ic/input-controller.ts:195-212` · `tl/grip-edit-tool.ts:64-112` | 누름이 그립 편집을 켠 것이고 안 끌고(4 px) 떼면 아무 일 없음(뜨거운 그립) · 다음 누름 = 놓음 · 끌어 떼면 놓음 · 손가락 톡도 같은 길 | mouse-acad | 마 | +| H4 | 상자 + Ctrl/Shift 엉뚱 · 상자 = 늘 새로 | `tl/select-tool.helpers.ts:114-169` | 상자 = 앞 고름 ∪ 상자 · Shift+상자 = 앞 고름 − 상자 | mouse-acad | 마 | +| H5 | 명령 중 누름 = 바꿈 · 끌기 상자 ○ · 스침 ○ | `tl/select-tool.helpers.ts:80-99` · `ic/input-controller.ts:283-293,311-330` | 누름 = 더함(쉼 · 명령 중 같음) · Shift = 뺌 · 고르기 단계도 누를 때 첫 점(`ic/selecting.ts` 의 `selectingActor()`) · 스침 강조 | mouse-acad | 마 | +| H7 | 점 찍는 명령 중 C · U = 원 · UNDO | `ic/ime-sink.ts:28-34` · `tl/factories/sequence-tool.ts:282-292` | 점 물음 중 명령 이름 거절(가) + PLINE · SPLINE 옵션 C · U(나) | prompt-frame · draw-acad | 가 · 나 | +| H8 | F3 끄면 직교 · 극 꺼짐 · 직교 강제 아님 | `ic/input-controller.ts:277-280` · `src/helpers/get-draw-guides.ts:37-48` · `src/helpers/get-point-from-event.ts:20-45` | 가이드 계산은 늘 · 객체 스냅점만 F3 · 직교(F8 · Shift 누른 동안 뒤집음)면 기준점에서 가로 · 세로로 늘 내림(커서 · 누름 · 거리 숫자 방향) · 스냅 점(`snapped`) · 친 좌표 · 각도 잠금은 안 덮음(오토캐드) | mouse-acad | 마 | +| H9 | 가이드 잡는 거리 = 도면 단위 15 | `src/helpers/get-draw-guides.ts:42` · `src/App.consts.ts:57` | `SNAP_ANGLE_DISTANCE / 화면 배율`(`maxSnapDistance` 와 같은 셈) | mouse-acad | 마 | +| H11 | ↑ 기록에 알림 줄 | `src/commands/run-command.ts:18-38` · `src/components/CommandLine.tsx:31-39` | 보이는 기록(log)과 친 명령 기록(input-history) 나눔 | prompt-frame | 가-2 | +| N1 | 이동 · 회전 · 축척 도중 딴 명령 → 고른 것 사라짐 · Ctrl+Z → 두 벌 | `tl/move-tool.ts:216-226` · `tl/rotate-tool.ts:241-259` · `tl/scale-tool.ts:247` · `src/commands/run-command.ts:44-50` · `ic/input-controller.ts:616-634` | ① 친 이름 거절 ② `runCommand` 가 `cancelActiveCommand` ③ 되돌림 = 끊고 뒤 ④ 도구 ESC = 원본 되살림 뒤 END | prompt-frame · transform-acad | 가 · 라 | +| N2 | 옮기기 무리 Esc 로 안 끝남 · 한 번 뒤 고르기로 | `tl/move-tool.ts:122-124,172-174` · `tl/copy-tool.ts:121-123` · `tl/rotate-tool.ts:129-131` · `tl/scale-tool.ts:134-136` · `tl/array-tool.ts:113-115` | ESC · 끝 = END | transform-acad | 라 | +| N3 | Esc 없는 단계 | `tl/rectangle-tool.ts:55-70` · `tl/eraser-tool.ts:62-74` · `tl/array-tool.ts:147-277` | 모든 물음 상태 ESC → END(계약 시험) | prompt-contract | 나 · 다 · 라 | +| N4 | PLINE · SPLINE Esc = 다 버리고 다시 | `tl/factories/sequence-tool.ts:252-261` | 늘 끝 · PLINE `escCommits` · SPLINE 버림[기] | draw-acad | 가 · 나 | +| N5 | 숫자 단계 `3.2*1.5` → 3.2 | `tl/factories/sequence-tool.ts:193-201,296-301` | 글 전체가 수일 때만 · 아니면 거절 | prompt-frame | 가 | +| N6 | 명령행 칸 소문자 c → 원 | `ic/input-controller.ts:168-172` · `ic/ime-sink.ts:29-34` | 맞추기 대문자 한 곳(2-4) | prompt-frame | 가 | +| N7 | 명령 안 고르기 중 그립 누름 → 그립 편집으로 바뀜 | `tl/select-tool.helpers.ts:55-72` | 그립 집기는 맨 위 도구가 선택 도구(쉼)일 때만 | mouse-acad | 마 | +| N8 | 그립 중 거리 숫자 · 길이 잠금 무시 | `tl/grip-edit-tool.ts:64-80` | `NUMBER_INPUT`(그립 점에서 커서 쪽) 받음 | mouse-acad | 마 | +| N9 | TRIM 멀리 눌러도 자름 · 사각형 → 지금 층 선 | `tl/eraser-tool.ts:94-100,125-139` | `pickRadius()` 밖 = 아무 일 없음 · 사각형 변 = 원래 층 · 꼴(`copyStyle`) | modify-acad | 다 | +| N10 | 타원 셋째 숫자 ≠ 단축 | `tl/draw/basic-draw-tools.ts:82-105` | 「다른 축으로 거리」 = 중심에서 잰 반 길이 | draw-acad | 나 | +| N11 | U · Ctrl+Z 두 길 | `src/commands/commands.file.ts:100-108` · `ic/input-controller.ts:616-634` | 2-8(되돌림 = 끊고 뒤 · 명령 중 U = 옵션일 때만) | prompt-frame | 가 | +| N14 | 스냅 이름 EXT · INS → 명령 돎 · 나머지 버림 | `ic/input-snap-override.ts:13-21` | 점 물음에서 오토캐드 스냅 이름은 늘 스냅 길 · QUA · NOD 더함 · 없는 것 안내 | prompt-frame | 가-2 | +| N17 | ARRAY `x,y` → 복사 0 | `tl/array-tool.ts:198-206,324-360` | ARRAY 새 차례(1-3)로 없어짐 | transform-acad | 라 | +| N18 | 앞글자 실행 | `src/commands/registry.ts:124-134` | 딱 맞음만 · 후보는 목록 | prompt-frame | 가 | +| 새 | 한 번 그리는 명령이 끝나도 첫 물음으로 돌아감 → 명령 이름 거절과 부딪힘 | `tl/line-tool.ts:133-139` · `tl/circle-tool.ts:88-107` · `tl/rectangle-tool.ts:80-99` · `tl/factories/sequence-tool.ts:231-249` | 1 장 「끝난 뒤」 열대로 END · 되풀이 | 창마다 시험 | 가 · 나 · 다 · 라 | + +- 뺌 — H2 나머지(특성 칸 · 리본 특성 · 도면층 관리 · 내보내기) · H6(숨긴 것 고름) · H10(그리드) · H12(명령행 한글) · N12(오토캐드도 같음) · N13(맥 ⌘V) · N15 · N16. + +## 4. 마우스 · 단축키 · 휠 + +| 무엇 | 오토캐드 | 이번 | 자리 | 창 | +|---|---|---|---|---| +| 왼 누름 고르기 | 누름 = 더함 · Shift = 뺌(PICKADD 2) | 넣음(쉼 · 명령 중 같음) · Ctrl+누름 = 지금처럼 넣고 빼기(오토캐드 2D 뜻 없음 · 둠) | `select-tool.helpers.ts` | 마 | +| 창 · 걸침 | 빈 곳 클릭-클릭 · 끌기 | 명령 중에도 끌기 · 상자 더함 · Shift 상자 뺌 | `input-controller.ts` 마우스 · `ic/selecting.ts`(새) | 마 | +| 스침 강조 | 고르기 물음에서도 | 넣음 | `input-controller.ts:283-293` | 마 | +| 고르기 키워드 | P · L · ALL … | ALL · L · P | `select-tool.ts` · `prompt.ts` | 마 · 가 | +| 그립 | 누름 = 뜨거움 → 옮겨 누름 = 놓음 · 끌기도 | 넣음 · 숫자 · Tab · Esc · X | `input-controller.ts` · `grip-edit-tool.ts` | 마 | +| 그립 모드 돌림 · C · B · 여러 그립 · 메뉴 | 있음 | 뺌 | — | — | +| 우클릭 | 명령 중 = Enter · 쉼 = 반복(설정) · 고른 채 = 메뉴(기본) | 명령 중 = Enter · 쉼(고른 채 포함) = 반복 · 메뉴 없음 | `input-controller.ts:174-180` → `handleEnterKey(true)` | 마 | +| Shift+우클릭 | 한 번 스냅 메뉴 | 넣음 — 점 물음일 때만 · 끝점 · 중간점 · 중심 · 사분점 · 교차점 · 수직 · 근처점 · 노드 · 없음 · 시작점에서(FROM) · 두 점 사이의 중간(M2P) → `submitText(이름)` | `ic/snap-menu.ts`(새 · DOM · 손가락 단추 꼴) | 마(FROM · M2P 처리 = 가-2) | +| 휠 굴림 | 밂(앞) = 확대 · 커서 자리 | 기본 밂 = 확대(☰ 뒤집기 그대로 · 모든 화면 일체) · 배율 10% 그대로 | `A00_Common/input_prefs.ts:23,45`(DEFAULT `push` · 없으면 `push`) | 마 | +| 휠 두 번 | 범위 | 가운데 단추 두 번(400 ms · 5 px 안) = 범위 · 앞 화면 기억 | `input-controller.ts` mousedown | 마 | +| 휠 끌기 · Ctrl+휠 | 이동 · 빠른 줌 | 그대로 | — | — | +| ZOOM | 옵션 물음 | E · A · W · P · nX(덧물음 · `'Z`) · ⛶ 단추 = 바로 범위(`zoomToFitScreen`) | `commands.view.ts:41-52` · `ic/zoom-prompt.ts`(새) · `ViewControls.tsx:31` | 마 | +| 직교 F8 | 강제 | 강제 · F3 와 뗌 | `get-point-from-event.ts` · `calculate-angle-guides-and-snap-points.ts` | 마 | +| Shift 누른 동안 | 직교 뒤집음(임시 우선 키) | 넣음 — 점 물음 · 기준점 있을 때 · 마우스 사건 `shiftKey` 로(keyup 안 씀) | `ic/ortho.ts`(새) | 마 | +| 그 밖 임시 키(Shift+E 등) | 있음 | 뺌 | — | — | +| Ctrl+C · X · Shift+C · Shift+V · 1 | 클립보드 · 특성 | COPYCLIP · CUTCLIP · COPYBASE · PASTEBLOCK · PROPERTIES · Ctrl+V 그대로(paste 사건) | `input-controller.ts` 키 · `input-shortcut.ts` | 가 | +| Ctrl+Z · Y · ↶ ↷ | 끊고 되돌림 | 2-8 | `prompt-input.ts` · `ViewControls.tsx:20-23`(마가 `undoCommand` 부름) | 가 · 마 | +| ↑ ↓ · Tab | 기록 · 최근 값 · 후보 | 2-12 | `input-controller.ts:413-422,464-475` · `CommandLine.tsx` | 가-2 | +| Delete | 고른 것 지움 | 그대로(보기 전용 막음 = H2) | — | 마 | +| F1 · F2 · F11 · F12 · Ctrl+9 · Ctrl+W 등 | 있음 | 뺌(브라우저 몫 · 335 정할 것 14) | — | — | + +- 안내 글 바꿈(가 · 끝에 한 번 · 공유 파일) — `ui_template/ui_template_locale_common.ts:120-125`(오른쪽 단추 = 「명령 중 Enter · 쉴 때 반복 · Shift = 스냅 메뉴」 · 휠 두 번 = 범위) · `:165-169` CAD 줄(「↑↓ = 기록 · 최근 값」 · 「Ctrl+C · X · 1」 · 「방향키 = 화면 옮김」 → 「← → · Shift+↑↓ = 화면 옮김」). + +## 5. 창 나눔 · 차례 + +### 5-1. 맡는 것 · 파일 몫 + +| 창 | 맡는 것 | 혼자 고치는 파일 | 같이 만지는 파일(그 줄만) | +|---|---|---|---| +| 가 | 틀 · 키보드 · 명령 해석 · 엔진 · 별칭 · 시험 도우미 · 안내 글 | 새 `src/commands/prompt.ts` · `ic/prompt-input.ts` · `ic/input-history.ts` · `ic/point-modifiers.ts` · `tl/factories/end-command.ts` · `T/helpers/cad-drive.ts` · `T/prompt-frame.test.ts` · `T/prompt-contract.test.ts` / 고침 `tl/factories/sequence-tool.ts` · `tl/tool.types.ts` · `ic/ime-sink.ts` · `ic/input-snap-override.ts` · `ic/input-shortcut.ts` · `src/commands/registry.ts` · `run-command.ts` · `command-catalog.json` · `src/components/CommandLine.tsx` · `src/state.ts`(갈고리 · LASTPOINT 10 줄 안 · 지금 647 줄) · `T/keyboard-chain.test.ts` · `T/command-catalog.test.ts` · `ic/ime-sink.test.ts` | `ic/input-controller.ts` 키 쪽(`handleKeyStroke` · `handleEnterKey` · `handleEscapeKey` · `handleUndo/Redo` · `submitText` · `sendText` · `draw`) · `commands.view.ts:35` · `commands.annotate.ts:84` · `ui_template_locale_common.ts`(끝에) | +| 나 | 그리기 12(1-1) | `tl/line-tool.ts` · `tl/circle-tool.ts` · `tl/rectangle-tool.ts` · `tl/draw/basic-draw-tools.ts` · `tl/draw/construction-tools.ts` · `tl/annotate/text-tools.ts` · `tl/draw/fill-tools.ts` · 새 `tl/draw/pline-tool.ts` 등(옛 파일에서 다시 내보내 `commands.*.ts` 안 만짐) · `T/draw-acad.test.ts` | — | +| 다 | 고치기 · 재기 · 치수 12(1-2) | `tl/eraser-tool.ts`(+helpers) · `tl/modify/corner-tools.ts` · `tl/modify/structure-tools.ts` · `tl/draw/divide-tools.ts` · `tl/utility/inquiry-tools.ts` · `tl/annotate/dimension-tools.ts` · `dimension-radial-tools.ts` · 새 `tl/modify/offset-tool.ts` · `lengthen-tool.ts` · `T/modify-acad.test.ts` | `commands.modify.ts` OFFSET · LENGTHEN import 두 줄 | +| 라 | 옮기기 무리 7 + PEDIT 고르기 상태 | `tl/move-tool.ts` · `copy-tool.ts` · `rotate-tool.ts` · `scale-tool.ts` · `array-tool.ts`(+새 helpers) · `pedit-tool.ts` · `tl/modify/transform-tools.ts`(MIRROR · STRETCH · 다 합친 뒤 옛 OFFSET · LENGTHEN 지움) · `T/transform-acad.test.ts` | — | +| 마 | 마우스 · 고르기 · 그립 · 휠 · 직교 · 보기 전용 문 · ZOOM · 스냅 메뉴 | `tl/select-tool.ts` · `select-tool.helpers.ts` · `grip-edit-tool.ts` · `src/helpers/get-point-from-event.ts` · `get-draw-guides.ts` · `calculate-angle-guides-and-snap-points.ts` · `src/helpers/draw.ts` · `drawControllers/fit-view.ts`(윈도우 줌) · 새 `ic/selecting.ts` · `ic/ortho.ts` · `ic/snap-menu.ts` · `ic/zoom-prompt.ts` · `src/components/ViewControls.tsx` · `A00_Common/input_prefs.ts` · 휠 시험들 · `T/mouse-acad.test.ts` | `ic/input-controller.ts` 마우스 쪽(constructor 마우스 줄 · `handleMouse*` · `sendMouseClick` · `getCanvasPoint`) · `commands.view.ts:41-52`(ZOOM) | + +- `input-controller.ts`(지금 635 줄) — 가는 사다리를 `prompt-input.ts` 로 빼서 줄이고 · 마는 새 논리를 `selecting.ts` · `ortho.ts` 등에 두고 이 파일엔 부르는 줄만. + +### 5-2. 차례 + +1. 가-1 — 2 장 계약 전부(타입 · 사다리 · 거절 · Esc · 빈 Enter · 우클릭 뜻 · END 갈고리 · 엔진 v2 · 기억 · 덧물음 · 별칭 · N1 ②③ · N5 · N6 · N11 · N18 · H1 · 옛 꼴 통과 · 계약 시험 · cad-drive · keyboard-chain 손질) → push → 브레인이 나 · 다 · 라 · 마에 「받기」. +2. 마-1 = 처음부터(가 안 기다림) — 휠 기본 · 고르기 더함/뺌 · 상자 · 명령 중 끌기 · 스침 · N7 · 그립 누르고-옮기고-누르기 · 그립 숫자 · H2 문 · 직교 강제 · Shift 직교 · H8 · H9 · 휠 두 번. +3. 가-1 받은 뒤 동시 — 나 · 다 · 라 · 마-2(ZOOM 덧물음 · 스냅 메뉴 · ALL/L/P · `snapped` 채움 · 우클릭 반복 · ↶↷). +4. 가-2 = 가-1 바로 뒤(나 · 다 · 라와 동시) — ↑↓ · Tab 후보 · FROM · M2P · `@` · `#` · `*` · 스냅 이름 늘림 · H11. +5. 끝 — 가: 옛 꼴 통과 지움 · 안내 글 · 라: 옛 OFFSET · LENGTHEN 지움 · 브레인 화면 검증 · 아티팩트. + +- 창 안 차례(먼저 → 나중) — 나 = LINE → PLINE → RECTANG → CIRCLE → POLYGON → ARC → ELLIPSE → XLINE → SPLINE → TEXT → MTEXT → HATCH · 다 = OFFSET → FILLET → TRIM → EXTEND → CHAMFER → BREAK → LENGTHEN → JOIN → DIVIDE → MEASURE → DIST → DIM · 라 = MOVE → COPY → ROTATE → SCALE → MIRROR → STRETCH → ARRAY. +- 어림(시험 포함 · 화면 확인 별도) — 가-1 1.5 일 · 가-2 1 일 · 나 2.5 일 · 다 2.5 일 · 라 2 일 · 마 2 일. +- 알릴 것 — 가-1 push(모두 받기) · 엔진 기본 바뀜(모든 순서 도구 확정 뒤 끝 · 고른 것 바로 받음) · 휠 기본 바뀜(모든 화면 · 마) · 공유 파일 `ui_template_locale_common.ts`(가). + +## 6. 시험 · 완료 기준 + +### 6-1. 돌리는 법 + +- CAD 시험 — `cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad <이름>`(`keyboard-chain.test.ts` 머리 그대로) · src 단위 — `npx vitest run src/inputController/ime-sink.test.ts` 꼴. +- 타입 — 뿌리 `npm run typecheck` + `npm --prefix B07_DesignDetail/openwebcad run check-types` · 빌드 — `npm --prefix B07_DesignDetail/openwebcad run build`. +- 파이썬 — `./venv/Scripts/python.exe -m pytest resources/tester/<시험> -q`(휠 기본 바뀜 손질 때만). +- 전체 시험 안 돌림(사용자 지시 때만) · 고친 대상 시험만. + +### 6-2. 공용 도우미 `T/helpers/cad-drive.ts`(가-1) + +- `initIdle()`(빈 도면 · 선택 도구로 시작 — 지금 `test/helpers/init-application.ts` 는 LINE 으로 시작해 새 규칙에선 「l」이 거절됨) · `key(k, mods?)` · `type(text)`(글자 + Space) · `typeEnter(text)` · `move(x, y)` · `click(x, y, mods?)` · `press` · `release` · `drag(a, b)` · `rightClick(shift?)` · `middleDouble(x, y)` · `entities(type?)` · `promptLine()` · `noticeLine()` · `activeTool()` · `tick()`. +- 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드로만 만들고 숫자로 판정(`keyboard-chain.test.ts:3` 그대로). + +### 6-3. 창마다 새 시험 · 꼭 넣을 줄 + +| 창 | 시험 | 꼭 볼 것 | +|---|---|---| +| 가 | `T/prompt-frame.test.ts` | 맞추기(대소문자 · `_` · kw · en · 사이 길이 · 짧으면 안 맞음 · 숨김 · absent) · 그림(「다음 점 지정 또는 [닫기(C)/명령 취소(U)]:」 · `<10.0000>` · `<변위>`) · 빈 Enter 넷 갈래 · 거절(점 물음 「L」 → 글 · 도구 그대로 · 숫자 「3.2*1.5」 「2m」 · 정수 범위) · 쉼(「R」 「LE」 → 알 수 없는 명령 · 「l」 → LINE · 내 별칭 우선 · SE 없음 · ED) · Esc 한 번 끝 · 우클릭 쉼 반복 · END → 선택 도구(같은 틱) · N1(MOVE 기준점 뒤 `runCommand(CIRCLE)` → 선 1 개 · Ctrl+Z 뒤 같은 id 두 벌 없음) · 엔진(loopFrom · escCommits · pickfirst · 빈 고르기 끝 · when · mode · next · keyword) · 기억 · Ctrl+C → COPYCLIP · Ctrl+글자 안 쌓임 · 가-2(↑↓ · Tab · FROM · M2P · `@` · `#` · END/ENDP · QUA · H11) | +| 가 | `T/prompt-contract.test.ts` | 2-16 | +| 나 | `T/draw-acad.test.ts` | LINE(U · C 둘째부터 · Enter 끝 → 쉼 · Space 반복 · 첫 점 Enter 이음 · Esc 남김) · PLINE(C · U · L · A 접선 호 · CL · Esc 남김) · RECTANG D(기억 · 사분면) · CIRCLE(D · 3P · 2P · 기억 · 한 번 뒤 끝) · POLYGON(E · I/C · 기억 · 2.5 거절) · ARC(C · E · R · A) · ELLIPSE(N10 · C · R) · SPLINE(U · C · Esc 버림) · XLINE(H · V · A · 되풀이) · TEXT(높이 · 각 · 줄 이어 · 빈 줄 끝 · 시작 Enter 앞 글 아래) · MTEXT(H · W) · HATCH(내부 점 · S · U) | +| 다 | `T/modify-acad.test.ts` | OFFSET(처음 <통과점> · 거리 기억 · 되풀이 · E · M · U · T) · FILLET(객체 먼저 · R 기억 · Shift 0 · T · M · U) · CHAMFER(D 기억 · Shift) · TRIM(되풀이 · 반경 밖 무시 · Shift 연장 · R · U · Esc · 사각형 층) · EXTEND(빠른 · Shift 자르기) · BREAK(누른 점 · F · `@`) · LENGTHEN(DE · P · T · 마지막 방식 · 누름 길이) · JOIN · DIVIDE 정수 · MEASURE 끝 · DIST 글 · DIMLINEAR(<객체 선택> · T · H · V) · DIMCONTINUE U | +| 라 | `T/transform-acad.test.ts` | MOVE(D · Enter 변위 · Esc 제자리 끝 · 한 번 뒤 끝 · N1) · COPY(되풀이 · Enter 끝 · O 단일 · A · F · U · E) · ROTATE(누름 한 번 = 각 · C · R `@` · P) · SCALE(누름 거리 · C · R) · MIRROR(Y · N · Enter = N) · STRETCH(걸침 꼭짓점만 · D · Enter) · ARRAY(R = 직사각형 · COU · S · COL · R · X · PO · I · A · F · ROT · Esc 안 만듦) · 고르기 ALL · P · L · 빈 Enter 끝 | +| 마 | `T/mouse-acad.test.ts` | 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 → 다음 누름 놓음 · 끌어 놓음 · 그립 숫자 · H2 문 · 직교 강제(커서 · 누름 · 거리) · 스냅 점은 안 덮음 · Shift 임시 직교 · F3 꺼도 직교 · H9 · 우클릭(쉼 반복 · 명령 중 Enter) · 휠 두 번 범위 · 휠 기본 밂 · ZOOM E · W · P · 2X · `'Z` · 스냅 메뉴 → 한 번 스냅 | + +- 휠 기본 바뀜 손질(마) — `ic/input-wheel.test.ts` · `T/input-prefs-cad.test.ts` · `resources/tester/test_input_prefs.py`(+`helper_input_prefs.cjs`) · `test_app_settings.py` · `test_164_detail_zoom.py`(+helper) · `helper_266_detail_home.cjs` 쓰는 시험 · `test_281_1_drainage_zoom_range.py`(+helper) · `test_244_4` · `_6` · `_8` · `helper_218_1_loupe.cjs` · `helper_230_1_popout_wheel.cjs` · `test_touch_bridge.py` — 방향이 뜻인 시험 = 기대값 뒤집음 · 방향과 무관 = 시험 앞에 pull 박음(그 시험 뜻대로) · 브라우저 띄우는 시험은 돌린 창이 결과만 보고. +- 옛 시험 손질(가) — `keyboard-chain.test.ts`(「선 중 다른 명령 이름은 그대로 명령」 → 거절 · C 뒤 쉼 · 글 바뀜 · `initIdle`) · `ime-sink.test.ts`(「빈 우클릭은 반복 안 함」 → 반복) · `command-catalog.test.ts`(SE · ED). + +### 6-4. 완료 기준(창마다) + +- 맡은 명령마다 1 장 줄대로 물음 글 · 옵션 · 기본값 · 빈 Enter · Esc · 끝난 뒤가 시험으로 확인. +- `prompt-contract` 초록(옵션 선언 ↔ 처리 · ESC) · 고친 대상 시험 + 손질한 옛 시험 초록. +- 타입 검사 둘 · CAD 빌드 성공 · 파일 700 줄 안 · prettier. +- PLAN 해당 줄 「검증완료」 한 줄 · 커밋(`git commit -F`) · `.claude/git-autopush.ps1` · push. +- worker_done 에 화면 확인 거리(1 장 [기] 줄 · 휠 · 그립 · 스냅 메뉴 · ZOOM) — 브레인이 끝에 브라우저 검증. + +## 7. 이번 밖 + +- 'CAL · 식 · QuickCalc 값 넣기 · 단위 꼴(3m · 피트) · 도분초 · 라디안 · 측량 각. +- TK · TT · 좌표 거르개(.X) · 극 추적 각 고르기 · 스냅 종류 끄기 · 켬 상태 기억 · 격자 스냅 · F9 · F11 · F12. +- 우클릭 메뉴(기본 · 편집 · 명령) · 시간차 우클릭 · 명령행 옵션 누르기 · DI ↓ 옵션 목록 · DI 절대/상대 바꾸기(정할 것 18 · 지금 = 절대 그대로). +- 그립 모드 돌림 · 그립 C · B · 여러 그립 · 다기능 그립 메뉴 · 끌어 옮기기 · Ctrl+화살표 · 두 번 눌러 고치기 늘림(N16). +- 고르기 W · C · F · WP · CP · 올가미 · 겹친 것 목록 · 「고른 채 명령」 더 넓히기. +- 1 장 「뺌」 옵션 전부 · 연관 배열 · 경로 배열 · MTEXT 편집기 · 글 자리맞추기 · HATCH 설정 · 3D 옵션 · OFFSET 도면층 · FILLET 폴리선. +- H2 나머지 · H6 · H10 · H12 · N13 · N15 · 명령 단위 되돌림 묶음(LINE 여러 토막을 쉴 때 U 한 번에). +- 다른 CAD · 리습 · 스크립트 · 손가락 몸짓 바꿈 · 토목 전용(비탈 1:n). + +## 8. 근거 쪽 + +- 한국어 도움말 2025 KOR(옵션 이름 · 줄) — LINE `GUID-E8C1190C` · PLINE `GUID-11883C70` · RECTANG `GUID-188B2DDA`(첫 줄 그대로) · CIRCLE `GUID-C60B6D5D` · ARC `GUID-30ECFD30` · POLYGON `GUID-E5CD464D` · ELLIPSE `GUID-45F9C588` · SPLINE `GUID-5E7D51E2` · XLINE `GUID-40650DCE` · OFFSET(2016 KOR) `GUID-C0E4246D` · FILLET `GUID-64F8B700` · CHAMFER `GUID-B1DCF991` · TRIM `GUID-B1A185EF` · EXTEND `GUID-89DD7B0F` · MOVE `GUID-47CE7325` · COPY `GUID-1CF9287F`(두 줄 그대로) · ROTATE `GUID-1C265537` · SCALE `GUID-D4E17E51` · STRETCH `GUID-F000A502` · MIRROR `GUID-595277C8` · ARRAY `GUID-E23F6125` · ARRAYRECT `GUID-BB3DA888` · ARRAYPOLAR `GUID-A6E74297` · BREAK `GUID-36A1CDE0` · JOIN `GUID-4D2A28C3`(줄 그대로) · LENGTHEN `GUID-76C74C62` · DIVIDE `GUID-3427CA9F` · MEASURE `GUID-212933E8` · DIST `GUID-A72EADC7` · TEXT `GUID-D1C664DD` · MTEXT 옵션 `GUID-F94BE932` · DIMLINEAR `GUID-E908848F` · HATCH `GUID-27C104F2` · 명령 입력 `GUID-945C8F74`(주소 꼴 `help.autodesk.com/cloudhelp/2025/KOR/AutoCAD-Core/files/.htm`). +- 키워드 줄임 규칙 — AutoLISP `initget`(2015 ENU `GUID-9ED8841B`) 「LType … LT · LTY · LTYP 됨 · L 모자람 · 대소문자 무관」. +- 거절 글 「점 또는 옵션 키워드를 요구함.」 — 오토캐드 한국어판 사용자 글(gocad.co.kr 질문 · 2차) · 나머지 거절 글 = 영문 기억 옮김. +- 영문 줄 · 동작 = 부록4 K1(E1 · E2 표) · 코드 줄 = 부록4 K2 + 이번 다시 읽음. From 193e03927b773c6e3569353e4ccc5eff302ec545 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:28:57 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(M02=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80):=20338-2b=20STmate=20=EA=BC=B4=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EA=B1=B0=EC=B0=BD=20=ED=9D=A9=EC=96=B4=EC=A7=84=20?= =?UTF-8?q?=EC=B9=B8=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=EB=84=93=ED=9E=98=20=C2=B7=20=EB=85=B8=EC=9E=84=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EB=AC=B8=EC=84=9C=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?400=20=C2=B7=20STmate=20=EA=B2=A9=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글) - 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0 - Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21 - Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안) - Grid STmate 시트 · 숨은 칸 글 뺌 - 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- M02_MasterTemplete/M02_Hopyo_Basis.py | 30 ++- M02_MasterTemplete/M02_Hopyo_Excel.py | 43 +++- M02_MasterTemplete/M02_Hopyo_Grid.py | 10 +- M02_MasterTemplete/M02_Hopyo_Stmate.py | 264 ++++++++++++++++++++++ resources/tester/test_m02_hopyo_basis.py | 17 ++ resources/tester/test_m02_hopyo_excel.py | 54 ++++- resources/tester/test_m02_hopyo_grid.py | 20 ++ resources/tester/test_m02_hopyo_stmate.py | 185 +++++++++++++++ 8 files changed, 607 insertions(+), 16 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Stmate.py create mode 100644 resources/tester/test_m02_hopyo_stmate.py diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index 7a7705054..2dd647063 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -6,7 +6,8 @@ 출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 = 건설품셈 · 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · -글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료). +글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은 +그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열). 견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · @@ -34,7 +35,7 @@ _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4 -GLINES, GCHARS = 200, 300 +GLINES, GCHARS, WIDE = 200, 300, 140 _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} @@ -45,6 +46,12 @@ def _flat(value: Any) -> str: # ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── +def _cell_text(value: Any) -> str: + if isinstance(value, float) and value == int(value): + value = int(value) + return str(value).strip() if value is not None else "" + + @lru_cache(maxsize=2) def _trade_names(whole_id: int) -> dict[str, str]: """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" @@ -78,11 +85,15 @@ def sources(text: str) -> list[list[str]]: def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: - """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}).""" + """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) · + 돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽).""" for at, row in enumerate(rows[:10]): flat = [_flat(v) for v in row] if "산출근거" in flat: - money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat} + last = len(flat) - 1 + money = { + key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat + } return at + 1, flat.index("산출근거"), money return 0, 1, {} @@ -93,14 +104,19 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: if not rows: return {} start, col, money = _columns(rows) + end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE names = trade_names() blocks: dict[int, dict[str, Any]] = {} block: dict[str, Any] | None = None for at, row in enumerate(rows[start:], start + 1): text = row[col] if col < len(row) else None - if not isinstance(text, str) or not text.strip(): - continue - head = None if text[:1].isspace() else _HEAD.match(text) + head = None + if isinstance(text, str) and text.strip(): + head = None if text[:1].isspace() else _HEAD.match(text) + else: + text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v)) + if not text: + continue if head: block = blocks.setdefault( int(head.group(1)), diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index c55a1b89c..e103d1d07 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -7,7 +7,12 @@ 호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 · 끝 = 다음 호표. 갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) · -자재(재료비만) · 기타 — 규격은 원문 그대로 둠. +자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠. +이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 → +호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면 +이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음. +STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` · +문서 보안(DRM) 파일 = 400 글. 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). @@ -25,6 +30,7 @@ from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate SHEET = "일위대가" SUMMARY = "일위대가총괄표" @@ -32,7 +38,9 @@ COSTS = ("노무비", "재료비", "경비") HEADS = ("호표", "품명", "규격", "수량", "단위", "비고") NO_SHEET = "일위대가 시트 없음" _HOPYO = re.compile(r"제\s*(\d+)\s*호표") -_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$") +_REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$") +DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림" +_OLE = b"\xd0\xcf\x11\xe0" _MACHINE_UNITS = ("hr", "시간", "h") @@ -77,11 +85,15 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) if not data: raise ExcelError("빈 파일") + if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE): + raise ExcelError(DRM) try: if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): import openpyxl book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]): + wanted = hopyo_stmate.SHEETS out = { ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] for ws in book.worksheets @@ -143,6 +155,8 @@ def kind_of(line: dict[str, Any]) -> str: return "단가산출" if unit in _MACHINE_UNITS: return "기계" + if unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names(): + return "노임" if not any(price.get(k) for k in ("계", *COSTS)): return "별도계상" if price.get("재료비") and not price.get("노무비") and not price.get("경비"): @@ -208,10 +222,28 @@ def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]: return out +def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None: + """비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음.""" + index: dict[tuple[str, str], list[int]] = {} + for no, block in blocks.items(): + index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no) + for line in (one for lines in raw for one in lines): + if line["참조"] or line["비고"] or line["갈래"] == "%": + continue + if not (line["단가"].get("노무비") or line["단가"].get("경비")): + continue + hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or [] + if len(hit) == 1: + line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름" + line["갈래"] = kind_of(line) + + # ── 읽기 ────────────────────────────────────────────────────────────── def read_book(name: str, data: bytes) -> dict[str, Any]: """엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수.""" sheets = _sheets(name, data) + if hopyo_stmate.is_stmate(sheets): + return hopyo_stmate.read(sheets) rows = sheets.get(SHEET) if rows is None: raise ExcelError(NO_SHEET) @@ -238,7 +270,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: if head or not name_ or not hopyos: continue note = _text(_cell(row, cols.get("비고"))) - ref = _REF.match(note.replace(" ", "")) + ref = _REF.match(_flat(note)) line = { "차례": len(raw[-1]), "이름": name_, @@ -248,7 +280,9 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "단가": _money(row, cols, 0), "금액": _money(row, cols, 1), "비고": note, - "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, + "참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))} + if ref + else None, } line["갈래"] = kind_of(line) line["행"] = hopyos[-1]["행"][1] = at @@ -256,6 +290,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: if not hopyos: raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET)) + _by_name(raw, blocks) for line in (one for lines in raw for one in lines): ref = line["참조"] or {} if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])): diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index 39af897fa..b41f477f2 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -1,11 +1,12 @@ -"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 → +"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 +(STmate = 「일위대가표」 · 「단가산출근거」 · 338) → A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 · 읽기 전용 보기). xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). -xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지. +xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. 결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. """ @@ -18,8 +19,9 @@ from openpyxl.utils import get_column_letter from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate -SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET) +SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC) HEAD_ROWS = 4 _HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} _VALIGN = {0: "top", 1: "center"} @@ -192,7 +194,7 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]: } for r, row in enumerate(rows) for c, v in enumerate(row[:width]) - if v not in (None, "") + if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글 } sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) return {"서식": [{}], "시트": sheets} diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py new file mode 100644 index 000000000..5d3a6cdd8 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -0,0 +1,264 @@ +"""M02 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 + +목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴 +(`M02_Hopyo_Excel.read_book`). + +일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 · +D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 · +다음 열 금액 · M 비고) → 「합계」 줄에서 끝. +줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) · +코드 없는 줄 = 호표 `근거글`. +갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 · +X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 · +PR · 단위 % = % · 그 밖 기타. +단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 · +소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 · +비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) · +머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` · +직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름). +검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀 + +SHEET = "일위대가표" +CALC = "단가산출근거" +LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표") +SHEETS = (SHEET, CALC, *LISTS) +_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$") +_META = re.compile(r"(? bool: + return SHEET in names and CALC in names + + +def _cell(row: list[Any], col: int) -> Any: + return row[col] if col < len(row) else None + + +def _s(value: Any) -> str: + return hopyo_excel._text(value) + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None: + for col in cols: + hit = _CODE.match(_s(_cell(row, col))) + if hit: + return hit.group(1) + return None + + +def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]: + """목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸 + 「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열).""" + out: dict[str, dict[str, str]] = {} + for title in LISTS: + for row in sheets.get(title) or []: + meta = _META.search(" ".join(_s(v) for v in row)) + code = meta.group(1) if meta else _code(row, range(len(row))) + if code and _s(_cell(row, 1)): + out[code] = { + "이름": _s(row[1]), + "규격": _s(_cell(row, 2)), + "단위": _s(_cell(row, 3)), + } + return out + + +# ── 단가산출근거 블록 ────────────────────────────────────────────────── +def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]: + """{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}.""" + names = hopyo_basis.trade_names() + out: dict[str, dict[str, Any]] = {} + block: dict[str, Any] | None = None + title = "" + for at, row in enumerate(rows, 1): + head = next( + (hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None + ) # 머리 = G 열(판마다 B 열 글이 다름) + if head: + code = head.group(1) + info = table.get(code) or {} + block = out.setdefault( + code, + { + "번": int(code[1:]), + "코드": code, + "이름": info.get("이름", ""), + "규격": info.get("규격", ""), + "단위": info.get("단위", ""), + "행": [at, at], + "글": [], + "자원": [], + }, + ) + title = "" + continue + if block is None: + continue + first = _flat(_cell(row, 0)) + if first.startswith("총계"): + block["행"][1] = at + block = None + continue + text = _s(_cell(row, 1)) + res = _RES.match(_s(_cell(row, RES_COL))) + code = res.group(1) + res.group(2) if res else None + if not code: + alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6))) + code = alt.group(1) if alt else None + if not text and not code: + continue + block["행"][1] = at + if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」 + if not block["이름"]: + name, _, unit = text.rpartition("/") + block["이름"], block["단위"] = name.strip() or text, unit.strip() + continue + if text and _flat(text) not in ("소계", "계"): + if len(block["글"]) < hopyo_basis.GLINES: + block["글"].append(text[: hopyo_basis.GCHARS]) + if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text): + title = sec.group(1).strip() + if not code: + continue + last = block["자원"][-1] if block["자원"] else None + if code[0] == "X" and last and last["코드"] == code and last["절"] == title: + continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 + name, _, spec = _s(_cell(row, NAME_COL)).partition("/") + info = table.get(code) or {} + qty = hopyo_excel._num(_cell(row, QTY_COL)) + block["자원"].append( + { + "갈래": code[0], + "코드": code, + "이름": name.strip() or info.get("이름", ""), + "규격": spec.strip() if name.strip() else info.get("규격", ""), + "품": float(qty) if qty is not None else None, + "절": title, + } + ) + for block in out.values(): + block["출처"] = hopyo_basis.sources(" ".join(block["글"])) + trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"] + block["직종"] = list(dict.fromkeys(t for t in trades if t)) + return out + + +# ── 일위대가표 ──────────────────────────────────────────────────────── +def _kind(code: str, line: dict[str, Any]) -> str: + if code.startswith("PR") or _flat(line["단위"]) == "%": + return "%" + if code[0] == "M": + apart = line["비고"].startswith("별산") or line["규격"] == "별도계상" + return "별도계상" if apart or not any(line["단가"].values()) else "자재" + return _KIND.get(code[0], "기타") + + +def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]: + hopyos: list[dict[str, Any]] = [] + raw: list[list[dict[str, Any]]] = [] + want_head = done = False + for at, row in enumerate(rows, 1): + first = _s(_cell(row, 0)) + hit = hopyo_excel._HOPYO.fullmatch(_flat(first)) + if hit: + code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None) + hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []}) + raw.append([]) + want_head, done = True, False + continue + if not hopyos or done: + continue + hopyo = hopyos[-1] + if want_head: + hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3))) + hopyo["금액"] = {} + want_head = False + continue + if _flat(first).startswith("합계") or _flat(first) == "계": + hopyo["금액"] = hopyo_excel._money(row, _COLS, 1) + done = True # 끝 — 다음 호표까지 줄 안 받음 + continue + if not first: + continue + code = _code(row) + hopyo["행"][1] = at + if not code: + if re.search(r"[0-9A-Za-z가-힣]", first): + hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip()) + continue + if code[0] in "TC" and code[1].isdigit(): + continue # 계 · 소계 · 계약단가 + line = { + "차례": len(raw[-1]), + "이름": first, + "규격": _s(_cell(row, 1)), + "수량": hopyo_excel._num(_cell(row, 2)), + "단위": _s(_cell(row, 3)), + "단가": hopyo_excel._money(row, _COLS, 0), + "금액": hopyo_excel._money(row, _COLS, 1), + "비고": _s(_cell(row, NOTE)), + "코드": code, + "행": at, + } + line["갈래"] = _kind(code, line) + line["참조"] = ( + {"종류": "단산", "번": int(code[1:]), "코드": code} + if code[0] == "D" + else {"종류": "일위", "번": None, "코드": code} + if code[0] == "B" + else None + ) + raw[-1].append(line) + return hopyos, raw + + +def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]: + """STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수).""" + hopyos, raw = _hopyos(sheets.get(SHEET) or []) + if not hopyos: + raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + table = _lists(sheets) + blocks = _blocks(sheets.get(CALC) or [], table) + by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]} + for line in (one for lines in raw for one in lines): + ref = line["참조"] or {} + if ref.get("종류") == "일위": + ref["번"] = by_code.get(ref["코드"]) + elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])): + line["근거"] = {k: block[k] for k in ("출처", "직종", "행")} + out = [] + for hopyo, lines in zip(hopyos, raw, strict=True): + for key in ("이름", "규격", "단위"): + hopyo.setdefault(key, "") + if not hopyo["근거글"]: + del hopyo["근거글"] + out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]}) + keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원") + return { + "꼴": "STmate", + "시트": SHEET, + "호표": out, + "단산": [{k: b[k] for k in keep} for b in blocks.values()], + "검산": {"어긋남": [], "총괄표": 0}, + } diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 8bc0a880b..1adb7f3be 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -4,6 +4,7 @@ 후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · 잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · 블록 글 HP(338-2a · 268 · 139 이음 그대로) · `clause` 윗마디 0.9 · 조항 오타 13-114 · +거창 꼴 흩어진 칸 이어 붙임(단산 줄 21 · 출처 17 · 직종 21 · 338-2b) · master_data 그대로. """ @@ -152,3 +153,19 @@ def test_조항_윗마디_오타(north): assert hb.sources("[산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] stone = next(b for b in north["단산"] if b["번"] == 63) assert stone["출처"] == [["산림품셈", "13-11-4"]] + + +def test_거창_흩어진_칸(): + path = ( + base.ROOT + / "resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서" + / "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" + ) + book = hx.read_book(path.name, path.read_bytes()) + lines = [x for h in book["호표"] for x in h["줄"] if x["갈래"] == "단가산출"] + assert len(lines) == 21 and all(x.get("근거") for x in lines) + assert sum(1 for x in lines if x["근거"]["출처"]) == 17 + assert sum(1 for x in lines if x["근거"]["직종"]) == 21 + first = book["단산"][0] + assert first["이름"] == "측구터파기(토사)" and first["직종"] == ["보통인부"] + assert any(t.startswith("보통인부 : 172,068") for t in first["글"]) diff --git a/resources/tester/test_m02_hopyo_excel.py b/resources/tester/test_m02_hopyo_excel.py index 6e6b14fdf..7129e28de 100644 --- a/resources/tester/test_m02_hopyo_excel.py +++ b/resources/tester/test_m02_hopyo_excel.py @@ -2,7 +2,8 @@ 견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) · 검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 · -양식 목록에 안 섞임 · +양식 목록에 안 섞임 · 이음 넓힘(산근 · 대가 · 호표 · 이름으로 이음) · 노임 갈래 · +문서 보안 400(338) · master_data 원본 그대로(sha256). 층은 임시 폴더. """ @@ -207,3 +208,54 @@ def test_마스터_층은_마스터_폴더(client): client.who.update(role="USER") assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나 assert client.delete(URL.format("system") + "/HP-000001").status_code == 403 + + +def _linked_xlsx() -> bytes: + """338 — 비고 「산근 2 호표」 · 「대가1」 · 빈 비고 + 이름 같은 블록 · 노임 줄.""" + import openpyxl + + book = openpyxl.Workbook() + ws = book.active + ws.title = "일위대가" + ws.append([None, "호표", "품명", "규격", "수량", "단위", "합계", None, "노무비", None, "비고"]) + ws.append([None, None, None, None, None, None, "단가", "금액", "단가", "금액"]) + ws.append([None, "제1호표", "표지석", "", None, "개소", None, 1000, None, 1000]) + ws.append([None, None, "터파기", "토사", 1, "㎥", 100, 100, 100, 100, "산근 2 호표"]) + ws.append([None, None, "되메우기", "", 1, "㎥", 100, 100, 100, 100, None]) + ws.append([None, None, "보통인부", "", 1, "인", 100, 100, 100, 100, None]) + ws.append([None, None, "호표 줄", "", 1, "개소", 100, 100, 100, 100, "대가1"]) + calc = book.create_sheet("단가산출") + calc.append([None, "산출근거", "합계", "노무비"]) + calc.append([None, "2.터파기[㎥]"]) + calc.append([None, " [건설공사표준품셈 3-2-1]"]) + calc.append([None, "3.되메우기[㎥]"]) + calc.append([None, " 보통인부 : 100 × 1 = 100"]) + out = io.BytesIO() + book.save(out) + return out.getvalue() + + +def test_이음_넓힘_노임(): + got = hx.read_book("a.xlsx", _linked_xlsx()) + lines = {x["이름"]: x for x in got["호표"][0]["줄"]} + assert lines["터파기"]["참조"] == {"종류": "단산", "번": 2} and "이음" not in lines["터파기"] + assert lines["터파기"]["근거"]["출처"] == [["건설품셈", "3-2-1"]] + assert lines["되메우기"]["참조"] == {"종류": "단산", "번": 3} # 빈 비고 · 이름 같은 블록 하나 + assert lines["되메우기"]["이음"] == "이름" and lines["되메우기"]["갈래"] == "단가산출" + assert lines["되메우기"]["근거"]["직종"] == ["보통인부"] + assert lines["보통인부"]["갈래"] == "노임" and "이음" not in lines["보통인부"] + assert lines["호표 줄"]["참조"] == {"종류": "일위", "번": 1} + for note, want in ( + ("단산12참조", ("단산", 12)), + ("단가산출 3", ("단산", 3)), + ("일위대가4호표", ("일위", 4)), + ("일위 5 참조", ("일위", 5)), + ): + hit = hx._REF.match(hx._flat(note)) + assert ("단산" if hit.group(1) else "일위", int(hit.group(3))) == want + assert hx._REF.match("단산참조") is None and hx._REF.match("D96") is None + + +def test_문서_보안_400(): + with pytest.raises(hx.ExcelError, match="문서 보안"): + hx.read_book("a.xlsx", bytes([0x9B]) + b" DRMONE This Document is secured" + bytes(64)) diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 58c6cac90..4db5efaa0 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -2,6 +2,7 @@ 북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 · 제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 · +STmate 격자(일위대가표 · 단가산출근거 · 숨은 칸 글 뺌 · 338) · master_data 그대로. """ @@ -131,3 +132,22 @@ def test_xlsx_값만(): assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5} assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지 assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == [] + + +def test_STmate_격자(): + from resources.tester.test_m02_hopyo_stmate import _path + + path = _path("영월") + data = path.read_bytes() + parsed = hx.read_book(path.name, data) + grid = hg.read(path.name, data, parsed) + assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"] + cells = _sheet(grid, "일위대가표")["칸"] + one = grid["호표"][0] + assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]} + assert cells[f"A{one['행'][0]}"]["값"].strip().startswith("제") + assert cells["A10"]["값"] == "보통인부" + calc = _sheet(grid, "단가산출근거")["칸"] + block = next(b for b in grid["단산"] if b["번"] == 2) + assert calc[f"B{block['행'][0] + 1}"]["값"].startswith("구조물터파기(인력)") + assert not any(str(c.get("값")).startswith("_x000") for c in {**cells, **calc}.values()) diff --git a/resources/tester/test_m02_hopyo_stmate.py b/resources/tester/test_m02_hopyo_stmate.py new file mode 100644 index 000000000..82ad43569 --- /dev/null +++ b/resources/tester/test_m02_hopyo_stmate.py @@ -0,0 +1,185 @@ +"""일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b · `M02_Hopyo_Stmate`). + +여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) · +봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 · +노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) · +문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로. +""" + +import hashlib +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Basis as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Stmate as st +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ROOT, _post, master_hash + +client = base.client # 공용 고정물(임시 층) +DOCS = ROOT / "resources" / "knowledge" / "original" / "실무문서" +FILES = { + "봉화": "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", + "영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", + "대흥설계": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/" + "2024년 간선임도사업(기번3).xlsx", + "대흥착공": "2024년 간선임도(기번3-울진.대흥)(공통)/" + "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx", + "영덕": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/" + "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx", + "소광": "25년 산불진화임도(기번8)(울진소광)/" + "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", +} +HOPYOS = {"봉화": 45, "영월": 23, "대흥설계": 34, "대흥착공": 34, "영덕": 9, "소광": 25} +LOCKED = { + "장수": "02.산불 장수/01.내역서/" + "00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1)내역+수량.xlsx", + "진안": "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2)내역+수량.xlsx", +} + + +def _path(tag: str) -> Path: + return DOCS / {**FILES, **LOCKED}[tag] + + +@pytest.fixture(scope="module") +def books() -> dict[str, dict]: + return {tag: hx.read_book(_path(tag).name, _path(tag).read_bytes()) for tag in FILES} + + +def _lines(book: dict) -> list[dict]: + return [x for h in book["호표"] for x in h["줄"]] + + +def test_여섯_파일_호표_수(books): + assert {tag: len(b["호표"]) for tag, b in books.items()} == HOPYOS + for book in books.values(): + assert (book["꼴"], book["시트"]) == ("STmate", "일위대가표") + assert book["검산"] == {"어긋남": [], "총괄표": 0} + assert len(_lines(books["대흥설계"])) == len(_lines(books["대흥착공"])) == 171 # 같은 설계 + + +def test_봉화_줄_갈래(books): + bong = books["봉화"] + lines = _lines(bong) + kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "노임", "호표", "%")} + assert kinds == {"단가산출": 103, "노임": 30, "호표": 22, "%": 7} + assert len(lines) + sum(len(h.get("근거글") or []) for h in bong["호표"]) == 216 + assert all(x["코드"] for x in lines) + for book in books.values(): + for x in _lines(book): + if x["갈래"] == "호표": + assert x["참조"]["번"] in {h["번"] for h in book["호표"]} + if x["갈래"] == "단가산출": + assert x["참조"] == {"종류": "단산", "번": int(x["코드"][1:]), "코드": x["코드"]} + assert "근거" in x # 블록 모두 이어짐 + by = {h["번"]: h for h in bong["호표"]} + assert by[13]["근거글"] == ["건설표준품셈 7-2-2(찰붙임)"] # 코드 없는 줄 = 호표 근거글 + assert hb.sources(" ".join(by[13]["근거글"])) == [["건설품셈", "7-2-2"]] + + +def test_영월_줄과_블록(books): + yw = books["영월"] + one = yw["호표"][0] + assert (one["번"], one["코드"], one["이름"], one["단위"]) == ( + 1, + "B00260", + "녹생토(종자배합)", + "M3", + ) + assert one["행"] == [5, 10] and one["금액"]["계"] == 155184 + assert [(x["이름"], x["코드"], x["갈래"], x["수량"]) for x in one["줄"]] == [ + ("산림종자", "M00024", "자재", 0.25), + ("산림종자", "M00211", "자재", 0.25), + ("인공토양(녹생토)", "M00832", "자재", 1.1), + ("보통인부", "L00002", "노임", 0.43), + ] + two = {x["이름"]: x for x in yw["호표"][1]["줄"]} + assert two["국가지점번호판 검증수수료"]["갈래"] == "별도계상" # 비고 「별산」 + assert two["구조물터파기(인력)"]["근거"] == { + "출처": [["건설품셈", "3-1-3"]], + "직종": ["보통인부"], + "행": [5, 54], + } + block = next(b for b in yw["단산"] if b["코드"] == "D00002") + assert (block["번"], block["이름"], block["규격"], block["단위"]) == ( + 2, + "구조물터파기(인력)", + "경질토사", + "M3", + ) + assert block["자원"] == [ + {"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.26, "절": ""} + ] + assert any("건설표준품셈3-1-3" in t for t in block["글"]) + + +def test_기계_자원_하나로(books): + block = next(b for b in books["봉화"]["단산"] if b["코드"] == "D00002") + machines = [r for r in block["자원"] if r["갈래"] == "X"] + assert len(machines) == 1 # 노무비 · 재료비 · 경비 셋 줄 + assert machines[0]["이름"] == "굴삭기(무한궤도)" and machines[0]["규격"] == "0.2㎥" + assert machines[0]["품"] == pytest.approx(0.0636537237529) + + +def test_노임_이름_어긋남_0(books): + names = hb.trade_names() + for tag in FILES: + sheets = hx._sheets(_path(tag).name, _path(tag).read_bytes()) + table = st._lists(sheets) + res = [r for b in books[tag]["단산"] for r in b["자원"] if r["갈래"] == "L" and r["이름"]] + assert res + assert sum(1 for r in res if r["코드"] in table) >= len(res) // 2 + for r in res: + if r["코드"] in table: # 목록표에 있는 직종 — X 열 이름과 같음 + assert st._flat(r["이름"]) == st._flat(table[r["코드"]]["이름"]) + if table.get(r["코드"], {}).get("단위") == "인": # L 코드 % 줄(기구손료) 뺌 + assert st._flat(r["이름"]) in names, (tag, r) + labor = [x for x in _lines(books[tag]) if x["갈래"] == "노임"] + assert all(st._flat(x["이름"]) in names for x in labor) + + +def test_이음_열쇠_코드_칸(): + row = [None] * 26 + row[12], row[14], row[15] = "별산M00985", "M00985_1", "T7_1" + assert st._code(row) == "M00985" + row = [None] * 26 + row[25] = "D01352 →" # O 열 없는 판 + assert st._code(row) == "D01352" + row = [None] * 47 + row[14], row[13] = "PRA01_1", "_x0007_`COD|PR_x0005_`EXI" + assert st._code(row) == "PRA01" + assert ( + st._code(["계", None, None, None, None, None, None, None] + [None] * 6 + ["T7_1"]) == "T7" + ) + assert st._code([None] * 12 + ["D96"]) is None # 비고 글은 코드 아님 + + +def test_문서_보안_400(client): + for tag in LOCKED: + data = _path(tag).read_bytes() + with pytest.raises(hx.ExcelError, match="문서 보안"): + hx.read_book(_path(tag).name, data) + res = _post(client, data=data, name=_path(tag).name) + assert res.status_code == 400 and res.json()["detail"] == hx.DRM + + +def test_여섯_파일_올라감(client): + before = master_hash() + sources = {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} + for at, tag in enumerate(FILES, 1): + res = _post(client, data=_path(tag).read_bytes(), name=_path(tag).name) + assert res.status_code == 200, res.text + assert res.json()["호표수"] == HOPYOS[tag] + doc = hs.read(layers.personal_dir(7, 42), f"HP-{at:06d}") + assert doc["꼴"] == "STmate" + assert sum(1 for b in doc["단산"] if not b["글"]) <= 3 # 봉화 무대처리 · 흙 쌓 기 = 값 0 + assert any(b["자원"] for b in doc["단산"]) + first = doc["호표"][0]["번"] + view = client.get(f"{base.URL.format('personal')}/HP-{at:06d}/{first}") + assert view.status_code == 200, view.text # 맞춤 보기가 STmate 줄을 받음 + assert {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} == sources + assert master_hash() == before From 75b8815dac6dd12a0f8162a639016d2139fd9667 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:30:44 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test(M02=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80):=20338-2c=20=EC=98=81=EC=9B=94=20=EC=A0=95=EB=8B=B5?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC=20y=5Fids=20=E2=80=94=20D=20=C2=B7=20X=20?= =?UTF-8?q?=EA=B3=A0=EC=9C=A0=20=EC=A4=84=2036(Y000~Y035)=20=EC=8B=A4?= =?UTF-8?q?=ED=97=98=20C1=20=EC=B0=A8=EB=A1=80=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B3=A8=EB=93=A0=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- resources/tester/test_m02_hopyo_stmate.py | 34 ++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m02_hopyo_stmate.py b/resources/tester/test_m02_hopyo_stmate.py index 82ad43569..671a2daf9 100644 --- a/resources/tester/test_m02_hopyo_stmate.py +++ b/resources/tester/test_m02_hopyo_stmate.py @@ -3,7 +3,8 @@ 여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) · 봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 · 노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) · -문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로. +문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로 · +영월 정답 자리(`y_ids` — 338 실험 C1 `labels_y` Y000~Y035 = D · X 고유 줄 차례 · 골든 시험이 씀). """ import hashlib @@ -183,3 +184,34 @@ def test_여섯_파일_올라감(client): assert view.status_code == 200, view.text # 맞춤 보기가 STmate 줄을 받음 assert {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} == sources assert master_hash() == before + + +def y_ids(book: dict) -> dict[str, dict]: + """영월 정답 자리 — D · X 줄을 (이름, 규격, 단위, 코드) 고유로 처음 나온 차례 → + `{"Y000": {호표, 차례, 열쇠}}`(338 실험 C1 `s09_stmate` 와 같은 번).""" + out: dict[str, dict] = {} + seen: set[tuple] = set() + for hopyo in book["호표"]: + for line in hopyo["줄"]: + key = (line["이름"], line["규격"], line["단위"], line["코드"]) + if line["코드"][0] in "DX" and key not in seen: + seen.add(key) + out[f"Y{len(out):03d}"] = {"호표": hopyo["번"], "차례": line["차례"], "열쇠": key} + return out + + +def test_영월_정답_자리(books): + got = y_ids(books["영월"]) + assert len(got) == 36 # 정답 34 + 우리에 없음 둘(Y004 · Y007) + assert got["Y000"] == { + "호표": 2, + "차례": 1, + "열쇠": ("무근콘크리트타설", "인력운반타설", "M3", "D02058"), + } + assert got["Y005"]["열쇠"] == ("크레인(타이어)", "10톤", "시간", "X02104") + assert got["Y010"]["열쇠"][3] == "D00060" and got["Y035"]["열쇠"][3] == "D01810" + by = {h["번"]: h for h in books["영월"]["호표"]} + for one in got.values(): + line = by[one["호표"]]["줄"][one["차례"]] + assert (line["이름"], line["규격"], line["단위"], line["코드"]) == one["열쇠"] + assert line["갈래"] == "기계" or "근거" in line # 단산 줄은 블록 이음