diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 58be791ee..3f29328c9 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -41,8 +41,10 @@ import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { buildSplit, emptyFlow, + designFields, examplesOf, fetchFlow, + fetchFlowFor, sameShape, withExamples, mountLogicFlow, @@ -78,11 +80,18 @@ interface Opened extends Draft { values: Record; /** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */ flowHost: HTMLElement; + /** 오른쪽 흐름도의 기준(예시값만 다른지 견줄 때) */ + flowBase?: CfLogic; } -/** 줄의 흐름(있으면 흐름도가 정본) */ +/** 줄의 흐름(있으면 흐름도로 셈) */ const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; const OWN_FILE = "로직_자체.json"; +/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) */ +const calcRow = (row: LogicRow | null): LogicRow | null => { + const f = flowOf(row); + return f && row ? { ...row, 입력: designFields(f) } : row; +}; const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); @@ -165,7 +174,8 @@ 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(); - const inputs = JSON.stringify(opened.row.입력 ?? []); + if (!flowOf(opened.row)) scheduleFlow(opened); // 왼쪽 편집기로 고침 — 오른쪽 흐름도도 따라감 + const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []); if (inputs !== calcInputs) drawCalc(); if (isOwnLogic(opened.row)) { // 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림 @@ -188,11 +198,11 @@ export async function mountM01LogicLab( return; } const current = opened; - calcInputs = JSON.stringify(current.row?.입력 ?? []); + calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []); buildCalc(calc, { savedKey: current.origKey, file: current.file, - row: current.row as LogicRow, + row: calcRow(current.row) as LogicRow, prices: current.prices, dirty: () => current.id in drafts, values: current.values, @@ -241,6 +251,22 @@ export async function mountM01LogicLab( 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; @@ -249,7 +275,7 @@ export async function mountM01LogicLab( const own = flowOf(row); const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file); // 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로 - let base = withExamples( + o.flowBase = withExamples( own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)), o.values, ); @@ -257,7 +283,7 @@ export async function mountM01LogicLab( try { const made = await mountLogicFlow({ split, - flow: clone(base), + flow: clone(o.flowBase), title: row.이름 || row.키, readOnly: !admin, notes: got?.notes ?? [], @@ -266,9 +292,9 @@ export async function mountM01LogicLab( onChange: (lg) => { if (!o.row || opened !== o) return; const was = flowOf(o.row); - if (sameShape(lg, was ?? base)) { + if (sameShape(lg, was ?? o.flowBase ?? lg)) { // 예시값만 바뀜 — 고친 것 아님 · 왼쪽 시험 계산 입력에 같은 이름으로 넣고 다시 셈 - base = lg; + o.flowBase = lg; syncLeft(o, examplesOf(lg)); return; } @@ -289,7 +315,7 @@ export async function mountM01LogicLab( /** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */ const syncLeft = (o: Opened, ex: Record): void => { - const names = new Set((o.row?.입력 ?? []).map((x) => x.이름)); + 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 = true; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts index 55b7c00be..e673caed8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -18,8 +18,8 @@ const TEXT = { New: ["신규 로직", "New logic"], Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"], Converted: [ - "옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본", - "Converted from the old formula — saving makes the flow the master", + "이 로직의 계산 방법을 흐름도로 보여 줌", + "Shows how this logic is calculated as a flow diagram", ], Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"], } as const; @@ -122,7 +122,7 @@ export interface FlowAnswer { notes: string[]; } -/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */ +/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 계산 방법을 흐름도로 바꿔 줌 · 못 받으면 null */ export async function fetchFlow(key: string, file = ""): Promise { try { const q = new URLSearchParams({ key, ...(file ? { file } : {}) }); @@ -135,6 +135,27 @@ export async function fetchFlow(key: string, file = ""): 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"), @@ -174,6 +195,25 @@ export function withExamples(lg: CfLogic, vals: Record): CfLogic 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 => { @@ -223,9 +263,9 @@ export async function mountLogicFlow(opts: LogicFlowOpts): Promise { let logicSig = ""; let gone = false; - /** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */ + /** 다른 로직 — 마스터 로직(흐름 받기) */ const getLogic = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null); + (await fetchFlow(key))?.flow ?? null; const left = (): void => { const at = opts.leftHost(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 1d341911d..37eb783c7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -47,6 +47,8 @@ export interface CfAnswer { name?: string; unit?: string; reason?: string; + /** 빈 단가 · 맞는 재료 없음 — 옛 셈처럼 그 값만 비우고 셈은 계속(흐름은 「#N/A」) */ + blank?: boolean; /** 재료 조건 찾기가 여럿 맞을 때 후보 다섯 */ candidates?: CfMaterial[]; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index 86659f59f..affad5786 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -28,6 +28,15 @@ from M01_MasterData import ( ) # _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 = "표", "값" @@ -156,7 +165,7 @@ def _element_value( 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 None + return NA_VALUE # 그 값을 쓰는 식은 빈 값(옛 엔진) — 비목 합은 0 if not got["ok"]: errors.append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}") return None @@ -305,32 +314,6 @@ def design_inputs(lg: dict) -> list[dict]: # ── 선 규칙(`M02_DF_Rule.applyRule` 와 같은 뜻 · 값 기준) ──────────────────── -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 _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 _compare(a: Any, op: str, b: Any) -> bool: if op in ("=", "≠"): return _same(a, b) == (op == "=") @@ -359,67 +342,6 @@ def _rounded(value: Any, r: dict) -> Any: ) -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 apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]: """(값, 까닭) — 까닭 = 통과 안 됨 · 표에 없는 조건 · 표에 여럿 맞음 · 오류 · None.""" kind = rule.get("kind") @@ -465,12 +387,6 @@ def _gate_conds(rule: dict) -> tuple[list[dict], str]: return rows, rule.get("join") or "그리고" -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")) - - # ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ───────────────────────────── def _depends(lg: dict) -> list[tuple[str, str]]: """상자 사이 기댐 — 선 + 선 규칙 조건 알약 + 재료 조건 찾기 알약(Model `depends`).""" @@ -516,7 +432,7 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: 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 v == NA_VALUE else v) + _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"))) @@ -524,7 +440,6 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: REST = "앞 조건이 안 맞아 쉬는 갈래" -RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE) NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음) @@ -545,6 +460,15 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N 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"): diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py index 949f0f2d3..e8e4e6199 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py @@ -284,6 +284,12 @@ class _Flow: 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" @@ -294,8 +300,8 @@ class _Flow: raise Unsupported(f"모르는 식 마디 「{kind}」") def name(self, name: str, take, scope: dict) -> str: - if name in scope: # 덧줄의 비목 합 · 줄 금액 - return self.excel(("@sum", scope[name]), take, scope) + 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: @@ -382,7 +388,7 @@ def _body(f: _Flow, row: dict) -> None: 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} 금액", ("bin", "*", qty, price)) + part = f.func(f"{name} 금액", ("@line", qty, price)) parts[item["비목"]].append(part) lines.setdefault(f"줄.{name}", []).append(part) for extra in row.get("덧줄") or []: @@ -392,7 +398,7 @@ def _body(f: _Flow, row: dict) -> None: parts[extra["비목"]].append(part) for c in COSTS: if parts[c]: - _output(f, c, c, f.func(f"{c} 합", ("@sum", parts[c])), rule, "계") + _output(f, c, c, f.func(f"{c} 합", ("@sum0", parts[c])), rule, "계") # ── 자동 배치 ──────────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css deleted file mode 100644 index 9058ccdb2..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ /dev/null @@ -1,287 +0,0 @@ -/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */ - -.m02-df { - position: fixed; - inset: 0; - z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */ - overflow-x: hidden; - overflow-y: auto; - background: var(--color-canvas); - color: var(--color-text); - font-size: var(--text-body); -} - -.m02-df:focus { - outline: none; -} - -.m02-df__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -/* 위 띠 — 밀어도 위에 붙어 있음 */ -.m02-df__head { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); - padding: var(--spacing-12) var(--spacing-24); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface-raised); -} - -.m02-df__title { - margin: 0; - font-size: var(--text-subheading); -} - -.m02-df__trial { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-df__strip { - display: flex; - flex: 1 1 480px; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); -} - -.m02-df__name { - flex: 1 1 260px; -} - -.m02-df__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; -} - -.m02-df__body { - padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); -} - -/* 시작 화면 */ -.m02-df__start { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--spacing-24); -} - -.m02-df__start-col { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m02-df__start-col h3 { - margin: var(--spacing-8) 0 0; -} - -/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */ -.m02-df__layout { - display: flex; - flex-direction: column; - gap: var(--spacing-16); -} - -.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__lower { - display: grid; - grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); - gap: var(--spacing-16); - align-items: start; -} - -.m02-df__box .m02-df__part-body { - min-height: 50vh; -} - -.m02-df__box-slots { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--spacing-12); -} - -.m02-df__slot { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m02-df__slot h4 { - margin: 0; -} - -@media (max-width: 1100px) { - .m02-df__lower, - .m02-df__start, - .m02-df__box-slots { - grid-template-columns: minmax(0, 1fr); - } -} - -.m02-df__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m02-df__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - width: 100%; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.m02-df__item:hover { - border-color: var(--color-accent); -} - -.m02-df__item-name { - flex: 1; - min-width: 0; -} - -.m02-df__item-unit { - color: var(--color-text-muted); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -.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); -} - -.m02-df__detail-dl { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-4) var(--spacing-12); - margin: 0; -} - -.m02-df__detail-dl dd { - margin: 0; -} - -.m02-df__detail-trace { - margin: 0; - padding-left: var(--spacing-16); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts deleted file mode 100644 index e2438aad8..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ /dev/null @@ -1,644 +0,0 @@ -/* ============================================================================= - * M02_DF_Entry.ts - * 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 - * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨 - * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게. - * 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 — - * M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 · - * 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - createTag, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import type { CfListItem } from "./M02_DF_Api"; -import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; -import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; -import type { CfSel } from "./M02_DF_Verify"; -import type { DfPillSel } from "./M02_DF_Diagram_Parts"; -import { - detailFrame, - drawHead, - drawWarnings, - fetchValues, - loadLogics, - queriesOf, - unitChoices, - type DfPick, -} from "./M02_DF_Entry_Detail"; -import "./M02_DF_Entry.css"; - -/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ -const loadMods = () => - Promise.all([ - import("./M02_DF_Model"), - import("./M02_DF_Eval"), - import("./M02_DF_Api"), - import("./M02_DF_Diagram"), - import("./M02_DF_FuncPanel"), - import("./M02_DF_OutputPanel"), - import("./M02_DF_Summary"), - import("./M02_DF_InputPanel"), - import("./M02_DF_PillPanel"), - import("./M02_DF_LinkPanel"), - import("./M02_DF_Standards"), - ]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({ - model, - ev, - api, - diagram, - func, - output, - summary, - input, - pill, - line, - std, - })); -type Mods = Awaited>; - -const TEXT = { - Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"], - Title: ["단가산출 흐름도", "Cost flow"], - Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], - Close: ["닫기", "Close"], - Loading: ["불러오는 중", "Loading"], - OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], - Start_New: ["새로 만들기", "New"], - Start_Mine: ["내 로직", "My logics"], - Start_MineNone: ["아직 없음", "None yet"], - Name: ["로직 이름", "Logic name"], - NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], - Unit: ["결과 단위", "Result unit"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Unsaved: ["저장 안 함", "Not saved"], - Diagram: ["다이어그램", "Diagram"], - Summary: ["원가", "Cost"], - Color: ["상자 색", "Box colour"], - BoxName: ["상자 이름", "Box name"], - NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], - Standards: ["표준 표", "Standards"], - Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], - NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], - SaveAnyway: [ - "고칠 곳이 있음 — {v} · 그래도 저장할까요?", - "There are issues — {v} · Save anyway?", - ], - Saving: ["저장 중", "Saving"], - Saved: ["저장했음", "Saved"], - SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], - Differs: [ - "저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름", - "Recalculated on save: {s} — differs from the screen value {c}", - ], - Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], - Deleted: ["지웠음", "Deleted"], - Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], - Leave_Ask: [ - "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", - "There are unsaved changes — close without saving?", - ], - Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], -} as const; - -type TextKey = keyof typeof TEXT; - -const te = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text }); - -/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ -export function createDataFlowButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow }); -} - -function openDataFlow(): void { - /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; - let units: string[] | null = null; - const mods = loadMods(); - - const mayLeave = async (): Promise => - !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - - const body = el("main", { className: "m02-df__body" }); - const strip = el("div", { className: "m02-df__strip" }); - const close = createButton({ - label: te("Close"), - variant: "ghost", - size: "sm", - onClick: () => - void (async () => { - if (await mayLeave()) paper ? void showStart() : shut(); - })(), - }); - const page = el("div", { - className: "m02-df", - attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, - children: [ - el("header", { - className: "m02-df__head", - children: [ - el("h2", { className: "m02-df__title", text: te("Title") }), - el("span", { className: "m02-df__trial", text: te("Trial") }), - strip, - close, - ], - }), - body, - ], - }); - const prevOverflow = document.body.style.overflow; - const shut = (): void => { - page.remove(); - document.body.style.overflow = prevOverflow; - }; - page.addEventListener("keydown", (ev) => { - if (ev.defaultPrevented) return; - if (ev.key === "Escape") { - void (async () => { - if (await mayLeave()) shut(); - })(); - return; - } - if (!paper || !(ev.ctrlKey || ev.metaKey)) return; - const key = ev.key.toLowerCase(); - if (key === "z" && !ev.shiftKey) { - ev.preventDefault(); - paper.undo(); - } else if (key === "s") { - ev.preventDefault(); - void paper.save(); - } - }); - - const run = (job: (M: Mods) => Promise): void => { - body.replaceChildren(muted(te("Loading"))); - void mods.then(job).catch((e: unknown) => { - showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); - void showStart(); - }); - }; - - /* ---------- 시작 화면 ---------- */ - - const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { - const row = el("button", { - className: "m02-df__item", - attrs: { type: "button", title }, - children: [ - el("span", { className: "m02-df__item-name", text: name }), - el("span", { className: "m02-df__item-unit", text: unit }), - ], - }); - row.addEventListener("click", onClick); - return row; - }; - - const showStart = async (): Promise => { - paper = null; - strip.replaceChildren(); - const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); - body.replaceChildren( - el("div", { - className: "m02-df__start", - children: [ - el("section", { - className: "m02-df__start-col", - children: [ - bigButton(te("Start_New"), () => - run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), - ), - el("h3", { text: te("Start_Mine") }), - mineHost, - ], - }), - ], - }), - ); - const M = await mods; - try { - const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); - mineHost.replaceChildren( - ...(mine.length - ? mine.map((x) => - listRow( - x.name, - x.resultUnit, - () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), - x.saved, - ), - ) - : [muted(te("Start_MineNone"))]), - ); - } catch (e) { - mineHost.replaceChildren(muted(M.api.cfPlain(e))); - } - }; - const units_ = async (): Promise => (units ??= await unitChoices()); - - /* ---------- 고치는 화면 ---------- */ - - const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { - const choices = await units_(); - let lg = first; - let savedLg: CfLogic | null = saved ? first : null; - let stack: CfLogic[] = []; - let pick: DfPick = null; - const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); - let values: CfValues = {}; - let valueSig = ""; - let logics: NonNullable = {}; - let logicSig = ""; - let logicNotes: string[] = []; - let broken: CfBreak[] = []; - let notes: string[] = []; - let result: CfEvalResult; - let busy = false; - - const unsaved = createTag(te("Unsaved"), "warning"); - const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); - const diagramHost = el("div"); - // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 - const colorBox = el("input", { - className: "m02-df__color-input", - attrs: { type: "color", "aria-label": te("Color") }, - }); - colorBox.addEventListener("change", () => { - const id = pickedBox(); - if (id) edit(M.model.setColor(lg, id, colorBox.value)); - }); - const colorRow = el("label", { - className: "m02-df__color", - children: [el("span", { text: te("Color") }), colorBox], - }); - // 상자 이름 — 입력 · 함수 상자(출력 상자는 출력 판의 이름 칸) · 빈 이름 · 같은 이름은 안 받음 - const boxName = createInputField({ label: te("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 = te("NameTaken", { v: next }); - if (!next || taken || next === box.name) return void (boxName.input.value = box.name); - edit(M.model.updateBox(lg, box.id, { name: next })); - }); - // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) - const funcHost = el("div"); - const outHost = el("div"); - const inHost = el("div"); - const summaryHost = el("div"); - const linkHost = el("div"); - const pillHost = el("div"); - const detail = detailFrame(); - detail.settings.append(boxName.root, colorRow); - detail.body.append(inHost, funcHost, outHost, linkHost, pillHost); - - const evalNow = (): CfEvalResult => { - try { - return M.ev.evalLogic(lg, values, { logics }); - } catch (e) { - const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; - } - }; - - const edit = (next: CfLogic): void => { - if (next === lg) return; - stack = [...stack, lg]; - 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, M.model.elementPort).map((a) => a.q)); - if (sig === valueSig) return; - valueSig = sig; - void fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( - (got) => { - if (sig !== valueSig) return; - values = got.values; - notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; - redraw(); - }, - (e: unknown) => { - notes = [te("Values", { v: M.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 = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null); - void loadLogics(lg, get).then((got) => { - if (sig !== logicSig) return; - logics = got.logics; - logicNotes = got.errors.length ? [te("Logics", { v: 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: M.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: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) }; - } - const id = pick?.kind === "link" ? pick.id : ""; - const link = lg.links.find((l) => l.id === id); - if (link) - return { - back: M.model.traceBack(lg, link.from), - forward: M.model.traceForward(lg, link.to), - }; - } catch { - /* 따라갈 수 없으면 칠하지 않음 */ - } - return null; - }; - - const name = createInputField({ - label: te("Name"), - placeholder: te("NamePlaceholder"), - value: lg.name, - compact: true, - onInput: (v) => edit({ ...lg, name: v }), - }); - name.root.classList.add("m02-df__name"); - const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; - const unit = createSelectField({ - label: te("Unit"), - options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: lg.resultUnit, - compact: true, - onChange: (v) => edit({ ...lg, resultUnit: v }), - }); - - 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(M.model.moveBox(lg, id, x, y)), - link: (from: CfPort, to: CfPort): string | null => { - const got = M.model.link(lg, from, to); - if (got.why) return got.why; - editFromDiagram(got.lg); - return null; - }, - unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), - add: (kind: CfKind, x: number, y: number): void => { - const got = M.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(M.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; - 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") - M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); - if (pick?.kind === "pill") - M.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") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); - if (box?.kind === "function") - M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics }); - if (box?.kind === "output") - M.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 = M.ev.breaks(lg, values, { logics }); - } catch { - broken = result.breaks ?? []; - } - unsaved.hidden = lg === savedLg; - undo.disabled = !stack.length; - deleteButton.hidden = !lg.key; - M.diagram.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, - }); - drawPanel(); - M.summary.renderSummary( - summaryHost, - lg, - result, - { change: edit, select: choose }, - { values }, - ); - note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); - }; - - const save = async (): Promise => { - if (busy) return; - if (!lg.name.trim()) { - note.textContent = te("NoName"); - return void name.input.focus(); - } - let problems: string[] = []; - try { - problems = M.ev.checkLogic(lg, values, { logics }); - } catch { - problems = []; - } - const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); - if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; - busy = true; - note.textContent = te("Saving"); - const screen = result.cost.계; - try { - const got = await M.api.cfSave(lg); - lg = got.logic; - savedLg = lg; - stack = []; - redraw(); - const server = got.values.cost.계; - if (Math.abs(server - screen) > 0.5) - notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; - note.textContent = [...notes, ...result.errors].join("\n"); - showToast(te("Saved"), "success"); - } catch (e) { - note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); - } finally { - busy = false; - } - }; - - const doUndo = (): void => { - const back = stack[stack.length - 1]; - if (!back) return; - stack = stack.slice(0, -1); - lg = back; - name.input.value = lg.name; - unit.select.value = lg.resultUnit; - redraw(); - }; - - const doDelete = async (): Promise => { - if (!lg.key || busy) return; - if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; - try { - await M.api.cfDelete(lg.key); - savedLg = lg; - showToast(te("Deleted"), "success"); - void showStart(); - } catch (e) { - note.textContent = M.api.cfPlain(e); - } - }; - - const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); - const deleteButton = createButton({ - label: te("Delete"), - variant: "danger", - size: "sm", - onClick: () => void doDelete(), - }); - strip.replaceChildren( - name.root, - unit.root, - unsaved, - el("div", { - className: "m02-df__actions", - children: [ - createButton({ - label: te("Standards"), - variant: "ghost", - size: "sm", - onClick: () => - openModal({ - title: te("Standards"), - closeLabel: te("Close"), - mount: (host: HTMLElement) => - M.std.renderStandards(host, { change: () => redraw() }, { title: false }), - }), - }), - undo, - createButton({ label: te("Save"), size: "sm", onClick: () => void save() }), - deleteButton, - ], - }), - ); - body.replaceChildren( - note, - el("div", { - className: "m02-df__layout", - children: [ - part(te("Diagram"), "m02-df__diagram", [diagramHost]), - el("div", { - className: "m02-df__lower", - children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])], - }), - ], - }), - ); - paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; - redraw(); - if (!lg.name) name.input.focus(); - }; - - void showStart(); - document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 - document.body.append(page); - page.tabIndex = -1; - page.focus(); -} - -/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ -function part(title: string, className: string, children: HTMLElement[]): HTMLElement { - return el("section", { - className: `m02-df__part ${className}`, - children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })], - }); -} - -/** 보통 크기 단추 — 칸 폭으로 늘어나지 않게 감쌈 */ -function bigButton(label: string, onClick: () => void): HTMLElement { - return el("div", { children: [createButton({ label, variant: "filled", size: "sm", onClick })] }); -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts index 9c0ae0366..5d4a95474 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts @@ -272,6 +272,7 @@ export async function fetchValues( 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; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index c1cadd47a..378d6b9e7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -46,6 +46,7 @@ export interface CfBreak { | "통과 안 됨" | "표에 없는 조건" | "맞는 재료 없음" + | "단가 없는 품목" | "조건 겹침" | "조건 안 맞음" | "오류"; @@ -112,6 +113,10 @@ const TEXT = { "「{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", @@ -234,7 +239,8 @@ function runFunc( const sheet: Sheet = structuredClone(func.sheet); for (const i of func.inputs) { const v = arrive[i.port] ?? given[i.port] ?? null; - if (v === NA_VALUE) sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; + if (isErrorValue(v)) + sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; // 오류 값 · 빈 단가 else put(sheet.칸, i.cell, v); } for (const v of func.variables) @@ -275,6 +281,19 @@ export function evalLogic( /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ 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( diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index cce05141a..6f068462c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -7,20 +7,14 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; +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, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Api"; +import { cfPlain, cfValues } from "./M02_DF_Api"; import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model"; import { GROUPS, @@ -40,7 +34,6 @@ import { } from "./M02_DF_InputPanel_Build"; import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; import { renderGroupRule } from "./M02_DF_InputPanel_Rule"; -import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick"; import { renderMaterial } from "./M02_DF_InputPanel_Material"; import { renderTablePick } from "./M02_DF_InputPanel_Table"; import { renderVariables } from "./M02_DF_InputPanel_Vars"; @@ -52,17 +45,7 @@ interface Answer { ok: boolean; value?: number | string | null; } -interface UserEl { - id: string; - group: string; - name: string; - spec: string; - unit: string; - value: number | null; -} const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise; -const apiUsers = cfUserElements as unknown as () => Promise; -const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise; const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], @@ -94,15 +77,6 @@ const TEXT = { Loading: ["찾는 중…", "Searching…"], NoRow: ["찾는 요소 없음", "Nothing found"], Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], - Mine: ["내가 만든 것", "Mine"], - NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"], - NewName: ["이름", "Name"], - NewSpec: ["규격", "Spec"], - NewUnit: ["단위", "Unit"], - NewValue: ["값(숫자)", "Value (number)"], - Make: ["만들어 넣기", "Create and add"], - NeedName: ["이름을 적어 주세요", "Enter a name"], - NeedValue: ["값은 숫자로", "Value must be a number"], BoxUnit: ["상자 단위", "Box unit"], Field: ["값 칸", "Value"], NoValue: ["—", "—"], @@ -208,7 +182,7 @@ function renderGroup( 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, newForm(lg, box, group, on)); + body.replaceChildren(find); void drawFind(find, lg, box, group, on); } }; @@ -306,7 +280,7 @@ function drawChosen( .filter((e) => e.mode !== "rule") .map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); if (!items.length) return; - void apiValues(items, lg.userElements).then( + void apiValues(items).then( (answers) => answers.forEach((a) => { const node = values.get(a.id); @@ -360,26 +334,10 @@ async function drawFind( list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); try { const word = q.input.value.trim(); - const [page, users] = await Promise.all([ - fetchRows(file, 1, 40, word, false, sub, detail), - apiUsers().catch((): UserEl[] => []), - ]); + const page = await fetchRows(file, 1, 40, word, false, sub, detail); if (mine !== seq) return; - const known = new Map(); - for (const u of [...lg.userElements, ...users]) - if (u.group === group) known.set(u.id, u as UserEl); - const mineRows: M01Row[] = [...known.values()] - .filter((u) => !word || `${u.name} ${u.spec}`.includes(word)) - .map((u) => ({ - 키: u.id, - 이름: u.name, - 규격: u.spec, - 단위: u.unit, - 값: u.value, - __mine: true, - })); - const rows = [...mineRows, ...(page.rows as M01Row[])]; - count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length }); + 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)) @@ -411,7 +369,6 @@ async function drawFind( children: [ el("strong", { text: String(r["이름"] ?? "") }), el("span", { className: "m02-dfip__muted", text: ` ${r["규격"] ?? ""}` }), - ...(r["__mine"] ? [el("span", { className: "m02-dfip__tag", text: t("Mine") })] : []), ], }), el("span", { @@ -448,66 +405,3 @@ async function drawFind( ); void load(); } - -/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */ -function newForm( - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): HTMLElement { - const name = createInputField({ type: "text", label: t("NewName"), compact: true }); - const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true }); - let unitValue = ""; - const unit = el("div", { className: "m02-dfip__unitfield" }); - const drawUnit = (): void => - unit.replaceChildren( - el("span", { className: "m02-dfip__muted", text: t("NewUnit") }), - createUnitPick({ - value: unitValue || NO_UNIT, - ariaLabel: t("NewUnit"), - onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()), - }), - ); - drawUnit(); - const value = createInputField({ type: "text", label: t("NewValue"), compact: true }); - const go = createButton({ - label: t("Make"), - onClick: () => { - const n = - value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, "")); - if (!name.input.value.trim()) return name.setError(t("NeedName")); - if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue")); - name.setError(undefined); - value.setError(undefined); - apiAdd({ - group, - name: name.input.value.trim(), - spec: spec.input.value.trim(), - unit: unitValue, - value: n, - }).then( - (made) => { - const el1 = elementOf(group, { - 키: made.id, - 이름: made.name, - 규격: made.spec, - 단위: made.unit, - 값: made.value, - }); - const withUser: CfLogic = { - ...lg, - userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }], - }; - on.change(addElement(withUser, box.id, el1)); - }, - (e) => showToast(plain(e), "error"), - ); - }, - }); - const d = el("details", { - className: "m02-dfip__new", - children: [el("summary", { text: t("NewHead") }), name.root, spec.root, unit, value.root, go], - }); - return d; -} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index d52c9bdd8..b650b9eca 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -45,9 +45,6 @@ export const M01_OF: Record = { /** M01 줄 한 개 — 파일 그대로(한글 칸) */ export type M01Row = Record; -/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */ -export const USER_PREFIX = "UE-"; - /** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */ export const MACHINE_SEP = "#"; export const machineFields = (row: M01Row): string[] => @@ -85,13 +82,6 @@ export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef { const key = text(row["키"]); const spec = text(row["규격"]); - if (key.startsWith(USER_PREFIX)) - return { - ref: key, - name: [text(row["이름"]), spec].filter(Boolean).join(" "), - spec, - unit: text(row["단위"]) || "-", - }; // 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름 const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec; const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게) @@ -123,7 +113,7 @@ export function pillNames(box: CfBox): string[] { /** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */ export function queryOf(group: CfGroup, ref: string): Record { - if (group === "기계" && !ref.startsWith(USER_PREFIX)) { + if (group === "기계") { const [key, field] = ref.split(MACHINE_SEP); return { group, ref: key, field: field || "가격" }; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index d23404fed..ef6b708dd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_DF_InputPanel_Logic.ts - * 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면 + * 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽) 가운데 하나를 고르면 * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길 * 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름 * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 @@ -19,7 +19,7 @@ import { fetchLogic, fetchLogics, } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; +import { cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; import { filterLogics, @@ -37,7 +37,7 @@ import { const TEXT = { Help: [ - "마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀", + "마스터 로직을 불러와 그 결과를 이 상자의 알약으로 씀", "Use any master logic or a logic of mine as an input", ], Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], @@ -49,7 +49,6 @@ const TEXT = { Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], Prev: ["이전", "Prev"], Next: ["다음", "Next"], - Mine: ["내가 만든 것", "Mine"], Blocked: ["읽을 수 없음", "Unreadable"], Chosen: ["고른 로직", "Chosen logic"], Converted: [ @@ -97,38 +96,20 @@ const plain = (e: unknown): string => { }; const PAGE = 12; -const MINE = "흐름도"; -/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */ +/** 마스터 로직 목록 — 한 번 받아 둠(M01 `fetchLogics`) */ let cache: Promise | null = null; const loadAll = (): Promise => - (cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])]) - .then(([master, mine]) => [ - ...mine - .filter((i) => !i.sample) - .map((i): MasterLogicBrief => ({ - file: MINE, - 구분: MINE, - 상세구분: t("Mine"), - 키: i.key, - 원문번호: "", - 이름: i.name, - 결과단위: i.resultUnit, - })), - ...master, - ]) - .catch((e: unknown) => ((cache = null), Promise.reject(e)))); + (cache ??= fetchLogics().catch((e: unknown) => ((cache = null), Promise.reject(e)))); /** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */ const readNotes = new Map(); -/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */ -async function flowOf( +/** 마스터 로직 하나를 읽어 흐름도로 — 흐름 받기 길(흐름이 없으면 옛 로직을 바꿔 읽음) */ +const flowOf = ( item: Pick, -): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { - if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] }; - return cfLogicFlow(item.file, item.키); -} +): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> => + cfLogicFlow(item.file, item.키); /** 다른 로직 입력 판 */ export function renderLogicInput( @@ -214,7 +195,7 @@ export function renderLogicInput( el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }), el("span", { className: "m02-dfip__tag", - text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분, + text: i.blocked ? t("Blocked") : i.상세구분, }), ], }), diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py b/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py new file mode 100644 index 000000000..2d54282b3 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Lookup.py @@ -0,0 +1,112 @@ +"""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_Panel.css b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css index f9ffd4fc8..37bdae5e5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css @@ -57,43 +57,64 @@ padding: var(--spacing-4) 0; } -/* 왼쪽 가장자리 끌기 */ +/* 왼쪽 가장자리 끌기 — 늘 보이는 손잡이(세로 막대 + 가운데 잡이) */ .m02-dfp__grip { position: absolute; top: 0; bottom: 0; - left: -4px; + left: -5px; z-index: 2; - width: 8px; + 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) 35%, transparent); + 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-12) 0; - border: 0; + padding: var(--spacing-8) 0; background: var(--color-surface); - color: var(--color-text-secondary); - font: inherit; - writing-mode: vertical-rl; - cursor: pointer; } -.m02-dfp__strip:hover, -.m02-dfp__strip:focus-visible { +/* 공용 작은 단추 크기는 그대로 — 글 방향만 세로 */ +.m02-dfp__strip .m02-dfp__toggle { + writing-mode: vertical-rl; +} + +.m02-dfp__toggle[aria-expanded="true"] { color: var(--color-accent); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index 173a32cd0..1d2c68ee0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -10,15 +10,18 @@ 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"; const TEXT = { Big: ["크게 보기", "Full screen"], - Fold: ["접기", "Collapse"], - Strip: ["흐름도 ▸", "Flow ▸"], - StripTitle: ["흐름도 펴기", "Open the flow panel"], + Standards: ["표준 표", "Standards"], + Toggle: ["흐름도", "Flow"], + ToggleOpen: ["흐름도 펴기", "Open the flow panel"], + ToggleClose: ["흐름도 접기", "Collapse the flow panel"], Close: ["닫기", "Close"], Detail: ["상세 내용", "Details"], Cost: ["원가 · 검증", "Cost · check"], @@ -108,6 +111,38 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane 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: () => 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", @@ -117,31 +152,12 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane const tools = el("div", { className: "m02-dfp__tools", children: [undo] }); const open = el("div", { className: "m02-dfp__open", - children: [ - el("header", { - className: "m02-dfp__head", - children: [ - el("strong", { className: "m02-dfp__title", text: opts.title }), - sm(tf("Big"), () => expand()), - sm(tf("Fold"), () => setFolded(true)), - ], - }), - tools, - note, - diagram, - detail, - costBox, - ], + children: [head, tools, note, diagram, detail, costBox], }); costBox.append(summary); - /* 접힌 띠 */ - const strip = el("button", { - className: "m02-dfp__strip", - text: tf("Strip"), - attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") }, - }); - strip.addEventListener("click", () => setFolded(false)); + /* 접힌 띠 — 같은 「흐름도」 단추만 */ + const strip = el("div", { className: "m02-dfp__strip" }); /* 왼쪽 가장자리 — 끌기 · 화살표 키 */ const grip = el("div", { @@ -195,12 +211,18 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane }); const setFolded = (on: boolean): void => { + // 접기 전 너비를 기억 — 끈 적이 없어도 펴면 그 너비로 + if (on && !folded && width === null && root.isConnected) + width = Math.round(root.getBoundingClientRect().width); folded = on; 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(); }; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.css b/M02_MasterTemplete/DataFlow/M02_DF_Paper.css new file mode 100644 index 000000000..30d08a0ef --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.css @@ -0,0 +1,113 @@ +/* 흐름도 고치는 핵심(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 index fd1a99b9e..40bd21d79 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_Paper.ts * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 - * Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. + * (옛 시험판 고치는 화면을 옮겨 적은) 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. @@ -31,7 +31,7 @@ import { queriesOf, type DfPick, } from "./M02_DF_Entry_Detail"; -import "./M02_DF_Entry.css"; +import "./M02_DF_Paper.css"; const TEXT = { Undo: ["실행 취소", "Undo"], @@ -51,7 +51,7 @@ export interface PaperOpts { readOnly?: boolean; /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ onChange(lg: CfLogic): void; - /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ + /** 다른 로직 입력이 부르는 로직 — 없으면 못 부름(빈 값) */ logics?(key: string): Promise; /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ fitMin?(): number; @@ -186,8 +186,7 @@ export function createPaper(opts: PaperOpts): Paper { const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); if (sig === logicSig) return; logicSig = sig; - const get = - opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null)); + const get = opts.logics ?? (async () => null); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; diff --git a/resources/tester/test_m02_df_inputpanel_build.py b/resources/tester/test_m02_df_inputpanel_build.py index b37fdd1c7..dbfe71c2d 100644 --- a/resources/tester/test_m02_df_inputpanel_build.py +++ b/resources/tester/test_m02_df_inputpanel_build.py @@ -127,12 +127,9 @@ def test_값_읽기_재료는_낮은_값_요율은_비율(): assert call("valueOf", "기계", MACHINE, "가격") == 76547 -def test_사용자_요소는_그_그룹의_ref_로_물음(): - assert call("queryOf", "인력", "UE-0001") == {"group": "인력", "ref": "UE-0001"} +def test_값_물음은_그룹과_ref(): + assert call("queryOf", "인력", "LB000033") == {"group": "인력", "ref": "LB000033"} assert call("queryOf", "재료", "MT1") == {"group": "재료", "ref": "MT1"} - row = {"키": "UE-0001", "이름": "특수공", "규격": "", "단위": "원/일", "값": 300000} - assert call("elementOf", "인력", row)["unit"] == "원/일" - assert call("elementOf", "요율", {**row, "단위": "%"})["unit"] == "%" def test_상자_단위는_같으면_그_단위_여럿이면_하이픈(): diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index ad50754b2..1e1250dd7 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -968,3 +968,69 @@ def test_표_찾기_글은_대소문자까지_같아야() -> None: ) 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]