Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-10-15 23:36:37 +09:00
14 changed files with 2022 additions and 240 deletions
+43 -14
View File
@@ -5,8 +5,11 @@
다음 머리 앞까지 한 블록 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로).
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글(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 · 직종도 뜻 낱말도 안 겹치면 버림 ·
같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이
하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 ·
@@ -31,6 +34,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, WIDE = 200, 300, 140
_TYPOS = (("깍기", "깎기"),)
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
@@ -41,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)."""
@@ -66,35 +77,46 @@ 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
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, {}
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}`
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액}}`
(행 = 엑셀 1부터)."""
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)),
@@ -105,6 +127,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 +140,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 +188,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 +235,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 +256,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}))
+219 -109
View File
@@ -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)}
+44 -6
View File
@@ -7,13 +7,19 @@
호표 줄 = 「제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(줄 금액 합) · 총괄표 합계 —
어긋남은 글 목록(막지 않음).
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
`단산` = 블록마다 `{번, 이름, 단위, 행}`.
`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) ·
`꼴` = 「엑셀꼴」.
"""
from __future__ import annotations
@@ -24,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 = "일위대가총괄표"
@@ -31,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")
@@ -76,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
@@ -142,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("경비"):
@@ -207,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)
@@ -237,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_,
@@ -247,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
@@ -255,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["번"])):
@@ -270,8 +306,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,
+6 -4
View File
@@ -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}
+267
View File
@@ -0,0 +1,267 @@
"""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 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}」")
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}」")
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]:
"""장부 한 줄 재검사(장부 화면 [저장]) → 쓸 꼴(누가 · 때 빼고)."""
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)}
+264
View File
@@ -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"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
_RES = re.compile(r"^([LMXS])(\d{5})_\d+$")
_RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
_COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
def is_stmate(names: Any) -> 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},
}
+114 -30
View File
@@ -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}")
@@ -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)] <r>:」 [이 · 첫 줄 한국어 꼴 기] | 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)] <I>:」 → 「원의 반지름 지정:」 [이] | 모서리(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) | 「문자의 시작점 지정:」 → 「높이 지정 <h>:」 → 「문자의 회전 각도 지정 <0>:」 → 글 줄(커서 곁 글 칸 · Enter = 아래 새 줄 · 빈 줄 Enter = 끝) [이] | 뺌: 자리맞추기(J) · 스타일(S) | 시작점 = 직전 명령도 TEXT 면 앞 글 아래 · 높이 · 각 안 물음[공] · 높이 기억(TEXTSIZE · 처음 지금 글자 크기) · 각 기억(TEXT.ANGLE · 0) | 끝(마친 줄 남음 · 치던 줄 버림[기]) | 시작점 → 한 줄 · 높이 · 각 안 물음 · 줄 이어 치기 없음 |
| MTEXT(T · MT) | 「첫 번째 구석 지정:」 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 글(편집기 대신 커서 곁 글 칸 · `\P` 줄바꿈 그대로) [이] | 높이(H)·HEIGHT → 「높이 지정 <h>:」 → 다시 반대 구석 · 폭(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 → 「모깎기 반지름 지정 <r>:」[옮] → 같은 물음 · 자르기(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 → 「첫 번째 모따기 거리 지정 <a>:」 → 「두 번째 모따기 거리 지정 <a>:」[옮] · 자르기(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 → 「열 사이의 거리 지정 <d>:」 → 「행 사이의 거리 지정 <d>:」 · 열(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`(원문 그대로) · 그 밖 물음에서 `'` 로 시작하면 바로 ⑧.
④ 점 물음 → 동적 각도 칸 숫자 · `<a` = 각도 잠금(지금 그대로) → 스냅 이름 · FROM · M2P · `@`(가-2) → ⑤ → 좌표 · 거리(`#` · `*` 뗌 · 기준점 없는 첫 점 `@dx,dy` = 마지막 점 기준 · 기준점 없는 거리 숫자 = ⑩ · `prompt.number` 있으면 거리 숫자도 그 검사 — CIRCLE 반지름 −5 거절) → ⑩.
⑤ 옵션(숨김 포함) 맞음 → `OPTION.키` · absent → 안내.
⑥ 숫자 물음 → 글 전체가 수(`NUMBER_REGEXP`)일 때만 + `number` 검사 → `NUMBER_INPUT` · 아니면 ⑩(N5 · 앞 숫자만 먹지 않음).
⑦ 키워드 · 고르기 · 객체 물음 → ⑤ 밖은 ⑩.
⑧ `'이름` → 즉시형(machine 없음) = 지금 도구 둔 채 실행 · 도구형 = 「「LINE」 명령은 투명하게 쓸 수 없음.」.
⑨ 쉼 → 이름 > 내 별칭 > 기본 별칭 **딱 맞음만** `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<key, StepOption>` | `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/<GUID>.htm`).
- 키워드 줄임 규칙 — AutoLISP `initget`(2015 ENU `GUID-9ED8841B`) 「LType … LT · LTY · LTYP 됨 · L 모자람 · 대소문자 무관」.
- 거절 글 「점 또는 옵션 키워드를 요구함.」 — 오토캐드 한국어판 사용자 글(gocad.co.kr 질문 · 2차) · 나머지 거절 글 = 영문 기억 옮김.
- 영문 줄 · 동작 = 부록4 K1(E1 · E2 표) · 코드 줄 = 부록4 K2 + 이번 다시 읽음.
+52
View File
@@ -3,6 +3,8 @@
블록 북부청 95 · 사방 67 · 단산93 근거 [건설 3-2-1] · [특별인부] ·
후보 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 그대로.
"""
@@ -117,3 +119,53 @@ 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"]]
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["글"])
+161 -76
View File
@@ -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
+53 -1
View File
@@ -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))
+20
View File
@@ -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())
+149
View File
@@ -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
+217
View File
@@ -0,0 +1,217 @@
"""일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b · `M02_Hopyo_Stmate`).
여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) ·
봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 ·
노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) ·
문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로 ·
영월 정답 자리(`y_ids` — 338 실험 C1 `labels_y` Y000~Y035 = D · X 고유 줄 차례 · 골든 시험이 씀).
"""
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
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 # 단산 줄은 블록 이음