refactor(794-1b): M01 흐름도 패널 걷음 — 로직 상세 오른쪽 = 원문만 · [신규 로직] = 빈 시트 줄 · 흐름 서버 길 · DataFlow 화면 지움
요구 651 · 653 — 계산 공식 참고용 흐름도(① M01 로직 상세 오른쪽 접는 패널)를 걷음. ② M02 일위대가 다이어그램은 그대로.
지움(64 파일 · 18,666 줄 · 되돌릴 길 = git 이력)
- M01 6 파일 1,064 줄 — Store_Flow.py · UI_LogicLab_Flow.ts/.css · FlowRows.ts · FlowView.ts/.css
- M02_MasterTemplete/DataFlow 43 파일 12,799 줄 — 흐름도 화면 · 셈(Compute · Convert · Lookup · Model · Eval …)
- 시험 15 파일 4,803 줄 — test_m01_logic_flow{view,_engine,_project,_save} · test_m02_df_*(값 찾기 values_old 뺌) — 지운 흐름 코드만 지키던 것
남김(바닥) — DataFlow M02_DF_UnitPick(일위대가 셋) · M02_DF_Values(M02_Item_Prices) · M02_DF_Round · _standard 표 ·
M02_DF_Router = GET /api/m02/df/standards 하나 · M02_DF_Api = cfStandards 하나 · CfRound 꼴은 Round 안으로
고침
- M01 Router — /save = Store_Make.save(개정 확인 · 자체 파일 새 줄 GX · added 그대로) · /calc /text /logic/auto = 옛 셈만 · /logic/flow 둘 지움
- master_formula — 흐름 훅 지움 · 줄에 「흐름」 이 남으면 「흐름도 로직은 걷음」 까닭 오류(저장된 흐름 로직 0 — 마스터 · storage 다시 확인)
- Sangeun_Calc._defaults = 옛 자동값 · M02_Logic_Lib_Read /logic/flow 지움 · main.py 주석
- 화면 — LogicLab(startFlow · scheduleFlow · 예시값 잇기 지움 · [신규 로직] 줄 = 입력 · 중간 · 호표 빈 목록 + 끝수 null) ·
Side(원문 패널 하나) · Source(머리 칸 이름 m01lab-src__top/__title · DF_Panel.css 안 씀) · Detail · View(nowInputs 지움) · Sheet(흐름 갈래 지움) · 글
- 시험 — 지운 파일을 가리키던 목록 넷(spreadsheet_size · 208_1 · 588 · input_prefs) · 717_1 흐름 길 두 줄 · values_old 흐름 셈 부분 지움
- test_713_3_list 기대 — 로직 1,461 → 1,515 · 묶음 307 → 335 · 작업량 21 → 65 · 부르는 506 → 512(782-1 · 2 · 3 건설 기초 로직 54 더함 ·
옛 키만 세면 307 · 21 · 506 그대로)
- 새 지킴 test_794_1b_flow_gone — 흐름 길 404 · 표준 표 남음 · 신규 로직 자체 GX · 흐름 남은 줄 까닭 오류
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 []]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */
|
||||
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<HTMLButtonElement>("[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<void> => {
|
||||
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<string, string>, loud = false): void => {
|
||||
/** 텍스트 수식 · 시트 입력 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 다시 그리고 셈 */
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>): 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<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
};
|
||||
/** 왼쪽 시험 계산 입력 — 칸이 바뀌면 흐름 예시값으로(이 로직이 열려 있을 때만) */
|
||||
let quiet = false;
|
||||
const watchValues = (): Record<string, string> => {
|
||||
const self: Record<string, string> = new Proxy({} as Record<string, string>, {
|
||||
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<void> => {
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Basic.ts
|
||||
* 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null
|
||||
* 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌.
|
||||
* 저장은 하지 않음 — 부른 쪽이 빈 시트 줄로 캐시(drafts)에 넣고 [저장] 때 서버가 GX 새 키를 줌.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
@@ -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<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<FlowAnswer | null> {
|
||||
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<FlowAnswer>;
|
||||
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<FlowAnswer | null> {
|
||||
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<FlowAnswer>;
|
||||
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<CfLogic> {
|
||||
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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>): 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<string>();
|
||||
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<string, string>): void;
|
||||
/** 패널 접기 · 펴기 */
|
||||
fold(on: boolean): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */
|
||||
export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
|
||||
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<string, CfLogic> = {};
|
||||
let logicSig = "";
|
||||
let gone = false;
|
||||
|
||||
/** 다른 로직 — 마스터 로직(흐름 받기) */
|
||||
const getLogic = async (key: string): Promise<CfLogic | null> =>
|
||||
(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<string, Record<string, unknown>> = {};
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<CfCost, FlowGroupKey> = {
|
||||
노무비: "labor",
|
||||
재료비: "material",
|
||||
경비: "cost",
|
||||
값: "plain",
|
||||
};
|
||||
export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"];
|
||||
const COST_OF: Record<Exclude<FlowGroupKey, "plain">, "노무비" | "재료비" | "경비"> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<FlowGroupKey, TextKey> = {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, readonly [string, string]>;
|
||||
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 : "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<string, string>): 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}`,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user