From be8868ca521168d8b2b7c9289ef688e4f0e556c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 19:50:27 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(M01):=20=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=83=88=20=EB=A1=9C=EC=A7=81=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EC=A4=84=EB=A1=9C=20=EC=A0=81=EC=9D=80?= =?UTF-8?q?=20=EC=8B=9D=EC=9D=98=20=EB=B3=80=EC=88=98=20=EB=BD=91=EA=B8=B0?= =?UTF-8?q?=C2=B7=EA=B2=80=EC=82=AC=C2=B7=EB=8B=A8=EC=9C=84=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=EA=B3=BC=20=EB=A1=9C=EC=A7=81=20=ED=8B=80=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 해석기 scripts/master_draft.py — 「이름 = 식」 줄에서 변수(처음 나온 줄 · 정의됨/미정)와 변수마다 정할 것(무엇 = 마스터요소·설계입력·고정값·표찾기 · 단위 · 비목)을 뽑음 · 검사 — 두 번 정의 · 서로 물고 돎 · 안 쓰임(결과가 하나인지) · 못 읽음 · 비목 합 이름 · 단위 검사 — 곱셈은 곱하고 나눗셈은 나누고 덧셈·뺄셈·만약·최소·최대는 같아야 함 · 어긋나면 그 줄과 글자 자리 · 「m * m」 과 「㎡」 는 같게 봄 · 비율(%)은 무차원 · 옮기기 — 돈 로직은 결과 식을 항으로 갈라 호표 줄(마스터요소 하나 + 나머지 곱이 수량)과 덧줄로 · 나머지 정의 줄은 중간(쓰는 차례) · 돈 아닌 로직은 중간 + 결과 식 · 표 찾기는 글자로 안 침 — 변수 하나로 두고 「찾기」 묶음을 모달이 채울 자리만 · 서버 길 POST /api/m01/logic/draft — 검사·미리보기 · save 면 자체 로직(키 GX)으로 저장 · 계약 _화면_계약.md 7장 · 시험 test_m01_draft.py 13개(자재비 식 · 단위 어긋난 식 · 물고 도는 식) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- M01_MasterData/M01_MasterData_Router.py | 14 + M01_MasterData/M01_MasterData_Store_Make.py | 30 + resources/master_data/_화면_계약.md | 18 + resources/master_data/scripts/master_draft.py | 574 ++++++++++++++++++ resources/tester/test_m01_draft.py | 233 +++++++ 5 files changed, 869 insertions(+) create mode 100644 resources/master_data/scripts/master_draft.py create mode 100644 resources/tester/test_m01_draft.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 2edbe080..eb949844 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -63,6 +63,14 @@ class DeleteLogic(BaseModel): version: str +class DraftLogic(BaseModel): + text: str # 줄로 적은 식 — 줄마다 「이름 = 식」 + decided: dict[str, Any] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}} + head: dict[str, Any] | None = None # 로직 머리(이름 · 결과단위 …) — 주면 로직 줄까지 + save: bool = False + owner: str = make.OWNERS[0] + + def _num(v): """Decimal → JSON 수(그냥 두면 글자로 나감).""" if isinstance(v, Decimal): @@ -208,3 +216,9 @@ def post_logic_edit(body: EditLogic) -> dict: @router.post("/logic/delete") def post_logic_delete(body: DeleteLogic) -> dict: return _call(make.logic_delete, body.key, body.version) + + +@router.post("/logic/draft") +def post_logic_draft(body: DraftLogic) -> dict: + """식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 로직 줄로 옮기기(`save` 면 저장까지).""" + return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner) diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 9cf6377c..7fe6e59c 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -237,3 +237,33 @@ def logic_delete(key: str, version: str) -> dict: if store.read(OWN)[1] != version: raise store.StoreError(409, {"stale": [OWN]}) return _write([{"op": "delete", "key": key}]) + + +# ── 식으로 만들기 ────────────────────────────────────────────────────── +def logic_draft( + text: str, + decided: dict | None = None, + head: dict | None = None, + save: bool = False, + owner: str = OWNERS[0], +) -> dict: + """줄로 적은 식 → 변수 · 검사(`_화면_계약.md` 7장). + + `head`(이름 · 결과단위 …)가 오면 로직 한 줄까지 미리 보이고, `save` 면 다 정해진 때만 + 자체 로직으로 저장(키 GX 는 서버) · 덜 정했거나 걸리면 422 로 아무것도 안 씀. + """ + import master_draft as draft # store 가 scripts 를 길에 올린 뒤라야 함 + + got = draft.read(text, decided) + if head is None: + return {**got, "logic": None} + row, bad = draft.옮기기(text, decided, head) + got["문제"] = got["문제"] + [x for x in bad if x not in got["문제"]] + got["다됨"] = not got["문제"] + if save and got["문제"]: + raise store.StoreError( + 422, {"errors": [f"{x['줄']}줄 · {x['말']}" for x in got["문제"]], "문제": got["문제"]} + ) + if not save: + return {**got, "logic": row or None} + return {**got, **logic_new(row, owner)} diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index e2c32f6b..38f856f7 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -106,3 +106,21 @@ - `자리` = 소수 자리(0 = 원 미만 · 3 = 소수 넷째 자리에서). - `방법` — `버림` · `올림` · `반올림`. - 옛 줄의 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적어 둔 설명 — 계산에 안 붙음(그 끊기는 이미 식 안에 있음). + +## 7. 식으로 만들기 + +`POST /logic/draft` 받음 `{text, decided, head?, save?, owner?}` — 줄로 적은 식을 받아 변수를 뽑고 검사한 뒤 로직 한 줄로 옮김(해석기 `scripts/master_draft.py`). + +- `text` = 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · **마지막 줄 왼쪽이 결과 변수**. 식 글자는 `_틀.md` 8장 범위만. +- `decided` = 변수마다 정한 것 `{이름: {무엇, 단위, 비목, 요소 | 찾기 | 값, 설명 · 고르기 · 범위}}`. + - `무엇` — `마스터요소`(키 또는 재료 고르기 조건을 `요소` 에 · `비목` 도 정함) · `설계입력`(입력 칸이 됨) · `고정값`(`값` 을 중간으로) · `표찾기`(`찾기` = `{표, 값칸, 조건}` → `찾기(…).값칸` 을 중간으로). + - `단위` — 마스터요소는 그 줄의 단위(「인」 · 「포」)를 적음. 식에서는 단가라 `원/단위` 로 셈. + - 비목 합 이름(`노무비` · `재료비` · `경비` · `계`)은 정하지 않음 — 덧줄에서만 쓰는 이름. +- `head` = 로직 머리(`이름` · `결과단위` · `원문번호` · `출처` · `비고` · `끝수`). 없으면 검사만 함. +- 줌 `{줄, 변수, 결과, 문제, 다됨, logic}` — `logic` = 옮긴 로직 한 줄(`head` 가 있을 때만 · 저장 전). + - `변수` = `{이름, 처음, 갈래, 정의줄, 쓰임, 무엇, 단위, 비목, 정할것}` · `갈래` = `정의됨`(왼쪽에 나옴) · `미정`(쓰기만 함) · `비목합` · `정할것` = 아직 안 정한 칸 이름들. + - `문제` = `{줄, 자리, 갈래, 말}` · `자리` = 그 줄의 글자 자리(모르면 null) · `갈래` = `못읽음` · `이름` · `두번정의` · `돌고돎` · `안쓰임`(= 결과가 하나인지) · `단위` · `덜정함` · `항` · `비목합`. + - 단위 검사 — 곱셈은 단위를 곱하고 나눗셈은 나눔 · 덧셈·뺄셈과 `만약`·`최소`·`최대`·비교는 단위가 같아야 함. 「m \* m」 과 「㎡」 는 같게 봄. 모르는 단위는 안 따짐. +- `save` = true 면 `POST /logic/new` 와 같은 자리에 저장(키 `GX` 는 서버 · 소유 `owner`) → 답에 `{file, version, key, logic}` 이 더 옴. 문제가 하나라도 있으면 422 `{detail: {errors, 문제}}` 로 아무것도 안 씀. +- 옮기는 법 — 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다: 마스터요소 변수 하나면 **호표 줄**(나머지 곱이 `수량`) · 비목만 정한 변수면 **덧줄** · 그 밖은 문제 `항`. 나머지 정의 줄은 `중간`(쓰는 차례대로). 돈 아닌 로직은 호표 없이 `중간` + `결과` 식. +- 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 `무엇` 을 `표찾기` 로 정할 때 모달이 `찾기` 묶음을 채움. diff --git a/resources/master_data/scripts/master_draft.py b/resources/master_data/scripts/master_draft.py new file mode 100644 index 00000000..f32a7b90 --- /dev/null +++ b/resources/master_data/scripts/master_draft.py @@ -0,0 +1,574 @@ +# -*- coding: utf-8 -*- +"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 7장). + + 수량 = 길이 * 폭 + 자재비 = 수량 * 시멘트 + +· 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄 왼쪽이 결과 변수 +· 식 글자는 `_틀.md` 8장 범위만 — 읽기는 `master_formula` 가 함 +· 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 「무엇」 을 `표찾기` 로 정할 때 이음 +· 값은 안 씀 — 검사만 하고 `옮기기` 가 로직 한 줄을 만듦(저장은 `Store_Make.logic_new`) +""" + +from __future__ import annotations + +import re + +import master_formula as mf +import master_keys as mk + +WHAT = ("마스터요소", "설계입력", "고정값", "표찾기") +COST_ITEMS = mf.COST_ITEMS +BUNDLE = mf.BUNDLE # 노무비 · 재료비 · 경비 · 계 — 덧줄에서만 쓰는 비목 합 +MONEY = {"원": 1} +# 같은 단위의 다른 표기 — 넓이·부피는 길이의 제곱으로 펴서 「m * m」 과 「㎡」 가 같아지게 +ALIAS = { + "㎡": ("m", 2), + "m²": ("m", 2), + "m2": ("m", 2), + "㎥": ("m", 3), + "m³": ("m", 3), + "m3": ("m", 3), + "M": ("m", 1), +} +SQUARED = {2: "㎡", 3: "㎥"} +DIMLESS = ("%", "할") # 비율은 단위가 없음 — 「1 + 증가율 / 100」 이 안 걸리게 +_ASSIGN = re.compile(r"^\s*([\w가-힣]+)\s*=(?!=)\s*(.*)$") + + +# ── 자리를 적어 두는 읽기 ────────────────────────────────────────────── +class _Pos(mf._Parser): + """`master_formula` 와 같은 식 문법 · 단위가 어긋난 자리를 짚으려고 마디에 글자 자리를 붙임.""" + + def add(self): + node = self.mul() + while True: + self.ws() + at = self.i + if self.take("+"): + node = ("bin", "+", node, self.mul(), at) + elif self.take("-"): + node = ("bin", "-", node, self.mul(), at) + else: + return node + + def compare(self): + node = self.add() + for op in mf._OPS: + self.ws() + at = self.i + if self.take(op): + return ("bin", op, node, self.add(), at) + return node + + def primary(self): + self.ws() + at = self.i + node = super().primary() + return (*node, at) if node[0] == "call" else node + + +def _at(node): + """마디에 붙여 둔 글자 자리 — 자리를 안 적은 마디는 None.""" + want = {"bin": 4, "call": 3}.get(node[0]) + return node[want] if want is not None and len(node) > want else None + + +# ── 단위 셈 ─────────────────────────────────────────────────────────── +def unit_of(text) -> dict[str, int] | None: + """단위 글 → {밑 단위: 제곱} — 「원/㎥」 = {원: 1, ㎥: -1}. + + 빈 글은 무차원 · 안 정했으면 None. + """ + if text is None: + return None + out: dict[str, int] = {} + sign = 1 + for token in re.split(r"([*/])", str(text).replace("·", "*")): + token = token.strip() + if token in ("*", "/"): + sign = 1 if token == "*" else -1 + elif token and token not in DIMLESS: + base, power = ALIAS.get(token, (token, 1)) + out[base] = out.get(base, 0) + sign * power + return {k: v for k, v in out.items() if v} + + +def _one_text(base: str, power: int) -> str: + if base == "m" and power in SQUARED: + return SQUARED[power] + return base if power == 1 else f"{base}{power}" + + +def unit_text(unit) -> str | None: + """{밑 단위: 제곱} → 단위 글 · 무차원은 빈 글.""" + if unit is None: + return None + up = [_one_text(k, v) for k, v in unit.items() if v > 0] + down = [_one_text(k, -v) for k, v in unit.items() if v < 0] + if not up and not down: + return "" + return ("*".join(up) or "1") + ("/" + "/".join(down) if down else "") + + +def _show(unit) -> str: + return "모름" if unit is None else (unit_text(unit) or "없음") + + +def _merge(a: dict, b: dict, sign: int) -> dict: + out = dict(a) + for k, v in b.items(): + out[k] = out.get(k, 0) + sign * v + return {k: v for k, v in out.items() if v} + + +def _trouble(line: int, at, kind: str, say: str) -> dict: + return {"줄": line, "자리": at, "갈래": kind, "말": say} + + +def _same(units: list, line: int, at, where: str, out: list): + """여럿이 같은 단위여야 하는 자리 — 다르면 문제 한 줄 · 모르는 것은 넘김.""" + known = [u for u in units if u is not None] + if known and any(u != known[0] for u in known): + out.append( + _trouble( + line, + at, + "단위", + f"「{where}」 의 단위가 서로 다름 — {' · '.join(_show(u) for u in known)}", + ) + ) + return known[0] if known else None + + +def _unit(node, units: dict, line: int, head: int, out: list): + """식 나무의 단위 — 모르면 None · 어긋나면 `out` 에 그 줄과 자리를 적음.""" + kind = node[0] + if kind == "num": + return {} + if kind == "name": + return units.get(node[1]) + if kind == "neg": + return _unit(node[1], units, line, head, out) + if kind in ("str", "ref", "field", "find", "logic"): + return None # 글자 · 요소 · 표 찾기 · 로직 — 단위는 마스터가 앎 + if kind == "bin": + op = node[1] + a = _unit(node[2], units, line, head, out) + b = _unit(node[3], units, line, head, out) + at = None if _at(node) is None else head + _at(node) + if op in ("*", "/"): + return None if a is None or b is None else _merge(a, b, 1 if op == "*" else -1) + if op in ("+", "-"): + if a is None or b is None: + return a if b is None else b + if a != b: + out.append( + _trouble( + line, at, "단위", f"「{op}」 양쪽 단위가 다름 — {_show(a)} {op} {_show(b)}" + ) + ) + return a + if op in mf._OPS and a and b: # 맨 수와 대보는 것은 늘 됨(「높이 <= 3」) + _same([a, b], line, at, op, out) + return {} + if kind == "call": + name, items = node[1], node[2] + at = None if _at(node) is None else head + _at(node) + kids = [_unit(x, units, line, head, out) for x in items] + if name == "만약": + return _same(kids[1:], line, at, "만약", out) + if name in ("최소", "최대"): + return _same(kids, line, at, name, out) + return kids[0] # 올림 · 버림 · 반올림 — 자리 인자는 무차원 + return None + + +# ── 식 되쓰기 ───────────────────────────────────────────────────────── +def _text(node) -> str: + """나무 → 식 글자(다시 읽으면 같은 나무) — 사칙은 괄호로 묶음.""" + kind = node[0] + if kind == "num": + return format(node[1].normalize(), "f") + if kind == "str": + return f"'{node[1]}'" + if kind in ("name", "ref"): + return node[1] + if kind == "neg": + return f"-{_text(node[1])}" + if kind == "field": + tail = node[2] if mf._NAME.fullmatch(node[2]) else f"'{node[2]}'" + return f"{_text(node[1])}.{tail}" + if kind == "bin": + return f"({_text(node[2])} {node[1]} {_text(node[3])})" + if kind == "call": + return f"{node[1]}({', '.join(_text(x) for x in node[2])})" + if kind in ("find", "logic"): + args = "".join(f", {k}={_text(v)}" for k, v in node[2]) + return f"{'찾기' if kind == 'find' else '로직'}({node[1]}{args})" + raise mf.FormulaError(f"모르는 마디 {kind}") + + +def _expr(node) -> str: + """식 한 벌 — 맨 바깥 괄호는 뺌(사람이 읽는 자리에 그대로 들어감).""" + text = _text(node) + return text[1:-1] if node[0] == "bin" else text + + +def find_text(pick: dict) -> str: + """표 찾기 자리 → `찾기(표, 조건=값).값칸` — 모달이 채운 묶음을 글자로.""" + conds = "".join(f", {k}={v}" for k, v in (pick.get("조건") or {}).items()) + col = str(pick.get("값칸") or "") + tail = col if mf._NAME.fullmatch(col) else f"'{col}'" + return f"찾기({pick.get('표')}{conds}).{tail}" + + +# ── 읽기 · 검사 ─────────────────────────────────────────────────────── +def _cut(text: str): + """(줄 번호, 왼쪽 이름, 식, 식이 시작하는 자리) — 빈 줄과 `#` 는 건너뜀.""" + for at, raw in enumerate(str(text).split("\n"), 1): + if not raw.strip() or raw.lstrip().startswith("#"): + continue + m = _ASSIGN.match(raw) + yield ( + at, + (m.group(1) if m else ""), + (m.group(2).strip() if m else raw.strip()), + (m.start(2) if m else len(raw) - len(raw.lstrip())), + ) + + +def _order(defined: dict, deps: dict) -> list[str]: + """중간 차례 — 먼저 쓰는 값이 앞에 오게(돌고 도는 것은 검사가 이미 잡음).""" + out, seen = [], set() + + def visit(name: str, path: tuple): + if name in seen or name in path or name not in defined: + return + for nxt in deps.get(name, ()): + visit(nxt, path + (name,)) + seen.add(name) + out.append(name) + + for name in sorted(defined, key=lambda n: defined[n]): + visit(name, ()) + return out + + +def _scan(text: str, 정함: dict | None = None) -> dict: + """줄 가르기 · 변수 · 검사 · 단위 — `read` 와 `옮기기` 가 같이 씀(나무를 들고 있음).""" + 정함 = 정함 or {} + lines, 문제 = [], [] + for at, name, body, head in _cut(text): + one = {"줄": at, "이름": name, "식": body, "앞": head} + if not name: + 문제.append(_trouble(at, head, "못읽음", "「이름 = 식」 이 아님")) + elif not body: + 문제.append(_trouble(at, head, "못읽음", f"「{name}」 오른쪽이 빔")) + else: + try: + one["나무"] = _Pos(body).parse() + except mf.FormulaError as e: + 문제.append(_trouble(at, head, "못읽음", str(e))) + lines.append(one) + + 정의: dict[str, int] = {} + for one in lines: + if "나무" not in one: + continue + if one["이름"] in BUNDLE: + 문제.append( + _trouble( + one["줄"], 0, "이름", f"「{one['이름']}」 은 비목 합 이름 — 다른 이름을 쓸 것" + ) + ) + continue + if one["이름"] in 정의: + 문제.append( + _trouble( + one["줄"], + 0, + "두번정의", + f"「{one['이름']}」 을 {정의[one['이름']]}줄에서 이미 정함", + ) + ) + continue + 정의[one["이름"]] = one["줄"] + + 쓰임: dict[str, list[int]] = {} + deps: dict[str, set[str]] = {} + for one in lines: + if "나무" not in one: + continue + used = {p[1] for p in mf.walk(one["나무"]) if p[0] == "name"} + if 정의.get(one["이름"]) == one["줄"]: # 두 번 정의한 뒷줄은 딸림에 안 넣음 + deps[one["이름"]] = used + for name in sorted(used): + 쓰임.setdefault(name, []).append(one["줄"]) + + 돎 = mf.find_loops({k: v & set(정의) for k, v in deps.items()}) + for loop in 돎: + 문제.append( + _trouble(정의.get(loop[0], 0), 0, "돌고돎", "서로 물고 돎 — " + " → ".join(loop)) + ) + + 성함 = [one for one in lines if "나무" in one] + 결과 = 성함[-1]["이름"] if 성함 else "" + if not 성함: + 문제.append(_trouble(0, 0, "빔", "읽을 식이 없음")) + for name, at in sorted(정의.items(), key=lambda kv: kv[1]): + if name != 결과 and not [x for x in 쓰임.get(name, []) if x != at]: + 문제.append( + _trouble(at, 0, "안쓰임", f"「{name}」 을 어디서도 안 씀 — 결과는 「{결과}」 하나") + ) + + units: dict[str, dict | None] = {k: MONEY for k in BUNDLE} + for name in set(쓰임) | set(정의): + if name in 정의 or name in BUNDLE: + continue + spec = 정함.get(name) or {} + one = unit_of(spec.get("단위")) + # 마스터요소 변수는 식에서 단가로 셈 — 정한 단위가 「포」 면 식에서는 「원/포」 + units[name] = ( + _merge(MONEY, one, -1) if one is not None and spec.get("무엇") == "마스터요소" else one + ) + loops = {n for loop in 돎 for n in loop} + order = _order(정의, deps) + by = {one["이름"]: one for one in 성함} + for name in order: + if name in loops: + continue + pinned = unit_of((정함.get(name) or {}).get("단위")) + one = by[name] + got = _unit(one["나무"], units, one["줄"], one["앞"], 문제) + units[name] = pinned if pinned is not None else got + + 변수 = [] + for name in sorted( + set(쓰임) | set(정의), key=lambda n: (min([쓰임.get(n, [10**9])[0], 정의.get(n, 10**9)]), n) + ): + spec = 정함.get(name) or {} + 갈래 = "비목합" if name in BUNDLE else ("정의됨" if name in 정의 else "미정") + 변수.append( + { + "이름": name, + "처음": min([*쓰임.get(name, []), 정의.get(name, 10**9)]), + "갈래": 갈래, + "정의줄": 정의.get(name), + "쓰임": 쓰임.get(name, []), + "무엇": spec.get("무엇") if 갈래 == "미정" else None, + "단위": unit_text(units.get(name)), + "비목": spec.get("비목"), + "정할것": _todo(갈래, spec, units.get(name)), + } + ) + return {"줄": lines, "변수": 변수, "결과": 결과, "문제": 문제, "차례": order, "정의": 정의} + + +def _todo(갈래: str, spec: dict, unit) -> list[str]: + """그 변수에 아직 안 정한 것 — 무엇 · 요소·찾기·값 · 단위 · 비목.""" + if 갈래 != "미정": + return [] + what, need = spec.get("무엇"), [] + if what not in WHAT: + need.append("무엇") + elif what == "마스터요소": + if not spec.get("요소"): + need.append("요소") + if spec.get("비목") not in COST_ITEMS: + need.append("비목") + elif what == "표찾기" and not (spec.get("찾기") or {}).get("표"): + need.append("찾기") + elif what == "고정값" and spec.get("값") in (None, ""): + need.append("값") + if unit is None: + need.append("단위") + return need + + +def read(text: str, 정함: dict | None = None) -> dict: + """줄로 적은 식 → `{줄, 변수, 결과, 문제, 다됨}` — 화면이 그대로 받는 모양(나무는 뺌).""" + got = _scan(text, 정함) + 다됨 = not got["문제"] and not any(v["정할것"] for v in got["변수"]) + return { + "줄": [{k: v for k, v in one.items() if k != "나무"} for one in got["줄"]], + "변수": got["변수"], + "결과": got["결과"], + "문제": got["문제"], + "다됨": 다됨, + } + + +# ── 로직 틀로 옮기기 ─────────────────────────────────────────────────── +def _terms(node) -> list: + return _terms(node[2]) + _terms(node[3]) if node[0] == "bin" and node[1] == "+" else [node] + + +def _factors(node) -> list: + return _factors(node[2]) + _factors(node[3]) if node[0] == "bin" and node[1] == "*" else [node] + + +def _spread(node, by: dict, 정함: dict, 편: set) -> list: + """결과 항 — 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침. + + 호표 줄을 항 안에서 찾으려는 것. + """ + out = [] + for term in _terms(node): + name = term[1] if term[0] == "name" else "" + if name in by and name not in 편 and not (정함.get(name) or {}).get("비목"): + 편.add(name) + out += _spread(by[name]["나무"], by, 정함, 편) + else: + out.append(term) + return out + + +def _kind_of(요소) -> str: + if isinstance(요소, dict): + return "재료" + return mk.GROUP_OF.get(mk.id_of(str(요소)), "") + + +def _input_of(name: str, spec: dict, unit) -> dict: + one = {"이름": name} + for k in ("설명", "단위", "고르기", "범위", "출처"): + if spec.get(k) not in (None, ""): + one[k] = spec[k] + one.setdefault("단위", unit_text(unit) or "") + return {k: one[k] for k in ("이름", "설명", "단위", "고르기", "범위", "출처") if k in one} + + +def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None) -> tuple[dict, list]: + """(로직 한 줄, 문제) — 다 정해졌으면 `_틀.md` 7장 모양 · 문제가 있으면 줄은 빈 묶음. + + 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다 — + 마스터요소 변수 하나면 호표 줄(나머지 곱이 수량) · 비목만 정한 변수면 덧줄. + 돈 아닌 로직은 호표 없이 `중간` + `결과` 식. + """ + 정함, 머리 = 정함 or {}, dict(머리 or {}) + got = _scan(text, 정함) + 문제 = list(got["문제"]) + for v in got["변수"]: + if v["정할것"]: + 문제.append( + _trouble( + v["처음"], 0, "덜정함", f"「{v['이름']}」 에 {' · '.join(v['정할것'])} 안 정함" + ) + ) + if 문제: + return {}, 문제 + + by, 결과 = {one["이름"]: one for one in got["줄"] if "나무" in one}, got["결과"] + 돈 = str(머리.get("결과단위") or "").startswith("원") + 입력, 중간, 호표, 덧줄 = [], [], [], [] + for v in got["변수"]: + spec = 정함.get(v["이름"]) or {} + if v["갈래"] != "미정": + continue + if spec["무엇"] == "설계입력": + 입력.append(_input_of(v["이름"], spec, unit_of(v["단위"]))) + elif spec["무엇"] == "고정값": + 중간.append({"이름": v["이름"], "식": str(spec["값"])}) + elif spec["무엇"] == "표찾기": + 중간.append({"이름": v["이름"], "식": find_text(spec["찾기"])}) + + 덧줄이름, 편 = set(), set() + if 돈: + for term in _spread(by[결과]["나무"], by, 정함, 편): + facts = _factors(term) + picks = [ + f + for f in facts + if f[0] == "name" and (정함.get(f[1]) or {}).get("무엇") == "마스터요소" + ] + if len(picks) > 1: + 문제.append( + _trouble( + by[결과]["줄"], + 0, + "항", + f"한 항에 마스터요소가 둘 — {' · '.join(p[1] for p in picks)}", + ) + ) + elif picks: + spec = 정함[picks[0][1]] + rest = [f for f in facts if f is not picks[0]] + 호표.append( + { + "종류": _kind_of(spec["요소"]), + "요소": spec["요소"], + "이름": spec.get("이름") or picks[0][1], + "단위": spec.get("단위") or "", + "수량": ( + _expr(rest[0]) if len(rest) == 1 else " * ".join(_text(f) for f in rest) + ) + or "1", + "비목": spec["비목"], + } + ) + elif ( + len(facts) == 1 + and facts[0][0] == "name" + and (정함.get(facts[0][1]) or {}).get("비목") + ): + name = facts[0][1] + if name not in by: + 문제.append(_trouble(by[결과]["줄"], 0, "항", f"덧줄 「{name}」 의 식이 없음")) + continue + 덧줄.append( + {"이름": name, "식": _expr(by[name]["나무"]), "비목": 정함[name]["비목"]} + ) + 덧줄이름.add(name) + else: + 문제.append( + _trouble( + by[결과]["줄"], + 0, + "항", + f"「{_text(term)}」 을 호표·덧줄로 못 옮김 — 마스터요소나 비목을 정할 것", + ) + ) + + 쓰임 = {v["이름"]: v["쓰임"] for v in got["변수"]} + for name in got["차례"]: + if name in 덧줄이름 or name == 결과: # 결과 줄은 호표·덧줄로 갈랐거나 「결과」 식이 됨 + continue + if name in 편 and 쓰임.get(name) == [by[결과]["줄"]]: + continue # 결과 항으로 펼쳐 넣었고 다른 데서 안 씀 — 중간에 두지 않음 + 쓴비목 = {p[1] for p in mf.walk(by[name]["나무"]) if p[0] == "name"} & set(BUNDLE) + if 쓴비목: + 문제.append( + _trouble( + by[name]["줄"], + 0, + "비목합", + f"「{' · '.join(sorted(쓴비목))}」 은 덧줄에서만 씀", + ) + ) + 중간.append({"이름": name, "식": _expr(by[name]["나무"])}) + if 문제: + return {}, 문제 + + row = { + "원문번호": 머리.get("원문번호", ""), + "이름": 머리.get("이름", ""), + "결과단위": 머리.get("결과단위", ""), + "출처": 머리.get("출처", "자체"), + "입력": 입력, + "중간": 중간, + } + row.update({"호표": 호표, "덧줄": 덧줄} if 돈 else {"결과": {"식": _expr(by[결과]["나무"])}}) + row["끝수"] = 머리.get("끝수") # 틀이 늘 두는 칸 — 안 정했으면 null + for k in ("비고", "시험입력"): + if 머리.get(k) not in (None, ""): + row[k] = 머리[k] + return row, [] + + +if __name__ == "__main__": # 견본 한 벌 — `python scripts/master_draft.py` + import json + import sys + + sys.stdout.reconfigure(encoding="utf-8") + print(json.dumps(read(sys.stdin.read()), ensure_ascii=False, indent=2, default=str)) diff --git a/resources/tester/test_m01_draft.py b/resources/tester/test_m01_draft.py new file mode 100644 index 00000000..9491cfd0 --- /dev/null +++ b/resources/tester/test_m01_draft.py @@ -0,0 +1,233 @@ +"""식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 단위 · 로직 틀로 옮기기 (`_화면_계약.md` 7장). + +견본 셋 — 자재비 식(제대로 도는 것) · 단위가 어긋난 식 · 서로 물고 도는 식. +해석기 `resources/master_data/scripts/master_draft.py` · 서버 길 `POST /api/m01/logic/draft`. +""" + +from __future__ import annotations + +import shutil +import sys +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Make as make + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) +import master_draft as md # noqa: E402 + +REAL = store.FOLDER +LABOR = "LB000002" # 보통인부 + +# ① 자재비 식 — 넓이 → 품 → 보통인부 한 줄 + 잡재료비 덧줄 +GOOD = """넓이 = 길이 * 폭 +인부 = 넓이 * 품 +잡재료비 = 노무비 * 0.05 +공사비 = 인부 * 보통인부 + 잡재료비 +""" +GOOD_정함 = { + "길이": {"무엇": "설계입력", "단위": "m", "설명": "쌓을 길이"}, + "폭": {"무엇": "설계입력", "단위": "m", "설명": "쌓을 폭"}, + "품": {"무엇": "고정값", "값": "0.15", "단위": "인/㎡"}, + "보통인부": {"무엇": "마스터요소", "요소": LABOR, "단위": "인", "비목": "노무비"}, + "잡재료비": {"비목": "재료비"}, +} +GOOD_머리 = {"이름": "시험 자재비", "결과단위": "원/㎡", "출처": "자체"} + +# ② 단위가 어긋난 식 — 길이(m) 와 넓이(㎡) 를 더함 +BAD_UNIT = """넓이 = 길이 * 폭 +틀린값 = 길이 + 넓이 +""" +BAD_UNIT_정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}} + +# ③ 서로 물고 도는 식 +LOOP = """가 = 나 + 1 +나 = 가 * 2 +끝 = 나 +""" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _post(client: TestClient, body: dict, want: int = 200) -> dict: + res = client.post("/api/m01/logic/draft", json=body) + assert res.status_code == want, res.text + return res.json() + + +def _var(got: dict, name: str) -> dict: + return next(v for v in got["변수"] if v["이름"] == name) + + +# ── ① 변수 뽑기 · 정할 것 ───────────────────────────────────────────── +def test_변수를_뽑고_정할_것을_알려_준다() -> None: + got = md.read(GOOD) # 아직 아무것도 안 정함 + assert got["결과"] == "공사비" and got["다됨"] is False + assert _var(got, "넓이")["갈래"] == "정의됨" and _var(got, "넓이")["정의줄"] == 1 + assert _var(got, "길이")["갈래"] == "미정" and _var(got, "길이")["처음"] == 1 + assert _var(got, "보통인부")["처음"] == 4 # 처음 나온 줄 + assert _var(got, "길이")["정할것"] == ["무엇", "단위"] + assert _var(got, "노무비")["갈래"] == "비목합" and _var(got, "노무비")["정할것"] == [] + + 정함 = dict(GOOD_정함, 보통인부={"무엇": "마스터요소", "요소": LABOR, "단위": "인"}) + assert _var(md.read(GOOD, 정함), "보통인부")["정할것"] == ["비목"] + assert md.read(GOOD, GOOD_정함)["다됨"] is True + + +def test_표_찾기는_변수_하나로_두고_자리만_만든다() -> None: + 정함 = {"석공": {"무엇": "표찾기", "단위": "인/㎡"}} + assert _var(md.read("품 = 넓이 * 석공", 정함), "석공")["정할것"] == ["찾기"] + 이은 = { + "석공": { + "무엇": "표찾기", + "단위": "인/㎡", + "찾기": {"표": "QF000421", "값칸": "석공", "조건": {"뒷길이": "뒷길이"}}, + } + } + assert _var(md.read("품 = 넓이 * 석공", 이은), "석공")["정할것"] == [] + assert md.find_text(이은["석공"]["찾기"]) == "찾기(QF000421, 뒷길이=뒷길이).석공" + + +# ── ② 단위 검사 ─────────────────────────────────────────────────────── +def test_단위가_어긋난_줄과_자리를_짚는다() -> None: + got = md.read(BAD_UNIT, BAD_UNIT_정함) + bad = [x for x in got["문제"] if x["갈래"] == "단위"] + assert len(bad) == 1 and bad[0]["줄"] == 2 + assert BAD_UNIT.split("\n")[1][bad[0]["자리"]] == "+" # 짚은 자리가 그 셈 기호 + assert "m + ㎡" in bad[0]["말"] + + +def test_곱셈은_단위를_곱하고_나눗셈은_나눈다() -> None: + 정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}} + assert _var(md.read("넓이 = 길이 * 폭", 정함), "넓이")["단위"] == "㎡" + 부피 = {**정함, "높이": {"무엇": "설계입력", "단위": "m"}} + assert _var(md.read("부피 = 길이 * 폭 * 높이", 부피), "부피")["단위"] == "㎥" + 나눔 = { + "부피": {"무엇": "설계입력", "단위": "㎥"}, + "시간당": {"무엇": "설계입력", "단위": "㎥/hr"}, + } + assert _var(md.read("시간 = 부피 / 시간당", 나눔), "시간")["단위"] == "hr" + assert _var(md.read(GOOD, GOOD_정함), "공사비")["단위"] == "원" # 인 × 원/인 = 원 + + +def test_만약과_최소는_갈래마다_단위가_같아야_한다() -> None: + 정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "넓이": {"무엇": "설계입력", "단위": "㎡"}} + got = md.read("값 = 만약(길이 > 3, 길이, 넓이)", 정함) + assert [x["갈래"] for x in got["문제"]] == ["단위"] and "만약" in got["문제"][0]["말"] + assert md.read("값 = 최소(길이, 넓이)", 정함)["문제"][0]["갈래"] == "단위" + assert md.read("값 = 최소(길이, 길이 * 2)", 정함)["문제"] == [] + + +# ── ③ 그 밖 검사 ────────────────────────────────────────────────────── +def test_서로_물고_도는_식을_잡는다() -> None: + kinds = [x["갈래"] for x in md.read(LOOP)["문제"]] + assert "돌고돎" in kinds + 말 = next(x["말"] for x in md.read(LOOP)["문제"] if x["갈래"] == "돌고돎") + assert "가" in 말 and "나" in 말 + assert md.read("가 = 가 + 1\n끝 = 가")["문제"][0]["갈래"] == "돌고돎" + + +def test_두_번_정의_안_쓰임_결과_하나() -> None: + 두번 = md.read("가 = 1\n가 = 2\n끝 = 가")["문제"] + assert 두번[0]["갈래"] == "두번정의" and 두번[0]["줄"] == 2 + 안씀 = md.read("가 = 1\n남 = 5\n끝 = 가")["문제"] + assert [x["갈래"] for x in 안씀] == ["안쓰임"] and 안씀[0]["줄"] == 2 + assert "결과는 「끝」 하나" in 안씀[0]["말"] + assert md.read("노무비 = 5\n끝 = 노무비")["문제"][0]["갈래"] == "이름" + assert md.read("이건 식이 아님")["문제"][0]["갈래"] == "못읽음" + + +# ── ⑤ 로직 틀로 옮기기 ──────────────────────────────────────────────── +def test_다_정하면_로직_틀로_옮긴다() -> None: + row, bad = md.옮기기(GOOD, GOOD_정함, GOOD_머리) + assert bad == [] + assert [x["이름"] for x in row["입력"]] == ["길이", "폭"] + assert row["입력"][0]["설명"] == "쌓을 길이" and row["입력"][0]["단위"] == "m" + assert [x["이름"] for x in row["중간"]] == ["품", "넓이", "인부"] # 쓰는 차례대로 + assert row["중간"][0]["식"] == "0.15" and row["중간"][1]["식"] == "길이 * 폭" + assert row["호표"] == [ + { + "종류": "인력", + "요소": LABOR, + "이름": "보통인부", + "단위": "인", + "수량": "인부", + "비목": "노무비", + } + ] + assert row["덧줄"] == [{"이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비"}] + assert row["결과단위"] == "원/㎡" and "결과" not in row + + 검사 = store.cm.mf.check_logic(store.cm.master(REAL), {**row, "키": "GX000001"})[0] + assert 검사 == [] # 옮긴 줄이 지금 로직 검사를 그대로 통과 + + +def test_돈_아닌_로직은_호표_없이_결과_식() -> None: + 정함 = { + "부피": {"무엇": "설계입력", "단위": "㎥"}, + "시간당": {"무엇": "설계입력", "단위": "㎥/hr"}, + } + row, bad = md.옮기기("시간 = 부피 / 시간당", 정함, {"이름": "작업 시간", "결과단위": "hr"}) + assert bad == [] and row["결과"] == {"식": "부피 / 시간당"} and row["중간"] == [] + assert "호표" not in row + + +def test_덜_정했으면_안_옮긴다() -> None: + row, bad = md.옮기기(GOOD, {}, GOOD_머리) + assert row == {} and any(x["갈래"] == "덜정함" for x in bad) + + +# ── 서버 길 ─────────────────────────────────────────────────────────── +def test_서버_길이_변수와_문제를_돌려_준다(client: TestClient) -> None: + got = _post(client, {"text": GOOD, "decided": GOOD_정함}) + assert got["다됨"] is True and got["logic"] is None and got["결과"] == "공사비" + assert _var(got, "보통인부")["단위"] == "원/인" # 마스터요소는 식에서 단가 + + 미리 = _post(client, {"text": GOOD, "decided": GOOD_정함, "head": GOOD_머리}) + assert 미리["logic"]["호표"][0]["요소"] == LABOR + assert not (store.FOLDER / make.OWN).is_file() # 미리보기는 파일에 안 씀 + + 틀림 = _post(client, {"text": BAD_UNIT, "decided": BAD_UNIT_정함}) + assert any(x["갈래"] == "단위" for x in 틀림["문제"]) and 틀림["다됨"] is False + + +def test_서버_길이_저장까지_하고_그_로직이_돈다(client: TestClient) -> None: + done = _post( + client, + {"text": GOOD, "decided": GOOD_정함, "head": GOOD_머리, "save": True, "owner": "현장"}, + ) + assert done["key"].startswith("GX") and done["file"] == make.OWN + assert done["logic"]["구분"] == "자체" and done["logic"]["소유"] == "현장" + + res = client.post("/api/m01/calc", json={"key": done["key"], "inputs": {"길이": 2, "폭": 3}}) + assert res.status_code == 200, res.text + calc = res.json() + assert calc["ok"] is True, calc + 노무 = Decimal(str(calc["sums"]["노무비"])) + assert 노무 > 0 and Decimal(str(calc["sums"]["재료비"])) == (노무 * Decimal("0.05")) + assert Decimal(str(calc["middle"]["넓이"])) == 6 + + +def test_저장은_문제가_있으면_아무것도_안_쓴다(client: TestClient) -> None: + res = client.post( + "/api/m01/logic/draft", + json={"text": LOOP, "decided": {}, "head": GOOD_머리, "save": True}, + ) + assert res.status_code == 422, res.text + assert res.json()["detail"]["errors"] + assert not (store.FOLDER / make.OWN).is_file()