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

This commit is contained in:
2026-09-21 19:56:00 +09:00
5 changed files with 869 additions and 0 deletions
+14
View File
@@ -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):
@@ -214,3 +222,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)
@@ -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)}
+18
View File
@@ -114,3 +114,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, 문제}}` 로 아무것도 안 씀.
- 옮기는 법 — 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다: 마스터요소 변수 하나면 **호표 줄**(나머지 곱이 `수량`) · 비목만 정한 변수면 **덧줄** · 그 밖은 문제 `항`. 나머지 정의 줄은 `중간`(쓰는 차례대로). 돈 아닌 로직은 호표 없이 `중간` + `결과` 식.
- 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 `무엇``표찾기` 로 정할 때 모달이 `찾기` 묶음을 채움.
@@ -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", 2),
"m2": ("m", 2),
"": ("m", 3),
"": ("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))
+233
View File
@@ -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()