From fbb6902576fc47ca12fca6f18920c86a6a7bb9f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:29:48 +0900 Subject: [PATCH 1/2] =?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 2/2] 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.시험입력`**(일괄 시험과 같은 값) 을 쓰는 것을 권함 — 흐름도 변환의 변수 예시값도 같은 규칙으로(⬜ 브레인 정함 필요: 화면 견본과 범위 끝이 다름).