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

# Conflicts:
#	M01_MasterData/M01_MasterData_UI_LogicLab.ts
#	M01_MasterData/M01_MasterData_UI_LogicLab_Style.css
#	M01_MasterData/M01_MasterData_UI_Logic_Api.ts
#	resources/master_data/ref/_검증_별칭_복사.md
This commit is contained in:
2026-09-21 21:37:11 +09:00
18 changed files with 1300 additions and 99 deletions
+96 -29
View File
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 7장).
"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 8장).
수량 = 길이 * 폭
자재비 = 수량 * 시멘트
· 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄 왼쪽이 결과 변수
· 줄마다 「이름 = 식」 또는 「식 = 이름」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄의 값 이름이 결과 변수
· 식 글자는 `_틀.md` 8장 범위만 — 읽기는 `master_formula` 가 함
· 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 「무엇」 을 `표찾기` 로 정할 때 이음
· 값은 안 씀 — 검사만 하고 `옮기기` 가 로직 한 줄을 만듦(저장은 `Store_Make.logic_new`)
@@ -32,8 +32,9 @@ ALIAS = {
"M": ("m", 1),
}
SQUARED = {2: "", 3: ""}
DIMLESS = ("%", "") # 비율은 단위가 없음 — 「1 + 증가율 / 100」 이 안 걸리게
_ASSIGN = re.compile(r"^\s*([\w가-힣]+)\s*=(?!=)\s*(.*)$")
# 단위가 없는 맨 수 — 횟수 · 배율 · 비율. 빈 글도 같음(「없음」 을 고른 것과 같게 봄)
DIMLESS = ("%", "", "없음", "맨수", "맨 수", "-")
_NAME_ONLY = re.compile(r"^[\w가-힣]+$")
# ── 자리를 적어 두는 읽기 ──────────────────────────────────────────────
@@ -224,18 +225,51 @@ def find_text(pick: dict) -> str:
# ── 읽기 · 검사 ───────────────────────────────────────────────────────
def _is_name(text: str) -> bool:
"""값 이름 하나 — 맨 수는 이름이 아님."""
return bool(_NAME_ONLY.fullmatch(text)) and not mf._NUMBER.fullmatch(text)
def _split(raw: str) -> int:
"""줄을 가를 등호 자리 — 괄호 안과 비교 기호(`==` `<=` `>=` `!=`)는 건너뜀 · 없으면 -1."""
depth = 0
for i, ch in enumerate(raw):
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "=" and depth == 0:
if raw[i - 1 : i] in ("<", ">", "!", "=") or raw[i + 1 : i + 2] == "=":
continue
return i
return -1
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())),
)
cut = _split(raw)
if cut < 0:
yield (
at,
"",
raw.strip(),
len(raw) - len(raw.lstrip()),
"「이름 = 식」 이 아님 — 등호가 없음",
)
continue
left, right = raw[:cut], raw[cut + 1 :]
if _is_name(left.strip()):
yield at, left.strip(), right.strip(), cut + 1 + len(right) - len(right.lstrip()), ""
elif _is_name(right.strip()): # 「A * B = C」 — 오른쪽이 이름
yield at, right.strip(), left.strip(), len(left) - len(left.lstrip()), ""
else:
yield at, "", raw.strip(), cut, "양쪽이 다 식 — 한쪽은 값 이름 하나여야 함"
def _order(defined: dict, deps: dict) -> list[str]:
@@ -255,14 +289,14 @@ def _order(defined: dict, deps: dict) -> list[str]:
return out
def _scan(text: str, 정함: dict | None = None) -> dict:
def _scan(text: str, 정함: dict | None = None, 머리: dict | None = None) -> dict:
"""줄 가르기 · 변수 · 검사 · 단위 — `read` 와 `옮기기` 가 같이 씀(나무를 들고 있음)."""
정함 = 정함 or {}
lines, 문제 = [], []
for at, name, body, head in _cut(text):
for at, name, body, head, why in _cut(text):
one = {"": at, "이름": name, "": body, "": head}
if not name:
문제.append(_trouble(at, head, "못읽음", "「이름 = 식」 이 아님"))
if why:
문제.append(_trouble(at, head, "못읽음", why))
elif not body:
문제.append(_trouble(at, head, "못읽음", f"{name}」 오른쪽이 빔"))
else:
@@ -343,6 +377,7 @@ def _scan(text: str, 정함: dict | None = None) -> dict:
got = _unit(one["나무"], units, one[""], one[""], 문제)
units[name] = pinned if pinned is not None else got
덧줄후보 = _extra_names(by, 결과, 정함, 머리) if 결과 in by and 결과 not in loops else set()
변수 = []
for name in sorted(
set(쓰임) | set(정의), key=lambda n: (min([쓰임.get(n, [10**9])[0], 정의.get(n, 10**9)]), n)
@@ -359,16 +394,18 @@ def _scan(text: str, 정함: dict | None = None) -> dict:
"무엇": spec.get("무엇") if 갈래 == "미정" else None,
"단위": unit_text(units.get(name)),
"비목": spec.get("비목"),
"정할것": _todo(갈래, spec, units.get(name)),
"정할것": _todo(갈래, spec, units.get(name), name in 덧줄후보),
}
)
return {"": lines, "변수": 변수, "결과": 결과, "문제": 문제, "차례": order, "정의": 정의}
def _todo(갈래: str, spec: dict, unit) -> list[str]:
def _todo(갈래: str, spec: dict, unit, 덧줄: bool = False) -> list[str]:
"""그 변수에 아직 안 정한 것 — 무엇 · 요소·찾기·값 · 단위 · 비목."""
if 갈래 != "미정":
if 갈래 == "비목합":
return []
if 갈래 == "정의됨": # 식이 있는 값 — 덧줄이 될 항이면 비목만 고르면 됨
return ["비목"] if 덧줄 and spec.get("비목") not in COST_ITEMS else []
what, need = spec.get("무엇"), []
if what not in WHAT:
need.append("무엇")
@@ -386,9 +423,9 @@ def _todo(갈래: str, spec: dict, unit) -> list[str]:
return need
def read(text: str, 정함: dict | None = None) -> dict:
def read(text: str, 정함: dict | None = None, 머리: dict | None = None) -> dict:
"""줄로 적은 식 → `{줄, 변수, 결과, 문제, 다됨}` — 화면이 그대로 받는 모양(나무는 뺌)."""
got = _scan(text, 정함)
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[""]],
@@ -408,22 +445,40 @@ 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:
"""결과 항 — 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침.
def _spread(node, by: dict, 정함: dict, : set, 뿌리: str = "") -> 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, 정함, )
out += _spread(by[name]["나무"], by, 정함, , name)
else:
out.append(term)
out.append((term, 뿌리))
return out
def _has_element(term, 정함: dict) -> bool:
return any(
f[0] == "name" and (정함.get(f[1]) or {}).get("무엇") == "마스터요소"
for f in _factors(term)
)
def _extra_names(by: dict, 결과: str, 정함: dict, 머리: dict | None) -> set[str]:
"""결과 식의 항 가운데 마스터요소가 없어 덧줄이 될 값 이름 — 비목을 골라야 함."""
if 머리 is not None and not str(머리.get("결과단위") or "").startswith(""):
return set() # 돈 아닌 로직은 덧줄이 없음
return {
뿌리
for term, 뿌리 in _spread(by[결과]["나무"], by, 정함, set())
if 뿌리 and not _has_element(term, 정함)
}
def _kind_of(요소) -> str:
if isinstance(요소, dict):
return "재료"
@@ -447,13 +502,16 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None)
돈 아닌 로직은 호표 없이 `중간` + `결과` 식.
"""
정함, 머리 = 정함 or {}, dict(머리 or {})
got = _scan(text, 정함)
got = _scan(text, 정함, 머리)
문제 = list(got["문제"])
for v in got["변수"]:
if v["정할것"]:
문제.append(
_trouble(
v["처음"], 0, "덜정함", f"{v['이름']}」 에 {' · '.join(v['정할것'])} 안 정함"
v["처음"],
0,
"덜정함",
f"{v['이름']}」 에 아직 안 정한 것 — {' · '.join(v['정할것'])}",
)
)
if 문제:
@@ -475,7 +533,7 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None)
덧줄이름, = set(), set()
if :
for term in _spread(by[결과]["나무"], by, 정함, ):
for term, 뿌리 in _spread(by[결과]["나무"], by, 정함, ):
facts = _factors(term)
picks = [
f
@@ -520,6 +578,15 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None)
{"이름": name, "": _expr(by[name]["나무"]), "비목": 정함[name]["비목"]}
)
덧줄이름.add(name)
elif 뿌리 in by: # 덧줄이 될 값 — 막지 않고 비목만 짚음
문제.append(
_trouble(
by[뿌리][""],
0,
"비목",
f"덧줄 「{뿌리}」 의 비목을 골라 주세요 — {' · '.join(COST_ITEMS)}",
)
)
else:
문제.append(
_trouble(