feat(792-1): 단가산출 사용자 식 칸 서버 — 변수줄 · 식덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사 길

- 새 M02_Sangeun_Formula.py — settle(바탕 줄 + 식 칸 → 엔진 줄 · 틀린 자리 = M01 식 + 짝잃음 · 저장 = 400) · to_name · to_engine(× ÷ · [노임 …] · [별칭]) · check_doc · 상한(길이 300 · 괄호 12 · 변수 100) · 셈 답 덧칸(식글 · 원식글 · 식덮개 · 변수 줄 · 이름표)
- Const formulas 첫머리 · clean_more 끝 · stamp(저장 엔진 식) · restore · kinds · no_flat · covers_n 에 식덮개
- Doc patched · _add_item 두 줄 고침(줄 수 그대로) · Calc stamp 한 줄
- 길 POST /api/m02/layers/{층}/sangeun/formula-check
- M01 로직 1,515 식 9,228 이름 꼴 왕복 같음 · 기존 SK 시험 99 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:23:05 +09:00
co-authored by Claude Opus 5.5
parent c20a18bf7a
commit 710827e901
5 changed files with 673 additions and 20 deletions
+1 -1
View File
@@ -536,7 +536,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
for cid, one in out["덮개"].items():
if cid in was:
one["원값"] = _was(was[cid])
out |= const.stamp(base["줄"]) # 바탕판 · M01판(790-2)
out |= const.stamp(base["줄"], out) # 바탕판 · M01판(790-2) · 식 칸 엔진 식(792-1)
ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2)
_stamp_adds(out, base, whole)
except sd.DocError as e:
+34 -15
View File
@@ -30,6 +30,7 @@ from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Formula as fx
NEW = ("상수덮개", "표값덮개", "글줄", "까닭")
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
@@ -114,7 +115,17 @@ def places(row: dict) -> dict[str, tuple[str, int]]:
def formulas(row: dict, doc: dict) -> dict:
"""바탕 줄 + 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수)."""
"""바탕 줄 + 식 칸(792-1) · 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수)."""
return formulas_lost(row, doc)[0]
def formulas_lost(row: dict, doc: dict) -> tuple[dict, list[dict], dict]:
"""(식 바꾼 사본, 틀린 식 자리 짝잃음, 수량식 엔진 꼴 문서) — `sd.patched` 첫머리."""
row, bad, doc = fx.settle(row, doc)
return _swapped(row, doc), bad, doc
def _swapped(row: dict, doc: dict) -> dict:
nums, finds = doc.get("상수덮개") or {}, doc.get("표값덮개") or {}
if not nums and not finds:
return row
@@ -147,13 +158,13 @@ def lost(row: dict, doc: dict) -> list[dict]:
def no_flat(doc: Any) -> None:
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개")):
raise sd.DocError("상수 · 표값 덮개 단가산출은 펼치지 않음(참조 그대로)")
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개") or fx.has(doc)):
raise sd.DocError("상수 · 표값 덮개 · 식 칸 단가산출은 펼치지 않음(참조 그대로)")
def covers_n(doc: dict) -> int:
"""[초기화] 걷힐 고침 수 — 덮개 · 뺀줄 · 상수 · 표값 덮개 칸."""
keys = ("덮개", "뺀줄", "상수덮개", "표값덮개")
keys = ("덮개", "뺀줄", "상수덮개", "표값덮개", "식덮개")
return sum(len(doc.get(k) or ()) for k in keys)
@@ -215,7 +226,7 @@ def clean_more(raw: Any, out: dict) -> dict:
why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()}
if why := {k: v for k, v in why.items() if v}:
out["까닭"] = why
return out
return fx.clean(raw, out) # 식 칸(792-1)
def _print(one: Any) -> str:
@@ -223,10 +234,15 @@ def _print(one: Any) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
def stamp(row: dict) -> dict[str, Any]:
"""저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문)."""
def stamp(row: dict, doc: dict | None = None) -> dict[str, Any]:
"""저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문) · `doc` = 식 칸(792-1)을
엔진 식으로 바꿔 넣음(제자리 · 틀린 식 = `DocError`)."""
from M02_MasterTemplete import M02_Sangeun_Calc as calc
if doc is not None and fx.has(doc):
fixed = fx.settle(row, doc, strict=True)[2]
doc.update({k: fixed[k] for k in ("더한줄", *fx.FIELDS) if k in fixed})
lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])]
spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row)
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
@@ -234,10 +250,10 @@ def stamp(row: dict) -> dict[str, Any]:
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
def restore(doc: dict, key: str | None = None) -> dict:
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 지움(더한줄 · 글줄 ·
옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부 `add:#k/cid` =
그 더한줄 세부)."""
out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "까닭")}
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 · 식 덮개 · 그 까닭 지움(더한줄 ·
글줄 · 변수줄 · 옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부
`add:#k/cid` = 그 더한줄 세부)."""
out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "식덮개", "까닭")}
why = dict(doc.get("까닭") or {})
if key is None:
out |= {"덮개": {}, "뺀줄": []}
@@ -256,7 +272,7 @@ def restore(doc: dict, key: str | None = None) -> dict:
else:
out["덮개"] = {c: v for c, v in (doc.get("덮개") or {}).items() if c != key}
out["뺀줄"] = [c for c in doc.get("뺀줄") or [] if c != key]
for name in ("상수덮개", "표값덮개"):
for name in ("상수덮개", "표값덮개", "식덮개"):
if got := {s: v for s, v in (doc.get(name) or {}).items() if s != key}:
out[name] = got
why.pop(key, None)
@@ -296,7 +312,9 @@ def kinds(doc: dict, spot: str) -> list[str]:
if any(k in cover for k in sd.PARTS):
out.append("단가")
out += [
n for n, k in (("상수", "상수덮개"), ("표값", "표값덮개")) if spot in (doc.get(k) or {})
n
for n, k in (("상수", "상수덮개"), ("표값", "표값덮개"), ("식", "식덮개"))
if spot in (doc.get(k) or {})
]
return out + (["뺌"] if spot in (doc.get("뺀줄") or []) else [])
@@ -311,7 +329,7 @@ def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -
def _bare(doc: dict) -> dict:
out = {k: v for k, v in doc.items() if k not in (*NEW, "뺀더한줄")}
out = {k: v for k, v in doc.items() if k not in (*NEW, *fx.FIELDS, "뺀더한줄")}
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
@@ -400,5 +418,6 @@ def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) ->
gone = lost(base, clean) + [
{"cid": s, "까닭": "M01 바뀜 — 바탕에 없는 줄"} for s in stamps if s not in fresh
]
top = {"글줄": clean.get("글줄") or [], "짝잃음": gone}
gone += fx.sheet_more(spots, base, clean, mids1) # 식 칸(792-1) 덧칸 · 변수 줄 · 틀린 식
top = {"글줄": clean.get("글줄") or [], "짝잃음": gone, "이름표": fx.name_table(base, clean)}
return {"원래": bare.get("기준"), "시트": {"자리": spots, **top}}
+3 -3
View File
@@ -436,7 +436,7 @@ def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]:
def _add_item(one: dict) -> dict:
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
qty = text(one["수량"])
qty = one.get("수량식") or text(one["수량"]) # 수량식(792-1) = 엔진 식(`fx.settle` 이 바꿈)
if "참조" in one or "고정" in one:
kept = {k: one[k] for k in ("참조", "고정") if k in one}
if is_rate(one): # 율 줄(778-4) = 그 비목 한 칸(엔진 묶음 · 보기 상자 = 비목)
@@ -460,12 +460,12 @@ def patched(
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
짝잃음 [{cid, 까닭}]}."""
row = const.formulas(row, doc) # 상수 · 표값 덮개(790-2) = 식 바꾼 바탕
row, bad, doc = const.formulas_lost(row, doc) # 식 칸(792-1) · 상수 · 표값 덮개(790-2)
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
ids = line_ids(row)
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
mine, marks = doc.get("고름") or {}, doc.get("자재표시") or {}
lost = [
lost = bad + [
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
]
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
+615
View File
@@ -0,0 +1,615 @@
"""M02 단가산출(SK) 사용자 식 칸(PLAN 792-1 · 요구서_652 1 장 3 · _분석_650-H3 E4) — 변수 줄 ·
식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사(「덮개 = 숫자만」 원칙을 바꿈).
짝 = `logic/M02_Sangeun_Formula_Model.ts`(캐시 고치기 — 서버 짝 함수 없음 · 꼴만 같음) ·
시험 `resources/tester/test_792_1_sangeun_formula.py`.
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 저장 = 엔진 식 · 이름 꼴 글도 받아 서버가 번역):
변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄로 끼움 · 자리 = 그 뒤 중간 `mid:이름`
("" = 맨 위 · 없는 자리 = 중간 끝 + 짝잃음) · 표시 = 엔진 `표시`(0~6 반올림)
식덮개 {자리: 식} 중간 `mid:이름` 식 · 호표 cid 수량 · 덧줄 cid 식
(원래 식 = 바탕 · M01 바뀜 = 790-2 M01판)
더한줄[k].수량식 인력 · 재료 · 기계 · 참조 · 고정(율 줄 빼고) · `수량` = 보기 값
이름 꼴 — `×` `÷`(= `*` `/`) · `[노임 형틀목공]`(요소 · 겹치면 키 덧붙임) · `[별칭]`(바탕 줄
`찾기` · `로직` 부르기 = `master_copy.aliases` · 별칭표는 바탕 줄에서만 지음 = 사용자 고침에
안 흔들림) · 맨 이름 = 입력 · 중간 · 변수 · 덧줄 · 비목 · 엔진 날식도 그대로 받음.
셈 = `settle`(바탕 줄 + 식 칸 → 엔진 줄 · `M02_Sangeun_Const.formulas` 첫머리 → `patched` →
`_Over` 로 같은 엔진 한 벌 · B09 굳힘 `GX9#####` 도 그 줄) · 틀린 식 = 그 자리만 M01 식으로 +
짝잃음(셈 길) · 저장(`strict`) = 400.
보안 — eval 없음(엔진 파서만) · 식 길이 `LONG` · 괄호 깊이 `DEEP` · 변수 줄 `MANY` ·
로직 부르기 깊이 20(엔진).
"""
from __future__ import annotations
import functools
import json
import re
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
FIELDS = ("변수줄", "식덮개")
LONG = 300 # 식 글 길이
DEEP = 12 # 괄호 깊이
MANY = 100 # 변수 줄 수
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
WORD = {
"LB": "노임",
"EQ": "기계",
"MT": "자재",
"MP": "자재",
"MO": "유가",
"FX": "환율",
"RT": "요율",
}
_VAR = re.compile(r"^[A-Za-z가-힣_][\w가-힣]{0,19}$")
_ADD = re.compile(r"^add:#(\d+)$")
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
def _eng() -> Any:
from M02_MasterTemplete import M02_Labor_Doc as labor
return labor.m01.cm.mf
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
return m01.loaded()[1]
# ── 꼴 검사(문서 · `sd.clean` 끝) ─────────────────────────────────────
def limits(text: str) -> list[str]:
"""식 글 상한 — 길이 · 괄호 깊이(파서 앞 · 깊은 괄호 = 서버 500 막음)."""
out, depth, top, quoted = [], 0, 0, False
if len(text) > LONG:
out.append(f"식이 {LONG} 자를 넘음")
for ch in text:
if ch == "'":
quoted = not quoted
elif not quoted and ch in "([":
depth += 1
top = max(top, depth)
elif not quoted and ch in ")]":
depth -= 1
if top > DEEP:
out.append(f"괄호가 {DEEP} 겹을 넘음")
return out
def _text(raw: Any, where: str) -> str:
got = str(raw if raw is not None else "").strip()
if not got:
raise sd.DocError(f"{where} · 식이 빔")
if bad := limits(got):
raise sd.DocError(f"{where} · {bad[0]}")
return got
def clean(raw: dict, out: dict) -> dict:
"""`sd.clean` 끝(790-2 `clean_more` 뒤) — 변수줄 · 식덮개 · 더한줄 수량식 꼴 · 상한 · 겹침."""
lines = raw.get("변수줄") or []
if not isinstance(lines, list) or len(lines) > MANY:
raise sd.DocError(f"변수줄 · {MANY} 줄까지 목록")
got, seen = [], set()
for at, one in enumerate(lines, 1):
one = one if isinstance(one, dict) else {}
name, where = str(one.get("이름") or "").strip(), f"변수줄 {at}째"
if not _VAR.match(name) or _eng().mk.KEY.fullmatch(name):
raise sd.DocError(f"{where} · 이름 꼴 아님 「{name}」(글자로 시작 · 20 자까지)")
if name in seen:
raise sd.DocError(f"{where} · 같은 이름 「{name}」")
seen.add(name)
line = {"이름": name, "식": _text(one.get("식"), f"{where} 「{name}」")}
if one.get("표시") not in (None, ""):
show = sd._num(one["표시"], f"{where} 표시")
if show != int(show) or not 0 <= show <= 6:
raise sd.DocError(f"{where} · 표시는 0~6 자리")
line["표시"] = int(show)
spot = str(one.get("자리") or "")
if spot and not spot.startswith("mid:"):
raise sd.DocError(f'{where} · 자리는 중간 `mid:이름` · 맨 위 ""')
got.append(line | {"자리": spot})
if got:
out["변수줄"] = got
covers = raw.get("식덮개") or {}
if not isinstance(covers, dict):
raise sd.DocError("식덮개 · 묶음 아님")
fx = {}
for spot, text in covers.items():
if not isinstance(spot, str) or not _SPOT.match(spot):
raise sd.DocError(f"식덮개 · 자리 꼴 아님 「{spot}」")
if spot in (out.get("상수덮개") or {}) or spot in (out.get("표값덮개") or {}):
raise sd.DocError(f"식덮개 「{spot}」 · 상수 · 표값 덮개와 같이 못 둠")
if "qty" in ((out.get("덮개") or {}).get(spot) or {}):
raise sd.DocError(f"식덮개 「{spot}」 · 품 덮개(qty)와 같이 못 둠 — 식에 넣을 것")
fx[spot] = _text(text, f"식덮개 「{spot}」")
if fx:
out["식덮개"] = fx
adds = raw.get("더한줄") or []
for k, one in enumerate(out.get("더한줄") or []):
text = adds[k].get("수량식") if k < len(adds) and isinstance(adds[k], dict) else None
if text in (None, ""):
continue
where = f"더한줄 {k + 1}째 수량식"
if sd.is_rate(one) or one.get("종류") == "로직":
raise sd.DocError(f"{where} · 율 줄 · 로직 줄은 수만")
one["수량식"] = _text(text, where)
return out
def has(doc: Any) -> bool:
"""식 칸을 쓰는 문서."""
if not isinstance(doc, dict):
return False
adds = [x for x in doc.get("더한줄") or [] if isinstance(x, dict)]
return any(doc.get(k) for k in FIELDS) or any(x.get("수량식") for x in adds)
# ── 이름 꼴 ↔ 엔진 식 ────────────────────────────────────────────────
@functools.lru_cache(maxsize=2)
def _labels(whole: Any) -> tuple[dict, dict]:
"""(키 → 이름 꼴 글, 글 → 키) — 같은 글이 여럿이면 글에 키를 덧붙임."""
base: dict[str, str] = {}
for tid, word in WORD.items():
for key, row in (whole.index.get(tid) or {}).items():
words = (word, str(row.get("이름") or ""), str(row.get("규격") or ""))
base[key] = re.sub(r"\s+", " ", re.sub(r"[\[\]]", "", " ".join(words))).strip()
count: dict[str, int] = {}
for label in base.values():
count[label] = count.get(label, 0) + 1
label_of = {k: v if count[v] == 1 else f"{v} {k}" for k, v in base.items()}
return label_of, {v: k for k, v in label_of.items()}
def _mc() -> Any:
_eng() # 엔진 길(scripts)이 sys.path 에 깔림
import master_copy
return master_copy
@functools.lru_cache(maxsize=512)
def _alias_of(raw: str) -> tuple[tuple[str, str], ...]:
"""바탕 줄 별칭표 ((이름, 원문)) — 줄 글마다 한 번."""
return tuple((a["이름"], a["원문"]) for a in _mc().aliases(json.loads(raw), _whole()))
def aliases(row: dict) -> dict[str, str]:
"""{별칭: 원문 부르기}(바탕 줄 · `master_copy.aliases`)."""
return dict(_alias_of(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)))
@functools.cache
def _catch() -> type:
"""엔진 파서 + 요소 키 · 부르기 글자 자리 기록."""
mf = _eng()
class Catch(mf._Parser):
def __init__(self, text: str) -> None:
super().__init__(text)
self.refs: list = []
self.calls: list = []
def primary(self): # noqa: ANN202
self.ws()
at = self.i
node = super().primary()
if node[0] == "ref" and mf.mk.KEY.fullmatch(node[1]):
self.refs.append((at, self.i, node[1]))
elif node[0] in ("find", "logic"):
self.calls.append((at, self.i))
return node
return Catch
def _spots(text: str) -> tuple[list, list] | None:
"""(요소 키 자리, 바깥 부르기 자리) · 못 읽음 = None."""
mf, p = _eng(), _catch()(text)
try:
p.parse()
except (mf.FormulaError, RecursionError):
return None
calls = [
c for c in p.calls if not any(o[0] <= c[0] and c[1] <= o[1] and o != c for o in p.calls)
]
return p.refs, calls
def to_name(text: str, row: dict) -> str:
"""엔진 식 → 이름 꼴(`*` → `×` · 요소 = `[노임 …]` · 바탕 별칭 부르기 = `[별칭]`)."""
got = _spots(text)
if got is None:
return text
refs, calls = got
label_of = _labels(_whole())[0]
raw_to = {re.sub(r"\s+", " ", v): k for k, v in aliases(row).items()}
cuts = []
for a, b in calls:
name = raw_to.get(re.sub(r"\s+", " ", text[a:b]))
if name:
cuts.append((a, b, f"[{name}]"))
for a, b, key in refs:
if key in label_of and not any(x <= a and b <= y for x, y in calls):
cuts.append((a, b, f"[{label_of[key]}]"))
out, at = [], 0
for a, b, word in sorted(cuts):
out += [_times(text[at:a]), word]
at = b
return "".join(out) + _times(text[at:])
def _times(part: str) -> str:
"""따옴표 밖 `*` → `×`."""
bits = part.split("'")
return "'".join(b.replace("*", "×") if i % 2 == 0 else b for i, b in enumerate(bits))
def to_engine(text: str, row: dict) -> tuple[str, list[str]]:
"""이름 꼴 → (엔진 식, 까닭) — `×` `÷` · `[…]` = 키 · 바탕 별칭 · 요소 글 · 맨 별칭 이름."""
names = aliases(row)
find = _labels(_whole())[1]
mk = _eng().mk
out, why, at, quoted = [], [], 0, False
while at < len(text):
ch = text[at]
if ch == "'":
quoted = not quoted
if quoted or ch not in "×÷[]":
out.append(ch)
at += 1
continue
if ch in "×÷":
out.append("*" if ch == "×" else "/")
at += 1
continue
end = text.find("]", at)
if ch == "]" or end < 0:
return text, ["대괄호 짝 안 맞음"]
word = re.sub(r"\s+", " ", text[at + 1 : end]).strip()
keys = mk.KEY.findall(word)
if word in names:
out.append(names[word])
elif word in find:
out.append(find[word])
elif keys and len(keys) == 1:
out.append(keys[0])
else:
why.append(f"없는 이름 「[{word}]」")
at = end + 1
got = "".join(out)
taken = _names(row)
bare = {k: v for k, v in names.items() if k not in taken}
return (_unmask(got, bare) if bare else got), why
def _unmask(text: str, raw_of: dict) -> str:
return _mc()._unmask(text, raw_of)
def _names(row: dict) -> set[str]:
return {str(x.get("이름")) for k in ("입력", "중간", "덧줄") for x in row.get(k) or []}
# ── 바탕 줄 + 식 칸 → 엔진 줄 ──────────────────────────────────────────
def _places(row: dict) -> dict[str, tuple[str, int]]:
from M02_MasterTemplete import M02_Sangeun_Const as const
return const.places(row)
def _bad(spot: str, why: str) -> dict:
return {"cid": spot, "까닭": f"식 — {why}"}
def settle(row: dict, doc: dict, strict: bool = False) -> tuple[dict, list[dict], dict]:
"""바탕 줄 + 식 칸 → (엔진 줄, 틀린 자리 [{cid, 까닭}], 엔진 식으로 바꾼 문서) · 틀린 자리 =
M01 식(변수 = 뺌 · 수량식 = 수) · `strict` = 하나라도 틀리면 `DocError`(저장)."""
if not has(doc):
return row, [], doc
where, bad, gone = _places(row), [], []
covers, lines, adds = {}, [], [dict(x) for x in doc.get("더한줄") or []]
for spot, text in (doc.get("식덮개") or {}).items():
got, why = to_engine(text, row)
if spot not in where:
gone.append(_bad(spot, "바탕에 없는 줄"))
elif why:
bad += [_bad(spot, w) for w in why]
else:
covers[spot] = got
taken, mf = _names(row), _eng()
for one in doc.get("변수줄") or []:
spot, (got, why) = f"mid:{one['이름']}", to_engine(one["식"], row)
if one["이름"] in taken or one["이름"] in _reserved():
bad.append(
_bad(spot, f"이름 「{one['이름']}」 이 이미 있음(입력 · 중간 · 덧줄 · 예약)")
)
elif why:
bad += [_bad(spot, w) for w in why]
else:
lines.append({**one, "식": got})
for k, one in enumerate(adds):
if one.get("수량식"):
got, why = to_engine(one["수량식"], row)
bad += [_bad(f"add:#{k}", w) for w in why]
if why:
one.pop("수량식")
else:
one["수량식"] = got
while True: # 틀린 자리를 빼면 다른 자리가 틀릴 수 있음 — 더 안 틀릴 때까지
out, lost = _build(row, covers, lines)
more = _check(out, covers, lines, adds, mf)
if not more:
break
bad += more
hit = {b["cid"] for b in more}
covers = {s: v for s, v in covers.items() if s not in hit}
lines = [x for x in lines if f"mid:{x['이름']}" not in hit]
for k in [int(_ADD.match(c)[1]) for c in hit if _ADD.match(c)]:
adds[k].pop("수량식", None)
if strict and bad: # 짝 잃음(바탕에 없는 줄 · 자리)은 알림만 — [M01 원래로] 로 걷음
raise sd.DocError(f"「{bad[0]['cid']}」 {bad[0]['까닭']}")
fixed = {k: v for k, v in doc.items() if k not in FIELDS} | {"더한줄": adds}
fixed |= ({"식덮개": covers} if covers else {}) | ({"변수줄": lines} if lines else {})
return out, bad + gone + lost, fixed
@functools.cache
def _reserved() -> frozenset:
mf = _eng()
return frozenset(
(*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄", "번지", "단계")
)
def _build(row: dict, covers: dict, lines: list[dict]) -> tuple[dict, list[dict]]:
"""식 덮개를 바탕 칸에 · 변수 줄을 자리 뒤에 끼움(같은 자리 = 넣은 차례)."""
out = {**row, **{k: list(row.get(k) or []) for k in FIELD}}
for spot, (part, at) in _places(row).items():
if spot in covers:
out[part][at] = {**out[part][at], FIELD[part]: covers[spot]}
mids, after, lost = out["중간"], {}, []
for one in lines:
spot = one["자리"]
names = [f"mid:{x.get('이름')}" for x in mids]
if spot and spot not in names:
lost.append(_bad(f"mid:{one['이름']}", f"자리 「{spot}」 없음 — 중간 끝에 둠"))
at = len(mids)
else:
at = after.get(spot, names.index(spot) + 1 if spot else 0)
step = {"이름": one["이름"], "식": one["식"], "변수": True}
mids.insert(at, step | ({"표시": one["표시"]} if "표시" in one else {}))
after = {k: v + (v >= at) for k, v in after.items()} | {spot: at + 1}
return out, lost
def _uses(text: str) -> set[str] | None:
mf = _eng()
try:
return {n[1] for n in mf.walk(mf.parse(text)) if n[0] == "name"}
except (mf.FormulaError, RecursionError):
return None
def _zero(node: tuple, mf: Any) -> bool:
return any(n[0] == "bin" and n[1] == "/" and n[3] == ("num", 0) for n in mf.walk(node))
def _check(row: dict, covers: dict, lines: list, adds: list, mf: Any) -> list[dict]:
"""식 칸 자리만 검사 — 식 · 0 나눔 · 이름(엔진 `_check_node`) · 아래 줄 · 순환."""
inputs = {str(x.get("이름")) for x in row.get("입력") or []}
mids = [str(x.get("이름")) for x in row["중간"]]
deps = {n: (_uses(str(x.get("식"))) or set()) & set(mids) for n, x in zip(mids, row["중간"])}
mine = set(covers) | {f"mid:{x['이름']}" for x in lines}
full = inputs | set(mids)
tail = full | set(mf.COST_ITEMS) | {"줄"} | ({"번지", "단계"} if mf.ms.uses(row) else set())
jobs = [
(f"mid:{n}", str(x["식"]), inputs | set(mids[:i]), n)
for i, (n, x) in enumerate(zip(mids, row["중간"]))
]
jobs = [j for j in jobs if j[0] in mine]
places = _places(row)
for spot, (part, at) in places.items():
if spot in covers and part != "중간":
names = (
tail | {str(x.get("이름")) for x in row["덧줄"][:at]} if part == "덧줄" else full
)
jobs.append((spot, covers[spot], names, None))
jobs += [(f"add:#{k}", x["수량식"], full, None) for k, x in enumerate(adds) if x.get("수량식")]
out = []
for spot, text, known, me in jobs:
try:
node = mf.parse(text)
except (mf.FormulaError, RecursionError) as e:
out.append(_bad(spot, str(e)))
continue
why = ["0 으로 나눔"] if _zero(node, mf) else []
used = {n[1] for n in mf.walk(node) if n[0] == "name"}
for n in sorted(used - known):
if n == me:
why.append(f"순환 — 「{n}」 이 자기 자신을 씀")
elif me and n in deps:
loop = _path(deps, n, me)
why.append(
f"순환 — {' → '.join([me, *loop])}"
if loop
else f"「{n}」 은 아래 줄 — 위로 옮길 것"
)
found: list[str] = []
mf._check_node(row, node, known | (used & set(mids)), _whole(), "", found, set())
why += [w.lstrip(" ·") for w in found]
out += [_bad(spot, w) for w in why]
return out
def _path(deps: dict, start: str, goal: str) -> list[str] | None:
"""`start` 에서 `goal` 로 가는 의존 길(있으면 순환)."""
seen, todo = {start}, [[start]]
while todo:
path = todo.pop()
if path[-1] == goal:
return path
for nxt in sorted(deps.get(path[-1], ())):
if nxt not in seen:
seen.add(nxt)
todo.append([*path, nxt])
return None
# ── 셈 답 덧칸(790-2 `시트`) ─────────────────────────────────────────
def sheet_more(spots: dict, base: dict, clean: dict, mids: dict) -> list[dict]:
"""790-2 시트 자리에 덧칸 — 식글(지금 식 이름 꼴) · 원식글 · 식덮개 · 식틀림 · 변수 줄 ·
더한줄 식글 · 돌려줌 = 틀린 식 · 짝 잃은 식 칸(시트 `짝잃음` 에 더함)."""
from M02_MasterTemplete import M02_Sangeun_Const as const
row, bad, fixed = settle(base, clean)
eff, covers = const._swapped(row, clean), fixed.get("식덮개") or {}
now, why = _places(eff), clean.get("까닭") or {}
wrong: dict[str, list[str]] = {}
for b in bad:
wrong.setdefault(b["cid"], []).append(b["까닭"])
for spot, one in spots.items():
if spot in now and one.get("갈래") != "더한줄":
part, at = now[spot]
one["원식글"] = to_name(str(one.get("식")), base)
one["식글"] = to_name(str(eff[part][at].get(FIELD[part])), base)
one["식덮개"] = spot in covers
hit = _ADD.match(spot)
adds = fixed.get("더한줄") or []
if hit and int(hit[1]) < len(adds) and adds[int(hit[1])].get("수량식"):
one["식"] = adds[int(hit[1])]["수량식"]
one["식글"] = to_name(one["식"], base)
if spot in wrong:
one["식틀림"] = wrong[spot]
good = {x["이름"]: x["식"] for x in fixed.get("변수줄") or []}
for line in clean.get("변수줄") or []:
spot, text = f"mid:{line['이름']}", good.get(line["이름"], line["식"])
spots[spot] = {
"갈래": "변수",
"이름": line["이름"],
"식": text,
"식글": to_name(text, base),
"표시": line.get("표시"),
"자리": line["자리"],
"원래": None,
"지금": mids.get(line["이름"]) if line["이름"] in good else None,
"덮개": [],
"까닭": why.get(spot),
"M01바뀜": False,
**({"식틀림": wrong[spot]} if spot in wrong else {}),
}
return bad
def name_table(base: dict, clean: dict) -> list[dict]:
"""식 칸에서 부를 이름 — 입력 · 중간 · 변수 · 덧줄 · 비목 · 별칭 · 자원 · 번지 · 단계."""
mf = _eng()
out = [{"글": str(x.get("이름")), "갈래": "입력"} for x in base.get("입력") or []]
out += [{"글": str(x.get("이름")), "갈래": "중간"} for x in base.get("중간") or []]
out += [{"글": x["이름"], "갈래": "변수"} for x in clean.get("변수줄") or []]
out += [{"글": str(x.get("이름")), "갈래": "덧줄"} for x in base.get("덧줄") or []]
out += [{"글": k, "갈래": "비목"} for k in mf.COST_ITEMS]
out += [{"글": f"[{k}]", "갈래": "별칭"} for k in aliases(base)]
label_of, seen = _labels(_whole())[0], set()
for part in FIELD:
for x in base.get(part) or []:
texts = [
str(x.get(FIELD[part])),
*([x["요소"]] if isinstance(x.get("요소"), str) else []),
]
for key in (k for t in texts for k in mf.mk.KEY.findall(t)):
if key in label_of and key not in seen:
seen.add(key)
out.append({"글": f"[{label_of[key]}]", "갈래": "자원"})
lines = [*(base.get("호표") or []), *(base.get("덧줄") or [])]
out += [{"글": f"번지.{x['번지']}", "갈래": "번지"} for x in lines if x.get("번지")]
out += [{"글": f"단계.'{x.get('이름')}'", "갈래": "단계"} for x in base.get("단계") or []]
return out
# ── 검사 길(시트가 칠 때마다 · 셈 없음) ──────────────────────────────
def check_doc(doc: Any, spot: str, text: str) -> dict[str, Any]:
"""식 글 하나 → {되나, 까닭, 알림, 식(엔진), 글(이름 꼴)} — 자리 = 중간 `mid:이름`(변수 줄
포함) · 호표 · 덧줄 cid · `add:#k` · 문서 = 화면 캐시(그 자리 옛 식은 빼고 봄)."""
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Sangeun_Calc as calc
text = str(text or "").strip()
out: dict[str, Any] = {"되나": False, "까닭": [], "알림": [], "식": None, "글": None}
if not text:
return out | {"까닭": ["식이 빔"]}
if bad := limits(text):
return out | {"까닭": bad}
whole, doc = _whole(), _without(doc if isinstance(doc, dict) else {}, spot)
try:
clean_ = sd.clean(doc, None, whole)
base = calc._base_of(clean_, whole, refs.read_ref, ())["줄"]
except sd.DocError as e:
return out | {"까닭": [f"문서 · {e}"]}
got, why = to_engine(text, base)
if why:
return out | {"까닭": why}
out |= {"식": got, "글": to_name(got, base)}
trial, why = _put(clean_, base, spot, got)
if why:
return out | {"까닭": [why]}
bad = [b["까닭"].removeprefix("식 — ") for b in settle(base, trial)[1] if b["cid"] == spot]
return out | {"되나": not bad, "까닭": bad, "알림": _risks(got, base, trial)}
def _without(doc: dict, spot: str) -> dict:
"""그 자리 옛 식을 뺀 문서(옛 식이 틀려도 문서 검사가 안 막게)."""
out = dict(doc)
if isinstance(out.get("식덮개"), dict):
out["식덮개"] = {k: v for k, v in out["식덮개"].items() if k != spot}
if isinstance(out.get("변수줄"), list):
out["변수줄"] = [
{**x, "식": "0"} if isinstance(x, dict) and f"mid:{x.get('이름')}" == spot else x
for x in out["변수줄"]
]
hit = _ADD.match(spot)
if hit and isinstance(out.get("더한줄"), list) and int(hit[1]) < len(out["더한줄"]):
adds = list(out["더한줄"])
adds[int(hit[1])] = {k: v for k, v in dict(adds[int(hit[1])]).items() if k != "수량식"}
out["더한줄"] = adds
return out
def _put(doc: dict, base: dict, spot: str, text: str) -> tuple[dict, str | None]:
"""문서 그 자리에 식(엔진) — (새 문서, 못 넣는 까닭)."""
names = [x["이름"] for x in doc.get("변수줄") or []]
hit = _ADD.match(spot)
if spot.startswith("mid:") and spot[4:] in names:
lines = [{**x, "식": text} if x["이름"] == spot[4:] else x for x in doc["변수줄"]]
return {**doc, "변수줄": lines}, None
if spot in _places(base):
if spot in (doc.get("상수덮개") or {}) or spot in (doc.get("표값덮개") or {}):
return doc, "상수 · 표값 덮개가 있는 줄 — [M01 원래로] 뒤 식 고침"
return {**doc, "식덮개": {**(doc.get("식덮개") or {}), spot: text}}, None
adds = doc.get("더한줄") or []
if hit and int(hit[1]) < len(adds):
one = adds[int(hit[1])]
if sd.is_rate(one) or one.get("종류") == "로직":
return doc, "율 줄 · 로직 줄 수량은 수만"
adds = [{**x, "수량식": text} if k == int(hit[1]) else x for k, x in enumerate(adds)]
return {**doc, "더한줄": adds}, None
return doc, f"없는 자리 「{spot}」"
def _risks(text: str, base: dict, doc: dict) -> list[str]:
"""0 나눔 위험 — 나누는 쪽 이름이 입력값 0 · 고르기에 0."""
mf, out = _eng(), []
given = doc.get("입력값") or {}
picks = {str(x.get("이름")): x.get("고르기") or [] for x in base.get("입력") or []}
for node in mf.walk(mf.parse(text)):
if node[0] == "bin" and node[1] == "/":
for n in (x[1] for x in mf.walk(node[3]) if x[0] == "name"):
zero = str(given.get(n)) in ("0", "0.0") or any(
str(c) == "0" for c in picks.get(n, [])
)
if zero and f"「{n}」" not in " ".join(out):
out.append(f"「{n}」 가 0 이면 0 나눔")
return out
+20 -1
View File
@@ -1,5 +1,6 @@
"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` ·
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한).
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) ·
식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한).
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
@@ -19,6 +20,7 @@ from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Formula as fx
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"])
@@ -71,3 +73,20 @@ def calc_sangeun(
if body.문서 is None and body.키 is None:
raise HTTPException(status_code=400, detail="문서 또는 키")
return _call(_root(session, layer, owner), _calc, body)
class FormulaCheckBody(BaseModel):
문서: dict[str, Any]
자리: str
식: str
@router.post("/layers/{layer}/sangeun/formula-check")
def check_formula(
layer: Layer,
body: FormulaCheckBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""식 칸 검사(792-1) — {되나, 까닭, 알림, 식(엔진), 글(이름 꼴)} · 셈 · 저장 없음."""
return _call(_root(session, layer, owner), fx.check_doc, body.문서, body.자리, body.식)