From c16d7fdda21d2ab79f6007b6282f7a8f3c5de06c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:57:01 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20Wizard=20=EC=99=84=EC=84=B1=20=E2=80=94=20?= =?UTF-8?q?=ED=91=9C=20=EC=B9=B8=20=EC=97=AD=ED=95=A0=20=C2=B7=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=A5=20=ED=8B=80=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EA=B0=92=20=EC=A2=85=EB=A5=98=20=C2=B7=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_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/LogicBuilder/M01_LB_Wizard.ts | 112 ++-------- .../LogicBuilder/M01_LB_Wizard_Build.ts | 21 +- .../LogicBuilder/M01_LB_Wizard_Calc.ts | 24 ++- .../LogicBuilder/M01_LB_Wizard_Parts.ts | 94 +++++++++ resources/tester/test_m01_lb_wizard_build.py | 193 ++++++++++++++++++ 5 files changed, 345 insertions(+), 99 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts create mode 100644 resources/tester/test_m01_lb_wizard_build.py diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts index 0cfceeb6f..9f172557f 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts @@ -6,7 +6,6 @@ * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨 * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, @@ -16,7 +15,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { ApiError, type CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; +import { type CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; import { calcLater, plainError } from "../EasyCalc/M01_EasyCalc_Api"; import { readLogic, type LbLogic } from "./M01_LB_Blocks"; import { autoLinks, stepCalc, stepLink } from "./M01_LB_Wizard_Calc"; @@ -30,6 +29,13 @@ import { type WCtx, type WTable, } from "./M01_LB_Wizard_Build"; +import { + get, + tablePreview, + toTable, + type SectionItem, + type TableHead, +} from "./M01_LB_Wizard_Parts"; import "./M01_LB_Wizard.css"; const TEXT = { @@ -67,6 +73,7 @@ const TEXT = { Unused: ["쓰지 않음", "Unused"], Choices: ["선택 목록", "Choices"], Free: ["숫자 입력", "Number input"], + PickTable: ["표를 하나 골라 주세요", "Pick a table"], NeedResult: ["결과 칸이 하나는 있어야 함", "At least one result column"], FixedVal: ["고정값", "Fixed value"], BaseUnit: ["표 기준 「{b}」", "Table base “{b}”"], @@ -95,32 +102,7 @@ const t = (key: TKey, v: Record = {}): string => const fmt = (n: number): string => Number(n.toPrecision(6)).toLocaleString("ko-KR"); -async function get(path: string, params: Record): Promise { - const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { - credentials: "include", - }); - const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); - return data; -} - -interface SectionItem { - section: string; - title: string; - chapter: string; - tables: number; - logics: number; -} -interface TableHead { - file: string; - 키: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - count: number; -} - +const LBL = { preview: t("Preview"), notes: t("Notes") }; const BOOKS = ["산림품셈", "건설품셈"]; const UNITS = ["㎡", "㎥", "m", "km", "ha", "개소", "개", "ton", "본", "100㎡", "100m", "10m"]; @@ -233,7 +215,7 @@ export function openWizard(): Promise { const per = a.resultUnit.replace(/^원\//, ""); const v = money ? (ans.sums?.["계"] ?? Number(ans.result ?? 0)) : Number(ans.result); result.textContent = money - ? t("AmountPer", { p: per, m: fmt(v) }) + ? t("AmountPer", { p: per, m: Math.round(v).toLocaleString("ko-KR") }) : t("ValueIs", { c: col, v: fmt(v), u: a.table.값칸[col] ?? "" }); }); }; @@ -369,7 +351,7 @@ export function openWizard(): Promise { tables .querySelectorAll(".m01-lbw__row") .forEach((n, i) => n.classList.toggle("is-on", heads[i]?.키 === h.키)); - preview.replaceChildren(tablePreview(a.table)); + preview.replaceChildren(tablePreview(a.table, LBL)); renderNav(); }; let timer = 0; @@ -379,8 +361,8 @@ export function openWizard(): Promise { ); main.replaceChildren(bookBox.root, q.root, list, tables, preview); void load(); - if (a.table.key) preview.replaceChildren(tablePreview(a.table)); - return () => (a.table.key ? null : t("Tables")); + if (a.table.key) preview.replaceChildren(tablePreview(a.table, LBL)); + return () => (a.table.key ? null : t("PickTable")); }; /* ── 2단계 칸 역할 ── */ @@ -471,7 +453,7 @@ export function openWizard(): Promise { box.replaceChildren( el("p", { className: "m01-lbw__muted", text: t("RoleHint") }), ...chips, - tablePreview(a.table, a.roles), + tablePreview(a.table, LBL, a.roles), ); renderNav(); }; @@ -580,9 +562,12 @@ export function openWizard(): Promise { stepCalc(ctx, main); valid = () => null; } else if (step === 5) { - stepLink(ctx, main); - if (!linked) - ((linked = true), void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main))); + if (linked) stepLink(ctx, main); + else { + linked = true; + main.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") })); + void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main)); + } valid = () => null; } else valid = stepName(); side.hidden = step === 1; @@ -641,58 +626,3 @@ export function openWizard(): Promise { draw(); }); } - -/** 서버 표 답 → WTable */ -function toTable(raw: WTable & { 키: string }, file: string): WTable { - return { - key: raw.키, - file, - 이름: raw.이름, - 기준: raw.기준 ?? "", - 출처: raw.출처 ?? "", - 조건: raw.조건, - 값칸: raw.값칸, - 범위규칙: raw.범위규칙 ?? null, - 줄: raw.줄, - 주: raw.주 ?? [], - }; -} - -const cellText = (v: unknown): string => - Array.isArray(v) - ? v.map((x) => x ?? "").join("~") - : v === null || v === undefined - ? "" - : String(v); - -/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */ -function tablePreview(tb: WTable, roles?: Record): HTMLElement { - const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; - const head = el("tr", { - children: cols.map((c) => - el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }), - ), - }); - const rows = tb.줄 - .slice(0, 6) - .map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) })); - return el("div", { - className: "m01-lbw__table", - children: [ - el("h5", { text: `${t("Preview")} — ${tb.이름} (${tb.기준 || "—"})` }), - el("div", { - className: "m01-lbw__scroll", - children: [el("table", { children: [head, ...rows] })], - }), - ...(tb.줄.length > 6 - ? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })] - : []), - ...(tb.주.length - ? [ - el("h5", { text: t("Notes") }), - ...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })), - ] - : []), - ], - }); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts index 47c8c6364..e9abc8a54 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts @@ -184,12 +184,25 @@ function inputsOf(a: WAnswers): LogicInput[] { if (m.k === "할증" && m.rate.t === "cond") add({ 이름: m.rate.input }); if (m.k === "나누기" && m.by.t === "input") add({ 이름: m.by.name }); } - for (const l of a.links) for (const n of l.needInputs ?? []) add(n); + if (a.kind === "금액") + for (const l of a.links) + if (l.kind !== "없음" && a.roles[l.col] === "결과") + for (const n of l.needInputs ?? []) add(n); return out; } const COST: Record = { 노무: "노무비", 재료: "재료비" }; +/** 사람 줄 노임 합 글 — 「(수량 * 노임 키 + …)」 · 사람 줄이 없으면 0 */ +const personSum = (ho: HoLine[]): string => { + const terms = ho + .filter((h) => h.종류 === "인력") + .map( + (h) => `${/^[\p{L}\p{N}_.]+$/u.test(h.수량) ? h.수량 : `(${h.수량})`} * ${String(h.요소)}`, + ); + return `(${terms.length ? terms.join(" + ") : "0"})`; +}; + /** 답 → 로직 줄 */ export function buildRow(a: WAnswers): LogicRow { const mids: { 이름: string; 식: string; 출처?: string }[] = []; @@ -222,7 +235,11 @@ export function buildRow(a: WAnswers): LogicRow { })); const 덧줄 = a.extras.map((e) => e.k === "비율" - ? { 이름: e.name, 식: `${e.base} * ${num(e.rate)} / 100`, 비목: e.cost } + ? { + 이름: e.name, + 식: `${e.base === "사람줄합" ? personSum(호표) : e.base} * ${num(e.rate)} / 100`, + 비목: e.cost, + } : { 이름: e.name, 식: num(e.amount), 비목: e.cost }, ); return { ...base, 입력: inputsOf(a), 중간: mids, 호표, 덧줄, 끝수: null }; diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts index 45cd91dc0..4410cad55 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts @@ -5,7 +5,13 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; import { fetchPick } from "../M01_MasterData_Api_Fetch"; import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; @@ -50,9 +56,11 @@ const TEXT = { DSurchargeNum: ["{p} % 를 더함", "add {p} %"], DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"], DSurchargeCond: [ - "「{i}」 가 {l} {o} 이면 0 · 넘으면 「{t}」 의 {c} % 를 더함", - "if “{i}” {o} {l} add 0, else “{t}” {c} %", + "「{i}」 가 {l} {o}이면 0 · 아니면 「{t}」 의 {c} % 를 더함", + "if “{i}” is {l} {o} add 0, else “{t}” {c} %", ], + OpLe: ["이하", "or less"], + OpLt: ["미만", "under"], DDivNum: ["{n} 로 나눔", "divide by {n}"], DDivIn: ["「{n}」 로 나눔(입력값)", "divide by “{n}” (input)"], DMin: ["{n} 보다 작은 값", "smaller of value and {n}"], @@ -127,7 +135,7 @@ const describe = (m: Mod): string => { : t("DSurchargeCond", { i: r.input, l: r.limit, - o: r.op === "<=" ? "≤" : "<", + o: t(r.op === "<=" ? "OpLe" : "OpLt"), t: r.table.이름, c: r.col, }); @@ -371,7 +379,7 @@ export function stepCalc(ctx: WCtx, host: HTMLElement): void { export async function autoLinks(ctx: WCtx): Promise { const { a } = ctx; for (const col of colsOf(a, "결과")) { - if (a.links.some((l) => l.col === col)) continue; + if (a.links.some((l) => l.col === col && l.kind !== "없음")) continue; const unit = a.table.값칸[col] ?? ""; const link: Link = { col, kind: "없음", name: col, spec: "", unit, element: null }; try { @@ -393,6 +401,7 @@ export async function autoLinks(ctx: WCtx): Promise { } catch { /* 못 찾으면 연결 안 함으로 둠 */ } + a.links = a.links.filter((l) => l.col !== col); a.links.push(link); } ctx.changed(); @@ -441,7 +450,10 @@ export function stepLink(ctx: WCtx, host: HTMLElement): void { attrs: { type: "button" }, text: t(label), }); - b.addEventListener("click", () => void pick(kind, pk).catch((e) => alert(plainError(e)))); + b.addEventListener( + "click", + () => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")), + ); return b; }); const none = el("button", { diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts new file mode 100644 index 000000000..3f3773190 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * M01_LB_Wizard_Parts.ts + * 단계 안내 곁부품 — 서버 읽기(절 · 표) · 표 → WTable · 원문 표 미리 보기 · `M01_LB_Wizard.ts` 가 부름 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el } from "@ui/ui_template_elements"; +import { ApiError } from "../M01_MasterData_UI_Logic_Api"; +import type { Role, WTable } from "./M01_LB_Wizard_Build"; + +export async function get(path: string, params: Record): Promise { + const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { + credentials: "include", + }); + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); + return data; +} + +export interface SectionItem { + section: string; + title: string; + chapter: string; + tables: number; + logics: number; +} +export interface TableHead { + file: string; + 키: string; + 이름: string; + 기준: string; + 조건: Record; + 값칸: Record; + count: number; +} + +/** 서버 표 답 → WTable */ +export function toTable(raw: WTable & { 키: string }, file: string): WTable { + return { + key: raw.키, + file, + 이름: raw.이름, + 기준: raw.기준 ?? "", + 출처: raw.출처 ?? "", + 조건: raw.조건, + 값칸: raw.값칸, + 범위규칙: raw.범위규칙 ?? null, + 줄: raw.줄, + 주: raw.주 ?? [], + }; +} + +const cellText = (v: unknown): string => + Array.isArray(v) + ? v.map((x) => x ?? "").join("~") + : v === null || v === undefined + ? "" + : String(v); + +/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */ +export function tablePreview( + tb: WTable, + labels: { preview: string; notes: string }, + roles?: Record, +): HTMLElement { + const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; + const head = el("tr", { + children: cols.map((c) => + el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }), + ), + }); + const rows = tb.줄 + .slice(0, 6) + .map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) })); + return el("div", { + className: "m01-lbw__table", + children: [ + el("h5", { text: `${labels.preview} — ${tb.이름} (${tb.기준 || "—"})` }), + el("div", { + className: "m01-lbw__scroll", + children: [el("table", { children: [head, ...rows] })], + }), + ...(tb.줄.length > 6 + ? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })] + : []), + ...(tb.주.length + ? [ + el("h5", { text: labels.notes }), + ...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })), + ] + : []), + ], + }); +} diff --git a/resources/tester/test_m01_lb_wizard_build.py b/resources/tester/test_m01_lb_wizard_build.py new file mode 100644 index 000000000..210a11172 --- /dev/null +++ b/resources/tester/test_m01_lb_wizard_build.py @@ -0,0 +1,193 @@ +"""로직 만들기 판 단계 안내 — 답 → 로직 줄 짜기(`M01_LB_Wizard_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. +견본 표 · 답은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Wizard_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "unit") return m.unitOfBase(job.base); + if (job.op === "row") return m.buildRow(job.a); + if (job.op === "trial") return m.trialOf(job.a, m.buildRow(job.a)); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + +TABLE = { + "key": "QF900001", + "이름": "견본 쌓기", + "기준": "1㎡", + "출처": "산림품셈 9-9-9", + "조건": {"뒷길이": "수", "돌": "고르기"}, + "값칸": {"석공": "인", "보통인부": "인"}, + "줄": [ + {"뒷길이": 25, "돌": "깬돌", "석공": 0.15, "보통인부": 0.12}, + {"뒷길이": 30, "돌": "깬돌", "석공": 0.26, "보통인부": 0.21}, + ], + "주": ["③ 높이 3m 초과 시 할증"], +} +RATE_TABLE = { + "key": "QF900002", + "이름": "견본 증가율표", + "기준": "", + "출처": "산림품셈 9-9-9 주③", + "조건": {"높이": "범위"}, + "값칸": {"증가율": "%"}, + "줄": [{"높이": [3, 4], "증가율": 30}], + "주": [], +} + + +def answers(**more) -> dict: + base = { + "book": "산림품셈", + "section": "9-9-9", + "table": TABLE, + "roles": {"뒷길이": "조건", "돌": "조건", "석공": "결과", "보통인부": "결과"}, + "fixed": {}, + "options": {"뒷길이": [25, 30], "돌": ["깬돌"]}, + "resultUnit": "원/㎡", + "mods": [], + "links": [ + { + "col": "석공", + "kind": "노무", + "name": "석공", + "spec": "", + "unit": "인", + "element": "LB000033", + }, + { + "col": "보통인부", + "kind": "노무", + "name": "보통인부", + "spec": "", + "unit": "인", + "element": "LB000002", + }, + ], + "extras": [], + "name": "견본", + "kind": "금액", + "valueCol": "", + } + base.update(more) + return base + + +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_결과단위는_표_기준에서(): + out = run([{"op": "unit", "base": b} for b in ("1㎡", "100㎡ 당", "㎡당", "m당", "")]) + assert out == ["원/㎡", "원/100㎡", "원/㎡", "원/m", "원/㎡"] + + +def test_기본_두_줄(): + row = run([{"op": "row", "a": answers()}])[0] + assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌"] + assert row["입력"][0]["고르기"] == [25, 30] + assert row["중간"] == [] + assert [h["수량"] for h in row["호표"]] == [ + "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공", + "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부", + ] + assert row["호표"][0]["종류"] == "인력" and row["호표"][0]["비목"] == "노무비" + assert row["결과단위"] == "원/㎡" and row["출처"] == "산림품셈 9-9-9" + + +def test_고정칸은_값으로(): + a = answers( + roles={"뒷길이": "고정", "돌": "조건", "석공": "결과", "보통인부": "쓰지 않음"}, + fixed={"뒷길이": 25}, + ) + row = run([{"op": "row", "a": a}])[0] + assert [i["이름"] for i in row["입력"]] == ["돌"] + assert [h["수량"] for h in row["호표"]] == ["찾기(QF900001, 돌=돌, 뒷길이=25).석공"] + + +def test_문장틀_차례대로_씌움(): + cond = { + "t": "cond", + "input": "높이", + "op": "<=", + "limit": 3, + "table": RATE_TABLE, + "col": "증가율", + } + a = answers( + mods=[ + {"k": "할증", "rate": cond, "src": "산림품셈 9-9-9 주③"}, + {"k": "나누기", "by": {"t": "input", "name": "시공량"}}, + {"k": "올림", "fn": "반올림", "places": 3}, + ] + ) + row = run([{"op": "row", "a": a}])[0] + assert row["중간"] == [ + { + "이름": "증가율", + "식": "만약(높이 <= 3, 0, 찾기(QF900002, 높이=높이).증가율)", + "출처": "산림품셈 9-9-9 주③", + } + ] + assert row["호표"][0]["수량"] == ( + "반올림((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)) / 시공량, 3)" + ) + assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌", "높이", "시공량"] + + +def test_비율_덧줄은_사람줄합(): + extras = [{"k": "비율", "name": "공구손료", "base": "사람줄합", "rate": 3, "cost": "재료비"}] + row = run([{"op": "row", "a": answers(extras=extras)}])[0] + assert row["덧줄"] == [ + { + "이름": "공구손료", + "식": "((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공) * LB000033 + (찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부) * LB000002) * 3 / 100", + "비목": "재료비", + } + ] + + +def test_값_종류는_결과_식_하나(): + a = answers(kind="값", valueCol="석공", resultUnit="인/㎡") + row = run([{"op": "row", "a": a}])[0] + assert row["결과"] == {"식": "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공"} + assert "호표" not in row and row["결과단위"] == "인/㎡" + + +def test_예시값은_첫_줄에_걸림(): + out = run([{"op": "trial", "a": answers()}])[0] + assert out == {"뒷길이": 25, "돌": "깬돌"} From f93ce9f6762e45f2e034dfb021cd9c9a9c9238fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:59:33 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(M01):=20=EA=B8=B0=EB=8C=80=EA=B0=92=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=ED=91=9C=EA=B0=80=20LB=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0(lbCalcNow)=EC=9D=84=20=EC=94=80=20?= =?UTF-8?q?=E2=80=94=20=ED=99=94=EB=A9=B4=20=EC=98=88=EC=8B=9C=EA=B0=92=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EA=B3=BC=20=EC=95=88=20=EB=B6=80=EB=94=AA?= =?UTF-8?q?=ED=9E=98(PLAN=2035-2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멈춤 까닭은 Api 쉬운 말로 - 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록 - ORCA GF000219 시험표 세 줄: 계 79,279.668 통과 · 틀린 기대값 실패 · 해당 없음 조합 계 0 실패 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts index 075c8a9f4..95d28fc75 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Check.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -12,7 +12,7 @@ * ========================================================================== */ import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import { lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { lbCalcNow, type LbTables } from "./M01_LB_Api"; import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks"; type Cell = string | number | boolean | null | [number | null, number | null]; @@ -212,20 +212,20 @@ type CalcFn = ( tables: LbTables, ) => Promise; -/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */ -const calcOnce: CalcFn = (row, inputs, tables) => - new Promise((resolve, reject) => - lbCalcLater(row, inputs, tables, (ans, plain) => - ans ? resolve(ans) : reject(new Error(plain ?? "")), - ), - ); +/** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */ +const calcNow: CalcFn = async (row, inputs, tables) => { + let plain = ""; + const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p)); + if (!ans) throw new Error(plain); + return ans.ok ? ans : { ok: false, reason: plain || ans.reason }; +}; /** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */ export async function runExpect( lb: LbLogic, tables: LbTables, rows: ExpectRow[], - calc: CalcFn = calcOnce, + calc: CalcFn = calcNow, ): Promise { const row = writeLogic(lb); const out: ExpectResult[] = []; From 8742cd10f902df6f2171018d85619045183dce9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:04:23 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=9B=90=EB=AC=B8=20=C2=B7=20=EB=AC=B8=EC=9E=A5/?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=C2=B7=20=EA=B2=B0=EA=B3=BC(=EC=98=88?= =?UTF-8?q?=EC=8B=9C=EA=B0=92=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=ED=91=9C=20=C2=B7=20=EB=B9=88=ED=8B=88=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=20=EC=8B=9C=ED=97=98=ED=91=9C)=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5=20=C2=B7=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EC=82=AD=EC=A0=9C=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=B7=A8=EC=86=8C=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=95=88=EB=82=B4?= 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/LogicBuilder/M01_LB_Entry.css | 39 ++ M01_MasterData/LogicBuilder/M01_LB_Entry.ts | 491 ++++++++++++------ .../LogicBuilder/M01_LB_Entry_Result.ts | 273 ++++++++++ 3 files changed, 657 insertions(+), 146 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css index 9975c9b32..26f9e736e 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.css +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.css @@ -201,3 +201,42 @@ overflow: auto; } } + +/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */ +.m01-lb__check { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lb__check-tools { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-lb__check-out ul { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px auto auto; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row .is-ok { + color: var(--color-success, var(--color-text)); + font-weight: 600; +} + +.m01-lb__expect-row .is-bad { + color: var(--color-danger); + font-weight: 600; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts index e68ed3189..792c39700 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -1,9 +1,11 @@ /* ============================================================================= * M01_LB_Entry.ts - * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠 + * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임. - * 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음. + * 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid) + * · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본) + * → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내. + * 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -21,14 +23,24 @@ import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; import { copyEasy, + deleteEasy, + editEasy, listEasy, + newEasy, openEasy, plainError, + usesOf, type EasyListItem, } from "../EasyCalc/M01_EasyCalc_Api"; import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; -import { lbCalcLater, type LbTables } from "./M01_LB_Api"; -import { readLogic, type LbLogic } from "./M01_LB_Blocks"; +import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks"; +import { lookupHits } from "./M01_LB_Check"; +import { drawExamples, renderChecks } from "./M01_LB_Entry_Result"; +import { renderGrid } from "./M01_LB_Grid"; +import { renderSource, type LbRole } from "./M01_LB_Source"; +import { renderSteps } from "./M01_LB_Steps"; +import { openWizard } from "./M01_LB_Wizard"; import "./M01_LB_Entry.css"; const TEXT = { @@ -68,14 +80,23 @@ const TEXT = { View_Grid: ["격자", "Grid"], Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], Value: ["결과 {v} {u}", "Result {v} {u}"], - Examples: ["예시값", "Example values"], - Examples_Note: [ - "예시값은 계산을 보려는 값 — 저장 안 됨", - "Example values are only for trying — not saved", - ], - Pick: ["선택", "Choose"], Sheet: ["일위대가표", "Unit-cost sheet"], - NotYet: ["준비 중", "Coming soon"], + NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"], + Blocked: ["못 읽은 곳:", "Not readable:"], + Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"], + Role_NoInput: [ + "「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함", + "To use “{v}” as a condition, add an input with the same name", + ], + NoName: ["이름을 넣어 주세요", "Enter a name"], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함", "Not saved"], + Delete: ["삭제", "Delete"], + Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["삭제했음", "Deleted"], + Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"], + Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -105,19 +126,32 @@ const amountOf = (resultUnit: string): string => { }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */ +/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */ interface Paper { - key: string; - version: string; + key: string | null; + version: string | null; isBase: boolean; row: LogicRow; trial: Record; lb: LbLogic | null; tables: LbTables; + changed: Set; } +/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */ +interface Snap { + lb: LbLogic | null; + tables: LbTables; + changed: Set; +} +type View = "sentence" | "grid"; -/** 블록 읽기 — 못 읽는 꼴이면 null(세 칸은 원래 줄로 보임) */ -const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => { +/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */ +const blocksOf = ( + row: LogicRow, + key: string | null, + version: string | null, + isBase: boolean, +): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); } catch { @@ -132,16 +166,27 @@ export function createLogicBuilderButton(): HTMLButtonElement { function openLogicBuilder(): void { let body: HTMLElement; - let paper: Paper | null = null; - let dirty = false; + /** 세 칸 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; - let view: "sentence" | "grid" = "sentence"; + let view: View = "sentence"; const mayLeave = async (): Promise => - !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - const notYet = (): void => showToast(te("NotYet"), "info"); + /** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */ + const onKey = (ev: KeyboardEvent): void => { + if (!paper || !(ev.ctrlKey || ev.metaKey)) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } + }; /* ---------- 시작 화면 ---------- */ @@ -166,7 +211,6 @@ function openLogicBuilder(): void { const showStart = async (): Promise => { paper = null; - dirty = false; const status = el("p", { className: "m01-lb__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-lb__list" }); const fieldHost = el("div", { className: "m01-lb__list" }); @@ -215,7 +259,7 @@ function openLogicBuilder(): void { el("div", { className: "m01-lb__start-col", children: [ - bigButton(te("Start_Wizard"), notYet), + bigButton(te("Start_Wizard"), () => void openNew()), el("h4", { text: te("Start_Field") }), fieldHost, ], @@ -240,9 +284,7 @@ function openLogicBuilder(): void { ); const field = logics.filter((x) => !x.isBase); fieldHost.replaceChildren( - ...(field.length - ? field.map(listRow) - : [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]), + ...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]), ); drawFound(); }; @@ -250,76 +292,232 @@ function openLogicBuilder(): void { /* ---------- 세 칸 화면 ---------- */ const openPaper = async (key: string): Promise => { - body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") })); + body.replaceChildren(muted(te("Loading"))); try { - const opened = await openEasy(key); - const lb = blocksOf(opened.row, key, opened.version, opened.isBase); - paper = { key, ...opened, lb, tables: {} }; - dirty = false; - showPaper(); + const o = await openEasy(key); + const lb = blocksOf(o.row, key, o.version, o.isBase); + showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial }); } catch (error) { showToast(plainError(error), "error"); void showStart(); } }; - const showPaper = (): void => { - const p = paper; - if (!p) return; + const openNew = async (): Promise => { + const lb = await openWizard(); + if (!lb) return; + const row = writeLogic(lb); + showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) }); + }; + + const showPaper = (first: Omit): void => { + const p: Paper = { ...first, tables: {}, changed: new Set() }; let ans: CalcAnswer | null = null; let busy = false; + const savedLb = p.key ? p.lb : null; + let stack: Snap[] = []; + const readOnly = p.isBase || !p.lb; + paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() }; const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = !dirty; const total = el("div", { className: "m01-lb__total" }); const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } }); const sheetHost = el("div"); const examplesHost = el("div", { className: "m01-lb__examples" }); + const checksHost = el("div"); const sourceHost = el("div", { className: "m01-lb__slot" }); const basisHost = el("div", { className: "m01-lb__slot" }); + const blockedNote = el("p", { className: "m01-lb__note" }); - const name = createInputField({ label: te("Name"), value: p.row.이름 }); - name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음 + const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row); + const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? ""; + const tableName = (key: string): string | null => + (p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null; + /** 서버에 넘길 표 — 고친 표만 */ + const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]])); + + const name = createInputField({ + label: te("Name"), + value: p.lb?.doc.name ?? p.row.이름, + onInput: (v) => { + if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } }); + }, + }); + name.input.disabled = readOnly; name.root.classList.add("m01-lb__name"); - const drawResult = (pending: boolean): void => { - const unit = p.row.결과단위 ?? ""; - const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; + /** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */ + const later = (host: HTMLElement, draw: () => void, answer: boolean): void => { + const a = document.activeElement; + const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement; + if (answer && typing && host.contains(a)) { + host.addEventListener("focusout", () => setTimeout(draw), { once: true }); + return; + } + draw(); + }; + + const drawSource = (): void => { + if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks"))); + renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), { + edit: onTableEdit, + role: onRole, + }); + }; + const drawBasis = (): void => { + if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks"))); + const on = { change: (lb: LbLogic) => edit(lb) }; + if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName }); + else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial }); + }; + + /** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */ + const draw = (pending: boolean, answer = false): void => { + const unit = unitNow(); + const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; total.textContent = isMoney(unit) - ? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) }) - : te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) }); + ? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) }) + : te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) }); total.classList.toggle("is-pending", pending); sheetHost.classList.toggle("is-pending", pending); - renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans); + unsaved.hidden = !paper?.dirty(); + undo.disabled = !stack.length; + blockedNote.textContent = p.lb?.blocked.length + ? `${te("Blocked")} ${p.lb.blocked.join(" · ")}` + : ""; + renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans); + later(sourceHost, drawSource, answer); + later(basisHost, drawBasis, answer); }; const calc = (): void => { - drawResult(true); - lbCalcLater(p.row, p.trial, p.tables, (next, plain) => { + draw(true); + lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => { ans = next; note.textContent = plain ?? ""; - drawResult(false); + draw(false, true); }); }; + const drawExamplesNow = (): void => + drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => { + p.trial = next; + calc(); + }); + + /** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */ + const edit = (next: LbLogic): void => { + if (readOnly || next === p.lb) return; + stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; + const inputsChanged = next.inputs !== p.lb?.inputs; + p.lb = next; + if (inputsChanged) { + p.trial = { ...autoTrial(next.inputs), ...p.trial }; + drawExamplesNow(); + } + void loadTables(); + calc(); + }; + + /** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */ + const onTableEdit = (e: TableEdit): void => { + const t = p.tables[e.table] as { 줄: Record[] } | undefined; + if (!t) return; + stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; + const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r)); + p.tables = { ...p.tables, [e.table]: { ...t, 줄 } }; + p.changed = new Set([...p.changed, e.table]); + calc(); + }; + + /** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */ + const onRole = (table: string, col: string, role: LbRole): void => { + const lb = p.lb; + if (!lb || readOnly) return; + if (role === "결과") return void (note.textContent = te("Role_Result")); + const hits = lookupHits(lb, p.tables, p.trial); + const rows = (p.tables[table] as { 줄?: Record[] } | undefined)?.줄; + let next = lb; + lb.lookups.forEach((lk, i) => { + if (lk.table !== table || lk.col === col) return; + const id = next.lookups[i]?.id ?? lk.id; + if (role === "조건") { + if (lb.inputs.some((x) => x.이름 === col)) + next = setCond(next, id, col, { k: "input", name: col }); + return; + } + const hit = hits[lk.id]; + const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col]; + const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell; + if (typeof value === "number" || typeof value === "string") + next = setCond(next, id, col, { k: "fixed", value }); + }); + if (next === lb) note.textContent = te("Role_NoInput", { v: col }); + else edit(next); + }; + + /** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */ + const loadTables = async (): Promise => { + const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])]; + const want = keys.filter((k) => !(k in p.tables)); + if (!want.length) return; + const got = await Promise.all( + want.map((k) => + fetchLbTable(k).then( + (t) => [k, t.table] as const, + () => [k, undefined] as const, + ), + ), + ); + p.tables = { ...p.tables, ...Object.fromEntries(got) }; + drawSource(); + drawBasis(); + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + ({ lb: p.lb, tables: p.tables, changed: p.changed } = back); + name.input.value = p.lb?.doc.name ?? p.row.이름; + drawExamplesNow(); + calc(); + }; + + /** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */ + const reopen = (row: LogicRow, key: string, version: string): void => { + const lb = blocksOf(row, key, version, false); + showPaper({ key, version, isBase: false, row, lb, trial: p.trial }); + }; + + const doSave = async (): Promise => { + if (readOnly || busy || !p.lb) return; + const row = writeLogic(p.lb); + if (!row.이름.trim()) return void (note.textContent = te("NoName")); + busy = true; + note.textContent = te("Saving"); + try { + const saved = + p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row); + logics = null; + showToast(te("Saved"), "success"); + reopen(saved.row, saved.key, saved.version); + } catch (error) { + note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; + } finally { + busy = false; + } + }; + const doCopy = async (): Promise => { - if (busy) return; + if (!p.key || busy) return; busy = true; note.textContent = te("Copying"); try { const saved = await copyEasy(p.key); - paper = { - key: saved.key, - version: saved.version, - isBase: false, - row: saved.row, - trial: p.trial, - lb: blocksOf(saved.row, saved.key, saved.version, false), - tables: {}, - }; logics = null; showToast(te("Copied"), "success"); - showPaper(); + reopen(saved.row, saved.key, saved.version); } catch (error) { note.textContent = plainError(error); } finally { @@ -327,44 +525,80 @@ function openLogicBuilder(): void { } }; - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; + const doDelete = async (): Promise => { + if (!p.key || !p.version || p.isBase || busy) return; + busy = true; + note.textContent = te("Uses_Wait"); + try { + const uses = (await usesOf(p.key)).filter((u) => u.key); + if (uses.length) { + note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") }); + return; + } + note.textContent = ""; + const ask = te("Delete_Ask", { v: rowNow().이름 }); + if (!(await showConfirmDialog(ask, te("Delete")))) return; + await deleteEasy(p.key, p.version); + logics = null; + paper = null; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (error) { + note.textContent = plainError(error); + } finally { + busy = false; + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + const fieldActions = [ + undo, + createButton({ label: te("Save"), onClick: () => void doSave() }), + ...(p.key + ? [ + createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), + }), + ] + : []), + close, + ]; const actions = el("div", { className: "m01-lb__actions", - children: [ - ...(p.isBase - ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })] - : [undo, createButton({ label: te("Save"), onClick: notYet })]), - createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }), - ], + children: p.isBase + ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close] + : fieldActions, }); - const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => { + const viewButtons = (["sentence", "grid"] as View[]).map((which) => { const b = createButton({ - label, + label: te(which === "sentence" ? "View_Sentence" : "View_Grid"), variant: view === which ? "filled" : "ghost", size: "sm", onClick: () => { view = which; - showPaper(); + for (const x of viewButtons) { + const on = x.dataset.view === view; + x.classList.toggle("ui-btn--filled", on); + x.classList.toggle("ui-btn--ghost", !on); + x.setAttribute("aria-pressed", String(on)); + } + drawBasis(); }, }); + b.dataset.view = which; b.setAttribute("aria-pressed", String(view === which)); return b; - }; - - sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); - basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); - drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => { - p.trial = next; - calc(); }); body.replaceChildren( @@ -382,25 +616,33 @@ function openLogicBuilder(): void { ], }), note, - ...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []), + ...(p.isBase ? [muted(te("StandardNote"))] : []), el("div", { className: "m01-lb__cols", children: [ column(te("Col_Source"), [sourceHost]), - column( - te("Col_Basis"), - [basisHost], - [viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))], - ), + column(te("Col_Basis"), [blockedNote, basisHost], viewButtons), column(te("Col_Result"), [ total, examplesHost, el("h5", { text: te("Sheet") }), sheetHost, + checksHost, ]), ], }), ); + drawExamplesNow(); + if (p.lb) { + renderChecks(checksHost, { + lb: () => p.lb as LbLogic, + tables: () => p.tables, + trial: () => p.trial, + ans: () => ans, + draftKey: p.key ?? "새", + }); + } + void loadTables(); calc(); }; @@ -412,11 +654,24 @@ function openLogicBuilder(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } +/** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */ +function autoTrial(inputs: LogicInput[]): Record { + const out: Record = {}; + for (const spec of inputs) { + if (String(spec.단위 ?? "").includes("원")) continue; + out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); + } + return out; +} + +const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text }); + /** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement { return el("section", { @@ -431,62 +686,6 @@ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = [ }); } -/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ -function drawExamples( - host: HTMLElement, - inputs: LogicInput[], - trial: Record, - onChange: (trial: Record) => void, -): void { - host.hidden = !inputs.length; - if (!inputs.length) return void host.replaceChildren(); - const state = { ...trial }; - const field = (spec: LogicInput): HTMLElement => { - const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; - let box: HTMLInputElement | HTMLSelectElement; - if (spec.고르기?.length) { - box = el("select", { - className: "ui-select ui-select--sm", - children: [ - el("option", { attrs: { value: "" }, text: te("Pick") }), - ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), - ], - }); - box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - const picked = spec.고르기?.find((v) => String(v) === box.value); - if (picked === undefined) delete state[spec.이름]; - else state[spec.이름] = picked; - onChange({ ...state }); - }); - } else { - box = el("input", { - className: "ui-input ui-input--sm", - attrs: { - type: "number", - ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), - }, - }); - box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - if (box.value === "") delete state[spec.이름]; - else state[spec.이름] = Number(box.value); - onChange({ ...state }); - }); - } - box.setAttribute("aria-label", spec.이름); - return el("label", { - className: "m01-lb__example", - children: [el("span", { text: label }), box], - }); - }; - host.replaceChildren( - el("strong", { text: te("Examples") }), - el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), - el("p", { className: "m01-lb__muted", text: te("Examples_Note") }), - ); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-lb__big"); diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts new file mode 100644 index 000000000..14c22a068 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * M01_LB_Entry_Result.ts + * 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름) + * 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbTables } from "./M01_LB_Api"; +import type { LbLogic } from "./M01_LB_Blocks"; +import { + gapSkipped, + gaps, + loadExpectRows, + pickValues, + runExpect, + saveExpectRows, + type ExpectRow, +} from "./M01_LB_Check"; + +const TEXT = { + Examples: ["예시값", "Example values"], + Examples_Note: [ + "예시값은 계산을 보려는 값 — 저장 안 됨", + "Example values are only for trying — not saved", + ], + Pick: ["선택", "Choose"], + Gaps: ["빈틈 검사", "Check for gaps"], + Gaps_None: [ + "빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음", + "No gaps — every combination finds one row", + ], + Gaps_No: ["해당 없음", "No match"], + Gaps_Dup: ["중복", "Duplicate"], + Gaps_Rows: ["줄 {v}", "rows {v}"], + Gaps_Skip: [ + "못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음", + "{n} lookups not checked — numeric inputs", + ], + Gaps_More: ["그 밖 {n} 건", "{n} more"], + Expect: ["기대값 시험표", "Expected-value table"], + Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"], + Expect_Run: ["모두 돌리기", "Run all"], + Expect_Total: ["합계", "Total"], + Expect_Ok: ["맞음", "Match"], + Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"], + Expect_Del: ["삭제", "Delete"], + Expect_None: [ + "줄 없음 — 예시값을 정한 뒤 한 줄 더하기", + "No rows — set examples, then add a row", + ], + Expect_Note: [ + "시험표는 이 화면에만 남음 — 저장 안 됨", + "The table stays on this screen only — not saved", + ], +} as const; + +type TextKey = keyof typeof TEXT; + +const tr = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +/** 볼 값 이름 — 합계는 「계」 · 값 결과는 「결과」 */ +const totalKey = (ans: CalcAnswer | null): "계" | "결과" => + ans?.ok && ans.result !== undefined ? "결과" : "계"; + +/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ +export function drawExamples( + host: HTMLElement, + inputs: LogicInput[], + trial: Record, + onChange: (trial: Record) => void, +): void { + host.hidden = !inputs.length; + if (!inputs.length) return void host.replaceChildren(); + const state = { ...trial }; + const field = (spec: LogicInput): HTMLElement => { + const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; + let box: HTMLInputElement | HTMLSelectElement; + if (spec.고르기?.length) { + box = el("select", { + className: "ui-select ui-select--sm", + children: [ + el("option", { attrs: { value: "" }, text: tr("Pick") }), + ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), + ], + }); + box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + const picked = spec.고르기?.find((v) => String(v) === box.value); + if (picked === undefined) delete state[spec.이름]; + else state[spec.이름] = picked; + onChange({ ...state }); + }); + } else { + box = el("input", { + className: "ui-input ui-input--sm", + attrs: { + type: "number", + ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), + }, + }); + box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + if (box.value === "") delete state[spec.이름]; + else state[spec.이름] = Number(box.value); + onChange({ ...state }); + }); + } + box.setAttribute("aria-label", spec.이름); + return el("label", { + className: "m01-lb__example", + children: [el("span", { text: label }), box], + }); + }; + host.replaceChildren( + el("strong", { text: tr("Examples") }), + el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), + el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }), + ); +} + +const condText = (conds: Record): string => + Object.entries(conds) + .map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`) + .join(" · "); + +/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */ +export function renderChecks( + host: HTMLElement, + s: { + lb(): LbLogic; + tables(): LbTables; + trial(): Record; + ans(): CalcAnswer | null; + draftKey: string; + }, +): void { + const gapHost = el("div", { className: "m01-lb__check-out" }); + const rowsHost = el("div", { className: "m01-lb__check-out" }); + let rows: ExpectRow[] = loadExpectRows(s.draftKey); + let results: (boolean | number | null)[] = []; + const keep = (): void => saveExpectRows(s.draftKey, rows); + + const runGaps = (): void => { + const found = gaps(s.lb(), s.tables()); + const skipped = gapSkipped(s.lb(), s.tables()).length; + const lines = found.slice(0, 20).map((g) => + el("li", { + text: [ + g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"), + condText(g.conds), + g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "", + ] + .filter(Boolean) + .join(" — "), + }), + ); + gapHost.replaceChildren( + found.length + ? el("ul", { children: lines }) + : el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }), + ...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []), + ...(skipped + ? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })] + : []), + ); + }; + + const drawRows = (): void => { + if (!rows.length) { + rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") })); + return; + } + rowsHost.replaceChildren( + ...rows.map((row, i) => { + const key = Object.keys(row.expect)[0] ?? "계"; + const box = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "number", "aria-label": tr("Expect_Total") }, + }); + box.value = String(row.expect[key] ?? ""); + box.addEventListener("change", () => { + row.expect = { [key]: Number(box.value) }; + results[i] = null; + keep(); + }); + const r = results[i]; + return el("div", { + className: "m01-lb__expect-row", + children: [ + el("span", { text: condText(row.inputs) || "—" }), + box, + el("span", { + className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "", + text: + r === true + ? tr("Expect_Ok") + : typeof r === "number" + ? tr("Expect_Bad", { v: formatNumber(r) }) + : "", + }), + createButton({ + label: tr("Expect_Del"), + variant: "ghost", + size: "sm", + onClick: () => { + rows = rows.filter((_, j) => j !== i); + results = results.filter((_, j) => j !== i); + keep(); + drawRows(); + }, + }), + ], + }); + }), + ); + }; + + const addRow = (): void => { + const ans = s.ans(); + const key = totalKey(ans); + const now = ans?.ok ? pickValues(ans)[key] : undefined; + rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }]; + keep(); + drawRows(); + }; + + const runRows = async (): Promise => { + const got = await runExpect(s.lb(), s.tables(), rows); + results = got.map((g, i) => { + const key = Object.keys(rows[i].expect)[0] ?? "계"; + return g.ok ? true : (g.got[key] ?? null); + }); + drawRows(); + }; + + host.replaceChildren( + el("div", { + className: "m01-lb__check", + children: [ + createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }), + gapHost, + el("strong", { text: tr("Expect") }), + el("div", { + className: "m01-lb__check-tools", + children: [ + createButton({ + label: tr("Expect_Add"), + variant: "ghost", + size: "sm", + onClick: addRow, + }), + createButton({ + label: tr("Expect_Run"), + variant: "ghost", + size: "sm", + onClick: () => void runRows(), + }), + ], + }), + rowsHost, + el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }), + ], + }), + ); + drawRows(); +} From 57ce7bb21bdf1f2b4f02dec9dfd00096db873ec4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:12:00 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore(M01):=20=ED=98=84=EC=9E=A5=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20GX000001=20=EB=8F=8C?= =?UTF-8?q?=EC=8C=93=EA=B8=B0=20=EB=A9=94=EC=8C=93=EA=B8=B0(=EC=9D=B8?= =?UTF-8?q?=EB=A0=A5)=20=E2=80=94=20=EB=A9=94=EC=9D=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=ED=99=94=EB=A9=B4=20=ED=8E=B8=EC=A7=91(=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=ED=8E=B8=EC=A7=91=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B4=84)?= 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 --- resources/master_data/_키대장.json | 3 ++- resources/master_data/로직_자체.json | 33 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 resources/master_data/로직_자체.json diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index 1a86176db..da881e552 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 1, "UA": 38}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 2, "UA": 38}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -2628,6 +2628,7 @@ "GF000328": {"원문번호": "13-11-1 원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000329": {"원문번호": "13-11-2 타원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000330": {"원문번호": "13-15-1 목재 껍질벗기기", "파일": "로직_산림품셈_13장_구조물.json"}, + "GX000001": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력)", "파일": "로직_자체.json"}, "LB000001": {"원문번호": "1001", "파일": "인력.json"}, "LB000002": {"원문번호": "1002", "파일": "인력.json"}, "LB000003": {"원문번호": "1003", "파일": "인력.json"}, diff --git a/resources/master_data/로직_자체.json b/resources/master_data/로직_자체.json new file mode 100644 index 000000000..767ef13bd --- /dev/null +++ b/resources/master_data/로직_자체.json @@ -0,0 +1,33 @@ +{ + "그룹": "로직", + "원문": "자체", + "판": "2026", + "줄": [ + { + "키": "GX000001", + "원문번호": "13-4-1", + "구분": "자체", + "상세구분": "13장 구조물", + "이름": "돌쌓기 메쌓기(인력)", + "결과단위": "원/㎡", + "출처": "산림품셈 13-4-1", + "소유": "현장", + "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", + "입력": [ + { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, + { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, + { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, + { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, + { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } + ], + "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], + "호표": [ + { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, + { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } + ], + "덧줄": [], + "끝수": null, + "시험입력": { "높이": 4.0, "돌": "깬잡석" } + } + ] +} From 545602f24de84845824a6c6fa0692fcfd09a5a3a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:19:48 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(M01-=EB=A1=9C=EC=A7=81=ED=8C=90):=20Ste?= =?UTF-8?q?ps=20=EB=8B=A4=EB=A5=B8=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=A1=9C=EC=A7=81=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9E=84=20-=20?= =?UTF-8?q?=EB=AA=BB=20=EC=B0=BE=EC=9C=BC=EB=A9=B4=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=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 --- M01_MasterData/LogicBuilder/M01_LB_Steps.ts | 24 ++++++++++++++++++- .../LogicBuilder/M01_LB_Steps_Cell.ts | 7 ++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts index 9e92ed7e1..0dca8c47b 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts @@ -6,7 +6,12 @@ * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { CalcAnswer, CalcLine, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { + fetchLogics, + type CalcAnswer, + type CalcLine, + type LogicInput, +} from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { addCard, @@ -23,6 +28,7 @@ import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Block import { calcBody, calcView } from "./M01_LB_Steps_Calc"; import { chip, + logicNames, lookupValue, note, numBox, @@ -42,6 +48,7 @@ import "./M01_LB_Steps.css"; type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; let picking = false; +let namesLoading = false; /* ------------------------------ ① 입력값 ------------------------------ */ @@ -367,4 +374,19 @@ export function renderSteps( ), section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]), ); + // 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림 + if ( + !logicNames.size && + !namesLoading && + JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"') + ) { + namesLoading = true; + fetchLogics() + .then((all) => { + for (const l of all) logicNames.set(l.키, l.이름); + renderSteps(host, lb, ans, on); + }) + .catch(() => undefined) + .finally(() => (namesLoading = false)); + } } diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts index e3b1ae55d..35a73f203 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts @@ -33,7 +33,7 @@ export const TEXT = { SrcCalc: ["계산 · ", "calc · "], SrcFixed: ["고정값", "fixed value"], SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"], - Other: ["다른 일위대가 결과", "Other unit price result"], + Other: ["다른 일위대가", "Other unit price"], Hard: ["이 화면에서 못 고치는 값", "Value not editable here"], Mul: ["×", "×"], Div: ["÷", "÷"], @@ -197,6 +197,9 @@ export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && export const lookupNo = (ctx: Ctx, id: string): number => ctx.lb.lookups.findIndex((l) => l.id === id) + 1; +/** 로직 이름 — 목록을 한 번 받아 채움(키 → 이름) · 못 받으면 「다른 일위대가」 */ +export const logicNames = new Map(); + export function refText(ctx: Ctx, r: Ref): string { switch (r.k) { case "input": @@ -210,7 +213,7 @@ export function refText(ctx: Ctx, r: Ref): string { return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`; } case "logic": - return `${tx("Other")} ${r.key}${r.field ? ` · ${r.field}` : ""}`; + return `${logicNames.get(r.key) ?? tx("Other")}${r.field ? ` · ${r.field}` : ""}`; case "cost": return tx(`Base_${r.name}`); default: From 89f8af2ad4bd5bac4da19ddf12f16e23dafde3c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:36:31 +0900 Subject: [PATCH 6/8] =?UTF-8?q?chore(M01):=20=ED=98=84=EC=9E=A5=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20GX000002=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=EC=9E=AC=20=C2=B7=20GX000003=20=EB=8F=8C=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EB=A9=94=EC=8C=93=EA=B8=B0=20=E2=80=94=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8F=B4=EB=8D=94=20=ED=99=94=EB=A9=B4=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91(=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=8E=B8=EC=A7=91)?= 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 --- resources/master_data/_키대장.json | 4 ++- resources/master_data/로직_자체.json | 42 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index da881e552..bade8a4aa 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 2, "UA": 38}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 4, "UA": 38}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -2629,6 +2629,8 @@ "GF000329": {"원문번호": "13-11-2 타원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000330": {"원문번호": "13-15-1 목재 껍질벗기기", "파일": "로직_산림품셈_13장_구조물.json"}, "GX000001": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력)", "파일": "로직_자체.json"}, + "GX000002": {"원문번호": "12-34-4 암거이음받침 채움재(T=20㎜)", "파일": "로직_자체.json"}, + "GX000003": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력) (2)", "파일": "로직_자체.json"}, "LB000001": {"원문번호": "1001", "파일": "인력.json"}, "LB000002": {"원문번호": "1002", "파일": "인력.json"}, "LB000003": {"원문번호": "1003", "파일": "인력.json"}, diff --git a/resources/master_data/로직_자체.json b/resources/master_data/로직_자체.json index 767ef13bd..87528c301 100644 --- a/resources/master_data/로직_자체.json +++ b/resources/master_data/로직_자체.json @@ -28,6 +28,48 @@ "덧줄": [], "끝수": null, "시험입력": { "높이": 4.0, "돌": "깬잡석" } + }, + { + "키": "GX000002", + "원문번호": "12-34-4", + "구분": "자체", + "상세구분": "12장 철근콘크리트", + "이름": "암거이음받침 채움재(T=20㎜)", + "결과단위": "원/㎡", + "출처": "산림품셈 12-34-4", + "소유": "현장", + "비고": "해석: 원문 재료비 「JOINT FILLER」 뿐 — ㎡당 1㎡ · 설치 품 원문 없음 · GF000208 를 본뜸", + "입력": [], + "중간": [], + "호표": [{ "종류": "재료", "요소": { "구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER" }, "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비" }], + "덧줄": [], + "끝수": null + }, + { + "키": "GX000003", + "원문번호": "13-4-1", + "구분": "자체", + "상세구분": "13장 구조물", + "이름": "돌쌓기 메쌓기(인력)", + "결과단위": "원/㎡", + "출처": "산림품셈 13-4-1", + "소유": "현장", + "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", + "입력": [ + { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, + { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, + { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, + { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, + { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } + ], + "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], + "호표": [ + { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, + { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } + ], + "덧줄": [], + "끝수": null, + "시험입력": { "높이": 4.0, "돌": "깬잡석" } } ] } From 87f2d04fa7aaf05e9e97472411d0e08fad325c66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:49:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore(M01):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=8B=9C=ED=97=98=ED=8C=90=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=A0=9C=EC=96=B4=20=EC=A0=90=EA=B2=80=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=97=AC?= =?UTF-8?q?=EC=84=AF?= 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 | 63 +++++++++++++++++++ 1 file changed, 63 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..94c353e78 --- /dev/null +++ b/docs/raw/M01_쉬운단가/로직판_관계제어_점검.md @@ -0,0 +1,63 @@ +# 35-3 점검 — 로직 만들기(시험)에서 「표끼리의 관계」를 초등학생이 제어할 수 있나 (sub6 · 확인만) + +- 대상: 내 포트 8008 · 5181 → M01 → 단가산출 로직 → 「로직 만들기(시험)」. git-sync 로 dev 최신(105 커밋)을 받은 뒤 서버를 다시 띄움. 판정 직전 세션 SYSTEM_ADMIN 확인. +- 시험 로직: 돌쌓기 메쌓기(인력) — 표준 GF000219 은 읽기만이라, 이미 있는 현장 사본 GX000001(같은 식)을 열어 메모리에서만 고침. **저장 · 복사 편집 · 삭제는 안 누름**. 끝에 「실행 취소」 10번으로 모두 되돌린 것 확인. 서버 로직 수 그대로 · git 깨끗. 덤프트럭 시간당 작업량 GF000133 · 그것을 부르는 골재운반 모래 GF000134 는 읽기만. +- 사진: `tmp/로직판_관계_사진/` (r01 표준 열기 · r02 현장 사본 · r03 문턱만 바꿈 → 금액 멈춤 · r04 표 칸까지 고침 → 할증 걸림 · r05 격자 · r06 비율 더하기 · r07 격자 칠하기 · r08 로직 부름). +- 걸음 = 고칠 화면이 열린 뒤 누름 수(칸에 글 넣기 1 번 = 1). 들어가기는 M01 → 단가산출 로직 → 로직 만들기(시험) → 로직 고름 = 3 누름. 표준이면 「복사 편집」 1 더(누르는 순간 서버에 현장 사본이 써짐 · 저장 단추를 거치지 않음). + +## 한 줄 결론 + +**이미 있는 관계의 「값 칸」을 바꾸는 일(문턱 숫자 · 할증율 · 조건을 고정값으로)은 고르기 하나 · 숫자 하나로 되고 식은 안 씀. 그러나 관계 자체를 더하고 · 지우고 · 다른 표나 다른 결과 칸으로 잇는 일은 화면에 단추가 없어 거의 안 됨.** 품셈에 없는 관계(새 입력값 · 새 조건 · 새 표)는 만들 수 없고, 「+ 비율」 한 갈래만 있음. 초등학생 판정은 바꾸기 = 중, 더하기 · 지우기 · 다시 잇기 = 불가. + +## 시나리오 표 + +| # | 할 일 | 됨 | 걸음 | 지나간 화면 | 막힌 곳 | 모를 낱말 | 식을 쓰나 | 초등 | 까닭 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| S1① | 높이 할증 문턱 3m → 2.5m | 일부 | 3 (문턱 칸 1 + 원문 표 칸 「3~4」→「2.5~4」 2) | 산출근거 ③ 계산 · 원문 표 | 문턱만 바꾸면 2.5~3m 에서 증가율 표에 맞는 줄이 없어 금액이 「—」로 멈춤. 표 칸 고침은 「미리 보기 — 저장은 결정 뒤」라 저장되는 것은 문턱뿐. 멈출 때 위 알림은 「서버와 이어지지 않음」(실제 까닭은 「표 QF000422 에 높이 2.7 인 줄이 없음」) | 조건 목록 · 「위에서 처음 맞는 줄의 값을 씀」 · 그 외 · 해당 없음 | 아니요 | 하 | 칸은 쉬우나 문턱과 표 범위가 엮인 것이 안 보이고, 깨진 뒤 까닭이 엉뚱함 | +| S1② | 높이 할증을 석공에만 · 보통인부는 빼기 | 됨(꼼수) | 1 (계산 3 율 칸 「계산 · 증가율」→「고정값」 · 0 이 저절로) | ③ 계산 | 「할증 빼기」 단추는 없음 — 율 0% 로 둠. 계산 3 은 남음 | 바탕 · 율 · 「% 를 더 얹음」 · 계산 · 증가율 | 아니요 | 중 | 문장 「표 조회 3 · 보통인부 에 계산 · 증가율 = 30 % 를 더 얹음」을 읽어야 고를 칸을 찾음 | +| S1③ | 증가율 표 대신 고정 30% | 됨(일부) | 4 (계산 2 · 3 율 칸 → 고정값 → 30 · 두 번) | ③ 계산 | 높이와 상관없이 늘 30%(3m 아래도). 「3m 넘을 때만 30%」는 계산 1 둘째 줄 「표에서 찾은 값」을 숫자로 못 바꿔 안 됨. 안 쓰게 된 계산 1 · 표 조회 1 을 지울 길 없음 | 고정값 · 율 | 아니요 | 중 | 율 칸 둘을 따로 바꿔야 함을 스스로 알아야 함 | +| S2① | 조건 칸 「돌」 입력값 → 고정 「깬돌」 | 됨 | 4 (표 조회 2 「돌」 고르기 → 고정값 → 「깬돌」 글 · 표 조회 3 에 또) | ② 표 조회 | 같은 표를 석공 · 보통인부 두 번 조회 — 한쪽만 바꾸면 석공은 깬돌 · 보통인부는 깬잡석 품으로 섞여 계산됨(실측 121,863원 · 경고 없음). 예시값 뒷길이 25 에는 깬돌 줄이 없어 「—」. 원문 표 칸 역할 알약도 「고정」으로 바뀌지만 어느 조회 것인지 안 보임 | 표 조회 · 무관(조건에서 뺌) · 고정값 | 아니요(글 「깬돌」은 손으로 적음) | 중 | 고르기는 쉬우나 같은 조건이 두 곳에 있음 · 오타면 해당 없음 | +| S2② | 쓰는 표 바꾸기(메쌓기 → 찰쌓기 표) | 안 됨 | — | ② 표 조회 | 표 이름은 글자 조각 — 누를 곳 없음. 바꾸는 함수(`setLookupTable` · `M01_LB_Blocks.ts:627`)는 있으나 화면에 안 붙음 | — | — | 불가 | 화면에 길 없음 | +| S2③ | 결과 칸 바꾸기(석공 → 보통인부 칸) | 안 됨(꼼수 하나) | 원문 칸 알약 1 → 「결과 칸 바꾸기는 아직 안 됨」 | 원문 · ② 표 조회 | 「인 줄의 석공」은 글자 조각. 알약은 조건 → 고정 → 결과로 돌고, 결과 칸을 조건으로 돌리면 「「석공」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함」. 꼼수: 계산 2 바탕을 「표 조회 3 · 보통인부」로 고르면 석공 줄이 보통인부 품을 씀(고르기 목록에 있음 · 실측 안 함) | 결과 · 바탕 | 아니요 | 불가 | 표 조회의 결과 칸을 고르는 곳이 없음 | +| S3① | 새 입력값 「경사」(완 / 급) · 「급이면 석공 품 10% 더함」 | 안 됨 | — | ① 입력값 · ③ 계산 | 입력값 더하기 · 지우기 단추 없음(① 은 단위 · 범위만 고침 · 선택 목록 낱말도 못 고침). 계산 더하기 없음. 단계 안내(새로 만들기)에서만 숫자 입력값을 「입력값으로 받음」으로 만들 수 있음 — 선택(완/급) 꼴 아님 · 모든 결과 칸에 같이 걸림 | — | — | 불가 | 화면에 길 없음 | +| S3② | 품셈에 없는 작은 표 만들어 잇기 | 안 됨 | — | 원문 | 표 만들기 화면 없음(이 판 · 옛 M01 표 화면 모두). 원문 표 칸 고침은 미리 보기만 · 줄 더하기도 없음 | — | — | 불가 | 표는 품셈 원문에서만 옴 | +| S3③ | 새 항목 「잡재료 = 노무비의 2%」 | 일부 | 2 (「+ 비율」 · 2 입력 · 바탕 노무비는 기본) | ④ 항목 연결 | 2,437원 더해짐. 그러나 이름 칸 없음(일위대가표에 이름 빈 「1 식」) · 넣을 자리 고르기 없음 → 경비로 들어감(잡재료는 재료비여야). 단계 안내에서는 이름 · 넣을 자리 있음 | 비율 · 노무 줄의 합 | 아니요 | 중 | 덧붙임 한 갈래만 · 이름 · 자리 못 정함 | +| S4 | 관계 지우기(할증 · 표 조회 · 입력값) | 안 됨 | — | ② ③ ① | 할증 · 표 조회 · 입력값 · 계산 모두 지움 단추 없음(할증은 율 0 꼼수). 항목 「지움」은 1 누름 · 안 물음 — 보통인부를 지워도 그 줄이 쓰던 표 조회 3 · 계산 3 이 화면에 남고 알림 없음. 「실행 취소」 10번으로 표 칸 고침까지 다 되돌아옴(확인) · 로직 삭제는 쓰는 곳 검사 뒤 물음 | — | 아니요 | 불가 (항목만 상) | 관계를 지우는 길이 없음 | +| S5 | 관계 한눈에 보기 | 일부 | 0 (보기 바꿈 1) | 문장 · 격자 · 원문 | **문장 보기**: ① → ⑤ 차례 · 「표 조회 2 메쌓기(인력) 에서 뒷길이 이 입력값 · 뒷길이 … 인 줄의 석공」 → 「계산 2 석공 수량 표 조회 2 · 석공 에 계산 · 증가율 = 30 % 를 더 얹음」 → 「【노무】 석공 이 계산 2 = 0.195 인 = 52,437 원」 — 번호를 거꾸로 따라가면 길이 보이나 선 · 화살표 없음. **격자 보기**: 줄을 누르면 그 줄이 쓰는 줄 하나만 칠함(석공 → 계산 2 · 표 조회 · 입력값까지 안 이어짐) · 값 칸이 오른쪽에 잘려 고정값(30 · 깬돌)이 안 보임 · 표 이름 대신 QF000421. **원문**: 「이 표를 쓰는 로직 1 개」 · 주③ 「계산에 쓰임」 · 칸 역할 알약 | 표 조회 1 · 2 · 3 번호 · 바탕 · QF 번호 | 아니요 | 중 | 읽을 수는 있으나 한 장 그림이 아님 | +| S6 | 로직끼리 관계(작업량 로직 → 받는 로직) | 보기 일부 · 바꾸기 안 됨 | 0 | 산출근거 | 골재운반 모래(GF000134) 에 「계산 1 작업량 = 60 · 골재운반 덤프트럭 시간당 작업량」 글자만. 넘기는 값(qt=qt · f=f · E=E · Cm=Cm) 안 보임 · 눌러서 그 로직 열기 없음 · 고르기 목록에 「다른 로직」 없음. GF000133 쪽에 「이 로직을 쓰는 로직」 목록 없음(표에만 있음 · 실제는 GF000134 · 135 · 136 셋). 계산 2 문장이 「1 = 1 ÷ 작업량 = 60」으로 읽기 어색 | qt · f · E · Cm(기호 그대로) | 아니요 | 하 | 보이기만 하고 따라가기 · 바꾸기 길 없음 | + +## 안 되는 것이 왜 안 되나 (코드 근거) + +1. 블록 모형이 「있는 관계 고치기」 세 함수뿐 — `setCond`(조건 칸 출처) · `setLookupTable`(쓰는 표 · 화면에 안 붙음) · `setCalc`(계산 조각 · 틀) — `M01_LB_Blocks.ts:604-645`. 표 조회 · 계산 · 입력값을 **더하거나 지우는 함수가 없음**. 고침은 원래 식 글의 그 자리만 갈아끼우는 방식이라 새 자리를 만들 수 없음. +2. 화면 쪽도 같은 범위 — 문장 보기는 항목 카드만 더하기 · 지우기(`M01_LB_Steps.ts:172` `addCard` · `:249` `removeCard`) · 격자 보기는 `setCond` · `setCalc` 만(`M01_LB_Grid.ts:369-412`) · 계산 틀(할증 → 값 등)은 못 바꿈. +3. 값 고르기 목록 = 입력값 · 표 조회 · 다른 계산 · 고정값(숫자)만(`M01_LB_Steps_Cell.ts:235-275`). 「다른 로직」 · 「글 고정값」은 목록에 없어 로직 부름 줄은 글자 조각으로만 보임. +4. 표 조회의 결과 칸 바꾸기는 「아직 안 됨」으로 막아 둠(`M01_LB_Entry.ts:86` · `:437`). +5. 표 칸 고침은 정함 5대로 메모리 사본 · 저장 안 함 — 그래서 S1① 처럼 관계와 표 범위를 함께 바꿔야 하는 일은 저장 뒤 깨짐. +6. 새 입력값 · 새 표 · 조건 「급이면」 같은 품셈에 없는 관계는 단계 안내(새로 만들기)에만 일부(숫자 입력값 · 품셈 절의 다른 표로 할증 조건 목록) — 고치는 화면에는 길이 없음. +7. 「+ 비율」 카드에는 이름 · 넣을 자리 칸이 없음(단계 안내의 덧줄은 있음) — 같은 뜻의 두 길이 서로 다름. + +## 지금 화면(옛 단가산출)에서는 + +공통: 표준 로직은 못 고침 → 「본떠 만들기」로 자체 사본을 만든 뒤 편집기에서 식 글을 고침(34-1B 실측 + 코드). 거의 다 되지만 모두 **식을 써야 함 → 초등 하**. + +| # | 옛 화면 | 어떻게 | +| --- | --- | --- | +| S1① | 됨 · 식 | 중간 값 식 `만약(높이 <= 3, 0, …)` 의 3 → 2.5 (표 범위 문제는 같음) | +| S1② | 됨 · 식 | 보통인부 수량 식 끝 `* (1 + 증가율 / 100)` 지움 | +| S1③ | 됨 · 식 | 수량 식을 `… * 1.3` 으로 · 또는 중간 값 증가율 = 30 | +| S2① | 됨 · 식 | `찾기(QF000421, …, 돌=돌, …)` → `돌='깬돌'` (두 줄 다) | +| S2② | 됨 · 식 | 식 안 QF 번호 고침 · 또는 「표」 단추(수량 칸을 통째로 바꿈) | +| S2③ | 됨 · 식 | 식 끝 `.석공` → `.보통인부` | +| S3① | 됨 · 식 | 설계에서 받을 값 「줄 추가」(이름 경사 · 고르기 「완, 급」) + 수량 식에 `* 만약(경사 == '급', 1.1, 1)` | +| S3② | 안 됨 | 표 만들기 없음 · `만약` 식으로 흉내만 | +| S3③ | 됨 · 식 조금 | 덧줄 「줄 추가」 · 이름 잡재료 · 식 `노무비 * 0.02` · 비목 재료비 | +| S4 | 됨 · 안 물음 | 칸마다 × — 쓰는 곳 검사 없음 · 저장 때 엔진 검사(422)로만 걸림 | +| S5 | 일부 | 텍스트 수식(이름 식 · 값 식 두 칸) · 알약 누르면 표 창 | +| S6 | 됨 · 식 | 중간 값 `로직(GF000133, qt=qt, f=f, E=E, Cm=Cm)` 글 고침 · 알약 「로직」 누르면 그 로직을 엶 | + +## 덧붙여 본 것 + +- 계산이 멈추면 위 알림이 「서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요」 — 서버는 `ok:true` 와 줄마다 까닭(「표 QF000422(13-4-1)에 높이 2.7 인 줄이 없음」)을 줌. 원문 표 위의 「해당 없음 — 예시값에 맞는 줄이 없음」만 맞는 말. +- 칸 고침은 글을 넣고 칸을 떠나야(Enter · 다른 곳 누름) 반영됨. +- 고친 뒤 처음 값으로 다 되돌리면 「닫기」가 묻지 않고 닫힘(맞는 동작) · 「저장 안 함」 표시는 그대로 남아 있었음. +- 원문 칸 역할 알약은 조건 → 고정 → 결과로 돌기만 하고 무엇이 바뀌는지 미리 말하지 않음. From 89220e3d3fee10c54ec1465fdf0fa8e335a64879 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 12:57:09 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore(M01):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=8B=9C=ED=97=98=ED=8C=90=20=EC=A0=95?= =?UTF-8?q?=ED=95=A8?= 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 | 38 ++++++++++++++++++++++++++ 1 file changed, 38 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..a3f21d53f --- /dev/null +++ b/docs/raw/M01_쉬운단가/시트로직_정함.md @@ -0,0 +1,38 @@ +# 시트 로직 시험판 — 정함 (PLAN 35-5 · 브레인 · 10-01 오후) + +사용자 허락(그대로): 「일단 이해한대로 시험판을 만들어보자. 완전히 새로운 품셈을 사용자가 만들려면 기존에 있는 정보를 가져올수도 있지만 새로 만들기도 되어야하면 그럴려면 저장이 되어야할것같아. 일단은 시험판이니까 임시폴더 내부에 저장하고 이상하면 한번에 삭제하는거야.」 +제안서 = https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 근거 = 같은 폴더 `로직판_관계제어_점검.md`(지금 시험판은 관계 더하기 · 지우기 · 다시 잇기 · 새 표가 안 됨) · `로직판_엑셀로직_지도.md` 2 · 3절(우리 스프레드시트 엔진 · 서버 Node 다시 셈). + +## 목적 + +사용자가 코딩 · 브레인 없이, 품셈에 묶이지 않고 단가산출 로직을 새로 만들고 · 기존 정보(품셈 표 · 노임 · 자재 · 장비 · 기존 로직)를 가져오고 · 관계를 더하고 · 바꾸고 · 지움. 관계 = 진짜 엑셀 수식 · 입출력 칸 = 알약. + +## 정함 + +1. **로직 한 벌 = 통합문서 하나**(우리 스프레드시트 Workbook) · **상자 하나 = 시트 하나** · **알약 = 이름 붙은 칸**(입력 알약 · 출력 알약). +2. **선 = 입력 알약 칸에 다른 시트 칸 참조 수식을 저절로 씀**(`='표 · 메쌓기'!E7`) — 엔진이 시트 사이 참조를 이미 셈 · 선을 지우면 그 수식을 지움 · 순환은 엔진 `#CYCLE!` + 그을 때 막음. +3. 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과. 모든 상자 아래 판은 같은 시트 편집기 + 종류별 손잡이. +4. **바깥 값 넣기** — 품셈 표 줄 · 노임 · 자재 · 장비 단가 · 기존 로직 결과는 시트에 「바깥 값」 칸으로 넣음: 화면은 열 때 API 로 받아 넣고, 저장 때 서버가 마스터에서 다시 넣은 뒤 셈(브라우저 값 안 받아 적음). 품셈 표는 사본으로 들고 있지 않음(열 때 · 저장 때 마스터에서 새로). +5. **계산 = 우리 스프레드시트 엔진 한 벌** — 화면은 브라우저, 저장은 서버가 같은 TS 엔진을 Node 로(`common_util/common_util_spreadsheet.py` 길 · 계산 자리 ②). 지금 로직 1,469개(파이썬 엔진)는 안 건드림. +6. **저장 = 임시 폴더** `M01_MasterData/SheetLogic/_data/` (폴더 안 `.gitignore` 로 git 밖) · 키 = `SL-0001` 꼴(자체 번호 · `_키대장.json` 안 씀) · 사용자 표도 여기. 폴더 `SheetLogic/` 를 지우면 코드 · 자료가 한 번에 사라짐. +7. **폴더 밖** — 단추 한 곳(LogicLab.ts 가져오기 한 줄 + 부르기 한 줄 · 「로직 만들기(시험)」 옆 「시트 로직(시험)」) · 서버 등록 한 줄(main.py). 기존 서버 · 엔진 · 마스터 파일 · EasyCalc · LogicBuilder 파일은 안 고침(가져다 쓰기만). +8. **화면** — 위 가로 다이어그램 띠(입력값 → 표 → 계산 → 항목 → 결과 · 단계마다 세로 쌓기 · 자리는 저절로 · 상자 양옆에 입력 · 출력 알약 · 출력 알약을 끌어 입력 자리에 놓으면 선) · 아래 왼쪽 = 고른 상자의 시트 판 · 아래 오른쪽 = 결과(합계 · 노무비 · 재료비 · 경비 · 일위대가표 · 예시값). +9. **가져오기** — 기존 로직을 시트 로직으로 바꿔 가져옴(LogicBuilder Blocks 로 읽고 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · 표 찾기 → 표 상자 + XLOOKUP 또는 INDEX/MATCH) · 시험 로직 6개(GF000208 · GF000219 · GF000025 · GF000249 · GF000133 · GC000051)는 바꾼 값 = 지금 /calc 값이어야 함. +10. **용어** — 시트 로직 · 상자 · 선 · 입력 알약 · 출력 알약 · 입력값 · 고정값 · 표 · 계산 · 항목(노무 · 재료 · 장비 · 일위대가 · 비율) · 다른 로직 · 결과 · 예시값 · 품셈 표 / 내 표 · 노무비 · 재료비 · 경비. + +## 파일 · 창 (모두 `M01_MasterData/SheetLogic/` · 파일마다 700줄 안) + +| 파일 | 창 | 하는 일 | +| --- | --- | --- | +| `M01_SL_Model.ts` · `M01_SL_Eval.ts` | sub7 | 타입(먼저 올림) · 상자 더하기 / 지우기(시트 만들기 · 지우기) · 선 잇기 / 끊기(참조 수식 쓰기 · 지우기) · 알약 정하기 · 검사(순환 · 빈 입력) · 화면 계산(바깥 값 넣기 → 엔진 → 출력 알약 · 결과 읽기) | +| `M01_SL_Router.py` · `M01_SL_Api.ts` · `.gitignore` | sub4 | `_data` 목록 · 열기 · 저장 · 지우기 · 저장 때 바깥 값 다시 넣고 Node 로 셈 · 바깥 값 한 번에 받기(표 · 노임 · 자재 · 장비 · 기존 로직 결과) · 등록 한 줄 | +| `M01_SL_Diagram.ts` + css | sub8 | 위 가로 띠 · 상자 · 알약 · 선(끌어 잇기 · 고르기 · 지우기) · 「+」 상자 더하기 · 이어진 선 진하게 | +| `M01_SL_Panel.ts` + css | sub2 | 아래 시트 판(우리 스프레드시트 편집기 넣기) · 칸 → 입력 / 출력 알약 정하기 · 칸 위 알약 표시 · 틀 단추(할증 · 나누기 · 만약 · 표에서 찾기 — 진짜 수식을 넣어 줌) | +| `M01_SL_Table.ts` + css | sub3 | 표 상자 손잡이 — 품셈 표 고르기(절 · 이름 찾기) → 시트에 넣기 · 내 표(빈 표 · 엑셀 붙여넣기) · 조건 칸 / 결과 칸 알약 · 「표에서 찾기」 수식 돕기 | +| `M01_SL_Item.ts` · `M01_SL_Result.ts` + css | sub5 | 항목 상자 손잡이(노무 · 재료 · 장비 · 일위대가 고르기 → 단가 바깥 값 · 수량 알약 · 노무비 / 재료비 / 경비 출력) · 결과 칸(합계 · 비목 · 일위대가표 · 예시값) | +| `M01_SL_Import.ts` | sub6 | 기존 로직 → 시트 로직(정함 9) · 6개 같은 값 시험 | +| `M01_SL_Entry.ts` + css | sub1 | 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 세 칸 틀 · 저장 · 지우기 · 실행 취소 · 저장 안 함 물음 · 잇기 · 끝 검증 | + +## 끝 검증 (sub1 · 사용자처럼) + +관계 제어 점검 여섯이 모두 되어야 함 — S1 할증 문턱 · 석공에만 · 고정 30% · S2 조건을 고정값으로 · 쓰는 표 바꾸기 · 결과 칸 바꾸기 · S3 새 입력값 「경사」 + 「급이면 10%」 · 내 표 만들어 잇기 · 새 항목 · S4 관계 지우기 · S5 한눈에 보기 · S6 로직끼리 잇기 + 완전히 새 로직 하나(품셈 없이) 만들어 저장 → 다시 열기 → 지우기 · 기존 로직 6개 가져와 값 같음 · 저장 때 서버 값 = 화면 값.