From 7ad2f2c133669aa6f1fedccfaa90d558d21495a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:24:18 +0900 Subject: [PATCH 01/26] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=A0=95=ED=95=A8=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=A8=BC=EC=A0=80?= =?UTF-8?q?=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98=EA=B8=B0=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/흐름도_적용_정함.md | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_정함.md b/docs/raw/M01_쉬운단가/흐름도_적용_정함.md new file mode 100644 index 000000000..2536a147b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_정함.md @@ -0,0 +1,37 @@ +# 흐름도 본 프로그램 적용 — 정함 (PLAN 36 · 브레인 · 10-01 23시) + +사용자 지시 · 결정은 PLAN.md 36장 머리 줄(그대로 인용)이 기준. 아래는 창끼리 맞출 약속. + +## 목적 + +「단가산출 로직」 화면에서 로직을 열면 왼쪽 = 지금 내용(보기) · 오른쪽 = 같은 로직의 흐름도(고치는 곳). 한 로직 · 두 보기 · 고칠 곳은 흐름도 한 곳. 옛 로직은 열 때 흐름도로 자동 변환(파일은 안 바뀜) · 흐름도로 [저장]하면 그 로직은 흐름도가 정본. + +## 약속 + +1. **로직 줄 「흐름」 칸** — 마스터 로직 줄(`resources/master_data/로직_*.json` 의 `줄[]`)에 `흐름`(CfLogic JSON · DataFlow Model 꼴 그대로). 있으면 흐름도가 정본. 쓰는 때 = 화면 [저장](기존 LogicLab `POST /save` 의 줄에 실어 보냄)뿐 · 자동 변환 결과를 파일에 쓰지 않음. 서버는 저장 때 흐름을 검사(Model 검사 꼴)하고 서버 셈으로 다시 셈. +2. **흐름 받기 길** — `GET /api/m01/logic/flow?file=&key=` → `{flow: CfLogic, converted: bool, notes: string[]}` · 줄에 `흐름` 이 있으면 그대로(converted false) · 없으면 변환(converted true · notes = 못 옮긴 곳 쉬운 말). 변환 모듈 = `M02_MasterTemplete/DataFlow/M02_DF_Convert.py`(sub7 · 서버 단독 · 옛 식 파서는 지금 서버가 쓰는 것을 그대로 씀). +3. **변환 규칙** — 입력 → 입력 상자(변수 · 고르기 = choice · 범위) · 중간 식 → 함수 상자(식 하나 = 결과 하나) · 호표 줄 → 요소 알약(단가) + 수량 함수 + 출력 상자(비목별: 노무비 · 재료비 · 경비) · 결과 식 · 덧줄 · 끝수 → 함수 · 끝수 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(M02 방식) · 만약 → IF · 또는 → OR · 최대/최소 → MAX/MIN · 올림/반올림/버림 → ROUNDUP/ROUND/ROUNDDOWN · 찾기(표, 조건=값).칸 → 표 알약 + 선 규칙 「표에서 찾기」 · 로직(…) → 다른 로직 입력 상자 · 자동 배치(입력 왼쪽 → 함수 가운데 → 출력 오른쪽 · 겹침 없음). 전수 대조 = 1,472 로직마다 옛 셈 값(기본 입력 · 시험입력) = 변환 흐름도 셈 값 · 맞는 비율 · 안 맞는 까닭 목록(읽기만 · 파일 안 씀). +4. **계산 길** — 기존 M01 계산(`/calc` 꼴 · 시험 계산 · 다른 화면이 부르는 셈)이 줄에 `흐름` 이 있으면 DataFlow 서버 셈(`M02_DF_Compute.py`)으로 · 없으면 지금 셈 그대로. 입력 이름 = 흐름의 「설계 때 정할 것」(변수 이름) 그대로. +5. **오른쪽 패널** — `M02_MasterTemplete/DataFlow/M02_DF_Panel.ts`(sub8) `mountFlowPanel(host, opts) → handle` · opts = { logic: CfLogic; readOnly?: boolean; title: string; onChange(lg: CfLogic): void; logics?(key) } · handle = { set(lg), destroy(), expand() } · 다이어그램 · 상세 내용 · 원가 · 검증 칸을 패널 안에(세로로) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면 · 닫으면 패널로) · 저장 단추는 패널에 없음(페이지 [저장] 하나) · 실행 취소는 패널 안. +6. **페이지 저장 하나** — 패널 조작 = 페이지 캐시(drafts · sessionStorage)에 쌓임 · [저장] 한 번에 줄(흐름 포함) 보냄 · [고친 것 버리기] = 흐름도 고친 것도 버림 · 자동저장 없음. +7. **왼쪽 보기(흐름 로직)** — 기본정보(지금대로) · 단가산출 상세 = 흐름도 출력 상자를 비용 종류별 줄(이름 · 단위 · 값) · 소계 · 계 · 텍스트 수식 = 함수 상자마다 「식 보기」 두 줄(이름 식 · 값 식) · 시험 계산 = 계산 길(4) 그대로. 흐름 없는 옛 로직의 왼쪽은 지금 그대로. +8. **식 보기 · 조건 만들기** — `M02_DF_FormulaView.ts`(sub2) `formulaLines(box: CfBox, values) → { name: string; named: string; valued: string; branches?: {when: string; then: string; now: boolean}[] }[]` (칸 주소 → 연결 알약 이름 · ×, ÷ · IF 는 만약 / 아니면 줄) — 상세 내용 함수 판과 왼쪽 텍스트 수식(sub5)이 함께 씀. 「조건 만들기」 = 엑셀 시트 안 기능(`A00_Common/spreadsheet/spreadsheet_condition.ts` 새 파일 · 편집기 우클릭 「조건 만들기」 · 부르는 쪽이 `conditions` 선택을 줄 때만 보임 → M02 화면은 그대로) · 만약 [칸 · 알약] [비교] [값] → [칸 · 값] 줄 · 아니면 만약 · 아니면 → 고른 칸에 IF 식 · 값 목록은 부르는 쪽 `valuesFor(a1)` 이 줌. +9. **선 조건(조건 통과)** — 웹앱 모양 그대로 · CfRule gate = `{ kind: "gate"; conds: { from: CfPort; op; value }[]; join: "그리고" | "또는" }`(옛 한 줄 꼴도 읽음) · Model `valueChoices(lg, port) → (string | number)[] | null`(변수 고르기 · 표 칸 값 · 재료 규격 · 없으면 null = 숫자 칸 + 범위 검사) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 이름표 「지반 = 토사 그리고 높이 ≥ 3 일 때만」. +10. **단추** — 「식으로 새로 만들기」 지움 · 「신규 로직」(빈 흐름도 + 기본정보 칸 이름 · 원문번호 · 결과 단위 · 출처 · 구분 자체 → drafts 새 줄 → [저장] 때 서버가 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽 · 흐름도 있으면 함께 복사) · 「시험판 ▾」 과 시험판 단추 둘 지움. +11. **다른 로직 입력** — 고를 대상 = 마스터 로직 전체(기존 로직 목록 찾기) · 내보내는 알약 = 그 로직 출력(흐름이면 출력 상자 · 옛 로직이면 노무비 · 재료비 · 경비 · 계) · 받는 알약 = 그 로직 입력 · 값 = 서버 계산 길(4). +12. **권한** — 흐름도 고치기 · 저장 = 시스템 관리자(공용 로직 포함) · 그 밖은 보기만. +13. **안전** — 서버는 환경 변수 `AISLO_MASTER_DIR` 가 있으면 그 폴더를 마스터 폴더로(없으면 지금대로 · sub4). 화면 저장 시험은 자기 폴더 `tmp/master_copy_36/`(resources/master_data 사본) + 그 변수로 띄운 자기 서버에서만 · 끝나면 git status 로 resources/master_data 안 바뀜 확인. 시험(pytest)은 사본 폴더 · 마스터 내용에 기대지 않음. +14. **정리** — DataFlow · CostFlow 폴더 그대로 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 `resources/tester/fixtures_df/` 로 옮기고 그 밖 견본 지움 · 시험판 입구 지움. + +## 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 36-0b 선 조건 틀(gate conds · join · valueChoices · Rule 식 · 시험) 먼저 → 36-2 변환(M02_DF_Convert.py · 자동 배치 · 전수 대조) | +| sub2 | 36-0 상세 내용 함수 판 「식 보기」 · 칸 연결 표 · 칸 색 · M02_DF_FormulaView.ts · 엑셀 「조건 만들기」(spreadsheet_condition.ts) · LinkPanel 조건 여러 줄 · 값 목록 고르기 | +| sub6 | 36-1 조사(옛 식 문법 전수 · 파서 위치 · 못 옮기는 꼴 · 계산 길을 부르는 곳) → sub7 에 넘김 → 36-7 정리(견본 · 시험 옮김) | +| sub8 | 36-3 M02_DF_Panel.ts(패널 꼴 · 너비 · 접기 · 크게 보기) · 선 이름표 여러 조건 | +| sub4 | 36-4 서버 — 「흐름」 칸 저장 · 검사 · /api/m01/logic/flow · 계산 길 갈래 · 신규 GX · 복사에 흐름 · 권한 · AISLO_MASTER_DIR · Compute 여러 조건 | +| sub5 | 36-4 왼쪽 보기(흐름 로직의 단가산출 상세 · 텍스트 수식 · 시험 계산) | +| sub3 | 36-6 다른 로직 입력 = 마스터 로직 고르기 · 값 | +| sub1 | 36-5 단추 · 시험판 지움 · LogicLab 두 칸 배치 · 패널 잇기 · drafts 저장 하나 → 36-8 끝 검증 | From 20720ea6f852406af96a74f262aba17cc9618289 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:26:58 +0900 Subject: [PATCH 02/26] =?UTF-8?q?feat(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=98=EB=8A=94=20=ED=95=B5=EC=8B=AC=20M02=5FDF?= =?UTF-8?q?=5FPaper.ts(=ED=8C=A8=EB=84=90=20=C2=B7=20=ED=81=AC=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B3=B5=EC=9A=A9)=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B8=B0(PLAN=2036-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entry 시험판 고치는 화면을 옮겨 적음(Entry 는 안 고침) — 다이어그램 · 상세 내용 · 원가 · 검증 · 실행 취소 · onChange 객체 그대로 - 다이어그램 opts.readOnly(끌기 · 잇기 · 더하기 · 지우기 막음 · 고르기 · 흐름 따라가기는 됨) · opts.fitKey(바뀌면 다시 맞춤) - 알약 정보 글 · SVG 도우미를 Parts 로(Diagram 681줄) - DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 78 ++-- .../DataFlow/M02_DF_Diagram_Parts.ts | 59 ++- M02_MasterTemplete/DataFlow/M02_DF_Paper.ts | 341 ++++++++++++++++++ 3 files changed, 429 insertions(+), 49 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Paper.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 9dd41038a..e74e30fa7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -23,6 +23,9 @@ import { restParts, linkLabel, midOf, + pillInfo, + svgAdd as add, + svgLabel as label, pillsOf, resolveDrop, showValue, @@ -46,6 +49,7 @@ interface HostState { panX: number; panY: number; fitted: boolean; + fitKey?: number; watch?: ResizeObserver; } @@ -79,6 +83,9 @@ export function renderDiagram( fitted: false, }; if (opts.selected !== undefined) st.selected = opts.selected; + if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey) + [st.fitKey, st.fitted] = [opts.fitKey, false]; + const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨) if (opts.selectedPill !== undefined) st.pill = opts.selectedPill; states.set(host, st); const values = opts.values ?? {}; @@ -243,7 +250,7 @@ export function renderDiagram( className: "m02-dfdg__ins", children: [ ...ins.map((p) => pillEl(b, p, "in")), - ...(b.kind === "function" ? [plusSlot(b)] : []), + ...(b.kind === "function" && !ro ? [plusSlot(b)] : []), ], }), el("div", { @@ -282,13 +289,13 @@ export function renderDiagram( ArrowUp: [0, -step], ArrowDown: [0, step], }[ev.key]; - if (d && st.selected === b.id) { + if (d && st.selected === b.id && !ro) { ev.preventDefault(); on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); } }); head.addEventListener("pointerdown", (ev) => { - if (ev.button === 0 && !spaceDown) dragBox(ev, b, node); + if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node); }); return node; }; @@ -350,15 +357,8 @@ export function renderDiagram( ); } else if (st.pill) { const p = st.pill; - const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; - const where = lg.links - .filter((l) => mine(p.dir === "in" ? l.to : l.from)) - .map((l) => endName(p.dir === "in" ? l.from : l.to)) - .join(", "); - const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; - const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; - tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") })); - if (p.dir === "out") + tools.push(el("span", { text: pillInfo(lg, p, values) })); + if (p.dir === "out" && !ro) tools.push( sm( "Connect", @@ -371,9 +371,9 @@ export function renderDiagram( const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); tools.push( el("span", { text: rule ? `${line} · ${rule}` : line }), - sm("Disconnect", () => on.unlink(selLink.id), "danger"), + ...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]), ); - } else if (selBox) { + } else if (selBox && !ro) { tools.push(sm("Remove", () => void askRemove(selBox), "danger")); } const legend = @@ -388,18 +388,26 @@ export function renderDiagram( className: "m02-dfdg__bar", children: [ el("strong", { className: "m02-dfdg__title", text: tt("Title") }), - addBtn("input", "AddInput"), - addBtn("function", "AddFunc"), - addBtn("output", "AddOutput"), + ...(ro + ? [] + : [ + addBtn("input", "AddInput"), + addBtn("function", "AddFunc"), + addBtn("output", "AddOutput"), + ]), el("span", { className: "m02-dfdg__gap" }), ...legend, sm("ZoomOut", () => setZoom(st.zoom / 1.2)), zoomLabel, sm("ZoomIn", () => setZoom(st.zoom * 1.2)), sm("Fit", fit), - sm("Arrange", () => { - for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y); - }), + ...(ro + ? [] + : [ + sm("Arrange", () => + Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)), + ), + ]), ], }); const root = el("div", { @@ -426,7 +434,7 @@ export function renderDiagram( st.pill = null; redraw(); } - if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; + if (ro || ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; if (selLink) on.unlink(selLink.id); else if (selBox) void askRemove(selBox); }); @@ -477,32 +485,6 @@ export function renderDiagram( const right = (dir === "out") === (side === "near"); return toWorld(right ? r.right : r.left, r.top + r.height / 2); }; - const add = ( - parent: Element, - tag: K, - attrs: Record, - ) => { - const n = document.createElementNS(SVG, tag); - for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); - parent.append(n); - return n; - }; - const label = (parent: Element, x: number, y: number, text: string, cls: string): number => { - const tag = add(parent, "g", { class: cls }); - const box = add(tag, "rect", { rx: "4", ry: "4" }); - const t = add(tag, "text", { - x: x.toFixed(1), - y: y.toFixed(1), - "dominant-baseline": "central", - "text-anchor": "middle", - }); - t.textContent = text; - const w = (t.getComputedTextLength?.() || text.length * 7) + 10; - Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => - box.setAttribute(k, v.toFixed(1)), - ); - return w; - }; const defs = add(svg, "defs", {}); const arrow = add(defs, "marker", { id: "m02-dfdg-arrow", @@ -657,7 +639,7 @@ export function renderDiagram( } function dragLink(ev: PointerEvent, from: CfPort): void { - if (ev.button !== 0 || spaceDown) return; + if (ev.button !== 0 || spaceDown || ro) return; ev.stopPropagation(); ev.preventDefault(); const pill = ev.currentTarget as HTMLElement; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index cf0616712..649aac97e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_Diagram_Parts.ts * 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 · - * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`. + * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -118,6 +118,10 @@ export interface DiagramOpts { trace?: { back?: CfStep[]; forward?: CfStep[] } | null; /** 끊긴 곳 — Eval breaks */ breaks?: CfBreak[]; + /** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */ + readOnly?: boolean; + /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ + fitKey?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ @@ -330,6 +334,25 @@ export function restParts( return { links, pills, boxes }; } +/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */ +export function pillInfo( + lg: CfLogic, + p: DfPillSel, + values: Record>, +): string { + const name = (e: CfPort): string => + `${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`; + const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; + const where = lg.links + .filter((l) => mine(p.dir === "in" ? l.to : l.from)) + .map((l) => name(p.dir === "in" ? l.from : l.to)) + .join(", "); + const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; + return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""] + .filter(Boolean) + .join(" · "); +} + /* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ /** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ @@ -360,3 +383,37 @@ export function follow( window.addEventListener("pointermove", mv); window.addEventListener("pointerup", up); } + +/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** SVG 마디 하나 붙이기 */ +export function svgAdd( + parent: Element, + tag: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const n = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); + parent.append(n); + return n; +} + +/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */ +export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number { + const tag = svgAdd(parent, "g", { class: cls }); + const box = svgAdd(tag, "rect", { rx: "4", ry: "4" }); + const t = svgAdd(tag, "text", { + x: x.toFixed(1), + y: y.toFixed(1), + "dominant-baseline": "central", + "text-anchor": "middle", + }); + t.textContent = text; + const w = (t.getComputedTextLength?.() || text.length * 7) + 10; + Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => + box.setAttribute(k, v.toFixed(1)), + ); + return w; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts new file mode 100644 index 000000000..6a90118bb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -0,0 +1,341 @@ +/* ============================================================================= + * M02_DF_Paper.ts + * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 + * Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. + * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). + * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). + * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import * as api from "./M02_DF_Api"; +import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram"; +import * as ev from "./M02_DF_Eval"; +import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; +import { renderFuncPanel } from "./M02_DF_FuncPanel"; +import { renderInputPanel } from "./M02_DF_InputPanel"; +import { renderLinkPanel } from "./M02_DF_LinkPanel"; +import * as model from "./M02_DF_Model"; +import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; +import { renderOutputPanel } from "./M02_DF_OutputPanel"; +import { renderPillPanel } from "./M02_DF_PillPanel"; +import { renderSummary } from "./M02_DF_Summary"; +import type { CfSel } from "./M02_DF_Verify"; +import { + detailFrame, + drawHead, + drawWarnings, + fetchValues, + loadLogics, + queriesOf, + type DfPick, +} from "./M02_DF_Entry_Detail"; +import "./M02_DF_Entry.css"; + +const TEXT = { + Undo: ["실행 취소", "Undo"], + Color: ["상자 색", "Box colour"], + BoxName: ["상자 이름", "Box name"], + NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], + Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], + Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], + ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"], +} as const; + +const tp = (key: keyof typeof TEXT, v = ""): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v); + +export interface PaperOpts { + logic: CfLogic; + readOnly?: boolean; + /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ + onChange(lg: CfLogic): void; + /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ + logics?(key: string): Promise; +} + +export interface Paper { + /** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */ + parts: { + diagram: HTMLElement; + detail: HTMLElement; + summary: HTMLElement; + note: HTMLElement; + undo: HTMLButtonElement; + }; + get(): CfLogic; + /** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */ + set(lg: CfLogic): void; + undo(): void; + /** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */ + refit(): void; + redraw(): void; +} + +export function createPaper(opts: PaperOpts): Paper { + const readOnly = !!opts.readOnly; + let lg = opts.logic; + let stack: CfLogic[] = []; + let pick: DfPick = null; + let values: CfValues = {}; + let valueSig = ""; + let logics: NonNullable = {}; + let logicSig = ""; + let logicNotes: string[] = []; + let notes: string[] = []; + let broken: CfBreak[] = []; + let result: CfEvalResult; + let fitKey = 0; + const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); + + const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); + const diagramHost = el("div", { className: "m02-dfp__diagram" }); + const summaryHost = el("div", { className: "m02-dfp__summary" }); + const colorBox = el("input", { + className: "m02-df__color-input", + attrs: { type: "color", "aria-label": tp("Color") }, + }); + colorBox.addEventListener("change", () => { + const id = pickedBox(); + if (id) edit(model.setColor(lg, id, colorBox.value)); + }); + const colorRow = el("label", { + className: "m02-df__color", + children: [el("span", { text: tp("Color") }), colorBox], + }); + const boxName = createInputField({ label: tp("BoxName"), compact: true }); + boxName.input.addEventListener("change", () => { + const box = lg.boxes.find((b) => b.id === pickedBox()); + if (!box) return; + const next = boxName.input.value.trim(); + const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); + if (taken) note.textContent = tp("NameTaken", next); + if (!next || taken || next === box.name) return void (boxName.input.value = box.name); + edit(model.updateBox(lg, box.id, { name: next })); + }); + // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만 + const funcHost = el("div"); + const outHost = el("div"); + const inHost = el("div"); + const linkHost = el("div"); + const pillHost = el("div"); + const detail = detailFrame(); + detail.settings.append(boxName.root, colorRow); + // 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨) + const lock = el("fieldset", { + className: "m02-dfp__lock", + children: [inHost, funcHost, outHost, linkHost, pillHost], + }); + lock.disabled = readOnly; + detail.body.append(lock); + if (readOnly) detail.root.prepend(el("p", { className: "m02-df__muted", text: tp("ReadOnly") })); + + const evalNow = (): CfEvalResult => { + try { + return ev.evalLogic(lg, values, { logics }); + } catch (e) { + const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] }; + } + }; + + /** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */ + const edit = (next: CfLogic): void => { + if (readOnly || next === lg) return; + stack = [...stack, lg]; + lg = next; + opts.onChange(lg); + redraw(); + }; + /** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */ + const editExample = (next: CfLogic): void => { + if (!readOnly) return edit(next); + lg = next; + redraw(); + }; + const editFromDiagram = (next: CfLogic): void => { + edit(next); + setTimeout(redraw); + }; + + const refreshValues = (): void => { + const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; + const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q)); + if (sig === valueSig) return; + valueSig = sig; + void fetchValues(api.cfValues, lg, pill, model.elementPort).then( + (got) => { + if (sig !== valueSig) return; + values = got.values; + notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : []; + redraw(); + }, + (e: unknown) => { + notes = [tp("Values", api.cfPlain(e))]; + redraw(); + }, + ); + }; + + const refreshLogics = (): void => { + const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); + if (sig === logicSig) return; + logicSig = sig; + const get = + opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null)); + void loadLogics(lg, get).then((got) => { + if (sig !== logicSig) return; + logics = got.logics; + logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : []; + redraw(); + }); + }; + + const choose = (sel: CfSel): void => { + const box = lg.boxes.find((b) => b.id === sel.box); + pick = sel.link + ? { kind: "link", id: sel.link } + : box && sel.port + ? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) } + : box + ? { kind: "box", id: box.id } + : null; + redraw(); + }; + + /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ + const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { + try { + if (pick?.kind === "pill") { + const p = { box: pick.box, name: pick.port }; + return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) }; + } + const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id); + if (link) + return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) }; + } catch { + /* 따라갈 수 없으면 칠하지 않음 */ + } + return null; + }; + + const diagramOn = { + select: (id: string | null, pill?: DfPillSel): void => { + pick = pill + ? { kind: "pill", ...pill } + : !id + ? null + : lg.links.some((l) => l.id === id) + ? { kind: "link", id } + : { kind: "box", id }; + setTimeout(redraw); + }, + move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)), + link: (from: CfPort, to: CfPort): string | null => { + const got = model.link(lg, from, to); + if (got.why) return got.why; + editFromDiagram(got.lg); + return null; + }, + unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)), + add: (kind: CfKind, x: number, y: number): void => { + const got = model.addBox(lg, kind, x, y); + pick = { kind: "box", id: got.box.id }; + editFromDiagram(got.lg); + }, + remove: (id: string): void => { + if (pickedBox() === id) pick = null; + editFromDiagram(model.removeBox(lg, id)); + }, + }; + + const drawPanel = (): void => { + const boxId = pickedBox(); + if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; + const box = lg.boxes.find((b) => b.id === pickedBox()); + drawHead(detail, lg, pick); + (detail.settings.parentElement as HTMLElement).hidden = !box || readOnly; + colorRow.hidden = !box; + boxName.root.hidden = !box || box.kind === "output"; + if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; + linkHost.hidden = pick?.kind !== "link"; + pillHost.hidden = pick?.kind !== "pill"; + if (pick?.kind === "link") + renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); + if (pick?.kind === "pill") + renderPillPanel( + pillHost, + lg, + pick.box, + pick.port, + { pick: (id) => choose({ box: id }) }, + { values, logics }, + ); + drawWarnings(detail, pick, broken); + if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; + funcHost.hidden = box?.kind !== "function"; + outHost.hidden = box?.kind !== "output"; + inHost.hidden = box?.kind !== "input"; + if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit }); + if (box?.kind === "function") renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "output") + renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); + }; + + const redraw = (): void => { + result = evalNow(); + refreshValues(); + refreshLogics(); + const linkId = pick?.kind === "link" ? pick.id : ""; + if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; + try { + broken = ev.breaks(lg, values, { logics }); + } catch { + broken = result.breaks ?? []; + } + undo.disabled = readOnly || !stack.length; + renderDiagram(diagramHost, lg, diagramOn, { + values: result.ports, + selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, + selectedPill: + pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, + trace: traceNow(), + breaks: broken, + readOnly, + fitKey, + }); + drawPanel(); + renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); + note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back || readOnly) return; + stack = stack.slice(0, -1); + lg = back; + opts.onChange(lg); + redraw(); + }; + const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); + + redraw(); + return { + parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo }, + get: () => lg, + set: (next) => { + lg = next; + stack = []; + pick = null; + fitKey += 1; + redraw(); + }, + undo: doUndo, + refit: () => { + fitKey += 1; + redraw(); + }, + redraw, + }; +} From 49fa7e5fbee604bbe666789218d7a14ae5cc569a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:27:12 +0900 Subject: [PATCH 03/26] =?UTF-8?q?wip(m02-df):=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=EB=B6=80=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel_Build.ts | 107 ++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index 541fd358c..3125278d4 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -236,19 +236,28 @@ export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string return null; } -/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리) */ +/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약) + * examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */ export function logicRefOf(key: string, other: CfLogic): CfLogicRef { const outputs = other.boxes .filter((b) => b.kind === "output") .map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" })); const designs: { name: string; unit: string }[] = []; + const examples: Record = {}; + const addDesign = (name: string, unit: string, example?: number | string | null): void => { + designs.push({ name, unit: unit || "-" }); + if (example !== null && example !== undefined && example !== "") examples[name] = example; + }; for (const b of other.boxes) { - if (b.kind !== "input" || !b.input) continue; - if (b.input.mode === "variable") - for (const v of b.input.variables) designs.push({ name: v.name, unit: v.unit || "-" }); - for (const e of b.input.elements) - if (e.mode === "choice" || e.mode === "rule") - designs.push({ name: e.slot || e.name, unit: e.unit || "-" }); + for (const v of b.input?.variables ?? b.func?.variables ?? []) + addDesign(v.name, v.unit, v.example); + if (b.input?.mode === "group") + for (const e of b.input.elements) + if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit); + if (b.input?.mode === "logic") + for (const d of b.input.logic?.designs ?? []) + if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name)) + addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]); } const seen = new Set(); return { @@ -256,9 +265,93 @@ export function logicRefOf(key: string, other: CfLogic): CfLogicRef { name: other.name, outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)), designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))), + ...(Object.keys(examples).length ? { examples } : {}), }; } +/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */ + +/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */ +export interface MasterLogicBrief { + file: string; + 구분: string; + 상세구분: string; + 키: string; + 원문번호: string; + 이름: string; + 결과단위: string; + blocked?: boolean; + reasons?: string[]; +} + +/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */ +export function filterLogics( + items: MasterLogicBrief[], + q: { text: string; book?: string; chapter?: string }, +): MasterLogicBrief[] { + const words = q.text.split(/\s+/).map(squash).filter(Boolean); + return items.filter((i) => { + if (q.book && i.구분 !== q.book) return false; + if (q.chapter && i.상세구분 !== q.chapter) return false; + const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`); + return words.every((w) => hay.includes(w)); + }); +} + +/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */ +export function logicBooks(items: MasterLogicBrief[]): string[] { + return [...new Set(items.map((i) => i.구분).filter(Boolean))]; +} +export function logicChapters(items: MasterLogicBrief[], book: string): string[] { + return [ + ...new Set( + items + .filter((i) => !book || i.구분 === book) + .map((i) => i.상세구분) + .filter(Boolean), + ), + ]; +} + +/** 옛 로직을 흐름도로 바꿔 읽은 것은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */ +export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const; + +/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */ +export function masterRef( + key: string, + flow: CfLogic, + converted: boolean, + resultUnit: string, +): CfLogicRef { + const ref = logicRefOf(key, flow); + return converted + ? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) } + : ref; +} + +/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */ +export async function wouldCycle( + selfKey: string | null, + startKey: string, + load: (key: string) => Promise, + depth = 8, +): Promise { + if (!selfKey) return false; + const seen = new Set(); + const walk = async (key: string, left: number): Promise => { + if (key === selfKey) return true; + if (seen.has(key) || left <= 0) return false; + seen.add(key); + const lg = await load(key).catch(() => null); + for (const b of lg?.boxes ?? []) { + const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined; + if (next && (await walk(next, left - 1))) return true; + } + return false; + }; + return walk(startKey, depth); +} + /** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */ export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic { return { From accf8ecc4fd17377e5082a072a07d174fc3534c6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:27:29 +0900 Subject: [PATCH 04/26] =?UTF-8?q?feat(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=B6=94=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=91=90=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「식으로 새로 만들기」 단추 · 화면 길 지움(LogicLab_New 다섯 파일 — 쓰는 곳 없음) - 「시험판 ▾」 메뉴 · 시험판 단추 둘(CostFlow · DataFlow 입구) · 가져오기 줄 지움 - 「본떠 만들기」 → 「로직 복사/생성」 · 단추 차례 = ← 로직 목록 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 - 로직 상세 두 칸(LogicLab_Flow) — 왼쪽 지금 내용 · 오른쪽 흐름도 자리 · 끌기 막대 · 너비 비율 기억(localStorage · 25~75%) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 127 ++------ .../M01_MasterData_UI_LogicLab_Flow.css | 40 +++ .../M01_MasterData_UI_LogicLab_Flow.ts | 95 ++++++ .../M01_MasterData_UI_LogicLab_New.css | 105 ------- .../M01_MasterData_UI_LogicLab_New.ts | 272 ------------------ .../M01_MasterData_UI_LogicLab_New_Find.ts | 218 -------------- .../M01_MasterData_UI_LogicLab_New_Text.ts | 71 ----- .../M01_MasterData_UI_LogicLab_New_Var.ts | 178 ------------ .../M01_MasterData_UI_LogicLab_Text.ts | 2 +- 9 files changed, 160 insertions(+), 948 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New.css delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New.ts delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index a74a98bb9..8bef8f3c8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,13 +1,13 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ -import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; +import { t as L } from "@ui/ui_template_locale"; import { createButton, el, @@ -37,12 +37,7 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail" 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 { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; -// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry"; -// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createDataFlowButton } from "../M02_MasterTemplete/DataFlow/M02_DF_Entry"; +import { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -72,49 +67,6 @@ interface Opened extends Draft { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); -/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */ -function trialMenu(items: HTMLElement[]): HTMLElement { - const list = el("div", { attrs: { role: "menu" }, children: items }); - Object.assign(list.style, { - position: "absolute", - top: "calc(100% + 4px)", - left: "0", - zIndex: "20", - display: "none", - flexDirection: "column", - gap: "var(--spacing-4)", - padding: "var(--spacing-8)", - border: "1px solid var(--color-border)", - borderRadius: "var(--radius-lg)", - background: "var(--color-surface-raised)", - boxShadow: "var(--shadow-lg)", - whiteSpace: "nowrap", - }); - const shut = (): void => { - list.style.display = "none"; - button.setAttribute("aria-expanded", "false"); - document.removeEventListener("click", outside, true); - }; - const outside = (ev: Event): void => { - if (!wrap.contains(ev.target as Node)) shut(); - }; - const button = createButton({ - label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0], - variant: "ghost", - onClick: () => { - if (list.style.display !== "none") return shut(); - list.style.display = "flex"; - button.setAttribute("aria-expanded", "true"); - document.addEventListener("click", outside, true); - }, - }); - button.setAttribute("aria-haspopup", "menu"); - list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶 - const wrap = el("div", { children: [button, list] }); - Object.assign(wrap.style, { position: "relative", display: "inline-flex" }); - wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut()); - return wrap; -} const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -137,13 +89,13 @@ export async function mountM01LogicLab( ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; - let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음 let calcInputs = ""; let recalc: number | undefined; const editor = el("div", { className: "m01-logic__editor" }); + // 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억 + const split = buildSplit(editor); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("div", { className: "m01lab__calc" }); - const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } }); const filterHost = el("div"); const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 @@ -233,12 +185,11 @@ export async function mountM01LogicLab( const drawEditor = (): void => { // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened && !creating; + const listing = !opened; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; - editor.hidden = !opened; + split.root.hidden = !opened; back.hidden = listing; - newHost.hidden = !creating; if (!opened) return; if (!opened.row) { editor.replaceChildren( @@ -264,7 +215,6 @@ export async function mountM01LogicLab( const show = (next: Opened | null): void => { opened = next; - creating = false; errors.hidden = true; drawEditor(); drawCalc(); @@ -326,7 +276,7 @@ export async function mountM01LogicLab( restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); - if (opened || creating) show(null); + if (opened) show(null); }, }), ); @@ -410,65 +360,36 @@ export async function mountM01LogicLab( } }; - /** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */ - const startNew = (): void => { - opened = null; - creating = true; - newHost.replaceChildren( - buildNewLogic({ - onSaved: (key) => { - void (async () => { - try { - await reload(); - await openKeyOn(key); - } catch (error) { - showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); - } - })(); - }, - onClose: () => show(null), - }), - ); - drawEditor(); - drawCalc(); - }; - const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), variant: "ghost", onClick: () => void onDiscard(), }); + const copyButton = createButton({ + label: tl("Copy_Open"), + variant: "ghost", + onClick: () => + opened?.origKey + ? void openCopyScreen({ + key: opened.origKey, + afterSave: async (newKey) => { + await reload(); + await open(logicId("자체", newKey), "자체", newKey); + }, + }) + : showToast(tl("Copy_NeedOpen"), "info"), + }); const bar = el("div", { className: "m01-logic__bar", children: [ back, el("h2", { text: tx("Title") }), pending, - createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), - trialMenu([ - // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createCostFlowButton(), - // 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createDataFlowButton(), - ]), + copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, - createButton({ - label: tl("Copy_Open"), - variant: "ghost", - onClick: () => - opened?.origKey - ? void openCopyScreen({ - key: opened.origKey, - afterSave: async (newKey) => { - await reload(); - await open(logicId("자체", newKey), "자체", newKey); - }, - }) - : showToast(tl("Copy_NeedOpen"), "info"), - }), ], }); host.replaceChildren( @@ -477,7 +398,7 @@ export async function mountM01LogicLab( children: [ el("main", { className: "m01-logic__main", - children: [bar, errors, waiting, list.root, editor, newHost], + children: [bar, errors, waiting, list.root, split.root], }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css new file mode 100644 index 000000000..64a4c5500 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css @@ -0,0 +1,40 @@ +/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */ +.m01lab-flow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; + gap: 0; +} + +.m01lab-flow[hidden] { + display: none; +} + +.m01lab-flow__left, +.m01lab-flow__right { + min-width: 0; +} + +.m01lab-flow__bar { + align-self: stretch; + width: 8px; + margin: 0 var(--spacing-4); + border-radius: var(--radius-sm); + background: var(--color-border); + cursor: col-resize; + touch-action: none; +} + +.m01lab-flow__bar:hover, +.m01lab-flow__bar:focus-visible { + background: var(--color-primary); +} + +.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; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts new file mode 100644 index 000000000..15c729006 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Flow.ts + * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임) + * 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import "./M01_MasterData_UI_LogicLab_Flow.css"; + +const TEXT = { + Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"], + Waiting: ["흐름도 자리", "Flow diagram"], +} as const; + +export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const STORE = "m01.logic.flowRatio"; +const MIN = 0.25; +const MAX = 0.75; + +const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r)); + +function readRatio(): number { + try { + const r = Number(localStorage.getItem(STORE)); + return r ? clamp(r) : 0.5; + } catch { + return 0.5; + } +} + +export interface SplitHandle { + root: HTMLElement; + /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ + right: HTMLElement; +} + +/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */ +export function buildSplit(left: HTMLElement): SplitHandle { + const right = el("div", { + className: "m01lab-flow__right", + children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })], + }); + const bar = el("div", { + className: "m01lab-flow__bar", + attrs: { + role: "separator", + "aria-orientation": "vertical", + "aria-label": tf("Resize"), + title: tf("Resize"), + tabindex: "0", + }, + }); + const leftCol = el("div", { className: "m01lab-flow__left", children: [left] }); + const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] }); + let ratio = readRatio(); + const apply = (): void => { + root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`; + }; + const keep = (): void => { + try { + localStorage.setItem(STORE, String(ratio)); + } catch { + /* 기억 못 해도 지금 화면은 그대로 */ + } + }; + bar.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + ev.preventDefault(); + const box = root.getBoundingClientRect(); + const move = (m: PointerEvent): void => { + ratio = clamp((m.clientX - box.left) / box.width); + apply(); + }; + const up = (): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + keep(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + // 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩 + bar.addEventListener("keydown", (ev) => { + const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0; + if (!step) return; + ev.preventDefault(); + ratio = clamp(ratio + step); + apply(); + keep(); + }); + apply(); + return { root, right }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css deleted file mode 100644 index bd23745e2..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css +++ /dev/null @@ -1,105 +0,0 @@ -/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */ -.m01lab-new { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 0; -} - -.m01lab-new__step { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -.m01lab-new__text { - width: 100%; - min-height: 140px; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-bg); - color: var(--color-text); - font-family: ui-monospace, Consolas, monospace; - font-size: var(--text-body); - resize: vertical; -} - -.m01lab-new__row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m01lab-new__vars { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m01lab-new__var { - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); -} - -.m01lab-new__var-head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); -} - -.m01lab-new__var-body { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8); - margin-top: var(--spacing-8); -} - -.m01lab-new__wide { - flex: 1 1 220px; -} - -.m01lab-new__picked { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - min-width: 0; -} - -.m01lab-new__src { - display: flex; - gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-lg); - background: var(--color-paper); - overflow-x: auto; -} - -.m01lab-new__no { - flex: 0 0 auto; - color: var(--color-text-secondary); -} - -.m01lab-new__src mark { - background: var(--color-danger); - color: var(--color-bg); - border-radius: 2px; -} - -.m01lab-new__bad { - color: var(--color-danger); -} - -.m01lab-new__soft { - color: var(--color-text-secondary); -} - -.m01lab-new__calc { - min-width: 0; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts deleted file mode 100644 index 7cc035b22..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New.ts - * 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장) - * ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름 - * ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX) - * 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`) - * ========================================================================== */ - -import { - createButton, - createInputField, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { - ApiError, - draftLogic, - type DraftAnswer, - type DraftDecided, - type DraftHead, - type DraftProblem, - type DraftVar, -} from "./M01_MasterData_UI_Logic_Api"; -import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; -import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var"; -import "./M01_MasterData_UI_LogicLab_New.css"; - -/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */ -const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요 -넓이 = 길이 * 폭 -인부 = 넓이 * 품 -잡재료비 = 노무비 * 0.05 -공사비 = 인부 * 보통인부 + 잡재료비 -`; - -export interface NewOptions { - /** 저장 끝 — 새 자체 로직의 키 */ - onSaved: (key: string) => void; - onClose: () => void; -} - -export function buildNewLogic(opts: NewOptions): HTMLElement { - const decided: Record = {}; - const rows = new Map(); - const values: Record = {}; - let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림 - let answer: DraftAnswer | null = null; - let calcSig = ""; - let seq = 0; - - const text = el("textarea", { - className: "m01lab-new__text", - attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") }, - }); - text.value = SAMPLE; - const stale = el("p", { - className: "m01-logic__muted", - text: tn("Stale"), - attrs: { hidden: "" }, - }); - const varsHost = el("div", { className: "m01lab-new__vars" }); - const problemHost = el("div", { className: "m01lab-new__problems" }); - const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" }); - const nameBox = createInputField({ - type: "text", - label: tn("Name"), - onInput: () => onEdit(), - }); - nameBox.input.setAttribute("data-key", "name"); - const unitBox = createInputField({ - type: "text", - label: tn("ResultUnit"), - placeholder: "원/㎡", - onInput: () => onEdit(), - }); - unitBox.input.setAttribute("data-key", "unit"); - const ownerBox = createInputField({ type: "text", label: tn("Owner") }); - const state = el("p", { className: "m01-logic__muted" }); - const save = createButton({ label: tn("Save"), onClick: () => void doSave() }); - save.setAttribute("data-act", "save"); - save.disabled = true; - - const head = (): DraftHead | undefined => { - const 이름 = nameBox.input.value.trim(); - const 결과단위 = unitBox.input.value.trim(); - return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined; - }; - - /** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */ - const drawProblems = (problems: DraftProblem[]): void => { - if (!analyzed) return problemHost.replaceChildren(); - const lines = analyzed.split("\n"); - const flagged = new Map(); - for (const p of problems) { - if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]); - } - const shown = [...flagged.keys()] - .sort((a, b) => a - b) - .filter((n) => n <= lines.length) - .map((n) => { - const raw = lines[n - 1]; - const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1; - return el("div", { - className: "m01lab-new__src", - children: [ - el("span", { className: "m01lab-new__no", text: String(n) }), - at >= 0 - ? el("code", { - children: [ - raw.slice(0, at), - el("mark", { text: raw.slice(at, at + 1) }), - raw.slice(at + 1), - ], - }) - : el("code", { text: raw }), - ], - }); - }); - const items = problems.map((p) => - el("li", { - className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad", - attrs: { "data-kind": p.갈래 }, - text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`, - }), - ); - problemHost.replaceChildren( - el("h4", { text: tn("Problems") }), - ...(items.length - ? [...shown, el("ul", { children: items })] - : [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]), - ); - }; - - const drawCalc = (): void => { - const row = answer?.logic ?? null; - save.disabled = !row || (answer?.문제.length ?? 1) > 0; - state.textContent = !answer - ? "" - : answer.문제.some((p) => p.갈래 !== "덜정함") - ? tn("SaveBlocked") - : !head() - ? tn("NeedHead") - : answer.다됨 - ? "" - : tn("Undecided"); - if (!row) { - calcSig = ""; - calcHost.replaceChildren(); - return; - } - const sig = JSON.stringify(row.입력 ?? []); - if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림 - calcSig = sig; - buildCalc(calcHost, { - savedKey: null, - row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄 - dirty: () => true, - values, - onLines: () => undefined, - }); - }; - - const drawVars = (vars: DraftVar[]): void => { - rows.clear(); - const shown = vars.filter((v) => v.갈래 !== "비목합"); - for (const name of Object.keys(decided)) { - if (!shown.some((v) => v.이름 === name)) delete decided[name]; - } - const result = answer?.결과 ?? ""; - const order = [ - ...shown.filter((v) => v.갈래 === "미정"), - ...shown.filter((v) => v.갈래 !== "미정"), - ]; - for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit })); - varsHost.replaceChildren(...[...rows.values()].map((r) => r.root)); - }; - - const evaluate = async (rebuild: boolean): Promise => { - if (!analyzed) return; - const mine = ++seq; - try { - const got = await draftLogic({ text: analyzed, decided, head: head() }); - if (mine !== seq) return; // 최신 응답만 - answer = got; - if (rebuild) drawVars(got.변수); - for (const v of got.변수) rows.get(v.이름)?.update(v); - drawProblems(got.문제); - drawCalc(); - } catch (error) { - showToast(error instanceof Error ? error.message : tn("Failed"), "error"); - } - }; - let timer = 0; - function onEdit(): void { - window.clearTimeout(timer); - timer = window.setTimeout(() => void evaluate(false), 250); - } - - const analyzeButton = createButton({ - label: tn("Analyze"), - onClick: () => { - analyzed = text.value; - stale.hidden = true; - void evaluate(true); - }, - }); - analyzeButton.setAttribute("data-act", "analyze"); - text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed)); - - async function doSave(): Promise { - const h = head(); - if (!h) return void showToast(tn("NeedHead"), "info"); - save.disabled = true; - showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림 - try { - const owner = ownerBox.input.value.trim(); - const done = await draftLogic({ - text: analyzed, - decided, - head: h, - save: true, - ...(owner ? { owner } : {}), - }); - showToast(tn("Saved", { v: done.key ?? "" }), "success"); - if (done.key) opts.onSaved(done.key); - } catch (error) { - const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null; - if (detail?.문제) drawProblems(detail.문제); - else showToast(error instanceof Error ? error.message : tn("Failed"), "error"); - save.disabled = false; - } finally { - hideLoadingOverlay(); - } - } - - const section = (title: string, ...children: HTMLElement[]): HTMLElement => - el("section", { - className: "m01lab__box m01lab-new__step", - children: [el("h3", { text: title }), ...children], - }); - - return el("div", { - className: "m01lab-new", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [ - el("h2", { text: tn("Title") }), - createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }), - ], - }), - section( - tn("Step1"), - el("p", { className: "m01-logic__muted", text: tn("Guide") }), - text, - el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }), - ), - section(tn("Step2"), varsHost, problemHost), - section( - tn("Step3"), - el("div", { - className: "m01lab-new__row", - children: [nameBox.root, unitBox.root, ownerBox.root], - }), - ), - section(tn("Step4"), calcHost, state, save), - ], - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts deleted file mode 100644 index 08761a627..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New_Find.ts - * 새 로직 만들기의 고르기 창 둘 - * ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기 - * ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음 - * ========================================================================== */ - -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { - fetchFiles, - fetchRows, - fetchSubsAll, - type FileInfo, - type SubInfo, -} from "./M01_MasterData_Api_Fetch"; -import type { DraftFind } from "./M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { loadTable } from "./M01_MasterData_UI_Logic_Note"; -import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; - -export interface PickedElement { - ref: string; - /** 이름 + 규격 */ - name: string; - /** 수량에 쓰는 단위(「원/일」 → 「일」) */ - unit: string; - group: string; -} - -const GROUPS = ["인력", "재료", "기계"]; -const SHOWN = 50; - -/** 창 뼈대 — 제목 · 닫기 · 몸통 */ -function shell(title: string, body: HTMLElement[]): { close: () => void } { - const close = (): void => backdrop.remove(); - const dialog = el("div", { - className: "m01-logic__pick", - attrs: { role: "dialog", "aria-label": title }, - children: [ - el("div", { - className: "m01-logic__section-head", - children: [ - el("h3", { text: title }), - createButton({ label: tn("Close"), variant: "ghost", onClick: close }), - ], - }), - ...body, - ], - }); - const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); - backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); - backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); - document.body.append(backdrop); - return { close }; -} - -/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */ -async function fileOf(group: string): Promise { - const files = await fetchFiles(group); - return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0]; -} - -const options = (values: string[]): { value: string; text: string }[] => [ - { value: "", text: tn("All") }, - ...values.map((v) => ({ value: v, text: v })), -]; - -export function openElementPick(done: (picked: PickedElement) => void): void { - let file: FileInfo | undefined; - let subs: SubInfo[] = []; - const groupBox = createSelectField({ - options: GROUPS.map((g) => ({ value: g, text: g })), - value: GROUPS[0], - compact: true, - label: tn("Group"), - }); - const subBox = createSelectField({ - options: options([]), - value: "", - compact: true, - label: tn("Sub"), - }); - const detailBox = createSelectField({ - options: options([]), - value: "", - compact: true, - label: tn("Detail"), - }); - const searchField = createInputField({ type: "search", placeholder: tn("Search") }); - const count = el("span", { className: "m01-logic__muted" }); - const list = el("div", { className: "m01-logic__pick-list" }); - const win = shell(tn("K_master"), [ - el("div", { - className: "m01-logic__pick-bar", - children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count], - }), - list, - ]); - - let seq = 0; - const load = async (): Promise => { - if (!file) return; - const mine = ++seq; - try { - const got = await fetchRows( - file.file, - 1, - SHOWN, - searchField.input.value.trim(), - false, - subBox.select.value, - detailBox.select.value, - ); - if (mine !== seq) return; - count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) }); - list.replaceChildren( - ...got.rows.map((row) => { - const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim(); - const unitText = String(row["단위"] ?? ""); - const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? ""); - const button = el("button", { - className: "m01-logic__pick-row m01-logic__pick-row--button", - attrs: { type: "button", "data-ref": String(row["키"]) }, - children: [ - el("div", { - className: "m01-logic__stack", - children: [ - el("strong", { text: name }), - el("span", { className: "m01-logic__muted", text: String(row["키"]) }), - ], - }), - el("span", { - className: "m01-logic__money", - text: `${formatNumber(row["값"])} ${unitText}`.trim(), - }), - ], - }); - button.addEventListener("click", () => { - win.close(); - done({ ref: String(row["키"]), name, unit, group: groupBox.select.value }); - }); - return button; - }), - ); - } catch (error) { - showToast(error instanceof Error ? error.message : tn("Failed"), "error"); - } - }; - - const changeGroup = async (): Promise => { - file = await fileOf(groupBox.select.value); - subs = file ? (await fetchSubsAll({ file: file.file })).subs : []; - subBox.setOptions(options(subs.map((s) => s.name)), ""); - detailBox.setOptions(options([]), ""); - detailBox.select.disabled = true; - await load(); - }; - groupBox.select.addEventListener("change", () => void changeGroup()); - subBox.select.addEventListener("change", () => { - const details = subs.find((s) => s.name === subBox.select.value)?.details ?? []; - detailBox.setOptions(options(details), ""); - detailBox.select.disabled = details.length === 0; - void load(); - }); - detailBox.select.addEventListener("change", () => void load()); - let timer = 0; - searchField.input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void load(), 250); - }); - void changeGroup(); -} - -/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */ -export function openTablePick(done: (find: DraftFind, label: string) => void): void { - openPicker("table", "소요량", (item, column) => { - void (async () => { - const table = await loadTable(item.ref); - const conds = Object.keys(table?.조건 ?? {}); - const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`; - const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record }; - if (!conds.length) { - done(find, label); - return; - } - const inputs = conds.map((c) => { - const box = createInputField({ type: "text", value: c }); - box.input.setAttribute("data-cond", c); - return { c, box }; - }); - const win = shell(tn("TableTitle"), [ - el("p", { className: "m01-logic__muted", text: label }), - el("p", { text: tn("Cond") }), - ...inputs.map(({ c, box }) => - el("label", { - className: "m01-logic__field", - children: [el("span", { text: c }), box.root], - }), - ), - createButton({ - label: tn("Ok"), - onClick: () => { - for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c; - win.close(); - done(find, label); - }, - }), - ]); - })(); - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts deleted file mode 100644 index 272d234fb..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New_Text.ts - * 「식으로 새 로직 만들기」 글자 — [한국어, 영어] - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; - -const TEXT = { - Open: ["식으로 새로 만들기", "New from formula"], - Title: ["식으로 새 로직 만들기", "New logic from a formula"], - Close: ["닫기", "Close"], - Step1: ["① 식 적기", "① Write the formula"], - Guide: [ - "한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀", - "One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped", - ], - Analyze: ["분석", "Analyze"], - Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"], - Step2: ["② 변수 정하기", "② Decide each variable"], - Step3: ["③ 이름·단위", "③ Name and unit"], - Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"], - Problems: ["고칠 곳", "To fix"], - Line: ["{n}번 줄", "Line {n}"], - AllGood: ["검사에 걸린 것 없음", "No problems found"], - Result: ["결과", "Result"], - Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"], - Kind: ["무엇인가", "What is it"], - K_master: ["마스터 요소", "Master element"], - K_input: ["설계 입력", "Design input"], - K_fixed: ["고정값", "Fixed value"], - K_table: ["표 찾기", "Table lookup"], - Pick: ["고르기", "Pick"], - Repick: ["다시 고르기", "Pick again"], - NotPicked: ["아직 안 골랐음", "Not picked"], - Unit: ["단위", "Unit"], - Cost: ["비목", "Cost item"], - CostNone: ["넣지 않음", "None"], - CostExtra: ["덧줄 비목", "Extra-line cost item"], - Value: ["값", "Value"], - Note: ["설명", "Note"], - Derived: ["식으로 정해짐", "Set by the formula"], - SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"], - Name: ["로직 이름", "Logic name"], - ResultUnit: ["결과 단위", "Result unit"], - Owner: ["소유", "Owner"], - Save: ["저장", "Save"], - Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"], - SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"], - NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"], - Group: ["종류", "Kind"], - Sub: ["구분", "Group"], - Detail: ["상세구분", "Detail"], - All: ["(전체)", "(All)"], - Search: ["이름 찾기", "Search name"], - Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"], - TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"], - TablePick: ["표 고르기", "Pick a table"], - TableCol: ["값 칸", "Value column"], - Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"], - Ok: ["확인", "OK"], - Cancel: ["취소", "Cancel"], - Failed: ["못 함", "Failed"], -} as const satisfies Record; - -export type NewTextKey = keyof typeof TEXT; - -export function tn(key: NewTextKey, fill: Record = {}): string { - const entry = TEXT[key]; - const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0]; - return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts deleted file mode 100644 index 7cfb70d18..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New_Var.ts - * 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름 - * 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로) - * ========================================================================== */ - -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api"; -import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; - -export interface VarCtx { - decided: Record; - /** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */ - onEdit: () => void; -} -export interface VarRow { - root: HTMLElement; - /** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */ - update: (v: DraftVar) => void; -} - -const KINDS: [string, () => string][] = [ - ["마스터요소", () => tn("K_master")], - ["설계입력", () => tn("K_input")], - ["고정값", () => tn("K_fixed")], - ["표찾기", () => tn("K_table")], -]; -const COSTS = ["노무비", "재료비", "경비"]; -/** 요소 종류 → 비목 첫 제안 */ -const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; - -const costOptions = (): { value: string; text: string }[] => [ - { value: "", text: tn("CostNone") }, - ...COSTS.map((c) => ({ value: c, text: c })), -]; - -const field = (label: string, control: HTMLElement): HTMLElement => - el("label", { - className: "m01-logic__field", - children: [el("span", { text: label }), control], - }); - -export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow { - const name = v.이름; - const info = el("span", { className: "m01-logic__muted m01lab-new__derived" }); - const body = el("div", { className: "m01lab-new__var-body" }); - const root = el("div", { - className: "m01lab-new__var", - attrs: { "data-var": name }, - children: [ - el("div", { - className: "m01lab-new__var-head", - children: [el("strong", { text: name }), info], - }), - body, - ], - }); - const now = (): DraftDecided => (ctx.decided[name] ??= {}); - const set = (patch: DraftDecided): void => { - ctx.decided[name] = { ...now(), ...patch }; - for (const [k, val] of Object.entries(ctx.decided[name])) { - if (val === "" || val === undefined) delete (ctx.decided[name] as Record)[k]; - } - ctx.onEdit(); - }; - const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => { - const box = createInputField({ - type: "text", - value: now()[key] ?? "", - onInput: (val) => set({ [key]: val.trim() }), - }); - box.input.setAttribute("data-key", key); - const cell = field(label, box.root); - if (wide) cell.classList.add("m01lab-new__wide"); - return cell; - }; - - let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글 - const draw = (): void => { - const kind = now().무엇 ?? ""; - if (v.갈래 === "비목합") { - body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") })); - return; - } - if (v.갈래 === "정의됨") { - if (isResult) return body.replaceChildren(); - const cost = createSelectField({ - options: costOptions(), - value: now().비목 ?? "", - compact: true, - label: tn("CostExtra"), - onChange: (val) => set({ 비목: val }), - }); - body.replaceChildren(cost.root); - return; - } - const kindBox = createSelectField({ - options: [ - { value: "", text: "—" }, - ...KINDS.map(([value, label]) => ({ value, text: label() })), - ], - value: kind, - compact: true, - label: tn("Kind"), - onChange: (val) => { - ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {}; - picked = ""; - draw(); - ctx.onEdit(); - }, - }); - const parts: HTMLElement[] = [kindBox.root]; - if (kind === "마스터요소") { - const cost = createSelectField({ - options: costOptions(), - value: now().비목 ?? "", - compact: true, - label: tn("Cost"), - onChange: (val) => set({ 비목: val }), - }); - const choose = createButton({ - label: now().요소 ? tn("Repick") : tn("Pick"), - variant: "ghost", - onClick: () => - openElementPick((p) => { - picked = `${p.name} · ${p.ref}`; - set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 }); - draw(); - }), - }); - choose.setAttribute("data-act", "pick-element"); - parts.push( - el("div", { - className: "m01lab-new__picked", - children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })], - }), - text(tn("Unit"), "단위"), - cost.root, - ); - } else if (kind === "설계입력") { - parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true)); - } else if (kind === "고정값") { - parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위")); - } else if (kind === "표찾기") { - const choose = createButton({ - label: now().찾기 ? tn("Repick") : tn("TablePick"), - variant: "ghost", - onClick: () => - openTablePick((find, label) => { - picked = label; - set({ 찾기: find }); - draw(); - }), - }); - choose.setAttribute("data-act", "pick-table"); - parts.push( - el("div", { - className: "m01lab-new__picked", - children: [choose, el("span", { text: picked || tn("NotPicked") })], - }), - text(tn("Unit"), "단위"), - ); - } - body.replaceChildren(...parts); - }; - draw(); - - const update = (next: DraftVar): void => { - v = next; - info.textContent = - next.갈래 === "정의됨" - ? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}` - : (next.단위 ?? ""); - }; - update(v); - return { root, update }; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 29a081d9a..283d7a063 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -81,7 +81,7 @@ const TEXT = { "지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음", "No row matches the current values — change them, or this table is on an unused branch", ], - Copy_Open: ["본떠 만들기", "Copy as mine"], + Copy_Open: ["로직 복사/생성", "Copy / create logic"], Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"], Copy_Title: ["복사해서 만들기", "Copy to edit"], Copy_Name: ["새 이름", "New name"], From 97f306a7aff773859670398af515fe9882920773 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:27:33 +0900 Subject: [PATCH 05/26] =?UTF-8?q?chore(m01):=2036-1=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9=20=EC=98=9B=20=EC=8B=9D=20?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=E2=80=94=20=ED=8C=8C=EC=84=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=201,472=20=EC=8B=9D=20=EB=A7=90=20=EC=A0=84?= =?UTF-8?q?=EC=88=98=20=C2=B7=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=EC=96=B4?= =?UTF-8?q?=EB=A0=A4=EC=9A=B4=20=EA=BC=B4=20=C2=B7=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B8=B8=20=EB=B6=80=EB=A5=B4=EB=8A=94=20=EA=B3=B3=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- docs/raw/M01_쉬운단가/흐름도_적용_조사.md | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_조사.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_조사.md b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md new file mode 100644 index 000000000..c2672300d --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md @@ -0,0 +1,121 @@ +# 흐름도 본 프로그램 적용 — 36-1 옛 식 조사 (sub6 · 읽기만) + +근거 = 마스터 로직 파일 57 개 · 로직 줄 **1,472**(자체 `로직_자체.json` 포함) · 식을 지금 서버 파서(`master_formula.parse`)로 전부 읽음 — **못 읽는 식 0** · 파일 안 씀. +수 칸 = 「나온 수(식 마디 수) / 그 꼴이 든 로직 수」 · 예 = 키 + 식 앞부분. + +## ① 옛 식을 푸는 서버 코드 + +| 무엇 | 자리 | +| --- | --- | +| 식 문법 · 파서(`_Parser` — 또는 → 그리고 → 비교 → + - → * / → 부호 · `.칸` → 기본) | `resources/master_data/scripts/master_formula.py:56`(`parse` :225 · 함수 목록 `FUNCS` :23 = 만약 · 올림 · 버림 · 반올림 · 최소 · 최대 · 비교 `_OPS` :27) | +| 기본 마디 — `( )` · `'글'` · 수 · `찾기(표, 조건=값…)` · `로직(키, 넘길=값…)` · 함수 · 요소 키(`LB000002`) · `ID:원문번호{입력}`(`EQ:{기계}`) · 이름 | 같은 파일 `primary` :185 · 요소 키 판정 `mk.KEY`(`master_keys.py`) | +| 셈(나무 풀기) — 이름 · 요소 값 · 찾기 · 로직 부름 · 함수 · 사칙 · 비교 · 그리고 / 또는(지름길) | `master_formula.py:385` `evaluate` | +| 표 찾기 — 빈 조건 칸 = 무엇이든 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하) · 여러 줄이면 위 끝 최소 · 찾는 칸 있는 줄 | `master_formula.py:331` `_match` · `:362` `find` | +| 로직 한 줄 셈 — 입력 검사 → 중간 차례 → 결과 식 또는 호표 줄(수량 × 단가 · 로직 줄은 비목 나눔) → 덧줄(앞 줄까지 비목 합) → 끝수 | `master_formula.py:538` `run` · 입력 `_inputs` :489 · 빈 가격 입력 `fill_empty` :479 · 끝수 `cut` :468 · `{입력}` 채우기 `fill` :314 | +| 요소 값 · 재료 단가 | `master_material.py:238` `element` · `master_pick.py:130` `priced` · `:123` `pick`(검색어 묶음 → 품목) | +| 정적 검사(저장 때) | `master_formula.py:744` `check_logic` · `M01_MasterData_Store_Cache.py:69` `_check` | +| M01 셈 진입 | `M01_MasterData/M01_MasterData_Store.py:543` `calc`(사본 읽기 → 줄 바꿔 끼움 `_swap` :524 → `cm.calc`) · `:572` `text`(읽는 식 줄 `master_text.py:493 lines`) · `:563` `auto`(견본 입력) | +| 일괄 | `check_master.py:174` `calc` · `:511` `calc_all`(시험입력으로 전 로직) | + +## ② 1,472 로직 식 말 전수 + +| 꼴 | 나온 수 | 로직 수 | 예(3) | +| --- | --- | --- | --- | +| 따옴표 글 | 12,536 | 1,085 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000273, 기계='6516-0060')` | +| 이름 = 입력 | 10,815 | 1,178 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기(…).칸 | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 찾기(표 · 조건) | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 사칙 * | 7,697 | 1,037 | GC000994 `찾기(QC000237).크롤러드릴_공기식 * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000997 `찾기(QC001866).크레인 * 8 / 시공량` | +| 이름 = 중간값 | 7,367 | 1,184 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 사칙 / | 3,824 | 1,019 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 비교 == | 3,736 | 451 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 함수 만약 | 3,541 | 477 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 호표 요소 = 키(인력) | 3,451 | 1,369 | GC000994 `LB000014` · GC000995 `LB000008` · GC000996 `LB000014` | +| 사칙 + | 3,094 | 668 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 로직(다른 로직 부르기) | 1,116 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 로직 넘길 값 있음 | 1,089 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 요소 = 로직 부르기(장비 · 일위대가) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 비목 없음(로직 줄 → 비목 나눔) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기 조건 없음(표 한 줄) | 1,022 | 286 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 요소 키 부르기(인력) | 924 | 393 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 호표 요소 = 재료 고르기 묶음(구분 · 상세구분 · 검색어) | 673 | 231 | GC000319 `{"구분": "도료·방수·접착", "상세구분": "도료", "검색어": "융착식 프라이머"}` · GC000337 `{"구분": "철근·강재", "상세구분": "철망·와이어메쉬", "검색어": "철망 Φ3.2."}` · GC000723 `{"구분": "잡재료", "상세구분": "용접·가스", "검색어": ""}` | +| 논리 또는 | 297 | 35 | GC000346 `(만약(공법군 == '발파굴착A군' 또는 공법군 == '기계굴착A군', 착암공인원, 0)) * 배율` · GC000111 `만약(토질_암분류 == '호박돌 섞인 고결토・경질토' 또는 토질_암분류 == '풍화암', 로직(GC000267, 기계='020` · GC001136 `찾기(QC002058, '규격(㎜)범위'=만약(규격 == 'ø15' 또는 규격 == '20' 또는 규격 == '25' 또는 규` | +| 사칙 - | 273 | 156 | GC000998 `찾기(QC001867).시공량 * (1 - (수직고초과단계 * 9 - 경사가산) / 100)` · GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` | +| 논리 그리고 | 199 | 23 | GC001120 `만약(부위 == '뿜칠재' 그리고 뿜칠면 == '콘크리트면', 1.2, 1)` · GC000753 `찾기(QC001431, '배관규격(mm)'=배관규격, 보온재=보온재, 보온두께=보온두께).시공량 * (1 + (0 - 만약(설` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` | +| 시험입력 있음 | 195 | 0 | | +| 요소키.칸(요소 칸 값) | 160 | 49 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000138 `EQ:{장비}.주연료종류` | +| 이름 = 비목 합(노무비 · 재료비 · 경비) | 153 | 146 | GC000722 `노무비 * 기계경비율 / 100` · GC000723 `노무비 * 기계경비율 / 100` · GC000724 `노무비 * 기계경비율 / 100` | +| 비교 <= | 123 | 36 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000346 `만약(갱구거리 <= 500, 0, 찾기(CC000077, 갱구에서뚫기점까지의거리=갱구거리).할증률)` · GC000185 `만약(타설깊이 <= 25, 찾기(QC000348, L='25이하').t_1, 만약(타설깊이 <= 30, 찾기(QC000348,` | +| 함수 최대 | 122 | 43 | GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000373 `최대(찾기(CC000125, 형식=만약(형식 == '박스거더' 또는 형식 == '강바닥판 박스거더', '박스거더', '박스거더` · GC000203 `최대(만약(용도 == '신구-콘크리트 접착제바르기', 찾기(QC000412, 용도=용도, 재료명='Epoxy신구-콘크리트접착제` | +| 비교 > | 99 | 44 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 요소 부르기 ID:원문번호(+{입력}) | 84 | 14 | GC000138 `EQ:{장비}.주연료종류` · GC000139 `EQ:{장비}.주연료종류` · GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 요소 키 부르기(기계) | 78 | 37 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000140 `EQ000004.주연료종류` | +| 찾기 조건 값이 식 | 70 | 33 | GC001008 `(특별인부 * LB000003 + 보통인부 * LB000002 + 그물막인부 * LB000002) * 찾기(QC001882, ` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 함수 올림 | 67 | 53 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 호표 요소 = 자리 표시 {입력}(LB:{직종} 등) | 66 | 57 | GC000559 `LB:{공수직종}` · GC001043 `MO:휘발유:{지역}` · GC000137 `MO:경유:{지역}` | +| 함수 최소 | 51 | 18 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 빼기 부호(-x) | 47 | 28 | GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` · GC001012 `찾기(QC001889).시공량 * (1 - (-살포량증감) / 100)` | +| 비교 < | 46 | 26 | GC000561 `만약(층수층 < 6, 찾기(CC000157, '강재사용량(㎏/㎡)'=강재사용량).'보정치(a)' * 찾기(CC000159, '` · GC000564 `만약(강재총사용량 < 300, 1, 찾기(CC000165, '강재총사용량(t)'=강재총사용량, '1t당 볼트본수(본)'=볼트본` · GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` | +| 함수 반올림 | 42 | 41 | GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000088 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 재료 묶음에 {입력} | 32 | 19 | GC000264 `{"구분": "레미콘", "상세구분": "레미콘", "검색어": "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"}` · GC000431 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "싱글코아바렐 {규격}"}` · GC000432 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "더블코아바렐 {규격}"}` | +| 함수 버림 | 32 | 17 | GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` · GC000268 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계` · GC000269 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 결과 식 로직(돈 아님) | 27 | 27 | GC000422 `a * E` · GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 요소 키 부르기(재료) | 23 | 8 | GC000360 `((찾기(QC000648, 품명='아세틸렌', 레일규격=레일규격).수량) * MT003163 + (찾기(QC000648, 품명` · GC000361 `((찾기(QC000650, 품명='테르밋용재', 레일규격=레일규격, 규격='점화용').수량) * MT030628 + (찾기(Q` · GC000374 `(찾기(QC000664, 품명='용접봉').수량 * MT003191 + 찾기(QC000664, 품명='산소').수량 * 100` | +| 비교 >= | 21 | 11 | GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` · GC000774 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001452,` | +| 로직(…).칸(비목 · 계) | 14 | 11 | GC000087 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000090 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000091 `로직(GC000268, 기계='5405-0110', 지역=지역).계 * (찾기(QC000189).유압식크롤러드릴)` | +| 비교 != | 12 | 8 | GC000068 `만약(작업 != '목재 붙이기', 찾기(QC000143, 구분='보통인부', 작업=작업).수량, 0)` · GC000176 `만약(비트 != '오거비트', 해머압축기대수 * T / 60, 0)` · GC000183 `만약(재질 != '토사', T1 / T * T / 60, 0)` | +| 줄.'호표 이름'(호표 줄 금액) | 7 | 3 | GC000211 `(줄.'합판(내수 12㎜)' + 줄.'각재') * 찾기(QC000428, 사용횟수=사용횟수).소모자재 / 100` · GF000185 `(노무비 + 재료비 + 경비 - 줄.'구체콘크리트(철근) 레미콘') * 찾기(QF000387, 구분='구체콘크리트(철근) - ` · GF000210 `(줄.'패널' + 줄.'내부패널') * 찾기(QF000418, 구분=유형).요율 / 100` | +| 로직 넘길 값이 식 | 5 | 5 | GC000332 `로직(GC000267, 기계=만약(형식 == 'A-Type', '0201-0040', '0201-0020'), 지역=지역, 보` · GF000294 `로직(GC000268, 기계=만약(근원직경 < 20, '2105-0010', 만약(찾기(QF000063, '근원직경(cm)'=` · GF000297 `로직(GC000268, 기계=만약(흉고직경 < 18, '2105-0010', 만약(찾기(QF000081, '흉고직경(cm)'=` | +| (글자) 이름( 꼴(함수 아닌 이름 뒤 괄호) | 1,177 | — | GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` | +| (글자) ∼ · ~ 물결 | 63 | — | GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC001026 `찾기(QC001905, 절단폭=절단폭, 차로차단=차로차단).'굴착기+브레이커(0.2㎥~0.6㎥)' * 8 / 시공량` · GC000573 `찾기(QC001207, 구분='그라우팅펌프', 규격='40∼125(ℓ/min)').수량` | +| (글자) % 글자 | 62 | — | GC000298 `찾기(QC000519, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000299 `찾기(QC000520, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000303 `찾기(QC000524, 형식=형식).시공량 * (1 - 만약(유도블록 == '아님', 0, 찾기(QC000583, 구분='유도` | +| (글자) {} 자리 표시 | 50 | — | GC000138 `EQ:{장비}.주연료종류` · GC000138 `1 * 8 / 시공량 * EQ:{장비}.주연료 * 찾기(QC000266, 구분='주연료 할증').값 / 100` · GC000139 `EQ:{장비}.주연료종류` | +| (글자) & 글자 | 53 | — | GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` · GC000931 `(찾기(QC001700, 공정별='소운반').비계공 + 찾기(QC001700, 공정별='Draft tube설치 가설된 Conc` · GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` | +- 「요소 부르기 꼴 `보통인부(…)`」 — **문법에 없음**(이름 뒤 괄호는 함수 · 찾기 · 로직만). 글자로 1,177 번 보이는 「이름(」 은 모두 따옴표 칸 이름 안(`찾기(…).'고소작업차(5ton)'`). 요소는 키로만 부름(`LB000006` · `EQ000532.주연료2` · `EQ:{기계}.가격`). +- `&` · `∼`/`~` · `%` · `{ }` 글자 — `&` · `∼` · `%` 는 모두 따옴표 글(조건 값 · 칸 이름) 안 · `{ }` 는 `ID:원문번호{입력}` 와 재료 검색어 · 자리 표시 안(아래 ③). +- 끝수 — 1,472 중 묶음(`{대상, 자리, 방법}`) **0** · 설명 글 37 · 없음 1,435 → 엔진이 끝수를 실제로 붙이는 로직은 지금 없음(덧줄 · 함수 안 `반올림(…)` 으로 씀). +- 표 — 로직이 찾는 표 1,865 · 범위 조건이 있는 표 106(범위규칙 글 40 꼴 — 첫 낱말은 넷뿐) · 값 칸에 「1/1.30」 같은 식 글이 든 표 104(엔진이 식으로 풂). + +## ③ 흐름도로 옮기기 어려운 꼴 + +| 꼴 | 수(마디 / 로직) | 왜 어려운가 | 이렇게 옮기면 됨 | +| --- | --- | --- | --- | +| 요소 칸 값 `EQ000532.주연료2` · `EQ000098.주연료` | 160 / 49 | 요소의 값이 아니라 **요소의 다른 칸**(기계 주연료 · 가격 · 계수) | 입력 상자 「기계」 요소 알약을 칸별로(바깥 값 「기계 key + field」 — Api 에 이미 있음 `CfQuery 기계 field`) | +| `ID:원문번호{입력}` — `EQ:{기계}.가격` · `EQ:{장비}.주연료` | 84 / 14 | 부를 요소가 **입력값에 따라 바뀜**(기계경비 로직 GC000267 등 핵심 로직) | 「재료 자리」 처럼 「기계 자리」(선택 자리 · 입력 알약 값으로 키 고름) — 지금 흐름도에 없음 → **새 입력 방식 필요(⬜ 브레인)** · 그 전엔 「다른 로직 입력」 으로 기계경비 로직을 통째로 부름 | +| 호표 요소 자리 표시 `LB:{공수직종}` · `MO:경유:{지역}` | 66 / 57 | 노임 직종 · 유가 지역이 입력값에 따라 바뀜 | 같은 「선택 자리」(인력 · 유가전력) — 재료 방식 셋을 다른 그룹에도 | +| 재료 묶음 검색어에 `{입력}` — `레미콘 {굵은골재최대치수}-{강도}-{슬럼프}` | 32 / 19 | 검색어가 입력값으로 만들어짐 | 재료 「조건 찾기」(이름 / 규격에 알약 값) — 알약 여럿을 이어 붙인 글이 필요 → 함수 상자로 글을 만들어 조건 알약으로 | +| 호표 로직 줄(비목 없음) `로직(GC000267, 기계='0201-0020', 지역=지역)` | 1,075 / 506 | 한 줄 금액이 **노무비 · 재료비 · 경비 셋으로 나뉨** | 다른 로직 입력 상자의 출력 알약 셋(노무비 · 재료비 · 경비) × 수량 함수 셋 → 출력 셋 | +| 로직 넘길 값이 식 `기계=만약(형식 == 'A-Type', '0201-0040', …)` | 5 / 5 | 넘길 값이 조건으로 바뀜 | 함수 상자(IF)로 값을 만들어 다른 로직의 받는 알약에 선 | +| 찾기 조건 값이 식 `'시공높이(m)'=최소(시공높이m, 7.2)` | 70 / 33 | 조건 값이 계산값 | 함수 상자로 먼저 셈 → 「표에서 찾기」 조건 알약에 그 출력 | +| 덧줄의 비목 합 `노무비 * 기계경비율 / 100` | 153 / 146 | 바탕이 **앞 줄까지의 비목 합**(덧줄이 덧줄을 바탕으로 쓸 수 있음) | 출력 상자 비목별 모음 함수(앞 줄 차례대로) → 비율 함수 · 앞 덧줄도 받는 알약으로 | +| 호표 줄 금액 `줄.'합판(내수 12㎜)'` | 7 / 3 | 특정 호표 줄 금액을 다시 씀 | 그 줄 금액 함수 출력에서 선 | +| 로직 결과 칸 `로직(…).계` | 14 / 11 | 다른 로직 돈 결과의 한 칸 | 다른 로직 입력의 그 비목 · 계 알약 | +| 표 한 줄(조건 없는 찾기) `찾기(QC001864).시공량` | 1,022 / 286 | 조건이 없어 「줄 하나」 표 | 「표에서 찾기」 조건 없이 칸만(표 첫 줄) — 줄이 둘 이상이면 옛 엔진도 오류 | +| 범위 조건 표(범위규칙 넷 · 위 끝 최소) | 표 106 | 엑셀 한 식으로 쓰기 어려움(빈 칸 = 무엇이든 · 규칙별 경계) | 선 규칙 「표에서 찾기」 가 엔진 `find` 와 같은 뜻으로(줄마다 맞음 칸 + INDEX/MATCH — 옛 가져오기에서 쓴 꼴) | +| 표 값의 식 글 「1/1.30」 | 표 104 | 엔진은 글을 식으로 풂 · 엑셀은 글 | 서버가 표 블록을 줄 때 수로 바꿔 줌 | +| 결과 식 로직(돈 아님) | 27 / 27 | 출력이 비용 아님 | 출력 종류 「값」 | +| 빈 입력 · 표에 없는 조건 | — | 엔진은 **그 줄만 비우고** 계속(합에서 뺌) | 흐름도 「끊긴 곳」 + 그 출력 0 으로 셈(같은 합) | + +## ④ 계산 길(로직 셈)을 부르는 곳 + +| 어디 | 무엇 | 자리 | +| --- | --- | --- | +| 서버 `POST /api/m01/calc` | 시험 계산(화면 · 저장 전 줄 포함) | `M01_MasterData_Router.py:222` → `Store.calc` :543 | +| 서버 `POST /api/m01/text` | 읽는 식 줄(텍스트 수식) | `M01_MasterData_Router.py:228` → `Store.text` :572 | +| 서버 `GET /api/m01/logic/auto` | 견본 입력값 | `M01_MasterData_Router.py:192` → `Store.auto` :563 → `master_auto.values` :80 | +| 화면 M01 단가산출 시험 계산 | `/calc` · `/text` · `/logic/auto` | `M01_MasterData_UI_Logic_Api.ts:224 · :229 · :245` · 부름 `M01_MasterData_UI_Logic_Calc.ts:142 · :155 · :184` | +| 서버 일위대가 조합 미리보기 | 조합에 담긴 로직마다 셈 | `M01_MasterData_Store_Combo.py:259 combo_preview` → `master_combo.py:106 run`(:128 `mf.run`) · M02 `POST /api/m02/layers/{layer}/combos/preview` `M02_MasterTemplete_Router_Combos.py:195` → `M02_MasterTemplete_Combo_Store.py:193` | +| 서버 프로젝트 사본 계산 | 프로젝트 항목 단가(복사본 표 · 노임) | `POST /api/m02/projects/{id}/items/{folder}/calc` `M02_Project_Items_Router.py:178` → `M02_Item_Copy.py:326` → `M02_Item_Prices.py:211`(:230 `mf.run`) · 화면 `ui_template/ui_template_item_picker_project.ts:218` | +| 흐름도 서버 | 단가 · 다른 로직 값(호표 한 줄 견본 로직으로 옛 셈 부름) | `M02_DF_Values.py:68 · :105 · :229 · :262`(`store.calc`) · `M02_DF_Router.py:216 logic_value`(흐름도 셈 `M02_DF_Compute`) | +| 옛 시험판 서버(CostFlow · 남김) | 같은 꼴 | `M02_CF_Router.py:230 · :267 · :390 · :421` | +| 일괄 · 대조 스크립트 | 전 로직 시험 계산 · 견적 대조 | `check_master.py:511 calc_all` · `compare_estx.py:258 · :456` · `master_text.py:504` | + +갈래(정함 4)를 넣을 곳 = **`Store.calc` · `Store.text` 둘**(M01 화면 · 흐름도 값이 모두 여기를 지남) + 조합 미리보기 `master_combo.run` · 프로젝트 `M02_Item_Prices.calc`(이 둘은 `mf.run` 을 바로 부름 — 흐름이 있는 로직을 거르려면 여기도). + +## ⑤ 시험입력 · 기본 입력 + +- 줄에 `시험입력` 이 있는 로직 **195**(1,472 중) — 일부 입력만 적힘(예 GF000219 `{"높이": 4.0, "돌": "깬잡석"}`). +- 기본 입력 만드는 법이 **둘**이고 범위에서 갈림: + - 일괄 시험(`check_master.py:496 시험입력`) — 고르기 첫 값 · 범위 **위 끝** · 그 밖 1 · 줄의 `시험입력` 이 이김. + - 화면 견본(`master_auto.py:80 values` · `/logic/auto`) — 고르기 첫 값 · 범위 **아래 끝** · 그 밖 = 그 입력으로 찾는 표의 가장 낮은 값(없으면 나누는 수면 1 · 아니면 0) · 단위에 「원」 이면 비움. +- 전수 대조(정함 3)는 **`check_master.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름). From 5d7291b89a98e1a43e1193cc70ed0a2919140091 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:28:34 +0900 Subject: [PATCH 06/26] =?UTF-8?q?feat(m01):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=99=BC=EC=AA=BD=20=E3=80=8C=EC=A7=80?= =?UTF-8?q?=EA=B8=88=20=EB=82=B4=EC=9A=A9=E3=80=8D=20=E2=80=94=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=C2=B7=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=8B=9D(buildFlowView)?= =?UTF-8?q?=20=C2=B7=20Detail=20=ED=9D=90=EB=A6=84=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowRows(순수): 출력 상자를 비용 종류별 줄 · 소계 · 계(합계 끝수 1원 미만 버림) · 함수 상자 결과 - FlowView: 화면 · sub2 식 보기가 있으면 식 두 줄 - Detail: DetailContext.flowHost 가 있으면 호표 · 읽는 식 대신 이 칸 - 시험 test_m01_logic_flowview.py Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M01_MasterData_UI_LogicLab_Detail.ts | 6 +- .../M01_MasterData_UI_LogicLab_FlowRows.ts | 122 +++++++++++ .../M01_MasterData_UI_LogicLab_FlowView.css | 30 +++ .../M01_MasterData_UI_LogicLab_FlowView.ts | 207 ++++++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Round.ts | 5 + resources/tester/test_m01_logic_flowview.py | 184 ++++++++++++++++ 6 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts create mode 100644 resources/tester/test_m01_logic_flowview.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 5842ec45e..2e599bb42 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -72,6 +72,9 @@ export interface DetailContext { onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */ calcHost: HTMLElement; + /** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 · + * 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */ + flowHost?: HTMLElement; } type Group = "labor" | "material" | "cost" | "other"; @@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { : null; host.replaceChildren( infoBox(ctx), - hoBox(ctx), - section(tl("Formula_Title"), [formula]), + ...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]), section(tl("Calc_Title"), [ctx.calcHost]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts new file mode 100644 index 000000000..a9652b42b --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowRows.ts + * 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어 + * 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py + * ========================================================================== */ + +import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; + +export type FlowGroupKey = "labor" | "material" | "cost" | "plain"; + +/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */ +export const GROUP_OF: Record = { + 노무비: "labor", + 재료비: "material", + 경비: "cost", + 값: "plain", +}; +export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"]; +const COST_OF: Record, "노무비" | "재료비" | "경비"> = { + labor: "노무비", + material: "재료비", + cost: "경비", +}; + +export interface FlowLine { + box: string; + name: string; + unit: string; + value: number | string | boolean | null; + /** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */ + text: string; +} + +export interface FlowGroup { + key: FlowGroupKey; + lines: FlowLine[]; + /** 비용 묶음만 — 합계 끝수를 적용한 소계 */ + sub: number | null; + subText: string; +} + +export interface FlowFunc { + box: string; + name: string; + unit: string; + value: unknown; + text: string; +} + +export interface FlowRows { + groups: FlowGroup[]; + /** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */ + total: number; + totalText: string; + funcs: FlowFunc[]; + errors: string[]; +} + +const plain = (v: unknown): string => + v === null || v === undefined || Array.isArray(v) + ? "—" + : typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : String(v); + +/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */ +export function flowRows( + lg: CfLogic, + ev: CfEvalResult, + round: { item: CfRound; total: CfRound }, +): 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), + }; + }).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), + funcs, + errors: ev.errors, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css new file mode 100644 index 000000000..4a4f282e8 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css @@ -0,0 +1,30 @@ +.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; +} + +.m01flow__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts new file mode 100644 index 000000000..87b9d9de2 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * 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 type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { itemRoundNow, totalRoundNow } 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"; + +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: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"], + None: ["출력 상자가 없음", "No output box"], + NoFunc: ["함수 상자가 없음", "No function box"], +} as const; + +type TextKey = keyof typeof TEXT; +const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; +const GROUP_TITLE: Record = { + labor: "Labor", + material: "Material", + cost: "Cost", + plain: "Plain", +}; + +/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */ +interface FormulaLine { + name: string; + named: string; + valued: string; + branches?: { when: string; then: string; now: boolean }[]; +} +type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[]; +const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>( + "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", +); +const loadFormula = async (): Promise => { + const load = Object.values(FORMULA)[0]; + return load ? ((await load()).formulaLines ?? null) : null; +}; + +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: "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) { + host.replaceChildren( + section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]), + ); + return; + } + const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() }); + const detail = section(tf("Detail"), [ + ...(rows.groups.length + ? rows.groups.map(groupTable) + : [el("p", { className: "m01-logic__muted", text: tf("None") })]), + el("p", { + className: "m01lab__total", + attrs: { "data-sum": String(rows.total) }, + text: `${tf("Total")} ${rows.totalText}`, + }), + el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), + ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), + ]); + const bodies = rows.funcs.map((f) => funcBlock(f, null)); + const text = section( + tf("Formula"), + bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })], + ); + host.replaceChildren(detail, text); + // sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함 + void loadFormula().then((fn) => { + if (!fn || !bodies.length) return; + const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func); + const merged = { ...ev.ports, ...ev.arrived } as CfValues; + text.replaceChildren( + text.firstElementChild!, + ...rows.funcs.map((f, i) => { + try { + return funcBlock(f, fn(boxes[i], merged)); + } catch { + return bodies[i]; + } + }), + ); + }); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index 5341b5a83..5101ad946 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string { maximumFractionDigits: Math.max(0, r.places), }); } + +/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */ +let totalNow: CfRound = { places: 0, method: "버림" }; +void defaultTotalRound().then((r) => (totalNow = r)); +export const totalRoundNow = (): CfRound => totalNow; diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py new file mode 100644 index 000000000..67a4d35ea --- /dev/null +++ b/resources/tester/test_m01_logic_flowview.py @@ -0,0 +1,184 @@ +"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 · +화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) }); +const cache = {}; +function load(f) { + if (cache[f]) return cache[f].exports; + const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[f] = { exports: {} }); + const req = (id) => { + if (id.startsWith(".")) { + const p = path.resolve(path.dirname(f), id); + return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; + } + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const m = load(file); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round)); +process.stdout.write(JSON.stringify(out)); +setTimeout(() => process.exit(0), 50); +""" + +ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}} + + +def box(id_, kind, name, unit="원", **extra): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#000", + "unit": unit, + "x": 0, + "y": 0, + **extra, + } + + +def out_box(id_, name, cost, unit="원", round_=None): + output = {"cost": cost, "unit": unit} + if round_: + output["round"] = round_ + return box(id_, "output", name, unit, output=output) + + +def func_box(id_, name, out_name, unit="시간"): + func = { + "sheet": {"id": "s-" + id_, "이름": name, "칸": {}}, + "inputs": [], + "variables": [], + "output": {"cell": "B1", "name": out_name, "unit": unit}, + } + return box(id_, "function", name, unit, func=func) + + +def logic(boxes): + return { + "key": None, + "name": "시험", + "resultUnit": "원/㎡", + "boxes": boxes, + "links": [], + "userElements": [], + } + + +def result(outputs, cost, ports=None, errors=None): + return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []} + + +def run(jobs: list[dict]) -> list: + node = "node" + done = subprocess.run( + [node, "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@pytest.fixture(scope="module") +def sample(): + lg = logic( + [ + out_box("o1", "인건비", "노무비"), + out_box("o2", "재료값", "재료비"), + out_box("o3", "시간당 작업량", "값", "㎥/시간"), + out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}), + func_box("f1", "운반 시간", "시간", "시간"), + ] + ) + ev = result( + {"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239}, + { + "노무비": 3216.69, + "재료비": 100.04, + "경비": 1.239, + "값들": {"시간당 작업량": 12.5}, + "계": 3317.969, + }, + ports={"f1": {"시간": 6.024}}, + ) + return run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + + +def group(rows, key): + return next(g for g in rows["groups"] if g["key"] == key) + + +def test_group_order_and_costs_only(sample): + assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"] + + +def test_line_uses_output_round(sample): + # 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음) + assert group(sample, "labor")["lines"][0]["text"] == "3,216.6" + assert group(sample, "material")["lines"][0]["text"] == "100" + # 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림) + assert group(sample, "cost")["lines"][0]["text"] == "1.24" + + +def test_plain_value_is_not_rounded_and_has_no_subtotal(sample): + plain = group(sample, "plain") + assert plain["lines"][0]["text"] == "12.5" + assert plain["lines"][0]["unit"] == "㎥/시간" + assert plain["sub"] is None + + +def test_subtotal_and_total_floor_to_won(sample): + assert group(sample, "labor")["sub"] == 3216 + assert group(sample, "labor")["subText"] == "3,216" + assert sample["total"] == 3317 + assert sample["totalText"] == "3,317" + + +def test_function_rows(sample): + assert sample["funcs"] == [ + {"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"} + ] + + +def test_empty_group_is_hidden(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10}) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert [g["key"] for g in rows["groups"]] == ["labor"] + assert rows["total"] == 10 + + +def test_missing_value_shows_dash(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result( + {"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"] + ) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert group(rows, "labor")["lines"][0]["text"] == "—" + assert rows["errors"] == ["끊김"] From 2d2c62defad0d077887c83043c7caba84bb83928 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:28:48 +0900 Subject: [PATCH 07/26] auto: 2026-10-01 23:28 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/흐름도_적용_조사.md | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_조사.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_조사.md b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md new file mode 100644 index 000000000..c2672300d --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md @@ -0,0 +1,121 @@ +# 흐름도 본 프로그램 적용 — 36-1 옛 식 조사 (sub6 · 읽기만) + +근거 = 마스터 로직 파일 57 개 · 로직 줄 **1,472**(자체 `로직_자체.json` 포함) · 식을 지금 서버 파서(`master_formula.parse`)로 전부 읽음 — **못 읽는 식 0** · 파일 안 씀. +수 칸 = 「나온 수(식 마디 수) / 그 꼴이 든 로직 수」 · 예 = 키 + 식 앞부분. + +## ① 옛 식을 푸는 서버 코드 + +| 무엇 | 자리 | +| --- | --- | +| 식 문법 · 파서(`_Parser` — 또는 → 그리고 → 비교 → + - → * / → 부호 · `.칸` → 기본) | `resources/master_data/scripts/master_formula.py:56`(`parse` :225 · 함수 목록 `FUNCS` :23 = 만약 · 올림 · 버림 · 반올림 · 최소 · 최대 · 비교 `_OPS` :27) | +| 기본 마디 — `( )` · `'글'` · 수 · `찾기(표, 조건=값…)` · `로직(키, 넘길=값…)` · 함수 · 요소 키(`LB000002`) · `ID:원문번호{입력}`(`EQ:{기계}`) · 이름 | 같은 파일 `primary` :185 · 요소 키 판정 `mk.KEY`(`master_keys.py`) | +| 셈(나무 풀기) — 이름 · 요소 값 · 찾기 · 로직 부름 · 함수 · 사칙 · 비교 · 그리고 / 또는(지름길) | `master_formula.py:385` `evaluate` | +| 표 찾기 — 빈 조건 칸 = 무엇이든 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하) · 여러 줄이면 위 끝 최소 · 찾는 칸 있는 줄 | `master_formula.py:331` `_match` · `:362` `find` | +| 로직 한 줄 셈 — 입력 검사 → 중간 차례 → 결과 식 또는 호표 줄(수량 × 단가 · 로직 줄은 비목 나눔) → 덧줄(앞 줄까지 비목 합) → 끝수 | `master_formula.py:538` `run` · 입력 `_inputs` :489 · 빈 가격 입력 `fill_empty` :479 · 끝수 `cut` :468 · `{입력}` 채우기 `fill` :314 | +| 요소 값 · 재료 단가 | `master_material.py:238` `element` · `master_pick.py:130` `priced` · `:123` `pick`(검색어 묶음 → 품목) | +| 정적 검사(저장 때) | `master_formula.py:744` `check_logic` · `M01_MasterData_Store_Cache.py:69` `_check` | +| M01 셈 진입 | `M01_MasterData/M01_MasterData_Store.py:543` `calc`(사본 읽기 → 줄 바꿔 끼움 `_swap` :524 → `cm.calc`) · `:572` `text`(읽는 식 줄 `master_text.py:493 lines`) · `:563` `auto`(견본 입력) | +| 일괄 | `check_master.py:174` `calc` · `:511` `calc_all`(시험입력으로 전 로직) | + +## ② 1,472 로직 식 말 전수 + +| 꼴 | 나온 수 | 로직 수 | 예(3) | +| --- | --- | --- | --- | +| 따옴표 글 | 12,536 | 1,085 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000273, 기계='6516-0060')` | +| 이름 = 입력 | 10,815 | 1,178 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기(…).칸 | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 찾기(표 · 조건) | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 사칙 * | 7,697 | 1,037 | GC000994 `찾기(QC000237).크롤러드릴_공기식 * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000997 `찾기(QC001866).크레인 * 8 / 시공량` | +| 이름 = 중간값 | 7,367 | 1,184 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 사칙 / | 3,824 | 1,019 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 비교 == | 3,736 | 451 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 함수 만약 | 3,541 | 477 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 호표 요소 = 키(인력) | 3,451 | 1,369 | GC000994 `LB000014` · GC000995 `LB000008` · GC000996 `LB000014` | +| 사칙 + | 3,094 | 668 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 로직(다른 로직 부르기) | 1,116 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 로직 넘길 값 있음 | 1,089 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 요소 = 로직 부르기(장비 · 일위대가) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 비목 없음(로직 줄 → 비목 나눔) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기 조건 없음(표 한 줄) | 1,022 | 286 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 요소 키 부르기(인력) | 924 | 393 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 호표 요소 = 재료 고르기 묶음(구분 · 상세구분 · 검색어) | 673 | 231 | GC000319 `{"구분": "도료·방수·접착", "상세구분": "도료", "검색어": "융착식 프라이머"}` · GC000337 `{"구분": "철근·강재", "상세구분": "철망·와이어메쉬", "검색어": "철망 Φ3.2."}` · GC000723 `{"구분": "잡재료", "상세구분": "용접·가스", "검색어": ""}` | +| 논리 또는 | 297 | 35 | GC000346 `(만약(공법군 == '발파굴착A군' 또는 공법군 == '기계굴착A군', 착암공인원, 0)) * 배율` · GC000111 `만약(토질_암분류 == '호박돌 섞인 고결토・경질토' 또는 토질_암분류 == '풍화암', 로직(GC000267, 기계='020` · GC001136 `찾기(QC002058, '규격(㎜)범위'=만약(규격 == 'ø15' 또는 규격 == '20' 또는 규격 == '25' 또는 규` | +| 사칙 - | 273 | 156 | GC000998 `찾기(QC001867).시공량 * (1 - (수직고초과단계 * 9 - 경사가산) / 100)` · GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` | +| 논리 그리고 | 199 | 23 | GC001120 `만약(부위 == '뿜칠재' 그리고 뿜칠면 == '콘크리트면', 1.2, 1)` · GC000753 `찾기(QC001431, '배관규격(mm)'=배관규격, 보온재=보온재, 보온두께=보온두께).시공량 * (1 + (0 - 만약(설` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` | +| 시험입력 있음 | 195 | 0 | | +| 요소키.칸(요소 칸 값) | 160 | 49 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000138 `EQ:{장비}.주연료종류` | +| 이름 = 비목 합(노무비 · 재료비 · 경비) | 153 | 146 | GC000722 `노무비 * 기계경비율 / 100` · GC000723 `노무비 * 기계경비율 / 100` · GC000724 `노무비 * 기계경비율 / 100` | +| 비교 <= | 123 | 36 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000346 `만약(갱구거리 <= 500, 0, 찾기(CC000077, 갱구에서뚫기점까지의거리=갱구거리).할증률)` · GC000185 `만약(타설깊이 <= 25, 찾기(QC000348, L='25이하').t_1, 만약(타설깊이 <= 30, 찾기(QC000348,` | +| 함수 최대 | 122 | 43 | GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000373 `최대(찾기(CC000125, 형식=만약(형식 == '박스거더' 또는 형식 == '강바닥판 박스거더', '박스거더', '박스거더` · GC000203 `최대(만약(용도 == '신구-콘크리트 접착제바르기', 찾기(QC000412, 용도=용도, 재료명='Epoxy신구-콘크리트접착제` | +| 비교 > | 99 | 44 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 요소 부르기 ID:원문번호(+{입력}) | 84 | 14 | GC000138 `EQ:{장비}.주연료종류` · GC000139 `EQ:{장비}.주연료종류` · GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 요소 키 부르기(기계) | 78 | 37 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000140 `EQ000004.주연료종류` | +| 찾기 조건 값이 식 | 70 | 33 | GC001008 `(특별인부 * LB000003 + 보통인부 * LB000002 + 그물막인부 * LB000002) * 찾기(QC001882, ` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 함수 올림 | 67 | 53 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 호표 요소 = 자리 표시 {입력}(LB:{직종} 등) | 66 | 57 | GC000559 `LB:{공수직종}` · GC001043 `MO:휘발유:{지역}` · GC000137 `MO:경유:{지역}` | +| 함수 최소 | 51 | 18 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 빼기 부호(-x) | 47 | 28 | GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` · GC001012 `찾기(QC001889).시공량 * (1 - (-살포량증감) / 100)` | +| 비교 < | 46 | 26 | GC000561 `만약(층수층 < 6, 찾기(CC000157, '강재사용량(㎏/㎡)'=강재사용량).'보정치(a)' * 찾기(CC000159, '` · GC000564 `만약(강재총사용량 < 300, 1, 찾기(CC000165, '강재총사용량(t)'=강재총사용량, '1t당 볼트본수(본)'=볼트본` · GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` | +| 함수 반올림 | 42 | 41 | GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000088 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 재료 묶음에 {입력} | 32 | 19 | GC000264 `{"구분": "레미콘", "상세구분": "레미콘", "검색어": "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"}` · GC000431 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "싱글코아바렐 {규격}"}` · GC000432 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "더블코아바렐 {규격}"}` | +| 함수 버림 | 32 | 17 | GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` · GC000268 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계` · GC000269 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 결과 식 로직(돈 아님) | 27 | 27 | GC000422 `a * E` · GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 요소 키 부르기(재료) | 23 | 8 | GC000360 `((찾기(QC000648, 품명='아세틸렌', 레일규격=레일규격).수량) * MT003163 + (찾기(QC000648, 품명` · GC000361 `((찾기(QC000650, 품명='테르밋용재', 레일규격=레일규격, 규격='점화용').수량) * MT030628 + (찾기(Q` · GC000374 `(찾기(QC000664, 품명='용접봉').수량 * MT003191 + 찾기(QC000664, 품명='산소').수량 * 100` | +| 비교 >= | 21 | 11 | GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` · GC000774 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001452,` | +| 로직(…).칸(비목 · 계) | 14 | 11 | GC000087 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000090 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000091 `로직(GC000268, 기계='5405-0110', 지역=지역).계 * (찾기(QC000189).유압식크롤러드릴)` | +| 비교 != | 12 | 8 | GC000068 `만약(작업 != '목재 붙이기', 찾기(QC000143, 구분='보통인부', 작업=작업).수량, 0)` · GC000176 `만약(비트 != '오거비트', 해머압축기대수 * T / 60, 0)` · GC000183 `만약(재질 != '토사', T1 / T * T / 60, 0)` | +| 줄.'호표 이름'(호표 줄 금액) | 7 | 3 | GC000211 `(줄.'합판(내수 12㎜)' + 줄.'각재') * 찾기(QC000428, 사용횟수=사용횟수).소모자재 / 100` · GF000185 `(노무비 + 재료비 + 경비 - 줄.'구체콘크리트(철근) 레미콘') * 찾기(QF000387, 구분='구체콘크리트(철근) - ` · GF000210 `(줄.'패널' + 줄.'내부패널') * 찾기(QF000418, 구분=유형).요율 / 100` | +| 로직 넘길 값이 식 | 5 | 5 | GC000332 `로직(GC000267, 기계=만약(형식 == 'A-Type', '0201-0040', '0201-0020'), 지역=지역, 보` · GF000294 `로직(GC000268, 기계=만약(근원직경 < 20, '2105-0010', 만약(찾기(QF000063, '근원직경(cm)'=` · GF000297 `로직(GC000268, 기계=만약(흉고직경 < 18, '2105-0010', 만약(찾기(QF000081, '흉고직경(cm)'=` | +| (글자) 이름( 꼴(함수 아닌 이름 뒤 괄호) | 1,177 | — | GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` | +| (글자) ∼ · ~ 물결 | 63 | — | GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC001026 `찾기(QC001905, 절단폭=절단폭, 차로차단=차로차단).'굴착기+브레이커(0.2㎥~0.6㎥)' * 8 / 시공량` · GC000573 `찾기(QC001207, 구분='그라우팅펌프', 규격='40∼125(ℓ/min)').수량` | +| (글자) % 글자 | 62 | — | GC000298 `찾기(QC000519, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000299 `찾기(QC000520, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000303 `찾기(QC000524, 형식=형식).시공량 * (1 - 만약(유도블록 == '아님', 0, 찾기(QC000583, 구분='유도` | +| (글자) {} 자리 표시 | 50 | — | GC000138 `EQ:{장비}.주연료종류` · GC000138 `1 * 8 / 시공량 * EQ:{장비}.주연료 * 찾기(QC000266, 구분='주연료 할증').값 / 100` · GC000139 `EQ:{장비}.주연료종류` | +| (글자) & 글자 | 53 | — | GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` · GC000931 `(찾기(QC001700, 공정별='소운반').비계공 + 찾기(QC001700, 공정별='Draft tube설치 가설된 Conc` · GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` | +- 「요소 부르기 꼴 `보통인부(…)`」 — **문법에 없음**(이름 뒤 괄호는 함수 · 찾기 · 로직만). 글자로 1,177 번 보이는 「이름(」 은 모두 따옴표 칸 이름 안(`찾기(…).'고소작업차(5ton)'`). 요소는 키로만 부름(`LB000006` · `EQ000532.주연료2` · `EQ:{기계}.가격`). +- `&` · `∼`/`~` · `%` · `{ }` 글자 — `&` · `∼` · `%` 는 모두 따옴표 글(조건 값 · 칸 이름) 안 · `{ }` 는 `ID:원문번호{입력}` 와 재료 검색어 · 자리 표시 안(아래 ③). +- 끝수 — 1,472 중 묶음(`{대상, 자리, 방법}`) **0** · 설명 글 37 · 없음 1,435 → 엔진이 끝수를 실제로 붙이는 로직은 지금 없음(덧줄 · 함수 안 `반올림(…)` 으로 씀). +- 표 — 로직이 찾는 표 1,865 · 범위 조건이 있는 표 106(범위규칙 글 40 꼴 — 첫 낱말은 넷뿐) · 값 칸에 「1/1.30」 같은 식 글이 든 표 104(엔진이 식으로 풂). + +## ③ 흐름도로 옮기기 어려운 꼴 + +| 꼴 | 수(마디 / 로직) | 왜 어려운가 | 이렇게 옮기면 됨 | +| --- | --- | --- | --- | +| 요소 칸 값 `EQ000532.주연료2` · `EQ000098.주연료` | 160 / 49 | 요소의 값이 아니라 **요소의 다른 칸**(기계 주연료 · 가격 · 계수) | 입력 상자 「기계」 요소 알약을 칸별로(바깥 값 「기계 key + field」 — Api 에 이미 있음 `CfQuery 기계 field`) | +| `ID:원문번호{입력}` — `EQ:{기계}.가격` · `EQ:{장비}.주연료` | 84 / 14 | 부를 요소가 **입력값에 따라 바뀜**(기계경비 로직 GC000267 등 핵심 로직) | 「재료 자리」 처럼 「기계 자리」(선택 자리 · 입력 알약 값으로 키 고름) — 지금 흐름도에 없음 → **새 입력 방식 필요(⬜ 브레인)** · 그 전엔 「다른 로직 입력」 으로 기계경비 로직을 통째로 부름 | +| 호표 요소 자리 표시 `LB:{공수직종}` · `MO:경유:{지역}` | 66 / 57 | 노임 직종 · 유가 지역이 입력값에 따라 바뀜 | 같은 「선택 자리」(인력 · 유가전력) — 재료 방식 셋을 다른 그룹에도 | +| 재료 묶음 검색어에 `{입력}` — `레미콘 {굵은골재최대치수}-{강도}-{슬럼프}` | 32 / 19 | 검색어가 입력값으로 만들어짐 | 재료 「조건 찾기」(이름 / 규격에 알약 값) — 알약 여럿을 이어 붙인 글이 필요 → 함수 상자로 글을 만들어 조건 알약으로 | +| 호표 로직 줄(비목 없음) `로직(GC000267, 기계='0201-0020', 지역=지역)` | 1,075 / 506 | 한 줄 금액이 **노무비 · 재료비 · 경비 셋으로 나뉨** | 다른 로직 입력 상자의 출력 알약 셋(노무비 · 재료비 · 경비) × 수량 함수 셋 → 출력 셋 | +| 로직 넘길 값이 식 `기계=만약(형식 == 'A-Type', '0201-0040', …)` | 5 / 5 | 넘길 값이 조건으로 바뀜 | 함수 상자(IF)로 값을 만들어 다른 로직의 받는 알약에 선 | +| 찾기 조건 값이 식 `'시공높이(m)'=최소(시공높이m, 7.2)` | 70 / 33 | 조건 값이 계산값 | 함수 상자로 먼저 셈 → 「표에서 찾기」 조건 알약에 그 출력 | +| 덧줄의 비목 합 `노무비 * 기계경비율 / 100` | 153 / 146 | 바탕이 **앞 줄까지의 비목 합**(덧줄이 덧줄을 바탕으로 쓸 수 있음) | 출력 상자 비목별 모음 함수(앞 줄 차례대로) → 비율 함수 · 앞 덧줄도 받는 알약으로 | +| 호표 줄 금액 `줄.'합판(내수 12㎜)'` | 7 / 3 | 특정 호표 줄 금액을 다시 씀 | 그 줄 금액 함수 출력에서 선 | +| 로직 결과 칸 `로직(…).계` | 14 / 11 | 다른 로직 돈 결과의 한 칸 | 다른 로직 입력의 그 비목 · 계 알약 | +| 표 한 줄(조건 없는 찾기) `찾기(QC001864).시공량` | 1,022 / 286 | 조건이 없어 「줄 하나」 표 | 「표에서 찾기」 조건 없이 칸만(표 첫 줄) — 줄이 둘 이상이면 옛 엔진도 오류 | +| 범위 조건 표(범위규칙 넷 · 위 끝 최소) | 표 106 | 엑셀 한 식으로 쓰기 어려움(빈 칸 = 무엇이든 · 규칙별 경계) | 선 규칙 「표에서 찾기」 가 엔진 `find` 와 같은 뜻으로(줄마다 맞음 칸 + INDEX/MATCH — 옛 가져오기에서 쓴 꼴) | +| 표 값의 식 글 「1/1.30」 | 표 104 | 엔진은 글을 식으로 풂 · 엑셀은 글 | 서버가 표 블록을 줄 때 수로 바꿔 줌 | +| 결과 식 로직(돈 아님) | 27 / 27 | 출력이 비용 아님 | 출력 종류 「값」 | +| 빈 입력 · 표에 없는 조건 | — | 엔진은 **그 줄만 비우고** 계속(합에서 뺌) | 흐름도 「끊긴 곳」 + 그 출력 0 으로 셈(같은 합) | + +## ④ 계산 길(로직 셈)을 부르는 곳 + +| 어디 | 무엇 | 자리 | +| --- | --- | --- | +| 서버 `POST /api/m01/calc` | 시험 계산(화면 · 저장 전 줄 포함) | `M01_MasterData_Router.py:222` → `Store.calc` :543 | +| 서버 `POST /api/m01/text` | 읽는 식 줄(텍스트 수식) | `M01_MasterData_Router.py:228` → `Store.text` :572 | +| 서버 `GET /api/m01/logic/auto` | 견본 입력값 | `M01_MasterData_Router.py:192` → `Store.auto` :563 → `master_auto.values` :80 | +| 화면 M01 단가산출 시험 계산 | `/calc` · `/text` · `/logic/auto` | `M01_MasterData_UI_Logic_Api.ts:224 · :229 · :245` · 부름 `M01_MasterData_UI_Logic_Calc.ts:142 · :155 · :184` | +| 서버 일위대가 조합 미리보기 | 조합에 담긴 로직마다 셈 | `M01_MasterData_Store_Combo.py:259 combo_preview` → `master_combo.py:106 run`(:128 `mf.run`) · M02 `POST /api/m02/layers/{layer}/combos/preview` `M02_MasterTemplete_Router_Combos.py:195` → `M02_MasterTemplete_Combo_Store.py:193` | +| 서버 프로젝트 사본 계산 | 프로젝트 항목 단가(복사본 표 · 노임) | `POST /api/m02/projects/{id}/items/{folder}/calc` `M02_Project_Items_Router.py:178` → `M02_Item_Copy.py:326` → `M02_Item_Prices.py:211`(:230 `mf.run`) · 화면 `ui_template/ui_template_item_picker_project.ts:218` | +| 흐름도 서버 | 단가 · 다른 로직 값(호표 한 줄 견본 로직으로 옛 셈 부름) | `M02_DF_Values.py:68 · :105 · :229 · :262`(`store.calc`) · `M02_DF_Router.py:216 logic_value`(흐름도 셈 `M02_DF_Compute`) | +| 옛 시험판 서버(CostFlow · 남김) | 같은 꼴 | `M02_CF_Router.py:230 · :267 · :390 · :421` | +| 일괄 · 대조 스크립트 | 전 로직 시험 계산 · 견적 대조 | `check_master.py:511 calc_all` · `compare_estx.py:258 · :456` · `master_text.py:504` | + +갈래(정함 4)를 넣을 곳 = **`Store.calc` · `Store.text` 둘**(M01 화면 · 흐름도 값이 모두 여기를 지남) + 조합 미리보기 `master_combo.run` · 프로젝트 `M02_Item_Prices.calc`(이 둘은 `mf.run` 을 바로 부름 — 흐름이 있는 로직을 거르려면 여기도). + +## ⑤ 시험입력 · 기본 입력 + +- 줄에 `시험입력` 이 있는 로직 **195**(1,472 중) — 일부 입력만 적힘(예 GF000219 `{"높이": 4.0, "돌": "깬잡석"}`). +- 기본 입력 만드는 법이 **둘**이고 범위에서 갈림: + - 일괄 시험(`check_master.py:496 시험입력`) — 고르기 첫 값 · 범위 **위 끝** · 그 밖 1 · 줄의 `시험입력` 이 이김. + - 화면 견본(`master_auto.py:80 values` · `/logic/auto`) — 고르기 첫 값 · 범위 **아래 끝** · 그 밖 = 그 입력으로 찾는 표의 가장 낮은 값(없으면 나누는 수면 1 · 아니면 0) · 단위에 「원」 이면 비움. +- 전수 대조(정함 3)는 **`check_master.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름). From 672dbbd1b7256d74c9d34217ba314999891fc576 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:29:20 +0900 Subject: [PATCH 08/26] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=A1=B0=EC=82=AC=20=E2=80=94=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20docs=20=EC=82=AC=EB=B3=B8=20=EB=8B=B4?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/흐름도_적용_조사.md | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_조사.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_조사.md b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md new file mode 100644 index 000000000..c2672300d --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md @@ -0,0 +1,121 @@ +# 흐름도 본 프로그램 적용 — 36-1 옛 식 조사 (sub6 · 읽기만) + +근거 = 마스터 로직 파일 57 개 · 로직 줄 **1,472**(자체 `로직_자체.json` 포함) · 식을 지금 서버 파서(`master_formula.parse`)로 전부 읽음 — **못 읽는 식 0** · 파일 안 씀. +수 칸 = 「나온 수(식 마디 수) / 그 꼴이 든 로직 수」 · 예 = 키 + 식 앞부분. + +## ① 옛 식을 푸는 서버 코드 + +| 무엇 | 자리 | +| --- | --- | +| 식 문법 · 파서(`_Parser` — 또는 → 그리고 → 비교 → + - → * / → 부호 · `.칸` → 기본) | `resources/master_data/scripts/master_formula.py:56`(`parse` :225 · 함수 목록 `FUNCS` :23 = 만약 · 올림 · 버림 · 반올림 · 최소 · 최대 · 비교 `_OPS` :27) | +| 기본 마디 — `( )` · `'글'` · 수 · `찾기(표, 조건=값…)` · `로직(키, 넘길=값…)` · 함수 · 요소 키(`LB000002`) · `ID:원문번호{입력}`(`EQ:{기계}`) · 이름 | 같은 파일 `primary` :185 · 요소 키 판정 `mk.KEY`(`master_keys.py`) | +| 셈(나무 풀기) — 이름 · 요소 값 · 찾기 · 로직 부름 · 함수 · 사칙 · 비교 · 그리고 / 또는(지름길) | `master_formula.py:385` `evaluate` | +| 표 찾기 — 빈 조건 칸 = 무엇이든 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하) · 여러 줄이면 위 끝 최소 · 찾는 칸 있는 줄 | `master_formula.py:331` `_match` · `:362` `find` | +| 로직 한 줄 셈 — 입력 검사 → 중간 차례 → 결과 식 또는 호표 줄(수량 × 단가 · 로직 줄은 비목 나눔) → 덧줄(앞 줄까지 비목 합) → 끝수 | `master_formula.py:538` `run` · 입력 `_inputs` :489 · 빈 가격 입력 `fill_empty` :479 · 끝수 `cut` :468 · `{입력}` 채우기 `fill` :314 | +| 요소 값 · 재료 단가 | `master_material.py:238` `element` · `master_pick.py:130` `priced` · `:123` `pick`(검색어 묶음 → 품목) | +| 정적 검사(저장 때) | `master_formula.py:744` `check_logic` · `M01_MasterData_Store_Cache.py:69` `_check` | +| M01 셈 진입 | `M01_MasterData/M01_MasterData_Store.py:543` `calc`(사본 읽기 → 줄 바꿔 끼움 `_swap` :524 → `cm.calc`) · `:572` `text`(읽는 식 줄 `master_text.py:493 lines`) · `:563` `auto`(견본 입력) | +| 일괄 | `check_master.py:174` `calc` · `:511` `calc_all`(시험입력으로 전 로직) | + +## ② 1,472 로직 식 말 전수 + +| 꼴 | 나온 수 | 로직 수 | 예(3) | +| --- | --- | --- | --- | +| 따옴표 글 | 12,536 | 1,085 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000273, 기계='6516-0060')` | +| 이름 = 입력 | 10,815 | 1,178 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기(…).칸 | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 찾기(표 · 조건) | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 사칙 * | 7,697 | 1,037 | GC000994 `찾기(QC000237).크롤러드릴_공기식 * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000997 `찾기(QC001866).크레인 * 8 / 시공량` | +| 이름 = 중간값 | 7,367 | 1,184 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 사칙 / | 3,824 | 1,019 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 비교 == | 3,736 | 451 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 함수 만약 | 3,541 | 477 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 호표 요소 = 키(인력) | 3,451 | 1,369 | GC000994 `LB000014` · GC000995 `LB000008` · GC000996 `LB000014` | +| 사칙 + | 3,094 | 668 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 로직(다른 로직 부르기) | 1,116 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 로직 넘길 값 있음 | 1,089 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 요소 = 로직 부르기(장비 · 일위대가) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 비목 없음(로직 줄 → 비목 나눔) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기 조건 없음(표 한 줄) | 1,022 | 286 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 요소 키 부르기(인력) | 924 | 393 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 호표 요소 = 재료 고르기 묶음(구분 · 상세구분 · 검색어) | 673 | 231 | GC000319 `{"구분": "도료·방수·접착", "상세구분": "도료", "검색어": "융착식 프라이머"}` · GC000337 `{"구분": "철근·강재", "상세구분": "철망·와이어메쉬", "검색어": "철망 Φ3.2."}` · GC000723 `{"구분": "잡재료", "상세구분": "용접·가스", "검색어": ""}` | +| 논리 또는 | 297 | 35 | GC000346 `(만약(공법군 == '발파굴착A군' 또는 공법군 == '기계굴착A군', 착암공인원, 0)) * 배율` · GC000111 `만약(토질_암분류 == '호박돌 섞인 고결토・경질토' 또는 토질_암분류 == '풍화암', 로직(GC000267, 기계='020` · GC001136 `찾기(QC002058, '규격(㎜)범위'=만약(규격 == 'ø15' 또는 규격 == '20' 또는 규격 == '25' 또는 규` | +| 사칙 - | 273 | 156 | GC000998 `찾기(QC001867).시공량 * (1 - (수직고초과단계 * 9 - 경사가산) / 100)` · GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` | +| 논리 그리고 | 199 | 23 | GC001120 `만약(부위 == '뿜칠재' 그리고 뿜칠면 == '콘크리트면', 1.2, 1)` · GC000753 `찾기(QC001431, '배관규격(mm)'=배관규격, 보온재=보온재, 보온두께=보온두께).시공량 * (1 + (0 - 만약(설` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` | +| 시험입력 있음 | 195 | 0 | | +| 요소키.칸(요소 칸 값) | 160 | 49 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000138 `EQ:{장비}.주연료종류` | +| 이름 = 비목 합(노무비 · 재료비 · 경비) | 153 | 146 | GC000722 `노무비 * 기계경비율 / 100` · GC000723 `노무비 * 기계경비율 / 100` · GC000724 `노무비 * 기계경비율 / 100` | +| 비교 <= | 123 | 36 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000346 `만약(갱구거리 <= 500, 0, 찾기(CC000077, 갱구에서뚫기점까지의거리=갱구거리).할증률)` · GC000185 `만약(타설깊이 <= 25, 찾기(QC000348, L='25이하').t_1, 만약(타설깊이 <= 30, 찾기(QC000348,` | +| 함수 최대 | 122 | 43 | GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000373 `최대(찾기(CC000125, 형식=만약(형식 == '박스거더' 또는 형식 == '강바닥판 박스거더', '박스거더', '박스거더` · GC000203 `최대(만약(용도 == '신구-콘크리트 접착제바르기', 찾기(QC000412, 용도=용도, 재료명='Epoxy신구-콘크리트접착제` | +| 비교 > | 99 | 44 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 요소 부르기 ID:원문번호(+{입력}) | 84 | 14 | GC000138 `EQ:{장비}.주연료종류` · GC000139 `EQ:{장비}.주연료종류` · GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 요소 키 부르기(기계) | 78 | 37 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000140 `EQ000004.주연료종류` | +| 찾기 조건 값이 식 | 70 | 33 | GC001008 `(특별인부 * LB000003 + 보통인부 * LB000002 + 그물막인부 * LB000002) * 찾기(QC001882, ` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 함수 올림 | 67 | 53 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 호표 요소 = 자리 표시 {입력}(LB:{직종} 등) | 66 | 57 | GC000559 `LB:{공수직종}` · GC001043 `MO:휘발유:{지역}` · GC000137 `MO:경유:{지역}` | +| 함수 최소 | 51 | 18 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 빼기 부호(-x) | 47 | 28 | GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` · GC001012 `찾기(QC001889).시공량 * (1 - (-살포량증감) / 100)` | +| 비교 < | 46 | 26 | GC000561 `만약(층수층 < 6, 찾기(CC000157, '강재사용량(㎏/㎡)'=강재사용량).'보정치(a)' * 찾기(CC000159, '` · GC000564 `만약(강재총사용량 < 300, 1, 찾기(CC000165, '강재총사용량(t)'=강재총사용량, '1t당 볼트본수(본)'=볼트본` · GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` | +| 함수 반올림 | 42 | 41 | GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000088 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 재료 묶음에 {입력} | 32 | 19 | GC000264 `{"구분": "레미콘", "상세구분": "레미콘", "검색어": "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"}` · GC000431 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "싱글코아바렐 {규격}"}` · GC000432 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "더블코아바렐 {규격}"}` | +| 함수 버림 | 32 | 17 | GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` · GC000268 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계` · GC000269 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 결과 식 로직(돈 아님) | 27 | 27 | GC000422 `a * E` · GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 요소 키 부르기(재료) | 23 | 8 | GC000360 `((찾기(QC000648, 품명='아세틸렌', 레일규격=레일규격).수량) * MT003163 + (찾기(QC000648, 품명` · GC000361 `((찾기(QC000650, 품명='테르밋용재', 레일규격=레일규격, 규격='점화용').수량) * MT030628 + (찾기(Q` · GC000374 `(찾기(QC000664, 품명='용접봉').수량 * MT003191 + 찾기(QC000664, 품명='산소').수량 * 100` | +| 비교 >= | 21 | 11 | GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` · GC000774 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001452,` | +| 로직(…).칸(비목 · 계) | 14 | 11 | GC000087 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000090 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000091 `로직(GC000268, 기계='5405-0110', 지역=지역).계 * (찾기(QC000189).유압식크롤러드릴)` | +| 비교 != | 12 | 8 | GC000068 `만약(작업 != '목재 붙이기', 찾기(QC000143, 구분='보통인부', 작업=작업).수량, 0)` · GC000176 `만약(비트 != '오거비트', 해머압축기대수 * T / 60, 0)` · GC000183 `만약(재질 != '토사', T1 / T * T / 60, 0)` | +| 줄.'호표 이름'(호표 줄 금액) | 7 | 3 | GC000211 `(줄.'합판(내수 12㎜)' + 줄.'각재') * 찾기(QC000428, 사용횟수=사용횟수).소모자재 / 100` · GF000185 `(노무비 + 재료비 + 경비 - 줄.'구체콘크리트(철근) 레미콘') * 찾기(QF000387, 구분='구체콘크리트(철근) - ` · GF000210 `(줄.'패널' + 줄.'내부패널') * 찾기(QF000418, 구분=유형).요율 / 100` | +| 로직 넘길 값이 식 | 5 | 5 | GC000332 `로직(GC000267, 기계=만약(형식 == 'A-Type', '0201-0040', '0201-0020'), 지역=지역, 보` · GF000294 `로직(GC000268, 기계=만약(근원직경 < 20, '2105-0010', 만약(찾기(QF000063, '근원직경(cm)'=` · GF000297 `로직(GC000268, 기계=만약(흉고직경 < 18, '2105-0010', 만약(찾기(QF000081, '흉고직경(cm)'=` | +| (글자) 이름( 꼴(함수 아닌 이름 뒤 괄호) | 1,177 | — | GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` | +| (글자) ∼ · ~ 물결 | 63 | — | GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC001026 `찾기(QC001905, 절단폭=절단폭, 차로차단=차로차단).'굴착기+브레이커(0.2㎥~0.6㎥)' * 8 / 시공량` · GC000573 `찾기(QC001207, 구분='그라우팅펌프', 규격='40∼125(ℓ/min)').수량` | +| (글자) % 글자 | 62 | — | GC000298 `찾기(QC000519, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000299 `찾기(QC000520, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000303 `찾기(QC000524, 형식=형식).시공량 * (1 - 만약(유도블록 == '아님', 0, 찾기(QC000583, 구분='유도` | +| (글자) {} 자리 표시 | 50 | — | GC000138 `EQ:{장비}.주연료종류` · GC000138 `1 * 8 / 시공량 * EQ:{장비}.주연료 * 찾기(QC000266, 구분='주연료 할증').값 / 100` · GC000139 `EQ:{장비}.주연료종류` | +| (글자) & 글자 | 53 | — | GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` · GC000931 `(찾기(QC001700, 공정별='소운반').비계공 + 찾기(QC001700, 공정별='Draft tube설치 가설된 Conc` · GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` | +- 「요소 부르기 꼴 `보통인부(…)`」 — **문법에 없음**(이름 뒤 괄호는 함수 · 찾기 · 로직만). 글자로 1,177 번 보이는 「이름(」 은 모두 따옴표 칸 이름 안(`찾기(…).'고소작업차(5ton)'`). 요소는 키로만 부름(`LB000006` · `EQ000532.주연료2` · `EQ:{기계}.가격`). +- `&` · `∼`/`~` · `%` · `{ }` 글자 — `&` · `∼` · `%` 는 모두 따옴표 글(조건 값 · 칸 이름) 안 · `{ }` 는 `ID:원문번호{입력}` 와 재료 검색어 · 자리 표시 안(아래 ③). +- 끝수 — 1,472 중 묶음(`{대상, 자리, 방법}`) **0** · 설명 글 37 · 없음 1,435 → 엔진이 끝수를 실제로 붙이는 로직은 지금 없음(덧줄 · 함수 안 `반올림(…)` 으로 씀). +- 표 — 로직이 찾는 표 1,865 · 범위 조건이 있는 표 106(범위규칙 글 40 꼴 — 첫 낱말은 넷뿐) · 값 칸에 「1/1.30」 같은 식 글이 든 표 104(엔진이 식으로 풂). + +## ③ 흐름도로 옮기기 어려운 꼴 + +| 꼴 | 수(마디 / 로직) | 왜 어려운가 | 이렇게 옮기면 됨 | +| --- | --- | --- | --- | +| 요소 칸 값 `EQ000532.주연료2` · `EQ000098.주연료` | 160 / 49 | 요소의 값이 아니라 **요소의 다른 칸**(기계 주연료 · 가격 · 계수) | 입력 상자 「기계」 요소 알약을 칸별로(바깥 값 「기계 key + field」 — Api 에 이미 있음 `CfQuery 기계 field`) | +| `ID:원문번호{입력}` — `EQ:{기계}.가격` · `EQ:{장비}.주연료` | 84 / 14 | 부를 요소가 **입력값에 따라 바뀜**(기계경비 로직 GC000267 등 핵심 로직) | 「재료 자리」 처럼 「기계 자리」(선택 자리 · 입력 알약 값으로 키 고름) — 지금 흐름도에 없음 → **새 입력 방식 필요(⬜ 브레인)** · 그 전엔 「다른 로직 입력」 으로 기계경비 로직을 통째로 부름 | +| 호표 요소 자리 표시 `LB:{공수직종}` · `MO:경유:{지역}` | 66 / 57 | 노임 직종 · 유가 지역이 입력값에 따라 바뀜 | 같은 「선택 자리」(인력 · 유가전력) — 재료 방식 셋을 다른 그룹에도 | +| 재료 묶음 검색어에 `{입력}` — `레미콘 {굵은골재최대치수}-{강도}-{슬럼프}` | 32 / 19 | 검색어가 입력값으로 만들어짐 | 재료 「조건 찾기」(이름 / 규격에 알약 값) — 알약 여럿을 이어 붙인 글이 필요 → 함수 상자로 글을 만들어 조건 알약으로 | +| 호표 로직 줄(비목 없음) `로직(GC000267, 기계='0201-0020', 지역=지역)` | 1,075 / 506 | 한 줄 금액이 **노무비 · 재료비 · 경비 셋으로 나뉨** | 다른 로직 입력 상자의 출력 알약 셋(노무비 · 재료비 · 경비) × 수량 함수 셋 → 출력 셋 | +| 로직 넘길 값이 식 `기계=만약(형식 == 'A-Type', '0201-0040', …)` | 5 / 5 | 넘길 값이 조건으로 바뀜 | 함수 상자(IF)로 값을 만들어 다른 로직의 받는 알약에 선 | +| 찾기 조건 값이 식 `'시공높이(m)'=최소(시공높이m, 7.2)` | 70 / 33 | 조건 값이 계산값 | 함수 상자로 먼저 셈 → 「표에서 찾기」 조건 알약에 그 출력 | +| 덧줄의 비목 합 `노무비 * 기계경비율 / 100` | 153 / 146 | 바탕이 **앞 줄까지의 비목 합**(덧줄이 덧줄을 바탕으로 쓸 수 있음) | 출력 상자 비목별 모음 함수(앞 줄 차례대로) → 비율 함수 · 앞 덧줄도 받는 알약으로 | +| 호표 줄 금액 `줄.'합판(내수 12㎜)'` | 7 / 3 | 특정 호표 줄 금액을 다시 씀 | 그 줄 금액 함수 출력에서 선 | +| 로직 결과 칸 `로직(…).계` | 14 / 11 | 다른 로직 돈 결과의 한 칸 | 다른 로직 입력의 그 비목 · 계 알약 | +| 표 한 줄(조건 없는 찾기) `찾기(QC001864).시공량` | 1,022 / 286 | 조건이 없어 「줄 하나」 표 | 「표에서 찾기」 조건 없이 칸만(표 첫 줄) — 줄이 둘 이상이면 옛 엔진도 오류 | +| 범위 조건 표(범위규칙 넷 · 위 끝 최소) | 표 106 | 엑셀 한 식으로 쓰기 어려움(빈 칸 = 무엇이든 · 규칙별 경계) | 선 규칙 「표에서 찾기」 가 엔진 `find` 와 같은 뜻으로(줄마다 맞음 칸 + INDEX/MATCH — 옛 가져오기에서 쓴 꼴) | +| 표 값의 식 글 「1/1.30」 | 표 104 | 엔진은 글을 식으로 풂 · 엑셀은 글 | 서버가 표 블록을 줄 때 수로 바꿔 줌 | +| 결과 식 로직(돈 아님) | 27 / 27 | 출력이 비용 아님 | 출력 종류 「값」 | +| 빈 입력 · 표에 없는 조건 | — | 엔진은 **그 줄만 비우고** 계속(합에서 뺌) | 흐름도 「끊긴 곳」 + 그 출력 0 으로 셈(같은 합) | + +## ④ 계산 길(로직 셈)을 부르는 곳 + +| 어디 | 무엇 | 자리 | +| --- | --- | --- | +| 서버 `POST /api/m01/calc` | 시험 계산(화면 · 저장 전 줄 포함) | `M01_MasterData_Router.py:222` → `Store.calc` :543 | +| 서버 `POST /api/m01/text` | 읽는 식 줄(텍스트 수식) | `M01_MasterData_Router.py:228` → `Store.text` :572 | +| 서버 `GET /api/m01/logic/auto` | 견본 입력값 | `M01_MasterData_Router.py:192` → `Store.auto` :563 → `master_auto.values` :80 | +| 화면 M01 단가산출 시험 계산 | `/calc` · `/text` · `/logic/auto` | `M01_MasterData_UI_Logic_Api.ts:224 · :229 · :245` · 부름 `M01_MasterData_UI_Logic_Calc.ts:142 · :155 · :184` | +| 서버 일위대가 조합 미리보기 | 조합에 담긴 로직마다 셈 | `M01_MasterData_Store_Combo.py:259 combo_preview` → `master_combo.py:106 run`(:128 `mf.run`) · M02 `POST /api/m02/layers/{layer}/combos/preview` `M02_MasterTemplete_Router_Combos.py:195` → `M02_MasterTemplete_Combo_Store.py:193` | +| 서버 프로젝트 사본 계산 | 프로젝트 항목 단가(복사본 표 · 노임) | `POST /api/m02/projects/{id}/items/{folder}/calc` `M02_Project_Items_Router.py:178` → `M02_Item_Copy.py:326` → `M02_Item_Prices.py:211`(:230 `mf.run`) · 화면 `ui_template/ui_template_item_picker_project.ts:218` | +| 흐름도 서버 | 단가 · 다른 로직 값(호표 한 줄 견본 로직으로 옛 셈 부름) | `M02_DF_Values.py:68 · :105 · :229 · :262`(`store.calc`) · `M02_DF_Router.py:216 logic_value`(흐름도 셈 `M02_DF_Compute`) | +| 옛 시험판 서버(CostFlow · 남김) | 같은 꼴 | `M02_CF_Router.py:230 · :267 · :390 · :421` | +| 일괄 · 대조 스크립트 | 전 로직 시험 계산 · 견적 대조 | `check_master.py:511 calc_all` · `compare_estx.py:258 · :456` · `master_text.py:504` | + +갈래(정함 4)를 넣을 곳 = **`Store.calc` · `Store.text` 둘**(M01 화면 · 흐름도 값이 모두 여기를 지남) + 조합 미리보기 `master_combo.run` · 프로젝트 `M02_Item_Prices.calc`(이 둘은 `mf.run` 을 바로 부름 — 흐름이 있는 로직을 거르려면 여기도). + +## ⑤ 시험입력 · 기본 입력 + +- 줄에 `시험입력` 이 있는 로직 **195**(1,472 중) — 일부 입력만 적힘(예 GF000219 `{"높이": 4.0, "돌": "깬잡석"}`). +- 기본 입력 만드는 법이 **둘**이고 범위에서 갈림: + - 일괄 시험(`check_master.py:496 시험입력`) — 고르기 첫 값 · 범위 **위 끝** · 그 밖 1 · 줄의 `시험입력` 이 이김. + - 화면 견본(`master_auto.py:80 values` · `/logic/auto`) — 고르기 첫 값 · 범위 **아래 끝** · 그 밖 = 그 입력으로 찾는 표의 가장 낮은 값(없으면 나누는 수면 1 · 아니면 0) · 단위에 「원」 이면 비움. +- 전수 대조(정함 3)는 **`check_master.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름). From fbb6902576fc47ca12fca6f18920c86a6a7bb9f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:29:48 +0900 Subject: [PATCH 09/26] =?UTF-8?q?feat(m02-df):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=3D=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EC=9B=90=EB=AC=B8=20=C2=B7=20=EC=9E=A5?= =?UTF-8?q?=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20=EC=AA=BD=20=C2=B7=20?= =?UTF-8?q?=EC=88=9C=ED=99=98=20=EB=A7=89=EC=9D=8C=20=C2=B7=20=EA=B0=92?= =?UTF-8?q?=EC=9D=80=20=EC=84=9C=EB=B2=84=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M02_MasterTemplete/DataFlow/M02_DF_Api.ts | 19 ++ .../DataFlow/M02_DF_InputPanel_Logic.ts | 288 +++++++++++++++--- resources/tester/test_m02_df_input_logic.py | 230 ++++++++++++++ 3 files changed, 493 insertions(+), 44 deletions(-) create mode 100644 resources/tester/test_m02_df_input_logic.py diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 35596a0c9..3e7775ebd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -206,6 +206,25 @@ export async function cfLogicValue( return send("/logic-value", { key, designs }); } +/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true · + * notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */ +export async function cfLogicFlow( + file: string, + key: string, +): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, { + credentials: "include", + }); + } catch { + throw new CfError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; + if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText); + return data as { flow: CfLogic; converted: boolean; notes: string[] }; +} + /** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */ export async function cfDesignInputs(key: string): Promise { const qs = new URLSearchParams({ key }).toString(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index a59e4e3d4..cdc55845d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -1,42 +1,83 @@ /* ============================================================================= * M02_DF_InputPanel_Logic.ts - * 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면 - * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) + * 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면 + * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길 + * 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름 * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; -import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api"; -import type { CfLogic } from "./M02_DF_Model"; -import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; +import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; +import { + filterLogics, + logicBooks, + logicChapters, + logicRefOf, + masterRef, + pageRange, + setKind, + setLogicInput, + wouldCycle, + type MasterLogicBrief, +} from "./M02_DF_InputPanel_Build"; const TEXT = { Help: [ - "저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀", - "Use a saved logic or sample as an input", + "마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀", + "Use any master logic or a logic of mine as an input", ], - Search: ["로직 이름으로 찾기", "Search by name"], + Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], + Book: ["원문", "Source"], + Chapter: ["장", "Chapter"], + All: ["전체", "All"], Loading: ["찾는 중…", "Searching…"], None: ["찾는 로직 없음", "No logic found"], - Sample: ["견본", "Sample"], + Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], + Prev: ["이전", "Prev"], + Next: ["다음", "Next"], Mine: ["내가 만든 것", "Mine"], + Blocked: ["읽을 수 없음", "Unreadable"], Chosen: ["고른 로직", "Chosen logic"], - Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"], + Converted: [ + "옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜", + "Old logic read as a flow — the file is unchanged", + ], + Notes: ["못 옮긴 곳", "Not carried over"], + Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"], Designs: [ - "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)", + "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)", "Pills it takes — its design inputs", ], NoOut: ["출력 상자가 없음", "No output box"], NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"], Change: ["다른 로직 고르기", "Pick another"], Remove: ["빼기", "Remove"], - Self: ["이 로직 자신은 못 씀", "Can't use itself"], + Self: ["이 로직 자신은 못 고름", "Can't pick itself"], + Cycle: [ + "돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름", + "That would loop — a logic that uses this one can't be picked", + ], Failed: ["불러오지 못함", "Could not load"], + Wait: ["값 셈 중…", "Calculating…"], } as const; type TKey = keyof typeof TEXT; -const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); const plain = (e: unknown): string => { try { @@ -46,6 +87,40 @@ const plain = (e: unknown): string => { } }; +const PAGE = 12; +const MINE = "흐름도"; + +/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */ +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)))); + +/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */ +const readNotes = new Map(); + +/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */ +async function 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.키); +} + /** 다른 로직 입력 판 */ export function renderLogicInput( host: HTMLElement, @@ -55,32 +130,86 @@ export function renderLogicInput( ): void { const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic; if (ref) return void showChosen(host, lg, boxId, ref, on); + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-dfip__list" }); - let all: CfListItem[] = []; - const draw = (): void => { - const word = q.input.value.trim().toLowerCase(); - const rows = all.filter( - (i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)), + const all = { value: "", text: t("All") }; + let book = ""; + let chapter = ""; + let page = 1; + let items: MasterLogicBrief[] = []; + const bookBox = createSelectField({ + label: t("Book"), + options: [all], + value: "", + compact: true, + onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()), + }); + const chapterBox = createSelectField({ + label: t("Chapter"), + options: [all], + value: "", + compact: true, + onChange: (v) => ((chapter = v), (page = 1), draw()), + }); + const fillChapters = (): void => + chapterBox.setOptions( + [all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))], + chapter, ); + const list = el("div", { className: "m02-dfip__list" }); + const info = el("span", { className: "m02-dfip__muted" }); + const prev = createButton({ + label: t("Prev"), + variant: "ghost", + size: "sm", + onClick: () => ((page -= 1), draw()), + }); + const next = createButton({ + label: t("Next"), + variant: "ghost", + size: "sm", + onClick: () => ((page += 1), draw()), + }); + + const draw = (): void => { + const rows = filterLogics( + items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌 + { text: q.input.value, book, chapter }, + ); + const range = pageRange(rows.length, page, PAGE); + page = Math.min(page, range.pages); + const shown = rows.slice((page - 1) * PAGE, page * PAGE); + info.textContent = t("Total", { + n: rows.length, + a: range.from, + b: range.to, + p: page, + m: range.pages, + }); + prev.disabled = page <= 1; + next.disabled = page >= range.pages; list.replaceChildren( - ...(rows.length - ? rows.map((i) => { + ...(shown.length + ? shown.map((i) => { const b = el("button", { className: "m02-dfip__row", - attrs: { type: "button" }, + attrs: { + type: "button", + ...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}), + }, children: [ el("span", { className: "m02-dfip__name", children: [ - el("strong", { text: i.name }), + el("strong", { text: i.이름 }), + el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }), el("span", { className: "m02-dfip__tag", - text: i.sample ? t("Sample") : t("Mine"), + text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분, }), ], }), - el("span", { className: "m02-dfip__muted", text: i.resultUnit }), + el("span", { className: "m02-dfip__muted", text: i.결과단위 }), ], }); b.addEventListener("click", () => void pick(i)); @@ -89,56 +218,114 @@ export function renderLogicInput( : [el("p", { className: "m02-dfip__muted", text: t("None") })]), ); }; - const pick = async (i: CfListItem): Promise => { + + /** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */ + const loadForCycle = async (key: string): Promise => { + const it = items.find((x) => x.키 === key); + return it ? (await flowOf(it)).flow : null; + }; + + const pick = async (i: MasterLogicBrief): Promise => { try { - const other = await cfGet(i.key); - const next = setLogicInput(lg, boxId, logicRefOf(i.key, other)); + if (i.키 === lg.key) return void showToast(t("Self"), "error"); + if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error"); + const got = await flowOf(i); + readNotes.set(i.키, { converted: got.converted, notes: got.notes }); + const picked = got.converted + ? masterRef(i.키, got.flow, true, i.결과단위) + : { ...logicRefOf(i.키, got.flow), name: i.이름 }; + const done = setLogicInput(lg, boxId, picked); // 기본 이름이면 고른 로직 이름으로 on.change({ - ...next, - boxes: next.boxes.map((b) => - b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b, + ...done, + boxes: done.boxes.map((b) => + b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b, ), }); } catch (e) { showToast(plain(e), "error"); } }; - q.input.addEventListener("input", draw); + + let timer = 0; + q.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => ((page = 1), draw()), 200); + }); list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); - host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list); - void cfList().then( - (items) => ((all = items), draw()), + host.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("Help") }), + el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }), + info, + list, + el("div", { className: "m02-dfip__chips", children: [prev, next] }), + ); + void loadAll().then( + (rows) => { + items = rows; + bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], ""); + fillChapters(); + draw(); + }, (e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })), ); } -/** 고른 로직 — 내보내는 알약 · 받는 알약 */ +/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */ function showChosen( host: HTMLElement, lg: CfLogic, boxId: string, - ref: NonNullable["logic"], + ref: CfLogicRef, on: { change(lg: CfLogic): void }, ): void { - if (!ref) return; - const pillLine = (p: { name: string; unit: string }): HTMLElement => + const read = readNotes.get(ref.key); + const values = new Map(); + const errors = el("p", { className: "m02-dfip__muted is-bad" }); + const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement => el("div", { className: "m02-dfip__item", children: [ - el("span", { className: "m02-dfip__name", text: p.name }), - el("span", { className: "m02-dfip__muted", text: p.unit || "-" }), + el("span", { className: "m02-dfip__name", text: name }), + ...(valueNode + ? [valueNode] + : extra + ? [el("span", { className: "m02-dfip__muted", text: extra })] + : []), + el("span", { className: "m02-dfip__muted", text: unit || "-" }), ], }); host.replaceChildren( el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }), + ...(read?.converted + ? [ + el("p", { className: "m02-dfip__muted", text: t("Converted") }), + ...(read.notes.length + ? [ + el("details", { + children: [ + el("summary", { text: `${t("Notes")} · ${read.notes.length}` }), + ...read.notes.map((n) => el("p", { text: n })), + ], + }), + ] + : []), + ] + : []), el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }), ...(ref.outputs.length - ? ref.outputs.map(pillLine) + ? ref.outputs.map((o) => { + const v = el("strong", { className: "m02-dfip__value", text: "—" }); + values.set(o.name, v); + return line(o.name, o.unit, v); + }) : [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]), + errors, el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }), ...(ref.designs.length - ? ref.designs.map(pillLine) + ? ref.designs.map((d) => + line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")), + ) : [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]), el("div", { className: "m02-dfip__chips", @@ -158,4 +345,17 @@ function showChosen( ], }), ); + // 값 — 서버 계산 길(받는 알약 예시값으로) + errors.textContent = t("Wait"); + void cfLogicValue(ref.key, ref.examples ?? {}).then( + (r) => { + for (const [name, node] of values) { + const v = r.outputs[name] ?? (r.cost as unknown as Record)[name]; + if (typeof v === "number") node.textContent = fmt(v); + else if (typeof v === "string") node.textContent = v; + } + errors.textContent = r.errors.join(" · "); + }, + (e) => (errors.textContent = plain(e)), + ); } diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py new file mode 100644 index 000000000..cc91ee13d --- /dev/null +++ b/resources/tester/test_m02_df_input_logic.py @@ -0,0 +1,230 @@ +"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +(async () => { + const jobs = JSON.parse(fs.readFileSync(0, "utf8")); + const out = []; + for (const j of jobs) { + if (j.name === "wouldCycle") { + const graph = j.args[2]; + out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null)); + } else out.push(await m[j.name](...j.args)); + } + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +def call(name: str, *args): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout)[0] + + +def row(key, name, book, chapter, no="", unit="원/㎡", **more): + return { + "file": f"로직_{book}.json", + "구분": book, + "상세구분": chapter, + "키": key, + "원문번호": no, + "이름": name, + "결과단위": unit, + **more, + } + + +ITEMS = [ + row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"), + row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"), + row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"), + row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"), + row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"), +] + + +def keys(items): + return [i["키"] for i in items] + + +def test_찾기는_띄어쓰기로_나눈_말을_모두_포함(): + got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"}) + assert keys(got) == ["GF000219"] + assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"] + assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [ + "GF000219" + ] # 띄어쓰기는 가리지 않음 + assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [ + "GF000219", + "GF000220", + ] # 원문번호 + assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [ + "GC000051" + ] # 번호 · 대소문자 + assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS + + +def test_원문_장으로_거름(): + assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"] + got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"}) + assert keys(got) == ["GF000025"] + assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == [] + + +def test_원문_장_목록은_나온_차례(): + assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"] + assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"] + assert call("logicChapters", ITEMS, "") == [ + "13장 구조물", + "05장 녹화", + "01장 공통", + "내가 만든 것", + ] + + +def box(id_, kind, name, **more): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#fff", + "unit": "-", + "x": 0, + "y": 0, + **more, + } + + +FLOW = { + "key": "GF000219", + "name": "돌쌓기 메쌓기(인력)", + "resultUnit": "원/㎡", + "userElements": [], + "links": [], + "boxes": [ + box( + "b1", + "input", + "입력", + input={ + "mode": "variable", + "elements": [], + "variables": [ + {"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30}, + { + "name": "돌", + "type": "choice", + "unit": "", + "choices": ["깬돌", "견치돌"], + "example": "깬돌", + }, + ], + }, + ), + box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}), + ], +} + + +def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것(): + ref = call("masterRef", "GF000219", FLOW, False, "원/㎡") + assert ref["key"] == "GF000219" + assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}] + assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}] + assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"} + + +def test_옛_로직_변환은_노무비_재료비_경비_계_넷(): + ref = call("masterRef", "GF000219", FLOW, True, "원/㎡") + assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] + assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"} + assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로 + + +def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것(): + inner = { + "mode": "logic", + "elements": [], + "variables": [], + "logic": { + "key": "GF000250", + "outputs": [{"name": "작업량", "unit": "㎥/시간"}], + "designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}], + "examples": {"지역": "서울"}, + }, + } + flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]} + flow["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}} + ] + ref = call("logicRefOf", "X", flow) + names = [d["name"] for d in ref["designs"]] + assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌 + assert ref["examples"]["지역"] == "서울" + + +def test_자기_자신과_돌고_도는_로직은_막음(): + graph = { + "A": { + "boxes": [ + box( + "b", + "input", + "x", + input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}}, + ) + ] + }, + "B": { + "boxes": [ + box( + "b", + "input", + "x", + input={ + "mode": "logic", + "elements": [], + "variables": [], + "logic": {"key": "SELF"}, + }, + ) + ] + }, + "C": {"boxes": []}, + } + assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신 + assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF + assert call("wouldCycle", "SELF", "C", graph) is False + assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 + assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 From 14382168018bf8bb93ab56cbd4fc8c60aebccba9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:29:59 +0900 Subject: [PATCH 10/26] auto: 2026-10-01 23:29 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/흐름도_적용_조사.md | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_조사.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_조사.md b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md new file mode 100644 index 000000000..c2672300d --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md @@ -0,0 +1,121 @@ +# 흐름도 본 프로그램 적용 — 36-1 옛 식 조사 (sub6 · 읽기만) + +근거 = 마스터 로직 파일 57 개 · 로직 줄 **1,472**(자체 `로직_자체.json` 포함) · 식을 지금 서버 파서(`master_formula.parse`)로 전부 읽음 — **못 읽는 식 0** · 파일 안 씀. +수 칸 = 「나온 수(식 마디 수) / 그 꼴이 든 로직 수」 · 예 = 키 + 식 앞부분. + +## ① 옛 식을 푸는 서버 코드 + +| 무엇 | 자리 | +| --- | --- | +| 식 문법 · 파서(`_Parser` — 또는 → 그리고 → 비교 → + - → * / → 부호 · `.칸` → 기본) | `resources/master_data/scripts/master_formula.py:56`(`parse` :225 · 함수 목록 `FUNCS` :23 = 만약 · 올림 · 버림 · 반올림 · 최소 · 최대 · 비교 `_OPS` :27) | +| 기본 마디 — `( )` · `'글'` · 수 · `찾기(표, 조건=값…)` · `로직(키, 넘길=값…)` · 함수 · 요소 키(`LB000002`) · `ID:원문번호{입력}`(`EQ:{기계}`) · 이름 | 같은 파일 `primary` :185 · 요소 키 판정 `mk.KEY`(`master_keys.py`) | +| 셈(나무 풀기) — 이름 · 요소 값 · 찾기 · 로직 부름 · 함수 · 사칙 · 비교 · 그리고 / 또는(지름길) | `master_formula.py:385` `evaluate` | +| 표 찾기 — 빈 조건 칸 = 무엇이든 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하) · 여러 줄이면 위 끝 최소 · 찾는 칸 있는 줄 | `master_formula.py:331` `_match` · `:362` `find` | +| 로직 한 줄 셈 — 입력 검사 → 중간 차례 → 결과 식 또는 호표 줄(수량 × 단가 · 로직 줄은 비목 나눔) → 덧줄(앞 줄까지 비목 합) → 끝수 | `master_formula.py:538` `run` · 입력 `_inputs` :489 · 빈 가격 입력 `fill_empty` :479 · 끝수 `cut` :468 · `{입력}` 채우기 `fill` :314 | +| 요소 값 · 재료 단가 | `master_material.py:238` `element` · `master_pick.py:130` `priced` · `:123` `pick`(검색어 묶음 → 품목) | +| 정적 검사(저장 때) | `master_formula.py:744` `check_logic` · `M01_MasterData_Store_Cache.py:69` `_check` | +| M01 셈 진입 | `M01_MasterData/M01_MasterData_Store.py:543` `calc`(사본 읽기 → 줄 바꿔 끼움 `_swap` :524 → `cm.calc`) · `:572` `text`(읽는 식 줄 `master_text.py:493 lines`) · `:563` `auto`(견본 입력) | +| 일괄 | `check_master.py:174` `calc` · `:511` `calc_all`(시험입력으로 전 로직) | + +## ② 1,472 로직 식 말 전수 + +| 꼴 | 나온 수 | 로직 수 | 예(3) | +| --- | --- | --- | --- | +| 따옴표 글 | 12,536 | 1,085 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000273, 기계='6516-0060')` | +| 이름 = 입력 | 10,815 | 1,178 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기(…).칸 | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 찾기(표 · 조건) | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 사칙 * | 7,697 | 1,037 | GC000994 `찾기(QC000237).크롤러드릴_공기식 * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000997 `찾기(QC001866).크레인 * 8 / 시공량` | +| 이름 = 중간값 | 7,367 | 1,184 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 사칙 / | 3,824 | 1,019 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 비교 == | 3,736 | 451 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 함수 만약 | 3,541 | 477 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 호표 요소 = 키(인력) | 3,451 | 1,369 | GC000994 `LB000014` · GC000995 `LB000008` · GC000996 `LB000014` | +| 사칙 + | 3,094 | 668 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 로직(다른 로직 부르기) | 1,116 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 로직 넘길 값 있음 | 1,089 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 요소 = 로직 부르기(장비 · 일위대가) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 비목 없음(로직 줄 → 비목 나눔) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기 조건 없음(표 한 줄) | 1,022 | 286 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 요소 키 부르기(인력) | 924 | 393 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 호표 요소 = 재료 고르기 묶음(구분 · 상세구분 · 검색어) | 673 | 231 | GC000319 `{"구분": "도료·방수·접착", "상세구분": "도료", "검색어": "융착식 프라이머"}` · GC000337 `{"구분": "철근·강재", "상세구분": "철망·와이어메쉬", "검색어": "철망 Φ3.2."}` · GC000723 `{"구분": "잡재료", "상세구분": "용접·가스", "검색어": ""}` | +| 논리 또는 | 297 | 35 | GC000346 `(만약(공법군 == '발파굴착A군' 또는 공법군 == '기계굴착A군', 착암공인원, 0)) * 배율` · GC000111 `만약(토질_암분류 == '호박돌 섞인 고결토・경질토' 또는 토질_암분류 == '풍화암', 로직(GC000267, 기계='020` · GC001136 `찾기(QC002058, '규격(㎜)범위'=만약(규격 == 'ø15' 또는 규격 == '20' 또는 규격 == '25' 또는 규` | +| 사칙 - | 273 | 156 | GC000998 `찾기(QC001867).시공량 * (1 - (수직고초과단계 * 9 - 경사가산) / 100)` · GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` | +| 논리 그리고 | 199 | 23 | GC001120 `만약(부위 == '뿜칠재' 그리고 뿜칠면 == '콘크리트면', 1.2, 1)` · GC000753 `찾기(QC001431, '배관규격(mm)'=배관규격, 보온재=보온재, 보온두께=보온두께).시공량 * (1 + (0 - 만약(설` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` | +| 시험입력 있음 | 195 | 0 | | +| 요소키.칸(요소 칸 값) | 160 | 49 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000138 `EQ:{장비}.주연료종류` | +| 이름 = 비목 합(노무비 · 재료비 · 경비) | 153 | 146 | GC000722 `노무비 * 기계경비율 / 100` · GC000723 `노무비 * 기계경비율 / 100` · GC000724 `노무비 * 기계경비율 / 100` | +| 비교 <= | 123 | 36 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000346 `만약(갱구거리 <= 500, 0, 찾기(CC000077, 갱구에서뚫기점까지의거리=갱구거리).할증률)` · GC000185 `만약(타설깊이 <= 25, 찾기(QC000348, L='25이하').t_1, 만약(타설깊이 <= 30, 찾기(QC000348,` | +| 함수 최대 | 122 | 43 | GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000373 `최대(찾기(CC000125, 형식=만약(형식 == '박스거더' 또는 형식 == '강바닥판 박스거더', '박스거더', '박스거더` · GC000203 `최대(만약(용도 == '신구-콘크리트 접착제바르기', 찾기(QC000412, 용도=용도, 재료명='Epoxy신구-콘크리트접착제` | +| 비교 > | 99 | 44 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 요소 부르기 ID:원문번호(+{입력}) | 84 | 14 | GC000138 `EQ:{장비}.주연료종류` · GC000139 `EQ:{장비}.주연료종류` · GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 요소 키 부르기(기계) | 78 | 37 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000140 `EQ000004.주연료종류` | +| 찾기 조건 값이 식 | 70 | 33 | GC001008 `(특별인부 * LB000003 + 보통인부 * LB000002 + 그물막인부 * LB000002) * 찾기(QC001882, ` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 함수 올림 | 67 | 53 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 호표 요소 = 자리 표시 {입력}(LB:{직종} 등) | 66 | 57 | GC000559 `LB:{공수직종}` · GC001043 `MO:휘발유:{지역}` · GC000137 `MO:경유:{지역}` | +| 함수 최소 | 51 | 18 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 빼기 부호(-x) | 47 | 28 | GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` · GC001012 `찾기(QC001889).시공량 * (1 - (-살포량증감) / 100)` | +| 비교 < | 46 | 26 | GC000561 `만약(층수층 < 6, 찾기(CC000157, '강재사용량(㎏/㎡)'=강재사용량).'보정치(a)' * 찾기(CC000159, '` · GC000564 `만약(강재총사용량 < 300, 1, 찾기(CC000165, '강재총사용량(t)'=강재총사용량, '1t당 볼트본수(본)'=볼트본` · GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` | +| 함수 반올림 | 42 | 41 | GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000088 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 재료 묶음에 {입력} | 32 | 19 | GC000264 `{"구분": "레미콘", "상세구분": "레미콘", "검색어": "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"}` · GC000431 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "싱글코아바렐 {규격}"}` · GC000432 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "더블코아바렐 {규격}"}` | +| 함수 버림 | 32 | 17 | GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` · GC000268 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계` · GC000269 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 결과 식 로직(돈 아님) | 27 | 27 | GC000422 `a * E` · GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 요소 키 부르기(재료) | 23 | 8 | GC000360 `((찾기(QC000648, 품명='아세틸렌', 레일규격=레일규격).수량) * MT003163 + (찾기(QC000648, 품명` · GC000361 `((찾기(QC000650, 품명='테르밋용재', 레일규격=레일규격, 규격='점화용').수량) * MT030628 + (찾기(Q` · GC000374 `(찾기(QC000664, 품명='용접봉').수량 * MT003191 + 찾기(QC000664, 품명='산소').수량 * 100` | +| 비교 >= | 21 | 11 | GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` · GC000774 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001452,` | +| 로직(…).칸(비목 · 계) | 14 | 11 | GC000087 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000090 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000091 `로직(GC000268, 기계='5405-0110', 지역=지역).계 * (찾기(QC000189).유압식크롤러드릴)` | +| 비교 != | 12 | 8 | GC000068 `만약(작업 != '목재 붙이기', 찾기(QC000143, 구분='보통인부', 작업=작업).수량, 0)` · GC000176 `만약(비트 != '오거비트', 해머압축기대수 * T / 60, 0)` · GC000183 `만약(재질 != '토사', T1 / T * T / 60, 0)` | +| 줄.'호표 이름'(호표 줄 금액) | 7 | 3 | GC000211 `(줄.'합판(내수 12㎜)' + 줄.'각재') * 찾기(QC000428, 사용횟수=사용횟수).소모자재 / 100` · GF000185 `(노무비 + 재료비 + 경비 - 줄.'구체콘크리트(철근) 레미콘') * 찾기(QF000387, 구분='구체콘크리트(철근) - ` · GF000210 `(줄.'패널' + 줄.'내부패널') * 찾기(QF000418, 구분=유형).요율 / 100` | +| 로직 넘길 값이 식 | 5 | 5 | GC000332 `로직(GC000267, 기계=만약(형식 == 'A-Type', '0201-0040', '0201-0020'), 지역=지역, 보` · GF000294 `로직(GC000268, 기계=만약(근원직경 < 20, '2105-0010', 만약(찾기(QF000063, '근원직경(cm)'=` · GF000297 `로직(GC000268, 기계=만약(흉고직경 < 18, '2105-0010', 만약(찾기(QF000081, '흉고직경(cm)'=` | +| (글자) 이름( 꼴(함수 아닌 이름 뒤 괄호) | 1,177 | — | GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` | +| (글자) ∼ · ~ 물결 | 63 | — | GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC001026 `찾기(QC001905, 절단폭=절단폭, 차로차단=차로차단).'굴착기+브레이커(0.2㎥~0.6㎥)' * 8 / 시공량` · GC000573 `찾기(QC001207, 구분='그라우팅펌프', 규격='40∼125(ℓ/min)').수량` | +| (글자) % 글자 | 62 | — | GC000298 `찾기(QC000519, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000299 `찾기(QC000520, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000303 `찾기(QC000524, 형식=형식).시공량 * (1 - 만약(유도블록 == '아님', 0, 찾기(QC000583, 구분='유도` | +| (글자) {} 자리 표시 | 50 | — | GC000138 `EQ:{장비}.주연료종류` · GC000138 `1 * 8 / 시공량 * EQ:{장비}.주연료 * 찾기(QC000266, 구분='주연료 할증').값 / 100` · GC000139 `EQ:{장비}.주연료종류` | +| (글자) & 글자 | 53 | — | GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` · GC000931 `(찾기(QC001700, 공정별='소운반').비계공 + 찾기(QC001700, 공정별='Draft tube설치 가설된 Conc` · GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` | +- 「요소 부르기 꼴 `보통인부(…)`」 — **문법에 없음**(이름 뒤 괄호는 함수 · 찾기 · 로직만). 글자로 1,177 번 보이는 「이름(」 은 모두 따옴표 칸 이름 안(`찾기(…).'고소작업차(5ton)'`). 요소는 키로만 부름(`LB000006` · `EQ000532.주연료2` · `EQ:{기계}.가격`). +- `&` · `∼`/`~` · `%` · `{ }` 글자 — `&` · `∼` · `%` 는 모두 따옴표 글(조건 값 · 칸 이름) 안 · `{ }` 는 `ID:원문번호{입력}` 와 재료 검색어 · 자리 표시 안(아래 ③). +- 끝수 — 1,472 중 묶음(`{대상, 자리, 방법}`) **0** · 설명 글 37 · 없음 1,435 → 엔진이 끝수를 실제로 붙이는 로직은 지금 없음(덧줄 · 함수 안 `반올림(…)` 으로 씀). +- 표 — 로직이 찾는 표 1,865 · 범위 조건이 있는 표 106(범위규칙 글 40 꼴 — 첫 낱말은 넷뿐) · 값 칸에 「1/1.30」 같은 식 글이 든 표 104(엔진이 식으로 풂). + +## ③ 흐름도로 옮기기 어려운 꼴 + +| 꼴 | 수(마디 / 로직) | 왜 어려운가 | 이렇게 옮기면 됨 | +| --- | --- | --- | --- | +| 요소 칸 값 `EQ000532.주연료2` · `EQ000098.주연료` | 160 / 49 | 요소의 값이 아니라 **요소의 다른 칸**(기계 주연료 · 가격 · 계수) | 입력 상자 「기계」 요소 알약을 칸별로(바깥 값 「기계 key + field」 — Api 에 이미 있음 `CfQuery 기계 field`) | +| `ID:원문번호{입력}` — `EQ:{기계}.가격` · `EQ:{장비}.주연료` | 84 / 14 | 부를 요소가 **입력값에 따라 바뀜**(기계경비 로직 GC000267 등 핵심 로직) | 「재료 자리」 처럼 「기계 자리」(선택 자리 · 입력 알약 값으로 키 고름) — 지금 흐름도에 없음 → **새 입력 방식 필요(⬜ 브레인)** · 그 전엔 「다른 로직 입력」 으로 기계경비 로직을 통째로 부름 | +| 호표 요소 자리 표시 `LB:{공수직종}` · `MO:경유:{지역}` | 66 / 57 | 노임 직종 · 유가 지역이 입력값에 따라 바뀜 | 같은 「선택 자리」(인력 · 유가전력) — 재료 방식 셋을 다른 그룹에도 | +| 재료 묶음 검색어에 `{입력}` — `레미콘 {굵은골재최대치수}-{강도}-{슬럼프}` | 32 / 19 | 검색어가 입력값으로 만들어짐 | 재료 「조건 찾기」(이름 / 규격에 알약 값) — 알약 여럿을 이어 붙인 글이 필요 → 함수 상자로 글을 만들어 조건 알약으로 | +| 호표 로직 줄(비목 없음) `로직(GC000267, 기계='0201-0020', 지역=지역)` | 1,075 / 506 | 한 줄 금액이 **노무비 · 재료비 · 경비 셋으로 나뉨** | 다른 로직 입력 상자의 출력 알약 셋(노무비 · 재료비 · 경비) × 수량 함수 셋 → 출력 셋 | +| 로직 넘길 값이 식 `기계=만약(형식 == 'A-Type', '0201-0040', …)` | 5 / 5 | 넘길 값이 조건으로 바뀜 | 함수 상자(IF)로 값을 만들어 다른 로직의 받는 알약에 선 | +| 찾기 조건 값이 식 `'시공높이(m)'=최소(시공높이m, 7.2)` | 70 / 33 | 조건 값이 계산값 | 함수 상자로 먼저 셈 → 「표에서 찾기」 조건 알약에 그 출력 | +| 덧줄의 비목 합 `노무비 * 기계경비율 / 100` | 153 / 146 | 바탕이 **앞 줄까지의 비목 합**(덧줄이 덧줄을 바탕으로 쓸 수 있음) | 출력 상자 비목별 모음 함수(앞 줄 차례대로) → 비율 함수 · 앞 덧줄도 받는 알약으로 | +| 호표 줄 금액 `줄.'합판(내수 12㎜)'` | 7 / 3 | 특정 호표 줄 금액을 다시 씀 | 그 줄 금액 함수 출력에서 선 | +| 로직 결과 칸 `로직(…).계` | 14 / 11 | 다른 로직 돈 결과의 한 칸 | 다른 로직 입력의 그 비목 · 계 알약 | +| 표 한 줄(조건 없는 찾기) `찾기(QC001864).시공량` | 1,022 / 286 | 조건이 없어 「줄 하나」 표 | 「표에서 찾기」 조건 없이 칸만(표 첫 줄) — 줄이 둘 이상이면 옛 엔진도 오류 | +| 범위 조건 표(범위규칙 넷 · 위 끝 최소) | 표 106 | 엑셀 한 식으로 쓰기 어려움(빈 칸 = 무엇이든 · 규칙별 경계) | 선 규칙 「표에서 찾기」 가 엔진 `find` 와 같은 뜻으로(줄마다 맞음 칸 + INDEX/MATCH — 옛 가져오기에서 쓴 꼴) | +| 표 값의 식 글 「1/1.30」 | 표 104 | 엔진은 글을 식으로 풂 · 엑셀은 글 | 서버가 표 블록을 줄 때 수로 바꿔 줌 | +| 결과 식 로직(돈 아님) | 27 / 27 | 출력이 비용 아님 | 출력 종류 「값」 | +| 빈 입력 · 표에 없는 조건 | — | 엔진은 **그 줄만 비우고** 계속(합에서 뺌) | 흐름도 「끊긴 곳」 + 그 출력 0 으로 셈(같은 합) | + +## ④ 계산 길(로직 셈)을 부르는 곳 + +| 어디 | 무엇 | 자리 | +| --- | --- | --- | +| 서버 `POST /api/m01/calc` | 시험 계산(화면 · 저장 전 줄 포함) | `M01_MasterData_Router.py:222` → `Store.calc` :543 | +| 서버 `POST /api/m01/text` | 읽는 식 줄(텍스트 수식) | `M01_MasterData_Router.py:228` → `Store.text` :572 | +| 서버 `GET /api/m01/logic/auto` | 견본 입력값 | `M01_MasterData_Router.py:192` → `Store.auto` :563 → `master_auto.values` :80 | +| 화면 M01 단가산출 시험 계산 | `/calc` · `/text` · `/logic/auto` | `M01_MasterData_UI_Logic_Api.ts:224 · :229 · :245` · 부름 `M01_MasterData_UI_Logic_Calc.ts:142 · :155 · :184` | +| 서버 일위대가 조합 미리보기 | 조합에 담긴 로직마다 셈 | `M01_MasterData_Store_Combo.py:259 combo_preview` → `master_combo.py:106 run`(:128 `mf.run`) · M02 `POST /api/m02/layers/{layer}/combos/preview` `M02_MasterTemplete_Router_Combos.py:195` → `M02_MasterTemplete_Combo_Store.py:193` | +| 서버 프로젝트 사본 계산 | 프로젝트 항목 단가(복사본 표 · 노임) | `POST /api/m02/projects/{id}/items/{folder}/calc` `M02_Project_Items_Router.py:178` → `M02_Item_Copy.py:326` → `M02_Item_Prices.py:211`(:230 `mf.run`) · 화면 `ui_template/ui_template_item_picker_project.ts:218` | +| 흐름도 서버 | 단가 · 다른 로직 값(호표 한 줄 견본 로직으로 옛 셈 부름) | `M02_DF_Values.py:68 · :105 · :229 · :262`(`store.calc`) · `M02_DF_Router.py:216 logic_value`(흐름도 셈 `M02_DF_Compute`) | +| 옛 시험판 서버(CostFlow · 남김) | 같은 꼴 | `M02_CF_Router.py:230 · :267 · :390 · :421` | +| 일괄 · 대조 스크립트 | 전 로직 시험 계산 · 견적 대조 | `check_master.py:511 calc_all` · `compare_estx.py:258 · :456` · `master_text.py:504` | + +갈래(정함 4)를 넣을 곳 = **`Store.calc` · `Store.text` 둘**(M01 화면 · 흐름도 값이 모두 여기를 지남) + 조합 미리보기 `master_combo.run` · 프로젝트 `M02_Item_Prices.calc`(이 둘은 `mf.run` 을 바로 부름 — 흐름이 있는 로직을 거르려면 여기도). + +## ⑤ 시험입력 · 기본 입력 + +- 줄에 `시험입력` 이 있는 로직 **195**(1,472 중) — 일부 입력만 적힘(예 GF000219 `{"높이": 4.0, "돌": "깬잡석"}`). +- 기본 입력 만드는 법이 **둘**이고 범위에서 갈림: + - 일괄 시험(`check_master.py:496 시험입력`) — 고르기 첫 값 · 범위 **위 끝** · 그 밖 1 · 줄의 `시험입력` 이 이김. + - 화면 견본(`master_auto.py:80 values` · `/logic/auto`) — 고르기 첫 값 · 범위 **아래 끝** · 그 밖 = 그 입력으로 찾는 표의 가장 낮은 값(없으면 나누는 수면 1 · 아니면 0) · 단위에 「원」 이면 비움. +- 전수 대조(정함 3)는 **`check_master.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름). From e799fbf345570d3fca340c91a2da245110d4b76a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:30:11 +0900 Subject: [PATCH 11/26] =?UTF-8?q?feat(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=B4=EA=B8=B0=20FormulaView=20=C2=B7=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=A1=B0=EA=B1=B4=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20spreadsheet=5Fcondition(=EC=9A=B0=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=20=C2=B7=20conditions=20=EC=A4=84=20=EB=95=8C=EB=A7=8C)=20+=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- A00_Common/spreadsheet/spreadsheet.ts | 7 + .../spreadsheet/spreadsheet_condition.css | 61 +++ .../spreadsheet/spreadsheet_condition.ts | 367 ++++++++++++++++++ .../spreadsheet/spreadsheet_condition_text.ts | 121 ++++++ .../DataFlow/M02_DF_FormulaView.ts | 284 ++++++++++++++ resources/tester/test_m02_df_formula_view.py | 180 +++++++++ 6 files changed, 1020 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_condition.css create mode 100644 A00_Common/spreadsheet/spreadsheet_condition.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_condition_text.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts create mode 100644 resources/tester/test_m02_df_formula_view.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 583cc173d..87ec2cc7b 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -22,6 +22,7 @@ import { createCalcEngine } from "./spreadsheet_graph"; import { createGrid } from "./spreadsheet_grid"; import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; +import type { ConditionOptions } from "./spreadsheet_condition"; import type { LinkOptions, Links } from "./spreadsheet_links"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; @@ -62,6 +63,8 @@ export interface SpreadsheetOptions { /** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 · * 알약 막대만. 다른 화면 모양은 그대로 */ preview?: boolean; + /** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */ + conditions?: ConditionOptions; } export interface SpreadsheetHandle { @@ -165,6 +168,10 @@ export function createSpreadsheet( grid.render(); select(ctx.selection); loadLinks(); + if (opts.conditions && !readOnly) + void import("./spreadsheet_condition").then((m) => { + if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!)); + }); function notify(message: string): void { toast.textContent = message; diff --git a/A00_Common/spreadsheet/spreadsheet_condition.css b/A00_Common/spreadsheet/spreadsheet_condition.css new file mode 100644 index 000000000..d98c625e3 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition.css @@ -0,0 +1,61 @@ +.ss-cond-menu { + position: fixed; + z-index: 10000; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.ss-cond { + min-width: min(46rem, 96vw); +} + +.ss-cond-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; +} + +.ss-cond-label { + min-width: 5em; + font-weight: var(--font-weight-bold); +} + +.ss-cond-pick { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.ss-cond-cell, +.ss-cond-value { + width: 7em; +} + +.ss-cond .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.ss-cond-note { + margin: 0 0 var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); +} + +.ss-cond-formula { + flex: 1 1 20em; + overflow-wrap: anywhere; + color: var(--color-text-secondary); +} + +.ss-cond-foot { + margin-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} diff --git a/A00_Common/spreadsheet/spreadsheet_condition.ts b/A00_Common/spreadsheet/spreadsheet_condition.ts new file mode 100644 index 000000000..628fd3d4f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition.ts @@ -0,0 +1,367 @@ +/* ============================================================================= + * spreadsheet_condition.ts + * 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF) + * 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내 + * 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로 + * 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import { toA1 } from "./spreadsheet_address"; +import { + COND_OPS, + CELL, + draftToFormula, + formulaToDraft, + type CondBranch, + type CondDraft, + type CondOp, + type CondOperand, +} from "./spreadsheet_condition_text"; +import "./spreadsheet_condition.css"; + +/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */ +export interface ConditionOptions { + valuesFor(a1: string): (string | number)[] | null; + pills?(): { name: string; cell: string }[]; +} + +export { COND_OPS, draftToFormula, formulaToDraft }; +export type { CondBranch, CondDraft, CondOp, CondOperand }; + +/* ── 글 ─────────────────────────────────────────────────────────────────── */ + +const TEXT = { + Menu: ["조건 만들기", "Make a condition"], + Title: ["조건 만들기", "Make a condition"], + If: ["만약", "If"], + ElseIf: ["아니면 만약", "Else if"], + Then: ["→", "→"], + Otherwise: ["아니면 →", "Otherwise →"], + AddElseIf: ["+ 아니면 만약", "+ Else if"], + Remove: ["지움", "Remove"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Close: ["닫기", "Close"], + CellPick: ["칸 직접", "A cell"], + ValuePick: ["값", "A value"], + Cell: ["칸 주소", "Cell address"], + Value: ["값", "Value"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], + Target: ["식이 들어갈 칸", "Formula goes into"], + AsIs: [ + "이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침", + "This formula can't be shown as lines — edit the formula as it is", + ], +} as const; + +const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 줄 판 ───────────────────────────────────────────────────────────────── */ + +const OWN = "\u0001cell"; +const OWN_VALUE = "\u0001value"; + +function select( + options: { value: string; text: string }[], + value: string, + onPick: (v: string) => void, + aria: string, +): HTMLSelectElement { + const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); + for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); + s.value = value; + s.addEventListener("change", () => onPick(s.value)); + return s; +} + +/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */ +function cellPick( + opts: ConditionOptions, + value: string, + onChange: (cell: string) => void, + aria: string, +): HTMLElement { + const pills = opts.pills?.() ?? []; + const hit = pills.find((p) => p.cell === value); + const options = [ + ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), + { value: OWN, text: tc("CellPick") }, + ]; + const box = el("span", { className: "ss-cond-pick" }); + const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria); + box.append(sel); + if (!hit) { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-cell", + attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" }, + }); + input.value = value; + input.addEventListener("change", () => { + const m = CELL.exec(input.value.trim().toUpperCase()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tc("BadCell"); + if (m) onChange(`${m[1]}${m[2]}`); + }); + box.append(input); + } + return box; +} + +/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */ +function operandPick( + opts: ConditionOptions, + o: CondOperand, + hint: string | null, + onChange: (o: CondOperand) => void, + aria: string, +): HTMLElement { + const box = el("span", { className: "ss-cond-pick" }); + const pills = opts.pills?.() ?? []; + const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell); + const options = [ + ...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })), + { value: OWN, text: tc("CellPick") }, + { value: OWN_VALUE, text: tc("ValuePick") }, + ]; + const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN; + box.append( + select( + options, + mode, + (v) => { + if (v === OWN_VALUE) onChange({ kind: "value", value: 0 }); + else if (v === OWN) onChange({ kind: "cell", cell: "B2" }); + else onChange({ kind: "cell", cell: v }); + }, + aria, + ), + ); + if (o.kind === "cell" && !isPill) { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-cell", + attrs: { type: "text", "aria-label": tc("Cell") }, + }); + input.value = o.cell; + input.addEventListener("change", () => { + const m = CELL.exec(input.value.trim().toUpperCase()); + input.classList.toggle("is-bad", !m); + if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` }); + }); + box.append(input); + } + if (o.kind === "value") { + const list = hint ? opts.valuesFor(hint) : null; + if (list && list.length) { + const key = (v: string | number): string => `${typeof v}:${v}`; + box.append( + select( + list.map((v) => ({ value: key(v), text: String(v) })), + key(o.value), + (k) => { + const hitv = list.find((v) => key(v) === k); + if (hitv !== undefined) onChange({ kind: "value", value: hitv }); + }, + tc("Value"), + ), + ); + } else { + const input = el("input", { + className: "ui-input ui-input--sm ss-cond-value", + attrs: { type: "text", "aria-label": tc("Value") }, + }); + input.value = String(o.value); + input.addEventListener("change", () => { + const t = input.value.trim(); + onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t }); + }); + box.append(input); + } + } + return box; +} + +function openJob( + ctx: SpreadsheetContext, + opts: ConditionOptions, + a1: string, + draft: CondDraft | null, + existing: string | null, +): void { + openModal({ + title: `${tc("Title")} — ${a1}`, + closeLabel: tc("Close"), + dialogClass: "ss-cond", + mount: (body, close) => { + let d: CondDraft = draft ?? { + branches: [ + { + left: "B1", + op: "=", + right: { kind: "value", value: "" }, + then: { kind: "value", value: 0 }, + }, + ], + otherwise: { kind: "value", value: 0 }, + }; + if (existing !== null && !draft) { + body.append( + el("p", { className: "ss-cond-note", text: tc("AsIs") }), + el("code", { className: "ss-cond-formula", text: `=${existing}` }), + ); + return; + } + const draw = (): void => { + const set = (next: CondDraft): void => ((d = next), draw()); + const rows = d.branches.map((b, i) => { + const edit = (patch: Partial): void => + set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); + return el("div", { + className: "ss-cond-row", + children: [ + el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }), + cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")), + select( + COND_OPS.map((o) => ({ value: o, text: o })), + b.op, + (v) => edit({ op: v as CondOp }), + "op", + ), + operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")), + tc("Then"), + operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")), + d.branches.length > 1 + ? createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }), + }) + : "", + ], + }); + }); + const last = d.branches[d.branches.length - 1]; + body.replaceChildren( + ...rows, + el("div", { + className: "ss-cond-row", + children: [ + createButton({ + label: tc("AddElseIf"), + variant: "ghost", + size: "sm", + onClick: () => + set({ + ...d, + branches: [ + ...d.branches, + { + left: last.left, + op: "=", + right: { kind: "value", value: "" }, + then: { kind: "value", value: 0 }, + }, + ], + }), + }), + ], + }), + el("div", { + className: "ss-cond-row", + children: [ + el("span", { className: "ss-cond-label", text: tc("Otherwise") }), + operandPick( + opts, + d.otherwise, + null, + (otherwise) => set({ ...d, otherwise }), + tc("Otherwise"), + ), + ], + }), + el("div", { + className: "ss-cond-row ss-cond-foot", + children: [ + el("code", { + className: "ss-cond-formula", + text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`, + }), + createButton({ + label: tc("Make"), + variant: "filled", + size: "sm", + onClick: () => { + const sheet = ctx.sheet(); + const { 값: _v, ...keep } = sheet.칸[a1] ?? {}; + ctx.dispatch({ + 종류: "칸", + 시트: sheet.id, + 칸: { [a1]: { ...keep, 식: draftToFormula(d) } }, + }); + close(); + }, + }), + createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }), + ], + }), + ); + }; + draw(); + }, + }); +} + +/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */ +export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle { + let menu: HTMLElement | null = null; + const close = (): void => { + menu?.remove(); + menu = null; + document.removeEventListener("pointerdown", away, true); + }; + const away = (e: Event): void => { + if (menu && !menu.contains(e.target as Node)) close(); + }; + const onContext = (e: MouseEvent): void => { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + if (!hit || hit.kind !== "cell" || ctx.readOnly) return; + e.preventDefault(); + e.stopPropagation(); + close(); + const a1 = toA1(hit.r, hit.c); + const sheet = ctx.sheet(); + const cell = sheet.칸[a1]; + menu = el("div", { className: "ss-cond-menu" }); + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + menu.append( + createButton({ + label: tc("Menu"), + variant: "ghost", + size: "sm", + onClick: () => { + close(); + const existing = cell?.식 ?? null; + const draft = existing !== null ? formulaToDraft(existing) : null; + openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null); + }, + }), + ); + document.body.append(menu); + document.addEventListener("pointerdown", away, true); + }; + ctx.root.addEventListener("contextmenu", onContext, true); + return { + root: null, + refresh() {}, + destroy() { + ctx.root.removeEventListener("contextmenu", onContext, true); + close(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_condition_text.ts b/A00_Common/spreadsheet/spreadsheet_condition_text.ts new file mode 100644 index 000000000..24e26fdbb --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_condition_text.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * spreadsheet_condition_text.ts + * 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림 + * ========================================================================== */ + +export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<"; +export type CondOperand = + { kind: "cell"; cell: string } | { kind: "value"; value: number | string }; +export interface CondBranch { + /** 비교하는 칸 */ + left: string; + op: CondOp; + right: CondOperand; + then: CondOperand; +} +export interface CondDraft { + branches: CondBranch[]; + otherwise: CondOperand; +} + +export const XL_OP: Record = { + "=": "=", + "≠": "<>", + "≥": ">=", + "≤": "<=", + ">": ">", + "<": "<", +}; +export const FROM_XL: Record = { + "=": "=", + "<>": "≠", + ">=": "≥", + "<=": "≤", + ">": ">", + "<": "<", +}; +export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"]; + +export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/; + +/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */ + +export const operandText = (o: CondOperand): string => + o.kind === "cell" + ? o.cell + : typeof o.value === "number" + ? String(o.value) + : `"${o.value.replace(/"/g, '""')}"`; + +/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */ +export function draftToFormula(d: CondDraft): string { + return d.branches.reduceRight( + (rest, b) => + `IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`, + operandText(d.otherwise), + ); +} + +/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */ +export function splitArgs(s: string): string[] { + const out: string[] = []; + let depth = 0; + let quote = false; + let from = 0; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') quote = !quote; + else if (quote) continue; + else if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === "," && depth === 0) { + out.push(s.slice(from, i)); + from = i + 1; + } + } + out.push(s.slice(from)); + return out; +} + +export function parseOperand(raw: string): CondOperand | null { + const t = raw.trim(); + const cell = CELL.exec(t); + if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` }; + if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) }; + const q = /^"((?:[^"]|"")*)"$/.exec(t); + return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null; +} + +/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */ +export function ifBody(f: string): string | null { + const t = f.trim(); + if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null; + let depth = 0; + let quote = false; + for (let i = 2; i < t.length; i++) { + if (t[i] === '"') quote = !quote; + else if (quote) continue; + else if (t[i] === "(") depth++; + else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null; + } + return t.slice(3, -1); +} + +/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */ +export function formulaToDraft(formula: string): CondDraft | null { + const body = ifBody(formula); + if (body === null) return null; + const args = splitArgs(body); + if (args.length !== 3) return null; + const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]); + if (!m) return null; + const left = parseOperand(m[1]); + const right = parseOperand(m[3]); + const then = parseOperand(args[1]); + if (!left || left.kind !== "cell" || !right || !then) return null; + const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then }; + const plain = parseOperand(args[2]); + if (plain) return { branches: [branch], otherwise: plain }; + const rest = formulaToDraft(args[2]); + return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts new file mode 100644 index 000000000..8d9581eb4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * M02_DF_FormulaView.ts + * 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 · + * IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text"; +import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CfBox } from "./M02_DF_Model"; + +export type CellScalar = number | string | boolean | null; + +export interface FormulaBranch { + when: string; + then: string; + /** 지금 타는 갈래 */ + now: boolean; +} + +export interface FormulaLine { + /** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */ + name: string; + /** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */ + named: string; + /** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */ + valued: string; + /** IF 면 만약 / 아니면 줄 */ + branches?: FormulaBranch[]; +} + +const TEXT = { + Title: ["식 보기", "Formulas"], + Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"], + Else: ["아니면", "Otherwise"], + If: ["만약", "If"], + Now: ["지금", "now"], +} as const; + +const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */ + +const REF = /(? string): string { + return text + .split(/("(?:[^"]|"")*")/) + .map((p, i) => (i % 2 ? p : fn(p))) + .join(""); +} + +const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, ""); + +/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */ +export function cellNames(box: CfBox): Record { + const f = box.func; + const out: Record = {}; + if (!f) return out; + for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port; + for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name; + if (f.output.cell) out[upper(f.output.cell)] = f.output.name; + return out; +} + +const refsIn = (formula: string): string[] => { + const found: string[] = []; + outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m))); + return found; +}; + +const OPS: [RegExp, string][] = [ + [/\*/g, " × "], + [/\//g, " ÷ "], + [/<>/g, " ≠ "], + [/>=/g, " ≥ "], + [/<=/g, " ≤ "], + [/(?≠≥≤])=/g, " = "], + [/\+/g, " + "], + [/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "], +]; + +/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */ +function pretty(text: string): string { + return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p)) + .replace(/\s{2,}/g, " ") + .trim(); +} + +const fmt = (n: number): string => + Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +function showScalar(v: CellScalar | undefined): string { + if (v === undefined || v === null) return "0"; + if (typeof v === "number") return fmt(v); + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return `"${v}"`; +} + +/** 이름으로 쓴 식 */ +export function namedText(formula: string, names: Record): string { + return pretty( + outside(formula, (p) => + p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`), + ), + ); +} + +/** 값 넣은 식(값이 문자면 따옴표) */ +export function valuedText(formula: string, values: Record): string { + return pretty( + outside(formula, (p) => + p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])), + ), + ); +} + +/* ── 갈래 ───────────────────────────────────────────────────────────────── */ + +const num = (v: CellScalar | undefined): number | null => { + if (typeof v === "number") return v; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); + return null; +}; + +/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */ +function holds(cond: string, values: Record): boolean | null { + const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond); + if (!m) return null; + const read = (t: string): CellScalar | undefined => { + const x = t.trim(); + const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x); + if (cell) return values[`${cell[1]}${cell[2]}`]; + const q = /^"((?:[^"]|"")*)"$/.exec(x); + if (q) return q[1].replace(/""/g, '"'); + return Number.isFinite(Number(x)) ? Number(x) : undefined; + }; + const a = read(m[1]); + const b = read(m[3]); + if (a === undefined || b === undefined) return null; + const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)]; + switch (m[2]) { + case "=": + return l === r; + case "<>": + return l !== r; + case ">=": + return l >= r; + case "<=": + return l <= r; + case ">": + return l > r; + default: + return l < r; + } +} + +/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */ +function branchesOf( + formula: string, + names: Record, + values: Record, +): FormulaBranch[] | undefined { + const out: FormulaBranch[] = []; + let rest = formula; + let taken = false; + for (;;) { + const body = ifBody(rest); + if (body === null) break; + const a = splitArgs(body); + if (a.length !== 3) return undefined; + const ok = holds(a[0], values) === true && !taken; + if (ok) taken = true; + out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok }); + rest = a[2]; + } + if (!out.length) return undefined; + out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken }); + return out; +} + +/* ── 줄 ─────────────────────────────────────────────────────────────────── */ + +/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */ +export function formulaLines(box: CfBox, values: Record): FormulaLine[] { + const f = box.func; + if (!f?.output.cell) return []; + const names = cellNames(box); + const cells = f.sheet.칸; + const order: string[] = []; + const seen = new Set(); + const visit = (a1: string): void => { + const key = upper(a1); + if (seen.has(key)) return; + seen.add(key); + const formula = cells[key]?.식; + if (formula === undefined) return; + for (const r of refsIn(formula)) visit(r); + order.push(key); + }; + visit(f.output.cell); + return order.map((key) => { + const formula = cells[key].식 as string; + const result = showScalar(values[key]); + const branches = branchesOf(formula, names, values); + return { + name: names[key] ?? key, + named: namedText(formula, names), + valued: `${valuedText(formula, values)} = ${result}`, + ...(branches ? { branches } : {}), + }; + }); +} + +/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */ +export function cellValuesOf( + box: CfBox, + arrived: Record | undefined, +): Record { + const f = box.func; + if (!f) return {}; + const sheet = structuredClone(f.sheet); + const scalar = (v: unknown): number | string | boolean | undefined => + typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined; + const put = (cell: string, v: number | string | boolean | undefined): void => { + if (!parseA1(cell) || v === undefined) return; + sheet.칸[upper(cell)] = { 값: v }; + }; + for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port])); + for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example); + const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); + const out: Record = {}; + for (const [key, cell] of Object.entries(sheet.칸)) { + const c: CalcValue | undefined = calc[sheet.id]?.[key]; + out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null; + } + return out; +} + +function fromCalc(v: CalcValue): CellScalar { + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +} + +/* ── 그리기 ─────────────────────────────────────────────────────────────── */ + +/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */ +export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void { + host.classList.add("m02-df-formula"); + host.replaceChildren( + el("strong", { className: "m02-df-formula__title", text: tv("Title") }), + ...(lines.length + ? lines.map((l) => + el("div", { + className: "m02-df-formula__line", + children: [ + el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }), + el("div", { className: "m02-df-formula__valued", text: l.valued }), + ...(l.branches + ? [ + el("ul", { + className: "m02-df-formula__branches", + children: l.branches.map((b, i) => + el("li", { + className: b.now ? "is-now" : "", + text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`, + }), + ), + }), + ] + : []), + ], + }), + ) + : [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]), + ); +} diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py new file mode 100644 index 000000000..d015c2a9e --- /dev/null +++ b/resources/tester/test_m02_df_formula_view.py @@ -0,0 +1,180 @@ +"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로. +견본 함수 상자는 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts" +TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, viewFile, textFile] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); +const cache = {}; +function load(f) { + if (cache[f]) return cache[f].exports; + const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[f] = { exports: {} }); + const req = (id) => { + if (id.startsWith(".") || id.startsWith("@ui/sheet/")) { + const p = id.startsWith("@ui/sheet/") + ? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length)) + : path.resolve(path.dirname(f), id); + return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; + } + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const v = load(viewFile); +const t = load(textFile); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "lines") { + const values = v.cellValuesOf(job.box, job.arrived); + return { values, lines: v.formulaLines(job.box, values) }; + } + if (job.op === "toFormula") return t.draftToFormula(job.draft); + if (job.op === "toDraft") return t.formulaToDraft(job.formula); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(VIEW), str(TEXT)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict: + return { + "id": "f1", + "kind": "function", + "name": "석공 품", + "color": "", + "unit": "인", + "x": 0, + "y": 0, + "func": { + "sheet": {"id": "s1", "이름": "석공 품", "칸": cells}, + "inputs": inputs, + "variables": variables, + "output": {"cell": out_cell, "name": out_name, "unit": "인"}, + }, + } + + +def var(name: str, cell: str, example) -> dict: + return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell} + + +# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100) +PLAIN = func_box( + { + "A1": {"값": "석공량"}, + "B3": {"식": "B1*(1+B2/100)"}, + }, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B3", + "석공 인", +) + + +def test_이름_식과_값_식(): + (got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}]) + assert got["values"]["B3"] == pytest.approx(0.156) + (line,) = got["lines"] + assert line["name"] == "석공 인" + assert line["named"] == "석공량 × (1 + 높이 ÷ 100)" + assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" + assert "branches" not in line + + +def test_중간_칸이_먼저_결과_칸이_마지막(): + box = func_box( + {"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}}, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B4", + "합", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}]) + assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로 + assert got["lines"][1]["named"] == "B3 + 1" + assert got["lines"][1]["valued"] == "8 + 1 = 9" + + +@pytest.mark.parametrize( + ("ground", "now"), + [("토사", [True, False]), ("암", [False, True])], +) +def test_IF_줄과_지금_갈래(ground, now): + box = func_box( + {"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}}, + [{"port": "석공량", "cell": "B1"}], + [{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}], + "B3", + "결과", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}]) + (line,) = got["lines"] + assert [b["now"] for b in line["branches"]] == now + assert line["branches"][0]["when"] == '지반 = "토사"' + assert line["branches"][0]["then"] == "석공량 × 2" + assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]} + assert line["valued"].endswith("= 20" if ground == "토사" else "= 30") + + +def test_조건_만들기_식과_다시_줄(): + draft = { + "branches": [ + { + "left": "B1", + "op": "=", + "right": {"kind": "value", "value": "토사"}, + "then": {"kind": "cell", "cell": "C1"}, + }, + { + "left": "B1", + "op": "≥", + "right": {"kind": "value", "value": 3}, + "then": {"kind": "value", "value": 0.5}, + }, + ], + "otherwise": {"kind": "value", "value": 0}, + } + (formula,) = run([{"op": "toFormula", "draft": draft}]) + assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))' + (back,) = run([{"op": "toDraft", "formula": formula}]) + assert back == draft + + +@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"]) +def test_못_바꾸는_식은_null(formula): + (back,) = run([{"op": "toDraft", "formula": formula}]) + assert back is None From 3c7832235048f0f99ac8bc341be91d605ea0aec1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:30:18 +0900 Subject: [PATCH 12/26] =?UTF-8?q?feat(M02):=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84=EB=8F=84=20=ED=8C=A8=EB=84=90=20M02=5FDF?= =?UTF-8?q?=5FPanel.ts=20=E2=80=94=20mountFlowPanel(host,=20opts)=20?= =?UTF-8?q?=E2=86=92=20set=20=C2=B7=20destroy=20=C2=B7=20expand(PLAN=2036-?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세로: 머리(제목 · 크게 보기 · 접기) · 실행 취소 · 다이어그램 · 상세 내용 · 원가 · 검증(접기) · 저장 단추 없음 - 왼쪽 가장자리 끌기 · 화살표 키 → onResize(px) · 마지막 너비 localStorage · 접기 = 얇은 띠 「흐름도 ▸」 - 크게 보기 = 같은 판을 화면 전체로 옮겨 끼움 · 닫으면 패널로(고친 것 그대로) · Esc · Ctrl+Z - 좁은 패널은 저절로 맞춤 바닥 0.5(한눈에) · 크게 보기는 0.9 — 다이어그램 opts.fitMin - 시험 test_m02_df_panel.py 2 통과 · typecheck · DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 2 +- .../DataFlow/M02_DF_Diagram_Parts.ts | 5 +- M02_MasterTemplete/DataFlow/M02_DF_Panel.css | 151 ++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 262 ++++++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Paper.ts | 3 + resources/tester/test_m02_df_panel.py | 83 ++++++ 6 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Panel.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Panel.ts create mode 100644 resources/tester/test_m02_df_panel.py diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index e74e30fa7..ae84fe5e1 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -320,7 +320,7 @@ export function renderDiagram( return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; }); const r = view.getBoundingClientRect(); - Object.assign(st, fitView(rects, { w: r.width, h: r.height })); + Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin)); applyView(); drawLines(); }; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 649aac97e..49052241f 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -122,6 +122,8 @@ export interface DiagramOpts { readOnly?: boolean; /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ fitKey?: number; + /** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */ + fitMin?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ @@ -193,13 +195,14 @@ export function fitView( rects: { x: number; y: number; w: number; h: number }[], view: { w: number; h: number }, pad = 40, + min = FIT_MIN, ): { zoom: number; panX: number; panY: number } { if (!rects.length) return { zoom: 1, panX: 0, panY: 0 }; const x0 = Math.min(...rects.map((r) => r.x)) - pad; const y0 = Math.min(...rects.map((r) => r.y)) - pad; const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; - const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); + const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ const place = (size: number, from: number, to: number): number => Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.css b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css new file mode 100644 index 000000000..f9ffd4fc8 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.css @@ -0,0 +1,151 @@ +/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */ +.m02-dfp { + position: relative; + display: flex; + width: 100%; + min-width: 0; + height: 100%; + border-left: 1px solid var(--color-border); + background: var(--color-canvas); +} + +.m02-dfp__open { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); +} + +.m02-dfp__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dfp__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-dfp__tools { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */ +.m02-dfp .m02-dfdg__view { + height: max(320px, 45vh); +} + +.m02-dfp__lock { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.m02-dfp__cost > summary { + cursor: pointer; + font-weight: 600; + padding: var(--spacing-4) 0; +} + +/* 왼쪽 가장자리 끌기 */ +.m02-dfp__grip { + position: absolute; + top: 0; + bottom: 0; + left: -4px; + z-index: 2; + width: 8px; + cursor: col-resize; + touch-action: none; +} + +.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); + outline: none; +} + +.m02-dfp.is-resizing { + user-select: none; +} + +/* 접기 = 얇은 띠 */ +.m02-dfp__strip { + width: 100%; + padding: var(--spacing-12) 0; + border: 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 { + color: var(--color-accent); +} + +.m02-dfp__strip[hidden], +.m02-dfp__open[hidden], +.m02-dfp__grip[hidden] { + display: none; +} + +/* 크게 보기 — 화면 전체 */ +.m02-dfp-full { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + overflow: auto; + background: var(--color-canvas); + outline: none; +} + +.m02-dfp-full__head { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dfp-full__head strong { + flex: 1; + font-size: var(--text-body); +} + +.m02-dfp-full__diagram .m02-dfdg__view { + height: max(420px, 60vh); +} + +.m02-dfp-full__lower { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: var(--spacing-16); + align-items: start; +} + +.m02-dfp-full__lower h3 { + margin: 0 0 var(--spacing-8); + font-size: var(--text-body); +} + +@media (max-width: 900px) { + .m02-dfp-full__lower { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts new file mode 100644 index 000000000..ade890724 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -0,0 +1,262 @@ +/* ============================================================================= + * M02_DF_Panel.ts + * 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand } + * 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) → + * 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange. + * 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage · + * 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로). + * 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfLogic } from "./M02_DF_Model"; +import { createPaper } from "./M02_DF_Paper"; +import "./M02_DF_Panel.css"; + +const TEXT = { + Big: ["크게 보기", "Full screen"], + Fold: ["접기", "Collapse"], + Strip: ["흐름도 ▸", "Flow ▸"], + StripTitle: ["흐름도 펴기", "Open the flow panel"], + Close: ["닫기", "Close"], + Detail: ["상세 내용", "Details"], + Cost: ["원가 · 검증", "Cost · check"], + Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"], +} as const; + +const tf = (key: keyof typeof TEXT): string => + TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; + +export interface FlowPanelOpts { + logic: CfLogic; + readOnly?: boolean; + title: string; + /** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */ + onChange(lg: CfLogic): void; + /** 다른 로직 입력이 부르는 로직 */ + logics?(key: string): Promise; + /** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */ + onResize?(px: number): void; +} + +export interface FlowPanel { + /** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */ + set(lg: CfLogic): void; + destroy(): void; + /** 크게 보기 열기 */ + expand(): void; +} + +/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */ + +export const PANEL_MIN = 320; +export const STRIP_WIDTH = 40; +/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */ +export const PANEL_FIT_MIN = 0.5; +const WIDTH_KEY = "m02.dfpanel.width"; +const FOLD_KEY = "m02.dfpanel.folded"; + +/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */ +export function dragWidth(start: number, startX: number, x: number, viewport: number): number { + const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85)); + return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x)))); +} + +/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */ +export function storedWidth(raw: string | null): number | null { + const n = Number(raw); + return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null; +} + +const readStore = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; +const writeStore = (key: string, value: string): void => { + try { + localStorage.setItem(key, value); + } catch { + /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */ + } +}; + +/* ── 패널 ───────────────────────────────────────────────────────────── */ + +export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel { + const paper = createPaper({ + logic: opts.logic, + readOnly: opts.readOnly, + onChange: opts.onChange, + logics: opts.logics, + // 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림 + fitMin: () => (full ? 0.9 : PANEL_FIT_MIN), + }); + const { diagram, detail, summary, note, undo } = paper.parts; + let width = storedWidth(readStore(WIDTH_KEY)); + let folded = readStore(FOLD_KEY) === "1"; + let full: HTMLElement | null = null; + const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth)); + + const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") => + createButton({ label, size: "sm", variant, onClick }); + + /* 펼친 패널 */ + const costBox = el("details", { + className: "m02-dfp__cost", + attrs: { open: "" }, + children: [el("summary", { text: tf("Cost") })], + }); + 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, + ], + }); + 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 grip = el("div", { + className: "m02-dfp__grip", + attrs: { + role: "separator", + "aria-orientation": "vertical", + "aria-label": tf("Grip"), + tabindex: "0", + }, + }); + const setWidth = (px: number, save: boolean): void => { + width = px; + if (save) writeStore(WIDTH_KEY, String(px)); + tell(); + }; + grip.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + ev.preventDefault(); + const start = root.getBoundingClientRect().width; + const startX = ev.clientX; + root.classList.add("is-resizing"); + const move = (m: PointerEvent): void => + setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false); + const up = (u: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + root.classList.remove("is-resizing"); + setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true); + paper.refit(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + grip.addEventListener("keydown", (ev) => { + const step = ev.shiftKey ? 80 : 20; + const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0; + if (!d) return; + ev.preventDefault(); + const now = root.getBoundingClientRect().width; + setWidth(dragWidth(now, 0, -d, window.innerWidth), true); + }); + + const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] }); + root.addEventListener("keydown", (ev) => { + if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; + ev.preventDefault(); + paper.undo(); + } + }); + + const setFolded = (on: boolean): void => { + folded = on; + writeStore(FOLD_KEY, on ? "1" : "0"); + root.classList.toggle("is-folded", on); + open.hidden = on; + strip.hidden = !on; + grip.hidden = on; + tell(); + if (!on) paper.refit(); + }; + + /* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */ + const expand = (): void => { + if (full) return; + const close = sm(tf("Close"), () => shrink()); + full = el("div", { + className: "m02-dfp-full", + attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" }, + children: [ + el("header", { + className: "m02-dfp-full__head", + children: [el("strong", { text: opts.title }), undo, close], + }), + note, + el("div", { className: "m02-dfp-full__diagram", children: [diagram] }), + el("div", { + className: "m02-dfp-full__lower", + children: [ + el("section", { children: [el("h3", { text: tf("Detail") }), detail] }), + el("section", { children: [el("h3", { text: tf("Cost") }), summary] }), + ], + }), + ], + }); + full.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && !ev.defaultPrevented) shrink(); + if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; + ev.preventDefault(); + paper.undo(); + } + }); + document.body.append(full); + full.focus(); + paper.refit(); + }; + const shrink = (): void => { + if (!full) return; + full.remove(); + full = null; + tools.append(undo); + open.insertBefore(note, diagram); + open.insertBefore(diagram, detail); + open.insertBefore(detail, costBox); + costBox.append(summary); + paper.refit(); + }; + + host.replaceChildren(root); + setFolded(folded); + + return { + set: (lg) => paper.set(lg), + destroy: () => { + shrink(); + root.remove(); + }, + expand, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts index 6a90118bb..85146e7f4 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -53,6 +53,8 @@ export interface PaperOpts { onChange(lg: CfLogic): void; /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ logics?(key: string): Promise; + /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ + fitMin?(): number; } export interface Paper { @@ -304,6 +306,7 @@ export function createPaper(opts: PaperOpts): Paper { breaks: broken, readOnly, fitKey, + fitMin: opts.fitMin?.(), }); drawPanel(); renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); diff --git a/resources/tester/test_m02_df_panel.py b/resources/tester/test_m02_df_panel.py new file mode 100644 index 000000000..0356e18d8 --- /dev/null +++ b/resources/tester/test_m02_df_panel.py @@ -0,0 +1,83 @@ +"""오른쪽 흐름도 패널(`M02_DF_Panel.ts`) 순수 부분 — 끌어서 정한 너비 · 저장해 둔 너비. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 판은 빈 흉내 · 순수 함수만). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Panel.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const stub = new Proxy({}, { get: () => () => undefined }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((j) => + j.op === "drag" ? m.dragWidth(j.start, j.startX, j.x, j.viewport) + : j.op === "stored" ? m.storedWidth(j.raw) + : j.op === "consts" ? [m.PANEL_MIN, m.STRIP_WIDTH] + : null, +); +process.stdout.write(JSON.stringify(out)); +""" + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_왼쪽_가장자리_끌기_너비(): + [consts, wider, narrower, floor, ceil] = run( + [ + {"op": "consts"}, + # 왼쪽으로 100 끌면 넓어짐 + {"op": "drag", "start": 480, "startX": 1000, "x": 900, "viewport": 1600}, + # 오른쪽으로 끌면 좁아짐 + {"op": "drag", "start": 480, "startX": 1000, "x": 1060, "viewport": 1600}, + # 너무 좁게 — 가장 작은 너비에서 멈춤 + {"op": "drag", "start": 480, "startX": 1000, "x": 1400, "viewport": 1600}, + # 너무 넓게 — 화면의 85% 에서 멈춤 + {"op": "drag", "start": 480, "startX": 1000, "x": -2000, "viewport": 1600}, + ] + ) + min_width, strip = consts + assert (min_width, strip) == (320, 40) + assert wider == 580 and narrower == 420 + assert floor == min_width and ceil == 1360 + + +def test_저장해_둔_너비(): + got = run( + [ + {"op": "stored", "raw": "640"}, + {"op": "stored", "raw": "640.6"}, + {"op": "stored", "raw": None}, + {"op": "stored", "raw": "abc"}, + {"op": "stored", "raw": "100"}, + ] + ) + assert got == [640, 641, None, None, None] From 2911dccc5535d65ee539a8f00c18311a5e27520e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:30:24 +0900 Subject: [PATCH 13/26] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=A1=B0=EC=82=AC=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=A8=BC=EC=A0=80?= =?UTF-8?q?=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98=EA=B8=B0=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/흐름도_적용_조사.md | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_적용_조사.md diff --git a/docs/raw/M01_쉬운단가/흐름도_적용_조사.md b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md new file mode 100644 index 000000000..c2672300d --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_적용_조사.md @@ -0,0 +1,121 @@ +# 흐름도 본 프로그램 적용 — 36-1 옛 식 조사 (sub6 · 읽기만) + +근거 = 마스터 로직 파일 57 개 · 로직 줄 **1,472**(자체 `로직_자체.json` 포함) · 식을 지금 서버 파서(`master_formula.parse`)로 전부 읽음 — **못 읽는 식 0** · 파일 안 씀. +수 칸 = 「나온 수(식 마디 수) / 그 꼴이 든 로직 수」 · 예 = 키 + 식 앞부분. + +## ① 옛 식을 푸는 서버 코드 + +| 무엇 | 자리 | +| --- | --- | +| 식 문법 · 파서(`_Parser` — 또는 → 그리고 → 비교 → + - → * / → 부호 · `.칸` → 기본) | `resources/master_data/scripts/master_formula.py:56`(`parse` :225 · 함수 목록 `FUNCS` :23 = 만약 · 올림 · 버림 · 반올림 · 최소 · 최대 · 비교 `_OPS` :27) | +| 기본 마디 — `( )` · `'글'` · 수 · `찾기(표, 조건=값…)` · `로직(키, 넘길=값…)` · 함수 · 요소 키(`LB000002`) · `ID:원문번호{입력}`(`EQ:{기계}`) · 이름 | 같은 파일 `primary` :185 · 요소 키 판정 `mk.KEY`(`master_keys.py`) | +| 셈(나무 풀기) — 이름 · 요소 값 · 찾기 · 로직 부름 · 함수 · 사칙 · 비교 · 그리고 / 또는(지름길) | `master_formula.py:385` `evaluate` | +| 표 찾기 — 빈 조건 칸 = 무엇이든 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하) · 여러 줄이면 위 끝 최소 · 찾는 칸 있는 줄 | `master_formula.py:331` `_match` · `:362` `find` | +| 로직 한 줄 셈 — 입력 검사 → 중간 차례 → 결과 식 또는 호표 줄(수량 × 단가 · 로직 줄은 비목 나눔) → 덧줄(앞 줄까지 비목 합) → 끝수 | `master_formula.py:538` `run` · 입력 `_inputs` :489 · 빈 가격 입력 `fill_empty` :479 · 끝수 `cut` :468 · `{입력}` 채우기 `fill` :314 | +| 요소 값 · 재료 단가 | `master_material.py:238` `element` · `master_pick.py:130` `priced` · `:123` `pick`(검색어 묶음 → 품목) | +| 정적 검사(저장 때) | `master_formula.py:744` `check_logic` · `M01_MasterData_Store_Cache.py:69` `_check` | +| M01 셈 진입 | `M01_MasterData/M01_MasterData_Store.py:543` `calc`(사본 읽기 → 줄 바꿔 끼움 `_swap` :524 → `cm.calc`) · `:572` `text`(읽는 식 줄 `master_text.py:493 lines`) · `:563` `auto`(견본 입력) | +| 일괄 | `check_master.py:174` `calc` · `:511` `calc_all`(시험입력으로 전 로직) | + +## ② 1,472 로직 식 말 전수 + +| 꼴 | 나온 수 | 로직 수 | 예(3) | +| --- | --- | --- | --- | +| 따옴표 글 | 12,536 | 1,085 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000273, 기계='6516-0060')` | +| 이름 = 입력 | 10,815 | 1,178 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기(…).칸 | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 찾기(표 · 조건) | 8,156 | 1,401 | GC000994 `찾기(QC001863, 지반구분=지반구분).시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 사칙 * | 7,697 | 1,037 | GC000994 `찾기(QC000237).크롤러드릴_공기식 * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000997 `찾기(QC001866).크레인 * 8 / 시공량` | +| 이름 = 중간값 | 7,367 | 1,184 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 사칙 / | 3,824 | 1,019 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).철근공 / 시공량` · GC000996 `찾기(QC001865).보링공 / 시공량` | +| 비교 == | 3,736 | 451 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 함수 만약 | 3,541 | 477 | GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` · GC000999 `만약(시공구분 == '기계시공', 찾기(QC001868, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` · GC001000 `만약(시공구분 == '기계시공', 찾기(QC001869, 시공구분=시공구분, 수종=수종, 흉고직경=흉고직경).고소작업차 * 8` | +| 호표 요소 = 키(인력) | 3,451 | 1,369 | GC000994 `LB000014` · GC000995 `LB000008` · GC000996 `LB000014` | +| 사칙 + | 3,094 | 668 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 로직(다른 로직 부르기) | 1,116 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 로직 넘길 값 있음 | 1,089 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 요소 = 로직 부르기(장비 · 일위대가) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 호표 비목 없음(로직 줄 → 비목 나눔) | 1,075 | 506 | GC000994 `로직(GC000276, 기계='5401-0017', 지역=지역)` · GC000996 `로직(GC000273, 기계='6105-0190')` · GC000997 `로직(GC000268, 기계=크레인, 지역=지역)` | +| 찾기 조건 없음(표 한 줄) | 1,022 | 286 | GC000994 `찾기(QC000237).보링공 / 시공량` · GC000995 `찾기(QC001864).시공량` · GC000996 `찾기(QC001865).시공량` | +| 요소 키 부르기(인력) | 924 | 393 | GC000996 `(보링공 * LB000014 + 기계설비공 * LB000051 + 특별인부 * LB000003) * 11 / 100` · GC000997 `(중급기술자 * LB000286 + 보링공 * LB000014 + 특별인부 * LB000003 + 보통인부 * LB000002` · GC000998 `(철공 * LB000009 + 보통인부 * LB000002) * 3 / 100` | +| 호표 요소 = 재료 고르기 묶음(구분 · 상세구분 · 검색어) | 673 | 231 | GC000319 `{"구분": "도료·방수·접착", "상세구분": "도료", "검색어": "융착식 프라이머"}` · GC000337 `{"구분": "철근·강재", "상세구분": "철망·와이어메쉬", "검색어": "철망 Φ3.2."}` · GC000723 `{"구분": "잡재료", "상세구분": "용접·가스", "검색어": ""}` | +| 논리 또는 | 297 | 35 | GC000346 `(만약(공법군 == '발파굴착A군' 또는 공법군 == '기계굴착A군', 착암공인원, 0)) * 배율` · GC000111 `만약(토질_암분류 == '호박돌 섞인 고결토・경질토' 또는 토질_암분류 == '풍화암', 로직(GC000267, 기계='020` · GC001136 `찾기(QC002058, '규격(㎜)범위'=만약(규격 == 'ø15' 또는 규격 == '20' 또는 규격 == '25' 또는 규` | +| 사칙 - | 273 | 156 | GC000998 `찾기(QC001867).시공량 * (1 - (수직고초과단계 * 9 - 경사가산) / 100)` · GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` | +| 논리 그리고 | 199 | 23 | GC001120 `만약(부위 == '뿜칠재' 그리고 뿜칠면 == '콘크리트면', 1.2, 1)` · GC000753 `찾기(QC001431, '배관규격(mm)'=배관규격, 보온재=보온재, 보온두께=보온두께).시공량 * (1 + (0 - 만약(설` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` | +| 시험입력 있음 | 195 | 0 | | +| 요소키.칸(요소 칸 값) | 160 | 49 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000138 `EQ:{장비}.주연료종류` | +| 이름 = 비목 합(노무비 · 재료비 · 경비) | 153 | 146 | GC000722 `노무비 * 기계경비율 / 100` · GC000723 `노무비 * 기계경비율 / 100` · GC000724 `노무비 * 기계경비율 / 100` | +| 비교 <= | 123 | 36 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000346 `만약(갱구거리 <= 500, 0, 찾기(CC000077, 갱구에서뚫기점까지의거리=갱구거리).할증률)` · GC000185 `만약(타설깊이 <= 25, 찾기(QC000348, L='25이하').t_1, 만약(타설깊이 <= 30, 찾기(QC000348,` | +| 함수 최대 | 122 | 43 | GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000373 `최대(찾기(CC000125, 형식=만약(형식 == '박스거더' 또는 형식 == '강바닥판 박스거더', '박스거더', '박스거더` · GC000203 `최대(만약(용도 == '신구-콘크리트 접착제바르기', 찾기(QC000412, 용도=용도, 재료명='Epoxy신구-콘크리트접착제` | +| 비교 > | 99 | 44 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 요소 부르기 ID:원문번호(+{입력}) | 84 | 14 | GC000138 `EQ:{장비}.주연료종류` · GC000139 `EQ:{장비}.주연료종류` · GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 요소 키 부르기(기계) | 78 | 37 | GC001043 `EQ000532.주연료2 * 찾기(QC001933, 맨홀구분=맨홀구분).'모르타르 믹서(0.3㎥)' * 8 / 시공량` · GC000137 `찾기(QC000264).IC진동롤러_자주식 * 8 * EQ000098.주연료 * 찾기(QC000270, 구분='주연료 할증')` · GC000140 `EQ000004.주연료종류` | +| 찾기 조건 값이 식 | 70 | 33 | GC001008 `(특별인부 * LB000003 + 보통인부 * LB000002 + 그물막인부 * LB000002) * 찾기(QC001882, ` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 함수 올림 | 67 | 53 | GC000570 `만약(층수 <= 10, 0, 만약(층수 <= 15, 4, 4 + 올림((층수 - 15) / 5)))` · GC000750 `올림(최대(댐퍼면적 - 0.1, 0) / 0.1)` · GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` | +| 호표 요소 = 자리 표시 {입력}(LB:{직종} 등) | 66 | 57 | GC000559 `LB:{공수직종}` · GC001043 `MO:휘발유:{지역}` · GC000137 `MO:경유:{지역}` | +| 함수 최소 | 51 | 18 | GC000575 `찾기(QC001210, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000576 `찾기(QC001212, '시공높이(m)'=최소(시공높이m, 7.2), 벽두께=벽두께).시공량 * (1 - (만약(시공높이m >` · GC000580 `찾기(QC001218, '시공높이(m)'=최소(시공높이m, 7.2), '블록규격(mm)'=블록규격mm, 마감=마감).시공량 *` | +| 빼기 부호(-x) | 47 | 28 | GC001008 `찾기(QC001880, 기계구분=기계구분).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC00` · GC001009 `찾기(QC001883).시공량 * (1 - (-만약(연간시공횟수 == '연3회 이상', 0, 찾기(QC001884, 연간시공횟` · GC001012 `찾기(QC001889).시공량 * (1 - (-살포량증감) / 100)` | +| 비교 < | 46 | 26 | GC000561 `만약(층수층 < 6, 찾기(CC000157, '강재사용량(㎏/㎡)'=강재사용량).'보정치(a)' * 찾기(CC000159, '` · GC000564 `만약(강재총사용량 < 300, 1, 찾기(CC000165, '강재총사용량(t)'=강재총사용량, '1t당 볼트본수(본)'=볼트본` · GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` | +| 함수 반올림 | 42 | 41 | GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000088 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 재료 묶음에 {입력} | 32 | 19 | GC000264 `{"구분": "레미콘", "상세구분": "레미콘", "검색어": "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}"}` · GC000431 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "싱글코아바렐 {규격}"}` · GC000432 `{"구분": "기계·설비", "상세구분": "기계류", "검색어": "더블코아바렐 {규격}"}` | +| 함수 버림 | 32 | 17 | GC000267 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` · GC000268 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) + EQ:{기계}.관리비계` · GC000269 `버림(EQ:{기계}.가격 * 1000 * ((EQ:{기계}.상각비계수 + EQ:{기계}.정비비계수) * (1 + 가산비율 / ` | +| 결과 식 로직(돈 아님) | 27 | 27 | GC000422 `a * E` · GF000003 `반올림(3600 * q * K * f * E / Cm, 3)` · GF000042 `반올림(3600 * q * K * f * E / Cm, 3)` | +| 요소 키 부르기(재료) | 23 | 8 | GC000360 `((찾기(QC000648, 품명='아세틸렌', 레일규격=레일규격).수량) * MT003163 + (찾기(QC000648, 품명` · GC000361 `((찾기(QC000650, 품명='테르밋용재', 레일규격=레일규격, 규격='점화용').수량) * MT030628 + (찾기(Q` · GC000374 `(찾기(QC000664, 품명='용접봉').수량 * MT003191 + 찾기(QC000664, 품명='산소').수량 * 100` | +| 비교 >= | 21 | 11 | GC000101 `만약(수심증감 == '적용 안 함', 0, 만약(수심 < 2.5, 0 - 찾기(QC000204, 수심=수심).감, 만약(수심 ` · GC000769 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001447,` · GC000774 `만약((규격 >= 15 그리고 규격 <= 25) 또는 (규격 >= 32 그리고 규격 <= 50), 0, 찾기(QC001452,` | +| 로직(…).칸(비목 · 계) | 14 | 11 | GC000087 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000090 `로직(GC000271, 기계='0201-0100', 브레이커='0230-0010', 지역=지역, 보정작업=보정작업).계 * (` · GC000091 `로직(GC000268, 기계='5405-0110', 지역=지역).계 * (찾기(QC000189).유압식크롤러드릴)` | +| 비교 != | 12 | 8 | GC000068 `만약(작업 != '목재 붙이기', 찾기(QC000143, 구분='보통인부', 작업=작업).수량, 0)` · GC000176 `만약(비트 != '오거비트', 해머압축기대수 * T / 60, 0)` · GC000183 `만약(재질 != '토사', T1 / T * T / 60, 0)` | +| 줄.'호표 이름'(호표 줄 금액) | 7 | 3 | GC000211 `(줄.'합판(내수 12㎜)' + 줄.'각재') * 찾기(QC000428, 사용횟수=사용횟수).소모자재 / 100` · GF000185 `(노무비 + 재료비 + 경비 - 줄.'구체콘크리트(철근) 레미콘') * 찾기(QF000387, 구분='구체콘크리트(철근) - ` · GF000210 `(줄.'패널' + 줄.'내부패널') * 찾기(QF000418, 구분=유형).요율 / 100` | +| 로직 넘길 값이 식 | 5 | 5 | GC000332 `로직(GC000267, 기계=만약(형식 == 'A-Type', '0201-0040', '0201-0020'), 지역=지역, 보` · GF000294 `로직(GC000268, 기계=만약(근원직경 < 20, '2105-0010', 만약(찾기(QF000063, '근원직경(cm)'=` · GF000297 `로직(GC000268, 기계=만약(흉고직경 < 18, '2105-0010', 만약(찾기(QF000081, '흉고직경(cm)'=` | +| (글자) 이름( 꼴(함수 아닌 이름 뒤 괄호) | 1,177 | — | GC000996 `찾기(QC001865).'그라우팅 믹서(190×2ℓ)' * 8 / 시공량` · GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC000996 `만약(작업위치 == '경사면(고소작업차)', 찾기(QC001865).'고소작업차(5ton)' * 8 / 시공량, 0)` | +| (글자) ∼ · ~ 물결 | 63 | — | GC000996 `찾기(QC001865).'그라우팅 펌프(30∼60ℓ/min)' * 8 / 시공량` · GC001026 `찾기(QC001905, 절단폭=절단폭, 차로차단=차로차단).'굴착기+브레이커(0.2㎥~0.6㎥)' * 8 / 시공량` · GC000573 `찾기(QC001207, 구분='그라우팅펌프', 규격='40∼125(ℓ/min)').수량` | +| (글자) % 글자 | 62 | — | GC000298 `찾기(QC000519, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000299 `찾기(QC000520, 포장=포장).시공량 * (1 - 만약(포장 == '공항포장', 만약(지장물 == '연속 포설 불가', ` · GC000303 `찾기(QC000524, 형식=형식).시공량 * (1 - 만약(유도블록 == '아님', 0, 찾기(QC000583, 구분='유도` | +| (글자) {} 자리 표시 | 50 | — | GC000138 `EQ:{장비}.주연료종류` · GC000138 `1 * 8 / 시공량 * EQ:{장비}.주연료 * 찾기(QC000266, 구분='주연료 할증').값 / 100` · GC000139 `EQ:{장비}.주연료종류` | +| (글자) & 글자 | 53 | — | GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` · GC000931 `(찾기(QC001700, 공정별='소운반').비계공 + 찾기(QC001700, 공정별='Draft tube설치 가설된 Conc` · GC000931 `(찾기(QC001700, 공정별='Draft tube설치 가설된 Concrete tube에 이어서 Leveling & Cent` | +- 「요소 부르기 꼴 `보통인부(…)`」 — **문법에 없음**(이름 뒤 괄호는 함수 · 찾기 · 로직만). 글자로 1,177 번 보이는 「이름(」 은 모두 따옴표 칸 이름 안(`찾기(…).'고소작업차(5ton)'`). 요소는 키로만 부름(`LB000006` · `EQ000532.주연료2` · `EQ:{기계}.가격`). +- `&` · `∼`/`~` · `%` · `{ }` 글자 — `&` · `∼` · `%` 는 모두 따옴표 글(조건 값 · 칸 이름) 안 · `{ }` 는 `ID:원문번호{입력}` 와 재료 검색어 · 자리 표시 안(아래 ③). +- 끝수 — 1,472 중 묶음(`{대상, 자리, 방법}`) **0** · 설명 글 37 · 없음 1,435 → 엔진이 끝수를 실제로 붙이는 로직은 지금 없음(덧줄 · 함수 안 `반올림(…)` 으로 씀). +- 표 — 로직이 찾는 표 1,865 · 범위 조건이 있는 표 106(범위규칙 글 40 꼴 — 첫 낱말은 넷뿐) · 값 칸에 「1/1.30」 같은 식 글이 든 표 104(엔진이 식으로 풂). + +## ③ 흐름도로 옮기기 어려운 꼴 + +| 꼴 | 수(마디 / 로직) | 왜 어려운가 | 이렇게 옮기면 됨 | +| --- | --- | --- | --- | +| 요소 칸 값 `EQ000532.주연료2` · `EQ000098.주연료` | 160 / 49 | 요소의 값이 아니라 **요소의 다른 칸**(기계 주연료 · 가격 · 계수) | 입력 상자 「기계」 요소 알약을 칸별로(바깥 값 「기계 key + field」 — Api 에 이미 있음 `CfQuery 기계 field`) | +| `ID:원문번호{입력}` — `EQ:{기계}.가격` · `EQ:{장비}.주연료` | 84 / 14 | 부를 요소가 **입력값에 따라 바뀜**(기계경비 로직 GC000267 등 핵심 로직) | 「재료 자리」 처럼 「기계 자리」(선택 자리 · 입력 알약 값으로 키 고름) — 지금 흐름도에 없음 → **새 입력 방식 필요(⬜ 브레인)** · 그 전엔 「다른 로직 입력」 으로 기계경비 로직을 통째로 부름 | +| 호표 요소 자리 표시 `LB:{공수직종}` · `MO:경유:{지역}` | 66 / 57 | 노임 직종 · 유가 지역이 입력값에 따라 바뀜 | 같은 「선택 자리」(인력 · 유가전력) — 재료 방식 셋을 다른 그룹에도 | +| 재료 묶음 검색어에 `{입력}` — `레미콘 {굵은골재최대치수}-{강도}-{슬럼프}` | 32 / 19 | 검색어가 입력값으로 만들어짐 | 재료 「조건 찾기」(이름 / 규격에 알약 값) — 알약 여럿을 이어 붙인 글이 필요 → 함수 상자로 글을 만들어 조건 알약으로 | +| 호표 로직 줄(비목 없음) `로직(GC000267, 기계='0201-0020', 지역=지역)` | 1,075 / 506 | 한 줄 금액이 **노무비 · 재료비 · 경비 셋으로 나뉨** | 다른 로직 입력 상자의 출력 알약 셋(노무비 · 재료비 · 경비) × 수량 함수 셋 → 출력 셋 | +| 로직 넘길 값이 식 `기계=만약(형식 == 'A-Type', '0201-0040', …)` | 5 / 5 | 넘길 값이 조건으로 바뀜 | 함수 상자(IF)로 값을 만들어 다른 로직의 받는 알약에 선 | +| 찾기 조건 값이 식 `'시공높이(m)'=최소(시공높이m, 7.2)` | 70 / 33 | 조건 값이 계산값 | 함수 상자로 먼저 셈 → 「표에서 찾기」 조건 알약에 그 출력 | +| 덧줄의 비목 합 `노무비 * 기계경비율 / 100` | 153 / 146 | 바탕이 **앞 줄까지의 비목 합**(덧줄이 덧줄을 바탕으로 쓸 수 있음) | 출력 상자 비목별 모음 함수(앞 줄 차례대로) → 비율 함수 · 앞 덧줄도 받는 알약으로 | +| 호표 줄 금액 `줄.'합판(내수 12㎜)'` | 7 / 3 | 특정 호표 줄 금액을 다시 씀 | 그 줄 금액 함수 출력에서 선 | +| 로직 결과 칸 `로직(…).계` | 14 / 11 | 다른 로직 돈 결과의 한 칸 | 다른 로직 입력의 그 비목 · 계 알약 | +| 표 한 줄(조건 없는 찾기) `찾기(QC001864).시공량` | 1,022 / 286 | 조건이 없어 「줄 하나」 표 | 「표에서 찾기」 조건 없이 칸만(표 첫 줄) — 줄이 둘 이상이면 옛 엔진도 오류 | +| 범위 조건 표(범위규칙 넷 · 위 끝 최소) | 표 106 | 엑셀 한 식으로 쓰기 어려움(빈 칸 = 무엇이든 · 규칙별 경계) | 선 규칙 「표에서 찾기」 가 엔진 `find` 와 같은 뜻으로(줄마다 맞음 칸 + INDEX/MATCH — 옛 가져오기에서 쓴 꼴) | +| 표 값의 식 글 「1/1.30」 | 표 104 | 엔진은 글을 식으로 풂 · 엑셀은 글 | 서버가 표 블록을 줄 때 수로 바꿔 줌 | +| 결과 식 로직(돈 아님) | 27 / 27 | 출력이 비용 아님 | 출력 종류 「값」 | +| 빈 입력 · 표에 없는 조건 | — | 엔진은 **그 줄만 비우고** 계속(합에서 뺌) | 흐름도 「끊긴 곳」 + 그 출력 0 으로 셈(같은 합) | + +## ④ 계산 길(로직 셈)을 부르는 곳 + +| 어디 | 무엇 | 자리 | +| --- | --- | --- | +| 서버 `POST /api/m01/calc` | 시험 계산(화면 · 저장 전 줄 포함) | `M01_MasterData_Router.py:222` → `Store.calc` :543 | +| 서버 `POST /api/m01/text` | 읽는 식 줄(텍스트 수식) | `M01_MasterData_Router.py:228` → `Store.text` :572 | +| 서버 `GET /api/m01/logic/auto` | 견본 입력값 | `M01_MasterData_Router.py:192` → `Store.auto` :563 → `master_auto.values` :80 | +| 화면 M01 단가산출 시험 계산 | `/calc` · `/text` · `/logic/auto` | `M01_MasterData_UI_Logic_Api.ts:224 · :229 · :245` · 부름 `M01_MasterData_UI_Logic_Calc.ts:142 · :155 · :184` | +| 서버 일위대가 조합 미리보기 | 조합에 담긴 로직마다 셈 | `M01_MasterData_Store_Combo.py:259 combo_preview` → `master_combo.py:106 run`(:128 `mf.run`) · M02 `POST /api/m02/layers/{layer}/combos/preview` `M02_MasterTemplete_Router_Combos.py:195` → `M02_MasterTemplete_Combo_Store.py:193` | +| 서버 프로젝트 사본 계산 | 프로젝트 항목 단가(복사본 표 · 노임) | `POST /api/m02/projects/{id}/items/{folder}/calc` `M02_Project_Items_Router.py:178` → `M02_Item_Copy.py:326` → `M02_Item_Prices.py:211`(:230 `mf.run`) · 화면 `ui_template/ui_template_item_picker_project.ts:218` | +| 흐름도 서버 | 단가 · 다른 로직 값(호표 한 줄 견본 로직으로 옛 셈 부름) | `M02_DF_Values.py:68 · :105 · :229 · :262`(`store.calc`) · `M02_DF_Router.py:216 logic_value`(흐름도 셈 `M02_DF_Compute`) | +| 옛 시험판 서버(CostFlow · 남김) | 같은 꼴 | `M02_CF_Router.py:230 · :267 · :390 · :421` | +| 일괄 · 대조 스크립트 | 전 로직 시험 계산 · 견적 대조 | `check_master.py:511 calc_all` · `compare_estx.py:258 · :456` · `master_text.py:504` | + +갈래(정함 4)를 넣을 곳 = **`Store.calc` · `Store.text` 둘**(M01 화면 · 흐름도 값이 모두 여기를 지남) + 조합 미리보기 `master_combo.run` · 프로젝트 `M02_Item_Prices.calc`(이 둘은 `mf.run` 을 바로 부름 — 흐름이 있는 로직을 거르려면 여기도). + +## ⑤ 시험입력 · 기본 입력 + +- 줄에 `시험입력` 이 있는 로직 **195**(1,472 중) — 일부 입력만 적힘(예 GF000219 `{"높이": 4.0, "돌": "깬잡석"}`). +- 기본 입력 만드는 법이 **둘**이고 범위에서 갈림: + - 일괄 시험(`check_master.py:496 시험입력`) — 고르기 첫 값 · 범위 **위 끝** · 그 밖 1 · 줄의 `시험입력` 이 이김. + - 화면 견본(`master_auto.py:80 values` · `/logic/auto`) — 고르기 첫 값 · 범위 **아래 끝** · 그 밖 = 그 입력으로 찾는 표의 가장 낮은 값(없으면 나누는 수면 1 · 아니면 0) · 단위에 「원」 이면 비움. +- 전수 대조(정함 3)는 **`check_master.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름). From 69e68a761bb1d8c0822274ac52e239ae47ee35b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:33:56 +0900 Subject: [PATCH 14/26] =?UTF-8?q?fix(m02-df):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=9D=90=EB=A6=84=20=EB=AA=BB=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EC=89=AC=EC=9A=B4=20=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index cdc55845d..3ac1c5507 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -66,6 +66,10 @@ const TEXT = { "돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름", "That would loop — a logic that uses this one can't be picked", ], + NoFlow: [ + "이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음", + "Can't read this logic's flow yet", + ], Failed: ["불러오지 못함", "Could not load"], Wait: ["값 셈 중…", "Calculating…"], } as const; @@ -243,7 +247,7 @@ export function renderLogicInput( ), }); } catch (e) { - showToast(plain(e), "error"); + showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error"); } }; From 96326eaa35520dbb8308062cb20abba880551504 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:35:01 +0900 Subject: [PATCH 15/26] =?UTF-8?q?chore(m02-df):=2036-7=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=ED=9D=90=EB=A6=84=EB=8F=84=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=ED=99=94=EB=A9=B4=20=EA=B2=AC=EB=B3=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=EC=9D=84=20resources/tester/fixtures=5Fdf=20?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataFlow/_sample 다섯 · CostFlow/_sample 셋 → fixtures_df(costflow) · 시험 길 고침(df_sample · df_model · cf_sample) - M02_DF_Sample.ts 지움(시험 밖에서 시작 화면만 씀) · Entry 견본 칸 · 글 · 부름 지움 · 시작 화면 한 칸 - CostFlow 폴더 나머지 · 시험판 단추 · 메뉴는 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/DataFlow/M02_DF_Entry.css | 2 +- M02_MasterTemplete/DataFlow/M02_DF_Entry.ts | 26 +------ M02_MasterTemplete/DataFlow/M02_DF_Sample.ts | 78 ------------------- .../tester/fixtures_df/costflow}/masonry.json | 0 .../tester/fixtures_df/costflow}/rate.json | 0 .../tester/fixtures_df/costflow}/value.json | 0 .../tester/fixtures_df}/ground.json | 0 .../tester/fixtures_df}/hume.json | 0 .../tester/fixtures_df}/linked.json | 0 .../tester/fixtures_df}/masonry.json | 0 .../tester/fixtures_df}/value.json | 0 resources/tester/test_m02_cf_sample.py | 4 +- resources/tester/test_m02_df_model.py | 4 +- resources/tester/test_m02_df_sample.py | 52 +------------ 14 files changed, 11 insertions(+), 155 deletions(-) delete mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Sample.ts rename {M02_MasterTemplete/CostFlow/_sample => resources/tester/fixtures_df/costflow}/masonry.json (100%) rename {M02_MasterTemplete/CostFlow/_sample => resources/tester/fixtures_df/costflow}/rate.json (100%) rename {M02_MasterTemplete/CostFlow/_sample => resources/tester/fixtures_df/costflow}/value.json (100%) rename {M02_MasterTemplete/DataFlow/_sample => resources/tester/fixtures_df}/ground.json (100%) rename {M02_MasterTemplete/DataFlow/_sample => resources/tester/fixtures_df}/hume.json (100%) rename {M02_MasterTemplete/DataFlow/_sample => resources/tester/fixtures_df}/linked.json (100%) rename {M02_MasterTemplete/DataFlow/_sample => resources/tester/fixtures_df}/masonry.json (100%) rename {M02_MasterTemplete/DataFlow/_sample => resources/tester/fixtures_df}/value.json (100%) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css index 05228f379..9058ccdb2 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css @@ -70,7 +70,7 @@ /* 시작 화면 */ .m02-df__start { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: var(--spacing-24); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index e6a60b0e8..0cd240ba0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -47,12 +47,11 @@ const loadMods = () => import("./M02_DF_FuncPanel"), import("./M02_DF_OutputPanel"), import("./M02_DF_Summary"), - import("./M02_DF_Sample"), import("./M02_DF_InputPanel"), import("./M02_DF_PillPanel"), import("./M02_DF_LinkPanel"), import("./M02_DF_Standards"), - ]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({ + ]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({ model, ev, api, @@ -60,7 +59,6 @@ const loadMods = () => func, output, summary, - sample, input, pill, line, @@ -78,7 +76,6 @@ const TEXT = { Start_New: ["새로 만들기", "New"], Start_Mine: ["내 로직", "My logics"], Start_MineNone: ["아직 없음", "None yet"], - Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], @@ -217,7 +214,6 @@ function openDataFlow(): void { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); - const sampleHost = el("div", { className: "m02-df__list" }); body.replaceChildren( el("div", { className: "m02-df__start", @@ -232,28 +228,10 @@ function openDataFlow(): void { mineHost, ], }), - el("section", { - className: "m02-df__start-col", - children: [el("h3", { text: te("Start_Samples") }), sampleHost], - }), ], }), ); const M = await mods; - sampleHost.replaceChildren( - ...M.sample.sampleList().map((s) => - listRow( - s.name, - s.resultUnit, - () => - run(async (M2) => { - const lg = M2.sample.sampleLoad(s.id); - if (lg) await showPaper(M2, lg, false); - }), - s.note, - ), - ), - ); try { const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); mineHost.replaceChildren( @@ -379,7 +357,7 @@ function openDataFlow(): void { if (sig === logicSig) return; logicSig = sig; const get = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key); + key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts deleted file mode 100644 index 96bda504e..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================================= - * M02_DF_Sample.ts - * 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬) - * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비 - * ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비 - * ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비 - * ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비 - * ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직 - * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). - * 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음. - * 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함 - * `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { CfLogic } from "./M02_DF_Model"; - -const FILES = import.meta.glob("./_sample/*.json", { eager: true, import: "default" }); - -/** 견본마다 한 줄 설명 — [한, 영] */ -const NOTE: Record = { - masonry: [ - "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", - "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", - ], - ground: [ - "지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비", - "Soil goes to the excavator, rock to the breaker — two gate lines → expense", - ], - hume: [ - "관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비", - "Pipe size picks the hume pipe item (spec contains size) → count × price → material cost", - ], - linked: [ - "작업량 로직의 출력을 받아 장비 시간 → 경비", - "Takes the output of a productivity logic → machine hours → expense", - ], - value: [ - "q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)", - "q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)", - ], -}; - -/** 보여 줄 차례 */ -const ORDER = ["masonry", "ground", "hume", "linked", "value"]; - -export interface CfSampleItem { - /** 파일 이름(확장자 뺌) */ - id: string; - name: string; - resultUnit: string; - /** 쉬운 말 한 줄 */ - note: string; -} - -const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, ""); -const byId = (): Map => - new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg])); - -/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */ -export function sampleList(): CfSampleItem[] { - const all = byId(); - const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length); - return [...all.entries()] - .sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name)) - .map(([id, lg]) => ({ - id, - name: lg.name, - resultUnit: lg.resultUnit, - note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "", - })); -} - -/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */ -export function sampleLoad(id: string): CfLogic | null { - const lg = byId().get(id); - return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null; -} diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/resources/tester/fixtures_df/costflow/masonry.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/masonry.json rename to resources/tester/fixtures_df/costflow/masonry.json diff --git a/M02_MasterTemplete/CostFlow/_sample/rate.json b/resources/tester/fixtures_df/costflow/rate.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/rate.json rename to resources/tester/fixtures_df/costflow/rate.json diff --git a/M02_MasterTemplete/CostFlow/_sample/value.json b/resources/tester/fixtures_df/costflow/value.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/value.json rename to resources/tester/fixtures_df/costflow/value.json diff --git a/M02_MasterTemplete/DataFlow/_sample/ground.json b/resources/tester/fixtures_df/ground.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/ground.json rename to resources/tester/fixtures_df/ground.json diff --git a/M02_MasterTemplete/DataFlow/_sample/hume.json b/resources/tester/fixtures_df/hume.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/hume.json rename to resources/tester/fixtures_df/hume.json diff --git a/M02_MasterTemplete/DataFlow/_sample/linked.json b/resources/tester/fixtures_df/linked.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/linked.json rename to resources/tester/fixtures_df/linked.json diff --git a/M02_MasterTemplete/DataFlow/_sample/masonry.json b/resources/tester/fixtures_df/masonry.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/masonry.json rename to resources/tester/fixtures_df/masonry.json diff --git a/M02_MasterTemplete/DataFlow/_sample/value.json b/resources/tester/fixtures_df/value.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/value.json rename to resources/tester/fixtures_df/value.json diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index 2acb47268..00d6fb661 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -1,4 +1,4 @@ -"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`. +"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`. · 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) · 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 @@ -21,7 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow" SAMPLE_TS = FOLDER / "M02_CF_Sample.ts" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip HOOKS = r""" import { readFileSync } from "node:fs"; diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 4cfbee4d8..f2e45f3c9 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None: def _ground() -> dict: - return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8")) + return json.loads( + (ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8") + ) def _server(lg: dict, values: dict) -> dict: diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py index 11bc2f9af..342436f79 100644 --- a/resources/tester/test_m02_df_sample.py +++ b/resources/tester/test_m02_df_sample.py @@ -1,4 +1,4 @@ -"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`. +"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움). 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value) · 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) @@ -6,7 +6,7 @@ · 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음 · 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 · 입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133 -· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 +· 파일 안 씀 """ from __future__ import annotations @@ -14,7 +14,6 @@ from __future__ import annotations import json import math import re -import subprocess from decimal import Decimal from pathlib import Path @@ -22,8 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow" -SAMPLE_TS = FOLDER / "M02_DF_Sample.ts" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip NAMES = ["masonry", "ground", "hume", "linked", "value"] BOX_COLOR = dict( re.findall( @@ -34,40 +32,6 @@ BOX_COLOR = dict( ) ) -HOOKS = r""" -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { stripTypeScriptTypes } from "node:module"; -export async function load(url, ctx, next) { - if (!url.endsWith(".ts")) return next(url, ctx); - let src = readFileSync(fileURLToPath(url), "utf8"); - src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); - src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { - const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); - return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; - }); - return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; -} -""" - - -def _node(body: str) -> object: - files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} - script = ( - "import { register } from 'node:module';\n" - f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" - "globalThis.__stub = { currentLanguageIndex: 0 };\n" - f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" - f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" - "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body - ) - result = subprocess.run( - ["node", "--no-warnings", "--input-type=module", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr[-2000:] - return json.loads(result.stdout) - # ── 알약 · 선 ───────────────────────────────────────────────────────────── def _ports_out(box: dict) -> list[str]: @@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None: } -def test_견본_목록_열기() -> None: - got = _node( - "out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });" - ) - assert [x["id"] for x in got["list"]] == NAMES - assert all(x["note"] for x in got["list"]) - assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"] - assert got["none"] is None - - # ── 값(내부 확인) ───────────────────────────────────────────────────────── @pytest.fixture(scope="module") def master(): From 14547d4f2002467edf3787c22995252ce277e9a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:35:09 +0900 Subject: [PATCH 16/26] =?UTF-8?q?fix(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=E2=80=94=20=ED=81=AC=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EB=A5=BC=20=EB=8B=AB=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=ED=8C=90=EC=9D=84=20=EC=B2=98=EC=9D=8C=20=EC=B0=A8=EB=A1=80?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A0=A4=20=EB=81=BC=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EB=A7=8C=EB=93=A4=20=EB=95=8C=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=C2=B7=20=EC=A2=81=EC=9D=80=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=A7=9E=EC=B6=A4=20=EB=B0=94=EB=8B=A5=200.25(PLAN?= =?UTF-8?q?=2036-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ORCA(5183): 540px 칸 상자 11 모두 판 안(0.288) · 머리 끌기 → onChange 새 객체 1 번 · 크게 보기 0.9 · 닫으면 패널 0.288 · 고친 자리 그대로 · 실행 취소 → onChange 되돌린 객체 · 접기 띠 「흐름도 ▸」 onResize 40 · 펴기 · 가장자리 끌기 +120 → onResize 764 · localStorage · 보기만: 더하기 0 · 끌기 onChange 0 · 고르기 됨 · 판 칸 막힘 · 여러 조건 이름표 · 밝음 · 어두움 - resources/master_data 안 바뀜(git status) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index ade890724..de5d49f87 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -53,7 +53,7 @@ export interface FlowPanel { export const PANEL_MIN = 320; export const STRIP_WIDTH = 40; /** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */ -export const PANEL_FIT_MIN = 0.5; +export const PANEL_FIT_MIN = 0.25; const WIDTH_KEY = "m02.dfpanel.width"; const FOLD_KEY = "m02.dfpanel.folded"; @@ -87,6 +87,8 @@ const writeStore = (key: string, value: string): void => { /* ── 패널 ───────────────────────────────────────────────────────────── */ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel { + /** 크게 보기 화면 — 열려 있을 때만 */ + let full: HTMLElement | null = null; const paper = createPaper({ logic: opts.logic, readOnly: opts.readOnly, @@ -98,7 +100,6 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane const { diagram, detail, summary, note, undo } = paper.parts; let width = storedWidth(readStore(WIDTH_KEY)); let folded = readStore(FOLD_KEY) === "1"; - let full: HTMLElement | null = null; const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth)); const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") => @@ -241,10 +242,16 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane full.remove(); full = null; tools.append(undo); - open.insertBefore(note, diagram); - open.insertBefore(diagram, detail); - open.insertBefore(detail, costBox); costBox.append(summary); + // 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움 + open.replaceChildren( + open.firstElementChild as HTMLElement, + tools, + note, + diagram, + detail, + costBox, + ); paper.refit(); }; From dcb023f4e6362625399215ae0059ee98e54c9c3d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:37:13 +0900 Subject: [PATCH 17/26] =?UTF-8?q?feat(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=ED=8C=90=20=EC=8B=9D=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=B9=B8=20=EC=97=B0=EA=B2=B0=20=ED=91=9C=20=C2=B7?= =?UTF-8?q?=20=EC=B9=B8=20=EC=83=89=20=C2=B7=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=97=B0=EA=B2=B0=20+=20?= =?UTF-8?q?=EC=84=A0=20=EC=A1=B0=EA=B1=B4=20=EC=97=AC=EB=9F=AC=20=EC=A4=84?= =?UTF-8?q?(=EA=B7=B8=EB=A6=AC=EA=B3=A0/=EB=98=90=EB=8A=94=20=C2=B7=20?= =?UTF-8?q?=EA=B0=92=20=EB=AA=A9=EB=A1=9D=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B2=94=EC=9C=84=20=EA=B2=80=EC=82=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- .../DataFlow/M02_DF_CellTable.ts | 135 +++++++++++++++ .../DataFlow/M02_DF_FuncExtra.ts | 85 ++++++++++ .../DataFlow/M02_DF_FuncPanel.css | 117 ++++++++++++- .../DataFlow/M02_DF_FuncPanel.ts | 39 ++++- .../DataFlow/M02_DF_LinkPanel.ts | 158 +++++++++++++++--- 5 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts new file mode 100644 index 000000000..1e8eac043 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts @@ -0,0 +1,135 @@ +/* ============================================================================= + * M02_DF_CellTable.ts + * 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기 + * 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfBox, CfLogic } from "./M02_DF_Model"; +import { portText } from "./M02_DF_Detail"; + +const TEXT = { + Title: ["칸 연결 표", "Cell connections"], + Cell: ["칸", "Cell"], + Pill: ["연결 알약", "Pill"], + Kind: ["종류", "Kind"], + Line: ["이어진 선", "Line"], + In: ["입력", "Input"], + Var: ["변수", "Variable"], + Out: ["결과", "Result"], + None: ["아직 연결된 칸이 없음", "No cell is connected yet"], + Unplaced: ["칸 없음", "no cell"], + NoLine: ["-", "-"], + Release: ["풀기", "Release"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], +} as const; + +const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +export type CellKind = "in" | "var" | "out"; + +export interface CellTableHandlers { + /** 표에서 칸을 바꿈 — key = 알약 이름 */ + move(kind: CellKind, key: string, cell: string): void; + /** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */ + release(kind: CellKind, key: string): void; +} + +interface Row { + kind: CellKind; + key: string; + cell: string; + line: string; +} + +function rowsOf(lg: CfLogic, box: CfBox): Row[] { + const f = box.func; + if (!f) return []; + const into = (port: string): string => { + const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port); + return l ? portText(lg, l.from) : tt("NoLine"); + }; + const out = (): string => { + const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name); + return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine"); + }; + return [ + ...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })), + ...f.variables.map((v): Row => ({ + kind: "var", + key: v.name, + cell: v.cell ?? "", + line: tt("NoLine"), + })), + { kind: "out", key: f.output.name, cell: f.output.cell, line: out() }, + ]; +} + +/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */ +export function renderCellTable( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + readOnly: boolean, + handlers: CellTableHandlers, +): void { + host.classList.add("m02-dfcells"); + const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key); + const head = el("tr", { + children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) => + el("th", { text: t }), + ), + }); + const body = rows.map((r) => { + const input = el("input", { + className: "ui-input ui-input--sm m02-dfcells__cell", + attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") }, + }); + input.value = r.cell; + input.readOnly = readOnly; + input.addEventListener("change", () => { + const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tt("BadCell"); + if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`); + }); + return el("tr", { + className: `m02-dfcells__row is-${r.kind}`, + children: [ + el("td", { children: [input] }), + el("td", { text: r.key }), + el("td", { + children: [ + el("span", { + className: "m02-dfcells__kind", + text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"), + }), + ], + }), + el("td", { text: r.line }), + el("td", { + children: + readOnly || (r.kind !== "var" && !r.cell) + ? [] + : [ + createButton({ + label: tt("Release"), + variant: "ghost", + size: "sm", + onClick: () => handlers.release(r.kind, r.key), + }), + ], + }), + ], + }); + }); + host.replaceChildren( + el("strong", { className: "m02-dfcells__title", text: tt("Title") }), + el("table", { + className: "m02-dfcells__table", + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts new file mode 100644 index 000000000..6d302fea5 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * M02_DF_FuncExtra.ts + * 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것 + * 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { showToast } from "@ui/ui_template_elements"; +import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition"; +import { renderCellTable } from "./M02_DF_CellTable"; +import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; +import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView"; +import { TEXT } from "./M02_DF_FuncText"; +import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model"; + +export interface ExtraContext { + lg: CfLogic; + boxId: string; + detail: DetailOptions; + readOnly: boolean; + place(port: string, cell: string): void; + setOutput(cell: string): void; + patch(make: (f: CfFunc) => CfFunc): void; + used(f: CfFunc): Map; +} + +const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0]; +const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId); + +/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */ +export function drawFormula(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + const ev = evalSafe(x.lg, x.detail); + renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId]))); +} + +/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */ +export function drawCells(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + renderCellTable(host, x.lg, box, x.readOnly, { + move: (kind, key, cell) => { + if (kind === "in") return x.place(key, cell); + if (kind === "out") return x.setOutput(cell); + if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error"); + x.patch((f) => ({ + ...f, + variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)), + })); + }, + release: (kind, key) => + x.patch((f) => + kind === "in" + ? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) } + : kind === "var" + ? { ...f, variables: f.variables.filter((v) => v.name !== key) } + : { ...f, output: { ...f.output, cell: "" } }, + ), + }); +} + +/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */ +export function conditionsOf(x: ExtraContext): ConditionOptions { + return { + valuesFor: (a1) => { + const f = boxOf(x)?.func; + const at = a1.toUpperCase(); + const v = f?.variables.find((y) => y.cell?.toUpperCase() === at); + if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; + const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port; + const link = port + ? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port) + : undefined; + return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null; + }, + pills: () => { + const f = boxOf(x)?.func; + return [ + ...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })), + ...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })), + ]; + }, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css index 7497c4a10..77a5471f1 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel-chip.is-out { - border-color: #f59e0b; + border-color: #27ae60; } .m02-dfpanel-chip.is-var { - border-color: #22a06b; + border-color: #d99a00; } /* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ @@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel .aislo-cell[data-cf-kind="var"] { - box-shadow: inset 0 0 0 2px #22a06b; + box-shadow: inset 0 0 0 2px #d99a00; } .m02-dfpanel .aislo-cell[data-cf-kind="out"] { - box-shadow: inset 0 0 0 2px #f59e0b; + box-shadow: inset 0 0 0 2px #27ae60; } .m02-dfpanel .aislo-cell[data-cf-kind]::after { @@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible { outline: 2px dashed var(--color-danger); outline-offset: -2px; } + +/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */ +.m02-dfpanel-body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-12); +} + +.m02-dfpanel-body > .m02-dfpanel-sheet { + flex: 1 1 28em; + min-width: 0; +} + +.m02-dfcells { + flex: 0 1 24em; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-dfcells__title { + font-weight: var(--font-weight-bold); +} + +.m02-dfcells__table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m02-dfcells__table th, +.m02-dfcells__table td { + padding: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m02-dfcells__cell { + width: 5em; +} + +.m02-dfcells .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m02-dfcells__kind { + padding: 0 var(--spacing-8); + border: 2px solid; + border-radius: var(--radius-pills); + white-space: nowrap; +} + +.m02-dfcells__row.is-in .m02-dfcells__kind { + border-color: #3b82f6; +} + +.m02-dfcells__row.is-var .m02-dfcells__kind { + border-color: #d99a00; +} + +.m02-dfcells__row.is-out .m02-dfcells__kind { + border-color: #27ae60; +} + +/* 식 보기 */ +.m02-df-formula { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m02-df-formula__title { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__line { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: var(--spacing-4); + border-top: 1px dashed var(--color-border); +} + +.m02-df-formula__valued { + color: var(--color-text-secondary); +} + +.m02-df-formula__branches { + margin: 0; + padding-left: var(--spacing-16); + list-style: none; +} + +.m02-df-formula__branches .is-now { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__empty { + margin: 0; + color: var(--color-text-muted); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index 45e3b2be5..17f9cb13a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -14,6 +14,8 @@ import type { Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; +import type { DetailOptions } from "./M02_DF_Detail"; +import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra"; import { createRoundRow } from "./M02_DF_RoundRow"; import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; @@ -37,6 +39,8 @@ interface State { lg: CfLogic; on: Handlers; readOnly: boolean; + /** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ + detail: DetailOptions; handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; @@ -48,6 +52,8 @@ interface State { suggest: number | null; parts: { head: HTMLElement; + formula: HTMLElement; + cells: HTMLElement; got: HTMLElement; vars: HTMLElement; bar: HTMLElement; @@ -201,6 +207,20 @@ function problems(st: State): string[] { return out; } +/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */ + +/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */ +const extra = (st: State): ExtraContext => ({ + lg: st.lg, + boxId: st.boxId, + detail: st.detail, + readOnly: st.readOnly, + place: (port, cell) => placePort(st, port, cell), + setOutput: (cell) => setOutput(st, cell), + patch: (make) => patchFunc(st, make), + used: (f) => usedCells(f), +}); + /* ------------------------------ 칸 위 표시 ------------------------------ */ function applyMarks(st: State): void { @@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise { preview: true, tabs: false, readOnly: st.readOnly, + conditions: conditionsOf(extra(st)), onChange: (b) => onBook(st, b), onSelect: (s) => { st.cell = s.칸; @@ -593,13 +614,14 @@ export function renderFuncPanel( lg: CfLogic, boxId: string, on: Handlers, - opts: { readOnly?: boolean } = {}, + opts: DetailOptions & { readOnly?: boolean } = {}, ): void { const old = states.get(host); const readOnly = !!opts.readOnly; if (old && old.boxId === boxId && old.readOnly === readOnly) { old.lg = lg; old.on = on; + old.detail = opts; const f = funcOf(old); if (old.handle && f && sheetJson(f) !== old.json) { old.json = sheetJson(f); @@ -615,6 +637,8 @@ export function renderFuncPanel( } const parts: State["parts"] = { head: el("div", { className: "m02-dfpanel-head" }), + formula: el("div"), + cells: el("div"), got: el("div", { className: "m02-dfpanel-row" }), vars: el("div", { className: "m02-dfpanel-vars" }), bar: el("div", { className: "m02-dfpanel-bar" }), @@ -626,6 +650,7 @@ export function renderFuncPanel( lg, on, readOnly, + detail: opts, handle: null, snap: {}, json: "", @@ -639,12 +664,22 @@ export function renderFuncPanel( drawGot(st); drawVars(st); drawBar(st); + drawFormula(st.parts.formula, extra(st)); + drawCells(st.parts.cells, extra(st)); applyMarks(st); }, }; states.set(host, st); host.classList.add("m02-dfpanel"); - host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + host.replaceChildren( + parts.head, + parts.formula, + parts.got, + parts.vars, + parts.bar, + parts.msg, + el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }), + ); parts.msg.hidden = true; st.redraw(); void mountSheet(st); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts index 5f08dcf38..f8efa089d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -9,8 +9,12 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { + gateConds, + makeGate, portsOut, reaches, + valueChoices, + type CfGateCond, type CfGateOp, type CfLogic, type CfPort, @@ -77,6 +81,12 @@ const TEXT = { ], WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"], Count: ["선", "Line"], + And: ["그리고", "and"], + Or: ["또는", "or"], + If: ["조건", "If"], + AddLine: ["+ 조건 줄", "+ Condition line"], + Join: ["잇는 말", "Join with"], + OutRange: ["범위 {n} 밖", "Outside the range {n}"], Range: ["{n} (범위)", "{n} (range)"], } as const; @@ -92,6 +102,7 @@ interface Ctx { linkId: string; readOnly: boolean; headers: string[]; + detail: DetailOptions; change(lg: CfLogic): void; } @@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract): HTML return out; } +/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */ +function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null { + const v = ctx.lg.boxes + .find((b) => b.id === p.box) + ?.input?.variables.find((x) => x.name === p.name); + return v?.type === "number" && v.range ? v.range : null; +} + +/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */ +function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] { + const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values }); + if (list?.length) { + const key = (v: string | number): string => `${typeof v}:${v}`; + const options = list.map((v) => ({ value: key(v), text: String(v) })); + return [ + select( + ctx, + options, + key(c.value), + (k) => { + const hit = list.find((v) => key(v) === k); + if (hit !== undefined) onPick(hit); + }, + tk("Value"), + ), + ]; + } + const range = rangeOf(ctx, c.from); + const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))]; + const n = Number(c.value); + if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) { + out.push( + el("span", { + className: "m02-dflink-warn", + text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`, + }), + ); + } + return out; +} + function gateEditor(ctx: Ctx, rule: Extract): HTMLElement[] { - const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); - return [ - el("p", { className: "m02-dflink-note", text: tk("GateHint") }), - row( - select( - ctx, - pillOptions(ctx), - toKey(rule.from), - (v) => put({ from: fromKey(v) }), - tk("Match"), + const g = gateConds(rule); + const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join)); + const edit = (i: number, patch: Partial): void => + put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c))); + const out: HTMLElement[] = [el("p", { className: "m02-dflink-note", text: tk("GateHint") })]; + g.conds.forEach((c, i) => { + out.push( + row( + el("span", { + className: "m02-dflink-label", + text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"), + }), + select( + ctx, + pillOptions(ctx), + toKey(c.from), + (v) => { + const from = fromKey(v); + edit(i, { + from, + value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0, + }); + }, + tk("Match"), + ), + select( + ctx, + GATE_OPS.map((o) => ({ value: o, text: o })), + c.op, + (v) => edit(i, { op: v as CfGateOp }), + tk("Kinds"), + ), + ...gateValue(ctx, c, (v) => edit(i, { value: v })), + ctx.readOnly || g.conds.length < 2 + ? "" + : createButton({ + label: tk("Remove"), + variant: "ghost", + size: "sm", + onClick: () => put(g.conds.filter((_, j) => j !== i)), + }), ), - select( - ctx, - GATE_OPS.map((o) => ({ value: o, text: o })), - rule.op, - (v) => put({ op: v as CfGateOp }), - tk("Kinds"), + ); + }); + if (!ctx.readOnly) { + const first = g.conds[0]; + out.push( + row( + createButton({ + label: tk("AddLine"), + variant: "ghost", + size: "sm", + onClick: () => + put([...g.conds, { from: first?.from ?? { box: "", name: "" }, op: "=", value: 0 }]), + }), + ...(g.conds.length > 1 + ? [ + el("span", { className: "m02-dflink-label", text: tk("Join") }), + select( + ctx, + [ + { value: "그리고", text: tk("And") }, + { value: "또는", text: tk("Or") }, + ], + g.join, + (v) => put(g.conds, v as "그리고" | "또는"), + tk("Join"), + ), + ] + : []), ), - numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")), - ), - ]; + ); + } + return out; } function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { @@ -323,7 +428,7 @@ function roundEditor( const DEFAULT: Record, (from: CfPort) => CfRule> = { lookup: () => ({ kind: "lookup", conds: [], col: "" }), - gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }), + gate: (from) => makeGate([{ from, op: "≥", value: 0 }]), scale: () => ({ kind: "scale", op: "×", value: 1 }), round: () => ({ kind: "round", places: 0, method: "반올림" }), }; @@ -345,14 +450,23 @@ export function renderLinkPanel( Array.isArray(table) && Array.isArray(table[0]) ? table[0].map((h) => String(h ?? "")).filter(Boolean) : []; - const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change }; + const ctx: Ctx = { + lg, + linkId, + readOnly: !!opts.readOnly, + headers, + detail: opts, + change: on.change, + }; const rule = link.rule; const kind: Kind = rule?.kind ?? "none"; const parts: HTMLElement[] = [ el("div", { className: "m02-dflink-head", children: [ - el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }), + el("strong", { + text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`, + }), el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }), ], }), From deabd978c00e4bc1c2362cbf7a7875a16ee38130 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:39:21 +0900 Subject: [PATCH 18/26] =?UTF-8?q?fix(M02):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=B2=98=EC=9D=8C=20=EB=B0=B0=EC=9C=A8?= =?UTF-8?q?=20=3D=20=EC=9D=BD=ED=9E=88=EB=8A=94=20=EC=B5=9C=EC=86=8C=200.6?= =?UTF-8?q?(=EB=84=98=EC=B9=98=EB=A9=B4=20=EC=99=BC=EC=AA=BD=20=EC=9C=84?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EC=83=81=EC=9E=90=EB=B6=80=ED=84=B0)?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=ED=95=9C=EB=88=88=EC=97=90=E3=80=8D=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=EB=8A=94=20=EC=A0=84=EC=B2=B4(0.25)=20=C2=B7?= =?UTF-8?q?=20=ED=81=AC=EA=B2=8C=20=EB=B3=B4=EA=B8=B0=EB=8A=94=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=200.9(PLAN=2036-3=20=EB=8D=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다이어그램 opts.fitAllMin(단추) · fitMin(저절로) 나눔 · Paper · Panel 이 넘김 - 시험 diagram_parts 8 + panel 2 통과 · DataFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 15 ++++++++++++--- .../DataFlow/M02_DF_Diagram_Parts.ts | 2 ++ M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 9 ++++++--- M02_MasterTemplete/DataFlow/M02_DF_Paper.ts | 3 +++ resources/tester/test_m02_df_diagram_parts.py | 17 ++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index ae84fe5e1..26dff53d5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -314,13 +314,22 @@ export function renderDiagram( applyView(); drawLines(); }; - const fit = (): void => { + /** 맞추기 — 저절로(처음 · 다시 맞춤)는 fitMin · 「한눈에」 단추는 fitAllMin(전체가 보이게) */ + const fit = (button = false): void => { const rects = lg.boxes.map((b) => { const n = world.querySelector(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`); return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; }); const r = view.getBoundingClientRect(); - Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin)); + Object.assign( + st, + fitView( + rects, + { w: r.width, h: r.height }, + 40, + button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin, + ), + ); applyView(); drawLines(); }; @@ -400,7 +409,7 @@ export function renderDiagram( sm("ZoomOut", () => setZoom(st.zoom / 1.2)), zoomLabel, sm("ZoomIn", () => setZoom(st.zoom * 1.2)), - sm("Fit", fit), + sm("Fit", () => fit(true)), ...(ro ? [] : [ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index 49052241f..a81da8fbc 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -124,6 +124,8 @@ export interface DiagramOpts { fitKey?: number; /** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */ fitMin?: number; + /** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */ + fitAllMin?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index de5d49f87..173a32cd0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -52,8 +52,10 @@ export interface FlowPanel { export const PANEL_MIN = 320; export const STRIP_WIDTH = 40; -/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */ -export const PANEL_FIT_MIN = 0.25; +/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */ +export const PANEL_FIT_MIN = 0.6; +/** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */ +export const PANEL_FIT_ALL_MIN = 0.25; const WIDTH_KEY = "m02.dfpanel.width"; const FOLD_KEY = "m02.dfpanel.folded"; @@ -94,8 +96,9 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane readOnly: opts.readOnly, onChange: opts.onChange, logics: opts.logics, - // 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림 + // 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90% fitMin: () => (full ? 0.9 : PANEL_FIT_MIN), + fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN), }); const { diagram, detail, summary, note, undo } = paper.parts; let width = storedWidth(readStore(WIDTH_KEY)); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts index 85146e7f4..228f3debf 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -55,6 +55,8 @@ export interface PaperOpts { logics?(key: string): Promise; /** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */ fitMin?(): number; + /** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */ + fitAllMin?(): number; } export interface Paper { @@ -307,6 +309,7 @@ export function createPaper(opts: PaperOpts): Paper { readOnly, fitKey, fitMin: opts.fitMin?.(), + fitAllMin: opts.fitAllMin?.(), }); drawPanel(); renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); diff --git a/resources/tester/test_m02_df_diagram_parts.py b/resources/tester/test_m02_df_diagram_parts.py index ae8248233..00e7301e8 100644 --- a/resources/tester/test_m02_df_diagram_parts.py +++ b/resources/tester/test_m02_df_diagram_parts.py @@ -43,7 +43,7 @@ const out = jobs.map((job) => { if (job.op === "deps") return m.depLines(job.lg).map((d) => ({ id: d.id, from: d.from, to: d.to })); if (job.op === "inTrace") return m.depLines(job.lg).map((d) => m.depInTrace(d, job.steps)); if (job.op === "rest") return job.breaks.map((b) => m.isRest(b)); - if (job.op === "fit") return m.fitView(job.rects, job.view); + if (job.op === "fit") return m.fitView(job.rects, job.view, 40, job.min); if (job.op === "value") return job.values.map((v) => m.showValue(v)); if (job.op === "end") return m.depEnd(job.a, job.tag); if (job.op === "restParts") { @@ -404,3 +404,18 @@ def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나(): assert got["boxes"] == ["brk"] assert got["links"] == ["l2", "l6"] assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"] + + +def test_패널_처음_배율은_읽히는_최소_넘치면_왼쪽_위부터(): + rects = [{"x": 40, "y": 40, "w": 200, "h": 120}, {"x": 1500, "y": 600, "w": 200, "h": 120}] + auto, whole = run( + [ + {"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.6}, + {"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.25}, + ] + ) + # 처음 — 0.6 아래로 안 내림 · 넘치면 왼쪽 위(첫 상자 - 여백)가 판 왼쪽 위 + assert auto["zoom"] == 0.6 + assert auto["panX"] == 0 and auto["panY"] == 0 + # 한눈에 — 전체가 들어가게 더 작게 + assert whole["zoom"] < 0.6 From 3ea0c0fdf24a5d96185c76e8a696559ba4059525 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:40:53 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refactor(m01):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=99=BC=EC=AA=BD=20=EB=B3=B4=EA=B8=B0=20=E2=80=94=20=EC=8B=9D?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=EB=A5=BC=20sub2=20formulaLines=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20import=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M01_MasterData_UI_LogicLab_FlowView.ts | 53 +++++++------------ 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts index 87b9d9de2..cdf1e7456 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_FlowView.ts * 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다 - * 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠. + * 식 두 줄 — sub2 `formulaLines`). 기본정보 · 시험 계산은 Detail 이 둠. * 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름. * 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름. * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8. @@ -15,7 +15,12 @@ import { type CfEvalResult, type CfValues, } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; -import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { + cellValuesOf, + formulaLines, + type FormulaLine, +} from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView"; +import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; import { flowRows, @@ -51,22 +56,6 @@ const GROUP_TITLE: Record = { plain: "Plain", }; -/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */ -interface FormulaLine { - name: string; - named: string; - valued: string; - branches?: { when: string; then: string; now: boolean }[]; -} -type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[]; -const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>( - "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", -); -const loadFormula = async (): Promise => { - const load = Object.values(FORMULA)[0]; - return load ? ((await load()).formulaLines ?? null) : null; -}; - const section = (title: string, body: HTMLElement[]): HTMLElement => el("section", { className: "m01-logic__section m01lab__box", @@ -182,26 +171,20 @@ export function buildFlowView( el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), ]); - const bodies = rows.funcs.map((f) => funcBlock(f, null)); + /** 함수 상자마다 식 두 줄(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: "m01-logic__muted", text: tf("NoFunc") })], ); host.replaceChildren(detail, text); - // sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함 - void loadFormula().then((fn) => { - if (!fn || !bodies.length) return; - const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func); - const merged = { ...ev.ports, ...ev.arrived } as CfValues; - text.replaceChildren( - text.firstElementChild!, - ...rows.funcs.map((f, i) => { - try { - return funcBlock(f, fn(boxes[i], merged)); - } catch { - return bodies[i]; - } - }), - ); - }); } From 7b90d28e2274df80e58e0d83f044dfedb4bbd2d7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:41:32 +0900 Subject: [PATCH 20/26] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=E3=80=8C=ED=9D=90=EB=A6=84=E3=80=8D=20=EC=B9=B8=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98=20=C2=B7=20=EC=8B=A0=EA=B7=9C=20GX=20=C2=B7?= =?UTF-8?q?=20=EB=8B=A4=EB=A5=B8=20=EB=A1=9C=EC=A7=81=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=ED=82=A4(PLAN=2036-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /save 줄의 흐름 = 꼴 검사(없는 알약 · 겹친 선 · 돌고 도는 선 422 · 빈 받는 자리 경고) → M02_DF_Compute 로 다시 셈 → 받는 칸 값 - 키 없는 새 흐름 줄 = 로직_자체.json 에 GX 다음 키 · 구분 자체 · 옛 식 칸은 빈 것 · 응답 {files, added, warnings} - GET /api/m01/logic/flow = 흐름 그대로 · 없으면 M02_DF_Convert.convert · 아직 없으면 빈 흐름 + 변환 준비 중 - /calc = 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · sums 노무비 · 재료비 · 경비 · 계 · outputs) · 없으면 옛 셈 - DataFlow 다른 로직 입력 · /logic-value · /design-inputs 가 마스터 키도(흐름 = 흐름 셈 · 옛 로직 = 옛 셈) · 서로 부르면 막음 - Compute 의 Router _num 은 모듈로 부름(서로 부름 풀기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/M01_MasterData_Router.py | 14 +- M01_MasterData/M01_MasterData_Store_Flow.py | 269 ++++++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Compute.py | 14 +- M02_MasterTemplete/DataFlow/M02_DF_Router.py | 14 +- resources/tester/test_m01_logic_flow_save.py | 240 ++++++++++++++++ 5 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Flow.py create mode 100644 resources/tester/test_m01_logic_flow_save.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 31501f7f8..de50a83e3 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -14,6 +14,7 @@ from pydantic import BaseModel 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_Make as make router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"]) @@ -221,6 +222,10 @@ 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.고름): return _call(store.calc, body.key, body.inputs, body.row, body.file) @@ -234,7 +239,14 @@ def post_text(body: CalcBody) -> dict: @router.post("/save") def post_save(body: SaveBody) -> dict: - return {"files": _call(store.save, [f.model_dump() for f in body.files])} + """「흐름」 줄은 검사 · 서버 다시 셈 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}.""" + return _call(flow.save, [f.model_dump() for f in body.files]) + + +@router.get("/logic/flow") +def get_logic_flow(key: str, file: str = "") -> dict: + """흐름 받기 — {flow, converted, notes} · 흐름이 있으면 그대로 · 없으면 변환(파일 안 바뀜).""" + return _call(flow.get_flow, file, key) @router.post("/logic/new") diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py new file mode 100644 index 000000000..eb2d329f7 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Flow.py @@ -0,0 +1,269 @@ +"""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 decimal import Decimal, InvalidOperation +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Make as make +from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc + +FLOW = "흐름" +KINDS = ("input", "function", "output") +CONVERT = "M02_MasterTemplete.DataFlow.M02_DF_Convert" +NOT_READY = "변환 준비 중" + + +# ── 검사(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: + """옛 로직 값 — 옛 셈 · 내보내는 알약 = 노무비 · 재료비 · 경비 · 계(돈 아닌 로직은 결과).""" + got = store.calc(key, {k: v for k, v in designs.items() if v is not None}) + 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`).""" + row = 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) -> Any: + """화면이 보낸 값 — 수 글은 수로(「3」 → 3) · 그 밖은 그대로.""" + 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) -> dict: + """흐름 셈 — 입력 = 「설계 때 정할 것」 이름 → 값.""" + given = {k: _value(v) for k, v in (inputs or {}).items()} + flow = {**flow, "key": key} + return dc.run(flow, dc._design_values(flow, given), load, flow.get("userElements") or [], [key]) + + +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 + got = run(flow, key, inputs) + 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) -> dict: + """{flow, converted, notes} — 흐름이 있으면 그대로 · 없으면 변환(파일은 안 바뀜).""" + row = _row(file, key) + if 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: + """새 흐름 로직 — 옛 식 칸은 빈 것으로(파일 틀 검사) · 출처 없으면 「자체」.""" + old = {"입력": [], "중간": [], "호표": [], "끝수": None} + return {**old, **row, "출처": row.get("출처") or "자체"} + + +def save(batch: list[dict]) -> dict: + """`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로.""" + 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 + 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) + ch["row"] = row + name = row.get("이름") or ch.get("key") or "새 로직" + warns += [f"「{name}」 {w}" for w in _redo(row, str(ch.get("key") or ""))] + if part.get("file") == make.OWN and not make._own_path().is_file(): + make._make_file() + fresh = True + part["version"] = part.get("version") or store.read(make.OWN)[1] + before = {p["file"]: _keys(p["file"]) for p in batch} + try: + files = store.save(batch) + except store.StoreError: + if fresh: + make._own_path().unlink(missing_ok=True) + raise + added = [{"file": f, "key": k} for f, old in before.items() for k in _keys(f) if k not in old] + return {"files": files, "added": added, "warnings": warns} + + +def _keys(file: str) -> list[str]: + try: + data = store.read(file)[0] + except store.StoreError: + return [] + return [str(r.get("키")) for r in data.get(store.items_key(data)) or []] diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index 331a2a151..e3d8cf96b 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -23,8 +23,10 @@ from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal from typing import Any, Callable from common_util import common_util_spreadsheet as spreadsheet +from M01_MasterData import ( + M01_MasterData_Router as m01, +) # _num — M01 Router 가 이 파일을 부름(서로 부름) from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData.M01_MasterData_Router import _num from M02_MasterTemplete.DataFlow import M02_DF_Values as dv _CELL = re.compile(r"^([A-Z]+)(\d+)$") @@ -188,6 +190,12 @@ def _logic_ports(lg: dict, box: dict, given: dict, ports: dict, errors: list, ct if link.get("to", {}).get("box") == box["id"]: src = link.get("from") or {} designs[link["to"].get("name")] = ports.get(src.get("box"), {}).get(src.get("name")) + if callable( + other.get("calc") + ): # 옛 마스터 로직(흐름 없음) — 옛 셈 · 노무비 · 재료비 · 경비 · 계 + result = other["calc"](designs) + errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]] + return result["outputs"] stack = [*ctx["stack"], key] result = run(other, _design_values(other, designs), ctx["load"], ctx["local"], stack) errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]] @@ -559,7 +567,9 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N outputs[b["id"]] = arrive(b).get(OUTPUT_PORT) if funcs: # 이 물결의 함수 시트를 한 번에(시트마다 통합문서 하나 — Eval 과 같음) books = [ - _num({"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]}) + m01._num( + {"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]} + ) for f in funcs ] try: diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Router.py b/M02_MasterTemplete/DataFlow/M02_DF_Router.py index c7375090c..3892588d9 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Router.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Router.py @@ -36,6 +36,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Flow as sflow from M01_MasterData.M01_MasterData_Router import _num from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc from M02_MasterTemplete.DataFlow import M02_DF_Values as dv @@ -184,7 +185,10 @@ def user_edit(op: str, element: dict | None, uid: str | None) -> dict: # ── 저장 · 계산(`M02_DF_Compute`) ───────────────────────────────────── def _load(key: str) -> dict: - """다른 로직 입력이 부르는 로직 — 내 것(`_data`) · 견본(`_sample`).""" + """다른 로직 입력이 부르는 로직 — 내 것(`_data`) · 견본(`_sample`) · 마스터 키(GX · GF …)는 + M01 계산 길(흐름이면 흐름 · 옛 로직이면 옛 셈 · 정함 11).""" + if not _KEY.match(key or "") and _sample(key) is None: + return sflow.load(key) logic, _ = _read(key) return {**logic, "key": key} @@ -216,6 +220,9 @@ def save(logic: dict) -> dict: def logic_value(key: str, designs: dict) -> dict: """다른 로직 값 — 저장 로직 · 견본을 받은 설계 값(없으면 예시값)으로 셈 · 출력 이름 → 값.""" other = _load(key) + if callable(other.get("calc")): # 옛 마스터 로직 — 노무비 · 재료비 · 경비 · 계 + got = other["calc"](designs) + return {"key": key, "outputs": got["outputs"], "cost": {}, "errors": got["errors"]} got = dc.run(other, dc._design_values(other, designs), _load, _local(other), [key]) names = {b["id"]: b.get("name") for b in other.get("boxes") or []} outputs = {names[i]: v for i, v in got["outputs"].items() if i in names} @@ -224,7 +231,10 @@ def logic_value(key: str, designs: dict) -> dict: def design_inputs(key: str) -> dict: """설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(선이 없을 때) · CfDesign 꼴.""" - return {"key": key, "designs": dc.design_inputs(_load(key))} + other = _load(key) + if callable(other.get("calc")): + return {"key": key, "designs": sflow.old_designs(key)} + return {"key": key, "designs": dc.design_inputs(other)} # ── 길 ──────────────────────────────────────────────────────────────── diff --git a/resources/tester/test_m01_logic_flow_save.py b/resources/tester/test_m01_logic_flow_save.py new file mode 100644 index 000000000..312664317 --- /dev/null +++ b/resources/tester/test_m01_logic_flow_save.py @@ -0,0 +1,240 @@ +# ruff: noqa: E501 +"""흐름도 적용(PLAN 36-4 서버 · 정함 1 · 2 · 4 · 10 · 11 · 12) — 로직 줄 「흐름」 칸. + +못박는 것(마스터 사본 폴더에서만 · 정본은 안 바뀜) + - `/save` 줄에 실은 흐름 = 검사 → 서버 다시 셈(받는 칸에 값) → 파일에 씀 · 다시 읽으면 그대로 + - 흐름 꼴이 틀리면(없는 알약 · 돌고 도는 선) 422 · 빈 받는 자리는 경고 + - `/calc` 갈래 — 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · 노무비 · 재료비 · 경비 · 계) · 없으면 옛 셈 + - 새 줄(키 없음) + 흐름 → 자체 파일 GX 다음 키 · 구분 자체 · 본떠 만들기는 흐름도 함께 + - `GET /logic/flow` — 흐름 있으면 그대로 · 없으면 변환(준비 전이면 빈 흐름 + 「변환 준비 중」) + - DataFlow 다른 로직 값 · 설계 입력이 마스터 키도 받음(흐름 · 옛 로직) · 서로 부르면 막음 + - 시스템 관리자가 아니면 403 +""" + +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import shutil +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import require_system_admin, verify_session +from M01_MasterData import M01_MasterData_Router as m01_router +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Flow as sflow +from M02_MasterTemplete.DataFlow import M02_DF_Router as df + +REAL = store.FOLDER +KEY = "GF000219" # 13-4-1 메쌓기 — 흐름을 얹어 볼 정본 로직 +OLD = "GF000160" # 12-4 합판거푸집 — 흐름 없는 옛 로직 +OWN = "로직_자체.json" + + +def _flow(name: str = "넓이 품") -> dict: + """길이 × 폭 × 1000 → 노무비 — 입력 상자(변수 둘) · 함수 상자 · 출력 상자.""" + sheet = {"id": "s-fn", "이름": "넓이", "칸": {"A2": {"값": "길이"}, "A3": {"값": "폭"}, + "A4": {"값": "금액"}, "B4": {"식": "B2*B3*1000"}}} # fmt: skip + return { + "key": None, "name": name, "resultUnit": "원/㎡", "userElements": [], + "boxes": [ + {"id": "in", "kind": "input", "name": "치수", "color": "", "unit": "-", "x": 0, "y": 0, + "input": {"mode": "variable", "elements": [], "variables": [ + {"name": "길이", "type": "number", "unit": "m", "example": 2}, + {"name": "폭", "type": "number", "unit": "m", "example": 3}]}}, + {"id": "fn", "kind": "function", "name": "넓이 금액", "color": "", "unit": "원", "x": 0, "y": 0, + "func": {"sheet": sheet, "inputs": [{"port": "길이", "cell": "B2"}, {"port": "폭", "cell": "B3"}], + "variables": [], "output": {"cell": "B4", "name": "금액", "unit": "원"}}}, + {"id": "out", "kind": "output", "name": "노무비", "color": "", "unit": "원/㎡", "x": 0, "y": 0, + "output": {"cost": "노무비", "unit": "원/㎡"}}, + ], + "links": [ + {"id": "l1", "from": {"box": "in", "name": "길이"}, "to": {"box": "fn", "name": "길이"}}, + {"id": "l2", "from": {"box": "in", "name": "폭"}, "to": {"box": "fn", "name": "폭"}}, + {"id": "l3", "from": {"box": "fn", "name": "금액"}, "to": {"box": "out", "name": "값"}}, + ], + } # fmt: skip + + +def _hashes(folder: Path) -> dict[str, str]: + return {p.name: hashlib.sha1(p.read_bytes()).hexdigest() for p in folder.glob("*.json")} + + +@pytest.fixture +def folder(tmp_path: Path, monkeypatch) -> Path: + master = tmp_path / "master" + master.mkdir() + for path in REAL.glob("*.json"): + shutil.copy(path, master / path.name) + monkeypatch.setattr(store, "FOLDER", master) + monkeypatch.setattr(df, "DATA", tmp_path / "_data") + return master + + +@pytest.fixture +def client(folder: Path) -> TestClient: + app = FastAPI() + app.include_router(m01_router.router) + app.include_router(df.router) + return TestClient(app) + + +def _get(client: TestClient, url: str, want: int = 200, **params) -> dict: + res = client.get(url, params=params) + assert res.status_code == want, res.text + return res.json() + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dict: + got = _get(client, "/api/m01/logic", key=key) + row = {**got["logic"], "흐름": flow} + part = {"file": got["file"], "version": got["version"], + "changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip + return _post(client, "/api/m01/save", {"files": [part]}, want) + + +def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None: + before = _hashes(REAL) + done = _save_flow(client, KEY, _flow()) + assert done["warnings"] == [] and done["added"] == [] + saved = _get(client, "/api/m01/logic", key=KEY)["logic"]["흐름"] + cells = saved["boxes"][1]["func"]["sheet"]["칸"] + assert ( + Decimal(str(cells["B2"]["값"])) == 2 and Decimal(str(cells["B3"]["값"])) == 3 + ) # 서버가 넣은 값 + assert "key" not in saved + got = _get(client, "/api/m01/logic/flow", key=KEY) + assert got["converted"] is False and got["notes"] == [] and got["flow"]["key"] == KEY + assert [b["id"] for b in got["flow"]["boxes"]] == ["in", "fn", "out"] + assert _hashes(REAL) == before # 사본 밖(정본)은 안 바뀜 + + +def test_흐름_꼴_검사_422_와_빈_자리_경고(client: TestClient) -> None: + bad = _flow() + bad["links"][0]["from"]["name"] = "없는알약" + assert "없는 알약" in str(_save_flow(client, KEY, bad, 422)) + loop = _flow() + loop["boxes"].append({**copy.deepcopy(loop["boxes"][1]), "id": "fn2", "name": "되돌이"}) + loop["links"] += [ + {"id": "l4", "from": {"box": "fn", "name": "금액"}, "to": {"box": "fn2", "name": "길이"}}, + {"id": "l5", "from": {"box": "fn2", "name": "금액"}, "to": {"box": "fn", "name": "폭"}}, + ] + loop["links"] = [x for x in loop["links"] if x["id"] != "l2"] + assert "돌고 도는" in str(_save_flow(client, KEY, loop, 422)) + empty = _flow() + empty["links"] = [x for x in empty["links"] if x["id"] != "l2"] + warns = _save_flow(client, KEY, empty)["warnings"] + assert any("넓이 금액 · 폭" in w for w in warns), warns + + +def test_계산_갈래_흐름이면_흐름_셈_아니면_옛_셈(client: TestClient) -> None: + _save_flow(client, KEY, _flow()) + got = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {"길이": 4, "폭": "5"}}) + assert got["flow"] is True and got["ok"] is True + assert {k: Decimal(str(v)) for k, v in got["sums"].items()} == { + "노무비": 20000, "재료비": 0, "경비": 0, "계": 20000} # fmt: skip + assert ( + got["outputs"][0]["name"] == "노무비" and Decimal(str(got["outputs"][0]["value"])) == 20000 + ) + plain = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}}) # 예시값 2 × 3 + assert Decimal(str(plain["sums"]["계"])) == 6000 + row = {**_get(client, "/api/m01/logic", key=KEY)["logic"], "흐름": _flow()} # 저장 전 줄 + row["흐름"]["boxes"][1]["func"]["sheet"]["칸"]["B4"] = {"식": "B2*B3*10"} + draft = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}, "row": row}) + assert Decimal(str(draft["sums"]["계"])) == 60 + old = _post(client, "/api/m01/calc", {"key": OLD, "inputs": {}}) + assert "flow" not in old # 흐름 없는 로직은 옛 셈 그대로 + + +def test_신규_흐름_로직은_자체_GX_복사는_흐름도(client: TestClient, folder: Path) -> None: + version = store.read(OWN)[1] if (folder / OWN).is_file() else "" + row = {"키": "", "이름": "시험 흐름 로직", "결과단위": "원/㎡", "출처": "", "구분": "산림품셈", + "흐름": _flow("시험 흐름 로직")} # fmt: skip + part = {"file": OWN, "version": version, "changes": [{"op": "add", "row": row}]} + done = _post(client, "/api/m01/save", {"files": [part]}) + assert len(done["added"]) == 1 and done["added"][0]["file"] == OWN + key = done["added"][0]["key"] + assert key.startswith("GX") + saved = _get(client, "/api/m01/logic", key=key)["logic"] + assert saved["구분"] == "자체" and saved["흐름"]["name"] == "시험 흐름 로직" + assert ( + Decimal(str(_post(client, "/api/m01/calc", {"key": key, "inputs": {}})["sums"]["계"])) + == 6000 + ) + made = _post(client, "/api/m01/logic/copy", {"key": key, "이름": "복사한 흐름"}) + assert made["key"].startswith("GX") and made["key"] != key + copied = _get(client, "/api/m01/logic/flow", key=made["key"]) + assert copied["converted"] is False and len(copied["flow"]["boxes"]) == 3 + bad = {"file": OWN, "version": store.read(OWN)[1], "changes": [{"op": "add", "row": row}]} + bad["changes"][0]["row"] = {**row, "흐름": {"boxes": "아님"}} + _post(client, "/api/m01/save", {"files": [bad]}, 422) + + +def test_흐름_없는_로직_받기는_변환(client: TestClient) -> None: + got = _get(client, "/api/m01/logic/flow", key=OLD) + assert got["converted"] is True and got["flow"]["key"] == OLD + if importlib.util.find_spec(sflow.CONVERT) is None: # sub7 변환이 오기 전 + assert got["notes"] == [sflow.NOT_READY] and got["flow"]["boxes"] == [] + _get(client, "/api/m01/logic/flow", 404, key="GX999999") + + +def test_다른_로직_값_마스터_키_흐름_옛_순환(client: TestClient) -> None: + _save_flow(client, KEY, _flow()) + got = _post(client, "/api/m02/df/logic-value", {"key": KEY, "designs": {"길이": 10}}) + assert Decimal(str(got["outputs"]["노무비"])) == 30000 and got["errors"] == [] + designs = _get(client, "/api/m02/df/design-inputs", key=KEY)["designs"] + assert [d["port"] for d in designs] == ["길이", "폭"] + given = {k: str(v) for k, v in store.cm.시험입력(store.logic(OLD)["logic"]).items()} + old = _post(client, "/api/m02/df/logic-value", {"key": OLD, "designs": given}) + want = _post(client, "/api/m01/calc", {"key": OLD, "inputs": given}) + assert old["outputs"] == want["sums"] and set(old["outputs"]) == { + "노무비", + "재료비", + "경비", + "계", + } + names = [d["port"] for d in _get(client, "/api/m02/df/design-inputs", key=OLD)["designs"]] + assert names == [x["이름"] for x in store.logic(OLD)["logic"].get("입력") or []] + # 흐름 안 다른 로직 입력이 옛 로직(OLD) 계 를 받아 노무비로 · 자기 자신을 부르면 막힘 + flow = _flow() + pills = [{"name": k, "unit": "-"} for k in given] + ref = {"key": OLD, "outputs": [{"name": "계", "unit": "원"}], "designs": pills} + flow["boxes"].append({"id": "lg", "kind": "input", "name": "거푸집", "color": "", "unit": "-", + "x": 0, "y": 0, "input": {"mode": "logic", "elements": [], "variables": [], + "logic": ref}}) # fmt: skip + flow["boxes"].append({**copy.deepcopy(flow["boxes"][2]), "id": "out2", "name": "경비"}) + flow["boxes"][-1]["output"]["cost"] = "경비" + flow["links"].append( + {"id": "l9", "from": {"box": "lg", "name": "계"}, "to": {"box": "out2", "name": "값"}} + ) + _save_flow(client, KEY, flow) + mixed = _post(client, "/api/m01/calc", {"key": KEY, "inputs": given}) + assert Decimal(str(mixed["sums"]["경비"])) == Decimal(str(want["sums"]["계"])) + ref["key"] = KEY + self_call = _save_flow(client, KEY, flow)["warnings"] + assert any("서로를 부름" in w for w in self_call), self_call + + +def test_시스템_관리자가_아니면_403(folder: Path) -> None: + app = FastAPI() + app.include_router( + m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] + ) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} + client = TestClient(app) + before = _hashes(folder) + assert client.get("/api/m01/logic/flow", params={"key": KEY}).status_code == 403 + assert client.post("/api/m01/save", json={"files": []}).status_code == 403 + assert client.post("/api/m01/calc", json={"key": KEY, "inputs": {}}).status_code == 403 + assert _hashes(folder) == before From bad349783be7a68918b4aecb92f15392c9094f08 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:42:50 +0900 Subject: [PATCH 21/26] =?UTF-8?q?feat(m02-df):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=8C=90=20=EC=9D=B8=EB=A0=A5=20=C2=B7=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A0=EA=B0=80=EC=A0=84=EB=A0=A5=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B0=BE=EA=B8=B0(=EC=9D=B4=EB=A6=84=20=EB=98=90?= =?UTF-8?q?=EB=8A=94=20=ED=82=A4=20=EA=B0=99=EC=9D=8C)=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel.ts | 88 ++++++++++- .../DataFlow/M02_DF_InputPanel_Build.ts | 46 +++++- .../DataFlow/M02_DF_InputPanel_Rule.ts | 138 ++++++++++++++++++ resources/tester/test_m02_df_input_logic.py | 24 +++ 4 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index a6a67a002..cce05141a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -24,6 +24,9 @@ import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Ap import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model"; import { GROUPS, + RULE_GROUPS, + matchesRow, + removeElementNamed, M01_OF, addElement, elementOf, @@ -36,6 +39,7 @@ import { type M01Row, } 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"; @@ -63,6 +67,10 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], Variable: ["변수", "Variable"], + ModeFixed: ["고정", "Fixed"], + ModeRule: ["조건 찾기", "Find by rule"], + RuleTag: ["조건 찾기", "Rule"], + RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"], Logic: ["다른 로직", "Another logic"], 인력: ["인력", "Labor"], 재료: ["재료", "Material"], @@ -193,10 +201,52 @@ function renderGroup( on: { change(lg: CfLogic): void }, ): void { const chosen = el("div", { className: "m02-dfip__chosen" }); - const find = el("div", { className: "m02-dfip__find" }); - host.replaceChildren(chosen, find, newForm(lg, box, group, on)); + const body = el("div", { className: "m02-dfip__stack" }); + let mode: "fixed" | "rule" = "fixed"; + const draw = (): void => { + if (mode === "rule") + renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on); + else { + const find = el("div", { className: "m02-dfip__find" }); + body.replaceChildren(find, newForm(lg, box, group, on)); + void drawFind(find, lg, box, group, on); + } + }; + // 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만 + const chips = el("div", { className: "m02-dfip__chips" }); + const drawChips = (): void => + chips.replaceChildren( + ...(["fixed", "rule"] as const).map((m) => + createButton({ + label: m === "fixed" ? t("ModeFixed") : t("ModeRule"), + variant: mode === m ? "filled" : "pill", + size: "sm", + onClick: () => ((mode = m), drawChips(), draw()), + }), + ), + ); + host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body); drawChosen(chosen, lg, box, group, on); - void drawFind(find, lg, box, group, on); + if (RULE_GROUPS.includes(group)) drawChips(); + draw(); +} + +/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */ +async function rulePreview( + group: CfGroup, + field: "이름" | "키", + value: unknown, +): Promise<{ found: number; names: string[]; unit: string }> { + if (value === null || value === undefined || value === "") + return { found: 0, names: [], unit: "" }; + const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", ""); + const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value)); + const units = new Set(hit.map((r) => elementOf(group, r).unit)); + return { + found: hit.length, + names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")), + unit: units.size === 1 ? [...units][0] : "", + }; } /** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ @@ -214,26 +264,48 @@ function drawChosen( ...(els.length ? els.map((e) => { const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") }); - values.set(e.ref, val); + const rule = e.mode === "rule"; + if (!rule) values.set(e.ref, val); return el("div", { className: "m02-dfip__item", children: [ - el("span", { className: "m02-dfip__name", text: e.name }), + el("span", { + className: "m02-dfip__name", + children: [ + ...(rule ? [el("span", { className: "m02-dfip__tag", text: t("RuleTag") })] : []), + el("strong", { text: ` ${e.name}` }), + ...(rule && e.rule + ? [ + el("div", { + className: "m02-dfip__muted", + text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }), + }), + ] + : []), + ], + }), val, el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), createButton({ label: t("Remove"), variant: "ghost", size: "sm", - onClick: () => on.change(removeElement(lg, box.id, e.ref)), + onClick: () => + on.change( + rule + ? removeElementNamed(lg, box.id, e.name) + : removeElement(lg, box.id, e.ref), + ), }), ], }); }) : [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]), ); - if (!els.length) return; - const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + const items = els + .filter((e) => e.mode !== "rule") + .map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + if (!items.length) return; void apiValues(items, lg.userElements).then( (answers) => answers.forEach((a) => { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index 3125278d4..b98570887 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -162,16 +162,21 @@ export function matches( return rule.op === "같음" ? target === v : target.includes(v); } -/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */ +/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */ export function catchNote( found: number, firstName = "", + noun = "재료", ): { kind: "none" | "one" | "many"; text: string } { - if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" }; - if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` }; + if (found === 0) + return { + kind: "none", + text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`, + }; + if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` }; return { kind: "many", - text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`, + text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`, }; } @@ -566,3 +571,36 @@ export function reconcileUnit(lg: CfLogic, id: string): CfLogic { ? lg : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; } + +/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(옛 로직 LB:{직종} · EQ:{기계} 꼴) ── */ + +/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */ +export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"]; + +export type GroupRuleField = "이름" | "키"; + +/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */ +export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean { + const v = squash(value); + if (!v) return false; + const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]]; + return targets.some((x) => squash(x) === v); +} + +/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */ +export function groupRuleElement( + name: string, + field: GroupRuleField, + from: CfMaterialRule["from"], +): CfElementRef { + const rule = { field, op: "같음", from } as unknown as CfMaterialRule; + return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule }; +} + +/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null { + if (!el.name) return "자리 이름을 적어 주세요"; + if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; + if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; + return null; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts new file mode 100644 index 000000000..98d23ec15 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts @@ -0,0 +1,138 @@ +/* ============================================================================= + * M02_DF_InputPanel_Rule.ts + * 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음 + * (옛 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래 + * 선택 자리(choice)는 재료만 — 이 판에 없음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model"; +import { + catchNote, + checkGroupRule, + groupRuleElement, + variablePills, + type GroupRuleField, +} from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Help: [ + "변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄", + "Catches the row whose name (or key) equals a variable pill's value", + ], + SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], + Field: ["같아야 하는 칸", "Column"], + FieldName: ["이름", "Name"], + FieldKey: ["키(원문번호)", "Key (source no.)"], + Pill: ["변수 알약", "Variable pill"], + NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], + Preview: ["지금 잡히는 줄", "Rows caught now"], + Put: ["넣기", "Add"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; + +/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */ +export type RulePreview = ( + group: CfGroup, + field: GroupRuleField, + value: unknown, +) => Promise<{ found: number; names: string[]; unit: string }>; + +/** 조건 찾기 요소 추가 판 */ +export function renderGroupRule( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, + preview: RulePreview, + on: { change(lg: CfLogic): void }, +): void { + const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? []; + const pills = variablePills(lg); + const name = createInputField({ + type: "text", + label: t("SlotName"), + compact: true, + placeholder: group === "인력" ? "예: 직종" : "예: 기종", + }); + const field = createSelectField({ + label: t("Field"), + options: [ + { value: "이름", text: t("FieldName") }, + { value: "키", text: t("FieldKey") }, + ], + value: "이름", + compact: true, + onChange: () => void refresh(), + }); + const pill = createSelectField({ + label: t("Pill"), + options: pills.map((p, i) => ({ + value: String(i), + text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, + })), + value: "0", + compact: true, + onChange: () => void refresh(), + }); + const note = el("p", { className: "m02-dfip__note" }); + const build = (): CfElementRef => + groupRuleElement( + name.input.value, + field.select.value as GroupRuleField, + pills[Number(pill.select.value)]?.port ?? { box: "", name: "" }, + ); + let seq = 0; + const refresh = async (): Promise => { + const mine = ++seq; + const value = pills[Number(pill.select.value)]?.example; + note.textContent = `${t("Preview")}: …`; + try { + const p = await preview(group, field.select.value as GroupRuleField, value); + if (mine !== seq) return; + const n = catchNote(p.found, p.names[0], "줄"); + note.textContent = `${t("Preview")}: ${n.text}`; + note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; + } catch { + if (mine === seq) note.textContent = t("Failed"); + } + }; + const add = createButton({ + label: t("Put"), + size: "sm", + onClick: () => { + const cand = build(); + const why = checkGroupRule(cand, els); + name.setError(why ?? undefined); + if (why) return void showToast(why, "error"); + // 잡히는 줄이 하나면 그 단위를 알약 단위로 + void preview( + group, + field.select.value as GroupRuleField, + pills[Number(pill.select.value)]?.example, + ).then( + (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), + () => on.change(addEl(lg, cand)), + ); + }, + }); + host.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("Help") }), + name.root, + ...(pills.length + ? [field.root, pill.root, note, add] + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]), + ); + if (pills.length) void refresh(); +} diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index cc91ee13d..1b5dd1765 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -228,3 +228,27 @@ def test_자기_자신과_돌고_도는_로직은_막음(): assert call("wouldCycle", "SELF", "C", graph) is False assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 + + +def test_조건_찾기는_인력_기계_유가전력에_쓰고_이름_또는_키가_같은_줄(): + labor = {"키": "LB000033", "원문번호": "1033", "이름": "석공"} + assert call("matchesRow", labor, "이름", "석공") is True + assert call("matchesRow", labor, "이름", "석 공") is True # 띄어쓰기는 가리지 않음 + assert call("matchesRow", labor, "이름", "할석공") is False # 같음이라 일부만 맞아선 안 잡음 + assert call("matchesRow", labor, "키", "lb000033") is True + assert call("matchesRow", labor, "키", "1033") is True # 원문번호도 키로 침 + assert call("matchesRow", labor, "이름", "") is False # 알약 값이 비면 안 잡음 + + +def test_조건_찾기_요소와_검사(): + port = {"box": "b2", "name": "직종"} + el = call("groupRuleElement", "노임", "이름", port) + assert ( + el["mode"] == "rule" and el["ref"] == "" and el["name"] == "노임" and el["slot"] == "노임" + ) + assert el["rule"] == {"field": "이름", "op": "같음", "from": port} + assert call("checkGroupRule", el, []) is None + assert call("checkGroupRule", {**el, "name": ""}, []) == "자리 이름을 적어 주세요" + assert call("checkGroupRule", el, [el]) == "같은 이름의 알약이 이미 있음" + no_pill = call("groupRuleElement", "노임", "이름", {"box": "", "name": ""}) + assert "변수 알약" in call("checkGroupRule", no_pill, []) From abbf61971a50e6ff80f3f933b98a6eda72a6c77e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:43:11 +0900 Subject: [PATCH 22/26] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=EB=8F=84=20=ED=8C=A8=EB=84=90=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=A0=EA=B7=9C=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로직을 열면 오른쪽에 흐름도 패널(mountFlowPanel) — 줄의 흐름 · 없으면 흐름 받기(/api/m01/logic/flow) · 없으면 빈 흐름 · 변환 알림 한 줄 - 패널 고침 → 줄의 흐름으로 drafts(캐시) · [저장] 한 번 · [고친 것 버리기] 함께 · 흐름 로직이면 왼쪽 보기(buildFlowView) 다시 - 패널 너비 = 패널이 알린 px(두 칸 끌기 막대는 숨김) - 「신규 로직」 — 기본정보 칸(이름 · 원문번호 · 결과 단위 · 출처) + 빈 흐름 → 로직_자체 새 줄 · 저장 뒤 새 키 열기 - 보기만 = 시스템 관리자가 아니면 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 97 ++++++++- .../M01_MasterData_UI_LogicLab_Basic.ts | 105 ++++++++++ .../M01_MasterData_UI_LogicLab_Flow.css | 14 ++ .../M01_MasterData_UI_LogicLab_Flow.ts | 188 +++++++++++++++++- 4 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 8bef8f3c8..caa1bf19c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -19,6 +19,7 @@ import { import { ApiError, fetchLogic, + fetchLogicFiles, fetchLogics, fetchLogicSubs, saveFiles, @@ -37,7 +38,17 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail" 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 { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow"; +import { + buildSplit, + emptyFlow, + fetchFlow, + mountLogicFlow, + tf, + type LogicFlow, +} from "./M01_MasterData_UI_LogicLab_Flow"; +import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; +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"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -62,8 +73,14 @@ interface Opened extends Draft { lastText?: TextAnswer; ownHost: HTMLElement; values: Record; + /** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */ + flowHost: HTMLElement; } +/** 줄의 흐름(있으면 흐름도가 정본) */ +const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; +const OWN_FILE = "로직_자체.json"; + const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); @@ -90,6 +107,11 @@ 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 editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억 @@ -210,14 +232,54 @@ export async function mountM01LogicLab( ownHost: current.ownHost, onChange: touch, calcHost: calc, + flowHost: flowOf(current.row) ? current.flowHost : undefined, }); + flowNow?.redrawLeft(); + }; + + /** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */ + const startFlow = async (o: Opened): Promise => { + const row = o.row; + if (!row) return; + split.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); + const flow = own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)); + if (opened !== o) return; + try { + const made = await mountLogicFlow({ + split, + flow: clone(flow), + 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); + o.row.흐름 = lg; + touch(); + if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로 + }, + }); + if (opened !== o) return made.destroy(); + flowNow = made; + } catch (e) { + split.right.replaceChildren( + el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }), + ); + } }; const show = (next: Opened | null): void => { + flowNow?.destroy(); + flowNow = null; opened = next; errors.hidden = true; drawEditor(); drawCalc(); + if (next?.row) void startFlow(next); }; const open = async (id: string, sub: string, key: string): Promise => { @@ -233,6 +295,7 @@ export async function mountM01LogicLab( text: null, ownHost: ownHost(), values: {}, + flowHost: el("div"), }); return; } @@ -252,6 +315,7 @@ export async function mountM01LogicLab( text: null, ownHost: ownHost(), values: {}, + flowHost: el("div"), }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); @@ -332,10 +396,16 @@ export async function mountM01LogicLab( try { await saveFiles([...byFile.values()]); const was = opened; + const before = new Set(allLogics.map((x) => x.키)); drafts = {}; await reload(); showToast(tx("Save_Done"), "success"); - if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); + const made = + was?.row && !was.row.키 + ? allLogics.find((x) => !before.has(x.키) && x.이름 === was.row?.이름) + : undefined; + if (made) await open(logicId(made.구분, made.키), made.구분, made.키); + else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); else show(null); } catch (error) { failed(error); @@ -360,6 +430,28 @@ export async function mountM01LogicLab( } }; + const onNew = async (): Promise => { + const units = [...new Set(allLogics.map((x) => x.결과단위).filter(Boolean))]; + const info = await askBasicInfo(units.length ? units : ["원"]); + if (!info) return; + try { + const own = (await fetchLogicFiles()).find((f) => f.file === OWN_FILE); + const row: LogicRow = { + 키: "", + ...info, + 입력: [], + 중간: [], + 흐름: await emptyFlow(info.이름, info.결과단위), + }; + const id = logicId("자체", `새${Date.now()}`); + drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row }; + persist(); + await open(id, "자체", ""); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), @@ -386,6 +478,7 @@ export async function mountM01LogicLab( back, el("h2", { text: tx("Title") }), pending, + createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }), copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts new file mode 100644 index 000000000..e4783675e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Basic.ts + * 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null + * 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; + +const TEXT = { + Title: ["신규 로직", "New logic"], + Name: ["이름", "Name"], + NamePh: ["예: 돌쌓기 메쌓기(인력)", "e.g. dry stone wall (labour)"], + Number: ["원문번호", "Source number"], + NumberPh: ["예: 13-4-1 · 없으면 비움", "e.g. 13-4-1 · may be empty"], + Unit: ["결과 단위", "Result unit"], + Source: ["출처", "Source"], + Ok: ["만들기", "Create"], + Cancel: ["취소", "Cancel"], + Close: ["닫기", "Close"], + NeedName: ["이름을 넣어 주세요", "Enter a name"], +} as const; + +const tb = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +export interface BasicInfo { + 이름: string; + 원문번호: string; + 결과단위: string; + 출처: string; +} + +/** 기본정보 묻기 — units = 결과 단위 후보(마스터 로직의 결과단위) */ +export function askBasicInfo(units: string[]): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (v: BasicInfo | null): void => { + if (done) return; + done = true; + resolve(v); + }; + const name = createInputField({ label: tb("Name"), placeholder: tb("NamePh"), compact: true }); + const number = createInputField({ + label: tb("Number"), + placeholder: tb("NumberPh"), + compact: true, + }); + const unit = createSelectField({ + label: tb("Unit"), + options: units.map((u) => ({ value: u, text: u })), + value: units[0] ?? "원", + compact: true, + }); + const source = createInputField({ label: tb("Source"), value: "자체", compact: true }); + const why = el("p", { className: "m01-logic__muted", attrs: { role: "alert" } }); + const modal = openModal({ + title: tb("Title"), + closeLabel: tb("Close"), + beforeClose: () => { + finish(null); + return true; + }, + mount: (body, close) => { + const ok = (): void => { + const 이름 = name.input.value.trim(); + if (!이름) { + why.textContent = tb("NeedName"); + return void name.input.focus(); + } + finish({ + 이름, + 원문번호: number.input.value.trim(), + 결과단위: unit.select.value, + 출처: source.input.value.trim() || "자체", + }); + close(); + }; + name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok()); + body.append( + name.root, + number.root, + unit.root, + source.root, + why, + el("div", { + className: "m01-logic__bar", + children: [ + createButton({ label: tb("Ok"), size: "sm", onClick: ok }), + createButton({ + label: tb("Cancel"), + variant: "ghost", + size: "sm", + onClick: () => { + finish(null); + modal.close(); + }, + }), + ], + }), + ); + }, + }); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css index 64a4c5500..4ef2696d4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css @@ -38,3 +38,17 @@ color: var(--color-text-secondary); text-align: center; } + +.m01lab-flow__bar[hidden] { + display: none; +} + +.m01lab-flow__note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01lab-flow__note[hidden] { + display: none; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts index 15c729006..5f554f83e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -1,19 +1,31 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Flow.ts - * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임) - * 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10 + * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 패널 + * 너비 — 패널이 없으면 두 칸 비율(끌기 막대 · localStorage) · 패널이 있으면 패널이 알린 너비(px · 패널 가장자리 끌기) + * 잇기(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 = { Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"], - Waiting: ["흐름도 자리", "Flow diagram"], + New: ["신규 로직", "New logic"], + Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"], + Converted: [ + "옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본", + "Converted from the old formula — saving makes the flow the master", + ], + Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"], } as const; -export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; +export const tf = (key: keyof typeof TEXT, v = ""): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); const STORE = "m01.logic.flowRatio"; const MIN = 0.25; @@ -34,14 +46,13 @@ export interface SplitHandle { root: HTMLElement; /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ right: HTMLElement; + /** 패널이 정한 너비(px) — 끌기 막대는 숨김(패널 가장자리로 조절) · null = 비율로 돌아감 */ + setRight(px: number | null): void; } /** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */ export function buildSplit(left: HTMLElement): SplitHandle { - const right = el("div", { - className: "m01lab-flow__right", - children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })], - }); + const right = el("div", { className: "m01lab-flow__right" }); const bar = el("div", { className: "m01lab-flow__bar", attrs: { @@ -55,8 +66,12 @@ export function buildSplit(left: HTMLElement): SplitHandle { const leftCol = el("div", { className: "m01lab-flow__left", children: [left] }); const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] }); let ratio = readRatio(); + let panelPx: number | null = null; const apply = (): void => { - root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`; + root.style.gridTemplateColumns = + panelPx === null + ? `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)` + : `minmax(0, 1fr) ${panelPx}px`; }; const keep = (): void => { try { @@ -90,6 +105,159 @@ export function buildSplit(left: HTMLElement): SplitHandle { apply(); keep(); }); + const setRight = (px: number | null): void => { + panelPx = px; + bar.hidden = px !== null; + apply(); + }; apply(); - return { root, right }; + return { root, right, setRight }; +} + +/* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */ + +export interface FlowAnswer { + flow: CfLogic; + converted: boolean; + notes: string[]; +} + +/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */ +export async function fetchFlow(key: string, file = ""): Promise { + try { + const q = new URLSearchParams({ key, ...(file ? { file } : {}) }); + const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" }); + if (!r.ok) return null; + const got = (await r.json()) as Partial; + return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null; + } catch { + return null; + } +} + +const loadFlowMods = () => + Promise.all([ + import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"), + import("./M01_MasterData_UI_LogicLab_FlowView"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Model"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Api"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"), + ]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail })); + +/** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */ +export async function emptyFlow(name: string, unit: string): Promise { + return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit); +} + +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; +} + +export interface LogicFlow { + /** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */ + set(lg: CfLogic): void; + /** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */ + redrawLeft(): void; + destroy(): void; +} + +/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */ +export async function mountLogicFlow(opts: LogicFlowOpts): Promise { + const M = await loadFlowMods(); + const host = el("div", { className: "m01lab-flow__panel" }); + const note = el("p", { className: "m01lab-flow__note" }); + const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes]; + note.textContent = notes.join(" · "); + note.hidden = !notes.length; + opts.split.right.replaceChildren(note, host); + let lg = opts.flow; + let values: CfValues = {}; + let valueSig = ""; + let logics: Record = {}; + let logicSig = ""; + let gone = false; + + /** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */ + const getLogic = async (key: string): Promise => + key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null); + + const left = (): void => { + const at = opts.leftHost(); + if (!at || gone) return; + M.view.buildFlowView(at, lg, values, { logics }); + }; + /** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */ + const refresh = (): void => { + let ports: Record> = {}; + try { + ports = M.ev.evalLogic(lg, values, { logics }).ports; + } catch { + /* 셈이 막혀도 물음은 보냄 */ + } + const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null; + const asks = M.detail.queriesOf(lg, pill, M.model.elementPort); + const sig = JSON.stringify(asks.map((a) => a.q)); + if (sig !== valueSig) { + valueSig = sig; + void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( + (got) => { + if (sig !== valueSig || gone) return; + values = got.values; + left(); + refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더 + }, + () => undefined, + ); + } + const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); + if (keys !== logicSig) { + logicSig = keys; + void M.detail.loadLogics(lg, getLogic).then((got) => { + if (keys !== logicSig || gone) return; + logics = got.logics; + left(); + }); + } + }; + + const panel = M.panel.mountFlowPanel(host, { + logic: lg, + title: opts.title, + readOnly: opts.readOnly, + logics: getLogic, + onResize: (px) => opts.split.setRight(px), + onChange: (next) => { + lg = next; + opts.onChange(next); + left(); + refresh(); + }, + }); + left(); + refresh(); + return { + set: (next) => { + lg = next; + panel.set(next); + left(); + refresh(); + }, + redrawLeft: left, + destroy: () => { + gone = true; + panel.destroy(); + opts.split.setRight(null); + opts.split.right.replaceChildren(); + }, + }; } From 27c5eba52163334d82b42c99933bb691586270c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:46:31 +0900 Subject: [PATCH 23/26] =?UTF-8?q?feat(m02-df):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=85=EB=A0=A5=20=E2=80=94=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=ED=9D=90=EB=A6=84=EC=9D=B4=20=EB=B9=84=EB=A9=B4=20?= =?UTF-8?q?=EC=98=9B=20=EC=A4=84=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=C2=B7=20=EC=A7=84=EC=A7=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B8=EB=A1=9C=20=EA=B0=92=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel_Build.ts | 22 +++++++++++++++++++ .../DataFlow/M02_DF_InputPanel_Logic.ts | 18 +++++++++++---- resources/tester/test_m02_df_input_logic.py | 14 ++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index b98570887..46d22c4ae 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -334,6 +334,28 @@ export function masterRef( : ref; } +/** 옛 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */ +export function rowRef( + key: string, + name: string, + resultUnit: string, + inputs: { 이름: string; 단위?: string }[], + auto: Record, +): CfLogicRef { + const examples: Record = {}; + for (const i of inputs) { + const v = auto[i.이름]; + if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v; + } + return { + key, + name, + outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })), + designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })), + ...(Object.keys(examples).length ? { examples } : {}), + }; +} + /** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */ export async function wouldCycle( selfKey: string | null, diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index 3ac1c5507..d23404fed 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -14,7 +14,11 @@ import { el, showToast, } from "@ui/ui_template_elements"; -import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { + fetchAuto, + fetchLogic, + fetchLogics, +} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; import { @@ -23,6 +27,7 @@ import { logicChapters, logicRefOf, masterRef, + rowRef, pageRange, setKind, setLogicInput, @@ -235,9 +240,14 @@ export function renderLogicInput( if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error"); const got = await flowOf(i); readNotes.set(i.키, { converted: got.converted, notes: got.notes }); - const picked = got.converted - ? masterRef(i.키, got.flow, true, i.결과단위) - : { ...logicRefOf(i.키, got.flow), name: i.이름 }; + // 옛 로직을 흐름도로 바꾼 것이 아직 비어 있으면 옛 줄의 입력 · 자동값에서 바로 읽음 + const empty = got.converted && got.flow.boxes.length === 0; + const row = empty ? await fetchLogic(i.키).catch(() => null) : null; + const picked = row + ? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키)) + : got.converted + ? masterRef(i.키, got.flow, true, i.결과단위) + : { ...logicRefOf(i.키, got.flow), name: i.이름 }; const done = setLogicInput(lg, boxId, picked); // 기본 이름이면 고른 로직 이름으로 on.change({ diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index 1b5dd1765..d5351d187 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -252,3 +252,17 @@ def test_조건_찾기_요소와_검사(): assert call("checkGroupRule", el, [el]) == "같은 이름의 알약이 이미 있음" no_pill = call("groupRuleElement", "노임", "이름", {"box": "", "name": ""}) assert "변수 알약" in call("checkGroupRule", no_pill, []) + + +def test_옛_줄_입력과_자동값으로_다른_로직_입력을_읽음(): + inputs = [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "돌"}, {"이름": "쌓기"}] + auto = {"뒷길이": 25, "돌": "깬잡석", "쌓기": "", "남는": 1} + ref = call("rowRef", "GF000219", "돌쌓기 메쌓기(인력)", "원/㎡", inputs, auto) + assert ref["key"] == "GF000219" + assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] + assert ref["designs"] == [ + {"name": "뒷길이", "unit": "㎝"}, + {"name": "돌", "unit": "-"}, + {"name": "쌓기", "unit": "-"}, + ] + assert ref["examples"] == {"뒷길이": 25, "돌": "깬잡석"} # 빈 글 · 입력에 없는 이름은 뺌 From 0d45014e38003cb92df256a5f15b144ee0e19f7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:46:37 +0900 Subject: [PATCH 24/26] =?UTF-8?q?feat(M01):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=88=20=EC=A4=84=EC=9D=80=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EC=A4=80=20=ED=82=A4(added)=EB=A1=9C=20=EC=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index caa1bf19c..14210902b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -394,16 +394,16 @@ export async function mountM01LogicLab( } showLoadingOverlay(); try { - await saveFiles([...byFile.values()]); + // 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶 + const saved = (await saveFiles([...byFile.values()])) as { + added?: { file: string; key: string }[]; + }; const was = opened; - const before = new Set(allLogics.map((x) => x.키)); drafts = {}; await reload(); showToast(tx("Save_Done"), "success"); - const made = - was?.row && !was.row.키 - ? allLogics.find((x) => !before.has(x.키) && x.이름 === was.row?.이름) - : undefined; + const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined; + const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined; if (made) await open(logicId(made.구분, made.키), made.구분, made.키); else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); else show(null); From 466fa56540eebc599f997ab58d993b429465d2d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:49:16 +0900 Subject: [PATCH 25/26] =?UTF-8?q?feat(M01):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=98=9B=20=EC=97=94=EC=A7=84=20=ED=95=9C?= =?UTF-8?q?=20=EA=B3=B3=20=EA=B0=88=EB=9E=98=20=C2=B7=20=EC=9D=B8=EB=A0=A5?= =?UTF-8?q?/=EA=B8=B0=EA=B3=84/=EC=9C=A0=EA=B0=80=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=C2=B7=20=ED=91=9C=20=EC=88=98=20=EC=8B=9D?= =?UTF-8?q?=20=EA=B8=80=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=ED=82=A4=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0(PLAN=2036-4=20=EB=8D=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_formula.run 에 FLOW_RUN 자리 — Store_Flow 가 꽂음 · 조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…) 도 흐름 로직이면 흐름 셈(단가는 마스터 · 프로젝트 사본 단가는 아직) - 흐름 → 옛 → 흐름 돌고 도는 부름 막음 · /logic/auto 흐름 로직 = 설계 예시값 - 조건 찾기 요소 인력 · 기계 · 유가전력(group_find · 이름 · 키 · 원문번호 같음/포함 · 기본 키 대체) - 표 값 칸 수 식 글(「(0.05)」 「1/1.30」) = 옛 엔진 파서로 수 · 재료 조건 찾기 field 키(자재 키 · 품번) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/M01_MasterData_Router.py | 5 +- M01_MasterData/M01_MasterData_Store_Flow.py | 50 +++++++- M02_MasterTemplete/DataFlow/M02_DF_Compute.py | 9 ++ M02_MasterTemplete/DataFlow/M02_DF_Values.py | 80 +++++++++++- .../master_data/scripts/master_formula.py | 7 + .../tester/test_m01_logic_flow_engine.py | 121 ++++++++++++++++++ 6 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_m01_logic_flow_engine.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index de50a83e3..3706dd065 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -192,8 +192,9 @@ def get_logic(key: str) -> dict: @router.get("/logic/auto") def get_logic_auto(key: str) -> dict: - """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님).""" - return _call(store.auto, key) + """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값.""" + got = _call(flow.auto, key) + return got if got is not None else _call(store.auto, key) @router.get("/elements") diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py index eb2d329f7..fbbd0b358 100644 --- a/M01_MasterData/M01_MasterData_Store_Flow.py +++ b/M01_MasterData/M01_MasterData_Store_Flow.py @@ -15,6 +15,7 @@ from __future__ import annotations import importlib +from contextvars import ContextVar from decimal import Decimal, InvalidOperation from typing import Any @@ -26,6 +27,7 @@ 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` 와 같은 뜻) ────────────────────────────── @@ -152,9 +154,46 @@ def _value(v: Any) -> Any: def run(flow: dict, key: str, inputs: dict) -> dict: """흐름 셈 — 입력 = 「설계 때 정할 것」 이름 → 값.""" - given = {k: _value(v) for k, v in (inputs or {}).items()} - flow = {**flow, "key": key} - return dc.run(flow, dc._design_values(flow, given), load, flow.get("userElements") or [], [key]) + active = _ACTIVE.get() + if key and key in active: # 옛 셈을 건너 다시 돌아온 흐름(흐름 → 옛 로직 → 흐름) + raise store.cm.mf.FormulaError(f"로직이 서로를 부름 — {' → '.join([*active, key])}") + token = _ACTIVE.set((*active, key)) + try: + given = {k: _value(v) for k, v in (inputs or {}).items()} + flow = {**flow, "key": key} + values = dc._design_values(flow, given) + return dc.run(flow, values, load, flow.get("userElements") or [], [key]) + finally: + _ACTIVE.reset(token) + + +def _engine(master, ref: str, row: dict, given: dict) -> dict: + """옛 엔진 `mf.run` 갈래(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…)) — `run` 과 같은 꼴. + + ponytail: 단가는 마스터에서(`M02_DF_Values`) — 프로젝트 사본 단가(master 인자)는 아직 안 씀. + """ + got = run(row[FLOW], ref, given) + if got["errors"]: + raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(got['errors'])}") + cost = got["cost"] + kinds = {(b.get("output") or {}).get("cost") for b in row[FLOW].get("boxes") or [] + if b.get("kind") == "output"} # fmt: skip + if kinds and kinds <= {"값"} and len(cost["값들"]) == 1: # 돈 아닌 로직 = 결과 하나 + return {"결과": next(iter(cost["값들"].values())), "중간": {}} + return {"줄": [], **{k: cost[k] for k in dc.COSTS}, "계": cost["계"], "중간": {}} + + +store.cm.mf.FLOW_RUN = _engine # 옛 엔진을 부르는 곳이 모두 흐름 로직을 흐름 셈으로(한 곳 갈래) + + +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: @@ -168,7 +207,10 @@ def calc(key: str, inputs: dict, row: dict | None = None) -> dict | None: return None if not isinstance(flow, dict): return None - got = run(flow, key, inputs) + 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"), diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index e3d8cf96b..835319d54 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -132,6 +132,15 @@ def _element_value( f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈" ) got = dv.slot_price({"mode": "fixed", "ref": el["default"]}) + elif group in dv.GROUP_RULE and mode == "rule": # 인력 · 기계 · 유가전력 조건 찾기 + rule = el.get("rule") or {} + src = rule.get("from") or {} + slot = {"field": rule.get("field"), "op": rule.get("op"), "scope": el.get("scope"), + "ref": el.get("ref"), "unit": el.get("unit")} # fmt: skip + got = dv.group_find(group, slot, ports.get(src.get("box"), {}).get(src.get("name"))) + if not got["ok"] and el.get("default"): + errors.append(f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본으로 셈") + got = dv.one_value({"group": group, "ref": el["default"], "unit": el.get("unit")}) elif given is not None: return given else: diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Values.py b/M02_MasterTemplete/DataFlow/M02_DF_Values.py index 1e6fa07c3..cf7ebde93 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Values.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Values.py @@ -158,11 +158,11 @@ def _table(key: str, at: Any = None) -> dict: if not isinstance(at, int) or not 0 <= at < len(lines): return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"} lines = [lines[at]] - rows = [] + rows, worth = [], set(table.get("값칸") or {}) for r in lines: line = [] for n in names: - v = r.get(n) + v = _arith(r.get(n)) if n in worth else r.get(n) if n in split: line += list(v) if isinstance(v, list) else [v, v] else: @@ -171,6 +171,16 @@ def _table(key: str, at: Any = None) -> dict: return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} +def _arith(v: Any) -> Any: + """값 칸 수 식 글(「(0.05)」 「1/1.30」) — 옛 셈처럼 수로(사칙연산 · 괄호만 · 옛 엔진 파서).""" + if not isinstance(v, str) or not cm.mf._ARITH.fullmatch(v): + return v + try: + return cm.mf.evaluate(cm.mf.parse(v), {}, None, 0) + except (cm.mf.FormulaError, ArithmeticError): + return v + + def _user(uid: str, local: list[dict]) -> dict: """사용자 요소 값 — local = 다이어그램 안 것 + 공용 목록(부르는 쪽이 모음).""" one = next((r for r in local if r.get("id") == uid), None) @@ -336,7 +346,7 @@ def materials(q: str, sub: str, detail: str, page: int, size: int) -> dict: return {"total": len(hits), "page": page, "size": size, "items": [_brief_row(r) for r in part]} -FIELDS_RULE, OPS_RULE = ("규격", "이름"), ("포함", "같음") # Model CfMaterialRule +FIELDS_RULE, OPS_RULE = ("규격", "이름", "키"), ("포함", "같음") # Model CfMaterialRule def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict: @@ -368,10 +378,13 @@ def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict: want = _squash(value) sub, detail = str(scope.get("구분") or ""), str(scope.get("상세구분") or "") rows = _found(sub, detail, scope.get("찾는말") or "") - if op == "같음": - hits = [r for r in rows if _squash(r.get(field)) == want] - else: - hits = [r for r in rows if want in _squash(r.get(field))] + numbers = _numbers() if field == "키" else {} # 키 = 자재 키 또는 품번(원문번호) + + def pool(r: dict) -> list[str]: + got = [r.get("키"), numbers.get(r.get("키"))] if field == "키" else [r.get(field)] + return [_squash(x) for x in got if x] + + hits = [r for r in rows if any(want == x if op == "같음" else want in x for x in pool(r))] if not hits: where = " · ".join(x for x in (sub, detail) if x) or "전체" return {"ok": False, "reason": f"맞는 재료 없음 — {where} 에서 {field}에 「{value}」 {op}"} @@ -386,6 +399,59 @@ def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict: return {**got, "ref": hits[0].get("키"), "name": hits[0].get("이름")} +GROUP_RULE = ( + "인력", + "기계", + "유가전력", +) # 조건 찾기를 재료 밖에서도(옛 LB:{직종} · EQ:{기계} · MO:…) + + +def _group_rows(group: str) -> list[dict]: + """그룹 줄 — 인력 · 기계 = 그 파일 · 유가전력 = 재료 안 MO 키.""" + files = store.loaded()[0] + want = "재료" if group == "유가전력" else group + return [ + r + for d in files.values() + if d.get("그룹") == want + for r in d.get(store.items_key(d)) or [] + if group != "유가전력" or str(r.get("키")).startswith("MO") + ] + + +def group_find(group: str, slot: dict, value: Any) -> dict: + """인력 · 기계 · 유가전력 조건 찾기 — 알약 값이 이름 · 키(원문번호) 와 같음(포함)인 줄 하나. + + slot = {field?: 이름 | 키, op?: 같음 | 포함, scope?: {찾는말?}, ref?: 「키#칸」 = 칸 이어 씀} + · field 가 없으면 키(원문번호) 또는 이름이 같은 줄 · 하나면 그 값(`one_value`). + """ + if value in (None, ""): + return {"ok": False, "reason": "찾을 값이 비어 있음 — 이어진 알약을 확인해 주세요"} + field, op, want = slot.get("field"), slot.get("op") or "같음", _squash(value) + words = str((slot.get("scope") or {}).get("찾는말") or "").split() + + def hit(r: dict) -> bool: + hay = _squash(f"{r.get('이름')}{r.get('규격') or ''}") + if not all(_squash(w) in hay for w in words): + return False + keys = [_squash(r.get("키")), _squash(r.get("원문번호"))] + names = [_squash(r.get("이름"))] + pool = keys if field == "키" else names if field == "이름" else keys + names + return any(want == x if op == "같음" else want in x for x in pool if x) + + hits = [r for r in _group_rows(group) if hit(r)] + if len(hits) != 1: + if not hits: + return {"ok": False, "reason": f"맞는 {group} 없음 — 「{value}」"} + names = ", ".join(f"{r.get('이름')} {r.get('규격') or ''}".strip() for r in hits[:5]) + return {"ok": False, "reason": f"맞는 {group} {len(hits)}개 — 좁혀 주세요({names} …)"} + ref = str(hits[0].get("키")) + suffix = str(slot.get("ref") or "").partition(MACHINE_SEP)[2] + got = one_value({"group": group, "ref": f"{ref}{MACHINE_SEP}{suffix}" if suffix else ref, + "unit": slot.get("unit") or ""}) # fmt: skip + return {**got, "ref": ref, "name": hits[0].get("이름")} + + # ── 단위 · 금액 표준 표 ──────────────────────────────────────────────── STANDARDS = {"unit": "단위표준.json", "money": "금액단위표준.json"} diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 83cc99f58..4bb9afb14 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -28,6 +28,11 @@ _OPS = ("<=", ">=", "==", "!=", "<", ">") _ARITH = re.compile(r"[\d.\s+\-*/()]*\d[\d.\s+\-*/()]*") # 분수·식 값 칸(「1/1.30」) +# 흐름도 로직(줄에 「흐름」) 셈 — 서버(`M01_MasterData_Store_Flow`)가 꽂음 · 없으면 옛 셈만 +# 꼴 = (master, 키, 줄, 입력) → `run` 과 같은 꼴(돈 = 줄 · 비목 합 · 계 / 돈 아님 = 결과) +FLOW_RUN = None + + class FormulaError(ValueError): pass @@ -538,6 +543,8 @@ def _blank_line(item: dict, why: str) -> dict: def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: """로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나.""" row = master.logic(ref) + if FLOW_RUN is not None and isinstance(row.get("흐름"), dict): + return FLOW_RUN(master, ref, row, given) env = _inputs(row, given) for step in row.get("중간", []): try: diff --git a/resources/tester/test_m01_logic_flow_engine.py b/resources/tester/test_m01_logic_flow_engine.py new file mode 100644 index 000000000..124670cfd --- /dev/null +++ b/resources/tester/test_m01_logic_flow_engine.py @@ -0,0 +1,121 @@ +# ruff: noqa: E501 +"""흐름도 적용(PLAN 36-4 덧) — 옛 엔진 한 곳 갈래 · 인력 · 기계 · 유가전력 조건 찾기. + +못박는 것(마스터 사본 폴더) + - `mf.run` 을 바로 부르는 곳(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…))도 흐름 로직이면 흐름 셈 + - `/logic/auto` — 흐름 로직은 「설계 때 정할 것」 예시값 + - 조건 찾기 요소가 인력 · 기계 · 유가전력에서도 이름 · 키(원문번호) 같음으로 찾음 +""" + +from __future__ import annotations + +import shutil +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as m01_router +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc +from M02_MasterTemplete.DataFlow import M02_DF_Values as dv +from resources.tester.test_m01_logic_flow_save import KEY, _flow, _get, _save_flow + +REAL = store.FOLDER + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(m01_router.router) + return TestClient(app) + + +def test_옛_엔진도_흐름_로직은_흐름_셈(client: TestClient) -> None: + _save_flow(client, KEY, _flow()) + whole = store.cm.mf.Master(store.cm.load(folder=store.FOLDER)) + got = store.cm.mf.run(whole, KEY, {"길이": Decimal(4), "폭": Decimal(5)}) + assert got["줄"] == [] and got["노무비"] == 20000 and got["계"] == 20000 + line = { + "종류": "로직", + "요소": f"로직({KEY}, 길이=4, 폭=5)", + "이름": "흐름 로직", + "단위": "㎡", + "수량": "2", + } + old = store.calc("", {}, dv._row(line, ()), None) # 옛 식이 흐름 로직을 부름 + assert old["ok"] and old["sums"]["노무비"] == 40000, old + auto = _get(client, "/api/m01/logic/auto", key=KEY) + assert auto == {"값": {"길이": 2, "폭": 3}} + + +def _labor_flow(field: str | None, name: str) -> dict: + flow = _flow() + el = {"ref": "", "name": "노임", "spec": "", "unit": "원/인", "mode": "rule", + "rule": {"field": field, "op": "같음", "from": {"box": "who", "name": "직종"}}} # fmt: skip + flow["boxes"] += [ + {"id": "who", "kind": "input", "name": "직종", "color": "", "unit": "-", "x": 0, "y": 0, + "input": {"mode": "variable", "elements": [], "variables": [ + {"name": "직종", "type": "choice", "unit": "-", "example": name, "choices": [name]}]}}, + {"id": "lab", "kind": "input", "name": "노임", "color": "", "unit": "-", "x": 0, "y": 0, + "input": {"mode": "group", "group": "인력", "elements": [el], "variables": []}}, + ] # fmt: skip + flow["boxes"][2]["output"]["cost"] = "값" + flow["links"][2] = { + "id": "l3", + "from": {"box": "lab", "name": "노임"}, + "to": {"box": "out", "name": "값"}, + } + return flow + + +def test_인력_기계_유가전력_조건_찾기(client: TestClient) -> None: + rows = dv._group_rows("인력") + one = next(r for r in rows if sum(x.get("이름") == r.get("이름") for x in rows) == 1) + want = dv.one_value({"group": "인력", "ref": one["키"]}) + for field, value in (("이름", one["이름"]), ("키", one["원문번호"]), (None, one["키"])): + got = dc.run(_labor_flow(field, value), {}, lambda k: {}, [], ["t"]) + assert got["errors"] == [] and got["cost"]["값들"]["노무비"] == want["value"], ( + field, + got["errors"], + ) + miss = dc.run(_labor_flow("이름", "없는직종zz"), {}, lambda k: {}, [], ["t"]) + assert any("맞는 인력 없음" in e for e in miss["errors"]) + machine = dv._group_rows("기계")[0] + got = dv.group_find("기계", {"field": "키"}, machine["원문번호"]) + assert got["ref"] == machine["키"] + oils = dv._group_rows("유가전력") + if oils: + got = dv.group_find("유가전력", {"field": "키"}, oils[0]["키"]) + assert got["ref"] == oils[0]["키"] + + +def test_표_값_수_식_글은_수로_재료_키_찾기(client: TestClient) -> None: + assert dv._arith("(0.05)") == Decimal("0.05") and dv._arith("1/1.30") == Decimal(1) / Decimal( + "1.30" + ) + assert ( + dv._arith("토사") == "토사" and dv._arith("1/0") == "1/0" + ) # 수 식 아니거나 못 세면 그대로 + files = store.loaded()[0] + hit = next( + ((t["키"], n) for d in files.values() if d.get("그룹") in store.cm.mf.TABLE_GROUPS + for t in d.get("표") or [] for r in t.get("줄") or [] for n in t.get("값칸") or {} + if isinstance(r.get(n), str) and store.cm.mf._ARITH.fullmatch(r[n])), + None, + ) # fmt: skip + if hit: # 마스터에 식 글 값이 있으면 표 알약에서 수로 나옴 + got = dv._table(hit[0]) + col = got["rows"][0].index(hit[1]) + assert not any( + isinstance(r[col], str) and store.cm.mf._ARITH.fullmatch(r[col]) + for r in got["rows"][1:] + ) + item = next(r for r in dv._group_rows("재료") if not str(r.get("키")).startswith("MO")) + got = dv.slot_price({"mode": "rule", "field": "키", "op": "같음", "scope": {}}, item["키"]) + assert got.get("ref") == item["키"], got From f0ecaa122dc0c3fdb246acd8116050793ba84e5b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:57:25 +0900 Subject: [PATCH 26/26] =?UTF-8?q?feat(M01):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=85=88=20=EB=8B=A8=EA=B0=80=20=3D=20mf.run=20=EC=9D=84=20?= =?UTF-8?q?=EB=B6=80=EB=A5=B8=20=EC=AA=BD=20=EC=9E=90=EB=A6=AC(=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=82=AC=EB=B3=B8=EC=9D=B4?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EB=8B=A8=EA=B0=80=20=C2=B7=20PLAN=2036?= =?UTF-8?q?-4=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EB=8B=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_DF_Values.SOURCE — 단가 · 값 · 표를 넘겨진 Master 에서(없으면 마스터) · 견본 로직은 그 Master 에 잠깐 얹어 셈 - Store_Flow 옛 엔진 갈래가 master 를 넘김 · 다른 로직(흐름 · 옛)도 같은 자리 - 찾기 목록(재료 조건 찾기 · 그룹 찾기 · 기계 짝)은 마스터 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/M01_MasterData_Store_Flow.py | 25 +++++++-- M02_MasterTemplete/DataFlow/M02_DF_Values.py | 51 +++++++++++++++++-- .../tester/test_m01_logic_flow_engine.py | 21 ++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py index fbbd0b358..2bd82fd28 100644 --- a/M01_MasterData/M01_MasterData_Store_Flow.py +++ b/M01_MasterData/M01_MasterData_Store_Flow.py @@ -22,6 +22,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Make as make 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") @@ -112,7 +113,18 @@ def check(flow: Any) -> list[str]: # ── 다른 로직 · 계산 ─────────────────────────────────────────────────── def _old(key: str, designs: dict) -> dict: """옛 로직 값 — 옛 셈 · 내보내는 알약 = 노무비 · 재료비 · 경비 · 계(돈 아닌 로직은 결과).""" - got = store.calc(key, {k: v for k, v in designs.items() if v is not None}) + 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: @@ -122,7 +134,8 @@ def _old(key: str, designs: dict) -> dict: def load(key: str) -> dict: """다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 옛 로직이면 옛 셈 함수(`calc`).""" - row = store.logic(key)["logic"] + 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)} @@ -170,9 +183,13 @@ def run(flow: dict, key: str, inputs: dict) -> dict: def _engine(master, ref: str, row: dict, given: dict) -> dict: """옛 엔진 `mf.run` 갈래(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…)) — `run` 과 같은 꼴. - ponytail: 단가는 마스터에서(`M02_DF_Values`) — 프로젝트 사본 단가(master 인자)는 아직 안 씀. + 단가 · 값 · 표 = 부른 쪽이 넘긴 master(프로젝트 사본이면 그 단가 · `M02_DF_Values.SOURCE`). """ - got = run(row[FLOW], ref, given) + token = dv.SOURCE.set(master) + try: + got = run(row[FLOW], ref, given) + finally: + dv.SOURCE.reset(token) if got["errors"]: raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(got['errors'])}") cost = got["cost"] diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Values.py b/M02_MasterTemplete/DataFlow/M02_DF_Values.py index cf7ebde93..0cc31950f 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Values.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Values.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import re +from contextvars import ContextVar from typing import Any from M01_MasterData import M01_MasterData_Store as store @@ -21,6 +22,36 @@ RANGE_LOW, RANGE_HIGH = "·아래", "·위" FIELDS = (*cm.mf.COST_ITEMS, "계") +# 단가 자리 — `mf.run` 을 부른 쪽이 넘긴 Master(프로젝트 사본이면 그 단가) · 없으면 마스터 +# ponytail: 단가 · 값 · 표만 이 자리 · 찾기 목록(재료 조건 찾기 · 그룹 찾기 · 기계 짝)은 마스터에서 +SOURCE: ContextVar = ContextVar("df_price_source", default=None) +_DRAFT = "GX999998" # 단가 자리에 잠깐 얹는 견본 로직 키 + + +def _whole(): + return SOURCE.get() or store.loaded()[1] + + +def _calc(inputs: dict, row: dict) -> dict: + """`Store.calc("", inputs, row)` 와 같은 꼴 — 단가 자리가 넘겨졌으면 그 Master 로 셈.""" + whole = SOURCE.get() + if whole is None: + return store.calc("", inputs, row, None) + slot = whole.index.setdefault(_DRAFT[:2], {}) + slot[_DRAFT] = {**store._dec(row), "키": _DRAFT} + try: + given = cm.mf.fill_empty(slot[_DRAFT], {k: store._dec(v) for k, v in inputs.items()}) + got = cm.mf.run(whole, _DRAFT, given) + except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + return {"ok": False, "reason": str(e) or type(e).__name__} + finally: + slot.pop(_DRAFT, None) + if "결과" in got: + return {"ok": True, "result": got["결과"], "middle": got["중간"]} + sums = {k: got[k] for k in cm.mf.BUNDLE} + return {"ok": True, "lines": got["줄"], "sums": sums, "middle": got["중간"]} + + # ── 입력 값 ──────────────────────────────────────────────────────────── def _row(line: dict, names) -> dict: """단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" @@ -65,7 +96,7 @@ def _pick(key: Any) -> dict | None: def _material(cond: dict, unit: str) -> dict: """검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산).""" line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"} - got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None) + got = _calc({}, _row({**line, "단위": unit, "비목": "재료비"}, ())) if not got["ok"]: return got one = got["lines"][0] @@ -85,7 +116,7 @@ def _material(cond: dict, unit: str) -> dict: def _brief(key: str, want: str) -> tuple[dict | None, str | None]: """마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키.""" try: - row = store.loaded()[1].get(key) + row = _whole().get(key) except cm.mf.FormulaError as e: return None, str(e) group = store.mk.GROUP_OF.get(store.mk.id_of(key)) @@ -102,7 +133,7 @@ def _price(group: str, key: str, unit: str = "") -> dict: kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group] line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"} line = {**line, "단위": unit or row.get("단위") or "", "비목": cost} - got = store.calc("", {}, _row(line, ()), None) + got = _calc({}, _row(line, ())) if not got["ok"]: return got one = got["lines"][0] @@ -135,6 +166,12 @@ def _plain_value(group: str, key: str, field: str | None) -> dict: def _table(key: str, at: Any = None) -> dict: """소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 · 그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나.""" + if SOURCE.get() is not None: # 단가 자리(프로젝트 사본)의 표 + try: + table = SOURCE.get().get(key) + except cm.mf.FormulaError: + table = None + return _table_of(table, key, at) files = store.loaded()[0] table = next( ( @@ -146,6 +183,10 @@ def _table(key: str, at: Any = None) -> dict: ), None, ) + return _table_of(table, key, at) + + +def _table_of(table: dict | None, key: str, at: Any) -> dict: if table is None: return {"ok": False, "reason": f"없는 표 「{key}」"} conds, lines = table.get("조건") or {}, table.get("줄") or [] @@ -236,7 +277,7 @@ def _machine_hourly(key: str, inputs: dict) -> dict: args = "".join(f", {n}='{v}'" for n, v in given.items()) ref = f"로직({logic['키']}, 기계='{code}'{args})" line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} - got = _money(store.calc("", {}, _row(line, ()), None), "계") + got = _money(_calc({}, _row(line, ())), "계") return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got @@ -269,7 +310,7 @@ def one_value(item: dict, local: list[dict] | None = None) -> dict: elif group == "기계" and str(ref or "").startswith("로직("): inputs = item.get("inputs") or {} line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} - got = _money(store.calc("", inputs, _row(line, inputs), None), field) + got = _money(_calc(inputs, _row(line, inputs)), field) elif group == "기계" and field == HOURLY: got = _machine_hourly(str(ref or ""), item.get("inputs") or {}) elif group in ("기계", "유가전력", "환율", "요율"): diff --git a/resources/tester/test_m01_logic_flow_engine.py b/resources/tester/test_m01_logic_flow_engine.py index 124670cfd..58ccea2e3 100644 --- a/resources/tester/test_m01_logic_flow_engine.py +++ b/resources/tester/test_m01_logic_flow_engine.py @@ -119,3 +119,24 @@ def test_표_값_수_식_글은_수로_재료_키_찾기(client: TestClient) -> item = next(r for r in dv._group_rows("재료") if not str(r.get("키")).startswith("MO")) got = dv.slot_price({"mode": "rule", "field": "키", "op": "같음", "scope": {}}, item["키"]) assert got.get("ref") == item["키"], got + + +def test_흐름_셈_단가는_부른_쪽_자리_프로젝트_사본(client: TestClient) -> None: + """옛 길과 같은 입력 → 같은 단가 — 프로젝트 사본(M02_Item_Prices 의 Master)이면 그 사본 노임.""" + flow = _flow() + el = {"ref": "LB000001", "name": "작업반장", "spec": "", "unit": "원/일", "mode": "fixed"} + flow["boxes"].append({"id": "lab", "kind": "input", "name": "노임", "color": "", "unit": "-", "x": 0, "y": 0, + "input": {"mode": "group", "group": "인력", "elements": [el], "variables": []}}) # fmt: skip + flow["links"][2] = { + "id": "l3", + "from": {"box": "lab", "name": "작업반장"}, + "to": {"box": "out", "name": "값"}, + } + _save_flow(client, KEY, flow) + files = store.cm.load(folder=store.FOLDER) + master = store.cm.mf.run(store.cm.mf.Master(files), KEY, {}) + labor = next(r for d in files.values() for r in d.get("줄") or [] if r.get("키") == "LB000001") + assert master["노무비"] == labor["값"] + labor["값"] = Decimal(100000) # 프로젝트 사본 노임 + copy = store.cm.mf.run(store.cm.mf.Master(files), KEY, {}) + assert copy["노무비"] == 100000 and master["노무비"] != 100000