diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index ea70058c7..ee90bf81d 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -15,7 +15,6 @@ from pydantic import BaseModel from M01_MasterData import M01_MasterData_Sheet as line_sheet from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store -from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_List as store_list from M01_MasterData import M01_MasterData_Store_Make as make from M01_MasterData import M01_MasterData_Store_Revise as revise @@ -215,9 +214,8 @@ def get_logic_source(src: str) -> dict: @router.get("/logic/auto") def get_logic_auto(key: str) -> dict: - """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값.""" - got = _call(flow.auto, key) - return got if got is not None else _call(store.auto, key) + """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님).""" + return _call(store.auto, key) @router.get("/elements") @@ -246,10 +244,6 @@ def get_materials( @router.post("/calc") def post_calc(body: CalcBody) -> dict: - """줄에 「흐름」 이 있으면 흐름 셈(입력 = 설계 때 정할 것) · 없으면 옛 셈(정함 4).""" - got = _call(flow.calc, body.key, body.inputs, body.row) - if got is not None: - return got with store.cm.mf.mp.choosing(body.고름): got = _call(store.calc, body.key, body.inputs, body.row, body.file) return _pct(got, body) @@ -272,36 +266,15 @@ def _pct(got: dict, body: CalcBody) -> dict: @router.post("/text") def post_text(body: CalcBody) -> dict: - """읽는 식 줄 — 몸은 `/calc` 와 같음 · 흐름 로직은 출력 상자 줄.""" - got = _call(flow.text, body.key, body.inputs, body.row) - if got is not None: - return got + """읽는 식 줄 — 몸은 `/calc` 와 같음.""" with store.cm.mf.mp.choosing(body.고름): return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1) @router.post("/save") def post_save(body: SaveBody) -> dict: - """「흐름」 줄은 검사 · 서버 다시 셈 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}.""" - return _call(flow.save, [f.model_dump() for f in body.files], body.개정확인) - - -@router.get("/logic/flow") -def get_logic_flow(key: str, file: str = "") -> dict: - """흐름 받기 — {flow, converted, notes} · 흐름이 있으면 그대로 · 없으면 변환(파일 안 바뀜).""" - return _call(flow.get_flow, file, key) - - -class FlowDraft(BaseModel): - file: str = "" - key: str = "" - row: dict[str, Any] - - -@router.post("/logic/flow") -def post_logic_flow(body: FlowDraft) -> dict: - """저장 안 한 고친 줄(옛 편집기) → 흐름 변환 — {flow, converted, notes} · 쓰기 없음.""" - return _call(flow.get_flow, body.file, body.key, body.row) + """로직 정본 줄 고침 = 개정 확인 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}.""" + return _call(make.save, [f.model_dump() for f in body.files], body.개정확인) @router.post("/logic/new") diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py deleted file mode 100644 index fecdbbcc5..000000000 --- a/M01_MasterData/M01_MasterData_Store_Flow.py +++ /dev/null @@ -1,422 +0,0 @@ -"""M01 로직 「흐름」 칸(PLAN 36-4 · 정함 1 · 2 · 4 · 10 · 11 · 12). - -정함 = `docs/raw/M01_쉬운단가/흐름도_적용_정함.md`. - -· 흐름 = DataFlow Model `CfLogic` 꼴 그대로 · 로직 줄 칸 「흐름」 · 있으면 흐름이 정본 -· 쓰는 때 = `/save` 줄에 실어 온 것뿐(자동 변환 결과는 안 씀) — 꼴 검사(상자 · 선 · 돌고 도는 선) → - 서버 셈(`M02_DF_Compute.run`)으로 다시 셈 → 받는 칸에 값만 되돌려 넣음 · 빈 받는 자리는 경고 -· 새 줄(키 없음) + 흐름 = 자체 파일(`로직_자체.json`)에 더함 → 키 = 대장 `GX` 다음 · 구분 자체 -· 계산 = 흐름이 있으면 흐름 셈 · 없으면 옛 셈(`Store.calc`) · 입력 이름 = 「설계 때 정할 것」 -· 다른 로직 입력 = 마스터 키도 — 흐름 로직은 흐름 셈 · 옛 로직은 옛 셈(노무비 · 재료비 · 경비 · 계) -· 흐름 받기 = 줄의 흐름 그대로 · 없으면 sub7 변환(`M02_DF_Convert.convert`) · 아직 없으면 빈 흐름 -권한 = `/api/m01` 전체가 시스템 관리자(`main.py`) — 공용 로직 흐름도 관리자가 고침. -""" - -from __future__ import annotations - -import importlib -from contextvars import ContextVar -from decimal import Decimal, InvalidOperation -from typing import Any - -from common_util import common_util_money as mfm -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData import M01_MasterData_Store_Make as make -from M01_MasterData import M01_MasterData_Store_Revise as revise -from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc -from M02_MasterTemplete.DataFlow import M02_DF_Values as dv - -FLOW = "흐름" -KINDS = ("input", "function", "output") -CONVERT = "M02_MasterTemplete.DataFlow.M02_DF_Convert" -NOT_READY = "변환 준비 중" -_ACTIVE: ContextVar[tuple] = ContextVar("m01_flow_active", default=()) # 셈 중인 흐름 키 - - -# ── 검사(Model `link` · `ruleWhy` 와 같은 뜻) ────────────────────────────── -def _outs(box: dict) -> set: - """내보내는 알약 — Model `portsOut`.""" - if box.get("kind") == "function": - return {((box.get("func") or {}).get("output") or {}).get("name")} - inp = box.get("input") or {} - if box.get("kind") != "input": - return set() - if inp.get("mode") == "logic": - return {p.get("name") for p in (inp.get("logic") or {}).get("outputs") or []} - out = {dc._port(e) for e in inp.get("elements") or []} if inp.get("mode") == "group" else set() - if inp.get("mode") == "group" and inp.get("table"): - out.add(dc.TABLE_PORT) - return out | {v.get("name") for v in inp.get("variables") or []} - - -def _ins(box: dict) -> set: - """받는 알약 — Model `portsIn`.""" - if box.get("kind") == "function": - return {p.get("port") for p in (box.get("func") or {}).get("inputs") or []} - if box.get("kind") == "output": - return {dc.OUTPUT_PORT} - inp = box.get("input") or {} - return {p.get("name") for p in (inp.get("logic") or {}).get("designs") or []} - - -def check(flow: Any) -> list[str]: - """흐름 꼴 검사 — 틀리면 422 · 돌려받는 것 = 경고(빈 받는 자리).""" - if not isinstance(flow, dict) or not isinstance(flow.get("boxes"), list): - raise store.StoreError(422, {"errors": ["흐름 꼴이 아님 — 상자(boxes) 목록이 없음"]}) - if not isinstance(flow.get("links"), list): - raise store.StoreError(422, {"errors": ["흐름 꼴이 아님 — 선(links) 목록이 없음"]}) - errors, boxes = [], {} - for b in flow["boxes"]: - if not isinstance(b, dict) or not b.get("id"): - errors.append("번호(id) 없는 상자가 있음") - continue - name = b.get("name") or b["id"] - if b["id"] in boxes: - errors.append(f"상자 번호가 겹침 「{b['id']}」") - boxes[b["id"]] = b - if b.get("kind") not in KINDS: - errors.append(f"「{name}」 모르는 상자 종류 「{b.get('kind')}」") - elif b["kind"] == "function" and not ((b.get("func") or {}).get("output") or {}).get( - "name" - ): - errors.append(f"「{name}」 함수 상자에 결과 알약이 없음") - taken = set() - for ln in flow["links"]: - src, to = (ln or {}).get("from") or {}, (ln or {}).get("to") or {} - a, b = boxes.get(src.get("box")), boxes.get(to.get("box")) - if ( - a is None - or b is None - or src.get("name") not in _outs(a) - or to.get("name") not in _ins(b) - ): - errors.append(f"선 「{(ln or {}).get('id')}」 이 없는 알약을 이음") - continue - if (to["box"], to["name"]) in taken: - errors.append(f"「{b.get('name')} · {to['name']}」 에 선이 둘 이상") - taken.add((to["box"], to["name"])) - for p in dc._sources(ln.get("rule")): - if p.get("name") not in _outs(boxes.get(p.get("box")) or {}): - errors.append(f"선 「{ln.get('id')}」 규칙의 조건 알약이 없음") - if not errors: - errors += [ - f"「{b.get('name')}」 상자가 서로를 기다림(돌고 도는 선)" for b in dc._waves(flow)[1] - ] - if errors: - raise store.StoreError(422, {"errors": errors}) - return [ - f"「{b.get('name')} · {port}」 받는 자리에 들어오는 선이 없음" - for b in boxes.values() - if b.get("kind") != "input" - for port in sorted(_ins(b) - {n for bx, n in taken if bx == b["id"]}, key=str) - ] - - -# ── 다른 로직 · 계산 ─────────────────────────────────────────────────── -def _old(key: str, designs: dict) -> dict: - """옛 로직 값 — 옛 셈 · 내보내는 알약 = 노무비 · 재료비 · 경비 · 계(돈 아닌 로직은 결과).""" - given = {k: v for k, v in designs.items() if v is not None} - whole, mf = dv.SOURCE.get(), store.cm.mf - if whole is not None: # 단가 자리(프로젝트 사본)의 옛 로직 - try: - given = mf.fill_empty(whole.logic(key), {k: _value(v) for k, v in given.items()}) - got = mf.run(whole, key, given) - except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: - return {"outputs": {}, "errors": [str(e) or type(e).__name__]} - if "결과" in got: - return {"outputs": {"결과": got["결과"]}, "errors": []} - return {"outputs": {k: got[k] for k in mf.BUNDLE}, "errors": []} - got = store.calc(key, given) - if not got.get("ok"): - return {"outputs": {}, "errors": [got.get("reason") or "옛 셈 오류"]} - if "result" in got: - return {"outputs": {"결과": got["result"]}, "errors": []} - return {"outputs": dict(got["sums"]), "errors": []} - - -def load(key: str) -> dict: - """다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 옛 로직이면 옛 셈 함수(`calc`).""" - whole = dv.SOURCE.get() - row = whole.logic(key) if whole is not None else store.logic(key)["logic"] - if isinstance(row.get(FLOW), dict): - return {**row[FLOW], "key": key} - return {"key": key, "calc": lambda designs: _old(key, designs)} - - -def old_designs(key: str) -> list[dict]: - """옛 로직의 받는 알약 = 로직 입력 — Model `CfDesign` 꼴.""" - out = [] - for one in store.logic(key)["logic"].get("입력") or []: - choices = one.get("고르기") - variable = {"name": one.get("이름"), "type": "choice" if choices else "number", - "unit": one.get("단위") or "-", "example": None} # fmt: skip - if choices: - variable["choices"] = choices - out.append({"box": "", "port": one.get("이름"), "kind": "변수", - "unit": one.get("단위") or "-", "variable": variable}) # fmt: skip - return out - - -def _value(v: Any, words: set | frozenset = frozenset()) -> Any: - """화면이 보낸 값 — 수 글은 수로(「3」 → 3) · 고르기 글 값(「080」 · 「1048」)은 글 그대로.""" - if isinstance(v, str) and v in words: - return v - if isinstance(v, str) and v.strip(): - try: - return Decimal(v.strip()) - except InvalidOperation: - return v - return store._dec(v) - - -def run(flow: dict, key: str, inputs: dict, strict: bool = False) -> dict: - """흐름 셈 — 입력 = 「설계 때 정할 것」 이름 → 값 · strict(조합 · 프로젝트) = 안 준 입력은 - 예시값 - 대신 빈 값 + 알림 「빈 입력」(옛 엔진처럼 그 값을 쓰는 곳만 비움).""" - active = _ACTIVE.get() - if key and key in active: # 옛 셈을 건너 다시 돌아온 흐름(흐름 → 옛 로직 → 흐름) - raise store.cm.mf.FormulaError(f"로직이 서로를 부름 — {' → '.join([*active, key])}") - token = _ACTIVE.set((*active, key)) - try: - words = { # 고르기에 글로 든 값 — 옛 셈처럼 글 그대로(수로 바꾸면 고르기 밖) - c - for d in dc.design_inputs(flow) - for c in (d.get("variable") or {}).get("choices") or [] - if isinstance(c, str) - } - given = {k: _value(v, words) for k, v in (inputs or {}).items()} - flow = {**flow, "key": key} - values = dc._design_values(flow, given) - designs = dc.design_inputs(flow) if strict else [] - lack = [d["port"] for d in designs if d["port"] not in given] - if lack: # 안 준 입력 — 엔진 `_inputs` 와 같이 멈춤(빈 값으로 준 입력은 아래 「빈 입력」) - raise store.cm.mf.FormulaError(f"「{key}」 입력 「{lack[0]}」 없음") - blank = [d for d in designs if given.get(d["port"]) is None] - for d in blank: - values.setdefault(d["box"], {})[d["port"]] = None - got = dc.run(flow, values, load, flow.get("userElements") or [], [key]) - got["notes"] = [*(f"입력 「{d['port']}」 빈 입력" for d in blank), *got.get("notes", [])] - return got - finally: - _ACTIVE.reset(token) - - -def _engine(master, ref: str, row: dict, given: dict) -> dict: - """옛 엔진 `mf.run` 갈래(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…)) — `run` 과 같은 꼴. - - 단가 · 값 · 표 = 부른 쪽이 넘긴 master(프로젝트 사본이면 그 단가 · `M02_DF_Values.SOURCE`). - """ - picks = ( - store.cm.mf.mp.CHOSEN.get() or {} - ) # 고른 자재 — 흐름은 재료 자리 이름으로(호표 차례 아님) - slots = {d["port"] for d in dc.design_inputs(row[FLOW]) if d.get("kind") == "재료 자리"} - given = {**given, **{k: v for k, v in picks.items() if k in slots}} - token = dv.SOURCE.set(master) - try: - got = run(row[FLOW], ref, given, strict=True) - finally: - dv.SOURCE.reset(token) - # 빈 값(빈 입력 · 단가 없는 품목 · 표에 없는 조건 → #N/A)을 쓴 칸은 엔진처럼 그 값만 비움 — 알림 - empty = [e for e in got["errors"] if "#N/A" in e or "단가 없는 품목" in e] - stop = [e for e in got["errors"] if e not in empty] - if stop: - raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(stop)}") - cost, notes = got["cost"], [*(got.get("notes") or []), *empty] - outs = [b for b in row[FLOW].get("boxes") or [] if b.get("kind") == "output"] - extra = {"중간": {}, "값들": cost["값들"], **({"알림": notes} if notes else {})} - if outs and all((b.get("output") or {}).get("cost") in (None, "값") for b in outs): - first = got["outputs"].get(outs[0]["id"]) # 돈 아닌 로직 = 첫 출력이 결과 · 나머지는 값들 - return {"결과": first, **extra} - lines = [] # 옛 「줄」 꼴 — 돈 출력 상자마다 한 줄(수량 1 · 단가 = 금액) - for b in outs: - kind, value = (b.get("output") or {}).get("cost"), got["outputs"].get(b["id"]) - value = None if value == dc.NA_VALUE else value - if kind in dc.COSTS: - unit = (b.get("output") or {}).get("unit") or "" - lines.append({"이름": b.get("name") or "", "단위": unit, - "수량": Decimal(1), "단가": value, "금액": value, "비목": {kind: value}, - **({} if value is not None else {"까닭": "값 없음"})}) # fmt: skip - sums = {k: cost[k] for k in dc.COSTS} - return {"줄": lines, **sums, "계": cost["계"], **extra} - - -def _inputs(row: dict) -> list[dict]: - """흐름 로직 입력 = 「설계 때 정할 것」 → 옛 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시).""" - out = [] - for d in dc.design_inputs(row[FLOW]): - v = d.get("variable") or {} - one = {"이름": d["port"], "단위": None if d.get("unit") in (None, "-") else d["unit"]} - one |= {"고르기": v["choices"]} if v.get("choices") else {} - one |= {"범위": v["range"]} if v.get("range") else {} - out.append(one | {"예시": v.get("example")}) - return out - - -store.cm.mf.FLOW_RUN = _engine # 옛 엔진을 부르는 곳이 모두 흐름 로직을 흐름 셈으로(한 곳 갈래) -store.cm.mf.FLOW_INPUTS = ( - _inputs # 입력을 읽는 곳(시험입력 · 자동값 · 프로젝트 입력 · 연결)도 흐름에서 -) - - -def text(key: str, inputs: dict, row: dict | None = None) -> dict | None: - """읽는 식 줄(`/text` 꼴) — 흐름 로직이면 출력 상자를 비목 묶음 줄로 · 아니면 None(옛 글).""" - got = calc(key, inputs, row) - if got is None or not got.get("flow") or "outputs" not in got: - return got - groups: dict[str, list] = {} - for o in got["outputs"]: - value = o["value"] - # 비목 소계 = 정수 · 그 밖 = 수량 꼴 - shown = (mfm.fmt_money if o["cost"] else mfm.fmt)(value) - line = {"이름": o["name"], "글": shown, "이름글": o["name"], "금액": value, "조각": []} - groups.setdefault(o["cost"] or "값", []).append(line) - out = [{"비목": k, "줄": v} for k, v in groups.items()] - return { - "ok": got["ok"], - "flow": True, - "groups": out, - **({"reason": got["reason"]} if "reason" in got else {}), - } - - -def auto(key: str) -> dict | None: - """견본 입력 — 흐름 로직이면 「설계 때 정할 것」 예시값 · 아니면 None(옛 자동값).""" - flow = store.logic(key)["logic"].get(FLOW) - if not isinstance(flow, dict): - return None - return { - "값": {d["port"]: (d.get("variable") or {}).get("example") for d in dc.design_inputs(flow)} - } - - -def calc(key: str, inputs: dict, row: dict | None = None) -> dict | None: - """계산 갈래 — 줄에 흐름이 없으면 None(옛 셈 그대로) · 있으면 흐름 셈을 `/calc` 꼴로.""" - if row is not None: - flow, key = row.get(FLOW), str(row.get("키") or key) - else: - try: - flow = store.logic(key)["logic"].get(FLOW) - except store.StoreError: - return None - if not isinstance(flow, dict): - return None - try: - got = run(flow, key, inputs) - except store.cm.mf.FormulaError as e: - return {"ok": False, "flow": True, "reason": str(e)} - names = {b["id"]: b for b in flow.get("boxes") or []} - outputs = [ - {"box": i, "name": names[i].get("name"), "cost": (names[i].get("output") or {}).get("cost"), - "unit": (names[i].get("output") or {}).get("unit") or names[i].get("unit"), "value": v} - for i, v in got["outputs"].items() if i in names - ] # fmt: skip - cost = got["cost"] - out = {"ok": not got["errors"], "flow": True, "lines": [], "middle": {}, - "sums": {k: cost[k] for k in (*dc.COSTS, "계")}, "values": cost["값들"], - "outputs": outputs, "errors": got["errors"], "breaks": got["breaks"]} # fmt: skip - if got["errors"]: - out["reason"] = " · ".join(got["errors"]) - return out - - -# ── 흐름 받기 ────────────────────────────────────────────────────────── -def _row(file: str, key: str) -> dict: - if not file: - return store.logic(key)["logic"] - data = store.read(file)[0] - hit = next((r for r in data.get("줄") or [] if str(r.get("키")) == key), None) - if hit is None: - raise store.StoreError(404, f"{file} · 없는 키 「{key}」") - return hit - - -def get_flow(file: str, key: str, draft: dict | None = None) -> dict: - """{flow, converted, notes} — 흐름이 있으면 그대로 · 없으면 변환(파일은 안 바뀜) - · draft = 저장 안 한 고친 줄(옛 편집기) — 늘 그 줄 그대로 변환(쓰기 없음).""" - row = _row(file, key) if draft is None else store._dec({**draft, "키": draft.get("키") or key}) - if draft is not None: - row.pop(FLOW, None) - elif isinstance(row.get(FLOW), dict): - return {"flow": {**row[FLOW], "key": key}, "converted": False, "notes": []} - try: - convert = importlib.import_module(CONVERT).convert - except ModuleNotFoundError as e: - if e.name != CONVERT: - raise - empty = {"key": key, "name": row.get("이름") or "", "resultUnit": row.get("결과단위") or "", - "boxes": [], "links": [], "userElements": []} # fmt: skip - return {"flow": empty, "converted": True, "notes": [NOT_READY]} - got = convert(row, {"key": key, "file": file, "master": store.loaded()[1]}) - return {"flow": {**got["flow"], "key": key}, "converted": True, "notes": got.get("notes") or []} - - -# ── 저장 ────────────────────────────────────────────────────────────── -def _redo(row: dict, key: str) -> list[str]: - """흐름 검사 → 서버 셈으로 다시 셈 → 받는 칸 값만 되돌려 넣음 · 돌려받는 것 = 경고.""" - warns = check(row[FLOW]) - got = run(row[FLOW], key, {}) - filled = got["filled"] - filled.pop("key", None) # 키는 줄의 「키」 하나 — 흐름 안에 두지 않음 - row[FLOW] = filled - return warns + got["errors"] - - -def _bare(row: dict) -> dict: - """흐름 로직 줄 — 계산 칸(중간 · 호표 · 결과 · 덧줄)은 지움(흐름이 정본) · 「입력」 은 흐름의 - 「설계 때 정할 것」 으로 같은 꼴로 다시 씀(로직 입력 약속 하나 · 설명은 이름이 같으면 살림) - · 끝수 · 시험입력 · 메타 칸은 둠 · 출처 없으면 「자체」.""" - kept = {k: v for k, v in row.items() if k not in store.cm.mf.CALC_FIELDS} - said = {s.get("이름"): s.get("설명") for s in row.get("입력") or [] if s.get("설명")} - inputs = [] - for spec in store.cm.mf.inputs_of(row): - one = { - "이름": spec["이름"], - **({"설명": said[spec["이름"]]} if spec["이름"] in said else {}), - } - one |= {k: spec[k] for k in ("단위", "고르기", "범위") if spec.get(k) is not None} - inputs.append(one) - return {"끝수": None, **kept, "입력": inputs, "출처": row.get("출처") or "자체"} - - -def save(batch: list[dict], confirmed: bool = False) -> dict: - """`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로 · - 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise` · 713-1).""" - revise.guard(batch, confirmed) - warns, fresh = [], False - for part in batch: - for ch in part.get("changes") or []: - row = ch.get("row") - if not isinstance(row, dict) or FLOW not in row: - continue - check(row[FLOW]) # 꼴부터 — 입력 약속을 흐름에서 다시 쓰기 전에 - new = ch.get("op") == "add" or (ch.get("op") == "edit" and not ch.get("key")) - if new: - if part.get("file") != make.OWN: - raise store.StoreError(400, f"새 흐름 로직은 「{make.OWN}」 에만 더함") - row = make._clean(_bare(row), str(row.get("소유") or make.OWNERS[0])) - ch.update(op="add", key=None) - row = _bare(row) if not new else row - ch["row"] = row - name = row.get("이름") or ch.get("key") or "새 로직" - warns += [f"「{name}」 {w}" for w in _redo(row, str(ch.get("key") or ""))] - if part.get("file") == make.OWN and not make._own_path().is_file(): - make._make_file() - fresh = True - part["version"] = part.get("version") or store.read(make.OWN)[1] - before = {p["file"]: _keys(p["file"]) for p in batch} - try: - files = store.save(batch) - except store.StoreError: - if fresh: - make._own_path().unlink(missing_ok=True) - raise - added = [{"file": f, "key": k} for f, old in before.items() for k in _keys(f) if k not in old] - return {"files": files, "added": added, "warnings": warns} - - -def _keys(file: str) -> list[str]: - try: - data = store.read(file)[0] - except store.StoreError: - return [] - return [str(r.get("키")) for r in data.get(store.items_key(data)) or []] diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 810b11c6f..7383ceb32 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -12,6 +12,7 @@ from pathlib import Path from common_util.common_util_time import kst_today from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Revise as revise cm, mk, mcp = store.cm, store.mk, store.mcp @@ -278,6 +279,43 @@ def _make_file() -> None: path.write_text(store.dump(head), encoding="utf-8", newline="\n") +def save(batch: list[dict], confirmed: bool = False) -> dict: + """`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) · + 자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}.""" + revise.guard(batch, confirmed) + fresh = False + for part in batch: + if part.get("file") != OWN: + continue + for ch in part.get("changes") or []: + row = ch.get("row") + if isinstance(row, dict) and (ch.get("op") == "add" or not ch.get("key")): + ch.update(op="add", key=None, row=_clean(row, str(row.get("소유") or OWNERS[0]))) + if not _own_path().is_file(): + _make_file() + fresh = True + part["version"] = part.get("version") or store.read(OWN)[1] + before = {p["file"]: _file_keys(p["file"]) for p in batch} + try: + files = store.save(batch) + except store.StoreError: + if fresh: + _own_path().unlink(missing_ok=True) + raise + added = [ + {"file": f, "key": k} for f, old in before.items() for k in _file_keys(f) if k not in old + ] + return {"files": files, "added": added, "warnings": []} + + +def _file_keys(file: str) -> list[str]: + try: + data = store.read(file)[0] + except store.StoreError: + return [] + return [str(r.get("키")) for r in data.get(store.items_key(data)) or []] + + def _find(key: str) -> tuple[str, dict]: got = store.logic(key) return got["file"], got["logic"] diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index fc06fced4..02a0f7afb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 흐름도 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 원문 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. @@ -38,18 +38,6 @@ import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_ import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -import { - emptyFlow, - designFields, - examplesOf, - fetchFlow, - fetchFlowFor, - sameShape, - withExamples, - mountLogicFlow, - tf, - type LogicFlow, -} from "./M01_MasterData_UI_LogicLab_Flow"; import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; @@ -57,7 +45,6 @@ import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; import "./M01_MasterData_UI_Logic_Style.css"; import { createEmpty } from "@ui/ui_template_empty"; @@ -83,20 +70,11 @@ interface Opened extends Draft { lastText?: TextAnswer; ownHost: HTMLElement; values: Record; - /** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */ - flowHost: HTMLElement; - /** 오른쪽 흐름도의 기준(예시값만 다른지 견줄 때) */ - flowBase?: CfLogic; } -/** 줄의 흐름(있으면 흐름도로 셈) */ -const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; const OWN_FILE = "로직_자체.json"; -/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) · 그 밖은 시험 고침(722-2) 덮은 사본 */ -const calcRow = (row: LogicRow | null): LogicRow | null => { - const f = flowOf(row); - return f && row ? { ...row, 입력: designFields(f) } : trial.apply(row); -}; +/** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */ +const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row); const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); @@ -123,20 +101,18 @@ export async function mountM01LogicLab( let drafts = loadDrafts(); let opened: Opened | null = null; let calcInputs = ""; - /** 오른쪽 흐름도 패널 — 로직을 열 때마다 새로 */ - let flowNow: LogicFlow | null = null; const admin = await fetchSessionUser() .then((u) => u?.role === "SYSTEM_ADMIN") .catch(() => false); let recalc: number | undefined; const sheet = createLabSheet({ admin, - onInput: (name, v) => opened && syncLeft(opened, { [name]: v }, true), + onInput: (name, v) => opened && syncLeft(opened, { [name]: v }), edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길) run: () => calc.querySelector("[data-calc-run]")?.click(), }); const editor = el("div", { className: "m01-logic__editor" }); - // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유) + // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」 const panes = buildLogicSide(editor); const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽 const errors = el("div", { @@ -195,7 +171,6 @@ export async function mountM01LogicLab( if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; else drafts[opened.id] = pick(opened, clone(opened.row)); persist(); - if (!flowOf(opened.row)) scheduleFlow(opened); // 왼쪽 편집기로 고침 — 오른쪽 흐름도도 따라감 const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []); if (inputs !== calcInputs) drawCalc(); if (isOwnLogic(opened.row)) { @@ -263,120 +238,31 @@ export async function mountM01LogicLab( values: current.values, ownHost: current.ownHost, onChange: touch, - onInput: (name, v) => syncLeft(current, { [name]: v }, true), + onInput: (name, v) => syncLeft(current, { [name]: v }), onTrial: drawCalc, calcHost: calc, - flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, sheet: sheet.root, }); sheet.draw(current.row as LogicRow, current.original, current.values); - flowNow?.redrawLeft(); }; - /** 왼쪽 고침 뒤 잠시 쉬면 고친 줄을 서버가 흐름도로 바꿔 줌 → 오른쪽에 넣음 · 마지막 물음만 받음 */ - let flowTimer: number | undefined; - let flowSeq = 0; - const scheduleFlow = (o: Opened): void => { - window.clearTimeout(flowTimer); - flowTimer = window.setTimeout(() => { - const seq = ++flowSeq; - if (!o.row) return; - void fetchFlowFor(o.row, o.origKey ?? "", o.file).then((got) => { - if (!got || seq !== flowSeq || opened !== o || !flowNow || flowOf(o.row)) return; - o.flowBase = withExamples(got.flow, o.values); - flowNow.set(clone(o.flowBase)); - }); - }, 700); - }; - - /** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */ - const startFlow = async (o: Opened): Promise => { - const row = o.row; - if (!row) return; - panes.flowSplit.right.replaceChildren( - el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }), - ); - const own = flowOf(row); - const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file); - // 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로 - o.flowBase = withExamples( - own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)), - o.values, - ); - if (opened !== o) return; - try { - const made = await mountLogicFlow({ - split: panes.flowSplit, - folded: panes.flowFolded(), - onFold: (on) => panes.pick(on ? "none" : "flow"), - flow: clone(o.flowBase), - title: row.이름 || row.키, - readOnly: !admin, - notes: got?.notes ?? [], - converted: !!got?.converted, - leftHost: () => (flowOf(o.row) ? o.flowHost : null), - onChange: (lg) => { - if (!o.row || opened !== o) return; - const was = flowOf(o.row); - if (sameShape(lg, was ?? o.flowBase ?? lg)) { - // 예시값만 바뀜 — 고친 것 아님 · 왼쪽 시험 계산 입력에 같은 이름으로 넣고 다시 셈 - o.flowBase = lg; - syncLeft(o, examplesOf(lg)); - return; - } - o.row.흐름 = lg; - touch(); - if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로 - }, - }); - if (opened !== o) return made.destroy(); - flowNow = made; - panes.bindFlow(made.fold); - made.setExamples(o.values); // 띄우는 사이에 온 자동값까지 - } catch (e) { - panes.flowSplit.right.replaceChildren( - el("p", { className: "ui-text ui-text--muted", text: tf("Failed", String(e)) }), - ); - } - }; - - /** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */ - const syncLeft = (o: Opened, ex: Record, loud = false): void => { + /** 텍스트 수식 · 시트 입력 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 다시 그리고 셈 */ + const syncLeft = (o: Opened, ex: Record): void => { const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); if (!changed.length) return; - quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길 for (const [k, v] of changed) o.values[k] = v; - quiet = false; drawCalc(); calc.querySelector("[data-calc-run]")?.click(); }; - /** 왼쪽 시험 계산 입력 — 칸이 바뀌면 흐름 예시값으로(이 로직이 열려 있을 때만) */ - let quiet = false; - const watchValues = (): Record => { - const self: Record = new Proxy({} as Record, { - set(t, k, v) { - const was = t[k as string]; - t[k as string] = v as string; - if (!quiet && was !== v && opened?.values === self) - flowNow?.setExamples({ [k as string]: v as string }); - return true; - }, - }); - return self; - }; const show = (next: Opened | null): void => { - flowNow?.destroy(); - flowNow = null; - panes.bindFlow(null); opened = next; panes.showSource(next?.row?.출처 ?? ""); errors.hidden = true; drawEditor(); drawCalc(); - if (next?.row) void startFlow(next); }; const open = async (id: string, sub: string, key: string): Promise => { @@ -391,8 +277,7 @@ export async function mountM01LogicLab( lines: null, text: null, ownHost: ownHost(), - values: watchValues(), - flowHost: el("div"), + values: {}, }); return; } @@ -411,8 +296,7 @@ export async function mountM01LogicLab( lines: null, text: null, ownHost: ownHost(), - values: watchValues(), - flowHost: el("div"), + values: {}, }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); @@ -557,7 +441,8 @@ export async function mountM01LogicLab( ...info, 입력: [], 중간: [], - 흐름: await emptyFlow(info.이름, info.결과단위), + 호표: [], // 빈 시트 줄 — 자체 로직 편집기 · 시트로 채움 + 끝수: null, }; const id = logicId("자체", `새${Date.now()}`); drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row }; @@ -595,7 +480,7 @@ export async function mountM01LogicLab( status: [pending], buttons: [ views.root, - createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }), + createButton({ label: tl("New"), variant: "filled", onClick: () => void onNew() }), copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts index c5234cc99..e80b01fcf 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Basic.ts * 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null - * 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌. + * 저장은 하지 않음 — 부른 쪽이 빈 시트 줄로 캐시(drafts)에 넣고 [저장] 때 서버가 GX 새 키를 줌. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 7d4a659c2..bcaba7f57 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -18,7 +18,7 @@ import { trial } from "./M01_MasterData_UI_LogicLab_Cover"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; -import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; +import { block } from "./M01_MasterData_UI_LogicLab_View"; export { trial }; @@ -30,10 +30,7 @@ export interface DetailContext extends HoContext { onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 둘째 컨테이너 안에 놓음 */ calcHost: HTMLElement; - /** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 · - * 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */ - flowHost?: HTMLElement; - /** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */ + /** 시스템 관리자 — 기본 로직 안내 글이 다름(시트에서 고침 / 보기만) */ admin?: boolean; /** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */ onTrial?: () => void; @@ -244,12 +241,8 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { ]), "est", ), - ...(ctx.flowHost && !ctx.cover - ? [block(nowInputs(ctx.values), "ho"), ctx.flowHost] - : [ - ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx), - block(section(tl("Ho_Title"), [formula]), "formula"), - ]), + ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx), + block(section(tl("Ho_Title"), [formula]), "formula"), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 const back = own ? host.querySelector(`[data-own="${own}"]`) : kept?.node; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css deleted file mode 100644 index 3d2278801..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css +++ /dev/null @@ -1,18 +0,0 @@ -.m01lab-flow__wait { - margin: 0; - padding: var(--spacing-16); - border: 1px dashed var(--color-border); - border-radius: var(--radius-md); - color: var(--color-text-secondary); - text-align: center; -} - -.m01lab-flow__note { - margin: 0 0 var(--spacing-8); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01lab-flow__note[hidden] { - display: none; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts deleted file mode 100644 index a751b64a0..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_Flow.ts - * 로직 상세 오른쪽 흐름도 — 칸 · 폭은 오른쪽 접는 패널(LogicLab_Side) 몫 - * 잇기(mountLogicFlow) — 패널(M02_DF_Panel) · 왼쪽 보기(LogicLab_FlowView) · 입력 값 · 다른 로직 흐름 받기 - * DataFlow 파일은 import() 로 늦게(엔진이 M01 첫 묶음에 안 들어가게). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 2 · 5 · 6 · 7 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import { API_BASE_URL } from "@config/config_frontend"; -import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; -import type { CfValues } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; -import "./M01_MasterData_UI_LogicLab_Flow.css"; - -const TEXT = { - New: ["신규 로직", "New logic"], - Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"], - Converted: [ - "이 로직의 계산 방법을 흐름도로 보여 줌", - "Shows how this logic is calculated as a flow diagram", - ], - Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"], -} as const; - -export const tf = (key: keyof typeof TEXT, v = ""): string => - TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); - -export interface SplitHandle { - /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ - right: HTMLElement; - /** 패널이 알린 너비(px) · null = 패널 내림 */ - setRight(px: number | null): void; -} - -/* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */ - -export interface FlowAnswer { - flow: CfLogic; - converted: boolean; - notes: string[]; -} - -/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 계산 방법을 흐름도로 바꿔 줌 · 못 받으면 null */ -export async function fetchFlow(key: string, file = ""): Promise { - try { - const q = new URLSearchParams({ key, ...(file ? { file } : {}) }); - const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" }); - if (!r.ok) return null; - const got = (await r.json()) as Partial; - return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null; - } catch { - return null; - } -} - -/** 고치는 중인 줄의 흐름 — 왼쪽 편집기로 고친 줄(저장 전)을 서버가 흐름도로 바꿔 줌 · 못 받으면 null */ -export async function fetchFlowFor( - row: unknown, - key: string, - file: string, -): Promise { - try { - const r = await fetch(`${API_BASE_URL}/m01/logic/flow`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ file, key, row }), - }); - if (!r.ok) return null; - const got = (await r.json()) as Partial; - return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null; - } catch { - return null; - } -} - -const loadFlowMods = () => - Promise.all([ - import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"), - import("./M01_MasterData_UI_LogicLab_FlowView"), - import("../M02_MasterTemplete/DataFlow/M02_DF_Model"), - import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"), - import("../M02_MasterTemplete/DataFlow/M02_DF_Api"), - import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"), - ]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail })); - -/** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */ -export async function emptyFlow(name: string, unit: string): Promise { - return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit); -} - -/* ── 시험 입력 한 벌 — 왼쪽 시험 계산 입력 ↔ 흐름 변수 예시값(이름이 같은 변수끼리) ── */ - -const varsOf = (lg: CfLogic) => - lg.boxes.flatMap((b) => [...(b.input?.variables ?? []), ...(b.func?.variables ?? [])]); - -/** 흐름 변수 예시값 — 이름 → 글(빈 값은 뺌) */ -export function examplesOf(lg: CfLogic): Record { - const out: Record = {}; - for (const v of varsOf(lg)) - if (v.example !== null && v.example !== undefined && v.example !== "") - out[v.name] = String(v.example); - return out; -} - -/** 예시값을 넣은 새 흐름 — 수처럼 생긴 글은 수로 · 빈 값은 그대로 둠 */ -export function withExamples(lg: CfLogic, vals: Record): CfLogic { - const next = JSON.parse(JSON.stringify(lg)) as CfLogic; - for (const v of varsOf(next)) { - const raw = (vals[v.name] ?? "").trim(); - if (raw) v.example = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw; - } - return next; -} - -/** 흐름 로직의 시험 계산 입력 칸 — 「설계 때 정할 것」(변수 · 고르기 · 범위 · 단위) · 이름이 겹치면 하나 */ -export function designFields( - lg: CfLogic, -): { 이름: string; 단위?: string; 고르기?: (string | number)[]; 범위?: [number, number] }[] { - const seen = new Set(); - return varsOf(lg).flatMap((v) => { - if (!v.name || seen.has(v.name)) return []; - seen.add(v.name); - return [ - { - 이름: v.name, - ...(v.unit && v.unit !== "-" ? { 단위: v.unit } : {}), - ...(v.type === "choice" && v.choices?.length ? { 고르기: v.choices } : {}), - ...(v.range ? { 범위: v.range } : {}), - }, - ]; - }); -} - -/** 예시값만 다른가 — 그 밖이 같으면 고친 것으로 안 셈 */ -export function sameShape(a: CfLogic, b: CfLogic): boolean { - const strip = (lg: CfLogic): string => { - const c = JSON.parse(JSON.stringify(lg)) as CfLogic; - for (const v of varsOf(c)) v.example = null; - return JSON.stringify(c); - }; - return strip(a) === strip(b); -} - -export interface LogicFlowOpts { - split: SplitHandle; - flow: CfLogic; - title: string; - readOnly: boolean; - /** 변환 알림 — 패널 위 한 줄 */ - notes: string[]; - converted: boolean; - /** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */ - leftHost(): HTMLElement | null; - onChange(lg: CfLogic): void; - /** 패널 접힘 — 부르는 쪽(오른쪽 두 패널)이 정함 */ - folded?: boolean; - onFold?(on: boolean): void; -} - -export interface LogicFlow { - /** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */ - set(lg: CfLogic): void; - /** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */ - redrawLeft(): void; - /** 왼쪽 시험 계산 입력이 바뀜 — 같은 이름 변수의 예시값으로(고친 것 아님) */ - setExamples(vals: Record): void; - /** 패널 접기 · 펴기 */ - fold(on: boolean): void; - destroy(): void; -} - -/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */ -export async function mountLogicFlow(opts: LogicFlowOpts): Promise { - const M = await loadFlowMods(); - const host = el("div", { className: "m01lab-flow__panel" }); - const note = el("p", { className: "m01lab-flow__note" }); - const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes]; - note.textContent = notes.join(" · "); - note.hidden = !notes.length; - opts.split.right.replaceChildren(note, host); - let lg = opts.flow; - let values: CfValues = {}; - let valueSig = ""; - let logics: Record = {}; - let logicSig = ""; - let gone = false; - - /** 다른 로직 — 마스터 로직(흐름 받기) */ - const getLogic = async (key: string): Promise => - (await fetchFlow(key))?.flow ?? null; - - const left = (): void => { - const at = opts.leftHost(); - if (!at || gone) return; - M.view.buildFlowView(at, lg, values, { logics }); - }; - /** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */ - const refresh = (): void => { - let ports: Record> = {}; - try { - ports = M.ev.evalLogic(lg, values, { logics }).ports; - } catch { - /* 셈이 막혀도 물음은 보냄 */ - } - const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null; - const asks = M.detail.queriesOf(lg, pill, M.model.elementPort); - const sig = JSON.stringify(asks.map((a) => a.q)); - if (sig !== valueSig) { - valueSig = sig; - void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( - (got) => { - if (sig !== valueSig || gone) return; - values = got.values; - left(); - refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더 - }, - () => undefined, - ); - } - const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); - if (keys !== logicSig) { - logicSig = keys; - void M.detail.loadLogics(lg, getLogic).then((got) => { - if (keys !== logicSig || gone) return; - logics = got.logics; - left(); - }); - } - }; - - const panel = M.panel.mountFlowPanel(host, { - logic: lg, - title: opts.title, - readOnly: opts.readOnly, - logics: getLogic, - onResize: (px) => opts.split.setRight(px), - folded: opts.folded, - onFold: opts.onFold, - onChange: (next) => { - lg = next; - opts.onChange(next); - left(); - refresh(); - }, - }); - left(); - refresh(); - return { - set: (next) => { - lg = next; - panel.set(next); - left(); - refresh(); - }, - redrawLeft: left, - fold: (on) => panel.fold(on), - setExamples: (vals) => { - const next = withExamples(lg, vals); - if (JSON.stringify(next) === JSON.stringify(lg)) return; - lg = next; - panel.set(next); - left(); - refresh(); - }, - destroy: () => { - gone = true; - panel.destroy(); - opts.split.setRight(null); - opts.split.right.replaceChildren(); - }, - }; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts deleted file mode 100644 index fa792b2fa..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_FlowRows.ts - * 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어 - * 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함. - * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py - * ========================================================================== */ - -import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; -import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; -import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; - -export type FlowGroupKey = "labor" | "material" | "cost" | "plain"; - -/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */ -export const GROUP_OF: Record = { - 노무비: "labor", - 재료비: "material", - 경비: "cost", - 값: "plain", -}; -export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"]; -const COST_OF: Record, "노무비" | "재료비" | "경비"> = { - labor: "노무비", - material: "재료비", - cost: "경비", -}; - -export interface FlowLine { - box: string; - name: string; - unit: string; - value: number | string | boolean | null; - /** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */ - text: string; -} - -export interface FlowGroup { - key: FlowGroupKey; - lines: FlowLine[]; - /** 비용 묶음만 — 합계 끝수를 적용한 소계 */ - sub: number | null; - subText: string; -} - -export interface FlowFunc { - box: string; - name: string; - unit: string; - value: unknown; - text: string; -} - -export interface FlowRows { - groups: FlowGroup[]; - /** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */ - total: number; - totalText: string; - funcs: FlowFunc[]; - errors: string[]; -} - -const plain = (v: unknown): string => - v === null || v === undefined || Array.isArray(v) - ? "—" - : typeof v === "number" - ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) - : String(v); - -/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */ -export function flowRows( - lg: CfLogic, - ev: CfEvalResult, - round: { item: CfRound; total: CfRound | null }, -): FlowRows { - const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); - const groups = GROUP_ORDER.map((key): FlowGroup => { - const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key); - const lines = mine.map((b): FlowLine => { - const v = ev.outputs[b.id] ?? null; - const r = b.output?.round ?? (key === "plain" ? undefined : round.item); - return { - box: b.id, - name: b.name, - unit: b.output?.unit ?? b.unit, - value: v, - text: typeof v === "number" ? showRounded(v, r) : plain(v), - }; - }); - const raw = key === "plain" ? null : ev.cost[COST_OF[key]]; - const sub = raw === null ? null : roundBy(round.total, raw); - return { - key, - lines, - sub, - subText: sub === null ? "" : showRounded(sub, round.total ?? undefined), - }; - }).filter((g) => g.lines.length); - const total = roundBy(round.total, ev.cost.계); - const funcs = lg.boxes - .filter((b) => b.kind === "function" && b.func) - .map((b): FlowFunc => { - const out = b.func!.output; - const v = ev.ports[b.id]?.[out.name]; - return { - box: b.id, - name: b.name, - unit: out.unit || b.unit, - value: v, - text: - typeof v === "number" - ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) - : plain(v), - }; - }); - return { - groups, - total, - totalText: showRounded(total, round.total ?? undefined), - funcs, - errors: ev.errors, - }; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css deleted file mode 100644 index 505f077b5..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css +++ /dev/null @@ -1,24 +0,0 @@ -.m01flow__group { - margin: 0 0 var(--spacing-12); -} - -.m01flow__block { - margin: 0 0 var(--spacing-8); -} - -.m01flow__func { - margin: 0 0 var(--spacing-4); - color: var(--color-text-body); - font-weight: 600; -} - -.m01flow__branch { - margin: 0 0 0 var(--spacing-16); - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01flow__branch--now { - color: var(--color-text-body); - font-weight: 600; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts deleted file mode 100644 index 16520184e..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_FlowView.ts - * 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다 - * 식 두 줄 — sub2 `formulaLines`). 기본정보 · 시험 계산은 Detail 이 둠. - * 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름. - * 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름. - * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import { - evalLogic, - type CfEvalOptions, - type CfEvalResult, - type CfValues, -} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; -import { - cellValuesOf, - formulaLines, - type FormulaLine, -} from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView"; -import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; -import { - itemRoundNow, - roundText, - totalRoundNow, - totalRoundOf, -} from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; -import { - flowRows, - type FlowFunc, - type FlowGroup, - type FlowGroupKey, -} from "./M01_MasterData_UI_LogicLab_FlowRows"; -import "./M01_MasterData_UI_LogicLab_FlowView.css"; -import { createText } from "@ui/ui_template_text"; - -const TEXT = { - Detail: ["단가산출 상세", "Unit-price detail"], - Formula: ["텍스트 수식", "Text formulas"], - Labor: ["인력 · 노무비", "Labor · labor cost"], - Material: ["재료 · 재료비", "Material · material cost"], - Cost: ["경비", "Expense"], - Plain: ["값(비용 아님)", "Values (not costs)"], - HName: ["이름", "Name"], - HUnit: ["단위", "Unit"], - HValue: ["값", "Value"], - Sub: ["소계", "Subtotal"], - Total: ["계", "Total"], - TotalNote: ["합계 끝수", "Total rounding"], - None: ["출력 상자가 없음", "No output box"], - NoFunc: ["함수 상자가 없음", "No function box"], -} as const; - -type TextKey = keyof typeof TEXT; -const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; -const GROUP_TITLE: Record = { - labor: "Labor", - material: "Material", - cost: "Cost", - plain: "Plain", -}; - -const section = (title: string, body: HTMLElement[]): HTMLElement => - el("section", { - className: "m01-logic__section m01lab__box", - children: [ - el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }), - ...body, - ], - }); - -const groupTable = (g: FlowGroup): HTMLElement => { - const sub = - g.sub === null - ? [] - : [ - el("tr", { - className: "m01lab__sub", - children: [ - el("td", { text: tf("Sub"), attrs: { colspan: "2" } }), - el("td", { className: "m01-logic__money", text: g.subText }), - ], - }), - ]; - return el("div", { - className: "m01flow__group", - children: [ - el("h4", { text: tf(GROUP_TITLE[g.key]) }), - el("div", { - className: "m01-logic__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m01-logic__grid", - children: [ - el("thead", { - children: [ - el("tr", { - children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) => - el("th", { text: h }), - ), - }), - ], - }), - el("tbody", { - children: [ - ...g.lines.map((l) => - el("tr", { - children: [ - el("td", { text: l.name }), - el("td", { text: l.unit }), - el("td", { className: "m01-logic__money", text: l.text }), - ], - }), - ), - ...sub, - ], - }), - ], - }), - ], - }), - ], - }); -}; - -/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */ -function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement { - const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : ""; - const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` }); - if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] }); - return el("div", { - className: "m01flow__block", - children: [ - head, - ...lines.flatMap((l) => [ - el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }), - el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }), - ...(l.branches ?? []).map((b) => - el("p", { - className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`, - text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`, - }), - ), - ]), - ], - }); -} - -/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */ -export function buildFlowView( - host: HTMLElement, - lg: CfLogic, - values: CfValues, - opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {}, -): void { - let ev: CfEvalResult; - try { - ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics }); - } catch (e) { - const failed = section(tf("Detail"), [ - el("p", { className: "ui-text ui-text--muted", text: String(e) }), - ]); - failed.dataset.block = "ho"; - host.replaceChildren(failed); - return; - } - const totalRound = totalRoundOf(lg, totalRoundNow()); - const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRound }); - const detail = section(tf("Detail"), [ - ...(rows.groups.length - ? rows.groups.map(groupTable) - : [el("p", { className: "ui-text ui-text--muted", text: tf("None") })]), - el("p", { - className: "m01lab__total", - attrs: { "data-sum": String(rows.total) }, - text: `${tf("Total")} ${rows.totalText}`, - }), - el("p", { - className: "ui-text ui-text--muted", - text: `${tf("TotalNote")}: ${roundText(totalRound)}`, - }), - ...rows.errors.slice(0, 3).map((t) => createText(t, "error", 14)), - ]); - /** 함수 상자마다 식 두 줄(sub2 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */ - const linesOf = (id: string): FormulaLine[] | null => { - const box = lg.boxes.find((b) => b.id === id); - if (!box?.func) return null; - try { - return formulaLines(box, cellValuesOf(box, ev.arrived?.[id])); - } catch { - return null; - } - }; - const bodies = rows.funcs.map((f) => funcBlock(f, linesOf(f.box))); - const text = section( - tf("Formula"), - bodies.length ? bodies : [el("p", { className: "ui-text ui-text--muted", text: tf("NoFunc") })], - ); - // 보기 고르기 이름표(`LogicLab_View.ts`) — 고른 덩이만 보임 - detail.dataset.block = "ho"; - text.dataset.block = "formula"; - host.replaceChildren(detail, text); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts index 3c099811d..b51e7af2b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Sheet.ts @@ -48,7 +48,6 @@ const TEXT = { Close: ["식 닫기", "Close formula"], Back: ["원래로", "Revert"], Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"], - Flow: ["흐름 로직 — 시트로 못 봄", "Flow logic — no sheet"], Address: ["번지", "Address"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; @@ -267,7 +266,6 @@ export function createLabSheet(opt: LabSheetOptions) { function paint(d: LabSheetDraw): void { last = d; drawStatus(d); - if (d.row.흐름) return void body.replaceChildren(createText(t("Flow"), "muted")); const items = labItems({ ...d, names }); const active = document.activeElement as HTMLElement | null; const keep = active && body.contains(active) ? active.dataset.sheetCell : ""; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css index 2cccf101c..258537609 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.css @@ -1,19 +1,3 @@ -/* 로직 상세 오른쪽 두 패널(흐름도 · 원문) — 공용 오른쪽 접는 패널 안 */ -.m01lab-side__slot { - min-width: 0; -} - -/* 접기 · 폭은 공용 패널 몫 — 흐름도 패널 자체 접기 단추 · 끌기 경계 · 테두리는 뺌 */ -.m01lab-side .m02-dfp__toggle, -.m01lab-side .m02-dfp__grip { - display: none; -} - -.m01lab-side .m02-dfp { - border-left: 0; - background: none; -} - /* 원문 꼴 한 벌(M01 · M02 같음) — 둘레 글자 크기(M01 14)를 안 따르고 본문 크기 */ .m01lab-src { font-size: var(--text-body); @@ -23,6 +7,22 @@ min-width: 0; } +/* 원문 머리 — 제목 · [원문 다운로드] */ +.m01lab-src__top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01lab-src__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .m01lab-src__body { display: flex; flex-direction: column; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts index c8c242459..fc83f2f02 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts @@ -1,77 +1,45 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Side.ts - * 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘 「흐름도」(M02_DF_Panel) · 「원문」(품셈 본문 절) - * 공용 오른쪽 접는 패널(ui_template_side_panels) — 하나만 열림 · 모두 접힘 됨 · 폭 공유 · 접힌 패널 = 세로 글 띠 - * 열린 패널 · 폭은 취향 키(`m01-logic-side`) · 원문 = 공용 원문 보기(`LogicLab_Source`) + * 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」(품셈 본문 절) + * 공용 오른쪽 접는 패널(ui_template_side_panels) — 접힘 됨 · 접힌 패널 = 세로 글 띠 + * 열림 · 폭은 취향 키(`m01-logic-side`) · 원문 = 공용 원문 보기(`LogicLab_Source`) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { el } from "@ui/ui_template_elements"; -import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow"; import { createSourceView } from "./M01_MasterData_UI_LogicLab_Source"; -import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { - Flow: ["흐름도", "Flow"], Source: ["원문", "Source"], } as const; const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; -export type PanelPick = "flow" | "source" | "none"; - export interface LogicSide { /** [왼쪽 지금 내용, 오른쪽 패널] 칸 */ root: HTMLElement; - /** 흐름도 패널에 넘길 칸 — right = 흐름도 자리 · 폭은 오른쪽 패널 몫(setRight 안 씀) */ - flowSplit: SplitHandle; - flowFolded(): boolean; - pick(next: PanelPick): void; - /** 흐름도 패널이 뜨면 접기 함수 · 내리면 null */ - bindFlow(fold: ((on: boolean) => void) | null): void; /** 연 로직의 출처 글 — 원문 패널이 열려 있으면 찾음 */ showSource(src: string): void; } export function buildLogicSide(left: HTMLElement): LogicSide { - const flowSlot = el("div", { className: "m01lab-side__slot" }); const source = createSourceView(); - const sp = createSidePanels({ - prefKey: "m01-logic-side", - defaultWidth: 520, - minWidth: 320, - panels: [ - { id: "flow", title: ts("Flow"), body: flowSlot }, - { id: "source", title: ts("Source"), body: source.root }, - ], - onChange: () => { - flowFold?.(sp.current() !== "flow"); // 다시 펴면 흐름도 판을 폭에 맞춤 - load(); - }, - }); - const root = el("div", { - className: "m01lab-side ui-side-panels-row", - children: [left, sp.root], - }); - - let flowFold: ((on: boolean) => void) | null = null; let want = ""; - /** 원문 패널이 열려 있을 때만 찾음 */ const load = (): void => { if (sp.current() === "source") source.show(want); }; - + const sp = createSidePanels({ + prefKey: "m01-logic-side", + defaultWidth: 520, + minWidth: 320, + panels: [{ id: "source", title: ts("Source"), body: source.root }], + onChange: load, + }); return { - root, - flowSplit: { right: flowSlot, setRight: () => undefined }, - flowFolded: () => sp.current() !== "flow", - pick: (next) => sp.open(next === "none" ? null : next), - bindFlow: (f) => { - flowFold = f; - }, + root: el("div", { className: "m01lab-side ui-side-panels-row", children: [left, sp.root] }), showSource: (src) => { want = src; load(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts index 399283989..b587d1892 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts @@ -8,7 +8,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; -import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css"; import "./M01_MasterData_UI_LogicLab_Side.css"; const TEXT = { @@ -85,7 +84,7 @@ export interface SourceViewOptions { export function createSourceView(opts: SourceViewOptions = {}): SourceView { const body = el("div", { className: "m01lab-src__body" }); - const title = el("strong", { className: "m02-dfp__title" }); + const title = el("strong", { className: "m01lab-src__title" }); const download = opts.pdf ? (el("a", { className: "ui-btn ui-btn--ghost ui-btn--sm", @@ -149,7 +148,7 @@ export function createSourceView(opts: SourceViewOptions = {}): SourceView { }; const head = download - ? el("div", { className: "m02-dfp__head", children: [title, download] }) + ? el("div", { className: "m01lab-src__top", children: [title, download] }) : title; return { root: el("div", { className: "m01lab-src", children: [head, body] }), show }; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index cdf9ea8ed..f33aece44 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -219,11 +219,6 @@ margin-bottom: var(--spacing-8); } -/* 상세 머리 「지금 입력」 한 줄 */ -.m01lab__now { - margin: 0; -} - /* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */ .m01lab__slots .ui-stat:not(.is-used) .ui-stat__value { font-weight: 400; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 50f9d3a74..b1e070df0 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -7,9 +7,10 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Info_Title: ["기본정보", "Basic info"], + New: ["신규 로직", "New logic"], Info_Base: [ - "기본 로직 — 식은 오른쪽 흐름도에서 고침(시스템 관리자) · 여기서는 비고만", - "Base logic — edit formulas in the flow diagram on the right (system admin) · only the note here", + "기본 로직 — 식은 시트에서 고침(시스템 관리자) · 여기서는 비고만", + "Base logic — edit formulas in the sheet (system admin) · only the note here", ], Info_ReadOnly: [ "기본 로직 — 보기만 · 비고만 고칠 수 있음", @@ -36,8 +37,6 @@ const TEXT = { View_Sheet: ["시트", "Sheet"], Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"], View_Label: ["보기", "View"], - Now_Inputs: ["지금 입력", "Current inputs"], - Now_None: ["없음", "none"], G_labor: ["인력", "Labor"], G_material: ["재료", "Material"], G_cost: ["경비", "Expense"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts index 549778772..32ffd6bfa 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -7,7 +7,6 @@ * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; import { createSegment } from "@ui/ui_template_segment"; import { createToolbar } from "@ui/ui_template_toolbar"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -38,14 +37,3 @@ export function createViewPicker(host: HTMLElement): { root: HTMLElement } { }); return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) }; } - -/** 상세 머리 「지금 입력」 한 줄(읽기만) — 숨은 시험 계산 칸에 든 값 */ -export function nowInputs(values: Record): HTMLElement { - const filled = Object.entries(values).filter(([, v]) => String(v ?? "").trim() !== ""); - const text = filled.length ? filled.map(([k, v]) => `${k} ${v}`).join(" · ") : tl("Now_None"); - return el("p", { - className: "ui-text ui-text--muted m01lab__now", - attrs: { "data-now": "" }, - text: `${tl("Now_Inputs")} · ${text}`, - }); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index a91dc98b7..bdaec0f98 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -1,195 +1,20 @@ /* ============================================================================= * M02_DF_Api.ts - * 단가산출 흐름도 Api — 입력 값 한 번에 받기 · 자재 찾기 · 다른 로직 값 · 설계 때 정할 것 · - * 단위 · 금액 표준 표(흐름 저장은 로직 줄 「흐름」 칸 · M01 `/save`). 모양 = sub7 Model · 아래는 Api 몫만. - * 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` + * 단위 · 금액 표준 표 읽기 — 서버 `GET /api/m02/df/standards`(`M02_DF_Router.py`) + * 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { CfDesign, CfElementRef, CfGroup, CfLogic, CfUserElement } from "./M02_DF_Model"; -/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */ -export type CfField = "노무비" | "재료비" | "경비" | "계"; - -/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `UE-…` 는 다이어그램 안 · `_data`)에서 - * · 인력 · 재료 ref = 키 → 그 줄 단가(기존 계산과 같은 셈) · 재료 ref = 검색어 묶음 JSON 글도 됨(unit 을 주면 - * 기존 로직 재료 줄과 같은 단가) - * · 유가전력 · 환율 = 그 줄 값 · 요율 = 값 묶음의 field(기본 rate_percent · %) - * · 기계 ref = EQ 키 + field(값 칸 — 가격 · 손료계수 …) 또는 `로직(GC…, 기계='…', 지역=지역)` + inputs → 한 시간 값 - * · 소요량 · 계수 table = {key, row?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」) - * · 재료 자리 slot = 요소 그대로(mode fixed · choice · rule · scope · default) + field · op(조건 찾기 규칙) · - * value = 이어진 알약 값(조건 찾기) · pick = 고른 품목 키(선택) → 단가 · 여럿이면 candidates */ -export interface CfQuery { - id: string; - group: CfGroup; - ref?: string; - table?: { key: string; row?: number }; - unit?: string; - field?: string; - inputs?: Record; - slot?: Partial & { - field?: "규격" | "이름" | "키" | "검색어"; - op?: "포함" | "같음"; - }; - value?: unknown; - pick?: string; - /** 다른 로직 입력 상자 — 서버가 그 로직을 받은 설계 값으로 셈(/logic-value · 화면 queriesOf 가 만듦 · /values 로는 안 감) */ - logic?: { key: string; designs: Record }; -} - -/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ -export interface CfAnswer { - id: string; - ok: boolean; - value?: number | string | null; - rows?: (string | number | null)[][]; - name?: string; - unit?: string; - reason?: string; - /** 빈 단가 · 맞는 재료 없음 — 옛 셈처럼 그 값만 비우고 셈은 계속(흐름은 「#N/A」) */ - blank?: boolean; - /** 재료 조건 찾기가 여럿 맞을 때 후보 다섯 */ - candidates?: CfMaterial[]; -} - -/** 자재 찾기 한 줄 */ -export interface CfMaterial { - ref: string; - name: string; - spec: string; - unit: string; - price: number | null; - 구분: string; - 상세구분: string; -} - -/** 단위 · 금액 표준 표 — 고친 것(`_data`)이 있으면 saved */ +/** 단위 · 금액 표준 표 */ export interface CfStandards { unit: { 이름: string; 줄: Record[]; [k: string]: unknown }; money: { 이름: string; 줄: Record[]; [k: string]: unknown }; saved: boolean; } -/** 서버 계산 결과 — 저장 답(화면 Eval 과 같은 차례 · 같은 규칙 · arrived = 상자마다 받은 값 · breaks = 끊긴 곳) */ -export interface CfComputed { - arrived?: Record>; - breaks?: { kind: string; box: string; port?: string; link?: string; text: string }[]; - /** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */ - ports: Record>; - /** 출력 상자 id → 값 */ - outputs: Record; - cost: { - 노무비: number; - 재료비: number; - 경비: number; - 값들: Record; - 계: number; - }; - /** 쉬운 말 */ - errors: string[]; +export async function cfStandards(): Promise { + const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" }); + if (!response.ok) throw new Error(`표준 표 읽기 실패 (${response.status})`); + return (await response.json()) as CfStandards; } - -/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */ -export class CfError extends Error { - constructor( - public status: number, - public detail: unknown, - ) { - super(typeof detail === "string" ? detail : JSON.stringify(detail)); - } -} - -async function send(path: string, body?: unknown, method?: string): Promise { - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/m02/df${path}`, { - method: method ?? (body === undefined ? "GET" : "POST"), - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch { - throw new CfError(0, ""); - } - const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText); - return data; -} - -/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 · - * userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */ -export async function cfValues( - items: CfQuery[], - userElements: CfUserElement[] = [], -): Promise { - return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values; -} - -const PLAIN: Record = { - 0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", - 400: "넣은 값의 모양이 맞지 않음", - 403: "견본은 바꾸거나 지울 수 없음 — 저장하면 새 다이어그램으로 남음", - 404: "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요", - 503: "엑셀 계산을 서버에서 못 함 — 잠시 뒤 다시 해 주세요", -}; - -/** 실패 → 쉬운 말(키 · 날 글이 섞인 서버 글은 뭉뚱그림) */ -export function cfPlain(e: unknown): string { - if (!(e instanceof CfError)) return e instanceof Error ? e.message : PLAIN[0]; - const text = typeof e.detail === "string" ? e.detail : ""; - if (e.status === 400 && text && !/[A-Z]{2}\d{6}|[{}]/.test(text)) return text; - return PLAIN[e.status] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요"; -} - -/** 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 규격 · 키 · 원문번호) · 쪽 넘기기 */ -export async function cfMaterials(q: { - q?: string; - sub?: string; - detail?: string; - page?: number; - size?: number; -}): Promise<{ total: number; page: number; size: number; items: CfMaterial[] }> { - const qs = new URLSearchParams( - Object.entries(q).flatMap(([k, v]) => (v === undefined || v === "" ? [] : [[k, String(v)]])), - ); - return send(`/materials?${qs.toString()}`); -} - -/** 다른 로직 값 — 저장 로직 · 견본을 받은 설계 값(없으면 예시값)으로 서버가 셈 · 출력 이름 → 값 */ -export async function cfLogicValue( - key: string, - designs: Record = {}, -): Promise<{ outputs: Record; cost: CfComputed["cost"]; errors: string[] }> { - return send("/logic-value", { key, designs }); -} - -/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true · - * notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */ -export async function cfLogicFlow( - file: string, - key: string, -): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, { - credentials: "include", - }); - } catch { - throw new CfError(0, ""); - } - const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; - if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText); - return data as { flow: CfLogic; converted: boolean; notes: string[] }; -} - -/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */ -export async function cfDesignInputs(key: string): Promise { - const qs = new URLSearchParams({ key }).toString(); - return (await send<{ designs: CfDesign[] }>(`/design-inputs?${qs}`)).designs; -} - -/** 단위 · 금액 표준 표 읽기 · 고치기(시스템 관리자) · 처음 값으로 */ -export const cfStandards = (): Promise => send("/standards"); -export const cfStandardsSave = (body: Omit): Promise => - send("/standards", body, "PUT"); -export const cfStandardsReset = (): Promise => send("/standards", undefined, "DELETE"); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts deleted file mode 100644 index 685a11e39..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* ============================================================================= - * M02_DF_CellTable.ts - * 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기 - * 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import type { CfBox, CfLogic } from "./M02_DF_Model"; -import { portText } from "./M02_DF_Detail"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_table.css"; - -const TEXT = { - Title: ["칸 연결 표", "Cell connections"], - Cell: ["칸", "Cell"], - Pill: ["연결 알약", "Pill"], - Kind: ["종류", "Kind"], - Line: ["이어진 선", "Line"], - In: ["입력", "Input"], - Var: ["변수", "Variable"], - Out: ["결과", "Result"], - None: ["아직 연결된 칸이 없음", "No cell is connected yet"], - Unplaced: ["칸 없음", "no cell"], - NoLine: ["-", "-"], - Release: ["풀기", "Release"], - BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], -} as const; - -const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -export type CellKind = "in" | "var" | "out"; - -export interface CellTableHandlers { - /** 표에서 칸을 바꿈 — key = 알약 이름 */ - move(kind: CellKind, key: string, cell: string): void; - /** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */ - release(kind: CellKind, key: string): void; -} - -interface Row { - kind: CellKind; - key: string; - cell: string; - line: string; -} - -function rowsOf(lg: CfLogic, box: CfBox): Row[] { - const f = box.func; - if (!f) return []; - const into = (port: string): string => { - const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port); - return l ? portText(lg, l.from) : tt("NoLine"); - }; - const out = (): string => { - const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name); - return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine"); - }; - return [ - ...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })), - ...f.variables.map((v): Row => ({ - kind: "var", - key: v.name, - cell: v.cell ?? "", - line: tt("NoLine"), - })), - { kind: "out", key: f.output.name, cell: f.output.cell, line: out() }, - ]; -} - -/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */ -export function renderCellTable( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - readOnly: boolean, - handlers: CellTableHandlers, -): void { - host.classList.add("m02-dfcells"); - const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key); - const head = el("tr", { - children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) => - el("th", { text: t }), - ), - }); - const body = rows.map((r) => { - const input = el("input", { - className: "ui-input ui-input--sm m02-dfcells__cell", - attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") }, - }); - input.value = r.cell; - input.readOnly = readOnly; - input.addEventListener("change", () => { - const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim()); - input.classList.toggle("is-bad", !m); - input.title = m ? "" : tt("BadCell"); - if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`); - }); - return el("tr", { - className: `m02-dfcells__row is-${r.kind}`, - children: [ - el("td", { children: [input] }), - el("td", { text: r.key }), - el("td", { - children: [ - el("span", { - className: `ui-badge ui-badge--pill ui-badge--${r.kind === "in" ? "info" : r.kind === "var" ? "warning" : "success"} m02-dfcells__kind`, - attrs: { "data-size": "20" }, - text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"), - }), - ], - }), - el("td", { text: r.line }), - el("td", { - children: - readOnly || (r.kind !== "var" && !r.cell) - ? [] - : [ - createButton({ - label: tt("Release"), - variant: "ghost", - size: "sm", - onClick: () => handlers.release(r.kind, r.key), - }), - ], - }), - ], - }); - }); - host.replaceChildren( - el("strong", { className: "m02-dfcells__title", text: tt("Title") }), - el("table", { - className: "ui-table ui-table--data m02-dfcells__table", - children: [el("thead", { children: [head] }), el("tbody", { children: body })], - }), - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py deleted file mode 100644 index affad5786..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ /dev/null @@ -1,624 +0,0 @@ -"""단가산출 흐름도 서버 계산(PLAN 35-9 · 정함 4 · 5 · 6 · 7 · 12) — 저장 다시 셈 · 다른 로직 값. - -모양 = sub7 Model `M02_DF_Model.ts`(CfLogic · CfLink.rule · CfElementRef.mode · CfInput.logic · -CfRound). 화면 계산 `M02_DF_Eval.evalLogic` 과 같은 차례 · 같은 뜻으로 셈(짝 ① — 화면은 TS · 서버는 -이 파일 · 함수 시트는 같은 엔진(Node · `common_util_spreadsheet`)): -· 상자 차례 = 선 · 선 규칙 조건 알약 · 재료 조건 찾기 알약에 기댐(Model `depends`) — 서로 안 기대는 - 함수 상자들은 한 번에 Node 로 셈(시트마다 통합문서 하나) -· 입력 상자 알약 = 요소(재료는 고정 · 선택 · 조건 찾기 — 알약 이름 = `elementPort`) · 표 · 변수 · - 다른 로직 출력 — 단가는 마스터에서 새로(`M02_DF_Values`) · 설계 값 values = {상자: {알약: 값}} -· 선 규칙 = `M02_DF_Rule.applyRule` 과 같은 뜻(값 기준) — 막히면(통과 안 됨 · 표에 없는 조건 · - 여럿 · - 오류) 받는 함수는 셈하지 않고 빈 값 · 끝수 = 엔진 ROUND · ROUNDUP · ROUNDDOWN 과 같음 -· 다른 로직 = 저장 로직 · 견본을 받은 설계 값(선 · 검증 칸 · 없으면 그 로직 예시값)으로 셈 · - 서로 부르면 막음 -마스터 파일은 읽기만. -""" - -from __future__ import annotations - -import copy -import re -from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal -from typing import Any, Callable - -from common_util import common_util_spreadsheet as spreadsheet -from M01_MasterData import ( - M01_MasterData_Router as m01, -) # _num — M01 Router 가 이 파일을 부름(서로 부름) -from M01_MasterData import M01_MasterData_Store as store -from M02_MasterTemplete.DataFlow import M02_DF_Values as dv -from M02_MasterTemplete.DataFlow.M02_DF_Lookup import ( # noqa: F401 — 시험이 cp._lookup 을 부름 - RANGE_RULE, - _cond_of, - _exact, - _is_err, - _lookup, - _num_of, - _same, -) - -_CELL = re.compile(r"^([A-Z]+)(\d+)$") -TABLE_PORT, OUTPUT_PORT = "표", "값" -COSTS = ("노무비", "재료비", "경비") -GATE = {"=": "=", "≠": "<>", "≥": ">=", "≤": "<=", ">": ">", "<": "<"} -ROUND = {"반올림": "ROUND", "올림": "ROUNDUP", "버림": "ROUNDDOWN"} -HELP = "_df_" # 보조 시트 이름 머리(표 블록 · 끝수) -FAR = Decimal("1E+300") # 범위 칸 열린 끝 — 엑셀 비교가 늘 참이 되게 -Loader = Callable[[str], dict] - - -def _col(letters: str) -> int: - n = 0 - for ch in letters: - n = n * 26 + ord(ch) - 64 - return n - - -def _letters(n: int) -> str: - out = "" - while n: - n, rest = divmod(n - 1, 26) - out = chr(65 + rest) + out - return out - - -def _at(cell: str) -> tuple[int, int, str]: - m = _CELL.match(str(cell or "").strip().upper()) - if not m: - raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」") - return _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}" - - -def _style(cells: dict, cell: str) -> dict: - return {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {} - - -def _put(cells: dict, cell: str, value: Any) -> None: - """칸 하나 — 값 · 수식 `{"식"}` · 빈 값은 값만 뺌(서식은 둠).""" - keep = _style(cells, cell) - if isinstance(value, dict) and "식" in value: - cells[cell] = {"식": value["식"], **keep} - elif value is None or value == "": - cells.pop(cell, None) if not keep else cells.__setitem__(cell, keep) - else: - cells[cell] = {"값": value, **keep} - - -def _place(cells: dict, cell: str, value: Any) -> None: - """받는 칸에 값 · 수식 · 표 블록(cell 이 왼위).""" - col, row, at = _at(cell) - if isinstance(value, list): - for dy, line in enumerate(value): - for dx, one in enumerate(line): - _put(cells, f"{_letters(col + dx)}{row + dy}", one) - else: - _put(cells, at, value) - - -def _plain(v: Any) -> Any: - """엔진 계산값 한 칸 → 수 · 글 · 참거짓 · 빈 값(오류는 None).""" - if not isinstance(v, dict): - return v - if "수" in v: - return Decimal(str(v["수"])) - return v.get("글", v.get("참")) - - -def _literal(v: Any) -> str: - """수식 안 값 글 — 수는 그대로 · 글은 따옴표 · 빈 값은 빈 글.""" - if v is None: - return '""' - if isinstance(v, bool): - return "TRUE" if v else "FALSE" - if isinstance(v, (int, float, Decimal)): - return str(v) - return '"' + str(v).replace('"', '""') + '"' - - -# ── 입력 상자 ────────────────────────────────────────────────────────── -def _element_value( - group: str, el: dict, given: Any, ports: dict, errors: list, box: dict, ctx: dict -) -> Any: - """요소 알약 값 — 재료는 방식 셋 · 그 밖은 그룹 값 · 설계 값이 이김(선택 자리 = 키 · 수).""" - mode = el.get("mode") or "fixed" - env = ctx.get("env") or {} # 로직 입력(자재지역 · 계약종별 …) — 옛 셈 재료 찾기가 읽음 - if group == "재료" and mode == "choice": - if isinstance(given, (int, float, Decimal)) and not isinstance(given, bool): - return given - slot = {"mode": "choice", "default": el.get("default"), "inputs": env} - got = dv.slot_price(slot, pick=given or None) - elif group == "재료" and mode == "rule": - rule = el.get("rule") or {} - src = rule.get("from") or {} - slot = { - "mode": "rule", - "scope": el.get("scope"), - "field": rule.get("field"), - "op": rule.get("op"), - "ref": el.get("ref"), - "unit": el.get("unit"), - "inputs": env, - } - value = ports.get(src.get("box"), {}).get(src.get("name")) - if rule.get("field") == "검색어": # 틀의 자리마다 알약 값 → 채운 검색어 글(빈 자리면 None) - value, slot["unit"] = _fill_template(rule, ports), el.get("unit") - got = dv.slot_price(slot, value) - if not got["ok"] and not got.get("blank") and el.get("default"): - errors.append( - f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈" - ) - got = dv.slot_price({"mode": "fixed", "ref": el["default"], "inputs": env}) - elif group in dv.GROUP_RULE and mode == "rule": # 인력 · 기계 · 유가전력 조건 찾기 - rule = el.get("rule") or {} - src = rule.get("from") or {} - slot = {"field": rule.get("field"), "op": rule.get("op"), "scope": el.get("scope"), - "ref": el.get("ref"), "unit": el.get("unit")} # fmt: skip - got = dv.group_find(group, slot, ports.get(src.get("box"), {}).get(src.get("name"))) - if not got["ok"] and not got.get("blank") and el.get("default"): - errors.append(f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본으로 셈") - got = dv.one_value({"group": group, "ref": el["default"], "unit": el.get("unit")}) - elif given is not None: - return given - else: - item = {"group": group, "ref": el.get("ref"), "unit": el.get("unit"), "inputs": env} - got = dv.one_value(item, ctx["local"]) - if not got["ok"] and got.get("blank"): # 옛 셈처럼 그 값만 비움(알림) — 셈은 계속 - ctx["notes"].append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}") - return NA_VALUE # 그 값을 쓰는 식은 빈 값(옛 엔진) — 비목 합은 0 - if not got["ok"]: - errors.append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}") - return None - return got.get("value") - - -def _fill_template(rule: dict, ports: dict) -> str | None: - """재료 검색어 틀의 자리에 slots 알약 값 — 옛 엔진 fill 그대로(빈 자리 = None).""" - env = {} - for x in rule.get("slots") or []: - src = x.get("from") or {} - env[x.get("name")] = ports.get(src.get("box"), {}).get(src.get("name")) - if any(v is None for v in env.values()): - return None - try: - return store.cm.mf.fill(str(rule.get("template") or ""), env) - except store.cm.mf.FormulaError: - return None - - -def _port(el: dict) -> str: - """요소 알약 이름 — Model `elementPort`(고정 아니면 자리 이름 slot · 없으면 name).""" - slot = str(el.get("slot") or "").strip() - return slot if el.get("mode") not in (None, "fixed") and slot else el.get("name") - - -def _input_ports(lg: dict, box: dict, values: dict, ports: dict, errors: list, ctx: dict) -> dict: - inp, given, out = box.get("input") or {}, values.get(box["id"]) or {}, {} - mode = inp.get("mode") - if mode == "logic": - return _logic_ports(lg, box, given, ports, errors, ctx) - if mode == "group": - group = inp.get("group") - for el in inp.get("elements") or []: - out[_port(el)] = _element_value( - group, el, given.get(_port(el)), ports, errors, box, ctx - ) - if inp.get("table"): - got = dv.one_value({"group": group, "table": inp["table"]}, ctx["local"]) - out[TABLE_PORT] = got.get("rows") if got["ok"] else None - if not got["ok"]: - errors.append(f"「{box.get('name')} · 표」 {got.get('reason')}") - for v in inp.get("variables") or []: - out[v.get("name")] = given.get(v.get("name"), v.get("example")) - return out - - -def _logic_ports(lg: dict, box: dict, given: dict, ports: dict, errors: list, ctx: dict) -> dict: - """다른 로직 — 받는 알약(설계 값) = 선 · 검증 칸 값 · 없으면 그 로직 예시값 → 그 로직 출력.""" - ref = (box.get("input") or {}).get("logic") or {} - key = str(ref.get("key") or "") - if key in ctx["stack"]: - errors.append( - f"「{box.get('name')}」 로직이 서로를 부름 — {' → '.join([*ctx['stack'], key])}" - ) - return {} - try: - other = ctx["load"](key) - except store.StoreError as e: - errors.append(f"「{box.get('name')}」 {e.detail}") - return {} - designs = {**given} - for link in lg.get("links") or []: - if link.get("to", {}).get("box") == box["id"]: - src = link.get("from") or {} - designs[link["to"].get("name")] = ports.get(src.get("box"), {}).get(src.get("name")) - if callable( - other.get("calc") - ): # 옛 마스터 로직(흐름 없음) — 옛 셈 · 노무비 · 재료비 · 경비 · 계 - result = other["calc"](designs) - errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]] - return result["outputs"] - stack = [*ctx["stack"], key] - result = run(other, _design_values(other, designs), ctx["load"], ctx["local"], stack) - errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]] - names = {b["id"]: b.get("name") for b in other.get("boxes") or []} - return {names[i]: v for i, v in result["outputs"].items() if i in names} - - -def _design_values(lg: dict, designs: dict) -> dict: - """다른 로직의 설계 값 {알약 이름: 값} → 그 로직의 values {상자 id: {알약: 값}}.""" - out: dict[str, dict] = {} - for d in design_inputs(lg): - if d["port"] in designs and designs[d["port"]] is not None: - out.setdefault(d["box"], {})[d["port"]] = designs[d["port"]] - return out - - -# ── 설계 때 정할 것 ──────────────────────────────────────────────────── -def design_inputs(lg: dict) -> list[dict]: - """변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) — Model `CfDesign` 꼴.""" - links = lg.get("links") or [] - fed = { - (ln.get("to") or {}).get("box", "") + "\0" + (ln.get("to") or {}).get("name", "") - for ln in links - } - out = [] - for box in lg.get("boxes") or []: - inp = box.get("input") or {} - if box.get("kind") != "input": - for v in (box.get("func") or {}).get("variables") or []: - out.append( - { - "box": box["id"], - "port": v.get("name"), - "kind": "변수", - "unit": v.get("unit") or "-", - "variable": v, - } - ) - continue - for v in inp.get("variables") or []: - out.append( - { - "box": box["id"], - "port": v.get("name"), - "kind": "변수", - "unit": v.get("unit") or "-", - "variable": v, - } - ) - for el in inp.get("elements") or [] if inp.get("group") == "재료" else []: - if el.get("mode") == "choice": - out.append( - { - "box": box["id"], - "port": _port(el), - "kind": "재료 자리", - "unit": el.get("unit") or "-", - "element": el, - } - ) - for pill in ( - ((inp.get("logic") or {}).get("designs") or []) if inp.get("mode") == "logic" else [] - ): - if box["id"] + "\0" + pill.get("name", "") not in fed: - out.append( - { - "box": box["id"], - "port": pill.get("name"), - "kind": "다른 로직", - "unit": pill.get("unit") or "-", - } - ) - return out - - -# ── 선 규칙(`M02_DF_Rule.applyRule` 와 같은 뜻 · 값 기준) ──────────────────── -def _compare(a: Any, op: str, b: Any) -> bool: - if op in ("=", "≠"): - return _same(a, b) == (op == "=") - na, nb = _num_of(a if a is not None else 0), _num_of(b) - if na is None or nb is None: - sa, sb = str(a or "").lower(), str(b or "").lower() - na, nb = (sa, sb) if na is None and nb is None else (None, None) - if na is None: - return False - return {"≥": na >= nb, "≤": na <= nb, ">": na > nb, "<": na < nb}.get(op, False) - - -def _rounded(value: Any, r: dict) -> Any: - """끝수 — 엔진 ROUND(반올림 · 0 에서 먼 쪽) · ROUNDUP · ROUNDDOWN · 자리는 음수도.""" - n = _num_of(value) - if n is None: - return value - mode = {"반올림": ROUND_HALF_UP, "올림": ROUND_UP, "버림": ROUND_DOWN}.get( - r.get("method"), ROUND_HALF_UP - ) - places = int(r.get("places") or 0) - return ( - n.quantize(Decimal(1).scaleb(-places), rounding=mode) - if places >= 0 - else ((n.scaleb(places).quantize(Decimal(1), rounding=mode)).scaleb(-places)) - ) - - -def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]: - """(값, 까닭) — 까닭 = 통과 안 됨 · 표에 없는 조건 · 표에 여럿 맞음 · 오류 · None.""" - kind = rule.get("kind") - if kind == "lookup": - return _lookup(rule, value, conds) - if kind == "gate": - rows, join = _gate_conds(rule) - hits = [ - _compare(_cond_of(conds, c.get("from") or {}), c.get("op"), c.get("value")) - for c in rows - ] - ok = any(hits) if join == "또는" else all(hits) - return (value, None) if ok else (None, "통과 안 됨") - n = _num_of(value) - if n is None: - return (None, None) if value is None else (None, "오류") - if kind == "round": - return _rounded(n, rule), None - v = Decimal(str(rule.get("value"))) - op = rule.get("op") - if op == "÷" and v == 0: - return None, "오류" - return {"×": n * v, "÷": n / v, "+%": n * (1 + v / 100), "-%": n * (1 - v / 100)}.get( - op, n - ), None - - -def _sources(rule: dict | None) -> list[dict]: - if not rule: - return [] - if rule.get("kind") == "lookup": - return [c.get("from") or {} for c in rule.get("conds") or []] - if rule.get("kind") == "gate": - return [c.get("from") or {} for c in _gate_conds(rule)[0]] - return [] - - -def _gate_conds(rule: dict) -> tuple[list[dict], str]: - """조건 통과 줄 · 잇는 말 — conds · join · 옛 한 줄 꼴은 줄 하나(Model gateConds).""" - rows = rule.get("conds") or [ - {"from": rule.get("from") or {}, "op": rule.get("op"), "value": rule.get("value")} - ] - return rows, rule.get("join") or "그리고" - - -# ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ───────────────────────────── -def _depends(lg: dict) -> list[tuple[str, str]]: - """상자 사이 기댐 — 선 + 선 규칙 조건 알약 + 재료 조건 찾기 알약(Model `depends`).""" - out = [] - for ln in lg.get("links") or []: - out.append((ln["from"].get("box"), ln["to"].get("box"))) - out += [(p.get("box"), ln["to"].get("box")) for p in _sources(ln.get("rule"))] - for b in lg.get("boxes") or []: - for el in (b.get("input") or {}).get("elements") or []: - if el.get("mode") == "rule" and el.get("rule"): - rule = el["rule"] - srcs = [ - rule.get("from") or {}, - *[x.get("from") or {} for x in rule.get("slots") or []], - ] - out += [(p.get("box"), b["id"]) for p in srcs] - return out - - -def _waves(lg: dict) -> tuple[list[list[dict]], list[dict]]: - """차례 물결 — 한 물결 안 상자는 서로 안 기댐(함수 시트를 한 번에 셈) · 돌고 도는 것은 따로.""" - deps, boxes = _depends(lg), lg.get("boxes") or [] - left, waves = {b["id"] for b in boxes}, [] - while left: - ready = [ - b - for b in boxes - if b["id"] in left and not any(t == b["id"] and f != t and f in left for f, t in deps) - ] - if not ready: - break - waves.append(ready) - left -= {b["id"] for b in ready} - return waves, [b for b in boxes if b["id"] in left] - - -def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: - """함수 상자 시트 — 받는 칸 · 변수 칸에 값(Eval `runFunc` 와 같음).""" - func = box["func"] - sheet = copy.deepcopy(func.get("sheet") or {}) - cells = sheet.setdefault("칸", {}) - for p in func.get("inputs") or []: - if str(p.get("cell") or "").strip(): - port = p.get("port") - v = arrived[port] if arrived.get(port) is not None else given.get(port) - _place(cells, p["cell"], {"식": "NA()"} if _is_err(v) else v) # 오류 값 · 빈 단가 - for v in func.get("variables") or []: - if v.get("cell"): - _place(cells, v["cell"], given.get(v.get("name"), v.get("example"))) - return sheet - - -REST = "앞 조건이 안 맞아 쉬는 갈래" -NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음) - - -def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | None = None) -> dict: - """셈 — {ports, outputs, cost, errors, breaks, filled}(filled = 받는 칸에 값만 되돌려 넣음).""" - ctx = {"load": load, "local": local, "stack": stack or [lg.get("key") or ""], "notes": [], - "env": _env(lg, values)} # fmt: skip - boxes = {b["id"]: b for b in lg.get("boxes") or []} - links = lg.get("links") or [] - name = lambda i: boxes.get(i, {}).get("name") or i # noqa: E731 - errors, breaks, ports, arrived, outputs = [], [], {}, {}, {} - blocked: set[str] = set() - resting: set[str] = set() # 조건이 안 맞아 쉬는 상자 — Eval 과 같은 뜻 - late: dict[str, list[dict]] = {} # 함수로 가는 표 찾기 실패 — 결과가 오류일 때만 끊긴 곳 - cost: dict[str, Any] = {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}} - - def through(ln: dict) -> Any: - src = ln.get("from") or {} - v = ports.get(src.get("box"), {}).get(src.get("name")) - rule = ln.get("rule") - if ( - v == NA_VALUE - and (boxes.get(src.get("box"), {}).get("input") or {}).get("mode") == "group" - ): - to = ln["to"] # 빈 단가 — 결과가 오류일 때만 끊긴 곳(Eval NoPrice 와 같음) - text = f"「{name(to.get('box'))}」 — 단가 없는 품목을 씀(「{src.get('name')}」)" - brk = {"kind": "단가 없는 품목", "level": "문제", "box": to.get("box")} - brk |= {"port": to.get("name"), "link": ln.get("id"), "text": text} - late.setdefault(to.get("box"), []).append(brk) - if not rule: - return v - if v is None and rule.get("kind") in ("scale", "round"): - return None - conds = {} - for p in _sources(rule): - got_v = ports.get(p.get("box"), {}).get(p.get("name")) - conds[p.get("name")] = got_v - conds[f"{p.get('box')}|{p.get('name')}"] = got_v - got, why = apply_rule(rule, v, conds) - if why: - text = f"「{name(src.get('box'))}」 → 「{name(ln['to'].get('box'))}」 {why}" - kind = "오류" if why in ("표에 여럿 맞음", "오류") else why - to = ln["to"] - brk = { - "kind": kind, - # 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음) - "level": "쉼" if kind == "통과 안 됨" else "문제", - "box": to.get("box"), - "port": to.get("name"), - "link": ln.get("id"), - "text": text, - } - if ( - rule.get("kind") == "lookup" - and boxes.get(to.get("box"), {}).get("kind") == "function" - ): - late.setdefault(to.get("box"), []).append(brk) - return NA_VALUE - blocked.add(to.get("box")) - if why == "통과 안 됨": - resting.add(to.get("box")) - breaks.append(brk) - if why != "통과 안 됨": - errors.append(text) - return None if why else got - - def arrive(box: dict) -> dict: - """받는 값 — 쉬는 갈래 빈 값은 「쉼」 줄만 · 선이 모두 쉬면 상자도 쉼(Eval 과 같음).""" - ins = [ln for ln in links if ln["to"].get("box") == box["id"]] - got, rest = {}, 0 - for ln in ins: - src, to = ln.get("from") or {}, ln["to"] - if ( - src.get("box") in resting - and ports.get(src.get("box"), {}).get(src.get("name")) is None - ): - rest += 1 - got[to.get("name")] = None - breaks.append( - { - "kind": "통과 안 됨", - "level": "쉼", - "box": to.get("box"), - "port": to.get("name"), - "link": ln.get("id"), - "text": f"「{name(src.get('box'))}」 → 「{name(to.get('box'))}」 — {REST}", - } - ) - continue - got[to.get("name")] = through(ln) - if ins and rest == len(ins): - resting.add(box["id"]) - blocked.add(box["id"]) - arrived[box["id"]] = got - return got - - waves, loop = _waves(lg) - errors += [f"「{b.get('name')}」 상자가 서로를 기다림(돌고 도는 선)" for b in loop] - sheets: dict[str, dict] = {} - for wave in waves: - funcs = [] - for b in wave: - given = values.get(b["id"]) or {} - if b.get("kind") == "input" and (b.get("input") or {}).get("mode") == "logic": - ports[b["id"]] = _logic_ports(lg, b, {**given, **arrive(b)}, ports, errors, ctx) - elif b.get("kind") == "input": - ports[b["id"]] = _input_ports(lg, b, values, ports, errors, ctx) - elif b.get("kind") == "function" and b.get("func"): - sheet = _sheet_of(b, arrive(b), given) - sheets[b["id"]] = sheet - if b["id"] in blocked: - ports[b["id"]] = {b["func"]["output"].get("name"): None} - else: - funcs.append(b) - elif b.get("kind") == "output": - outputs[b["id"]] = arrive(b).get(OUTPUT_PORT) - if funcs: # 이 물결의 함수 시트를 한 번에(시트마다 통합문서 하나 — Eval 과 같음) - books = [ - m01._num( - {"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]} - ) - for f in funcs - ] - try: - calcs = spreadsheet.recalc_workbooks(books) - except spreadsheet.RecalcError as e: - raise store.StoreError(503, str(e)) from e - for f, book, calc in zip(funcs, books, calcs): - sheet = book["시트"][0] - for cell, v in (calc.get(sheet.get("id")) or {}).items(): - if "오류" in v and (sheet.get("칸") or {}).get(cell, {}).get("식") != "NA()": - errors.append(f"「{f.get('name')}」 {cell} 칸 계산 오류 {v['오류']}") - out = f["func"].get("output") or {} - at = _at(out.get("cell") or "A1")[2] - hit = (calc.get(sheet.get("id")) or {}).get(at) - value = ( - hit["오류"] # 오류 값을 넘김(Eval fromCalc 와 같음 · 받는 칸은 그 글/NA()) - if isinstance(hit, dict) and "오류" in hit - else _plain(hit) - if hit is not None - else ((sheet.get("칸") or {}).get(at) or {}).get("값") - ) - if out.get("round") and _num_of(value) is not None: - value = _rounded(value, out["round"]) - ports[f["id"]] = {out.get("name"): value} - if value is None or (isinstance(hit, dict) and "오류" in hit): - for brk in late.get(f["id"], []): - breaks.append(brk) - errors.append(brk["text"]) - for b in boxes.values(): # 출력 상자 · 원가(Eval 과 같음) - if b.get("kind") != "output": - continue - value = outputs.get(b["id"]) - value = None if isinstance(value, list) else value - if (b.get("output") or {}).get("round") and _num_of(value) is not None: - value = _rounded(value, b["output"]["round"]) - outputs[b["id"]] = value - kind = (b.get("output") or {}).get("cost") or "값" - if kind == "값": - cost["값들"][b.get("name")] = value - elif _num_of(value) is not None: - cost[kind] += _num_of(value) - elif value is not None: - errors.append(f"「{b.get('name')}」 출력 값이 수가 아님") - cost["계"] = sum(cost[k] for k in COSTS) - filled = copy.deepcopy(lg) # 받는 칸 · 변수 칸에 값만 되돌려 넣음 - for b in filled.get("boxes") or []: - if b["id"] in sheets: - b["func"]["sheet"] = sheets[b["id"]] - return {"ports": ports, "arrived": arrived, "outputs": outputs, "cost": cost, "errors": errors, - "breaks": breaks, "notes": ctx["notes"], "filled": filled} # fmt: skip - - -def _env(lg: dict, values: dict) -> dict: - """입력 상자 변수 값(설계 값 · 없으면 예시값) — 재료 찾기가 읽는 로직 입력.""" - return { - v.get("name"): (values.get(b["id"]) or {}).get(v.get("name"), v.get("example")) - for b in lg.get("boxes") or [] - if b.get("kind") == "input" - for v in (b.get("input") or {}).get("variables") or [] - if v.get("name") - } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py deleted file mode 100644 index e8e4e6199..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py +++ /dev/null @@ -1,431 +0,0 @@ -"""M02 단가산출 흐름도 — 옛 로직 줄 → 흐름도(CfLogic) 변환 (PLAN 36-2 · 흐름도_적용_정함 2 · 3). - -옛 식은 지금 서버 파서(`master_formula.parse` · `store.cm.mf`)로 나무를 읽어 함수 상자 시트로 -· 이름마다 받는 알약 → 칸(B2 · B3 …) · 식은 칸 주소 · 함수 상자 하나 = 결과 하나. - - 입력 → 입력 상자 「설계 값」(변수 · 고르기 = choice · 범위) - 중간 · 호표 수량 × 단가 · 덧줄 · 결과 → 함수 상자 · 비목 합 → 출력 상자(비목 셋 · 결과 = 값) - 호표 단가 · 식 속 요소 키 → 그룹 입력 상자(인력 · 재료 · 기계 · 유가전력 · 환율 · 요율) 요소 알약 - · 키에 {입력} 이 끼면 조건 찾기(field 「키」 · 같음 · from = 입력 알약 · 자리 여럿 = 글 잇는 함수) - 찾기(표, 조건=값).칸 → 표 입력 상자 「표」 알약 + 선 규칙 「표에서 찾기」(lookup) - 로직(키, 넘길 값=…) → 다른 로직 입력 상자(받는 알약 = 넘길 값 · 내보내는 알약 = 비목 · 결과) - 만약 → IF · 그리고 / 또는 → AND / OR · 최소 / 최대 → MIN / MAX - · 올림 / 버림 / 반올림 → ROUNDUP / ROUNDDOWN / ROUND - 자동 배치 — 입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위에서 아래로 - -부르는 꼴 — convert(row, ctx={key, file, master}) → {flow, notes} · row = 옛 로직 줄 · 파일 안 씀. -""" - -from __future__ import annotations - -import json -import re -from decimal import Decimal -from typing import Any - -from M01_MasterData import M01_MasterData_Store as store - -mf = store.cm.mf -COSTS = ("노무비", "재료비", "경비") -OUTPUT_PORT = "값" -TABLE_PORT = "표" -COLORS = {"input": "#2f80ed", "function": "#7b4fd6", "output": "#27ae60"} -GROUP_OF = {"LB": "인력", "MT": "재료", "MP": "재료", "MO": "유가전력", "EQ": "기계"} -GROUP_OF |= {"FX": "환율", "RT": "요율"} -FUNCS = {"만약": "IF", "최소": "MIN", "최대": "MAX", "올림": "ROUNDUP", "버림": "ROUNDDOWN"} -FUNCS["반올림"] = "ROUND" -OPS = {"==": "=", "!=": "<>"} -JOINS = {"그리고": "AND", "또는": "OR"} -_HOLE = re.compile(r"\{([^{}]+)\}") - - -class Unsupported(Exception): - """옮기지 못하는 꼴 — 쉬운 말 까닭.""" - - -def plain(v: Any) -> Any: - """JSON 값 — Decimal → int · float.""" - if isinstance(v, Decimal): - return int(v) if v == v.to_integral_value() else float(v) - if isinstance(v, (list, tuple)): - return [plain(x) for x in v] - if isinstance(v, dict): - return {k: plain(x) for k, x in v.items()} - return v - - -def literal(v: Any) -> str: - if isinstance(v, str): - return '"' + v.replace('"', '""') + '"' - if isinstance(v, Decimal): - return format(v.normalize(), "f") - return str(plain(v)) - - -class _Flow: - """흐름 한 벌 — 상자 · 선 · 못 옮긴 곳.""" - - def __init__(self, row: dict, master: Any): - self.row = row - self.master = master - self.specs = {s["이름"]: s for s in row.get("입력") or []} - self.boxes: dict[str, dict] = {} - self.links: list[dict] = [] - self.notes: list[str] = [] - self.named: dict[str, dict] = {} # 중간 · 덧줄 이름 → 내보내는 알약 - self.cache: dict[str, dict] = {} # 같은 요소 · 표 · 로직 · 값 → 알약(하나만) - - # ── 상자 · 선 ───────────────────────────────────────────────────── - def box(self, kind: str, name: str, **body: Any) -> dict: - bid = f"{kind[:2]}-{len(self.boxes) + 1}" - self.boxes[bid] = {"id": bid, "kind": kind, "name": name, "color": COLORS[kind]} - self.boxes[bid] |= {"unit": "-", "x": 0, "y": 0, **body} - return self.boxes[bid] - - def link(self, src: dict, to: dict, rule: dict | None = None) -> None: - one = {"id": f"l{len(self.links) + 1}", "from": dict(src), "to": dict(to)} - self.links.append(one | ({"rule": rule} if rule else {})) - - def design(self) -> None: - """설계 값 상자 — 예시값 = 줄의 시험입력 · 없으면 왼쪽 시험 계산 기본값(master_auto).""" - given = store.mau.values(self.master, self.row) - given.update(self.row.get("시험입력") or {}) - for ( - k, - v, - ) in given.items(): # 글로 적힌 수 → 수(고르기 값은 그대로 · 옛 엔진 _inputs 와 같음) - spec = self.specs.get(k) or {} - if isinstance(v, str) and "고르기" not in spec: - try: - given[k] = Decimal(v.strip()) - except ArithmeticError: - pass - variables = [] - for s in self.row.get("입력") or []: - pick = s.get("고르기") - one = {"name": s["이름"], "type": "choice" if pick else "number"} - one |= {"unit": str(s.get("단위") or "-"), "example": plain(given.get(s["이름"]))} - if pick: - one["choices"] = plain(pick) - if "범위" in s: - one["range"] = plain(s["범위"]) - variables.append(one) - box = {"id": "in-design", "kind": "input", "name": "설계 값", "color": COLORS["input"]} - inp = {"mode": "variable", "elements": [], "variables": variables} - self.boxes["in-design"] = box | {"unit": "-", "x": 0, "y": 0, "input": inp} - - # ── 바깥 값 알약 ────────────────────────────────────────────────── - def element( - self, group: str, ref: str, unit: str, label: str, rule: dict | None = None, **more: Any - ) -> dict: - """그룹 입력 상자의 요소 알약 — 그룹마다 상자 하나 · 같은 ref · 단위 · 조건이면 알약 하나 - · rule = 조건 찾기(CfMaterialRule · 알약 값은 Model depends 로 · 선 없음) · more = scope.""" - key = f"el|{group}|{ref}|{unit}|{json.dumps(rule, ensure_ascii=False, sort_keys=True)}" - if key in self.cache: - return self.cache[key] - gkey = f"group|{group}" - if gkey not in self.cache: - inp = {"mode": "group", "group": group, "elements": [], "variables": []} - self.cache[gkey] = self.box("input", group, input=inp) - els = self.cache[gkey]["input"]["elements"] - name = label - while any(e["name"] == name or e.get("slot") == name for e in els): - name += "·" - one = {"ref": ref, "name": name, "spec": "", "unit": unit or "-", "mode": "fixed"} - if rule: - one |= {"mode": "rule", "rule": rule, "slot": name, **more} - els.append(one) - self.cache[key] = {"box": self.cache[gkey]["id"], "name": name} - return self.cache[key] - - def priced(self, group: str, ref: Any, unit: str, label: str, field: str | None = None): - """요소 → 식 마디 「@port」 — 키에 {입력} 이 끼면 조건 찾기(field 키 · 같음 · from 알약) - · 자리 표시가 여럿이면 함수 상자가 글을 이어 만든 알약 하나로(정함 · 조사 ③).""" - if isinstance(ref, dict): - texts = {k: v for k, v in ref.items() if isinstance(v, str) and _HOLE.search(v)} - else: - texts = {None: ref} if _HOLE.search(ref) else {} - if not texts: - text = ref if isinstance(ref, str) else json.dumps(plain(ref), ensure_ascii=False) - return ("@port", self.element(group, self._key(text, field), unit, label)) - if len(texts) > 1: - raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」") - ((at, text),) = texts.items() - if at == "검색어": # 재료 검색어 틀 — 자리마다 알약 · 서버는 옛 검색어 찾기(정함 · sub4) - names = list(dict.fromkeys(_HOLE.findall(text))) - slots = [{"name": n, "from": self.hole(n)} for n in names] - rule = {"field": "검색어", "op": "같음", "from": slots[0]["from"]} - rule |= {"template": text, "slots": slots} - scope = {k: str(v) for k, v in ref.items() if k in ("구분", "상세구분") and v} - tpl = json.dumps(plain(ref), ensure_ascii=False) - return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule, scope=scope)) - # 찾는 값 = 원문번호(「EQ:{기계}」 → 기계 · 「MO:경유:{지역}」 → "경유:"&지역) - want = text.partition(":")[2] if at is None and ":" in text else text - if _HOLE.fullmatch(want): - src = self.hole(want[1:-1]) - else: - src = self.func(f"{label} 찾는 말", ("@text", want)) - tpl = text if at is None else json.dumps(plain(ref), ensure_ascii=False) - tpl += f"#{field}" if field else "" - rule = {"field": "키", "op": "같음", "from": src} - return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule)) - - def hole(self, name: str) -> dict: - """자리 표시 {이름} 의 값 알약 — 입력 · 중간.""" - if name in self.specs: - return {"box": "in-design", "name": name} - if name in self.named: - return self.named[name] - raise Unsupported(f"자리 표시 {{{name}}} 의 값이 없음") - - def _key(self, ref: str, field: str | None) -> str: - """「ID:원문번호」 → 키 · 기계 칸은 「EQ키#칸」(입력 판 꼴).""" - if ":" in ref and not ref.lstrip().startswith("{"): - ref = str(self.master.get(ref).get("키")) - if field and ref[:2] != "EQ": - raise Unsupported(f"「{ref}」 의 칸 「{field}」 읽기") - return f"{ref}#{field}" if field else ref - - def table(self, key: str) -> dict: - if f"tb|{key}" not in self.cache: - group = "소요량" if key[:2] in ("QF", "QC") else "계수" - inp = {"mode": "group", "group": group, "elements": [], "variables": []} - box = self.box("input", f"표 {key}", input=inp | {"table": {"key": key}}) - self.cache[f"tb|{key}"] = {"box": box["id"], "name": TABLE_PORT} - return self.cache[f"tb|{key}"] - - def logic(self, node: tuple) -> str: - """로직(키, 넘길 값) → 다른 로직 입력 상자 id(같은 부르기는 하나).""" - key = "lg|" + repr(node) - if key not in self.cache: - head = node[1] - if "{" in head: - raise Unsupported(f"입력 따라 바뀌는 로직 「{head}」") - ref = str(self.master.logic(head).get("키")) - outs = [{"name": n, "unit": "원"} for n in (*COSTS, "결과")] - designs = [{"name": k, "unit": "-"} for k, _ in node[2]] - logic = {"key": ref, "outputs": outs, "designs": designs} - box = self.box("input", f"로직 {ref}", input={"mode": "logic", "elements": []}) - box["input"] |= {"variables": [], "logic": logic} - for k, v in node[2]: - self.link(self.value(v), {"box": box["id"], "name": k}) - self.cache[key] = {"box": box["id"]} - return self.cache[key]["box"] - - def value(self, node: tuple) -> dict: - """조건 · 넘길 값 → 내보내는 알약(입력 · 중간 · 아니면 함수 상자 · 같은 식은 하나).""" - if node[0] == "name" and node[1] in self.specs: - return {"box": "in-design", "name": node[1]} - if node[0] == "name" and node[1] in self.named: - return self.named[node[1]] - key = "val|" + repr(node) - if key not in self.cache: - label = literal(node[1]).strip('"') if node[0] in ("num", "str") else "값" - self.cache[key] = self.func(label, node) - return self.cache[key] - - # ── 함수 상자 ───────────────────────────────────────────────────── - def func(self, name: str, node: tuple, scope: dict | None = None) -> dict: - """식 하나 → 함수 상자(결과 하나) · 받는 알약마다 B2 · B3 … · 결과 칸은 그 아래.""" - bid = f"fn-{len(self.boxes) + 1}" - self.boxes[bid] = {} # 자리 먼저 — 안에서 만드는 상자와 id 가 겹치지 않게 - inputs: list[dict] = [] - seen: dict[str, str] = {} - - def take(src: dict, label: str, rule: dict | None = None) -> str: - k = json.dumps([src, rule], ensure_ascii=False, sort_keys=True, default=str) - if k not in seen: - port = label - while any(i["port"] == port for i in inputs): - port += "·" - seen[k] = f"B{len(inputs) + 2}" - inputs.append({"port": port, "cell": seen[k]}) - self.link(src, {"box": bid, "name": port}, rule) - return seen[k] - - formula = self.excel(node, take, scope or {}) - out = f"B{len(inputs) + 2}" - cells: dict[str, Any] = {"A1": {"값": "알약"}, "B1": {"값": "값"}} - for i in inputs: - cells["A" + i["cell"][1:]] = {"값": i["port"]} - cells["A" + out[1:]] = {"값": name} - cells[out] = {"식": formula} - sheet = {"id": f"s-{bid}", "이름": name[:31] or bid, "칸": cells} - func = {"sheet": sheet, "inputs": inputs, "variables": []} - func["output"] = {"cell": out, "name": name, "unit": "-"} - body = {"id": bid, "kind": "function", "name": name, "color": COLORS["function"]} - self.boxes[bid] = body | {"unit": "-", "x": 0, "y": 0, "func": func} - return {"box": bid, "name": name} - - def excel(self, node: tuple, take, scope: dict) -> str: # noqa: C901 — 마디 갈래 그대로 - kind = node[0] - if kind in ("num", "str"): - return literal(node[1]) - if kind == "neg": - return f"-({self.excel(node[1], take, scope)})" - if kind == "bin": - a, b = (self.excel(x, take, scope) for x in node[2:4]) - if node[1] in JOINS: - return f"{JOINS[node[1]]}({a},{b})" - return f"({a}{OPS.get(node[1], node[1])}{b})" - if kind == "call": - items = [self.excel(x, take, scope) for x in node[2]] - if node[1] in ("올림", "버림", "반올림") and len(items) == 1: - items.append("0") - return f"{FUNCS[node[1]]}({','.join(items)})" - if kind == "name": - return self.name(node[1], take, scope) - if kind == "ref": - group = GROUP_OF.get(node[1][:2], "기계") - return self.excel(self.priced(group, node[1], "", node[1]), take, scope) - if kind == "field": - return self.field(node, take, scope) - if kind == "logic": - return take({"box": self.logic(node), "name": "결과"}, f"{node[1]} 결과") - if kind == "@port": - return take(node[1], node[1]["name"]) - if kind == "@sum0": # 비목 합 — 빈 줄(빈 단가 · 못 셈 덧줄)은 0(옛 엔진 sums += 0) - cells = [f"IFERROR({take(p, p['name'])},0)" for p in node[1]] - return f"SUM({','.join(cells)})" if cells else "0" - if kind == "@line": # 호표 줄 수량 × 단가 — 수량 0 이면 0(옛 엔진 · 빈 단가여도) - q, p = self.excel(node[1], take, scope), self.excel(node[2], take, scope) - return f"IF({q}=0,0,{q}*{p})" - if kind == "@sum": - cells = [take(p, p["name"]) for p in node[1]] - return f"SUM({','.join(cells)})" if cells else "0" - if kind == "@text": # 자리 표시 글 「레미콘 {a}-{b}」 → "레미콘 "&B2&"-"&B3 - bits = _HOLE.split(node[1]) - out = [literal(b) if i % 2 == 0 else take(self.hole(b), b) for i, b in enumerate(bits)] - return "&".join(x for x in out if x != '""') or '""' - raise Unsupported(f"모르는 식 마디 「{kind}」") - - def name(self, name: str, take, scope: dict) -> str: - if name in scope: # 덧줄의 비목 합(빈 줄 = 0) · 줄 금액(빈 줄이면 빈 값 — 옛 env 「줄」) - return self.excel(("@sum0" if name in COSTS else "@sum", scope[name]), take, scope) - if name in self.named: - return take(self.named[name], name) - if name in self.specs: - return take({"box": "in-design", "name": name}, name) - raise Unsupported(f"모르는 이름 「{name}」") - - def field(self, node: tuple, take, scope: dict) -> str: - base, col = node[1], node[2] - if base[0] == "find": - if "{" in base[1]: - raise Unsupported(f"입력 따라 바뀌는 표 「{base[1]}」") - key = str(self.master.get(base[1]).get("키")) - conds = [{"col": k, "from": self.value(v)} for k, v in base[2]] - return take(self.table(key), col, {"kind": "lookup", "conds": conds, "col": col}) - if base[0] == "ref": - group = GROUP_OF.get(base[1][:2], "기계") - node2 = self.priced(group, base[1], "", f"{base[1]} {col}", col) - return self.excel(node2, take, scope) - if base[0] == "logic" and col == "계": # 다른 로직 출력은 비목 셋 — 계 = 그 합 - bid = self.logic(base) - return self.excel(("@sum", [{"box": bid, "name": k} for k in COSTS]), take, scope) - if base[0] == "logic": - return take({"box": self.logic(base), "name": col}, f"{base[1]} {col}") - if base[0] == "name" and base[1] == "줄" and f"줄.{col}" in scope: - return self.name(f"줄.{col}", take, scope) - raise Unsupported(f"칸 읽기 「{col}」") - - -# ── 한 로직 ────────────────────────────────────────────────────────── -def convert(row: dict, ctx: dict | None = None) -> dict: - """옛 로직 줄 → {flow: CfLogic, notes: 못 옮긴 곳} · ctx = {key, file, master}(M01 저장소 - /api/m01/logic/flow) · master 가 없으면 저장소가 읽어 둔 것.""" - master = (ctx or {}).get("master") or store.loaded()[1] - f = _Flow(row, master) - f.design() - try: - _body(f, row) - except Unsupported as e: - f.notes.append(f"못 옮김 — {e}") - except mf.FormulaError as e: - f.notes.append(f"못 옮김 — {e}") - f.boxes = {k: b for k, b in f.boxes.items() if b} # 못 옮겨 비운 자리 - _layout(f) - flow = {"key": row.get("키"), "name": row.get("이름") or ""} - flow |= {"resultUnit": row.get("결과단위") or "", "boxes": list(f.boxes.values())} - flow |= {"links": f.links, "userElements": []} - flow["totalRound"] = None # 옛 계는 끝수 없음(줄.끝수 묶음은 출력 상자 끝수로 실음) - return {"flow": flow, "notes": list(dict.fromkeys(f.notes))} - - -def _output(f: _Flow, name: str, cost: str, src: dict, rule: dict | None, on: str) -> None: - out = {"cost": cost, "unit": "원" if cost != "값" else "-"} - if rule and rule.get("대상") == on: - out["round"] = {"places": int(rule.get("자리") or 0), "method": str(rule.get("방법"))} - box = f.box("output", name, output=out) - f.link(src, {"box": box["id"], "name": OUTPUT_PORT}) - - -def _body(f: _Flow, row: dict) -> None: - rule = row.get("끝수") if isinstance(row.get("끝수"), dict) else None - for step in row.get("중간") or []: - f.named[step["이름"]] = f.func(step["이름"], mf.parse(step["식"])) - if "결과" in row: - _output(f, "결과", "값", f.func("결과", mf.parse(row["결과"]["식"])), rule, "결과") - return - parts: dict[str, list[dict]] = {c: [] for c in COSTS} - lines: dict[str, list[dict]] = {} - for i, item in enumerate(row.get("호표") or []): - name = str(item.get("이름") or f"줄 {i + 1}") - qty = mf.parse(str(item["수량"])) - if item.get("종류") == "로직": - call = mf.parse(str(item["요소"])) - if call[0] != "logic": - raise Unsupported(f"「{name}」 단가가 로직 부르기 꼴 아님") - bid = f.logic(call) - whole = ("@sum", [{"box": bid, "name": k} for k in COSTS]) - for c in [item["비목"]] if item.get("비목") else COSTS: - price = whole if item.get("비목") else ("@port", {"box": bid, "name": c}) - part = f.func(f"{name} {c}", ("bin", "*", qty, price)) - parts[c].append(part) - lines.setdefault(f"줄.{name}", []).append(part) - continue - unit = str(item.get("단위") or "") - ref = item["요소"] # 그룹 = 키 앞 두 글자(「MO:경유:{지역}」 = 유가전력) · 없으면 줄 종류 - group = GROUP_OF.get(ref[:2], str(item.get("종류"))) if isinstance(ref, str) else "재료" - price = f.priced(group, ref, unit, f"{name} 단가") - part = f.func(f"{name} 금액", ("@line", qty, price)) - parts[item["비목"]].append(part) - lines.setdefault(f"줄.{name}", []).append(part) - for extra in row.get("덧줄") or []: - scope = {c: list(parts[c]) for c in COSTS} | lines - part = f.func(extra["이름"], mf.parse(extra["식"]), scope) - f.named[extra["이름"]] = part - parts[extra["비목"]].append(part) - for c in COSTS: - if parts[c]: - _output(f, c, c, f.func(f"{c} 합", ("@sum0", parts[c])), rule, "계") - - -# ── 자동 배치 ──────────────────────────────────────────────────────── -def _layout(f: _Flow) -> None: - """입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위→아래(겹침 없음).""" - feeds: dict[str, set[str]] = {} - for ln in f.links: - srcs = [ln["from"], *[c["from"] for c in (ln.get("rule") or {}).get("conds", [])]] - feeds.setdefault(ln["to"]["box"], set()).update(s["box"] for s in srcs) - for bid, b in f.boxes.items(): # 조건 찾기 요소 — 값을 주는 알약 상자 뒤 - for e in (b.get("input") or {}).get("elements") or []: - rule = e.get("rule") or {} - srcs = [rule.get("from"), *[x["from"] for x in rule.get("slots") or []]] - feeds.setdefault(bid, set()).update(p["box"] for p in srcs if p) - depth: dict[str, int] = {} - - def level(bid: str, path: frozenset = frozenset()) -> int: - if bid not in depth: - ins = [x for x in feeds.get(bid, ()) if x not in path and x in f.boxes] - depth[bid] = 1 + max((level(x, path | {bid}) for x in ins), default=-1) - return depth[bid] - - for bid in f.boxes: - level(bid) - last = max(depth.values(), default=0) + 1 - rows: dict[int, int] = {} - for bid, box in f.boxes.items(): - col = last if box["kind"] == "output" else depth[bid] - box["x"], box["y"] = 40 + col * 300, 40 + rows.get(col, 0) * 170 - rows[col] = rows.get(col, 0) + 1 diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts deleted file mode 100644 index 2f4cf9fc2..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* ============================================================================= - * M02_DF_Detail.ts - * 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 끊김 글 · 흐름 줄 · DOM 없음 - * 셈은 모델 것 그대로(Eval 의 evalLogic · breaks · Model 의 traceBack · traceForward · Rule 의 ruleLabel) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - breaks, - evalLogic, - type CfBreak, - type CfEvalOptions, - type CfEvalResult, - type CfValue, - type CfValues, -} from "./M02_DF_Eval"; -import { - portDir, - portsOut, - traceBack, - traceForward, - type CfBox, - type CfGateOp, - type CfLink, - type CfLogic, - type CfPort, - type CfRule, - type CfStep, -} from "./M02_DF_Model"; -import { ruleLabel } from "./M02_DF_Rule"; - -const TEXT = { - Table: ["표 {n}줄", "table, {n} rows"], - Empty: ["값 없음", "no value"], - NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"], -} as const; - -export const td = (key: keyof typeof TEXT, fill: Record = {}): string => - Object.entries(fill).reduce( - (s, [k, v]) => s.replace(`{${k}}`, String(v)), - TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, - ); - -export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"]; -export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"]; - -/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ -export interface DetailOptions { - values?: CfValues; - logics?: CfEvalOptions["logics"]; -} - -export const boxOf = (lg: CfLogic, id: string): CfBox | undefined => - lg.boxes.find((b) => b.id === id); - -export const portText = (lg: CfLogic, p: CfPort): string => - `${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`; - -/** 계산 한 번 — 한창 만드는 중 막히면 null */ -export function evalSafe(lg: CfLogic, o: DetailOptions = {}): CfEvalResult | null { - try { - return evalLogic(lg, o.values, { logics: o.logics }); - } catch { - return null; - } -} - -export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined => - ev?.ports[p.box]?.[p.name]; - -/** 숫자 글 — 천 단위 쉼표 · 군더더기 소수는 뗌 */ -export const commas = (n: number): string => - Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); - -/** 화면에 보일 값 글 — 표는 줄 수 */ -export function showValue(v: CfValue | undefined): string { - if (v === undefined || v === null) return td("Empty"); - if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) }); - return typeof v === "number" ? commas(v) : String(v); -} - -export function unitOf(lg: CfLogic, p: CfPort): string { - const box = boxOf(lg, p.box); - return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-"; -} - -/** 알약이 나가는(out) 쪽인가 */ -export const isOut = (box: CfBox, name: string): boolean => portDir(box, name) === "out"; - -/** 선 위 규칙 글 */ -export const labelOf = (rule: CfRule | undefined): string => - rule ? ruleLabel(rule) : td("NoRule"); - -/** 지나가기 전 값 → 지난 뒤 값 — 지난 뒤 값은 셈이 받는 알약에 실제로 들여보낸 것 · 못 들어갔으면 끊긴 까닭 */ -export function preview(link: CfLink, ev: CfEvalResult | null): { before: string; after: string } { - const before = showValue(valueOf(ev, link.from)); - if (!link.rule) return { before, after: before }; - const arrived = ev?.arrived?.[link.to.box]?.[link.to.name]; - if (arrived !== undefined && arrived !== null) return { before, after: showValue(arrived) }; - const why = ev?.breaks?.find((b) => b.link === link.id)?.text; - return { before, after: why ?? td("Empty") }; -} - -/** 이 선에서 알릴 것 — 이 선이 막힌 까닭 · 같은 알약에서 나간 조건 선의 겹침 · 안 맞음 */ -export function linkNotes(link: CfLink, ev: CfEvalResult | null): string[] { - return (ev?.breaks ?? []) - .filter( - (b) => - b.link === link.id || - ((b.kind === "조건 겹침" || b.kind === "조건 안 맞음") && - b.box === link.from.box && - b.port === link.from.name), - ) - .map((b) => b.text); -} - -/** 온 길(dir "in") · 가는 길(dir "out") — 모델 것에서 자기 자신 줄은 뺌 */ -export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] { - const steps = dir === "in" ? traceBack(lg, p) : traceForward(lg, p); - return steps.filter((s) => !(s.box === p.box && s.port === p.name)); -} - -/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */ -export function breakLines( - lg: CfLogic, - boxId: string, - name?: string, - o: DetailOptions = {}, -): string[] { - let list: CfBreak[] = []; - try { - list = breaks(lg, o.values, { logics: o.logics }); - } catch { - // 만드는 중 셈이 막히면 끊긴 곳 없음으로 둠 - } - return list - .filter((b) => b.box === boxId && (!name || !b.port || b.port === name)) - .map((b) => b.text); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css deleted file mode 100644 index a6d6726b4..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.css +++ /dev/null @@ -1,499 +0,0 @@ -/* 단가산출 다이어그램 — 자유 배치 */ -.m02-dfdg { - --cfdg-in: var(--color-accent); - --cfdg-out: #c2610c; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - font-size: var(--text-body-sm); -} - -:root[data-theme="dark"] .m02-dfdg { - --cfdg-out: #e5a866; - --cfdg-lift: #fff; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .m02-dfdg { - --cfdg-out: #e5a866; - --cfdg-lift: #fff; - } -} - -.m02-dfdg__bar, -.m02-dfdg__tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m02-dfdg__tools { - min-height: 1.75rem; -} - -.m02-dfdg__title { - font-size: var(--text-body); -} - -.m02-dfdg__gap { - flex: 1; -} - -.m02-dfdg__zoom { - min-width: 3rem; - text-align: center; - font-variant-numeric: tabular-nums; - color: var(--color-text-secondary); -} - -/* 판 — 화면 위쪽 큰 영역 */ -.m02-dfdg__view { - position: relative; - height: max(360px, 55vh); - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background-color: var(--color-canvas); - background-image: radial-gradient( - color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px, - transparent 1px - ); - background-size: 20px 20px; - cursor: default; - touch-action: none; - /* 왼쪽 끌기로 글자가 잡히지 않게 */ - user-select: none; - -webkit-user-select: none; -} - -.m02-dfdg__view.is-panning { - cursor: grabbing; -} - -.m02-dfdg__world { - position: absolute; - inset: 0 auto auto 0; - width: 1px; - height: 1px; - transform-origin: 0 0; -} - -.m02-dfdg__lines { - position: absolute; - inset: 0 auto auto 0; - width: 1px; - height: 1px; - overflow: visible; - pointer-events: none; -} - -/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */ -.m02-dfdg__box { - position: absolute; - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 11rem; - max-width: 16rem; - padding: 0 0 var(--spacing-8); - border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border)); - border-radius: var(--radius-lg); - background: var(--color-surface); - box-shadow: 0 1px 3px rgb(0 0 0 / 12%); - cursor: pointer; -} - -.m02-dfdg__box:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - -.m02-dfdg__box.is-selected { - border: 2px solid var(--cf-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent); -} - -.m02-dfdg__head { - display: grid; - grid-template-columns: 1fr auto; - align-items: baseline; - gap: 0 var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface)); - cursor: move; -} - -.m02-dfdg__kind { - grid-column: 1; - /* 어두운 화면은 종류 색을 밝게 섞음 */ - color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color))); - font-size: var(--text-caption); - font-weight: 600; -} - -.m02-dfdg__unit { - grid-column: 2; - grid-row: 1; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-dfdg__name { - grid-column: 1 / -1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.m02-dfdg__pills { - display: flex; - justify-content: space-between; - gap: var(--spacing-8); - padding: 0 var(--spacing-8); -} - -.m02-dfdg__ins, -.m02-dfdg__outs { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-dfdg__outs { - align-items: flex-end; -} - -/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */ -.m02-dfdg__pill { - display: inline-flex; - align-items: baseline; - gap: var(--spacing-4); - max-width: 9rem; - padding: 0 var(--spacing-8); - border: 1px solid var(--cfdg-in); - border-radius: 999px; - background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface)); - color: var(--cfdg-in); - font: inherit; - font-size: var(--text-caption); - cursor: pointer; - white-space: nowrap; -} - -.m02-dfdg__pill.is-out { - border-color: var(--cfdg-out); - background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface)); - color: var(--cfdg-out); - cursor: grab; - touch-action: none; -} - -.m02-dfdg__pill.is-empty { - border-style: dashed; - border-color: var(--color-text-muted); - background: transparent; - color: var(--color-text-muted); -} - -.m02-dfdg__pill.is-chosen { - box-shadow: 0 0 0 2px currentColor; -} - -.m02-dfdg.is-linking .m02-dfdg__pill.is-target { - box-shadow: 0 0 0 2px var(--cfdg-in); -} - -.m02-dfdg__pill-name { - overflow: hidden; - text-overflow: ellipsis; -} - -.m02-dfdg__pill-value { - color: var(--color-text-secondary); - font-variant-numeric: tabular-nums; -} - -.m02-dfdg__pill-value:empty { - display: none; -} - -/* 선 — 굽은 선 */ -.m02-dfdg__link { - pointer-events: auto; - cursor: pointer; -} - -.m02-dfdg__hit { - fill: none; - stroke: transparent; - stroke-width: 12; -} - -.m02-dfdg__wire { - fill: none; - stroke: var(--color-text-muted); - stroke-width: 1.5; -} - -.m02-dfdg__link.is-strong .m02-dfdg__wire { - stroke: var(--color-accent); - stroke-width: 3; -} - -.m02-dfdg__link.is-dim .m02-dfdg__wire { - opacity: 0.25; -} - -.m02-dfdg__drag { - fill: none; - stroke: var(--cfdg-out); - stroke-width: 2; - stroke-dasharray: 5 4; -} - -/* 함수 상자 흐린 「+」 받는 자리 */ -.m02-dfdg__pill.is-plus { - justify-content: center; - min-width: 2rem; - border-style: dashed; - border-color: var(--color-text-muted); - background: transparent; - color: var(--color-text-muted); - opacity: 0.7; -} - -.m02-dfdg__pill.is-plus:hover, -.m02-dfdg.is-linking .m02-dfdg__pill.is-plus.is-target { - opacity: 1; - border-color: var(--cfdg-in); - color: var(--cfdg-in); -} - -/* ── 흐름도 더함: 선 규칙 이름표 · 흐름 길 · 끊긴 곳 · 알약 고름 ── */ -.m02-dfdg { - --dfdg-back: var(--color-accent); - --dfdg-fwd: #c2410c; -} - -:root[data-theme="dark"] .m02-dfdg { - --dfdg-fwd: #f0a35e; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .m02-dfdg { - --dfdg-fwd: #f0a35e; - } -} - -.m02-dfdg__rule rect { - fill: var(--color-surface); - stroke: var(--color-border); -} - -.m02-dfdg__rule text { - fill: var(--color-text-secondary); - font-size: 11px; -} - -.m02-dfdg__link.is-strong .m02-dfdg__rule rect { - stroke: var(--color-accent); -} - -/* 흐름 길 — 색 + 굵기 + 점선(색만으로 뜻을 안 전함) */ -.m02-dfdg__link.is-back .m02-dfdg__wire { - stroke: var(--dfdg-back); - stroke-width: 3.5; -} - -.m02-dfdg__link.is-fwd .m02-dfdg__wire { - stroke: var(--dfdg-fwd); - stroke-width: 3.5; - stroke-dasharray: 8 5; -} - -.m02-dfdg__link.is-dim .m02-dfdg__wire, -.m02-dfdg__link.is-dim .m02-dfdg__rule { - opacity: 0.25; -} - -.m02-dfdg__box.is-back { - outline: 3px solid var(--dfdg-back); - outline-offset: 2px; -} - -.m02-dfdg__box.is-fwd { - outline: 3px dashed var(--dfdg-fwd); - outline-offset: 2px; -} - -.m02-dfdg__box.is-dim { - opacity: 0.45; -} - -.m02-dfdg__pill.is-back { - box-shadow: 0 0 0 2px var(--dfdg-back); -} - -.m02-dfdg__pill.is-fwd { - outline: 2px dashed var(--dfdg-fwd); - outline-offset: 1px; -} - -.m02-dfdg__legend { - padding: 0 var(--spacing-8); - border-radius: 999px; - font-size: var(--text-caption); -} - -.m02-dfdg__legend.is-back { - border: 2px solid var(--dfdg-back); - color: var(--dfdg-back); -} - -.m02-dfdg__legend.is-fwd { - border: 2px dashed var(--dfdg-fwd); - color: var(--dfdg-fwd); -} - -/* 고른 알약 테두리 */ -.m02-dfdg__pill.is-chosen { - box-shadow: 0 0 0 2px var(--color-text); -} - -/* 끊긴 곳 */ -.m02-dfdg__warn { - position: absolute; - top: -9px; - right: -9px; - width: 18px; - height: 18px; - padding: 0; - border: 0; - border-radius: 50%; - background: var(--color-danger); - color: #fff; - font: inherit; - font-size: 12px; - font-weight: 700; - line-height: 18px; - cursor: pointer; -} - -.m02-dfdg__pill.is-broken { - border-color: var(--color-danger); -} - -.m02-dfdg__pill-warn { - color: var(--color-danger); - font-weight: 700; -} - -.m02-dfdg__linkwarn circle { - fill: var(--color-danger); -} - -.m02-dfdg__linkwarn text { - fill: #fff; - font-size: 11px; - font-weight: 700; -} - -/* 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 + 화살 */ -.m02-dfdg__dep { - pointer-events: auto; - cursor: pointer; -} - -.m02-dfdg__depwire { - fill: none; - stroke: color-mix(in srgb, var(--color-text-muted) 70%, transparent); - stroke-width: 1.5; - stroke-dasharray: 3 4; -} - -.m02-dfdg__arrow { - fill: var(--color-text-muted); -} - -.m02-dfdg__deptext rect { - fill: var(--color-canvas); - stroke: none; - opacity: 0.85; -} - -.m02-dfdg__deptext text { - fill: var(--color-text-muted); - font-size: 10px; -} - -.m02-dfdg__dep.is-strong .m02-dfdg__depwire { - stroke: var(--color-accent); - stroke-width: 2.5; -} - -.m02-dfdg__dep.is-back .m02-dfdg__depwire { - stroke: var(--dfdg-back); - stroke-width: 2.5; -} - -.m02-dfdg__dep.is-fwd .m02-dfdg__depwire { - stroke: var(--dfdg-fwd); - stroke-width: 2.5; -} - -.m02-dfdg__dep.is-dim { - opacity: 0.25; -} - -/* 쉼 — 조건이 안 맞아 이번엔 안 지나감(문제 아님) */ -.m02-dfdg__link.is-rest .m02-dfdg__wire { - stroke: var(--color-text-muted); - stroke-width: 1.5; - stroke-dasharray: 2 5; -} - -.m02-dfdg__restmark rect { - fill: var(--color-surface); - stroke: var(--color-text-muted); - stroke-dasharray: 2 2; -} - -.m02-dfdg__restmark text { - fill: var(--color-text-muted); - font-size: 10px; -} - -.m02-dfdg__box.is-rest { - opacity: 0.5; -} - -/* 빈 판 안내 — 가운데 세 줄 */ -.m02-dfdg__empty { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - margin: 0; - padding: var(--spacing-16) var(--spacing-24); - list-style: none; - border: 1px dashed var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text-secondary); - display: flex; - flex-direction: column; - gap: var(--spacing-8); - pointer-events: none; -} - -/* 쉼 — 받는 알약만(상자 흐림은 들어오는 선이 모두 쉼일 때) */ -.m02-dfdg__pill.is-in.is-rest { - border-style: dashed; - border-color: var(--color-text-muted); - color: var(--color-text-muted); - opacity: 0.7; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts deleted file mode 100644 index 51c8363f0..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ /dev/null @@ -1,697 +0,0 @@ -/* ============================================================================= - * M02_DF_Diagram.ts - * 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」) - * 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 · - * 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) · - * 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음. - * 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`. - * ========================================================================== */ - -import { wheelZoomsIn } from "../../A00_Common/touch_bridge"; -import { guardMenuAfterDrag, isAuxPanButton } from "../../A00_Common/input_prefs"; -import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; -import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; -import { - arrange, - clampZoom, - fitView, - KIND_COLOR, - depEnd, - depInTrace, - depPath, - follow, - depLines, - isRest, - restParts, - linkLabel, - midOf, - pillInfo, - svgAdd as add, - svgLabel as label, - pillsOf, - resolveDrop, - showValue, - traceOf, - tt, - type DfPillSel, - type DiagramOn, - type DiagramOpts, - type TEXT, -} from "./M02_DF_Diagram_Parts"; -import "./M02_DF_Diagram.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_toolbar.css"; - -export * from "./M02_DF_Diagram_Parts"; - -interface HostState { - selected: string | null; - linking: CfPort | null; - pill: DfPillSel | null; - note: string; - zoom: number; - panX: number; - panY: number; - fitted: boolean; - fitKey?: number; - watch?: ResizeObserver; -} - -const states = new WeakMap(); -const SVG = "http://www.w3.org/2000/svg"; -/** Space 를 누르고 있음 — 터치패드용 판 옮기기 */ -let spaceDown = false; -if (typeof window !== "undefined") { - window.addEventListener("keydown", (e) => { - if (e.code === "Space" && !(e.target as HTMLElement)?.closest?.("input, textarea, select")) - spaceDown = true; - }); - window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false)); - window.addEventListener("blur", () => (spaceDown = false)); -} - -export function renderDiagram( - host: HTMLElement, - lg: CfLogic, - on: DiagramOn, - opts: DiagramOpts = {}, -): void { - const st: HostState = states.get(host) ?? { - selected: null, - linking: null, - pill: null, - note: "", - zoom: 1, - panX: 0, - panY: 0, - fitted: false, - }; - if (opts.selected !== undefined) st.selected = opts.selected; - if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey) - [st.fitKey, st.fitted] = [opts.fitKey, false]; - const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨) - if (opts.selectedPill !== undefined) st.pill = opts.selectedPill; - states.set(host, st); - const values = opts.values ?? {}; - const labels = opts.labels ?? {}; - const allBreaks = opts.breaks ?? []; - /** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */ - const breaks = allBreaks.filter((k) => !isRest(k)); - const rests = allBreaks.filter(isRest); - const { - links: restLinks, - pills: restPills, - boxes: restBoxes, - } = restParts(lg, rests, opts.values ?? {}); - const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? ""; - const redraw = (): void => - renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill }); - const select = (id: string | null, pill?: DfPillSel, note = ""): void => { - st.selected = pill ? null : id; - st.pill = pill ?? null; - st.note = note; - on.select(id, pill); - redraw(); - }; - const tryLink = (from: CfPort, target: Parameters[2]): void => { - const got = resolveDrop(lg, from, target); - st.linking = null; - st.note = "why" in got ? got.why : on.link(from, got.to) || ""; - redraw(); - }; - const markOf = (pick: Parameters[1]): string => { - const m = traceOf(opts.trace, pick); - return m ? ` is-${m}` : ""; - }; - const warn = (why: string, onClick: () => void): HTMLElement => { - const b = el("button", { - className: "m02-dfdg__warn", - text: "!", - attrs: { type: "button", title: why, "aria-label": `${tt("Break")} · ${why}` }, - }); - b.addEventListener("pointerdown", (ev) => ev.stopPropagation()); - b.addEventListener("click", (ev) => { - ev.stopPropagation(); - onClick(); - }); - return b; - }; - - /* 판 · 세상(확대 · 옮김을 한 번에) */ - const world = el("div", { className: "m02-dfdg__world" }); - const svg = document.createElementNS(SVG, "svg"); - svg.classList.add("m02-dfdg__lines"); - world.append(svg); - const view = el("div", { - className: "m02-dfdg__view", - attrs: { "aria-label": tt("Title") }, - children: [world], - }); - const zoomLabel = el("span", { className: "m02-dfdg__zoom" }); - const applyView = (): void => { - world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`; - zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`; - }; - /** 화면 점 → 세상 자리 */ - const toWorld = (cx: number, cy: number): { x: number; y: number } => { - const r = view.getBoundingClientRect(); - return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom }; - }; - - /* 상자 · 알약 */ - const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => { - const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name); - const chosen = st.pill?.box === b.id && st.pill.port === p.name && st.pill.dir === dir; - const broken = breaks.find((k) => !k.link && k.box === b.id && k.port === p.name); - const sel: DfPillSel = { box: b.id, port: p.name, dir }; - const btn = el("button", { - className: - [ - "m02-dfdg__pill", - `is-${dir}`, - wired ? "" : "is-empty", - chosen ? "is-chosen" : "", - broken ? "is-broken" : "", - dir === "in" && restPills.has(`${b.id}|${p.name}`) ? "is-rest" : "", - st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", - ].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)), - attrs: { - type: "button", - "data-box": b.id, - "data-name": p.name, - "data-dir": dir, - title: `${p.name} (${p.unit || "-"})`, - }, - children: [ - ...(broken ? [el("span", { className: "m02-dfdg__pill-warn", text: "!" })] : []), - el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }), - el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }), - ], - }); - btn.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir }); - st.linking = null; - if (chosen) select(null); - else select(b.id, sel, broken?.text ?? ""); - }); - if (dir === "out") - btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name })); - return btn; - }; - - /** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */ - const plusSlot = (b: CfBox): HTMLElement => { - const slot = el("button", { - className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`, - text: "+", - attrs: { - type: "button", - "data-box": b.id, - "data-dir": "in", - "data-plus": "1", - title: tt("PlusIn"), - }, - }); - slot.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" }); - st.note = tt("PlusIn"); - redraw(); - }); - return slot; - }; - - const boxEl = (b: CfBox): HTMLElement => { - const { ins, outs } = pillsOf(b); - const kind = tt(`K_${b.kind}` as keyof typeof TEXT); - const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id); - const head = el("div", { - className: "m02-dfdg__head", - children: [ - el("span", { className: "m02-dfdg__kind", text: kind }), - el("strong", { className: "m02-dfdg__name", text: b.name }), - el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }), - ], - }); - const node = el("div", { - className: - `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` + - (st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) + - (restBoxes.has(b.id) ? " is-rest" : ""), - attrs: { - "data-box": b.id, - tabindex: "0", - role: "button", - "aria-label": `${kind} · ${b.name}`, - }, - children: [ - head, - el("div", { - className: "m02-dfdg__pills", - children: [ - el("div", { - className: "m02-dfdg__ins", - children: [ - ...ins.map((p) => pillEl(b, p, "in")), - ...(b.kind === "function" && !ro ? [plusSlot(b)] : []), - ], - }), - el("div", { - className: "m02-dfdg__outs", - children: outs.map((p) => pillEl(b, p, "out")), - }), - ], - }), - ...(broken.length - ? [ - warn(broken.map((k) => k.text).join(" · "), () => - select(b.id, undefined, broken.map((k) => k.text).join(" · ")), - ), - ] - : []), - ], - }); - node.style.left = `${b.x}px`; - node.style.top = `${b.y}px`; - node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]); - node.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null }); - select(st.selected === b.id ? null : b.id); - }); - node.addEventListener("keydown", (ev) => { - if (ev.target !== node) return; - if (ev.key === "Enter") { - ev.preventDefault(); - node.click(); - } - const step = ev.shiftKey ? 50 : 10; - const d = { - ArrowLeft: [-step, 0], - ArrowRight: [step, 0], - ArrowUp: [0, -step], - ArrowDown: [0, step], - }[ev.key]; - if (d && st.selected === b.id && !ro) { - ev.preventDefault(); - on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); - } - }); - head.addEventListener("pointerdown", (ev) => { - if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node); - }); - return node; - }; - - world.append(...lg.boxes.map(boxEl)); - - /* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */ - const setZoom = (z: number, cx?: number, cy?: number): void => { - const r = view.getBoundingClientRect(); - const px = cx ?? r.left + r.width / 2; - const py = cy ?? r.top + r.height / 2; - const at = toWorld(px, py); - st.zoom = clampZoom(z); - st.panX = px - r.left - at.x * st.zoom; - st.panY = py - r.top - at.y * st.zoom; - applyView(); - drawLines(); - }; - /** 맞추기 — 저절로(처음 · 다시 맞춤)는 fitMin · 「한눈에」 단추는 fitAllMin(전체가 보이게) */ - const fit = (button = false): void => { - const rects = lg.boxes.map((b) => { - const n = world.querySelector(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`); - return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; - }); - const r = view.getBoundingClientRect(); - Object.assign( - st, - fitView( - rects, - { w: r.width, h: r.height }, - 40, - button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin, - ), - ); - applyView(); - drawLines(); - }; - const freeSpot = (): { x: number; y: number } => { - const r = view.getBoundingClientRect(); - const c = toWorld(r.left + r.width / 3, r.top + r.height / 3); - let y = c.y; - while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100; - return { x: Math.round(c.x), y: Math.round(y) }; - }; - /** 공용 작은 단추 */ - const sm = ( - label: keyof typeof TEXT, - onClick: () => void, - variant: "ghost" | "filled" | "danger" = "ghost", - ) => createButton({ label: tt(label), size: "sm", variant, onClick }); - const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement => - sm(label, () => { - const at = freeSpot(); - on.add(kind, at.x, at.y); - }); - const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`; - - const selLink = lg.links.find((l) => l.id === st.selected) ?? null; - const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null; - const askRemove = async (b: CfBox): Promise => { - if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id); - }; - const tools: HTMLElement[] = []; - if (st.linking) { - tools.push( - el("span", { text: tt("PickTarget", { v: st.linking.name }) }), - sm("Cancel", () => ((st.linking = null), redraw())), - ); - } else if (st.pill) { - const p = st.pill; - tools.push(el("span", { text: pillInfo(lg, p, values) })); - if (p.dir === "out" && !ro) - tools.push( - sm( - "Connect", - () => ((st.linking = { box: p.box, name: p.port }), (st.pill = null), redraw()), - "filled", - ), - ); - } else if (selLink) { - const rule = labels[selLink.id] ?? linkLabel(selLink); - const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); - tools.push( - el("span", { text: rule ? `${line} · ${rule}` : line }), - ...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]), - ); - } else if (selBox && !ro) { - tools.push(sm("Remove", () => void askRemove(selBox), "danger")); - } - const legend = - opts.trace && (opts.trace.back || opts.trace.forward) - ? [ - el("span", { className: "m02-dfdg__legend is-back", text: tt("Back") }), - el("span", { className: "m02-dfdg__legend is-fwd", text: tt("Forward") }), - ] - : []; - - const bar = el("div", { - className: "ui-toolbar m02-dfdg__bar", - children: [ - el("strong", { className: "m02-dfdg__title", text: tt("Title") }), - ...(ro - ? [] - : [ - addBtn("input", "AddInput"), - addBtn("function", "AddFunc"), - addBtn("output", "AddOutput"), - ]), - el("span", { className: "m02-dfdg__gap" }), - ...legend, - sm("ZoomOut", () => setZoom(st.zoom / 1.2)), - zoomLabel, - sm("ZoomIn", () => setZoom(st.zoom * 1.2)), - sm("Fit", () => fit(true)), - ...(ro - ? [] - : [ - sm("Arrange", () => - Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)), - ), - ]), - ], - }); - const root = el("div", { - className: `m02-dfdg${st.linking ? " is-linking" : ""}`, - children: [ - bar, - el("div", { - className: "ui-toolbar m02-dfdg__tools", - children: [ - ...tools, - el("span", { className: "ui-text ui-text--error", text: st.note }), - el("span", { - className: "ui-text ui-text--muted", - text: tools.length || st.note ? "" : tt("Hint"), - }), - ], - }), - view, - ], - }); - root.addEventListener("keydown", (ev) => { - if (ev.key === "Escape" && (st.linking || st.pill)) { - st.linking = null; - st.pill = null; - redraw(); - } - if (ro || ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; - if (selLink) on.unlink(selLink.id); - else if (selBox) void askRemove(selBox); - }); - view.addEventListener("click", (ev) => { - if (ev.target === view || ev.target === world) select(null); - }); - view.addEventListener( - "wheel", - (ev) => { - ev.preventDefault(); - setZoom(st.zoom * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332) - }, - { passive: false }, - ); - // 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함(마우스) - view.addEventListener( - "pointerdown", - (ev) => { - // 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3) - const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world); - if (isAuxPanButton(ev.button) || (ev.button === 0 && spaceDown) || touchBlank) { - guardMenuAfterDrag(ev); - ev.preventDefault(); - ev.stopPropagation(); - dragPan(ev); - } - }, - { capture: true }, - ); - view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault()); - view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault()); - if (!lg.boxes.length) - view.append( - el("ol", { - className: "m02-dfdg__empty", - children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })), - }), - ); - host.replaceChildren(root); - applyView(); - - /* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */ - function drawLines(): void { - svg.replaceChildren(); - const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => { - const pill = world.querySelector( - `.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`, - ); - if (!pill) return null; - const r = pill.getBoundingClientRect(); - const right = (dir === "out") === (side === "near"); - return toWorld(right ? r.right : r.left, r.top + r.height / 2); - }; - const defs = add(svg, "defs", {}); - const arrow = add(defs, "marker", { - id: "m02-dfdg-arrow", - viewBox: "0 0 8 8", - refX: "7", - refY: "4", - markerWidth: "7", - markerHeight: "7", - orient: "auto", - }); - add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" }); - const dot = add(defs, "marker", { - id: "m02-dfdg-dot", - viewBox: "0 0 8 8", - refX: "4", - refY: "4", - markerWidth: "6", - markerHeight: "6", - }); - add(dot, "circle", { cx: "4", cy: "4", r: "3", class: "m02-dfdg__arrow" }); - const mids = new Map(); - for (const l of lg.links) { - const a = at(l.from, "out"); - const b = at(l.to, "in"); - if (!a || !b) continue; - const dx = Math.max(40, Math.abs(b.x - a.x) / 2); - const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; - const strong = - st.selected === l.id || - (selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id)); - const traced = markOf((s) => s.some((x) => x.link === l.id)); - const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음 - const dim = !mark && st.selected !== null && !strong ? " is-dim" : ""; - const rest = restLinks.has(l.id) ? " is-rest" : ""; - const g = add(svg, "g", { - class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`, - "data-link": l.id, - "data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","), - }); - add(g, "path", { d, class: "m02-dfdg__hit" }); - add(g, "path", { d, class: "m02-dfdg__wire" }); - const mid = midOf(a, b); - const rule = labels[l.id] ?? linkLabel(l); - mids.set(l.id, { ...mid, w: rule ? label(g, mid.x, mid.y, rule, "m02-dfdg__rule") : 0 }); - if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark"); - const broken = breaks.filter((k) => k.link === l.id); - const why = [...broken, ...rests.filter((k) => k.link === l.id)] - .map((k) => k.text) - .join(" · "); - if (broken.length) { - const cy = (mid.y - (rule ? 18 : 0)).toFixed(1); - const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why }); - add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" }); - const t = add(badge, "text", { - x: mid.x.toFixed(1), - y: cy, - "dominant-baseline": "central", - "text-anchor": "middle", - }); - t.textContent = "!"; - add(badge, "title", {}).textContent = why; - } - g.addEventListener("click", (ev) => { - ev.stopPropagation(); - select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why); - }); - } - // 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살 - for (const dep of depLines(lg)) { - // 시작 = 조건 알약 오른쪽 · 끝 = 그 규칙 선의 이름표 가장자리 또는 받는 재료 알약 오른쪽 - const a = at(dep.from, "out"); - const tag = "link" in dep.to ? mids.get(dep.to.link) : null; - const pill = "box" in dep.to ? at({ box: dep.to.box, name: dep.to.port }, "out") : null; - const end = tag ? depEnd(a, tag) : pill && { ...pill, side: "right" as const }; - if (!a || !end) continue; - const b = { x: end.x, y: end.y }; - const d = depPath(a, b, end.side); - const where = "link" in dep.to ? dep.to.link : dep.to.box; - const steps = opts.trace; - const traced = - steps?.back && depInTrace(dep, steps.back) - ? " is-back" - : steps?.forward && depInTrace(dep, steps.forward) - ? " is-fwd" - : steps && (steps.back?.length || steps.forward?.length) - ? " is-dim" - : ""; - const g = add(svg, "g", { - class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`, - "data-dep": dep.id, - "data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","), - }); - add(g, "path", { d, class: "m02-dfdg__hit" }); - add(g, "path", { - d, - class: "m02-dfdg__depwire", - "marker-start": "url(#m02-dfdg-dot)", - "marker-end": "url(#m02-dfdg-arrow)", - }); - const m = midOf(a, b); - label(g, m.x, m.y, dep.text, "m02-dfdg__deptext"); - g.addEventListener("click", (ev) => { - ev.stopPropagation(); - select(where); - }); - } - } - const firstFit = (): void => { - if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines(); - st.fitted = true; - fit(); - }; - st.watch?.disconnect(); - st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit); - st.watch?.observe(view); - if (view.isConnected) firstFit(); - - /* 끌기 셋 — 상자 · 판 · 선 */ - function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void { - ev.preventDefault(); // 끌면서 글자가 잡히지 않게 - const start = toWorld(ev.clientX, ev.clientY); - let x = b.x; - let y = b.y; - follow( - ev, - (m) => { - const now = toWorld(m.clientX, m.clientY); - x = Math.round(b.x + now.x - start.x); - y = Math.round(b.y + now.y - start.y); - node.style.left = `${x}px`; - node.style.top = `${y}px`; - drawLines(); - }, - (_u, moved) => { - if (moved) on.move(b.id, x, y); - }, - ); - } - - function dragPan(ev: PointerEvent): void { - const p0 = { x: st.panX, y: st.panY }; - view.classList.add("is-panning"); - follow( - ev, - (m) => { - st.panX = p0.x + m.clientX - ev.clientX; - st.panY = p0.y + m.clientY - ev.clientY; - applyView(); - }, - () => view.classList.remove("is-panning"), - ); - } - - function dragLink(ev: PointerEvent, from: CfPort): void { - if (ev.button !== 0 || spaceDown || ro) return; - ev.stopPropagation(); - ev.preventDefault(); - const pill = ev.currentTarget as HTMLElement; - const r = pill.getBoundingClientRect(); - const a = toWorld(r.right, r.top + r.height / 2); - let line: SVGPathElement | null = null; - follow( - ev, - (m) => { - const b = toWorld(m.clientX, m.clientY); - if (!line) { - line = document.createElementNS(SVG, "path"); - line.setAttribute("class", "m02-dfdg__drag"); - svg.append(line); - } - line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`); - }, - (u, moved) => { - if (!moved) return; - line?.remove(); - const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; - const p = hit?.closest(".m02-dfdg__pill"); - const bx = hit?.closest(".m02-dfdg__box"); - tryLink( - from, - p - ? { - box: p.dataset.box ?? "", - name: p.dataset.name ?? null, - dir: (p.dataset.dir as "in" | "out") ?? null, - } - : bx - ? { box: bx.dataset.box ?? "", name: null, dir: null } - : null, - ); - }, - ); - } -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts deleted file mode 100644 index cbf1c78f3..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ /dev/null @@ -1,424 +0,0 @@ -/* ============================================================================= - * M02_DF_Diagram_Parts.ts - * 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 · - * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - portsIn, - portsOut, - type CfBox, - type CfKind, - type CfLogic, - type CfPill, - type CfPort, - type CfLink, - type CfStep, - elementPort, - ruleSources, -} from "./M02_DF_Model"; -import type { CfBreak } from "./M02_DF_Eval"; -import { ruleLabel } from "./M02_DF_Rule"; - -export const TEXT = { - Title: ["다이어그램", "Diagram"], - K_input: ["입력", "Input"], - K_function: ["함수", "Function"], - K_output: ["출력", "Output"], - AddInput: ["+ 입력", "+ Input"], - AddFunc: ["+ 함수", "+ Function"], - AddOutput: ["+ 출력", "+ Output"], - ZoomIn: ["확대", "Zoom in"], - ZoomOut: ["축소", "Zoom out"], - Fit: ["한눈에", "Fit all"], - Arrange: ["줄 맞추기", "Line up"], - Cancel: ["취소", "Cancel"], - Connect: ["잇기", "Connect"], - Disconnect: ["끊기", "Disconnect"], - Remove: ["상자 지우기", "Remove box"], - AskRemove: [ - "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", - "Remove box “{v}”? Its lines go too", - ], - PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"], - Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"], - NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"], - SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], - NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"], - NoMatch: [ - "같은 이름 받는 알약 없음 — 알약에 바로 놓으세요", - "No pill with that name — drop on a pill", - ], - Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"], - From: ["어디서", "From"], - To: ["어디로", "To"], - Value: ["지금 값", "Now"], - PlusIn: [ - "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", - "Drop an output pill here to add an input", - ], - LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], - Hint: [ - "상자 머리를 끌어 옮김 · 가운데 버튼(또는 Space + 끌기)로 판 옮김 · 휠로 확대", - "Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom", - ], - Break: ["끊긴 곳", "Break"], - Back: ["온 길", "Came from"], - DepLookup: ["표 조건", "table key"], - DepGate: ["조건", "condition"], - DepMaterial: ["{f}에 {o}", "{f} {o}"], - Rest: ["쉼", "Resting"], - Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"], - Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"], - Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"], - Forward: ["가는 길", "Goes to"], -} as const; - -export const tt = (key: keyof typeof TEXT, fill: Record = {}): string => - TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); - -/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */ -export const KIND_COLOR: Record = { - input: "#2a6fb0", - function: "#7a4fb5", - output: "#c2610c", -}; - -/* ── 맞출 모양 ──────────────────────────────────────────────────────── */ - -/** 고른 알약 */ -export interface DfPillSel { - box: string; - port: string; - dir: "in" | "out"; -} - -export interface DiagramOn { - /** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */ - select(id: string | null, pill?: DfPillSel): void; - move(boxId: string, x: number, y: number): void; - /** 잇기 — 막히면 까닭(쉬운 말) */ - link(from: CfPort, to: CfPort): string | null | void; - unlink(linkId: string): void; - add(kind: CfKind, x: number, y: number): void; - remove(boxId: string): void; -} - -export interface DiagramOpts { - /** 상자 id → 알약 이름 → 지금 값 */ - values?: Record>; - /** 고른 상자 id · 선 id */ - selected?: string | null; - /** 고른 알약 */ - selectedPill?: DfPillSel | null; - /** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */ - labels?: Record; - /** 흐름 길 — Model traceBack · traceForward 걸음 */ - trace?: { back?: CfStep[]; forward?: CfStep[] } | null; - /** 끊긴 곳 — Eval breaks */ - breaks?: CfBreak[]; - /** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */ - readOnly?: boolean; - /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ - fitKey?: number; - /** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */ - fitMin?: number; - /** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */ - fitAllMin?: number; -} - -/* ── 알약 · 자리 ────────────────────────────────────────────────────── */ - -/** 알약 — Model 의 받는 · 내보내는 알약 */ -export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } { - return { ins: portsIn(box), outs: portsOut(box) }; -} - -/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ -export function resolveDrop( - lg: CfLogic, - from: CfPort, - target: { box: string; name: string | null; dir: "in" | "out" | null } | null, -): { to: CfPort } | { why: string } { - if (!target) return { why: tt("NotIn") }; - if (target.box === from.box) return { why: tt("SameBox") }; - if (target.dir === "out") return { why: tt("NotIn") }; - const box = lg.boxes.find((b) => b.id === target.box); - // 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」) - if (box?.kind === "function" && target.name === null) - return { to: { box: box.id, name: from.name } }; - const ins = box ? pillsOf(box).ins : []; - if (!box || !ins.length) return { why: tt("NoIn") }; - const name = - target.name ?? - ins.find((p) => p.name === from.name)?.name ?? - (ins.length === 1 ? ins[0].name : null); - if (!name) return { why: tt("NoMatch") }; - if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") }; - return { to: { box: box.id, name } }; -} - -/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */ -export function arrange( - lg: CfLogic, - gap = { x: 260, y: 130 }, -): Record { - const depth = new Map(lg.boxes.map((b) => [b.id, 0])); - for (let round = 0; round < lg.boxes.length; round++) { - let moved = false; - for (const l of lg.links) { - const d = (depth.get(l.from.box) ?? 0) + 1; - if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) { - depth.set(l.to.box, d); - moved = true; - } - } - if (!moved) break; - } - const rows = new Map(); - const out: Record = {}; - for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) { - const d = depth.get(b.id) ?? 0; - const r = rows.get(d) ?? 0; - rows.set(d, r + 1); - out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y }; - } - return out; -} - -/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */ -export const FIT_MIN = 0.9; - -export const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3))); - -/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */ -export function fitView( - rects: { x: number; y: number; w: number; h: number }[], - view: { w: number; h: number }, - pad = 40, - min = FIT_MIN, -): { zoom: number; panX: number; panY: number } { - if (!rects.length) return { zoom: 1, panX: 0, panY: 0 }; - const x0 = Math.min(...rects.map((r) => r.x)) - pad; - const y0 = Math.min(...rects.map((r) => r.y)) - pad; - const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; - const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; - const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); - /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ - const place = (size: number, from: number, to: number): number => - Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; - return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) }; -} - -/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */ -export const showValue = (v: unknown): string => { - if (v === null || v === undefined || v === "") return ""; - if (typeof v === "number") - return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : ""; - if (Array.isArray(v)) { - const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0; - return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`; - } - return String(v).slice(0, 12); -}; - -/* ── 흐름 길 · 선 규칙 글 ───────────────────────────────────────────── */ - -/** 흐름 길 자리 — 온 길 · 가는 길 · 흐림(길이 있는데 안 듦) · 「」(길 없음) */ -export type TraceMark = "back" | "fwd" | "dim" | ""; - -export function traceOf( - trace: DiagramOpts["trace"], - pick: (steps: CfStep[]) => boolean, -): TraceMark { - if (!trace || (!trace.back?.length && !trace.forward?.length)) return ""; - if (trace.back && pick(trace.back)) return "back"; - if (trace.forward && pick(trace.forward)) return "fwd"; - return "dim"; -} - -/** 선 위 규칙 글 — Rule 의 ruleLabel · 규칙 없으면 「」 */ -export const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : ""); - -/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */ -export const midOf = ( - a: { x: number; y: number }, - b: { x: number; y: number }, -): { x: number; y: number } => ({ - x: (a.x + b.x) / 2, - y: (a.y + b.y) / 2, -}); - -/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */ - -/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */ -export interface DepLine { - id: string; - from: CfPort; - to: { link: string } | { box: string; port: string }; - text: string; -} - -export function depLines(lg: CfLogic): DepLine[] { - const out: DepLine[] = []; - for (const l of lg.links) - ruleSources(l.rule).forEach((from, i) => - out.push({ - id: `dep:${l.id}:${i}`, - from, - to: { link: l.id }, - text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"), - }), - ); - for (const b of lg.boxes) - for (const e of b.input?.elements ?? []) - if (e.mode === "rule" && e.rule) - out.push({ - id: `dep:${b.id}:${elementPort(e)}`, - from: e.rule.from, - to: { box: b.id, port: elementPort(e) }, - text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }), - }); - return out; -} - -/** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */ -export function depInTrace(dep: DepLine, steps: CfStep[]): boolean { - const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name); - const dst = - "link" in dep.to - ? steps.some((s) => s.link === (dep.to as { link: string }).link) - : steps.some((s) => s.box === (dep.to as { box: string }).box); - return src && dst; -} - -type Pt = { x: number; y: number }; - -/** 이름표에 닿는 끝 — 시작이 왼쪽이면 이름표 왼쪽 가장자리 · 오른쪽이면 오른쪽 가장자리(이름표가 없으면 선 가운데) */ -export function depEnd(a: Pt | null, tag: Pt & { w: number }): Pt & { side: "left" | "right" } { - const fromLeft = !a || a.x <= tag.x; - return { - x: tag.x + (fromLeft ? -1 : 1) * (tag.w / 2 + 2), - y: tag.y, - side: fromLeft ? "left" : "right", - }; -} - -/** 기댐 선 길 — 알약 오른쪽에서 나가 끝에 닿음 · side = 끝으로 들어가는 쪽(재료 알약은 오른쪽에서) */ -export function depPath(a: Pt, b: Pt, side: "left" | "right"): string { - const dx = Math.max(40, Math.abs(b.x - a.x) / 2); - const bx = side === "left" ? b.x - dx : b.x + dx; - return `M${a.x},${a.y} C${a.x + dx},${a.y} ${bx},${b.y} ${b.x},${b.y}`; -} - -/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — Eval 의 level 그대로 */ -export const isRest = (k: CfBreak): boolean => k.level === "쉼"; - -/** 쉼 모양 자리 — 선 · 받는 알약(「상자|알약」) · 흐릴 상자 - * 상자 흐림은 들어오는 선이 모두 쉼일 때 · 또는 상자에 걸린 쉼이 있고 그 상자 내보내는 값이 모두 빈 값일 때만 */ -export function restParts( - lg: CfLogic, - rests: CfBreak[], - values: Record>, -): { links: Set; pills: Set; boxes: Set } { - const links = new Set(rests.map((k) => k.link).filter((x): x is string => !!x)); - const pills = new Set( - lg.links.filter((l) => links.has(l.id)).map((l) => `${l.to.box}|${l.to.name}`), - ); - const boxes = new Set(); - const empty = (v: unknown): boolean => v === null || v === undefined || v === ""; - for (const b of lg.boxes) { - const incoming = lg.links.filter((l) => l.to.box === b.id); - const allRest = incoming.length > 0 && incoming.every((l) => links.has(l.id)); - // 내보내는 알약 값만(들어온 값은 빼고) · 쉼이 그 상자에 걸렸나(Eval 쉼 줄은 link 가 늘 있음) - const outs = portsOut(b).map((p) => values[b.id]?.[p.name]); - const hit = rests.some((k) => k.box === b.id); - if (allRest || (hit && outs.every(empty))) boxes.add(b.id); - } - return { links, pills, boxes }; -} - -/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */ -export function pillInfo( - lg: CfLogic, - p: DfPillSel, - values: Record>, -): string { - const name = (e: CfPort): string => - `${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`; - const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; - const where = lg.links - .filter((l) => mine(p.dir === "in" ? l.to : l.from)) - .map((l) => name(p.dir === "in" ? l.from : l.to)) - .join(", "); - const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; - return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""] - .filter(Boolean) - .join(" · "); -} - -/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ - -/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ -export function follow( - ev: PointerEvent, - move: (m: PointerEvent) => void, - end: (u: PointerEvent, moved: boolean) => void, -): void { - const x0 = ev.clientX; - const y0 = ev.clientY; - let moved = false; - const mv = (m: PointerEvent): void => { - if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; - moved = true; - move(m); - }; - const up = (u: PointerEvent): void => { - window.removeEventListener("pointermove", mv); - window.removeEventListener("pointerup", up); - if (moved) { - // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 - const swallow = (c: MouseEvent): void => c.stopPropagation(); - window.addEventListener("click", swallow, { capture: true, once: true }); - setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); - } - end(u, moved); - }; - window.addEventListener("pointermove", mv); - window.addEventListener("pointerup", up); -} - -/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */ - -const SVG_NS = "http://www.w3.org/2000/svg"; - -/** SVG 마디 하나 붙이기 */ -export function svgAdd( - parent: Element, - tag: K, - attrs: Record, -): SVGElementTagNameMap[K] { - const n = document.createElementNS(SVG_NS, tag); - for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); - parent.append(n); - return n; -} - -/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */ -export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number { - const tag = svgAdd(parent, "g", { class: cls }); - const box = svgAdd(tag, "rect", { rx: "4", ry: "4" }); - const t = svgAdd(tag, "text", { - x: x.toFixed(1), - y: y.toFixed(1), - "dominant-baseline": "central", - "text-anchor": "middle", - }); - t.textContent = text; - const w = (t.getComputedTextLength?.() || text.length * 7) + 10; - Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => - box.setAttribute(k, v.toFixed(1)), - ); - return w; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts deleted file mode 100644 index 667f9a3d1..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts +++ /dev/null @@ -1,281 +0,0 @@ -/* ============================================================================= - * M02_DF_Entry_Detail.ts - * 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름) - * · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움. - * 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { CfAnswer, CfQuery } from "./M02_DF_Api"; -import { fillTemplate } from "./M02_DF_InputPanel_Build"; -import type { CfBreak, CfValues } from "./M02_DF_Eval"; -import type { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model"; -import "@ui/ui_template_text.css"; - -const TEXT = { - Detail: ["상세 내용", "Details"], - Settings: ["설정", "Settings"], - Body: ["본문", "Content"], - Warn: ["경고", "Warnings"], - WarnNone: ["경고 없음", "No warnings"], - PickSome: [ - "다이어그램에서 상자 · 알약 · 선을 고르면 여기에 그 상세 내용이 보임", - "Choose a box, pill or line in the diagram", - ], - Line: ["선", "Line"], - In: ["받는 알약", "Incoming pill"], - Out: ["내보내는 알약", "Outgoing pill"], -} as const; - -const tt = (key: keyof typeof TEXT): string => - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; - -/** 고른 것 — 상자 · 선 · 알약 */ -export type DfPick = - | { kind: "box"; id: string } - | { kind: "link"; id: string } - | { kind: "pill"; box: string; port: string; dir: "in" | "out" } - | null; - -export interface DetailParts { - root: HTMLElement; - title: HTMLElement; - pill: HTMLElement; - hint: HTMLElement; - settings: HTMLElement; - body: HTMLElement; - warn: HTMLElement; -} - -const row = (label: string, host: HTMLElement): HTMLElement => - el("div", { - className: "m02-df__detail-row", - children: [el("h4", { text: label }), host], - }); - -/** 상세 내용 칸 — 머리 + 설정 · 본문 · 경고 */ -export function detailFrame(): DetailParts { - const title = el("strong", { className: "m02-df__detail-title" }); - const pill = el("span", { className: "m02-df__detail-pill" }); - const hint = el("p", { className: "m02-df__muted", text: tt("PickSome") }); - const settings = el("div", { className: "m02-df__detail-settings" }); - const body = el("div", { className: "m02-df__detail-body" }); - const warn = el("ul", { className: "m02-df__detail-warn" }); - const root = el("section", { - className: "m02-df__part m02-df__detail", - children: [ - el("div", { - className: "m02-df__detail-head", - children: [ - el("h3", { text: tt("Detail") }), - el("div", { className: "m02-df__detail-name", children: [title, pill] }), - ], - }), - el("div", { - className: "m02-df__part-body", - children: [ - hint, - row(tt("Settings"), settings), - row(tt("Body"), body), - row(tt("Warn"), warn), - ], - }), - ], - }); - return { root, title, pill, hint, settings, body, warn }; -} - -const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; - -/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */ -export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void { - d.hint.hidden = !!pick; - for (const r of [d.settings, d.body, d.warn]) (r.parentElement as HTMLElement).hidden = !pick; - const link = pick?.kind === "link" ? lg.links.find((l) => l.id === pick.id) : undefined; - d.title.textContent = - pick?.kind === "box" - ? boxName(lg, pick.id) - : pick?.kind === "pill" - ? boxName(lg, pick.box) - : link - ? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}` - : ""; - d.pill.textContent = - pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : ""; - d.pill.hidden = pick?.kind !== "pill"; -} - -/** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */ -export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void { - const mine = all.filter((k) => - pick?.kind === "box" - ? k.box === pick.id - : pick?.kind === "pill" - ? k.box === pick.box && k.port === pick.port && !k.link - : pick?.kind === "link" - ? k.link === pick.id - : false, - ); - d.warn.replaceChildren( - ...(mine.length - ? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w })) - : [el("li", { className: "ui-text ui-text--muted", text: tt("WarnNone") })]), - ); -} - -/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */ -export async function loadLogics( - lg: CfLogic, - get: (key: string) => Promise, -): Promise<{ logics: Record; errors: string[] }> { - const logics: Record = {}; - const errors: string[] = []; - const keysOf = (x: CfLogic): string[] => - x.boxes.flatMap((b) => - b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [], - ); - const seen = new Set(lg.key ? [lg.key] : []); - let todo = keysOf(lg); - while (todo.length) { - const key = todo.shift()!; - if (seen.has(key)) continue; - seen.add(key); - try { - const got = await get(key); - if (!got) throw new Error(key); - logics[key] = got; - todo = [...todo, ...keysOf(got)]; - } catch (e) { - errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`); - } - } - return { logics, errors }; -} - -/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ -export async function unitChoices(): Promise { - const count = new Map(); - try { - for (const x of await fetchLogics()) { - if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); - } - } catch { - /* 못 받으면 기본 하나 */ - } - const units = [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - return units.length ? units : ["원/㎡"]; -} - -/** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */ -const RULE_GROUPS = ["인력", "기계", "유가전력"]; - -/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을 - * pill 로 읽어 넘김 · 인력 · 기계 · 유가 조건 찾기도) · 표(알약 「표」) · 다른 로직(서버가 받은 설계 값으로 셈 · port 「*」) - * · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ -export function queriesOf( - lg: CfLogic, - pill: (p: CfPort) => unknown = () => null, - elementPort: (e: CfElementRef) => string = (e) => e.name, -): { q: CfQuery; box: string; port: string }[] { - return lg.boxes.flatMap((b) => { - const inp = b.input; - if (b.kind === "input" && inp?.mode === "logic" && inp.logic?.key) { - const ref = inp.logic; - const designs: Record = {}; - for (const d of ref.designs) { - const l = lg.links.find((x) => x.to.box === b.id && x.to.name === d.name); - const v = l ? pill(l.from) : ref.examples?.[d.name]; - if (v !== null && v !== undefined) designs[d.name] = v; - } - const q: CfQuery = { id: `${b.id}*`, group: "기계", logic: { key: ref.key, designs } }; - return [{ q, box: b.id, port: "*" }]; - } - if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; - const group = inp.group; - const slots = inp.elements - .filter( - (e) => - (group === "재료" && e.mode && e.mode !== "fixed") || - (RULE_GROUPS.includes(group) && e.mode === "rule"), - ) - .map((e) => { - const port = elementPort(e); - const q: CfQuery = { - id: `${b.id}\u0001${port}`, - group, - unit: e.unit, - slot: { ...e, field: e.rule?.field, op: e.rule?.op }, - }; - // 조건 찾기 — 「검색어」 면 틀에 알약 값을 채운 글 · 그 밖은 이어진 알약 값 - if (e.mode === "rule" && e.rule) - q.value = - e.rule.field === "검색어" - ? fillTemplate(e.rule.template ?? "", e.rule.slots ?? [], pill).text - : pill(e.rule.from); - return { q, box: b.id, port }; - }); - const els = inp.elements - .filter((e) => e.ref && !e.ref.startsWith("UE-") && !slots.some((s) => s.port === e.name)) - .map((e) => { - // 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김 - const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined]; - const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit }; - if (field) q.field = field; - return { q, box: b.id, port: e.name }; - }); - const table = inp.table - ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] - : []; - return [...slots, ...els, ...table]; - }); -} - -/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ -export async function fetchValues( - ask: (qs: CfQuery[], els: never) => Promise, - lg: CfLogic, - pill?: (p: CfPort) => unknown, - elementPort?: (e: CfElementRef) => string, -): Promise<{ values: CfValues; errors: string[] }> { - const all = queriesOf(lg, pill, elementPort); - const asks = all.filter((a) => !a.q.logic); - const values: CfValues = {}; - const errors: string[] = []; - // 다른 로직 — 서버가 받은 설계 값으로 셈(그 안 조건 찾기 · 단가까지) → 출력 이름마다 값 - const logicAsks = all.filter((a) => a.q.logic); - const logicDone = logicAsks.length - ? import("./M02_DF_Api").then(({ cfLogicValue }) => - Promise.all( - logicAsks.map((a) => - cfLogicValue(a.q.logic!.key, a.q.logic!.designs).then( - (got) => { - values[a.box] = { ...(values[a.box] ?? {}), ...(got.outputs as CfValues[string]) }; - }, - (e: unknown) => errors.push(`${a.q.logic!.key} — ${String(e)}`), - ), - ), - ), - ) - : Promise.resolve(); - if (!asks.length) { - await logicDone; - return { values, errors }; - } - const answers = await ask( - asks.map((a) => a.q), - lg.userElements as never, - ); - await logicDone; - for (const a of asks) { - const got = answers.find((x) => x.id === a.q.id); - if (got && !got.ok && got.blank) (values[a.box] ??= {})[a.port] = "#N/A"; // 빈 단가 — Eval 이 NA() - if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); - else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; - } - return { values, errors }; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts deleted file mode 100644 index 378d6b9e7..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ /dev/null @@ -1,538 +0,0 @@ -/* ============================================================================= - * M02_DF_Eval.ts - * 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈 - * 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`) - * 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 · - * 조건 겹침 · 조건 안 맞음 · 오류) · DOM 없음 - * 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { - OUTPUT_PORT, - depends, - designInputs, - elementPort, - gateConds, - portsIn, - portsOut, - ruleSources, - type CfBox, - type CfGateRule, - type CfLink, - type CfLogic, -} from "./M02_DF_Model"; -import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule"; - -/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ -export type CfValue = number | string | boolean | null | (number | string | null)[][]; - -/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값 · 다른 로직 출력) */ -export type CfValues = Record>; - -type Scalar = number | string | boolean | null; - -/** 함수 상자 칸으로 가는 표 찾기 실패 값 — 칸에 NA() 를 넣음(엑셀과 같게 · IF 가 안 고른 갈래면 무시) */ -const NA_VALUE = "#N/A"; -const isErrorValue = (v: unknown): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); - -/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */ -export interface CfBreak { - kind: - | "빈 받는 자리" - | "통과 안 됨" - | "표에 없는 조건" - | "맞는 재료 없음" - | "단가 없는 품목" - | "조건 겹침" - | "조건 안 맞음" - | "오류"; - /** 무게 — 「쉼」 = 조건이 안 맞아 쉬는 갈래(그 아래로 번진 빈 값 포함) · 「문제」 = 고쳐야 할 끊김 */ - level: "문제" | "쉼"; - box: string; - port?: string; - link?: string; - /** 쉬운 말 */ - text: string; -} - -export interface CfEvalResult { - /** 상자 id → 내보내는 알약 이름 → 값 */ - ports: Record>; - /** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */ - arrived?: Record>; - /** 출력 상자 id → 값 */ - outputs: Record; - cost: { - 노무비: number; - 재료비: number; - 경비: number; - /** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */ - 값들: Record; - 계: number; - }; - /** 쉬운 말 */ - errors: string[]; - /** 끊긴 곳(셈 때 생긴 것) */ - breaks?: CfBreak[]; -} - -/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */ -export interface CfEvalOptions { - logics?: Record; - depth?: number; -} - -const TEXT = { - /** {box} {cell} {err} */ - CellError: [ - "「{box}」 {cell} 칸 계산이 막힘 — {err}", - "「{box}」 cell {cell} can't be worked out — {err}", - ], - Cycle: ["돌고 도는 참조", "circular reference"], - /** {box} */ - Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"], - NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"], - /** {box} {pill} */ - EmptyIn: [ - "「{box}」 의 받는 알약 「{pill}」 에 선이 없음", - "Pill 「{pill}」 of 「{box}」 has no line", - ], - NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], - NoOutput: ["출력 상자가 없음", "There is no output box"], - NoInCell: [ - "「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음", - "Pill 「{pill}」 of 「{box}」 is not placed in a cell", - ], - NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], - /** {from} {box} {rule} */ - NotPassed: [ - "「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음", - "Line 「{from}」 → 「{box}」 doesn't pass ({rule})", - ], - NoPrice: [ - "「{box}」 — 단가 없는 품목을 씀(「{pill}」)", - "「{box}」 — uses an item with no price (「{pill}」)", - ], - NoRow: [ - "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음", - "Line 「{from}」 → 「{box}」 — no matching table row", - ], - ManyRows: [ - "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿", - "Line 「{from}」 → 「{box}」 — several table rows match", - ], - /** {from} {box} */ - Rest: [ - "「{from}」 → 「{box}」 — 앞 조건이 안 맞아 쉬는 갈래", - "「{from}」 → 「{box}」 — resting branch (an earlier condition doesn't match)", - ], - RuleError: [ - "「{from}」 → 「{box}」 선 규칙을 셀 수 없음", - "Line 「{from}」 → 「{box}」 rule can't be worked out", - ], - /** {box} {pill} */ - NoMaterial: [ - "「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음", - "「{pill}」 of 「{box}」 — no material matches", - ], - Overlap: [ - "「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함", - "Several condition lines from 「{pill}」 of 「{box}」 pass", - ], - NoneMatch: [ - "「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음", - "No condition line from 「{pill}」 of 「{box}」 passes", - ], - NoLogic: [ - "「{box}」 이 부르는 로직을 찾을 수 없음", - "The logic used by 「{box}」 can't be found", - ], -} as const; - -const tl = (key: keyof typeof TEXT, fill: Record = {}): string => - Object.entries(fill).reduce( - (s, [k, v]) => s.replace(`{${k}}`, v), - TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, - ); - -/* ── 차례 ───────────────────────────────────────────────────────────────── */ - -/** 상자 차례 — 기댐(선 · 규칙 조건 · 재료 조건)대로 · 돌고 도는 상자는 loop */ -function order(lg: CfLogic): { boxes: CfBox[]; loop: CfBox[] } { - const deps = depends(lg); - const done: CfBox[] = []; - const left = new Set(lg.boxes.map((b) => b.id)); - for (let moved = true; moved && left.size;) { - moved = false; - for (const b of lg.boxes) { - if (!left.has(b.id)) continue; - if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue; - done.push(b); - left.delete(b.id); - moved = true; - } - } - return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.id)) }; -} - -/* ── 칸 ─────────────────────────────────────────────────────────────────── */ - -function putValue(칸: Record, cell: string, value: Scalar): void { - const style = 칸[cell]?.서식; - if (value === null) { - if (style) 칸[cell] = { 서식: style }; - else delete 칸[cell]; - } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; -} - -/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */ -function put(칸: Record, cell: string, value: CfValue): void { - const at = parseA1(cell); - if (!at) return; - if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value); - value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x))); -} - -const fromCalc = (v: CalcValue | undefined): Scalar => { - if (!v) return null; - if ("수" in v) return Number(v.수); - if ("글" in v) return v.글; - if ("참" in v) return v.참; - return v.오류; -}; - -/* ── 상자마다 ───────────────────────────────────────────────────────────── */ - -/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ -function inputPorts( - lg: CfLogic, - box: CfBox, - given: Record, -): Record { - const out: Record = {}; - const inp = box.input; - if (!inp) return out; - const pills = portsOut(box); - const sources: CfValue[] = [ - ...(inp.mode === "group" - ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) - : []), - ...(inp.mode === "group" && inp.table ? [null] : []), - ...inp.variables.map((v) => v.example), - ]; - pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null))); - return out; -} - -/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 · 출력 끝수 */ -function runFunc( - box: CfBox, - arrive: Record, - given: Record, - errors: string[], -): Scalar { - const func = box.func!; - const sheet: Sheet = structuredClone(func.sheet); - for (const i of func.inputs) { - const v = arrive[i.port] ?? given[i.port] ?? null; - if (isErrorValue(v)) - sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; // 오류 값 · 빈 단가 - else put(sheet.칸, i.cell, v); - } - for (const v of func.variables) - if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); - const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); - for (const [cell, v] of Object.entries(values[sheet.id] ?? {})) - if ("오류" in v && sheet.칸[cell]?.식 !== "NA()") { - const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; - errors.push(tl("CellError", { box: box.name, cell, err })); - } - const at = func.output.cell.toUpperCase(); - const c = sheet.칸[at]; - const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); - return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got; -} - -/* ── 계산 ───────────────────────────────────────────────────────────────── */ - -/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */ -export function evalLogic( - lg: CfLogic, - values: CfValues = {}, - opts: CfEvalOptions = {}, -): CfEvalResult { - const errors: string[] = []; - const found: CfBreak[] = []; - const ports: CfEvalResult["ports"] = {}; - const arrived: NonNullable = {}; - const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; - const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null; - const passed = new Map(); - /** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */ - const blocked = new Set(); - /** 조건이 안 맞아 쉬는 상자 — 그 아래로 번짐 */ - const resting = new Set(); - /** 함수 상자로 가는 표 찾기 실패 — 그 함수 결과가 오류일 때만 끊긴 곳(엑셀과 같게) */ - const late = new Map(); - /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ - const through = (l: CfLink): CfValue => { - const v = valueOf(l.from.box, l.from.name); - // 빈 단가(#N/A) — 옛 엔진처럼 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳(late) - if (v === NA_VALUE && lg.boxes.find((b) => b.id === l.from.box)?.input?.mode === "group") { - const text = tl("NoPrice", { box: name(l.to.box), pill: l.from.name }); - const brk: CfBreak = { - kind: "단가 없는 품목", - level: "문제", - box: l.to.box, - port: l.to.name, - link: l.id, - text, - }; - late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]); - } - if (!l.rule) return v; - if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null; - const conds = Object.fromEntries( - ruleSources(l.rule).flatMap((p) => [ - [p.name, valueOf(p.box, p.name)], - [`${p.box}|${p.name}`, valueOf(p.box, p.name)], - ]), - ); - const res = applyRule(l.rule, v, conds); - if (l.rule.kind === "gate") passed.set(l.id, res.why === null); - const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) }; - if (res.why === "통과 안 됨") { - resting.add(l.to.box); - found.push({ - kind: "통과 안 됨", - level: "쉼", - box: l.to.box, - port: l.to.name, - link: l.id, - text: tl("NotPassed", fill), - }); - } else if (res.why) { - const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류"; - const key = - res.why === "표에 없는 조건" - ? "NoRow" - : res.why === "표에 여럿 맞음" - ? "ManyRows" - : "RuleError"; - const text = tl(key, fill); - const brk: CfBreak = { - kind, - level: "문제", - box: l.to.box, - port: l.to.name, - link: l.id, - text, - }; - if ( - l.rule.kind === "lookup" && - lg.boxes.find((b) => b.id === l.to.box)?.kind === "function" - ) { - late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]); - return NA_VALUE; - } - found.push(brk); - errors.push(text); - } - if (res.why) blocked.add(l.to.box); - return res.why ? null : res.value; - }; - /** 받는 값 — 쉬는 갈래에서 온 빈 값은 「쉼」 줄만 남김 · 들어오는 선이 모두 쉬는 갈래면 받는 상자도 쉼 - * (하나라도 살아 있으면 셈 · 쉬는 갈래 값은 빈 칸) · 조건 선 바로 뒤 상자는 through 가 막음 */ - const arrive = (box: CfBox): Record => { - const ins = lg.links.filter((l) => l.to.box === box.id); - const got: Record = {}; - let rest = 0; - for (const l of ins) { - if (resting.has(l.from.box) && valueOf(l.from.box, l.from.name) === null) { - rest++; - got[l.to.name] = null; - const text = tl("Rest", { from: name(l.from.box), box: name(l.to.box) }); - found.push({ - kind: "통과 안 됨", - level: "쉼", - box: l.to.box, - port: l.to.name, - link: l.id, - text, - }); - continue; - } - got[l.to.name] = through(l); - } - if (ins.length && rest === ins.length) { - resting.add(box.id); - blocked.add(box.id); - } - arrived[box.id] = got; - return got; - }; - const { boxes, loop } = order(lg); - for (const b of loop) errors.push(tl("Loop", { box: b.name })); - const outputs: CfEvalResult["outputs"] = {}; - const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - for (const b of boxes) { - const given = values[b.id] ?? {}; - if (b.kind === "input" && b.input?.mode !== "logic") { - ports[b.id] = inputPorts(lg, b, given); - for (const e of b.input?.elements ?? []) - if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) { - const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) }); - found.push({ - kind: "맞는 재료 없음", - level: "문제", - box: b.id, - port: elementPort(e), - text, - }); - errors.push(text); - } - } else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors); - else if (b.kind === "function") { - const got = arrive(b); - const out = blocked.has(b.id) ? null : runFunc(b, got, given, errors); - ports[b.id] = { [b.func!.output.name]: out }; - if (out === null || isErrorValue(out)) - for (const brk of late.get(b.id) ?? []) { - found.push(brk); - errors.push(brk.text); - } - } else { - const v = arrive(b)[OUTPUT_PORT] ?? null; - let scalar: Scalar = Array.isArray(v) ? null : v; - const round = b.output?.round; - if (typeof scalar === "number" && round) scalar = applyRound(round, scalar); - outputs[b.id] = scalar; - const kind = b.output?.cost ?? "값"; - if (kind === "값") cost.값들[b.name] = scalar; - else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; - else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); - } - } - cost.계 = cost.노무비 + cost.재료비 + cost.경비; - gates(lg, passed, name, found, errors); - return { ports, arrived, outputs, cost, errors, breaks: found }; -} - -/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */ -function otherLogic( - box: CfBox, - arrive: Record, - given: Record, - opts: CfEvalOptions, - errors: string[], -): Record { - const ref = box.input?.logic; - const out: Record = {}; - // 서버가 받은 설계 값으로 셈한 출력(Entry_Detail 다른 로직 물음)이 있으면 그것 — 그 안 단가 · 조건 찾기까지 정본 - if (ref?.outputs.some((p) => given[p.name] !== undefined)) { - for (const p of ref.outputs) out[p.name] = given[p.name] ?? null; - return out; - } - const other = ref ? opts.logics?.[ref.key] : undefined; - if (!other || (opts.depth ?? 0) > 8) { - if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name })); - for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null; - return out; - } - const inner: CfValues = {}; - for (const d of designInputs(other)) { - // 값 차례 = 이어진 선 > 넘긴 값 > 상자에 둔 예시값 > 그 로직 자기 예시값(inner 에 안 넣음) - const v = d.port in arrive ? arrive[d.port] : (given[d.port] ?? ref?.examples?.[d.port]); - if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v; - } - const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 }); - for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`); - for (const p of ref?.outputs ?? []) { - const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name); - out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null); - } - return out; -} - -/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */ -function gates( - lg: CfLogic, - passed: Map, - name: (id: string) => string, - found: CfBreak[], - errors: string[], -): void { - const groups = new Map(); - for (const l of lg.links) - if (l.rule?.kind === "gate") { - // 같은 알약에서 나가 같은 조건 알약들로 가르는 선끼리 — 여러 조건이면 조건 알약 묶음이 같을 때 - const srcs = ruleSources(l.rule) - .map((p) => `${p.box}|${p.name}`) - .sort() - .join("+"); - const k = `${srcs}|${l.from.box}|${l.from.name}`; - groups.set(k, [...(groups.get(k) ?? []), l]); - } - for (const list of groups.values()) { - if (list.length < 2) continue; - const n = list.filter((l) => passed.get(l.id)).length; - if (n === 1) continue; - const g = gateConds(list[0].rule as CfGateRule).conds[0].from; - const fill = { box: name(g.box), pill: g.name }; - const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill); - found.push({ - kind: n > 1 ? "조건 겹침" : "조건 안 맞음", - level: "문제", - box: g.box, - port: g.name, - text, - }); - errors.push(text); - } -} - -/* ── 검사 ───────────────────────────────────────────────────────────────── */ - -/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */ -export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] { - const out: CfBreak[] = []; - for (const b of lg.boxes) { - if (b.kind === "input") continue; - for (const p of portsIn(b)) - if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) - out.push({ - kind: "빈 받는 자리", - level: "문제", - box: b.id, - port: p.name, - text: tl("EmptyIn", { box: b.name, pill: p.name }), - }); - } - return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])]; -} - -/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ -export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] { - const out: string[] = []; - for (const b of lg.boxes) { - if (b.kind !== "input") - for (const p of portsIn(b)) - if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) - out.push(tl("EmptyIn", { box: b.name, pill: p.name })); - if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) - out.push(tl("NoCell", { box: b.name })); - for (const i of b.func?.inputs ?? []) - if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); - } - if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); - const ev = evalLogic(lg, values, opts); - for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic")) - for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {})) - if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill)) - out.push(tl("NoValue", { box: b.name, pill })); - return [...new Set([...out, ...ev.errors])]; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts deleted file mode 100644 index 97b02b364..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts +++ /dev/null @@ -1,291 +0,0 @@ -/* ============================================================================= - * M02_DF_FormulaView.ts - * 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 · - * IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀 - * 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; -import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text"; -import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { CfBox } from "./M02_DF_Model"; -import { createEmpty } from "@ui/ui_template_empty"; - -export type CellScalar = number | string | boolean | null; - -export interface FormulaBranch { - when: string; - then: string; - /** 지금 타는 갈래 */ - now: boolean; -} - -export interface FormulaLine { - /** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */ - name: string; - /** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */ - named: string; - /** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */ - valued: string; - /** IF 면 만약 / 아니면 줄 */ - branches?: FormulaBranch[]; -} - -const TEXT = { - Title: ["식 보기", "Formulas"], - Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"], - Else: ["아니면", "Otherwise"], - If: ["만약", "If"], - Now: ["지금", "now"], - NoValue: ["값 없음", "no value"], -} as const; - -const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */ - -const REF = /(? string): string { - return text - .split(/("(?:[^"]|"")*")/) - .map((p, i) => (i % 2 ? p : fn(p))) - .join(""); -} - -const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, ""); - -/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */ -export function cellNames(box: CfBox): Record { - const f = box.func; - const out: Record = {}; - if (!f) return out; - for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port; - for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name; - if (f.output.cell) out[upper(f.output.cell)] = f.output.name; - return out; -} - -const refsIn = (formula: string): string[] => { - const found: string[] = []; - outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m))); - return found; -}; - -const OPS: [RegExp, string][] = [ - [/\*/g, " × "], - [/\//g, " ÷ "], - [/<>/g, " ≠ "], - [/>=/g, " ≥ "], - [/<=/g, " ≤ "], - [/(?≠≥≤])=/g, " = "], - [/\+/g, " + "], - [/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "], -]; - -/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */ -function pretty(text: string): string { - return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p)) - .replace(/\s{2,}/g, " ") - .trim(); -} - -const fmt = (n: number): string => - Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); - -/** 오류 글(#N/A …)은 값이 없다는 뜻 — 따옴표째 보이지 않음 */ -const isError = (v: CellScalar | undefined): boolean => - typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); - -function showScalar(v: CellScalar | undefined): string { - if (isError(v)) return tv("NoValue"); - if (v === undefined || v === null) return "0"; - if (typeof v === "number") return fmt(v); - if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; - return `"${v}"`; -} - -/** 이름으로 쓴 식 */ -export function namedText(formula: string, names: Record): string { - return pretty( - outside(formula, (p) => - p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`), - ), - ); -} - -/** 값 넣은 식(값이 문자면 따옴표) */ -export function valuedText(formula: string, values: Record): string { - return pretty( - outside(formula, (p) => - p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])), - ), - ); -} - -/* ── 갈래 ───────────────────────────────────────────────────────────────── */ - -const num = (v: CellScalar | undefined): number | null => { - if (typeof v === "number") return v; - if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); - return null; -}; - -/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */ -function holds(cond: string, values: Record): boolean | null { - const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond); - if (!m) return null; - const read = (t: string): CellScalar | undefined => { - const x = t.trim(); - const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x); - if (cell) return values[`${cell[1]}${cell[2]}`]; - const q = /^"((?:[^"]|"")*)"$/.exec(x); - if (q) return q[1].replace(/""/g, '"'); - return Number.isFinite(Number(x)) ? Number(x) : undefined; - }; - const a = read(m[1]); - const b = read(m[3]); - if (a === undefined || b === undefined) return null; - const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)]; - switch (m[2]) { - case "=": - return l === r; - case "<>": - return l !== r; - case ">=": - return l >= r; - case "<=": - return l <= r; - case ">": - return l > r; - default: - return l < r; - } -} - -/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */ -function branchesOf( - formula: string, - names: Record, - values: Record, -): FormulaBranch[] | undefined { - const out: FormulaBranch[] = []; - let rest = formula; - let taken = false; - for (;;) { - const body = ifBody(rest); - if (body === null) break; - const a = splitArgs(body); - if (a.length !== 3) return undefined; - const ok = holds(a[0], values) === true && !taken; - if (ok) taken = true; - out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok }); - rest = a[2]; - } - if (!out.length) return undefined; - out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken }); - return out; -} - -/* ── 줄 ─────────────────────────────────────────────────────────────────── */ - -/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */ -export function formulaLines(box: CfBox, values: Record): FormulaLine[] { - const f = box.func; - if (!f?.output.cell) return []; - const names = cellNames(box); - const cells = f.sheet.칸; - const order: string[] = []; - const seen = new Set(); - const visit = (a1: string): void => { - const key = upper(a1); - if (seen.has(key)) return; - seen.add(key); - const formula = cells[key]?.식; - if (formula === undefined) return; - for (const r of refsIn(formula)) visit(r); - order.push(key); - }; - visit(f.output.cell); - return order.map((key) => { - const formula = cells[key].식 as string; - const result = showScalar(values[key]); - const branches = branchesOf(formula, names, values); - return { - name: names[key] ?? key, - named: namedText(formula, names), - valued: `${valuedText(formula, values)} = ${result}`, - ...(branches ? { branches } : {}), - }; - }); -} - -/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */ -export function cellValuesOf( - box: CfBox, - arrived: Record | undefined, -): Record { - const f = box.func; - if (!f) return {}; - const sheet = structuredClone(f.sheet); - const scalar = (v: unknown): number | string | boolean | undefined => - typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined; - const put = (cell: string, v: number | string | boolean | undefined): void => { - if (!parseA1(cell) || v === undefined) return; - sheet.칸[upper(cell)] = { 값: v }; - }; - for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port])); - for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example); - const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); - const out: Record = {}; - for (const [key, cell] of Object.entries(sheet.칸)) { - const c: CalcValue | undefined = calc[sheet.id]?.[key]; - out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null; - } - return out; -} - -function fromCalc(v: CalcValue): CellScalar { - if ("수" in v) return Number(v.수); - if ("글" in v) return v.글; - if ("참" in v) return v.참; - return v.오류; -} - -/* ── 그리기 ─────────────────────────────────────────────────────────────── */ - -/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */ -export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void { - host.classList.add("ui-card", "m02-df-formula"); - host.replaceChildren( - el("strong", { className: "m02-df-formula__title", text: tv("Title") }), - ...(lines.length - ? lines.map((l) => - el("div", { - className: "m02-df-formula__line", - children: [ - el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }), - el("div", { className: "m02-df-formula__valued", text: l.valued }), - ...(l.branches - ? [ - el("ul", { - className: "m02-df-formula__branches", - children: l.branches.map((b, i) => - el("li", { - className: b.now ? "is-now" : "", - text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`, - }), - ), - }), - ] - : []), - ], - }), - ) - : [createEmpty({ text: tv("Empty"), small: true })]), - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts deleted file mode 100644 index 287a19351..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* ============================================================================= - * M02_DF_FuncExtra.ts - * 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것 - * 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { showToast } from "@ui/ui_template_elements"; -import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition"; -import { renderCellTable } from "./M02_DF_CellTable"; -import type { DetailOptions } from "./M02_DF_Detail"; -import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView"; -import { TEXT } from "./M02_DF_FuncText"; -import { valueChoices, type CfFunc, type CfLogic, type CfVariable } from "./M02_DF_Model"; - -export interface ExtraContext { - lg: CfLogic; - boxId: string; - detail: DetailOptions; - /** 선 규칙을 지나 들어온 값 */ - arrived: Record; - readOnly: boolean; - place(port: string, cell: string): void; - setOutput(cell: string): void; - patch(make: (f: CfFunc) => CfFunc): void; - used(f: CfFunc): Map; -} - -const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0]; -const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId); - -/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */ -export function drawFormula(host: HTMLElement, x: ExtraContext): void { - const box = boxOf(x); - if (!box?.func) return; - renderFormulaView(host, formulaLines(box, cellValuesOf(box, x.arrived))); -} - -/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */ -export function drawCells(host: HTMLElement, x: ExtraContext): void { - const box = boxOf(x); - if (!box?.func) return; - renderCellTable(host, x.lg, box, x.readOnly, { - move: (kind, key, cell) => { - if (kind === "in") return x.place(key, cell); - if (kind === "out") return x.setOutput(cell); - if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error"); - x.patch((f) => ({ - ...f, - variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)), - })); - }, - release: (kind, key) => - x.patch((f) => - kind === "in" - ? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) } - : kind === "var" - ? { ...f, variables: f.variables.filter((v) => v.name !== key) } - : { ...f, output: { ...f.output, cell: "" } }, - ), - }); -} - -/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 - * get 으로 부를 때마다 지금 상태를 다시 읽음(나중에 놓은 알약 · 만든 변수도 목록에 뜸) */ -export function conditionsOf(get: () => ExtraContext): ConditionOptions { - return { - valuesFor: (a1) => { - const x = get(); - const f = boxOf(x)?.func; - const at = a1.toUpperCase(); - const v = f?.variables.find((y) => y.cell?.toUpperCase() === at); - if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; - const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port; - const link = port - ? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port) - : undefined; - return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null; - }, - pills: () => { - const x = get(); - const f = boxOf(x)?.func; - return [ - ...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })), - ...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })), - ]; - }, - }; -} - -/** 변수 만들기 폼 → 변수 · 틀리면 까닭(글 열쇠) — 같은 이름 · 이미 쓰는 칸 · 숫자 아님 · 범위 거꾸로 */ -export interface VarFormValues { - name: string; - type: CfVariable["type"]; - unit: string; - min: string; - max: string; - choices: string; - example: string; -} - -export function variableFrom( - form: VarFormValues, - f: CfFunc, - cell: string, - used: (f: CfFunc) => Map, -): - | { v: CfVariable; cell: string } - | { why: "NeedName" | "SameName" | "Busy" | "NotNumber" | "BadRange" } { - const name = form.name.trim(); - if (!name) return { why: "NeedName" }; - const mine = f.variables.find((v) => v.name === name); - if (!mine && [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)].includes(name)) - return { why: "SameName" }; - if (used({ ...f, variables: f.variables.filter((v) => v.name !== name) }).has(cell)) - return { why: "Busy" }; - const v: CfVariable = { - name, - type: form.type, - unit: form.unit.trim() || "-", - example: null, - cell, - }; - let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); - if (form.type === "number") { - if (example !== null) { - const n = Number(String(example).replace(/,/g, "")); - if (!Number.isFinite(n)) return { why: "NotNumber" }; - example = n; - } - const lo = form.min.trim() === "" ? null : Number(form.min); - const hi = form.max.trim() === "" ? null : Number(form.max); - if (lo !== null && hi !== null) { - if (!(lo < hi)) return { why: "BadRange" }; - v.range = [lo, hi]; - } - } else if (form.type === "choice") { - v.choices = form.choices - .split(",") - .map((x) => x.trim()) - .filter(Boolean); - } - v.example = example; - return { v, cell }; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css deleted file mode 100644 index 70e698edd..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ /dev/null @@ -1,210 +0,0 @@ -.m02-dfpanel { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m02-dfpanel-head, -.m02-dfpanel-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m02-dfpanel-head { - justify-content: space-between; -} - -.m02-dfpanel-title { - font-size: var(--text-subheading); -} - -.m02-dfpanel-unitbox { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - margin-left: auto; -} - -.m02-dfpanel-unit { - width: 6em; - text-align: center; -} - -.m02-dfpanel-label { - font-weight: var(--font-weight-bold); -} - -/* 겉 = 카드 부품 · 여기는 아래 맞춤 · 안 여백만 */ -.m02-dfpanel-form.ui-card { - align-items: flex-end; - padding: var(--spacing-8) var(--spacing-12); -} - -.m02-dfpanel-fieldbox { - display: inline-flex; - flex-direction: column; - gap: 2px; -} - -.m02-dfpanel-field { - width: 9em; -} - -.m02-dfpanel-field.is-num { - width: 6em; -} - -/* 알림 = 띠 알림 부품 — 숨김은 부품 display 를 이김 */ -.m02-dfpanel-msg { - margin: 0; -} - -.m02-dfpanel-msg[hidden] { - display: none; -} - -/* 받음 · 변수 · 출력 알약 = 배지 부품(info · warning · success) — 안 놓인 받음은 점선 */ -.m02-dfpanel-chip { - font-family: inherit; -} - -button.m02-dfpanel-chip { - cursor: pointer; -} - -button.m02-dfpanel-chip:focus-visible { - outline: 2px solid var(--color-focus-ring); -} - -.m02-dfpanel-chip.is-in:not(.is-placed) { - border-color: var(--badge-tone); - border-style: dashed; -} - -/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ -.m02-dfpanel-sheet { - position: relative; - height: max(640px, calc(100dvh - 260px)); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - overflow: hidden; -} - -/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */ -.m02-dfpanel .aislo-cell[data-cf-kind="in"] { - box-shadow: inset 0 0 0 2px var(--color-info); -} - -.m02-dfpanel .aislo-cell[data-cf-kind="var"] { - box-shadow: inset 0 0 0 2px var(--color-warning); -} - -.m02-dfpanel .aislo-cell[data-cf-kind="out"] { - box-shadow: inset 0 0 0 2px var(--color-success); -} - -.m02-dfpanel .aislo-cell[data-cf-kind]::after { - content: attr(data-cf-name); - position: absolute; - top: 0; - right: 0; - padding: 0 4px; - border-radius: 0 0 0 6px; - background: var(--color-surface); - color: var(--color-text); - font-size: 10px; - line-height: 14px; - pointer-events: none; -} - -.m02-dfpanel .aislo-cell[data-cf-lock]::before { - content: attr(data-cf-lock); - position: absolute; - bottom: 0; - left: 0; - padding: 0 4px; - background: var(--color-surface); - font-size: 10px; - line-height: 14px; - pointer-events: none; -} - -.m02-dfpanel .aislo-cell[data-cf-err] { - outline: 2px dashed var(--color-danger); - outline-offset: -2px; -} - -/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */ -.m02-dfpanel-body { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: var(--spacing-12); -} - -.m02-dfpanel-body > .m02-dfpanel-sheet { - flex: 1 1 28em; - min-width: 0; -} - -.m02-dfcells { - flex: 0 1 24em; - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-dfcells__title { - font-weight: var(--font-weight-bold); -} - -.m02-dfcells__cell { - width: 5em; -} - -.m02-dfcells .ui-input.is-bad { - border-color: var(--color-danger); - border-style: dashed; -} - -/* 칸 종류 = 배지 부품(받음 info · 변수 warning · 출력 success) */ - -/* 식 보기 */ -/* 겉 = 카드 부품 · 여기는 쌓기 · 안 여백만 */ -.m02-df-formula.ui-card { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8) var(--spacing-12); -} - -.m02-df-formula__title { - font-weight: var(--font-weight-bold); -} - -.m02-df-formula__line { - display: flex; - flex-direction: column; - gap: 2px; - padding-top: var(--spacing-4); - border-top: 1px dashed var(--color-border); -} - -.m02-df-formula__valued { - color: var(--color-text-secondary); -} - -.m02-df-formula__branches { - margin: 0; - padding-left: var(--spacing-16); - list-style: none; -} - -.m02-df-formula__branches .is-now { - font-weight: var(--font-weight-bold); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts deleted file mode 100644 index 74984eed0..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ /dev/null @@ -1,716 +0,0 @@ -/* ============================================================================= - * M02_DF_FuncPanel.ts - * 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) · - * 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류 - * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로 - * 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { - CalcValues, - SpreadsheetHandle, - Workbook, -} from "../../A00_Common/spreadsheet/spreadsheet"; -import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; -import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; -import { - conditionsOf, - drawCells, - drawFormula, - variableFrom, - type ExtraContext, -} from "./M02_DF_FuncExtra"; -import { createRoundRow } from "./M02_DF_RoundRow"; -import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; -import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; -import "./M02_DF_FuncPanel.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_banner.css"; -import "@ui/ui_template_badge.css"; - -type Handlers = { change(lg: CfLogic): void }; - -interface VarForm { - name: string; - type: CfVariable["type"]; - unit: string; - min: string; - max: string; - choices: string; - example: string; - why: string; -} - -interface State { - boxId: string; - lg: CfLogic; - on: Handlers; - readOnly: boolean; - /** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ - detail: DetailOptions; - handle: SpreadsheetHandle | null; - /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ - snap: CalcValues; - /** 선 규칙을 지나 이 상자 받는 알약에 들어온 값 */ - arrived: Record; - /** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */ - json: string; - cell: string; - form: VarForm | null; - /** 단위 표준이 제안한 출력 소수자리 */ - suggest: number | null; - parts: { - head: HTMLElement; - formula: HTMLElement; - cells: HTMLElement; - got: HTMLElement; - vars: HTMLElement; - bar: HTMLElement; - msg: HTMLElement; - sheet: HTMLElement; - }; - observer: MutationObserver | null; - redraw(): void; -} - -const states = new WeakMap(); -let sheetModule: Promise | null = null; -/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ -const loadSheet = (): Promise => - (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); - -const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId); -const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func; -const bookOf = (sheet: Sheet): Workbook => ({ - 종류: "통합문서", - 판: 1, - 열: "cf", - 서식: [{}], - 시트: [structuredClone(sheet)], - 활성: sheet.id, -}); - -/** 보이는 시트 — 받은 알약 칸에는 선 규칙을 지나 들어온 값(arrived)을 넣어 보임(저장하는 시트에는 안 넣음) */ -function withArrived(f: CfFunc, base: Sheet, arrived: Record): Sheet { - const sheet = structuredClone(base); - for (const i of f.inputs) { - const v = arrived[i.port]; - if (i.cell && (typeof v === "number" || typeof v === "string" || typeof v === "boolean")) - sheet.칸[i.cell.toUpperCase()] = { 값: v }; - } - return sheet; -} -const shownOf = (st: State): Sheet => withArrived(funcOf(st)!, funcOf(st)!.sheet, st.arrived); - -/* ------------------------------ 바꾸기 ------------------------------ */ - -function patchBox(st: State, make: (b: CfBox) => CfBox): void { - const next: CfLogic = { - ...st.lg, - boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)), - }; - st.lg = next; - st.on.change(next); -} - -const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void => - patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b)); - -const usedCells = (f: CfFunc): Map => { - const m = new Map(); - for (const i of f.inputs) m.set(i.cell, i.port); - for (const v of f.variables) if (v.cell) m.set(v.cell, v.name); - if (f.output.cell) m.set(f.output.cell, f.output.name); - return m; -}; - -function placePort(st: State, port: string, cell: string): void { - const f = funcOf(st); - if (!f || st.readOnly) return; - const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell); - if (other) return void showToast(tf("Busy"), "error"); - patchFunc(st, (x) => ({ - ...x, - inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }], - })); -} - -function makeVariable(st: State): void { - const form = st.form; - const f = funcOf(st); - if (!form || !f) return; - const made = variableFrom(form, f, st.cell, usedCells); - if ("why" in made) { - form.why = tf(made.why); - return drawVars(st); - } - const { v, cell } = made; - st.form = null; - patchFunc(st, (x) => { - const sheet = structuredClone(x.sheet); - if (v.example !== null) sheet.칸[cell] = { 값: v.example }; - return { - ...x, - sheet, - inputs: x.inputs.filter((i) => i.cell !== cell), - variables: [...x.variables.filter((o) => o.name !== v.name && o.cell !== cell), v], - }; - }); -} - -/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */ -function incoming(st: State): { port: string; unit: string }[] { - const out = new Map(); - for (const l of st.lg.links) { - if (l.to.box !== st.boxId || out.has(l.to.name)) continue; - const src = st.lg.boxes.find((b) => b.id === l.from.box); - const unit = - src?.func && src.func.output.name === l.from.name - ? src.func.output.unit - : (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ?? - src?.input?.variables.find((v) => v.name === l.from.name)?.unit ?? - src?.unit ?? - "-"); - out.set(l.to.name, unit || "-"); - } - // 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리) - for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-"); - return [...out].map(([port, unit]) => ({ port, unit })); -} - -function problems(st: State): string[] { - const f = funcOf(st); - if (!f) return []; - const out: string[] = []; - for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) { - if (typeof v === "object" && "오류" in v) { - out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`); - } - } - for (const p of incoming(st)) { - if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`); - } - if (!f.output.cell) out.push(tf("OutNone")); - return out; -} - -/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */ - -/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */ -const extra = (st: State): ExtraContext => ({ - lg: st.lg, - boxId: st.boxId, - detail: st.detail, - arrived: st.arrived, - readOnly: st.readOnly, - place: (port, cell) => placePort(st, port, cell), - setOutput: (cell) => setOutput(st, cell), - patch: (make) => patchFunc(st, make), - used: (f) => usedCells(f), -}); - -/* ------------------------------ 칸 위 표시 ------------------------------ */ - -function applyMarks(st: State): void { - const f = funcOf(st); - if (!f) return; - const marks = new Map(); - for (const i of f.inputs.filter((x) => x.cell)) - marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); - for (const v of f.variables) - if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); - if (f.output.cell) - marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); - const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell)); - const list = problems(st); - const bad = new Set(list.map((p) => p.split(" ")[0])); - for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { - const addr = c.dataset.addr ?? ""; - for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a]; - const m = marks.get(addr); - if (m) { - c.dataset.cfKind = m.kind; - c.dataset.cfName = m.label; - } - if (locked.has(addr)) c.dataset.cfLock = "🔒"; - if (bad.has(addr)) c.dataset.cfErr = "1"; - } - st.parts.msg.textContent = list.length - ? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}` - : ""; - st.parts.msg.hidden = !list.length; -} - -/* ------------------------------ 위 줄들 ------------------------------ */ - -function drawHead(st: State): void { - const box = boxOf(st); - const unit = createUnitPick({ - value: box?.unit ?? "-", - readOnly: st.readOnly, - extra: st.lg.resultUnit ? [st.lg.resultUnit] : [], - ariaLabel: tf("Unit"), - onPick: (v) => { - patchBox(st, (x) => ({ - ...x, - unit: v, - func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func, - })); - void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st))); - }, - }); - st.parts.head.replaceChildren( - el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "24" }, - text: `${tf("Cell")} ${st.cell}`, - }), - el("label", { - className: "m02-dfpanel-unitbox", - children: [el("span", { className: "ui-text ui-text--muted", text: tf("Unit") }), unit], - }), - ); -} - -function drawGot(st: State): void { - const f = funcOf(st); - const items = incoming(st); - const chips = items.map(({ port, unit }) => { - const at = f?.inputs.find((i) => i.port === port)?.cell; - const b = el("button", { - className: `ui-badge ui-badge--pill ui-badge--info m02-dfpanel-chip is-in${at ? " is-placed" : ""}`, - text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`, - attrs: { type: "button", "data-size": "24", draggable: st.readOnly ? "false" : "true" }, - }); - b.addEventListener("click", () => placePort(st, port, st.cell)); - b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port)); - return b; - }); - st.parts.got.replaceChildren( - el("span", { className: "m02-dfpanel-label", text: tf("Got") }), - ...(chips.length - ? chips - : [el("span", { className: "ui-text ui-text--muted", text: tf("NoGot") })]), - ...(chips.length && !st.readOnly - ? [el("span", { className: "ui-text ui-text--muted", text: tf("GotHint") })] - : []), - ); -} - -function formField( - st: State, - label: keyof typeof TEXT, - key: "name" | "unit" | "min" | "max" | "choices" | "example", - cls = "", -): HTMLElement { - const form = st.form!; - if (key === "unit") { - return el("label", { - className: "m02-dfpanel-fieldbox", - children: [ - el("span", { className: "ui-text ui-text--muted", text: tf(label) }), - createUnitPick({ - value: form.unit || "-", - ariaLabel: tf(label), - onPick: (u) => ((form.unit = u), drawVars(st)), - }), - ], - }); - } - const input = el("input", { - className: `ui-input ui-input--sm m02-dfpanel-field ${cls}`, - attrs: { type: "text", "aria-label": tf(label) }, - }); - input.value = form[key]; - input.addEventListener("input", () => (form[key] = input.value)); - input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st)); - return el("label", { - className: "m02-dfpanel-fieldbox", - children: [el("span", { className: "ui-text ui-text--muted", text: tf(label) }), input], - }); -} - -function drawVars(st: State): void { - const f = funcOf(st); - const row: HTMLElement[] = [el("span", { className: "m02-dfpanel-label", text: tf("Vars") })]; - for (const v of f?.variables ?? []) { - const chip = el("span", { - className: "ui-badge ui-badge--pill ui-badge--warning m02-dfpanel-chip is-var", - attrs: { "data-size": "24" }, - children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`], - }); - if (!st.readOnly) { - chip.append( - createButton({ - label: "×", - variant: "ghost", - size: "sm", - onClick: () => - patchFunc(st, (x) => ({ - ...x, - variables: x.variables.filter((o) => o.name !== v.name), - })), - }), - ); - chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`); - } - row.push(chip); - } - if (!f?.variables.length) - row.push(el("span", { className: "ui-text ui-text--muted", text: tf("NoVars") })); - const parts: HTMLElement[] = [el("div", { className: "m02-dfpanel-row", children: row })]; - if (!st.readOnly) { - const form = st.form; - if (!form) { - parts.push( - el("div", { - className: "m02-dfpanel-row", - children: [ - createButton({ - label: tf("MakeVar"), - variant: "ghost", - size: "sm", - onClick: () => { - st.form = { - name: "", - type: "number", - unit: "", - min: "", - max: "", - choices: "", - example: "", - why: "", - }; - drawVars(st); - }, - }), - ], - }), - ); - } else { - const type = el("select", { - className: "ui-select ui-select--sm m02-dfpanel-field", - attrs: { "aria-label": tf("VarType") }, - }); - for (const [v, k] of [ - ["number", "Number"], - ["choice", "Choice"], - ["text", "Text"], - ] as const) { - type.append(el("option", { attrs: { value: v }, text: tf(k) })); - } - type.value = form.type; - type.addEventListener("change", () => { - form.type = type.value as CfVariable["type"]; - drawVars(st); - }); - parts.push( - el("div", { - className: "ui-card m02-dfpanel-row m02-dfpanel-form", - children: [ - formField(st, "VarName", "name"), - el("label", { - className: "m02-dfpanel-fieldbox", - children: [ - el("span", { className: "ui-text ui-text--muted", text: tf("VarType") }), - type, - ], - }), - formField(st, "VarUnit", "unit"), - ...(form.type === "number" - ? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")] - : []), - ...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []), - formField(st, "VarExample", "example"), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "24" }, - text: `${tf("VarCell")} ${st.cell}`, - }), - createButton({ - label: tf("Make"), - variant: "filled", - size: "sm", - onClick: () => makeVariable(st), - }), - createButton({ - label: tf("Cancel"), - variant: "ghost", - size: "sm", - onClick: () => ((st.form = null), drawVars(st)), - }), - ...(form.why - ? [el("span", { className: "ui-text ui-text--warning", text: `⚠ ${form.why}` })] - : []), - ], - }), - ); - } - } - st.parts.vars.replaceChildren(...parts); -} - -function drawBar(st: State): void { - const f = funcOf(st); - if (st.readOnly) { - st.parts.bar.replaceChildren( - el("span", { className: "ui-text ui-text--muted", text: tf("ReadOnly") }), - ); - return; - } - const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone"); - st.parts.bar.replaceChildren( - el("div", { - className: "m02-dfpanel-row", - children: [ - createButton({ - label: tf("OutBtn"), - variant: "ghost", - size: "sm", - onClick: () => setOutput(st, st.cell), - }), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--success m02-dfpanel-chip is-out", - attrs: { "data-size": "24" }, - text: outNow, - }), - el("span", { className: "ui-text ui-text--muted", text: tf("OutOnly") }), - ], - }), - ...(f?.output.cell - ? [ - createRoundRow({ - round: f.output.round, - suggest: st.suggest, - readOnly: st.readOnly, - onApply: (round) => writeRound(st, round), - }), - ] - : []), - el("div", { - className: "m02-dfpanel-row", - children: [ - el("span", { className: "m02-dfpanel-label", text: tf("Frames") }), - ...FRAMES.map(([key, formula]) => - createButton({ - label: tf(key), - variant: "ghost", - size: "sm", - onClick: () => putFormula(st, formula), - }), - ), - el("span", { className: "ui-text ui-text--muted", text: tf("FrameHint") }), - ], - }), - ); -} - -/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */ -function writeRound(st: State, round: CfRound | null): void { - patchFunc(st, (x) => { - const { round: _old, ...rest } = x.output; - return { ...x, output: round ? { ...rest, round } : rest }; - }); -} - -/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ -function setOutput(st: State, cell: string): void { - const f = funcOf(st); - if (!f) return; - if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) { - return void showToast(tf("Busy"), "error"); - } - const row = cell.replace(/^[A-Z]+/, ""); - const label = f.sheet.칸[`A${row}`]; - const name = String(label?.값 ?? f.output.name ?? "").trim() || cell; - patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } })); -} - -/* ------------------------------ 편집기 ------------------------------ */ - -/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ -function putFormula(st: State, formula: string): void { - const root = st.parts.sheet; - const bar = root.querySelector(".ss-formula"); - const addr = root.querySelector(".ss-addr"); - if (!bar || !addr || st.readOnly) return; - const at = st.cell; - bar.focus(); - bar.value = formula; - bar.dispatchEvent(new Event("input", { bubbles: true })); - bar.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), - ); - addr.value = at; - addr.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), - ); -} - -/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */ -function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] { - for (const cell of Object.values(sheet.칸)) delete cell.서식; - return sheet; -} - -/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */ -function onBook(st: State, book: Workbook): void { - const f = funcOf(st); - if (!f) return; - const sheet = plain(structuredClone(book.시트[0])); - const shown = shownOf(st); - const hit = f.inputs.find( - (i) => - i.cell && - JSON.stringify(shown.칸[i.cell.toUpperCase()]) !== - JSON.stringify(sheet.칸[i.cell.toUpperCase()]), - ); - if (hit) { - showToast(tf("Locked"), "error"); - st.json = JSON.stringify(shown); - return st.handle?.setDoc(bookOf(shown)); - } - // 보이려고 넣은 들어온 값은 저장하는 시트에 남기지 않음 - for (const i of f.inputs) { - const key = i.cell.toUpperCase(); - if (!i.cell) continue; - if (f.sheet.칸[key]) sheet.칸[key] = f.sheet.칸[key]; - else delete sheet.칸[key]; - } - st.json = JSON.stringify(withArrived(f, sheet, st.arrived)); - patchFunc(st, (x) => ({ ...x, sheet })); - setTimeout(() => refreshSnap(st), 0); -} - -/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ -function refreshSnap(st: State): void { - if (!st.handle) return; - st.snap = st.handle.recalc(); - st.redraw(); -} - -async function mountSheet(st: State): Promise { - const { createSpreadsheet } = await loadSheet(); - const f = funcOf(st); - if (!st.parts.sheet.isConnected || st.handle || !f) return; - st.parts.sheet.replaceChildren(); - const shown = shownOf(st); - st.json = JSON.stringify(shown); - st.handle = createSpreadsheet(st.parts.sheet, bookOf(shown), { - preview: true, - tabs: false, - readOnly: st.readOnly, - conditions: conditionsOf(() => extra(st)), - onChange: (b) => onBook(st, b), - onSelect: (s) => { - st.cell = s.칸; - drawHead(st); - if (st.form) drawVars(st); - }, - }); - st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); - st.observer.observe(st.parts.sheet, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["data-addr"], - }); - st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault()); - st.parts.sheet.addEventListener("drop", (e) => { - e.preventDefault(); - const port = e.dataTransfer?.getData("text/plain"); - const cell = (e.target as Element | null)?.closest(".aislo-cell")?.dataset.addr; - if (port && cell) placePort(st, port, cell); - }); - refreshSnap(st); -} - -/** 이 상자 받는 알약에 선 규칙을 지나 들어온 값 — 셈이 막히면 빈 값 */ -const arrivedOf = (lg: CfLogic, boxId: string, o: DetailOptions): Record => - evalSafe(lg, o)?.arrived?.[boxId] ?? {}; - -/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ -export function renderFuncPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: Handlers, - opts: DetailOptions & { readOnly?: boolean } = {}, -): void { - const old = states.get(host); - const readOnly = !!opts.readOnly; - if (old && old.boxId === boxId && old.readOnly === readOnly) { - old.lg = lg; - old.on = on; - old.detail = opts; - old.arrived = arrivedOf(lg, boxId, opts); - const f = funcOf(old); - if (old.handle && f && JSON.stringify(shownOf(old)) !== old.json) { - old.json = JSON.stringify(shownOf(old)); - old.handle.setDoc(bookOf(shownOf(old))); - setTimeout(() => refreshSnap(old), 0); - return; - } - return old.redraw(); - } - if (old) { - old.observer?.disconnect(); - old.handle?.destroy(); - } - const parts: State["parts"] = { - head: el("div", { className: "m02-dfpanel-head" }), - formula: el("div"), - cells: el("div"), - got: el("div", { className: "m02-dfpanel-row" }), - vars: el("div", { className: "m02-dfpanel-vars" }), - bar: el("div", { className: "m02-dfpanel-bar" }), - msg: el("p", { - className: "ui-banner ui-banner--warning m02-dfpanel-msg", - attrs: { role: "status" }, - }), - sheet: el("div", { className: "m02-dfpanel-sheet", text: tf("Loading") }), - }; - const st: State = { - boxId, - lg, - on, - readOnly, - detail: opts, - arrived: arrivedOf(lg, boxId, opts), - handle: null, - snap: {}, - json: "", - cell: "A1", - form: null, - suggest: null, - parts, - observer: null, - redraw: () => { - drawHead(st); - drawGot(st); - drawVars(st); - drawBar(st); - drawFormula(st.parts.formula, extra(st)); - drawCells(st.parts.cells, extra(st)); - applyMarks(st); - }, - }; - states.set(host, st); - host.classList.add("m02-dfpanel"); - host.replaceChildren( - parts.head, - parts.formula, - parts.got, - parts.vars, - parts.bar, - parts.msg, - el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }), - ); - parts.msg.hidden = true; - st.redraw(); - void mountSheet(st); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts deleted file mode 100644 index b0d6e4643..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* ============================================================================= - * M02_DF_FuncText.ts - * 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; - -export const TEXT = { - Unit: ["단위", "Unit"], - Cell: ["고른 칸", "Selected cell"], - Got: ["받은 알약", "Received pills"], - GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], - NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], - Placed: ["놓임", "placed"], - Free: ["아직 안 놓은 알약", "not placed yet"], - Busy: ["이미 쓰는 칸", "That cell is already in use"], - Locked: [ - "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", - "This cell is filled by the wire — cannot edit", - ], - Vars: ["변수", "Variables"], - MakeVar: ["변수 만들기", "Make variable"], - NoVars: ["아직 변수가 없음", "No variables yet"], - VarName: ["이름", "Name"], - VarType: ["갈래", "Type"], - Number: ["숫자", "Number"], - Choice: ["선택", "Choice"], - Text: ["글", "Text"], - VarUnit: ["단위", "Unit"], - VarMin: ["최소", "Min"], - VarMax: ["최대", "Max"], - VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], - VarExample: ["예시값", "Example value"], - VarCell: ["붙을 칸", "Attach to"], - Make: ["만들기", "Make"], - Cancel: ["취소", "Cancel"], - Remove: ["지움", "Remove"], - NeedName: ["이름을 적음", "Enter a name"], - SameName: ["같은 이름이 이미 있음", "That name already exists"], - NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], - BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], - OutBtn: ["출력 칸으로", "Make output cell"], - OutNow: ["출력", "Output"], - OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], - OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], - Frames: ["틀", "Templates"], - FrameMul: ["곱하기", "Multiply"], - FrameMore: ["할증", "Markup"], - FrameDiv: ["나누기", "Divide"], - FrameIf: ["만약", "If"], - FrameMin: ["최소", "Min"], - FrameMax: ["최대", "Max"], - FrameUp: ["올림", "Round up"], - FrameLook: ["표에서 찾기", "Look up in table"], - FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], - ReadOnly: ["읽기만", "Read only"], - Loading: ["시트를 불러오는 중", "Loading sheet"], - Problem: ["⚠ ", "⚠ "], - Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], - Err_DIV0: ["0으로 나눔", "divides by zero"], - Err_NAME: ["알 수 없는 이름", "unknown name"], - Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], - Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], - Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], - Err_NA: ["찾는 값이 없음", "value not found"], - Err_OTHER: ["수식이 틀림", "formula is wrong"], -} as const; - -export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ -export const FRAMES: [keyof typeof TEXT, string][] = [ - ["FrameMul", "=B1*B2"], - ["FrameMore", "=B1*(1+B2/100)"], - ["FrameDiv", "=B1/B2"], - ["FrameIf", '=IF(B3="급",10,0)'], - ["FrameMin", "=MIN(B1,B2)"], - ["FrameMax", "=MAX(B1,B2)"], - ["FrameUp", "=ROUNDUP(B4,3)"], - ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], -]; - -export const ERR_KEY: Record = { - "#DIV/0!": "Err_DIV0", - "#NAME?": "Err_NAME", - "#REF!": "Err_REF", - "#VALUE!": "Err_VALUE", - "#CYCLE!": "Err_CYCLE", - "#N/A": "Err_NA", -}; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css deleted file mode 100644 index 98e17874c..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.css +++ /dev/null @@ -1,119 +0,0 @@ -.m02-dfip { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - color: var(--color-text); - font-size: var(--text-body-lg, 1.05rem); -} -.m02-dfip h4, -.m02-dfip h5 { - margin: 0; - color: var(--color-text-secondary); -} -.m02-dfip h4 { - font-size: 1.15em; - color: var(--color-text); -} -.m02-dfip__unit { - margin: 0; - font-weight: 600; -} -.m02-dfip__body, -.m02-dfip__chosen, -.m02-dfip__find, -.m02-dfip__stack, -.m02-dfip__list { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} -.m02-dfip__list { - max-height: 28vh; - overflow: auto; -} -.m02-dfip__chips { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} -/* 고르는 줄 = 목록 줄 부품(ui-list-row) — 여기는 안 여백 · 두 칸 벌림만 */ -button.m02-dfip__row { - padding: var(--spacing-8) var(--spacing-12); - display: flex; - flex: 1 1 auto; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; - text-align: left; -} -.m02-dfip__rowbox { - display: flex; - gap: var(--spacing-8); - align-items: center; -} -.m02-dfip__bar { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: center; -} -.m02-dfip__bar > :last-child { - flex: 1 1 180px; -} -.m02-dfip__item { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); -} -.m02-dfip__name { - flex: 1 1 auto; - min-width: 0; -} -.m02-dfip__value { - color: var(--color-accent); -} -/* 태그 = 배지 부품 — 이름 뒤 띄움만 */ -.m02-dfip__name .ui-badge { - margin-left: var(--spacing-8); -} -.m02-dfip__new, -.m02-dfip__form { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); - border: 1px dashed var(--color-border); - border-radius: var(--radius-cards); -} -.m02-dfip__new > summary { - cursor: pointer; - font-weight: 600; -} -/* 미리 보기 = 표 부품 데이터표 — 여기는 줄바꿈 막기 · 가운데 · 찾은 줄만 */ -.m02-dfip__preview table { - width: auto; -} -.m02-dfip__preview th, -.m02-dfip__preview td { - white-space: nowrap; - text-align: center; -} -.m02-dfip__preview tr.is-hit td { - background: color-mix(in srgb, var(--color-warning) 35%, transparent); - font-weight: 700; -} -.m02-dfip__preview { - max-height: 24vh; - overflow: auto; -} - -.m02-dfip__unitfield { - display: flex; - gap: var(--spacing-8); - align-items: center; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts deleted file mode 100644 index 2c43befb2..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ /dev/null @@ -1,429 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel.ts - * 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) · - * 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위 - * M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/df/user-elements` = 시험 폴더 `_data`) - * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위) - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import { - fetchFiles, - fetchRows, - fetchSubs, - type SubInfo, -} from "../../M01_MasterData/M01_MasterData_Api_Fetch"; -import { cfPlain, cfValues } from "./M02_DF_Api"; -import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model"; -import { - GROUPS, - RULE_GROUPS, - matchesRow, - removeElementNamed, - M01_OF, - addElement, - elementOf, - machineFields, - queryOf, - reconcileUnit, - removeElement, - setKind, - valueOf, - type M01Row, -} from "./M02_DF_InputPanel_Build"; -import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; -import { renderGroupRule } from "./M02_DF_InputPanel_Rule"; -import { renderMaterial } from "./M02_DF_InputPanel_Material"; -import { renderTablePick } from "./M02_DF_InputPanel_Table"; -import { renderVariables } from "./M02_DF_InputPanel_Vars"; -import "./M02_DF_InputPanel.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; - -/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */ -interface Answer { - id: string; - ok: boolean; - value?: number | string | null; -} -const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise; - -const TEXT = { - Kind: ["무엇을 넣을까요?", "What goes in?"], - Variable: ["변수", "Variable"], - ModeFixed: ["고정", "Fixed"], - ModeRule: ["조건 찾기", "Find by rule"], - RuleTag: ["조건 찾기", "Rule"], - RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"], - Logic: ["다른 로직", "Another logic"], - 인력: ["인력", "Labor"], - 재료: ["재료", "Material"], - 유가전력: ["유가·전력", "Fuel · power"], - 기계: ["기계", "Machine"], - 소요량: ["소요량", "Quantity table"], - 계수: ["계수", "Factor table"], - 환율: ["환율", "Exchange rate"], - 요율: ["요율", "Rate"], - Chosen: [ - "넣은 요소 — 알약마다 이름 · 값 · 단위", - "Chosen elements — name · value · unit per pill", - ], - NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"], - Remove: ["빼기", "Remove"], - Put: ["넣기", "Add"], - Search: ["이름으로 찾기", "Search by name"], - AllSub: ["전체", "All"], - SubLabel: ["구분", "Group"], - DetailLabel: ["상세구분", "Detail"], - Loading: ["찾는 중…", "Searching…"], - NoRow: ["찾는 요소 없음", "Nothing found"], - Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], - BoxUnit: ["상자 단위", "Box unit"], - Field: ["값 칸", "Value"], - NoValue: ["—", "—"], - Failed: ["불러오지 못함", "Could not load"], - Pills: ["알약 {n} 개", "{n} pills"], - NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); - -/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */ -const plain = (e: unknown): string => { - try { - return cfPlain(e); - } catch { - return e instanceof Error ? e.message : t("Failed"); - } -}; - -const fileCache = new Map(); -/** 그룹의 M01 파일 이름 */ -async function fileOf(group: CfGroup): Promise { - const hit = fileCache.get(group); - if (hit) return hit; - const { group: g, part } = M01_OF[group]; - const files = await fetchFiles(g); - const f = files.find((x) => !part || x.file.includes(part)) ?? files[0]; - fileCache.set(group, f.file); - return f.file; -} - -/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */ -export function renderInputPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, -): void { - host.className = "m02-dfip"; - const box = lg.boxes.find((b) => b.id === boxId); - if (!box || box.kind !== "input") { - host.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("NotInput") })); - return; - } - // 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감 - const fixed = reconcileUnit(lg, boxId); - if (fixed !== lg) return on.change(fixed); - const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; - const kind: CfGroup | "변수" | "다른 로직" = - input.mode === "variable" - ? "변수" - : input.mode === "logic" - ? "다른 로직" - : (input.group ?? "인력"); - const body = el("div", { className: "m02-dfip__body" }); - - // ① 갈래 — 그룹 여덟 + 다른 로직 + 변수 - const kinds = el("div", { - className: "m02-dfip__chips", - attrs: { role: "tablist", "aria-label": t("Kind") }, - children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) => - createButton({ - label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k), - variant: k === kind ? "filled" : "ghost", - size: "sm", - onClick: () => k !== kind && on.change(setKind(lg, boxId, k)), - }), - ), - }); - const unit = el("p", { - className: "m02-dfip__unit", - text: `${t("BoxUnit")}: ${box.unit || "-"}`, - }); - host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); - - if (kind === "변수") renderVariables(body, lg, boxId, on); - else if (kind === "다른 로직") renderLogicInput(body, lg, boxId, on); - else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); - else if (kind === "재료") renderMaterial(body, lg, boxId, on, (l, e) => addElement(l, boxId, e)); - else renderGroup(body, lg, box, kind, on); -} - -/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */ -function renderGroup( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): void { - const chosen = el("div", { className: "m02-dfip__chosen" }); - const body = el("div", { className: "m02-dfip__stack" }); - let mode: "fixed" | "rule" = "fixed"; - const draw = (): void => { - if (mode === "rule") - renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on); - else { - const find = el("div", { className: "m02-dfip__find" }); - body.replaceChildren(find); - void drawFind(find, lg, box, group, on); - } - }; - // 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만 - const chips = el("div", { className: "m02-dfip__chips" }); - const drawChips = (): void => - chips.replaceChildren( - ...(["fixed", "rule"] as const).map((m) => - createButton({ - label: m === "fixed" ? t("ModeFixed") : t("ModeRule"), - variant: mode === m ? "filled" : "ghost", - size: "sm", - onClick: () => ((mode = m), drawChips(), draw()), - }), - ), - ); - host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body); - drawChosen(chosen, lg, box, group, on); - if (RULE_GROUPS.includes(group)) drawChips(); - draw(); -} - -/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */ -async function rulePreview( - group: CfGroup, - field: "이름" | "키", - value: unknown, -): Promise<{ found: number; names: string[]; unit: string }> { - if (value === null || value === undefined || value === "") - return { found: 0, names: [], unit: "" }; - const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", ""); - const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value)); - const units = new Set(hit.map((r) => elementOf(group, r).unit)); - return { - found: hit.length, - names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")), - unit: units.size === 1 ? [...units][0] : "", - }; -} - -/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ -function drawChosen( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): void { - const els = box.input?.elements ?? []; - const values = new Map(); - host.replaceChildren( - el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }), - ...(els.length - ? els.map((e) => { - const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") }); - const rule = e.mode === "rule"; - if (!rule) values.set(e.ref, val); - return el("div", { - className: "m02-dfip__item", - children: [ - el("span", { - className: "m02-dfip__name", - children: [ - ...(rule - ? [ - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: t("RuleTag"), - }), - ] - : []), - el("strong", { text: ` ${e.name}` }), - ...(rule && e.rule - ? [ - el("div", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }), - }), - ] - : []), - ], - }), - val, - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: e.unit || "-", - }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => - on.change( - rule - ? removeElementNamed(lg, box.id, e.name) - : removeElement(lg, box.id, e.ref), - ), - }), - ], - }); - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoneChosen") })]), - ); - const items = els - .filter((e) => e.mode !== "rule") - .map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); - if (!items.length) return; - void apiValues(items).then( - (answers) => - answers.forEach((a) => { - const node = values.get(a.id); - if (node && a.ok && a.value !== null && a.value !== undefined) - node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value); - }), - () => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」 - ); -} - -/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */ -async function drawFind( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): Promise { - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-dfip__list" }); - const count = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } }); - const bar: HTMLElement[] = []; - let file = ""; - let sub = ""; - let detail = ""; - let subs: SubInfo[] = []; - const all = { value: "", text: t("AllSub") }; - const subBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: t("SubLabel"), - onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()), - }); - const detailBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: t("DetailLabel"), - onChange: (v) => ((detail = v), void load()), - }); - const fillDetail = (): void => { - const d = subs.find((s) => s.name === sub)?.details ?? []; - detailBox.root.hidden = !d.length; - detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], ""); - }; - fillDetail(); - let seq = 0; - const load = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") })); - try { - const word = q.input.value.trim(); - const page = await fetchRows(file, 1, 40, word, false, sub, detail); - if (mine !== seq) return; - const rows = page.rows as M01Row[]; - count.textContent = t("Count", { n: page.total, m: rows.length }); - list.replaceChildren( - ...(rows.length - ? rows.map((r) => rowOf(r)) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoRow") })]), - ); - } catch (e) { - if (mine === seq) - list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) })); - } - }; - const rowOf = (r: M01Row): HTMLElement => { - const fields = group === "기계" ? machineFields(r) : []; - const field = createSelectField({ - options: fields.map((f) => ({ value: f, text: f })), - value: fields.includes("가격") ? "가격" : (fields[0] ?? ""), - compact: true, - ariaLabel: t("Field"), - }); - const pick = (): CfLogic => - addElement(lg, box.id, elementOf(group, r, field.select.value || "가격")); - const v = valueOf(group, r, field.select.value || "가격"); - const unitText = group === "기계" ? "" : String(r["단위"] ?? ""); - const b = el("button", { - className: "m02-dfip__row ui-list-row ui-sidebar-row", - attrs: { type: "button" }, - children: [ - el("span", { - className: "m02-dfip__name", - children: [ - el("strong", { text: String(r["이름"] ?? "") }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: ` ${r["규격"] ?? ""}`, - }), - ], - }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(), - }), - ], - }); - b.addEventListener("click", () => on.change(pick())); - return el("div", { - className: "m02-dfip__rowbox", - children: [b, ...(fields.length ? [field.root] : [])], - }); - }; - try { - file = await fileOf(group); - subs = await fetchSubs({ file }).catch(() => []); - subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], ""); - subBox.root.hidden = !subs.length; - bar.push(subBox.root, detailBox.root); - } catch (e) { - host.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) })); - return; - } - let timer = 0; - q.input.addEventListener( - "input", - () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), - ); - host.replaceChildren( - el("div", { className: "ui-toolbar m02-dfip__bar", children: [...bar, q.root] }), - count, - list, - ); - void load(); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts deleted file mode 100644 index ebc94a459..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ /dev/null @@ -1,672 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Build.ts - * 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic) - * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts` - * ========================================================================== */ - -import type { - CfBox, - CfElementRef, - CfGroup, - CfInput, - CfLogic, - CfLogicRef, - CfMaterialMode, - CfMaterialRule, - CfPort, - CfScope, - CfVariable, -} from "./M02_DF_Model"; -import { matchLogic } from "@ui/ui_template_logic_find"; - -/** 입력 상자가 고르는 그룹 — 화면 차례 */ -export const GROUPS: CfGroup[] = [ - "인력", - "재료", - "유가전력", - "기계", - "소요량", - "계수", - "환율", - "요율", -]; - -/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */ -export const M01_OF: Record = { - 인력: { group: "인력", part: "" }, - 재료: { group: "재료", part: "자재품목" }, - 유가전력: { group: "재료", part: "유가전력" }, - 기계: { group: "기계", part: "" }, - 소요량: { group: "소요량", part: "" }, - 계수: { group: "계수", part: "" }, - 환율: { group: "환율", part: "" }, - 요율: { group: "요율", part: "" }, -}; - -/** M01 줄 한 개 — 파일 그대로(한글 칸) */ -export type M01Row = Record; - -/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */ -export const MACHINE_SEP = "#"; -export const machineFields = (row: M01Row): string[] => - row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : []; - -const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); -const dictUnit = (row: M01Row, field: string): string => - row["단위"] && typeof row["단위"] === "object" - ? text((row["단위"] as Record)[field]) - : ""; - -/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */ -export function lowest(row: M01Row): number | null { - const found = ["물가자료", "유통물가", "물가정보", "거래가격"] - .map((k) => row[k]) - .filter((v): v is number => typeof v === "number"); - return found.length ? Math.min(...found) : null; -} - -/** 줄 옆에 보일 값 — 모르면 null */ -export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null { - if (group === "재료") return lowest(row); - if (group === "기계") - return typeof (row["값"] as Record | undefined)?.[field] === "number" - ? (row["값"] as Record)[field] - : null; - if (group === "요율") { - const v = row["값"] as Record | undefined; - return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null; - } - return typeof row["값"] === "number" ? row["값"] : null; -} - -/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */ -export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef { - const key = text(row["키"]); - const spec = text(row["규격"]); - // 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름 - const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec; - const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게) - if (group === "기계") - return { - ref: `${key}${MACHINE_SEP}${field}`, - name: `${named} · ${field}`, - spec, - unit: dictUnit(row, field) || "-", - }; - if (group === "요율") - return { - ref: key, - name: text(row["이름"]), - spec: "", - unit: valueOf(group, row) === null ? "-" : "%", - }; - return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" }; -} - -/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */ -export function pillNames(box: CfBox): string[] { - const i = box.input; - if (!i) return []; - if (i.table) return ["표"]; - if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name); - return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); -} - -/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */ -export function queryOf(group: CfGroup, ref: string): Record { - if (group === "기계") { - const [key, field] = ref.split(MACHINE_SEP); - return { group, ref: key, field: field || "가격" }; - } - return { group, ref }; -} - -/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */ - -/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */ - -/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */ -export interface MatItem { - ref: string; - name: string; - spec: string; - unit: string; - price: number | null; - 구분?: string; - 상세구분?: string; -} - -const squash = (v: unknown): string => - String(v ?? "") - .replace(/\s+/g, "") - .toLowerCase(); - -/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */ -export function matches( - item: Pick, - rule: Pick, - value: unknown, -): boolean { - const v = squash(value); - if (!v) return false; - const target = squash(rule.field === "규격" ? item.spec : item.name); - return rule.op === "같음" ? target === v : target.includes(v); -} - -/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */ -export function catchNote( - found: number, - firstName = "", - noun = "재료", -): { kind: "none" | "one" | "many"; text: string } { - if (found === 0) - return { - kind: "none", - text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`, - }; - if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` }; - return { - kind: "many", - text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`, - }; -} - -/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */ -export function pageRange( - total: number, - page: number, - size: number, -): { from: number; to: number; pages: number } { - const pages = Math.max(1, Math.ceil(total / size)); - const p = Math.min(Math.max(1, page), pages); - return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages }; -} - -/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */ -export function variablePills( - lg: CfLogic, -): { port: CfPort; unit: string; example: CfVariable["example"] }[] { - return lg.boxes.flatMap((b) => - b.input?.mode === "variable" - ? b.input.variables.map((v) => ({ - port: { box: b.id, name: v.name }, - unit: v.unit, - example: v.example, - })) - : [], - ); -} - -/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */ -export function materialElement( - mode: CfMaterialMode, - name: string, - scope: CfScope, - extra: { rule?: CfMaterialRule; item?: Pick } = {}, -): CfElementRef { - const item = extra.item; - return { - ref: mode === "rule" ? "" : (item?.ref ?? ""), - name: name.trim(), - slot: name.trim(), - spec: item?.spec ?? "", - unit: item?.unit || "-", - mode, - scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope, - ...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}), - ...(mode === "choice" && item?.ref ? { default: item.ref } : {}), - }; -} - -/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ -export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null { - if (!el.name) return "자리 이름을 적어 주세요"; - if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; - if (el.mode === "rule" && el.rule?.field === "검색어") { - if (!el.rule.template?.trim() || !templateSlots(el.rule.template).length) - return "검색어 틀에 자리 {이름} 이 하나는 있어야 함"; - if ( - (el.rule.slots ?? []).length < templateSlots(el.rule.template).length || - (el.rule.slots ?? []).some((x) => !x.from.name) - ) - return "틀의 자리마다 변수 알약을 골라 주세요"; - return null; - } - if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; - if ( - el.mode !== "fixed" && - !(el.mode === "choice" && el.default) && - !Object.keys(el.scope ?? {}).length - ) - return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요"; - return null; -} - -/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약) - * examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */ -export function logicRefOf(key: string, other: CfLogic): CfLogicRef { - const outputs = other.boxes - .filter((b) => b.kind === "output") - .map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" })); - const designs: { name: string; unit: string }[] = []; - const examples: Record = {}; - const addDesign = (name: string, unit: string, example?: number | string | null): void => { - designs.push({ name, unit: unit || "-" }); - if (example !== null && example !== undefined && example !== "") examples[name] = example; - }; - for (const b of other.boxes) { - for (const v of b.input?.variables ?? b.func?.variables ?? []) - addDesign(v.name, v.unit, v.example); - if (b.input?.mode === "group") - for (const e of b.input.elements) - if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit); - if (b.input?.mode === "logic") - for (const d of b.input.logic?.designs ?? []) - if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name)) - addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]); - } - const seen = new Set(); - return { - key, - name: other.name, - outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)), - designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))), - ...(Object.keys(examples).length ? { examples } : {}), - }; -} - -/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */ - -/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */ -export interface MasterLogicBrief { - file: string; - 구분: string; - 상세구분: string; - 키: string; - 원문번호: string; - 이름: string; - 결과단위: string; - /** 쉼표로 여럿(208-1) */ - 별칭?: string; - blocked?: boolean; - reasons?: string[]; -} - -/** 찾기 — 공용 `matchLogic`(띄어 나눈 말마다 이름 · 원문번호 · 키 · 구분 · 상세구분 · 별칭 · 208-1) · 원문(구분) · 장(상세구분) 거름 */ -export function filterLogics( - items: MasterLogicBrief[], - q: { text: string; book?: string; chapter?: string }, -): MasterLogicBrief[] { - return items.filter((i) => { - if (q.book && i.구분 !== q.book) return false; - if (q.chapter && i.상세구분 !== q.chapter) return false; - return matchLogic(q.text, i).ok; - }); -} - -/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */ -export function logicBooks(items: MasterLogicBrief[]): string[] { - return [...new Set(items.map((i) => i.구분).filter(Boolean))]; -} -export function logicChapters(items: MasterLogicBrief[], book: string): string[] { - return [ - ...new Set( - items - .filter((i) => !book || i.구분 === book) - .map((i) => i.상세구분) - .filter(Boolean), - ), - ]; -} - -/** 계산 방법을 흐름도로 읽은 로직은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */ -export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const; - -/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */ -export function masterRef( - key: string, - flow: CfLogic, - converted: boolean, - resultUnit: string, -): CfLogicRef { - const ref = logicRefOf(key, flow); - return converted - ? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) } - : ref; -} - -/** 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */ -export function rowRef( - key: string, - name: string, - resultUnit: string, - inputs: { 이름: string; 단위?: string }[], - auto: Record, -): CfLogicRef { - const examples: Record = {}; - for (const i of inputs) { - const v = auto[i.이름]; - if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v; - } - return { - key, - name, - outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })), - designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })), - ...(Object.keys(examples).length ? { examples } : {}), - }; -} - -/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */ -export async function wouldCycle( - selfKey: string | null, - startKey: string, - load: (key: string) => Promise, - depth = 8, -): Promise { - if (!selfKey) return false; - const seen = new Set(); - const walk = async (key: string, left: number): Promise => { - if (key === selfKey) return true; - if (seen.has(key) || left <= 0) return false; - seen.add(key); - const lg = await load(key).catch(() => null); - for (const b of lg?.boxes ?? []) { - const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined; - if (next && (await walk(next, left - 1))) return true; - } - return false; - }; - return walk(startKey, depth); -} - -/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */ -export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic { - return { - ...lg, - boxes: lg.boxes.map((b) => { - if (b.id !== id || !b.input) return b; - const input: CfInput = ref - ? { mode: "logic", elements: [], variables: [], logic: ref } - : { mode: "variable", elements: [], variables: [] }; - return { ...b, input, unit: unitOfInput(input) }; - }), - links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), // 나가던 선 · 그 로직의 받는 알약으로 들어오던 선 모두 - }; -} - -/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ -export function unitOfInput(input: CfInput): string { - if (input.table) return "-"; - if (input.mode === "logic") { - const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-"); - return u.length && u.every((x) => x === u[0]) ? u[0] : "-"; - } - const units = ( - input.mode === "variable" - ? input.variables.map((v) => v.unit) - : input.elements.map((e) => e.unit) - ).map((u) => u || "-"); - return units.length && units.every((u) => u === units[0]) ? units[0] : "-"; -} - -/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ -export function checkVariable(v: CfVariable, others: CfVariable[]): string | null { - const name = v.name.trim(); - if (!name) return "이름을 적어 주세요"; - if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음"; - if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상"; - if (v.type === "number") { - if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함"; - if ( - typeof v.example === "number" && - v.range && - (v.example < v.range[0] || v.example > v.range[1]) - ) - return "예시값이 범위 밖"; - if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자"; - } - if ( - v.type === "choice" && - v.example !== null && - !(v.choices ?? []).map(String).includes(String(v.example)) - ) - return "예시값이 선택 목록에 없음"; - return null; -} - -/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */ - -type Pills = (box: CfBox) => string[]; -const defaultPills: Pills = pillNames; - -const touch = ( - lg: CfLogic, - id: string, - fn: (b: CfBox) => CfBox, - pills: Pills = defaultPills, -): CfLogic => { - const old = lg.boxes.find((b) => b.id === id); - if (!old) return lg; - const next = fn(old); - const boxes = lg.boxes.map((b) => - b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b, - ); - // 알약이 없어진 선은 지움 - const keep = new Set(pills(next)); - const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name)); - return { ...lg, boxes, links }; -}; - -const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; - -/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ -export function setKind( - lg: CfLogic, - id: string, - kind: CfGroup | "변수" | "다른 로직", - pills?: Pills, -): CfLogic { - return touch( - lg, - id, - (b) => { - const cur = inputOf(b); - if (kind === "다른 로직") - return { ...b, input: { mode: "logic", elements: [], variables: [] }, unit: "-" }; - if (kind === "변수") - return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } }; - const same = cur.mode === "group" && cur.group === kind; - return { - ...b, - input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] }, - }; - }, - pills, - ); -} - -/** 요소 넣기 — 같은 요소는 한 번만 */ -export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => { - const cur = inputOf(b); - return cur.elements.some((e) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name) - ? b - : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; - }, - pills, - ); -} - -/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */ -export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => ({ - ...b, - input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) }, - }), - pills, - ); -} - -export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => ({ - ...b, - input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) }, - }), - pills, - ); -} - -/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */ -export function setTable( - lg: CfLogic, - id: string, - table: { key: string; row?: number } | null, - name?: string, - pills?: Pills, -): CfLogic { - const next = touch( - lg, - id, - (b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }), - pills, - ); - return name - ? { - ...next, - boxes: next.boxes.map((b) => - b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b, - ), - } - : next; -} - -/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */ -export function putVariable( - lg: CfLogic, - id: string, - v: CfVariable, - oldName?: string, - pills?: Pills, -): CfLogic { - return touch( - lg, - id, - (b) => { - const cur = inputOf(b); - const vars = - oldName === undefined - ? [...cur.variables, v] - : cur.variables.map((x) => (x.name === oldName ? v : x)); - return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } }; - }, - pills, - ); -} - -export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => ({ - ...b, - input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) }, - }), - pills, - ); -} - -/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */ -export function reconcileUnit(lg: CfLogic, id: string): CfLogic { - const box = lg.boxes.find((b) => b.id === id); - if (!box?.input) return lg; - const want = unitOfInput(box.input); - return box.unit === want - ? lg - : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; -} - -/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(마스터 로직의 LB:{직종} · EQ:{기계} 꼴) ── */ - -/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */ -export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"]; - -export type GroupRuleField = "이름" | "키"; - -/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */ -export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean { - const v = squash(value); - if (!v) return false; - const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]]; - return targets.some((x) => squash(x) === v); -} - -/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */ -export function groupRuleElement( - name: string, - field: GroupRuleField, - from: CfMaterialRule["from"], -): CfElementRef { - const rule = { field, op: "같음", from } as unknown as CfMaterialRule; - return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule }; -} - -/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ -export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null { - if (!el.name) return "자리 이름을 적어 주세요"; - if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; - if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; - return null; -} - -/* ── 재료 조건 찾기 field 「검색어」 — 틀(「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 의 자리마다 변수 알약 값을 끼운 글로 찾음 ── */ - -/** 틀 안 자리 이름들 — 나온 차례 · 겹침 없이 */ -export function templateSlots(template: string): string[] { - return [ - ...new Set([...template.matchAll(/\{([^{}]+)\}/g)].map((m) => m[1].trim()).filter(Boolean)), - ]; -} - -/** 틀을 채운 글 — valueOf 가 알약 값을 줌 · 값이 빈 자리는 missing 에 모음(그 자리는 그대로 둠) */ -export function fillTemplate( - template: string, - slots: { name: string; from: CfPort }[], - valueOf: (port: CfPort) => unknown, -): { text: string; missing: string[] } { - const missing: string[] = []; - const text = template.replace(/\{([^{}]+)\}/g, (all, raw: string) => { - const name = raw.trim(); - const slot = slots.find((x) => x.name === name); - const v = slot ? valueOf(slot.from) : undefined; - if (v === null || v === undefined || v === "") { - if (!missing.includes(name)) missing.push(name); - return all; - } - return String(v); - }); - return { text, missing }; -} - -/** 검색어 틀 규칙 — 첫 자리의 알약이 from(Model 호환) */ -export function templateRule( - template: string, - slots: { name: string; from: CfPort }[], -): CfMaterialRule { - return { - field: "검색어", - op: "포함", - from: slots[0]?.from ?? { box: "", name: "" }, - template, - slots, - }; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts deleted file mode 100644 index 091e9b9f8..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ /dev/null @@ -1,389 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Logic.ts - * 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽) 가운데 하나를 고르면 - * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길 - * 흐름이 없는 로직은 서버가 계산 방법을 흐름도로 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름 - * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; -import { - fetchAuto, - fetchLogic, - fetchLogics, -} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; -import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; -import { - filterLogics, - logicBooks, - logicChapters, - logicRefOf, - masterRef, - rowRef, - pageRange, - setKind, - setLogicInput, - wouldCycle, - type MasterLogicBrief, -} from "./M02_DF_InputPanel_Build"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - Help: [ - "마스터 로직을 불러와 그 결과를 이 상자의 알약으로 씀", - "Use any master logic or a logic of mine as an input", - ], - Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], - Book: ["원문", "Source"], - Chapter: ["장", "Chapter"], - All: ["전체", "All"], - Loading: ["찾는 중…", "Searching…"], - None: ["찾는 로직 없음", "No logic found"], - Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], - Prev: ["이전", "Prev"], - Next: ["다음", "Next"], - Blocked: ["읽을 수 없음", "Unreadable"], - Chosen: ["고른 로직", "Chosen logic"], - Converted: [ - "이 로직의 계산 방법을 흐름도로 읽음 — 파일은 안 바뀜", - "Old logic read as a flow — the file is unchanged", - ], - Notes: ["못 옮긴 곳", "Not carried over"], - Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"], - Designs: [ - "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)", - "Pills it takes — its design inputs", - ], - NoOut: ["출력 상자가 없음", "No output box"], - NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"], - Change: ["다른 로직 고르기", "Pick another"], - Remove: ["빼기", "Remove"], - Self: ["이 로직 자신은 못 고름", "Can't pick itself"], - Cycle: [ - "돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름", - "That would loop — a logic that uses this one can't be picked", - ], - NoFlow: [ - "이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음", - "Can't read this logic's flow yet", - ], - Failed: ["불러오지 못함", "Could not load"], - Wait: ["값 셈 중…", "Calculating…"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); - -const plain = (e: unknown): string => { - try { - return cfPlain(e); - } catch { - return e instanceof Error ? e.message : t("Failed"); - } -}; - -const PAGE = 12; - -/** 마스터 로직 목록 — 한 번 받아 둠(M01 `fetchLogics`) */ -let cache: Promise | null = null; -const loadAll = (): Promise => - (cache ??= fetchLogics().catch((e: unknown) => ((cache = null), Promise.reject(e)))); - -/** 고른 로직에서 읽은 것 — 계산 방법을 흐름도로 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */ -const readNotes = new Map(); - -/** 마스터 로직 하나를 읽어 흐름도로 — 흐름 받기 길(흐름이 없으면 계산 방법을 흐름도로 읽음) */ -const flowOf = ( - item: Pick, -): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> => - cfLogicFlow(item.file, item.키); - -/** 다른 로직 입력 판 */ -export function renderLogicInput( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, -): void { - const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic; - if (ref) return void showChosen(host, lg, boxId, ref, on); - - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const all = { value: "", text: t("All") }; - let book = ""; - let chapter = ""; - let page = 1; - let items: MasterLogicBrief[] = []; - const bookBox = createSelectField({ - label: t("Book"), - options: [all], - value: "", - compact: true, - onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()), - }); - const chapterBox = createSelectField({ - label: t("Chapter"), - options: [all], - value: "", - compact: true, - onChange: (v) => ((chapter = v), (page = 1), draw()), - }); - const fillChapters = (): void => - chapterBox.setOptions( - [all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))], - chapter, - ); - const list = el("div", { className: "m02-dfip__list" }); - const info = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } }); - const prev = createButton({ - label: t("Prev"), - variant: "ghost", - size: "sm", - onClick: () => ((page -= 1), draw()), - }); - const next = createButton({ - label: t("Next"), - variant: "ghost", - size: "sm", - onClick: () => ((page += 1), draw()), - }); - - const draw = (): void => { - const rows = filterLogics( - items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌 - { text: q.input.value, book, chapter }, - ); - const range = pageRange(rows.length, page, PAGE); - page = Math.min(page, range.pages); - const shown = rows.slice((page - 1) * PAGE, page * PAGE); - info.textContent = t("Total", { - n: rows.length, - a: range.from, - b: range.to, - p: page, - m: range.pages, - }); - prev.disabled = page <= 1; - next.disabled = page >= range.pages; - list.replaceChildren( - ...(shown.length - ? shown.map((i) => { - const b = el("button", { - className: "m02-dfip__row ui-list-row ui-sidebar-row", - attrs: { - type: "button", - ...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}), - }, - children: [ - el("span", { - className: "m02-dfip__name", - children: [ - el("strong", { text: i.이름 }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: ` ${i.원문번호}`, - }), - // 별칭으로 맞았으면 그 별칭을 작게(208-1) - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: ` ${aliasNote(matchLogic(q.input.value, i).alias)}`, - }), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: i.blocked ? t("Blocked") : i.상세구분, - }), - ], - }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: i.결과단위, - }), - ], - }); - b.addEventListener("click", () => void pick(i)); - return b; - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("None") })]), - ); - }; - - /** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */ - const loadForCycle = async (key: string): Promise => { - const it = items.find((x) => x.키 === key); - return it ? (await flowOf(it)).flow : null; - }; - - const pick = async (i: MasterLogicBrief): Promise => { - try { - if (i.키 === lg.key) return void showToast(t("Self"), "error"); - if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error"); - const got = await flowOf(i); - readNotes.set(i.키, { converted: got.converted, notes: got.notes }); - // 흐름도로 읽은 것이 아직 비어 있으면 로직 줄의 입력 · 자동값에서 바로 읽음 - const empty = got.converted && got.flow.boxes.length === 0; - const row = empty ? await fetchLogic(i.키).catch(() => null) : null; - const picked = row - ? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키)) - : got.converted - ? masterRef(i.키, got.flow, true, i.결과단위) - : { ...logicRefOf(i.키, got.flow), name: i.이름 }; - const done = setLogicInput(lg, boxId, picked); - // 기본 이름이면 고른 로직 이름으로 - on.change({ - ...done, - boxes: done.boxes.map((b) => - b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b, - ), - }); - } catch (e) { - showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error"); - } - }; - - let timer = 0; - q.input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => ((page = 1), draw()), 200); - }); - list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") })); - host.replaceChildren( - el("p", { className: "ui-text ui-text--muted", text: t("Help") }), - el("div", { - className: "ui-toolbar m02-dfip__bar", - children: [bookBox.root, chapterBox.root, q.root], - }), - info, - list, - el("div", { className: "m02-dfip__chips", children: [prev, next] }), - ); - void loadAll().then( - (rows) => { - items = rows; - bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], ""); - fillChapters(); - draw(); - }, - (e) => list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) })), - ); -} - -/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */ -function showChosen( - host: HTMLElement, - lg: CfLogic, - boxId: string, - ref: CfLogicRef, - on: { change(lg: CfLogic): void }, -): void { - const read = readNotes.get(ref.key); - const values = new Map(); - const errors = el("p", { className: "ui-text ui-text--error" }); - const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement => - el("div", { - className: "m02-dfip__item", - children: [ - el("span", { className: "m02-dfip__name", text: name }), - ...(valueNode - ? [valueNode] - : extra - ? [ - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: extra, - }), - ] - : []), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: unit || "-", - }), - ], - }); - host.replaceChildren( - el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }), - ...(read?.converted - ? [ - el("p", { className: "ui-text ui-text--muted", text: t("Converted") }), - ...(read.notes.length - ? [ - el("details", { - children: [ - el("summary", { text: `${t("Notes")} · ${read.notes.length}` }), - ...read.notes.map((n) => el("p", { text: n })), - ], - }), - ] - : []), - ] - : []), - el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }), - ...(ref.outputs.length - ? ref.outputs.map((o) => { - const v = el("strong", { className: "m02-dfip__value", text: "—" }); - values.set(o.name, v); - return line(o.name, o.unit, v); - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoOut") })]), - errors, - el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }), - ...(ref.designs.length - ? ref.designs.map((d) => - line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")), - ) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoDesign") })]), - el("div", { - className: "m02-dfip__chips", - children: [ - createButton({ - label: t("Change"), - variant: "ghost", - size: "sm", - onClick: () => on.change(setKind(lg, boxId, "다른 로직")), - }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => on.change(setLogicInput(lg, boxId, null)), - }), - ], - }), - ); - // 값 — 서버 계산 길(받는 알약 예시값으로) - errors.textContent = t("Wait"); - void cfLogicValue(ref.key, ref.examples ?? {}).then( - (r) => { - for (const [name, node] of values) { - const v = r.outputs[name] ?? (r.cost as unknown as Record)[name]; - if (typeof v === "number") node.textContent = fmt(v); - else if (typeof v === "string") node.textContent = v; - } - errors.textContent = r.errors.join(" · "); - }, - (e) => (errors.textContent = plain(e)), - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts deleted file mode 100644 index 498a8f91d..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts +++ /dev/null @@ -1,648 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Material.ts - * 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수) - * 서버 `/api/m02/df/materials`(cfMaterials) - * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch"; -import { cfMaterials, cfValues } from "./M02_DF_Api"; -import { templateBox } from "./M02_DF_InputPanel_Template"; -import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model"; -import { - catchNote, - fillTemplate, - templateRule, - checkMaterial, - elementOf, - matches, - materialElement, - pageRange, - removeElementNamed, - variablePills, - type MatItem, -} from "./M02_DF_InputPanel_Build"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"], - None: ["아직 고른 재료가 없음", "Nothing chosen yet"], - Remove: ["빼기", "Remove"], - Fixed: ["고정", "Fixed"], - Choice: ["선택 자리", "Choice slot"], - Rule: ["조건 찾기", "Find by rule"], - FixedHelp: ["품목 하나를 못 박음", "One fixed item"], - ChoiceHelp: [ - "자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)", - "A slot — pick the item per project", - ], - RuleHelp: [ - "변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관", - "Find by a variable pill", - ], - Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], - AllSub: ["전체", "All"], - SubLabel: ["구분", "Group"], - DetailLabel: ["상세구분", "Detail"], - Loading: ["찾는 중…", "Searching…"], - NoRow: ["찾는 재료 없음", "Nothing found"], - Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], - Prev: ["이전", "Prev"], - Next: ["다음", "Next"], - SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], - Scope: ["범위", "Scope"], - Word: ["찾는 말(범위 안에서)", "Word in scope"], - Default: ["기본 품목", "Default item"], - DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"], - DefaultClear: ["기본 품목 비움", "Clear"], - RuleHow: ["찾는 방식", "How to find"], - HowPlain: ["규격 · 이름에 알약 값", "Spec / name vs a pill"], - HowTemplate: ["검색어 틀", "Search template"], - TemplateLine: ["검색어 틀 「{t}」", "template “{t}”"], - Missing: ["자리 「{n}」 알약 값이 비어 있음", "Slot “{n}” has no value"], - RuleField: ["찾는 칸", "Field"], - FieldSpec: ["규격", "Spec"], - FieldName: ["이름", "Name"], - RuleOp: ["조건", "Condition"], - OpIn: ["에 알약 값이 들어 있음", "contains the pill value"], - OpEq: ["이 알약 값과 같음", "equals the pill value"], - RulePill: ["변수 알약", "Variable pill"], - NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], - Put: ["넣기", "Add"], - Pick: ["넣기", "Add"], - Preview: ["지금 잡히는 재료", "Items caught now"], - Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"], - RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"], - ScopeLine: ["범위 {s}", "scope {s}"], - Failed: ["불러오지 못함", "Could not load"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); - -/* ── 자재 찾기 ── */ -export interface MatQuery { - q: string; - sub?: string; - detail?: string; - page: number; - size: number; -} -export interface MatPage { - total: number; - page: number; - size: number; - items: MatItem[]; -} - -/** 서버 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기 · 전체 수 */ -export async function searchMaterials(q: MatQuery): Promise { - return cfMaterials({ q: q.q, sub: q.sub, detail: q.detail, page: q.page, size: q.size }); -} - -let subsCache: SubInfo[] | null = null; -async function materialSubs(): Promise { - if (subsCache) return subsCache; - const files = await fetchFiles("재료"); - const f = files.find((x) => x.file.includes("자재품목")) ?? files[0]; - subsCache = await fetchSubs({ file: f.file }); - return subsCache; -} - -/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */ -function scopeBoxes( - init: { sub: string; detail: string }, - onChange: () => void, -): { root: HTMLElement; get(): { sub: string; detail: string } } { - const state = { ...init }; - const all = { value: "", text: t("AllSub") }; - const subBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: t("SubLabel"), - onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()), - }); - const detailBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: t("DetailLabel"), - onChange: (v) => ((state.detail = v), onChange()), - }); - let subs: SubInfo[] = []; - const fill = (): void => { - const d = subs.find((s) => s.name === state.sub)?.details ?? []; - detailBox.root.hidden = !d.length; - detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail); - }; - fill(); - void materialSubs().then( - (s) => { - subs = s; - subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub); - fill(); - }, - () => undefined, - ); - return { - root: el("div", { - className: "ui-toolbar m02-dfip__bar", - children: [subBox.root, detailBox.root], - }), - get: () => ({ ...state }), - }; -} - -/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */ -function itemList( - onPick: (item: MatItem) => void, - base?: () => { sub: string; detail: string }, -): HTMLElement { - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-dfip__list" }); - const info = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } }); - let page = 1; - let seq = 0; - const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load())); - const scope = (): { sub: string; detail: string } => (base ? base() : own!.get()); - const prev = createButton({ - label: t("Prev"), - variant: "ghost", - size: "sm", - onClick: () => ((page -= 1), void load()), - }); - const next = createButton({ - label: t("Next"), - variant: "ghost", - size: "sm", - onClick: () => ((page += 1), void load()), - }); - const load = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") })); - try { - const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 }); - if (mine !== seq) return; - const range = pageRange(r.total, page, 12); - info.textContent = t("Total", { - n: r.total, - a: range.from, - b: range.to, - p: Math.min(page, range.pages), - m: range.pages, - }); - prev.disabled = page <= 1; - next.disabled = page >= range.pages; - list.replaceChildren( - ...(r.items.length - ? r.items.map((it) => { - const b = el("button", { - className: "m02-dfip__row ui-list-row ui-sidebar-row", - attrs: { type: "button" }, - children: [ - el("span", { - className: "m02-dfip__name", - children: [ - el("strong", { text: it.name }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: ` ${it.spec}`, - }), - ], - }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(), - }), - ], - }); - b.addEventListener("click", () => onPick(it)); - return b; - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoRow") })]), - ); - } catch { - if (mine === seq) - list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Failed") })); - } - }; - let timer = 0; - q.input.addEventListener( - "input", - () => ( - window.clearTimeout(timer), - (timer = window.setTimeout(() => ((page = 1), void load()), 250)) - ), - ); - void load(); - return el("div", { - className: "m02-dfip__find", - children: [ - ...(own ? [own.root] : []), - q.root, - info, - list, - el("div", { className: "m02-dfip__chips", children: [prev, next] }), - ], - }); -} - -/* ── 잡히는 재료 미리 보기 ── */ -export async function previewOf( - el1: CfElementRef, - lg: CfLogic, -): Promise<{ - found: number; - names: string[]; - cheapest: MatItem | null; - unit: string; - missing?: string[]; -}> { - const scope = el1.scope ?? {}; - const word = scope.찾는말 ?? ""; - if (el1.mode === "rule" && el1.rule?.field === "검색어") { - // 틀의 자리마다 알약 값을 끼운 글로 찾음(띄어쓴 말은 모두 들어간 것) - const valueOf = (port: { box: string; name: string }): unknown => - variablePills(lg).find((p) => p.port.box === port.box && p.port.name === port.name)?.example; - const { text, missing } = fillTemplate(el1.rule.template ?? "", el1.rule.slots ?? [], valueOf); - if (missing.length) return { found: 0, names: [], cheapest: null, unit: "", missing }; - const r = await searchMaterials({ - q: `${word} ${text}`.trim(), - sub: scope.구분, - detail: scope.상세구분, - page: 1, - size: 200, - }); - return { - found: r.total, - names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), - cheapest: cheapestOf(r.items), - unit: commonUnit(r.items), - }; - } - if (el1.mode === "rule" && el1.rule) { - const pill = variablePills(lg).find( - (p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name, - ); - const value = pill?.example; - if (value === null || value === undefined || value === "") - return { found: 0, names: [], cheapest: null, unit: "" }; - const r = await searchMaterials({ - q: `${word} ${value}`.trim(), - sub: scope.구분, - detail: scope.상세구분, - page: 1, - size: 200, - }); - const hit = r.items.filter((i) => matches(i, el1.rule!, value)); - return { - found: hit.length, - names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), - cheapest: cheapestOf(hit), - unit: commonUnit(hit), - }; - } - const r = await searchMaterials({ - q: word, - sub: scope.구분, - detail: scope.상세구분, - page: 1, - size: 200, - }); - return { - found: r.total, - names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), - cheapest: cheapestOf(r.items), - unit: commonUnit(r.items), - }; -} - -/** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */ -const commonUnit = (items: MatItem[]): string => - items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : ""; - -const cheapestOf = (items: MatItem[]): MatItem | null => - items - .filter((i) => i.price !== null) - .sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null; - -/** 미리 보기 글 — 틀 자리 값이 비면 그 까닭 · 아니면 잡힌 수에 따른 알림 */ -const noteOf = (p: { - found: number; - names: string[]; - missing?: string[]; -}): ReturnType => - p.missing?.length - ? { kind: "none", text: t("Missing", { n: p.missing.join(" · ") }) } - : catchNote(p.found, p.names[0]); - -/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */ -function summaryOf(e: CfElementRef, lg: CfLogic): string { - const parts: string[] = []; - const scope = Object.entries(e.scope ?? {}) - .map(([, v]) => v) - .join(" / "); - if (scope) parts.push(t("ScopeLine", { s: scope })); - if (e.rule?.field === "검색어") parts.push(t("TemplateLine", { t: e.rule.template ?? "" })); - else if (e.rule) { - const p = variablePills(lg).find( - (x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name, - ); - parts.push( - t("RuleLine", { - f: e.rule.field, - o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"), - n: e.rule.from.name, - v: p?.example ?? "—", - }), - ); - } - return parts.join(" · "); -} - -/** 재료 판 — 넣은 재료 목록 + 방식 셋 */ -export function renderMaterial( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, - addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - const els = (box?.input?.elements ?? []) as CfElementRef[]; - const chosen = el("div", { className: "m02-dfip__chosen" }); - const body = el("div", { className: "m02-dfip__stack" }); - let mode: CfMaterialMode = "fixed"; - let how: "plain" | "template" = "plain"; - - const drawChosen = (): void => { - chosen.replaceChildren( - el("h5", { text: `${t("Chosen")} · ${els.length}` }), - ...(els.length - ? els.map((e) => { - const val = el("strong", { className: "m02-dfip__value", text: "—" }); - const note = el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - }); - const kind = - e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed"); - const row = el("div", { - className: "m02-dfip__item", - children: [ - el("span", { - className: "m02-dfip__name", - children: [ - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: kind, - }), - el("strong", { text: ` ${e.name}` }), - el("div", { className: "ui-text ui-text--muted", text: summaryOf(e, lg) }), - note, - ], - }), - val, - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: e.unit || "-", - }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => on.change(removeElementNamed(lg, boxId, e.name)), - }), - ], - }); - const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref; - if (ref) - void cfValues([{ id: ref, group: "재료", ref }] as never) - .then((a) => - a.forEach( - (x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)), - ), - ) - .catch(() => undefined); - if (e.mode && e.mode !== "fixed") - void previewOf(e, lg).then( - (p) => { - const n = noteOf(p); - note.textContent = `${t("Preview")}: ${n.text}`; - note.classList.toggle("is-bad", n.kind !== "one"); - if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price); - }, - () => undefined, - ); - return row; - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("None") })]), - ); - }; - - const chips = el("div", { className: "m02-dfip__chips" }); - const drawChips = (): void => - chips.replaceChildren( - ...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) => - createButton({ - label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"), - variant: mode === m ? "filled" : "ghost", - size: "sm", - onClick: () => ((mode = m), drawChips(), drawBody()), - }), - ), - ); - - const drawBody = (): void => { - if (mode === "fixed") { - body.replaceChildren( - el("p", { className: "ui-text ui-text--muted", text: t("FixedHelp") }), - itemList((it) => - on.change( - addEl(lg, { - ...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }), - mode: "fixed", - }), - ), - ), - ); - return; - } - // 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기 - const name = createInputField({ - type: "text", - label: t("SlotName"), - compact: true, - placeholder: "예: 흄관", - }); - const word = createInputField({ type: "text", label: t("Word"), compact: true }); - let def: MatItem | null = null; - const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh()); - const note = el("p", { className: "m02-dfip__note" }); - const pills = variablePills(lg); - const fieldBox = createSelectField({ - label: t("RuleField"), - options: [ - { value: "규격", text: t("FieldSpec") }, - { value: "이름", text: t("FieldName") }, - ], - value: "규격", - compact: true, - onChange: () => void refresh(), - }); - const opBox = createSelectField({ - label: t("RuleOp"), - options: [ - { value: "포함", text: t("OpIn") }, - { value: "같음", text: t("OpEq") }, - ], - value: "포함", - compact: true, - onChange: () => void refresh(), - }); - const pillBox = createSelectField({ - label: t("RulePill"), - options: pills.map((p, i) => ({ - value: String(i), - text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, - })), - value: "0", - compact: true, - onChange: () => void refresh(), - }); - const howBox = createSelectField({ - label: t("RuleHow"), - options: [ - { value: "plain", text: t("HowPlain") }, - { value: "template", text: t("HowTemplate") }, - ], - value: how, - compact: true, - onChange: (v) => ((how = v as "plain" | "template"), drawBody()), - }); - const tpl = templateBox(lg, () => void refresh()); - const defNote = el("p", { className: "ui-text ui-text--muted" }); - const build = (): CfElementRef => { - const sc = scope.get(); - if (mode === "rule" && howBox.select.value === "template") { - const g = tpl.get(); - const sc2 = scope.get(); - return materialElement( - mode, - name.input.value, - { 구분: sc2.sub, 상세구분: sc2.detail, 찾는말: word.input.value.trim() }, - { rule: templateRule(g.template, g.slots) }, - ); - } - const rule: CfMaterialRule | undefined = - mode === "rule" && pills[Number(pillBox.select.value)] - ? { - field: fieldBox.select.value as CfMaterialRule["field"], - op: opBox.select.value as CfMaterialRule["op"], - from: pills[Number(pillBox.select.value)].port, - } - : undefined; - return materialElement( - mode, - name.input.value, - { 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() }, - { rule, item: def ?? undefined }, - ); - }; - let seq = 0; - const refresh = async (): Promise => { - const mine = ++seq; - defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone"); - const cand = build(); - note.textContent = `${t("Preview")}: …`; - try { - const p = await previewOf(cand, lg); - if (mine !== seq) return; - const n = noteOf(p); - note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`; - note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; - } catch { - if (mine === seq) note.textContent = t("Failed"); - } - }; - name.input.addEventListener("input", () => name.setError(undefined)); - word.input.addEventListener("input", () => void refresh()); - const add = createButton({ - label: t("Put"), - size: "sm", - onClick: () => { - const cand = build(); - const why = checkMaterial(cand, els); - name.setError(why ?? undefined); - if (why) return void showToast(why, "error"); - // 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로 - void previewOf(cand, lg).then( - (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), - () => on.change(addEl(lg, cand)), - ); - }, - }); - const clear = createButton({ - label: t("DefaultClear"), - variant: "ghost", - size: "sm", - onClick: () => ((def = null), void refresh()), - }); - body.replaceChildren( - el("p", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"), - }), - name.root, - el("h5", { text: t("Scope") }), - scope.root, - word.root, - ...(mode === "rule" - ? [ - howBox.root, - ...(howBox.select.value === "template" - ? [tpl.root] - : pills.length - ? [fieldBox.root, opBox.root, pillBox.root] - : [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]), - ] - : [ - el("h5", { text: t("Default") }), - defNote, - clear, - itemList((it) => ((def = it), void refresh())), - ]), - note, - add, - ); - void refresh(); - }; - - host.replaceChildren(chosen, chips, body); - drawChosen(); - drawChips(); - drawBody(); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts deleted file mode 100644 index 778b4fbd2..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Rule.ts - * 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음 - * (마스터 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래 - * 선택 자리(choice)는 재료만 — 이 판에 없음 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model"; -import { - catchNote, - checkGroupRule, - groupRuleElement, - variablePills, - type GroupRuleField, -} from "./M02_DF_InputPanel_Build"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - Help: [ - "변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄", - "Catches the row whose name (or key) equals a variable pill's value", - ], - SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], - Field: ["같아야 하는 칸", "Column"], - FieldName: ["이름", "Name"], - FieldKey: ["키(원문번호)", "Key (source no.)"], - Pill: ["변수 알약", "Variable pill"], - NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], - Preview: ["지금 잡히는 줄", "Rows caught now"], - Put: ["넣기", "Add"], - Failed: ["불러오지 못함", "Could not load"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; - -/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */ -export type RulePreview = ( - group: CfGroup, - field: GroupRuleField, - value: unknown, -) => Promise<{ found: number; names: string[]; unit: string }>; - -/** 조건 찾기 요소 추가 판 */ -export function renderGroupRule( - host: HTMLElement, - lg: CfLogic, - boxId: string, - group: CfGroup, - addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, - preview: RulePreview, - on: { change(lg: CfLogic): void }, -): void { - const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? []; - const pills = variablePills(lg); - const name = createInputField({ - type: "text", - label: t("SlotName"), - compact: true, - placeholder: group === "인력" ? "예: 직종" : "예: 기종", - }); - const field = createSelectField({ - label: t("Field"), - options: [ - { value: "이름", text: t("FieldName") }, - { value: "키", text: t("FieldKey") }, - ], - value: "이름", - compact: true, - onChange: () => void refresh(), - }); - const pill = createSelectField({ - label: t("Pill"), - options: pills.map((p, i) => ({ - value: String(i), - text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, - })), - value: "0", - compact: true, - onChange: () => void refresh(), - }); - const note = el("p", { className: "m02-dfip__note" }); - const build = (): CfElementRef => - groupRuleElement( - name.input.value, - field.select.value as GroupRuleField, - pills[Number(pill.select.value)]?.port ?? { box: "", name: "" }, - ); - let seq = 0; - const refresh = async (): Promise => { - const mine = ++seq; - const value = pills[Number(pill.select.value)]?.example; - note.textContent = `${t("Preview")}: …`; - try { - const p = await preview(group, field.select.value as GroupRuleField, value); - if (mine !== seq) return; - const n = catchNote(p.found, p.names[0], "줄"); - note.textContent = `${t("Preview")}: ${n.text}`; - note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; - } catch { - if (mine === seq) note.textContent = t("Failed"); - } - }; - const add = createButton({ - label: t("Put"), - size: "sm", - onClick: () => { - const cand = build(); - const why = checkGroupRule(cand, els); - name.setError(why ?? undefined); - if (why) return void showToast(why, "error"); - // 잡히는 줄이 하나면 그 단위를 알약 단위로 - void preview( - group, - field.select.value as GroupRuleField, - pills[Number(pill.select.value)]?.example, - ).then( - (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), - () => on.change(addEl(lg, cand)), - ); - }, - }); - host.replaceChildren( - el("p", { className: "ui-text ui-text--muted", text: t("Help") }), - name.root, - ...(pills.length - ? [field.root, pill.root, note, add] - : [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]), - ); - if (pills.length) void refresh(); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts deleted file mode 100644 index a9d71da3d..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Table.ts +++ /dev/null @@ -1,332 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Table.ts - * 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나) - * `M02_DF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래 - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import type { CfGroup, CfLogic } from "./M02_DF_Model"; -import { setTable } from "./M02_DF_InputPanel_Build"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; -import "@ui/ui_template_table.css"; - -const TEXT = { - Book: ["적용 품셈", "Standard"], - Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], - Loading: ["찾는 중…", "Searching…"], - NoSection: ["찾는 절 없음", "No section"], - SecLine: ["표 {t} 개", "{t} tables"], - Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], - NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"], - TableLine: [ - "{b} · 조건 {c} · 결과 {v} · {n} 줄", - "{b} · {c} conditions · {v} results · {n} rows", - ], - Chosen: ["고른 표", "Chosen table"], - Whole: [ - "표 통째 — 함수 상자가 조건에 맞는 줄을 찾음", - "Whole table — a function box looks it up", - ], - OneRow: ["줄 하나 고정", "Fix one row"], - RowNo: ["몇 번째 줄", "Row number"], - Preview: ["미리 보기", "Preview"], - Notes: ["주(註)", "Notes"], - Remove: ["표 빼기", "Remove table"], - Change: ["다른 표 고르기", "Pick another table"], - Failed: ["불러오지 못함", "Could not load"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -async function get(path: string, params: Record): Promise { - const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { - credentials: "include", - }); - const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!res.ok) throw new Error(String(data.detail ?? res.statusText)); - return data; -} - -interface SectionItem { - section: string; - title: string; - chapter: string; - tables: number; -} -interface TableHead { - file: string; - 키: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - count: number; -} -interface RawTable { - 키: string; - 원문번호: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - 줄: Record[]; - 주?: string[]; -} - -const BOOKS = ["산림품셈", "건설품셈"]; -const files = new Map(); -const tables = new Map(); - -/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */ -export async function loadTable(group: CfGroup, key: string): Promise { - const hit = tables.get(key); - if (hit) return hit; - let file = files.get(key); - if (!file) { - const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find( - (x) => x.키 === key, - ); - if (!found) throw new Error(t("Failed")); - file = found.file; - } - const raw = (await get<{ table: RawTable }>("/table", { file, key })).table; - tables.set(key, raw); - return raw; -} - -const cell = (v: unknown): string => - Array.isArray(v) - ? v.map((x) => x ?? "").join("~") - : v === null || v === undefined - ? "" - : String(v); - -/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */ -function previewOf(tb: RawTable, row?: number): HTMLElement { - const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; - const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1)); - return el("div", { - className: "m02-dfip__preview", - children: [ - el("table", { - className: "ui-table ui-table--data", - children: [ - el("tr", { children: cols.map((c) => el("th", { text: c })) }), - ...body.map((r, i) => - el("tr", { - className: i === row ? "is-hit" : "", - children: cols.map((c) => el("td", { text: cell(r[c]) })), - }), - ), - ], - }), - ...(tb.줄.length > body.length - ? [el("p", { className: "ui-text ui-text--muted", text: `… ${tb.줄.length} 줄` })] - : []), - ...(tb.주?.length - ? [ - el("details", { - children: [ - el("summary", { text: t("Notes") }), - ...tb.주.map((n) => el("p", { text: n })), - ], - }), - ] - : []), - ], - }); -} - -/** 소요량 · 계수 표 고르기 화면 */ -export function renderTablePick( - host: HTMLElement, - lg: CfLogic, - boxId: string, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - const cur = box?.input?.table; - if (cur) return void showChosen(host, lg, boxId, group, cur, on); - let book = BOOKS[0]; - const bookBox = createSelectField({ - label: t("Book"), - options: BOOKS.map((b) => ({ value: b, text: b })), - value: book, - compact: true, - onChange: (v) => ((book = v), load()), - }); - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-dfip__list" }); - const tableList = el("div", { className: "m02-dfip__list" }); - let seq = 0; - const load = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") })); - try { - const r = await get<{ sections: SectionItem[] }>("/sections", { - book, - q: q.input.value.trim(), - limit: "60", - }); - if (mine !== seq) return; - list.replaceChildren( - ...(r.sections.length - ? r.sections.map((s) => { - const b = el("button", { - className: "m02-dfip__row ui-list-row ui-sidebar-row", - attrs: { type: "button" }, - children: [ - el("strong", { text: `${s.section} ${s.title}` }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, - }), - ], - }); - b.addEventListener("click", () => void pickSection(s)); - return b; - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoSection") })]), - ); - } catch (e) { - if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error"); - } - }; - const pickSection = async (s: SectionItem): Promise => { - try { - const heads = ( - await get<{ tables: TableHead[] }>("/tables", { - group, - section: s.section, - sub: book, - size: "100", - }) - ).tables; - tableList.replaceChildren( - el("h5", { text: t("Tables") }), - ...(heads.length - ? heads.map((h) => { - const b = el("button", { - className: "m02-dfip__row ui-list-row ui-sidebar-row", - attrs: { type: "button" }, - children: [ - el("strong", { text: h.이름 }), - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: t("TableLine", { - b: h.기준 || "—", - c: Object.keys(h.조건).length, - v: Object.keys(h.값칸).length, - n: h.count, - }), - }), - ], - }); - b.addEventListener("click", () => { - files.set(h.키, h.file); - void loadTable(group, h.키).then( - (tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)), - (e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"), - ); - }); - return b; - }) - : [el("p", { className: "ui-text ui-text--muted", text: t("NoTable") })]), - ); - } catch (e) { - showToast(e instanceof Error ? e.message : t("Failed"), "error"); - } - }; - let timer = 0; - q.input.addEventListener( - "input", - () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), - ); - host.replaceChildren(bookBox.root, q.root, list, tableList); - void load(); -} - -/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */ -function showChosen( - host: HTMLElement, - lg: CfLogic, - boxId: string, - group: CfGroup, - cur: { key: string; row?: number }, - on: { change(lg: CfLogic): void }, -): void { - const body = el("div", { className: "m02-dfip__stack" }); - host.replaceChildren(body); - void loadTable(group, cur.key).then( - (tb) => { - const whole = cur.row === undefined; - const modeWhole = createButton({ - label: t("Whole"), - variant: whole ? "filled" : "ghost", - onClick: () => on.change(setTable(lg, boxId, { key: cur.key })), - }); - const modeOne = createButton({ - label: t("OneRow"), - variant: whole ? "ghost" : "filled", - onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })), - }); - const rowBox = createSelectField({ - label: t("RowNo"), - options: tb.줄.map((r, i) => ({ - value: String(i), - text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)] - .slice(0, 4) - .map((c) => cell(r[c])) - .join(" / ")}`, - })), - value: String(cur.row ?? 0), - compact: true, - onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })), - }); - body.replaceChildren( - el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }), - el("div", { className: "m02-dfip__chips", children: [modeWhole, modeOne] }), - ...(whole ? [] : [rowBox.root]), - el("h5", { text: t("Preview") }), - previewOf(tb, cur.row), - el("div", { - className: "m02-dfip__chips", - children: [ - createButton({ - label: t("Change"), - variant: "ghost", - onClick: () => on.change(setTable(lg, boxId, null)), - }), - ], - }), - ); - }, - (e) => - body.replaceChildren( - el("p", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: e instanceof Error ? e.message : t("Failed"), - }), - ), - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts deleted file mode 100644 index 2898a7dbd..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Template.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Template.ts - * 입력 판 — 재료 조건 찾기 「검색어 틀」: 틀(예: 「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 입력 · 틀 자리 {이름} 마다 - * 값을 줄 변수 알약 고르기 · 글 [한, 영] 짝은 아래 · 재료 판(`M02_DF_InputPanel_Material.ts`)이 부름 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { CfLogic, CfPort } from "./M02_DF_Model"; -import { templateSlots, variablePills } from "./M02_DF_InputPanel_Build"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - Template: ["검색어 틀 — 자리는 {이름} 으로", "Search template — slots as {name}"], - Placeholder: ["예: 레미콘 {굵은골재최대치수}-{강도}-{슬럼프}", "e.g. Ready-mix {a}-{b}-{c}"], - Help: [ - "틀의 자리마다 변수 알약을 고르면 그 값이 끼워진 글로 재료를 찾음(띄어쓴 말은 모두 들어간 것)", - "Each slot takes a variable pill's value; words must all match", - ], - Slot: ["자리 「{n}」 ← 알약", "Slot “{n}” ← pill"], - NoSlot: ["틀에 자리 {이름} 을 넣어 주세요", "Add a {slot} to the template"], - NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, x), - TEXT[key][currentLanguageIndex] as string, - ); - -export interface TemplateBox { - root: HTMLElement; - /** 지금 틀 · 자리별 알약 — 틀이 비었으면 null */ - get(): { template: string; slots: { name: string; from: CfPort }[] }; -} - -/** 틀 칸 + 자리 고르기 칸 — 바뀌면 onChange */ -export function templateBox(lg: CfLogic, onChange: () => void): TemplateBox { - const pills = variablePills(lg); - const tpl = createInputField({ - type: "text", - label: t("Template"), - compact: true, - placeholder: t("Placeholder"), - }); - const slotBox = el("div", { className: "m02-dfip__stack" }); - const picks = new Map(); - const draw = (): void => { - const names = templateSlots(tpl.input.value); - const before = new Map([...picks].map(([k, s]) => [k, s.value])); - picks.clear(); - slotBox.replaceChildren( - ...(names.length - ? pills.length - ? names.map((n) => { - // 자리 이름과 같은 알약이 있으면 먼저 고름 - const same = pills.findIndex((p) => p.port.name === n); - const sel = createSelectField({ - label: t("Slot", { n }), - options: pills.map((p, i) => ({ - value: String(i), - text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, - })), - value: before.get(n) ?? String(same >= 0 ? same : 0), - compact: true, - onChange: () => onChange(), - }); - picks.set(n, sel.select); - return sel.root; - }) - : [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })] - : [el("p", { className: "ui-text ui-text--muted", text: t("NoSlot") })]), - ); - }; - tpl.input.addEventListener("input", () => (draw(), onChange())); - draw(); - return { - root: el("div", { - className: "m02-dfip__stack", - children: [ - el("p", { className: "ui-text ui-text--muted", text: t("Help") }), - tpl.root, - slotBox, - ], - }), - get: () => ({ - template: tpl.input.value.trim(), - slots: templateSlots(tpl.input.value).flatMap((n) => { - const p = pills[Number(picks.get(n)?.value)]; - return p ? [{ name: n, from: p.port }] : []; - }), - }), - }; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts deleted file mode 100644 index 09f766493..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Vars.ts +++ /dev/null @@ -1,216 +0,0 @@ -/* ============================================================================= - * M02_DF_InputPanel_Vars.ts - * 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값 - * 변수마다 출력 알약 하나 · `M02_DF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { CfLogic, CfVariable } from "./M02_DF_Model"; -import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick"; -import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_badge.css"; -import "@ui/ui_template_list_row.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - List: ["변수 — 알약마다 하나", "Variables — one pill each"], - None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"], - Name: ["이름", "Name"], - Type: ["갈래", "Type"], - Number: ["숫자", "Number"], - Choice: ["선택", "Choice"], - Text: ["글", "Text"], - Unit: ["단위", "Unit"], - Min: ["범위 아래", "Range from"], - Max: ["범위 위", "Range to"], - Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"], - Example: ["예시값", "Example"], - Add: ["변수 넣기", "Add variable"], - Save: ["고치기 끝", "Save change"], - Edit: ["고치기", "Edit"], - Remove: ["삭제", "Remove"], - Cancel: ["취소", "Cancel"], - Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"], - Range: ["범위 {a}~{b}", "range {a}~{b}"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const numOf = (s: string): number | null => - s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null; - -/** 변수 한 줄 글 */ -export function variableLine(v: CfVariable): string { - const base = t("Line", { - n: v.name, - u: v.unit || "-", - e: v.example === null ? "—" : String(v.example), - }); - const range = - v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : ""; - const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : ""; - return `${base}${range}${choices}`; -} - -export function renderVariables( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - const vars = box?.input?.variables ?? []; - let editing: CfVariable | null = null; - - const form = (): HTMLElement => { - const cur = editing; - const name = createInputField({ - type: "text", - label: t("Name"), - value: cur?.name ?? "", - placeholder: "예: 면적", - compact: true, - }); - const type = createSelectField({ - label: t("Type"), - options: [ - { value: "number", text: t("Number") }, - { value: "choice", text: t("Choice") }, - { value: "text", text: t("Text") }, - ], - value: cur?.type ?? "number", - compact: true, - onChange: () => paint(), - }); - // 단위 = 고르기만(표준 단위 목록) — 손글 안 받음 - let unitValue = cur?.unit ?? ""; - const unit = el("div", { className: "m02-dfip__unitfield" }); - const drawUnit = (): void => - unit.replaceChildren( - el("span", { - className: "ui-text ui-text--muted", - attrs: { "data-text": "14" }, - text: t("Unit"), - }), - createUnitPick({ - value: unitValue || NO_UNIT, - ariaLabel: t("Unit"), - onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()), - }), - ); - drawUnit(); - const lo = createInputField({ - type: "text", - label: t("Min"), - value: cur?.range ? String(cur.range[0]) : "", - compact: true, - }); - const hi = createInputField({ - type: "text", - label: t("Max"), - value: cur?.range ? String(cur.range[1]) : "", - compact: true, - }); - const choices = createInputField({ - type: "text", - label: t("Choices"), - value: (cur?.choices ?? []).join(", "), - compact: true, - }); - const example = createInputField({ - type: "text", - label: t("Example"), - value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example), - compact: true, - }); - const extra = el("div", { className: "m02-dfip__stack" }); - const paint = (): void => { - const ty = type.select.value; - extra.replaceChildren( - ...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []), - ); - }; - paint(); - const submit = (): void => { - const ty = type.select.value as CfVariable["type"]; - const ex = example.input.value.trim(); - const v: CfVariable = { - name: name.input.value.trim(), - type: ty, - unit: unitValue, - example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex, - ...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null - ? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] } - : {}), - ...(ty === "choice" - ? { - choices: choices.input.value - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - } - : {}), - }; - const why = checkVariable( - v, - vars.filter((x) => x.name !== cur?.name), - ); - name.setError(why ?? undefined); - if (why) return; - on.change(putVariable(lg, boxId, v, cur?.name)); - }; - const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit }); - const cancel = cur - ? createButton({ - label: t("Cancel"), - variant: "ghost", - onClick: () => ((editing = null), draw()), - }) - : null; - return el("div", { - className: "m02-dfip__form", - children: [ - name.root, - type.root, - unit, - extra, - example.root, - el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }), - ], - }); - }; - - const draw = (): void => { - const list = vars.length - ? vars.map((v) => - el("div", { - className: "m02-dfip__item", - children: [ - el("span", { text: variableLine(v) }), - createButton({ - label: t("Edit"), - variant: "ghost", - size: "sm", - onClick: () => ((editing = v), draw()), - }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => on.change(removeVariable(lg, boxId, v.name)), - }), - ], - }), - ) - : [el("p", { className: "ui-text ui-text--muted", text: t("None") })]; - host.replaceChildren(el("h5", { text: t("List") }), ...list, form()); - }; - draw(); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css deleted file mode 100644 index 346a5abae..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css +++ /dev/null @@ -1,63 +0,0 @@ -.m02-dflink { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m02-dflink-head, -.m02-dflink-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m02-dflink-label { - font-weight: var(--font-weight-bold); -} - -.m02-dflink-note { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m02-dflink-num { - width: 7em; -} - -/* 겉 = 띠 알림 부품 */ -.m02-dflink-warn { - margin: 0; -} - -/* 알약 판 */ -.m02-dfpill { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m02-dfpill-title { - font-weight: var(--font-weight-bold); -} - -.m02-dfpill-list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - margin: 0; - padding: 0; - list-style: none; -} - -.m02-dfpill-step { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: center; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts deleted file mode 100644 index 3c7cc1e3b..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts +++ /dev/null @@ -1,541 +0,0 @@ -/* ============================================================================= - * M02_DF_LinkPanel.ts - * 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) · - * 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림 - * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기 - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import { - gateConds, - makeGate, - portsOut, - reaches, - valueChoices, - type CfGateCond, - type CfGateOp, - type CfLogic, - type CfPort, - type CfRound, - type CfRule, -} from "./M02_DF_Model"; -import { - GATE_OPS, - commas, - SCALE_OPS, - evalSafe, - labelOf, - linkNotes, - portText, - preview, - unitOf, - valueOf, - type DetailOptions, -} from "./M02_DF_Detail"; -import { suggestPlaces } from "./M02_DF_UnitPick"; -import "./M02_DF_LinkPanel.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_banner.css"; -import "@ui/ui_template_badge.css"; - -const TEXT = { - Title: ["선 규칙", "Line rule"], - Kinds: ["규칙 고르기", "Pick a rule"], - K_none: ["없음", "None"], - K_lookup: ["표에서 찾기", "Look up in table"], - K_gate: ["조건 통과", "Pass if"], - K_scale: ["바꾸기", "Change"], - K_round: ["끝수", "Rounding"], - Label: ["선 위 글", "Text on the line"], - Before: ["지나가기 전 값", "Value before"], - After: ["지난 뒤 값", "Value after"], - Col: ["조건 칸", "Condition column"], - Match: ["맞출 알약", "Pill to match"], - Take: ["꺼낼 칸", "Column to take"], - AddCond: ["+ 조건 칸", "+ Condition"], - Remove: ["지움", "Remove"], - NoHeader: [ - "보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름", - "The sender is not a table or it is not loaded yet", - ], - Pick: ["고르기", "pick"], - GateHint: [ - "알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음", - "Sent only when the pill passes", - ], - Value: ["값", "Value"], - ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"], - Places: ["소수 {n}자리", "{n} decimals"], - Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], - Apply: ["적용", "Apply"], - WarnBoth: [ - "같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침", - "{n} conditional lines pass at once — overlap", - ], - WarnNone: [ - "같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음", - "None of the {n} conditional lines match", - ], - WarnLookup: [ - "조건 칸이 없음 — 표에서 줄을 못 고름", - "No condition column — a row can't be picked", - ], - WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"], - Count: ["선", "Line"], - And: ["그리고", "and"], - Or: ["또는", "or"], - If: ["조건", "If"], - AddLine: ["+ 조건 줄", "+ Condition line"], - PickOne: ["고르세요", "choose"], - Join: ["잇는 말", "Join with"], - OutRange: ["범위 {n} 밖", "Outside the range {n}"], - Range: ["{n} (범위)", "{n} (range)"], -} as const; - -const tk = (key: keyof typeof TEXT, n?: number | string): string => - TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? "")); - -type Kind = "none" | CfRule["kind"]; -const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"]; -const SEP = "\u0001"; - -interface Ctx { - lg: CfLogic; - linkId: string; - readOnly: boolean; - headers: string[]; - detail: DetailOptions; - change(lg: CfLogic): void; -} - -const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`; -const fromKey = (k: string): CfPort => { - const [box, ...rest] = k.split(SEP); - return { box, name: rest.join(SEP) }; -}; - -function setRule(ctx: Ctx, rule: CfRule | undefined): void { - ctx.change({ - ...ctx.lg, - links: ctx.lg.links.map((l) => { - if (l.id !== ctx.linkId) return l; - const { rule: _old, ...rest } = l; - return rule ? { ...rest, rule } : rest; - }), - }); -} - -function select( - ctx: Ctx, - options: { value: string; text: string }[], - value: string, - onPick: (v: string) => void, - aria: string, -): HTMLElement { - const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); - for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); - if (!options.some((o) => o.value === value)) - s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") })); - s.value = value; - s.disabled = ctx.readOnly; - s.addEventListener("change", () => onPick(s.value)); - return s; -} - -/** 숫자 칸 — 보일 때만 천 단위 쉼표 · 고치는 동안은 쉼표 없이 */ -function numberBox( - ctx: Ctx, - value: number | string, - onCommit: (v: number | string) => void, - aria: string, -): HTMLElement { - const i = el("input", { - className: "ui-input ui-input--sm m02-dflink-num", - attrs: { type: "text", inputmode: "decimal", "aria-label": aria }, - }); - const shown = (): string => (typeof value === "number" ? commas(value) : String(value)); - i.value = shown(); - i.readOnly = ctx.readOnly; - i.addEventListener("focus", () => (i.value = String(value))); - i.addEventListener("blur", () => (i.value = shown())); - i.addEventListener("change", () => { - const t = i.value.trim().replace(/,/g, ""); - onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t); - }); - return i; -} - -/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */ -const pillOptions = (ctx: Ctx): { value: string; text: string }[] => { - const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? ""; - return ctx.lg.boxes - .filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id)) - .flatMap((b) => - portsOut(b).map((p) => ({ - value: toKey({ box: b.id, name: p.name }), - text: portText(ctx.lg, { box: b.id, name: p.name }), - })), - ); -}; - -const row = (...children: (HTMLElement | string)[]): HTMLElement => - el("div", { className: "m02-dflink-row", children }); - -/* ── 규칙마다 ─────────────────────────────────────────────────────────── */ - -function lookupEditor(ctx: Ctx, rule: Extract): HTMLElement[] { - // 꺼낼 칸 = 표 머리 칸만 - const cols = [...new Set([...ctx.headers, rule.col].filter(Boolean))]; - const colOpts = cols.map((c) => ({ value: c, text: c })); - // 조건 칸 — 「이름·아래」 「이름·위」 짝은 「이름 (범위)」 한 줄만(고르면 칸 이름 = 이름 · Rule 이 아래 < 값 ≤ 위 로 찾음) - const range = (c: string): string | null => { - const m = /^(.+)·(아래|위)$/.exec(c); - return m && cols.includes(`${m[1]}·아래`) && cols.includes(`${m[1]}·위`) ? m[1] : null; - }; - const bases = [...new Set(cols.map(range).filter((x): x is string => !!x))]; - const condOpts = [ - ...cols.filter((c) => !range(c) && !bases.includes(c)).map((c) => ({ value: c, text: c })), - ...bases.map((x) => ({ value: x, text: tk("Range", x) })), - ]; - const pills = pillOptions(ctx); - const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); - const rows = rule.conds.map((c, i) => - row( - select( - ctx, - condOpts, - c.col, - (v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }), - tk("Col"), - ), - "=", - select( - ctx, - pills, - toKey(c.from), - (v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }), - tk("Match"), - ), - ctx.readOnly - ? "" - : createButton({ - label: tk("Remove"), - variant: "ghost", - size: "sm", - onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }), - }), - ), - ); - const out: HTMLElement[] = [...rows]; - if (!ctx.readOnly) { - out.push( - row( - createButton({ - label: tk("AddCond"), - variant: "ghost", - size: "sm", - disabled: !pills.length || !condOpts.length, - onClick: () => - put({ - conds: [...rule.conds, { col: condOpts[0].value, from: fromKey(pills[0].value) }], - }), - }), - ), - ); - } - out.push( - row( - el("span", { className: "m02-dflink-label", text: tk("Take") }), - select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")), - ), - ); - if (!cols.length) - out.push(el("p", { className: "ui-text ui-text--muted", text: tk("NoHeader") })); - return out; -} - -/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */ -function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null { - const v = ctx.lg.boxes - .find((b) => b.id === p.box) - ?.input?.variables.find((x) => x.name === p.name); - return v?.type === "number" && v.range ? v.range : null; -} - -/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */ -function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] { - const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values }); - if (list?.length) { - const key = (v: string | number): string => `${typeof v}:${v}`; - const known = list.some((v) => key(v) === key(c.value)); - const options = [ - ...(known ? [] : [{ value: "", text: tk("PickOne") }]), - ...list.map((v) => ({ value: key(v), text: String(v) })), - ]; - return [ - select( - ctx, - options, - known ? key(c.value) : "", - (k) => { - const hit = list.find((v) => key(v) === k); - if (hit !== undefined) onPick(hit); - }, - tk("Value"), - ), - ]; - } - const range = rangeOf(ctx, c.from); - const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))]; - const n = Number(c.value); - if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) { - out.push( - el("span", { - className: "ui-text ui-text--warning", - text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`, - }), - ); - } - return out; -} - -function gateEditor(ctx: Ctx, rule: Extract): HTMLElement[] { - const g = gateConds(rule); - const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join)); - const edit = (i: number, patch: Partial): void => - put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c))); - const out: HTMLElement[] = [ - el("p", { className: "ui-text ui-text--muted", text: tk("GateHint") }), - ]; - g.conds.forEach((c, i) => { - out.push( - row( - el("span", { - className: "m02-dflink-label", - text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"), - }), - select( - ctx, - pillOptions(ctx), - toKey(c.from), - (v) => { - const from = fromKey(v); - edit(i, { - from, - value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0, - }); - }, - tk("Match"), - ), - select( - ctx, - GATE_OPS.map((o) => ({ value: o, text: o })), - c.op, - (v) => edit(i, { op: v as CfGateOp }), - tk("Kinds"), - ), - ...gateValue(ctx, c, (v) => edit(i, { value: v })), - ctx.readOnly || g.conds.length < 2 - ? "" - : createButton({ - label: tk("Remove"), - variant: "ghost", - size: "sm", - onClick: () => put(g.conds.filter((_, j) => j !== i)), - }), - ), - ); - }); - if (!ctx.readOnly) { - const first = g.conds[0]; - out.push( - row( - createButton({ - label: tk("AddLine"), - variant: "ghost", - size: "sm", - onClick: () => { - const from = first?.from ?? { box: "", name: "" }; - // 새 줄 기본 값 — 목록이 있는 알약이면 그 첫 값 · 아니면 0 - const value = valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0; - put([...g.conds, { from, op: "=", value }]); - }, - }), - ...(g.conds.length > 1 - ? [ - el("span", { className: "m02-dflink-label", text: tk("Join") }), - select( - ctx, - [ - { value: "그리고", text: tk("And") }, - { value: "또는", text: tk("Or") }, - ], - g.join, - (v) => put(g.conds, v as "그리고" | "또는"), - tk("Join"), - ), - ] - : []), - ), - ); - } - return out; -} - -function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { - return [ - el("p", { className: "ui-text ui-text--muted", text: tk("ScaleHint") }), - row( - select( - ctx, - SCALE_OPS.map((o) => ({ value: o, text: o })), - rule.op, - (v) => setRule(ctx, { ...rule, op: v as typeof rule.op }), - tk("Kinds"), - ), - numberBox( - ctx, - rule.value, - (v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }), - tk("Value"), - ), - ), - ]; -} - -function roundEditor( - ctx: Ctx, - rule: Extract, - unit: string, -): HTMLElement[] { - const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); - const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) })); - const hint = el("span", { className: "m02-dflink-note" }); - void suggestPlaces(unit).then((n) => { - if (n === null || n === rule.places || ctx.readOnly) return; - hint.append( - tk("Suggest", n), - " ", - createButton({ - label: tk("Apply"), - variant: "ghost", - size: "sm", - onClick: () => put({ places: n }), - }), - ); - }); - return [ - row( - select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")), - select( - ctx, - (["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })), - rule.method, - (v) => put({ method: v as CfRound["method"] }), - tk("K_round"), - ), - hint, - ), - ]; -} - -const DEFAULT: Record, (from: CfPort) => CfRule> = { - lookup: () => ({ kind: "lookup", conds: [], col: "" }), - gate: (from) => makeGate([{ from, op: "≥", value: 0 }]), - scale: () => ({ kind: "scale", op: "×", value: 1 }), - round: () => ({ kind: "round", places: 0, method: "반올림" }), -}; - -/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */ -export function renderLinkPanel( - host: HTMLElement, - lg: CfLogic, - linkId: string, - on: { change(lg: CfLogic): void }, - opts: DetailOptions & { readOnly?: boolean } = {}, -): void { - const link = lg.links.find((l) => l.id === linkId); - host.classList.add("m02-dflink"); - if (!link) return host.replaceChildren(); - const ev = evalSafe(lg, opts); - const table = valueOf(ev, link.from); - const headers = - Array.isArray(table) && Array.isArray(table[0]) - ? table[0].map((h) => String(h ?? "")).filter(Boolean) - : []; - const ctx: Ctx = { - lg, - linkId, - readOnly: !!opts.readOnly, - headers, - detail: opts, - change: on.change, - }; - const rule = link.rule; - const kind: Kind = rule?.kind ?? "none"; - const parts: HTMLElement[] = [ - el("div", { - className: "m02-dflink-head", - children: [ - el("strong", { - text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`, - }), - el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }), - ], - }), - row( - el("span", { className: "m02-dflink-label", text: tk("Kinds") }), - ...KINDS.map((k) => - createButton({ - label: tk(`K_${k}` as "K_none"), - variant: k === kind ? "filled" : "ghost", - size: "sm", - disabled: ctx.readOnly, - onClick: () => - k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)), - }), - ), - ), - ]; - if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule)); - if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule)); - if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule)); - if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from))); - - const pv = preview(link, ev); - parts.push( - row( - el("span", { className: "m02-dflink-label", text: tk("Before") }), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: pv.before, - }), - "→", - el("span", { className: "m02-dflink-label", text: tk("After") }), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: pv.after, - }), - ), - row( - el("span", { className: "m02-dflink-label", text: tk("Label") }), - el("span", { text: labelOf(rule) }), - ), - ); - - const warns: string[] = linkNotes(link, ev); - if (rule?.kind === "lookup") { - if (!rule.conds.length) warns.push(tk("WarnLookup")); - if (!rule.col) warns.push(tk("WarnTake")); - } - parts.push( - ...warns.map((w) => - el("p", { className: "ui-banner ui-banner--warning m02-dflink-warn", text: `⚠ ${w}` }), - ), - ); - host.replaceChildren(...parts); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py b/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py deleted file mode 100644 index 2d54282b3..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py +++ /dev/null @@ -1,112 +0,0 @@ -"""M02 흐름도 — 선 규칙 「표에서 찾기」 · 값 같음(Compute 에서 나눔 · Rule lookup 과 같은 뜻). - -표 블록 = 머리 줄 + 줄 · 범위 칸 「이름·아래」 「이름·위」 · 끝 칸 「·범위규칙」. -빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄은 뺌 · 글은 대소문자까지 · 여럿이면 위 끝 최소 줄(옛 find). -""" - -from __future__ import annotations - -import re -from decimal import Decimal -from typing import Any - -from M02_MasterTemplete.DataFlow import M02_DF_Values as dv - -RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE) - - -def _num_of(v: Any) -> Decimal | None: - if isinstance(v, bool) or v is None: - return None - if isinstance(v, (int, float, Decimal)): - return Decimal(str(v)) - return None - - -def _same(a: Any, b: Any) -> bool: - """엑셀 「=」 — 수끼리는 수로 · 글끼리는 대소문자 무시 · 빈 칸은 0 · 빈 글과 같음.""" - na, nb = _num_of(a), _num_of(b) - if a is None or b is None: - other = b if a is None else a - return other is None or other == "" or _num_of(other) == 0 - if na is not None and nb is not None: - return na == nb - if isinstance(a, str) and isinstance(b, str): - return a.lower() == b.lower() - return False - - -def _is_err(v: Any) -> bool: - return isinstance(v, str) and re.fullmatch(r"#[A-Z/0!?]+", v) is not None - - -def _exact(a: Any, b: Any) -> bool: - """표 찾기 같음 — 글끼리는 대소문자까지(옛 find · Rule lookup FIND) · 그 밖은 엑셀 「=」.""" - return a == b if isinstance(a, str) and isinstance(b, str) else _same(a, b) - - -def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: - """표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나 - · 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄 - (옛 엔진 find 와 같음 · Rule lookup 과 같음).""" - if not isinstance(block, list) or len(block) < 2: - return None, "오류" - head = [str(h if h is not None else "") for h in block[0]] - out = rule.get("col") - two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head) - if out not in head and not two: - return None, "오류" - tests, ups = [], [] - rk = head.index(RANGE_RULE) if RANGE_RULE in head else None - for c in rule.get("conds") or []: - col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {}) - if col in head: - i = head.index(col) - tests.append(lambda row, i=i, want=want: row[i] is None or _exact(row[i], want)) - elif col + dv.RANGE_LOW in head and col + dv.RANGE_HIGH in head: - lo, hi, x = ( - head.index(col + dv.RANGE_LOW), - head.index(col + dv.RANGE_HIGH), - _num_of(want), - ) - ups.append(hi) - - def fits(row, lo=lo, hi=hi, x=x) -> bool: - low, high = _num_of(row[lo]), _num_of(row[hi]) - if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함) - return x is not None and x == low - word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음) - return ( - x is not None - and ( - row[lo] is None - or (low is not None and (low <= x if word[:2] == "이상" else low < x)) - ) - and ( - row[hi] is None - or (high is not None and (x < high if word[-2:] == "미만" else x <= high)) - ) - ) - - tests.append(fits) - else: - return None, "오류" - at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값 - rows = [list(r) + [None] * len(head) for r in block[1:]] - hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)] - if not hits: - return None, "표에 없는 조건" - for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄 - tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None] - hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)] - if len(hits) > 1: - return None, "표에 여럿 맞음" - if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]): - return None, "오류" - return hits[0][at], None - - -def _cond_of(conds: dict, port: dict) -> Any: - """조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf).""" - key = f"{port.get('box')}|{port.get('name')}" - return conds[key] if key in conds else conds.get(port.get("name")) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_Material.ts deleted file mode 100644 index 27204e11a..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Material.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* ============================================================================= - * M02_DF_Material.ts - * 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials`(Api `cfMaterials` · - * 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기) · 단가 = 그 줄의 단가(값을 받는 길과 같은 셈) - * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { cfMaterials, type CfMaterial } from "./M02_DF_Api"; - -export type CfMat = CfMaterial; - -export interface CfMatPage { - total: number; - page: number; - size: number; - items: CfMat[]; -} - -/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */ -export function findMaterials(cond: { - q?: string; - sub?: string; - detail?: string; - page?: number; - size?: number; -}): Promise { - return cfMaterials({ ...cond, page: cond.page ?? 1, size: cond.size ?? 10 }); -} - -const byKey = new Map(); - -/** 재료 키 → 그 줄(이름 · 규격 · 단가) — 한 번 물은 것은 다시 안 물음 */ -export async function materialOf(key: string): Promise { - if (!key) return null; - if (byKey.has(key)) return byKey.get(key) ?? null; - let hit: CfMat | null = null; - try { - hit = (await cfMaterials({ q: key, size: 20 })).items.find((m) => m.ref === key) ?? null; - } catch { - hit = null; - } - byKey.set(key, hit); - return hit; -} - -/** 재료 키 → 단가(원) — 못 구하면 null */ -export async function priceOf(key: string): Promise { - return (await materialOf(key))?.price ?? null; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts deleted file mode 100644 index 6939e9823..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ /dev/null @@ -1,697 +0,0 @@ -/* ============================================================================= - * M02_DF_Model.ts - * 단가산출 흐름도 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나) - * 선 규칙(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) · 재료 방식 셋 · 다른 로직 입력 · 흐름 따라가기 · 설계 때 정할 것 - * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 4~8 · 12 · 1판 약속 `원가다이어그램_정함.md` - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; - -export type CfKind = "input" | "function" | "output"; -export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율"; -export type CfCost = "노무비" | "재료비" | "경비" | "값"; - -/** 재료 요소 방식 — 고정(품목 하나) · 선택 자리(프로젝트에서 고름) · 조건 찾기(규격 / 이름에 알약 값) - * 조건 찾기는 인력 · 기계 · 유가전력 요소도 씀(field 「키」 — 입력값으로 요소를 고름) */ -export type CfMaterialMode = "fixed" | "choice" | "rule"; - -/** 재료 찾을 범위 */ -export interface CfScope { - 구분?: string; - 상세구분?: string; - 찾는말?: string; -} - -/** 재료 조건 찾기 — 품목의 규격 / 이름에 [알약 값] 이 포함 · 같음 - * field 「키」 = 요소 ref 의 자리 표시 {…} 에 알약 값을 끼운 요소(op 같음) — ref 꼴 「EQ:{기계}」 · 「EQ:{기계}#가격」 · - * 「MO:경유:{지역}」 · 「LB:{직종}」 · 재료 검색어 묶음 JSON(검색어 안 {…}) · 옛 엔진 `fill` → `Master.get` 과 같은 뜻 */ -export interface CfMaterialRule { - field: "규격" | "이름" | "키" | "검색어"; - op: "포함" | "같음"; - from: CfPort; - /** field 「검색어」 — 옛 재료 검색어 찾기(master_pick.search) 의 틀(「레미콘 {a}-{b}-{c}」) · scope 구분 · 상세구분 안 */ - template?: string; - /** 틀의 자리 {이름} 마다 값을 줄 알약 — from = 첫 자리 */ - slots?: { name: string; from: CfPort }[]; -} - -/** 재료 조건 찾기가 값을 받는 알약 — from + 틀 자리들(겹침 없이) */ -export function materialSources(rule: CfMaterialRule): CfPort[] { - const all = [rule.from, ...(rule.slots ?? []).map((x) => x.from)]; - return all.filter((p, i) => all.findIndex((q) => q.box === p.box && q.name === p.name) === i); -} - -/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id - * 재료면 mode · scope · rule · default · slot(알약 이름 = 자리 이름 · 없으면 name) */ -export interface CfElementRef { - ref: string; - name: string; - spec: string; - unit: string; - mode?: CfMaterialMode; - scope?: CfScope; - rule?: CfMaterialRule; - /** 기본 품목 키(선택 자리 · 조건 찾기) */ - default?: string; - slot?: string; -} - -/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */ -export interface CfVariable { - name: string; - type: "number" | "choice" | "text"; - unit: string; - choices?: (string | number)[]; - range?: [number, number]; - example: number | string | null; - /** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */ - cell?: string; -} - -/** 다른 로직 입력 — 저장 로직(DF-…) · 견본 id · outputs · designs 는 고를 때 그 로직에서 읽어 둔 것 */ -export interface CfLogicRef { - key: string; - name?: string; - /** 내보내는 알약 = 그 로직 출력 상자 이름(비용 · 값) */ - outputs: CfPill[]; - /** 받는 알약 = 그 로직의 「설계 때 정할 것」(변수 · 재료 자리) */ - designs: CfPill[]; - /** 선 없는 받는 알약의 예시값(키 = 받는 알약 이름) — 값 차례 = 이어진 선 > 넘긴 값 > 이것 > 그 로직 자기 예시값 */ - examples?: Record; -} - -export interface CfInput { - mode: "group" | "variable" | "logic"; - group?: CfGroup; - elements: CfElementRef[]; - /** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */ - table?: { key: string; row?: number }; - variables: CfVariable[]; - /** mode "logic" */ - logic?: CfLogicRef; -} - -/** 끝수 — 소수 자리 · 방법 */ -export interface CfRound { - places: number; - method: "반올림" | "올림" | "버림"; -} - -export interface CfFunc { - /** 우리 스프레드시트 시트 하나 */ - sheet: Sheet; - /** 받는 알약 → 칸 */ - inputs: { port: string; cell: string }[]; - variables: CfVariable[]; - /** 내보내는 알약 하나 */ - output: { cell: string; name: string; unit: string; round?: CfRound }; -} - -export interface CfOutput { - cost: CfCost; - unit: string; - round?: CfRound; -} - -export interface CfBox { - id: string; - kind: CfKind; - name: string; - color: string; - /** 오른쪽 위 단위 — 기본 「-」 */ - unit: string; - x: number; - y: number; - input?: CfInput; - func?: CfFunc; - output?: CfOutput; -} - -/** 알약 한쪽 끝 */ -export interface CfPort { - box: string; - name: string; -} - -export type CfGateOp = "=" | "≠" | "≥" | "≤" | ">" | "<"; - -/** 선 규칙(선 하나에 하나 · 없으면 그대로) — 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌로 셈 - * lookup = 보낸 표에서 조건 칸마다 맞출 알약 · 꺼낼 칸(범위 칸은 「이름·아래」 「이름·위」) - * gate = 알약 값이 조건을 통과할 때만 보냄 · scale = × ÷ +% -% · round = 끝수 */ -/** 조건 통과의 조건 한 줄 */ -export interface CfGateCond { - from: CfPort; - op: CfGateOp; - value: number | string; -} - -/** 조건 통과 — conds 여러 줄을 join(그리고 · 또는)으로 · 옛 한 줄 꼴(from · op · value)도 읽음 - * from · op · value 는 첫 조건과 같게 둠(옛 화면 코드가 그대로 돌게 · makeGate 가 맞춰 줌) — conds 가 있으면 conds 가 이김 */ -export type CfGateRule = { - kind: "gate"; - conds?: CfGateCond[]; - join?: "그리고" | "또는"; - from: CfPort; - op: CfGateOp; - value: number | string; -}; - -export type CfRule = - | { kind: "lookup"; conds: { col: string; from: CfPort }[]; col: string } - | CfGateRule - | { kind: "scale"; op: "×" | "÷" | "+%" | "-%"; value: number } - | ({ kind: "round" } & CfRound); - -export interface CfLink { - id: string; - from: CfPort; - to: CfPort; - rule?: CfRule; -} - -/** 흐름 한 걸음 — 그 알약 · 그 알약으로 들어온(나간) 선의 규칙 */ -export interface CfStep { - box: string; - port: string; - dir: "in" | "out"; - link?: string; - rule?: CfRule; -} - -/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직의 받는 알약(선이 없을 때) */ -export interface CfDesign { - box: string; - port: string; - kind: "변수" | "재료 자리" | "다른 로직"; - unit: string; - variable?: CfVariable; - element?: CfElementRef; - /** 보일 예시값 — 변수 = variable.example · 다른 로직 = logic.examples(없으면 빈 칸 → 그 로직 자기 예시값) */ - example?: number | string | null; -} - -/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */ -export interface CfUserElement { - id: string; - group: CfGroup; - name: string; - spec: string; - unit: string; - value: number | null; -} - -export interface CfLogic { - key: string | null; - name: string; - resultUnit: string; - boxes: CfBox[]; - links: CfLink[]; - userElements: CfUserElement[]; - /** 원가 합계 끝수 — 없으면 기본(일위대가표 계금 1원 미만 버림) · null = 끝수 없음(값 그대로 · 옛 로직 변환) */ - totalRound?: CfRound | null; -} - -/** 알약 하나의 모습 */ -export interface CfPill { - name: string; - unit: string; -} - -const TEXT = { - Input: ["입력", "Input"], - Func: ["함수", "Function"], - Output: ["출력", "Output"], - Result: ["결과", "Result"], - Value: ["값", "Value"], - NoPill: ["알약이 없음", "No such pill"], - FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"], - ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"], - Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], - Taken: ["이미 선이 들어온 자리", "This spot already has a line"], - Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], - CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"], -} as const; - -const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -/** 종류별 기본 색 */ -export const BOX_COLOR: Record = { - input: "#2f80ed", - function: "#7b4fd6", - output: "#27ae60", -}; - -/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */ -export const OUTPUT_PORT = "값"; -export const TABLE_PORT = "표"; - -const clone = (v: T): T => structuredClone(v); - -/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */ -function nextId(prefix: string, ids: string[]): string { - const nums = ids - .filter((x) => x.startsWith(prefix)) - .map((x) => Number(x.slice(prefix.length))) - .filter((n) => Number.isInteger(n)); - return `${prefix}${Math.max(0, ...nums) + 1}`; -} - -/** 같은 이름이면 「 2」 「 3」 */ -function uniqueName(used: string[], base: string): string { - if (!used.includes(base)) return base; - for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`; -} - -const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); - -/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */ -export function updateBox( - lg: CfLogic, - id: string, - patch: Partial>, -): CfLogic { - return { - ...lg, - boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)), - }; -} - -/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ - -export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic { - return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] }; -} - -/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */ -export function addBox( - lg: CfLogic, - kind: CfKind, - x: number, - y: number, -): { lg: CfLogic; box: CfBox } { - const id = nextId( - "b", - lg.boxes.map((b) => b.id), - ); - const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output"); - const name = uniqueName( - lg.boxes.map((b) => b.name), - base, - ); - const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; - if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; - if (kind === "function") { - // 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음 - const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : [])); - let n = 1; - while (taken.includes(`${tl("Result")} ${n}`)) n++; - const out = `${tl("Result")} ${n}`; - box.func = { - sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } }, - inputs: [], - variables: [], - output: { cell: "B1", name: out, unit: "-" }, - }; - } - if (kind === "output") box.output = { cost: "노무비", unit: "-" }; - return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; -} - -/** 상자 지우기 — 이어진 선도 지움 */ -export function removeBox(lg: CfLogic, id: string): CfLogic { - // 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리 - let next = lg; - for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id); - return { ...next, boxes: next.boxes.filter((b) => b.id !== id) }; -} - -export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { - return updateBox(lg, id, { x, y }); -} - -export function setColor(lg: CfLogic, id: string, color: string): CfLogic { - return updateBox(lg, id, { color }); -} - -/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */ -export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { - const box = boxOf(lg, id); - if (!box) return lg; - const u = unit.trim() || "-"; - const patch: Partial = { unit: u }; - if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } }; - if (box.output) patch.output = { ...box.output, unit: u }; - return updateBox(lg, id, patch); -} - -/* ── 알약 ───────────────────────────────────────────────────────────────── */ - -/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */ -export const elementPort = (e: CfElementRef): string => - e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name; - -/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 variable.name - * · 다른 로직이면 그 로직 출력 · 함수 = func.output.name 하나 · 출력 = 없음 */ -export function portsOut(box: CfBox): CfPill[] { - if (box.kind === "function" && box.func) - return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; - if (box.kind !== "input" || !box.input) return []; - const inp = box.input; - if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p })); - return [ - ...(inp.mode === "group" - ? inp.elements.map((e) => ({ name: elementPort(e), unit: e.unit || "-" })) - : []), - ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), - ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), - ]; -} - -/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 다른 로직 입력 = 그 로직의 설계 때 정할 것 · 그 밖 입력 = 없음 */ -export function portsIn(box: CfBox): CfPill[] { - if (box.kind === "function" && box.func) - return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); - if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; - if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p })); - return []; -} - -/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */ -export function portDir(box: CfBox, name: string): "in" | "out" { - if (box.kind === "output") return "in"; - if (portsOut(box).some((p) => p.name === name)) return "out"; - return "in"; -} - -/* ── 선 ─────────────────────────────────────────────────────────────────── */ - -/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */ -export function ruleSources(rule: CfRule | undefined): CfPort[] { - if (!rule) return []; - if (rule.kind === "lookup") return rule.conds.map((c) => c.from); - if (rule.kind === "gate") return gateConds(rule).conds.map((c) => c.from); - return []; -} - -/** 조건 통과의 조건 줄 · 잇는 말 — 옛 한 줄 꼴은 조건 하나 */ -export function gateConds(rule: CfGateRule): { conds: CfGateCond[]; join: "그리고" | "또는" } { - const conds = rule.conds?.length - ? rule.conds - : [{ from: rule.from, op: rule.op, value: rule.value }]; - return { conds, join: rule.join ?? "그리고" }; -} - -/** 조건 통과 만들기 — 옛 칸(from · op · value)은 첫 조건과 같게 */ -export function makeGate(conds: CfGateCond[], join: "그리고" | "또는" = "그리고"): CfGateRule { - const first = conds[0] ?? { from: { box: "", name: "" }, op: "=" as const, value: "" }; - return { - kind: "gate", - conds: clone(conds), - join, - from: { ...first.from }, - op: first.op, - value: first.value, - }; -} - -/** 알약이 가질 수 있는 값 목록 — 조건 값 고르기 · null = 숫자 칸(범위 검사) - * 입력 변수 고르기 = choices · 재료 알약 = 그 요소들의 규격 · 표 알약 = opts.col 칸 값들(opts.values 의 표 덩어리) - * · 함수 결과 · 다른 로직 출력 · 수 변수 = null */ -export function valueChoices( - lg: CfLogic, - port: CfPort, - opts: { col?: string; values?: Record> } = {}, -): (string | number)[] | null { - const box = boxOf(lg, port.box); - const inp = box?.input; - if (!box || box.kind !== "input" || !inp || inp.mode === "logic") return null; - const v = inp.variables.find((x) => x.name === port.name); - if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; - if (port.name === TABLE_PORT && inp.table) { - const block = opts.values?.[box.id]?.[TABLE_PORT]; - if (!opts.col || !Array.isArray(block) || !Array.isArray(block[0])) return null; - const head = (block[0] as unknown[]).map(String); - const at = head.indexOf(opts.col); - if (at < 0) return null; - const seen: (string | number)[] = []; - for (const row of block.slice(1) as unknown[][]) { - const x = row[at]; - if ((typeof x === "number" || typeof x === "string") && x !== "" && !seen.includes(x)) - seen.push(x); - } - return seen.length ? seen : null; - } - const specs = inp.elements - .filter((e) => elementPort(e) === port.name && e.spec.trim()) - .map((e) => e.spec.trim()); - const uniq = [...new Set(specs)]; - return uniq.length ? uniq : null; -} - -/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */ -export function depends(lg: CfLogic): { from: string; to: string }[] { - const out: { from: string; to: string }[] = []; - for (const l of lg.links) { - out.push({ from: l.from.box, to: l.to.box }); - for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box }); - } - for (const b of lg.boxes) - for (const e of b.input?.elements ?? []) - if (e.mode === "rule" && e.rule) - for (const p of materialSources(e.rule)) out.push({ from: p.box, to: b.id }); - return out; -} - -/** from 상자에서 기댐을 따라 to 상자에 닿나 */ -export function reaches(lg: CfLogic, from: string, to: string): boolean { - const deps = depends(lg); - const seen = new Set(); - const walk = (id: string): boolean => { - if (id === to) return true; - if (seen.has(id)) return false; - seen.add(id); - return deps.filter((d) => d.from === id).some((d) => walk(d.to)); - }; - return walk(from); -} - -/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 · - * 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음) - * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · - * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ -export function link( - lg: CfLogic, - from: CfPort, - to: CfPort, - rule?: CfRule, -): { lg: CfLogic; why: string | null } { - const a = boxOf(lg, from.box); - const b = boxOf(lg, to.box); - if (!a || !b) return { lg, why: tl("NoPill") }; - if (a.kind === "output") return { lg, why: tl("FromOutput") }; - if (b.kind === "input" && b.input?.mode !== "logic") return { lg, why: tl("ToInput") }; - if (a.id === b.id) return { lg, why: tl("Self") }; - if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; - const has = portsIn(b).some((p) => p.name === to.name); - if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; - const why = ruleWhy(lg, b.id, rule); - if (why) return { lg, why }; - if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; - if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) - return { lg, why: tl("Taken") }; - let next = lg; - let port = to.name; - if (!has) { - const used = portsIn(b).map((p) => p.name); - const base = to.name.trim() || from.name; - port = used.includes(base) ? uniqueSuffix(used, base) : base; - const func = b.func!; - next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } }); - } - const id = nextId( - "l", - next.links.map((l) => l.id), - ); - const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } }; - if (rule) made.rule = clone(rule); - return { lg: { ...next, links: [...next.links, made] }, why: null }; -} - -/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */ -function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null { - for (const p of ruleSources(rule)) { - const box = boxOf(lg, p.box); - if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill"); - if (box.id === target || reaches(lg, target, box.id)) return tl("Loop"); - } - return null; -} - -/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */ -export function setRule( - lg: CfLogic, - id: string, - rule: CfRule | null, -): { lg: CfLogic; why: string | null } { - const l = lg.links.find((x) => x.id === id); - if (!l) return { lg, why: tl("NoPill") }; - const why = rule ? ruleWhy(lg, l.to.box, rule) : null; - if (why) return { lg, why }; - const links = lg.links.map((x) => { - if (x.id !== id) return x; - const { rule: _old, ...rest } = x; - return rule ? { ...rest, rule: clone(rule) } : rest; - }); - return { lg: { ...lg, links }, why: null }; -} - -/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ -function uniqueSuffix(used: string[], base: string): string { - for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`; -} - -/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */ -export function unlink(lg: CfLogic, id: string): CfLogic { - const gone = lg.links.find((l) => l.id === id); - const next = { ...lg, links: lg.links.filter((l) => l.id !== id) }; - const box = gone && boxOf(next, gone.to.box); - if (!gone || !box?.func) return next; - const port = box.func.inputs.find((i) => i.port === gone.to.name); - const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name); - if (!port || port.cell.trim() || still) return next; - const inputs = box.func.inputs.filter((i) => i !== port); - return updateBox(next, box.id, { func: { ...box.func, inputs } }); -} - -/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */ - -/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */ -export function traceBack(lg: CfLogic, port: CfPort): CfStep[] { - const steps: CfStep[] = []; - const seen = new Set(); - const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => { - const box = boxOf(lg, p.box); - const key = `${p.box}|${p.name}|${dir}`; - if (!box || seen.has(key)) return; - seen.add(key); - steps.push({ - box: p.box, - port: p.name, - dir, - ...(via ? { link: via.id } : {}), - ...(via?.rule ? { rule: clone(via.rule) } : {}), - }); - if (dir === "in") { - for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) { - visit(l.from, "out"); - for (const c of ruleSources(l.rule)) visit(c, "out"); - } - return; - } - // 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴 - for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : []) - visit( - { box: box.id, name: q.name }, - "in", - lg.links.find((l) => l.to.box === box.id && l.to.name === q.name), - ); - for (const e of box.input?.elements ?? []) - if (e.mode === "rule" && e.rule && elementPort(e) === p.name) - for (const q of materialSources(e.rule)) visit(q, "out"); - }; - const box = boxOf(lg, port.box); - if (!box) return []; - const dir = portDir(box, port.name); - visit( - port, - dir, - dir === "in" - ? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name) - : undefined, - ); - return steps; -} - -/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */ -export function traceForward(lg: CfLogic, port: CfPort): CfStep[] { - const steps: CfStep[] = []; - const seen = new Set(); - const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => { - const box = boxOf(lg, p.box); - const key = `${p.box}|${p.name}|${dir}`; - if (!box || seen.has(key)) return; - seen.add(key); - steps.push({ - box: p.box, - port: p.name, - dir, - ...(via ? { link: via.id } : {}), - ...(via?.rule ? { rule: clone(via.rule) } : {}), - }); - if (dir === "in") { - // 받는 알약 → 그 상자의 내보내는 알약 - for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : []) - visit({ box: box.id, name: q.name }, "out"); - return; - } - for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name)) - visit(l.to, "in", l); - // 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기 - for (const l of lg.links.filter((x) => - ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name), - )) - visit(l.to, "in", l); - for (const b of lg.boxes) - for (const e of b.input?.elements ?? []) - if ( - e.mode === "rule" && - e.rule && - materialSources(e.rule).some((q) => q.box === p.box && q.name === p.name) - ) - visit({ box: b.id, name: elementPort(e) }, "out"); - }; - const box = boxOf(lg, port.box); - if (!box) return []; - visit(port, portDir(box, port.name)); - return steps; -} - -/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */ - -/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */ -export function designInputs(lg: CfLogic): CfDesign[] { - const out: CfDesign[] = []; - for (const b of lg.boxes) { - const vars = b.input?.variables ?? b.func?.variables ?? []; - for (const v of vars) - out.push({ - box: b.id, - port: v.name, - kind: "변수", - unit: v.unit || "-", - variable: clone(v), - example: v.example, - }); - for (const e of b.input?.mode === "group" ? b.input.elements : []) - if (e.mode === "choice") - out.push({ - box: b.id, - port: elementPort(e), - kind: "재료 자리", - unit: e.unit || "-", - element: clone(e), - }); - if (b.input?.mode === "logic") - for (const d of b.input.logic?.designs ?? []) - if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) - out.push({ - box: b.id, - port: d.name, - kind: "다른 로직", - unit: d.unit || "-", - example: b.input.logic?.examples?.[d.name] ?? null, - }); - } - return out; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css deleted file mode 100644 index 0d2b21589..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css +++ /dev/null @@ -1,63 +0,0 @@ -.m02-dfout__title { - color: var(--color-text-body); - font-size: var(--text-body); -} - -.m02-dfout__body { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m02-dfout__got { - min-width: 220px; - color: var(--color-text-body); -} - -.m02-dfout__got dl { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-4) var(--spacing-8); - margin: var(--spacing-4) 0 0; - font-size: var(--text-body-sm); -} - -.m02-dfout__got dt { - color: var(--color-text-secondary); -} - -.m02-dfout__got dd { - margin: 0; -} - -.m02-dfout__num { - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.m02-dfout__fields { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8) var(--spacing-16); -} - -.m02-dfout__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-dfout__input { - width: 160px; - max-width: 100%; -} - -.m02-dfout__round { - margin: var(--spacing-8) 0; - color: var(--color-text-body); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts deleted file mode 100644 index edfa88881..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts +++ /dev/null @@ -1,292 +0,0 @@ -/* ============================================================================= - * M02_DF_OutputPanel.ts - * 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸 - * 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름 - * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval"; -import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model"; -import { defaultItemRound, roundText } from "./M02_DF_Round"; -import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; -import "./M02_DF_OutputPanel.css"; -import "@ui/ui_template_text.css"; - -const TEXT = { - Title: ["출력", "Output"], - Name: ["이름", "Name"], - Cost: ["비용 종류", "Cost kind"], - Labor: ["노무비", "Labor cost"], - Material: ["재료비", "Material cost"], - Expense: ["경비", "Expense"], - Plain: ["값(비용 아님)", "Value (not a cost)"], - Unit: ["단위", "Unit"], - Got: ["받은 값", "Received value"], - From: ["어디서", "From"], - Value: ["값", "Value"], - NoLink: [ - "아직 이어진 선이 없음 — 입력이나 함수 상자에서 이 상자로 선을 이어야 함", - "Nothing is linked yet — draw a line from an input or function box", - ], - Table: ["표", "Table"], - Calc: ["계산 중이 아님", "Not calculated"], - Cant: ["지금은 바꿀 수 없음", "Cannot change this now"], - Round: ["끝수", "Rounding"], - RoundPlaces: ["자리", "Places"], - RoundMethod: ["방법", "Method"], - RoundDefault: ["끝수 기본", "Default rounding"], - RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"], - RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"], - Suggest: ["단위 표준 제안", "Unit standard suggests"], - Apply: ["적용", "Apply"], - RoundReset: ["기본으로", "Back to default"], - RoundUser: ["고른 끝수", "Chosen rounding"], - PlainHint: ["원가 합계에 안 넣고 값으로만 보임", "Shown as a value, not added to the cost total"], -} as const; - -type TextKey = keyof typeof TEXT; -const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -const COSTS: [CfCost, TextKey][] = [ - ["노무비", "Labor"], - ["재료비", "Material"], - ["경비", "Expense"], - ["값", "Plain"], -]; - -/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */ -export function showValue(v: CfValue | undefined, money = false): string { - if (v === undefined || v === null) return "—"; - if (Array.isArray(v)) return to("Table"); - if (typeof v === "number") - return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); - return String(v); -} - -const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({ - ...lg, - boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)), -}); - -/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */ -export function renderOutputPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, - opts: { result?: CfEvalResult } = {}, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - if (!box || box.kind !== "output") { - host.replaceChildren(); - return; - } - const msg = el("p", { className: "ui-text ui-text--error" }); - const emit = (make: () => CfLogic): void => { - msg.textContent = ""; - try { - on.change(make()); - } catch { - msg.textContent = to("Cant"); - } - }; - const cost = box.output?.cost ?? "노무비"; - - /* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */ - const link = lg.links.find((l) => l.to.box === box.id); - const from = link && lg.boxes.find((b) => b.id === link.from.box); - let result = opts.result; - if (!result && link) { - try { - result = evalLogic(lg); - } catch { - result = undefined; - } - } - let unit = ""; - if (from && link) { - try { - unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit; - } catch { - unit = from.unit; - } - } - const got = el("div", { className: "m02-dfout__got" }); - if (link && from) { - const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id]; - got.replaceChildren( - el("strong", { text: to("Got") }), - el("dl", { - children: [ - el("dt", { text: to("From") }), - el("dd", { text: `${from.name} · ${link.from.name}` }), - el("dt", { text: to("Value") }), - el("dd", { - className: "m02-dfout__num", - text: `${showValue(value)}${unit && unit !== "-" ? ` ${unit}` : ""}`, - }), - ], - }), - ); - } else { - got.replaceChildren( - el("strong", { text: to("Got") }), - el("p", { className: "ui-text ui-text--error", text: to("NoLink") }), - ); - } - - /* 이름 · 비용 종류 · 단위 */ - const nameBox = el("input", { - className: "ui-input m02-dfout__input", - attrs: { "data-size": "32", type: "text", "aria-label": to("Name") }, - }); - nameBox.value = box.name; - nameBox.addEventListener("change", () => { - const next = nameBox.value.trim(); - if (!next || next === box.name) { - nameBox.value = box.name; - return; - } - emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next }))); - }); - const costField = createSelectField({ - label: to("Cost"), - options: COSTS.map(([value, key]) => ({ value, text: to(key) })), - value: cost, - compact: true, - onChange: (v) => - emit(() => - withBox(lg, box.id, (b) => ({ - ...b, - output: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost }, - })), - ), - }); - const setUnit = (next: string): void => - emit(() => - withBox(lg, box.id, (b) => ({ - ...b, - unit: next, - output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next }, - })), - ); - const unitBox = createUnitPick({ - value: box.output?.unit ?? box.unit, - ariaLabel: to("Unit"), - extra: lg.resultUnit.includes("/") ? [lg.resultUnit.split("/")[0]] : [], - onPick: setUnit, - }); - /** 단위 표준의 소수자리 제안(주 ① 반올림) — 값(비용 아님)이고 끝수를 안 정했을 때 */ - const suggestNote = el("span", { className: "ui-text ui-text--muted" }); - if (cost === "값" && !box.output?.round) - void suggestPlaces(box.output?.unit ?? box.unit).then((p) => { - if (p === null) return; - suggestNote.textContent = `${to("Suggest")}: ${to("RoundPlaces")} ${p} · 반올림`; - suggestNote.append( - createButton({ - label: to("Apply"), - size: "sm", - variant: "ghost", - onClick: () => setRound({ places: p, method: "반올림" }), - }), - ); - }); - const field = (label: string, control: HTMLElement): HTMLElement => - el("label", { - className: "m02-dfout__field", - children: [el("span", { text: label }), control], - }); - - /* 끝수 — 기본 = 일위대가표 금액란 0.1원 미만 버림(표준 표를 읽음) · 값(비용 아님)은 정하지 않으면 그대로 */ - const round = box.output?.round; - const stepName = (n: number): string => (n === 0 ? "1" : String(Number((10 ** -n).toFixed(n)))); - let fallback: CfRound = { places: 1, method: "버림" }; - const roundNote = el("span", { className: "ui-text ui-text--muted" }); - const noteOf = (r: CfRound | undefined): string => - r - ? `${to("RoundUser")}: ${roundText(r)}` - : cost === "값" - ? to("RoundNone") - : `${to("RoundDefault")}: ${to("RoundStd")} ${roundText(fallback)}`; - roundNote.textContent = noteOf(round); - if (cost !== "값") - void defaultItemRound().then((r) => { - fallback = r; - roundNote.textContent = noteOf(round); - }); - const setRound = (next: CfRound | undefined): void => - emit(() => - withBox(lg, box.id, (b) => { - const output = { ...(b.output ?? { cost, unit: b.unit }) }; - if (next) output.round = next; - else delete output.round; - return { ...b, output }; - }), - ); - const placesField = createSelectField({ - options: [ - { value: "", text: cost === "값" ? to("RoundNone") : to("RoundDefault") }, - ...[0, 1, 2, 3, 4].map((n) => ({ value: String(n), text: `${stepName(n)}원 단위` })), - ], - value: round ? String(round.places) : "", - compact: true, - ariaLabel: to("RoundPlaces"), - onChange: (v) => - setRound(v === "" ? undefined : { places: Number(v), method: round?.method ?? "버림" }), - }); - const methodField = createSelectField({ - options: (["버림", "올림", "반올림"] as const).map((m) => ({ value: m, text: `미만 ${m}` })), - value: round?.method ?? fallback.method, - compact: true, - disabled: !round, - ariaLabel: to("RoundMethod"), - onChange: (v) => round && setRound({ places: round.places, method: v as CfRound["method"] }), - }); - const roundBlock = el("div", { - className: "m02-dfout__round", - children: [ - el("strong", { text: to("Round") }), - el("div", { - className: "m02-dfout__fields", - children: [ - field(to("RoundPlaces"), placesField.root), - field(to("RoundMethod"), methodField.root), - ...(round - ? [ - createButton({ - label: to("RoundReset"), - size: "sm", - variant: "ghost", - onClick: () => setRound(undefined), - }), - ] - : []), - ], - }), - roundNote, - suggestNote, - ], - }); - - host.replaceChildren( - el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }), - el("div", { - className: "m02-dfout__body", - children: [ - got, - el("div", { - className: "m02-dfout__fields", - children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)], - }), - ], - }), - roundBlock, - ...(cost === "값" - ? [el("p", { className: "ui-text ui-text--muted", text: to("PlainHint") })] - : []), - msg, - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css deleted file mode 100644 index 37bdae5e5..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css +++ /dev/null @@ -1,172 +0,0 @@ -/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */ -.m02-dfp { - position: relative; - display: flex; - width: 100%; - min-width: 0; - height: 100%; - border-left: 1px solid var(--color-border); - background: var(--color-canvas); -} - -.m02-dfp__open { - display: flex; - flex: 1; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); -} - -.m02-dfp__head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m02-dfp__title { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.m02-dfp__tools { - display: flex; - justify-content: flex-end; - gap: var(--spacing-8); -} - -/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */ -.m02-dfp .m02-dfdg__view { - height: max(320px, 45vh); -} - -.m02-dfp__lock { - min-width: 0; - margin: 0; - padding: 0; - border: 0; -} - -.m02-dfp__cost > summary { - cursor: pointer; - font-weight: 600; - padding: var(--spacing-4) 0; -} - -/* 왼쪽 가장자리 끌기 — 늘 보이는 손잡이(세로 막대 + 가운데 잡이) */ -.m02-dfp__grip { - position: absolute; - top: 0; - bottom: 0; - left: -5px; - z-index: 2; - width: 10px; - cursor: col-resize; - touch-action: none; - background: color-mix(in srgb, var(--color-border) 70%, transparent); - border-radius: 4px; -} - -.m02-dfp__grip::after { - content: ""; - position: absolute; - top: 50%; - left: 50%; - width: 4px; - height: 40px; - transform: translate(-50%, -50%); - border-radius: 2px; - background: var(--color-text-muted); -} - -.m02-dfp__grip:hover, -.m02-dfp__grip:focus-visible, -.m02-dfp.is-resizing .m02-dfp__grip { - background: color-mix(in srgb, var(--color-accent) 40%, transparent); - outline: none; -} - -.m02-dfp__grip:hover::after, -.m02-dfp__grip:focus-visible::after, -.m02-dfp.is-resizing .m02-dfp__grip::after { - background: var(--color-accent); -} - -.m02-dfp.is-resizing { - user-select: none; -} - -/* 접기 = 얇은 띠 — 같은 「흐름도」 단추를 세로로 */ -.m02-dfp__strip { - display: flex; - justify-content: center; - width: 100%; - padding: var(--spacing-8) 0; - background: var(--color-surface); -} - -/* 공용 작은 단추 크기는 그대로 — 글 방향만 세로 */ -.m02-dfp__strip .m02-dfp__toggle { - writing-mode: vertical-rl; -} - -.m02-dfp__toggle[aria-expanded="true"] { - color: var(--color-accent); -} - -.m02-dfp__strip[hidden], -.m02-dfp__open[hidden], -.m02-dfp__grip[hidden] { - display: none; -} - -/* 크게 보기 — 화면 전체 */ -.m02-dfp-full { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12) var(--spacing-16); - overflow: auto; - background: var(--color-canvas); - outline: none; -} - -.m02-dfp-full__head { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.m02-dfp-full__head strong { - flex: 1; - font-size: var(--text-body); -} - -.m02-dfp-full__diagram .m02-dfdg__view { - height: max(420px, 60vh); -} - -.m02-dfp-full__lower { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); - gap: var(--spacing-16); - align-items: start; -} - -.m02-dfp-full__lower h3 { - margin: 0 0 var(--spacing-8); - font-size: var(--text-body); -} - -@media (max-width: 900px) { - .m02-dfp-full__lower { - grid-template-columns: minmax(0, 1fr); - } -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts deleted file mode 100644 index efddb4fbf..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* ============================================================================= - * M02_DF_Panel.ts - * 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand } - * 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) → - * 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange. - * 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage · - * 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로). - * 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움). - * ========================================================================== */ - -import { isCtrlKey } from "../../A00_Common/key_shortcut"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import type { CfLogic } from "./M02_DF_Model"; -import { createPaper } from "./M02_DF_Paper"; -import { renderStandards } from "./M02_DF_Standards"; -import "./M02_DF_Panel.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - Big: ["크게 보기", "Full screen"], - Standards: ["표준 표", "Standards"], - Toggle: ["흐름도", "Flow"], - ToggleOpen: ["흐름도 펴기", "Open the flow panel"], - ToggleClose: ["흐름도 접기", "Collapse the flow panel"], - Close: ["닫기", "Close"], - Detail: ["상세 내용", "Details"], - Cost: ["원가 · 검증", "Cost · check"], - Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"], -} as const; - -const tf = (key: keyof typeof TEXT): string => - TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; - -export interface FlowPanelOpts { - logic: CfLogic; - readOnly?: boolean; - title: string; - /** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */ - onChange(lg: CfLogic): void; - /** 다른 로직 입력이 부르는 로직 */ - logics?(key: string): Promise; - /** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */ - onResize?(px: number): void; - /** 접힘을 부르는 쪽이 정함(처음 값) — 주면 접기 단추는 onFold 만 부르고 기억도 부르는 쪽 몫 */ - folded?: boolean; - onFold?(on: boolean): void; -} - -export interface FlowPanel { - /** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */ - set(lg: CfLogic): void; - destroy(): void; - /** 크게 보기 열기 */ - expand(): void; - /** 밖에서 접기 · 펴기(onFold 를 부르지 않음) */ - fold(on: boolean): void; -} - -/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */ - -export const PANEL_MIN = 320; -export const STRIP_WIDTH = 40; -/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */ -export const PANEL_FIT_MIN = 0.6; -/** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */ -export const PANEL_FIT_ALL_MIN = 0.25; -const WIDTH_KEY = "m02.dfpanel.width"; -const FOLD_KEY = "m02.dfpanel.folded"; - -/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */ -export function dragWidth(start: number, startX: number, x: number, viewport: number): number { - const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85)); - return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x)))); -} - -/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */ -export function storedWidth(raw: string | null): number | null { - const n = Number(raw); - return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null; -} - -const readStore = (key: string): string | null => { - try { - return localStorage.getItem(key); - } catch { - return null; - } -}; -const writeStore = (key: string, value: string): void => { - try { - localStorage.setItem(key, value); - } catch { - /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */ - } -}; - -/* ── 패널 ───────────────────────────────────────────────────────────── */ - -export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel { - /** 크게 보기 화면 — 열려 있을 때만 */ - let full: HTMLElement | null = null; - const paper = createPaper({ - logic: opts.logic, - readOnly: opts.readOnly, - onChange: opts.onChange, - logics: opts.logics, - // 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90% - fitMin: () => (full ? 0.9 : PANEL_FIT_MIN), - fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN), - }); - const { diagram, detail, summary, note, undo } = paper.parts; - let width = storedWidth(readStore(WIDTH_KEY)); - let folded = opts.folded ?? readStore(FOLD_KEY) === "1"; - const tell = (): void => - opts.onResize?.(folded ? STRIP_WIDTH : (width ?? Math.max(PANEL_MIN, host.clientWidth))); - - const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") => - createButton({ label, size: "sm", variant, onClick }); - - /* 「흐름도」 단추 하나 — 누르면 접고 · 다시 누르면 펼침(펼치면 머리 · 접으면 얇은 띠에 놓임) */ - const toggle = createButton({ - label: tf("Toggle"), - size: "sm", - variant: "ghost", - onClick: () => (opts.onFold ? opts.onFold(!folded) : setFolded(!folded)), - }); - toggle.classList.add("m02-dfp__toggle"); - const head = el("header", { - className: "m02-dfp__head", - children: [ - toggle, - el("strong", { className: "m02-dfp__title", text: opts.title }), - sm(tf("Big"), () => expand()), - sm(tf("Standards"), () => openStandards()), - ], - }); - - /** 「표준 표」(단위 · 금액 표준) — 고치기는 시스템 관리자만(보기만이면 칸 · 단추를 막음) */ - const openStandards = (): void => { - openModal({ - title: tf("Standards"), - closeLabel: tf("Close"), - mount: (box: HTMLElement) => { - const lock = el("fieldset", { className: "m02-dfp__lock" }); - lock.disabled = !!opts.readOnly; - box.append(lock); - renderStandards(lock, { change: () => paper.redraw() }, { title: false }); - }, - }); - }; - - /* 펼친 패널 */ - const costBox = el("details", { - className: "m02-dfp__cost", - attrs: { open: "" }, - children: [el("summary", { text: tf("Cost") })], - }); - const tools = el("div", { className: "ui-toolbar m02-dfp__tools", children: [undo] }); - const open = el("div", { - className: "m02-dfp__open", - children: [head, tools, note, diagram, detail, costBox], - }); - costBox.append(summary); - - /* 접힌 띠 — 같은 「흐름도」 단추만 */ - const strip = el("div", { className: "m02-dfp__strip" }); - - /* 왼쪽 가장자리 — 끌기 · 화살표 키 */ - const grip = el("div", { - className: "m02-dfp__grip", - attrs: { - role: "separator", - "aria-orientation": "vertical", - "aria-label": tf("Grip"), - tabindex: "0", - }, - }); - const setWidth = (px: number, save: boolean): void => { - width = px; - if (save) writeStore(WIDTH_KEY, String(px)); - tell(); - }; - grip.addEventListener("pointerdown", (ev) => { - if (ev.button !== 0) return; - ev.preventDefault(); - const start = root.getBoundingClientRect().width; - const startX = ev.clientX; - root.classList.add("is-resizing"); - const move = (m: PointerEvent): void => - setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false); - const up = (u: PointerEvent): void => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - root.classList.remove("is-resizing"); - setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true); - paper.refit(); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - }); - grip.addEventListener("keydown", (ev) => { - const step = ev.shiftKey ? 80 : 20; - const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0; - if (!d) return; - ev.preventDefault(); - const now = root.getBoundingClientRect().width; - setWidth(dragWidth(now, 0, -d, window.innerWidth), true); - }); - - const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] }); - root.addEventListener("keydown", (ev) => { - if (isCtrlKey(ev, "z") && !ev.shiftKey) { - if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; - ev.preventDefault(); - paper.undo(); - } - }); - - const setFolded = (on: boolean): void => { - // 접기 전 너비를 기억 — 끈 적이 없어도 펴면 그 너비로 - if (on && !folded && width === null && root.isConnected) - width = Math.round(root.getBoundingClientRect().width); - folded = on; - if (!opts.onFold) writeStore(FOLD_KEY, on ? "1" : "0"); - root.classList.toggle("is-folded", on); - open.hidden = on; - strip.hidden = !on; - grip.hidden = on; - (on ? strip : head).prepend(toggle); - toggle.setAttribute("aria-expanded", String(!on)); - toggle.title = tf(on ? "ToggleOpen" : "ToggleClose"); - tell(); - if (!on) paper.refit(); - }; - - /* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */ - const expand = (): void => { - if (full) return; - const close = sm(tf("Close"), () => shrink()); - full = el("div", { - className: "m02-dfp-full", - attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" }, - children: [ - el("header", { - className: "m02-dfp-full__head", - children: [el("strong", { text: opts.title }), undo, close], - }), - note, - el("div", { className: "m02-dfp-full__diagram", children: [diagram] }), - el("div", { - className: "m02-dfp-full__lower", - children: [ - el("section", { children: [el("h3", { text: tf("Detail") }), detail] }), - el("section", { children: [el("h3", { text: tf("Cost") }), summary] }), - ], - }), - ], - }); - full.addEventListener("keydown", (ev) => { - if (ev.key === "Escape" && !ev.defaultPrevented) shrink(); - if (isCtrlKey(ev, "z") && !ev.shiftKey) { - if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; - ev.preventDefault(); - paper.undo(); - } - }); - document.body.append(full); - full.focus(); - paper.refit(); - }; - const shrink = (): void => { - if (!full) return; - full.remove(); - full = null; - tools.append(undo); - costBox.append(summary); - // 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움 - open.replaceChildren( - open.firstElementChild as HTMLElement, - tools, - note, - diagram, - detail, - costBox, - ); - paper.refit(); - }; - - host.replaceChildren(root); - setFolded(folded); - - return { - set: (lg) => paper.set(lg), - destroy: () => { - shrink(); - root.remove(); - }, - expand, - fold: (on) => on !== folded && setFolded(on), - }; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.css b/M02_MasterTemplete/DataFlow/M02_DF_Paper.css deleted file mode 100644 index 30d08a0ef..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.css +++ /dev/null @@ -1,113 +0,0 @@ -/* 흐름도 고치는 핵심(M02_DF_Paper) · 상세 내용 틀(M02_DF_Entry_Detail) 꾸밈 — 옛 시험판 Entry.css 에서 옮김 */ -.m02-df__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m02-df__part { - display: flex; - flex-direction: column; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); -} - -.m02-df__part h3 { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m02-df__part-body { - padding: var(--spacing-12); -} - -.m02-df__note { - margin: 0 0 var(--spacing-12); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: pre-line; -} - -.m02-df__note:empty { - display: none; -} - -.m02-df__color { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - margin-bottom: var(--spacing-8); - font-size: var(--text-body-sm); -} - -.m02-df__color[hidden] { - display: none; -} - -.m02-df__color-input { - width: 44px; - height: 32px; - padding: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: none; - cursor: pointer; -} - -.m02-df__detail-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--spacing-12); - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m02-df__detail-head h3 { - margin: 0; - padding: 0; - border: 0; -} - -.m02-df__detail-name { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: flex-end; - gap: var(--spacing-8); -} - -.m02-df__detail-pill { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-df__detail-row { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8) 0; - border-bottom: 1px dashed var(--color-border); -} - -.m02-df__detail-row:last-child { - border-bottom: 0; -} - -.m02-df__detail-row[hidden] { - display: none; -} - -.m02-df__detail-row h4 { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-df__detail-warn { - margin: 0; - padding-left: var(--spacing-16); - font-size: var(--text-body-sm); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts deleted file mode 100644 index 9f9682304..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ /dev/null @@ -1,349 +0,0 @@ -/* ============================================================================= - * M02_DF_Paper.ts - * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 - * (옛 시험판 고치는 화면을 옮겨 적은) 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. - * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). - * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). - * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, el } from "@ui/ui_template_elements"; -import * as api from "./M02_DF_Api"; -import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram"; -import * as ev from "./M02_DF_Eval"; -import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; -import { renderFuncPanel } from "./M02_DF_FuncPanel"; -import { renderInputPanel } from "./M02_DF_InputPanel"; -import { renderLinkPanel } from "./M02_DF_LinkPanel"; -import * as model from "./M02_DF_Model"; -import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; -import { renderOutputPanel } from "./M02_DF_OutputPanel"; -import { renderPillPanel } from "./M02_DF_PillPanel"; -import { renderSummary } from "./M02_DF_Summary"; -import type { CfSel } from "./M02_DF_Verify"; -import { - detailFrame, - drawHead, - drawWarnings, - fetchValues, - loadLogics, - queriesOf, - type DfPick, -} from "./M02_DF_Entry_Detail"; -import "./M02_DF_Paper.css"; -import "@ui/ui_template_text.css"; - -const TEXT = { - Undo: ["실행 취소", "Undo"], - Color: ["상자 색", "Box colour"], - BoxName: ["상자 이름", "Box name"], - NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], - Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], - Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], - ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"], -} as const; - -const tp = (key: keyof typeof TEXT, v = ""): string => - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v); - -export interface PaperOpts { - logic: CfLogic; - readOnly?: boolean; - /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ - onChange(lg: CfLogic): void; - /** 다른 로직 입력이 부르는 로직 — 없으면 못 부름(빈 값) */ - logics?(key: string): Promise; - /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ - fitMin?(): number; - /** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */ - fitAllMin?(): number; -} - -export interface Paper { - /** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */ - parts: { - diagram: HTMLElement; - detail: HTMLElement; - summary: HTMLElement; - note: HTMLElement; - undo: HTMLButtonElement; - }; - get(): CfLogic; - /** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */ - set(lg: CfLogic): void; - undo(): void; - /** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */ - refit(): void; - redraw(): void; -} - -export function createPaper(opts: PaperOpts): Paper { - const readOnly = !!opts.readOnly; - let lg = opts.logic; - let stack: CfLogic[] = []; - let pick: DfPick = null; - let values: CfValues = {}; - let valueSig = ""; - let logics: NonNullable = {}; - let logicSig = ""; - let logicNotes: string[] = []; - let notes: string[] = []; - let broken: CfBreak[] = []; - let result: CfEvalResult; - let fitKey = 0; - const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); - - const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); - const diagramHost = el("div", { className: "m02-dfp__diagram" }); - const summaryHost = el("div", { className: "m02-dfp__summary" }); - const colorBox = el("input", { - className: "m02-df__color-input", - attrs: { type: "color", "aria-label": tp("Color") }, - }); - colorBox.addEventListener("change", () => { - const id = pickedBox(); - if (id) edit(model.setColor(lg, id, colorBox.value)); - }); - const colorRow = el("label", { - className: "m02-df__color", - children: [el("span", { text: tp("Color") }), colorBox], - }); - const boxName = createInputField({ label: tp("BoxName"), compact: true }); - boxName.input.addEventListener("change", () => { - const box = lg.boxes.find((b) => b.id === pickedBox()); - if (!box) return; - const next = boxName.input.value.trim(); - const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); - if (taken) note.textContent = tp("NameTaken", next); - if (!next || taken || next === box.name) return void (boxName.input.value = box.name); - edit(model.updateBox(lg, box.id, { name: next })); - }); - // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만 - const funcHost = el("div"); - const outHost = el("div"); - const inHost = el("div"); - const linkHost = el("div"); - const pillHost = el("div"); - const detail = detailFrame(); - detail.settings.append(boxName.root, colorRow); - // 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨) - const lock = el("fieldset", { - className: "m02-dfp__lock", - children: [inHost, funcHost, outHost, linkHost, pillHost], - }); - lock.disabled = readOnly; - detail.body.append(lock); - if (readOnly) - detail.root.prepend(el("p", { className: "ui-text ui-text--muted", text: tp("ReadOnly") })); - - const evalNow = (): CfEvalResult => { - try { - return ev.evalLogic(lg, values, { logics }); - } catch (e) { - const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] }; - } - }; - - /** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */ - const edit = (next: CfLogic): void => { - if (readOnly || next === lg) return; - stack = [...stack, lg]; - lg = next; - opts.onChange(lg); - redraw(); - }; - /** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */ - const editExample = (next: CfLogic): void => { - if (!readOnly) return edit(next); - lg = next; - redraw(); - }; - const editFromDiagram = (next: CfLogic): void => { - edit(next); - setTimeout(redraw); - }; - - const refreshValues = (): void => { - const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; - const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q)); - if (sig === valueSig) return; - valueSig = sig; - void fetchValues(api.cfValues, lg, pill, model.elementPort).then( - (got) => { - if (sig !== valueSig) return; - values = got.values; - notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : []; - redraw(); - }, - (e: unknown) => { - notes = [tp("Values", api.cfPlain(e))]; - redraw(); - }, - ); - }; - - const refreshLogics = (): void => { - const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); - if (sig === logicSig) return; - logicSig = sig; - const get = opts.logics ?? (async () => null); - void loadLogics(lg, get).then((got) => { - if (sig !== logicSig) return; - logics = got.logics; - logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : []; - redraw(); - }); - }; - - const choose = (sel: CfSel): void => { - const box = lg.boxes.find((b) => b.id === sel.box); - pick = sel.link - ? { kind: "link", id: sel.link } - : box && sel.port - ? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) } - : box - ? { kind: "box", id: box.id } - : null; - redraw(); - }; - - /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ - const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { - try { - if (pick?.kind === "pill") { - const p = { box: pick.box, name: pick.port }; - return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) }; - } - const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id); - if (link) - return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) }; - } catch { - /* 따라갈 수 없으면 칠하지 않음 */ - } - return null; - }; - - const diagramOn = { - select: (id: string | null, pill?: DfPillSel): void => { - pick = pill - ? { kind: "pill", ...pill } - : !id - ? null - : lg.links.some((l) => l.id === id) - ? { kind: "link", id } - : { kind: "box", id }; - setTimeout(redraw); - }, - move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)), - link: (from: CfPort, to: CfPort): string | null => { - const got = model.link(lg, from, to); - if (got.why) return got.why; - editFromDiagram(got.lg); - return null; - }, - unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)), - add: (kind: CfKind, x: number, y: number): void => { - const got = model.addBox(lg, kind, x, y); - pick = { kind: "box", id: got.box.id }; - editFromDiagram(got.lg); - }, - remove: (id: string): void => { - if (pickedBox() === id) pick = null; - editFromDiagram(model.removeBox(lg, id)); - }, - }; - - const drawPanel = (): void => { - const boxId = pickedBox(); - if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; - const box = lg.boxes.find((b) => b.id === pickedBox()); - drawHead(detail, lg, pick); - (detail.settings.parentElement as HTMLElement).hidden = !box || readOnly; - colorRow.hidden = !box; - boxName.root.hidden = !box || box.kind === "output"; - if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; - linkHost.hidden = pick?.kind !== "link"; - pillHost.hidden = pick?.kind !== "pill"; - if (pick?.kind === "link") - renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); - if (pick?.kind === "pill") - renderPillPanel( - pillHost, - lg, - pick.box, - pick.port, - { pick: (id) => choose({ box: id }) }, - { values, logics }, - ); - drawWarnings(detail, pick, broken); - if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; - funcHost.hidden = box?.kind !== "function"; - outHost.hidden = box?.kind !== "output"; - inHost.hidden = box?.kind !== "input"; - if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit }); - if (box?.kind === "function") - renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics }); - if (box?.kind === "output") - renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); - }; - - const redraw = (): void => { - result = evalNow(); - refreshValues(); - refreshLogics(); - const linkId = pick?.kind === "link" ? pick.id : ""; - if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; - try { - broken = ev.breaks(lg, values, { logics }); - } catch { - broken = result.breaks ?? []; - } - undo.disabled = readOnly || !stack.length; - renderDiagram(diagramHost, lg, diagramOn, { - values: result.ports, - selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, - selectedPill: - pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, - trace: traceNow(), - breaks: broken, - readOnly, - fitKey, - fitMin: opts.fitMin?.(), - fitAllMin: opts.fitAllMin?.(), - }); - drawPanel(); - renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); - note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); - }; - - const doUndo = (): void => { - const back = stack[stack.length - 1]; - if (!back || readOnly) return; - stack = stack.slice(0, -1); - lg = back; - opts.onChange(lg); - redraw(); - }; - const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); - - redraw(); - return { - parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo }, - get: () => lg, - set: (next) => { - lg = next; - stack = []; - pick = null; - fitKey += 1; - redraw(); - }, - undo: doUndo, - refit: () => { - fitKey += 1; - redraw(); - }, - redraw, - }; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts deleted file mode 100644 index cb96d913f..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* ============================================================================= - * M02_DF_PillPanel.ts - * 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭 - * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import type { CfLogic, CfStep } from "./M02_DF_Model"; -import { - boxOf, - breakLines, - evalSafe, - isOut, - labelOf, - portText, - showValue, - trace, - unitOf, - valueOf, - type DetailOptions, -} from "./M02_DF_Detail"; -import "./M02_DF_LinkPanel.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_banner.css"; -import "@ui/ui_template_badge.css"; - -const TEXT = { - Name: ["이름", "Name"], - Value: ["값", "Value"], - Unit: ["단위", "Unit"], - Kind_out: ["내보내는 알약", "Sending pill"], - Kind_in: ["받는 알약", "Receiving pill"], - From: ["온 길", "Where it came from"], - To: ["가는 곳", "Where it goes"], - Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"], - End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"], - Breaks: ["끊긴 곳", "Breaks"], - NoBreak: ["끊긴 곳 없음", "No breaks"], - Open: ["이 상자 보기", "Open this box"], - Line: ["선", "Line"], -} as const; - -const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement => - el("div", { - className: "m02-dflink-row", - children: [el("span", { className: "m02-dflink-label", text: label }), ...value], - }); - -/** 한 줄 = 선 하나면 「보낸 알약 → (규칙 글) → 받는 알약」 · 함수 안 걸음이면 알약 하나 */ -function steps( - lg: CfLogic, - list: CfStep[], - arrow: string, - empty: string, - onBox?: (id: string) => void, -): HTMLElement { - if (!list.length) return el("p", { className: "ui-text ui-text--muted", text: empty }); - const pill = (box: string, name: string): HTMLElement | string => - onBox - ? createButton({ - label: portText(lg, { box, name }), - variant: "ghost", - size: "sm", - onClick: () => onBox(box), - }) - : portText(lg, { box, name }); - return el("ol", { - className: "m02-dfpill-list", - children: list.map((s) => { - const link = s.link ? lg.links.find((l) => l.id === s.link) : undefined; - const parts: (HTMLElement | string)[] = link - ? [ - arrow, - pill(link.from.box, link.from.name), - "→", - ...(s.rule - ? [ - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: `(${labelOf(s.rule)})`, - }), - "→", - ] - : []), - pill(link.to.box, link.to.name), - ] - : [arrow, pill(s.box, s.port)]; - return el("li", { className: "m02-dfpill-step", children: parts }); - }), - }); -} - -/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */ -export function renderPillPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - port: string, - on: { pick?(boxId: string): void } = {}, - opts: DetailOptions = {}, -): void { - host.classList.add("m02-dfpill"); - const box = boxOf(lg, boxId); - if (!box) return host.replaceChildren(); - const ev = evalSafe(lg, opts); - const out = isOut(box, port); - // 받는 알약의 값 = 선 규칙을 지나 실제로 들어온 값(없으면 보낸 알약 값) - const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port); - const value = out - ? valueOf(ev, { box: boxId, name: port }) - : (ev?.arrived?.[boxId]?.[port] ?? (incoming ? valueOf(ev, incoming.from) : undefined)); - const unit = out - ? unitOf(lg, { box: boxId, name: port }) - : incoming - ? unitOf(lg, incoming.from) - : box.unit; - const back = trace(lg, { box: boxId, name: port }, "in"); - const forward = trace(lg, { box: boxId, name: port }, "out"); - const breaks = breakLines(lg, boxId, port, opts); - host.replaceChildren( - el("div", { - className: "m02-dflink-head", - children: [ - el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }), - el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }), - ], - }), - line(tp("Name"), port), - line( - tp("Value"), - el("span", { - className: "ui-badge ui-badge--pill ui-badge--neutral", - attrs: { "data-size": "20" }, - text: showValue(value), - }), - ), - line(tp("Unit"), unit || "-"), - el("div", { - children: [ - el("div", { className: "m02-dflink-label", text: tp("From") }), - steps(lg, back, "◀", tp("Start"), on.pick), - ], - }), - el("div", { - children: [ - el("div", { className: "m02-dflink-label", text: tp("To") }), - steps(lg, forward, "▶", tp("End"), on.pick), - ], - }), - el("div", { - children: [ - el("div", { className: "m02-dflink-label", text: tp("Breaks") }), - ...(breaks.length - ? breaks.map((b) => - el("p", { - className: "ui-banner ui-banner--warning m02-dflink-warn", - text: `⚠ ${b}`, - }), - ) - : [el("p", { className: "ui-text ui-text--muted", text: tp("NoBreak") })]), - ], - }), - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index ffa191436..f13348e09 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -3,11 +3,16 @@ * 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어 * 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림) * 표 읽기 = 서버 `GET /api/m02/df/standards`(Api `cfStandards`) → {unit, money, saved} - * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨. + * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. * ========================================================================== */ import { cfStandards } from "./M02_DF_Api"; -import type { CfRound } from "./M02_DF_Model"; + +/** 끝수 — 자리 · 방법 */ +export interface CfRound { + places: number; + method: "반올림" | "올림" | "버림"; +} /** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */ export interface CfMoneyRow { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts b/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts deleted file mode 100644 index b7580b380..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* ============================================================================= - * M02_DF_RoundRow.ts - * 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추) - * 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import type { CfRound } from "./M02_DF_Model"; -import "@ui/ui_template_text.css"; - -const TEXT = { - Label: ["끝수", "Rounding"], - None: ["안 씀", "none"], - Places: ["소수 {n}자리", "{n} decimals"], - Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], - Apply: ["적용", "Apply"], - PlacesAria: ["소수자리", "Decimal places"], - ModeAria: ["방법", "Method"], -} as const; - -const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"]; - -const tr = (key: keyof typeof TEXT, n?: number): string => - TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? "")); - -export interface RoundRowOptions { - /** 지금 끝수 — 없으면 undefined */ - round: CfRound | undefined; - /** 단위 표준이 제안한 소수자리 */ - suggest: number | null; - readOnly?: boolean; - /** null = 끝수 벗김 */ - onApply(round: CfRound | null): void; -} - -export function createRoundRow(opts: RoundRowOptions): HTMLElement { - const now = opts.round ?? null; - const places = el("select", { - className: "ui-select ui-select--sm", - attrs: { "aria-label": tr("PlacesAria") }, - }); - places.append(el("option", { attrs: { value: "" }, text: tr("None") })); - for (let n = 0; n <= 6; n++) { - places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) })); - } - places.value = now ? String(now.places) : ""; - const method = el("select", { - className: "ui-select ui-select--sm", - attrs: { "aria-label": tr("ModeAria") }, - }); - for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m })); - method.value = now?.method ?? "반올림"; - if (opts.readOnly) places.disabled = method.disabled = true; - const emit = (): void => - opts.onApply( - places.value === "" - ? null - : { places: Number(places.value), method: method.value as CfRound["method"] }, - ); - places.addEventListener("change", emit); - method.addEventListener("change", () => places.value !== "" && emit()); - const row = el("div", { - className: "m02-dfpanel-row", - children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method], - }); - if (opts.suggest !== null && !now && !opts.readOnly) { - row.append( - el("span", { className: "ui-text ui-text--muted", text: tr("Suggest", opts.suggest) }), - createButton({ - label: tr("Apply"), - variant: "ghost", - size: "sm", - onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }), - }), - ); - } - return row; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Router.py b/M02_MasterTemplete/DataFlow/M02_DF_Router.py index 980b1526d..04b9e9728 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Router.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Router.py @@ -1,136 +1,27 @@ -"""단가산출 흐름도 서버 길 — 입력 값 · 다른 로직 값 · 설계 때 정할 것 · 자재 찾기 · 표준 표. +"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved}. - POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]} - POST /api/m02/df/logic-value {key, designs} → {key, outputs, cost, errors} - GET /api/m02/df/design-inputs?key= → {key, designs: CfDesign[]} - GET /api/m02/df/materials?q=&sub=&detail=&page=&size= → {total, page, size, items} - GET · PUT · DELETE /api/m02/df/standards → {unit, money, saved} - -모양 = sub7 Model `M02_DF_Model.ts` CfLogic. -흐름 저장 = 로직 줄 「흐름」 칸(M01 `/save` · `M01_MasterData_Store_Flow`) — 이 길은 쓰지 않음. -다른 로직 = 마스터 로직 키(흐름이면 흐름 셈 · 아니면 지금 계산 · `M01_MasterData_Store_Flow.load`). -사용자 요소 = 흐름 안 `userElements` 만. 권한 = 시스템 관리자(`main.py` 한 줄). +부르는 곳 = 단위 고르기(`M02_DF_UnitPick.ts`) · 끝수 표준(`M02_DF_Round.ts`). +표 = `_standard/` 파일(git 이 따라감) · 권한 = 시스템 관리자(`main.py` 한 줄). """ from __future__ import annotations -import json -import os from pathlib import Path -from typing import Any from fastapi import APIRouter, HTTPException -from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData import M01_MasterData_Store_Flow as sflow from M01_MasterData.M01_MasterData_Router import _num -from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc from M02_MasterTemplete.DataFlow import M02_DF_Values as dv router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow"]) -HERE = Path(__file__).resolve().parent -STANDARD = HERE / "_standard" # 단위 · 금액 표준 표 — 고치면 이 파일에 바로(git 이 따라감) - - -class ValuesBody(BaseModel): - items: list[dict[str, Any]] - userElements: list[dict[str, Any]] = [] - - -class LogicValueBody(BaseModel): - key: str - designs: dict[str, Any] = {} - - -class StandardsBody(BaseModel): - unit: dict[str, Any] - money: dict[str, Any] - - -# ── 다른 로직(`M02_DF_Compute`) ──────────────────────────────────────── -def _load(key: str) -> dict: - """다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 아니면 지금 계산(정함 11).""" - return sflow.load(key) - - -def logic_value(key: str, designs: dict) -> dict: - """다른 로직 값 — 받은 설계 값(없으면 예시값)으로 셈 · 출력 이름 → 값.""" - other = _load(key) - if callable(other.get("calc")): # 흐름 없는 로직 — 노무비 · 재료비 · 경비 · 계 - got = other["calc"](designs) - return {"key": key, "outputs": got["outputs"], "cost": {}, "errors": got["errors"]} - local = other.get("userElements") or [] - got = dc.run(other, dc._design_values(other, designs), _load, local, [key]) - names = {b["id"]: b.get("name") for b in other.get("boxes") or []} - outputs = {names[i]: v for i, v in got["outputs"].items() if i in names} - return {"key": key, "outputs": outputs, "cost": got["cost"], "errors": got["errors"]} - - -def design_inputs(key: str) -> dict: - """설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(선이 없을 때) · CfDesign 꼴.""" - other = _load(key) - if callable(other.get("calc")): - return {"key": key, "designs": sflow.old_designs(key)} - return {"key": key, "designs": dc.design_inputs(other)} - - -# ── 표준 표 ──────────────────────────────────────────────────────────── -def _standards_put(body: dict) -> dict: - """고친 표준 표 = 본 프로그램 값 — git 이 따라가는 `_standard/` 파일에 바로(시스템 관리자).""" - dv.standards_check(body) - for part, name in dv.STANDARDS.items(): - tmp = STANDARD / f".{name}.tmp" - text = json.dumps(body[part], ensure_ascii=False, indent=2) + "\n" - tmp.write_text(text, encoding="utf-8", newline="\n") - os.replace(tmp, STANDARD / name) - return dv.standards(STANDARD) - - -def _standards_reset() -> dict: - """고친 값을 따로 두지 않음(바로 저장) — 되돌릴 처음 값 없음 · 지금 값 그대로.""" - return dv.standards(STANDARD) - - -# ── 길 ──────────────────────────────────────────────────────────────── -def _call(fn, *args): - try: - return _num(fn(*args)) - except store.StoreError as e: - raise HTTPException(status_code=e.status, detail=e.detail) from e - - -@router.post("/values") -def post_values(body: ValuesBody) -> dict: - return _call(dv.values, body.items, body.userElements) - - -@router.post("/logic-value") -def post_logic_value(body: LogicValueBody) -> dict: - return _call(logic_value, body.key, body.designs) - - -@router.get("/design-inputs") -def get_design_inputs(key: str) -> dict: - return _call(design_inputs, key) - - -@router.get("/materials") -def get_materials(q: str = "", sub: str = "", detail: str = "", page: int = 1, size: int = 30): - return _call(dv.materials, q, sub, detail, page, size) +STANDARD = Path(__file__).resolve().parent / "_standard" @router.get("/standards") def get_standards() -> dict: - return _call(dv.standards, STANDARD) - - -@router.put("/standards") -def put_standards(body: StandardsBody) -> dict: - return _call(_standards_put, body.model_dump()) - - -@router.delete("/standards") -def delete_standards() -> dict: - return _call(_standards_reset) + try: + return _num(dv.standards(STANDARD)) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts deleted file mode 100644 index d5ee5b516..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* ============================================================================= - * M02_DF_Rule.ts - * 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음 - * - * 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식 - * lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림 · 아래 = 위면 값 == 그 수) - * → 맞은 줄 수 · INDEX/MATCH - * gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { gateConds, type CfGateOp, type CfRound, type CfRule } from "./M02_DF_Model"; - -type Scalar = number | string | boolean | null; - -/** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */ -export interface CfRuleResult { - value: Scalar; - why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null; - /** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */ - formula: string; -} - -const TEXT = { - Lookup: ["표에서 찾기", "Look up in table"], - Gate: ["일 때만", "only when"], - And: ["그리고", "and"], - Or: ["또는", "or"], - Places: ["소수 {n}자리", "{n} decimals"], - Round: ["반올림", "round"], - Up: ["올림", "round up"], - Down: ["버림", "round down"], -} as const; - -const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -/** 엑셀 비교 기호 */ -const OP: Record = { - "=": "=", - "≠": "<>", - "≥": ">=", - "≤": "<=", - ">": ">", - "<": "<", -}; -const ROUND_FN: Record = { - 반올림: "ROUND", - 올림: "ROUNDUP", - 버림: "ROUNDDOWN", -}; - -/** 선 위에 늘 보일 규칙 글 */ -export function ruleLabel(rule: CfRule): string { - switch (rule.kind) { - case "lookup": { - const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · "); - return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`; - } - case "gate": { - const g = gateConds(rule); - const join = g.join === "또는" ? tl("Or") : tl("And"); - return `${g.conds.map((c) => `${c.from.name} ${c.op} ${c.value}`).join(` ${join} `)} ${tl("Gate")}`; - } - case "scale": - return rule.op === "+%" || rule.op === "-%" - ? `${rule.op[0]}${rule.value}%` - : `${rule.op} ${rule.value}`; - case "round": - return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`; - } -} - -const roundWord = (m: CfRound["method"]): string => - m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down"); - -/* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */ - -const fromCalc = (v: CalcValue | undefined): Scalar => { - if (!v) return null; - if ("수" in v) return Number(v.수); - if ("글" in v) return v.글; - if ("참" in v) return v.참; - return v.오류; -}; - -const cellOf = (v: unknown): Cell | null => - v === null || v === undefined || v === "" - ? null - : typeof v === "number" || typeof v === "string" || typeof v === "boolean" - ? { 값: v } - : { 값: String(v) }; - -/** 칸들을 시트 하나로 셈 → 읽을 칸 값 */ -function run(칸: Record, read: string[]): Scalar[] { - const id = "rule"; - const values = recalcWorkbook({ - 종류: "통합문서", - 판: 1, - 열: "DF", - 서식: [{}], - 시트: [{ id, 이름: "규칙", 칸 }], - }); - return read.map((a) => - 칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null), - ); -} - -const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); -const lit = (v: number | string): string => - typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; - -/* ── 규칙 ───────────────────────────────────────────────────────────────── */ - -/** 조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름 */ -const condOf = (conds: Record, p: { box: string; name: string }): unknown => - `${p.box}|${p.name}` in conds ? conds[`${p.box}|${p.name}`] : conds[p.name]; - -/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */ -export function applyRule( - rule: CfRule, - value: unknown, - conds: Record = {}, -): CfRuleResult { - if (rule.kind === "lookup") return lookup(rule, value, conds); - const 칸: Record = {}; - const a1 = cellOf(Array.isArray(value) ? null : value); - if (a1) 칸.A1 = a1; - let formula: string; - if (rule.kind === "gate") { - // 조건 값 = C1 · D1 · E1 … · 여러 줄은 AND / OR - const g = gateConds(rule); - const tests = g.conds.map((c, i) => { - const at = `${colName(2 + i)}1`; - const cv = cellOf(condOf(conds, c.from)); - if (cv) 칸[at] = cv; - return `${at}${OP[c.op]}${lit(c.value)}`; - }); - const test = - tests.length > 1 ? `${g.join === "또는" ? "OR" : "AND"}(${tests.join(",")})` : tests[0]; - formula = `IF(${test},A1,NA())`; - } else if (rule.kind === "scale") { - const v = rule.value; - formula = - rule.op === "×" - ? `A1*${v}` - : rule.op === "÷" - ? `A1/${v}` - : rule.op === "+%" - ? `A1*(1+${v}/100)` - : `A1*(1-${v}/100)`; - } else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`; - 칸.B1 = { 식: formula }; - const [got] = run(칸, ["B1"]); - if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula }; - if (isError(got)) return { value: null, why: "오류", formula }; - return { value: got, why: null, formula }; -} - -/** 표 블록 범위 경계 규칙 칸(줄마다 「초과·이하」 「이상·이하」 「이상·미만」 「이하」 · Values._table) */ -export const RANGE_RULE = "·범위규칙"; - -/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) - * 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find · 서버 Compute `_lookup` 과 같음) */ -function lookup( - rule: Extract, - value: unknown, - conds: Record, -): CfRuleResult { - const block = Array.isArray(value) ? (value as unknown[][]) : []; - const head = (block[0] ?? []).map((h) => String(h ?? "")); - const rows = block.length - 1; - const W = head.length; - const 칸: Record = {}; - block.forEach((line, r) => - line.forEach((x, c) => { - const cell = cellOf(x); - if (cell) 칸[`${colName(c)}${r + 1}`] = cell; - }), - ); - const at = (name: string): string | null => { - const i = head.indexOf(name); - return i < 0 ? null : colName(i); - }; - // 값 칸이 범위로 갈림(「이름·아래」 「이름·위」) — 아래 = 위면 그 값 - const two = !at(rule.col) && !!at(`${rule.col}·아래`) && !!at(`${rule.col}·위`); - const out = two ? at(`${rule.col}·아래`) : at(rule.col); - // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 - const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : []; - const ups: string[] = []; - // 범위 경계 — 줄마다 「·범위규칙」 칸(이상 … = 아래 끝 포함 · … 미만 = 위 끝 뺌) · 없으면 초과 · 이하 - const rc = at(RANGE_RULE); - const lowCmp = (c: string, r: number, x: string): string => - rc ? `IF(LEFT(${rc}${r},2)="이상",${c}${r}<=${x},${c}${r}<${x})` : `${c}${r}<${x}`; - const highCmp = (c: string, r: number, x: string): string => - rc ? `IF(RIGHT(${rc}${r},2)="미만",${x}<${c}${r},${x}<=${c}${r})` : `${x}<=${c}${r}`; - let bad = !out || rows < 1; - rule.conds.forEach((c, i) => { - const ref = `$${colName(W + 1 + i)}$1`; - const cv = cellOf(condOf(conds, c.from)); - if (cv) 칸[`${colName(W + 1 + i)}1`] = cv; - const same = at(c.col); - const low = at(`${c.col}·아래`); - const high = at(`${c.col}·위`); - // 글끼리는 대소문자까지 같아야 맞음(옛 find · 「t_2」 ≠ 「T_2」) — FIND 는 대소문자를 가림 - if (same) - tests.push( - (r) => - `OR(ISBLANK(${same}${r}),IF(AND(ISTEXT(${same}${r}),ISTEXT(${ref})),` + - `AND(LEN(${same}${r})=LEN(${ref}),ISNUMBER(FIND(${ref},${same}${r}))),${same}${r}=${ref}))`, - ); - else if (low && high) { - ups.push(high); - tests.push( - (r) => - `IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${r},` + - `AND(OR(ISBLANK(${low}${r}),${lowCmp(low, r, ref)}),OR(ISBLANK(${high}${r}),${highCmp(high, r, ref)})))`, - ); - } else bad = true; - }); - const hc = colName(W + 1 + rule.conds.length); - const last = rows + 1; - for (let r = 2; r <= last; r++) - 칸[`${hc}${r}`] = { - 식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1", - }; - // 여럿이면 범위 위 끝이 가장 작은 줄(옛 find · 「20 이하」 「40 이하」 …) — 둘째 맞음 칸 - const hc2 = colName(W + 3 + rule.conds.length); - const span = (c: string): string => `${c}2:${c}${Math.max(2, last)}`; - for (let r = 2; r <= last; r++) { - const top = ups.map((h) => `OR(ISBLANK(${h}${r}),${h}${r}=MINIFS(${span(h)},${span(hc)},1))`); - 칸[`${hc2}${r}`] = { - 식: top.length ? `IF(AND(${hc}${r}=1,${top.join(",")}),1,0)` : `${hc}${r}`, - }; - } - const range = span(hc2); - const count = `${colName(W + 2 + rule.conds.length)}1`; - const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()"; - 칸[count] = { 식: `SUM(${range})` }; - 칸.ZZ1 = { 식: formula }; - const up = at(`${rule.col}·위`); - if (two && up) 칸.ZZ2 = { 식: formula.replace(`INDEX(${out}2:${out}`, `INDEX(${up}2:${up}`) }; - if (bad) return { value: null, why: "오류", formula }; - const [n, got, top] = run(칸, [count, "ZZ1", "ZZ2"]); - if (two && n === 1 && got !== top) return { value: null, why: "오류", formula }; - if (n === 0) return { value: null, why: "표에 없는 조건", formula }; - if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula }; - if (isError(got)) return { value: null, why: "오류", formula }; - return { value: got, why: null, formula }; -} - -/** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */ -export function applyRound(round: CfRound, value: number): number { - const [got] = run( - { A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } }, - ["B1"], - ); - return typeof got === "number" ? got : value; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.css b/M02_MasterTemplete/DataFlow/M02_DF_Standards.css deleted file mode 100644 index 74f63e5ba..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Standards.css +++ /dev/null @@ -1,37 +0,0 @@ -/* 단가산출 흐름도 — 표준 표 판(M02_DF_Standards.ts) · 단추 · 칸 크기는 공용 그대로(덮지 않음) */ -.m02-df-std { - display: flex; - flex-direction: column; - gap: var(--spacing-8, 8px); -} - -.m02-df-std__head { - display: flex; - align-items: center; - gap: var(--spacing-8, 8px); -} - -/* 첫 칸(제목 · 제목이 없으면 상태 글)이 단추를 오른쪽으로 밂 */ -.m02-df-std__head > :first-child { - margin: 0 auto 0 0; -} - -.m02-df-std__line { - color: var(--color-text-muted); -} - -/* 겉 = 표 부품 데이터표(ui-table-wrap) · 여기는 줄바꿈 막기 · 누르는 칸만 */ -.m02-df-std__table th, -.m02-df-std__table td { - white-space: nowrap; -} - -.m02-df-std__table td[tabindex] { - cursor: pointer; -} - -.m02-df-std__notes { - margin: 0; - padding-left: 0; - list-style: none; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts deleted file mode 100644 index 6bd1a8a71..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts +++ /dev/null @@ -1,365 +0,0 @@ -/* ============================================================================= - * M02_DF_Standards.ts - * 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12) - * 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침) - * 서버 = `M02_DF_Api` cfStandards · cfStandardsSave · cfStandardsReset(`/api/m02/df/standards`) → {unit, money, saved} · - * 고친 것은 `_data`(마스터에 안 씀) - * `renderStandards(host, on, { title })` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로) - * `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것) - * 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { cfStandards, cfStandardsReset, cfStandardsSave, type CfStandards } from "./M02_DF_Api"; -import unitDefault from "./_standard/단위표준.json"; -import moneyDefault from "./_standard/금액단위표준.json"; -import "./M02_DF_Standards.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_table.css"; - -const TEXT = { - Title: ["표준 표", "Standards"], - UnitTitle: ["설계서의 단위 및 소수의 표준", "Units and decimals in design documents"], - MoneyTitle: ["금액의 단위표준", "Money rounding units"], - Source: ["원문 · 산림품셈 1-2-2", "Source · Forest standard 1-2-2"], - Hint: [ - "칸을 눌러 고침 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 고친 것은 이 시험판에만 저장", - "Click a cell to edit · decimals “-” = 0 · unit “-” = none · edits are saved in this trial only", - ], - Item: ["종목", "Item"], - SpecUnit: ["규격 단위", "Size unit"], - SpecPlaces: ["규격 소수자리", "Size decimals"], - QtyUnit: ["단위수량 단위", "Quantity unit"], - QtyPlaces: ["단위수량 소수자리", "Quantity decimals"], - Note: ["비고", "Note"], - Line: ["원문 줄", "Source line"], - Unit: ["단위", "Unit"], - Step: ["지위", "Step"], - Save: ["저장", "Save"], - Reset: ["처음 값으로", "Back to original"], - ResetAsk: [ - "고친 표준 표를 지우고 원문 처음 값으로 되돌릴까요?", - "Discard edits and go back to the original values?", - ], - Saved: ["표준 표를 저장했어요", "Standards saved"], - Restored: ["처음 값으로 되돌렸어요", "Back to the original values"], - Unsaved: ["저장 안 함", "Not saved"], - Edited: ["고친 표", "Edited"], - Original: ["원문 처음 값", "Original"], - Failed: [ - "표준 표를 못 가져왔어요 — 처음 값을 보여 줘요", - "Could not load — showing the original", - ], - SaveFailed: ["저장하지 못했어요", "Could not save"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Merged: ["위 종목과 한 칸(원문 병합)", "Same cell as the item above (merged in source)"], -} as const; -const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0]; - -/* ── 모양 ─────────────────────────────────────────────────────────────── */ - -export interface UnitStdRow { - 종목: string; - "규격 단위": string | null; - "규격 소수자리": number | null; - "단위수량 단위": string | null; - "단위수량 소수자리": number | null; - 비고: string; - "원문 줄": number; - 병합?: boolean; -} -export interface UnitStd { - 이름: string; - 출처: string; - 원문: string; - 바꿈?: string; - 줄: UnitStdRow[]; - 주: string[]; -} -export interface MoneyStdRow { - 종목: string; - 단위: string; - 지위: number; - 비고: string; - "원문 줄": number; -} -export interface MoneyStd { - 이름: string; - 출처: string; - 원문: string; - 줄: MoneyStdRow[]; -} -export interface Standards { - unit: UnitStd; - money: MoneyStd; - /** `_data` 에 고친 것이 있음 */ - saved: boolean; -} - -/** 단위 고르기 · 끝수 제안이 쓰는 한 줄 */ -export interface StandardItem { - 종목: string; - 갈래: "규격" | "단위수량" | "금액"; - 단위: string; - /** 규격 · 단위수량 = 소수자리 · 금액 = 지위에서 나온 자리(1,000 원 → -3 · 0.1 원 → 1) */ - 소수자리: number; - /** 금액만 — 지위(원) */ - 지위?: number; - 방법: "반올림" | "버림"; -} - -const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; -const ORIGINAL: Standards = { - unit: unitDefault as UnitStd, - money: moneyDefault as MoneyStd, - saved: false, -}; -let current: Standards = clone(ORIGINAL); - -/* ── 목록(동기) ───────────────────────────────────────────────────────── */ - -/** 지위 → 소수자리 — 1,000 원 → -3 · 1 원 → 0 · 0.1 원 → 1 */ -const placesOf = (step: number): number => -Math.round(Math.log10(step)); - -/** 단위 표준(주 ① 반올림) 줄마다 규격 · 단위수량 둘 + 금액(미만 버림) — 단위가 비었거나 「없음」 이면 뺌 */ -export function standardsList(std: Standards = current): StandardItem[] { - const out: StandardItem[] = []; - for (const r of std.unit.줄) { - for (const [갈래, unit, places] of [ - ["규격", r["규격 단위"], r["규격 소수자리"]], - ["단위수량", r["단위수량 단위"], r["단위수량 소수자리"]], - ] as const) { - if (!unit || unit === "없음") continue; - out.push({ 종목: r.종목, 갈래, 단위: unit, 소수자리: places ?? 0, 방법: "반올림" }); - } - } - for (const r of std.money.줄) - out.push({ - 종목: r.종목, - 갈래: "금액", - 단위: r.단위, - 소수자리: placesOf(r.지위), - 지위: r.지위, - 방법: "버림", - }); - return out; -} - -/** 지금 표(판이 서버 값을 받았으면 그것) — 사본 */ -export const standardsNow = (): Standards => clone(current); - -/* ── 서버 ─────────────────────────────────────────────────────────────── */ - -/** 서버 답(`M02_DF_Api` CfStandards) → 이 판의 모양 */ -const asStd = (v: CfStandards): Standards => v as unknown as Standards; - -/** 서버 표 받기 — 못 받으면 처음 값 */ -export async function loadStandards(): Promise { - current = asStd(await cfStandards()); - return clone(current); -} - -/* ── 판 ───────────────────────────────────────────────────────────────── */ - -export interface StandardsOn { - /** 저장 · 되돌리기 뒤 — 단위 고르기 등이 새 목록을 다시 읽게 */ - change?(std: Standards): void; -} - -type UnitKey = "규격 단위" | "규격 소수자리" | "단위수량 단위" | "단위수량 소수자리" | "비고"; -type MoneyKey = "단위" | "지위" | "비고"; - -/** 칸 글 → 값 — 소수자리 · 지위는 수(「-」 = 0 · 빈 칸 = null) · 단위는 글(「-」 = 없음 · 빈 칸 = null) */ -function parseCell(key: string, text: string): string | number | null | undefined { - const t = text.trim(); - if (key === "비고") return t; - if (key.includes("소수자리") || key === "지위") { - if (t === "") return key === "지위" ? undefined : null; - if (t === "-") return 0; - const n = Number(t.replace(/,/g, "")); - return Number.isFinite(n) ? n : undefined; - } - if (t === "-") return "없음"; - return t || null; -} - -const show = (v: unknown): string => - v === null || v === undefined ? "" : typeof v === "number" ? v.toLocaleString() : String(v); - -export interface StandardsOpts { - /** 판 제목 「표준 표」 — 모달처럼 겉에 제목이 있으면 false(기본 true) */ - title?: boolean; -} - -/** 시험판 안 「표준 표」 판 */ -export function renderStandards( - host: HTMLElement, - on: StandardsOn = {}, - opts: StandardsOpts = {}, -): void { - let work = clone(current); - let dirty = false; - const state = el("span", { className: "ui-text ui-text--muted m02-df-std__state" }); - const save = createButton({ label: tx("Save"), size: "sm", onClick: () => void doSave() }); - const reset = createButton({ - label: tx("Reset"), - size: "sm", - variant: "ghost", - onClick: () => void doReset(), - }); - const body = el("div", { className: "m02-df-std__body" }); - - const mark = (): void => { - state.textContent = dirty ? tx("Unsaved") : work.saved ? tx("Edited") : tx("Original"); - save.disabled = !dirty; - }; - - /** 칸 하나 — 누르면 입력 칸 · Enter / 칸 밖 = 끝 · Esc = 취소 */ - const cell = (value: unknown, commit: (text: string) => boolean, title = ""): HTMLElement => { - const td = el("td", { - text: show(value), - attrs: { tabindex: "0", ...(title ? { title } : {}) }, - }); - const edit = (): void => { - if (td.querySelector("input")) return; - const input = el("input", { className: "ui-input ui-input--sm" }); - input.value = show(value).replace(/,/g, ""); - td.replaceChildren(input); - input.focus(); - let done = false; - const finish = (keep: boolean): void => { - if (done) return; - done = true; - if (keep && !commit(input.value)) { - showToast(tx("Bad"), "error"); - done = false; - input.focus(); - return; - } - draw(); - }; - input.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") finish(true); - if (ev.key === "Escape") finish(false); - }); - input.addEventListener("blur", () => finish(true)); - }; - td.addEventListener("click", edit); - td.addEventListener("keydown", (ev) => ev.key === "Enter" && edit()); - return td; - }; - - const table = (heads: string[], rows: HTMLElement[][]): HTMLElement => - el("div", { - className: "ui-table-wrap m02-df-std__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m02-df-std__table", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], - }), - el("tbody", { children: rows.map((cells) => el("tr", { children: cells })) }), - ], - }), - ], - }); - - const put = (row: T, key: string, text: string): boolean => { - const v = parseCell(key, text); - if (v === undefined) return false; - (row as Record)[key] = v; - dirty = true; - return true; - }; - - const draw = (): void => { - const unitKeys: UnitKey[] = ["규격 단위", "규격 소수자리", "단위수량 단위", "단위수량 소수자리", "비고"]; // prettier-ignore - const unitRows = work.unit.줄.map((r) => [ - el("td", { text: r.종목, attrs: r.병합 ? { title: tx("Merged") } : {} }), - ...unitKeys.map((k) => cell(r[k], (t) => put(r, k, t))), - el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }), - ]); - const moneyKeys: MoneyKey[] = ["단위", "지위", "비고"]; - const moneyRows = work.money.줄.map((r) => [ - el("td", { text: r.종목 }), - ...moneyKeys.map((k) => cell(r[k], (t) => put(r, k, t))), - el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }), - ]); - body.replaceChildren( - el("h4", { text: tx("UnitTitle") }), - table( - [tx("Item"), tx("SpecUnit"), tx("SpecPlaces"), tx("QtyUnit"), tx("QtyPlaces"), tx("Note"), tx("Line")], // prettier-ignore - unitRows, - ), - el("ol", { - className: "m02-df-std__notes", - children: work.unit.주.map((n) => el("li", { text: n })), - }), - el("h4", { text: tx("MoneyTitle") }), - table([tx("Item"), tx("Unit"), tx("Step"), tx("Note"), tx("Line")], moneyRows), - ); - mark(); - }; - - const take = (std: Standards): void => { - current = std; - work = clone(std); - dirty = false; - draw(); - on.change?.(clone(std)); - }; - - async function doSave(): Promise { - save.disabled = true; - try { - take( - asStd( - await cfStandardsSave({ unit: work.unit, money: work.money } as unknown as Omit< - CfStandards, - "saved" - >), - ), - ); - showToast(tx("Saved"), "success"); - } catch { - showToast(tx("SaveFailed"), "error"); - mark(); - } - } - - async function doReset(): Promise { - if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return; - try { - take(asStd(await cfStandardsReset())); - } catch { - take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값 - } - showToast(tx("Restored"), "success"); - } - - host.replaceChildren( - el("div", { - className: "m02-df-std", - children: [ - el("div", { - className: "m02-df-std__head", - children: [ - ...(opts.title === false ? [] : [el("h3", { text: tx("Title") })]), - state, - reset, - save, - ], - }), - el("p", { className: "ui-text ui-text--muted", text: `${tx("Source")} · ${tx("Hint")}` }), - body, - ], - }), - ); - draw(); - void loadStandards().then( - (std) => take(std), - () => showToast(tx("Failed"), "info"), - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.css b/M02_MasterTemplete/DataFlow/M02_DF_Summary.css deleted file mode 100644 index b84b349fb..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Summary.css +++ /dev/null @@ -1,75 +0,0 @@ -.m02-dfsum__big { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); - margin: 0 0 var(--spacing-8); - color: var(--color-text-secondary); -} - -.m02-dfsum__big strong { - color: var(--color-text-body); - font-size: 28px; - font-variant-numeric: tabular-nums; -} - -.m02-dfsum__costs { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-16); - margin: 0 0 var(--spacing-12); - font-size: var(--text-body-sm); -} - -.m02-dfsum__costs dt { - display: inline; - color: var(--color-text-secondary); -} - -.m02-dfsum__costs dd { - display: inline; - margin: 0 0 0 var(--spacing-4); - color: var(--color-text-body); - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.m02-dfsum__block { - margin: 0 0 var(--spacing-12); - color: var(--color-text-body); -} - -.m02-dfsum__scroll { - max-width: 100%; - margin-top: var(--spacing-4); -} - -/* 겉 = 표 부품 데이터표(ui-table-wrap) · 여기는 최소 폭 · 수 칸 오른쪽만 */ -.m02-dfsum__table { - min-width: 360px; -} - -.m02-dfsum__table td.m02-dfsum__num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.m02-dfsum__strip { - margin: 0 0 var(--spacing-12); - color: var(--color-text-body); -} - -.m02-dfsum__fields { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m02-dfsum__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts deleted file mode 100644 index dd6dc9089..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* ============================================================================= - * M02_DF_Summary.ts - * 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄 - * · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈) - * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. - * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; -import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model"; -import { showValue } from "./M02_DF_OutputPanel"; -import { - defaultTotalRound, - itemRoundNow, - roundBy, - roundText, - showRounded, - totalRoundOf, -} from "./M02_DF_Round"; -import { renderVerify, type CfSel } from "./M02_DF_Verify"; -import "./M02_DF_Summary.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_table.css"; - -const TEXT = { - Per: ["{per} 당", "per {per}"], - Total: ["원가 합계", "Cost total"], - Won: ["원", "KRW"], - Labor: ["노무비", "Labor cost"], - Material: ["재료비", "Material cost"], - Expense: ["경비", "Expense"], - Lines: ["출력 상자별", "By output box"], - HName: ["이름", "Name"], - HCost: ["비용 종류", "Cost kind"], - HValue: ["값", "Value"], - HUnit: ["단위", "Unit"], - Plain: ["값(비용 아님)", "Values (not costs)"], - RoundTotal: ["합계 끝수", "Total rounding"], - Plain3: ["값", "Value"], -} as const; - -type TextKey = keyof typeof TEXT; -const ts = (key: TextKey, fill: Record = {}): string => - TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace( - /\{(\w+)\}/g, - (m, k: string) => fill[k] ?? m, - ); - -const COSTS = ["노무비", "재료비", "경비"] as const; -const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; - -const costText = (c: string): string => (c === "값" ? ts("Plain3") : c); - -/** 원가 합계 끝수 — 기본 = 일위대가표 계금 1원 미만 버림(표를 읽어 오면 그 값) */ -let totalRound: CfRound = { places: 0, method: "버림" }; -void defaultTotalRound().then((r) => (totalRound = r)); - -/** 출력 상자 값 — 그 출력의 끝수 자리대로(끝수를 안 정했으면 비용은 표준 기본 · 값은 그대로) */ -const shown = (b: CfBox, v: unknown, isCost: boolean): string => - typeof v === "number" - ? showRounded(v, b.output?.round ?? (isCost ? itemRoundNow() : undefined)) - : showValue(v as never); - -/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */ -export function renderSummary( - host: HTMLElement, - lg: CfLogic, - result: CfEvalResult, - on?: { change(lg: CfLogic): void; select?(sel: CfSel): void }, - opts?: { values?: CfValues; logics?: CfEvalOptions["logics"] }, -): void { - const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); - const perUnit = lg.resultUnit.split("/").slice(1).join("/"); - const perText = !perUnit - ? ts("Total") - : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); - const { cost } = result; - /** 합계 끝수 — 비용 출력 상자에 실린 끝수(옮긴 로직의 자신 끝수) · 없으면 기본 1원 미만 버림 */ - const sumRound = totalRoundOf(lg, totalRound); - const big = el("p", { - className: "m02-dfsum__big", - children: [ - el("span", { text: money ? perText : ts("Total") }), - el("strong", { text: `${formatMoney(roundBy(sumRound, cost.계))} ${ts("Won")}` }), - ], - }); - const costs = el("dl", { - className: "m02-dfsum__costs", - children: COSTS.flatMap((c) => [ - el("dt", { text: ts(COST_KEY[c]) }), - el("dd", { text: formatMoney(roundBy(sumRound, cost[c])) }), - ]), - }); - - /* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */ - const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); - const costed = outs.filter((b) => b.output?.cost !== "값"); - const plain = outs.filter((b) => b.output?.cost === "값"); - const line = (b: CfBox, isCost: boolean): HTMLElement => - el("tr", { - children: [ - el("td", { text: b.name }), - ...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []), - el("td", { - className: "m02-dfsum__num", - text: shown(b, result.outputs[b.id], isCost), - }), - el("td", { text: b.output?.unit ?? b.unit }), - ], - }); - const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement => - el("div", { - className: "m02-dfsum__block", - children: [ - el("strong", { text: title }), - el("div", { - className: "ui-table-wrap m02-dfsum__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m02-dfsum__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: [ - el("th", { text: ts("HName") }), - ...(isCost ? [el("th", { text: ts("HCost") })] : []), - el("th", { text: ts("HValue") }), - el("th", { text: ts("HUnit") }), - ], - }), - ], - }), - el("tbody", { children: list.map((b) => line(b, isCost)) }), - ], - }), - ], - }), - ], - }); - - const verifyHost = el("div", { className: "m02-dfsum__verify" }); - renderVerify( - verifyHost, - lg, - result, - { change: (next) => on?.change(next), select: on?.select }, - { values: opts?.values, logics: opts?.logics }, - ); - const plainValues = Object.entries(cost.값들); - host.replaceChildren( - big, - costs, - ...(costed.length ? [table(costed, true, ts("Lines"))] : []), - ...(plain.length - ? [table(plain, false, ts("Plain"))] - : plainValues.length - ? [ - el("div", { - className: "m02-dfsum__block", - children: [ - el("strong", { text: ts("Plain") }), - ...plainValues.map(([k, v]) => - el("p", { className: "ui-text ui-text--muted", text: `${k} · ${showValue(v)}` }), - ), - ], - }), - ] - : []), - el("p", { - className: "ui-text ui-text--muted", - text: `${ts("RoundTotal")}: ${roundText(sumRound)}`, - }), - verifyHost, - ); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Verify.css b/M02_MasterTemplete/DataFlow/M02_DF_Verify.css deleted file mode 100644 index 78cb13578..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Verify.css +++ /dev/null @@ -1,108 +0,0 @@ -.m02-dfver__block { - margin: 0 0 var(--spacing-12); - color: var(--color-text-body); -} - -/* 끊긴 곳 = 띠 알림 부품(위험) · 여기는 제목 · 목록 쌓기만 */ -.m02-dfver__breaks { - flex-direction: column; -} - -.m02-dfver__title { - font-size: var(--text-body); -} - -.m02-dfver__group { - margin: var(--spacing-8) 0; -} - -.m02-dfver__fields { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-4) 0; -} - -.m02-dfver__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-dfver__input { - width: 160px; - max-width: 100%; -} - -.m02-dfver__item { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - margin: var(--spacing-4) 0; -} - -.m02-dfver__line { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-body-sm); -} - -.m02-dfver__now { - color: var(--color-text-body); -} - -.m02-dfver__price { - color: var(--color-text-secondary); - font-variant-numeric: tabular-nums; -} - -.m02-dfver__warn { - color: var(--color-danger); -} - -.m02-dfver__panel { - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); -} - -.m02-dfver__search, -.m02-dfver__pager { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - margin: var(--spacing-4) 0; -} - -.m02-dfver__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - margin: var(--spacing-4) 0; - padding: 0; - list-style: none; -} - -.m02-dfver__list .ui-btn { - max-width: 100%; - text-align: left; - white-space: normal; -} - -/* 쉬는 갈래 — 문제가 아니라 회색으로 */ -.m02-dfver__rest { - padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-muted); -} - -.m02-dfver__rest .ui-btn { - color: var(--color-text-muted); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts deleted file mode 100644 index 8bba628f8..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts +++ /dev/null @@ -1,503 +0,0 @@ -/* ============================================================================= - * M02_DF_Verify.ts - * 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부: - * 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭) - * · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select. - * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; -import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material"; -import { - designInputs, - elementPort, - type CfBox, - type CfElementRef, - type CfLogic, - type CfVariable, -} from "./M02_DF_Model"; -import "./M02_DF_Verify.css"; -import "@ui/ui_template_text.css"; -import "@ui/ui_template_banner.css"; -import "@ui/ui_template_toolbar.css"; - -const TEXT = { - Breaks: ["끊긴 곳", "Broken spots"], - BreaksHint: ["누르면 그 상자 · 선을 고름", "Click to select the box or line"], - Rest: ["쉬는 갈래", "Resting branches"], - RestHint: [ - "조건이 안 맞아 쉬는 곳 — 누르면 그 선을 고름", - "Idle because a condition is not met — click to select the line", - ], - Design: ["설계 때 정할 것", "To decide at design time"], - DesignHint: ["여기서 바꾸면 그 상자도 바뀜", "Changing here changes the box too"], - Vars: ["변수 예시값", "Variable samples"], - Slots: ["재료 선택 자리", "Material choice"], - Rules: ["재료 조건 자리", "Material by condition"], - Logics: ["이어지지 않은 다른 로직 값(예시값)", "Unlinked values of other logics (samples)"], - Choose: ["고르기", "Choose"], - Close: ["닫기", "Close"], - Find: ["찾기", "Find"], - Prev: ["이전", "Prev"], - Next: ["다음", "Next"], - Search: ["재료 이름 · 규격", "Material name or size"], - Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"], - NoHit: ["찾은 재료 없음", "No material found"], - NoDefault: ["아직 고르지 않음", "Nothing chosen yet"], - Price: ["단가", "Unit price"], - Won: ["원", "KRW"], - Loading: ["찾는 중", "Searching"], - NeedValue: ["조건 값이 아직 없음", "The condition value is not there yet"], - Caught: ["잡힌 품목", "Matched item"], - Zero: ["맞는 재료 없음(0)", "No matching material (0)"], - Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"], - Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"], - Pick: ["선택", "Choose"], - Bad: ["숫자를 넣어야 함", "Enter a number"], - Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], - Cant: ["지금은 바꿀 수 없음", "Cannot change this now"], - OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"], - OtherLogic: [ - "이어 주지 않아 그 로직의 예시값을 씀", - "Not linked — the logic's own sample is used", - ], -} as const; - -type TextKey = keyof typeof TEXT; -const tv = (key: TextKey, fill: Record = {}): string => - TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) => - k in fill ? String(fill[k]) : m, - ); - -/** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */ -export interface CfSel { - box: string; - port?: string; - link?: string; -} - -export interface CfVerifyOn { - change(lg: CfLogic): void; - select?(sel: CfSel): void; -} - -const PAGE = 10; - -/** 재료 요소 하나 고치기 — 나머지는 그대로 */ -function withElement( - lg: CfLogic, - boxId: string, - name: string, - patch: Partial, -): CfLogic { - return { - ...lg, - boxes: lg.boxes.map((b) => - b.id === boxId && b.input - ? { - ...b, - input: { - ...b.input, - elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)), - }, - } - : b, - ), - }; -} - -const matText = (m: CfMat): string => - `${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`; - -/** 변수 예시값 칸 */ -function sampleField( - v: CfVariable, - put: (value: number | string) => void, - msg: HTMLElement, -): HTMLElement { - const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); - let control: HTMLElement; - if (v.type === "choice" && v.choices?.length) { - control = createSelectField({ - options: [ - { value: "", text: tv("Pick") }, - ...v.choices.map((c) => ({ value: String(c), text: String(c) })), - ], - value: v.example === null ? "" : String(v.example), - compact: true, - ariaLabel: v.name, - onChange: (s) => { - const hit = v.choices?.find((c) => String(c) === s); - if (hit !== undefined) put(hit); - }, - }).root; - } else { - const box = el("input", { - className: "ui-input m02-dfver__input", - attrs: { - "data-size": "32", - type: "text", - inputmode: v.type === "number" ? "decimal" : "text", - "aria-label": v.name, - }, - }); - if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: v.range[1] }); - box.value = v.example === null ? "" : String(v.example); - box.addEventListener("change", () => { - const raw = box.value.trim(); - if (raw === "") return; - if (v.type !== "number") return put(raw); - const n = Number(raw); - if (Number.isNaN(n)) msg.textContent = tv("Bad"); - else put(n); - }); - control = box; - } - return el("label", { className: "m02-dfver__field", children: [label, control] }); -} - -/** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */ -function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement { - const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") }); - const price = el("span", { className: "m02-dfver__price" }); - if (e.default) { - void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? ""))); - void priceOf(e.default).then((p) => { - price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`; - }); - } - const panel = el("div", { className: "m02-dfver__panel" }); - panel.hidden = true; - const q = el("input", { - className: "ui-input m02-dfver__input", - attrs: { - "data-size": "32", - type: "text", - placeholder: tv("Search"), - "aria-label": tv("Search"), - }, - }); - q.value = e.scope?.찾는말 ?? ""; - const list = el("ul", { className: "m02-dfver__list" }); - const pager = el("div", { className: "ui-toolbar m02-dfver__pager" }); - let page = 1; - const load = async (): Promise => { - list.replaceChildren(el("li", { className: "ui-text ui-text--muted", text: tv("Loading") })); - const got = await findMaterials({ - q: q.value.trim(), - sub: e.scope?.구분, - detail: e.scope?.상세구분, - page, - size: PAGE, - }); - const pages = Math.max(1, Math.ceil(got.total / PAGE)); - list.replaceChildren( - ...(got.items.length - ? got.items.map((m) => - el("li", { - children: [ - createButton({ - label: matText(m), - variant: "ghost", - size: "sm", - onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })), - }), - ], - }), - ) - : [el("li", { className: "ui-text ui-text--muted", text: tv("NoHit") })]), - ); - pager.replaceChildren( - createButton({ - label: tv("Prev"), - size: "sm", - variant: "ghost", - disabled: page <= 1, - onClick: () => { - page--; - void load(); - }, - }), - el("span", { - className: "ui-text ui-text--muted", - text: tv("Page", { p: got.page, n: pages, t: got.total }), - }), - createButton({ - label: tv("Next"), - size: "sm", - variant: "ghost", - disabled: page >= pages, - onClick: () => { - page++; - void load(); - }, - }), - ); - }; - q.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") { - page = 1; - void load(); - } - }); - const toggle = createButton({ - label: tv("Choose"), - size: "sm", - onClick: () => { - panel.hidden = !panel.hidden; - toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden - ? tv("Choose") - : tv("Close"); - if (!panel.hidden) void load(); - }, - }); - panel.append( - el("div", { - className: "ui-toolbar m02-dfver__search", - children: [ - q, - createButton({ - label: tv("Find"), - size: "sm", - onClick: () => { - page = 1; - void load(); - }, - }), - ], - }), - list, - pager, - ); - return el("div", { - className: "m02-dfver__item", - children: [ - el("div", { - className: "m02-dfver__line", - children: [el("strong", { text: elementPort(e) }), now, price, toggle], - }), - panel, - ], - }); -} - -/** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */ -function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement { - const rule = e.rule!; - const raw = result.ports[rule.from.box]?.[rule.from.name]; - const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw); - const state = el("span", { - className: "m02-dfver__now", - text: value ? tv("Loading") : tv("NeedValue"), - }); - const note = el("span", { className: "ui-text ui-text--muted" }); - if (value) { - void findMaterials({ - q: e.scope?.찾는말, - sub: e.scope?.구분, - detail: e.scope?.상세구분, - size: 100, - }).then((got) => { - const hit = got.items.filter((m) => { - const t = rule.field === "규격" ? m.spec : m.name; - return rule.op === "같음" ? t === value : t.includes(value); - }); - const first = hit[0]; - state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`; - state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`; - note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : ""; - }); - } - const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? ""; - return el("div", { - className: "m02-dfver__item", - children: [ - el("div", { - className: "m02-dfver__line", - children: [el("strong", { text: elementPort(e) }), state, note], - }), - el("span", { - className: "ui-text ui-text--muted", - text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`, - }), - ], - }); -} - -/** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */ -export function renderVerify( - host: HTMLElement, - lg: CfLogic, - result: CfEvalResult, - on: CfVerifyOn, - opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {}, -): void { - const msg = el("p", { className: "ui-text ui-text--error" }); - const safe = (make: () => CfLogic): void => { - msg.textContent = ""; - try { - on.change(make()); - } catch { - msg.textContent = tv("Cant"); - } - }; - - /* 끊긴 곳 */ - const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics }); - const spotList = (list: typeof spots, hint: TextKey): HTMLElement[] => [ - el("ul", { - className: "m02-dfver__list", - children: list.map((s) => - el("li", { - children: [ - createButton({ - label: s.text, - variant: "ghost", - size: "sm", - disabled: !s.box || !on.select, - onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }), - }), - ], - }), - ), - }), - el("p", { className: "ui-text ui-text--muted", text: tv(hint) }), - ]; - const problems = spots.filter((s) => s.level !== "쉼"); - const resting = spots.filter((s) => s.level === "쉼"); - const spotBlock = [ - ...(problems.length - ? [ - el("div", { - className: "ui-banner ui-banner--danger m02-dfver__block m02-dfver__breaks", - children: [el("strong", { text: tv("Breaks") }), ...spotList(problems, "BreaksHint")], - }), - ] - : []), - ...(resting.length - ? [ - el("div", { - className: "m02-dfver__block m02-dfver__rest", - children: [el("strong", { text: tv("Rest") }), ...spotList(resting, "RestHint")], - }), - ] - : []), - ]; - - /* 설계 때 정할 것 — Model `designInputs`(변수 · 재료 자리 · 다른 로직) + 재료 조건 자리 */ - const designs = designInputs(lg); - const boxOf = (id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); - /** 변수 예시값을 그 상자(입력 · 함수)의 변수에 되적음 */ - const putExample = (box: string, name: string, value: number | string): CfLogic => ({ - ...lg, - boxes: lg.boxes.map((x) => { - if (x.id !== box) return x; - const set = (list: CfVariable[]): CfVariable[] => - list.map((y) => (y.name === name ? { ...y, example: value } : y)); - return { - ...x, - ...(x.input ? { input: { ...x.input, variables: set(x.input.variables) } } : {}), - ...(x.func ? { func: { ...x.func, variables: set(x.func.variables) } } : {}), - }; - }), - }); - const vars = designs.flatMap((d) => - d.kind === "변수" && d.variable - ? [sampleField(d.variable, (value) => safe(() => putExample(d.box, d.port, value)), msg)] - : [], - ); - const slots = designs.flatMap((d) => { - const box = boxOf(d.box); - return d.kind === "재료 자리" && d.element && box - ? [slotRow(box, d.element, { ...on, change: (n) => safe(() => n) }, lg)] - : []; - }); - const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input); - const rules = inputs.flatMap((b) => - (b.input?.elements ?? []) - .filter((e) => e.mode === "rule" && e.rule) - .map((e) => ruleRow(e, lg, result)), - ); - /** 다른 로직의 받는 알약 — 예시값을 적으면 logic.examples · 비우면 그 로직 자기 예시값 */ - const putLogicExample = (box: string, port: string, raw: string): CfLogic => ({ - ...lg, - boxes: lg.boxes.map((x) => { - if (x.id !== box || !x.input?.logic) return x; - const examples = { ...x.input.logic.examples }; - const text = raw.trim(); - if (text === "") delete examples[port]; - else examples[port] = Number.isNaN(Number(text)) ? text : Number(text); - return { ...x, input: { ...x.input, logic: { ...x.input.logic, examples } } }; - }), - }); - const logics = designs.flatMap((d) => { - if (d.kind !== "다른 로직") return []; - const input = el("input", { - className: "ui-input m02-dfver__input", - attrs: { - "data-size": "32", - type: "text", - placeholder: tv("OwnSample"), - "aria-label": d.port, - }, - }); - input.value = d.example === null || d.example === undefined ? "" : String(d.example); - input.addEventListener("change", () => safe(() => putLogicExample(d.box, d.port, input.value))); - const unit = d.unit && d.unit !== "-" ? ` (${d.unit})` : ""; - return [ - el("li", { - className: "m02-dfver__line", - children: [ - createButton({ - label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${unit}`, - variant: "ghost", - size: "sm", - disabled: !on.select, - onClick: () => on.select?.({ box: d.box, port: d.port }), - }), - input, - ], - }), - ]; - }); - const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] => - body.length - ? [ - el("div", { - className: "m02-dfver__group", - children: [el("strong", { text: tv(title) }), ...body], - }), - ] - : []; - const design = [ - ...group( - "Vars", - [el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length), - ), - ...group("Slots", slots), - ...group("Rules", rules), - ...group( - "Logics", - logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [], - ), - ]; - host.replaceChildren( - ...spotBlock, - ...(design.length - ? [ - el("div", { - className: "m02-dfver__block", - children: [ - el("strong", { className: "m02-dfver__title", text: tv("Design") }), - ...design, - el("p", { className: "ui-text ui-text--muted", text: tv("DesignHint") }), - msg, - ], - }), - ] - : []), - ); -} diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py index 751134d50..73e4471d1 100644 --- a/M02_MasterTemplete/M02_Logic_Lib_Read.py +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -26,12 +26,6 @@ def get_auto(key: str) -> dict: return m01.get_logic_auto(key) -@router.get("/logic/flow") -def get_flow(key: str, file: str = "") -> dict: - """흐름 보기(읽기 · 변환해도 파일 안 바뀜).""" - return m01.get_logic_flow(key, file) - - @router.get("/elements") def get_elements(group: str, q: str = "", limit: int = 50) -> dict: return m01.get_elements(group, q, min(limit, 200)) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index ca53d62f8..71531c2a8 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -192,12 +192,11 @@ NO_QTY_WHY = "수량 없음 — 쓰는 자리에서 연결" def _defaults(whole: Any, code: str) -> dict: - """로직 기본값 = M01 견본 입력(`/m01/logic/auto` 와 같은 길 · 흐름 예시값 › 옛 자동값).""" + """로직 기본값 = M01 견본 입력(`/m01/logic/auto` 와 같은 길).""" from M01_MasterData import M01_MasterData_Store as m01 - from M01_MasterData import M01_MasterData_Store_Flow as flow try: - got = flow.auto(code) or {"값": m01.mau.values(whole, whole.logic(code))} + got = {"값": m01.mau.values(whole, whole.logic(code))} except Exception: # noqa: BLE001 — 없는 로직 · 자동값 못 셈 = 기본값 없음 return {} return got.get("값") or {} diff --git a/main.py b/main.py index cb2670172..927c277c4 100644 --- a/main.py +++ b/main.py @@ -553,7 +553,7 @@ app.include_router( __import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router, dependencies=system_admin_only, ) # 별칭 장부 모으기(718-2) -# 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움 +# 단위 · 금액 표준 표 읽기(`GET /api/m02/df/standards` · 단위 고르기 `M02_DF_UnitPick`) app.include_router( __import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router, dependencies=system_admin_only, diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 41bd5edd6..ddb0cf557 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -7,12 +7,9 @@ from __future__ import annotations -import importlib import itertools import re -import sys from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal -from pathlib import Path import master_keys as mk @@ -31,27 +28,13 @@ _OPS = ("<=", ">=", "==", "!=", "<", ">") _ARITH = re.compile(r"[\d.\s+\-*/()]*\d[\d.\s+\-*/()]*") # 분수·식 값 칸(「1/1.30」) -# 흐름도 로직(줄에 「흐름」) 셈 — 서버(`M01_MasterData_Store_Flow`)가 꽂음 · 없으면 옛 셈만 -# 꼴 = (master, 키, 줄, 입력) → `run` 과 같은 꼴(돈 = 줄 · 비목 합 · 계 / 돈 아님 = 결과) -FLOW_RUN = None -# 흐름 로직 입력 = 「설계 때 정할 것」 → 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시) — 서버가 꽂음 -FLOW_INPUTS = None -CALC_FIELDS = ("중간", "호표", "결과", "덧줄") # 계산 칸 — 흐름 로직 줄엔 없음(입력은 둠) - - -def _flow_hooks() -> None: - """흐름 셈 · 입력 갈래를 깖 — 서버가 안 깔았으면(스크립트 · 시험) 여기서 서버 모듈을 불러 깖.""" - if FLOW_RUN is None or FLOW_INPUTS is None: - root = str(Path(__file__).resolve().parents[3]) - if root not in sys.path: - sys.path.insert(0, root) - importlib.import_module("M01_MasterData.M01_MasterData_Store_Flow") +CALC_FIELDS = ("중간", "호표", "결과", "덧줄") # 계산 칸 +FLOW_GONE = "흐름도 로직은 걷음 — M01 시트로 다시 만듦" # 794-1b · 저장된 흐름 로직 0 def inputs_of(row: dict) -> list[dict]: - """흐름의 「설계 때 정할 것」 → 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시).""" - _flow_hooks() - return FLOW_INPUTS(row) + """옛 흐름 로직(줄에 「흐름」) 입력 — 흐름도를 걷어 늘 못 셈.""" + raise FormulaError(FLOW_GONE) class FormulaError(ValueError): @@ -564,9 +547,8 @@ def _blank_line(item: dict, why: str) -> dict: def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: """로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나.""" row = master.logic(ref) - if isinstance(row.get("흐름"), dict): # 흐름 로직 — 깔림에 기대지 않고 스스로 깖 - _flow_hooks() - return FLOW_RUN(master, ref, row, given) + if isinstance(row.get("흐름"), dict): + raise FormulaError(FLOW_GONE) env = _inputs(row, given, master) for step in row.get("중간", []): try: diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py index 773e34580..e5057e125 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_size.py +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -50,12 +50,6 @@ _STATIC = re.compile( r"^\s*import\s+(?!type\b)[^;]*?from\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M ) _SIDE = re.compile(r"^\s*import\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M) -# 흐름도 동기 계산 — M01 로직 실험실 `loadFlowMods` 의 import() 로만 열림 · 첫 묶음 막음은 번들 실측 시험(static · first)이 지킴 -_FLOW = { - "M02_MasterTemplete/DataFlow/M02_DF_Eval.ts", - "M02_MasterTemplete/DataFlow/M02_DF_Rule.ts", - "M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", -} def test_M02_만_늦게_불러옴(): @@ -70,7 +64,7 @@ def test_M02_만_늦게_불러옴(): ).stdout.splitlines() for rel in listed: # 시험용 진입(`resources/tester/`)은 앱 번들 밖 - if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")) or rel in _FLOW: + if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")): continue if not (ROOT / rel).is_file(): continue diff --git a/resources/tester/test_208_1_logic_alias.py b/resources/tester/test_208_1_logic_alias.py index c06cfe8f0..483769715 100644 --- a/resources/tester/test_208_1_logic_alias.py +++ b/resources/tester/test_208_1_logic_alias.py @@ -170,8 +170,6 @@ def test_M01_목록_별칭_열은_이름_옆_고치면_캐시() -> None: [ "M01_MasterData/M01_MasterData_UI_Logic_List.ts", # M01 로직 목록 "M01_MasterData/M01_MasterData_UI_Logic_Pick.ts", # M01 요소 찾기 창 · 로직 그룹 - "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts", # M02 흐름도 다른 로직 고르기 - "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts", # 〃 결과 별칭 표시 "M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts", # M02 · B09 [로직 더하기] ], ) diff --git a/resources/tester/test_588_key_registry.py b/resources/tester/test_588_key_registry.py index 34d1f6f1b..f5dc54cc5 100644 --- a/resources/tester/test_588_key_registry.py +++ b/resources/tester/test_588_key_registry.py @@ -141,7 +141,6 @@ PAN_SITES = [ "B05_Profile/B05_Profile_UI_Drainage_Interact.ts", "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", - "M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts", ] diff --git a/resources/tester/test_713_3_list.py b/resources/tester/test_713_3_list.py index ba54f8850..c4149d6b2 100644 --- a/resources/tester/test_713_3_list.py +++ b/resources/tester/test_713_3_list.py @@ -10,19 +10,21 @@ COLS = {"묶음", "묶음수", "작업량", "부모", "부르는수", "불리는 def test_목록_줄_수_그대로_덧칸_다_있음() -> None: got = router.get_logics()["logics"] - assert len(got) == 1461 # 묶음은 화면 접기만 — 목록 줄은 정본 로직 수 그대로(713-4 뒤 1,461) + assert ( + len(got) == 1515 + ) # 묶음은 화면 접기만 — 목록 줄은 정본 로직 수 그대로(713-4 1,461 + 782 건설 기초 54) assert all(COLS <= set(x) for x in got) def test_묶음_작업량_부르기() -> None: more = store_list.extras() - # 한 절에 로직 둘 넘음(조사 12-B ㉯ 323 → 713-4 합침 · 지움 뒤 307) - assert sum(1 for v in more.values() if v["묶음"]) == 307 + # 한 절에 로직 둘 넘음(조사 12-B ㉯ 323 → 713-4 합침 · 지움 뒤 307 → 782 건설 기초 335) + assert sum(1 for v in more.values() if v["묶음"]) == 335 work = {k for k, v in more.items() if v["작업량"]} - assert len(work) == 21 # 27 − 터파기 작업량 6(713-4 · 단가 로직 안에 품음) + assert len(work) == 65 # 27 − 터파기 작업량 6(713-4 · 단가 로직 안에 품음) + 782 시공능력 44 assert more["GF000098"]["부모"] == ["GF000099"] # 측구터파기 작업량 → 단가 로직 밑 assert more["GC000268"]["불리는수"] >= 300 # 기계경비 로직 - assert sum(1 for v in more.values() if v["부르는수"]) == 506 + assert sum(1 for v in more.values() if v["부르는수"]) == 512 # 506 + 782 건설 기초 6 assert more["GC000294"]["관련"] == ["GC000292"] # 비고 「1-5-6(주③)」 # 인재경 줄 수 = 712-1 묶음 파생 · 못 가른 「하위」 = 복합 하위 로직 부르기 31(조사 12-B ③4) assert sum(v["인재경"]["하위"] for v in more.values()) == 31 diff --git a/resources/tester/test_717_1_logic_lib.py b/resources/tester/test_717_1_logic_lib.py index 555263a36..6040c645f 100644 --- a/resources/tester/test_717_1_logic_lib.py +++ b/resources/tester/test_717_1_logic_lib.py @@ -63,8 +63,6 @@ def test_일반_계정_읽기(user: TestClient) -> None: assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200 assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"] assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200 - flow = user.get(f"{BASE}/logic/flow", params={"key": KEY}) - assert flow.status_code == 200 and "flow" in flow.json() found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json() file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025") assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"] diff --git a/resources/tester/test_794_1b_flow_gone.py b/resources/tester/test_794_1b_flow_gone.py new file mode 100644 index 000000000..087fae8c1 --- /dev/null +++ b/resources/tester/test_794_1b_flow_gone.py @@ -0,0 +1,70 @@ +"""794-1b — M01 흐름도 걷음 뒤 지킴(마스터 사본 폴더 · 정본 안 바뀜). + +못박는 것 + - 흐름 길(`/api/m01/logic/flow` · `/api/m02/df/values` …) 없음 · 남긴 바닥 `GET /api/m02/df/standards` 는 그대로 + - [신규 로직] = 흐름 없는 빈 시트 줄 → `/save` 가 자체 파일 GX 새 키 · 구분 자체 · 셈은 옛 셈 + - 줄에 「흐름」 이 남아 있으면 엔진이 까닭 글로 막음(조용히 틀린 값 안 냄) +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as m01_router +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete.DataFlow import M02_DF_Router as df + +REAL = store.FOLDER +OWN = "로직_자체.json" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + master = tmp_path / "master" + master.mkdir() + for path in REAL.glob("*.json"): + shutil.copy(path, master / path.name) + monkeypatch.setattr(store, "FOLDER", master) + app = FastAPI() + app.include_router(m01_router.router) + app.include_router(df.router) + return TestClient(app) + + +def test_흐름_길은_없고_표준_표는_남음(client: TestClient) -> None: + assert client.get("/api/m01/logic/flow", params={"key": "GF000219"}).status_code == 404 + assert client.post("/api/m02/df/values", json={"items": []}).status_code == 404 + got = client.get("/api/m02/df/standards").json() + assert got["unit"]["줄"] and got["money"]["줄"] + + +def test_신규_로직은_시트_줄로_자체_GX(client: TestClient) -> None: + version = store.read(OWN)[1] if (store.FOLDER / OWN).is_file() else "" + row = { + "키": "", + "이름": "sub3임시 새 로직", + "결과단위": "원/㎡", + "출처": "자체", + "입력": [], + "중간": [], + "호표": [], + "끝수": None, + } + part = {"file": OWN, "version": version, "changes": [{"op": "add", "row": row}]} + res = client.post("/api/m01/save", json={"files": [part]}) + assert res.status_code == 200, res.text + added = res.json()["added"] + assert len(added) == 1 and added[0]["key"].startswith("GX") + saved = client.get("/api/m01/logic", params={"key": added[0]["key"]}).json()["logic"] + assert saved["구분"] == "자체" and saved["이름"] == "sub3임시 새 로직" and "흐름" not in saved + + +def test_흐름이_남은_줄은_까닭으로_막음() -> None: + mf = store.cm.mf + with pytest.raises(mf.FormulaError, match="흐름도 로직은 걷음"): + mf.inputs_of({"흐름": {"boxes": [], "links": []}}) diff --git a/resources/tester/test_input_prefs.py b/resources/tester/test_input_prefs.py index e0244bda4..a7b564a55 100644 --- a/resources/tester/test_input_prefs.py +++ b/resources/tester/test_input_prefs.py @@ -92,7 +92,6 @@ WHEEL_SITES = { "A00_Common/touch_bridge.ts": "wheelEventIn(e, isPinchWheel(e))", "A00_Common/b_svg_zoom_pan.ts": "wheelEventIn(event)", "B04_PreProcess/B04_PreProcess_UI_Camera.ts": "wheelEventIn(event)", - "M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts": "wheelZoomsIn(ev)", "M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts": "wheelZoomsIn(e)", "ui_template/ui_template_loupe.ts": "wheelIn(deltaY)", "A00_Common/spreadsheet/spreadsheet_grid.ts": "wheelZoomsIn(ev)", diff --git a/resources/tester/test_m01_logic_flow_engine.py b/resources/tester/test_m01_logic_flow_engine.py deleted file mode 100644 index d3f7be0ac..000000000 --- a/resources/tester/test_m01_logic_flow_engine.py +++ /dev/null @@ -1,175 +0,0 @@ -# ruff: noqa: E501 -"""흐름도 적용(PLAN 36-4 덧) — 옛 엔진 한 곳 갈래 · 인력 · 기계 · 유가전력 조건 찾기. - -못박는 것(마스터 사본 폴더) - - `mf.run` 을 바로 부르는 곳(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…))도 흐름 로직이면 흐름 셈 - - `/logic/auto` — 흐름 로직은 「설계 때 정할 것」 예시값 - - 조건 찾기 요소가 인력 · 기계 · 유가전력에서도 이름 · 키(원문번호) 같음으로 찾음 -""" - -from __future__ import annotations - -import itertools -import shutil -from decimal import Decimal -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from M01_MasterData import M01_MasterData_Router as m01_router -from M01_MasterData import M01_MasterData_Store as store -from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc -from M02_MasterTemplete.DataFlow import M02_DF_Values as dv -from resources.tester.test_m01_logic_flow_save import KEY, _flow, _get, _save_flow - -REAL = store.FOLDER - - -@pytest.fixture -def client(tmp_path: Path, monkeypatch) -> TestClient: - for path in REAL.glob("*.json"): - shutil.copy(path, tmp_path / path.name) - monkeypatch.setattr(store, "FOLDER", tmp_path) - app = FastAPI() - app.include_router(m01_router.router) - return TestClient(app) - - -def test_옛_엔진도_흐름_로직은_흐름_셈(client: TestClient) -> None: - _save_flow(client, KEY, _flow()) - whole = store.cm.mf.Master(store.cm.load(folder=store.FOLDER)) - got = store.cm.mf.run(whole, KEY, {"길이": Decimal(4), "폭": Decimal(5)}) - assert [(x["이름"], x["금액"]) for x in got["줄"]] == [("노무비", 20000)] and got["계"] == 20000 - line = { - "종류": "로직", - "요소": f"로직({KEY}, 길이=4, 폭=5)", - "이름": "흐름 로직", - "단위": "㎡", - "수량": "2", - } - old = store.calc("", {}, dv._row(line, ()), None) # 옛 식이 흐름 로직을 부름 - assert old["ok"] and old["sums"]["노무비"] == 40000, old - auto = _get(client, "/api/m01/logic/auto", key=KEY) - assert auto == {"값": {"길이": 2, "폭": 3}} - - -def _labor_flow(field: str | None, name: str) -> dict: - flow = _flow() - el = {"ref": "", "name": "노임", "spec": "", "unit": "원/인", "mode": "rule", - "rule": {"field": field, "op": "같음", "from": {"box": "who", "name": "직종"}}} # fmt: skip - flow["boxes"] += [ - {"id": "who", "kind": "input", "name": "직종", "color": "", "unit": "-", "x": 0, "y": 0, - "input": {"mode": "variable", "elements": [], "variables": [ - {"name": "직종", "type": "choice", "unit": "-", "example": name, "choices": [name]}]}}, - {"id": "lab", "kind": "input", "name": "노임", "color": "", "unit": "-", "x": 0, "y": 0, - "input": {"mode": "group", "group": "인력", "elements": [el], "variables": []}}, - ] # fmt: skip - flow["boxes"][2]["output"]["cost"] = "값" - flow["links"][2] = { - "id": "l3", - "from": {"box": "lab", "name": "노임"}, - "to": {"box": "out", "name": "값"}, - } - return flow - - -def test_인력_기계_유가전력_조건_찾기(client: TestClient) -> None: - rows = dv._group_rows("인력") - one = next(r for r in rows if sum(x.get("이름") == r.get("이름") for x in rows) == 1) - want = dv.one_value({"group": "인력", "ref": one["키"]}) - for field, value in (("이름", one["이름"]), ("키", one["원문번호"]), (None, one["키"])): - got = dc.run(_labor_flow(field, value), {}, lambda k: {}, [], ["t"]) - assert got["errors"] == [] and got["cost"]["값들"]["노무비"] == want["value"], ( - field, - got["errors"], - ) - miss = dc.run(_labor_flow("이름", "없는직종zz"), {}, lambda k: {}, [], ["t"]) - assert any("맞는 인력 없음" in e for e in miss["errors"]) - machine = dv._group_rows("기계")[0] - got = dv.group_find("기계", {"field": "키"}, machine["원문번호"]) - assert got["ref"] == machine["키"] - oils = dv._group_rows("유가전력") - if oils: - got = dv.group_find("유가전력", {"field": "키"}, oils[0]["키"]) - assert got["ref"] == oils[0]["키"] - - -def test_표_값_수_식_글은_수로_재료_키_찾기(client: TestClient) -> None: - assert dv._arith("(0.05)") == Decimal("0.05") and dv._arith("1/1.30") == Decimal(1) / Decimal( - "1.30" - ) - assert ( - dv._arith("토사") == "토사" and dv._arith("1/0") == "1/0" - ) # 수 식 아니거나 못 세면 그대로 - files = store.loaded()[0] - hit = next( - ((t["키"], n) for d in files.values() if d.get("그룹") in store.cm.mf.TABLE_GROUPS - for t in d.get("표") or [] for r in t.get("줄") or [] for n in t.get("값칸") or {} - if isinstance(r.get(n), str) and store.cm.mf._ARITH.fullmatch(r[n])), - None, - ) # fmt: skip - if hit: # 마스터에 식 글 값이 있으면 표 알약에서 수로 나옴 - got = dv._table(hit[0]) - col = got["rows"][0].index(hit[1]) - assert not any( - isinstance(r[col], str) and store.cm.mf._ARITH.fullmatch(r[col]) - for r in got["rows"][1:] - ) - item = next(r for r in dv._group_rows("재료") if not str(r.get("키")).startswith("MO")) - got = dv.slot_price({"mode": "rule", "field": "키", "op": "같음", "scope": {}}, item["키"]) - assert got.get("ref") == item["키"], got - - -def test_흐름_셈_단가는_부른_쪽_자리_프로젝트_사본(client: TestClient) -> None: - """옛 길과 같은 입력 → 같은 단가 — 프로젝트 사본(M02_Item_Prices 의 Master)이면 그 사본 노임.""" - flow = _flow() - el = {"ref": "LB000001", "name": "작업반장", "spec": "", "unit": "원/일", "mode": "fixed"} - flow["boxes"].append({"id": "lab", "kind": "input", "name": "노임", "color": "", "unit": "-", "x": 0, "y": 0, - "input": {"mode": "group", "group": "인력", "elements": [el], "variables": []}}) # fmt: skip - flow["links"][2] = { - "id": "l3", - "from": {"box": "lab", "name": "작업반장"}, - "to": {"box": "out", "name": "값"}, - } - _save_flow(client, KEY, flow) - files = store.cm.load(folder=store.FOLDER) - master = store.cm.mf.run(store.cm.mf.Master(files), KEY, {"길이": 1, "폭": 1}) - labor = next(r for d in files.values() for r in d.get("줄") or [] if r.get("키") == "LB000001") - assert master["노무비"] == labor["값"] - labor["값"] = Decimal(100000) # 프로젝트 사본 노임 - copy = store.cm.mf.run(store.cm.mf.Master(files), KEY, {"길이": 1, "폭": 1}) - assert copy["노무비"] == 100000 and master["노무비"] != 100000 - - -def test_프로젝트에서_고른_자재는_흐름_재료_자리로(client: TestClient) -> None: - """고름(`mp.choosing`) — 흐름 로직은 호표 차례 대신 재료 자리 이름으로 · 고른 품목 단가로 셈.""" - items = (r["키"] for r in dv._group_rows("재료") if not str(r["키"]).startswith("MO")) - first, other = list(itertools.islice((k for k in items if dv._price("재료", k).get("ok")), 2)) - flow = _flow() - el = { - "ref": "", - "name": "자재", - "spec": "", - "unit": "", - "mode": "choice", - "default": first, - "slot": "자재 자리", - } - flow["boxes"].append({"id": "mat", "kind": "input", "name": "재료", "color": "", "unit": "-", "x": 0, "y": 0, - "input": {"mode": "group", "group": "재료", "elements": [el], "variables": []}}) # fmt: skip - flow["boxes"][2]["output"]["cost"] = "재료비" - flow["links"][2] = { - "id": "l3", - "from": {"box": "mat", "name": "자재 자리"}, - "to": {"box": "out", "name": "값"}, - } - _save_flow(client, KEY, flow) - row = store.logic(KEY)["logic"] - assert "자재 자리" in [s["이름"] for s in row["입력"]] # 재료 자리도 입력 약속에 - whole = store.cm.mf.Master(store.cm.load(folder=store.FOLDER)) - given = {"길이": Decimal(1), "폭": Decimal(1), "자재 자리": None} - with store.cm.mf.mp.choosing({"자재 자리": other}): - got = store.cm.mf.run(whole, KEY, given) - assert got["재료비"] == dv._price("재료", other)["value"] diff --git a/resources/tester/test_m01_logic_flow_project.py b/resources/tester/test_m01_logic_flow_project.py deleted file mode 100644 index 6dcdbc4f8..000000000 --- a/resources/tester/test_m01_logic_flow_project.py +++ /dev/null @@ -1,97 +0,0 @@ -# ruff: noqa: E501 -"""흐름 로직이 일위대가 조합 · 프로젝트 길에서도 그대로(PLAN 36 · 사용자 원칙 「조합은 로직을 가져가 더하기만」 · 사본 폴더). - -못박는 것 — 조합에 담은 로직을 모두 흐름으로 저장(계산 칸 지움 · 「입력」 은 흐름에서 같은 꼴로)한 뒤 - - 로직마다 입력 목록(이름 · 단위 · 고르기 · 범위) = 바꾸기 전 - - 조합 셈(`master_combo.run`) 로직별 결과 · 비목 합 = 바꾸기 전 - - 프로젝트 단가 묶음(`M02_Item_Prices.collect`)만으로 셈 = 바꾸기 전(흐름이 쓰는 요소 · 표 · 다른 로직이 담김) - - 빈 값으로 준 입력은 「빈 입력」 알림(예시값으로 안 넘어감) · 안 준 입력은 멈춤 -""" - -from __future__ import annotations - -import shutil -from decimal import Decimal -from pathlib import Path - -import pytest - -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData import M01_MasterData_Store_Flow as sflow -from M02_MasterTemplete import M02_Item_Prices as prices -from M02_MasterTemplete.DataFlow import M02_DF_Convert as cv - -REAL = store.FOLDER -KEYS = ("GF000185", "GF000219") # 레미콘 검색어 · 펌프차 기계 · 인력 / 표 찾기 · 인력 -COMBO = { - "키": "UA999999", - "이름": "시험 조합", - "담은로직": [{"로직": k, "메모": None} for k in KEYS], -} - - -def _specs(row: dict) -> list: - return [ - (s["이름"], s.get("단위"), s.get("고르기"), s.get("범위")) for s in row.get("입력") or [] - ] - - -def _round(v): - return round(Decimal(str(v)), 6) if v is not None else None - - -def _combo(folder: Path) -> tuple[list, dict]: - mf, mcb = store.cm.mf, store.cm.mcb - given = {k: dict(store.cm.시험입력(store.logic(k)["logic"])) for k in KEYS} - got = mcb.run(mf.Master(store.cm.load(folder=folder)), COMBO, given) - lines = [ - (x["로직"], x["ok"], {k: _round(x.get(k)) for k in ("노무비", "재료비", "경비", "계")}) - for x in got["줄"] - ] - return lines, {k: _round(got.get(k)) for k in ("노무비", "재료비", "경비", "계")} - - -def _bundle_sums() -> dict: - mf = store.cm.mf - snapshot = prices.load_prices(prices.to_json(prices.collect(list(KEYS)))) - out = {} - for k in KEYS: - got = mf.run(snapshot, k, dict(store.cm.시험입력(store.logic(k)["logic"]))) - out[k] = {n: _round(got[n]) for n in ("노무비", "재료비", "경비", "계")} - return out - - -def test_조합에_담은_로직을_흐름으로_바꿔도_같음(tmp_path: Path, monkeypatch) -> None: - for path in REAL.glob("*.json"): - shutil.copy(path, tmp_path / path.name) - monkeypatch.setattr(store, "FOLDER", tmp_path) - specs = {k: _specs(store.logic(k)["logic"]) for k in KEYS} - lines, sums = _combo(tmp_path) - assert all(ok for _, ok, _ in lines) and sums["계"] > 0 - bundle = _bundle_sums() - for key in KEYS: - got = store.logic(key) - flow = cv.convert(got["logic"], {"key": key, "master": store.loaded()[1]})["flow"] - flow.pop("key", None) - change = {"op": "edit", "key": key, "row": {**got["logic"], "흐름": flow}} - sflow.save( - [store._dec({"file": got["file"], "version": got["version"], "changes": [change]})], - True, # 정본 줄 고침 = 개정 확인(713-1) - ) - for key in KEYS: - row = store.logic(key)["logic"] - assert not set(row) & set(store.cm.mf.CALC_FIELDS) and row["흐름"] # 계산 칸만 지움 - assert _specs(row) == specs[key] # 입력 약속 그대로 - assert _combo(tmp_path) == (lines, sums) - assert "_빠진" not in prices.collect(list(KEYS)) - assert _bundle_sums() == bundle - mf = store.cm.mf - whole = mf.Master(store.cm.load(folder=tmp_path)) - given = dict(store.cm.시험입력(store.logic(KEYS[0])["logic"])) - first = next(iter(given)) - blank = mf.run( - whole, KEYS[0], {**given, first: None} - ) # 빈 값으로 준 입력 = 그 값을 쓰는 곳만 비움 - assert any(f"「{first}」 빈 입력" in n for n in blank.get("알림") or []) - with pytest.raises(mf.FormulaError, match="없음"): # 안 준 입력 = 멈춤(엔진과 같음) - mf.run(whole, KEYS[0], {k: v for k, v in given.items() if k != first}) diff --git a/resources/tester/test_m01_logic_flow_save.py b/resources/tester/test_m01_logic_flow_save.py deleted file mode 100644 index cef39c2c7..000000000 --- a/resources/tester/test_m01_logic_flow_save.py +++ /dev/null @@ -1,282 +0,0 @@ -# ruff: noqa: E501 -"""흐름도 적용(PLAN 36-4 서버 · 정함 1 · 2 · 4 · 10 · 11 · 12) — 로직 줄 「흐름」 칸. - -못박는 것(마스터 사본 폴더에서만 · 정본은 안 바뀜) - - `/save` 줄에 실은 흐름 = 검사 → 서버 다시 셈(받는 칸에 값) → 파일에 씀 · 다시 읽으면 그대로 - - 흐름 꼴이 틀리면(없는 알약 · 돌고 도는 선) 422 · 빈 받는 자리는 경고 - - `/calc` 갈래 — 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · 노무비 · 재료비 · 경비 · 계) · 없으면 옛 셈 - - 새 줄(키 없음) + 흐름 → 자체 파일 GX 다음 키 · 구분 자체 · 본떠 만들기는 흐름도 함께 - - `GET /logic/flow` — 흐름 있으면 그대로 · 없으면 변환(준비 전이면 빈 흐름 + 「변환 준비 중」) - - DataFlow 다른 로직 값 · 설계 입력이 마스터 키도 받음(흐름 · 옛 로직) · 서로 부르면 막음 - - 시스템 관리자가 아니면 403 -""" - -from __future__ import annotations - -import copy -import hashlib -import importlib.util -import shutil -from decimal import Decimal -from pathlib import Path - -import pytest -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - -from common_util.common_util_auth import require_system_admin, verify_session -from M01_MasterData import M01_MasterData_Router as m01_router -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData import M01_MasterData_Store_Flow as sflow -from M02_MasterTemplete.DataFlow import M02_DF_Router as df - -REAL = store.FOLDER -KEY = "GF000219" # 13-4-1 메쌓기 — 흐름을 얹어 볼 정본 로직 -OLD = "GF000160" # 12-4 합판거푸집 — 흐름 없는 옛 로직 -OWN = "로직_자체.json" - - -def _flow(name: str = "넓이 품") -> dict: - """길이 × 폭 × 1000 → 노무비 — 입력 상자(변수 둘) · 함수 상자 · 출력 상자.""" - sheet = {"id": "s-fn", "이름": "넓이", "칸": {"A2": {"값": "길이"}, "A3": {"값": "폭"}, - "A4": {"값": "금액"}, "B4": {"식": "B2*B3*1000"}}} # fmt: skip - return { - "key": None, "name": name, "resultUnit": "원/㎡", "userElements": [], - "boxes": [ - {"id": "in", "kind": "input", "name": "치수", "color": "", "unit": "-", "x": 0, "y": 0, - "input": {"mode": "variable", "elements": [], "variables": [ - {"name": "길이", "type": "number", "unit": "m", "example": 2}, - {"name": "폭", "type": "number", "unit": "m", "example": 3}]}}, - {"id": "fn", "kind": "function", "name": "넓이 금액", "color": "", "unit": "원", "x": 0, "y": 0, - "func": {"sheet": sheet, "inputs": [{"port": "길이", "cell": "B2"}, {"port": "폭", "cell": "B3"}], - "variables": [], "output": {"cell": "B4", "name": "금액", "unit": "원"}}}, - {"id": "out", "kind": "output", "name": "노무비", "color": "", "unit": "원/㎡", "x": 0, "y": 0, - "output": {"cost": "노무비", "unit": "원/㎡"}}, - ], - "links": [ - {"id": "l1", "from": {"box": "in", "name": "길이"}, "to": {"box": "fn", "name": "길이"}}, - {"id": "l2", "from": {"box": "in", "name": "폭"}, "to": {"box": "fn", "name": "폭"}}, - {"id": "l3", "from": {"box": "fn", "name": "금액"}, "to": {"box": "out", "name": "값"}}, - ], - } # fmt: skip - - -def _hashes(folder: Path) -> dict[str, str]: - return {p.name: hashlib.sha1(p.read_bytes()).hexdigest() for p in folder.glob("*.json")} - - -@pytest.fixture -def folder(tmp_path: Path, monkeypatch) -> Path: - master = tmp_path / "master" - master.mkdir() - for path in REAL.glob("*.json"): - shutil.copy(path, master / path.name) - monkeypatch.setattr(store, "FOLDER", master) - return master - - -@pytest.fixture -def client(folder: Path) -> TestClient: - app = FastAPI() - app.include_router(m01_router.router) - app.include_router(df.router) - return TestClient(app) - - -def _get(client: TestClient, url: str, want: int = 200, **params) -> dict: - res = client.get(url, params=params) - assert res.status_code == want, res.text - return res.json() - - -def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: - res = client.post(url, json=body) - assert res.status_code == want, res.text - return res.json() - - -def _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dict: - got = _get(client, "/api/m01/logic", key=key) - row = {**got["logic"], "흐름": flow} - part = {"file": got["file"], "version": got["version"], - "changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip - body = {"files": [part], "개정확인": True} # 정본 줄 고침 = 개정 확인(713-1) - return _post(client, "/api/m01/save", body, want) - - -def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None: - before = _hashes(REAL) - done = _save_flow(client, KEY, _flow()) - assert done["warnings"] == [] and done["added"] == [] - saved = _get(client, "/api/m01/logic", key=KEY)["logic"]["흐름"] - cells = saved["boxes"][1]["func"]["sheet"]["칸"] - assert ( - Decimal(str(cells["B2"]["값"])) == 2 and Decimal(str(cells["B3"]["값"])) == 3 - ) # 서버가 넣은 값 - assert "key" not in saved - got = _get(client, "/api/m01/logic/flow", key=KEY) - assert got["converted"] is False and got["notes"] == [] and got["flow"]["key"] == KEY - assert [b["id"] for b in got["flow"]["boxes"]] == ["in", "fn", "out"] - assert _hashes(REAL) == before # 사본 밖(정본)은 안 바뀜 - - -def test_흐름_꼴_검사_422_와_빈_자리_경고(client: TestClient) -> None: - bad = _flow() - bad["links"][0]["from"]["name"] = "없는알약" - assert "없는 알약" in str(_save_flow(client, KEY, bad, 422)) - loop = _flow() - loop["boxes"].append({**copy.deepcopy(loop["boxes"][1]), "id": "fn2", "name": "되돌이"}) - loop["links"] += [ - {"id": "l4", "from": {"box": "fn", "name": "금액"}, "to": {"box": "fn2", "name": "길이"}}, - {"id": "l5", "from": {"box": "fn2", "name": "금액"}, "to": {"box": "fn", "name": "폭"}}, - ] - loop["links"] = [x for x in loop["links"] if x["id"] != "l2"] - assert "돌고 도는" in str(_save_flow(client, KEY, loop, 422)) - empty = _flow() - empty["links"] = [x for x in empty["links"] if x["id"] != "l2"] - warns = _save_flow(client, KEY, empty)["warnings"] - assert any("넓이 금액 · 폭" in w for w in warns), warns - - -def test_계산_갈래_흐름이면_흐름_셈_아니면_옛_셈(client: TestClient) -> None: - _save_flow(client, KEY, _flow()) - got = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {"길이": 4, "폭": "5"}}) - assert got["flow"] is True and got["ok"] is True - assert {k: Decimal(str(v)) for k, v in got["sums"].items()} == { - "노무비": 20000, "재료비": 0, "경비": 0, "계": 20000} # fmt: skip - assert ( - got["outputs"][0]["name"] == "노무비" and Decimal(str(got["outputs"][0]["value"])) == 20000 - ) - plain = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}}) # 예시값 2 × 3 - assert Decimal(str(plain["sums"]["계"])) == 6000 - row = {**_get(client, "/api/m01/logic", key=KEY)["logic"], "흐름": _flow()} # 저장 전 줄 - row["흐름"]["boxes"][1]["func"]["sheet"]["칸"]["B4"] = {"식": "B2*B3*10"} - draft = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}, "row": row}) - assert Decimal(str(draft["sums"]["계"])) == 60 - old = _post(client, "/api/m01/calc", {"key": OLD, "inputs": {}}) - assert "flow" not in old # 흐름 없는 로직은 옛 셈 그대로 - - -def test_신규_흐름_로직은_자체_GX_복사는_흐름도(client: TestClient, folder: Path) -> None: - version = store.read(OWN)[1] if (folder / OWN).is_file() else "" - row = {"키": "", "이름": "시험 흐름 로직", "결과단위": "원/㎡", "출처": "", "구분": "산림품셈", - "흐름": _flow("시험 흐름 로직")} # fmt: skip - part = {"file": OWN, "version": version, "changes": [{"op": "add", "row": row}]} - done = _post(client, "/api/m01/save", {"files": [part]}) - assert len(done["added"]) == 1 and done["added"][0]["file"] == OWN - key = done["added"][0]["key"] - assert key.startswith("GX") - saved = _get(client, "/api/m01/logic", key=key)["logic"] - assert saved["구분"] == "자체" and saved["흐름"]["name"] == "시험 흐름 로직" - assert not set(store.logic(key)["logic"]) & set( - store.cm.mf.CALC_FIELDS - ) # 파일 줄에 옛 식 칸 없음 - assert ( - Decimal(str(_post(client, "/api/m01/calc", {"key": key, "inputs": {}})["sums"]["계"])) - == 6000 - ) - made = _post(client, "/api/m01/logic/copy", {"key": key, "이름": "복사한 흐름"}) - assert made["key"].startswith("GX") and made["key"] != key - copied = _get(client, "/api/m01/logic/flow", key=made["key"]) - assert copied["converted"] is False and len(copied["flow"]["boxes"]) == 3 - bad = {"file": OWN, "version": store.read(OWN)[1], "changes": [{"op": "add", "row": row}]} - bad["changes"][0]["row"] = {**row, "흐름": {"boxes": "아님"}} - _post(client, "/api/m01/save", {"files": [bad]}, 422) - - -def test_흐름_없는_로직_받기는_변환(client: TestClient) -> None: - got = _get(client, "/api/m01/logic/flow", key=OLD) - assert got["converted"] is True and got["flow"]["key"] == OLD - if importlib.util.find_spec(sflow.CONVERT) is None: # sub7 변환이 오기 전 - assert got["notes"] == [sflow.NOT_READY] and got["flow"]["boxes"] == [] - _get(client, "/api/m01/logic/flow", 404, key="GX999999") - - -def test_다른_로직_값_마스터_키_흐름_옛_순환(client: TestClient) -> None: - _save_flow(client, KEY, _flow()) - got = _post(client, "/api/m02/df/logic-value", {"key": KEY, "designs": {"길이": 10}}) - assert Decimal(str(got["outputs"]["노무비"])) == 30000 and got["errors"] == [] - designs = _get(client, "/api/m02/df/design-inputs", key=KEY)["designs"] - assert [d["port"] for d in designs] == ["길이", "폭"] - given = {k: str(v) for k, v in store.cm.시험입력(store.logic(OLD)["logic"]).items()} - old = _post(client, "/api/m02/df/logic-value", {"key": OLD, "designs": given}) - want = _post(client, "/api/m01/calc", {"key": OLD, "inputs": given}) - assert old["outputs"] == want["sums"] and set(old["outputs"]) == { - "노무비", - "재료비", - "경비", - "계", - } - names = [d["port"] for d in _get(client, "/api/m02/df/design-inputs", key=OLD)["designs"]] - assert names == [x["이름"] for x in store.logic(OLD)["logic"].get("입력") or []] - # 흐름 안 다른 로직 입력이 옛 로직(OLD) 계 를 받아 노무비로 · 자기 자신을 부르면 막힘 - flow = _flow() - pills = [{"name": k, "unit": "-"} for k in given] - ref = {"key": OLD, "outputs": [{"name": "계", "unit": "원"}], "designs": pills} - flow["boxes"].append({"id": "lg", "kind": "input", "name": "거푸집", "color": "", "unit": "-", - "x": 0, "y": 0, "input": {"mode": "logic", "elements": [], "variables": [], - "logic": ref}}) # fmt: skip - flow["boxes"].append({**copy.deepcopy(flow["boxes"][2]), "id": "out2", "name": "경비"}) - flow["boxes"][-1]["output"]["cost"] = "경비" - flow["links"].append( - {"id": "l9", "from": {"box": "lg", "name": "계"}, "to": {"box": "out2", "name": "값"}} - ) - _save_flow(client, KEY, flow) - mixed = _post(client, "/api/m01/calc", {"key": KEY, "inputs": given}) - assert Decimal(str(mixed["sums"]["경비"])) == Decimal(str(want["sums"]["계"])) - ref["key"] = KEY - self_call = _save_flow(client, KEY, flow)["warnings"] - assert any("서로를 부름" in w for w in self_call), self_call - - -def test_시스템_관리자가_아니면_403(folder: Path) -> None: - app = FastAPI() - app.include_router( - m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] - ) - app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} - client = TestClient(app) - before = _hashes(folder) - assert client.get("/api/m01/logic/flow", params={"key": KEY}).status_code == 403 - assert client.post("/api/m01/logic/flow", json={"key": KEY, "row": {}}).status_code == 403 - assert client.post("/api/m01/save", json={"files": []}).status_code == 403 - assert client.post("/api/m01/calc", json={"key": KEY, "inputs": {}}).status_code == 403 - assert _hashes(folder) == before - - -def test_저장_안_한_고친_줄_변환(client: TestClient, folder: Path) -> None: - """POST /logic/flow — 옛 편집기 고친 줄 그대로 변환 · 파일은 안 바뀜 · 줄에 흐름이 있어도 변환.""" - before = _hashes(folder) - row = copy.deepcopy(_get(client, "/api/m01/logic", key=OLD)["logic"]) - row["이름"] = "고친 이름" - row["흐름"] = _flow() - got = _post(client, "/api/m01/logic/flow", {"key": OLD, "row": row}) - assert got["converted"] is True and got["flow"]["key"] == OLD - if importlib.util.find_spec(sflow.CONVERT) is not None: - assert got["flow"]["name"] == "고친 이름" and got["flow"]["boxes"] - assert _hashes(folder) == before - - -def test_흐름_로직_줄은_옛_식_칸_없음_읽는_곳은_흐름에서(client: TestClient, folder: Path) -> None: - """흐름으로 저장 = 입력 · 중간 · 호표 · 결과 · 덧줄 지움(사용자 결정) · 입력을 읽는 곳은 「설계 때 정할 것」.""" - mf = store.cm.mf - _save_flow(client, KEY, _flow()) - row = store.logic(KEY)["logic"] # 파일 줄 그대로 - assert not set(row) & set(mf.CALC_FIELDS) and "끝수" in row and row["흐름"] - view = _get(client, "/api/m01/logic", key=KEY)[ - "logic" - ] # 화면 보기 = 설계 때 정할 것을 입력 꼴로 - assert [x["이름"] for x in view["입력"]] == ["길이", "폭"] - assert [(s["이름"], s["단위"], s["예시"]) for s in mf.inputs_of(row)] == [ - ("길이", "m", 2), - ("폭", "m", 3), - ] - assert {"길이", "폭"} <= set(store.cm.시험입력(row)) # 줄의 시험입력 칸은 그대로 둠(이김) - assert store.mau.values(store.loaded()[1], row) == {"길이": 2, "폭": 3} - assert _get(client, "/api/m01/logic/auto", key=KEY) == {"값": {"길이": 2, "폭": 3}} - text = _post(client, "/api/m01/text", {"key": KEY, "inputs": {"길이": 4, "폭": 5}}) - assert text["flow"] is True and text["groups"][0]["비목"] == "노무비" - assert Decimal(str(text["groups"][0]["줄"][0]["금액"])) == 20000 - whole = mf.Master(store.cm.load(folder=folder)) # 옛 엔진 길(조합 · 프로젝트 단가) - assert mf.run(whole, KEY, {"길이": Decimal(4), "폭": Decimal(5)})["계"] == 20000 - assert _get(client, "/api/m01/logic", key=KEY)["blocked"] is False diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py deleted file mode 100644 index e18577e28..000000000 --- a/resources/tester/test_m01_logic_flowview.py +++ /dev/null @@ -1,215 +0,0 @@ -"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분. - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 · -화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음). -""" - -import json -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) }); -const cache = {}; -function load(f) { - if (cache[f]) return cache[f].exports; - const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = (cache[f] = { exports: {} }); - const req = (id) => { - if (id.startsWith(".")) { - const p = path.resolve(path.dirname(f), id); - return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; - } - return stub; - }; - new Function("exports", "module", "require", js)(box.exports, box, req); - return box.exports; -} -const m = load(file); -const r = load(path.join(root, "M02_MasterTemplete", "DataFlow", "M02_DF_Round.ts")); -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => job.op === "totalRound" ? r.totalRoundOf(job.lg, job.fallback) : m.flowRows(job.lg, job.ev, job.round)); -process.stdout.write(JSON.stringify(out)); -setTimeout(() => process.exit(0), 50); -""" - -ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}} - - -def box(id_, kind, name, unit="원", **extra): - return { - "id": id_, - "kind": kind, - "name": name, - "color": "#000", - "unit": unit, - "x": 0, - "y": 0, - **extra, - } - - -def out_box(id_, name, cost, unit="원", round_=None): - output = {"cost": cost, "unit": unit} - if round_: - output["round"] = round_ - return box(id_, "output", name, unit, output=output) - - -def func_box(id_, name, out_name, unit="시간"): - func = { - "sheet": {"id": "s-" + id_, "이름": name, "칸": {}}, - "inputs": [], - "variables": [], - "output": {"cell": "B1", "name": out_name, "unit": unit}, - } - return box(id_, "function", name, unit, func=func) - - -def logic(boxes): - return { - "key": None, - "name": "시험", - "resultUnit": "원/㎡", - "boxes": boxes, - "links": [], - "userElements": [], - } - - -def result(outputs, cost, ports=None, errors=None): - return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []} - - -def run(jobs: list[dict]) -> list: - node = "node" - done = subprocess.run( - [node, "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs), - capture_output=True, - text=True, - timeout=60, - check=False, - ) - assert done.returncode == 0, done.stderr - return json.loads(done.stdout) - - -@pytest.fixture(scope="module") -def sample(): - lg = logic( - [ - out_box("o1", "인건비", "노무비"), - out_box("o2", "재료값", "재료비"), - out_box("o3", "시간당 작업량", "값", "㎥/시간"), - out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}), - func_box("f1", "운반 시간", "시간", "시간"), - ] - ) - ev = result( - {"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239}, - { - "노무비": 3216.69, - "재료비": 100.04, - "경비": 1.239, - "값들": {"시간당 작업량": 12.5}, - "계": 3317.969, - }, - ports={"f1": {"시간": 6.024}}, - ) - return run([{"lg": lg, "ev": ev, "round": ROUND}])[0] - - -def group(rows, key): - return next(g for g in rows["groups"] if g["key"] == key) - - -def test_group_order_and_costs_only(sample): - assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"] - - -def test_line_uses_output_round(sample): - # 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음) - assert group(sample, "labor")["lines"][0]["text"] == "3,216.6" - assert group(sample, "material")["lines"][0]["text"] == "100" - # 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림) - assert group(sample, "cost")["lines"][0]["text"] == "1.24" - - -def test_plain_value_is_not_rounded_and_has_no_subtotal(sample): - plain = group(sample, "plain") - assert plain["lines"][0]["text"] == "12.5" - assert plain["lines"][0]["unit"] == "㎥/시간" - assert plain["sub"] is None - - -def test_subtotal_and_total_floor_to_won(sample): - assert group(sample, "labor")["sub"] == 3216 - assert group(sample, "labor")["subText"] == "3,216" - assert sample["total"] == 3317 - assert sample["totalText"] == "3,317" - - -def test_function_rows(sample): - assert sample["funcs"] == [ - {"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"} - ] - - -def test_empty_group_is_hidden(): - lg = logic([out_box("o1", "인건비", "노무비")]) - ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10}) - rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] - assert [g["key"] for g in rows["groups"]] == ["labor"] - assert rows["total"] == 10 - - -def test_missing_value_shows_dash(): - lg = logic([out_box("o1", "인건비", "노무비")]) - ev = result( - {"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"] - ) - rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] - assert group(rows, "labor")["lines"][0]["text"] == "—" - assert rows["errors"] == ["끊김"] - - -def test_total_round_follows_converted_logic_own_round(): - """합계 끝수 정본 = CfLogic.totalRound — 옮긴 로직 = 자신 끝수(null = 없음) · 안 정함 = 기본.""" - default = {"places": 0, "method": "버림"} - own = {"places": 1, "method": "반올림"} - converted = {**logic([out_box("o1", "인건비", "노무비")]), "totalRound": own} - no_round = {**logic([out_box("o1", "인건비", "노무비")]), "totalRound": None} - fresh = logic( - [out_box("o1", "인건비", "노무비", round_=own)] - ) # 출력 상자 끝수는 합계 끝수 아님 - got = run( - [ - {"op": "totalRound", "lg": converted, "fallback": default}, - {"op": "totalRound", "lg": no_round, "fallback": default}, - {"op": "totalRound", "lg": fresh, "fallback": default}, - ] - ) - assert got == [own, None, default] - - -def test_subtotal_and_total_use_converted_round(): - own = {"places": 1, "method": "반올림"} - lg = logic([out_box("o1", "인건비", "노무비", round_=own)]) - ev = result( - {"o1": 3216.7}, {"노무비": 3216.7, "재료비": 0, "경비": 0, "값들": {}, "계": 3216.7} - ) - rows = run([{"lg": lg, "ev": ev, "round": {"item": ROUND["item"], "total": own}}])[0] - assert group(rows, "labor")["subText"] == "3,216.7" - assert rows["totalText"] == "3,216.7" diff --git a/resources/tester/test_m02_df_convert.py b/resources/tester/test_m02_df_convert.py deleted file mode 100644 index f103e916d..000000000 --- a/resources/tester/test_m02_df_convert.py +++ /dev/null @@ -1,307 +0,0 @@ -"""M02 흐름도 — 옛 로직 줄 → 흐름도 변환(M02_DF_Convert) 시험 (PLAN 36-2). - -· 꼴 시험 — 견본 로직 몇을 옮겨 상자 · 선 · 규칙 · 자동 배치(겹침 없음)를 봄 -· 전수 대조 — 사본 폴더(tmp/master_copy_36 · AISLO_MASTER_DIR)의 로직 1,472 마다 - 옛 셈(master_formula.run) = 변환 흐름도 셈(Compute.run) · 입력 = check_master.시험입력(정함 ①) - · 기본 입력(줄의 시험입력 뺌)도 같이 · 맞는 비율 · 안 맞는 까닭 → tmp/convert_report_36.json -· 조건 찾기 field 「키」(입력값으로 요소 고르기 · 정함 ②) · 표 값 식 글 → 수(정함 ③)는 서버 길로 - (Compute · Values · sub4) · 값 찾기(dv.one_value)는 같은 물음이면 한 번만 셈 -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import sys -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -COPY = ROOT / "tmp" / "master_copy_36" -REPORT = ROOT / "tmp" / "convert_report_36.json" -WORKERS = 6 -STEP = ( - 1 if os.environ.get("AISLO_DF_FULL") else 10 -) # 지킴 = 열 줄에 하나(약 1분) · 전수(약 12분) = AISLO_DF_FULL=1 - -# 일꾼(따로 뜬 파이썬 · 사본 폴더) — 표준 입력 = 키 목록 JSON · 표준 출력 = 줄마다 결과 JSON -WORKER = r""" -import json, os, sys -sys.path.insert(0, os.getcwd()) -from M01_MasterData import M01_MasterData_Store as store -from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp, M02_DF_Convert as cv -from M02_MasterTemplete.DataFlow import M02_DF_Values as dv -mf, cm = store.cm.mf, store.cm -master = store.loaded()[1] -seen, flows = {}, {} -one_value = dv.one_value -def cached(item, local=None): - k = json.dumps(item, ensure_ascii=False, sort_keys=True, default=str) - if k not in seen: - seen[k] = one_value(item, local) - return seen[k] -dv.one_value = cached -def load(key): - if key not in flows: - flows[key] = cv.convert(master.logic(key))["flow"] - return flows[key] -def num(v): - try: - return float(v) - except (TypeError, ValueError): - return None -def same(a, b): - a, b = num(a), num(b) - return a is not None and b is not None and abs(a - b) <= 1e-6 * max(1.0, abs(a)) -def check(row, given): - try: - old = mf.run(master, row["키"], given) - except Exception as e: - return {"state": "옛 셈 오류", "why": str(e)[:200]} - got = cv.convert(row) - flows[row["키"]] = got["flow"] - try: - env = mf._inputs(row, given) # 옛 엔진처럼 글로 적힌 수 → 수(화면은 수로 넘김) - design = {"in-design": {k: cv.plain(v) for k, v in env.items()}} - new = cp.run(got["flow"], design, load, []) - except Exception as e: - return {"state": "흐름 셈 멈춤", "why": repr(e)[:200], "notes": got["notes"]} - cost = new["cost"] - if "결과" in old: - pairs = [("결과", old["결과"], cost["값들"].get("결과"))] - else: - pairs = [(k, old[k], cost[k]) for k in ("노무비", "재료비", "경비", "계")] - bad = [[k, num(a), num(b)] for k, a, b in pairs if not same(a, b)] - if not bad: - return {"state": "맞음"} - return {"state": "다름", "diff": bad, "notes": got["notes"], "errors": new["errors"][:6]} -for key in json.loads(sys.stdin.read()): - row = master.logic(key) - plain = cm.시험입력({k: v for k, v in row.items() if k != "시험입력"}) - test = cm.시험입력(row) - out = {"key": key, "시험입력": check(row, test)} - out["기본 입력"] = out["시험입력"] if plain == test else check(row, plain) - print(json.dumps(out, ensure_ascii=False, default=str), flush=True) -""" - - -def _copy() -> None: - """사본 폴더 — 정본과 다르거나 없는 JSON 만 복사(정본은 읽기만 · 옛 사본이 남아 어긋나지 않게).""" - COPY.mkdir(parents=True, exist_ok=True) - for f in (ROOT / "resources" / "master_data").glob("*.json"): - to = COPY / f.name - if ( - not to.exists() - or to.stat().st_mtime != f.stat().st_mtime - or to.stat().st_size != f.stat().st_size - ): - shutil.copy2(f, to) - - -def _env() -> dict: - return {**os.environ, "AISLO_MASTER_DIR": str(COPY), "PYTHONIOENCODING": "utf-8"} - - -def _run(code: str, stdin: str = "") -> str: - got = subprocess.run( - [sys.executable, "-c", code], - input=stdin, - capture_output=True, - text=True, - encoding="utf-8", - cwd=ROOT, - env=_env(), - timeout=7200, - ) - assert got.returncode == 0, got.stderr[-2000:] - return got.stdout - - -def _keys() -> list[str]: - code = ( - "import json, os, sys; sys.path.insert(0, os.getcwd())\n" - "from M01_MasterData import M01_MasterData_Store as store\n" - "files = store.loaded()[0]\n" - "print(json.dumps([r['키'] for d in files.values() if d.get('그룹') == '로직'" - " for r in d.get('줄') or []]))" - ) - return json.loads(_run(code)) - - -REASONS = [ # (알림에 든 말, 까닭) — 앞이 먼저 - ("표에 여럿 맞음", "표 범위 규칙 다름 — 여럿 맞음(이상·이하 · 이상·미만 경계)"), - ("표에 없는 조건", "표 범위 규칙 다름 — 없는 조건(이상·이하 · 이상·미만 경계)"), - ("검색어", "재료 검색어 틀 — 서버 검색어 찾기 대기(sub4)"), - ("같음", "조건 찾기 못 찾음(키 같음)"), - ("맞는 재료 없음", "재료 빈 검색어 — 서버 재료 값 찾기 다름"), - ("비어 있음", "단가 빈 품목 — 서버 처리 다름"), - ("값 없음", "단가 빈 품목 — 서버 처리 다름"), -] - - -def _reason(r: dict) -> str: - if r["state"] != "다름": - return r["state"] - moved = [n for n in r.get("notes") or [] if n.startswith("못 옮김")] - if moved: - return "못 옮김 — " + moved[0].split("—", 1)[1].strip().split("「")[0].strip() - text = " | ".join(r.get("errors") or []) - return next((why for word, why in REASONS if word in text), "값 다름(알림 없음)") - - -# ── 꼴 시험 ────────────────────────────────────────────────────────── -SHAPE = r""" -import json, os, sys -sys.path.insert(0, os.getcwd()) -from M01_MasterData import M01_MasterData_Store as store -from M02_MasterTemplete.DataFlow import M02_DF_Convert as cv -master = store.loaded()[1] -got = {k: cv.convert(master.logic(k)) for k in sys.argv[1:]} -print(json.dumps(got, ensure_ascii=False, default=str)) -""" - - -def _shape(*keys: str) -> dict: - got = subprocess.run( - [sys.executable, "-c", SHAPE, *keys], - capture_output=True, - text=True, - encoding="utf-8", - cwd=ROOT, - env=_env(), - timeout=600, - ) - assert got.returncode == 0, got.stderr[-2000:] - return json.loads(got.stdout) - - -def test_꼴_상자_선_규칙_배치() -> None: - _copy() - got = _shape("GC000994", "GC000267", "GF000003", "GC000264", "GF000185") - for key, one in got.items(): - flow = one["flow"] - ids = [b["id"] for b in flow["boxes"]] - assert len(ids) == len(set(ids)), key - assert all(ln["from"]["box"] in ids and ln["to"]["box"] in ids for ln in flow["links"]), key - spots = [(b["x"], b["y"]) for b in flow["boxes"]] - assert len(spots) == len(set(spots)), f"{key} 상자 겹침" - kinds = {b["kind"] for b in flow["boxes"]} - assert {"input", "function", "output"} <= kinds, key - outs = [b for b in flow["boxes"] if b["kind"] == "output"] - ins = [b for b in flow["boxes"] if b["kind"] == "input"] - assert max(b["x"] for b in ins) < min(b["x"] for b in outs), ( - f"{key} 입력 왼쪽 · 출력 오른쪽" - ) - for b in flow["boxes"]: - if b["kind"] == "function": - f = b["func"] - assert ( - f["output"]["cell"] in f["sheet"]["칸"] - and "식" in f["sheet"]["칸"][f["output"]["cell"]] - ) - # 돈 아닌 로직 = 출력 「결과」(값) · 반올림 → ROUND - res = got["GF000003"]["flow"] - assert [b["output"]["cost"] for b in res["boxes"] if b["kind"] == "output"] == ["값"] - assert any("ROUND(" in str(b.get("func")) for b in res["boxes"]) - # 표 찾기 = 표 알약 + 선 규칙 lookup · 로직 줄 = 다른 로직 입력(비목 셋) - one = got["GC000994"]["flow"] - assert any((ln.get("rule") or {}).get("kind") == "lookup" for ln in one["links"]) - logic = [b for b in one["boxes"] if (b.get("input") or {}).get("mode") == "logic"] - assert logic and logic[0]["input"]["logic"]["key"].startswith("GC") - assert {b["output"]["cost"] for b in one["boxes"] if b["kind"] == "output"} <= { - "노무비", - "재료비", - "경비", - } - # EQ:{기계} = 기계 요소 조건 찾기(field 키 · from = 입력 알약 「기계」) - els = [ - e - for b in got["GC000267"]["flow"]["boxes"] - for e in (b.get("input") or {}).get("elements", []) - ] - keyed = [e for e in els if e.get("mode") == "rule"] - assert keyed and all(e["rule"]["field"] == "키" and "{" in e["ref"] for e in keyed) - machine = [e for e in keyed if e["ref"].startswith("EQ:{기계}#")] - assert machine and {e["rule"]["from"]["name"] for e in machine} == {"기계"} - # 「MO:경유:{지역}」 = 원문번호 글을 잇는 함수(&) → 유가 조건 찾기 - assert any("&" in str(b.get("func")) for b in got["GC000267"]["flow"]["boxes"]) - # 레미콘 {a}-{b}-{c} = 재료 조건 찾기 field 검색어 · 틀 · 자리 셋 · 구분 범위(정함 · 선 없음) - remi = got["GC000264"]["flow"] - found = [e for b in remi["boxes"] for e in (b.get("input") or {}).get("elements", [])] - word = [e for e in found if (e.get("rule") or {}).get("field") == "검색어"] - assert word and word[0]["rule"]["template"] == "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}" - assert [x["name"] for x in word[0]["rule"]["slots"]] == ["굵은골재최대치수", "강도", "슬럼프"] - assert word[0]["scope"]["구분"] == "레미콘" - groups = {b["id"] for b in remi["boxes"] if (b.get("input") or {}).get("mode") == "group"} - assert not [ln for ln in remi["links"] if ln["to"]["box"] in groups] - # 덧줄 줄.'이름' · 비목 합 - assert got["GF000185"]["notes"] == [] or all( - not n.startswith("못 옮김") for n in got["GF000185"]["notes"] - ) - - -# ── 전수 대조 ──────────────────────────────────────────────────────── -def test_전수_대조_옛_셈_같음() -> None: - _copy() - all_keys = _keys() - assert len(all_keys) >= 1400 - keys = all_keys[::STEP] - chunks = [keys[i::WORKERS] for i in range(WORKERS)] - with ThreadPoolExecutor(WORKERS) as pool: - outs = list(pool.map(lambda c: _run(WORKER, json.dumps(c)), chunks)) - rows = [json.loads(x) for out in outs for x in out.splitlines() if x.startswith("{")] - assert len(rows) == len(keys) - # 일꾼이 동시에 Node 계산 묶음을 부르다 드물게 한 번 실패 — 멈춘 줄만 혼자 한 번 더(혼자서도 멈추면 진짜 흠) - again = [ - r["key"] - for r in rows - if "흐름 셈 멈춤" in (r["시험입력"]["state"], r["기본 입력"]["state"]) - ] - if again: - redo = {r["key"]: r for r in map(json.loads, _run(WORKER, json.dumps(again)).splitlines())} - rows = [redo.get(r["key"], r) for r in rows] - report: dict = {"로직": len(rows)} - for kind in ("시험입력", "기본 입력"): - states = [r[kind]["state"] for r in rows] - run = [s for s in states if s != "옛 셈 오류"] - reasons: dict[str, int] = {} - for r in rows: - if r[kind]["state"] not in ("맞음", "옛 셈 오류"): - why = _reason(r[kind]) - reasons[why] = reasons.get(why, 0) + 1 - report[kind] = { - "옛 셈 오류(대조 밖)": states.count("옛 셈 오류"), - "대조": len(run), - "맞음": run.count("맞음"), - "맞는 비율": round(run.count("맞음") / max(1, len(run)), 4), - "안 맞는 까닭": dict(sorted(reasons.items(), key=lambda x: -x[1])), - } - report["안 맞는 로직"] = [ - {"키": r["key"], **r["시험입력"]} for r in rows if r["시험입력"]["state"] not in ("맞음",) - ] - REPORT.write_text( - json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8" - ) - # 변환이 멈추는 로직은 없어야 함 - assert all(r["시험입력"]["state"] != "흐름 셈 멈춤" for r in rows), [ - r for r in rows if r["시험입력"]["state"] == "흐름 셈 멈춤" - ][:3] - assert report["시험입력"]["맞는 비율"] >= 0.5, report["시험입력"] - - -def test_변수_예시값_합계_끝수() -> None: - _copy() - got = _shape("GF000007", "GF000226", "GF000004") - for key, one in got.items(): - flow = one["flow"] - design = next(b for b in flow["boxes"] if b["id"] == "in-design") - assert all(v["example"] is not None for v in design["input"]["variables"]), key - assert flow["totalRound"] is None # 옛 계는 끝수 없음 - box = next(b for b in got["GF000226"]["flow"]["boxes"] if b["id"] == "in-design") - vars226 = {v["name"]: v["example"] for v in box["input"]["variables"]} - assert vars226["직경"] == 60 # 줄의 시험입력(글 「60.0」 → 수) - box = next(b for b in got["GF000004"]["flow"]["boxes"] if b["id"] == "in-design") - vars4 = {v["name"]: v["example"] for v in box["input"]["variables"]} - assert vars4["횡단경사"] == 55 # 왼쪽 시험 계산 기본값(master_auto) diff --git a/resources/tester/test_m02_df_diagram_parts.py b/resources/tester/test_m02_df_diagram_parts.py deleted file mode 100644 index 00e7301e8..000000000 --- a/resources/tester/test_m02_df_diagram_parts.py +++ /dev/null @@ -1,421 +0,0 @@ -"""단가산출 흐름도 다이어그램 순수 부분(`M02_DF_Diagram_Parts.ts`) — 기댐 선 · 흐름 길 포함 · 쉼 가르기 · 맞춤. - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(Model · Rule …)는 이어 트랜스파일 · -화면 부품(@ui)은 빈 흉내. 견본 로직은 이 시험 안에 둠. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Diagram_Parts.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); -const cache = {}; -function load(f) { - if (cache[f]) return cache[f].exports; - const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = (cache[f] = { exports: {} }); - const req = (id) => { - if (id.startsWith(".")) { - const p = path.resolve(path.dirname(f), id); - return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; - } - return stub; - }; - new Function("exports", "module", "require", js)(box.exports, box, req); - return box.exports; -} -const m = load(file); -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => { - if (job.op === "deps") return m.depLines(job.lg).map((d) => ({ id: d.id, from: d.from, to: d.to })); - if (job.op === "inTrace") return m.depLines(job.lg).map((d) => m.depInTrace(d, job.steps)); - if (job.op === "rest") return job.breaks.map((b) => m.isRest(b)); - if (job.op === "fit") return m.fitView(job.rects, job.view, 40, job.min); - if (job.op === "value") return job.values.map((v) => m.showValue(v)); - if (job.op === "end") return m.depEnd(job.a, job.tag); - if (job.op === "restParts") { - const r = m.restParts(job.lg, job.rests, job.values ?? {}); - return { links: [...r.links].sort(), pills: [...r.pills].sort(), boxes: [...r.boxes].sort() }; - } - if (job.op === "path") return m.depPath(job.a, job.b, job.side); - return null; -}); -process.stdout.write(JSON.stringify(out)); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -def port(box, name): - return {"box": box, "name": name} - - -# 견본 — 관경 → 흄관 재료(조건 찾기) · 지반 → 조건 선 · 뒷길이 → 표 조건 -LOGIC = { - "key": None, - "name": "견본", - "resultUnit": "원/m", - "userElements": [], - "boxes": [ - { - "id": "v", - "kind": "input", - "name": "설계 값", - "color": "", - "unit": "-", - "x": 0, - "y": 0, - "input": { - "mode": "variable", - "elements": [], - "variables": [ - {"name": "관경", "type": "choice", "unit": "㎜", "example": 600}, - {"name": "지반", "type": "choice", "unit": "-", "example": "암"}, - {"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30}, - ], - }, - }, - { - "id": "m", - "kind": "input", - "name": "재료", - "color": "", - "unit": "-", - "x": 0, - "y": 200, - "input": { - "mode": "group", - "group": "재료", - "variables": [], - "elements": [ - { - "ref": "", - "name": "흄관", - "spec": "", - "unit": "본", - "mode": "rule", - "slot": "흄관", - "rule": {"field": "규격", "op": "포함", "from": port("v", "관경")}, - }, - {"ref": "MT1", "name": "모래", "spec": "", "unit": "㎥", "mode": "fixed"}, - ], - }, - }, - { - "id": "t", - "kind": "input", - "name": "품 표", - "color": "", - "unit": "-", - "x": 0, - "y": 400, - "input": { - "mode": "group", - "group": "소요량", - "elements": [], - "variables": [], - "table": {"key": "QF1"}, - }, - }, - { - "id": "f", - "kind": "function", - "name": "셈", - "color": "", - "unit": "-", - "x": 300, - "y": 100, - "func": { - "sheet": {}, - "inputs": [{"port": "품", "cell": "B1"}, {"port": "흄관", "cell": "B2"}], - "variables": [], - "output": {"cell": "B9", "name": "금액", "unit": "원"}, - }, - }, - ], - "links": [ - { - "id": "l1", - "from": port("t", "표"), - "to": port("f", "품"), - "rule": { - "kind": "lookup", - "conds": [{"col": "뒷길이", "from": port("v", "뒷길이")}], - "col": "석공", - }, - }, - { - "id": "l2", - "from": port("m", "흄관"), - "to": port("f", "흄관"), - "rule": {"kind": "gate", "from": port("v", "지반"), "op": "=", "value": "암"}, - }, - {"id": "l3", "from": port("v", "관경"), "to": port("f", "흄관")}, - ], -} - - -def test_기댐_선_셋(): - [deps] = run([{"op": "deps", "lg": LOGIC}]) - got = sorted((d["from"]["name"], json.dumps(d["to"], ensure_ascii=False)) for d in deps) - assert got == sorted( - [ - ("뒷길이", json.dumps({"link": "l1"})), - ("지반", json.dumps({"link": "l2"})), - ("관경", json.dumps({"box": "m", "port": "흄관"}, ensure_ascii=False)), - ] - ) - - -def test_기댐_선이_흐름_길에_듦(): - steps = [ - {"box": "v", "port": "지반", "dir": "out"}, - {"box": "f", "port": "흄관", "dir": "in", "link": "l2"}, - {"box": "m", "port": "흄관", "dir": "out"}, - ] - [marks] = run([{"op": "inTrace", "lg": LOGIC, "steps": steps}]) - # 차례: l1 표 조건(뒷길이) · l2 조건(지반) · 재료 조건 찾기(관경) - assert marks == [False, True, False] - - -def test_쉼_가르기(): - [rest] = run( - [ - { - "op": "rest", - "breaks": [ - { - "kind": "통과 안 됨", - "level": "쉼", - "box": "f", - "link": "l2", - "text": "지반이 암이 아님", - }, - { - "kind": "빈 받는 자리", - "level": "문제", - "box": "f", - "port": "품", - "text": "비어 있음", - }, - {"kind": "통과 안 됨", "level": "문제", "box": "f", "text": "level 이 정함"}, - {"kind": "조건 겹침", "level": "쉼", "box": "f", "text": "level 이 정함"}, - ], - } - ] - ) - assert rest == [True, False, False, True] - - -def test_한눈에_90_아래로_안_내림과_알약_값(): - fit, values = run( - [ - { - "op": "fit", - "rects": [{"x": 0, "y": 0, "w": 3000, "h": 200}], - "view": {"w": 1000, "h": 600}, - }, - { - "op": "value", - "values": [ - 97882.512, - 268908, - 0.3640, - "깬돌", - [["뒷길이", "석공"], [25, 0.15], [30, 0.22]], - ], - }, - ] - ) - assert fit["zoom"] == 0.9 - assert values == ["97,882.512", "268,908", "0.364", "깬돌", "· 2줄"] - - -def test_기댐_선_끝이_알약과_이름표에_붙음(): - left, right, path_left, path_right = run( - [ - {"op": "end", "a": {"x": 100, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}}, - {"op": "end", "a": {"x": 600, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}}, - {"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 358, "y": 120}, "side": "left"}, - {"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 130, "y": 300}, "side": "right"}, - ] - ) - # 시작이 왼쪽이면 이름표 왼쪽 가장자리(400 − 40 − 2) · 오른쪽이면 오른쪽 가장자리 - assert left == {"x": 358, "y": 120, "side": "left"} - assert right == {"x": 442, "y": 120, "side": "right"} - # 길은 시작 알약에서 나가 끝에서 끝남 · 재료 알약은 오른쪽에서 들어감(끝 손잡이가 끝보다 오른쪽) - assert path_left.startswith("M100,50 ") and path_left.endswith(" 358,120") - assert path_right.startswith("M100,50 ") and path_right.endswith(" 130,300") - handle = float(path_right.split(" ")[2].split(",")[0]) - assert handle > 130 - - -def test_쉼은_일부면_선과_받는_알약만_모두면_상자도(): - two_in = { - **LOGIC, - "links": [ - {"id": "a", "from": port("v", "지반"), "to": port("f", "품")}, - {"id": "b", "from": port("m", "흄관"), "to": port("f", "흄관")}, - ], - } - one_rest = [ - {"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "흄관", "link": "b", "text": "쉼"} - ] - all_rest = one_rest + [ - {"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "품", "link": "a", "text": "쉼"} - ] - box_rest = [{"kind": "통과 안 됨", "level": "쉼", "box": "m", "text": "쉼"}] - part, every, filled, empty = run( - [ - {"op": "restParts", "lg": two_in, "rests": one_rest, "values": {"f": {"금액": 2000}}}, - {"op": "restParts", "lg": two_in, "rests": all_rest, "values": {"f": {"금액": None}}}, - {"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": 52000}}}, - {"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": None}}}, - ] - ) - # 하나만 쉼 — 셈하는 상자는 안 흐림 · 그 선과 받는 알약만 - assert part == {"links": ["b"], "pills": ["f|흄관"], "boxes": []} - # 들어오는 선이 모두 쉼 — 상자도 흐림 - assert every["boxes"] == ["f"] - # 상자에 걸린 쉼 — 내보내는 값이 있으면 안 흐림 · 모두 빈 값이면 흐림 - assert filled["boxes"] == [] and empty["boxes"] == ["m"] - - -def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나(): - def fn(id_, name, ins, out): - return { - "id": id_, - "kind": "function", - "name": name, - "color": "", - "unit": "-", - "x": 0, - "y": 0, - "func": { - "sheet": {}, - "inputs": [{"port": p, "cell": f"B{i + 1}"} for i, p in enumerate(ins)], - "variables": [], - "output": {"cell": "B9", "name": out, "unit": "원"}, - }, - } - - gate = {"kind": "gate", "from": port("ig", "지반"), "op": "=", "value": "암"} - ground = { - "key": None, - "name": "지반", - "resultUnit": "원/㎥", - "userElements": [], - "boxes": [ - { - "id": "ig", - "kind": "input", - "name": "지반 · 굴착량", - "color": "", - "unit": "-", - "x": 0, - "y": 0, - "input": { - "mode": "variable", - "elements": [], - "variables": [ - {"name": "지반", "type": "choice", "unit": "-", "example": "토사"}, - {"name": "굴착량", "type": "number", "unit": "㎥", "example": 1}, - ], - }, - }, - fn("exc", "굴삭기로 파기", ["굴착량", "시간당 경비"], "굴삭기 경비"), - fn("brk", "브레이커로 깨기", ["굴착량", "시간당 경비"], "브레이커 경비"), - fn("sum", "1㎥ 경비", ["굴삭기 경비", "브레이커 경비", "굴착량"], "1㎥ 경비"), - { - "id": "out", - "kind": "output", - "name": "경비", - "color": "", - "unit": "원", - "x": 0, - "y": 0, - "output": {"cost": "경비", "unit": "원"}, - }, - ], - "links": [ - { - "id": "l1", - "from": port("ig", "굴착량"), - "to": port("exc", "굴착량"), - "rule": {**gate, "op": "≠"}, - }, - {"id": "l2", "from": port("ig", "굴착량"), "to": port("brk", "굴착량"), "rule": gate}, - {"id": "l5", "from": port("exc", "굴삭기 경비"), "to": port("sum", "굴삭기 경비")}, - {"id": "l6", "from": port("brk", "브레이커 경비"), "to": port("sum", "브레이커 경비")}, - {"id": "l7", "from": port("ig", "굴착량"), "to": port("sum", "굴착량")}, - {"id": "l8", "from": port("sum", "1㎥ 경비"), "to": port("out", "값")}, - ], - } - # Eval 쉼 줄은 link 가 늘 있음 — 조건 선 l2(받는 상자 브레이커) · 빈 값이 흘러간 l6(받는 상자 1㎥ 경비) - rests = [ - { - "kind": "통과 안 됨", - "level": "쉼", - "box": "brk", - "port": "굴착량", - "link": "l2", - "text": "암이 아님", - }, - { - "kind": "통과 안 됨", - "level": "쉼", - "box": "sum", - "port": "브레이커 경비", - "link": "l6", - "text": "쉼", - }, - ] - values = { - "brk": {"시간당 경비": 80000, "굴착량": None, "브레이커 경비": None}, - "sum": {"굴삭기 경비": 2000, "브레이커 경비": None, "굴착량": 1, "1㎥ 경비": 2000}, - "out": {"값": 2000}, - } - [got] = run([{"op": "restParts", "lg": ground, "rests": rests, "values": values}]) - assert got["boxes"] == ["brk"] - assert got["links"] == ["l2", "l6"] - assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"] - - -def test_패널_처음_배율은_읽히는_최소_넘치면_왼쪽_위부터(): - rects = [{"x": 40, "y": 40, "w": 200, "h": 120}, {"x": 1500, "y": 600, "w": 200, "h": 120}] - auto, whole = run( - [ - {"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.6}, - {"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.25}, - ] - ) - # 처음 — 0.6 아래로 안 내림 · 넘치면 왼쪽 위(첫 상자 - 여백)가 판 왼쪽 위 - assert auto["zoom"] == 0.6 - assert auto["panX"] == 0 and auto["panY"] == 0 - # 한눈에 — 전체가 들어가게 더 작게 - assert whole["zoom"] < 0.6 diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py deleted file mode 100644 index e262efa20..000000000 --- a/resources/tester/test_m02_df_formula_view.py +++ /dev/null @@ -1,209 +0,0 @@ -"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`). - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로. -견본 함수 상자는 이 시험 안에 둠. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts" -TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, viewFile, textFile] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); -const cache = {}; -function load(f) { - if (cache[f]) return cache[f].exports; - const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = (cache[f] = { exports: {} }); - const req = (id) => { - if (id.startsWith(".") || id.startsWith("@ui/sheet/")) { - const p = id.startsWith("@ui/sheet/") - ? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length)) - : path.resolve(path.dirname(f), id); - return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; - } - return stub; - }; - new Function("exports", "module", "require", js)(box.exports, box, req); - return box.exports; -} -const v = load(viewFile); -const t = load(textFile); -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => { - if (job.op === "lines") { - const values = v.cellValuesOf(job.box, job.arrived); - return { values, lines: v.formulaLines(job.box, values) }; - } - if (job.op === "toFormula") return t.draftToFormula(job.draft); - if (job.op === "toDraft") return t.formulaToDraft(job.formula); - return null; -}); -process.stdout.write(JSON.stringify(out)); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(VIEW), str(TEXT)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict: - return { - "id": "f1", - "kind": "function", - "name": "석공 품", - "color": "", - "unit": "인", - "x": 0, - "y": 0, - "func": { - "sheet": {"id": "s1", "이름": "석공 품", "칸": cells}, - "inputs": inputs, - "variables": variables, - "output": {"cell": out_cell, "name": out_name, "unit": "인"}, - }, - } - - -def var(name: str, cell: str, example) -> dict: - return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell} - - -# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100) -PLAIN = func_box( - { - "A1": {"값": "석공량"}, - "B3": {"식": "B1*(1+B2/100)"}, - }, - [{"port": "석공량", "cell": "B1"}], - [var("높이", "B2", 4)], - "B3", - "석공 인", -) - - -def test_이름_식과_값_식(): - (got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}]) - assert got["values"]["B3"] == pytest.approx(0.156) - (line,) = got["lines"] - assert line["name"] == "석공 인" - assert line["named"] == "석공량 × (1 + 높이 ÷ 100)" - assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" - assert "branches" not in line - - -def test_중간_칸이_먼저_결과_칸이_마지막(): - box = func_box( - {"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}}, - [{"port": "석공량", "cell": "B1"}], - [var("높이", "B2", 4)], - "B4", - "합", - ) - (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}]) - assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로 - assert got["lines"][1]["named"] == "B3 + 1" - assert got["lines"][1]["valued"] == "8 + 1 = 9" - - -@pytest.mark.parametrize( - ("ground", "now"), - [("토사", [True, False]), ("암", [False, True])], -) -def test_IF_줄과_지금_갈래(ground, now): - box = func_box( - {"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}}, - [{"port": "석공량", "cell": "B1"}], - [{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}], - "B3", - "결과", - ) - (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}]) - (line,) = got["lines"] - assert [b["now"] for b in line["branches"]] == now - assert line["branches"][0]["when"] == '지반 = "토사"' - assert line["branches"][0]["then"] == "석공량 × 2" - assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]} - assert line["valued"].endswith("= 20" if ground == "토사" else "= 30") - - -def test_조건_만들기_식과_다시_줄(): - draft = { - "branches": [ - { - "left": "B1", - "op": "=", - "right": {"kind": "value", "value": "토사"}, - "then": {"kind": "cell", "cell": "C1"}, - }, - { - "left": "B1", - "op": "≥", - "right": {"kind": "value", "value": 3}, - "then": {"kind": "value", "value": 0.5}, - }, - ], - "otherwise": {"kind": "value", "value": 0}, - } - (formula,) = run([{"op": "toFormula", "draft": draft}]) - assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))' - (back,) = run([{"op": "toDraft", "formula": formula}]) - assert back == draft - - -@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"]) -def test_못_바꾸는_식은_null(formula): - (back,) = run([{"op": "toDraft", "formula": formula}]) - assert back is None - - -def test_받는_칸은_선_규칙을_지난_값으로_그림(): - # 시트에 남은 옛 값(99)이 아니라 규칙을 지나 들어온 값(arrived 0.15)으로 값 식을 그림 - box = func_box( - {"B1": {"값": 99}, "B3": {"식": "B1*(1+B2/100)"}}, - [{"port": "석공량", "cell": "B1"}], - [var("높이", "B2", 4)], - "B3", - "석공 인", - ) - (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 0.15}}]) - assert got["lines"][0]["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" - (stale,) = run([{"op": "lines", "box": box, "arrived": {}}]) - assert stale["lines"][0]["valued"].startswith("99 ×") # 들어온 값이 없을 때만 칸 값 - - -def test_오류_글은_따옴표_없이_값_없음(): - box = func_box( - {"B1": {"식": "NA()"}, "B3": {"식": "B1+1"}}, - [], - [], - "B3", - "합", - ) - (got,) = run([{"op": "lines", "box": box, "arrived": {}}]) - valued = [x["valued"] for x in got["lines"]] - assert all('"#' not in v for v in valued) - assert valued[-1] == "값 없음 + 1 = 값 없음" diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py deleted file mode 100644 index e0c659a65..000000000 --- a/resources/tester/test_m02_df_input_logic.py +++ /dev/null @@ -1,311 +0,0 @@ -"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`). - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음). -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const load = (src, req) => { - const js = ts.transpileModule(fs.readFileSync(src, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, req); - return box.exports; -}; -// 로직 찾기는 공용 맞춤(208-1) — 그것만 실제로 싣고 나머지 import 는 빈 것 -const find = load(path.join(root, "ui_template", "ui_template_logic_find.ts"), () => ({})); -const m = load(file, (p) => (p.endsWith("ui_template_logic_find") ? find : {})); -(async () => { - const jobs = JSON.parse(fs.readFileSync(0, "utf8")); - const out = []; - for (const j of jobs) { - if (j.name === "wouldCycle") { - const graph = j.args[2]; - out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null)); - } else if (j.name === "fillTemplate") { - const vals = j.args[2]; - out.push(m.fillTemplate(j.args[0], j.args[1], (p) => vals[`${p.box}.${p.name}`])); - } else out.push(await m[j.name](...j.args)); - } - process.stdout.write(JSON.stringify(out)); -})(); -""" - - -def call(name: str, *args): - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout)[0] - - -def row(key, name, book, chapter, no="", unit="원/㎡", **more): - return { - "file": f"로직_{book}.json", - "구분": book, - "상세구분": chapter, - "키": key, - "원문번호": no, - "이름": name, - "결과단위": unit, - **more, - } - - -ITEMS = [ - row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"), - row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"), - row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"), - row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"), - row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"), -] - - -def keys(items): - return [i["키"] for i in items] - - -def test_찾기는_띄어쓰기로_나눈_말을_모두_포함(): - got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"}) - assert keys(got) == ["GF000219"] - assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"] - assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [ - "GF000219" - ] # 띄어쓰기는 가리지 않음 - assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [ - "GF000219", - "GF000220", - ] # 원문번호 - assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [ - "GC000051" - ] # 번호 · 대소문자 - assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS - - -def test_원문_장으로_거름(): - assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"] - got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"}) - assert keys(got) == ["GF000025"] - assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == [] - - -def test_원문_장_목록은_나온_차례(): - assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"] - assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"] - assert call("logicChapters", ITEMS, "") == [ - "13장 구조물", - "05장 녹화", - "01장 공통", - "내가 만든 것", - ] - - -def box(id_, kind, name, **more): - return { - "id": id_, - "kind": kind, - "name": name, - "color": "#fff", - "unit": "-", - "x": 0, - "y": 0, - **more, - } - - -FLOW = { - "key": "GF000219", - "name": "돌쌓기 메쌓기(인력)", - "resultUnit": "원/㎡", - "userElements": [], - "links": [], - "boxes": [ - box( - "b1", - "input", - "입력", - input={ - "mode": "variable", - "elements": [], - "variables": [ - {"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30}, - { - "name": "돌", - "type": "choice", - "unit": "", - "choices": ["깬돌", "견치돌"], - "example": "깬돌", - }, - ], - }, - ), - box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}), - ], -} - - -def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것(): - ref = call("masterRef", "GF000219", FLOW, False, "원/㎡") - assert ref["key"] == "GF000219" - assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}] - assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}] - assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"} - - -def test_옛_로직_변환은_노무비_재료비_경비_계_넷(): - ref = call("masterRef", "GF000219", FLOW, True, "원/㎡") - assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] - assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"} - assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로 - - -def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것(): - inner = { - "mode": "logic", - "elements": [], - "variables": [], - "logic": { - "key": "GF000250", - "outputs": [{"name": "작업량", "unit": "㎥/시간"}], - "designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}], - "examples": {"지역": "서울"}, - }, - } - flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]} - flow["links"] = [ - {"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}} - ] - ref = call("logicRefOf", "X", flow) - names = [d["name"] for d in ref["designs"]] - assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌 - assert ref["examples"]["지역"] == "서울" - - -def test_자기_자신과_돌고_도는_로직은_막음(): - graph = { - "A": { - "boxes": [ - box( - "b", - "input", - "x", - input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}}, - ) - ] - }, - "B": { - "boxes": [ - box( - "b", - "input", - "x", - input={ - "mode": "logic", - "elements": [], - "variables": [], - "logic": {"key": "SELF"}, - }, - ) - ] - }, - "C": {"boxes": []}, - } - assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신 - assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF - assert call("wouldCycle", "SELF", "C", graph) is False - assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 - assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 - - -def test_조건_찾기는_인력_기계_유가전력에_쓰고_이름_또는_키가_같은_줄(): - labor = {"키": "LB000033", "원문번호": "1033", "이름": "석공"} - assert call("matchesRow", labor, "이름", "석공") is True - assert call("matchesRow", labor, "이름", "석 공") is True # 띄어쓰기는 가리지 않음 - assert call("matchesRow", labor, "이름", "할석공") is False # 같음이라 일부만 맞아선 안 잡음 - assert call("matchesRow", labor, "키", "lb000033") is True - assert call("matchesRow", labor, "키", "1033") is True # 원문번호도 키로 침 - assert call("matchesRow", labor, "이름", "") is False # 알약 값이 비면 안 잡음 - - -def test_조건_찾기_요소와_검사(): - port = {"box": "b2", "name": "직종"} - el = call("groupRuleElement", "노임", "이름", port) - assert ( - el["mode"] == "rule" and el["ref"] == "" and el["name"] == "노임" and el["slot"] == "노임" - ) - assert el["rule"] == {"field": "이름", "op": "같음", "from": port} - assert call("checkGroupRule", el, []) is None - assert call("checkGroupRule", {**el, "name": ""}, []) == "자리 이름을 적어 주세요" - assert call("checkGroupRule", el, [el]) == "같은 이름의 알약이 이미 있음" - no_pill = call("groupRuleElement", "노임", "이름", {"box": "", "name": ""}) - assert "변수 알약" in call("checkGroupRule", no_pill, []) - - -def test_옛_줄_입력과_자동값으로_다른_로직_입력을_읽음(): - inputs = [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "돌"}, {"이름": "쌓기"}] - auto = {"뒷길이": 25, "돌": "깬잡석", "쌓기": "", "남는": 1} - ref = call("rowRef", "GF000219", "돌쌓기 메쌓기(인력)", "원/㎡", inputs, auto) - assert ref["key"] == "GF000219" - assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] - assert ref["designs"] == [ - {"name": "뒷길이", "unit": "㎝"}, - {"name": "돌", "unit": "-"}, - {"name": "쌓기", "unit": "-"}, - ] - assert ref["examples"] == {"뒷길이": 25, "돌": "깬잡석"} # 빈 글 · 입력에 없는 이름은 뺌 - - -def test_검색어_틀_자리와_채움(): - tpl = "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}" - assert call("templateSlots", tpl) == ["굵은골재최대치수", "강도", "슬럼프"] - assert call("templateSlots", "{a} {a} {b}") == ["a", "b"] # 겹친 자리는 한 번 - slots = [ - {"name": "굵은골재최대치수", "from": {"box": "b2", "name": "굵은"}}, - {"name": "강도", "from": {"box": "b2", "name": "강도"}}, - {"name": "슬럼프", "from": {"box": "b2", "name": "슬럼프"}}, - ] - values = {"b2.굵은": 25, "b2.강도": 18, "b2.슬럼프": 120} - got = call("fillTemplate", tpl, slots, values) - assert got == {"text": "레미콘 25-18-120", "missing": []} - part = call("fillTemplate", tpl, slots, {"b2.굵은": 25, "b2.강도": ""}) - assert part["missing"] == ["강도", "슬럼프"] - assert part["text"] == "레미콘 25-{강도}-{슬럼프}" # 빈 자리는 그대로 둠 - - -def test_검색어_틀_규칙과_검사(): - slots = [{"name": "a", "from": {"box": "b2", "name": "굵은"}}] - rule = call("templateRule", "레미콘 {a}", slots) - assert ( - rule["field"] == "검색어" - and rule["template"] == "레미콘 {a}" - and rule["from"] == slots[0]["from"] - ) - el = call("materialElement", "rule", "콘크리트", {"구분": "레미콘"}, {"rule": rule}) - assert call("checkMaterial", el, []) is None - no_slot = call("templateRule", "레미콘", []) - bad = call("materialElement", "rule", "콘크리트", {"구분": "레미콘"}, {"rule": no_slot}) - assert "자리" in call("checkMaterial", bad, []) - short = call("templateRule", "레미콘 {a}-{b}", slots) # 자리 둘인데 알약은 하나 - two = call("materialElement", "rule", "콘크리트", {}, {"rule": short}) - assert "알약" in call("checkMaterial", two, []) diff --git a/resources/tester/test_m02_df_inputpanel_build.py b/resources/tester/test_m02_df_inputpanel_build.py deleted file mode 100644 index dbfe71c2d..000000000 --- a/resources/tester/test_m02_df_inputpanel_build.py +++ /dev/null @@ -1,251 +0,0 @@ -"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`M02_DF_InputPanel_Build.ts`). - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 줄 · 상자는 이 시험 안에 둠. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => ({})); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((j) => { - if (j.op === "fn") return m[j.name](...j.args); - let lg = j.lg; - for (const [fn, ...args] of j.steps || []) lg = m[fn](lg, ...args); - return lg; -}); -process.stdout.write(JSON.stringify(out)); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -def logic() -> dict: - plain = {"color": "#fff", "unit": "-", "x": 0, "y": 0} - return { - "key": None, - "name": "견본", - "resultUnit": "원/㎡", - "userElements": [], - "boxes": [ - { - "id": "b1", - "kind": "input", - "name": "입력", - **plain, - "input": {"mode": "variable", "elements": [], "variables": []}, - }, - {"id": "b2", "kind": "function", "name": "함수", **plain}, - ], - "links": [], - } - - -def call(name: str, *args): - return run([{"op": "fn", "name": name, "args": list(args)}])[0] - - -def edit(steps: list, lg: dict | None = None) -> dict: - return run([{"op": "lg", "lg": lg or logic(), "steps": steps}])[0] - - -LABOR = {"키": "LB000033", "이름": "석공", "규격": "", "단위": "원/일", "값": 268908} -LABOR2 = {"키": "LB000002", "이름": "보통인부", "규격": "", "단위": "원/일", "값": 172068} -MAT = { - "키": "MT1", - "이름": "각형흄관", - "규격": "D=300mm", - "단위": "M", - "물가자료": 30000, - "물가정보": 29600, -} -MACHINE = { - "키": "EQ000001", - "이름": "불도저", - "규격": "7ton", - "단위": {"가격": "천원", "손료계수": "10^-7/시간"}, - "값": {"가격": 76547, "손료계수": 1811}, -} -RATE_ONE = {"키": "RT1", "이름": "산재보험료", "값": {"rate_percent": 3.56}} -RATE_BRACKET = {"키": "RT2", "이름": "고용보험료", "값": {"brackets": []}} - - -def test_요소는_이름과_규격으로_알약_이름이_됨(): - labor = call("elementOf", "인력", LABOR) - assert labor == {"ref": "LB000033", "name": "석공", "spec": "", "unit": "원/일"} - got = call("elementOf", "재료", MAT) - assert got["name"] == "각형흄관 D=300mm" and got["unit"] == "M" - - -def test_기계는_값_칸을_골라_요소가_됨(): - assert call("machineFields", MACHINE) == ["가격", "손료계수"] - got = call("elementOf", "기계", MACHINE, "손료계수") - assert got["ref"] == "EQ000001#손료계수" and got["unit"] == "10^-7/시간" - assert got["name"].endswith("· 손료계수") - assert call("queryOf", "기계", "EQ000001#손료계수") == { - "group": "기계", - "ref": "EQ000001", - "field": "손료계수", - } - - -def test_값_읽기_재료는_낮은_값_요율은_비율(): - assert call("valueOf", "재료", MAT) == 29600 - assert call("valueOf", "요율", RATE_ONE) == 3.56 - assert call("valueOf", "요율", RATE_BRACKET) is None - assert call("elementOf", "요율", RATE_ONE)["unit"] == "%" - assert call("elementOf", "요율", RATE_BRACKET)["unit"] == "-" - assert call("valueOf", "기계", MACHINE, "가격") == 76547 - - -def test_값_물음은_그룹과_ref(): - assert call("queryOf", "인력", "LB000033") == {"group": "인력", "ref": "LB000033"} - assert call("queryOf", "재료", "MT1") == {"group": "재료", "ref": "MT1"} - - -def test_상자_단위는_같으면_그_단위_여럿이면_하이픈(): - one = edit([["setKind", "b1", "인력"], ["addElement", "b1", call("elementOf", "인력", LABOR)]]) - assert one["boxes"][0]["unit"] == "원/일" - two = edit([["addElement", "b1", call("elementOf", "인력", LABOR2)]], one) - assert two["boxes"][0]["unit"] == "원/일" and len(two["boxes"][0]["input"]["elements"]) == 2 - oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} - assert edit([["addElement", "b1", oil]], two)["boxes"][0]["unit"] == "-" - - -def test_같은_요소_이름은_한_번만(): - el = call("elementOf", "인력", LABOR) - lg = edit( - [ - ["setKind", "b1", "인력"], - ["addElement", "b1", el], - ["addElement", "b1", el], - ["addElement", "b1", {**el, "ref": "다른"}], - ] - ) - assert len(lg["boxes"][0]["input"]["elements"]) == 1 - - -def test_요소를_빼면_그_알약에서_나간_선도_지움(): - el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) - base = edit([["setKind", "b1", "인력"], ["addElement", "b1", el1], ["addElement", "b1", el2]]) - base["links"] = [ - {"id": "l1", "from": {"box": "b1", "name": "석공"}, "to": {"box": "b2", "name": "a"}}, - {"id": "l2", "from": {"box": "b1", "name": "보통인부"}, "to": {"box": "b2", "name": "b"}}, - ] - out = edit([["removeElement", "b1", "LB000033"]], base) - assert [x["id"] for x in out["links"]] == ["l2"] - assert [e["name"] for e in out["boxes"][0]["input"]["elements"]] == ["보통인부"] - - -def test_갈래를_바꾸면_고른_것을_비움_표는_요소와_함께_못_둠(): - el = call("elementOf", "인력", LABOR) - lg = edit([["setKind", "b1", "인력"], ["addElement", "b1", el], ["setKind", "b1", "재료"]]) - assert lg["boxes"][0]["input"]["group"] == "재료" - assert lg["boxes"][0]["input"]["elements"] == [] - t = edit([["setKind", "b1", "소요량"], ["setTable", "b1", {"key": "QF000421"}, "메쌓기(인력)"]]) - assert t["boxes"][0]["input"]["table"] == {"key": "QF000421"} - assert t["boxes"][0]["name"] == "메쌓기(인력)" and t["boxes"][0]["unit"] == "-" - row = edit([["setTable", "b1", {"key": "QF000421", "row": 3}]], t) - assert row["boxes"][0]["input"]["table"] == {"key": "QF000421", "row": 3} - assert call("pillNames", row["boxes"][0]) == ["표"] - - -def test_변수_넣기_고치기_삭제_알약_이름(): - v = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} - lg = edit([["setKind", "b1", "변수"], ["putVariable", "b1", v]]) - box = lg["boxes"][0] - assert box["input"]["mode"] == "variable" and box["unit"] == "㎡" - assert call("pillNames", box) == ["면적"] - renamed = edit([["putVariable", "b1", {**v, "name": "연장", "unit": "m"}, "면적"]], lg) - assert call("pillNames", renamed["boxes"][0]) == ["연장"] and renamed["boxes"][0]["unit"] == "m" - gone = edit([["removeVariable", "b1", "연장"]], renamed) - assert gone["boxes"][0]["input"]["variables"] == [] and gone["boxes"][0]["unit"] == "-" - - -def test_변수_검사는_쉬운_말(): - ok = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} - assert call("checkVariable", ok, []) is None - assert call("checkVariable", {**ok, "name": " "}, []) == "이름을 적어 주세요" - assert call("checkVariable", ok, [ok]) == "같은 이름의 변수가 이미 있음" - assert call("checkVariable", {**ok, "range": [9, 1]}, []) == "범위는 아래가 위보다 작아야 함" - assert call("checkVariable", {**ok, "example": 5000}, []) == "예시값이 범위 밖" - many = {**ok, "type": "choice", "choices": ["가"]} - assert call("checkVariable", many, []) == "선택 목록은 둘 이상" - bad = {**ok, "type": "choice", "choices": ["가", "나"], "example": "다"} - assert call("checkVariable", bad, []) == "예시값이 선택 목록에 없음" - - -def test_환율_유가전력_알약_이름에_날짜가_안_들어감(): - fx = {"키": "FX1", "이름": "USD", "규격": "2026-08-14", "단위": "원/USD", "값": 1414.9} - assert call("elementOf", "환율", fx)["name"] == "USD" - oil = { - "키": "MO1", - "이름": "휘발유", - "규격": "2026-08-14", - "상세구분": "서울", - "단위": "원/L", - "값": 1900, - } - assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울" - - -def test_단위가_안_맞게_저장된_상자는_열_때_맞춤(): - el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) - lg = logic() - lg["boxes"][0]["input"] = { - "mode": "group", - "group": "인력", - "elements": [el1, el2], - "variables": [], - } - assert lg["boxes"][0]["unit"] == "-" - fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0] - assert fixed["boxes"][0]["unit"] == "원/일" - again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0] - assert again == fixed - oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} - lg["boxes"][0]["input"]["elements"].append(oil) - lg["boxes"][0]["unit"] = "원/일" - assert ( - run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] - == "-" - ) - one = { - "mode": "variable", - "elements": [], - "variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}], - } - lg["boxes"][0]["input"] = one - assert ( - run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] - == "㎡" - ) diff --git a/resources/tester/test_m02_df_inputpanel_material.py b/resources/tester/test_m02_df_inputpanel_material.py deleted file mode 100644 index 7f5b2657b..000000000 --- a/resources/tester/test_m02_df_inputpanel_material.py +++ /dev/null @@ -1,249 +0,0 @@ -"""단가산출 흐름도 입력 판 — 재료 요소 방식 셋 · 쪽 넘기기 · 다른 로직 입력(`M02_DF_InputPanel_Build.ts`). - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 상자 · 로직은 이 시험 안에 둠. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => ({})); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(jobs.map((j) => m[j.name](...j.args)))); -""" - - -def call(name: str, *args): - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout)[0] - - -PLAIN = {"color": "#fff", "unit": "-", "x": 0, "y": 0} - - -def logic() -> dict: - return { - "key": None, - "name": "견본", - "resultUnit": "원/㎡", - "userElements": [], - "boxes": [ - { - "id": "b1", - "kind": "input", - "name": "입력", - **PLAIN, - "input": {"mode": "variable", "elements": [], "variables": []}, - }, - { - "id": "b2", - "kind": "input", - "name": "관경", - **PLAIN, - "input": { - "mode": "variable", - "elements": [], - "variables": [{"name": "관경", "type": "text", "unit": "", "example": "300"}], - }, - }, - ], - "links": [], - } - - -HUME = {"name": "각형흄관", "spec": "D=300mm"} - - -def test_규칙이_품목을_잡음_규격_이름_포함_같음(): - in_spec = {"field": "규격", "op": "포함"} - assert call("matches", HUME, in_spec, "300") is True - assert call("matches", HUME, in_spec, "400") is False - assert call("matches", HUME, {"field": "이름", "op": "포함"}, "흄관") is True - assert call("matches", HUME, {"field": "이름", "op": "같음"}, "흄관") is False - assert ( - call("matches", HUME, {"field": "이름", "op": "같음"}, "각형 흄관") is True - ) # 띄어쓰기는 가리지 않음 - assert call("matches", HUME, in_spec, "") is False # 알약 값이 비면 안 잡음 - - -def test_잡힌_수에_따른_알림(): - assert call("catchNote", 0)["kind"] == "none" - one = call("catchNote", 1, "각형흄관 D=300mm") - assert one["kind"] == "one" and "각형흄관" in one["text"] - many = call("catchNote", 3) - assert many["kind"] == "many" and "3 개" in many["text"] - - -def test_쪽_넘기기_범위(): - assert call("pageRange", 25, 1, 12) == {"from": 1, "to": 12, "pages": 3} - assert call("pageRange", 25, 3, 12) == {"from": 25, "to": 25, "pages": 3} - assert call("pageRange", 25, 9, 12) == { - "from": 25, - "to": 25, - "pages": 3, - } # 쪽이 넘치면 마지막 쪽 - assert call("pageRange", 0, 1, 12) == {"from": 0, "to": 0, "pages": 1} - - -def test_변수_알약_목록(): - pills = call("variablePills", logic()) - assert pills == [{"port": {"box": "b2", "name": "관경"}, "unit": "", "example": "300"}] - - -def test_선택_자리와_조건_찾기_요소(): - item = {"ref": "MT009686", "spec": "D=300mm", "unit": "M"} - slot = call( - "materialElement", - "choice", - "흄관", - {"구분": "관·이음관", "상세구분": "", "찾는말": "흄관"}, - {"item": item}, - ) - assert slot["mode"] == "choice" and slot["name"] == "흄관" and slot["slot"] == "흄관" - assert slot["default"] == "MT009686" and slot["scope"] == { - "구분": "관·이음관", - "찾는말": "흄관", - } - rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}} - found = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule}) - assert found["mode"] == "rule" and found["ref"] == "" and found["rule"] == rule - assert "default" not in found - - -def test_재료_요소_검사(): - ok = call("materialElement", "choice", "흄관", {"찾는말": "흄관"}) - assert call("checkMaterial", ok, []) is None - assert call("checkMaterial", {**ok, "name": ""}, []) == "자리 이름을 적어 주세요" - assert call("checkMaterial", ok, [ok]) == "같은 이름의 알약이 이미 있음" - assert "범위" in call("checkMaterial", call("materialElement", "choice", "흄관", {}), []) - rule_no_pill = call( - "materialElement", - "rule", - "흄관", - {"찾는말": "흄관"}, - {"rule": {"field": "규격", "op": "포함", "from": {"box": "", "name": ""}}}, - ) - assert "변수 알약" in call("checkMaterial", rule_no_pill, []) - - -def test_조건_찾기_요소는_품목_키가_없어도_따로_넣고_이름으로_뺌(): - rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}} - a = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule}) - b = call("materialElement", "rule", "받침", {"찾는말": "받침"}, {"rule": rule}) - lg = call("addElement", logic(), "b1", a) - lg = call("addElement", lg, "b1", b) - assert [e["name"] for e in lg["boxes"][0]["input"]["elements"]] == ["흄관", "받침"] - assert call("addElement", lg, "b1", a) == lg # 같은 이름은 한 번만 - gone = call("removeElementNamed", lg, "b1", "흄관") - assert [e["name"] for e in gone["boxes"][0]["input"]["elements"]] == ["받침"] - - -def other_logic() -> dict: - return { - "key": "DF-0009", - "name": "시간당 작업량", - "resultUnit": "㎥/시간", - "userElements": [], - "links": [], - "boxes": [ - { - "id": "i1", - "kind": "input", - "name": "조건", - **PLAIN, - "input": { - "mode": "variable", - "elements": [], - "variables": [{"name": "q", "type": "number", "unit": "㎥", "example": 6}], - }, - }, - { - "id": "i2", - "kind": "input", - "name": "재료", - **PLAIN, - "input": { - "mode": "group", - "group": "재료", - "elements": [ - { - "ref": "", - "name": "흄관", - "spec": "", - "unit": "M", - "mode": "rule", - "slot": "흄관", - } - ], - "variables": [], - }, - }, - { - "id": "o1", - "kind": "output", - "name": "작업량", - **PLAIN, - "unit": "㎥/시간", - "output": {"cost": "값", "unit": "㎥/시간"}, - }, - ], - } - - -def test_다른_로직_입력_알약(): - ref = call("logicRefOf", "DF-0009", other_logic()) - assert ref["key"] == "DF-0009" and ref["name"] == "시간당 작업량" - assert ref["outputs"] == [{"name": "작업량", "unit": "㎥/시간"}] - assert ref["designs"] == [{"name": "q", "unit": "㎥"}, {"name": "흄관", "unit": "M"}] - - -def test_다른_로직_상자는_출력_단위를_따르고_빼면_변수로_돌아감(): - ref = call("logicRefOf", "DF-0009", other_logic()) - lg = call("setLogicInput", logic(), "b1", ref) - box = lg["boxes"][0] - assert box["input"]["mode"] == "logic" and box["input"]["logic"]["key"] == "DF-0009" - assert box["unit"] == "㎥/시간" - assert call("pillNames", box) == ["작업량"] - lg["links"] = [ - {"id": "l1", "from": {"box": "b1", "name": "작업량"}, "to": {"box": "b2", "name": "x"}}, - {"id": "l2", "from": {"box": "b2", "name": "관경"}, "to": {"box": "b1", "name": "q"}}, - {"id": "l3", "from": {"box": "b2", "name": "관경"}, "to": {"box": "b3", "name": "y"}}, - ] - back = call("setLogicInput", lg, "b1", None) - assert back["boxes"][0]["input"]["mode"] == "variable" - assert [x["id"] for x in back["links"]] == ["l3"] - assert call("reconcileUnit", lg, "b1") == lg # 다른 로직 상자 단위는 안 건드림 - - -def test_갈래를_다른_로직으로_바꾸면_고른_것을_비움(): - el = {"ref": "LB1", "name": "석공", "spec": "", "unit": "원/일"} - lg = call("addElement", call("setKind", logic(), "b1", "인력"), "b1", el) - out = call("setKind", lg, "b1", "다른 로직") - assert ( - out["boxes"][0]["input"]["mode"] == "logic" and out["boxes"][0]["input"]["elements"] == [] - ) diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py deleted file mode 100644 index 1e1250dd7..000000000 --- a/resources/tester/test_m02_df_model.py +++ /dev/null @@ -1,1036 +0,0 @@ -"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_DF_Model.ts` · `M02_DF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. - -· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 -· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사 -· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길) -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -CF = ROOT / "M02_MasterTemplete" / "DataFlow" - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -LOADER = r""" -const fs = require("fs"); -const path = require("path"); -const ROOT = process.argv[1]; -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full).exports; - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, - }).outputText; - const mod = { exports: {} }; - cache.set(full, mod); - const req = (r) => { - if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; - if (r.endsWith(".css")) return {}; - if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); - if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`)); - return require(r); - }; - new Function("exports", "module", "require", js)(mod.exports, mod, req); - return mod.exports; -} -const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Model.ts")); -const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Eval.ts")); -const out = (x) => console.log(JSON.stringify(x)); -const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y); -const join = (lg, a, ap, b, bp) => { - const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }); - if (r.why) throw new Error(r.why); - return r.lg; -}; -/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */ -const func = (lg, box, cells, inputs, output, variables = []) => - M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables, - output: { ...box.func.output, ...output } } }); -""" - - -def _run(body: str) -> object: - result = subprocess.run( - ["node", "-e", LOADER + body, str(ROOT)], - capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout.strip().splitlines()[-1]) - - -# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비 -ONE = r""" -let lg = M.newLogic("견본 하나", "원/㎡"); -let r = add(lg, "input"); lg = r.lg; let man = r.box; -lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력", - elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } }); -r = add(lg, "input"); lg = r.lg; let area = r.box; -lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [], - variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } }); -r = add(lg, "function"); lg = r.lg; let f = r.box; -lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } }, - [{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" }, - [{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]); -r = add(lg, "output"); lg = r.lg; let o = r.box; -lg = join(lg, man, "석공", f, "노임"); -lg = join(lg, area, "면적", f, "면적"); -lg = join(lg, f, "노무비", o, "값"); -const V = { [man.id]: { 석공: 268908 } }; -""" - -# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬) -TWO = r""" -let lg = M.newLogic("견본 둘"); -let r = add(lg, "input"); lg = r.lg; const inp = r.box; -lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력", - elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }], - variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } }); -const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box; - lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; }; -const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]); -const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]); -const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]); -const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]); -lg = join(lg, inp, "면적", f1, "가"); -lg = join(lg, inp, "보통인부", f2, "가"); -lg = join(lg, f1, "결과", f3, "가"); -lg = join(lg, f2, "결과", f3, "나"); -lg = join(lg, f3, "결과", f4, "가"); -r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } }); -r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } }); -r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } }); -lg = join(lg, f4, "결과", oExp, "값"); -lg = join(lg, f1, "결과", oVal, "값"); -lg = join(lg, inp, "면적", oMat, "값"); -const V = { [inp.id]: { 보통인부: 172068 } }; -""" - - -def test_견본하나_입력_변수_함수_출력_노무비() -> None: - got = _run( - ONE - + "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);" - ) - ev, check, boxes = got - assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908 - assert ev["outputs"] == {"b4": 537816} - assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816} - assert ev["errors"] == [] and check == [] - assert boxes == [ - ["b1", "input", "입력", "#2f80ed", "-"], - ["b2", "input", "입력 2", "#2f80ed", "-"], - ["b3", "function", "함수", "#7b4fd6", "-"], - ["b4", "output", "출력", "#27ae60", "-"], - ] - - -def test_변수값_바꾸면_값이_바뀜() -> None: - got = _run( - ONE - + "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);" - ) - assert got == 0.1 * 20 * 268908 - - -def test_견본둘_직렬_병렬_원가_합() -> None: - got = _run( - TWO - + "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);" - ) - ports, cost, errors, check = got - f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5")) - assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938) - assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943} - assert errors == [] and check == [] - - -def test_선_규칙() -> None: - got = _run( - TWO - + "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n" - "out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적')," - " t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값')," - " M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);" - ) - assert got == [ - "이으면 돌고 도는 선이 됨", - "이으면 돌고 도는 선이 됨", - "출력 상자에서는 선이 나가지 않음", - "입력 상자로는 선이 들어가지 않음", - "같은 상자끼리는 못 이음", - "이미 선이 들어온 자리", - "알약이 없음", - "이미 선이 들어온 자리", - True, - ] - - -def test_알약_출력은_하나() -> None: - got = _run( - TWO - + "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n" - "out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3])," - " M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);" - ) - in_out, in_in, f_out, f_in, o_out, o_in = got - assert in_out == [ - {"name": "보통인부", "unit": "인"}, - {"name": "표", "unit": "-"}, - {"name": "면적", "unit": "㎡"}, - ] - assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}] - assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}] - assert o_out == [] and o_in == [{"name": "값", "unit": "원"}] - - -def test_선_끊기_상자_지우기_검사() -> None: - got = _run( - TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n" - "const a = M.unlink(lg, cut);\n" - "const b = M.removeBox(lg, f3.id);\n" - "const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n" - "out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length," - " E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);" - ) - f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got - assert f3_after == {"결과": 10} - assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"] - assert (n_b, n_all) == (5, 8) - assert check_b == [ - "「함수 4」 의 받는 알약 「가」 에 선이 없음", - ] - assert check_c == ["출력 상자가 없음"] - assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"] - - -def test_오류칸_순환칸_숫자아닌_출력() -> None: - got = _run( - ONE - + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," - " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n" - "const a = E.evalLogic(lg, V);\n" - "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," - " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n" - "const b = E.evalLogic(lg, V);\n" - "out([a.errors, a.cost.계, b.errors]);" - ) - a_err, total, b_err = got - assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"] - assert total == 0 - assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err - - -def test_자리_색_단위() -> None: - got = _run( - ONE - + "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n" - "g = M.setUnit(g, o.id, ' ');\n" - "const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n" - "out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);" - ) - assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0] - - -def test_사용자_요소_값() -> None: - got = _run( - ONE - + "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n" - "lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n" - "out(E.evalLogic(lg, {}).cost.노무비);" - ) - assert got == 0.2 * 10 * 300000 - - -def test_화면_글에_금지_낱말_없음() -> None: - for name in ("M02_DF_Model.ts", "M02_DF_Eval.ts"): - src = (CF / name).read_text(encoding="utf-8") - block = src[src.index("const TEXT = {") : src.index("} as const;")] - korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] - assert korean - for text in korean: - for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): - assert bad not in text, (name, bad, text) - - -def test_알약_이름_규칙() -> None: - got = _run( - "let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n" - "lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료'," - " elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 }," - " variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n" - "out(M.portsOut(lg.boxes[0]).map(p => p.name));" - ) - assert got == ["시멘트", "표", "길이"] - - -def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None: - got = _run( - ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" - "let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n" - "let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n" - "let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n" - "let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n" - "const gb = (x) => x.boxes.find(y => y.id === g.id);\n" - "const inputs = gb(d.lg).func.inputs;\n" - "const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n" - "const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n" - "const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n" - "const removed = M.removeBox(d.lg, man.id);\n" - "out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to)," - " check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port)," - " M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);" - ) - a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got - assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None) - assert inputs == [ - {"port": "석공", "cell": ""}, - {"port": "면적", "cell": ""}, - {"port": "노무비", "cell": ""}, - ] - assert ports == ["석공", "면적", "노무비"] - assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}] - assert check == [ - "「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음", - "「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음", - "「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음", - ] - assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음 - assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움 - assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음 - - -def test_같은_이름_받는_알약은_번호() -> None: - got = _run( - ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" - "let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" - "x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n" - "r = add(x, 'input'); x = r.lg; const m2 = r.box;\n" - "x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n" - "x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" - "x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n" - "out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));" - ) - assert got == ["석공", "노무비", "석공 (2)", "할증"] - - -def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None: - got = _run( - "let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n" - "r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n" - "r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n" - "lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n" - "lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" - "lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n" - "r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n" - "lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n" - "lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" - "out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값])," - " lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);" - ) - names, ports = got - assert names == [ - ["결과 1", "결과 1"], - ["결과 3", "결과 3"], - ["결과 2", "결과 2"], - ["결과 4", "결과 4"], - ] - assert ports == ["결과 1", "결과 2", "결과 1 (2)"] - - -# ── 흐름도 넓힘(35-9) — 견본 셋 ───────────────────────────────────────────── -RULE = r""" -const R = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts")); -const rjoin = (lg, a, ap, b, bp, rule) => { - const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }, rule); - if (r.why) throw new Error(r.why); - return r.lg; -}; -const vars = (lg, box, list) => M.updateBox(lg, box.id, { input: { mode: "variable", elements: [], variables: list } }); -const num = (name, example, unit = "-") => ({ name, type: "number", unit, example }); -""" - -# 견본 가 — 재료 조건 찾기: 관경(변수) → 흄관(규격에 관경 포함) 단가 · 수량 × 단가 → 재료비 · 거푸집 = 선택 자리 -PIPE = ( - RULE - + r""" -let lg = M.newLogic("흄관 조건 찾기"); -let r = add(lg, "input"); lg = r.lg; const dia = r.box; -lg = vars(lg, dia, [num("관경", 300, "㎜"), num("길이", 12, "m")]); -r = add(lg, "input"); lg = r.lg; const mat = r.box; -lg = M.updateBox(lg, mat.id, { input: { mode: "group", group: "재료", variables: [], elements: [ - { ref: "", name: "흄관", spec: "", unit: "m", mode: "rule", slot: "흄관 단가", scope: { 구분: "관·수로", 상세구분: "흄관" }, - rule: { field: "규격", op: "포함", from: { box: dia.id, name: "관경" } } }, - { ref: "", name: "거푸집", spec: "", unit: "㎡", mode: "choice", slot: "거푸집 자리", default: "MT000123" } ] } }); -r = add(lg, "function"); lg = r.lg; const f = r.box; -lg = func(lg, f, { B9: { 식: "B1*B2" } }, [{ port: "길이", cell: "B1" }, { port: "흄관 단가", cell: "B2" }], { cell: "B9", name: "재료값" }); -r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "재료비", unit: "원" } }); -lg = join(lg, dia, "길이", f, "길이"); -lg = join(lg, mat, "흄관 단가", f, "흄관 단가"); -lg = join(lg, f, "재료값", o, "값"); -""" -) - - -def test_가_재료_조건_찾기_흐름() -> None: - got = _run( - PIPE + "const ok = E.evalLogic(lg, { [mat.id]: { '흄관 단가': 52000 } });\n" - "const none = E.breaks(lg, {});\n" - "out([M.portsOut(lg.boxes[1]).map(p => p.name), ok.cost.재료비, ok.arrived[f.id], none," - " M.traceBack(lg, { box: o.id, name: '값' }), M.designInputs(lg).map(d => [d.kind, d.port])," - " M.reaches(lg, dia.id, f.id)]);" - ) - pills, cost, arrived, none, back, designs, dep = got - assert pills == ["흄관 단가", "거푸집 자리"] # 재료 자리 = 알약 이름 - assert cost == 12 * 52000 and arrived == {"길이": 12, "흄관 단가": 52000} - assert [(b["kind"], b["port"]) for b in none] == [("맞는 재료 없음", "흄관 단가")] - assert none[0]["text"] == "「입력 2」 의 「흄관 단가」 — 조건에 맞는 재료가 없음" - # 출력 값 ← 함수 재료값 ← 길이 · 흄관 단가 ← (조건) 관경 - assert [(s["box"], s["port"], s["dir"]) for s in back] == [ - ("b4", "값", "in"), - ("b3", "재료값", "out"), - ("b3", "길이", "in"), - ("b1", "길이", "out"), - ("b3", "흄관 단가", "in"), - ("b2", "흄관 단가", "out"), - ("b1", "관경", "out"), - ] - assert designs == [["변수", "관경"], ["변수", "길이"], ["재료 자리", "거푸집 자리"]] - assert dep is True # 관경 → (재료 조건) → 함수 - - -# 견본 나 — 조건 통과로 둘로 가르기(지반 토사 · 암) + 표에서 찾기 + 바꾸기 · 끝수 -SPLIT = ( - RULE - + r""" -let lg = M.newLogic("지반 가르기"); -let r = add(lg, "input"); lg = r.lg; const v = r.box; -lg = vars(lg, v, [{ name: "지반", type: "choice", unit: "-", choices: ["토사", "암"], example: "토사" }, - num("높이", 4.5, "m"), num("품", 0.5, "인")]); -r = add(lg, "input"); lg = r.lg; const t = r.box; -lg = M.updateBox(lg, t.id, { input: { mode: "group", group: "소요량", elements: [], variables: [], table: { key: "QF000422" } } }); -const fn = (out) => { r = add(lg, "function"); lg = r.lg; const b = r.box; - lg = func(lg, b, { B9: { 식: "B1*100000" } }, [{ port: "가", cell: "B1" }], { cell: "B9", name: out }); return b; }; -const soil = fn("토사 노임"), rock = fn("암 노임"); -r = add(lg, "function"); lg = r.lg; const rate = r.box; -lg = func(lg, rate, { B9: { 식: "B1" } }, [{ port: "증가율", cell: "B1" }], { cell: "B9", name: "증가율 값" }); -r = add(lg, "output"); lg = r.lg; const o1 = r.box; -r = add(lg, "output"); lg = r.lg; const o2 = r.box; -r = add(lg, "output"); lg = r.lg; const o3 = r.box; lg = M.updateBox(lg, o3.id, { output: { cost: "값", unit: "%" } }); -const gate = (value, op = "=") => ({ kind: "gate", from: { box: v.id, name: "지반" }, op, value }); -lg = rjoin(lg, v, "품", soil, "가", gate("토사")); -lg = rjoin(lg, v, "품", rock, "가", gate("암")); -lg = rjoin(lg, t, "표", rate, "증가율", { kind: "lookup", conds: [{ col: "높이", from: { box: v.id, name: "높이" } }], col: "증가율" }); -lg = rjoin(lg, soil, "토사 노임", o1, "값", { kind: "scale", op: "+%", value: 10 }); -lg = rjoin(lg, rock, "암 노임", o2, "값", { kind: "round", places: -3, method: "버림" }); -lg = join(lg, rate, "증가율 값", o3, "값"); -const TABLE = [["높이·아래", "높이·위", "증가율"], [3, 4, 30], [4, 5.5, 40], [5.5, 7.5, 60]]; -const V = (soilKind, h = 4.5) => ({ [v.id]: { 지반: soilKind, 높이: h }, [t.id]: { 표: TABLE } }); -""" -) - - -def test_나_조건_통과로_둘로_가르기_표에서_찾기() -> None: - got = _run( - SPLIT + "const a = E.evalLogic(lg, V('토사'));\n" - "const b = E.evalLogic(lg, V('암', 3.5));\n" - "out([a.outputs, a.breaks.map(x => [x.kind, x.link]), b.outputs, b.breaks.map(x => x.kind)," - " E.breaks(lg, V('토사', 9)).map(x => [x.kind, x.text])]);" - ) - a_out, a_breaks, b_out, b_breaks, far = got - # 토사: 0.5 × 100000 × 1.1 · 암 줄은 통과 안 됨 · 높이 4.5 → 증가율 40 - assert a_out == {"b6": 55000, "b7": None, "b8": 40} - assert a_breaks == [["통과 안 됨", "l2"], ["통과 안 됨", "l5"]] # 암 갈래 + 그 아래로 번진 쉼 - assert b_out == { - "b6": None, - "b7": 50000, - "b8": 30, - } # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30 - assert b_breaks == ["통과 안 됨", "통과 안 됨"] # 토사 갈래 + 번진 쉼 - assert far == [ - ["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"], - ["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"], - ["통과 안 됨", "「함수 2」 → 「출력 2」 — 앞 조건이 안 맞아 쉬는 갈래"], - ] - - -def test_나_조건_선_겹침_안맞음() -> None: - got = _run( - SPLIT + "const l2 = lg.links[1].id;\n" - "const overlap = M.setRule(lg, l2, { kind: 'gate', from: { box: v.id, name: '지반' }, op: '≠', value: '암' }).lg;\n" - "out([E.breaks(overlap, V('토사')).map(x => [x.kind, x.text]), E.breaks(lg, V('자갈')).map(x => x.kind)]);" - ) - overlap, none = got - assert overlap == [["조건 겹침", "「입력」 의 「지반」 에서 나간 조건 선이 둘 이상 통과함"]] - assert none == ["통과 안 됨", "통과 안 됨", "통과 안 됨", "통과 안 됨", "조건 안 맞음"] - - -def test_나_규칙_글과_엑셀_수식() -> None: - got = _run( - SPLIT + "out([lg.links.map(l => l.rule ? R.ruleLabel(l.rule) : null)," - " lg.links.map(l => l.rule ? R.applyRule(l.rule, 1000, { 지반: '토사', 높이: 4.5 }).formula : null).slice(0, 2)," - " R.applyRule({ kind: 'scale', op: '+%', value: 10 }, 200).formula, R.applyRule({ kind: 'scale', op: '÷', value: 4 }, 10).value," - " R.applyRule({ kind: 'round', places: 1, method: '올림' }, 1.21).value, R.applyRound({ places: 0, method: '버림' }, 9.9)]);" - ) - labels, gates, scale_f, div, up, down = got - assert labels == [ - "지반 = 토사 일 때만", - "지반 = 암 일 때만", - "표에서 찾기 (높이 = 높이) → 증가율", - "+10%", - "소수 -3자리 버림", - None, - ] - assert gates == ['IF(C1="토사",A1,NA())', 'IF(C1="암",A1,NA())'] - assert scale_f == "A1*(1+10/100)" and div == 2.5 and up == 1.3 and down == 9 - - -def test_나_흐름_따라가기_앞으로() -> None: - got = _run( - SPLIT - + "out(M.traceForward(lg, { box: v.id, name: '지반' }).map(s => [s.box, s.port, s.dir, s.rule ? s.rule.kind : null]));" - ) - assert got == [ - ["b1", "지반", "out", None], - ["b3", "가", "in", "gate"], - ["b3", "토사 노임", "out", None], - ["b6", "값", "in", "scale"], - ["b4", "가", "in", "gate"], - ["b4", "암 노임", "out", None], - ["b7", "값", "in", "round"], - ] - - -def test_규칙_조건_알약_없으면_막음_순환() -> None: - got = _run( - SPLIT - + "const bad = M.link(lg, { box: v.id, name: '품' }, { box: rate.id, name: '' }, { kind: 'gate', from: { box: v.id, name: '없음' }, op: '=', value: 1 }).why;\n" - "const loop = M.setRule(lg, lg.links[0].id, { kind: 'gate', from: { box: soil.id, name: '토사 노임' }, op: '>', value: 0 }).why;\n" - "const off = M.setRule(lg, lg.links[3].id, null).lg.links[3];\n" - "out([bad, loop, off]);" - ) - bad, loop, off = got - assert bad == "규칙의 조건 알약이 없음" - assert loop == "이으면 돌고 도는 선이 됨" - assert "rule" not in off - - -# 견본 다 — 다른 로직 잇기: 안쪽 로직(면적 × 계수 = 작업량 값) · 바깥 로직이 길이를 면적으로 넘기고 작업량을 받아 경비 -LOGIC = ( - RULE - + r""" -let inner = M.newLogic("작업량"); -let r = add(inner, "input"); inner = r.lg; const ia = r.box; -inner = vars(inner, ia, [num("면적", 1, "㎡"), num("계수", 3)]); -r = add(inner, "function"); inner = r.lg; const ifn = r.box; -inner = func(inner, ifn, { B9: { 식: "B1*B2" } }, [{ port: "면적", cell: "B1" }, { port: "계수", cell: "B2" }], { cell: "B9", name: "작업량 값" }); -r = add(inner, "output"); inner = r.lg; const io = r.box; -inner = M.updateBox(inner, io.id, { name: "작업량", output: { cost: "값", unit: "㎥/시간" } }); -inner = join(inner, ia, "면적", ifn, "면적"); -inner = join(inner, ia, "계수", ifn, "계수"); -inner = join(inner, ifn, "작업량 값", inner.boxes.find(b => b.id === io.id), "값"); -let lg = M.newLogic("바깥"); -r = add(lg, "input"); lg = r.lg; const len = r.box; lg = vars(lg, len, [num("길이", 7, "m")]); -r = add(lg, "input"); lg = r.lg; const other = r.box; -lg = M.updateBox(lg, other.id, { input: { mode: "logic", elements: [], variables: [], - logic: { key: "DF-0001", name: "작업량", outputs: [{ name: "작업량", unit: "㎥/시간" }], - designs: M.designInputs(inner).map(d => ({ name: d.port, unit: d.unit })) } } }); -r = add(lg, "function"); lg = r.lg; const f = r.box; -lg = func(lg, f, { B9: { 식: "B1*1000" } }, [{ port: "작업량", cell: "B1" }], { cell: "B9", name: "경비 값" }); -r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "경비", unit: "원" } }); -lg = join(lg, len, "길이", other, "면적"); -lg = join(lg, other, "작업량", f, "작업량"); -lg = join(lg, f, "경비 값", o, "값"); -const LOGICS = { "DF-0001": inner }; -""" -) - - -def test_다_다른_로직_잇기() -> None: - got = _run( - LOGIC + "const a = E.evalLogic(lg, {}, { logics: LOGICS });\n" - "const b = E.evalLogic(lg, { [len.id]: { 길이: 2 }, [other.id]: { 계수: 10 } }, { logics: LOGICS });\n" - "const c = E.evalLogic(lg, { [other.id]: { 작업량: 5 } });\n" - "out([M.portsIn(lg.boxes[1]).map(p => p.name), M.portsOut(lg.boxes[1]).map(p => p.name), a.ports[other.id], a.cost.경비," - " b.cost.경비, c.cost.경비, M.designInputs(lg).map(d => [d.kind, d.port])," - " M.link(lg, { box: len.id, name: '길이' }, { box: other.id, name: '없음' }).why," - " M.traceBack(lg, { box: o.id, name: '값' }).map(s => [s.box, s.port]), E.evalLogic(lg, {}, { logics: {} }).errors]);" - ) - ins, outs, ports, a, b, c, designs, no_pill, back, missing = got - assert ins == ["면적", "계수"] and outs == ["작업량"] - assert ports == {"작업량": 21} # 길이 7 → 면적 · 계수 예시값 3 - assert (a, b, c) == (21000, 20000, 5000) - assert designs == [["변수", "길이"], ["다른 로직", "계수"]] # 면적은 선이 있어 빠짐 - assert no_pill == "알약이 없음" - assert back == [ - ["b4", "값"], - ["b3", "경비 값"], - ["b3", "작업량"], - ["b2", "작업량"], - ["b2", "면적"], - ["b1", "길이"], - ["b2", "계수"], - ] - assert missing == ["「입력 2」 이 부르는 로직을 찾을 수 없음"] - - -def test_끝수_함수_출력_출력_상자() -> None: - got = _run( - ONE - + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, output: { ...lg.boxes[2].func.output, round: { places: -3, method: '버림' } } } });\n" - "const a = E.evalLogic(lg, V).cost.노무비;\n" - "lg = M.updateBox(lg, o.id, { output: { cost: '노무비', unit: '원', round: { places: -4, method: '올림' } } });\n" - "out([a, E.evalLogic(lg, V).cost.노무비]);" - ) - assert got == [537000, 540000] - - -def test_다_다른_로직_받는_알약_예시값_차례() -> None: - got = _run( - LOGIC - + "lg = M.updateBox(lg, other.id, { input: { ...lg.boxes[1].input, logic: { ...lg.boxes[1].input.logic, examples: { 계수: 10, 면적: 99 } } } });\n" - "const a = E.evalLogic(lg, {}, { logics: LOGICS }).cost.경비;\n" - "const b = E.evalLogic(lg, { [other.id]: { 계수: 2 } }, { logics: LOGICS }).cost.경비;\n" - "const noEx = M.updateBox(lg, other.id, { input: { ...lg.boxes[1].input, logic: { ...lg.boxes[1].input.logic, examples: undefined } } });\n" - "out([a, b, E.evalLogic(noEx, {}, { logics: LOGICS }).cost.경비," - " M.designInputs(lg).map(d => [d.kind, d.port, d.example]), M.designInputs(noEx).map(d => d.example)]);" - ) - a, b, c, designs, plain = got - # 면적 = 이어진 선(길이 7) > 예시값 99 · 계수 = 상자 예시값 10 · 넘긴 값 2 가 이김 · 없으면 그 로직 예시값 3 - assert (a, b, c) == (70000, 14000, 21000) - assert designs == [["변수", "길이", 7], ["다른 로직", "계수", 10]] - assert plain == [7, None] - - -def test_표_범위칸_숫자하나는_같음() -> None: - got = _run( - RULE - + "const T = [['높이·아래', '높이·위', '석공'], [4, 4, 0.5], [4, 6, 0.6], [null, 3, 0.4]];\n" - "const rule = { kind: 'lookup', conds: [{ col: '높이', from: { box: 'b1', name: '높이' } }], col: '석공' };\n" - "out([4, 5, 2, 3.5, 6].map(h => { const r = R.applyRule(rule, T, { 높이: h }); return [r.value, r.why]; }));" - ) - # 4 = 「4」 줄만(같음) · 4 는 「4~6」(초과) 에 안 듦 · 빈 아래 끝은 열림 - assert got == [[0.5, None], [0.6, None], [0.4, None], [None, "표에 없는 조건"], [0.6, None]] - - -def test_쉬는_갈래와_문제_무게() -> None: - got = _run( - SPLIT + "const a = E.breaks(lg, V('토사'));\n" - "const b = E.breaks(lg, V('자갈', 9));\n" - "out([a.map(x => [x.kind, x.level, x.box, x.text]), b.filter(x => x.level === '문제').map(x => x.kind)," - " E.evalLogic(lg, V('토사')).outputs]);" - ) - a, b_problems, outs = got - assert a == [ - [ - "통과 안 됨", - "쉼", - "b4", - "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음", - ], - ["통과 안 됨", "쉼", "b7", "「함수 2」 → 「출력 2」 — 앞 조건이 안 맞아 쉬는 갈래"], - ] - # 하나도 안 맞음 · 표에 없는 조건만 문제 — 쉬는 갈래 넷은 문제로 안 셈 - assert b_problems == ["표에 없는 조건", "조건 안 맞음"] - assert outs["b7"] is None # 쉬는 갈래 끝 출력은 빈 값(0 아님) - - -def _ground() -> dict: - return json.loads( - (ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8") - ) - - -def _server(lg: dict, values: dict) -> dict: - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - return cp.run(lg, values, lambda key: {}, []) - - -def test_만나는_상자는_살아_있는_선이_있으면_셈_서버와_같음() -> None: - lg = _ground() - soil = {"in-ground": {"지반": "토사"}} - rock = {"in-ground": {"지반": "암"}} - got = _run( - f"const lg = {json.dumps(lg, ensure_ascii=False)};\n" - f"const s = E.evalLogic(lg, {json.dumps(soil, ensure_ascii=False)});\n" - f"const r = E.evalLogic(lg, {json.dumps(rock, ensure_ascii=False)});\n" - "out([s.outputs, s.breaks.map(b => [b.kind, b.level, b.link]), r.outputs, r.breaks.map(b => [b.kind, b.level, b.link])," - " s.ports['fn-sum'], r.ports['fn-sum']]);" - ) - s_out, s_breaks, r_out, r_breaks, s_sum, r_sum = got - assert s_out == {"out-exp": 2000} # 굴삭기 갈래만 · 브레이커 갈래는 쉼(빈 칸) - assert s_breaks == [["통과 안 됨", "쉼", "l2"], ["통과 안 됨", "쉼", "l6"]] - assert [b for b in s_breaks if b[1] == "문제"] == [] - assert r_breaks == [["통과 안 됨", "쉼", "l1"], ["통과 안 됨", "쉼", "l5"]] - assert r_out["out-exp"] == r_sum["1㎥ 경비"] and r_out["out-exp"] != 2000 # 브레이커 갈래 값 - assert s_sum == {"1㎥ 경비": 2000} - # 서버(Compute) = 화면(Eval) - for values, screen_out, screen_breaks in ((soil, s_out, s_breaks), (rock, r_out, r_breaks)): - srv = _server(lg, values) - assert {k: float(v) for k, v in srv["outputs"].items()} == { - k: float(v) for k, v in screen_out.items() - } - assert [[b["kind"], b["level"], b["link"]] for b in srv["breaks"]] == screen_breaks - - -# ── 36-0b 선 조건 여러 줄(그리고 · 또는) · 값 목록 ───────────────────────────── -def test_조건_여러_줄_그리고_또는_글_식_서버같음() -> None: - lg = _ground() - two = { - "kind": "gate", - "conds": [ - {"from": {"box": "in-ground", "name": "지반"}, "op": "=", "value": "토사"}, - {"from": {"box": "in-ground", "name": "굴착량"}, "op": "≥", "value": 50}, - ], - "join": "그리고", - "from": {"box": "in-ground", "name": "지반"}, - "op": "=", - "value": "토사", - } - either = {**two, "join": "또는"} - cases = [ - (two, {"in-ground": {"지반": "토사", "굴착량": 100}}), - (two, {"in-ground": {"지반": "토사", "굴착량": 10}}), - (either, {"in-ground": {"지반": "암", "굴착량": 100}}), - (either, {"in-ground": {"지반": "암", "굴착량": 10}}), - ] - got = _run( - RULE - + f"const base = {json.dumps(lg, ensure_ascii=False)};\n" - + f"const cases = {json.dumps(cases, ensure_ascii=False)};\n" - "out(cases.map(([rule, vals]) => {\n" - " const lg = { ...base, links: base.links.map(l => l.id === 'l1' ? { ...l, rule } : l) };\n" - " const ev = E.evalLogic(lg, vals);\n" - " return [R.ruleLabel(rule), R.applyRule(rule, 1, { 지반: vals['in-ground'].지반, 굴착량: vals['in-ground'].굴착량 }).formula," - " ev.ports['fn-exc'], ev.breaks.filter(b => b.link === 'l1').map(b => b.kind)];\n" - "}));" - ) - labels = [g[0] for g in got] - assert labels[0] == "지반 = 토사 그리고 굴착량 ≥ 50 일 때만" - assert labels[2] == "지반 = 토사 또는 굴착량 ≥ 50 일 때만" - assert got[0][1] == 'IF(AND(C1="토사",D1>=50),A1,NA())' - assert got[2][1] == 'IF(OR(C1="토사",D1>=50),A1,NA())' - passed = [g[3] == [] for g in got] - assert passed == [True, False, True, False] - # 서버(Compute) = 화면(Eval) - for (rule, values), (_, _, ports, breaks) in zip(cases, got): - srv_lg = { - **lg, - "links": [{**ln, "rule": rule} if ln["id"] == "l1" else ln for ln in lg["links"]], - } - srv = _server(srv_lg, values) - exc = srv["ports"].get("fn-exc") or {} - assert {k: (None if v is None else float(v)) for k, v in exc.items()} == { - k: (None if v is None else float(v)) for k, v in ports.items() - } - assert [b["kind"] for b in srv["breaks"] if b.get("link") == "l1"] == breaks - - -def test_옛_한_줄_꼴과_makeGate() -> None: - got = _run( - RULE - + "const old = { kind: 'gate', from: { box: 'b1', name: '지반' }, op: '=', value: '토사' };\n" - "const made = M.makeGate([{ from: { box: 'b1', name: '지반' }, op: '≠', value: '암' }, { from: { box: 'b1', name: '높이' }, op: '<', value: 3 }], '또는');\n" - "out([M.gateConds(old), R.ruleLabel(old), made.from, made.op, made.value, M.ruleSources(made).map(p => p.name)," - " R.applyRule(made, 7, { 지반: '암', 높이: 2 }).value, R.applyRule(made, 7, { 지반: '암', 높이: 5 }).why]);" - ) - conds, label, first_from, first_op, first_value, sources, ok, blocked = got - assert conds == { - "conds": [{"from": {"box": "b1", "name": "지반"}, "op": "=", "value": "토사"}], - "join": "그리고", - } - assert label == "지반 = 토사 일 때만" - assert (first_from, first_op, first_value) == ({"box": "b1", "name": "지반"}, "≠", "암") - assert sources == ["지반", "높이"] - assert (ok, blocked) == (7, "통과 안 됨") - - -def test_값_목록() -> None: - got = _run( - PIPE - + "lg = M.updateBox(lg, dia.id, { input: { ...lg.boxes[0].input, variables: [...lg.boxes[0].input.variables," - " { name: '지반', type: 'choice', unit: '-', choices: ['토사', '암'], example: '토사' }] } });\n" - "lg = M.updateBox(lg, mat.id, { input: { ...lg.boxes[1].input, elements: lg.boxes[1].input.elements.map((e, i) => i === 1 ? { ...e, spec: '12mm' } : e) } });\n" - "r = add(lg, 'input'); lg = r.lg; const tb = r.box;\n" - "lg = M.updateBox(lg, tb.id, { input: { mode: 'group', group: '소요량', elements: [], variables: [], table: { key: 'QF1' } } });\n" - "const vals = { [tb.id]: { 표: [['돌', '석공'], ['깬돌', 0.2], ['견치돌', 0.3], ['깬돌', 0.25]] } };\n" - "const v = (box, name, opts) => M.valueChoices(lg, { box, name }, opts);\n" - "out([v(dia.id, '지반'), v(dia.id, '관경'), v(mat.id, '거푸집 자리'), v(mat.id, '흄관 단가')," - " v(tb.id, '표', { col: '돌', values: vals }), v(tb.id, '표', { col: '없음', values: vals }), v(tb.id, '표')," - " v(f.id, '재료값'), v('없는 상자', '지반')]);" - ) - assert got == [["토사", "암"], None, ["12mm"], None, ["깬돌", "견치돌"], None, None, None, None] - - -def test_조건_여러_줄_겹침_안맞음() -> None: - got = _run( - SPLIT - + "const both = (op1, v1) => M.makeGate([{ from: { box: v.id, name: '지반' }, op: op1, value: v1 }, { from: { box: v.id, name: '높이' }, op: '≥', value: 3 }]);\n" - "let g = M.setRule(lg, lg.links[0].id, both('=', '토사')).lg;\n" - "g = M.setRule(g, lg.links[1].id, both('≠', '암')).lg;\n" - "const over = E.breaks(g, V('토사')).filter(b => b.level === '문제').map(b => b.kind);\n" - "const none = E.breaks(g, V('암', 2)).filter(b => b.level === '문제').map(b => b.kind);\n" - "out([over, none]);" - ) - over, none = got - assert "조건 겹침" in over - assert "조건 안 맞음" in none - - -# ── 36-2 표 찾기 — 옛 find 와 같은 뜻 · 실패는 칸에 #N/A(엑셀과 같게) ──────────────── -BLOCK = [["구분", "종류", "값"], ["가", None, 5], ["가", "특", None], ["다", "보", 8]] - - -def _lazy_flow() -> dict: - var = {"mode": "variable", "elements": [], "variables": []} - var["variables"] = [ - {"name": "x", "type": "number", "unit": "-", "example": 1}, - {"name": "구분", "type": "text", "unit": "-", "example": "가"}, - {"name": "종류", "type": "text", "unit": "-", "example": "보"}, - ] - table = {"mode": "group", "group": "소요량", "elements": [], "variables": []} - table["table"] = {"key": "QT"} - cells = {"B2": {"값": 0}, "B3": {"값": 0}, "B4": {"식": "IF(B3=1,B2,0)"}} - func = {"sheet": {"id": "s1", "이름": "f", "칸": cells}, "variables": []} - func["inputs"] = [{"port": "찾은 값", "cell": "B2"}, {"port": "x", "cell": "B3"}] - func["output"] = {"cell": "B4", "name": "결과", "unit": "-"} - box = lambda i, k, n, **b: { - "id": i, - "kind": k, - "name": n, - "color": "#000", - "unit": "-", - "x": 0, - "y": 0, - **b, - } # noqa: E731 - conds = [{"col": "구분", "from": {"box": "v", "name": "구분"}}] - conds.append({"col": "종류", "from": {"box": "v", "name": "종류"}}) - links = [ - {"id": "l1", "from": {"box": "t", "name": "표"}, "to": {"box": "f", "name": "찾은 값"}}, - {"id": "l2", "from": {"box": "v", "name": "x"}, "to": {"box": "f", "name": "x"}}, - {"id": "l3", "from": {"box": "f", "name": "결과"}, "to": {"box": "o", "name": "값"}}, - ] - links[0]["rule"] = {"kind": "lookup", "conds": conds, "col": "값"} - boxes = [box("v", "input", "설계", input=var), box("t", "input", "표", input=table)] - boxes += [ - box("f", "function", "f", func=func), - box("o", "output", "결과", output={"cost": "값", "unit": "-"}), - ] - return { - "key": None, - "name": "t", - "resultUnit": "", - "boxes": boxes, - "links": links, - "userElements": [], - } - - -def test_표_찾기_빈_조건칸_무엇이든_빈_값줄_건너뜀_실패는_쓸때만_끊김(monkeypatch) -> None: - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - lg = _lazy_flow() - cases = [(1, "가", "보"), (1, "다", "보"), (1, "라", "보"), (0, "라", "보")] - vals = [{"v": {"x": x, "구분": g, "종류": k}, "t": {"표": BLOCK}} for x, g, k in cases] - got = _run( - f"const lg = {json.dumps(lg, ensure_ascii=False)};\n" - f"const vals = {json.dumps(vals, ensure_ascii=False)};\n" - "out(vals.map(v => { const e = E.evalLogic(lg, v); return [e.outputs.o, e.breaks.map(b => b.kind)]; }));" - ) - assert [g[0] for g in got[:2]] == [5, 8] - assert got[2][1] == ["표에 없는 조건"] and got[3] == [0, []] - monkeypatch.setattr(cp.dv, "one_value", lambda item, local=None: {"ok": True, "rows": BLOCK}) - for v, (screen_out, screen_breaks) in zip(vals, got): - srv = cp.run(lg, {"v": v["v"]}, lambda key: {}, []) - out = srv["outputs"]["o"] # 오류 값은 화면과 같은 글(「#N/A」) - assert out == screen_out if isinstance(screen_out, str) else float(out) == float(screen_out) - assert [b["kind"] for b in srv["breaks"]] == screen_breaks - - -def test_표_찾기_값_칸이_범위로_갈림_아래_같으면_그_값() -> None: - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - block = [["구분", "값·아래", "값·위"], ["가", 3, 3], ["나", 1, 2]] - rule = { - "kind": "lookup", - "conds": [{"col": "구분", "from": {"box": "v", "name": "구분"}}], - "col": "값", - } - got = _run( - RULE - + f"const block = {json.dumps(block, ensure_ascii=False)};\n" - + f"const rule = {json.dumps(rule, ensure_ascii=False)};\n" - "out(['가', '나'].map(g => { const r = R.applyRule(rule, block, { 구분: g }); return [r.value, r.why]; }));" - ) - assert got == [[3, None], [None, "오류"]] - assert [list(cp._lookup(rule, block, {"구분": g})) for g in ("가", "나")] == got - - -def test_표_찾기_여럿이면_위_끝_가장_작은_줄() -> None: - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - block = [["거리·아래", "거리·위", "Q"], [None, 20, 11], [None, 40, 10], [None, 60, 9]] - rule = { - "kind": "lookup", - "conds": [{"col": "거리", "from": {"box": "v", "name": "x"}}], - "col": "Q", - } - got = _run( - RULE - + f"const block = {json.dumps(block)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n" - "out([1, 30, 60, 70].map(x => { const r = R.applyRule(rule, block, { x }); return [r.value, r.why]; }));" - ) - assert got == [[11, None], [10, None], [9, None], [None, "표에 없는 조건"]] - assert [list(cp._lookup(rule, block, {"x": x})) for x in (1, 30, 60, 70)] == got - - -def test_표_찾기_범위_경계_규칙_칸() -> None: - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - head = ["h·아래", "h·위", "Q", "·범위규칙"] - block = [head, [None, 10, 1, "이상·미만"], [10, 20, 2, "이상·미만"], [20, 30, 3, "이상·이하"]] - rule = { - "kind": "lookup", - "conds": [{"col": "h", "from": {"box": "v", "name": "x"}}], - "col": "Q", - } - xs = [5, 10, 20, 30, 31] - got = _run( - RULE - + f"const block = {json.dumps(block, ensure_ascii=False)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n" - f"out({json.dumps(xs)}.map(x => {{ const r = R.applyRule(rule, block, {{ x }}); return [r.value, r.why]; }}));" - ) - assert got == [[1, None], [2, None], [3, None], [3, None], [None, "표에 없는 조건"]] - assert [list(cp._lookup(rule, block, {"x": x})) for x in xs] == got - - -def test_화면_값_물음_조건_찾기_인력기계유가_검색어_틀_다른_로직() -> None: - lg = { - "key": None, "name": "t", "resultUnit": "", "userElements": [], - "boxes": [ - {"id": "v", "kind": "input", "name": "설계", "input": {"mode": "variable", "elements": [], "variables": []}}, - {"id": "g", "kind": "input", "name": "기계", "input": {"mode": "group", "group": "기계", "variables": [], "elements": [ - {"ref": "EQ:{기계}#가격", "name": "가격 자리", "slot": "가격 자리", "spec": "", "unit": "-", "mode": "rule", - "rule": {"field": "키", "op": "같음", "from": {"box": "v", "name": "기계"}}}]}}, - {"id": "m", "kind": "input", "name": "재료", "input": {"mode": "group", "group": "재료", "variables": [], "elements": [ - {"ref": "{}", "name": "레미콘 자리", "slot": "레미콘 자리", "spec": "", "unit": "㎥", "mode": "rule", "scope": {"구분": "레미콘"}, - "rule": {"field": "검색어", "op": "같음", "from": {"box": "v", "name": "a"}, "template": "레미콘 {a}-{b}", - "slots": [{"name": "a", "from": {"box": "v", "name": "a"}}, {"name": "b", "from": {"box": "v", "name": "b"}}]}}]}}, - {"id": "o", "kind": "input", "name": "로직", "input": {"mode": "logic", "elements": [], "variables": [], "logic": { - "key": "GC000270", "outputs": [{"name": "노무비", "unit": "원"}], "designs": [{"name": "기계", "unit": "-"}, {"name": "지역", "unit": "-"}], - "examples": {"지역": "전국평균"}}}}, - ], - "links": [{"id": "l1", "from": {"box": "v", "name": "기계"}, "to": {"box": "o", "name": "기계"}}], - } # fmt: skip - pills = {"기계": "0201-0020", "a": 25, "b": "18"} - got = _run( - "cache.set(path.resolve(path.join(ROOT, 'M01_MasterData', 'M01_MasterData_UI_Logic_Api.ts')), { exports: { fetchLogics: async () => [] } });\n" - "const D = loadTs(path.join(ROOT, 'M02_MasterTemplete', 'DataFlow', 'M02_DF_Entry_Detail.ts'));\n" - f"const lg = {json.dumps(lg, ensure_ascii=False)};\nconst pills = {json.dumps(pills, ensure_ascii=False)};\n" - "const qs = D.queriesOf(lg, (p) => pills[p.name] ?? null, M.elementPort)" - ".map(a => [a.port, a.q.group, a.q.value ?? null, a.q.logic ?? null, !!a.q.slot]);\n" - "out([qs, E.evalLogic(lg, { o: { 노무비: 123 } }, { logics: {} }).ports.o]);" - ) - qs, server = got - assert ["가격 자리", "기계", "0201-0020", None, True] in qs - assert ["레미콘 자리", "재료", "레미콘 25-18", None, True] in qs - designs = {"기계": "0201-0020", "지역": "전국평균"} - assert ["*", "기계", None, {"key": "GC000270", "designs": designs}, False] in qs - assert server == {"노무비": 123} # 서버가 셈한 다른 로직 출력이 있으면 그것 - - -def test_표_찾기_글은_대소문자까지_같아야() -> None: - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - block = [["구분", "값"], ["T_2", 1], ["t_2", 3], ["e", 0.96], ["E", 0.45]] - rule = { - "kind": "lookup", - "conds": [{"col": "구분", "from": {"box": "v", "name": "g"}}], - "col": "값", - } - gs = ["t_2", "T_2", "E", "e", "x"] - got = _run( - RULE - + f"const block = {json.dumps(block)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n" - f"out({json.dumps(gs)}.map(g => {{ const r = R.applyRule(rule, block, {{ g }}); return [r.value, r.why]; }}));" - ) - assert got == [[3, None], [1, None], [0.45, None], [0.96, None], [None, "표에 없는 조건"]] - assert [list(cp._lookup(rule, block, {"g": g})) for g in gs] == got - - -def _fn(i: str, ins: list[str], formula: str) -> dict: - cells = {f"B{k + 2}": {"값": 0} for k in range(len(ins))} - out = f"B{len(ins) + 2}" - cells[out] = {"식": formula} - func = {"sheet": {"id": f"s{i}", "이름": i, "칸": cells}, "variables": []} - func["inputs"] = [{"port": p, "cell": f"B{k + 2}"} for k, p in enumerate(ins)] - func["output"] = {"cell": out, "name": i, "unit": "-"} - return { - "id": i, - "kind": "function", - "name": i, - "color": "#000", - "unit": "-", - "x": 0, - "y": 0, - "func": func, - } - - -def test_빈_단가_쓰는_식은_빈_값_비목_합은_0_끊긴_곳(monkeypatch) -> None: - g = {"mode": "group", "group": "재료", "variables": [], "elements": [ - {"ref": "MT1", "name": "p1", "spec": "", "unit": "-"}, {"ref": "MT2", "name": "p2", "spec": "", "unit": "-"}]} # fmt: skip - boxes = [ - {"id": "g", "kind": "input", "name": "재료", "color": "#000", "unit": "-", "x": 0, "y": 0, "input": g}, - _fn("a", ["p1"], "IF(2=0,0,2*B2)"), - _fn("b", ["p2"], "IF(1=0,0,1*B2)"), - _fn("z", ["a", "b"], "(B2+B3)*2/100"), # 줄 이름을 부르는 덧줄 — 빈 줄이 끼면 빈 값 - _fn("s", ["a", "b", "z"], "SUM(IFERROR(B2,0),IFERROR(B3,0),IFERROR(B4,0))"), - {"id": "o", "kind": "output", "name": "재료비", "color": "#000", "unit": "원", "x": 0, "y": 0, - "output": {"cost": "재료비", "unit": "원"}}, - ] # fmt: skip - pairs = [ - ("g", "p1", "a", "p1"), - ("g", "p2", "b", "p2"), - ("a", "a", "z", "a"), - ("b", "b", "z", "b"), - ] - pairs += [ - ("a", "a", "s", "a"), - ("b", "b", "s", "b"), - ("z", "z", "s", "z"), - ("s", "s", "o", "값"), - ] - links = [{"id": f"l{k}", "from": {"box": a, "name": ap}, "to": {"box": b, "name": bp}} - for k, (a, ap, b, bp) in enumerate(pairs)] # fmt: skip - lg = { - "key": None, - "name": "t", - "resultUnit": "", - "boxes": boxes, - "links": links, - "userElements": [], - } - values = {"g": {"p1": "#N/A", "p2": 100}} - got = _run( - f"const ev = E.evalLogic({json.dumps(lg, ensure_ascii=False)}, {json.dumps(values)});\n" - "out([ev.cost.재료비, ev.ports.z.z, ev.breaks.map(b => [b.kind, b.box])]);" - ) - assert got == [100, "#N/A", [["단가 없는 품목", "a"]]] - from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp - - srv = cp.run(lg, values, lambda key: {}, []) - assert float(srv["cost"]["재료비"]) == 100 and srv["ports"]["z"]["z"] == "#N/A" - assert [[b["kind"], b["box"]] for b in srv["breaks"]] == got[2] diff --git a/resources/tester/test_m02_df_panel.py b/resources/tester/test_m02_df_panel.py deleted file mode 100644 index 0356e18d8..000000000 --- a/resources/tester/test_m02_df_panel.py +++ /dev/null @@ -1,83 +0,0 @@ -"""오른쪽 흐름도 패널(`M02_DF_Panel.ts`) 순수 부분 — 끌어서 정한 너비 · 저장해 둔 너비. - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 판은 빈 흉내 · 순수 함수만). -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Panel.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const stub = new Proxy({}, { get: () => () => undefined }); -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => stub); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((j) => - j.op === "drag" ? m.dragWidth(j.start, j.startX, j.x, j.viewport) - : j.op === "stored" ? m.storedWidth(j.raw) - : j.op === "consts" ? [m.PANEL_MIN, m.STRIP_WIDTH] - : null, -); -process.stdout.write(JSON.stringify(out)); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -def test_왼쪽_가장자리_끌기_너비(): - [consts, wider, narrower, floor, ceil] = run( - [ - {"op": "consts"}, - # 왼쪽으로 100 끌면 넓어짐 - {"op": "drag", "start": 480, "startX": 1000, "x": 900, "viewport": 1600}, - # 오른쪽으로 끌면 좁아짐 - {"op": "drag", "start": 480, "startX": 1000, "x": 1060, "viewport": 1600}, - # 너무 좁게 — 가장 작은 너비에서 멈춤 - {"op": "drag", "start": 480, "startX": 1000, "x": 1400, "viewport": 1600}, - # 너무 넓게 — 화면의 85% 에서 멈춤 - {"op": "drag", "start": 480, "startX": 1000, "x": -2000, "viewport": 1600}, - ] - ) - min_width, strip = consts - assert (min_width, strip) == (320, 40) - assert wider == 580 and narrower == 420 - assert floor == min_width and ceil == 1360 - - -def test_저장해_둔_너비(): - got = run( - [ - {"op": "stored", "raw": "640"}, - {"op": "stored", "raw": "640.6"}, - {"op": "stored", "raw": None}, - {"op": "stored", "raw": "abc"}, - {"op": "stored", "raw": "100"}, - ] - ) - assert got == [640, 641, None, None, None] diff --git a/resources/tester/test_m02_df_router.py b/resources/tester/test_m02_df_router.py deleted file mode 100644 index 676776a66..000000000 --- a/resources/tester/test_m02_df_router.py +++ /dev/null @@ -1,691 +0,0 @@ -# ruff: noqa: E501 — 견본 상자 · 선 덩어리는 한눈에 보이게 한 줄로 둠 -"""단가산출 다이어그램 시험 길(`M02_MasterTemplete/DataFlow/M02_DF_Router.py`) 시험 — PLAN 35-7. - -· Model CfLogic(boxes · links · userElements) 저장 → 열기 → 목록 → 지우기 · 견본(`_sample`)은 읽기만 -· 저장 때 입력 값을 마스터에서 다시 받고 함수 상자 시트를 한 통합문서로 Node 셈 → - 받는 칸에 값만 되돌림 - (입력 → 함수 · 함수 → 함수 직렬 · 표 블록 · 변수 · 출력 상자 비용 종류 · 원가) -· 입력 값 — 인력 · 재료(키 · 검색어 묶음) · 유가전력 · 환율 · 요율 · 기계 값 칸 · 표 블록 · - 한 줄 · 사용자 요소 -· 사용자 요소 만들기 · 지우기 · 권한 없음 403 · 마스터 사본 파일은 안 바뀜 -견본 표 · 다이어그램은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 값에 안 기댐). -""" - -from __future__ import annotations - -import copy -import json -import shutil -import subprocess -from decimal import Decimal -from pathlib import Path - -import pytest -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - -from common_util.common_util_auth import require_system_admin, verify_session -from M01_MasterData import M01_MasterData_Router as m01_router -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData import M01_MasterData_Store_Flow as sflow -from M02_MasterTemplete.DataFlow import M02_DF_Router as cf -from M02_MasterTemplete.DataFlow import M02_DF_Values as dv - -ROOT = Path(__file__).resolve().parents[2] -REAL = store.FOLDER -OWN = "로직_자체.json" -SAMPLE = "QF999995" -TABLE = { - "키": SAMPLE, - "원문번호": "", - "구분": "", - "상세구분": "", - "이름": "견본 높이 표", - "기준": "1㎡", - "출처": "자체", - "조건": {"돌": "고르기", "높이": "범위"}, - "범위규칙": "초과·이하", - "값칸": {"석공": "인", "증가율": "%"}, - "줄": [ - {"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]}, - {"돌": "견치돌", "높이": 4, "석공": 0.7}, - ], - "주": [], -} -HEAD = ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] -NO_NODE = shutil.which("node") is None - - -def _first(name: str, pred) -> dict: - rows = json.loads((store.FOLDER / name).read_text(encoding="utf-8"))["줄"] - return next(r for r in rows if pred(r)) - - -def _labor() -> tuple[str, Decimal]: - row = _first( - "인력.json", - lambda r: ( - r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float)) - ), - ) - return row["키"], Decimal(str(row["값"])) - - -def _box(id_: str, kind: str, name: str, **part) -> dict: - return {"id": id_, "kind": kind, "name": name, "color": "", "unit": "-", "x": 0, "y": 0, **part} - - -def _func(sheet: dict, inputs: list, output: dict, variables: list | None = None) -> dict: - return {"sheet": sheet, "inputs": inputs, "variables": variables or [], "output": output} - - -def _link(i: str, a: str, pa: str, b: str, pb: str) -> dict: - return {"id": i, "from": {"box": a, "name": pa}, "to": {"box": b, "name": pb}} - - -def _logic(labor: str, key: str | None = None) -> dict: - """노임 × 2 × 면적 × (1 + 할증%) = 노무비 → +1 = 경비(함수 → 함수) · 표 블록 E1 → H3 = 품(값) - · 브라우저가 보낸 받는 칸 값(999)은 엉터리.""" - s1 = {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 999}, "B1": {"식": "A1*2*A2*(1+A3/100)"}}} - group = {"mode": "group", "elements": [], "variables": []} - return { - "key": key, - "name": "견본 다이어그램", - "resultUnit": "원/㎡", - "boxes": [ - _box("in1", "input", "인력", input={**group, "group": "인력", - "elements": [{"ref": labor, "name": "석공", "spec": "", "unit": "인"}]}), - _box("in2", "input", "표", - input={**group, "group": "소요량", "table": {"key": SAMPLE}}), - _box("in3", "input", "면적", input={"mode": "variable", "elements": [], "variables": [ - {"name": "면적", "type": "number", "unit": "㎡", "example": 3}]}), - _box("f1", "function", "노무 셈", func=_func( - s1, [{"port": "노임", "cell": "A1"}, {"port": "면적", "cell": "A2"}], - {"cell": "B1", "name": "노무비", "unit": "원"}, - [{"name": "할증", "type": "number", "unit": "%", "example": 10, "cell": "A3"}])), - _box("f2", "function", "더하기", func=_func( - {"id": "s2", "이름": "더하기", "칸": {"B1": {"식": "A1+1"}}}, - [{"port": "앞", "cell": "A1"}], {"cell": "B1", "name": "합", "unit": "원"})), - _box("f3", "function", "표 찾기", func=_func( - {"id": "s3", "이름": "표 찾기", "칸": {"A1": {"식": "H3"}}}, - [{"port": "표", "cell": "E1"}], {"cell": "A1", "name": "품", "unit": "인"})), - _box("o1", "output", "노무비", output={"cost": "노무비", "unit": "원"}), - _box("o2", "output", "경비", output={"cost": "경비", "unit": "원"}), - _box("o3", "output", "품", output={"cost": "값", "unit": "인"}), - ], - "links": [ - _link("l1", "in1", "석공", "f1", "노임"), - _link("l2", "in3", "면적", "f1", "면적"), - _link("l3", "f1", "노무비", "f2", "앞"), - _link("l4", "in2", "표", "f3", "표"), - _link("l5", "f1", "노무비", "o1", "값"), - _link("l6", "f2", "합", "o2", "값"), - _link("l7", "f3", "품", "o3", "값"), - ], - "userElements": [], - } # fmt: skip - - -@pytest.fixture -def folder(tmp_path: Path, monkeypatch) -> Path: - master = tmp_path / "master" - master.mkdir() - for path in REAL.glob("*.json"): - if path.name == OWN: - continue - if not path.name.startswith("_") or path.name == store.mk.BOOK.name: - shutil.copy(path, master / path.name) - host = sorted(master.glob("소요량_*.json"))[0] - data = json.loads(host.read_text(encoding="utf-8")) - data["표"].append(TABLE) - host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - monkeypatch.setattr(store, "FOLDER", master) - return master - - -@pytest.fixture -def client(folder: Path) -> TestClient: - app = FastAPI() - app.include_router(m01_router.router) - app.include_router(cf.router) - return TestClient(app) - - -def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: - res = client.post(url, json=body) - assert res.status_code == want, res.text - return res.json() - - -def _d(v) -> Decimal: - return Decimal(str(v)) - - -def _save(logic: dict) -> dict: - """서버 셈(`M02_DF_Compute.run`) — 받는 칸에 값을 되돌린 흐름 · 값(흐름 저장은 M01 로직 줄 「흐름」 칸).""" - got = cf.dc.run(logic, {}, cf._load, logic.get("userElements") or [], [logic.get("key") or ""]) - return {"key": logic.get("key"), "logic": got.pop("filled"), "values": got} - - -def _store(logic: dict) -> str: - """흐름을 마스터 사본의 새 자체 로직 줄 「흐름」 칸에 저장(M01 `/save` 와 같은 길) — 다른 로직 입력이 부름.""" - own = store.FOLDER / "로직_자체.json" - version = store.read(own.name)[1] if own.is_file() else "" - row = { - "키": "", - "이름": logic["name"], - "결과단위": logic.get("resultUnit") or "원", - "흐름": {**logic, "key": None}, - } - done = sflow.save( - [{"file": own.name, "version": version, "changes": [{"op": "add", "row": row}]}] - ) - return done["added"][0]["key"] - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_저장_서버가_다시_셈_값만_되돌림(client: TestClient, folder: Path) -> None: - labor, price = _labor() - before = {p.name: p.read_bytes() for p in folder.glob("*.json")} - - saved = _save(_logic(labor)) - v = saved["values"] - labor_cost = price * 2 * 3 * Decimal("1.1") - assert _d(v["ports"]["f1"]["노무비"]) == labor_cost - assert _d(v["ports"]["f2"]["합"]) == labor_cost + 1 # 함수 → 함수(직렬) - assert _d(v["ports"]["f3"]["품"]) == Decimal("0.7") # 표 블록 E1 부터 → H3 - assert _d(v["cost"]["노무비"]) == labor_cost and _d(v["cost"]["경비"]) == labor_cost + 1 - assert _d(v["cost"]["계"]) == 2 * labor_cost + 1 and _d(v["cost"]["값들"]["품"]) == Decimal( - "0.7" - ) - assert v["errors"] == [] - - boxes = {b["id"]: b for b in saved["logic"]["boxes"]} - c1 = boxes["f1"]["func"]["sheet"]["칸"] - assert c1["A1"]["값"] == price and c1["A2"]["값"] == 3 and c1["A3"]["값"] == 10 # 999 는 버림 - assert c1["B1"] == {"식": "A1*2*A2*(1+A3/100)"} and boxes["f1"]["func"]["sheet"]["id"] == "s1" - assert _d(boxes["f2"]["func"]["sheet"]["칸"]["A1"]["값"]) == labor_cost # 참조 아닌 값으로 - c3 = boxes["f3"]["func"]["sheet"]["칸"] - assert [c3[f"{c}1"]["값"] for c in "EFGHIJ"] == HEAD and c3["F3"]["값"] == c3["G3"]["값"] == 4 - assert saved["logic"]["links"] == _logic(labor)["links"] - - assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로 - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_빈_입력_값은_쉬운_말(client: TestClient) -> None: - labor, _ = _labor() - logic = _logic(labor) - logic["boxes"][0]["input"]["elements"][0]["ref"] = "LB999999" - v = _save(logic)["values"] - assert v["errors"] and "석공" in v["errors"][0] - - -def test_입력_값_그룹마다(client: TestClient) -> None: - labor, price = _labor() - mat = _first( - "재료_자재품목.json", - lambda r: isinstance(r.get("물가자료"), (int, float)) and r.get("구분"), - ) - word = f"{mat['이름']} {mat['규격'] or ''}".strip() - cond = {"구분": mat["구분"], "상세구분": mat["상세구분"], "검색어": word} - line = {"종류": "재료", "요소": cond, "이름": "재료", "단위": mat["단위"], "수량": "1", - "비목": "재료비"} # fmt: skip - row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체", - "입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip - want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"] - oil = _first("재료_유가전력.json", lambda r: isinstance(r.get("값"), (int, float))) - fx = _first("환율_한국은행환율.json", lambda r: isinstance(r.get("값"), (int, float))) - rate = _first("요율_조달청제비율.json", lambda r: isinstance(r.get("값"), dict)) - eq = _first("기계.json", lambda r: isinstance((r.get("값") or {}).get("가격"), (int, float))) - local = [{"id": "UE-0007", "group": "재료", "name": "현장 자재", "spec": "", "unit": "개", - "value": 500}] # fmt: skip - items = [ - {"id": "l", "group": "인력", "ref": labor}, - { - "id": "m", - "group": "재료", - "ref": json.dumps(cond, ensure_ascii=False), - "unit": mat["단위"], - }, - {"id": "o", "group": "유가전력", "ref": oil["키"]}, - {"id": "f", "group": "환율", "ref": fx["키"]}, - {"id": "r", "group": "요율", "ref": rate["키"]}, - {"id": "e", "group": "기계", "ref": eq["키"], "field": "가격"}, - {"id": "e2", "group": "기계", "ref": eq["키"]}, - {"id": "t", "group": "소요량", "table": {"key": SAMPLE}}, - {"id": "t1", "group": "소요량", "table": {"key": SAMPLE, "row": 1}}, - {"id": "u", "group": "재료", "ref": "UE-0007"}, - {"id": "w", "group": "인력", "ref": oil["키"]}, - {"id": "z", "group": "모름", "ref": ""}, - ] - body = {"items": items, "userElements": local} - got = {v["id"]: v for v in _post(client, "/api/m02/df/values", body)["values"]} - assert _d(got["l"]["value"]) == price - assert got["m"]["value"] == want # 검색어 묶음 = 기존 /calc 재료 줄 단가 - assert got["o"]["value"] == oil["값"] and got["f"]["value"] == fx["값"] - assert got["r"]["value"] == rate["값"]["rate_percent"] and got["r"]["unit"] == "%" - assert got["e"]["value"] == eq["값"]["가격"] - ruled = [*HEAD, dv.RANGE_RULE] # 끝 칸 = 줄 경계 말(범위규칙 없는 표 = 초과·이하) - assert got["t"]["rows"][0] == ruled - assert got["t1"]["rows"] == [ruled, ["견치돌", 4, 4, 0.7, None, None, "초과·이하"]] - assert got["u"]["value"] == 500 and got["u"]["unit"] == "개" - for k in ("w", "z", "e2"): # 기계 묶음은 칸을 골라야 함 - assert got[k]["ok"] is False and got[k]["reason"], got[k] - - -def test_사용자_요소는_흐름_안_것만(client: TestClient, folder: Path) -> None: - before = {p.name: p.read_bytes() for p in folder.glob("*.json")} - mine = [{"id": "UE-0002", "group": "재료", "name": "나", "spec": "", "unit": "회", "value": None}, - {"id": "UE-0003", "group": "재료", "name": "다", "spec": "", "unit": "개", "value": 2.5}] # fmt: skip - items = [ - {"id": "x", "group": "재료", "ref": "UE-0002"}, - {"id": "y", "group": "재료", "ref": "UE-0003"}, - ] - got = _post(client, "/api/m02/df/values", {"items": items, "userElements": mine})["values"] - assert got[0]["ok"] is False and _d(got[1]["value"]) == Decimal("2.5") # 빈 값은 쉬운 말 - assert client.get("/api/m02/df/user-elements").status_code in (404, 405) # 공용 목록 길 없음 - assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터에 안 씀 - - -def test_시스템_관리자가_아니면_403(folder: Path) -> None: - app = FastAPI() - app.include_router( - cf.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] - ) - app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} - client = TestClient(app) - assert client.post("/api/m02/df/values", json={"items": []}).status_code == 403 - assert client.get("/api/m02/df/design-inputs", params={"key": "GF000219"}).status_code == 403 - app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} - assert client.post("/api/m02/df/values", json={"items": []}).json() == {"values": []} - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None: - """입력 판(sub3) 기계 ref 「EQ키#칸」 · 물음 {ref, field} · 「시간당 경비」 = 기계경비 로직.""" - logic = min( - (r for r in dv._machine_family() if "지역" in [i["이름"] for i in r["입력"]]), - key=dv._machine_rank, - ) - code = str(dv._codes(logic)[0]) - eq = _first("기계.json", lambda r: str(r.get("원문번호")) == code) - family = [r for r in dv._machine_family() if code in [str(c) for c in dv._codes(r)]] - chosen = min(family, key=dv._machine_rank) - given = { - i["이름"]: (code if i["이름"] == "기계" else (i.get("고르기") or [None])[0]) - for i in chosen["입력"] - } - want = _post(client, "/api/m01/calc", {"key": chosen["키"], "inputs": given})["sums"]["계"] - - items = [ - {"id": "p", "group": "기계", "ref": f"{eq['키']}#가격"}, - {"id": "p2", "group": "기계", "ref": eq["키"], "field": "가격"}, - {"id": "h", "group": "기계", "ref": f"{eq['키']}#시간당 경비"}, - {"id": "h2", "group": "기계", "ref": eq["키"], "field": "시간당 경비"}, - {"id": "x", "group": "기계", "ref": f"{eq['키']}#없는칸"}, - ] - got = {v["id"]: v for v in _post(client, "/api/m02/df/values", {"items": items})["values"]} - assert got["p"]["value"] == got["p2"]["value"] == eq["값"]["가격"] - assert got["h"]["value"] == got["h2"]["value"] == want and got["h"]["unit"] == "원/시간" - assert got["x"]["ok"] is False and "없는칸" not in got["x"].get("value", "") - - # 저장 — 입력 상자 요소 ref 그대로(「EQ키#칸」) - box = _box("m1", "input", "기계", input={"mode": "group", "group": "기계", "variables": [], - "elements": [{"ref": f"{eq['키']}#시간당 경비", "name": "기계 · 시간당 경비", - "spec": "", "unit": "원/시간"}]}) # fmt: skip - f1 = _box("f1", "function", "곱", func=_func( - {"id": "s1", "이름": "곱", "칸": {"B1": {"식": "A1*2"}}}, - [{"port": "단가", "cell": "A1"}], - {"cell": "B1", "name": "경비", "unit": "원"})) # fmt: skip - o1 = _box("o1", "output", "경비", output={"cost": "경비", "unit": "원"}) - body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1], - "links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"), - _link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip - saved = _save(body) - assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want) - - -# ── 35-9 넓힘 — 재료 셋 · 자재 찾기 · 다른 로직 · 설계 때 정할 것 · 선 규칙 · 끝수 · 표준 표 ───── -PIPES = [ # 견본 자재(사본 자재품목에만 더함) - {"이름": "견본관", "규격": "D300", "물가자료": 30000}, - {"이름": "견본관", "규격": "D450", "물가자료": 45000}, - {"이름": "견본관", "규격": "D450 B종", "물가자료": 47000}, -] - - -def _add_pipes(folder: Path) -> list[str]: - path = folder / "재료_자재품목.json" - data = json.loads(path.read_text(encoding="utf-8")) - cols = list(data["줄"][0]) - keys = [] - for i, p in enumerate(PIPES): - row = dict.fromkeys(cols) - row.update( - {"키": f"MT99999{i}", "구분": "시험구분", "상세구분": "시험상세", "단위": "m", **p} - ) - data["줄"].append(row) - keys.append(row["키"]) - path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - return keys - - -def _slot(**slot) -> dict: - return {"id": "s", "group": "재료", "slot": slot} - - -def test_재료_세_방식(client: TestClient, folder: Path) -> None: - keys = _add_pipes(folder) - scope = {"구분": "시험구분", "상세구분": "시험상세", "찾는말": "견본관"} - ask = [ - {**_slot(mode="fixed", ref=keys[0]), "id": "fixed"}, - {**_slot(mode="choice", default=keys[0]), "id": "choice"}, - {**_slot(mode="choice", default=keys[0]), "id": "picked", "pick": keys[1]}, - {**_slot(mode="choice"), "id": "none"}, - {**_slot(mode="rule", scope=scope, field="규격", op="포함"), "id": "one", "value": "D300"}, - {**_slot(mode="rule", scope=scope, field="규격", op="포함"), "id": "many", "value": "D450"}, - {**_slot(mode="rule", scope=scope, field="규격", op="같음"), "id": "same", "value": "D450"}, - { - **_slot(mode="rule", scope=scope, field="이름", op="포함"), - "id": "zero", - "value": "없는관", - }, - ] - got = {v["id"]: v for v in _post(client, "/api/m02/df/values", {"items": ask})["values"]} - assert got["fixed"]["value"] == got["choice"]["value"] == 30000 - assert got["picked"]["value"] == 45000 and got["none"]["ok"] is False - assert got["one"]["value"] == 30000 and got["same"]["value"] == 45000 - assert got["many"]["ok"] is False and "2개" in got["many"]["reason"] - assert [c["spec"] for c in got["many"]["candidates"]] == ["D450", "D450 B종"] - assert got["zero"]["ok"] is False and got["zero"]["reason"].startswith("맞는 재료 없음") - - -def test_자재_찾기_띄어쓰기_AND_쪽(client: TestClient, folder: Path) -> None: - keys = _add_pipes(folder) - - def find(**q) -> dict: - res = client.get("/api/m02/df/materials", params=q) - assert res.status_code == 200, res.text - return res.json() - - assert find(q="견본관 D450")["total"] == 2 - assert [x["ref"] for x in find(q="견본관 b종")["items"]] == [keys[2]] - assert find(q=f"{keys[1]}")["total"] == 1 # 키로도 - page = find(q="견본관", sub="시험구분", detail="시험상세", page=2, size=2) - assert page["total"] == 3 and page["page"] == 2 and len(page["items"]) == 1 - one = page["items"][0] - assert one == {"ref": keys[2], "name": "견본관", "spec": "D450 B종", "unit": "m", "price": 47000, - "구분": "시험구분", "상세구분": "시험상세"} # fmt: skip - - -def _area(x: int = 100) -> dict: - """면적(예시 2) × x = 결과(값) — 다른 로직이 부를 견본.""" - return { - "key": None, - "name": "면적 곱", - "resultUnit": "-", - "boxes": [ - _box("v", "input", "면적", input={"mode": "variable", "elements": [], "variables": [ - {"name": "면적", "type": "number", "unit": "㎡", "example": 2}]}), - _box("f", "function", "곱", func=_func({"id": "s", "이름": "곱", "칸": {"B1": {"식": f"A1*{x}"}}}, - [{"port": "면적", "cell": "A1"}], {"cell": "B1", "name": "곱", "unit": "-"})), - _box("o", "output", "결과", output={"cost": "값", "unit": "-"}), - ], - "links": [_link("a", "v", "면적", "f", "면적"), _link("b", "f", "곱", "o", "값")], - "userElements": [], - } # fmt: skip - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_다른_로직_값_설계_값_순환(client: TestClient) -> None: - a = _store(_area()) - assert _post(client, "/api/m02/df/logic-value", {"key": a})["outputs"] == {"결과": 200} - assert _post(client, "/api/m02/df/logic-value", {"key": a, "designs": {"면적": 5}})[ - "outputs" - ] == {"결과": 500} - - ref = {"key": a, "name": "면적 곱", "outputs": [{"name": "결과", "unit": "-"}], - "designs": [{"name": "면적", "unit": "㎡"}]} # fmt: skip - b = { - "key": None, "name": "다른 로직 잇기", "resultUnit": "원", - "boxes": [ - _box("L", "input", "면적 곱", input={"mode": "logic", "elements": [], "variables": [], "logic": ref}), - _box("o", "output", "경비", output={"cost": "경비", "unit": "원"}), - ], - "links": [_link("a", "L", "결과", "o", "값")], - "userElements": [], - } # fmt: skip - saved = _save({**b, "key": _store(b)}) - assert ( - saved["values"]["ports"]["L"] == {"결과": 200} - and _d(saved["values"]["cost"]["경비"]) == 200 - ) - designs = client.get("/api/m02/df/design-inputs", params={"key": saved["key"]}).json()[ - "designs" - ] - assert [(d["box"], d["port"], d["kind"]) for d in designs] == [("L", "면적", "다른 로직")] - - # 순환 — 로직이 자기를 부름 - loop = copy.deepcopy({**b, "key": saved["key"]}) - loop["boxes"][0]["input"]["logic"] = {**ref, "key": saved["key"]} - got = _save(loop)["values"] - assert any("서로를 부름" in e for e in got["errors"]) - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_선_규칙_끝수_재료_조건_설계_때_정할_것(client: TestClient, folder: Path) -> None: - """Eval 과 같은 뜻 — 규칙은 값 기준 · 막힌 선(통과 안 됨 · 표에 없는 조건)은 받는 상자를 멈춤.""" - keys = _add_pipes(folder) - scope = {"구분": "시험구분", "상세구분": "시험상세", "찾는말": "견본관"} - vars_ = [{"name": "돌", "type": "choice", "unit": "-", "example": "깬돌"}, - {"name": "높이", "type": "number", "unit": "m", "example": 2}, - {"name": "관경", "type": "text", "unit": "-", "example": "D300"}] # fmt: skip - pipe = {"ref": "", "name": "관", "slot": "관 자리", "spec": "", "unit": "m", "mode": "rule", "scope": scope, - "rule": {"field": "규격", "op": "포함", "from": {"box": "v", "name": "관경"}}} # fmt: skip - pick = {"ref": "", "name": "관2", "slot": "골라 쓰는 관", "spec": "", "unit": "m", "mode": "choice", - "default": keys[1]} # fmt: skip - sheet = {"id": "s", "이름": "셈", "칸": {"B1": {"식": "A1+A2+A3"}}} - lg = { - "key": None, "name": "규칙 견본", "resultUnit": "원", - "boxes": [ - _box("v", "input", "값들", input={"mode": "variable", "elements": [], "variables": vars_}), - _box("t", "input", "표", input={"mode": "group", "group": "소요량", "elements": [], - "variables": [], "table": {"key": SAMPLE}}), - _box("m", "input", "재료", input={"mode": "group", "group": "재료", "elements": [pipe, pick], - "variables": []}), - _box("f", "function", "셈", func=_func(sheet, [{"port": "품", "cell": "A1"}, - {"port": "관", "cell": "A2"}, {"port": "높이", "cell": "A3"}], - {"cell": "B1", "name": "합", "unit": "원", "round": {"places": 0, "method": "버림"}})), - _box("o", "output", "재료비", output={"cost": "재료비", "unit": "원", - "round": {"places": -1, "method": "버림"}}), - _box("o2", "output", "골라 쓴 관", output={"cost": "값", "unit": "원"}), - _box("o3", "output", "낮을 때만", output={"cost": "값", "unit": "m"}), - ], - "links": [ - {**_link("l1", "t", "표", "f", "품"), "rule": {"kind": "lookup", "col": "석공", "conds": [ - {"col": "돌", "from": {"box": "v", "name": "돌"}}, {"col": "높이", "from": {"box": "v", "name": "높이"}}]}}, - {**_link("l2", "m", "관 자리", "f", "관"), "rule": {"kind": "scale", "op": "+%", "value": 10}}, - {**_link("l3", "v", "높이", "f", "높이"), "rule": {"kind": "gate", "from": {"box": "v", "name": "높이"}, - "op": "≥", "value": 1}}, - _link("l4", "f", "합", "o", "값"), - {**_link("l5", "m", "골라 쓰는 관", "o2", "값"), "rule": {"kind": "round", "places": -3, "method": "반올림"}}, - {**_link("l6", "v", "높이", "o3", "값"), "rule": {"kind": "gate", "from": {"box": "v", "name": "높이"}, - "op": "<", "value": 1}}, - ], - "userElements": [], - } # fmt: skip - got = _save(lg) - v = got["values"] - assert v["ports"]["m"] == { - "관 자리": 30000, - "골라 쓰는 관": 45000, - } # 조건 찾기 D300 · 선택 = 기본 - total = Decimal("0.5") + 30000 * Decimal("1.1") + 2 # 깬돌 · 높이 2 → 「~3」 줄 · +10% · 통과 - assert _d(v["ports"]["f"]["합"]) == int(total) # 함수 출력 끝수 0 자리 버림 - assert _d(v["cost"]["재료비"]) == int(total) // 10 * 10 # 출력 끝수 −1 자리 버림 - assert _d(v["cost"]["값들"]["골라 쓴 관"]) == 45000 and v["cost"]["값들"]["낮을 때만"] is None - assert [b["kind"] for b in v["breaks"]] == ["통과 안 됨"] and v["errors"] == [] - cells = {b["id"]: b for b in got["logic"]["boxes"]}["f"]["func"]["sheet"]["칸"] - assert _d(cells["A1"]["값"]) == Decimal("0.5") and cells["B1"] == { - "식": "A1+A2+A3" - } # 받는 칸에 값만 - - key = _store(lg) # 로직 줄 「흐름」 칸에 저장한 뒤 길로 읽음 - designs = client.get("/api/m02/df/design-inputs", params={"key": key}).json()["designs"] - assert [(d["port"], d["kind"]) for d in designs] == [ - ("돌", "변수"), ("높이", "변수"), ("관경", "변수"), ("골라 쓰는 관", "재료 자리")] # fmt: skip - - lg["boxes"][0]["input"]["variables"][1]["example"] = 3 # 「~3」 줄(위 끝 포함) - assert _d(_save(lg)["values"]["ports"]["f"]["합"]) == int(Decimal("0.5") + 33000 + 3) - lg["boxes"][0]["input"]["variables"][0]["example"] = ( - "견치돌" # 「높이 4」 줄 = 아래 = 위 → 같음(정함) - ) - lg["boxes"][0]["input"]["variables"][1]["example"] = 4 - assert _d(_save(lg)["values"]["ports"]["f"]["합"]) == int(Decimal("0.7") + 33000 + 4) - lookup = { - "kind": "lookup", - "col": "석공", - "conds": [{"col": "높이", "from": {"box": "v", "name": "높이"}}], - } - block = [["높이·아래", "높이·위", "석공"], [4, 4, 0.7], [None, 3, 0.5]] - assert [cf.dc.apply_rule(lookup, block, {"높이": h})[0] for h in (4, 3.5, 3)] == [ - 0.7, - None, - 0.5, - ] - lg["boxes"][0]["input"]["variables"][2]["example"] = "D999" # 맞는 재료 없음 - lg["boxes"][0]["input"]["variables"][0]["example"] = "없는돌" # 표에 없는 조건 → 함수 멈춤 - bad = _save(lg)["values"] - assert any("맞는 재료 없음" in e for e in bad["errors"]) and any( - "표에 없는 조건" in e for e in bad["errors"] - ) - # 함수 결과 오류 = 오류 값(화면 Eval 과 같음 · 받는 칸으로 번짐) - assert bad["ports"]["f"]["합"] == "#N/A" and _d(bad["cost"]["재료비"]) == 0 - - -def test_표준_표_읽기_쓰기_바로_저장(client: TestClient, tmp_path: Path, monkeypatch) -> None: - """고친 표준 표 = `_standard/` 파일에 바로(본 프로그램 값) — 시험은 그 사본 폴더 · 정본 파일은 안 바뀜.""" - real = cf.STANDARD - before = {p.name: p.read_bytes() for p in real.glob("*.json")} - shutil.copytree(real, tmp_path / "_standard") - monkeypatch.setattr(cf, "STANDARD", tmp_path / "_standard") - first = client.get("/api/m02/df/standards").json() - assert first["saved"] is False and first["unit"]["줄"] and first["money"]["줄"] - edited = json.loads(json.dumps(first)) - edited["money"]["줄"][4]["지위"] = 1 - res = client.put( - "/api/m02/df/standards", json={"unit": edited["unit"], "money": edited["money"]} - ) - assert res.status_code == 200 - assert client.get("/api/m02/df/standards").json()["money"]["줄"][4]["지위"] == 1 - saved = json.loads((cf.STANDARD / "금액단위표준.json").read_text(encoding="utf-8")) - assert saved["줄"][4]["지위"] == 1 # 파일에 바로 - assert client.put("/api/m02/df/standards", json={"unit": {}, "money": {}}).status_code == 400 - assert ( - client.delete("/api/m02/df/standards").json()["money"]["줄"][4]["지위"] == 1 - ) # 되돌릴 처음 값 없음 - assert {p.name: p.read_bytes() for p in real.glob("*.json")} == before - - -def test_새_길도_시스템_관리자만(folder: Path) -> None: - app = FastAPI() - app.include_router( - cf.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] - ) - app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} - client = TestClient(app) - for method, url in ( - ("get", "/api/m02/df/materials"), - ("get", "/api/m02/df/standards"), - ("delete", "/api/m02/df/standards"), - ("get", "/api/m02/df/design-inputs?key=x"), - ): - assert getattr(client, method)(url).status_code == 403 # fmt: skip - assert client.post("/api/m02/df/logic-value", json={"key": "x"}).status_code == 403 - - -def test_자재_찾기_이름_속_띄어쓰기(client: TestClient, folder: Path) -> None: - """품목 글 · 찾는 낱말 모두 띄어쓰기를 지우고 비교 — 「흄 관(B형)」 도 「흄관」 으로 걸림.""" - path = folder / "재료_자재품목.json" - data = json.loads(path.read_text(encoding="utf-8")) - row = dict.fromkeys(data["줄"][0]) - row.update( - { - "키": "MT999990", - "구분": "시험구분", - "이름": "견 본 관(B형)", - "규격": "Φ 300mm", - "물가자료": 1, - } - ) - data["줄"].append(row) - path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") - for q in ("견본관 300", "견 본관 Φ300", "본관(b형)"): - got = client.get("/api/m02/df/materials", params={"q": q, "sub": "시험구분"}).json() - assert [x["ref"] for x in got["items"]] == ["MT999990"], q - - -RULE_RUNNER = r""" -const fs = require("fs"), path = require("path"); -const [root, jobs] = [process.argv[1], JSON.parse(process.argv[2])]; -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const cache = {}; -function load(file) { - if (cache[file]) return cache[file].exports; - const m = { exports: {} }; - cache[file] = m; - const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - const req = (spec) => { - if (spec === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; - if (spec.endsWith(".css")) return {}; - const base = spec.startsWith("@ui/") ? path.join(root, "ui_template", spec.slice(4)) - : spec.startsWith("@config/") ? path.join(root, "config", spec.slice(8)) - : spec.startsWith(".") ? path.resolve(path.dirname(file), spec) : null; - if (!base) return require(spec); - const hit = [base + ".ts", base].find((c) => fs.existsSync(c) && fs.statSync(c).isFile()); - if (!hit) throw new Error("못 찾음 " + spec); - return hit.endsWith(".ts") ? load(hit) : require(hit); - }; - new Function("exports", "module", "require", js)(m.exports, m, req); - return m.exports; -} -const rule = load(path.join(root, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts")); -console.log(JSON.stringify(jobs.map((j) => { const r = rule.applyRule(j.rule, j.value, j.conds); return [r.value, r.why]; }))); -""" - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_선_규칙_화면_Rule_ts_와_서버가_같은_값() -> None: - """화면 `M02_DF_Rule.applyRule`(Node 로 그대로) = 서버 `apply_rule` — 값 · 까닭.""" - block = [["돌", "높이·아래", "높이·위", "석공"], ["깬돌", None, 3, 0.5], ["깬돌", 3, 7.5, 0.6], - ["깬돌", 7.5, None, 0.8], ["견치돌", 4, 4, 0.7], ["견치돌", 4, 4, 0.9]] # fmt: skip - look = {"kind": "lookup", "col": "석공", "conds": [{"col": "돌", "from": {"box": "v", "name": "돌"}}, - {"col": "높이", "from": {"box": "v", "name": "높이"}}]} # fmt: skip - gate = {"kind": "gate", "from": {"box": "v", "name": "높이"}, "op": "≥", "value": 3} - jobs = [{"rule": look, "value": block, "conds": {"돌": d, "높이": h}} - for d, h in (("깬돌", 2), ("깬돌", 3), ("깬돌", 3.1), ("깬돌", 7.5), ("깬돌", 100), ("깬돌", -5), - ("견치돌", 4), ("견치돌", 5), ("없는돌", 1))] # fmt: skip - jobs += [{"rule": gate, "value": 10, "conds": {"높이": h}} for h in (2, 3, 4)] - jobs += [{"rule": {"kind": "gate", "from": {"box": "v", "name": "s"}, "op": op, "value": "가"}, - "value": 1, "conds": {"s": s}} for op, s in (("=", "가"), ("≠", "가"), ("=", "나"))] # fmt: skip - jobs += [{"rule": {"kind": "scale", "op": op, "value": 7}, "value": 123.45, "conds": {}} - for op in ("×", "÷", "+%", "-%")] # fmt: skip - jobs += [{"rule": {"kind": "round", "places": p, "method": m}, "value": v, "conds": {}} - for p, m, v in ((0, "반올림", 2.5), (1, "올림", 1.21), (-1, "버림", 1239), (-3, "반올림", 45500), - (2, "버림", -1.239), (0, "반올림", -2.5))] # fmt: skip - result = subprocess.run( - ["node", "-e", RULE_RUNNER, str(ROOT), json.dumps(jobs, ensure_ascii=False)], - capture_output=True, text=True, encoding="utf-8", timeout=120, - ) # fmt: skip - assert result.returncode == 0, result.stderr - screen = json.loads(result.stdout) - server = [cf.dc.apply_rule(j["rule"], j["value"], j["conds"]) for j in jobs] - for job, (sv, sw), (cv, cw) in zip(jobs, screen, server): - same = (sv is None and cv is None) or ( - sv is not None - and cv is not None - and abs(Decimal(str(sv)) - Decimal(str(cv))) < Decimal("1e-9") - ) - assert same and sw == cw, (job, (sv, sw), (cv, cw)) diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py deleted file mode 100644 index 342436f79..000000000 --- a/resources/tester/test_m02_df_sample.py +++ /dev/null @@ -1,319 +0,0 @@ -"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움). - -견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value) -· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) -· 선 규칙(조건 통과 · 끝수) · 재료 방식(조건 찾기) · 다른 로직 입력의 알약이 있는 자리를 가리킴 -· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음 -· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 · - 입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133 -· 파일 안 씀 -""" - -from __future__ import annotations - -import json -import math -import re -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip -NAMES = ["masonry", "ground", "hume", "linked", "value"] -BOX_COLOR = dict( - re.findall( - r'(\w+): "(#[0-9a-fA-F]{6})"', - re.search( - r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8") - ).group(1), - ) -) - - -# ── 알약 · 선 ───────────────────────────────────────────────────────────── -def _ports_out(box: dict) -> list[str]: - if box["kind"] == "function": - return [box["func"]["output"]["name"]] - if box["kind"] == "output": - return [] - inp = box["input"] - if inp["mode"] == "logic": - return [p["name"] for p in inp["logic"]["outputs"]] - return ( - [e["name"] for e in inp.get("elements", [])] - + (["표"] if inp.get("table") else []) - + [v["name"] for v in inp.get("variables", [])] - ) - - -def _ports_in(box: dict) -> list[str]: - if box["kind"] == "function": - return [i["port"] for i in box["func"]["inputs"]] - return ["값"] if box["kind"] == "output" else [] - - -def _deps(lg: dict, box_id: str) -> list[dict]: - """그 상자가 기대는 알약 — 들어온 선 · 그 선 규칙의 조건 알약 · 재료 조건 알약""" - out = [] - for link in lg["links"]: - if link["to"]["box"] != box_id: - continue - out.append(link["from"]) - rule = link.get("rule") or {} - if rule.get("kind") == "gate": - out.append(rule["from"]) - out += [c["from"] for c in rule.get("conds", [])] - box = next(b for b in lg["boxes"] if b["id"] == box_id) - for e in (box.get("input") or {}).get("elements", []): - if e.get("rule"): - out.append(e["rule"]["from"]) - return out - - -def _order(lg: dict) -> list[dict]: - """상자 차례(기대는 것이 앞) — 순환이면 실패""" - boxes = {b["id"]: b for b in lg["boxes"]} - need = {i: {p["box"] for p in _deps(lg, i)} for i in boxes} - done: list[str] = [] - while len(done) < len(boxes): - ready = [i for i in boxes if i not in done and need[i] <= set(done)] - assert ready, "선이 돌고 돎" - done += ready - return [boxes[i] for i in done] - - -def _trace_back(lg: dict, box_id: str) -> set[tuple[str, str]]: - """그 상자 값이 온 길의 출처 알약(입력 상자) — 선 · 규칙 조건 · 재료 조건을 거꾸로 끝까지""" - boxes = {b["id"]: b for b in lg["boxes"]} - seen: set[tuple[str, str]] = set() - todo = [box_id] - while todo: - cur = todo.pop() - for p in _deps(lg, cur): - if boxes[p["box"]]["kind"] == "input": - seen.add((p["box"], p["name"])) - todo.append(p["box"]) # 재료 조건처럼 입력 상자도 다른 알약에 기댐 - else: - todo.append(p["box"]) - return seen - - -# ── 모양 ───────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("name", NAMES) -def test_견본_모양(name: str) -> None: - lg = SAMPLES[name] - assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] - boxes = {b["id"]: b for b in lg["boxes"]} - assert len(boxes) == len(lg["boxes"]) - sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"] - assert len(set(sheets)) == len(sheets) - for b in lg["boxes"]: - assert b["kind"] in ("input", "function", "output") - assert b["color"] == BOX_COLOR[b["kind"]] and b["unit"] - assert isinstance(b["x"], int) and isinstance(b["y"], int) - if b["kind"] == "function": - fn = b["func"] - assert "식" in fn["sheet"]["칸"][fn["output"]["cell"]] # 출력 하나 = 수식 칸 - assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) - if b["kind"] == "output": - assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") - units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []} - if len(units) == 1: - assert b["unit"] == next(iter(units)), b["id"] - for link in lg["links"]: - a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] - assert link["from"]["name"] in _ports_out(a), link - assert link["to"]["name"] in _ports_in(z), link - assert a["kind"] != "output" and a["x"] < z["x"] - for b in lg["boxes"]: - for port in _ports_in(b): - got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] - assert len(got) == 1, (b["id"], port) - # 규칙 · 재료 조건이 가리키는 알약은 있는 자리 - for b in lg["boxes"]: - for p in _deps(lg, b["id"]): - assert p["name"] in _ports_out(boxes[p["box"]]), p - _order(lg) - - -def test_견본_흐름_규칙_방식() -> None: - """견본마다 보여 주려는 흐름 — 조건 통과 선 둘 · 재료 조건 찾기 · 다른 로직 입력 · 출처 사슬""" - g = SAMPLES["ground"] - gates = [x for x in g["links"] if (x.get("rule") or {}).get("kind") == "gate"] - assert len(gates) == 2 and {x["from"]["name"] for x in gates} == {"굴착량"} - assert {x["rule"]["op"] for x in gates} == {"=", "≠"} and len( - {x["rule"]["value"] for x in gates} - ) == 1 - assert ("in-ground", "지반") in _trace_back(g, "out-exp") # 조건 알약도 출처 사슬에 - - h = SAMPLES["hume"] - pipe = next(b for b in h["boxes"] if b["id"] == "in-pipe")["input"]["elements"][0] - assert pipe["mode"] == "rule" and pipe["slot"] == pipe["name"] and pipe["rule"]["op"] == "포함" - assert pipe["scope"]["상세구분"] == "흄관" and pipe["default"] == pipe["ref"] - assert _trace_back(h, "out-mat") == { - ("in-size", "연장"), - ("in-pipe", "흄관"), - ("in-size", "관경"), - } - - lk = SAMPLES["linked"] - logic = next(b for b in lk["boxes"] if b["id"] == "in-logic")["input"]["logic"] - called = SAMPLES[logic["key"]] - assert [p["name"] for p in logic["outputs"]] == [ - b["name"] for b in called["boxes"] if b["kind"] == "output" - ] - assert [p["name"] for p in logic["designs"]] == [ - v["name"] for b in called["boxes"] if b["kind"] == "input" for v in b["input"]["variables"] - ] - assert ("in-logic", "시간당 작업량") in _trace_back(lk, "out-exp") - - m = SAMPLES["masonry"] - assert _trace_back(m, "out-pay") >= { - ("in-var", "높이"), - ("in-poom", "표"), - ("in-rate", "표"), - ("in-labor", "석공"), - } - - -# ── 값(내부 확인) ───────────────────────────────────────────────────────── -@pytest.fixture(scope="module") -def master(): - from M01_MasterData import M01_MasterData_Store as store - - cm = store.cm - return cm, cm.load(folder=store.FOLDER) - - -def _plain(v): - return float(v) if isinstance(v, Decimal) else v - - -def _table_rows(table: dict) -> list[list]: - """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙""" - conds, lines = table.get("조건") or {}, table.get("줄") or [] - names = [*conds, *(table.get("값칸") or {})] - split = { - n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) - } - head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] - rows = [] - for r in lines: - line: list = [] - for n in names: - v = r.get(n) - line += (list(v) if isinstance(v, list) else [v, v]) if n in split else [v] - rows.append(line) - return [head, *rows] - - -def _cut(value, rnd): - if value is None or not rnd: - return value - k = 10 ** rnd["places"] - way = {"반올림": lambda x: math.floor(x + 0.5), "올림": math.ceil, "버림": math.floor}[ - rnd["method"] - ] - return way(round(value * k, 9)) / k - - -def _gate(rule, value, ports): - have = ports[rule["from"]["box"]][rule["from"]["name"]] - want = rule["value"] - ok = {"=": have == want, "≠": have != want, "≥": have >= want, "≤": have <= want, ">": have > want, "<": have < want} # fmt: skip - return value if ok[rule["op"]] else None - - -def _run(lg: dict, master, examples: dict | None = None) -> dict: - """상자 차례대로 — 입력(예시값 · 노임 · 표 · 재료 · 다른 로직) → 선 규칙 → 함수 시트(엔진) → 끝수 → 출력""" - from common_util.common_util_spreadsheet import recalc_workbooks - - cm, files = master - m = cm.mf.Master(files) - tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip - items = next(d for d in files.values() if d.get("그룹") == "재료" and any("흄관" in str(r.get("이름")) for r in d["줄"]))["줄"] # fmt: skip - ports: dict[str, dict] = {} - for b in _order(lg): - into = {} - for x in lg["links"]: - if x["to"]["box"] == b["id"]: - value, rule = ports[x["from"]["box"]][x["from"]["name"]], x.get("rule") or {} - if rule.get("kind") == "gate": - value = _gate(rule, value, ports) - elif rule.get("kind") == "round": - value = _cut(value, rule) - into[x["to"]["name"]] = value - if b["kind"] == "input": - inp = b["input"] - got = { - v["name"]: (examples or {}).get(v["name"], v["example"]) - for v in inp.get("variables", []) - } - for e in inp.get("elements", []): - key = e["ref"] - if ( - e.get("mode") == "rule" - ): # 범위 안 품목 중 규격 / 이름에 조건 알약 값이 든 것 하나 - want = str(ports[e["rule"]["from"]["box"]][e["rule"]["from"]["name"]]) - sc = e["scope"] - hits = [r for r in items if r.get("구분") == sc["구분"] and r.get("상세구분") == sc["상세구분"] - and sc["찾는말"] in r.get("이름", "") and want in str(r.get(e["rule"]["field"], ""))] # fmt: skip - assert len(hits) == 1, (want, [h["키"] for h in hits]) - key = hits[0]["키"] - got[e["name"]] = _plain(cm.mf.element(m, key, {})[0]) - if inp.get("table"): - got["표"] = _table_rows(tables[inp["table"]["key"]]) - if inp["mode"] == "logic": - got = _run(SAMPLES[inp["logic"]["key"]], master) # 설계 값은 그 로직의 예시값 - ports[b["id"]] = got - elif b["kind"] == "function": - fn = b["func"] - sheet = json.loads(json.dumps(fn["sheet"])) - for i in fn["inputs"]: - value = into[i["port"]] - col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) - for r, line in enumerate(value if isinstance(value, list) else [[value]]): - for c, x in enumerate(line): - if x is not None: - sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} - book = {"종류": "통합문서", "판": 1, "열": "df", "서식": [{}], "시트": [sheet]} - got = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] - assert "수" in got, (b["id"], got) - ports[b["id"]] = { - fn["output"]["name"]: _cut(float(got["수"]), fn["output"].get("round")) - } - else: - ports[b["id"]] = {"값": _cut(into["값"], b["output"].get("round"))} - return {b["name"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} - - -def test_값_메쌓기_value_는_calc_와_같음(master) -> None: - from M01_MasterData import M01_MasterData_Store as store - - given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} - want = store.calc("GF000219", {**given, "초과증가율": 80}) - assert abs(_run(SAMPLES["masonry"], master)["노무비"] - float(want["sums"]["계"])) < 1 - given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} - given["qt"] = given.pop("q") - assert _run(SAMPLES["value"], master)["시간당 작업량"] == float( - store.calc("GF000133", given)["result"] - ) - - -def test_값_지반_흄관_다른로직(master) -> None: - # 지반 = 토사 → 굴삭기 길만 · 암 → 브레이커 길만(조건 통과 안 된 선은 빈 칸 = 0) - assert _run(SAMPLES["ground"], master)["경비"] == 100 / 30 * 60000 / 100 - assert _run(SAMPLES["ground"], master, {"지반": "암"})["경비"] == 100 / 8 * 80000 / 100 - # 관경 600 → Φ600mm 품목 · 4 본 × 단가 ÷ 10 m · 0.1 원 미만 버림 - cm, files = master - price = float(cm.mf.element(cm.mf.Master(files), "MT014991", {})[0]) - assert _run(SAMPLES["hume"], master)["재료비"] == _cut( - 4 * price / 10, {"places": 1, "method": "버림"} - ) - # 다른 로직(작업량 15.552) → 운반 시간 120 ÷ 15.552 = 7.72(소수 둘째 반올림) → 7.72 × 50,000 ÷ 120 - assert _run(SAMPLES["linked"], master)["경비"] == _cut( - 7.72 * 50000 / 120, {"places": 1, "method": "버림"} - ) diff --git a/resources/tester/test_m02_df_standards.py b/resources/tester/test_m02_df_standards.py deleted file mode 100644 index 0c69a23b6..000000000 --- a/resources/tester/test_m02_df_standards.py +++ /dev/null @@ -1,157 +0,0 @@ -"""M02 단가산출 흐름도 — 단위 · 금액 표준 표(35-9 · 정함 11) 시험. - -· `_standard/단위표준.json` — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」 표 줄 수 · 병합 칸 폄 · 「-」 바꾸기 · 주 ①~③ -· `_standard/금액단위표준.json` — 「금액의 단위표준」 다섯 줄 · 미만버림 -· 원문 줄 번호가 원문의 그 줄을 가리킴 · 원문 흠은 고치지 않음(사용자가 시험판에서 고침) -""" - -from __future__ import annotations - -import json -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -STD = ROOT / "M02_MasterTemplete" / "DataFlow" / "_standard" -SRC = ( - ROOT - / "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md" -) -UNIT = json.loads((STD / "단위표준.json").read_text(encoding="utf-8")) -MONEY = json.loads((STD / "금액단위표준.json").read_text(encoding="utf-8")) -LINES = SRC.read_text(encoding="utf-8").split("\n") - - -def _cells(no: int) -> list[str]: - return [c.strip() for c in LINES[no - 1].strip().strip("|").split("|")] - - -def test_단위표준_줄_수_병합_원문줄() -> None: - rows = UNIT["줄"] - # 원문 표 = 머리 · 가름 · 몸 1행(단위 · 소수자리) 다음 73 줄 — 병합 칸 10 줄(주석의 14~15 · 17~20 · 32~33 · 35~36 · 45~47 · 61~63) - assert len(rows) == 73 - assert sum(1 for r in rows if r.get("병합")) == 10 - spread = {r["종목"] for r in rows if r.get("병합")} - assert spread == { - "견치돌, 깬돌", - "돌쌓기 및 돌붙임", - "목재(판재)", - "말뚝", - "돌망태", - "관류(管類)", - } - for r in rows: - cells = _cells(r["원문 줄"]) - assert cells[0] == ("" if r.get("병합") else r["종목"]) - assert [r["종목"] for r in rows[:3]] == ["공사연장", "공사폭원", "직공인부"] - assert rows[-1]["종목"] == "마대" - - -def test_단위표준_바꾸기() -> None: - """소수자리 「-」 = 0 · 단위 「-」 = 「없음」 · 빈 칸 = null · 원문 글자는 그대로""" - for r in UNIT["줄"]: - src = _cells(r["원문 줄"]) - for i, key in [(2, "규격 소수자리"), (4, "단위수량 소수자리")]: - want = 0 if src[i] == "-" else (int(src[i]) if src[i] else None) - assert r[key] == want, (r["원문 줄"], key) - for i, key in [(1, "규격 단위"), (3, "단위수량 단위")]: - want = "없음" if src[i] == "-" else (src[i] or None) - assert r[key] == want, (r["원문 줄"], key) - 공사연장 = UNIT["줄"][0] - assert (공사연장["규격 단위"], 공사연장["규격 소수자리"], 공사연장["단위수량 소수자리"]) == ( - "m", - 2, - 0, - ) - 말뚝 = [r for r in UNIT["줄"] if r["종목"] == "말뚝"] - assert 말뚝[1]["단위수량 단위"] is None # 원문 빈 칸 그대로 - assert [n[:1] for n in UNIT["주"]] == ["①", "②", "③"] - - -def test_금액_다섯_줄() -> None: - rows = MONEY["줄"] - assert [(r["종목"], r["지위"]) for r in rows] == [ - ("설계서의 총액", 1000), - ("설계서의 소계", 1), - ("설계서의 금액란", 1), - ("일위대가표의 계금", 1), - ("일위대가표의 금액란", 0.1), - ] - assert all(r["단위"] == "원" and r["비고"] == "미만버림" for r in rows) - assert all(_cells(r["원문 줄"])[0] == r["종목"] for r in rows) - - -# ── standardsList(단위 고르기 · 끝수 제안이 씀) — Node 로 `M02_DF_Standards.ts` 를 바로 돌림 ────────── -HOOKS = r""" -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { stripTypeScriptTypes } from "node:module"; -export async function load(url, ctx, next) { - if (!url.endsWith(".ts")) return next(url, ctx); - let src = readFileSync(fileURLToPath(url), "utf8"); - src = src.replace(/import\s+"[^"]+\.css";/g, ""); - src = src.replace(/import\s+(\w+)\s+from\s+"\.\/_standard\/([^"]+)\.json";/g, "const $1 = globalThis.__std[\"$2\"];"); - src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { - const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); - return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; - }); - return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; -} -""" - - -def _node(body: str) -> object: - import shutil - import subprocess - - assert shutil.which("node") - script = ( - "import { register } from 'node:module';\n" - f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" - "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : k === 'API_BASE_URL' ? '/api' : () => undefined });\n" # noqa: E501 - f"globalThis.__std = {json.dumps({'단위표준': UNIT, '금액단위표준': MONEY}, ensure_ascii=False)};\n" - f"const S = await import({json.dumps((STD.parent / 'M02_DF_Standards.ts').as_uri())});\n" - "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body - ) - result = subprocess.run( - ["node", "--no-warnings", "--input-type=module", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr[-2000:] - return json.loads(result.stdout) - - -def test_standardsList_목록() -> None: - got = _node("out(S.standardsList());") - units = [ - (r["종목"], g, u, p) - for r in UNIT["줄"] - for g, u, p in [ - ("규격", r["규격 단위"], r["규격 소수자리"]), - ("단위수량", r["단위수량 단위"], r["단위수량 소수자리"]), - ] - if u and u != "없음" - ] - assert [ - (x["종목"], x["갈래"], x["단위"], x["소수자리"]) for x in got if x["갈래"] != "금액" - ] == [(n, g, u, p or 0) for n, g, u, p in units] - assert all(x["방법"] == "반올림" for x in got if x["갈래"] != "금액") - money = [x for x in got if x["갈래"] == "금액"] - assert [(x["지위"], x["소수자리"], x["방법"]) for x in money] == [ - (1000, -3, "버림"), - (1, 0, "버림"), - (1, 0, "버림"), - (1, 0, "버림"), - (0.1, 1, "버림"), - ] - - -def test_서버_표준_답이_처음_값과_같음() -> None: - """서버(`M02_DF_Values.standards`) 답 = `_standard` 처음 값 · 그 답으로 만든 목록 = 번들 처음 값 목록""" - from M02_MasterTemplete.DataFlow import M02_DF_Values as dv - - mine = STD.parent / "_data" / "시험-없는-폴더" # 만들지 않음 — 고친 것 없음 → 처음 값 - assert not mine.exists() - got = dv.standards(STD, mine) - assert got == {"unit": UNIT, "money": MONEY, "saved": False} - server = _node(f"out(S.standardsList({json.dumps(got, ensure_ascii=False)}));") - assert server == _node("out(S.standardsList());") diff --git a/resources/tester/test_m02_df_values_old.py b/resources/tester/test_m02_df_values_old.py index f68a7aecd..526b21e22 100644 --- a/resources/tester/test_m02_df_values_old.py +++ b/resources/tester/test_m02_df_values_old.py @@ -1,21 +1,17 @@ # ruff: noqa: E501 -"""흐름 셈 값 찾기 = 옛 셈과 같은 뜻(PLAN 36-2 변환 대조 · sub4) — 마스터 읽기만. +"""값 찾기(`M02_DF_Values` · 남긴 바닥) = 옛 셈과 같은 뜻 — 마스터 읽기만. 못박는 것 - 표 블록 끝 칸 「·범위규칙」 = 줄마다 경계 말(범위규칙 첫 낱말 · 「이하 포함」 이면 맨 윗줄 이상·이하) - 재료 값이 로직 입력(자재지역 · 계약종별)을 읽음 · 빈 단가 = 알림(blank) — 셈은 계속 - 재료 조건 찾기 field 검색어 = 옛 검색어 찾기(틀의 {자리} 를 채운 글) - - 고르기 글 값(「080」)은 글 그대로 — 수로 바꾸면 고르기 밖 """ from __future__ import annotations import json -from decimal import Decimal from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData import M01_MasterData_Store_Flow as sflow -from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc from M02_MasterTemplete.DataFlow import M02_DF_Values as dv @@ -59,27 +55,6 @@ def test_재료는_로직_입력을_읽고_빈_단가는_알림() -> None: assert dv._material(cond, "㎥", old["given"])["value"] == line["단가"] alone = dv._material(cond, "㎥") assert alone["ok"] is False and alone["blank"] is True # 입력이 없으면 그 값만 비움 - el = { - "ref": "MT030715", - "name": "바퀴숫돌", - "spec": "", - "unit": "개", - "mode": "fixed", - } # 값 빈 품목 - flow = { - "boxes": [ - { - "id": "m", - "kind": "input", - "name": "재료", - "input": {"mode": "group", "group": "재료", "elements": [el], "variables": []}, - } - ], - "links": [], - } - if dv._price("재료", "MT030715").get("blank"): - got = dc.run(flow, {}, lambda k: {}, [], ["t"]) - assert got["errors"] == [] and any("바퀴숫돌" in n for n in got["notes"]) def test_검색어_조건_찾기는_옛_검색어_찾기() -> None: @@ -98,8 +73,3 @@ def test_검색어_조건_찾기는_옛_검색어_찾기() -> None: assert dv.slot_price({**slot, "ref": None}, text)["value"] == want # 묶음 없이 범위 + 검색어 assert dv._filled("레미콘 {a}-{b}-{c}", "레미콘 25-18-080") == "레미콘 25-18-080" assert dv._filled("싱글코아바렐 {규격}", "BX") == "싱글코아바렐 BX" - - -def test_고르기_글_값은_글_그대로() -> None: - assert sflow._value("080", {"080"}) == "080" and sflow._value("080") == Decimal(80) - assert sflow._value("3") == Decimal(3) and sflow._value("토사") == "토사"