feat(796-2c): M02 수식 탭이 SK 셈 답 전체를 그림 — 더한 기초 · 뺀줄 · 덮개 · 율 줄 · 합밖 소계표 · 합계 = SK 금액 · 고친 칸 표지

- 서버 M02_Sangeun_Equation_Full(새 파일) — 합계 = SK 셈 답 기준 · 소계 = 셈 답 줄마다 한 행(바탕 · 덮음 · 더함 · 뺌 · 합밖) + 끝수 · 더한 기초 수식 나무 · 줄 표지(식 · 상수 · 표값 · 품 · 단가 · 변수줄 · 뺌 + 원래 → 지금)
- M02_Sangeun_Equation — read · preview 가 얹음(전 = 고침 없는 SK 금액) · 수식 입력 = 자동값(SK 셈과 같은 입력)
- 부품 createEqSum(tree, open) 상태 알약 · 원래 · 접힘 기초 수식 · 줄 표지 · 흐림 CSS
- 시험 test_796_2c(견본 넷) · 낡은 시험 정리 — test_792_1 TS 거울 시험(799-2 에 걷은 모델 파일 읽음) 지움 · test_799_1 호출 글 갱신
- SK 금액 전후 같음 · M01 수식 탭 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 13:24:35 +09:00
co-authored by Claude Sonnet 5.5
parent c9bdf6d891
commit 137cfb701d
10 changed files with 646 additions and 63 deletions
@@ -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: "",
+32 -5
View File
@@ -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["합계"]}
@@ -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
+1 -42
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
+57 -1
View File
@@ -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);
}
+22 -2
View File
@@ -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<string, unknown>;
/** 799-1 — 하위 로직 블록마다 노 · 재 · 경 소계 + 끝수(합계 − 줄 합) · 결과 로직은 없음 */
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown> };
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown>; 끝수까닭?: 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 {
+21
View File
@@ -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) {
+93 -11
View File
@@ -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<string, string> = { 자원: "자기 자원", 덧줄: "덧줄" };
const TONE: Record<string, BadgeTone> = { 더함: "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<EqTree, "합계" | "소계">): HTMLElement | null {
/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */
function itemRow(r: EqSumRow, open: Set<string>, 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<EqTree, "합계" | "소계" | "답까닭">,
open: Set<string> = 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<EqTree, "합계" | "소계">): 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;
}