diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py index d89913e30..a0061cd98 100644 --- a/M02_MasterTemplete/M02_Library_Same.py +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -131,6 +131,8 @@ class Same: "덮개": norm(_bare(doc.get("덮개"))), "더한줄": _sorted([self._line(x, trail, True) for x in doc.get("더한줄") or []]), "뺀줄": sorted(map(str, doc.get("뺀줄") or [])), + # 값 바꾸는 새 칸(790-2 · 792-1) — 없으면 빠짐 = 옛 지문 그대로 + **{k: norm(doc.get(k)) for k in ("상수덮개", "표값덮개", "식덮개", "변수줄")}, } return {k: v for k, v in body.items() if v not in EMPTY} diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index bd9a087ea..8dd8e61db 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -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: diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py index 07f63a82d..b0c8647c2 100644 --- a/M02_MasterTemplete/M02_Sangeun_Const.py +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -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}} diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index e01857fc5..af60e8522 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -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 = [], [], [], {}, {} diff --git a/M02_MasterTemplete/M02_Sangeun_Formula.py b/M02_MasterTemplete/M02_Sangeun_Formula.py new file mode 100644 index 000000000..b090043b1 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Formula.py @@ -0,0 +1,619 @@ +"""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 {} + try: + tree = mf.parse(text) + except (mf.FormulaError, RecursionError): + return out + picks = {str(x.get("이름")): x.get("고르기") or [] for x in base.get("입력") or []} + for node in mf.walk(tree): + 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 diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index cbbe03c3f..5399add5a 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -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.식) diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts index 2e48fe763..a53486995 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts @@ -31,7 +31,7 @@ export interface ConstDoc { [k: string]: unknown; } -export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌"; +export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌"; const ADD = /^add:#(\d+)(\/.*)?$/; const PARTS = ["labor", "material", "expense"]; @@ -120,6 +120,7 @@ export function restoreM01(doc: T, key?: string): T { let out: ConstDoc = { ...doc }; delete out.상수덮개; delete out.표값덮개; + delete out.식덮개; // 식 칸(792-1) delete out.까닭; const why = { ...(doc.까닭 ?? {}) }; if (key == null) { @@ -149,8 +150,9 @@ export function restoreM01(doc: T, key?: string): T { } else { out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key)); out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key); - for (const name of ["상수덮개", "표값덮개"] as const) { - const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key)); + for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) { + const all = (doc[name] ?? {}) as Record; + const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key)); out = put(out, name, got); } } @@ -187,6 +189,7 @@ export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] { if (PARTS.some((k) => k in cover)) out.push("단가"); if (doc.상수덮개?.[spot]) out.push("상수"); if (doc.표값덮개?.[spot]) out.push("표값"); + if ((doc.식덮개 as Record | undefined)?.[spot]) out.push("식"); if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌"); return out; } diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts new file mode 100644 index 000000000..4e72098bc --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts + * 단가산출(SK) 사용자 식 칸 캐시 고치기(PLAN 792-1 · 요구서_652 · _분석_650-H3 E4) — + * 서버 `M02_MasterTemplete/M02_Sangeun_Formula.py`(꼴 검사 · 번역 · 셈)와 같은 꼴 · 서버 짝 함수 없음 + * · 시험 `resources/tester/test_792_1_sangeun_formula.py`(여기 결과를 서버 `clean` 이 받음). + * 변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄 · 자리 = 그 뒤 중간 `mid:이름`("" = 맨 위) + * 식덮개 {자리: 식} 중간 `mid:이름` · 호표 · 덧줄 cid + * 더한줄[k].수량식 율 줄 · 로직 줄 빼고 + * 식 글 = 검사 길(`formula-check`) 답 `식`(엔진) 을 넣음 · 이름 꼴 글을 넣어도 서버가 번역. + * 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈). + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +import type { ConstDoc } from "./M02_Sangeun_Const_Model"; + +export interface VarLine { + 이름: string; + 식: string; + 표시?: number; + 자리: string; +} + +export interface FormulaDoc extends ConstDoc { + 변수줄?: VarLine[]; + 식덮개?: Record; +} + +const ADD_ONE = /^add:#(\d+)$/; + +/** 빈 묶음 · 목록 칸은 지움(서버처럼 없으면 안 둠). */ +function putField(doc: T, name: string, value: unknown): T { + const out: FormulaDoc = { ...doc }; + const empty = + value == null || + (Array.isArray(value) + ? value.length === 0 + : typeof value === "object" && Object.keys(value as object).length === 0); + if (empty) delete out[name]; + else out[name] = value; + return out as T; +} + +const isVar = (doc: FormulaDoc, spot: string): boolean => + spot.startsWith("mid:") && (doc.변수줄 ?? []).some((v) => v.이름 === spot.slice(4)); + +/** 자리 식 — 변수 줄 = 그 식 · `add:#k` = 수량식 · 그 밖 = 식덮개 · null · "" = 지움(M01 식 · 수). */ +export function setFormula(doc: T, spot: string, text: string | null): T { + const t = (text ?? "").trim(); + if (isVar(doc, spot)) return editVar(doc, spot.slice(4), { 식: t }); + const hit = ADD_ONE.exec(spot); + if (hit) { + const k = Number(hit[1]); + const adds = (doc.더한줄 ?? []).map((x, i) => { + if (i !== k) return x; + const one: Record = { ...x }; + if (t) one.수량식 = t; + else delete one.수량식; + return one; + }); + return { ...doc, 더한줄: adds }; + } + const covers = { ...(doc.식덮개 ?? {}) }; + if (t) covers[spot] = t; + else delete covers[spot]; + return putField(doc, "식덮개", covers); +} + +/** 변수 줄 넣기 — `at` 차례(없으면 끝) · 같은 이름이 있으면 그대로. */ +export function addVar(doc: T, line: VarLine, at?: number): T { + const lines = [...(doc.변수줄 ?? [])]; + if (lines.some((v) => v.이름 === line.이름)) return doc; + lines.splice(at ?? lines.length, 0, { ...line }); + return putField(doc, "변수줄", lines); +} + +/** 변수 줄 고침 — 이름 바꾸면 까닭 · 글줄 · 다른 변수 자리 열쇠 `mid:이름` 도 따라감(식 속 이름은 + * 그대로 — 검사 길이 짚음). */ +export function editVar(doc: T, name: string, patch: Partial): T { + const to = patch.이름 ?? name; + const lines = (doc.변수줄 ?? []).map((v) => { + if (v.이름 === name) { + const one: VarLine = { ...v, ...patch }; + if (patch.표시 == null && "표시" in patch) delete one.표시; + return one; + } + return v.자리 === `mid:${name}` ? { ...v, 자리: `mid:${to}` } : v; + }); + const out = putField(doc, "변수줄", lines); + return to === name ? out : moveKey(out, `mid:${name}`, `mid:${to}`); +} + +/** 변수 줄 빼기 — 그 뒤에 붙은 변수는 그 자리를 이어받음 · 까닭 지움 · 글줄은 맨 위로. */ +export function dropVar(doc: T, name: string): T { + const gone = (doc.변수줄 ?? []).find((v) => v.이름 === name); + if (!gone) return doc; + const lines = (doc.변수줄 ?? []) + .filter((v) => v.이름 !== name) + .map((v) => (v.자리 === `mid:${name}` ? { ...v, 자리: gone.자리 } : v)); + return moveKey(putField(doc, "변수줄", lines), `mid:${name}`, null); +} + +/** 까닭 · 글줄 열쇠 옮김(null = 까닭 지움 · 글줄 맨 위). */ +function moveKey(doc: T, from: string, to: string | null): T { + let out: T = doc; + if (doc.까닭 && from in doc.까닭) { + const why = { ...doc.까닭 }; + const text = why[from]; + delete why[from]; + if (to) why[to] = text; + out = putField(out, "까닭", why); + } + if (doc.글줄) { + out = { ...out, 글줄: doc.글줄.map((t) => (t.자리 === from ? { ...t, 자리: to ?? "" } : t)) }; + } + return out; +} diff --git a/resources/tester/test_792_1_sangeun_formula.py b/resources/tester/test_792_1_sangeun_formula.py new file mode 100644 index 000000000..c85ecb194 --- /dev/null +++ b/resources/tester/test_792_1_sangeun_formula.py @@ -0,0 +1,324 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""792-1 단가산출(SK) 사용자 식 칸 — 변수 줄 · 식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사. + +- 금액 = 서버 셈 = _분석_650-H2 4 장 견본(인력운반 「할증율 20」 변수 줄 + 보통인부 품 식 + × (1 + 할증율 / 100) = 51,501) · 합판거푸집 줄 식 고침 · 공구손료 식 2 % = 51,802(790-2 같은 값) +- 이름 꼴(`×` · `[노임 형틀목공]` · `[별칭]`) ↔ 엔진 식 왕복 = M01 로직 견본 식 전부 같음 +- 검사 까닭 — 없는 이름 · 괄호 · 순환 · 0 나눔 · 길이 · 괄호 깊이 · 변수 줄 수 · 자리 겹침 +- 옛 SK(식 칸 없음) = 꼴 · 금액 그대로 · 저장 = 엔진 식 · 틀린 식 저장 400 · 셈 답 덧칸 +- B09 사본 굳힘(`GX9#####`) 줄 = 식 칸 그대로 · 다시 셈 같은 금액 +- TS `Formula_Model` · `Const_Model`(식덮개) 결과 = 서버가 받음 · [M01 원래로] 거울 +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Sangeun as sk +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Flat as flat +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Const as const +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Formula as fx +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +FORM, CARRY = "GC000211", "GC001140" # 합판거푸집 · 인력운반(지게) +MAN = "labor:LB000002#0" # 인력운반 보통인부 +TOOL = "pct:공구손료 및 경장비#0" +F211 = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님"} +F211 |= {"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} +PLUS = "무게 / 운반량 / 일운반회수 × (1 + 할증율 / 100)" + + +def _whole(): + return m01.loaded()[1] + + +def _carry(): + base = json.loads(json.dumps(_whole().logic(CARRY)["시험입력"], default=str)) + return {**base, "재료종별": "암석", "재료형상": "화강암", "운반회수": 0} + + +def _doc(code, given, **more): + return { + "키": "SK-000001", + "이름": "견본", + "단위": "식", + "바탕": {"로직": code}, + "입력값": given, + } + + +def _sk_doc(code, given, **more): + return {**_doc(code, given), **more} + + +def _run(doc): + whole = _whole() + return calc.run(sd.clean(doc, None, whole), whole) + + +def _sum(doc): + return float(_run(doc)["기준"]["계"]) + + +def _plus(**more): + var = [{"이름": "할증율", "식": "20", "자리": ""}] + return _sk_doc(CARRY, _carry(), **({"변수줄": var, "식덮개": {MAN: PLUS}} | more)) + + +# ── 견본 금액 ───────────────────────────────────────────────────────── +def test_변수_줄_할증율_견본(): + assert _sum(_doc(CARRY, _carry())) == 42917 + assert _sum(_plus()) == 51501 # _분석_650-H2 4 장 + var = [{"이름": "할증율", "식": "만약(운반거리 > 5, 20, 0)", "표시": 0, "자리": "mid:무게"}] + assert _sum(_sk_doc(CARRY, _carry(), 변수줄=var, 식덮개={MAN: PLUS})) == 51501 + eng = PLUS.replace("×", "*") + assert _sum(_sk_doc(CARRY, _carry(), 변수줄=var, 식덮개={MAN: eng})) == 51501 # 엔진 꼴도 + + +def test_합판거푸집_줄_식_고침(): + base = _sum(_doc(FORM, F211)) + assert base == 51419 + tool = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 2 / 100" + assert _sum(_sk_doc(FORM, F211, 식덮개={TOOL: tool})) == 51802 # = 790-2 공구손료 2 % + more = "[합판거푸집설치및해체일당].형틀목공 × 1.1 / 시공량" + got = _run(_sk_doc(FORM, F211, 식덮개={"mid:형틀목공": more})) + line = next(x for x in got["줄"] if x["cid"] == "labor:LB000007#0") + assert float(line["수량"]) == pytest.approx(5 * 1.1 / 45) + assert got["기준"]["계"] > base + + +def test_더한줄_수량식(): + add = { + "종류": "인력", + "요소": "LB000002", + "수량": 1, + "수량식": "무게 / 운반량 / 일운반회수 / 2", + } + got = _run(_sk_doc(CARRY, _carry(), 더한줄=[add])) + one, mine = (next(x for x in got["줄"] if x["cid"] == c) for c in (MAN, "add:#0")) + assert float(mine["수량"]) == pytest.approx(float(one["수량"]) / 2) + for bad in ({"수량식": "1", "종류": "로직", "로직": FORM, "수량": 1},): + with pytest.raises(sd.DocError, match="수만"): + sd.clean(_sk_doc(FORM, F211, 더한줄=[bad]), None, _whole()) + + +# ── 이름 꼴 ↔ 엔진 식 ──────────────────────────────────────────────── +def test_이름_꼴_양방향(): + whole = _whole() + row = whole.logic(FORM) + tool = row["덧줄"][0]["식"] + named = fx.to_name(tool, row) + assert named == "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100" + mid = fx.to_name(row["중간"][2]["식"], row) + assert mid.startswith("[합판거푸집설치및해체일당].시공량 × (1 - ") + assert fx.to_engine("[LB000007] ÷ 2", row) == ("LB000007 / 2", []) + assert fx.to_engine("[노임 형틀목공 LB000007] × 2", row)[0] == "LB000007 * 2" + assert fx.to_engine("합판거푸집구멍땜.시멘트", row)[0] == "찾기(QC000430).시멘트" # 맨 별칭 + assert fx.to_engine("'[가]' × 1", row)[0] == "'[가]' * 1" # 따옴표 안 그대로 + assert fx.to_engine("[없는것] × 1", row)[1] == ["없는 이름 「[없는것]」"] + assert fx.to_engine("[노임 × 1", row)[1] == ["대괄호 짝 안 맞음"] + for code in (FORM, CARRY, "GC000374", "GC000360"): # 띄어 쓴 자재 이름 · 긴 덧줄 + one = whole.logic(code) + for part, f in (("중간", "식"), ("호표", "수량"), ("덧줄", "식")): + for x in one.get(part) or []: + assert fx.to_engine(fx.to_name(str(x[f]), one), one) == (str(x[f]), []) + + +# ── 검사 ───────────────────────────────────────────────────────────── +def _check(spot, text, doc=None): + return fx.check_doc(doc or _plus(), spot, text) + + +def test_검사_까닭(): + ok = _check(MAN, PLUS) + assert ok["되나"] and ok["식"] == PLUS.replace("×", "*") and ok["글"] == PLUS + assert _check(MAN, "무게 / 없는것")["까닭"] == ["모르는 이름 「없는것」"] + assert "「)」 없음" in _check(MAN, "(무게 / 2")["까닭"][0] + assert _check("mid:할증율", "할증율 + 1")["까닭"] == ["순환 — 「할증율」 이 자기 자신을 씀"] + two = _plus( + 변수줄=[{"이름": "가", "식": "1", "자리": ""}, {"이름": "나", "식": "가", "자리": ""}] + ) + assert _check("mid:가", "나 + 1", two)["까닭"] == ["순환 — 가 → 나 → 가"] + assert _check("mid:할증율", "운반무게 / 0")["까닭"] == ["0 으로 나눔"] + assert _check(MAN, "무게 / 운반회수")["알림"] == ["「운반회수」 가 0 이면 0 나눔"] + assert _check(MAN, "1" + " + 1" * 80)["까닭"] == [f"식이 {fx.LONG} 자를 넘음"] + assert _check(MAN, "(" * 13 + "1" + ")" * 13)["까닭"] == [f"괄호가 {fx.DEEP} 겹을 넘음"] + assert _check("add:#3", "1")["까닭"] == ["없는 자리 「add:#3」"] + assert _check(MAN, " ")["까닭"] == ["식이 빔"] + + +def test_꼴_상한_겹침(): + whole = _whole() + many = [{"이름": f"v{i}", "식": "1", "자리": ""} for i in range(fx.MANY + 1)] + for bad, why in ( + ({"변수줄": many}, "까지 목록"), + ({"변수줄": [{"이름": "1가", "식": "1"}]}, "이름 꼴"), + ({"변수줄": [{"이름": "가", "식": "1"}, {"이름": "가", "식": "2"}]}, "같은 이름"), + ({"변수줄": [{"이름": "가", "식": "1", "표시": 7}]}, "0~6"), + ({"식덮개": {"mid:시공량": "1"}, "상수덮개": {"mid:시공량": {"0": 1}}}, "같이 못 둠"), + ({"식덮개": {"labor:LB000007#0": "1"}, "덮개": {"labor:LB000007#0": {"qty": 1}}}, "qty"), + ({"식덮개": {"엉뚱": "1"}}, "자리 꼴"), + ({"식덮개": {"mid:시공량": "(" * 13}}, "괄호"), + ): + with pytest.raises(sd.DocError, match=why): + sd.clean(_sk_doc(FORM, F211, **bad), None, whole) + + +def test_틀린_식은_그_자리만_M01_식(): + """셈 길 = 틀린 자리만 M01 식 + 짝잃음 · 이름 겹친 변수 = 뺌.""" + doc = _sk_doc( + CARRY, + _carry(), + 변수줄=[{"이름": "무게", "식": "1", "자리": ""}], + 식덮개={MAN: "무게 / 없는것", "mid:없는줄": "1"}, + ) + got = _run(doc) + assert got["기준"]["계"] == 42917 + why = {(x["cid"], x["까닭"]) for x in got["짝잃음"]} + assert (MAN, "식 — 모르는 이름 「없는것」") in why + assert ("mid:없는줄", "식 — 바탕에 없는 줄") in why + assert any(c == "mid:무게" and "이미 있음" in w for c, w in why) + + +# ── 옛 SK · 저장 · 셈 답 · 펼침 · 초기화 ───────────────────────────────── +def test_옛_SK_그대로(): + whole = _whole() + old = _sk_doc( + FORM, F211, 덮개={"labor:LB000007#0": {"qty": 0.2}}, 상수덮개={"mid:시공량": {"2": 20}} + ) + got = sd.clean(old, None, whole) + assert not set(got) & set(fx.FIELDS) and not fx.has(got) + row = whole.logic(FORM) + assert fx.settle(row, got) == (row, [], got) + assert _sum(_doc(FORM, F211)) == 51419 and _sum(old) == _sum({**old, "식덮개": {}}) + + +def _sk(folder, **doc): + with store.at(folder): + made = refs.create("sangeun", {"이름": "내 단가산출", "단위": "식", "더한줄": [], **doc}) + return made["이름"] + + +def _read(folder, key): + with store.at(folder): + return store.read("sangeun", key)["문서"] + + +def _calc(folder, doc): + with store.at(folder): + return calc.calc_doc(doc) + + +def test_저장_엔진_식_셈_답(world): + mine = world["personal"] + add = {"종류": "인력", "요소": "LB000002", "수량": 1, "수량식": "1 ÷ 일운반회수"} + var = [{"이름": "할증율", "식": "20", "자리": ""}] + key = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 변수줄=var, 식덮개={MAN: PLUS}) + doc = _read(mine, key) + assert doc["식덮개"] == {MAN: PLUS.replace("×", "*")} and doc["변수줄"] == var + got = _calc(mine, doc) + assert got["기준"]["계"] == 51501 and got["원래"]["계"] == 42917 + spot = got["시트"]["자리"][MAN] + assert spot["식덮개"] is True and spot["덮개"] == ["식"] and spot["식글"] == PLUS + assert spot["원식글"] == "무게 / 운반량 / 일운반회수" and spot["식"] == spot["원식글"] + v = got["시트"]["자리"]["mid:할증율"] + assert v["갈래"] == "변수" and v["식글"] == "20" and float(v["지금"]) == 20 + names = {(x["글"], x["갈래"]) for x in got["시트"]["이름표"]} + assert {("할증율", "변수"), ("[인력운반기본공식]", "별칭"), ("노무비", "비목")} <= names + assert ("[노임 보통인부]", "자원") in names # 호표 요소 + key2 = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 더한줄=[add]) + doc2 = _read(mine, key2) + assert doc2["더한줄"][0]["수량식"] == "1 / 일운반회수" + assert _calc(mine, doc2)["시트"]["자리"]["add:#0"]["식글"] == "1 / 일운반회수" + with store.at(mine), pytest.raises(store.StoreError) as e: # 틀린 식 저장 = 400 + refs.create("sangeun", {**_plus(식덮개={MAN: "무게 / 없는것"}), "더한줄": []}) + assert e.value.status == 400 and "모르는 이름" in str(e.value.detail) + + +def test_펼침_막음_초기화_걷음(world): + mine = world["personal"] + a = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 변수줄=[{"이름": "할증율", "식": "20"}]) + with store.at(mine), pytest.raises(store.StoreError): + flat.flat_lines("sangeun", {**MINE, "키": a}) + doc, n = flat.reset("sangeun", {**_plus(), "까닭": {MAN: "할증"}}) + assert n == 1 and "식덮개" not in doc and "까닭" not in doc and doc["변수줄"] + assert const.restore(_plus(), MAN).get("식덮개") is None + assert const.kinds(_plus(), MAN) == ["식"] and const.covers_n(_plus()) == 1 + + +# ── B09 사본 굳힘 ───────────────────────────────────────────────────── +def test_B09_굳힘_식_칸_그대로(): + whole = _whole() + add = {"종류": "인력", "요소": "LB000002", "수량": 1, "수량식": "1 / 일운반회수"} + doc = sd.clean({**_plus(), "더한줄": [add]}, None, whole) + live = calc.run(doc, whole)["기준"]["계"] + rid = "personal|7/42|SK-000001" + frozen = sk.freeze({rid: doc})[rid] + row, key = frozen[sk.ROW], frozen["바탕"]["로직"] + assert key.startswith("GX9") and frozen["원본"]["식덮개"] == doc["식덮개"] + assert [x["이름"] for x in row["중간"]][0] == "할증율" + assert row["호표"][0]["수량"] == PLUS.replace("×", "*") + assert row["호표"][1]["수량"] == "1 / 일운반회수" + over = calc._Over(whole, key, json.loads(json.dumps(row))) + again = calc.run(sd.clean(frozen, None, over), over)["기준"]["계"] + assert again == live and float(live) > 51501 + + +# ── TS ─────────────────────────────────────────────────────────────── +def _ts(body: str): + src = "".join( + (ROOT / f"M02_MasterTemplete/logic/M02_Sangeun_{n}_Model.ts").read_text("utf-8") + for n in ("Const", "Formula") + ) + src = "\n".join(x for x in src.splitlines() if not x.startswith("import type")) + got = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_TS_고치기_꼴_서버가_받음(): + ts = _ts( + f"\nlet d = {json.dumps(_doc(CARRY, _carry()), ensure_ascii=False)};" + "\nd = {...d, 더한줄: [{종류: '인력', 요소: 'LB000002', 수량: 1}]};" + "\nd = addVar(d, {이름: '할증', 식: '10', 자리: ''});" + "\nd = addVar(d, {이름: '덤', 식: '0', 자리: 'mid:할증'});" + "\nd = editVar(d, '할증', {이름: '할증율', 식: '20'});" + "\nd = setFormula(d, 'mid:할증율', '20'); d = dropVar(d, '덤');" + f"\nd = setFormula(d, '{MAN}', '{PLUS}'); d = setFormula(d, 'mid:운반량', '25');" + "\nd = setFormula(d, 'mid:운반량', null); d = setFormula(d, 'add:#0', '1 / 일운반회수');" + "\nd = setReason(d, 'mid:할증율', '할증');" + f"\nconst out = {{d, r: restoreM01(d, '{MAN}'), k: coverKinds(d, '{MAN}')}};" + "\nconsole.log(JSON.stringify(out));" + ) + d = ts["d"] + assert d["변수줄"] == [{"이름": "할증율", "식": "20", "자리": ""}] + assert d["식덮개"] == {MAN: PLUS} and d["까닭"] == {"mid:할증율": "할증"} + got = sd.clean(d, None, _whole()) + assert {k: got[k] for k in fx.FIELDS} == {k: d[k] for k in fx.FIELDS} + assert got["더한줄"][0]["수량식"] == "1 / 일운반회수" + assert _sum(d) > 51501 + assert ts["r"] == const.restore(d, MAN) and ts["k"] == const.kinds(d, MAN) == ["식"] + + +def test_같은_내용_지문_식_칸_가림(): + """식 칸만 다른 SK = 다른 내용(409 같은 것 물음 안 함) · 옛 SK 지문 그대로.""" + from M02_MasterTemplete import M02_Library_Same as same + + plain = _doc(CARRY, _carry()) + one, two = same.finger("sangeun", plain), same.finger("sangeun", _plus()) + assert one and two and one != two + assert same.finger("sangeun", {**plain, "식덮개": {}, "변수줄": []}) == one