Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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}))
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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 + 이번 다시 읽음.
|
||||
@@ -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["글"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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 # 단산 줄은 블록 이음
|
||||
Reference in New Issue
Block a user