diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index df9e25363..4ef848b28 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -458,7 +458,7 @@ export function createLabEquation(opt: LabEquationOptions) { return; } const edit = canEdit(); - const sum = createEqSum(eq); + const sum = createEqSum(eq, open); body.replaceChildren( createEqChain(eq.줄, { path: "", diff --git a/M02_MasterTemplete/M02_Sangeun_Equation.py b/M02_MasterTemplete/M02_Sangeun_Equation.py index d0fb68bd3..45d8de546 100644 --- a/M02_MasterTemplete/M02_Sangeun_Equation.py +++ b/M02_MasterTemplete/M02_Sangeun_Equation.py @@ -15,6 +15,9 @@ 저장 = SK [저장](화면 캐시 `수식고침` → `clean_doc` 이 다시 얹어 검사 · 셈 · 브라우저 값 안 받음). 바탕 참조 SK(`바탕.참조`) = 수식 없음(바탕 SK 의 고침은 그 SK 가 얹음). +합계 · 소계(796-2c) = SK 셈 답 기준(`M02_Sangeun_Equation_Full` — 더한 기초 · 뺀줄 · 덮개 · 율 줄 · + 합밖) · 줄 값 = 식 덮개 · 상수 · 표값 · 변수줄을 얹은 식(고침 주소는 바탕 차례 그대로) · + 전 = 고침 없는 SK 금액. """ from __future__ import annotations @@ -76,8 +79,12 @@ def _stand(row: dict) -> tuple[dict, str]: def export(whole: Any, row: dict, inputs: dict | None) -> dict: + """수식 읽기 — 입력 = 받은 값 › 자동값(SK 셈 `run_line` 과 같음 · 기본 제안값이 안 덮음).""" stand, key = _stand(row) - return _eqn().export_row(whole, stand, inputs) | {"열쇠": key} + eqn = _eqn() + auto = eqn.meq.ma.values(eqn.me.swap(whole, stand), stand) + given = {**{k: v for k, v in auto.items() if v is not None}, **(inputs or {})} + return eqn.export_row(whole, stand, given) | {"열쇠": key} def apply(row: dict, edits: list, whole: Any) -> tuple[dict, list[str]]: @@ -130,28 +137,48 @@ def _plain(doc: Any) -> tuple[dict, Any, dict]: return doc, whole, row +def _tree(doc: dict, whole: Any, base: dict, inputs: dict | None, ans: dict | None) -> dict: + """수식 = 바탕 줄(+ 고침 · 식 덮개 · 상수 · 표값 · 변수줄) + SK 셈 답 합계 · 소계(796-2c).""" + from M02_MasterTemplete import M02_Sangeun_Const as const + from M02_MasterTemplete import M02_Sangeun_Equation_Full as full + + try: + shown = const.formulas(base, doc) + except Exception: # noqa: BLE001 — 틀린 식 덮개 = 바탕 그대로(짝잃음은 셈 길이 알림) + shown = base + return full.attach(export(whole, shown, inputs), doc, whole, base, inputs, ans) + + def read(doc: Any, inputs: dict | None = None) -> dict[str, Any]: """{ok, 수식} — 저장본 · 화면 캐시 고침 얹은 수식(못 얹음 = 바탕 그대로 + 까닭).""" + from M02_MasterTemplete import M02_Sangeun_Equation_Full as full + eqn = _eqn() try: doc, whole, row = _plain(doc) now, lost = on(row, doc, whole) - out = {"ok": True, "수식": export(whole, now, inputs)} + out = {"ok": True, "수식": _tree(doc, whole, now, inputs, full.answer(doc, inputs))} except (sd.DocError, *eqn._ERR) as e: return {"ok": False, "reason": str(e) or type(e).__name__} return out | ({"reason": lost[0]["까닭"]} if lost else {}) def preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]: - """고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본), 후}.""" + """고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본 SK 금액), 후}.""" + from M02_MasterTemplete import M02_Sangeun_Equation_Full as full + eqn = _eqn() try: doc, whole, row = _plain(doc) - before = export(whole, row, inputs)["합계"] new, why = apply(row, edits, whole) if edits else (row, []) if why: return {"되나": False, "까닭": why} - eq = export(whole, new, inputs) + bare = {k: v for k, v in doc.items() if k != KEY} + mine = {**bare, KEY: edits} if edits else bare + ans = full.answer(mine, inputs) + eq = _tree(mine, whole, new, inputs, ans) + before = full.total(full.answer(bare, inputs) if edits else ans) + before = before or export(whole, row, inputs)["합계"] except (sd.DocError, *eqn._ERR) as e: return {"되나": False, "까닭": [f"셈 막힘 — {e}"]} return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]} diff --git a/M02_MasterTemplete/M02_Sangeun_Equation_Full.py b/M02_MasterTemplete/M02_Sangeun_Equation_Full.py new file mode 100644 index 000000000..232ae1a4a --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Equation_Full.py @@ -0,0 +1,220 @@ +"""M02 단가산출(SK) 수식 탭 — SK 셈 답 기준 소계 · 합계 · 고친 칸 표지(PLAN 796-2c · 요구 657). + +수식 탭 줄(`M02_Sangeun_Equation`) = 바탕 사본 줄(+ 수식고침). 그 위에 SK 셈 답 +(`M02_Sangeun_Calc.calc_doc` = 화면 · 저장 · B09 가 쓰는 한 벌)을 얹어 화면 금액 = SK 금액 +(셈은 안 바꿈 · 보이기만): + 합계 = 셈 답 `기준`(노 · 재 · 경 · 계 · 1 단위) — 바탕 줄만 셈한 합계 대신 + 소계 = 셈 답 `줄` 한 줄마다 한 행 {cid, 갈래(자원 | 블록 | 덧줄 | 율), 이름, 상태, 수량, + 노 · 재 · 경 · 계, 덮개?, 원래?, 까닭?, 나무?, 합밖?, 밖금액?} + + 끝수(합계 − 행 합 · 까닭 글) + 합밖 줄(엔진이 합계에서 뺌) = 행 금액 0 + 밖금액 + 상태 = 바탕(그대로) · 덮음(품 · 단가 · 상수 · 표값 · 식 덮개 · 변수줄) · + 더함(더한줄) · 뺌(뺀줄 · 금액 0) + 나무 = 더한 기초(더한줄 로직)의 수식(세부 덮개 · 뺀줄 얹은 줄) — 화면은 접힘 블록 + 표지 = 수식 탭 줄에 고친 칸 {표지[갈래], 원래, 지금, 뺌} — 줄 값 = 식 덮개 · 상수 · 표값 · + 변수줄을 얹은 식(`M02_Sangeun_Const.formulas`) · 품 · 단가 덮개는 줄 값에 안 얹고 + `지금` 으로 보임(고침 주소 = 바탕 차례 그대로) +셈 답이 안 서는 SK(틀린 문서 · 돈 아닌 로직) = 바탕 줄 합계 그대로 + `답까닭`. +시험 `resources/tester/test_796_2c_m02_equation_full.py`. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +COSTS = ("노무비", "재료비", "경비") +SUMS = (*COSTS, "계") +_ADD = re.compile(r"^add:#(\d+)$") +_HEAD = {"labor": "자원", "material": "자원", "machine": "자원", "logic": "블록", "pct": "덧줄"} +_ADD_KIND = {"인력": "자원", "재료": "자원", "기계": "자원"} + + +def _d(value: Any) -> Decimal: + return Decimal(str(value)) if value is not None else Decimal(0) + + +def _errors() -> tuple: + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + return (sd.DocError, store.StoreError, ArithmeticError, KeyError, TypeError, ValueError) + + +def answer(doc: dict, inputs: dict | None) -> dict | None: + """SK 셈 답 — 못 셈(틀린 문서 · 돈 아닌 로직) = None.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + try: + got = calc.calc_doc(doc, inputs or None, False) + except _errors(): + return None + return got if got.get("기준") else None + + +def total(ans: dict | None) -> dict | None: + return {k: _d(ans["기준"].get(k)) for k in SUMS} if ans else None + + +# ── 행 ──────────────────────────────────────────────────────────────── +def _kind(cid: str, adds: list) -> str: + hit = _ADD.match(cid) + if not hit: + return _HEAD.get(cid.split(":", 1)[0], "자원") + one = adds[int(hit[1])] if int(hit[1]) < len(adds) else {} + if sd.is_rate(one): + return "율" + return _ADD_KIND.get(str(one.get("종류")), "블록") + + +def _row(cid: str, kind: str, name: str, state: str, parts: dict | None, **more: Any) -> dict: + cost = {k: _d((parts or {}).get(k)) for k in COSTS} + return { + "cid": cid, + "갈래": kind, + "이름": name, + "상태": state, + **cost, + "계": sum(cost.values(), Decimal(0)), + **{k: v for k, v in more.items() if v not in (None, [], "")}, + } + + +def rows(doc: dict, ans: dict, tree: Any, out_ids: set[str] = frozenset()) -> list[dict]: + """셈 답 줄 → 행(셈 답 순서) + 뺀줄(금액 0) — `tree(더한줄 차례)` = 더한 기초 수식.""" + adds = doc.get("더한줄") or [] + spots = (ans.get("시트") or {}).get("자리") or {} + out = [] + for line in ans.get("줄") or []: + cid = str(line.get("cid") or "") + hit = _ADD.match(cid) + info = spots.get(cid) or {} + cover = [k for k in info.get("덮개") or [] if k != "뺌"] or [ + k for k in line.get("덮개") or [] + ] + k = int(hit[1]) if hit else -1 + state = "더함" if hit else "덮음" if cover else "바탕" + one = adds[k] if 0 <= k < len(adds) else {} + if hit and one.get("종류") == "로직" and sd.sub_patched(one): + cover = cover or ["세부"] + outside = cid in out_ids # 합계 밖 줄(가져온 식 `합밖` · 제외 단계) — 합에 안 듦 + out.append( + _row( + cid, + _kind(cid, adds), + str(line.get("이름") or info.get("이름") or ""), + state, + None if outside else line.get("비목"), + 합밖=outside or None, + 밖금액=sum((_d(v) for v in (line.get("비목") or {}).values()), Decimal(0)) + if outside + else None, + 수량=line.get("수량"), + 덮개=cover, + 원래=info.get("원래") if state == "덮음" else None, + 까닭=line.get("까닭") or info.get("까닭"), + 나무=tree(k) if hit and one.get("종류") == "로직" else None, + ) + ) + for cid in doc.get("뺀줄") or []: # 지운 줄 — 금액 0 · 원래 금액만 보임 + info = spots.get(cid) or {} + out.append( + _row( + cid, + _kind(cid, adds), + str(info.get("이름") or ""), + "뺌", + None, + 원래=info.get("원래"), + 까닭=info.get("까닭"), + ) + ) + return out + + +def _why(ans: dict) -> str: + """끝수 까닭 — 합계는 엔진이 비목마다 합쳐 버린 값 · 행은 줄마다 버린 값.""" + text = ["줄마다 버린 값의 합 ≠ 엔진 비목 합(품셈 1-2-2 끝수)"] + if _d(ans.get("화면배") or 1) != 1: + text.append(f"단위 환산 ÷ {ans['화면배']}") + rate = ans.get("켜쌓기율") + if rate not in (None, 100): + text.append(f"켜쌓기율 {rate} %") + return " · ".join(text) + + +# ── 더한 기초 수식 ───────────────────────────────────────────────────── +def _basic_tree(whole: Any, doc: dict, one: dict, inputs: dict | None) -> dict | None: + """더한줄 로직 하나의 수식 — 세부 덮개 · 뺀줄 얹은 줄 · 입력 = 줄 값 › 공통 옵션 › 자동.""" + from M02_MasterTemplete import M02_Sangeun_Equation as eqm + + if any(one.get(k) not in (None, {}, []) for k in sd.BODY): + return None + code = str(one["로직"]) + mine = {k: v for k, v in (one.get("입력값") or {}).items() if str(v).strip()} + given = {**(doc.get("입력값") or {}), **(inputs or {}), **mine} + try: + row = sd.patched(whole.logic(code), one)["줄"] + return eqm.export(whole, {**row, "키": code}, given) + except Exception: # noqa: BLE001 — 엔진 FormulaError · 기초 하나가 틀려도 나머지는 그림 + return None + + +# ── 줄 표지 ──────────────────────────────────────────────────────────── +def _mark(eq: dict, base: dict, ans: dict, doc: dict) -> None: + """수식 탭 줄에 고친 칸 표지 — 자리 = 바탕 cid(호표 · 덧줄) · `mid:이름`.""" + spots = (ans.get("시트") or {}).get("자리") or {} + ids = sd.line_ids(base) + heads = len(base.get("호표") or []) + for line in eq.get("줄") or []: + if line["갈래"] == "중간": + spot = f"mid:{line['열쇠']}" + elif line["갈래"] == "호표": + spot = ids[line["차례"]] if line.get("차례", 0) < len(ids) else "" + elif line["갈래"] == "덧줄": + at = heads + line.get("차례", 0) + spot = ids[at] if at < len(ids) else "" + else: + continue + info = spots.get(spot) or {} + kinds = list(info.get("덮개") or []) + if not kinds: + continue + line["표지"] = [k for k in kinds if k != "뺌"] + line["원래"], line["지금"] = info.get("원래"), info.get("지금") + if "뺌" in kinds: + line["뺌"] = True + + +def _outside(base: dict) -> set[str]: + """합계 밖 줄 cid — 엔진이 비목 합에서 빼는 줄(`master_formula_sum.skipped`).""" + import master_cut as mcut # M01 이 길을 둔 뒤(수식 탭 export 가 먼저) + + if not mcut.ms.uses(base): + return set() + ids = sd.line_ids(base) + return {ids[i] for i in mcut.ms.skipped(base) if i < len(ids)} + + +# ── 붙이기 ──────────────────────────────────────────────────────────── +def attach( + eq: dict, doc: dict, whole: Any, base: dict, inputs: dict | None, ans: dict | None +) -> dict: + """수식 `eq` 에 SK 셈 답 합계 · 소계 · 표지를 얹음(제자리) — 답 없음 = `답까닭` 만.""" + if ans is None: + return eq | {"답까닭": "단가산출 셈 답이 서지 않음 — 바탕 줄 합계를 보임"} + _mark(eq, base, ans, doc) + adds = doc.get("더한줄") or [] + table = rows( + doc, + ans, + lambda k: _basic_tree(whole, doc, adds[k], inputs) if k >= 0 else None, + _outside(base), + ) + now = total(ans) or {} + rest = {k: now[k] - sum((r[k] for r in table), Decimal(0)) for k in SUMS} + eq["합계"] = now + eq["소계"] = {"줄": table, "끝수": rest, "끝수까닭": _why(ans)} + return eq diff --git a/resources/tester/test_792_1_sangeun_formula.py b/resources/tester/test_792_1_sangeun_formula.py index c85ecb194..b5f46dbe1 100644 --- a/resources/tester/test_792_1_sangeun_formula.py +++ b/resources/tester/test_792_1_sangeun_formula.py @@ -7,13 +7,12 @@ - 검사 까닭 — 없는 이름 · 괄호 · 순환 · 0 나눔 · 길이 · 괄호 깊이 · 변수 줄 수 · 자리 겹침 - 옛 SK(식 칸 없음) = 꼴 · 금액 그대로 · 저장 = 엔진 식 · 틀린 식 저장 400 · 셈 답 덧칸 - B09 사본 굳힘(`GX9#####`) 줄 = 식 칸 그대로 · 다시 셈 같은 금액 -- TS `Formula_Model` · `Const_Model`(식덮개) 결과 = 서버가 받음 · [M01 원래로] 거울 +- 799-2 — TS 거울(`Formula_Model.ts` · `Const_Model.ts`)은 시트 탭과 함께 걷음 · 서버만 지킴 """ from __future__ import annotations import json -import subprocess from pathlib import Path import pytest @@ -274,46 +273,6 @@ def test_B09_굳힘_식_칸_그대로(): 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 diff --git a/resources/tester/test_796_2c_m02_equation_full.py b/resources/tester/test_796_2c_m02_equation_full.py new file mode 100644 index 000000000..41a9eb897 --- /dev/null +++ b/resources/tester/test_796_2c_m02_equation_full.py @@ -0,0 +1,198 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""796-2c M02 수식 탭이 SK 셈 답 전체를 그림 — 더한 기초 · 뺀줄 · 덮개 · 율 줄 · 참조 · 가져온 식. + +견본 셋(임시 storage · M01 그대로): + A 거푸집 + 더한 기초(굴착기) + 더한 인력 + 뺀줄 + 품 덮개 + 식 덮개 + B 포장 + 단가 덮개 + 율 줄 + 참조 SK + C 가져온 식 + 더한 인력 + 품 덮개 +지킴: 수식 탭 합계 = SK 셈 답 `기준`(원 단위까지) · 행 합 + 끝수 = 합계(비목마다) · +상태(덮음 · 더함 · 뺌) · 고친 칸 표지(원래 · 지금) · 더한 기초 수식 나무 · +미리 셈 전/후 = SK 금액 · SK 금액 전후 같음. +""" + +from __future__ import annotations + +from decimal import Decimal as D + +import pytest + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Equation as eqm +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from resources.tester.test_795_1_sk_copy import _calc, _doc, _imported_row, _save, _sk +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +SUMS = ("노무비", "재료비", "경비", "계") +BASIC = {"종류": "로직", "로직": "GC001148", "수량": 2, "입력값": {}} # 굴착기 시간당 641 +MAN = {"종류": "인력", "요소": "LB000002", "이름": "sub2임시 인부", "단위": "인", "수량": 1} +FORM = "GC000211" # 합판거푸집 — 형틀목공 · 보통인부 · 중간 시공량 +MINE = {"층": "personal", "주인": "7/42"} + + +def _d(v) -> D: + return D(str(v)) + + +def _read(folder, doc, inputs=None): + with store.at(folder): + got = eqm.read(doc, inputs) + assert got["ok"], got + return got["수식"] + + +def _rows(eq) -> dict: + return {r["cid"]: r for r in eq["소계"]["줄"]} + + +def _closes(eq, ans) -> None: + """합계 = SK 셈 답 원 단위까지 · 행 합 + 끝수 = 합계 · 끝수 작음.""" + sub = eq["소계"] + for k in SUMS: + assert eq["합계"][k] == _d(ans["기준"][k]), k + assert sum((r[k] for r in sub["줄"]), D(0)) + sub["끝수"][k] == eq["합계"][k], k + assert abs(sub["끝수"]["계"]) < 2, sub["끝수"] + + +def _line(eq, kind, key): + return next(x for x in eq["줄"] if x["갈래"] == kind and x["열쇠"] == key) + + +@pytest.fixture +def made(world): + me = world["personal"] + plain = _sk(me, 바탕={"로직": FORM}) # 고침 없는 같은 바탕 — 원래 값 대조용 + key = _sk( + me, + 바탕={"로직": FORM}, + 더한줄=[dict(BASIC), dict(MAN)], + 뺀줄=["labor:LB000002#0"], + 덮개={"labor:LB000007#0": {"qty": 0.5}}, + 식덮개={"mid:시공량": "50"}, + ) + return me, key, plain + + +def test_A_added_removed_covered_total_equals_sk(made): + me, key, plain = made + doc, bare = _doc(me, key), _calc(me, _doc(me, plain)) + ans = _calc(me, doc) + eq = _read(me, doc) + _closes(eq, ans) + rows = _rows(eq) + # 덮음 — 형틀목공 품 덮개 · 원래 = 고침 없는 SK 의 같은 줄 금액 + cover = rows["labor:LB000007#0"] + was = next(x for x in bare["줄"] if x["cid"] == "labor:LB000007#0")["금액"] + assert cover["상태"] == "덮음" and "품" in cover["덮개"] + assert _d(cover["원래"]) == _d(was) + assert cover["노무비"] == D("137895") # 품 0.5 × 단가 275,790 + # 뺌 — 지운 줄 = 금액 0 · 원래 금액만 + gone = rows["labor:LB000002#0"] + assert gone["상태"] == "뺌" and gone["계"] == 0 + assert _d(gone["원래"]) == _d(next(x for x in bare["줄"] if x["cid"] == gone["cid"])["금액"]) + # 더함 — 기초(굴착기 641 × 2) · 인력 · 기초는 수식 나무 + basic, man = rows["add:#0"], rows["add:#1"] + assert ( + basic["상태"] == man["상태"] == "더함" and basic["갈래"] == "블록" and man["갈래"] == "자원" + ) + assert basic["계"] > 0 and man["노무비"] > 0 + assert basic["나무"]["열쇠"] == "GC001148" and basic["나무"]["줄"] + # 나무 합계 × 수량 = 행(같은 입력) + assert abs(basic["나무"]["합계"]["계"] * 2 - basic["계"]) <= 2 + # 고친 칸 표지 — 수식 탭 줄(식 덮개 · 품 · 뺌) + mid = _line(eq, "중간", "시공량") + assert mid["표지"] == ["식"] and mid.get("원래") is not None and "50" in mid["식"] + assert _line(eq, "호표", "형틀목공")["표지"] == ["품"] + out = _line(eq, "호표", "보통인부") + assert out["뺌"] is True and out["표지"] == [] + # 고침 없는 SK(plain)는 표지도 더함도 없음 — 합계 = 같은 바탕 금액 + flat = _read(me, _doc(me, plain)) + assert {r["상태"] for r in flat["소계"]["줄"]} == {"바탕"} + assert flat["합계"]["계"] == _d(bare["기준"]["계"]) + assert not any("표지" in x for x in flat["줄"]) + + +def test_A_preview_before_after_and_save_same(made): + """고침 미리 셈 전/후 = SK 금액 · 저장 뒤 셈 = 미리 셈 · 더한 것도 합계에 듦.""" + me, key, _ = made + doc = _doc(me, key) + was = _calc(me, doc)["기준"]["계"] + edit = [{"할일": "식", "자리": "호표:3", "식": "0.038 × 2", "까닭": "sub2임시 2c"}] + with store.at(me): + got = eqm.preview(doc, edit) + assert got["되나"], got["까닭"] + assert got["전"]["계"] == _d(was) and got["후"]["계"] != got["전"]["계"] + _save(me, key, {**doc, eqm.KEY: edit}) + now = _calc(me, _doc(me, key)) + assert _d(now["기준"]["계"]) == got["후"]["계"] + eq = _read(me, _doc(me, key)) + _closes(eq, now) + + +def test_B_unit_cover_rate_and_reference(world): + """B 단가 덮개 · 율 줄 · 참조 SK 가 소계에 들고 합계 = SK 금액.""" + me = world["personal"] + ref = _sk(me, 바탕={"로직": FORM}) + rate = { + "고정": { + "이름": "sub2임시 율", + "단가": {}, + "이음": {}, + "비율": {"기준": "인력", "비목": "경비"}, + } + } + adds = [{**rate, "수량": 3}, {"참조": {**MINE, "키": ref}, "수량": 1}] + key = _sk( + me, + 바탕={"로직": "GC001109"}, + 더한줄=adds, + 덮개={"labor:LB000002#0": {"labor": 200000}}, + ) + doc, ans = _doc(me, key), _calc(me, _doc(me, key)) + eq = _read(me, doc) + _closes(eq, ans) + rows = _rows(eq) + price = rows["labor:LB000002#0"] + assert price["상태"] == "덮음" and "단가" in price["덮개"] and price["원래"] is not None + assert rows["add:#0"]["갈래"] == "율" and rows["add:#0"]["경비"] > 0 + assert rows["add:#1"]["갈래"] == "블록" and rows["add:#1"]["상태"] == "더함" + assert _line(eq, "호표", price["이름"])["표지"] == ["단가"] + + +def test_C_imported_sk_same_rule(world): + """C 가져온 식 SK — 더한 인력 · 품 덮개 · 합계 = SK 금액.""" + me = world["personal"] + with store.at(me): + key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"] + doc = _doc(me, key) + cid = next(x["cid"] for x in _calc(me, doc)["줄"] if x["cid"].startswith("labor:")) + _save(me, key, {**doc, "더한줄": [dict(MAN)], "덮개": {cid: {"qty": 1}}}) + doc = _doc(me, key) + ans = _calc(me, doc) + eq = _read(me, doc) + _closes(eq, ans) + rows = _rows(eq) + assert rows["add:#0"]["상태"] == "더함" and rows[cid]["상태"] == "덮음" + + +def test_plain_sk_has_answer_table(world): + """고침 없는 SK = 답까닭 없음 · 소계 표 있음.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": FORM}) + eq = _read(me, _doc(me, key)) + assert "답까닭" not in eq and eq["소계"]["줄"] + + +def test_D_imported_outside_total_line_not_in_sum(world): + """D 가져온 식 `합밖` 줄 = 합계에 안 듦 → 행 0 · 밖금액 · 끝수에 안 섞임.""" + me = world["personal"] + row = _imported_row() + row["호표"][1] = {**row["호표"][1], "합밖": True} + with store.at(me): + key = imp.create(row, None, "sub2임시 합밖")["이름"] + doc = _doc(me, key) + ans = _calc(me, doc) + eq = _read(me, doc) + _closes(eq, ans) + out = [r for r in eq["소계"]["줄"] if r.get("합밖")] + assert len(out) == 1 and out[0]["계"] == 0 and out[0]["밖금액"] > 0 + assert abs(eq["소계"]["끝수"]["계"]) < 1 diff --git a/resources/tester/test_799_1_subtotals.py b/resources/tester/test_799_1_subtotals.py index 9ca89b3e0..792ac1fda 100644 --- a/resources/tester/test_799_1_subtotals.py +++ b/resources/tester/test_799_1_subtotals.py @@ -99,4 +99,4 @@ def test_화면_부품이_서버_칸을_읽음(): for name in ("노무비", "재료비", "경비", "끝수", "합계", "자원", "덧줄"): assert name in ts line = Path("M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8") - assert "createEqSum(eq)" in line + assert "createEqSum(eq, open)" in line diff --git a/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css index 7293b4929..dd7ef3587 100644 --- a/ui_template/ui_template_equation.css +++ b/ui_template/ui_template_equation.css @@ -323,7 +323,28 @@ display: none !important; } -/* ── 단가산출 소계 · 합계(799-1) ── */ +/* ── M02 SK 고친 칸 표지(796-2c) ── */ +.ui-eq__mark { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.ui-eq__mark-was { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-eq__line[data-removed] > .ui-eq__main { + opacity: 0.5; +} + +.ui-eq__line[data-removed] .ui-eq__res { + text-decoration: line-through; +} + +/* ── 단가산출 소계 · 합계(799-1 · 796-2c) ── */ .ui-eq__sum { display: flex; flex-direction: column; @@ -348,6 +369,41 @@ text-align: right; } +.ui-eq__sum-name { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + min-width: 0; +} + +.ui-eq__sum-was, +.ui-eq__sum-note { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-eq__sum-note { + padding: var(--spacing-4) var(--spacing-8); +} + +.ui-eq__sum-row[data-eq-sum-state="뺌"] { + opacity: 0.5; +} + +.ui-eq__sum-row[data-eq-sum-state="뺌"] .ui-eq__sum-label { + text-decoration: line-through; +} + +.ui-eq__sum-tree { + padding: var(--spacing-4) var(--spacing-8) var(--spacing-8) var(--spacing-16); + border-bottom: 1px solid var(--color-border); +} + +.ui-eq__sum-tree[hidden] { + display: none !important; +} + .ui-eq__sum-row[data-eq-sum-kind="끝수"] { color: var(--color-text-secondary); } diff --git a/ui_template/ui_template_equation.ts b/ui_template/ui_template_equation.ts index 693f340b9..d670473c8 100644 --- a/ui_template/ui_template_equation.ts +++ b/ui_template/ui_template_equation.ts @@ -44,7 +44,7 @@ export interface EqNode { 사슬?: boolean; } -/** 단가산출 소계 줄(799-1) — 갈래 블록 | 자원 | 덧줄 */ +/** 단가산출 소계 줄(799-1) — 갈래 블록 | 자원 | 덧줄 | 율 · M02 SK(796-2c) 는 셈 답 한 줄마다 + 상태 */ export interface EqSumRow { 갈래: string; 이름: string; @@ -52,6 +52,19 @@ export interface EqSumRow { 재료비: unknown; 경비: unknown; 계: unknown; + /** M02 SK — 바탕 | 덮음 | 더함 | 뺌 (M01 은 없음) */ + 상태?: string; + 수량?: unknown; + /** 덮음 갈래(품 · 단가 · 상수 · 표값 · 식) */ + 덮개?: string[]; + /** 덮음 · 뺌 — 고치기 전 금액 */ + 원래?: unknown; + 까닭?: string; + /** 합계 밖 줄(엔진이 비목 합에서 뺌) — 행 금액은 0 · 밖금액에 원래 금액 */ + 합밖?: boolean; + 밖금액?: unknown; + /** 더한 기초의 수식(접힘 블록) */ + 나무?: EqTree | null; } /** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */ @@ -63,7 +76,9 @@ export interface EqTree { 변수표: EqVarRow[]; 합계: Record; /** 799-1 — 하위 로직 블록마다 노 · 재 · 경 소계 + 끝수(합계 − 줄 합) · 결과 로직은 없음 */ - 소계?: { 줄: EqSumRow[]; 끝수: Record }; + 소계?: { 줄: EqSumRow[]; 끝수: Record; 끝수까닭?: string }; + /** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */ + 답까닭?: string; } export interface EqVarRow { @@ -93,6 +108,11 @@ export interface EqLine { 글?: string; 표시?: number; 비목?: unknown; + /** M02 SK 고친 칸(796-2c) — 갈래(식 · 상수 · 표값 · 품 · 단가 · 변수줄) · 원래 → 지금 · 뺀줄 */ + 표지?: string[]; + 원래?: unknown; + 지금?: unknown; + 뺌?: boolean; } export interface EqHandlers { diff --git a/ui_template/ui_template_equation_line.ts b/ui_template/ui_template_equation_line.ts index 45ceee1d3..dc5767e7b 100644 --- a/ui_template/ui_template_equation_line.ts +++ b/ui_template/ui_template_equation_line.ts @@ -10,6 +10,7 @@ * 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) * ========================================================================== */ +import { createBadge } from "./ui_template_badge"; import { createButton } from "./ui_template_button"; import { eqText, @@ -164,6 +165,23 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { return box; } +/** M02 SK 고친 칸(796-2c) — 갈래 알약 + 원래 → 지금 · 뺀줄 알약 */ +function markOf(line: EqLine): HTMLElement | null { + const kinds = line.표지 ?? []; + if (!kinds.length && !line.뺌) return null; + const box = span("ui-eq__mark"); + if (kinds.length) box.append(createBadge(`고침 · ${kinds.join(" · ")}`, "warning", { size: 18 })); + if (line.뺌) box.append(createBadge("뺌", "neutral", { size: 18 })); + if (line.원래 !== undefined && line.원래 !== null) + box.append( + span( + "ui-eq__mark-was", + `원래 ${eqText(line.원래)}${line.뺌 ? "" : ` → 지금 ${eqText(line.지금) || "—"}`}`, + ), + ); + return box; +} + /** 줄 하나 */ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement { const id = lineId(line); @@ -224,6 +242,9 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM span("ui-eq__bad", line.까닭 ?? ""), ); } + const mark = markOf(line); + if (mark) body.append(mark); + if (line.뺌) root.dataset.removed = "1"; const solve = div("ui-eq__solve", ...vars.map((v) => solveRow(v, o))); const main = div("ui-eq__main", body, solve); if (o.onSelect) { diff --git a/ui_template/ui_template_equation_sum.ts b/ui_template/ui_template_equation_sum.ts index 2087f9504..4441cde14 100644 --- a/ui_template/ui_template_equation_sum.ts +++ b/ui_template/ui_template_equation_sum.ts @@ -1,15 +1,20 @@ /* ============================================================================= * ui_template_equation_sum.ts - * 부품 「수식」 셋째 파일(799-1 · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). + * 부품 「수식」 셋째 파일(799-1 · 796-2c · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로). * 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합) - * 값 = 서버 `소계`(`master_equation.subtotals` · 버린 줄 비목 0.1 원) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림 - * 쓰는 법 = `createEqSum(tree)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 + * 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림 + * M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0 + * 쓰는 법 = `createEqSum(tree, open?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 * ========================================================================== */ -import { eqText, type EqTree } from "./ui_template_equation"; +import { createBadge, type BadgeTone } from "./ui_template_badge"; +import { createButton } from "./ui_template_button"; +import { eqText, type EqSumRow, type EqTree } from "./ui_template_equation"; +import { createEqChain } from "./ui_template_equation_line"; const COLS = ["노무비", "재료비", "경비", "계"] as const; const KIND: Record = { 자원: "자기 자원", 덧줄: "덧줄" }; +const TONE: Record = { 더함: "success", 덮음: "warning", 뺌: "neutral" }; const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => { const out = document.createElement(tag); @@ -18,19 +23,89 @@ const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => return out; }; -function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement { - const line = node("div", "ui-eq__sum-row"); - line.dataset.eqSumKind = kind; - line.append(node("span", "ui-eq__sum-name", name)); +function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): void { for (const k of COLS) { const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0"); cell.dataset.eqSumCol = k; line.append(cell); } +} + +function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement { + const line = node("div", "ui-eq__sum-row"); + line.dataset.eqSumKind = kind; + line.append(node("span", "ui-eq__sum-name", name)); + cells(line, get); return line; } -export function createEqSum(tree: Pick): HTMLElement | null { +/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */ +function itemRow(r: EqSumRow, open: Set, at: number): HTMLElement { + const item = node("div", "ui-eq__sum-item"); + const line = node("div", "ui-eq__sum-row"); + line.dataset.eqSumKind = r.갈래; + const name = node("span", "ui-eq__sum-name"); + const tree = r.나무; + const id = `sum:${at}:${r.이름}`; + const body = node("div", "ui-eq__sum-tree"); + body.hidden = true; + if (tree) { + const head = createButton({ + label: r.이름, + variant: "ghost", + size: 22, + iconStart: "chevronRight", + onClick: () => toggle(), + }); + head.classList.add("ui-eq__block-head"); + const toggle = (force?: boolean): void => { + const on = force ?? !open.has(id); + if (on) { + open.add(id); + if (!body.childElementCount) + body.append( + createEqChain(tree.줄, { + path: id, + open, + resultUnit: tree.결과단위, + numbered: false, + }), + ); + } else open.delete(id); + item.dataset.open = on ? "1" : ""; + body.hidden = !on; + }; + name.append(head); + if (open.has(id)) toggle(true); + } else name.append(node("span", "ui-eq__sum-label", r.이름)); + if (r.상태 && r.상태 !== "바탕") { + const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : ""; + name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 })); + } + if (r.합밖) { + name.append(createBadge("합 밖", "neutral", { size: 18 })); + line.dataset.eqSumOut = "1"; + } + const notes: string[] = []; + if (r.합밖) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`); + if (r.상태 === "더함" && r.수량 !== undefined && r.수량 !== null) + notes.push(`× ${eqText(r.수량)}`); + if (r.원래 !== undefined && r.원래 !== null && r.상태 !== "더함") + notes.push(`원래 ${eqText(r.원래)}`); + if (r.까닭) notes.push(r.까닭); + if (notes.length) name.append(node("span", "ui-eq__sum-was", notes.join(" · "))); + line.dataset.eqSumState = r.상태 ?? "바탕"; + line.append(name); + cells(line, (k) => r[k]); + item.dataset.eqSumItem = r.갈래; + item.append(line, body); + return item; +} + +export function createEqSum( + tree: Pick, + open: Set = new Set(), +): HTMLElement | null { const sub = tree.소계; if (!sub?.줄.length) return null; const box = node("div", "ui-eq__sum"); @@ -39,9 +114,16 @@ export function createEqSum(tree: Pick): HTMLElemen head.append(node("span", "ui-eq__sum-name", "소계")); for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k)); box.append(head); - for (const r of sub.줄) box.append(row(r.갈래, KIND[r.갈래] ?? r.이름, (k) => r[k])); + sub.줄.forEach((r, at) => { + if (r.상태) box.append(itemRow(r, open, at)); + else + box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k])); + }); const rest = sub.끝수; - if (COLS.some((k) => Number(rest[k] ?? 0) !== 0)) box.append(row("끝수", "끝수", (k) => rest[k])); + const odd = COLS.some((k) => Number(rest[k] ?? 0) !== 0); + if (odd) box.append(row("끝수", "끝수", (k) => rest[k])); box.append(row("합계", "합계", (k) => tree.합계[k])); + const note = [odd ? sub.끝수까닭 : "", tree.답까닭].filter(Boolean).join(" · "); + if (note) box.append(node("div", "ui-eq__sum-note", note)); return box; }