From e9447ae72f14490ad71e6c4181952641da48b374 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 20:13:05 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EC=8B=9C=ED=97=98=20=E3=80=8C=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0?= =?UTF-8?q?=E3=80=8D=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=8B=9D=20?= =?UTF-8?q?=EC=A0=81=EA=B8=B0=20=C2=B7=20=EB=B6=84=EC=84=9D=20=C2=B7=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=A0=95=ED=95=98=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=A7=89=ED=9E=8C=20=EC=9E=90=EB=A6=AC=20=EC=A7=9A=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=ED=97=98=20=EA=B3=84=EC=82=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20(PLAN=203-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 39 ++- .../M01_MasterData_UI_LogicLab_New.css | 105 +++++++ .../M01_MasterData_UI_LogicLab_New.ts | 272 ++++++++++++++++++ .../M01_MasterData_UI_LogicLab_New_Find.ts | 218 ++++++++++++++ .../M01_MasterData_UI_LogicLab_New_Text.ts | 71 +++++ .../M01_MasterData_UI_LogicLab_New_Var.ts | 178 ++++++++++++ M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 63 ++++ .../M01_MasterData_UI_Logic_Calc.ts | 3 +- 8 files changed, 944 insertions(+), 5 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New.css create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 236bc196..77ecbd8b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -34,6 +34,8 @@ import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterDa import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail"; +import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -80,10 +82,12 @@ export async function mountM01LogicLab( ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; + let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음 let calcInputs = ""; const editor = el("div", { className: "m01-logic__editor" }); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("div", { className: "m01lab__calc" }); + const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } }); const filterHost = el("div"); const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 @@ -165,10 +169,12 @@ export async function mountM01LogicLab( const drawEditor = (): void => { // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened; + const listing = !opened && !creating; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; - editor.hidden = back.hidden = listing; + editor.hidden = !opened; + back.hidden = listing; + newHost.hidden = !creating; if (!opened) return; if (!opened.row) { editor.replaceChildren( @@ -192,6 +198,7 @@ export async function mountM01LogicLab( const show = (next: Opened | null): void => { opened = next; + creating = false; errors.hidden = true; drawEditor(); drawCalc(); @@ -251,7 +258,7 @@ export async function mountM01LogicLab( restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); - if (opened) show(null); + if (opened || creating) show(null); }, }), ); @@ -335,6 +342,29 @@ export async function mountM01LogicLab( } }; + /** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */ + const startNew = (): void => { + opened = null; + creating = true; + newHost.replaceChildren( + buildNewLogic({ + onSaved: (key) => { + void (async () => { + try { + await reload(); + await openKeyOn(key); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + })(); + }, + onClose: () => show(null), + }), + ); + drawEditor(); + drawCalc(); + }; + const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), @@ -347,6 +377,7 @@ export async function mountM01LogicLab( back, el("h2", { text: tx("Title") }), pending, + createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, @@ -358,7 +389,7 @@ export async function mountM01LogicLab( children: [ el("main", { className: "m01-logic__main", - children: [bar, errors, waiting, list.root, editor], + children: [bar, errors, waiting, list.root, editor, newHost], }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css new file mode 100644 index 00000000..bd23745e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css @@ -0,0 +1,105 @@ +/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */ +.m01lab-new { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m01lab-new__step { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m01lab-new__text { + width: 100%; + min-height: 140px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg); + color: var(--color-text); + font-family: ui-monospace, Consolas, monospace; + font-size: var(--text-body); + resize: vertical; +} + +.m01lab-new__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01lab-new__vars { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01lab-new__var { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01lab-new__var-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); +} + +.m01lab-new__var-body { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +.m01lab-new__wide { + flex: 1 1 220px; +} + +.m01lab-new__picked { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + min-width: 0; +} + +.m01lab-new__src { + display: flex; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-lg); + background: var(--color-paper); + overflow-x: auto; +} + +.m01lab-new__no { + flex: 0 0 auto; + color: var(--color-text-secondary); +} + +.m01lab-new__src mark { + background: var(--color-danger); + color: var(--color-bg); + border-radius: 2px; +} + +.m01lab-new__bad { + color: var(--color-danger); +} + +.m01lab-new__soft { + color: var(--color-text-secondary); +} + +.m01lab-new__calc { + min-width: 0; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts new file mode 100644 index 00000000..eb828837 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts @@ -0,0 +1,272 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New.ts + * 「로직 개선 시험」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장) + * ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름 + * ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX) + * 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`) + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + ApiError, + draftLogic, + type DraftAnswer, + type DraftDecided, + type DraftHead, + type DraftProblem, + type DraftVar, +} from "./M01_MasterData_UI_Logic_Api"; +import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; +import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var"; +import "./M01_MasterData_UI_LogicLab_New.css"; + +/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */ +const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요 +넓이 = 길이 * 폭 +인부 = 넓이 * 품 +잡재료비 = 노무비 * 0.05 +공사비 = 인부 * 보통인부 + 잡재료비 +`; + +export interface NewOptions { + /** 저장 끝 — 새 자체 로직의 키 */ + onSaved: (key: string) => void; + onClose: () => void; +} + +export function buildNewLogic(opts: NewOptions): HTMLElement { + const decided: Record = {}; + const rows = new Map(); + const values: Record = {}; + let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림 + let answer: DraftAnswer | null = null; + let calcSig = ""; + let seq = 0; + + const text = el("textarea", { + className: "m01lab-new__text", + attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") }, + }); + text.value = SAMPLE; + const stale = el("p", { + className: "m01-logic__muted", + text: tn("Stale"), + attrs: { hidden: "" }, + }); + const varsHost = el("div", { className: "m01lab-new__vars" }); + const problemHost = el("div", { className: "m01lab-new__problems" }); + const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" }); + const nameBox = createInputField({ + type: "text", + label: tn("Name"), + onInput: () => onEdit(), + }); + nameBox.input.setAttribute("data-key", "name"); + const unitBox = createInputField({ + type: "text", + label: tn("ResultUnit"), + placeholder: "원/㎡", + onInput: () => onEdit(), + }); + unitBox.input.setAttribute("data-key", "unit"); + const ownerBox = createInputField({ type: "text", label: tn("Owner") }); + const state = el("p", { className: "m01-logic__muted" }); + const save = createButton({ label: tn("Save"), onClick: () => void doSave() }); + save.setAttribute("data-act", "save"); + save.disabled = true; + + const head = (): DraftHead | undefined => { + const 이름 = nameBox.input.value.trim(); + const 결과단위 = unitBox.input.value.trim(); + return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined; + }; + + /** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */ + const drawProblems = (problems: DraftProblem[]): void => { + if (!analyzed) return problemHost.replaceChildren(); + const lines = analyzed.split("\n"); + const flagged = new Map(); + for (const p of problems) { + if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]); + } + const shown = [...flagged.keys()] + .sort((a, b) => a - b) + .filter((n) => n <= lines.length) + .map((n) => { + const raw = lines[n - 1]; + const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1; + return el("div", { + className: "m01lab-new__src", + children: [ + el("span", { className: "m01lab-new__no", text: String(n) }), + at >= 0 + ? el("code", { + children: [ + raw.slice(0, at), + el("mark", { text: raw.slice(at, at + 1) }), + raw.slice(at + 1), + ], + }) + : el("code", { text: raw }), + ], + }); + }); + const items = problems.map((p) => + el("li", { + className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad", + attrs: { "data-kind": p.갈래 }, + text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`, + }), + ); + problemHost.replaceChildren( + el("h4", { text: tn("Problems") }), + ...(items.length + ? [...shown, el("ul", { children: items })] + : [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]), + ); + }; + + const drawCalc = (): void => { + const row = answer?.logic ?? null; + save.disabled = !row || (answer?.문제.length ?? 1) > 0; + state.textContent = !answer + ? "" + : answer.문제.some((p) => p.갈래 !== "덜정함") + ? tn("SaveBlocked") + : !head() + ? tn("NeedHead") + : answer.다됨 + ? "" + : tn("Undecided"); + if (!row) { + calcSig = ""; + calcHost.replaceChildren(); + return; + } + const sig = JSON.stringify(row.입력 ?? []); + if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림 + calcSig = sig; + buildCalc(calcHost, { + savedKey: null, + row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄 + dirty: () => true, + values, + onLines: () => undefined, + }); + }; + + const drawVars = (vars: DraftVar[]): void => { + rows.clear(); + const shown = vars.filter((v) => v.갈래 !== "비목합"); + for (const name of Object.keys(decided)) { + if (!shown.some((v) => v.이름 === name)) delete decided[name]; + } + const result = answer?.결과 ?? ""; + const order = [ + ...shown.filter((v) => v.갈래 === "미정"), + ...shown.filter((v) => v.갈래 !== "미정"), + ]; + for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit })); + varsHost.replaceChildren(...[...rows.values()].map((r) => r.root)); + }; + + const evaluate = async (rebuild: boolean): Promise => { + if (!analyzed) return; + const mine = ++seq; + try { + const got = await draftLogic({ text: analyzed, decided, head: head() }); + if (mine !== seq) return; // 최신 응답만 + answer = got; + if (rebuild) drawVars(got.변수); + for (const v of got.변수) rows.get(v.이름)?.update(v); + drawProblems(got.문제); + drawCalc(); + } catch (error) { + showToast(error instanceof Error ? error.message : tn("Failed"), "error"); + } + }; + let timer = 0; + function onEdit(): void { + window.clearTimeout(timer); + timer = window.setTimeout(() => void evaluate(false), 250); + } + + const analyzeButton = createButton({ + label: tn("Analyze"), + onClick: () => { + analyzed = text.value; + stale.hidden = true; + void evaluate(true); + }, + }); + analyzeButton.setAttribute("data-act", "analyze"); + text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed)); + + async function doSave(): Promise { + const h = head(); + if (!h) return void showToast(tn("NeedHead"), "info"); + save.disabled = true; + showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림 + try { + const owner = ownerBox.input.value.trim(); + const done = await draftLogic({ + text: analyzed, + decided, + head: h, + save: true, + ...(owner ? { owner } : {}), + }); + showToast(tn("Saved", { v: done.key ?? "" }), "success"); + if (done.key) opts.onSaved(done.key); + } catch (error) { + const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null; + if (detail?.문제) drawProblems(detail.문제); + else showToast(error instanceof Error ? error.message : tn("Failed"), "error"); + save.disabled = false; + } finally { + hideLoadingOverlay(); + } + } + + const section = (title: string, ...children: HTMLElement[]): HTMLElement => + el("section", { + className: "m01lab__box m01lab-new__step", + children: [el("h3", { text: title }), ...children], + }); + + return el("div", { + className: "m01lab-new", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h2", { text: tn("Title") }), + createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }), + ], + }), + section( + tn("Step1"), + el("p", { className: "m01-logic__muted", text: tn("Guide") }), + text, + el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }), + ), + section(tn("Step2"), varsHost, problemHost), + section( + tn("Step3"), + el("div", { + className: "m01lab-new__row", + children: [nameBox.root, unitBox.root, ownerBox.root], + }), + ), + section(tn("Step4"), calcHost, state, save), + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts new file mode 100644 index 00000000..08761a62 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts @@ -0,0 +1,218 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New_Find.ts + * 새 로직 만들기의 고르기 창 둘 + * ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기 + * ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음 + * ========================================================================== */ + +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { + fetchFiles, + fetchRows, + fetchSubsAll, + type FileInfo, + type SubInfo, +} from "./M01_MasterData_Api_Fetch"; +import type { DraftFind } from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { loadTable } from "./M01_MasterData_UI_Logic_Note"; +import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; + +export interface PickedElement { + ref: string; + /** 이름 + 규격 */ + name: string; + /** 수량에 쓰는 단위(「원/일」 → 「일」) */ + unit: string; + group: string; +} + +const GROUPS = ["인력", "재료", "기계"]; +const SHOWN = 50; + +/** 창 뼈대 — 제목 · 닫기 · 몸통 */ +function shell(title: string, body: HTMLElement[]): { close: () => void } { + const close = (): void => backdrop.remove(); + const dialog = el("div", { + className: "m01-logic__pick", + attrs: { role: "dialog", "aria-label": title }, + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h3", { text: title }), + createButton({ label: tn("Close"), variant: "ghost", onClick: close }), + ], + }), + ...body, + ], + }); + const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); + backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); + backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); + document.body.append(backdrop); + return { close }; +} + +/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */ +async function fileOf(group: string): Promise { + const files = await fetchFiles(group); + return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0]; +} + +const options = (values: string[]): { value: string; text: string }[] => [ + { value: "", text: tn("All") }, + ...values.map((v) => ({ value: v, text: v })), +]; + +export function openElementPick(done: (picked: PickedElement) => void): void { + let file: FileInfo | undefined; + let subs: SubInfo[] = []; + const groupBox = createSelectField({ + options: GROUPS.map((g) => ({ value: g, text: g })), + value: GROUPS[0], + compact: true, + label: tn("Group"), + }); + const subBox = createSelectField({ + options: options([]), + value: "", + compact: true, + label: tn("Sub"), + }); + const detailBox = createSelectField({ + options: options([]), + value: "", + compact: true, + label: tn("Detail"), + }); + const searchField = createInputField({ type: "search", placeholder: tn("Search") }); + const count = el("span", { className: "m01-logic__muted" }); + const list = el("div", { className: "m01-logic__pick-list" }); + const win = shell(tn("K_master"), [ + el("div", { + className: "m01-logic__pick-bar", + children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count], + }), + list, + ]); + + let seq = 0; + const load = async (): Promise => { + if (!file) return; + const mine = ++seq; + try { + const got = await fetchRows( + file.file, + 1, + SHOWN, + searchField.input.value.trim(), + false, + subBox.select.value, + detailBox.select.value, + ); + if (mine !== seq) return; + count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) }); + list.replaceChildren( + ...got.rows.map((row) => { + const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim(); + const unitText = String(row["단위"] ?? ""); + const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? ""); + const button = el("button", { + className: "m01-logic__pick-row m01-logic__pick-row--button", + attrs: { type: "button", "data-ref": String(row["키"]) }, + children: [ + el("div", { + className: "m01-logic__stack", + children: [ + el("strong", { text: name }), + el("span", { className: "m01-logic__muted", text: String(row["키"]) }), + ], + }), + el("span", { + className: "m01-logic__money", + text: `${formatNumber(row["값"])} ${unitText}`.trim(), + }), + ], + }); + button.addEventListener("click", () => { + win.close(); + done({ ref: String(row["키"]), name, unit, group: groupBox.select.value }); + }); + return button; + }), + ); + } catch (error) { + showToast(error instanceof Error ? error.message : tn("Failed"), "error"); + } + }; + + const changeGroup = async (): Promise => { + file = await fileOf(groupBox.select.value); + subs = file ? (await fetchSubsAll({ file: file.file })).subs : []; + subBox.setOptions(options(subs.map((s) => s.name)), ""); + detailBox.setOptions(options([]), ""); + detailBox.select.disabled = true; + await load(); + }; + groupBox.select.addEventListener("change", () => void changeGroup()); + subBox.select.addEventListener("change", () => { + const details = subs.find((s) => s.name === subBox.select.value)?.details ?? []; + detailBox.setOptions(options(details), ""); + detailBox.select.disabled = details.length === 0; + void load(); + }); + detailBox.select.addEventListener("change", () => void load()); + let timer = 0; + searchField.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void load(), 250); + }); + void changeGroup(); +} + +/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */ +export function openTablePick(done: (find: DraftFind, label: string) => void): void { + openPicker("table", "소요량", (item, column) => { + void (async () => { + const table = await loadTable(item.ref); + const conds = Object.keys(table?.조건 ?? {}); + const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`; + const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record }; + if (!conds.length) { + done(find, label); + return; + } + const inputs = conds.map((c) => { + const box = createInputField({ type: "text", value: c }); + box.input.setAttribute("data-cond", c); + return { c, box }; + }); + const win = shell(tn("TableTitle"), [ + el("p", { className: "m01-logic__muted", text: label }), + el("p", { text: tn("Cond") }), + ...inputs.map(({ c, box }) => + el("label", { + className: "m01-logic__field", + children: [el("span", { text: c }), box.root], + }), + ), + createButton({ + label: tn("Ok"), + onClick: () => { + for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c; + win.close(); + done(find, label); + }, + }), + ]); + })(); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts new file mode 100644 index 00000000..272d234f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New_Text.ts + * 「식으로 새 로직 만들기」 글자 — [한국어, 영어] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Open: ["식으로 새로 만들기", "New from formula"], + Title: ["식으로 새 로직 만들기", "New logic from a formula"], + Close: ["닫기", "Close"], + Step1: ["① 식 적기", "① Write the formula"], + Guide: [ + "한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀", + "One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped", + ], + Analyze: ["분석", "Analyze"], + Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"], + Step2: ["② 변수 정하기", "② Decide each variable"], + Step3: ["③ 이름·단위", "③ Name and unit"], + Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"], + Problems: ["고칠 곳", "To fix"], + Line: ["{n}번 줄", "Line {n}"], + AllGood: ["검사에 걸린 것 없음", "No problems found"], + Result: ["결과", "Result"], + Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"], + Kind: ["무엇인가", "What is it"], + K_master: ["마스터 요소", "Master element"], + K_input: ["설계 입력", "Design input"], + K_fixed: ["고정값", "Fixed value"], + K_table: ["표 찾기", "Table lookup"], + Pick: ["고르기", "Pick"], + Repick: ["다시 고르기", "Pick again"], + NotPicked: ["아직 안 골랐음", "Not picked"], + Unit: ["단위", "Unit"], + Cost: ["비목", "Cost item"], + CostNone: ["넣지 않음", "None"], + CostExtra: ["덧줄 비목", "Extra-line cost item"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Derived: ["식으로 정해짐", "Set by the formula"], + SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"], + Name: ["로직 이름", "Logic name"], + ResultUnit: ["결과 단위", "Result unit"], + Owner: ["소유", "Owner"], + Save: ["저장", "Save"], + Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"], + SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"], + NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"], + Group: ["종류", "Kind"], + Sub: ["구분", "Group"], + Detail: ["상세구분", "Detail"], + All: ["(전체)", "(All)"], + Search: ["이름 찾기", "Search name"], + Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"], + TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"], + TablePick: ["표 고르기", "Pick a table"], + TableCol: ["값 칸", "Value column"], + Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"], + Ok: ["확인", "OK"], + Cancel: ["취소", "Cancel"], + Failed: ["못 함", "Failed"], +} as const satisfies Record; + +export type NewTextKey = keyof typeof TEXT; + +export function tn(key: NewTextKey, fill: Record = {}): string { + const entry = TEXT[key]; + const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0]; + return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts new file mode 100644 index 00000000..7cfb70d1 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New_Var.ts + * 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름 + * 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로) + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api"; +import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; + +export interface VarCtx { + decided: Record; + /** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */ + onEdit: () => void; +} +export interface VarRow { + root: HTMLElement; + /** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */ + update: (v: DraftVar) => void; +} + +const KINDS: [string, () => string][] = [ + ["마스터요소", () => tn("K_master")], + ["설계입력", () => tn("K_input")], + ["고정값", () => tn("K_fixed")], + ["표찾기", () => tn("K_table")], +]; +const COSTS = ["노무비", "재료비", "경비"]; +/** 요소 종류 → 비목 첫 제안 */ +const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; + +const costOptions = (): { value: string; text: string }[] => [ + { value: "", text: tn("CostNone") }, + ...COSTS.map((c) => ({ value: c, text: c })), +]; + +const field = (label: string, control: HTMLElement): HTMLElement => + el("label", { + className: "m01-logic__field", + children: [el("span", { text: label }), control], + }); + +export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow { + const name = v.이름; + const info = el("span", { className: "m01-logic__muted m01lab-new__derived" }); + const body = el("div", { className: "m01lab-new__var-body" }); + const root = el("div", { + className: "m01lab-new__var", + attrs: { "data-var": name }, + children: [ + el("div", { + className: "m01lab-new__var-head", + children: [el("strong", { text: name }), info], + }), + body, + ], + }); + const now = (): DraftDecided => (ctx.decided[name] ??= {}); + const set = (patch: DraftDecided): void => { + ctx.decided[name] = { ...now(), ...patch }; + for (const [k, val] of Object.entries(ctx.decided[name])) { + if (val === "" || val === undefined) delete (ctx.decided[name] as Record)[k]; + } + ctx.onEdit(); + }; + const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => { + const box = createInputField({ + type: "text", + value: now()[key] ?? "", + onInput: (val) => set({ [key]: val.trim() }), + }); + box.input.setAttribute("data-key", key); + const cell = field(label, box.root); + if (wide) cell.classList.add("m01lab-new__wide"); + return cell; + }; + + let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글 + const draw = (): void => { + const kind = now().무엇 ?? ""; + if (v.갈래 === "비목합") { + body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") })); + return; + } + if (v.갈래 === "정의됨") { + if (isResult) return body.replaceChildren(); + const cost = createSelectField({ + options: costOptions(), + value: now().비목 ?? "", + compact: true, + label: tn("CostExtra"), + onChange: (val) => set({ 비목: val }), + }); + body.replaceChildren(cost.root); + return; + } + const kindBox = createSelectField({ + options: [ + { value: "", text: "—" }, + ...KINDS.map(([value, label]) => ({ value, text: label() })), + ], + value: kind, + compact: true, + label: tn("Kind"), + onChange: (val) => { + ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {}; + picked = ""; + draw(); + ctx.onEdit(); + }, + }); + const parts: HTMLElement[] = [kindBox.root]; + if (kind === "마스터요소") { + const cost = createSelectField({ + options: costOptions(), + value: now().비목 ?? "", + compact: true, + label: tn("Cost"), + onChange: (val) => set({ 비목: val }), + }); + const choose = createButton({ + label: now().요소 ? tn("Repick") : tn("Pick"), + variant: "ghost", + onClick: () => + openElementPick((p) => { + picked = `${p.name} · ${p.ref}`; + set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 }); + draw(); + }), + }); + choose.setAttribute("data-act", "pick-element"); + parts.push( + el("div", { + className: "m01lab-new__picked", + children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })], + }), + text(tn("Unit"), "단위"), + cost.root, + ); + } else if (kind === "설계입력") { + parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true)); + } else if (kind === "고정값") { + parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위")); + } else if (kind === "표찾기") { + const choose = createButton({ + label: now().찾기 ? tn("Repick") : tn("TablePick"), + variant: "ghost", + onClick: () => + openTablePick((find, label) => { + picked = label; + set({ 찾기: find }); + draw(); + }), + }); + choose.setAttribute("data-act", "pick-table"); + parts.push( + el("div", { + className: "m01lab-new__picked", + children: [choose, el("span", { text: picked || tn("NotPicked") })], + }), + text(tn("Unit"), "단위"), + ); + } + body.replaceChildren(...parts); + }; + draw(); + + const update = (next: DraftVar): void => { + v = next; + info.textContent = + next.갈래 === "정의됨" + ? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}` + : (next.단위 ?? ""); + }; + update(v); + return { root, update }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index f2c06d1e..4a8ce52b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -214,3 +214,66 @@ export const copyLogic = ( key: string, ): Promise<{ file: string; version: string; key: string; logic: LogicRow }> => request("/logic/copy", { key }); + +/* ── 식으로 새 로직 만들기(계약 7장) ── */ +export interface DraftFind { + 표: string; + 값칸: string; + 조건: Record; +} +/** 변수마다 정한 것 */ +export interface DraftDecided { + 무엇?: string; + 단위?: string; + 비목?: string; + 요소?: string; + 찾기?: DraftFind; + 값?: string; + 설명?: string; +} +export interface DraftVar { + 이름: string; + 처음: number; + 갈래: "정의됨" | "미정" | "비목합"; + 정의줄: number | null; + 쓰임: number[]; + 무엇: string | null; + 단위: string | null; + 비목: string | null; + 정할것: string[]; +} +export interface DraftProblem { + 줄: number | null; + 자리: number | null; + 갈래: string; + 말: string; +} +export interface DraftAnswer { + 줄: { 줄: number; 이름: string; 식: string; 앞: number }[]; + 변수: DraftVar[]; + 결과: string; + 문제: DraftProblem[]; + 다됨: boolean; + logic: LogicRow | null; + /** 저장했을 때만 */ + file?: string; + version?: string; + key?: string; +} +export interface DraftHead { + 이름: string; + 결과단위: string; + 원문번호?: string; + 출처?: string; + 비고?: string; + 끝수?: string; +} + +/** 검사·미리보기 · `save` 면 자체 로직(키 `GX`)으로 저장 — 문제가 있으면 422 */ +export const draftLogic = (body: { + text: string; + decided: Record; + head?: DraftHead; + save?: boolean; + owner?: string; +}): Promise => request("/logic/draft", body); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index e01d490f..9582a37b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -19,7 +19,8 @@ import { tx } from "./M01_MasterData_UI_Logic_Text"; export interface CalcContext { /** 저장된 키 — 새 로직은 null */ savedKey: string | null; - file: string; + /** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */ + file?: string; row: LogicRow; dirty: () => boolean; /** 로직마다 넣은 값 — 다시 그려도 남음 */ From 8a9721fa8f0f6bb61e031ad845b18371647872be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 20:53:04 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix(M01):=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81(GX)=EC=97=90=EB=8A=94=20=E3=80=8C?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=A1=9C=EC=A7=81=EC=9D=80=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EC=A7=80=20=EB=AA=BB=ED=95=A8=E3=80=8D=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=8C=80=EC=8B=A0=20=EA=B3=A0=EC=B9=98=EB=8A=94=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EA=B8=B0=EB=A5=BC=20=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `isOwnLogic`(키 GX…)이면 기본정보 · 호표 자리에 정본 화면과 같은 편집기 · 정본 로직은 그대로 읽기 전용 - ORCA 견본 GX 초안 — 문구 없음 · 입력칸 51개 · 정본 13-4-1 소계 203,281.2 그대로 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- .../M01_MasterData_UI_LogicLab_Detail.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ff323a85..5d81ccc7 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -15,7 +15,8 @@ import type { NamedFormula, TextAnswer, } from "./M01_MasterData_UI_Logic_Api"; -import { formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; +import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; +import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal"; @@ -280,13 +281,33 @@ function hoBox(ctx: DetailContext): HTMLElement { return section(tl("Ho_Title"), blocks, hint); } +/** 자체 로직(키 GX…) — 정본은 아님 */ +export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); + +/** 자체 로직은 고치는 화면(정본 로직 화면과 같은 편집기) 하나로 기본정보 · 호표를 대신함 */ +function ownBox(ctx: DetailContext): HTMLElement { + const host = el("div"); + buildEditor(host, { + row: ctx.row, + file: ctx.file, + isNew: false, + files: [], + reasons: ctx.reasons, + prices: ctx.prices, + lines: ctx.lines, + onChange: ctx.onChange, + onFile: () => undefined, + onPick: openPicker, + }); + return section(tl("Info_Title"), [host]); +} + /** 상세 화면 넷을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); buildFormula(formula, { text: ctx.text }); host.replaceChildren( - infoBox(ctx), - hoBox(ctx), + ...(isOwnLogic(ctx.row) ? [ownBox(ctx)] : [infoBox(ctx), hoBox(ctx)]), section(tl("Formula_Title"), [formula]), section(tl("Calc_Title"), [ctx.calcHost]), ); From 8a91fa3cca0e048128a7c03ebda449a89c0399d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 20:57:43 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=EF=BB=BFfix(M01):=20=EC=8B=9D=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D=EA=B8=B0=20=ED=9D=A0=20=EC=85=8B=20=E2=80=94=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=97=86=EB=8A=94=20=EB=A7=A8=20=EC=88=98?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=EC=8B=9D=20=3D=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=E3=80=8D=20=EA=BC=B4=20=C2=B7=20=EB=B9=84=EB=AA=A9=20=EC=95=88?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=EB=8D=A7=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 단위 칸에 「없음」(맨수 · - · % · 빈 글)을 골라 무차원으로 둘 수 있게 함 — 횟수·배율을 「회」 로 두어 「원/회」 가 남던 덧셈 검사 걸림을 풂 · 같은 단위끼리 나누면 맨 수가 되는 것도 맞음 · 줄을 「이름 = 식」 과 「식 = 이름」(A * B = C) 둘 다 받음 — 한쪽이 값 이름 하나면 그쪽이 이름 · 양쪽이 다 식이면 그 줄의 등호 자리를 짚음 · 괄호 안 등호와 비교 기호는 안 가름 · 비목 안 고른 덧줄을 「못 옮김」 으로 막지 않고 그 값의 「정할것」 에 「비목」 을 얹어 짚어 줌 · 돈 아닌 로직은 덧줄이 없어 안 물음 · 계약 _화면_계약.md 8장(다른 창이 7장을 먼저 씀) · 시험 test_m01_draft.py 18개 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- M01_MasterData/M01_MasterData_Store_Make.py | 4 +- resources/master_data/_화면_계약.md | 10 +- resources/master_data/scripts/master_draft.py | 125 ++++++++++++++---- resources/tester/test_m01_draft.py | 64 +++++++++ 4 files changed, 168 insertions(+), 35 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 886554d0..8ab1def6 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -278,14 +278,14 @@ def logic_draft( save: bool = False, owner: str = OWNERS[0], ) -> dict: - """줄로 적은 식 → 변수 · 검사(`_화면_계약.md` 7장). + """줄로 적은 식 → 변수 · 검사(`_화면_계약.md` 8장). `head`(이름 · 결과단위 …)가 오면 로직 한 줄까지 미리 보이고, `save` 면 다 정해진 때만 자체 로직으로 저장(키 GX 는 서버) · 덜 정했거나 걸리면 422 로 아무것도 안 씀. """ import master_draft as draft # store 가 scripts 를 길에 올린 뒤라야 함 - got = draft.read(text, decided) + got = draft.read(text, decided, head) if head is None: return {**got, "logic": None} row, bad = draft.옮기기(text, decided, head) diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index dfeab059..165145ae 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -140,16 +140,18 @@ `POST /logic/draft` 받음 `{text, decided, head?, save?, owner?}` — 줄로 적은 식을 받아 변수를 뽑고 검사한 뒤 로직 한 줄로 옮김(해석기 `scripts/master_draft.py`). -- `text` = 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · **마지막 줄 왼쪽이 결과 변수**. 식 글자는 `_틀.md` 8장 범위만. +- `text` = 줄마다 「이름 = 식」 **또는 「식 = 이름」**(`A * B = C`) · 한쪽이 값 이름 하나면 그쪽이 이름 · 양쪽이 다 식이면 그 줄의 등호 자리를 짚어 `못읽음`. 괄호 안 등호(`찾기(…, 뒷길이=높이)`)와 비교 기호(`==` `<=` `>=` `!=`)는 안 가름. 빈 줄과 `#` 는 건너뜀 · **마지막 줄의 값 이름이 결과 변수**. 식 글자는 `_틀.md` 8장 범위만. - `decided` = 변수마다 정한 것 `{이름: {무엇, 단위, 비목, 요소 | 찾기 | 값, 설명 · 고르기 · 범위}}`. - `무엇` — `마스터요소`(키 또는 재료 고르기 조건을 `요소` 에 · `비목` 도 정함) · `설계입력`(입력 칸이 됨) · `고정값`(`값` 을 중간으로) · `표찾기`(`찾기` = `{표, 값칸, 조건}` → `찾기(…).값칸` 을 중간으로). - `단위` — 마스터요소는 그 줄의 단위(「인」 · 「포」)를 적음. 식에서는 단가라 `원/단위` 로 셈. + - **단위 없는 맨 수**(횟수 · 배율 · 비율)는 단위 칸에 `없음`(또는 `맨수` · `-` · `%` · 빈 글)을 골라 무차원으로 둠 — 「전용 4회」 를 「회」 로 두면 `원/회` 가 남아 덧셈 검사에 걸림. 칸 자체가 없으면 아직 안 정한 것(`정할것` 에 `단위`). - 비목 합 이름(`노무비` · `재료비` · `경비` · `계`)은 정하지 않음 — 덧줄에서만 쓰는 이름. - `head` = 로직 머리(`이름` · `결과단위` · `원문번호` · `출처` · `비고` · `끝수`). 없으면 검사만 함. - 줌 `{줄, 변수, 결과, 문제, 다됨, logic}` — `logic` = 옮긴 로직 한 줄(`head` 가 있을 때만 · 저장 전). - - `변수` = `{이름, 처음, 갈래, 정의줄, 쓰임, 무엇, 단위, 비목, 정할것}` · `갈래` = `정의됨`(왼쪽에 나옴) · `미정`(쓰기만 함) · `비목합` · `정할것` = 아직 안 정한 칸 이름들. - - `문제` = `{줄, 자리, 갈래, 말}` · `자리` = 그 줄의 글자 자리(모르면 null) · `갈래` = `못읽음` · `이름` · `두번정의` · `돌고돎` · `안쓰임`(= 결과가 하나인지) · `단위` · `덜정함` · `항` · `비목합`. - - 단위 검사 — 곱셈은 단위를 곱하고 나눗셈은 나눔 · 덧셈·뺄셈과 `만약`·`최소`·`최대`·비교는 단위가 같아야 함. 「m \* m」 과 「㎡」 는 같게 봄. 모르는 단위는 안 따짐. + - `변수` = `{이름, 처음, 갈래, 정의줄, 쓰임, 무엇, 단위, 비목, 정할것}` · `갈래` = `정의됨`(식이 있음) · `미정`(쓰기만 함) · `비목합` · `정할것` = 아직 안 정한 칸 이름들. `단위` 는 그 값이 **식에서** 가지는 단위(무차원은 빈 글). + - `문제` = `{줄, 자리, 갈래, 말}` · `자리` = 그 줄의 글자 자리(모르면 null) · `갈래` = `못읽음` · `이름` · `두번정의` · `돌고돎` · `안쓰임`(= 결과가 하나인지) · `단위` · `덜정함` · `항` · `비목` · `비목합`. + - 단위 검사 — 곱셈은 단위를 곱하고 나눗셈은 나눔(같은 단위끼리 나누면 맨 수) · 덧셈·뺄셈과 `만약`·`최소`·`최대`는 단위가 같아야 함 · 비교는 한쪽이 맨 수면 안 따짐(`높이 <= 3`). 「m \* m」 과 「㎡」 는 같게 봄. 모르는 단위는 안 따짐. + - **덧줄이 될 값**(결과 식의 항 가운데 마스터요소가 없는 것)은 막지 않고 그 값의 `정할것` 에 `비목` 을 얹음 — 화면은 「비목을 골라 주세요」 로 보임. - `save` = true 면 `POST /logic/new` 와 같은 자리에 저장(키 `GX` 는 서버 · 소유 `owner`) → 답에 `{file, version, key, logic}` 이 더 옴. 문제가 하나라도 있으면 422 `{detail: {errors, 문제}}` 로 아무것도 안 씀. - 옮기는 법 — 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다: 마스터요소 변수 하나면 **호표 줄**(나머지 곱이 `수량`) · 비목만 정한 변수면 **덧줄** · 그 밖은 문제 `항`. 나머지 정의 줄은 `중간`(쓰는 차례대로). 돈 아닌 로직은 호표 없이 `중간` + `결과` 식. - 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 `무엇` 을 `표찾기` 로 정할 때 모달이 `찾기` 묶음을 채움. diff --git a/resources/master_data/scripts/master_draft.py b/resources/master_data/scripts/master_draft.py index f32a7b90..8f26ba19 100644 --- a/resources/master_data/scripts/master_draft.py +++ b/resources/master_data/scripts/master_draft.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 7장). +"""줄로 적은 식 → 변수 뽑기 · 검사 · 단위 맞춤 · 로직 틀로 옮기기 (`_화면_계약.md` 8장). 수량 = 길이 * 폭 자재비 = 수량 * 시멘트 -· 줄마다 「이름 = 식」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄 왼쪽이 결과 변수 +· 줄마다 「이름 = 식」 또는 「식 = 이름」 · 빈 줄과 `#` 는 건너뜀 · 마지막 줄의 값 이름이 결과 변수 · 식 글자는 `_틀.md` 8장 범위만 — 읽기는 `master_formula` 가 함 · 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 「무엇」 을 `표찾기` 로 정할 때 이음 · 값은 안 씀 — 검사만 하고 `옮기기` 가 로직 한 줄을 만듦(저장은 `Store_Make.logic_new`) @@ -32,8 +32,9 @@ ALIAS = { "M": ("m", 1), } SQUARED = {2: "㎡", 3: "㎥"} -DIMLESS = ("%", "할") # 비율은 단위가 없음 — 「1 + 증가율 / 100」 이 안 걸리게 -_ASSIGN = re.compile(r"^\s*([\w가-힣]+)\s*=(?!=)\s*(.*)$") +# 단위가 없는 맨 수 — 횟수 · 배율 · 비율. 빈 글도 같음(「없음」 을 고른 것과 같게 봄) +DIMLESS = ("%", "할", "없음", "맨수", "맨 수", "-") +_NAME_ONLY = re.compile(r"^[\w가-힣]+$") # ── 자리를 적어 두는 읽기 ────────────────────────────────────────────── @@ -224,18 +225,51 @@ def find_text(pick: dict) -> str: # ── 읽기 · 검사 ─────────────────────────────────────────────────────── +def _is_name(text: str) -> bool: + """값 이름 하나 — 맨 수는 이름이 아님.""" + return bool(_NAME_ONLY.fullmatch(text)) and not mf._NUMBER.fullmatch(text) + + +def _split(raw: str) -> int: + """줄을 가를 등호 자리 — 괄호 안과 비교 기호(`==` `<=` `>=` `!=`)는 건너뜀 · 없으면 -1.""" + depth = 0 + for i, ch in enumerate(raw): + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == "=" and depth == 0: + if raw[i - 1 : i] in ("<", ">", "!", "=") or raw[i + 1 : i + 2] == "=": + continue + return i + return -1 + + def _cut(text: str): - """(줄 번호, 왼쪽 이름, 식, 식이 시작하는 자리) — 빈 줄과 `#` 는 건너뜀.""" + """(줄 번호, 이름, 식, 식이 시작하는 자리, 까닭) — 빈 줄과 `#` 는 건너뜀. + + 「이름 = 식」 과 「식 = 이름」 을 다 받음 — 한쪽이 값 이름 하나면 그쪽이 이름. + """ for at, raw in enumerate(str(text).split("\n"), 1): if not raw.strip() or raw.lstrip().startswith("#"): continue - m = _ASSIGN.match(raw) - yield ( - at, - (m.group(1) if m else ""), - (m.group(2).strip() if m else raw.strip()), - (m.start(2) if m else len(raw) - len(raw.lstrip())), - ) + cut = _split(raw) + if cut < 0: + yield ( + at, + "", + raw.strip(), + len(raw) - len(raw.lstrip()), + "「이름 = 식」 이 아님 — 등호가 없음", + ) + continue + left, right = raw[:cut], raw[cut + 1 :] + if _is_name(left.strip()): + yield at, left.strip(), right.strip(), cut + 1 + len(right) - len(right.lstrip()), "" + elif _is_name(right.strip()): # 「A * B = C」 — 오른쪽이 이름 + yield at, right.strip(), left.strip(), len(left) - len(left.lstrip()), "" + else: + yield at, "", raw.strip(), cut, "양쪽이 다 식 — 한쪽은 값 이름 하나여야 함" def _order(defined: dict, deps: dict) -> list[str]: @@ -255,14 +289,14 @@ def _order(defined: dict, deps: dict) -> list[str]: return out -def _scan(text: str, 정함: dict | None = None) -> dict: +def _scan(text: str, 정함: dict | None = None, 머리: dict | None = None) -> dict: """줄 가르기 · 변수 · 검사 · 단위 — `read` 와 `옮기기` 가 같이 씀(나무를 들고 있음).""" 정함 = 정함 or {} lines, 문제 = [], [] - for at, name, body, head in _cut(text): + for at, name, body, head, why in _cut(text): one = {"줄": at, "이름": name, "식": body, "앞": head} - if not name: - 문제.append(_trouble(at, head, "못읽음", "「이름 = 식」 이 아님")) + if why: + 문제.append(_trouble(at, head, "못읽음", why)) elif not body: 문제.append(_trouble(at, head, "못읽음", f"「{name}」 오른쪽이 빔")) else: @@ -343,6 +377,7 @@ def _scan(text: str, 정함: dict | None = None) -> dict: got = _unit(one["나무"], units, one["줄"], one["앞"], 문제) units[name] = pinned if pinned is not None else got + 덧줄후보 = _extra_names(by, 결과, 정함, 머리) if 결과 in by and 결과 not in loops else set() 변수 = [] for name in sorted( set(쓰임) | set(정의), key=lambda n: (min([쓰임.get(n, [10**9])[0], 정의.get(n, 10**9)]), n) @@ -359,16 +394,18 @@ def _scan(text: str, 정함: dict | None = None) -> dict: "무엇": spec.get("무엇") if 갈래 == "미정" else None, "단위": unit_text(units.get(name)), "비목": spec.get("비목"), - "정할것": _todo(갈래, spec, units.get(name)), + "정할것": _todo(갈래, spec, units.get(name), name in 덧줄후보), } ) return {"줄": lines, "변수": 변수, "결과": 결과, "문제": 문제, "차례": order, "정의": 정의} -def _todo(갈래: str, spec: dict, unit) -> list[str]: +def _todo(갈래: str, spec: dict, unit, 덧줄: bool = False) -> list[str]: """그 변수에 아직 안 정한 것 — 무엇 · 요소·찾기·값 · 단위 · 비목.""" - if 갈래 != "미정": + if 갈래 == "비목합": return [] + if 갈래 == "정의됨": # 식이 있는 값 — 덧줄이 될 항이면 비목만 고르면 됨 + return ["비목"] if 덧줄 and spec.get("비목") not in COST_ITEMS else [] what, need = spec.get("무엇"), [] if what not in WHAT: need.append("무엇") @@ -386,9 +423,9 @@ def _todo(갈래: str, spec: dict, unit) -> list[str]: return need -def read(text: str, 정함: dict | None = None) -> dict: +def read(text: str, 정함: dict | None = None, 머리: dict | None = None) -> dict: """줄로 적은 식 → `{줄, 변수, 결과, 문제, 다됨}` — 화면이 그대로 받는 모양(나무는 뺌).""" - got = _scan(text, 정함) + got = _scan(text, 정함, 머리) 다됨 = not got["문제"] and not any(v["정할것"] for v in got["변수"]) return { "줄": [{k: v for k, v in one.items() if k != "나무"} for one in got["줄"]], @@ -408,22 +445,40 @@ def _factors(node) -> list: return _factors(node[2]) + _factors(node[3]) if node[0] == "bin" and node[1] == "*" else [node] -def _spread(node, by: dict, 정함: dict, 편: set) -> list: - """결과 항 — 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침. +def _spread(node, by: dict, 정함: dict, 편: set, 뿌리: str = "") -> list: + """결과 항 — (항, 펼치기 전 이름). 항이 이름 하나이고 비목을 안 정했으면 그 식으로 펼침. - 호표 줄을 항 안에서 찾으려는 것. + 호표 줄을 항 안에서 찾으려는 것 · 못 펼친 항의 `뿌리` 가 덧줄이 될 값 이름. """ out = [] for term in _terms(node): name = term[1] if term[0] == "name" else "" if name in by and name not in 편 and not (정함.get(name) or {}).get("비목"): 편.add(name) - out += _spread(by[name]["나무"], by, 정함, 편) + out += _spread(by[name]["나무"], by, 정함, 편, name) else: - out.append(term) + out.append((term, 뿌리)) return out +def _has_element(term, 정함: dict) -> bool: + return any( + f[0] == "name" and (정함.get(f[1]) or {}).get("무엇") == "마스터요소" + for f in _factors(term) + ) + + +def _extra_names(by: dict, 결과: str, 정함: dict, 머리: dict | None) -> set[str]: + """결과 식의 항 가운데 마스터요소가 없어 덧줄이 될 값 이름 — 비목을 골라야 함.""" + if 머리 is not None and not str(머리.get("결과단위") or "").startswith("원"): + return set() # 돈 아닌 로직은 덧줄이 없음 + return { + 뿌리 + for term, 뿌리 in _spread(by[결과]["나무"], by, 정함, set()) + if 뿌리 and not _has_element(term, 정함) + } + + def _kind_of(요소) -> str: if isinstance(요소, dict): return "재료" @@ -447,13 +502,16 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None) 돈 아닌 로직은 호표 없이 `중간` + `결과` 식. """ 정함, 머리 = 정함 or {}, dict(머리 or {}) - got = _scan(text, 정함) + got = _scan(text, 정함, 머리) 문제 = list(got["문제"]) for v in got["변수"]: if v["정할것"]: 문제.append( _trouble( - v["처음"], 0, "덜정함", f"「{v['이름']}」 에 {' · '.join(v['정할것'])} 안 정함" + v["처음"], + 0, + "덜정함", + f"「{v['이름']}」 에 아직 안 정한 것 — {' · '.join(v['정할것'])}", ) ) if 문제: @@ -475,7 +533,7 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None) 덧줄이름, 편 = set(), set() if 돈: - for term in _spread(by[결과]["나무"], by, 정함, 편): + for term, 뿌리 in _spread(by[결과]["나무"], by, 정함, 편): facts = _factors(term) picks = [ f @@ -520,6 +578,15 @@ def 옮기기(text: str, 정함: dict | None = None, 머리: dict | None = None) {"이름": name, "식": _expr(by[name]["나무"]), "비목": 정함[name]["비목"]} ) 덧줄이름.add(name) + elif 뿌리 in by: # 덧줄이 될 값 — 막지 않고 비목만 짚음 + 문제.append( + _trouble( + by[뿌리]["줄"], + 0, + "비목", + f"덧줄 「{뿌리}」 의 비목을 골라 주세요 — {' · '.join(COST_ITEMS)}", + ) + ) else: 문제.append( _trouble( diff --git a/resources/tester/test_m01_draft.py b/resources/tester/test_m01_draft.py index 9491cfd0..16f3199f 100644 --- a/resources/tester/test_m01_draft.py +++ b/resources/tester/test_m01_draft.py @@ -133,6 +133,31 @@ def test_만약과_최소는_갈래마다_단위가_같아야_한다() -> None: assert md.read("값 = 최소(길이, 길이 * 2)", 정함)["문제"] == [] +def test_단위_없음을_골라_맨_수로_다룬다() -> None: + """횟수 · 배율 — 단위 칸에 「없음」 을 고르면 무차원(3-3 화면 흠 ①).""" + 회 = {"금액": {"무엇": "설계입력", "단위": "원"}, "전용": {"무엇": "설계입력", "단위": "회"}} + got = md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 회) + assert _var(got, "몫")["단위"] == "원/회" # 「회」 로 두면 덧셈에서 걸림 + assert [x["갈래"] for x in got["문제"]] == ["단위"] + + 맨수 = dict(회, 전용={"무엇": "설계입력", "단위": "없음"}) + 깨끗 = md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 맨수) + assert _var(깨끗, "몫")["단위"] == "원" and 깨끗["문제"] == [] + assert _var(깨끗, "전용")["단위"] == "" and _var(깨끗, "전용")["정할것"] == [] + for 글 in ("", "맨수", "-", "%"): + 쓴 = dict(회, 전용={"무엇": "설계입력", "단위": 글}) + assert md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 쓴)["문제"] == [], 글 + 없으면 = dict(회, 전용={"무엇": "설계입력"}) # 단위 칸 자체가 없으면 아직 안 정한 것 + assert _var(md.read("몫 = 금액 / 전용", 없으면), "전용")["정할것"] == ["단위"] + + +def test_나눗셈으로_단위가_없어진다() -> None: + 정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "넓이": {"무엇": "설계입력", "단위": "㎡"}} + assert _var(md.read("폭 = 넓이 / 길이", 정함), "폭")["단위"] == "m" + assert _var(md.read("비 = 길이 / 길이", 정함), "비")["단위"] == "" # 같은 단위끼리 나누면 맨 수 + assert md.read("합 = 길이 / 길이 + 2", 정함)["문제"] == [] + + # ── ③ 그 밖 검사 ────────────────────────────────────────────────────── def test_서로_물고_도는_식을_잡는다() -> None: kinds = [x["갈래"] for x in md.read(LOOP)["문제"]] @@ -152,6 +177,29 @@ def test_두_번_정의_안_쓰임_결과_하나() -> None: assert md.read("이건 식이 아님")["문제"][0]["갈래"] == "못읽음" +def test_식이_왼쪽에_와도_받는다() -> None: + """「A * B = C」 로 적는 사용자도 있음(3-3 화면 흠 ②).""" + 정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}} + got = md.read("길이 * 폭 = 넓이", 정함) + assert got["문제"] == [] and got["결과"] == "넓이" + assert _var(got, "넓이")["갈래"] == "정의됨" and _var(got, "넓이")["단위"] == "㎡" + assert got["줄"][0]["식"] == "길이 * 폭" + assert md.read("넓이 = 길이 * 폭", 정함)["변수"] == got["변수"] # 어느 쪽으로 적든 같음 + + 양쪽 = md.read("길이 * 폭 = 넓이 * 2", 정함)["문제"] + assert 양쪽[0]["갈래"] == "못읽음" and "양쪽이 다 식" in 양쪽[0]["말"] + assert 양쪽[0]["자리"] == "길이 * 폭 = 넓이 * 2".index("=") + assert md.read("길이 * 폭 = 4", 정함)["문제"][0]["갈래"] == "못읽음" # 맨 수는 이름이 아님 + + +def test_비교_기호와_찾기_안_등호는_안_가른다() -> None: + 정함 = {"높이": {"무엇": "설계입력", "단위": "m"}} + assert md.read("값 = 만약(높이 <= 3, 1, 2)", 정함)["문제"] == [] + assert md.read("값 = 만약(높이 == 3, 1, 2)", 정함)["문제"] == [] + 안쪽 = md.read("품 = 찾기(QF000421, 뒷길이=높이).석공", 정함) + assert 안쪽["문제"] == [] and 안쪽["줄"][0]["이름"] == "품" + + # ── ⑤ 로직 틀로 옮기기 ──────────────────────────────────────────────── def test_다_정하면_로직_틀로_옮긴다() -> None: row, bad = md.옮기기(GOOD, GOOD_정함, GOOD_머리) @@ -192,6 +240,22 @@ def test_덜_정했으면_안_옮긴다() -> None: assert row == {} and any(x["갈래"] == "덜정함" for x in bad) +def test_비목_안_고른_덧줄은_막지_않고_짚어_준다() -> None: + """「못 옮김」 으로 막지 않고 그 값에 「비목」 을 정할 것으로 얹음(3-3 화면 흠 ③).""" + 빼기 = {k: v for k, v in GOOD_정함.items() if k != "잡재료비"} + got = md.read(GOOD, 빼기, GOOD_머리) + assert _var(got, "잡재료비")["정할것"] == ["비목"] and got["다됨"] is False + assert [x["갈래"] for x in got["문제"]] == [] # 검사에는 안 걸림 — 고를 것이 남았을 뿐 + + row, bad = md.옮기기(GOOD, 빼기, GOOD_머리) + assert row == {} and [x["갈래"] for x in bad] == ["덜정함"] + assert "잡재료비" in bad[0]["말"] and "비목" in bad[0]["말"] + assert _var(md.read(GOOD, GOOD_정함, GOOD_머리), "잡재료비")["정할것"] == [] + + 안돈 = md.read(GOOD, 빼기, {"이름": "수량만", "결과단위": "㎥"}) # 돈 아닌 로직은 덧줄이 없음 + assert _var(안돈, "잡재료비")["정할것"] == [] + + # ── 서버 길 ─────────────────────────────────────────────────────────── def test_서버_길이_변수와_문제를_돌려_준다(client: TestClient) -> None: got = _post(client, {"text": GOOD, "decided": GOOD_정함}) From 96e58106bb4869ba771abee7d51225412fb9ba20 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 20:58:38 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81(GX)=20=EC=83=81=EC=84=B8=EB=8F=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=84=B7=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EB=A9=B4=20=ED=98=B8=ED=91=9C=20=EC=86=8C=EA=B3=84?= =?UTF-8?q?=EC=99=80=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=8B=9D?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A4=EC=8B=9C=20=EA=B3=84=EC=82=B0=EB=90=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본정보 칸(번호·이름·단위·출처)을 자체 로직이면 고칠 수 있게 · 호표 묶음·소계 아래에 편집기(입력·호표·중간 값·덧줄·끝수)를 폄 - 고친 뒤 0.7 초 쉬면 시험 계산을 다시 눌러 소계 · 텍스트 수식을 새로 그림 · 편집기 칸은 다시 그려도 그대로 씀 - ORCA 본뜬 GX — 컨테이너 넷 정본과 같음 · 증가율 고침 203,281.2→223,609.32 소계·텍스트 수식 계 같이 바뀜 · 시험 자료·키 대장 되돌림 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 13 ++- .../M01_MasterData_UI_LogicLab_Detail.ts | 79 ++++++++++++------- .../M01_MasterData_UI_LogicLab_Style.css | 8 ++ .../M01_MasterData_UI_LogicLab_Text.ts | 8 ++ 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 77ecbd8b..852facb2 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -33,7 +33,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail"; +import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"; import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; import "./M01_MasterData_UI_Logic_Style.css"; @@ -56,10 +56,12 @@ interface Opened extends Draft { reasons: string[]; lines: CalcLine[] | null; text: TextAnswer | null; + ownHost: HTMLElement; values: Record; } const cacheKey = "m01_logic_drafts_lab"; +const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -84,6 +86,7 @@ export async function mountM01LogicLab( let opened: Opened | null = null; let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음 let calcInputs = ""; + let recalc: number | undefined; const editor = el("div", { className: "m01-logic__editor" }); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("div", { className: "m01lab__calc" }); @@ -134,6 +137,11 @@ export async function mountM01LogicLab( persist(); const inputs = JSON.stringify(opened.row.입력 ?? []); if (inputs !== calcInputs) drawCalc(); + if (isOwnLogic(opened.row)) { + // 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림 + window.clearTimeout(recalc); + recalc = window.setTimeout(() => calc.querySelector("button")?.click(), 700); + } }; const pick = (o: Opened, row: LogicRow | null): Draft => ({ @@ -191,6 +199,7 @@ export async function mountM01LogicLab( lines: current.lines, text: current.text, values: current.values, + ownHost: current.ownHost, onChange: touch, calcHost: calc, }); @@ -215,6 +224,7 @@ export async function mountM01LogicLab( reasons: [], lines: null, text: null, + ownHost: ownHost(), values: {}, }); return; @@ -233,6 +243,7 @@ export async function mountM01LogicLab( reasons: one.reasons, lines: null, text: null, + ownHost: ownHost(), values: {}, }); } catch (error) { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 5d81ccc7..dc657656 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -33,6 +33,8 @@ export interface DetailContext { /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ text: TextAnswer | null; values: Record; + /** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */ + ownHost: HTMLElement; onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */ calcHost: HTMLElement; @@ -80,6 +82,20 @@ const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => function infoBox(ctx: DetailContext): HTMLElement { const row = ctx.row; + const own = isOwnLogic(row); + const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => { + if (!own) return cell(label, row[key] ?? ""); + const box = el("input", { className: "m01-logic__input", attrs: { "data-own": key } }); + box.value = row[key] ?? ""; + box.addEventListener("input", () => { + row[key] = box.value; + ctx.onChange(); + }); + return el("label", { + className: "m01lab__cell", + children: [el("span", { className: "m01-logic__muted", text: label }), box], + }); + }; const cell = (label: string, value: string): HTMLElement => el("div", { className: "m01lab__cell", @@ -107,17 +123,20 @@ function infoBox(ctx: DetailContext): HTMLElement { ] : []; return section(tl("Info_Title"), [ - el("p", { className: "m01-logic__muted", text: tl("Info_ReadOnly") }), + el("p", { + className: "m01-logic__muted", + text: tl(own ? "Info_Own" : "Info_ReadOnly"), + }), ...reasons, el("div", { className: "m01lab__info", children: [ cell(tx("Head_File"), ctx.file), cell(tx("Head_Key"), row.키), - cell(tx("Head_Number"), row.원문번호), - cell(tx("Head_Name"), row.이름), - cell(tx("Head_Unit"), row.결과단위), - cell(tx("Head_Source"), row.출처), + field(tx("Head_Number"), "원문번호"), + field(tx("Head_Name"), "이름"), + field(tx("Head_Unit"), "결과단위"), + field(tx("Head_Source"), "출처"), cell(tx("Head_Owner"), row.소유 ?? ""), el("label", { className: "m01lab__cell m01lab__cell--wide", @@ -255,6 +274,31 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme ]; } +/** 자체 로직(키 GX…) — 정본은 아님 */ +export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); + +/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */ +function ownEditor(ctx: DetailContext): HTMLElement { + if (!ctx.ownHost.childElementCount) + buildEditor(ctx.ownHost, { + row: ctx.row, + file: ctx.file, + isNew: false, + files: [], + reasons: [], + prices: ctx.prices, + lines: ctx.lines, + onChange: ctx.onChange, + onFile: () => undefined, + onPick: openPicker, + }); + return el("details", { + className: "m01lab__edit", + attrs: { open: "" }, + children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost], + }); +} + function hoBox(ctx: DetailContext): HTMLElement { const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원"); if (!money) { @@ -277,37 +321,18 @@ function hoBox(ctx: DetailContext): HTMLElement { text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`, }), ); + if (isOwnLogic(ctx.row)) blocks.push(ownEditor(ctx)); const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; return section(tl("Ho_Title"), blocks, hint); } -/** 자체 로직(키 GX…) — 정본은 아님 */ -export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); - -/** 자체 로직은 고치는 화면(정본 로직 화면과 같은 편집기) 하나로 기본정보 · 호표를 대신함 */ -function ownBox(ctx: DetailContext): HTMLElement { - const host = el("div"); - buildEditor(host, { - row: ctx.row, - file: ctx.file, - isNew: false, - files: [], - reasons: ctx.reasons, - prices: ctx.prices, - lines: ctx.lines, - onChange: ctx.onChange, - onFile: () => undefined, - onPick: openPicker, - }); - return section(tl("Info_Title"), [host]); -} - /** 상세 화면 넷을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); buildFormula(formula, { text: ctx.text }); host.replaceChildren( - ...(isOwnLogic(ctx.row) ? [ownBox(ctx)] : [infoBox(ctx), hoBox(ctx)]), + infoBox(ctx), + hoBox(ctx), section(tl("Formula_Title"), [formula]), section(tl("Calc_Title"), [ctx.calcHost]), ); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index e5bdfcda..7bd6fa71 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -109,3 +109,11 @@ font-weight: 600; text-align: right; } + +.m01lab__own > :first-child { + display: none; +} + +.m01lab__edit { + margin-top: var(--spacing-12); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 7c1ffbf3..dd725120 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -11,6 +11,14 @@ const TEXT = { "기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음", "Base logic follows the source book; only the note can be edited", ], + Info_Own: [ + "자체 로직 — 칸을 고치면 잠시 뒤 호표 소계와 텍스트 수식이 다시 계산됨", + "Own logic — edits recalculate the subtotals and formula shortly", + ], + Edit_Title: [ + "고치기 (호표 · 중간 값 · 덧줄 · 끝수)", + "Edit (lines · middles · extras · rounding)", + ], Ho_Title: ["일위대가 호표", "Unit-cost table"], Formula_Title: ["텍스트 수식", "Text formula"], Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"], From c8ae36764372a0bcbe8c080a7d29b8ead56cff74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 21:07:25 +0900 Subject: [PATCH 5/9] =?UTF-8?q?knowledge(=EB=A1=9C=EC=A7=81):=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EC=8B=9C=ED=97=98=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=E2=80=94=20=EC=A0=95=EB=B3=B8=20=EB=8B=A4?= =?UTF-8?q?=EC=84=AF=20=C2=B7=20=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=A7=89=ED=9E=8C=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- .../ref/_검증_로직개선_사용자경로.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 resources/master_data/ref/_검증_로직개선_사용자경로.md diff --git a/resources/master_data/ref/_검증_로직개선_사용자경로.md b/resources/master_data/ref/_검증_로직개선_사용자경로.md new file mode 100644 index 00000000..8d32a52d --- /dev/null +++ b/resources/master_data/ref/_검증_로직개선_사용자경로.md @@ -0,0 +1,35 @@ +# 로직 개선 시험 — 사용자 경로 검증 (PLAN 3 마감) + +ORCA · 포트 8006/5179 · 넓은 화면 928px · 좁은 화면 390px. 고치지 않고 적기만 함. + +## ① 정본 로직 다섯 + +컨테이너 넷(기본정보 · 호표 · 텍스트 수식 · 시험 계산) 모두 있음. 기본정보는 읽기 전용, 비고만 고쳐짐. 호표에 [줄 더하기] 없음. + +| 로직 | 호표 소계 합 | 계 | 모달 | +|---|---|---|---| +| 13-4-1 GF000219 (뒷길이 45 · 호박돌 · 켜쌓기 · 높이 2) | 인력 100,672.2 | 100,672.2 | 표 2개 | +| 12-4 GF000160 | 인력 82,948.3992 + 자재 36,163.252 | 119,111.6512 | 표 + 노란 줄 | +| 12-17-1 GF000182 | 6,615.989 + 438.8951 + 6,400.8296 | 13,455.7136 (합 13,455.7137) | 표 | +| 9-16-1 GF000119 | 그 밖 4,522.9569 | 4,522.9569 | 「다른 로직을 부름」 글만 · 표 없음 | +| 10-4 GF000137 | 그 밖 85,970.6407 | 85,970.6407 | 「다른 로직을 부름」 글만 · 표 없음 | + +## ② 만들기 + +- [식으로 새로 만들기] : 보기 글 → [분석] 8개 변수 → 보통인부 마스터 요소 · 길이·폭 설계 입력 · 품 고정값 · 잡재료비 덧줄 비목 → 이름·단위 → 시험 계산 길이 2 · 폭 3 → 계 162,604.26 → 저장 → 자체 로직 GX000001 상세가 열림(저장 뒤 약 10초). 지움까지 됨. +- 막힘 셋 걸림 : 「길이 + 넓이」 단위 · 물고 돎 · 「가」 두 번 정의. 자리 표시 · 저장 단추 막힘. +- [새로 만들기] · [본떠 만들기] : 「로직 개선 시험」 화면에 [식으로 새로 만들기]만 있고 [본떠 만들기] 단추가 없음(글 `Edit_Copy` 만 있고 단추 코드 없음) → 정본을 본떠 한 줄 고쳐 저장하는 길은 못 걸어 봄. + +## ③ 막히거나 어색한 자리 + +1. 본떠 만들기 단추 없음(PLAN 3-4 미착수) — 정본에서 자체 로직으로 가는 길이 화면에 없음. +2. 13-4-1 시험 계산 기본 선택(25 · 견치돌 · 골쌓기)이 「맞는 줄 0개」로 멈춤. 비고에 「원문에 없는 조합」 안내가 있으나 계산 화면에서는 안 보임. 첫 번째 선택으로 바로 계산되는 조합을 기본으로 두거나 멈춘 글에 안내 필요. +3. 13-4-1 모달 : 시험 계산 뒤에도 「걸린 줄을 아직 못 맞힘」 안내가 뜨고 강조 줄이 0개(석공 줄 · 계산 후 눌렀을 때). +4. 호표 줄 「표 찾기 찾기(QF000421 …」 — 「찾기」가 겹쳐 보임. +5. 호표 묶음 소계와 텍스트 수식 소계가 다름 : 12-17-1 호표 인력 6,615.989 · 경비 438.8951 ↔ 텍스트 노무비 소계 8,777.9014 · 경비 3,098.2357(호표는 줄 종류별 · 텍스트는 비목별). 합(계)은 같음. 같은 「소계」 이름이라 헷갈림. +6. 호표 묶음이 인력·자재·경비에 「그 밖」이 더해져 네 묶음 : 12-17-1 · 9-16-1 · 10-4. 9-16-1 · 10-4 는 인력·자재·경비 세 묶음이 모두 「줄 없음」이고 기계 줄이 「그 밖」 한 줄. +7. 자체 로직(GX) 상세는 옛 편집 꼴(설계에서 받을 값 · 호표 · 중간 값 · 덧줄 · [줄 더하기])이고 네 컨테이너 꼴이 아님 — 정본과 화면이 다름. +8. 새로 만들기 보기 글은 변수 8개를 다 골라야 저장됨(첫 사용자에게 많음). 저장에 약 10초 걸림 · 불러오는 표시만 뜸. +9. 좁은 화면 390px : 상세 화면 폭 안(가로 스크롤 없음). 모달은 iframe 조작으로 열리지 않아 폭을 못 잼. + +시험 흔적 : GX000001 지움 · 키 대장 되돌림 · 자체 로직 파일 지움. From c2bde8534e6812658a585569ea5adcf7f3c6c603 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 21:10:23 +0900 Subject: [PATCH 6/9] =?UTF-8?q?knowledge(M01):=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EA=B2=80=EC=A6=9D=20=E2=80=94=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=9C=20=EA=B2=83=20=ED=95=9C=20=EA=B1=B4=20(?= =?UTF-8?q?=EC=9D=BC=EA=B0=90=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- resources/master_data/ref/_검증_별칭_복사.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 resources/master_data/ref/_검증_별칭_복사.md diff --git a/resources/master_data/ref/_검증_별칭_복사.md b/resources/master_data/ref/_검증_별칭_복사.md new file mode 100644 index 00000000..66e49ad9 --- /dev/null +++ b/resources/master_data/ref/_검증_별칭_복사.md @@ -0,0 +1,17 @@ +# 별칭 복사(3-4) 검증 — 어긋난 것만 + +읽기 전용 검증 · 사본 폴더에서만 저장(정본·대장·키 대장 안 바뀜 → 시험 GX 지울 것 없음). + +## 어긋남 1건 + +- **사용자가 별칭과 같은 이름의 자기 변수를 만들면 조용히 원문 부르기로 바뀜** (`master_copy.gather` → `_unmask`) + - 재현: `GF000219` 를 펴서 별칭 `메쌓기인력` 을 얻은 뒤, 중간에 `메쌓기인력 = 1` 을 더하고 수량 칸을 `메쌓기인력 * 2` 로 씀 → 되돌리면 수량 칸이 `찾기(QF000421, …) * 2` 로 바뀌고 사용자 변수는 안 쓰인 채 남음. 오류 없이 저장됨. + - 원인: `gather` 가 별칭 이름을 입력·중간·덧줄 이름과 견주지 않음(펴기 쪽 `_taken` 은 견줌). + - 고칠 곳: `gather` 에서 별칭 이름이 입력·중간·덧줄 이름과 같으면 `FormulaError`. + +## 어긋남 없음 (확인한 것) + +- ① 정본 40개(산림 9 · 건설 31 · 찾기 많은 8 · 호표 요소에 로직() 14 · 덧줄 13 포함): 펴고 그대로 되돌려 저장 → 40/40 원본과 글자 같음 · 같은 입력 계 40/40 같음. +- ② 별칭 이름 겹침 0건 · 기존 이름과 겹침 0건. 낱말 일부(`별칭량` · `별칭_2`) · 작은따옴표 안 · 이미 원문인 부르기 안 · 점 뒤(값 칸 이름) 는 안 바뀜. 같은 별칭에 두 원문은 막음. +- ③ 호표 요소 안 `로직()` 14개 별칭으로 보였다가 원문으로 돌아옴. +- ④ 저장한 GX 40개 틀·로직 검사 통과(대장 넣은 검사 · 정본 전체 검사 0건). From e0691e241871c3faf09049d5e1ea66344aab803c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 21:13:09 +0900 Subject: [PATCH 7/9] =?UTF-8?q?docs(old=EC=BD=94=EB=93=9C):=20=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EC=83=88=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=ED=9B=84=EB=B3=B4=20=EC=97=B4=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80=20=E2=80=94=20Z01=C2=B7B08=C2=B7B09=C2=B7common=5Futi?= =?UTF-8?q?l=C2=B7=ED=99=94=EB=A9=B4=2024=EC=A4=84=20=EC=A1=B0=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공종 마스터 계열(열쇠대장·연결·자원줄)은 새 마스터에 대응 없음 — 별도 새 마스터 필요. 기계·노임·제비율·재료 계열은 이름 대응 후보 확인 — 구조 비교는 안 함. 실제 코드는 이번에 안 고침 — 딸린 시험(~40개) 함께 고치는 큰 걸음이라 판단 갈림. --- resources/master_data/ref/_걷을_옛코드.md | 80 ++++++++++++----------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/resources/master_data/ref/_걷을_옛코드.md b/resources/master_data/ref/_걷을_옛코드.md index 558c9326..111bf9d5 100644 --- a/resources/master_data/ref/_걷을_옛코드.md +++ b/resources/master_data/ref/_걷을_옛코드.md @@ -4,6 +4,8 @@ 판정: 걷음 = 부르는 곳 없음 · 바꿈 = 부르는 곳 있음(새 마스터 읽기로 바꾼 뒤 걷음). +「새 마스터 후보」= 이름만 대응 확인 · 구조 비교 안 함 · 실제 코드는 안 고침. + ## 걷음 (부르는 곳 없음) | 파일 | 읽는 것 | 부르는 곳 | 판정 | @@ -14,54 +16,54 @@ ## 바꿈 — Z01 -| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | -|---|---|---| -| Z01_MasterData/Z01_MasterData_Tables.py | old/ 화면 이름표 | Z01 BasePrices · BasePrices_Machine · Router · WorkItems · test_z01_master_data | -| Z01_MasterData/Z01_MasterData_BasePrices_Machine.py | old/ 기계 파일 | Z01 BasePrices | -| Z01_MasterData/Z01_MasterData_WorkItems.py | old/ 공종 마스터 · 연결 | Z01 BasePrices · Router · test_z01_work_items · test_z01_base_tables | +| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 | +|---|---|---|---| +| Z01_MasterData/Z01_MasterData_Tables.py | old/ 화면 이름표(4_화면_이름표) | Z01 BasePrices · BasePrices_Machine · Router · WorkItems · test_z01_master_data | 없음(공종 계열) | +| Z01_MasterData/Z01_MasterData_BasePrices_Machine.py | old/ 기계 파일(B09 MachineCost·MachineOperating 통해) | Z01 BasePrices | B09 MachineCost·MachineOperating 판단 따름 | +| Z01_MasterData/Z01_MasterData_WorkItems.py | old/ 공종 마스터 · 열쇠대장 · 연결 | Z01 BasePrices · Router · test_z01_work_items · test_z01_base_tables | 없음(공종 마스터 계열 자체가 새 마스터에 아직 없음) | ## 바꿈 — B08 -| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | -|---|---|---| -| B08_Quantity_Build_WorkItemMaster.py | old/3_품셈_산림_표전체 (읽고 old/ 에 씀) | WorkItemMaster_Const · _Table · 시험 6 | -| B08_Quantity_Build_WorkItemMaster_Const.py | old/3_품셈_건설_표전체 · work_item_keys_const.json | WorkItemMaster · test_work_item_master_const | -| B08_Quantity_Build_WorkItemMaster_Keys.py | old/ 열쇠대장 | WorkItemMaster · _Const · Z01 WorkItems · 시험 3 | -| B08_Quantity_Engine_BasisUnit.py | old/ 기준 원단위 | Handoff · Handoff_Rows · test_b08_basis_unit | -| B08_Quantity_Engine_Formwork.py | old/ 거푸집 사용횟수 | Handoff_Mapping · UnitQuantity · 시험 3 | -| B08_Quantity_Engine_Handoff_Mapping.py | ref/ 수량 연결 · old/ 철근 · 돌쌓기 · 목재 갈래 | Handoff 계열 7 · RockSplit · Provenance | -| B08_Quantity_Engine_MaterialSummary.py | ref/ 재료 할증 | Handoff_Boundaries · Provenance · Router_Material · 시험 다수 | -| B08_Quantity_Engine_ObservedUnit.py | ref/ 구조물 원단위 | StructureFigure · UnitQuantity · UnitQuantity_Base · 시험 다수 | -| B08_Quantity_Engine_Pipe.py | ref/ 사방 기슭막이 치수 | StructureFigure · StructureSummary · Router_Material · B06_Section_Structure_Layouts.ts · 시험 다수 | -| B08_Quantity_Engine_UnitQuantity_StoneSpec.py | ref/ 돌 종류 · old/ 돌쌓기 경사 | ObservedUnit · StructureTemplate · UnitQuantity · _Masonry · common_util_masonry_slope.ts · 시험 3 | +| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 | +|---|---|---|---| +| B08_Quantity_Build_WorkItemMaster.py | old/3_품셈_산림_표전체 (읽고 old/ 에 씀) | WorkItemMaster_Const · _Table · 시험 6 | 로직_산림품셈_*·계수_산림품셈_*·소요량_산림품셈_* 전 장 — 짜맞추기 새로 필요, 단순 대응 아님 | +| B08_Quantity_Build_WorkItemMaster_Const.py | old/3_품셈_건설_표전체 · work_item_keys_const.json | WorkItemMaster · test_work_item_master_const | 로직_건설품셈_*·계수_건설품셈_*·소요량_건설품셈_* 전 장 — 짜맞추기 새로 필요, 단순 대응 아님 | +| B08_Quantity_Build_WorkItemMaster_Keys.py | old/ 열쇠대장 | WorkItemMaster · _Const · Z01 WorkItems · 시험 3 | 없음(공종 마스터 계열) | +| B08_Quantity_Engine_BasisUnit.py | old/ 기준 원단위 | Handoff · Handoff_Rows · test_b08_basis_unit | 후보 낱개 확인 필요(소요량_*.json 장 미확정) | +| B08_Quantity_Engine_Formwork.py | old/ 거푸집 사용횟수(1-7-1) | Handoff_Mapping · UnitQuantity · 시험 3 | 계수_산림품셈_01장_적용기준.json | +| B08_Quantity_Engine_Handoff_Mapping.py | ref/ 수량 연결 · old/ 철근 · 돌쌓기 · 목재 갈래 | Handoff 계열 7 · RockSplit · Provenance | old 부분: 계수_산림품셈_12·13장·로직_산림품셈_12·13장 / ref 수량연결은 공종_수량연결_실무 그대로 | +| B08_Quantity_Engine_MaterialSummary.py | ref/ 재료 할증 | Handoff_Boundaries · Provenance · Router_Material · 시험 다수 | 없음(재료_품셈재료.json 구조 확인 안 됨) | +| B08_Quantity_Engine_ObservedUnit.py | ref/ 구조물 원단위 | StructureFigure · UnitQuantity · UnitQuantity_Base · 시험 다수 | 없음(기술기준 원자료 — 별도 마스터 미확보) | +| B08_Quantity_Engine_Pipe.py | ref/ 사방 기슭막이 치수 | StructureFigure · StructureSummary · Router_Material · B06_Section_Structure_Layouts.ts · 시험 다수 | 없음(사방기술교본 원자료 — 별도 마스터 미확보) | +| B08_Quantity_Engine_UnitQuantity_StoneSpec.py | ref/ 돌 종류 · old/ 돌쌓기 경사 | ObservedUnit · StructureTemplate · UnitQuantity · _Masonry · common_util_masonry_slope.ts · 시험 3 | old 부분: 계수_산림품셈_13장_구조물.json / 돌 종류(ref)는 후보 없음 | ## 바꿈 — B09 -| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | -|---|---|---| -| B09_Estimation_BasisSheet.py | old/_manifest.json · 마스터 | Provenance_Sources · Router · 시험 3 | -| B09_Estimation_LaborSurcharge.py | old/3_품셈_산림_계수 | BasisSheet · Router · Router_Factors · UnitPrice · 시험 2 | -| B09_Estimation_MachineCost.py | old/ 기계 카탈로그 | Lists · Lists_Sources · MachineExpenseSheet · MachineOperating · MachineProductivity 계열 | -| B09_Estimation_MachineExpenseSheet.py | old/ 기계 카탈로그 | Provenance · Router · 시험 4 | -| B09_Estimation_MachineOperating.py | old/ 기계 카탈로그 (읽고 old/ 에 씀) | Consumables · Lists_Sources · UnitPrice · UnitPrice_View · WoodChipping · 시험 다수 | -| B09_Estimation_MachineProductivity_Data.py | old/ 작업량 | MachineProductivity_Dozer · _Dump | -| B09_Estimation_MaterialCatalog.py | old/ 재료 카탈로그 | MaterialSheet · ResourceAxis_Sources · UnitPrice_View | -| B09_Estimation_MaterialPrices.py | old/재료_나라장터자재.json | BillOfQuantities 계열 · Router 계열 · UnitPrice · test_b09_material_prices | -| B09_Estimation_Rates.py | old/ 제비율 | CostSheet · Engine_Cost · Provenance · RateOverride · RateTable · Router 계열 | -| B09_Estimation_ResourceAxis.py | old/ 공종 마스터 · 카탈로그 | B08 Variants · BillOfQuantities · 공종 계산 모듈 다수 | -| B09_Estimation_ResourceAxis_Join.py | old/ 자원줄 | MaterialPrices · ResourceAxis · _ChooseOne · _JudgedTable · _Sources · 시험 2 | -| B09_Estimation_ResourceAxis_Sources.py | old/ 마스터 · 카탈로그 | B08 Variants · ResourceAxis · _Join · 시험 2 | -| B09_Estimation_UnitPrice.py | old/ 각종 (파일명 인자) | B08 Variants · StructurePriceLink · Router 계열 · BillOfQuantities 계열 | +| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 | +|---|---|---|---| +| B09_Estimation_BasisSheet.py | old/_manifest.json · 마스터 | Provenance_Sources · Router · 시험 3 | 후보 낱개 확인 필요(_원문목록.json·_키대장.json 구조 다름) | +| B09_Estimation_LaborSurcharge.py | old/3_품셈_산림_계수 | BasisSheet · Router · Router_Factors · UnitPrice · 시험 2 | 계수_산림품셈_*.json(장별) | +| B09_Estimation_MachineCost.py | old/ 기계경비기준(3_품셈_건설_기계경비기준_8장) | Lists · Lists_Sources · MachineExpenseSheet · MachineOperating · MachineProductivity 계열 | 계수_건설품셈_08장_건설기계.json | +| B09_Estimation_MachineExpenseSheet.py | old/ 기계 카탈로그(MachineCost 통해) | Provenance · Router · 시험 4 | MachineCost 판단 따름 | +| B09_Estimation_MachineOperating.py | old/ 기계운전경비(3_품셈_건설_기계운전경비_8-4, 읽고 old/ 에 씀) | Consumables · Lists_Sources · UnitPrice · UnitPrice_View · WoodChipping · 시험 다수 | 로직_건설품셈_08장_건설기계.json | +| B09_Estimation_MachineProductivity_Data.py | old/ 작업량(3_품셈_산림건설_기계작업량) | MachineProductivity_Dozer · _Dump | 후보 없음(장별 미확정) | +| B09_Estimation_MaterialCatalog.py | old/ 재료 카탈로그 | MaterialSheet · ResourceAxis_Sources · UnitPrice_View | 재료_자재품목.json·재료_품셈재료.json | +| B09_Estimation_MaterialPrices.py | old/재료_나라장터자재.json | BillOfQuantities 계열 · Router 계열 · UnitPrice · test_b09_material_prices | 재료_자재품목.json(이름 직접 대응) | +| B09_Estimation_Rates.py | old/ 제비율 | CostSheet · Engine_Cost · Provenance · RateOverride · RateTable · Router 계열 | 요율_조달청제비율.json(이름 직접 대응) | +| B09_Estimation_ResourceAxis.py | old/ 공종 마스터 · 카탈로그 | B08 Variants · BillOfQuantities · 공종 계산 모듈 다수 | 공종 부분 후보 없음 — 절반 블록 | +| B09_Estimation_ResourceAxis_Join.py | old/ 자원줄 | MaterialPrices · ResourceAxis · _ChooseOne · _JudgedTable · _Sources · 시험 2 | 없음(공종 마스터 계열) | +| B09_Estimation_ResourceAxis_Sources.py | old/ 마스터 · 카탈로그 | B08 Variants · ResourceAxis · _Join · 시험 2 | ResourceAxis.py 판단 따름 | +| B09_Estimation_UnitPrice.py | old/ 각종 (파일명 인자) | B08 Variants · StructurePriceLink · Router 계열 · BillOfQuantities 계열 | 공용 로더 — 낱개 판단 필요(특정 불가) | ## 바꿈 — common_util · 화면 -| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | -|---|---|---| -| common_util/common_util_node_bundle.py | old/ 통째(SOURCE_DATA_DIRS) | B05 Corridor_Prebuild · B06 Router_HaulPlan · B06 Server_Calc_Prebuild · B08 Engine_Formula | -| common_util/common_util_work_item_key.py | old/ 열쇠대장 | B08 Handoff · B09 BillOfQuantities_Input · 시험 2 | -| common_util/common_util_work_item_link.py | old/ 연결 | B09 BillOfQuantities · _Input · test_work_item_link_choice | -| B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts | ref/수량_사방기슭막이치수 (import) | B05 화면 | -| B06_Section/B06_Section_UI_Cross_Lean.ts | old/3_품셈_산림_돌쌓기경사 (import) | B06 화면 | +| 파일 | old/ref 에서 읽는 것 | 부르는 곳 | 새 마스터 후보 | +|---|---|---|---| +| common_util/common_util_node_bundle.py | old/ 통째(SOURCE_DATA_DIRS) | B05 Corridor_Prebuild · B06 Router_HaulPlan · B06 Server_Calc_Prebuild · B08 Engine_Formula | 없음 — 전체 재설계 필요(개별 대응 불가) | +| common_util/common_util_work_item_key.py | old/ 열쇠대장 | B08 Handoff · B09 BillOfQuantities_Input · 시험 2 | 없음(공종 마스터 계열) | +| common_util/common_util_work_item_link.py | old/ 연결 | B09 BillOfQuantities · _Input · test_work_item_link_choice | 없음(공종 마스터 계열) | +| B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts | ref/수량_사방기슭막이치수 (import) | B05 화면 | B08 Engine_Pipe.py 판단 따름(후보 없음) | +| B06_Section/B06_Section_UI_Cross_Lean.ts | old/3_품셈_산림_돌쌓기경사 (import) | B06 화면 | 계수_산림품셈_13장_구조물.json(StoneSpec.py 와 같음) | ## 바꿈 — 시험 From f7ecefa2fcf4dd306588ab46ca5c3e1af738feec Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 21:13:10 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(M01):=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=84=B8=EC=9D=98=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=EC=A4=91=EB=B3=B5=20=EC=A0=95=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EB=8A=94=20=EC=A4=91=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자체 로직은 고치는 자리를 편집기 하나로 두고 읽기용 묶음 소계 표는 접어 둠 - 자동 재계산으로 다시 그려도 고치던 칸에 커서와 선택 위치가 남음 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- .../M01_MasterData_UI_LogicLab_Detail.ts | 35 ++++++++++++++----- .../M01_MasterData_UI_LogicLab_Text.ts | 1 + 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index dc657656..02d734f3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -309,19 +309,24 @@ function hoBox(ctx: DetailContext): HTMLElement { } const all = entries(ctx); const calculated = ctx.lines !== null; - const blocks = GROUPS.flatMap((group) => { + const tables = GROUPS.flatMap((group) => { const mine = all.filter((e) => e.group === group); return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만 }); const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); - blocks.push( - el("p", { - className: "m01lab__total", - attrs: { "data-sum": calculated ? String(total) : "" }, - text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`, - }), - ); - if (isOwnLogic(ctx.row)) blocks.push(ownEditor(ctx)); + const totalLine = el("p", { + className: "m01lab__total", + attrs: { "data-sum": calculated ? String(total) : "" }, + text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`, + }); + // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임) + const fold = el("details", { + className: "m01lab__fold", + attrs: ctx.ownHost.dataset.fold ? { open: "" } : {}, + children: [el("summary", { text: tl("Ho_Fold") }), ...tables], + }); + fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : "")); + const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine]; const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; return section(tl("Ho_Title"), blocks, hint); } @@ -330,10 +335,22 @@ function hoBox(ctx: DetailContext): HTMLElement { export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); buildFormula(formula, { text: ctx.text }); + // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 + const active = document.activeElement; + const kept = + active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement + ? { node: active, from: active.selectionStart, to: active.selectionEnd } + : null; host.replaceChildren( infoBox(ctx), hoBox(ctx), section(tl("Formula_Title"), [formula]), section(tl("Calc_Title"), [ctx.calcHost]), ); + const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 + const back = own ? host.querySelector(`[data-own="${own}"]`) : kept?.node; + if (kept && back && (own || ctx.ownHost.contains(back))) { + back.focus(); + if (kept.from !== null) back.setSelectionRange(kept.from, kept.to); + } } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index dd725120..72b44b35 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -19,6 +19,7 @@ const TEXT = { "고치기 (호표 · 중간 값 · 덧줄 · 끝수)", "Edit (lines · middles · extras · rounding)", ], + Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"], Ho_Title: ["일위대가 호표", "Unit-cost table"], Formula_Title: ["텍스트 수식", "Text formula"], Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"], From bf7764fb99259eb1f318a35fcd11683f3881938e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 21:24:20 +0900 Subject: [PATCH 9/9] =?UTF-8?q?test(M01):=20=EB=82=A1=EC=9D=80=20=ED=92=88?= =?UTF-8?q?=EC=85=88=EC=9E=AC=EB=A3=8C=20=EB=8B=A8=EA=B0=80=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=EC=9D=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4+=EB=8C=80=ED=91=9C=20=EC=A4=84=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GC000628 시멘트 줄이 고르기 조건으로 바뀌어 「MP000002」 열쇠가 없어진 것 — 데이터는 그대로 두고 시험을 지금 모양에 맞춤(대표 MT004176 줄의 낮은 값 · 후보 수). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- resources/tester/test_m01_api.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 3a294054..e85d7485 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -375,11 +375,12 @@ def test_못_이은_줄만_거름(client: TestClient) -> None: assert 0 < cut["total"] < every["total"] and not any(r["연결"] for r in cut["rows"]) -def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClient) -> None: - key = key_of("GC000628") - one = _get(client, "/api/m01/logic", key=key) - cement = one["prices"][key_of("MP:시멘트")] - assert cement["값"] > 0 and cement["출처"].startswith("MT004176" + ".") +def test_재료_고르기_줄_단가는_대표_자재품목의_낮은_값(client: TestClient) -> None: + """옛 「MP:시멘트」 줄이 고르기 조건+대표로 바뀜 — 값은 대표 자재품목 줄의 낮은 값.""" + one = _get(client, "/api/m01/logic", key=key_of("GC000628")) + cond = {"구분": "시멘트·골재", "상세구분": "시멘트", "대표": "MT004176"} + cement = one["prices"][store.cm.mf.cond_text(cond)] + assert cement["ref"] == "MT004176" and cement["값"] > 0 and cement["후보"] >= 1 def test_전력은_계약종별을_골라야_값이_섬(client: TestClient) -> None: