From 65e3c919ca265dccabaa781dcd80d3f8122ffe2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:42:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(m02-df):=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=91=9C=20=ED=8C=90=20=C2=B7=20standardsList=20=E2=80=94=20?= =?UTF-8?q?=EB=91=90=20=ED=91=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=B9=B8?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=C2=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=EC=B2=98=EC=9D=8C=20=EA=B0=92=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_DF_Standards.ts — renderStandards(host, on) · standardsList()(단위 고르기 · 끝수 제안 · 동기) · loadStandards · 서버 GET/PUT/DELETE /api/m02/df/standards - 시험 test_m02_df_standards.py 에 standardsList 목록(단위 반올림 · 금액 지위 → 자리 · 버림) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- .../DataFlow/M02_DF_Standards.css | 57 +++ .../DataFlow/M02_DF_Standards.ts | 350 ++++++++++++++++++ resources/tester/test_m02_df_standards.py | 65 ++++ 3 files changed, 472 insertions(+) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Standards.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Standards.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.css b/M02_MasterTemplete/DataFlow/M02_DF_Standards.css new file mode 100644 index 000000000..066d8dc0f --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Standards.css @@ -0,0 +1,57 @@ +/* 단가산출 흐름도 — 표준 표 판(M02_DF_Standards.ts) · 단추 · 칸 크기는 공용 그대로(덮지 않음) */ +.m02-df-std { + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); +} + +.m02-df-std__head { + display: flex; + align-items: center; + gap: var(--spacing-8, 8px); +} + +.m02-df-std__head h3 { + margin: 0 auto 0 0; +} + +.m02-df-std__state, +.m02-df-std__hint, +.m02-df-std__line { + color: var(--color-text-muted, #6b7280); +} + +.m02-df-std__hint { + margin: 0; +} + +.m02-df-std__scroll { + overflow-x: auto; +} + +.m02-df-std__table { + border-collapse: collapse; + width: 100%; +} + +.m02-df-std__table th, +.m02-df-std__table td { + border: 1px solid var(--color-border, #e5e7eb); + padding: 2px 6px; + text-align: left; + white-space: nowrap; +} + +.m02-df-std__table td[tabindex] { + cursor: pointer; +} + +.m02-df-std__table th { + background: var(--color-surface-muted, rgba(127, 127, 127, 0.08)); +} + +.m02-df-std__notes { + margin: 0; + padding-left: 0; + list-style: none; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts new file mode 100644 index 000000000..a94582b9b --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts @@ -0,0 +1,350 @@ +/* ============================================================================= + * M02_DF_Standards.ts + * 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12) + * 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침) + * 서버 = `GET · PUT · DELETE /api/m02/df/standards` → {unit, money, saved} · 고친 것은 `_data`(마스터에 안 씀) + * `renderStandards(host, on)` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로) + * `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것) + * 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import unitDefault from "./_standard/단위표준.json"; +import moneyDefault from "./_standard/금액단위표준.json"; +import "./M02_DF_Standards.css"; + +const TEXT = { + Title: ["표준 표", "Standards"], + UnitTitle: ["설계서의 단위 및 소수의 표준", "Units and decimals in design documents"], + MoneyTitle: ["금액의 단위표준", "Money rounding units"], + Source: ["원문 · 산림품셈 1-2-2", "Source · Forest standard 1-2-2"], + Hint: [ + "칸을 눌러 고침 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 고친 것은 이 시험판에만 저장", + "Click a cell to edit · decimals “-” = 0 · unit “-” = none · edits are saved in this trial only", + ], + Item: ["종목", "Item"], + SpecUnit: ["규격 단위", "Size unit"], + SpecPlaces: ["규격 소수자리", "Size decimals"], + QtyUnit: ["단위수량 단위", "Quantity unit"], + QtyPlaces: ["단위수량 소수자리", "Quantity decimals"], + Note: ["비고", "Note"], + Line: ["원문 줄", "Source line"], + Unit: ["단위", "Unit"], + Step: ["지위", "Step"], + Save: ["저장", "Save"], + Reset: ["처음 값으로", "Back to original"], + ResetAsk: [ + "고친 표준 표를 지우고 원문 처음 값으로 되돌릴까요?", + "Discard edits and go back to the original values?", + ], + Saved: ["표준 표를 저장했어요", "Standards saved"], + Restored: ["처음 값으로 되돌렸어요", "Back to the original values"], + Unsaved: ["저장 안 함", "Not saved"], + Edited: ["고친 표", "Edited"], + Original: ["원문 처음 값", "Original"], + Failed: [ + "표준 표를 못 가져왔어요 — 처음 값을 보여 줘요", + "Could not load — showing the original", + ], + SaveFailed: ["저장하지 못했어요", "Could not save"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Merged: ["위 종목과 한 칸(원문 병합)", "Same cell as the item above (merged in source)"], +} as const; +const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 모양 ─────────────────────────────────────────────────────────────── */ + +export interface UnitStdRow { + 종목: string; + "규격 단위": string | null; + "규격 소수자리": number | null; + "단위수량 단위": string | null; + "단위수량 소수자리": number | null; + 비고: string; + "원문 줄": number; + 병합?: boolean; +} +export interface UnitStd { + 이름: string; + 출처: string; + 원문: string; + 바꿈?: string; + 줄: UnitStdRow[]; + 주: string[]; +} +export interface MoneyStdRow { + 종목: string; + 단위: string; + 지위: number; + 비고: string; + "원문 줄": number; +} +export interface MoneyStd { + 이름: string; + 출처: string; + 원문: string; + 줄: MoneyStdRow[]; +} +export interface Standards { + unit: UnitStd; + money: MoneyStd; + /** `_data` 에 고친 것이 있음 */ + saved: boolean; +} + +/** 단위 고르기 · 끝수 제안이 쓰는 한 줄 */ +export interface StandardItem { + 종목: string; + 갈래: "규격" | "단위수량" | "금액"; + 단위: string; + /** 규격 · 단위수량 = 소수자리 · 금액 = 지위에서 나온 자리(1,000 원 → -3 · 0.1 원 → 1) */ + 소수자리: number; + /** 금액만 — 지위(원) */ + 지위?: number; + 방법: "반올림" | "버림"; +} + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +const ORIGINAL: Standards = { + unit: unitDefault as UnitStd, + money: moneyDefault as MoneyStd, + saved: false, +}; +let current: Standards = clone(ORIGINAL); + +/* ── 목록(동기) ───────────────────────────────────────────────────────── */ + +/** 지위 → 소수자리 — 1,000 원 → -3 · 1 원 → 0 · 0.1 원 → 1 */ +const placesOf = (step: number): number => -Math.round(Math.log10(step)); + +/** 단위 표준(주 ① 반올림) 줄마다 규격 · 단위수량 둘 + 금액(미만 버림) — 단위가 비었거나 「없음」 이면 뺌 */ +export function standardsList(std: Standards = current): StandardItem[] { + const out: StandardItem[] = []; + for (const r of std.unit.줄) { + for (const [갈래, unit, places] of [ + ["규격", r["규격 단위"], r["규격 소수자리"]], + ["단위수량", r["단위수량 단위"], r["단위수량 소수자리"]], + ] as const) { + if (!unit || unit === "없음") continue; + out.push({ 종목: r.종목, 갈래, 단위: unit, 소수자리: places ?? 0, 방법: "반올림" }); + } + } + for (const r of std.money.줄) + out.push({ + 종목: r.종목, + 갈래: "금액", + 단위: r.단위, + 소수자리: placesOf(r.지위), + 지위: r.지위, + 방법: "버림", + }); + return out; +} + +/** 지금 표(판이 서버 값을 받았으면 그것) — 사본 */ +export const standardsNow = (): Standards => clone(current); + +/* ── 서버 ─────────────────────────────────────────────────────────────── */ + +async function call(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { + method, + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as Standards & { detail?: unknown }; + if (!response.ok) throw new Error(String(data.detail ?? response.statusText)); + return data; +} + +/** 서버 표 받기 — 못 받으면 처음 값 */ +export async function loadStandards(): Promise { + current = await call("GET"); + return clone(current); +} + +/* ── 판 ───────────────────────────────────────────────────────────────── */ + +export interface StandardsOn { + /** 저장 · 되돌리기 뒤 — 단위 고르기 등이 새 목록을 다시 읽게 */ + change?(std: Standards): void; +} + +type UnitKey = "규격 단위" | "규격 소수자리" | "단위수량 단위" | "단위수량 소수자리" | "비고"; +type MoneyKey = "단위" | "지위" | "비고"; + +/** 칸 글 → 값 — 소수자리 · 지위는 수(「-」 = 0 · 빈 칸 = null) · 단위는 글(「-」 = 없음 · 빈 칸 = null) */ +function parseCell(key: string, text: string): string | number | null | undefined { + const t = text.trim(); + if (key === "비고") return t; + if (key.includes("소수자리") || key === "지위") { + if (t === "") return key === "지위" ? undefined : null; + if (t === "-") return 0; + const n = Number(t.replace(/,/g, "")); + return Number.isFinite(n) ? n : undefined; + } + if (t === "-") return "없음"; + return t || null; +} + +const show = (v: unknown): string => + v === null || v === undefined ? "" : typeof v === "number" ? v.toLocaleString() : String(v); + +/** 시험판 안 「표준 표」 판 */ +export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void { + let work = clone(current); + let dirty = false; + const state = el("span", { className: "m02-df-std__state" }); + const save = createButton({ label: tx("Save"), size: "sm", onClick: () => void doSave() }); + const reset = createButton({ + label: tx("Reset"), + size: "sm", + variant: "ghost", + onClick: () => void doReset(), + }); + const body = el("div", { className: "m02-df-std__body" }); + + const mark = (): void => { + state.textContent = dirty ? tx("Unsaved") : work.saved ? tx("Edited") : tx("Original"); + save.disabled = !dirty; + }; + + /** 칸 하나 — 누르면 입력 칸 · Enter / 칸 밖 = 끝 · Esc = 취소 */ + const cell = (value: unknown, commit: (text: string) => boolean, title = ""): HTMLElement => { + const td = el("td", { + text: show(value), + attrs: { tabindex: "0", ...(title ? { title } : {}) }, + }); + const edit = (): void => { + if (td.querySelector("input")) return; + const input = el("input", { className: "ui-input ui-input--sm" }); + input.value = show(value).replace(/,/g, ""); + td.replaceChildren(input); + input.focus(); + let done = false; + const finish = (keep: boolean): void => { + if (done) return; + done = true; + if (keep && !commit(input.value)) { + showToast(tx("Bad"), "error"); + done = false; + input.focus(); + return; + } + draw(); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") finish(true); + if (ev.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); + }; + td.addEventListener("click", edit); + td.addEventListener("keydown", (ev) => ev.key === "Enter" && edit()); + return td; + }; + + const table = (heads: string[], rows: HTMLElement[][]): HTMLElement => + el("div", { + className: "m02-df-std__scroll", + children: [ + el("table", { + className: "m02-df-std__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], + }), + el("tbody", { children: rows.map((cells) => el("tr", { children: cells })) }), + ], + }), + ], + }); + + const put = (row: T, key: string, text: string): boolean => { + const v = parseCell(key, text); + if (v === undefined) return false; + (row as Record)[key] = v; + dirty = true; + return true; + }; + + const draw = (): void => { + const unitKeys: UnitKey[] = ["규격 단위", "규격 소수자리", "단위수량 단위", "단위수량 소수자리", "비고"]; // prettier-ignore + const unitRows = work.unit.줄.map((r) => [ + el("td", { text: r.종목, attrs: r.병합 ? { title: tx("Merged") } : {} }), + ...unitKeys.map((k) => cell(r[k], (t) => put(r, k, t))), + el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }), + ]); + const moneyKeys: MoneyKey[] = ["단위", "지위", "비고"]; + const moneyRows = work.money.줄.map((r) => [ + el("td", { text: r.종목 }), + ...moneyKeys.map((k) => cell(r[k], (t) => put(r, k, t))), + el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }), + ]); + body.replaceChildren( + el("h4", { text: tx("UnitTitle") }), + table( + [tx("Item"), tx("SpecUnit"), tx("SpecPlaces"), tx("QtyUnit"), tx("QtyPlaces"), tx("Note"), tx("Line")], // prettier-ignore + unitRows, + ), + el("ol", { + className: "m02-df-std__notes", + children: work.unit.주.map((n) => el("li", { text: n })), + }), + el("h4", { text: tx("MoneyTitle") }), + table([tx("Item"), tx("Unit"), tx("Step"), tx("Note"), tx("Line")], moneyRows), + ); + mark(); + }; + + const take = (std: Standards): void => { + current = std; + work = clone(std); + dirty = false; + draw(); + on.change?.(clone(std)); + }; + + async function doSave(): Promise { + save.disabled = true; + try { + take(await call("PUT", { unit: work.unit, money: work.money })); + showToast(tx("Saved"), "success"); + } catch { + showToast(tx("SaveFailed"), "error"); + mark(); + } + } + + async function doReset(): Promise { + if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return; + try { + take(await call("DELETE")); + } catch { + take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값 + } + showToast(tx("Restored"), "success"); + } + + host.replaceChildren( + el("div", { + className: "m02-df-std", + children: [ + el("div", { + className: "m02-df-std__head", + children: [el("h3", { text: tx("Title") }), state, reset, save], + }), + el("p", { className: "m02-df-std__hint", text: `${tx("Source")} · ${tx("Hint")}` }), + body, + ], + }), + ); + draw(); + void loadStandards().then( + (std) => take(std), + () => showToast(tx("Failed"), "info"), + ); +} diff --git a/resources/tester/test_m02_df_standards.py b/resources/tester/test_m02_df_standards.py index 26af3dd7b..2aae5fe0d 100644 --- a/resources/tester/test_m02_df_standards.py +++ b/resources/tester/test_m02_df_standards.py @@ -78,3 +78,68 @@ def test_금액_다섯_줄() -> None: ] assert all(r["단위"] == "원" and r["비고"] == "미만버림" for r in rows) assert all(_cells(r["원문 줄"])[0] == r["종목"] for r in rows) + + +# ── standardsList(단위 고르기 · 끝수 제안이 씀) — Node 로 `M02_DF_Standards.ts` 를 바로 돌림 ────────── +HOOKS = r""" +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\s+"[^"]+\.css";/g, ""); + src = src.replace(/import\s+(\w+)\s+from\s+"\.\/_standard\/([^"]+)\.json";/g, "const $1 = globalThis.__std[\"$2\"];"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" + + +def _node(body: str) -> object: + import shutil + import subprocess + + assert shutil.which("node") + script = ( + "import { register } from 'node:module';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : k === 'API_BASE_URL' ? '/api' : () => undefined });\n" # noqa: E501 + f"globalThis.__std = {json.dumps({'단위표준': UNIT, '금액단위표준': MONEY}, ensure_ascii=False)};\n" + f"const S = await import({json.dumps((STD.parent / 'M02_DF_Standards.ts').as_uri())});\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-2000:] + return json.loads(result.stdout) + + +def test_standardsList_목록() -> None: + got = _node("out(S.standardsList());") + units = [ + (r["종목"], g, u, p) + for r in UNIT["줄"] + for g, u, p in [ + ("규격", r["규격 단위"], r["규격 소수자리"]), + ("단위수량", r["단위수량 단위"], r["단위수량 소수자리"]), + ] + if u and u != "없음" + ] + assert [ + (x["종목"], x["갈래"], x["단위"], x["소수자리"]) for x in got if x["갈래"] != "금액" + ] == [(n, g, u, p or 0) for n, g, u, p in units] + assert all(x["방법"] == "반올림" for x in got if x["갈래"] != "금액") + money = [x for x in got if x["갈래"] == "금액"] + assert [(x["지위"], x["소수자리"], x["방법"]) for x in money] == [ + (1000, -3, "버림"), + (1, 0, "버림"), + (1, 0, "버림"), + (1, 0, "버림"), + (0.1, 1, "버림"), + ] From 67ced35ade05452339e42d90925601630305e512 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:42:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore(M01):=20=EC=98=9B=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=ED=8C=90=20=EC=85=8B=20=EC=A7=80=EC=9B=80=20=E2=80=94?= =?UTF-8?q?=20=EC=89=AC=EC=9A=B4=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=C2=B7=20=EB=A1=9C=EC=A7=81=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=C2=B7=20=EC=8B=9C=ED=97=98=20=C2=B7=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=EB=AA=A9=EB=A1=9D=20=ED=95=AD=EB=AA=A9=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=93=B1=EB=A1=9D=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 남김: 단가산출 다이어그램(CostFlow) · 로직_자체.json - CostFlow 견본 시험의 /calc 대조를 M01 store 셈으로 바꿈(LogicBuilder 안 기댐) - 추적 안 된 찌꺼기(__pycache__ · _data)는 tmp/trash_1001/ 로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts | 571 -------------- .../EasyCalc/M01_EasyCalc_Cards.css | 192 ----- M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts | 542 -------------- M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css | 97 --- M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts | 620 ---------------- M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts | 567 -------------- M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts | 587 --------------- M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css | 100 --- M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts | 495 ------------- .../EasyCalc/M01_EasyCalc_Sheet.css | 112 --- M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts | 246 ------- .../EasyCalc/M01_EasyCalc_Style.css | 175 ----- .../EasyCalc/M01_EasyCalc_Table.css | 161 ---- M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts | 696 ------------------ M01_MasterData/LogicBuilder/M01_LB_Api.ts | 121 --- M01_MasterData/LogicBuilder/M01_LB_Blocks.ts | 665 ----------------- M01_MasterData/LogicBuilder/M01_LB_Calc.py | 114 --- M01_MasterData/LogicBuilder/M01_LB_Check.ts | 266 ------- M01_MasterData/LogicBuilder/M01_LB_Entry.css | 242 ------ M01_MasterData/LogicBuilder/M01_LB_Entry.ts | 693 ----------------- .../LogicBuilder/M01_LB_Entry_Result.ts | 273 ------- M01_MasterData/LogicBuilder/M01_LB_Grid.css | 109 --- M01_MasterData/LogicBuilder/M01_LB_Grid.ts | 540 -------------- M01_MasterData/LogicBuilder/M01_LB_Source.css | 196 ----- M01_MasterData/LogicBuilder/M01_LB_Source.ts | 371 ---------- M01_MasterData/LogicBuilder/M01_LB_Steps.css | 201 ----- M01_MasterData/LogicBuilder/M01_LB_Steps.ts | 392 ---------- .../LogicBuilder/M01_LB_Steps_Calc.ts | 201 ----- .../LogicBuilder/M01_LB_Steps_Cell.ts | 287 -------- M01_MasterData/LogicBuilder/M01_LB_Wizard.css | 199 ----- M01_MasterData/LogicBuilder/M01_LB_Wizard.ts | 628 ---------------- .../LogicBuilder/M01_LB_Wizard_Build.ts | 270 ------- .../LogicBuilder/M01_LB_Wizard_Calc.ts | 558 -------------- .../LogicBuilder/M01_LB_Wizard_Parts.ts | 94 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 12 - M01_MasterData/SheetLogic/.gitignore | 1 - M01_MasterData/SheetLogic/M01_SL_Api.ts | 93 --- M01_MasterData/SheetLogic/M01_SL_Diagram.css | 251 ------- M01_MasterData/SheetLogic/M01_SL_Diagram.ts | 513 ------------- M01_MasterData/SheetLogic/M01_SL_Entry.css | 175 ----- M01_MasterData/SheetLogic/M01_SL_Entry.ts | 299 -------- .../SheetLogic/M01_SL_Entry_Paper.ts | 469 ------------ M01_MasterData/SheetLogic/M01_SL_Eval.ts | 245 ------ M01_MasterData/SheetLogic/M01_SL_Import.ts | 694 ----------------- .../SheetLogic/M01_SL_Import_Parse.ts | 273 ------- M01_MasterData/SheetLogic/M01_SL_Item.css | 76 -- M01_MasterData/SheetLogic/M01_SL_Item.ts | 647 ---------------- .../SheetLogic/M01_SL_LogicTools.css | 87 --- .../SheetLogic/M01_SL_LogicTools.ts | 297 -------- M01_MasterData/SheetLogic/M01_SL_Model.ts | 538 -------------- M01_MasterData/SheetLogic/M01_SL_Panel.css | 174 ----- M01_MasterData/SheetLogic/M01_SL_Panel.ts | 544 -------------- M01_MasterData/SheetLogic/M01_SL_Result.css | 98 --- M01_MasterData/SheetLogic/M01_SL_Result.ts | 266 ------- M01_MasterData/SheetLogic/M01_SL_Router.py | 396 ---------- M01_MasterData/SheetLogic/M01_SL_Table.css | 138 ---- M01_MasterData/SheetLogic/M01_SL_Table.ts | 616 ---------------- .../SheetLogic/M01_SL_Table_Build.ts | 480 ------------ main.py | 10 - resources/tester/test_m01_easycalc_api.py | 349 --------- resources/tester/test_m01_easycalc_cond.py | 198 ----- resources/tester/test_m01_easycalc_model.py | 402 ---------- resources/tester/test_m01_easycalc_table.py | 155 ---- resources/tester/test_m01_lb_blocks.py | 309 -------- resources/tester/test_m01_lb_calc.py | 181 ----- resources/tester/test_m01_lb_check.py | 382 ---------- resources/tester/test_m01_lb_wizard_build.py | 193 ----- resources/tester/test_m01_sl_import.py | 333 --------- resources/tester/test_m01_sl_model.py | 413 ----------- resources/tester/test_m01_sl_router.py | 366 --------- resources/tester/test_m01_sl_table_build.py | 276 ------- resources/tester/test_m02_cf_sample.py | 24 +- 72 files changed, 4 insertions(+), 22550 deletions(-) delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Style.css delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Table.css delete mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Api.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Blocks.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Calc.py delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Check.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Entry.css delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Entry.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Grid.css delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Grid.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Source.css delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Source.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps.css delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard.css delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts delete mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts delete mode 100644 M01_MasterData/SheetLogic/.gitignore delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Api.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Entry.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Entry.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Eval.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Import.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Item.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Item.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_LogicTools.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_LogicTools.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Model.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Panel.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Panel.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Result.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Result.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Router.py delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Table.css delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Table.ts delete mode 100644 M01_MasterData/SheetLogic/M01_SL_Table_Build.ts delete mode 100644 resources/tester/test_m01_easycalc_api.py delete mode 100644 resources/tester/test_m01_easycalc_cond.py delete mode 100644 resources/tester/test_m01_easycalc_model.py delete mode 100644 resources/tester/test_m01_easycalc_table.py delete mode 100644 resources/tester/test_m01_lb_blocks.py delete mode 100644 resources/tester/test_m01_lb_calc.py delete mode 100644 resources/tester/test_m01_lb_check.py delete mode 100644 resources/tester/test_m01_lb_wizard_build.py delete mode 100644 resources/tester/test_m01_sl_import.py delete mode 100644 resources/tester/test_m01_sl_model.py delete mode 100644 resources/tester/test_m01_sl_router.py delete mode 100644 resources/tester/test_m01_sl_table_build.py diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts deleted file mode 100644 index 694236d86..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts +++ /dev/null @@ -1,571 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Api.ts - * 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 · - * 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값 - * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀) - * 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로) - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - ApiError, - copyLogic, - fetchAuto, - fetchLogic, - runCalc, - type CalcAnswer, - type LogicInput, - type LogicRow, -} from "../M01_MasterData_UI_Logic_Api"; -import { safeReason } from "../M01_MasterData_UI_Logic_Text"; - -/** 목록 한 줄 */ -export interface EasyListItem { - key: string; - name: string; - /** 품셈 기본(읽기만) · false = 내가 만든 것 */ - isBase: boolean; - /** 화면 글 「품셈 기본」 · 「내가 만든 것」 */ - owner: string; - /** 결과단위 「원/㎡」 */ - resultUnit: string; - /** 쉬운 길로 열 수 있나 — 돈 아닌 로직(작업량 등)은 false */ - canOpen: boolean; - /** 못 여는 까닭 쉬운 말 · 열 수 있으면 null */ - whyNot: string | null; - /** 어디 것인지 — 「산림품셈 13장 구조물」 · 내가 만든 것은 장만 */ - where: string; -} - -/** 열기 답 — `trial` = 설계 때 정하는 값의 견본(자동값) */ -export interface EasyOpened { - row: LogicRow; - version: string; - isBase: boolean; - trial: Record; -} - -/** 새로 · 본뜨기 · 고치기 답 */ -export interface EasySaved { - key: string; - version: string; - row: LogicRow; -} - -/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직 - * `where` = 회사 · 개인 층 조합의 자리 글(「회사 · 산림조합」) · 마스터 층 · 로직은 없음 - * 층을 못 읽었으면 `key` "" · `name` = 「일부를 못 읽음」 한 줄(지우기 전에 확인하게) */ -export interface EasyUse { - kind: "combo" | "logic"; - where?: string; - key: string; - name: string; -} - -/** 기계 고르기 목록 한 줄 */ -export interface EasyMachine { - 분류번호: string; - 이름: string; - 규격: string; - /** 줄 단위(「시간」) */ - 단위: string; -} - -/** 기종 → 호표 「요소」(기계경비 로직 부름 글) · 그 로직이 받는 설계 때 정하는 값 */ -export interface EasyMachineElement { - element: string; - needInputs: LogicInput[]; -} - -/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */ -export interface MachineLogic { - key: string; - name: string; - resultUnit: string; - inputs: LogicInput[]; -} - -/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */ -const TEXT = { - Base: ["품셈 기본", "Standard"], - Mine: ["내가 만든 것", "Mine"], - NotMoney: [ - "금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄", - "Gives a non-money value (output etc.) — open it in the full screen", - ], - E403: [ - "품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침", - "Standard items are read-only — use “Edit a copy”", - ], - E404: [ - "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요", - "Not found — it may be deleted; reopen the list", - ], - E409: [ - "다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요", - "Changed elsewhere first — reopen and edit again", - ], - Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"], - Generic: [ - "저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요", - "Did not pass the check — review your changes", - ], - More: ["그 밖 {n} 건", "{n} more"], - UnknownName: [ - "「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요", - "「{name}」 is not defined — add it as a design value", - ], - NeedCost: [ - "넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요", - "Pick where it goes (labor · material · equipment)", - ], - NoLines: [ - "줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요", - "No lines — add at least one labor, material or machine line", - ], - Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"], - CallNeeds: [ - "부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요", - "The called item needs 「{name}」 — add it as a design value", - ], - CallExtra: [ - "부르는 다른 일위대가는 「{name}」 값을 받지 않음", - "The called item does not take 「{name}」", - ], - Kind: [ - "고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요", - "The picked item does not match the line type — pick again", - ], - Loop: [ - "서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요", - "Items call each other — remove one side", - ], - Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"], - Gone: [ - "이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음", - "A linked item is missing — items still in use cannot be deleted", - ], - NoName: ["이름을 넣어 주세요", "Enter a name"], - Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"], - Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"], - Material: [ - "재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요", - "No material matches — pick again", - ], - Stop: [ - "이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요", - "Cannot be calculated with these values — check the design values", - ], - NoMachine: [ - "이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요", - "No hourly cost data for this machine — pick another", - ], - AtCompany: ["회사 · {name}", "Company · {name}"], - AtPerson: ["개인 · {name}", "Personal · {name}"], - MyCompany: ["내 회사", "My company"], - Me: ["나", "Me"], - Unseen: [ - "회사 · 개인 조합 일부를 못 읽음 — 지우기 전에 확인 필요", - "Some company or personal combos could not be read — check before deleting", - ], -} as const satisfies Record; - -type SayKey = keyof typeof TEXT; - -function say(key: SayKey, fill: Record = {}): string { - const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; - return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); -} - -/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */ -function plainCheck(line: string): string { - const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null; - const unknown = name(/모르는 이름 「([^」]+)」/); - if (unknown) return say("UnknownName", { name: unknown }); - const needs = name(/입력 「([^」]+)」 안 줌/); - if (needs) return say("CallNeeds", { name: needs }); - const extra = name(/에 입력 「([^」]+)」 없음/); - if (extra) return say("CallExtra", { name: extra }); - if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines"); - if (/비목 「/.test(line)) return say("NeedCost"); - if (/출처 모양/.test(line)) return say("Source"); - if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind"); - if (/돌고 도는 참조/.test(line)) return say("Loop"); - if (/식 못 읽음/.test(line)) return say("Unread"); - if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone"); - if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName"); - if (/끝수/.test(line)) return say("Cut"); - if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material"); - if (/칸 「[^」]+」 없음/.test(line)) return say("Broken"); - return say("Generic"); -} - -/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */ -function plainOf(status: number, detail: unknown): string { - if (status === 0) return say("Net"); - if (status === 403) return say("E403"); - if (status === 404) return say("E404"); - if (status === 409) return say("E409"); - const body = detail as { errors?: unknown } | null; - const lines = - status === 422 && body && Array.isArray(body.errors) - ? body.errors.map((x) => String(x)) - : typeof detail === "string" - ? [detail] - : []; - if (!lines.length) return say("Generic"); - const plain = [...new Set(lines.map(plainCheck))]; - const shown = plain.slice(0, 3); - if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 })); - return shown.join("\n"); -} - -/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */ -function plainStop(text: string): string { - return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text; -} - -const MACHINE = "기계"; -/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */ -const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]); - -/** 그 로직이 받는 기종(분류번호) 목록 */ -function machineCodes(m: MachineLogic): string[] { - const spec = m.inputs.find((i) => i.이름 === MACHINE); - return (spec?.고르기 ?? []).map(String); -} - -/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터. - * 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */ -function machineRank(m: MachineLogic): number[] { - const names = m.inputs.map((i) => i.이름); - const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0; - const lossOnly = names.includes("지역") ? 0 : 1; - const boost = lossOnly && names.includes("보정작업") ? 1 : 0; - return [attach, lossOnly, boost, machineCodes(m).length]; -} - -/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */ -export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null { - const ranked = family - .filter((m) => machineCodes(m).includes(code)) - .map((m) => ({ m, rank: machineRank(m) })) - .sort((a, b) => { - for (let i = 0; i < a.rank.length; i += 1) { - if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i]; - } - return a.m.key < b.m.key ? -1 : 1; - }); - return ranked[0]?.m ?? null; -} - -/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */ -export function machineCall(m: MachineLogic, code: string): EasyMachineElement { - const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); - const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE); - const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); - return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs }; -} - -/** 층 조합 줄 통째(`담은로직` 포함) */ -export interface LayerCombo { - 키: string; - 이름?: string; - 담은로직?: { 로직?: string }[]; -} - -/** 이 로직을 담은 층 조합 — `layer` = company · personal · `name` = 층 주인 이름 */ -export function holding( - combos: LayerCombo[], - key: string, - layer: "company" | "personal", - name: string, -): EasyUse[] { - const where = say(layer === "company" ? "AtCompany" : "AtPerson", { name }); - return combos - .filter((c) => (c.담은로직 ?? []).some((one) => one?.로직 === key)) - .map((c): EasyUse => ({ kind: "combo", where, key: c.키, name: c.이름 ?? "" })); -} -/* ── 순수 끝 ── */ - -const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`) -const OWNER = "현장"; -const CALC_WAIT_MS = 300; -const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」) - -/** `/api/m01` 길 · M02 층 조합 읽기는 `m02` */ -const request = (path: string, body?: unknown): Promise => - send(`${API_BASE_URL}/m01${path}`, body); -const m02 = (path: string): Promise => send(`${API_BASE_URL}/m02${path}`); - -async function send(url: string, body?: unknown): Promise { - let response: Response; - try { - response = await fetch(url, { - method: body === undefined ? "GET" : "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch { - throw new ApiError(0, ""); - } - const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); - return data; -} - -const query = (params: Record): string => new URLSearchParams(params).toString(); - -interface Summary { - file: string; - 키: string; - 구분: string; - 상세구분: string; - 이름: string; - 결과단위: string; -} - -const isMoney = (unit: string): boolean => unit.startsWith("원"); - -/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */ -export async function listEasy(q: string): Promise { - const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`); - const items = got.logics.map((s): EasyListItem => { - const isBase = s.file !== OWN; - const canOpen = isMoney(s.결과단위); - return { - key: s.키, - name: s.이름, - isBase, - owner: say(isBase ? "Base" : "Mine"), - resultUnit: s.결과단위, - canOpen, - whyNot: canOpen ? null : say("NotMoney"), - where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분, - }; - }); - return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)]; -} - -/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */ -export async function openEasy(key: string): Promise { - const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]); - return { - row: one.logic, - version: one.version, - isBase: one.file !== OWN, - trial: trialFor(one.logic, auto), - }; -} - -/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */ -function trialFor(row: LogicRow, trial: Record): Record { - return Object.fromEntries( - (row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]), - ); -} - -let calcSeq = 0; -let calcTimer: ReturnType | undefined; - -/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ -export function calcLater( - row: LogicRow, - trial: Record, - onDone: (ans: CalcAnswer | null, plain: string | null) => void, -): void { - const mine = ++calcSeq; - clearTimeout(calcTimer); - calcTimer = setTimeout(() => { - runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row }) - .then((ans) => { - if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason))); - }) - .catch((e: unknown) => { - if (mine === calcSeq) onDone(null, plainError(e)); - }); - }, CALC_WAIT_MS); -} - -/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */ -export async function newEasy(row: LogicRow): Promise { - const logic = { ...row, 출처: row.출처 || "자체" }; - const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", { - logic, - owner: OWNER, - }); - return { key: got.key, version: got.version, row: got.logic }; -} - -/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */ -export async function copyEasy(key: string): Promise { - const got = await copyLogic(key); - return { key: got.key, version: got.version, row: got.logic }; -} - -/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */ -export async function editEasy(key: string, version: string, row: LogicRow): Promise { - const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", { - key, - version, - logic: row, - }); - return { key, version: got.version, row: got.logic }; -} - -/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */ -export async function deleteEasy(key: string, version: string): Promise<{ version: string }> { - const got = await request<{ version: string }>("/logic/delete", { key, version }); - return { version: got.version }; -} - -/** 쓰는 곳 — 담은 일위대가 조합(마스터 · 회사 · 개인 층) + 이 로직을 부르는 내가 만든 것. - * 지우기 누를 때 부름(무거움) · 서버 지우기 검사는 마스터 층 조합만 막음. - * ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) — - * 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */ -export async function usesOf(key: string): Promise { - const [combos, mine, layered] = await Promise.all([ - request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`), - request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`), - layerUses(key), - ]); - const call = `로직(${key}`; - const others = mine.logics.filter((s) => s.키 !== key); - const rows = await Promise.all(others.map((s) => fetchLogic(s.키))); - const callers = rows - .filter((one) => JSON.stringify(one.logic).includes(call)) - .map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 })); - return [ - ...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })), - ...layered, - ...callers, - ]; -} - -interface LayerTarget { - layer: "company" | "personal"; - owner: string; - name: string; - rows?: { 키: string }[]; -} - -/** 회사 · 개인 층 조합 가운데 이 로직을 담은 것 — M02 층 길 읽기만(시스템 관리자는 모든 층). - * 내 층 = `/combos/all` · 남의 회사 = `/layers/company/owners` · 회사마다 사람 = `/layers/personal/owners`. - * 못 읽은 층이 있으면 「일부를 못 읽음」 한 줄을 더함. - * ponytail: 층마다 조합 하나씩 받아 훑음 — 조합이 수천이면 서버에 「이 로직을 담은 층 조합」 길을 더할 것 */ -async function layerUses(key: string): Promise { - let missed = false; - const soft = (p: Promise, empty: T): Promise => - p.catch(() => { - missed = true; - return empty; - }); - type Own = { layer: string; owner: string | null; 줄: { 키: string }[] }; - type Owner = { owner: string; name: string }; - const own = await soft(m02("/combos/all"), []); - const targets: LayerTarget[] = []; - let myCompany = ""; - for (const one of own) { - if (one.layer === "system" || !one.owner) continue; - const company = one.layer === "company"; - if (company) myCompany = one.owner; - targets.push({ - layer: company ? "company" : "personal", - owner: one.owner, - name: say(company ? "MyCompany" : "Me"), - rows: one.줄, - }); - } - const companies = await soft(m02("/layers/company/owners"), []); - for (const c of companies) targets.push({ layer: "company", ...c }); - const people = await Promise.all( - [myCompany, ...companies.map((c) => c.owner)] - .filter(Boolean) - .map((c) => soft(m02(`/layers/personal/owners?${query({ company: c })}`), [])), - ); - for (const p of people.flat()) targets.push({ layer: "personal", ...p }); - - const found = await Promise.all( - targets.map(async (t) => { - const at = `/layers/${t.layer}/combos`; - const own = `?${query({ owner: t.owner })}`; - const rows = t.rows ?? (await soft(m02<{ 줄: { 키: string }[] }>(at + own), { 줄: [] })).줄; - const combos = await Promise.all( - rows.map((r) => - soft(m02<{ combo: LayerCombo }>(`${at}/${encodeURIComponent(r.키)}${own}`), null), - ), - ); - const full = combos.flatMap((x) => (x ? [x.combo] : [])); - return holding(full, key, t.layer, t.name); - }), - ); - const out = found.flat(); - if (missed) out.push({ kind: "combo", key: "", name: say("Unseen") }); - return out; -} - -let family: Promise | null = null; - -/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */ -function machineFamily(): Promise { - family ??= request<{ logics: Summary[] }>("/logics") - .then((got) => - Promise.all( - got.logics - .filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위)) - .map((s) => fetchLogic(s.키)), - ), - ) - .then((all) => - all - .map((one) => ({ - key: one.logic.키, - name: one.logic.이름, - resultUnit: one.logic.결과단위, - inputs: one.logic.입력 ?? [], - })) - .filter((m) => machineCodes(m).length > 0), - ) - .catch((e: unknown) => { - family = null; - throw e; - }); - return family; -} - -interface MachineRow { - 원문번호?: string; - 이름?: string; - 규격?: string; -} - -/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */ -export async function listMachines(q: string): Promise { - const [all, got] = await Promise.all([ - machineFamily(), - request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`), - ]); - return got.items.flatMap((row): EasyMachine[] => { - const code = String(row.원문번호 ?? ""); - const m = pickMachine(all, code); - if (!m) return []; - const unit = m.resultUnit.split("/")[1] ?? ""; - return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }]; - }); -} - -/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */ -export async function machineElement(기종: string): Promise { - const m = pickMachine(await machineFamily(), 기종); - if (!m) throw new Error(say("NoMachine")); - return machineCall(m, 기종); -} - -/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */ -export function plainError(e: unknown): string { - if (e instanceof ApiError) return plainOf(e.status, e.detail); - if (e instanceof Error && e.message === say("NoMachine")) return e.message; - return say("Net"); -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css deleted file mode 100644 index 2d5692659..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css +++ /dev/null @@ -1,192 +0,0 @@ -.m01-easycards { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m01-easycards-group { - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); -} - -.m01-easycards-group__head, -.m01-easycards-group--empty { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); - min-height: 44px; - padding: var(--spacing-8) var(--spacing-16); -} - -.m01-easycards-group__head { - cursor: pointer; -} - -.m01-easycards-group--empty { - border-style: dashed; - color: var(--color-text-muted); -} - -.m01-easycards-group__title { - font-weight: var(--font-weight-bold); -} - -.m01-easycards-group__sum { - font-weight: var(--font-weight-medium); -} - -.m01-easycards-list { - display: flex; - flex-direction: column; - margin: 0; - padding: 0; - list-style: none; -} - -.m01-easycards-card { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8) var(--spacing-16); - border-top: 1px solid var(--color-border); -} - -.m01-easycards-card--locked { - border-left: 4px dashed var(--color-border); - background: var(--color-surface-raised); -} - -.m01-easycards-line { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8) var(--spacing-16); -} - -.m01-easycards-sentence { - display: flex; - flex: 1 1 24em; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4); - min-width: 0; -} - -.m01-easycards-tag { - font-weight: var(--font-weight-bold); -} - -.m01-easycards-name { - font-weight: var(--font-weight-medium); -} - -.m01-easycards-name--pick { - min-height: 44px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: inherit; - font: inherit; - cursor: pointer; -} - -.m01-easycards-name--pick:focus-visible { - outline: 2px solid var(--color-focus-ring); -} - -.m01-easycards .ui-input, -.m01-easycards .ui-select { - min-height: 44px; - font-size: var(--text-body); -} - -.m01-easycards-qty { - flex: none; - width: 7em; -} - -.m01-easycards-text { - flex: none; - width: 10em; -} - -.m01-easycards-select { - flex: none; - width: auto; -} - -.m01-easycards-auto, -.m01-easycards-why { - color: var(--color-text-secondary); -} - -.m01-easycards-amount { - margin-left: auto; - font-weight: var(--font-weight-bold); - white-space: nowrap; -} - -.m01-easycards-amount--wait { - opacity: 0.5; - font-weight: var(--font-weight-regular); -} - -.m01-easycards-tools { - display: flex; - gap: var(--spacing-4); -} - -.m01-easycards-tools .ui-btn, -.m01-easycards-locknote .ui-btn { - min-height: 44px; - font-size: var(--text-body); -} - -.m01-easycards-warn { - margin: 0; - padding: var(--spacing-4) var(--spacing-8); - border-left: 4px solid var(--color-warning); - font-weight: var(--font-weight-medium); -} - -.m01-easycards-locknote { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m01-easycards-lock { - padding: 0 var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - font-size: var(--text-body-sm); - font-weight: var(--font-weight-bold); -} - -.m01-easycards-add { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.m01-easycards-add .ui-btn { - min-height: 44px; - font-size: var(--text-body); -} - -@media (max-width: 640px) { - .m01-easycards-amount { - margin-left: 0; - } - - .m01-easycards-tools { - width: 100%; - } -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts deleted file mode 100644 index 7c9efb7b9..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts +++ /dev/null @@ -1,542 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Cards.ts - * 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추 - * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; -import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { - addCard, - addPercent, - checkCard, - removeCard, - updateCard, - withInputs, - type CostKind, - type EasyCard, - type EasyDoc, - type PickResult, -} from "./M01_EasyCalc_Model"; -import { openEasyPick } from "./M01_EasyCalc_Pick"; -import { openEasyCond, readCond } from "./M01_EasyCalc_Cond"; -import { openEasyTable, readFinds } from "./M01_EasyCalc_Table"; -import "./M01_EasyCalc_Cards.css"; - -const TEXT = { - Group_person: ["사람", "Workers"], - Group_material: ["재료", "Materials"], - Group_machine: ["기계·경비", "Machines"], - Group_logic: ["다른 일위대가", "Other unit prices"], - Group_percent: ["덧붙임", "Add-ons"], - Subtotal: ["소계", "Subtotal"], - Won: ["원", "KRW"], - Empty: ["아직 없음", "none yet"], - Pending: ["계산 중", "calculating"], - Tag_person: ["사람", "Worker"], - Tag_material: ["재료", "Material"], - Tag_machine: ["기계", "Machine"], - Tag_logic: ["다른 일위대가", "Other unit price"], - Tag_percent: ["덧붙임", "Add-on"], - Pre_person: [" 이 ", " works "], - Post_person: [" 일함", ""], - Pre_pick: [" 를 ", " uses "], - Post_material: [" 씀", ""], - Post_machine: [" 씀", ""], - Post_logic: ["", ""], - Pre_percent: [" 의 ", " of "], - Mid_percent: [" % 를 ", " % as "], - Post_percent: [" 로 더함", ""], - Slot: ["넣을 자리", "put into"], - Pick_Empty: ["고르기", "choose"], - Repick: ["다른 것으로 다시 고르기", "Choose another"], - Eg_qty: ["예: 0.072", "e.g. 0.072"], - Eg_percent: ["예: 2", "e.g. 2"], - Eg_name: ["예: 공구손료", "e.g. tool wear"], - Qty: ["수량", "Amount"], - Name: ["이름", "Name"], - Auto: ["자동", "auto"], - Locked: ["잠김", "Locked"], - ToNumber: ["숫자로 바꾸기", "Make it a number"], - ByTable: ["표 보고 고르기", "Choose from table"], - ByCond: ["조건 보고 고치기", "Edit conditions"], - ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"], - ToNumber_Ask: [ - "자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음", - "Freeze the auto value as a number", - ], - ToNumber_Ok: ["바꾸기", "Convert"], - Remove: ["지움", "Remove"], - Up: ["위로", "Move up"], - Down: ["아래로", "Move down"], - Add_person: ["+ 사람", "+ Worker"], - Add_material: ["+ 재료", "+ Material"], - Add_machine: ["+ 기계", "+ Machine"], - Add_logic: ["+ 다른 일위대가", "+ Other unit price"], - Add_percent: ["+ 덧붙임", "+ Add-on"], - Base_노무비: ["사람 품값", "labor cost"], - Base_재료비: ["재료값", "material cost"], - Base_경비: ["기계·경비", "machine & expenses"], - Base_사람줄합: ["사람 줄의 합", "sum of worker lines"], -} as const; - -const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -type Group = "person" | "material" | "machine" | "logic" | "percent"; -const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"]; -type PercentBase = NonNullable; -const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"]; -const COSTS: CostKind[] = ["노무비", "재료비", "경비"]; - -interface Ctx { - doc: EasyDoc; - lines: Map; - readOnly: boolean; - on: { change(doc: EasyDoc): void; trial?(): Record }; - /** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */ - badText: Map; -} - -const badTexts = new WeakMap>(); -const closedGroups = new WeakMap>(); -let picking = false; - -const groupOf = (card: EasyCard): Group => { - if (card.kind !== "locked") return card.kind; - if (card.source?.part === "덧줄") return "percent"; - const kind = (card.raw as { 종류?: string } | null)?.종류; - return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic"; -}; - -/** Model 의 toRow 와 같은 나눔 — 줄 차례가 어긋나지 않게 */ -const partOf = (card: EasyCard): "호표" | "덧줄" => - card.kind === "percent" - ? "덧줄" - : card.kind === "locked" - ? (card.source?.part ?? "덧줄") - : "호표"; - -/** 식 수량이거나 종류를 못 알아본 줄 — 숫자로 바꾸기 전까지 잠김 */ -const isLocked = (card: EasyCard): boolean => - card.qtyFormula !== null || (card.kind === "locked" && card.lockedWhy !== null); - -/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */ -function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map { - const out = new Map(); - if (!ans || !ans.ok || !ans.lines) return out; - const ordered = [ - ...doc.cards.filter((c) => partOf(c) === "호표"), - ...doc.cards.filter((c) => partOf(c) === "덧줄"), - ]; - if (ordered.length !== ans.lines.length) return out; - ordered.forEach((card, i) => out.set(card.id, ans.lines![i])); - return out; -} - -const parseQty = (text: string): number | null => { - const t = text.replace(/[,\s]/g, ""); - if (t === "") return null; - return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : Number.NaN; -}; - -/** 검사가 막혀도 그리기는 계속 */ -function warnOf(ctx: Ctx, card: EasyCard): string | null { - if (ctx.doc.isBase) return null; - try { - return checkCard(card, ctx.doc); - } catch { - return null; - } -} - -function patchCard(ctx: Ctx, card: EasyCard, patch: Partial): void { - ctx.on.change(updateCard(ctx.doc, card.id, patch)); -} - -/** 화면 묶음 안 이웃과 자리 바꿈 — 잠금 카드도 같은 묶음이라 Model.moveCard 의 묶음과 달라 여기서 바꿈 */ -function swapInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc { - const same = doc.cards.filter((c) => groupOf(c) === groupOf(card)); - const neighbor = same[same.indexOf(card) + step]; - if (!neighbor) return doc; - const cards = doc.cards.slice(); - const a = cards.indexOf(card); - const b = cards.indexOf(neighbor); - [cards[a], cards[b]] = [cards[b], cards[a]]; - return { ...doc, cards }; -} - -async function repick(ctx: Ctx, card: EasyCard): Promise { - if (picking || card.kind === "locked" || card.kind === "percent") return; - picking = true; - try { - const pick = await openEasyPick(card.kind, { qty: card.qty }); - if (!pick) return; - const next = updateCard(ctx.doc, card.id, { - name: pick.name, - spec: pick.spec, - unit: pick.unit, - element: pick.element, - qty: pick.qty, - }); - ctx.on.change(withInputs(next, pick.needInputs ?? [])); - } finally { - picking = false; - } -} - -async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise { - if (picking) return; - picking = true; - try { - const pick = await openEasyPick(kind); - if (pick) ctx.on.change(addCard(ctx.doc, pick)); - } finally { - picking = false; - } -} - -function addBlankPercent(ctx: Ctx): void { - ctx.on.change(addPercent(ctx.doc, "", "노무비", null, "경비")); -} - -async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise { - const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`; - if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) { - patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null }); - } -} - -/** 표 · 조건 창 — 답(새 식)이 있으면 그 카드 식만 바꿈 */ -async function editFormula( - ctx: Ctx, - card: EasyCard, - open: typeof openEasyTable | typeof openEasyCond, -): Promise { - if (picking) return; - picking = true; - try { - const formula = await open(card, ctx.doc, ctx.on.trial?.()); - if (formula !== null) patchCard(ctx, card, { qtyFormula: formula }); - } finally { - picking = false; - } -} - -/* ------------------------------ 칸 만들기 ------------------------------ */ - -const text = (cls: string, value: string): HTMLSpanElement => - el("span", { className: cls, text: value }); - -function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement { - const bad = ctx.badText.get(card.id); - const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty); - const input = el("input", { - className: "ui-input m01-easycards-qty", - attrs: { - type: "text", - inputmode: "decimal", - placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"), - "aria-label": tc("Qty"), - }, - }); - input.value = shown; - if (ctx.readOnly) input.readOnly = true; - input.addEventListener("change", () => { - const qty = parseQty(input.value); - if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value); - else ctx.badText.delete(card.id); - if (Object.is(qty, card.qty)) return; - patchCard(ctx, card, { qty }); - }); - return input; -} - -function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement { - if (card.kind === "percent") { - const input = el("input", { - className: "ui-input m01-easycards-text", - attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") }, - }); - input.value = card.name; - if (ctx.readOnly) input.readOnly = true; - input.addEventListener("change", () => { - if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() }); - }); - return input; - } - const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty"); - if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label); - const button = el("button", { - className: "m01-easycards-name m01-easycards-name--pick", - text: `${label} ▾`, - attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` }, - }); - button.addEventListener("click", () => void repick(ctx, card)); - return button; -} - -function selectBox( - ctx: Ctx, - values: T[], - current: T | null, - label: (v: T) => string, - onPick: (v: T) => void, - aria: string, -): HTMLElement { - if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : ""); - const select = el("select", { - className: "ui-select m01-easycards-select", - attrs: { "aria-label": aria }, - }); - if (current === null) - select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") })); - for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) })); - select.value = current ?? ""; - select.addEventListener("change", () => { - if (select.value) onPick(select.value as T); - }); - return select; -} - -function amountText(ctx: Ctx, card: EasyCard): HTMLElement { - const line = ctx.lines.get(card.id); - if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`); - return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`); -} - -function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null { - if (ctx.readOnly) return null; - const at = group.indexOf(card); - const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => { - const b = createButton({ - label: glyph, - variant: "ghost", - size: "sm", - disabled: off, - onClick: () => ctx.on.change(swapInGroup(ctx.doc, card, step)), - }); - b.setAttribute("aria-label", label); - return b; - }; - return el("div", { - className: "m01-easycards-tools", - children: [ - move(tc("Up"), "▲", -1, at <= 0), - move(tc("Down"), "▼", 1, at >= group.length - 1), - createButton({ - label: tc("Remove"), - variant: "ghost", - size: "sm", - onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)), - }), - ], - }); -} - -/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */ -function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement { - const g = groupOf(card); - const locked = isLocked(card); - const percent = card.kind === "percent"; - const plain = card.kind === "locked"; - const name = nameWidget(ctx, card); - const value = locked ? lockedValue(ctx, card) : qtyInput(ctx, card); - const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)]; - - if (percent) { - parts.push( - selectBox( - ctx, - BASES, - card.percentBase, - (v) => tc(`Base_${v}`), - (v) => patchCard(ctx, card, { percentBase: v }), - tc("Slot"), - ), - tc("Pre_percent"), - value, - tc("Mid_percent"), - name, - tc("Post_percent"), - ); - } else { - parts.push( - name, - plain ? " " : g === "person" ? tc("Pre_person") : tc("Pre_pick"), - value, - text("m01-easycards-unit", ` ${card.unit}`), - plain ? "" : tc(`Post_${g}` as "Post_person"), - ); - } - - const slot = percent - ? el("span", { - className: "m01-easycards-slot", - children: [ - ` · ${tc("Slot")} `, - selectBox( - ctx, - COSTS, - card.cost, - (v) => tc(`Base_${v}`), - (v) => patchCard(ctx, card, { cost: v }), - tc("Slot"), - ), - ], - }) - : null; - - const head = el("div", { - className: "m01-easycards-line", - children: [ - el("div", { - className: "m01-easycards-sentence", - children: slot ? [...parts, slot] : parts, - }), - amountText(ctx, card), - toolButtons(ctx, card, group) ?? "", - ], - }); - const rows: HTMLElement[] = [head]; - - const warn = warnOf(ctx, card); - if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` })); - if (locked) rows.push(lockedNote(ctx, card)); - return el("li", { - className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`, - children: rows, - }); -} - -/** 잠금 카드 값 자리 — 「자동 0.50」 */ -function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement { - const line = ctx.lines.get(card.id); - const value = line ? formatNumber(line.수량) : tc("Pending"); - return text("m01-easycards-auto", `(${tc("Auto")} ${value})`); -} - -function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement { - const line = ctx.lines.get(card.id); - const note: (HTMLElement | string)[] = [ - text("m01-easycards-lock", `🔒 ${tc("Locked")}`), - text("m01-easycards-why", card.lockedWhy ?? ""), - ]; - if (!ctx.readOnly) { - const button = createButton({ - label: tc("ToNumber"), - variant: "ghost", - size: "sm", - disabled: !line, - onClick: () => line && void lockToNumber(ctx, card, line.수량), - }); - if (!line) button.title = tc("ToNumber_Wait"); - note.push(button); - const formula = card.qtyFormula; - const tools: [boolean, keyof typeof TEXT, typeof openEasyTable | typeof openEasyCond][] = [ - [formula !== null && !!readFinds(formula)?.length, "ByTable", openEasyTable], - [formula !== null && !!readCond(formula, ctx.doc.row), "ByCond", openEasyCond], - ]; - for (const [has, label, open] of tools) { - if (has) - note.push( - createButton({ - label: tc(label), - variant: "ghost", - size: "sm", - onClick: () => void editFormula(ctx, card, open), - }), - ); - } - } - return el("div", { className: "m01-easycards-locknote", children: note }); -} - -/* ------------------------------ 묶음 · 전체 ------------------------------ */ - -function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement { - const title = tc(`Group_${group}`); - if (!cards.length) { - return el("section", { - className: "m01-easycards-group m01-easycards-group--empty", - children: [ - text("m01-easycards-group__title", title), - text("m01-easycards-group__none", tc("Empty")), - ], - }); - } - const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l); - const sum = - found.length === cards.length - ? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}` - : `${tc("Subtotal")} ${tc("Pending")}`; - const details = el("details", { - className: "m01-easycards-group", - children: [ - el("summary", { - className: "m01-easycards-group__head", - children: [ - text("m01-easycards-group__title", title), - text( - `m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`, - sum, - ), - ], - }), - el("ul", { - className: "m01-easycards-list", - children: cards.map((c) => cardView(ctx, c, cards)), - }), - ], - }); - const closed = closedGroups.get(host) ?? new Set(); - closedGroups.set(host, closed); - details.open = !closed.has(group); - details.addEventListener("toggle", () => { - if (details.open) closed.delete(group); - else closed.add(group); - }); - return details; -} - -function addBar(ctx: Ctx): HTMLElement { - const add = (key: keyof typeof TEXT, run: () => void) => - createButton({ label: tc(key), variant: "ghost", onClick: run }); - return el("div", { - className: "m01-easycards-add", - children: [ - add("Add_person", () => void addByPick(ctx, "person")), - add("Add_material", () => void addByPick(ctx, "material")), - add("Add_machine", () => void addByPick(ctx, "machine")), - add("Add_logic", () => void addByPick(ctx, "logic")), - add("Add_percent", () => addBlankPercent(ctx)), - ], - }); -} - -/** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */ -export function renderCards( - host: HTMLElement, - doc: EasyDoc, - ans: CalcAnswer | null, - on: { change(doc: EasyDoc): void; trial?(): Record }, -): void { - const badText = badTexts.get(host) ?? new Map(); - badTexts.set(host, badText); - const ctx: Ctx = { doc, lines: lineMap(doc, ans), readOnly: doc.isBase, on, badText }; - host.classList.add("m01-easycards"); - host.replaceChildren( - ...GROUPS.map((g) => - groupView( - ctx, - host, - g, - doc.cards.filter((c) => groupOf(c) === g), - ), - ), - ...(ctx.readOnly ? [] : [addBar(ctx)]), - ); -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css deleted file mode 100644 index 892145742..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css +++ /dev/null @@ -1,97 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Cond.css — 조건 보고 고치기 창 - * ========================================================================== */ - -.m01-easycond { - width: min(760px, calc(100vw - 32px)); -} - -.m01-easycond__lines { - display: flex; - flex-direction: column; - gap: 12px; -} - -.m01-easycond__line { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - font-size: 16px; - line-height: 1.6; -} - -.m01-easycond__slot { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.m01-easycond__select, -.m01-easycond__num { - min-height: 36px; - padding: 4px 8px; - border: 1px solid var(--color-border, #c9ced6); - border-radius: 8px; - background: var(--color-surface, #fff); - color: inherit; - font: inherit; -} - -.m01-easycond__num { - width: 7em; - text-align: right; -} - -.m01-easycond__num.is-bad { - border-color: var(--color-danger, #d64545); - outline: 2px solid var(--color-danger, #d64545); -} - -.m01-easycond__word { - padding: 4px 8px; - border-radius: 8px; - background: var(--color-surface-raised, #eef1f5); - font-weight: 600; -} - -.m01-easycond__chip { - min-height: 36px; - padding: 4px 12px; - border: 1px dashed var(--color-accent, #3a6fd8); - border-radius: 999px; - background: transparent; - color: var(--color-accent, #3a6fd8); - font: inherit; - cursor: pointer; -} - -.m01-easycond__chip:disabled { - border-color: var(--color-border, #c9ced6); - color: inherit; - cursor: default; -} - -.m01-easycond__money { - margin-top: 16px; - padding: 12px; - border-radius: 8px; - background: var(--color-surface-raised, #eef1f5); - font-size: 16px; - font-weight: 600; -} - -.m01-easycond__money.is-waiting { - opacity: 0.5; -} - -.m01-easycond__foot { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 16px; -} - -.m01-easycond__cannot { - font-size: 16px; -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts deleted file mode 100644 index fb304fc91..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts +++ /dev/null @@ -1,620 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Cond.ts - * 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고 - * 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null - * - * 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음 - * 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리) - * 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로 - * 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로) - * 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄 - * 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만) - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { - runCalc, - type CalcAnswer, - type LogicInput, - type LogicRow, -} from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { calcLater } from "./M01_EasyCalc_Api"; -import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model"; -import "./M01_EasyCalc_Cond.css"; - -const TEXT = { - Title: ["조건 보고 고치기", "Change by conditions"], - Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"], - Part: ["조건", "Condition"], - If: ["만약", "If"], - ElseIf: ["아니고 만약", "Else if"], - Then: ["→", "→"], - Else: ["아니면 →", "Otherwise →"], - Subject: ["가", "is"], - Big: ["가운데 큰 값", "whichever is larger"], - Small: ["가운데 작은 값", "whichever is smaller"], - And: ["와", "and"], - RoundA: ["을 소수", "to"], - RoundB: ["자리까지", "decimal places,"], - Up: ["올림", "round up"], - Down: ["버림", "round down"], - Half: ["반올림", "round off"], - OpEq: ["이면", "equal to"], - OpNe: ["이 아니면", "not equal to"], - OpGt: ["보다 크면", "greater than"], - OpGe: ["이상이면", "at least"], - OpLt: ["보다 작으면", "less than"], - OpLe: ["이하이면", "at most"], - ChipTable: ["표에서 찾은 값", "Value from table"], - ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"], - ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"], - ChipCalc: ["계산한 값", "Calculated value"], - ToNumber: ["숫자로", "Use a number"], - Line: ["이 줄", "This line"], - Total: ["합계", "Total"], - Was: ["전", "before"], - Waiting: ["계산 중 …", "Calculating …"], - Use: ["이걸로", "Use this"], - Cancel: ["취소", "Cancel"], - NotNumber: ["숫자만 넣음", "Numbers only"], -} as const; - -const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */ - -/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */ -type CNode = { src: string; p: [number, number] } & ( - | { t: "num" | "str" | "name" | "ref"; v: string } - | { t: "call"; f: string; items: CNode[] } - | { t: "bin"; op: string; a: CNode; b: CNode } - | { t: "neg" | "paren"; a: CNode } - | { t: "field"; a: CNode; f: string } -); - -const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"]; -const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"]; -const NAME_RE = /[\p{L}\p{N}_]+/uy; -const NUM_RE = /\d+(?:\.\d+)?/y; - -/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */ -function parseCond(s: string): CNode | null { - let i = 0; - const fail = (): never => { - throw new Error("parse"); - }; - const ws = (): void => { - while (i < s.length && /\s/.test(s[i])) i++; - }; - const take = (w: string): boolean => { - ws(); - if (!s.startsWith(w, i)) return false; - i += w.length; - return true; - }; - const need = (w: string): void => void (take(w) || fail()); - const mk = (start: number, body: T): CNode => - ({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode; - const sticky = (re: RegExp): string | null => { - re.lastIndex = i; - const m = re.exec(s); - if (m) i = re.lastIndex; - return m ? m[0] : null; - }; - const word = (): string => (ws(), sticky(NAME_RE) ?? fail()); - const quoted = (): string => { - const end = s.indexOf("'", i); - if (end < 0) fail(); - const v = s.slice(i, end); - i = end + 1; - return v; - }; - /** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */ - const skipGroup = (): void => { - let depth = 0; - let q = false; - for (; i < s.length; i++) { - if (s[i] === "'") q = !q; - else if (!q && s[i] === "(") depth++; - else if (!q && s[i] === ")" && --depth === 0) return void i++; - } - fail(); - }; - /** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */ - const skipRef = (): void => { - for (let depth = 0; i < s.length; i++) { - const ch = s[i]; - if ("({".includes(ch)) depth++; - else if (")}".includes(ch) && depth-- === 0) return; - else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return; - else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return; - } - }; - const fields = (start: number, node: CNode): CNode => { - while (take(".")) { - const f = take("'") ? quoted() : word(); - node = mk(start, { t: "field", a: node, f }); - } - return node; - }; - const primary = (): CNode => { - ws(); - const start = i; - if (take("(")) { - const a = expr(); - need(")"); - return fields(start, mk(start, { t: "paren", a })); - } - if (take("'")) return mk(start, { t: "str", v: quoted() }); - const num = sticky(NUM_RE); - if (num) return mk(start, { t: "num", v: num }); - const w = word(); - if (w === "찾기" || w === "로직") { - ws(); - if (s[i] !== "(") fail(); - skipGroup(); - return fields(start, mk(start, { t: "ref", v: w })); - } - if (COND_FUNCS.includes(w)) { - need("("); - const items = [expr()]; - while (take(",")) items.push(expr()); - need(")"); - return fields(start, mk(start, { t: "call", f: w, items })); - } - if (s[i] === ":") { - i++; - skipRef(); - return fields(start, mk(start, { t: "ref", v: w })); - } - return fields(start, mk(start, { t: "name", v: w })); - }; - const unary = (): CNode => { - ws(); - const start = i; - return take("-") ? mk(start, { t: "neg", a: unary() }) : primary(); - }; - const chain = (next: () => CNode, ops: string[], once = false): CNode => { - ws(); - const start = i; - let node = next(); - for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) - node = mk(start, { t: "bin", op, a: node, b: next() }); - return node; - }; - const mul = (): CNode => chain(unary, ["*", "/"]); - const add = (): CNode => chain(mul, ["+", "-"]); - const compare = (): CNode => chain(add, COMPARE_OPS, true); - const both = (): CNode => chain(compare, ["그리고"]); - const expr = (): CNode => chain(both, ["또는"]); - try { - const node = expr(); - ws(); - return i === s.length ? node : null; - } catch { - return null; - } -} - -/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */ -export type CondSlot = - | { kind: "num"; value: number } - | { kind: "text"; value: string } - | { kind: "input"; name: string } - | { kind: "table" | "calc"; src: string }; -export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<="; -export interface CondTest { - left: CondSlot; - op: CondOp; - right: CondSlot; -} -export type CondShape = - | { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot } - | { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] } - | { - type: "round"; - fn: "올림" | "버림" | "반올림"; - value: CondSlot; - places: number; - hadPlaces: boolean; - }; -/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */ -export interface CondBlock { - shape: CondShape; - p: [number, number]; -} -/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */ -export interface CondRead { - text: string; - via: string | null; - blocks: CondBlock[]; -} - -const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n); - -function slotOf(node: CNode, inputs: LogicInput[]): CondSlot { - const n = unparen(node); - if (n.t === "num") return { kind: "num", value: Number(n.v) }; - if (n.t === "neg" && unparen(n.a).t === "num") - return { kind: "num", value: -Number(unparen(n.a).src) }; - if (n.t === "str") return { kind: "text", value: n.v }; - if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v }; - if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기")) - return { kind: "table", src: n.src }; - return { kind: "calc", src: node.src.trim() }; -} - -function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null { - if (n.t !== "call") return null; - const slot = (x: CNode): CondSlot => slotOf(x, inputs); - if (n.f === "만약") { - const t = unparen(n.items[0]); - if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null; - const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) }; - const branch = { test, value: slot(n.items[1]) }; - const rest = shapeOf(unparen(n.items[2]), inputs); - return rest?.type === "if" - ? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise } - : { type: "if", branches: [branch], otherwise: slot(n.items[2]) }; - } - if (n.f === "최소" || n.f === "최대") - return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null; - const places = n.items[1] ? unparen(n.items[1]) : null; - if (n.items.length > 2 || (places && places.t !== "num")) return null; - const fn = n.f as "올림" | "버림" | "반올림"; - const value = slot(n.items[0]); - return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places }; -} - -const condNum = (n: number): string => { - const t = String(Number(n.toPrecision(12))); - return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; -}; - -function slotText(s: CondSlot): string { - if (s.kind === "num") return condNum(s.value); - if (s.kind === "text") return `'${s.value}'`; - return s.kind === "input" ? s.name : s.src; -} - -/** 덩어리 → 식 글 */ -export function writeCond(shape: CondShape): string { - if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`; - if (shape.type === "round") { - const v = slotText(shape.value); - return shape.places === 0 && !shape.hadPlaces - ? `${shape.fn}(${v})` - : `${shape.fn}(${v}, ${condNum(shape.places)})`; - } - return shape.branches.reduceRight((acc, b) => { - const t = b.test; - return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`; - }, slotText(shape.otherwise)); -} - -const flat = (s: string): string => s.replace(/[\s()]+/g, ""); - -/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */ -function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] { - if (n.t === "call") { - const shape = shapeOf(n, inputs); - if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }]; - return n.items.flatMap((x) => blocksOf(x, inputs)); - } - if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)]; - if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs); - return []; -} - -/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */ -export function readCond(formula: string, row: Pick): CondRead | null { - let text = formula.trim(); - let via: string | null = null; - for (let hop = 0; hop < 5; hop++) { - const mid = (row.중간 ?? []).find((m) => m.이름 === text); - if (!mid) break; - via = mid.이름; - text = mid.식.trim(); - } - const node = parseCond(text); - const blocks = node ? blocksOf(node, row.입력 ?? []) : []; - return blocks.length ? { text, via, blocks } : null; -} - -/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */ -export function writeRead(read: CondRead): string { - let out = read.text; - for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0])) - out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]); - return out; -} - -/* ── 화면 ───────────────────────────────────────────────────────────────── */ - -const OPS: [CondOp, keyof typeof TEXT][] = [ - ["==", "OpEq"], - ["!=", "OpNe"], - [">", "OpGt"], - [">=", "OpGe"], - ["<", "OpLt"], - ["<=", "OpLe"], -]; - -/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */ -const TABLE_MODULE = import.meta.glob<{ - openEasyTable: ( - card: EasyCard, - doc: EasyDoc, - trial?: Record, - ) => Promise; -}>("./M01_EasyCalc_Table.ts"); - -/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */ -const put = (slot: CondSlot, next: CondSlot): void => { - for (const k of Object.keys(slot)) delete (slot as Record)[k]; - Object.assign(slot, next); -}; - -function select( - options: { value: string; text: string }[], - value: string, - onChange: (v: string) => void, -): HTMLSelectElement { - const sel = el("select", { className: "m01-easycond__select" }); - const all = options.some((o) => o.value === value) - ? options - : [...options, { value, text: value }]; - for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } })); - sel.value = value; - sel.addEventListener("change", () => onChange(sel.value)); - return sel; -} - -function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement { - const box = el("input", { - className: "m01-easycond__num", - attrs: { type: "text", inputmode: "decimal", value: condNum(value) }, - }); - box.addEventListener("input", () => { - const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value); - box.classList.toggle("is-bad", !ok); - box.title = ok ? "" : te("NotNumber"); - if (ok) onChange(Number(box.value)); - }); - return box; -} - -function chipLabel(slot: CondSlot, inputs: LogicInput[]): string { - if (slot.kind === "table") return te("ChipTable"); - if (slot.kind !== "calc") return ""; - if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc"); - const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? []; - return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc"); -} - -/** 문장 짓기 — 고칠 때마다 changed() */ -class Sentence { - constructor( - private card: EasyCard, - private doc: EasyDoc, - private changed: () => void, - private trial: Record, - ) {} - - /** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */ - slot(s: CondSlot): HTMLElement { - const host = el("span", { className: "m01-easycond__slot" }); - const draw = (): void => { - host.replaceChildren(); - if (s.kind === "num") { - host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed()))); - return; - } - if (s.kind === "text" || s.kind === "input") { - const text = s.kind === "text" ? s.value : s.name; - host.append(el("span", { className: "m01-easycond__word", text })); - return; - } - const chip = el("button", { - className: "m01-easycond__chip", - text: chipLabel(s, this.doc.inputs), - attrs: { type: "button" }, - }); - const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"]; - if (load && s.kind === "table") - chip.addEventListener("click", async () => { - const got = await ( - await load() - ).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial); - if (got) (put(s, { kind: "table", src: got }), draw(), this.changed()); - }); - else chip.disabled = true; - const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed()); - host.append( - chip, - createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }), - ); - }; - draw(); - return host; - } - - /** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */ - test(t: CondTest): HTMLElement[] { - const inputs = this.doc.inputs; - const right = el("span", { className: "m01-easycond__slot" }); - const drawRight = (): void => { - const name = t.left.kind === "input" ? t.left.name : null; - const inp = inputs.find((x) => x.이름 === name); - const choices = inp?.고르기 ?? []; - const r = t.right; - if (!choices.length || (r.kind !== "text" && r.kind !== "num")) - return right.replaceChildren(this.slot(r)); - const pickOne = (v: string): void => { - const asNum = choices.some((c) => typeof c === "number" && String(c) === v); - put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v }); - this.changed(); - }; - const opts = choices.map((c) => ({ value: String(c), text: String(c) })); - right.replaceChildren(select(opts, String(r.value), pickOne)); - }; - drawRight(); - const l = t.left; - const left = - l.kind === "input" - ? select( - inputs.map((x) => ({ value: x.이름, text: x.이름 })), - l.name, - (v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()), - ) - : this.slot(l); - const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) })); - const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed())); - return [left, el("span", { text: te("Subject") }), right, op]; - } - - /** 덩어리 하나 → 줄들 */ - block(shape: CondShape): HTMLElement[] { - if (shape.type === "if") - return [ - ...shape.branches.map((b, i) => - line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)), - ), - line(te("Else"), this.slot(shape.otherwise)), - ]; - if (shape.type === "pick") { - const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)])); - const opts = [ - { value: "최대", text: te("Big") }, - { value: "최소", text: te("Small") }, - ]; - const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed())); - return [line(...kids, fn)]; - } - const ways = [ - { value: "올림", text: te("Up") }, - { value: "버림", text: te("Down") }, - { value: "반올림", text: te("Half") }, - ]; - const setPlaces = (v: number): void => ( - (shape.places = Math.max(0, Math.round(v))), - this.changed() - ); - const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed())); - const places = numberBox(shape.places, setPlaces); - return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)]; - } -} - -const line = (...kids: (HTMLElement | string)[]): HTMLElement => - el("div", { - className: "m01-easycond__line", - children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)), - }); - -/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */ -function moneyView(card: EasyCard, doc: EasyDoc, trial: Record) { - const box = el("div", { className: "m01-easycond__money", text: te("Waiting") }); - const ho = doc.cards.filter( - (c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"), - ); - const at = ho.findIndex((c) => c.id === card.id); - let first: [number | null, number | null] | null = null; - let seq = 0; - let timer = 0; - const show = (ans: CalcAnswer | null, plain: string | null): void => { - box.classList.remove("is-waiting"); - if (!ans || !ans.ok) - return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : "")); - const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null; - const sums = ans.sums ?? {}; - const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0); - first ??= [lineSum, total]; - const part = (label: string, v: number | null, was: number | null): string => - v === null - ? "" - : `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`; - const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])]; - box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t }))); - }; - const update = (text: string): void => { - box.classList.add("is-waiting"); - const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null })); - const mine = ++seq; - const done = (ans: CalcAnswer | null, plain: string | null = null): void => - void (mine === seq && show(ans, plain)); - try { - calcLater(row, trial, done); - } catch { - // Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만) - window.clearTimeout(timer); - timer = window.setTimeout(() => { - runCalc({ key: "", inputs: trial, row }).then( - (a) => done(a), - () => done(null), - ); - }, 300); - } - }; - return { box, update }; -} - -/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */ -export function openEasyCond( - card: EasyCard, - doc: EasyDoc, - trial: Record = {}, -): Promise { - const read = readCond(card.qtyFormula ?? "", doc.row); - return new Promise((resolve) => { - let done = false; - const finish = (v: string | null): void => { - if (!done) ((done = true), resolve(v)); - }; - openModal({ - title: te("Title"), - closeLabel: te("Cancel"), - dialogClass: "m01-easycond", - beforeClose: () => (finish(null), true), - mount: (body, close) => { - if (!read) - return void body.append( - el("p", { className: "m01-easycond__cannot", text: te("Cannot") }), - ); - const base = flat(writeRead(read)); - const money = moneyView(card, doc, trial); - const use = createButton({ - label: te("Use"), - variant: "filled", - disabled: true, - onClick: () => (finish(writeRead(read)), close()), - }); - const changed = (): void => { - const text = writeRead(read); - use.disabled = flat(text) === base; - money.update(text); - }; - const say = new Sentence(card, doc, changed, trial); - const lines = el("div", { className: "m01-easycond__lines" }); - read.blocks.forEach((b, i) => { - if (read.blocks.length > 1) - lines.append( - el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }), - ); - lines.append(...say.block(b.shape)); - }); - const cancel = createButton({ - label: te("Cancel"), - variant: "ghost", - onClick: () => (finish(null), close()), - }); - const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] }); - body.append(lines, money.box, foot); - money.update(writeRead(read)); - }, - }); - }); -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts deleted file mode 100644 index 79366262c..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts +++ /dev/null @@ -1,567 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Entry.ts - * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기 - * 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다 - * EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본) - * · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - createTag, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { - calcLater, - copyEasy, - deleteEasy, - editEasy, - listEasy, - newEasy, - openEasy, - plainError, - usesOf, - type EasyListItem, -} from "./M01_EasyCalc_Api"; -import { renderCards } from "./M01_EasyCalc_Cards"; -import { - checkName, - EasyHistory, - newDoc, - setEasyModelLanguage, - toDoc, - toRow, - type EasyCard, - type EasyDoc, -} from "./M01_EasyCalc_Model"; -import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet"; -import "./M01_EasyCalc_Style.css"; - -const TEXT = { - Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"], - Title: ["쉬운 단가산출", "Easy unit-price"], - CloseWindow: ["창 닫기", "Close window"], - Close: ["닫기", "Close"], - Loading: ["불러오는 중", "Loading"], - LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], - Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"], - Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"], - Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"], - Start_Mine: ["내가 만든 것", "Made by me"], - Start_MineNone: ["아직 없음", "None yet"], - Start_Found: ["찾은 것", "Found"], - Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], - Start_None: ["찾은 것 없음", "Nothing found"], - Per: ["{u} 당", "per {u}"], - CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], - Name: ["이름", "Name"], - NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"], - Spec: ["크기", "Size"], - Unit: ["단위", "Unit"], - Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], - Undo: ["되돌리기", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Unsaved: ["저장 안 함", "Not saved"], - Saving: ["저장 중", "Saving"], - Saved: ["저장했음", "Saved"], - SaveFailed: ["저장 못 함", "Not saved"], - BaseNote: [ - "품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦", - "Standard items are read-only — use the button below to make your own copy", - ], - BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"], - Copying: ["내 것으로 만드는 중", "Making your copy"], - Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"], - Cards: ["무엇이 들어가나", "What goes in"], - Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"], - BadLines: [ - "⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨", - "Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved", - ], - Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"], - Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"], - Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], - Delete_Ok: ["지우기", "Delete"], - Deleted: ["지웠음", "Deleted"], - Leave_Ask: [ - "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", - "There are unsaved changes — close without saving?", - ], - Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], -} as const; - -type TextKey = keyof typeof TEXT; - -const te = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -/** 목록에 한 번에 보이는 줄 수 */ -const SHOWN = 50; - -/** 「원/㎡」 → 「㎡」 */ -const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); -/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */ -const amountOf = (resultUnit: string): string => { - const unit = unitOf(resultUnit); - return /^\d/.test(unit) ? unit : `1${unit}`; -}; - -/** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */ -const blocksSave = (c: EasyCard): boolean => - !c.name.trim() || - (c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0)); - -/** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙: - * 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */ -function fillNew( - inputs: LogicInput[], - trial: Record, - known: Set, -): Record { - const out = { ...trial }; - for (const spec of inputs) { - if (known.has(spec.이름)) continue; - known.add(spec.이름); - if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue; - out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); - } - return out; -} - -/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */ -export function createEasyCalcButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc }); -} - -function openEasyCalc(): void { - setEasyModelLanguage(currentLanguageIndex); - let body: HTMLElement; - /** 고치는 화면이 열려 있을 때만 */ - let paper: { history: EasyHistory; save: () => Promise; undo: () => void } | null = null; - let logics: EasyListItem[] | null = null; - let query = ""; - - /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ - const mayLeave = async (): Promise => - !paper?.history.dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - - /** 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(); - } - }; - - /* ---------- 시작 화면 ---------- */ - - const listRow = (item: EasyListItem): HTMLElement => { - const row = el("button", { - className: "m01-easycalc__item", - attrs: { type: "button", title: item.where }, - children: [ - el("span", { className: "m01-easycalc__item-name", text: item.name }), - createTag(item.owner, item.isBase ? "neutral" : "accent"), - el("span", { - className: "m01-easycalc__item-unit", - text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, - }), - ...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]), - ], - }); - if (item.whyNot) row.title = item.whyNot; - row.disabled = !item.canOpen; - row.addEventListener("click", () => void openPaper(item.key)); - return row; - }; - - const showStart = async (): Promise => { - paper = null; - const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") }); - const mineHost = el("div", { className: "m01-easycalc__list" }); - const foundHost = el("div", { className: "m01-easycalc__list" }); - const search = createInputField({ - type: "search", - placeholder: te("Start_Search"), - value: query, - onInput: (v) => { - query = v; - drawFound(); - }, - }); - search.root.classList.add("m01-easycalc__search"); - - const drawFound = (): void => { - if (!logics) return; - const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - const hits = words.length - ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) - : logics; - status.textContent = !hits.length - ? te("Start_None") - : hits.length > SHOWN - ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) - : ""; - foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow)); - }; - - body.replaceChildren( - el("div", { - className: "m01-easycalc__start", - children: [ - el("div", { - className: "m01-easycalc__start-col", - children: [ - el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }), - search.root, - el("h4", { text: te("Start_Found") }), - status, - foundHost, - ], - }), - el("div", { - className: "m01-easycalc__start-col m01-easycalc__start-side", - children: [ - createBigButton(te("Start_Blank"), () => openBlank()), - el("h4", { text: te("Start_Mine") }), - mineHost, - ], - }), - ], - }), - ); - search.input.focus(); - - try { - logics ??= await listEasy(""); - } catch (error) { - status.textContent = te("LoadFailed", { v: plainError(error) }); - return; - } - const mine = logics.filter((x) => !x.isBase); - mineHost.replaceChildren( - ...(mine.length - ? mine.map(listRow) - : [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]), - ); - drawFound(); - }; - - /** 단위 고르기 후보 — 목록에 다섯 번 넘게 쓰인 「원/…」 차례 · 설명 붙은 드문 단위는 뺌(연 로직 것은 따로 더함) */ - const unitChoices = (): string[] => { - const count = new Map(); - for (const x of logics ?? []) { - if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); - } - return [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - }; - - /* ---------- 고치는 화면 ---------- */ - - const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {}); - - const openPaper = async (key: string): Promise => { - body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); - try { - const opened = await openEasy(key); - showPaper( - toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }), - opened.trial, - ); - } catch (error) { - showToast(plainError(error), "error"); - void showStart(); - } - }; - - const showPaper = (first: EasyDoc, firstTrial: Record): void => { - let doc = first; - let trial = firstTrial; - let ans: CalcAnswer | null = null; - let history = new EasyHistory(); - history.push(doc); - let busy = false; - - const unsaved = createTag(te("Unsaved"), "warning"); - const total = el("div", { className: "m01-easycalc__total" }); - const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } }); - const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" }); - const cardsHost = el("div", { className: "m01-easycalc__cards" }); - const sheetHost = el("div"); - let trialShape = ""; - /** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */ - const known = new Set(doc.inputs.map((i) => i.이름)); - - const name = createInputField({ - label: te("Name"), - placeholder: te("NamePlaceholder"), - onInput: (v) => edit({ ...doc, name: v }), - }); - name.root.classList.add("m01-easycalc__name"); - const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) }); - const unit = createSelectField({ - label: te("Unit"), - options: [], - onChange: (v) => edit({ ...doc, resultUnit: v }), - }); - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() }); - const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() }); - const deleteButton = createButton({ - label: te("Delete"), - variant: "danger", - onClick: () => void doDelete(), - }); - const close = createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }); - const actions = el("div", { className: "m01-easycalc__actions" }); - const baseBar = el("div", { - className: "m01-easycalc__base", - children: [ - el("p", { text: te("BaseNote") }), - createBigButton(te("BaseCopy"), () => void doCopy()), - ], - }); - - /** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */ - const fillStrip = (): void => { - const units = unitChoices(); - if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit); - unit.setOptions( - units.map((u) => ({ value: u, text: unitOf(u) })), - doc.resultUnit, - ); - name.input.value = doc.name; - spec.input.value = doc.spec; - for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase; - actions.replaceChildren( - ...(doc.isBase ? [] : [undo, saveButton]), - ...(doc.key && !doc.isBase ? [deleteButton] : []), - close, - ); - baseBar.hidden = !doc.isBase; - }; - - /** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */ - const draw = (pending: boolean, answer = false): void => { - const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—"; - total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value }); - total.classList.toggle("is-pending", pending); - sheetHost.classList.toggle("is-pending", pending); - unsaved.hidden = !history.dirty; - undo.disabled = !history.canUndo; - const shape = JSON.stringify(doc.inputs); - if (shape !== trialShape) { - trialShape = shape; - trial = fillNew(doc.inputs, trial, known); - renderTrialStrip(trialHost, doc.inputs, trial, (next) => { - trial = next; - calc(); - }); - } - drawCards(answer); - renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans); - }; - - /** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게). - * doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */ - let waiting = false; - const drawCards = (answer = false): void => { - const active = document.activeElement; - if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) { - if (!waiting) { - waiting = true; - cardsHost.addEventListener( - "focusout", - () => - setTimeout(() => { - waiting = false; - drawCards(true); - }), - { once: true }, - ); - } - return; - } - renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial }); - }; - - const calc = (): void => { - draw(true); - calcLater(toRow(doc), trial, (next, plain) => { - ans = next; - note.textContent = plain ?? ""; - draw(false, true); - }); - }; - - const edit = (next: EasyDoc): void => { - doc = next; - history.push(doc); - calc(); - }; - - const doUndo = (): void => { - const back = history.undo(); - if (!back) return; - doc = back; - fillStrip(); - calc(); - }; - - /** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */ - const reopen = (next: EasyDoc): void => { - doc = next; - history = new EasyHistory(); - history.push(doc); - logics = null; - fillStrip(); - calc(); - }; - - const doSave = async (): Promise => { - if (doc.isBase || busy) return; - const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name); - const bad = checkName(doc.name, mine); - name.setError(bad ?? undefined); - if (bad) return void name.input.focus(); - if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines")); - busy = true; - saveButton.disabled = true; - note.textContent = te("Saving"); - try { - const row = toRow(doc); - const saved = - doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row); - reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false })); - note.textContent = ""; - showToast(te("Saved"), "success"); - } catch (error) { - note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; - } finally { - busy = false; - saveButton.disabled = false; - } - }; - - const doCopy = async (): Promise => { - if (!doc.key || busy) return; - busy = true; - note.textContent = te("Copying"); - try { - const saved = await copyEasy(doc.key); - reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false })); - showToast(te("Copied"), "success"); - } catch (error) { - note.textContent = plainError(error); - } finally { - busy = false; - } - }; - - const doDelete = async (): Promise => { - if (!doc.key || !doc.version || doc.isBase || busy) return; - busy = true; - deleteButton.disabled = true; - note.textContent = te("Uses_Wait"); - try { - const uses = await usesOf(doc.key); - const blocking = uses.filter((u) => u.key); - if (blocking.length) { - note.textContent = te("Uses_Block", { - v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "), - }); - return; - } - note.textContent = uses.map((u) => u.name).join(" · "); - if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) { - return; - } - await deleteEasy(doc.key, doc.version); - logics = null; - paper = null; - showToast(te("Deleted"), "success"); - void showStart(); - } catch (error) { - note.textContent = plainError(error); - } finally { - busy = false; - deleteButton.disabled = false; - } - }; - - paper = { - get history() { - return history; - }, - save: doSave, - undo: doUndo, - }; - - body.replaceChildren( - el("div", { - className: "m01-easycalc__strip", - children: [name.root, spec.root, unit.root, total, unsaved, actions], - }), - note, - baseBar, - trialHost, - el("section", { - className: "m01-easycalc__part", - children: [el("h4", { text: te("Cards") }), cardsHost], - }), - el("details", { - className: "m01-easycalc__part m01-easycalc__sheet", - children: [el("summary", { text: te("Sheet") }), sheetHost], - }), - ); - fillStrip(); - calc(); - if (!doc.isBase) name.input.focus(); - }; - - // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 - openModal({ - title: te("Title"), - closeLabel: te("CloseWindow"), - dialogClass: "m01-easycalc", - beforeClose: mayLeave, - mount: (host) => { - body = host; - (host.parentElement ?? host).addEventListener("keydown", onKey); - void showStart(); - }, - }); -} - -function createBigButton(label: string, onClick: () => void): HTMLButtonElement { - const button = createButton({ label, variant: "filled", onClick }); - button.classList.add("m01-easycalc__big"); - return button; -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts deleted file mode 100644 index ba2f0782e..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts +++ /dev/null @@ -1,587 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Model.ts - * 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기 - * DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로 - * - * 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic - * 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말) - * 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked - * 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림 - * 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤 - * ========================================================================== */ - -import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api"; - -export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; -export type CostKind = "노무비" | "재료비" | "경비"; - -export interface EasyCard { - /** 화면 번호 · 저장 안 함 */ - id: string; - kind: CardKind; - /** 보이는 이름(호표 · 덧줄 이름) */ - name: string; - /** 크기(규격) · 없으면 "" */ - spec: string; - /** 인 · ㎥ · 시간 · % … */ - unit: string; - /** 숫자 수량 · 식이면 null */ - qty: number | null; - /** 잠금 식(원문 그대로) · 숫자면 null */ - qtyFormula: string | null; - /** 넣을 자리 · 다른 일위대가 · 기계는 null(엔진이 나눔) */ - cost: CostKind | null; - /** 덧붙임 바탕 · 덧붙임 아니면 null */ - percentBase: CostKind | "사람줄합" | null; - /** 호표 「요소」 칸 값 그대로(인력 LB 키 · 재료 검색어 묶음 · 로직 부름 글) */ - element: unknown; - /** 잠금 까닭 쉬운 말 · 아니면 null */ - lockedWhy: string | null; - /** 원래 자리 · 새 카드 null */ - source: { part: "호표" | "덧줄"; index: number } | null; - /** 원래 줄 통째 — 카드가 안 다룬 칸을 저장 때 그대로 살림 */ - raw: unknown; -} - -export interface EasyDoc { - /** 새 로직 null */ - key: string | null; - /** 판본(409 대조) */ - version: string | null; - /** 품셈 기본 = 읽기만 */ - isBase: boolean; - name: string; - spec: string; - /** 결과단위 「원/㎡」 */ - resultUnit: string; - /** 설계 때 정하는 값 */ - inputs: LogicInput[]; - cards: EasyCard[]; - /** 원래 로직 줄(새 로직은 최소 칸 줄) */ - row: LogicRow; -} - -/** 물음 셋 고르기 결과 — Pick 이 가져다 씀 */ -export interface PickResult { - kind: "person" | "material" | "machine" | "logic"; - name: string; - spec: string; - unit: string; - element: unknown; - qty: number | null; - needInputs?: LogicInput[]; -} - -/* ── 글 ─────────────────────────────────────────────────────────────────── */ - -const TEXT = { - WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"], - WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"], - WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"], - WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"], - WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"], - NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"], - Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"], - Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"], - NoName: ["이름이 비어 있음", "The name is empty"], - SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"], - SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"], - /** {per} {qty} {unit} 을 채움 */ - TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"], -} as const; - -let lang = 0; - -/** 화면 말 고르기 — 0 한국어 · 1 영어 */ -export function setEasyModelLanguage(index: number): void { - lang = index === 1 ? 1 : 0; -} - -const tx = (key: keyof typeof TEXT): string => TEXT[key][lang]; - -/* ── 작은 도움 ──────────────────────────────────────────────────────────── */ - -const COSTS: readonly string[] = ["노무비", "재료비", "경비"]; -const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v); -/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */ -const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/; -const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/; -const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/; - -let seq = 0; -const newId = (): string => `c${++seq}`; - -const clone = (v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T)); -const isObj = (v: unknown): v is Record => - typeof v === "object" && v !== null && !Array.isArray(v); -const norm = (s: string): string => s.replace(/\s+/g, ""); - -/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */ -function numText(n: number): string { - const s = String(Number(n.toPrecision(12))); - return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s; -} - -/** 바깥을 통째 감싼 괄호 하나 벗김 */ -function unwrap(s: string): string { - const t = s.trim(); - if (!t.startsWith("(") || !t.endsWith(")")) return t; - let depth = 0; - for (let i = 0; i < t.length; i++) { - if (t[i] === "(") depth++; - else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t; - } - return t.slice(1, -1).trim(); -} - -/** 괄호 밖의 + 로 나눔 */ -function splitPlus(s: string): string[] { - const out: string[] = []; - let depth = 0; - let cur = ""; - for (const ch of s) { - if (ch === "(") depth++; - if (ch === ")") depth--; - if (ch === "+" && depth === 0) { - out.push(cur.trim()); - cur = ""; - } else cur += ch; - } - out.push(cur.trim()); - return out; -} - -const hasName = (formula: string, name: string): boolean => { - const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula); -}; - -/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */ -function lockedWhy(formula: string, row: LogicRow): string { - const seen = new Set(); - const flags = { table: false, cond: false, input: false }; - const walk = (f: string, depth: number): void => { - if (/찾기\s*\(/.test(f)) flags.table = true; - if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true; - for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true; - if (depth > 6) return; - for (const m of row.중간 ?? []) { - if (seen.has(m.이름) || !hasName(f, m.이름)) continue; - seen.add(m.이름); - walk(m.식, depth + 1); - } - }; - walk(formula, 0); - if (flags.table) return tx("WhyTable"); - if (flags.cond) return tx("WhyCond"); - if (flags.input) return tx("WhyInput"); - return tx("WhyCalc"); -} - -/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */ - -/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */ -function personTerm(qtyText: string, element: unknown): string { - const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`; - return `${q} * ${String(element)}`; -} - -/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */ -function parsePercent(formula: string): { base: string; rate: number } | null { - const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim()); - if (!m || !m[1].trim()) return null; - return { base: unwrap(m[1]), rate: Number(m[2]) }; -} - -/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */ -function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean { - if (persons.length === 0) return false; - const terms = splitPlus(base); - if (terms.length !== persons.length) return false; - return terms.every((t, i) => { - const p = persons[i]; - const want = personTerm(p.qtyText, p.element); - return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`); - }); -} - -function baseOf( - base: string, - persons: { qtyText: string; element: unknown }[], -): EasyCard["percentBase"] { - if (isCost(base)) return base; - return isPersonSum(base, persons) ? "사람줄합" : null; -} - -/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */ - -const qtyTextOf = (line: HoLine): string => String(line.수량 ?? ""); - -function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard { - const kind: CardKind = - line.종류 === "인력" - ? "person" - : line.종류 === "재료" - ? "material" - : line.종류 === "로직" - ? MACHINE_CALL.test(String(line.요소)) - ? "machine" - : "logic" - : "locked"; - const qtyText = qtyTextOf(line); - const numeric = NUMBER_TEXT.test(qtyText); - return { - id: newId(), - kind, - name: line.이름 ?? "", - spec: line.규격 ?? "", - unit: line.단위 ?? "", - qty: numeric ? Number(qtyText) : null, - qtyFormula: numeric ? null : qtyText, - cost: isCost(line.비목) ? line.비목 : null, - percentBase: null, - element: clone(line.요소), - lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row), - source: { part: "호표", index }, - raw: clone(line), - }; -} - -function addCardOf( - line: NamedFormula, - index: number, - row: LogicRow, - persons: { qtyText: string; element: unknown }[], -): EasyCard { - const p = parsePercent(line.식); - const base = p ? baseOf(p.base, persons) : null; - const percent = p !== null && base !== null; - return { - id: newId(), - kind: percent ? "percent" : "locked", - name: line.이름 ?? "", - spec: "", - unit: percent ? "%" : "", - qty: percent ? p.rate : null, - qtyFormula: percent ? null : line.식, - cost: isCost(line.비목) ? line.비목 : null, - percentBase: percent ? base : null, - element: null, - lockedWhy: percent ? null : lockedWhy(line.식, row), - source: { part: "덧줄", index }, - raw: clone(line), - }; -} - -export function toDoc( - row: LogicRow, - meta: { key: string | null; version: string | null; isBase: boolean }, -): EasyDoc { - const ho = row.호표 ?? []; - const persons = ho - .filter((h) => h.종류 === "인력") - .map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 })); - return { - key: meta.key, - version: meta.version, - isBase: meta.isBase, - name: row.이름 ?? "", - spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "", - resultUnit: row.결과단위 ?? "", - inputs: clone(row.입력 ?? []), - cards: [ - ...ho.map((h, i) => hoCard(h, i, row)), - ...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)), - ], - row: clone(row), - }; -} - -/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */ -export function emptyRow(name = "", per = "㎡"): LogicRow { - return { - 키: "", - 원문번호: "", - 이름: name, - 결과단위: `원/${per}`, - 출처: "자체", - 입력: [], - 중간: [], - 호표: [], - 덧줄: [], - 끝수: null, - }; -} - -/** 빈 종이로 새로 */ -export function newDoc(name = "", per = "㎡"): EasyDoc { - return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false }); -} - -/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */ - -const partOf = (c: EasyCard): "호표" | "덧줄" => - c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; - -const TYPE_OF: Record = { - person: "인력", - material: "재료", - machine: "로직", - logic: "로직", -}; - -/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */ -function qtyTextFor(c: EasyCard, rawText: unknown): string { - if (c.qtyFormula !== null) return c.qtyFormula; - if (c.qty === null || !Number.isFinite(c.qty)) return ""; - const keep = - typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty; - return keep ? (rawText as string) : numText(c.qty); -} - -/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */ -function setField(o: Record, k: string, v: string): void { - if (v !== "" || k in o) o[k] = v; -} - -function hoLineOf(c: EasyCard): HoLine { - const raw = isObj(c.raw) ? clone(c.raw) : {}; - const line: Record = raw; - if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind]; - line.요소 = clone(c.element); - setField(line, "이름", c.name); - setField(line, "규격", c.spec); - setField(line, "단위", c.unit); - line.수량 = qtyTextFor(c, raw.수량); - if (c.cost !== null) line.비목 = c.cost; - else if (isCost(raw.비목)) delete line.비목; - return line as unknown as HoLine; -} - -function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string { - const rate = numText(c.qty ?? 0); - if (c.percentBase === "사람줄합") { - const terms = persons.map((p) => personTerm(p.qtyText, p.element)); - return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`; - } - return `${c.percentBase ?? "노무비"} * ${rate} / 100`; -} - -function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula { - const raw = isObj(c.raw) ? clone(c.raw) : {}; - const line: Record = raw; - line.이름 = c.name; - const rawText = typeof raw.식 === "string" ? raw.식 : ""; - if (c.kind === "percent") { - const p = parsePercent(rawText); - const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase; - line.식 = same ? rawText : percentText(c, persons); - } else { - line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText); - } - if (c.cost !== null) line.비목 = c.cost; - else if (isCost(raw.비목)) delete line.비목; - return line as unknown as NamedFormula; -} - -export function toRow(doc: EasyDoc): LogicRow { - const row = clone(doc.row) as LogicRow & Record; - row.이름 = doc.name; - setField(row, "규격", doc.spec); - row.결과단위 = doc.resultUnit; - row.입력 = clone(doc.inputs); - const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf); - const persons = ho - .filter((h) => h.종류 === "인력") - .map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 })); - const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons)); - if (ho.length || "호표" in row) row.호표 = ho; - if (add.length || "덧줄" in row) row.덧줄 = add; - return row; -} - -/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */ - -const COST_OF: Record = { - person: "노무비", - material: "재료비", - machine: null, - logic: null, -}; - -/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */ -const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind); - -/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */ -function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] { - const last = (hit: (c: EasyCard) => boolean): number => { - let at = -1; - cards.forEach((c, i) => { - if (hit(c)) at = i; - }); - return at; - }; - let at = last((c) => groupOf(c) === groupOf(card)); - if (at < 0) at = last((c) => partOf(c) === partOf(card)); - if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1; - const out = cards.slice(); - out.splice(at + 1, 0, card); - return out; -} - -export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc { - const card: EasyCard = { - id: newId(), - kind: pick.kind, - name: pick.name, - spec: pick.spec, - unit: pick.unit, - qty: pick.qty, - qtyFormula: null, - cost: COST_OF[pick.kind], - percentBase: null, - element: clone(pick.element), - lockedWhy: null, - source: null, - raw: null, - }; - return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []); -} - -/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */ -export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc { - const inputs = doc.inputs.slice(); - for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need)); - return inputs.length === doc.inputs.length ? doc : { ...doc, inputs }; -} - -/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */ -export function addPercent( - doc: EasyDoc, - name: string, - base: CostKind | "사람줄합", - rate: number | null, - cost: CostKind | null, -): EasyDoc { - const card: EasyCard = { - id: newId(), - kind: "percent", - name, - spec: "", - unit: "%", - qty: rate, - qtyFormula: null, - cost, - percentBase: base, - element: null, - lockedWhy: null, - source: null, - raw: null, - }; - return { ...doc, cards: insertAt(doc.cards, card) }; -} - -/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */ -export function updateCard(doc: EasyDoc, id: string, patch: Partial): EasyDoc { - return { - ...doc, - cards: doc.cards.map((c) => { - if (c.id !== id) return c; - const next: EasyCard = { ...c, ...patch, id: c.id }; - if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null; - return next; - }), - }; -} - -export function removeCard(doc: EasyDoc, id: string): EasyDoc { - return { ...doc, cards: doc.cards.filter((c) => c.id !== id) }; -} - -/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */ -export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc { - const from = doc.cards.findIndex((c) => c.id === id); - if (from < 0) return doc; - const g = groupOf(doc.cards[from]); - let to = from + step; - while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step; - if (to < 0 || to >= doc.cards.length) return doc; - const cards = doc.cards.slice(); - [cards[from], cards[to]] = [cards[to], cards[from]]; - return { ...doc, cards }; -} - -/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */ - -/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */ -const TOO_BIG: Record = { 인: 10, 일: 10, 시간: 24, "%": 100 }; -const TOO_BIG_ELSE = 10000; - -export function checkCard(card: EasyCard, doc: EasyDoc): string | null { - if (!card.name.trim()) return tx("NoName"); - if (card.qtyFormula !== null) return null; - if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber"); - if (card.qty === 0) return tx("Zero"); - if (card.qty < 0) return tx("Negative"); - const same = doc.cards.some( - (c) => - c.id !== card.id && - c.kind === card.kind && - c.name === card.name && - c.spec === card.spec && - JSON.stringify(c.element) === JSON.stringify(card.element) && - (card.kind !== "percent" || c.percentBase === card.percentBase), - ); - if (same) return tx("SameLine"); - if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) { - const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`; - return tx("TooBig") - .replace("{per}", per) - .replace("{qty}", numText(card.qty)) - .replace("{unit}", card.unit); - } - return null; -} - -/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */ -export function checkName(name: string, mine: string[]): string | null { - if (!name.trim()) return tx("NoName"); - return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null; -} - -/* ── 되돌리기 ───────────────────────────────────────────────────────────── */ - -const HISTORY_MAX = 200; - -/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */ -export class EasyHistory { - private stack: string[] = []; - private saved: string | null = null; - - push(doc: EasyDoc): void { - const s = JSON.stringify(doc); - if (this.stack[this.stack.length - 1] === s) return; - this.stack.push(s); - if (this.saved === null) this.saved = s; - if (this.stack.length > HISTORY_MAX) this.stack.shift(); - } - - undo(): EasyDoc | null { - if (this.stack.length < 2) return null; - this.stack.pop(); - return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc; - } - - get canUndo(): boolean { - return this.stack.length > 1; - } - - /** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */ - get dirty(): boolean { - return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved; - } - - markSaved(): void { - this.saved = this.stack[this.stack.length - 1] ?? null; - } -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css deleted file mode 100644 index 3c1b40947..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css +++ /dev/null @@ -1,100 +0,0 @@ -.m01-easypick { - --modal-max: 86vh; - width: min(760px, 94vw); - font-size: var(--text-body-lg, 1.125rem); -} -.m01-easypick__ask { - margin: 0; - font-size: 1.35em; - color: var(--color-text); -} -.m01-easypick__muted { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} -.m01-easypick__kinds { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: var(--spacing-12); -} -.m01-easypick__kind, -.m01-easypick__row { - display: flex; - gap: var(--spacing-8); - padding: var(--spacing-12) var(--spacing-16); - border: 2px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} -.m01-easypick__kind { - flex-direction: column; - min-height: 96px; -} -.m01-easypick__kind strong { - font-size: 1.3em; -} -.m01-easypick__kind span { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} -.m01-easypick__kind:hover, -.m01-easypick__row:hover, -.m01-easypick__kind:focus-visible, -.m01-easypick__row:focus-visible { - border-color: var(--color-accent); - outline: 3px solid var(--color-accent); - outline-offset: 1px; -} -.m01-easypick__bar { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: center; -} -.m01-easypick__bar > :last-child { - flex: 1 1 200px; -} -.m01-easypick__list { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - max-height: 46vh; - overflow: auto; -} -.m01-easypick__row { - align-items: center; - justify-content: space-between; - min-height: 52px; -} -.m01-easypick__name { - flex: 1 1 auto; - min-width: 0; -} -.m01-easypick__unit, -.m01-easypick__price { - flex: none; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} -.m01-easypick__tag { - margin-left: var(--spacing-8); - padding: 0 var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} -.m01-easypick__chosen { - margin: 0; - font-size: 1.15em; -} -.m01-easypick__rough { - min-height: 1.4em; - margin: 0; - color: var(--color-accent); - font-weight: 600; -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts deleted file mode 100644 index 04d8a375d..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts +++ /dev/null @@ -1,495 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Pick.ts - * 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음. - * 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절. - * 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch"; -import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api"; -import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api"; -import type { PickResult } from "./M01_EasyCalc_Model"; -import "./M01_EasyCalc_Pick.css"; - -type Kind = PickResult["kind"]; -/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */ -type Brief = ElementBrief & { 구분?: string; 상세구분?: string }; - -const TEXT = { - Title: ["넣을 것 고르기", "Pick what to add"], - Close: ["닫기", "Close"], - Back: ["← 뒤로", "← Back"], - AskKind: ["무엇을 넣을까요?", "What do you want to add?"], - AskWhich: ["어떤 것인가요?", "Which one?"], - AskQty: ["얼마나 쓰나요?", "How much?"], - Person: ["사람", "People"], - Material: ["재료", "Material"], - Machine: ["기계", "Machine"], - Logic: ["다른 일위대가", "Another unit-price job"], - PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"], - MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"], - MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"], - LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"], - Search: ["이름을 쳐서 찾기", "Type a name to search"], - AllSub: ["전체", "All"], - SubLabel: ["구분", "Group"], - DetailLabel: ["상세구분", "Detail"], - Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"], - Loading: ["찾는 중…", "Searching…"], - None: ["찾는 것 없음", "Nothing found"], - Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], - NoPrice: ["단가 없음", "No price"], - PerDay: ["하루 {p} 원", "{p} won per day"], - PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"], - Picked: ["고른 것", "Chosen"], - QtyPlace: ["예: 0.072", "e.g. 0.072"], - QtyLabel: ["{u} (숫자)", "{u} (number)"], - Rough: ["금액 어림 약 {m} 원", "About {m} won"], - Add: ["넣기", "Add"], - QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"], - Failed: ["불러오지 못함", "Could not load"], -} as const; - -const te = (key: keyof typeof TEXT, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - TEXT[key][currentLanguageIndex] as string, - ); - -const won = (n: number): string => Math.round(n).toLocaleString("ko-KR"); -/** 목록에 그릴 줄 수 — 더 찾으려면 이름을 쳐서 좁힘 */ -const SHOW = 100; -const MAT_FILE = "재료_자재품목.json"; - -/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */ -interface Candidate { - name: string; - spec: string; - unit: string; - /** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */ - price: number | null; - /** 줄 오른쪽 글(단가 표시) */ - priceText: string; - tag?: string; - make: () => Promise>; -} - -interface Filter { - q: string; - sub: string; - detail: string; -} - -interface Source { - title: string; - desc: string; - /** 구분 · 상세구분 칸을 보일지(재료만) */ - grouped?: boolean; - /** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */ - load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>; -} - -/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 · - * 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */ -const byName = (items: T[], q: string): T[] => { - const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase(); - const w = squash(q); - const rank = (it: T): number => { - const n = squash(it.이름 ?? ""); - if (n === w) return 0; - if (!n.startsWith(w)) return 3; - return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1; - }; - return items - .map((it, i) => ({ it, i, r: rank(it) })) - .sort((a, b) => a.r - b.r || a.i - b.i) - .map((x) => x.it); -}; - -const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); - -const SOURCES: Partial> = { - person: { - title: te("Person"), - desc: te("PersonDesc"), - load: async (f) => { - const { total, items } = await fetchPick("job", f.q); - return { - total, - items: items.map((it) => { - const price = num(it.값); - return { - name: it.이름, - spec: it.규격, - unit: "인", - price, - priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }), - make: async () => ({ - kind: "person", - name: it.이름, - spec: it.규격, - unit: "인", - element: it.ref, - }), - }; - }), - }; - }, - }, - material: { - title: te("Material"), - desc: te("MaterialDesc"), - grouped: true, - load: async (f) => { - if (!f.q && !f.sub) return null; - const found = f.q - ? await fetchPick("price", f.q).then((r) => ({ - total: r.total, - items: byName(r.items, f.q) - .filter((it) => !f.sub || (it as Brief).구분 === f.sub) - .map((it) => ({ ...it, 구분: (it as Brief).구분 })), - })) - : await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then( - (r) => ({ - total: r.total, - items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })), - }), - ); - return { - total: found.total, - items: found.items.map((it) => { - const name = it.이름 ?? ""; - const spec = it.규격 ?? ""; - const unit = typeof it.단위 === "string" ? it.단위 : ""; - const price = num(it.값); - const element: Record = { 구분: it.구분 ?? f.sub }; - const detail = (it as Brief).상세구분; - if (detail) element["상세구분"] = detail; - element["검색어"] = `${name} ${spec}`.trim(); - return { - name, - spec, - unit, - price, - priceText: - price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }), - tag: element["구분"], - make: async () => ({ kind: "material", name, spec, unit, element }), - }; - }), - }; - }, - }, - machine: { - title: te("Machine"), - desc: te("MachineDesc"), - load: async (f) => { - const items = await listMachines(f.q); - return { - total: items.length, - items: items.map((m) => ({ - name: m.이름, - spec: m.규격, - unit: m.단위, - price: null, - priceText: "", - make: async () => { - const { element, needInputs } = await machineElement(m.분류번호); - return { - kind: "machine", - name: m.이름, - spec: m.규격, - unit: m.단위, - element, - needInputs, - }; - }, - })), - }; - }, - }, - logic: { - title: te("Logic"), - desc: te("LogicDesc"), - load: async (f) => { - // 돈으로 나오는 일위대가만 — 「원/㎡」 꼴 - const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/")); - return { - total: items.length, - items: items.map((e) => { - const unit = e.resultUnit.slice(2); - return { - name: e.name, - spec: "", - unit, - price: null, - priceText: "", - tag: e.owner, - make: async () => { - // 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴) - const needInputs = (await fetchLogic(e.key)).logic.입력; - const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); - const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); - return { - kind: "logic", - name: e.name, - spec: "", - unit, - element: `로직(${e.key}${rest})`, - needInputs, - }; - }, - }; - }), - }; - }, - }, -}; - -/** 물음 셋 — 고른 것 · 취소는 null */ -export function openEasyPick( - kind?: Kind, - opts: { qty?: number | null } = {}, -): Promise { - return new Promise((resolve) => { - let settled = false; - const finish = (r: PickResult | null): void => { - if (!settled) resolve(r); - settled = true; - }; - openModal({ - title: te("Title"), - closeLabel: te("Close"), - dialogClass: "m01-easypick", - beforeClose: () => (finish(null), true), - mount: (body, close) => { - const done = (r: PickResult): void => (finish(r), close()); - const stepKinds = (): void => { - body.replaceChildren( - el("h4", { className: "m01-easypick__ask", text: te("AskKind") }), - kindButtons(), - ); - body.querySelector("button")?.focus(); - }; - const kindButtons = (): HTMLElement => { - const order: Kind[] = ["person", "material", "machine", "logic"]; - const grid = el("div", { className: "m01-easypick__kinds" }); - order.forEach((k, i) => { - const src = SOURCES[k]; - const b = el("button", { - className: "m01-easypick__kind", - attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) }, - children: [ - el("strong", { text: src?.title ?? labelOf(k) }), - el("span", { text: src?.desc ?? descOf(k) }), - ], - }); - b.addEventListener("click", () => stepList(k, true)); - grid.append(b); - }); - grid.addEventListener("keydown", (ev) => { - const k = order[Number(ev.key) - 1]; - if (k) stepList(k, true); - }); - return grid; - }; - const stepList = (k: Kind, canBack: boolean): void => { - const src = SOURCES[k]; - if (!src) return; - const back = canBack ? stepKinds : null; - const search = createInputField({ type: "search", placeholder: te("Search") }); - const filter: Filter = { q: "", sub: "", detail: "" }; - const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } }); - const count = el("span", { className: "m01-easypick__muted" }); - const bar: HTMLElement[] = [search.root]; - let seq = 0; - const load = async (): Promise => { - const mine = ++seq; - count.textContent = ""; - list.replaceChildren( - el("p", { className: "m01-easypick__muted", text: te("Loading") }), - ); - try { - const found = await src.load(filter); - if (mine !== seq) return; // 최신 응답만 - if (!found) - return void list.replaceChildren( - el("p", { className: "m01-easypick__muted", text: te("Hint") }), - ); - const shown = found.items.slice(0, SHOW); - count.textContent = te("Count", { n: found.total, m: shown.length }); - list.replaceChildren( - ...(shown.length - ? shown.map((c) => - rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)), - ) - : [el("p", { className: "m01-easypick__muted", text: te("None") })]), - ); - } catch (e) { - if (mine === seq) showToast(plainError(e), "error"); - } - }; - if (src.grouped) bar.unshift(...subFilters(filter, load)); - let timer = 0; - search.input.addEventListener("input", () => { - filter.q = search.input.value.trim(); - window.clearTimeout(timer); - timer = window.setTimeout(load, 250); - }); - body.replaceChildren( - ...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []), - el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }), - el("div", { className: "m01-easypick__bar", children: bar }), - count, - list, - ); - body.onkeydown = (ev) => { - if (back && ev.altKey && ev.key === "ArrowLeft") back(); - else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev); - }; - search.input.focus(); - void load(); - }; - const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => { - const qty = createInputField({ - type: "text", - label: te("QtyLabel", { u: c.unit || "—" }), - placeholder: te("QtyPlace"), - }); - qty.input.inputMode = "decimal"; - if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움 - const rough = el("p", { className: "m01-easypick__rough" }); - const parse = (): number | null => { - const n = Number(qty.input.value.trim().replace(/,/g, "")); - return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null; - }; - const refresh = (): void => { - const n = parse(); - rough.textContent = - n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : ""; - qty.setError(undefined); - }; - const submit = async (): Promise => { - const n = parse(); - if (n === null) return qty.setError(te("QtyBad")); - try { - done({ ...(await c.make()), qty: n }); - } catch (e) { - qty.setError(plainError(e)); - } - }; - qty.input.addEventListener("input", refresh); - qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit()); - body.replaceChildren( - ...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []), - el("h4", { className: "m01-easypick__ask", text: te("AskQty") }), - el("p", { - className: "m01-easypick__chosen", - children: [ - el("span", { className: "m01-easypick__muted", text: te("Picked") }), - el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }), - ], - }), - qty.root, - rough, - createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }), - ); - body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.(); - qty.input.focus(); - void k; - }; - if (kind) stepList(kind, false); - else stepKinds(); - }, - }); - }); -} - -const labelOf = (k: Kind): string => - te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]); -const descOf = (k: Kind): string => - te( - ( - { - person: "PersonDesc", - material: "MaterialDesc", - machine: "MachineDesc", - logic: "LogicDesc", - } as const - )[k], - ); - -/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */ -function rowOf(c: Candidate, onPick: () => void): HTMLElement { - const b = el("button", { - className: "m01-easypick__row", - attrs: { type: "button", role: "option" }, - children: [ - el("span", { - className: "m01-easypick__name", - children: [ - el("strong", { text: c.name }), - ...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []), - ...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []), - ], - }), - el("span", { className: "m01-easypick__unit", text: c.unit }), - el("span", { className: "m01-easypick__price", text: c.priceText }), - ], - }); - b.addEventListener("click", onPick); - return b; -} - -/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */ -function moveFocus(body: HTMLElement, ev: KeyboardEvent): void { - const items = [...body.querySelectorAll("input[type=search], .m01-easypick__row")]; - const at = items.indexOf(document.activeElement as HTMLElement); - if (at < 0) return; - const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)]; - if (!next) return; - ev.preventDefault(); - next.focus(); -} - -/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */ -function subFilters(filter: Filter, load: () => Promise): HTMLElement[] { - let subs: SubInfo[] = []; - const all = { value: "", text: te("AllSub") }; - const subBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: te("SubLabel"), - onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()), - }); - const detailBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: te("DetailLabel"), - onChange: (v) => ((filter.detail = v), void load()), - }); - const fillDetail = (): void => { - const details = subs.find((s) => s.name === filter.sub)?.details ?? []; - detailBox.root.hidden = !details.length; - detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], ""); - }; - fillDetail(); - void fetchSubs({ file: MAT_FILE }) - .then((r) => { - subs = r; - subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub); - }) - .catch(() => showToast(te("Failed"), "error")); - return [subBox.root, detailBox.root]; -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css deleted file mode 100644 index 3a57c2b6f..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css +++ /dev/null @@ -1,112 +0,0 @@ -.m01-easysheet__title { - margin: 0 0 var(--spacing-8); - color: var(--color-text-body); - font-size: var(--text-body); - font-weight: 600; -} - -.m01-easysheet__status, -.m01-easysheet__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -/* 좁은 폭은 표 안에서만 가로 스크롤 — 쪽 전체는 안 밀림 */ -.m01-easysheet__scroll { - max-width: 100%; - overflow-x: auto; -} - -.m01-easysheet__table { - width: 100%; - min-width: 920px; - border-collapse: collapse; - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01-easysheet__table th, -.m01-easysheet__table td { - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - text-align: left; - vertical-align: middle; -} - -.m01-easysheet__table thead th { - background: var(--color-surface); - color: var(--color-text-secondary); - font-weight: 600; - white-space: nowrap; -} - -.m01-easysheet__table .m01-easysheet__group { - text-align: center; -} - -.m01-easysheet__table .m01-easysheet__num { - text-align: right; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.m01-easysheet__table .m01-easysheet__note { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-easysheet__table tfoot th, -.m01-easysheet__table tfoot td { - background: var(--color-surface); - font-weight: 700; -} - -.m01-easysheet__strip-title { - color: var(--color-text-body); -} - -.m01-easysheet__fields { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m01-easysheet__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-easysheet__ctl { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-easysheet__input { - box-sizing: border-box; - width: 160px; - max-width: 100%; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); -} - -.m01-easysheet__input[aria-invalid] { - border-color: var(--color-danger); -} - -.m01-easysheet__warn { - color: var(--color-danger); - font-size: var(--text-caption); -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts deleted file mode 100644 index ca04ab25e..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts +++ /dev/null @@ -1,246 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Sheet.ts - * 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨) - * 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; -import { - searchElements, - type CalcAnswer, - type CalcLine, - type LogicInput, -} from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money"; -import { safeReason } from "../M01_MasterData_UI_Logic_Text"; -import "./M01_EasyCalc_Sheet.css"; - -const TEXT = { - Busy: ["계산 중", "Calculating"], - Stopped: ["계산하지 못했어요", "Could not calculate"], - Name: ["명칭", "Name"], - Spec: ["규격", "Size"], - Qty: ["수량", "Qty"], - Unit: ["단위", "Unit"], - Total: ["합계", "Total"], - Labor: ["노무비", "Labor"], - Material: ["재료비", "Material"], - Expense: ["경비", "Expense"], - Note: ["비고", "Note"], - Price: ["단가", "Price"], - Amount: ["금액", "Amount"], - Strip: ["설계 때 정하는 값", "Values set at design time"], - Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"], - Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], - Pick: ["고르세요", "Choose"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - OutOfRange: ["범위 밖이에요", "Out of range"], -} as const; - -const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -const COSTS = ["노무비", "재료비", "경비"] as const; -const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; - -const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -const cell = (text: string, className = ""): HTMLElement => el("td", { className, text }); -const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num"); - -/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */ -const note = (line: CalcLine): string => - [line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · "); - -function lineRow(line: CalcLine): HTMLElement { - // 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임 - const per = (k: string): number => - line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0; - return el("tr", { - children: [ - cell(line.이름), - cell(line.품목?.규격 ?? ""), - money(qty(line.수량)), - cell(line.단위), - money(formatPrice(line.단가)), - money(formatMoney(line.금액)), - ...COSTS.flatMap((k) => [ - money(formatPrice(per(k))), - money(formatMoney(line.비목?.[k] ?? 0)), - ]), - cell(note(line), "m01-easysheet__note"), - ], - }); -} - -/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */ -export function renderSheet( - host: HTMLElement, - head: { name: string; resultUnit: string }, - ans: CalcAnswer | null, -): void { - const title = el("p", { - className: "m01-easysheet__title", - text: [head.name, head.resultUnit].filter(Boolean).join(" · "), - }); - if (!ans || !ans.ok) { - host.replaceChildren( - title, - el("p", { - className: "m01-easysheet__status", - text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"), - }), - ); - return; - } - const lines = ans.lines ?? []; - const sums = ans.sums ?? {}; - const group = (label: string): HTMLElement => - el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } }); - const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } }); - const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h })); - const table = el("table", { - className: "m01-easysheet__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: [ - one(ts("Name")), - one(ts("Spec")), - one(ts("Qty")), - one(ts("Unit")), - group(ts("Total")), - ...COSTS.map((k) => group(ts(COST_HEAD[k]))), - one(ts("Note")), - ], - }), - el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }), - ], - }), - el("tbody", { children: lines.map(lineRow) }), - el("tfoot", { - children: [ - el("tr", { - children: [ - el("th", { text: ts("Total"), attrs: { colspan: "4" } }), - cell(""), - money(formatMoney(sums["계"] ?? 0)), - ...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]), - cell(""), - ], - }), - ], - }), - ], - }); - host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] })); -} - -/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */ -const KEY_RE = /^[A-Z]{2}\d{6}$/; -const KEY_GROUP: Record = { MT: "재료", EQ: "기계", LB: "인력" }; -const nameCache = new Map(); - -async function keyName(key: string): Promise { - if (nameCache.has(key)) return nameCache.get(key)!; - const group = KEY_GROUP[key.slice(0, 2)]; - let name: string | null = null; - if (group) { - try { - const hit = (await searchElements(group, key)).items.find((it) => it.ref === key); - name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null; - } catch { - name = null; - } - } - nameCache.set(key, name); - return name; -} - -/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */ -type Trial = Record; - -function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement { - const [lo, hi] = spec.범위 ?? []; - const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : ""; - const box = el("input", { - className: "m01-easysheet__input", - attrs: { type: "text", inputmode: "decimal", placeholder: hint }, - }); - const warn = el("span", { className: "m01-easysheet__warn" }); - box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - const raw = box.value.trim(); - const num = Number(raw); - const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : ""; - const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : ""; - warn.textContent = bad || out; - box.toggleAttribute("aria-invalid", !!(bad || out)); - if (bad) return; - if (raw === "") delete state[spec.이름]; - else state[spec.이름] = num; - emit(); - }); - return el("span", { className: "m01-easysheet__ctl", children: [box, warn] }); -} - -function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement { - const picks = spec.고르기 ?? []; - const optionText = (o: string | number): string => - (typeof o === "string" && nameCache.get(o)) || String(o); - const options = (): { value: string; text: string }[] => [ - { value: "", text: ts("Pick") }, - ...picks.map((o) => ({ value: String(o), text: optionText(o) })), - ]; - const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]); - const field = createSelectField({ - options: options(), - value: cur, - compact: true, - ariaLabel: spec.이름, - onChange: (v) => { - const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄 - if (hit === undefined) delete state[spec.이름]; - else state[spec.이름] = hit; - emit(); - }, - }); - const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o)); - if (keys.length) { - void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur)); - } - return field.root; -} - -/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */ -export function renderTrialStrip( - host: HTMLElement, - inputs: LogicInput[], - trial: Record, - onChange: (trial: Record) => void, -): void { - if (!inputs.length) { - host.replaceChildren(); - host.hidden = true; - return; - } - host.hidden = false; - const state = { ...trial }; - const emit = (): void => onChange({ ...state }); - host.replaceChildren( - el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }), - el("div", { - className: "m01-easysheet__fields", - children: inputs.map((spec) => - el("label", { - className: "m01-easysheet__field", - children: [ - el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }), - spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit), - ], - }), - ), - }), - el("p", { className: "m01-easysheet__muted", text: ts("Trial") }), - ); -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css deleted file mode 100644 index 5f98a5da2..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css +++ /dev/null @@ -1,175 +0,0 @@ -/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */ - -/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */ -.ui-modal.m01-easycalc { - --modal-max: 90vh; - width: min(1280px, 96vw); - height: var(--modal-max); - font-size: var(--text-body); -} - -.m01-easycalc .ui-modal__body { - flex: 1; - min-height: 0; - gap: var(--spacing-16); -} - -.m01-easycalc__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-easycalc__big { - min-height: 56px; - padding: 0 var(--spacing-24); - font-size: var(--text-body); -} - -/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */ -.m01-easycalc__start { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: var(--spacing-24); - min-height: 0; -} - -.m01-easycalc__start-col { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -.m01-easycalc__start-col h4 { - margin: var(--spacing-8) 0 0; -} - -.m01-easycalc__ask { - margin: 0; - font-size: var(--text-subheading); -} - -.m01-easycalc__search .ui-input { - font-size: var(--text-body); -} - -.m01-easycalc__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-easycalc__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - width: 100%; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.m01-easycalc__item:hover:not(:disabled) { - border-color: var(--color-accent); -} - -.m01-easycalc__item:disabled { - cursor: default; - opacity: 0.6; -} - -.m01-easycalc__item-name { - flex: 1; - min-width: 0; -} - -.m01-easycalc__item-unit { - color: var(--color-text-muted); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -/* 고치는 화면 — 위 띠 */ -.m01-easycalc__strip { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); - padding-bottom: var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m01-easycalc__strip .ui-field { - min-width: 120px; -} - -.m01-easycalc__name { - flex: 1 1 240px; -} - -.m01-easycalc__total { - font-size: var(--text-subheading); - font-weight: 700; - white-space: nowrap; -} - -.m01-easycalc__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; -} - -.m01-easycalc__base { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-16); -} - -.m01-easycalc__base p { - margin: 0; -} - -.m01-easycalc__part { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); -} - -.m01-easycalc__part h4 { - margin: 0; -} - -.m01-easycalc__cards { - flex: 1; -} - -.m01-easycalc__sheet summary { - cursor: pointer; - font-weight: 600; -} - -/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */ -.m01-easycalc .is-pending { - opacity: 0.45; -} - -.m01-easycalc__note { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: pre-line; -} - -.m01-easycalc__note:empty { - display: none; -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css deleted file mode 100644 index b2788c7b7..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css +++ /dev/null @@ -1,161 +0,0 @@ -/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */ -.ui-modal.m01-easytable { - width: min(1100px, 96vw); -} - -.m01-easytable .ui-modal__body { - display: flex; - flex-direction: column; - gap: var(--spacing-12); -} - -.m01-easytable__note { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-easytable__which, -.m01-easytable__actions, -.m01-easytable__seg { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.m01-easytable__which:empty { - display: none; -} - -.m01-easytable__actions { - justify-content: flex-end; -} - -.m01-easytable__head { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-easytable__conds { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m01-easytable__conds h4 { - margin: 0; - font-size: var(--text-body); -} - -.m01-easytable__cond { - display: grid; - grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-body-sm); -} - -.m01-easytable__cond-value { - font-weight: 600; -} - -.m01-easytable__grid { - max-height: 46vh; - overflow: auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); -} - -.m01-easytable__grid table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-body-sm); -} - -.m01-easytable__grid th, -.m01-easytable__grid td { - padding: var(--spacing-4) var(--spacing-8); - border-bottom: 1px solid var(--color-border); - text-align: left; - white-space: nowrap; -} - -.m01-easytable__grid thead th { - position: sticky; - top: 0; - background: var(--color-surface); - color: var(--color-text-secondary); -} - -.m01-easytable__grid td.is-value { - text-align: right; -} - -.m01-easytable__grid .is-col { - font-weight: 700; - color: var(--color-text); -} - -.m01-easytable__grid tbody tr { - cursor: pointer; -} - -.m01-easytable__grid tbody tr:hover { - background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas)); -} - -.m01-easytable__grid tr.is-hit { - background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); -} - -.m01-easytable__grid tr.is-picked td:first-child { - box-shadow: inset 3px 0 0 var(--color-accent); -} - -.m01-easytable__money { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-16); -} - -.m01-easytable__money.is-busy { - opacity: 0.5; -} - -.m01-easytable__hint { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas)); - font-size: var(--text-body-sm); -} - -.m01-easytable__hint:empty { - display: none; -} - -.m01-easytable__warn { - margin: 0; - color: var(--color-danger); - font-size: var(--text-body-sm); -} - -.m01-easytable__warn:empty { - display: none; -} - -@media (max-width: 600px) { - .m01-easytable__cond { - grid-template-columns: 1fr 1fr; - } - - .m01-easytable__seg { - grid-column: 1 / -1; - } -} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts deleted file mode 100644 index 14b8be121..000000000 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts +++ /dev/null @@ -1,696 +0,0 @@ -/* ============================================================================= - * M01_EasyCalc_Table.ts - * 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`) - * 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고, - * 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null). - * - * 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 — - * 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`). - * 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { fetchTable } from "../M01_MasterData_Api_Fetch"; -import { - fetchAuto, - runCalc, - runText, - searchElements, - type HoLine, - type LogicRow, - type NamedFormula, -} from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model"; -import "./M01_EasyCalc_Table.css"; - -const TEXT = { - Title: ["표 보고 고르기", "Pick from the table"], - Close: ["닫기", "Close"], - Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"], - NoTable: ["표를 불러오지 못함", "Couldn't load the table"], - Loading: ["불러오는 중", "Loading"], - Hint: [ - "줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸", - "Tap a row to use it · yellow = row in use · bold = column the quantity comes from", - ], - Conds: ["어느 줄을 쓸까", "Which row"], - Design: ["설계 때 정함", "Set at design"], - Fix: ["이 값으로 고정", "Fix this value"], - AsIs: ["지금대로", "As is"], - NotYet: ["(아직 없음)", "(none yet)"], - Amount: ["이 줄 금액", "This line"], - Total: ["합계", "Total"], - Wait: ["계산 중", "Calculating"], - Stopped: ["계산 멈춤", "Calculation stopped"], - DesignHint: [ - "이 값은 설계 때 정함 — 늘 이 줄로 하려면", - "This value is set at design — to always use this row,", - ], - Mismatch: [ - "고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요", - "The picked row isn't matched by these conditions", - ], - Use: ["이걸로", "Use this"], - Cancel: ["취소", "Cancel"], - Which: ["표 고르기", "Choose table"], -} as const; - -const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1]; - -/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */ - -export interface FindArg { - /** 표 조건 칸 이름 */ - key: string; - /** `=` 오른쪽 식 글 그대로 */ - rhs: string; -} - -export interface FindCall { - /** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */ - start: number; - end: number; - table: string; - args: FindArg[]; - col: string; -} - -const NAME = /[\p{L}\p{N}_]/u; -const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch); - -class Reader { - i = 0; - constructor(readonly s: string) {} - ws(): void { - while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++; - } - take(ch: string): boolean { - this.ws(); - if (this.s[this.i] !== ch) return false; - this.i++; - return true; - } - name(): string | null { - this.ws(); - const start = this.i; - while (isName(this.s[this.i])) this.i++; - return this.i > start ? this.s.slice(start, this.i) : null; - } - quoted(): string | null { - const end = this.s.indexOf("'", this.i); - if (end < 0) return null; - const text = this.s.slice(this.i, end); - this.i = end + 1; - return text; - } - /** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */ - until(head: boolean): string | null { - const start = this.i; - let depth = 0; - while (this.i < this.s.length) { - const ch = this.s[this.i]; - if (ch === "'" && !head) { - const end = this.s.indexOf("'", this.i + 1); - if (end < 0) return null; - this.i = end + 1; - continue; - } - if (ch === "(" || ch === "{") depth++; - else if (ch === ")" || ch === "}") { - if (depth === 0) break; - depth--; - } else if (ch === "," && depth === 0) break; - this.i++; - } - return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null; - } -} - -/** 한 자리의 찾기 — 못 읽으면 null */ -function readOne(s: string, at: number): FindCall | null { - const r = new Reader(s); - r.i = at + 2; - if (!r.take("(")) return null; - r.ws(); - const table = r.until(true); - if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸 - const args: FindArg[] = []; - while (r.take(",")) { - r.ws(); - const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name(); - if (!key || !r.take("=")) return null; - r.ws(); - const rhs = r.until(false); - if (!rhs) return null; - args.push({ key, rhs }); - } - if (!r.take(")") || !r.take(".")) return null; - r.ws(); - const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name(); - if (!col) return null; - return { start: at, end: r.i, table, args, col }; -} - -/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */ -export function readFinds(formula: string): FindCall[] | null { - const out: FindCall[] = []; - let i = 0; - while (i < formula.length) { - const ch = formula[i]; - if (ch === "'") { - const end = formula.indexOf("'", i + 1); - if (end < 0) return null; - i = end + 1; - continue; - } - if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) { - const call = readOne(formula, i); - if (!call) return null; - out.push(call); - i = call.end; - continue; - } - i++; - } - return out; -} - -const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`); - -/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */ -export function writeFind(table: string, args: FindArg[], col: string): string { - const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)]; - return `찾기(${parts.join(", ")}).${plain(col)}`; -} - -/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */ -export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string { - let out = formula; - for (let k = calls.length - 1; k >= 0; k--) { - const text = swaps[k]; - if (text !== null && text !== undefined) - out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end); - } - return out; -} - -/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */ -export function literal(value: unknown, rule = ""): string | null { - if (typeof value === "number") return String(value); - if (typeof value === "string") return value.includes("'") ? null : `'${value}'`; - if (Array.isArray(value)) { - const n = rangeNumber(value as [number | null, number | null], rule); - return n === null ? null : String(n); - } - return null; -} - -/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */ -export function rangeNumber(cell: [number | null, number | null], rule: string): number | null { - const [low, high] = cell; - const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0]; - const round = (n: number): number => Number(n.toFixed(6)); - if (low !== null && high !== null) return round((low + high) / 2); - if (high !== null) { - if (kind !== "이상·미만") return high; - return round(high > 1 ? high - 1 : high / 2); - } - if (low !== null) return kind.startsWith("이상") ? low : low + 1; - return null; -} - -/** 식 글이 따옴표 글 · 수면 그 값 */ -export function literalValue(rhs: string): string | number | null { - const q = rhs.match(/^'([^']*)'$/); - if (q) return q[1]; - return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null; -} - -/* ── 표 · 서버 ──────────────────────────────────────────────────────── */ - -type Cell = string | number | [number | null, number | null] | null | undefined; -interface EasyTable { - 이름?: string; - 기준?: string; - 범위규칙?: string; - 조건: Record; - 값칸: Record; - 줄: Record[]; -} - -const tables = new Map>(); - -function loadTable(key: string): Promise { - const hit = tables.get(key); - if (hit) return hit; - const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"]; - const job = (async (): Promise => { - for (const group of groups) { - const found = await searchElements(group, key).catch(() => null); - const file = found?.items.find((x) => x.ref === key)?.file; - if (!file) continue; - const got = await fetchTable(file, key).catch(() => null); - if (got?.table) return got.table as unknown as EasyTable; - } - return null; - })(); - tables.set(key, job); - return job; -} - -/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */ -function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula { - const raw = (card.raw ?? {}) as Record; - if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula; - return { - 종류: "인력", - 요소: String(card.element ?? ""), - 이름: card.name, - 단위: card.unit, - ...raw, - 수량: formula, - } as HoLine; -} - -/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */ -function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow { - const line = cardLine(card, formula); - if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] }; - return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] }; -} - -/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */ -function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow { - try { - const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c)); - return { ...toRow({ ...doc, cards }), 키: "" }; - } catch { - const row: LogicRow = { ...doc.row, 키: "" }; - const at = card.source?.index ?? -1; - if (card.source?.part === "덧줄" && row.덧줄?.[at]) - row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x)); - else if (row.호표?.[at]) - row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x)); - return row; - } -} - -interface Preview { - ok: boolean; - /** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */ - hits: (number | null)[]; - line: number | null; - total: number | null; -} - -async function preview( - card: EasyCard, - doc: EasyDoc, - formula: string, - calls: FindCall[], - inputs: Record, -): Promise { - const [text, calc] = await Promise.all([ - runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null), - runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null), - ]); - const lines = text?.ok - ? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name) - : []; - const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표"); - const seen = new Map(); - const hits = calls.map((c) => { - const nth = seen.get(c.table) ?? 0; - seen.set(c.table, nth + 1); - const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth]; - return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null; - }); - const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0); - return { - ok: !!text?.ok && !!calc?.ok, - hits, - line: lines.length ? sum : null, - total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null, - }; -} - -/* ── 창 ─────────────────────────────────────────────────────────────── */ - -type Mode = "design" | "fix" | "asis"; -interface CondState { - key: string; - /** 원래 식 글 · 새로 더한 조건은 null */ - orig: string | null; - mode: Mode; - /** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */ - input: string | null; - fix: Cell; -} -interface CallState { - conds: CondState[]; - col: string; - picked: number | null; - /** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */ - tryValues: Record; - /** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */ - designHint: string[]; -} - -const showCell = (v: Cell): string => { - if (v === null || v === undefined) return ""; - if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; - return String(v); -}; - -function startState(call: FindCall, doc: EasyDoc): CallState { - const names = new Set(doc.inputs.map((x) => x.이름)); - const conds = call.args.map((a): CondState => { - const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null; - const lit = literalValue(a.rhs); - const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis"; - return { key: a.key, orig: a.rhs, mode, input, fix: lit }; - }); - return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] }; -} - -function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null { - const out: FindArg[] = []; - for (const c of st.conds) { - if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input }); - else if (c.mode === "fix") { - const rhs = literal(c.fix, table?.범위규칙); - if (rhs === null) return null; - out.push({ key: c.key, rhs }); - } else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig }); - } - return out; -} - -/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */ -function compose( - formula: string, - calls: FindCall[], - states: CallState[], - tbl: (EasyTable | null)[], -) { - const swaps = calls.map((call, k) => { - const args = stateArgs(states[k], tbl[k]); - if (!args) return null; - const same = - states[k].col === call.col && - args.length === call.args.length && - args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs); - return same ? null : writeFind(call.table, args, states[k].col); - }); - return replaceFinds(formula, calls, swaps); -} - -/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */ -export function openEasyTable( - card: EasyCard, - doc: EasyDoc, - trial?: Record, -): Promise { - const formula = card.qtyFormula ?? ""; - const calls = readFinds(formula); - return new Promise((resolve) => { - let done = false; - const finish = (value: string | null): void => { - if (done) return; - done = true; - resolve(value); - }; - openModal({ - title: tt("Title"), - closeLabel: tt("Close"), - dialogClass: "m01-easytable", - beforeClose: () => (finish(null), true), - mount: (body, close) => { - if (!calls || !calls.length) { - body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") })); - return; - } - void mountTable(body, { card, doc, formula, calls, trial }, (v) => { - finish(v); - close(); - }); - }, - }); - }); -} - -async function mountTable( - body: HTMLElement, - ctx: { - card: EasyCard; - doc: EasyDoc; - formula: string; - calls: FindCall[]; - trial?: Record; - }, - finish: (value: string | null) => void, -): Promise { - const { card, doc, formula, calls } = ctx; - body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") })); - const [base, tbl] = await Promise.all([ - ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})), - Promise.all(calls.map((c) => loadTable(c.table))), - ]); - const states = calls.map((c) => startState(c, doc)); - let now = 0; - let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null }; - let seq = 0; - let busy = false; - - const which = el("div", { className: "m01-easytable__which" }); - const head = el("div", { className: "m01-easytable__head" }); - const condBox = el("div", { className: "m01-easytable__conds" }); - const grid = el("div", { className: "m01-easytable__grid" }); - const money = el("div", { className: "m01-easytable__money" }); - const warn = el("p", { className: "m01-easytable__warn" }); - const hint = el("p", { className: "m01-easytable__hint" }); - const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) }); - const cancel = createButton({ - label: tt("Cancel"), - variant: "ghost", - onClick: () => finish(null), - }); - - const newFormula = (): string => compose(formula, calls, states, tbl); - const inputs = (): Record => ({ ...base, ...states[now].tryValues }); - - const refresh = async (): Promise => { - const mine = ++seq; - busy = true; - paint(); - const got = await preview(card, doc, newFormula(), calls, inputs()); - if (mine !== seq) return; - last = got; - busy = false; - paint(); - }; - - const pickRow = (idx: number, col: string | null): void => { - const table = tbl[now]; - const st = states[now]; - const row = table?.줄[idx]; - if (!table || !row) return; - st.designHint = []; - for (const key of Object.keys(table.조건)) { - if (row[key] === undefined) continue; - let c = st.conds.find((x) => x.key === key); - if (!c) { - const input = doc.inputs.some((x) => x.이름 === key) ? key : null; - c = { key, orig: null, mode: "fix", input, fix: null }; - st.conds.push(c); - } - if (c.mode === "design" && c.input) { - const n = Array.isArray(row[key]) - ? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "") - : row[key]; - st.tryValues[c.input] = n; - st.designHint.push(key); - } else { - c.mode = "fix"; - c.fix = row[key]; - } - } - if (col) st.col = col; - st.picked = idx; - void refresh(); - }; - - const setMode = (c: CondState, mode: Mode): void => { - if (mode === "fix" && (c.fix === null || c.fix === undefined)) { - const hit = last.hits[now]; - const row = hit === null ? null : tbl[now]?.줄[hit]; - c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null); - } - c.mode = mode; - states[now].designHint = states[now].designHint.filter((k) => k !== c.key); - void refresh(); - }; - - /** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */ - const fixPicked = (): void => { - const st = states[now]; - const row = st.picked === null ? null : tbl[now]?.줄[st.picked]; - if (!row) return; - for (const c of st.conds) - if (st.designHint.includes(c.key)) { - c.mode = "fix"; - c.fix = row[c.key]; - } - st.designHint = []; - void refresh(); - }; - - const paintHint = (): void => { - const st = states[now]; - hint.replaceChildren( - ...(st.picked !== null && st.designHint.length - ? [ - el("span", { text: `${tt("DesignHint")} ` }), - createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }), - ] - : []), - ); - }; - - const paintConds = (): void => { - const st = states[now]; - condBox.replaceChildren( - el("h4", { text: tt("Conds") }), - ...st.conds.map((c) => { - const opts: [Mode, string][] = []; - if (c.input) opts.push(["design", tt("Design")]); - if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null) - opts.push(["asis", tt("AsIs")]); - opts.push(["fix", tt("Fix")]); - const hit = last.hits[now]; - const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]); - const value = - c.mode === "fix" - ? showCell(c.fix) - : c.mode === "design" && c.input - ? showCell(inputs()[c.input] as Cell) || used - : used; - return el("div", { - className: "m01-easytable__cond", - children: [ - el("span", { className: "m01-easytable__cond-name", text: c.key }), - el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }), - el("span", { - className: "m01-easytable__seg", - children: opts.map(([mode, label]) => { - const b = createButton({ - label, - size: "sm", - variant: c.mode === mode ? "filled" : "ghost", - onClick: () => setMode(c, mode), - }); - b.setAttribute("aria-pressed", String(c.mode === mode)); - return b; - }), - }), - ], - }); - }), - ); - }; - - const paintGrid = (): void => { - const table = tbl[now]; - if (!table) { - grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") })); - return; - } - const st = states[now]; - const conds = Object.keys(table.조건); - const vals = Object.keys(table.값칸); - const hit = last.hits[now]; - const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text }); - const rows = table.줄.map((row, idx) => { - const tr = el("tr", { - className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`, - children: [ - ...conds.map((k) => el("td", { text: showCell(row[k]) })), - ...vals.map((k) => - el("td", { - className: `is-value${k === st.col ? " is-col" : ""}`, - text: showCell(row[k]), - attrs: { "data-col": k }, - }), - ), - ], - }); - tr.addEventListener("click", (ev) => { - const cell = (ev.target as HTMLElement).closest("td"); - pickRow(idx, cell?.dataset.col ?? null); - }); - return tr; - }); - const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k); - const top = [ - ...conds.map((k) => th(k)), - ...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")), - ]; - const thead = el("thead", { children: [el("tr", { children: top })] }); - grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] })); - }; - - const paint = (): void => { - const table = tbl[now]; - which.replaceChildren( - ...(calls.length > 1 - ? calls.map((_c, k) => - createButton({ - label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`, - size: "sm", - variant: k === now ? "filled" : "ghost", - onClick: () => { - now = k; - void refresh(); - }, - }), - ) - : []), - ); - head.replaceChildren( - el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }), - el("span", { className: "m01-easytable__note", text: tt("Hint") }), - ); - paintConds(); - paintHint(); - paintGrid(); - const picked = states[now].picked; - const mismatch = !busy && picked !== null && last.hits[now] !== picked; - warn.textContent = mismatch ? tt("Mismatch") : ""; - const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`); - money.replaceChildren( - el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }), - el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }), - el("span", { - className: "m01-easytable__note", - text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"), - }), - ); - money.classList.toggle("is-busy", busy); - use.disabled = busy || mismatch || !last.ok; - }; - - body.replaceChildren( - which, - head, - condBox, - hint, - grid, - money, - warn, - el("div", { className: "m01-easytable__actions", children: [cancel, use] }), - ); - await refresh(); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Api.ts b/M01_MasterData/LogicBuilder/M01_LB_Api.ts deleted file mode 100644 index 3b63641c6..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Api.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* ============================================================================= - * M01_LB_Api.ts - * 로직 만들기 판 Api — 고친 표로 예시값 계산(0.3초 · 최신만) · 표를 쓰는 로직(사용처) · 표 읽기 - * 서버 = `M01_LB_Calc.py`(`/api/m01/lb/*`) · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` 「맞출 모양」 - * 시험: `resources/tester/test_m01_lb_calc.py` - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { fetchTable, fetchTables } from "../M01_MasterData_Api_Fetch"; -import { ApiError, type CalcAnswer, type LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import { safeReason } from "../M01_MasterData_UI_Logic_Text"; -import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; - -/** 표 키 → 표 사본(고친 값 포함 · 표 통째) — 서버가 메모리에서만 바꿔 끼움 */ -export type LbTables = Record; - -/** 표를 쓰는 로직 한 줄 — `via` = 표 「용도」 에 적힘 · 로직 글에 표 키가 있음 · 둘 다 */ -export interface LbTableUser { - key: string; - 이름: string; - via: "용도" | "글" | "둘"; -} - -/** 표 한 벌 — `file` · `version` 은 표가 든 파일 */ -export interface LbTable { - file: string; - version: string; - table: Record & { 줄: Record[] }; -} - -const CALC_WAIT_MS = 300; -const TABLE_GROUPS = ["소요량", "계수"]; // 표가 드는 그룹(계수 표가 소요량 파일에 든 것도 있음) - -async function send(path: string, body?: unknown): Promise { - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/m01/lb${path}`, { - method: body === undefined ? "GET" : "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch { - throw new ApiError(0, ""); - } - const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); - return data; -} - -/** 로직이 받는 입력값만 — 모르는 이름을 보내면 서버가 멈춤 */ -function inputsFor(row: LogicRow, inputs: Record): Record { - return Object.fromEntries( - (row.입력 ?? []).filter((i) => i.이름 in inputs).map((i) => [i.이름, inputs[i.이름]]), - ); -} - -/** 계산 멈춤 까닭 — 표 키(`QF000421`)가 섞인 글은 뭉뚱그림 */ -function plainStop(reason: string): string { - const plain = safeReason(reason); - return /[A-Z]{2}\d{6}/.test(plain) ? safeReason("{}") : plain; -} - -let calcSeq = 0; -let calcTimer: ReturnType | undefined; - -/** 0.3초 뒤 고친 표로 서버 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ -export function lbCalcLater( - row: LogicRow, - inputs: Record, - tables: LbTables, - onDone: (ans: CalcAnswer | null, plain: string | null) => void, -): void { - const mine = ++calcSeq; - clearTimeout(calcTimer); - calcTimer = setTimeout(() => { - send("/calc", { row, inputs: inputsFor(row, inputs), tables }) - .then((ans) => { - if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(ans.reason)); - }) - .catch((e: unknown) => { - if (mine === calcSeq) onDone(null, plainError(e)); - }); - }, CALC_WAIT_MS); -} - -/** 기다림 없이 바로 셈(기대값 시험표처럼 여러 줄을 차례로) — `lbCalcLater` 의 최신만과 안 걸림. - * 서버에 못 닿으면 null · 멈춤(ok false)은 답 그대로 · 둘 다 `onPlain` 에 쉬운 말 */ -export async function lbCalcNow( - row: LogicRow, - inputs: Record, - tables: LbTables, - onPlain?: (plain: string) => void, -): Promise { - try { - const ans = await send("/calc", { row, inputs: inputsFor(row, inputs), tables }); - if (!ans.ok) onPlain?.(plainStop(ans.reason)); - return ans; - } catch (e: unknown) { - onPlain?.(plainError(e)); - return null; - } -} - -/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */ -export async function tableUsers(key: string): Promise { - const got = await send<{ users: LbTableUser[] }>( - `/table-users?${new URLSearchParams({ key }).toString()}`, - ); - return got.users; -} - -/** 표 키 하나로 표 통째 — 든 파일을 찾아(소요량 · 계수) Api_Fetch 의 표 읽기로 */ -export async function fetchLbTable(key: string): Promise { - for (const group of TABLE_GROUPS) { - const page = await fetchTables(group, "", "", key, 1, 5); - const head = page.tables.find((t) => t.키 === key); - if (head) return { file: head.file, ...(await fetchTable(head.file, key)) }; - } - throw new ApiError(404, `없는 표 「${key}」`); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts b/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts deleted file mode 100644 index 700d95767..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts +++ /dev/null @@ -1,665 +0,0 @@ -/* ============================================================================= - * M01_LB_Blocks.ts - * 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기 - * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로 - * - * 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나 - * 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어 - * ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서 - * 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로) - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond"; -import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model"; -import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table"; - -/** 값이 오는 곳 */ -export type LbRef = - | { k: "input"; name: string } // ① 입력값 - | { k: "fixed"; value: number | string } // 고정값 - | { k: "lookup"; id: string } // ② 표 조회 결과 - | { k: "calc"; name: string } // ③ 이름 붙은 계산값(중간) - | { k: "logic"; key: string; field: string | null } // 다른 로직 결과값(.노무비 등) - | { k: "cost"; name: "노무비" | "재료비" | "경비" | "사람줄합" }; - -/** 블록으로 못 나눈 조각 — 원래 글 그대로 */ -export interface LbExpr { - k: "expr"; - text: string; -} - -/** 식 자리 — 중간 · 호표 수량 · 덧줄 · 결과 · 장비/일위대가 요소 */ -export type LbPart = "중간" | "수량" | "비율" | "결과" | "요소"; - -export interface LbLookup { - /** 화면 번호 · 저장 안 함 */ - id: string; - /** QF000421 · 현장 사본이면 QX… */ - table: string; - conds: { col: string; from: LbRef | LbExpr }[]; - /** 결과 칸 */ - col: string; - at: { part: LbPart; index: number; start: number; end: number }[]; -} - -export type LbCalcKind = - "값" | "곱나눔" | "할증" | "더하기" | "조건목록" | "최소최대" | "올림" | "단계"; - -export interface LbCalc { - /** 중간 이름 · 수량 자리면 null */ - name: string | null; - kind: LbCalcKind; - /** 곱나눔 · 더하기 · 할증(바탕 · 율) */ - parts: (LbRef | LbExpr)[]; - ops?: ("*" | "/" | "+" | "-")[]; - /** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */ - cond?: CondRead; - at: { part: Exclude; index: number }; - /** 원래 글(안 고치면 그대로 씀) */ - text: string; -} - -export interface LbLogic { - /** ④ 항목 · ⑤ 금액 — Model 것 그대로 */ - doc: EasyDoc; - /** ① */ - inputs: LogicInput[]; - /** ② */ - lookups: LbLookup[]; - /** ③ */ - calcs: LbCalc[]; - /** ⑤ */ - result: { kind: "금액"; unit: string } | { kind: "값"; unit: string; calc: LbCalc }; - /** 못 읽은 까닭(쉬운 말) — 비면 다 읽힘 */ - blocked: string[]; -} - -/** 원문 표 칸 고치기(메모리 사본) */ -export interface TableEdit { - table: string; - row: number; - col: string; - value: number | string | [number | null, number | null]; -} - -const TEXT = { - BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"], - CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"], - AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"], - PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"], - Unknown: ["모르는 이름이 있음", "Has an unknown name"], - Result: ["결과값", "Result value"], - Qty: ["수량", "quantity"], -} as const; - -const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -/* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */ - -type Node = { - t: - "num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin"; - v?: string; - f?: string; - op?: string; - a?: Node; - b?: Node; - items?: Node[]; - head?: string; - args?: [string, Node][]; - src: string; -}; - -const CMPS = ["<=", ">=", "==", "!=", "<", ">"]; -const ROUND = ["올림", "버림", "반올림"]; - -function parse(s: string): Node | null { - let i = 0; - const fail = (): never => { - throw new Error("parse"); - }; - const ws = (): void => { - while (i < s.length && /\s/.test(s[i])) i++; - }; - const take = (w: string): boolean => { - ws(); - if (!s.startsWith(w, i)) return false; - i += w.length; - return true; - }; - const need = (w: string): void => void (take(w) || fail()); - const stick = (re: RegExp): string | null => { - re.lastIndex = i; - const m = re.exec(s); - if (m) i = re.lastIndex; - return m ? m[0] : null; - }; - const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail()); - const quoted = (): string => { - const e = s.indexOf("'", i); - if (e < 0) fail(); - const v = s.slice(i, e); - i = e + 1; - return v; - }; - /** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */ - const ref = (head: boolean): string => { - const st = i; - for (let d = 0; i < s.length; i++) { - const c = s[i]; - if ("({".includes(c)) d++; - else if (")}".includes(c)) { - if (!d) break; - d--; - } else if (!d && c === ",") break; - else if (head) continue; - else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break; - else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break; - } - return s.slice(st, i).trim(); - }; - const mk = (st: number, o: Omit): Node => ({ ...o, src: s.slice(st, i).trim() }); - const fields = (st: number, n: Node): Node => { - while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() }); - return n; - }; - const args = (): [string, Node][] => { - const out: [string, Node][] = []; - while (take(",")) { - const k = take("'") ? quoted() : word(); - need("="); - out.push([k, expr()]); - } - need(")"); - return out; - }; - const primary = (): Node => { - ws(); - const st = i; - if (take("(")) { - const a = expr(); - need(")"); - return fields(st, mk(st, { t: "paren", a })); - } - if (take("'")) return mk(st, { t: "str", v: quoted() }); - const n = stick(/\d+(?:\.\d+)?/y); - if (n) return mk(st, { t: "num", v: n }); - const w = word(); - if (w === "찾기" || w === "로직") { - need("("); - ws(); - const head = ref(true); - return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() })); - } - if (["만약", "최소", "최대", ...ROUND].includes(w)) { - need("("); - const items = [expr()]; - while (take(",")) items.push(expr()); - need(")"); - return fields(st, mk(st, { t: "call", f: w, items })); - } - if (s[i] === ":") { - i++; - ref(false); - return fields(st, mk(st, { t: "eref" })); - } - return fields(st, mk(st, { t: "name", v: w })); - }; - const unary = (): Node => { - ws(); - const st = i; - return take("-") ? mk(st, { t: "neg", a: unary() }) : primary(); - }; - const chain = (next: () => Node, ops: string[], once = false): Node => { - ws(); - const st = i; - let n = next(); - for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) - n = mk(st, { t: "bin", op, a: n, b: next() }); - return n; - }; - const mul = (): Node => chain(unary, ["*", "/"]); - const add = (): Node => chain(mul, ["+", "-"]); - const cmp = (): Node => chain(add, CMPS, true); - const both = (): Node => chain(cmp, ["그리고"]); - const expr = (): Node => chain(both, ["또는"]); - try { - const node = expr(); - ws(); - return i === s.length ? node : null; - } catch { - return null; - } -} - -const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n); -const flat = (s: string): string => s.replace(/\s+/g, ""); -const KEY = /^[A-Z]{2}\d{6}$/; - -/* ── 읽기 ───────────────────────────────────────────────────────────────── */ - -interface Ctx { - row: LogicRow; - inputs: Set; - mids: Set; - /** 찾기 글(빈칸 뺌) → 표 조회 번호 */ - finds: Map; - /** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */ - personSum: string | null; - bad: Set; -} - -/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */ -const SRC = new WeakMap(); -const keep = (ref: T, text: string): T => { - SRC.set(ref, { text, snap: JSON.stringify(ref) }); - return ref; -}; - -const isSimple = (n: Node, c: Ctx): boolean => { - const u = un(n); - if (u.t === "num" || u.t === "str") return true; - if (u.t === "neg") return un(u.a!).t === "num"; - return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!)); -}; - -/** 식 나무를 훑어 못 읽는 까닭을 모음 */ -function check(n: Node, c: Ctx): void { - const u = un(n); - if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr")); - if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup")); - if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc")); - if (u.t === "find" || u.t === "logic") return; - if (u.t === "name") { - const v = u.v!; - const known = - c.inputs.has(v) || - c.mids.has(v) || - KEY.test(v) || - ["노무비", "재료비", "경비", "계", "줄"].includes(v); - const item = (c.row.호표 ?? []).some((h) => h.이름 === v); - if (!known && !item) c.bad.add(tl("Unknown")); - } - for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c); -} - -/** 잎 하나 → 값이 오는 곳 */ -function refOf(node: Node, c: Ctx): LbRef | LbExpr { - const u = un(node); - const text = node.src; - if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text); - if (u.t === "neg" && un(u.a!).t === "num") - return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text); - if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text); - if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text); - if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text); - if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!)) - return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text); - if (c.personSum !== null && flat(text) === c.personSum) - return keep({ k: "cost", name: "사람줄합" }, text); - if (u.t === "field" && u.a!.t === "find") { - const id = c.finds.get(flat(u.src)); - if (id) return keep({ k: "lookup", id }, text); - } - if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) { - const call = u.t === "logic" ? u : u.a!; - return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text); - } - return { k: "expr", text }; -} - -const isLeaf = (n: Node): boolean => { - const u = un(n); - return ( - ["num", "str", "name", "field", "logic", "eref"].includes(u.t) || - (u.t === "neg" && un(u.a!).t === "num") - ); -}; - -/** 같은 연산 사슬 — a op b op c … */ -function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } { - const u = un(n); - if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] }; - const left = chainOf(u.a!, ops); - return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] }; -} - -function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc { - const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] }; - const node = parse(text); - if (!node) { - c.bad.add(tl("BadText")); - return { ...base, kind: "단계" }; - } - check(node, c); - const u = un(node); - const inner = (): CondRead | undefined => { - const r = readCond(text, c.row); - return r && r.via === null ? r : undefined; - }; - if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] }; - if (u.t === "call") { - const kind: LbCalcKind = - u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대"; - const cond = inner(); - const whole = - cond && - cond.blocks.length === 1 && - cond.blocks[0].p[0] === 0 && - cond.blocks[0].p[1] >= cond.text.length; - return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond }; - } - if (u.t === "bin" && u.op === "*") { - const r = un(u.b!); - if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") { - const rate = un(r.b!); - if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100") - return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() }; - } - } - for (const [kind, ops] of [ - ["곱나눔", ["*", "/"]], - ["더하기", ["+", "-"]], - ] as const) { - const ch = chainOf(u === node ? node : u, [...ops]); - if (ch.items.length > 1) - return { - ...base, - kind, - parts: ch.items.map((x) => refOf(x, c)), - ops: ch.ops as LbCalc["ops"], - cond: inner(), - }; - } - return { ...base, kind: "단계", cond: inner() }; -} - -/** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */ -function personSumText(row: LogicRow): string | null { - const terms = (row.호표 ?? []) - .filter((h) => h.종류 === "인력") - .map((h) => { - const q = String(h.수량 ?? "").trim(); - return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`; - }); - return terms.length ? `(${terms.join(" + ")})` : null; -} - -/** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */ -function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] { - const ho = row.호표 ?? []; - return [ - ...(row.중간 ?? []).map((m, i) => ({ - part: "중간" as const, - index: i, - text: m.식, - label: m.이름, - })), - ...ho.map((h, i) => ({ - part: "수량" as const, - index: i, - text: String(h.수량 ?? ""), - label: `${h.이름 ?? ""} ${tl("Qty")}`, - })), - ...ho - .map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" })) - .filter( - (p): p is { part: "요소"; index: number; text: string; label: string } => - typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text), - ), - ...(row.덧줄 ?? []).map((d, i) => ({ - part: "비율" as const, - index: i, - text: d.식, - label: d.이름, - })), - ...(row.결과 - ? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }] - : []), - ]; -} - -/** 로직 줄 → 블록 */ -export function readLogic( - row: LogicRow, - meta: { key: string | null; version: string | null; isBase: boolean }, -): LbLogic { - const doc = toDoc(row, meta); - const ps = personSumText(row); - const c: Ctx = { - row, - inputs: new Set((row.입력 ?? []).map((x) => x.이름)), - mids: new Set((row.중간 ?? []).map((x) => x.이름)), - finds: new Map(), - personSum: ps === null ? null : flat(ps), - bad: new Set(), - }; - const lookups: LbLookup[] = []; - const blocked: string[] = []; - const places = placesOf(row); - for (const p of places) { - const calls = readFinds(p.text); - if (!calls) { - blocked.push(`${p.label} — ${tl("BadText")}`); - continue; - } - for (const call of calls) { - const key = flat(p.text.slice(call.start, call.end)); - let id = c.finds.get(key); - if (!id) { - id = `L${lookups.length + 1}`; - c.finds.set(key, id); - const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] }; - look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) })); - ARGS.set(look, call.args); - lookups.push(look); - } - lookups - .find((x) => x.id === id)! - .at.push({ part: p.part, index: p.index, start: call.start, end: call.end }); - } - } - const calcs: LbCalc[] = []; - for (const p of places) { - if (p.part === "요소") { - const n = parse(p.text); - c.bad.clear(); - if (n) check(n, c); - else c.bad.add(tl("BadText")); - for (const b of c.bad) blocked.push(`${p.label} — ${b}`); - continue; - } - c.bad.clear(); - const name = p.part === "중간" ? p.label : null; - calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c)); - for (const b of c.bad) blocked.push(`${p.label} — ${b}`); - } - const resultCalc = calcs.find((x) => x.at.part === "결과"); - return { - doc, - inputs: row.입력 ?? [], - lookups, - calcs, - result: resultCalc - ? { kind: "값", unit: row.결과단위, calc: resultCalc } - : { kind: "금액", unit: row.결과단위 }, - blocked: [...new Set(blocked)], - }; -} - -/** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */ -const ARGS = new WeakMap(); - -function argRef(a: FindArg, c: Ctx): LbRef | LbExpr { - const n = parse(a.rhs); - return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs }; -} - -/* ── 쓰기 ───────────────────────────────────────────────────────────────── */ - -/** 블록 → 로직 줄 */ -export function writeLogic(lb: LbLogic): LogicRow { - return toRow(lb.doc); -} - -/** 단계 안내가 채울 빈 로직 */ -export function newLogic(name = "", per = "㎡"): LbLogic { - return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false }); -} - -const metaOf = (lb: LbLogic) => ({ - key: lb.doc.key, - version: lb.doc.version, - isBase: lb.doc.isBase, -}); - -/** 한 자리 글 읽기 · 쓰기 */ -function textAt(row: LogicRow, part: LbPart, index: number): string { - if (part === "중간") return row.중간[index].식; - if (part === "비율") return row.덧줄![index].식; - if (part === "결과") return row.결과!.식; - const h = row.호표![index]; - return String(part === "수량" ? h.수량 : h.요소); -} - -function putText(row: LogicRow, part: LbPart, index: number, text: string): void { - if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text }; - else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text }; - else if (part === "결과") row.결과 = { ...row.결과!, 식: text }; - else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text }; - else row.호표![index] = { ...row.호표![index], 요소: text }; -} - -/** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */ -function splice( - lb: LbLogic, - edits: { part: LbPart; index: number; start: number; end: number; text: string }[], -): LbLogic { - const row = writeLogic(lb); - const groups = new Map(); - for (const e of edits) - groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]); - for (const list of groups.values()) { - const { part, index } = list[0]; - let text = textAt(row, part, index); - for (const e of [...list].sort((x, y) => y.start - x.start)) - text = text.slice(0, e.start) + e.text + text.slice(e.end); - putText(row, part, index, text); - } - return readLogic(row, metaOf(lb)); -} - -const numText = (n: number): string => { - const t = String(Number(n.toPrecision(12))); - return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; -}; - -/** 조각 → 글 — 안 바뀐 조각은 원래 글 */ -function refText(ref: LbRef | LbExpr, lb: LbLogic): string { - const src = SRC.get(ref); - if (src && src.snap === JSON.stringify(ref)) return src.text; - switch (ref.k) { - case "expr": - return ref.text; - case "input": - case "calc": - return ref.name; - case "fixed": - return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`; - case "cost": - return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name; - case "logic": - return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`; - case "lookup": { - const look = lb.lookups.find((x) => x.id === ref.id); - return look ? findText(look, look.conds, look.table) : "0"; - } - } -} - -/** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */ -function wrap(text: string, strong: boolean): string { - const n = parse(text); - const low = n && n.t === "bin" && ["+", "-"].includes(n.op!); - const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!); - return low || (strong && mid) ? `(${text})` : text; -} - -function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string { - const raw = ARGS.get(look) ?? []; - const args: FindArg[] = conds.map((cd) => { - const old = raw.find((a) => a.key === cd.col); - const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from; - return { - key: cd.col, - rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic), - }; - }); - return writeFind(table, args, look.col); -} - -/** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */ -export function setCond( - lb: LbLogic, - lookupId: string, - col: string, - from: LbRef | LbExpr | null, -): LbLogic { - const look = lb.lookups.find((x) => x.id === lookupId); - if (!look) return lb; - const has = look.conds.some((x) => x.col === col); - const conds = - from === null - ? look.conds.filter((x) => x.col !== col) - : has - ? look.conds.map((x) => (x.col === col ? { col, from } : x)) - : [...look.conds, { col, from }]; - const text = findText(look, conds, look.table); - return splice( - lb, - look.at.map((a) => ({ ...a, text })), - ); -} - -/** 표 조회가 보는 표 바꾸기(현장 사본 등) */ -export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic { - const look = lb.lookups.find((x) => x.id === lookupId); - if (!look) return lb; - const text = findText(look, look.conds, table); - return splice( - lb, - look.at.map((a) => ({ ...a, text })), - ); -} - -/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */ -export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial): LbLogic { - const next: LbCalc = { ...calc, ...patch }; - const old = textAt(writeLogic(lb), calc.at.part, calc.at.index); - const text = patch.text !== undefined ? patch.text : buildText(next, lb, old); - return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]); -} - -function buildText(c: LbCalc, lb: LbLogic, old: string): string { - const parts = c.parts.map((p) => refText(p, lb)); - switch (c.kind) { - case "값": - return parts[0] ?? old; - case "할증": - return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`; - case "곱나눔": - case "더하기": { - const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+")); - const strong = c.kind === "곱나눔"; - return parts.reduce( - (acc, p, i) => - i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false), - "", - ); - } - default: - return c.cond ? writeRead(c.cond) : old; - } -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Calc.py b/M01_MasterData/LogicBuilder/M01_LB_Calc.py deleted file mode 100644 index 471a562f3..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Calc.py +++ /dev/null @@ -1,114 +0,0 @@ -"""로직 만들기 판 시험 길(PLAN 35-2) — 고친 표로 시험 계산 · 표를 쓰는 로직. - - POST /api/m01/lb/calc {row, inputs, tables: {표 키: 표 통째}} → `/calc` 와 같은 답 - GET /api/m01/lb/table-users ?key=표 키 → {key, users: [{key, 이름, via}]} - -계산은 `/calc`(`M01_MasterData_Store.calc`)와 같은 길 — 부를 때마다 새로 읽은 마스터 사본 -(`cm.load`)에 로직 줄 · 표를 바꿔 끼워 셈. 전역 캐시 · 파일은 안 건드림(동시 요청에 안전) · -표 고침은 저장하지 않음(정함 5). 권한 = `/api/m01` 과 같음(등록 자리 `main.py` 한 줄 · -폴더째 지우면 그 줄도 지움). -""" - -from __future__ import annotations - -import json -import re -from typing import Any - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData.M01_MasterData_Router import _num - -cm = store.cm -router = APIRouter(prefix="/api/m01/lb", tags=["M01 LogicBuilder (시험)"]) - - -class LbCalcBody(BaseModel): - row: dict[str, Any] - inputs: dict[str, Any] = {} - tables: dict[str, dict[str, Any]] = {} - - -def _put_tables(files: dict[str, dict], tables: dict[str, dict]) -> None: - """표 키 → 고친 표 — 그 표가 든 파일 사본 자리에 바꿔 끼움 · 키는 못 바꿈 · 없는 표는 404.""" - for key, table in tables.items(): - for data in files.values(): - if data.get("그룹") not in cm.mf.TABLE_GROUPS: - continue - items = data.get("표") or [] - at = next((i for i, t in enumerate(items) if str(t.get("키")) == key), None) - if at is not None: - items[at] = {**store._dec(table), "키": key} - break - else: - raise store.StoreError(404, f"없는 표 「{key}」") - - -def calc(row: dict, inputs: dict, tables: dict[str, dict]) -> dict: - """`Store.calc` 와 같은 셈 + 표 바꿔 끼우기 — 답 모양도 같음.""" - given = {k: store._dec(v) for k, v in inputs.items()} - files = cm.load(folder=store.FOLDER) # 부를 때마다 새 사본 — 캐시 아님 - _put_tables(files, tables) - key = store._swap(files, str(row.get("키") or ""), row, None) - try: - given = cm.mf.fill_empty(cm.mf.Master(files).logic(key), given) - result = cm.calc(files, key, given) - except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: - return {"ok": False, "reason": str(e) or type(e).__name__} - if "결과" in result: - return {"ok": True, "result": result["결과"], "middle": result["중간"]} - return { - "ok": True, - "lines": result["줄"], - "sums": {k: result[k] for k in cm.mf.BUNDLE}, - "middle": result["중간"], - } - - -def table_users(key: str) -> dict: - """표를 쓰는 로직 — 표 「용도.로직키」 + 로직 글에 표 키가 든 것(읽기 전용 캐시에서).""" - files, _ = store.loaded() - table = next( - ( - t - for data in files.values() - if data.get("그룹") in cm.mf.TABLE_GROUPS - for t in data.get("표") or [] - if str(t.get("키")) == key - ), - None, - ) - if table is None: - raise store.StoreError(404, f"없는 표 「{key}」") - listed = {str(k) for k in (table.get("용도") or {}).get("로직키") or []} - named = re.compile(rf"(? dict: - return _call(calc, body.row, body.inputs, body.tables) - - -@router.get("/table-users") -def get_table_users(key: str) -> dict: - return _call(table_users, key) diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts deleted file mode 100644 index 95d28fc75..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Check.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* ============================================================================= - * M01_LB_Check.ts - * 로직 만들기 판 — 검사(정함 7 · 설계초안 4절) - * ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈) - * ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복 - * ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패 - * 시험표 줄은 sessionStorage 초안만(저장 안 함) - * - * 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 · - * 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈. - * DOM 없음. - * ========================================================================== */ - -import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_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]; -export interface CheckTable { - 키?: string; - 범위규칙?: string; - 조건?: Record; - 값칸?: Record; - 줄: Record[]; -} - -/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */ - -const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); - -function match( - table: CheckTable, - row: Record, - cond: string, - want: unknown, -): boolean { - if (!(cond in row)) return true; // 빈 조건 칸 = 무관 - const have = row[cond] as Cell; - if (table.조건?.[cond] === "범위") { - if (!Array.isArray(have) || !isNum(want)) return false; - const [low, high] = have; - const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · "); - const rule = head.split(" (")[0]; - const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low); - if (rule === "이상·이하") return above(false) && (high === null || want <= high); - if (rule === "초과·이하" || rule === "이하") - return above(true) && (high === null || want <= high); - if (rule === "이상·미만") { - const tops = table.줄 - .map((r) => r[cond]) - .filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null) - .map((c) => c[1]); - const closed = - more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops); - return above(false) && (high === null || want < high || (closed && want === high)); - } - return false; // 모르는 범위규칙 — 엔진은 멈춤 - } - if (isNum(have) && isNum(want)) return have === want; - return typeof have === "string" && typeof want === "string" && have === want; -} - -/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */ -export function findRows(table: CheckTable, conds: Record, want = ""): number[] { - let hits = table.줄 - .map((row, i) => ({ row, i })) - .filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v))); - const first = (table.범위규칙 ?? "").split(" · ")[0]; - if (hits.length > 1 && (first === "이하" || first === "이상·이하")) { - for (const c of Object.keys(conds)) { - if (table.조건?.[c] !== "범위") continue; - const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]); - if (!ups.length || ups.includes(null)) continue; - const low = Math.min(...(ups as number[])); - hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low); - } - } - if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row); - return hits.map((h) => h.i); -} - -/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */ - -export interface Gap { - table: string; - /** 어느 표 조회에서 나왔나(화면 번호) */ - lookup: string; - conds: Record; - kind: "없음" | "중복"; - /** 중복이면 걸린 줄 차례 */ - rows: number[]; -} - -/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ -function combos(lb: LbLogic, lk: LbLookup): Record[] | null { - const lists: [string, unknown[]][] = []; - for (const { col, from } of lk.conds) { - if (from.k === "fixed") lists.push([col, [from.value]]); - else if (from.k === "input") { - const spec = lb.inputs.find((x) => x.이름 === from.name); - if (!spec?.고르기?.length) return null; - lists.push([col, spec.고르기]); - } else return null; - } - let out: Record[] = [{}]; - for (const [col, values] of lists) - out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v }))); - return out; -} - -/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ -export function gaps(lb: LbLogic, tables: Record): Gap[] { - const out: Gap[] = []; - const seen = new Set(); - for (const lk of lb.lookups) { - const table = tables[lk.table] as CheckTable | undefined; - const sets = table?.줄 ? combos(lb, lk) : null; - if (!table || !sets) continue; - for (const conds of sets) { - const rows = findRows(table, conds, lk.col); - if (rows.length === 1) continue; - const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`; - if (seen.has(tag)) continue; - seen.add(tag); - out.push({ - table: lk.table, - lookup: lk.id, - conds, - kind: rows.length ? "중복" : "없음", - rows, - }); - } - } - return out; -} - -/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ -export function gapSkipped(lb: LbLogic, tables: Record): string[] { - return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); -} - -/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */ - -/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */ -export type LbHit = number | null | "없음" | "중복"; - -export function lookupHits( - lb: LbLogic, - tables: Record, - values: Record, -): Record { - const out: Record = {}; - for (const lk of lb.lookups) { - const table = tables[lk.table] as CheckTable | undefined; - const conds: Record = {}; - let known = !!table?.줄; - for (const { col, from } of lk.conds) { - if (from.k === "fixed") conds[col] = from.value; - else if (from.k === "input" && values[from.name] !== undefined) - conds[col] = values[from.name]; - else known = false; - } - if (!known || !table) { - out[lk.id] = null; - continue; - } - const rows = findRows(table, conds, lk.col); - out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음"; - } - return out; -} - -/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */ - -export interface ExpectRow { - /** 예시값 */ - inputs: Record; - /** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */ - expect: Record; -} - -export interface ExpectResult { - ok: boolean; - got: Record; - /** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */ - why: string | null; -} - -/** 계산 답 → 볼 값 모음 */ -export function pickValues(ans: CalcAnswer): Record { - const got: Record = {}; - if (!ans.ok) return got; - if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"]; - if (isNum(ans.result)) got["결과"] = ans.result; - for (const line of ans.lines ?? []) { - if (isNum(line.수량)) got[line.이름] = line.수량; - if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액; - } - return got; -} - -/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */ -export function sameAsExpect(got: number | undefined, expect: number): boolean { - if (!isNum(got)) return false; - const places = (String(expect).split(".")[1] ?? "").length; - return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9; -} - -type CalcFn = ( - row: LogicRow, - inputs: Record, - tables: LbTables, -) => Promise; - -/** 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 = calcNow, -): Promise { - const row = writeLogic(lb); - const out: ExpectResult[] = []; - for (const r of rows) { - try { - const ans = await calc(row, r.inputs, tables); - const got = pickValues(ans); - const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v)); - out.push({ ok, got, why: ans.ok ? null : ans.reason }); - } catch (e) { - out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) }); - } - } - return out; -} - -/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */ - -const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`; - -export function loadExpectRows(logic: string): ExpectRow[] { - try { - const raw = sessionStorage.getItem(draftKey(logic)); - const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : []; - return Array.isArray(rows) ? rows : []; - } catch { - return []; - } -} - -export function saveExpectRows(logic: string, rows: ExpectRow[]): void { - try { - if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows)); - else sessionStorage.removeItem(draftKey(logic)); - } catch { - /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */ - } -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css deleted file mode 100644 index 26f9e736e..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.css +++ /dev/null @@ -1,242 +0,0 @@ -/* 로직 만들기 판 — 큰 창 · 시작 화면 · 위 띠 · 세 칸 */ - -/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 · 칸마다 따로 굴림 */ -.ui-modal.m01-lb { - --modal-max: 92vh; - width: min(1680px, 98vw); - height: var(--modal-max); - font-size: var(--text-body); -} - -.m01-lb .ui-modal__body { - flex: 1; - min-height: 0; - gap: var(--spacing-12); -} - -.m01-lb__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-lb__note { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: pre-line; -} - -.m01-lb__note:empty { - display: none; -} - -.m01-lb__big { - min-height: 56px; - padding: 0 var(--spacing-24); - font-size: var(--text-body); -} - -.m01-lb .is-pending { - opacity: 0.45; -} - -/* 시작 화면 */ -.m01-lb__start { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: var(--spacing-24); - min-height: 0; - overflow: auto; -} - -.m01-lb__start-col { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -.m01-lb__start-col h4 { - margin: var(--spacing-8) 0 0; -} - -.m01-lb__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-lb__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - width: 100%; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.m01-lb__item:hover { - border-color: var(--color-accent); -} - -.m01-lb__item-name { - flex: 1; - min-width: 0; -} - -.m01-lb__item-unit { - color: var(--color-text-muted); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -/* 위 띠 */ -.m01-lb__strip { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); - padding-bottom: var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m01-lb__name { - flex: 1 1 280px; -} - -.m01-lb__applied { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -.m01-lb__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; -} - -/* 세 칸 — 원문 · 산출근거 · 결과 */ -.m01-lb__cols { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr); - gap: var(--spacing-12); -} - -.m01-lb__col { - display: flex; - flex-direction: column; - min-height: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); -} - -.m01-lb__col-head { - display: flex; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m01-lb__col-head h4 { - flex: 1; - margin: 0; -} - -.m01-lb__col-body { - flex: 1; - min-height: 0; - overflow: auto; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12); -} - -.m01-lb__col-body h5 { - margin: var(--spacing-8) 0 0; -} - -.m01-lb__total { - font-size: var(--text-subheading); - font-weight: 700; -} - -.m01-lb__examples { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); - border-radius: var(--radius-lg); - background: var(--color-surface); -} - -.m01-lb__example-list { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.m01-lb__example { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - font-size: var(--text-body-sm); -} - -/* 좁은 화면 — 세 칸을 위아래로 */ -@media (max-width: 1100px) { - .m01-lb__cols { - grid-template-columns: minmax(0, 1fr); - 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 deleted file mode 100644 index 792c39700..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts +++ /dev/null @@ -1,693 +0,0 @@ -/* ============================================================================= - * M01_LB_Entry.ts - * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기 - * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid) - * · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본) - * → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내. - * 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createTag, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -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 { 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 = { - Open: ["로직 만들기(시험)", "Logic builder (trial)"], - Title: ["로직 만들기", "Logic builder"], - CloseWindow: ["창 닫기", "Close window"], - Close: ["닫기", "Close"], - Loading: ["불러오는 중", "Loading"], - LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], - Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], - Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], - Start_Found: ["찾은 것", "Found"], - Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], - Start_None: ["찾은 것 없음", "Nothing found"], - Start_Field: ["현장 일위대가", "Field unit prices"], - Start_FieldNone: ["아직 없음", "None yet"], - Start_Wizard: ["단계 안내로 새로 만들기", "Create step by step"], - Standard: ["표준", "Standard"], - Field: ["현장", "Field"], - Per: ["{u} 당", "per {u}"], - Name: ["이름", "Name"], - Applied: ["적용 품셈", "Applied standard"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - CopyEdit: ["복사 편집", "Copy and edit"], - Copying: ["현장 일위대가로 복사하는 중", "Copying to a field unit price"], - Copied: ["현장 일위대가로 복사했음 — 이제 고칠 수 있음", "Copied — you can edit it now"], - Unsaved: ["저장 안 함", "Not saved"], - StandardNote: [ - "표준은 읽기만 — 고치려면 「복사 편집」으로 현장 일위대가를 만듦", - "Standard items are read-only — use “Copy and edit” to make a field copy", - ], - Col_Source: ["원문", "Source"], - Col_Basis: ["산출근거", "Basis"], - Col_Result: ["결과", "Result"], - View_Sentence: ["문장", "Sentences"], - View_Grid: ["격자", "Grid"], - Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], - Value: ["결과 {v} {u}", "Result {v} {u}"], - Sheet: ["일위대가표", "Unit-cost sheet"], - 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?", - ], - Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], -} as const; - -type TextKey = keyof typeof TEXT; - -const te = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -/** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 10) */ -const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; -/** 찾은 것 목록에 한 번에 보이는 줄 수 */ -const SHOWN = 50; - -/** 「원/㎡」 → 「㎡」 */ -const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); -/** 「㎡」 → 「1㎡」 · 「100㎡」 은 그대로 */ -const amountOf = (resultUnit: string): string => { - const unit = unitOf(resultUnit); - return /^\d/.test(unit) ? unit : `1${unit}`; -}; -const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); - -/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */ -interface Paper { - 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 | null, - version: string | null, - isBase: boolean, -): LbLogic | null => { - try { - return readLogic(row, { key, version, isBase }); - } catch { - return null; - } -}; - -/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ -export function createLogicBuilderButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder }); -} - -function openLogicBuilder(): void { - let body: HTMLElement; - /** 세 칸 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; - let logics: EasyListItem[] | null = null; - let query = ""; - let view: View = "sentence"; - - const mayLeave = async (): Promise => - !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - - /** 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(); - } - }; - - /* ---------- 시작 화면 ---------- */ - - const listRow = (item: EasyListItem): HTMLElement => { - const row = el("button", { - className: "m01-lb__item", - attrs: { type: "button", title: item.where }, - children: [ - el("span", { className: "m01-lb__item-name", text: item.name }), - createTag(item.isBase ? te("Standard") : te("Field"), item.isBase ? "neutral" : "accent"), - el("span", { - className: "m01-lb__item-unit", - text: isMoney(item.resultUnit) - ? te("Per", { u: amountOf(item.resultUnit) }) - : item.resultUnit, - }), - ], - }); - row.addEventListener("click", () => void openPaper(item.key)); - return row; - }; - - const showStart = async (): Promise => { - paper = null; - 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" }); - const foundHost = el("div", { className: "m01-lb__list" }); - const search = createInputField({ - type: "search", - placeholder: te("Start_Search"), - value: query, - onInput: (v) => { - query = v; - drawFound(); - }, - }); - - const drawFound = (): void => { - if (!logics) return; - const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - const hits = words.length - ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) - : []; - status.textContent = !words.length - ? "" - : !hits.length - ? te("Start_None") - : hits.length > SHOWN - ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) - : ""; - foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow)); - }; - - body.replaceChildren( - el("div", { - className: "m01-lb__start", - children: [ - el("div", { - className: "m01-lb__start-col", - children: [ - el("h4", { text: te("Start_Trial") }), - trialHost, - search.root, - el("h4", { text: te("Start_Found") }), - status, - foundHost, - ], - }), - el("div", { - className: "m01-lb__start-col", - children: [ - bigButton(te("Start_Wizard"), () => void openNew()), - el("h4", { text: te("Start_Field") }), - fieldHost, - ], - }), - ], - }), - ); - search.input.focus(); - - try { - logics ??= await listEasy(""); - } catch (error) { - status.textContent = te("LoadFailed", { v: plainError(error) }); - return; - } - const byKey = new Map(logics.map((x) => [x.key, x])); - trialHost.replaceChildren( - ...TRIAL_KEYS.flatMap((k) => { - const item = byKey.get(k); - return item ? [listRow(item)] : []; - }), - ); - const field = logics.filter((x) => !x.isBase); - fieldHost.replaceChildren( - ...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]), - ); - drawFound(); - }; - - /* ---------- 세 칸 화면 ---------- */ - - const openPaper = async (key: string): Promise => { - body.replaceChildren(muted(te("Loading"))); - try { - 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 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"); - 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 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 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: 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); - 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 => { - draw(true); - lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => { - ans = next; - note.textContent = plain ?? ""; - 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 (!p.key || busy) return; - busy = true; - note.textContent = te("Copying"); - try { - const saved = await copyEasy(p.key); - logics = null; - showToast(te("Copied"), "success"); - reopen(saved.row, saved.key, saved.version); - } catch (error) { - note.textContent = plainError(error); - } finally { - busy = false; - } - }; - - 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() }), close] - : fieldActions, - }); - - const viewButtons = (["sentence", "grid"] as View[]).map((which) => { - const b = createButton({ - label: te(which === "sentence" ? "View_Sentence" : "View_Grid"), - variant: view === which ? "filled" : "ghost", - size: "sm", - onClick: () => { - view = which; - 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; - }); - - body.replaceChildren( - el("div", { - className: "m01-lb__strip", - children: [ - name.root, - createTag(p.isBase ? te("Standard") : te("Field"), p.isBase ? "neutral" : "accent"), - el("span", { - className: "m01-lb__applied", - text: `${te("Applied")} · ${p.row.출처 || "—"}`, - }), - unsaved, - actions, - ], - }), - note, - ...(p.isBase ? [muted(te("StandardNote"))] : []), - el("div", { - className: "m01-lb__cols", - children: [ - column(te("Col_Source"), [sourceHost]), - 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(); - }; - - // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 - openModal({ - title: te("Title"), - closeLabel: te("CloseWindow"), - dialogClass: "m01-lb", - 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", { - className: "m01-lb__col", - children: [ - el("div", { - className: "m01-lb__col-head", - children: [el("h4", { text: title }), ...tools], - }), - el("div", { className: "m01-lb__col-body", children }), - ], - }); -} - -function bigButton(label: string, onClick: () => void): HTMLButtonElement { - const button = createButton({ label, variant: "filled", onClick }); - button.classList.add("m01-lb__big"); - return button; -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts deleted file mode 100644 index 14c22a068..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* ============================================================================= - * 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(); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.css b/M01_MasterData/LogicBuilder/M01_LB_Grid.css deleted file mode 100644 index 03a75d0cb..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Grid.css +++ /dev/null @@ -1,109 +0,0 @@ -.m01-lbgrid__scroll { - max-width: 100%; - max-height: 70vh; - overflow: auto; -} - -.m01-lbgrid__table { - width: 100%; - min-width: 720px; - border-collapse: separate; - border-spacing: 0; - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01-lbgrid__table th, -.m01-lbgrid__table td { - padding: var(--spacing-4) var(--spacing-8); - border-right: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - text-align: left; - vertical-align: middle; -} - -.m01-lbgrid__table th:first-child, -.m01-lbgrid__table td:first-child { - border-left: 1px solid var(--color-border); -} - -.m01-lbgrid__table thead th { - position: sticky; - top: 0; - z-index: 1; - border-top: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-secondary); - font-weight: 600; - white-space: nowrap; -} - -.m01-lbgrid__row { - cursor: pointer; -} - -.m01-lbgrid__row--first td { - border-top: 2px solid var(--color-border); -} - -.m01-lbgrid__row--sub .m01-lbgrid__name { - padding-left: var(--spacing-16); - color: var(--color-text-secondary); -} - -.m01-lbgrid__step { - color: var(--color-text-secondary); - font-weight: 600; - white-space: nowrap; -} - -.m01-lbgrid__table td.m01-lbgrid__num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.m01-lbgrid__src { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */ -.m01-lbgrid__row--on td { - background: color-mix(in srgb, var(--color-primary) 18%, transparent); - font-weight: 600; -} - -.m01-lbgrid__row--pre td { - background: color-mix(in srgb, var(--color-warning) 28%, transparent); -} - -.m01-lbgrid__row--pre td:first-child { - box-shadow: inset 4px 0 0 var(--color-warning); -} - -.m01-lbgrid__input { - box-sizing: border-box; - width: 96px; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); - text-align: right; -} - -.m01-lbgrid__msg { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m01-lbgrid__note, -.m01-lbgrid__hint { - margin: var(--spacing-4) 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts deleted file mode 100644 index 1932674df..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts +++ /dev/null @@ -1,540 +0,0 @@ -/* ============================================================================= - * M01_LB_Grid.ts - * 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change - * 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값 - * 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만 - * 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; -import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { safeReason } from "../M01_MasterData_UI_Logic_Text"; -import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model"; -import { - setCalc, - setCond, - type LbCalc, - type LbExpr, - type LbLogic, - type LbRef, -} from "./M01_LB_Blocks"; -import "./M01_LB_Grid.css"; - -const TEXT = { - Step1: ["① 입력값", "① Input"], - Step2: ["② 표 조회", "② Table lookup"], - Step3: ["③ 계산", "③ Calculation"], - Step4: ["④ 항목 연결", "④ Items"], - Step5: ["⑤ 결과값", "⑤ Result"], - HStep: ["단계", "Step"], - HName: ["이름", "Name"], - HHow: ["무엇으로", "From"], - HValue: ["값", "Value"], - HUnit: ["단위", "Unit"], - HSource: ["출처", "Source"], - Busy: ["계산 중", "Calculating"], - Stopped: ["계산하지 못했어요", "Could not calculate"], - ReadOnly: [ - "표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요", - "Standard is read-only — use “Copy and edit” first", - ], - Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"], - Typed: ["숫자 칸", "Number"], - Choice: ["고르기", "Choice"], - Range: ["범위", "Range"], - FromInput: ["입력값", "Input"], - FromCalc: ["계산", "Calc"], - FromLookup: ["표 조회", "Lookup"], - FromFixed: ["고정값", "Fixed"], - FromLogic: ["다른 일위대가 결과", "Other unit-price result"], - FromPeople: ["사람 품값 합", "People cost total"], - FromNone: ["무관", "Any"], - Own: ["직접 쓴 계산", "Written by hand"], - Cond: ["조건", "Condition"], - Table: ["표", "Table"], - Number: ["숫자", "Number"], - Calc: ["계산", "Calculation"], - Sum: ["항목 합계", "Item total"], - UseName: ["계산 이름", "Calc name"], - UseQty: ["항목 수량", "Item amount"], - UseRate: ["항목 비율", "Item rate"], - UseResult: ["결과값", "Result"], - Won: ["원", "KRW"], - Unnamed: ["수량 계산", "Amount calc"], - Price: ["단가", "Unit price"], - Result: ["결과", "Result"], - KValue: ["값", "Value"], - KMul: ["곱하기 · 나누기", "Multiply / divide"], - KRaise: ["할증", "Surcharge"], - KAdd: ["더하기", "Add"], - KCond: ["조건 목록", "Conditions"], - KMinMax: ["최소 · 최대", "Min / max"], - KRound: ["올림 · 버림", "Round"], - KSteps: ["여러 단계", "Several steps"], - Person: ["사람", "Person"], - Material: ["재료", "Material"], - Machine: ["기계", "Machine"], - Logic: ["다른 일위대가", "Other unit price"], - Percent: ["덧붙임", "Add-on %"], - Locked: ["그 밖 줄", "Other line"], - Base: ["바탕", "Base"], - Rate: ["율(%)", "Rate (%)"], - First: ["처음", "First"], - Times: ["곱하기", "times"], - Divide: ["나누기", "divided by"], - Plus: ["더하기", "plus"], - Minus: ["빼기", "minus"], -} as const; - -type TextKey = keyof typeof TEXT; -const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex]; - -type Part = Exclude; -type Ref = LbRef | LbExpr; -type Cell = string | HTMLElement; - -interface GridRow { - id: string; - step: string; - sub: boolean; - name: Cell; - how: Cell; - value: Cell; - unit: string; - source: string; - /** 이 줄이 쓰는 줄 번호 */ - uses: string[]; -} - -const KIND_KEY: Record = { - 값: "KValue", - 곱나눔: "KMul", - 할증: "KRaise", - 더하기: "KAdd", - 조건목록: "KCond", - 최소최대: "KMinMax", - 올림: "KRound", - 단계: "KSteps", -}; -const CARD_KEY: Record = { - person: "Person", - material: "Material", - machine: "Machine", - logic: "Logic", - percent: "Percent", - locked: "Locked", -}; -const USE_KEY: Record = { - 중간: "UseName", - 수량: "UseQty", - 비율: "UseRate", - 결과: "UseResult", -}; - -const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -const show = (v: unknown): string => - typeof v === "number" - ? num(v) - : v === undefined || v === null || typeof v === "object" - ? "" - : String(v); - -/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */ -const picked = new WeakMap(); - -/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */ -function mentions(text: string, name: string): boolean { - if (!name) return false; - if (!/^\w+$/.test(name)) return text.includes(name); - return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text); -} - -export interface GridOptions { - /** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */ - trial?: Record; -} - -/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */ -export function renderGrid( - host: HTMLElement, - lb: LbLogic, - ans: CalcAnswer | null, - on: { change(lb: LbLogic): void }, - opts: GridOptions = {}, -): void { - const readOnly = lb.doc.isBase; - const okAns = ans && ans.ok ? ans : null; - const middle = okAns?.middle ?? {}; - const lines = okAns?.lines ?? []; - const msg = el("p", { className: "m01-lbgrid__msg" }); - const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name); - const apply = (make: () => LbLogic): void => { - if (readOnly) return; - msg.textContent = ""; - try { - on.change(make()); - } catch { - msg.textContent = tg("Cant"); - } - }; - - /* ---- 값 · 줄 번호 ---- */ - const rowOf = (r: Ref): string[] => { - if (r.k === "input") return [`in:${r.name}`]; - if (r.k === "lookup") return [`lk:${r.id}`]; - if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : []; - if (r.k === "expr") return textRows(r.text); - return []; - }; - const textRows = (text: string, self = -1): string[] => [ - ...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`), - ...lb.calcs.flatMap((c, i) => - i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [], - ), - ]; - const lookupsAt = (part: Part | "요소", index: number): string[] => - lb.lookups - .filter((l) => l.at.some((a) => a.part === part && a.index === index)) - .map((l) => `lk:${l.id}`); - const lineOf = (card: EasyCard) => - lines.find((l) => l.이름 === card.name && l.단위 === card.unit); - const lookupValue = (id: string): unknown => { - const solo = lb.calcs.find( - (c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id, - ); - if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name]; - const at = lb.lookups - .find((l) => l.id === id) - ?.at.find((a) => a.part === "수량" && a.start === 0); - const card = lb.doc.cards.find( - (c) => c.source?.part === "호표" && c.source.index === at?.index, - ); - const line = card && lineOf(card); - return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : ""; - }; - const valueOf = (r: Ref): unknown => { - if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name]; - if (r.k === "fixed") return r.value; - if (r.k === "calc") return middle[r.name]; - if (r.k === "lookup") return lookupValue(r.id); - if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name]; - return ""; - }; - const unitOf = (r: Ref): string => - r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : ""; - - /* ---- 칸 만들기 ---- */ - const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => { - const box = el("input", { - className: "m01-lbgrid__input", - attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") }, - }); - box.value = show(value); - box.readOnly = readOnly; - box.addEventListener("click", (e) => e.stopPropagation()); - box.addEventListener("change", () => { - const n = Number(box.value.trim()); - if (box.value.trim() === "" || Number.isNaN(n)) { - msg.textContent = tg("Bad"); - box.value = show(value); - return; - } - apply(() => make(n)); - }); - return box; - }; - - const refLabel = (r: Ref): string => { - if (r.k === "input") return `${tg("FromInput")} · ${r.name}`; - if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`; - if (r.k === "lookup") { - const l = lb.lookups.find((x) => x.id === r.id); - return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`; - } - if (r.k === "fixed") return tg("FromFixed"); - if (r.k === "logic") return tg("FromLogic"); - if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name; - return tg("Own"); - }; - const refKey = (r: Ref): string => - r.k === "input" - ? `input:${r.name}` - : r.k === "calc" - ? `calc:${r.name}` - : r.k === "lookup" - ? `lookup:${r.id}` - : r.k === "fixed" - ? "fixed" - : r.k === "logic" - ? `logic:${r.key}` - : r.k === "cost" - ? `cost:${r.name}` - : "expr"; - - /** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */ - const refSelect = ( - cur: Ref, - pool: Ref[], - none: boolean, - pick: (r: Ref | null) => LbLogic, - ): HTMLElement => { - const all = [...pool]; - if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur); - const options = [ - ...(none ? [{ value: "none", text: tg("FromNone") }] : []), - ...all.map((r) => ({ value: refKey(r), text: refLabel(r) })), - ]; - const field = createSelectField({ - options, - value: refKey(cur), - compact: true, - ariaLabel: refLabel(cur), - disabled: readOnly || cur.k === "expr", - onChange: (v) => { - if (v === refKey(cur)) return; - apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur))); - }, - }); - field.root.addEventListener("click", (e) => e.stopPropagation()); - return field.root; - }; - const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 })); - const calcRefs = (before: number): Ref[] => - lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : [])); - const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id })); - - /* ---- 줄 모으기 ---- */ - const rows: GridRow[] = []; - const add = (r: Partial & { id: string; name: Cell }): GridRow => { - const row: GridRow = { - step: "", - sub: false, - how: "", - value: "", - unit: "", - source: "", - uses: [], - ...r, - }; - rows.push(row); - return row; - }; - - lb.inputs.forEach((inp, i) => { - add({ - id: `in:${inp.이름}`, - step: i === 0 ? tg("Step1") : "", - name: inp.이름, - how: inp.고르기?.length ? tg("Choice") : tg("Typed"), - value: show(valueOf({ k: "input", name: inp.이름 })), - unit: inp.단위 ?? "", - source: inp.고르기?.length - ? inp.고르기.join(" · ") - : inp.범위 - ? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}` - : "", - }); - }); - - lb.lookups.forEach((lk, i) => { - const head = add({ - id: `lk:${lk.id}`, - step: i === 0 ? tg("Step2") : "", - name: lk.col, - how: tg("FromLookup"), - value: show(lookupValue(lk.id)), - source: `${tg("Table")} ${lk.table}`, - }); - lk.conds.forEach((cd) => { - const from = cd.from; - const fixedNum = from.k === "fixed" && typeof from.value === "number"; - const subRow = add({ - id: `lc:${lk.id}:${cd.col}`, - sub: true, - name: `${tg("Cond")} · ${cd.col}`, - how: refSelect( - from, - [ - ...inputRefs, - ...calcRefs(lb.calcs.length).filter((r) => { - const c = lb.calcs.find((x) => x.name === (r as { name: string }).name); - return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기("); - }), - ], - true, - (r) => setCond(lb, lk.id, cd.col, r), - ), - value: - fixedNum && from.k === "fixed" - ? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n })) - : show(valueOf(from)), - unit: unitOf(from), - uses: rowOf(from), - }); - head.uses.push(...subRow.uses); - }); - }); - - lb.calcs.forEach((c, i) => { - const first = i === 0; - const own = lookupsAt(c.at.part, c.at.index); - const head = add({ - id: `ca:${i}`, - step: first ? tg("Step3") : "", - name: c.name ?? `${tg("Unnamed")} ${i + 1}`, - how: tg(KIND_KEY[c.kind]), - value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""), - source: tg(USE_KEY[c.at.part]), - uses: [...textRows(c.text, i), ...own], - }); - if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return; - c.parts.forEach((p, pi) => { - const label = - c.kind === "할증" - ? tg(pi === 0 ? "Base" : "Rate") - : c.kind === "값" - ? tg("KValue") - : pi === 0 - ? tg("First") - : tg( - ({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[ - c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*") - ], - ); - const fixedNum = p.k === "fixed" && typeof p.value === "number"; - const setPart = (r: Ref | null): LbLogic => { - const parts = c.parts.slice(); - if (r) parts[pi] = r; - return setCalc(lb, c, { parts }); - }; - const sub = add({ - id: `cp:${i}:${pi}`, - sub: true, - name: label, - how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart), - value: - fixedNum && p.k === "fixed" - ? numberCell(p.value, (n) => setPart({ k: "fixed", value: n })) - : show(valueOf(p)), - unit: unitOf(p), - uses: rowOf(p), - }); - head.uses.push(...sub.uses); - }); - }); - - lb.doc.cards.forEach((card, i) => { - const sp = card.source; - const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"]; - const viaLookup = sp - ? parts - .flatMap((p) => lookupsAt(p, sp.index)) - .concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : []) - : []; - const viaCalc = sp - ? lb.calcs.flatMap((c, ci) => - parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [], - ) - : []; - const line = lineOf(card); - const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc"); - add({ - id: `it:${card.id}`, - step: i === 0 ? tg("Step4") : "", - name: card.spec ? `${card.name} ${card.spec}` : card.name, - how: tg(CARD_KEY[card.kind]), - value: - card.qty !== null - ? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) })) - : show(line?.수량), - unit: card.unit, - source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`, - uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])], - }); - }); - - const res = lb.result; - add({ - id: "res", - step: tg("Step5"), - name: tg(res.kind === "금액" ? "Price" : "Result"), - how: res.kind, - value: - res.kind === "금액" - ? okAns - ? formatMoney(okAns.sums?.["계"] ?? 0) - : "" - : show(okAns?.result), - unit: res.unit, - source: res.kind === "금액" ? tg("Sum") : tg("Calc"), - uses: - res.kind === "금액" - ? lb.doc.cards.map((c) => `it:${c.id}`) - : [ - ...textRows(res.calc.text), - ...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])), - ], - }); - - /* ---- 그리기 ---- */ - const tbody = el("tbody"); - const paint = (): void => { - const sel = picked.get(host); - const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []); - tbody.querySelectorAll("tr").forEach((tr) => { - const id = tr.dataset.row ?? ""; - tr.classList.toggle("m01-lbgrid__row--on", id === sel); - tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id)); - }); - }; - const td = (c: Cell, className = ""): HTMLElement => - el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) }); - rows.forEach((r) => { - const tr = el("tr", { - className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`, - attrs: { "data-row": r.id }, - children: [ - td(r.step, "m01-lbgrid__step"), - td(r.name, "m01-lbgrid__name"), - td(r.how), - td(r.value, "m01-lbgrid__num"), - td(r.unit), - td(r.source, "m01-lbgrid__src"), - ], - }); - tr.addEventListener("click", () => { - if (picked.get(host) === r.id) picked.delete(host); - else picked.set(host, r.id); - paint(); - }); - tbody.append(tr); - }); - const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"]; - const status = - ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy"); - host.replaceChildren( - ...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []), - ...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []), - el("div", { - className: "m01-lbgrid__scroll", - children: [ - el("table", { - className: "m01-lbgrid__table", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })], - }), - tbody, - ], - }), - ], - }), - msg, - el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }), - ); - paint(); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.css b/M01_MasterData/LogicBuilder/M01_LB_Source.css deleted file mode 100644 index 0d90c4871..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Source.css +++ /dev/null @@ -1,196 +0,0 @@ -/* 로직 만들기 판 — 왼쪽 원문 */ -.m01-lbsrc { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 0; - font-size: var(--text-body-sm); -} - -.m01-lbsrc p { - margin: 0; -} - -.m01-lbsrc__muted { - color: var(--color-text-muted); -} - -.m01-lbsrc__muted:empty { - display: none; -} - -.m01-lbsrc__preview { - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas)); - color: var(--color-text); -} - -.m01-lbsrc__table { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - min-width: 0; -} - -.m01-lbsrc__head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); -} - -.m01-lbsrc__users { - margin-left: auto; - padding: 0; - border: 0; - background: none; - color: var(--color-accent); - font: inherit; - cursor: pointer; - text-decoration: underline; -} - -.m01-lbsrc__warn { - color: var(--color-danger); - font-weight: 600; -} - -/* 엑셀 같은 격자 */ -.m01-lbsrc__grid { - max-height: 22rem; - overflow: auto; - border: 1px solid var(--color-border); -} - -.m01-lbsrc__grid table { - border-collapse: collapse; - width: 100%; -} - -.m01-lbsrc__grid th, -.m01-lbsrc__grid td { - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - white-space: nowrap; - text-align: left; -} - -.m01-lbsrc__grid thead th { - position: sticky; - top: 0; - z-index: 1; - background: var(--color-paper); - color: var(--color-text-secondary); - font-weight: 500; - vertical-align: top; -} - -.m01-lbsrc__grid thead th span { - display: block; -} - -.m01-lbsrc__grid .m01-lbsrc__no { - width: 2.5rem; - background: var(--color-paper); - color: var(--color-text-muted); - text-align: right; -} - -.m01-lbsrc__grid td { - cursor: cell; -} - -.m01-lbsrc__grid td.is-value { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.m01-lbsrc__grid td.is-col { - font-weight: 700; -} - -.m01-lbsrc__grid tr.is-hit td { - background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); -} - -.m01-lbsrc__grid td.is-edited { - box-shadow: inset 0 0 0 2px var(--color-accent); -} - -.m01-lbsrc__grid td.is-bad { - box-shadow: inset 0 0 0 2px var(--color-danger); -} - -.m01-lbsrc__input { - width: 100%; - min-width: 4rem; - padding: 0 var(--spacing-4); - border: 1px solid var(--color-accent); - font: inherit; - background: var(--color-canvas); - color: var(--color-text); -} - -/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */ -.m01-lbsrc__chip { - margin-top: var(--spacing-4); - padding: 0 var(--spacing-8); - border: 1px solid currentColor; - border-radius: 999px; - background: transparent; - font: inherit; - font-size: var(--text-caption); - cursor: pointer; -} - -.m01-lbsrc__chip.is-cond { - color: var(--color-accent); - background: color-mix(in srgb, var(--color-accent) 12%, transparent); -} - -.m01-lbsrc__chip.is-fixed { - color: var(--color-text-secondary); -} - -.m01-lbsrc__chip.is-result { - color: var(--color-success, #2f6b4f); - background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent); -} - -.m01-lbsrc__chip.is-free { - color: var(--color-text-muted); - border-style: dashed; -} - -.m01-lbsrc__notes summary { - cursor: pointer; - color: var(--color-text-secondary); -} - -.m01-lbsrc__notes ol { - margin: var(--spacing-4) 0 0; - padding-left: var(--spacing-16); - list-style: none; - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-lbsrc__notes li.is-used { - font-weight: 600; -} - -.m01-lbsrc__tag { - margin-left: var(--spacing-8); - padding: 0 var(--spacing-4); - border-radius: 4px; - background: color-mix(in srgb, var(--color-accent) 14%, transparent); - color: var(--color-accent); - font-size: var(--text-caption); - font-weight: 500; -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.ts b/M01_MasterData/LogicBuilder/M01_LB_Source.ts deleted file mode 100644 index 8a16e89a9..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Source.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* ============================================================================= - * M01_LB_Source.ts - * 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절) - * 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과) - * · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처. - * 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import { tableUsers, type LbTables } from "./M01_LB_Api"; -import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks"; -import type { LbHit } from "./M01_LB_Check"; -import "./M01_LB_Source.css"; - -const TEXT = { - Preview: [ - "표 고침은 미리 보기 — 저장은 결정 뒤", - "Table edits are a preview — saving comes later", - ], - Users: ["사용처", "Used by"], - UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"], - UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"], - UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"], - Loading: ["불러오는 중", "Loading"], - Missing: ["표를 불러오지 못함", "Couldn't load the table"], - NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"], - DupRow: [ - "중복 — 예시값에 맞는 줄이 둘 이상", - "Duplicate — more than one row fits the sample values", - ], - Notes: ["주", "Notes"], - NoteUsed: ["계산에 쓰임", "Used in the calculation"], - Cond: ["조건", "Condition"], - Fixed: ["고정", "Fixed"], - Result: ["결과", "Result"], - Free: ["무관", "Any"], - RoleHint: ["눌러 역할 바꿈", "Tap to change the role"], - EditHint: [ - "칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소", - "Tap a cell to edit · Enter to finish · Esc to cancel", - ], - Edited: ["고친 칸", "Edited cell"], -} as const; - -const ts = (key: keyof typeof TEXT, n?: number): string => - TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? "")); - -export type LbRole = "조건" | "고정" | "결과"; -type Cell = string | number | boolean | null | [number | null, number | null] | undefined; - -interface SourceTable { - 키?: string; - 이름?: string; - 원문번호?: string; - 기준?: string; - 범위규칙?: string; - 조건?: Record; - 값칸?: Record; - 주?: string[]; - 용도?: { 로직키?: string[] }; - 줄: Record[]; -} - -export interface SourceOn { - edit(e: TableEdit): void; - role(table: string, col: string, role: LbRole): void; -} - -/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */ - -const isRange = (v: unknown): v is [number | null, number | null] => - Array.isArray(v) && v.length === 2; - -/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */ -export function showCell(v: Cell): string { - if (v === null || v === undefined) return ""; - if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; - return String(v); -} - -/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */ -export function parseCell(text: string, before: Cell): TableEdit["value"] | null { - const t = text.trim(); - const num = (s: string): number | null | undefined => { - const c = s.replace(/,/g, "").trim(); - if (!c) return null; - return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined; - }; - if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) { - const parts = t.split(/[~~]/); - if (parts.length !== 2) return null; - const [a, b] = parts.map(num); - if (a === undefined || b === undefined || (a === null && b === null)) return null; - if (a !== null && b !== null && a > b) return null; - return [a, b]; - } - if (!t) return null; - const n = num(t); - if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) { - return typeof n === "number" ? n : null; - } - return t; -} - -/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */ - -/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */ -export function roleOf(lookups: LbLookup[], col: string): LbRole | null { - for (const lk of lookups) { - if (lk.col === col) return "결과"; - const c = lk.conds.find((x) => x.col === col); - if (c) return c.from.k === "fixed" ? "고정" : "조건"; - } - return null; -} - -const NEXT: Record = { 조건: "고정", 고정: "결과", 결과: "조건" }; - -/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */ -export function usedNotes(row: unknown): Map> { - const out = new Map>(); - const add = (where: string, mark: string): void => { - const set = out.get(where) ?? new Set(); - set.add(mark); - out.set(where, set); - }; - const walk = (v: unknown): void => { - if (typeof v === "string") { - for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]); - } else if (Array.isArray(v)) v.forEach(walk); - else if (v && typeof v === "object") Object.values(v).forEach(walk); - }; - walk(row); - return out; -} - -/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */ -function hitsOf( - key: string, - lookups: LbLookup[], - hits: Record, -): { rows: Set; none: boolean; dup: boolean } { - const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null); - return { - rows: new Set(got.filter((h): h is number => typeof h === "number")), - none: got.includes("없음"), - dup: got.includes("중복"), - }; -} - -/* ── 그리기 ─────────────────────────────────────────────────────── */ - -/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */ -const originals = new Map(); - -function snapshot(key: string, table: SourceTable, cols: string[]): string[][] { - const had = originals.get(key); - if (had && had.length === table.줄.length) return had; - const shot = table.줄.map((r) => cols.map((c) => showCell(r[c]))); - originals.set(key, shot); - return shot; -} - -function roleChip( - key: string, - col: string, - role: LbRole | null, - isCond: boolean, - on: SourceOn, -): HTMLElement { - const shown = role ?? (isCond ? null : undefined); - if (shown === undefined) return el("span"); - const label = shown - ? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result") - : ts("Free"); - const chip = el("button", { - className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`, - text: label, - attrs: { type: "button", title: ts("RoleHint") }, - }); - chip.addEventListener("click", (ev) => { - ev.stopPropagation(); - on.role(key, col, shown ? NEXT[shown] : "조건"); - }); - return chip; -} - -function editCell( - td: HTMLTableCellElement, - before: Cell, - done: (v: TableEdit["value"]) => void, -): void { - if (td.querySelector("input")) return; - const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } }); - input.value = showCell(before); - const text = td.textContent ?? ""; - td.replaceChildren(input); - input.focus(); - input.select(); - let closed = false; - const close = (save: boolean): void => { - if (closed) return; - closed = true; - const value = save ? parseCell(input.value, before) : null; - const same = value !== null && showCell(value) === showCell(before); - td.textContent = value === null || same ? text : showCell(value); - td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text); - if (value !== null && !same) done(value); - }; - input.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") close(true); - if (ev.key === "Escape") close(false); - ev.stopPropagation(); - }); - input.addEventListener("blur", () => close(true)); -} - -function renderTable( - key: string, - table: SourceTable | undefined, - lb: LbLogic, - hits: Record, - notes: Map>, - on: SourceOn, -): HTMLElement { - const lookups = lb.lookups.filter((lk) => lk.table === key); - if (!table?.줄) { - return el("section", { - className: "m01-lbsrc__table", - children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })], - }); - } - const conds = Object.keys(table.조건 ?? {}); - const vals = Object.keys(table.값칸 ?? {}); - const cols = [...conds, ...vals]; - const orig = snapshot(key, table, cols); - const hit = hitsOf(key, lookups, hits); - - const users = (table.용도?.로직키 ?? []).length; - const userBtn = el("button", { - className: "m01-lbsrc__users", - text: users ? ts("UsersN", users) : ts("UsersNone"), - attrs: { type: "button" }, - }); - const userList = el("p", { className: "m01-lbsrc__muted" }); - userBtn.addEventListener("click", () => { - userList.textContent = ts("Loading"); - tableUsers(key) - .then((list) => { - userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone"); - userList.textContent = list.map((u) => u.이름).join(" · "); - }) - .catch(() => (userList.textContent = ts("UsersFail"))); - }); - - const head = el("tr", { - children: [ - el("th", { className: "m01-lbsrc__no", text: "" }), - ...cols.map((c) => { - const unit = table.값칸?.[c]; - return el("th", { - className: vals.includes(c) ? "is-value" : "", - children: [ - el("span", { text: unit ? `${c} (${unit})` : c }), - roleChip(key, c, roleOf(lookups, c), conds.includes(c), on), - ], - }); - }), - ], - }); - const body = table.줄.map((row, i) => { - const tr = el("tr", { - className: hit.rows.has(i) ? "is-hit" : "", - children: [ - el("td", { className: "m01-lbsrc__no", text: String(i + 1) }), - ...cols.map((c, j) => { - const td = el("td", { - className: [ - vals.includes(c) ? "is-value" : "", - lookups.some((lk) => lk.col === c) ? "is-col" : "", - showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "", - ].join(" "), - text: showCell(row[c]), - }); - td.addEventListener("click", () => - editCell(td, row[c], (value) => { - td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]); - on.edit({ table: key, row: i, col: c, value }); - }), - ); - return td; - }), - ], - }); - return tr; - }); - - const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]); - const noteList = (table.주 ?? []).map((n) => { - const used = marks.has(n.trim().charAt(0)); - return el("li", { - className: used ? "is-used" : "", - children: [ - el("span", { text: n }), - ...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []), - ], - }); - }); - - return el("section", { - className: "m01-lbsrc__table", - children: [ - el("div", { - className: "m01-lbsrc__head", - children: [ - el("strong", { text: table.이름 ?? "" }), - el("span", { - className: "m01-lbsrc__muted", - text: [table.원문번호, table.기준].filter(Boolean).join(" · "), - }), - userBtn, - ], - }), - userList, - ...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []), - ...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []), - el("div", { - className: "m01-lbsrc__grid", - children: [ - el("table", { - children: [el("thead", { children: [head] }), el("tbody", { children: body })], - }), - ], - }), - ...(noteList.length - ? [ - el("details", { - className: "m01-lbsrc__notes", - attrs: marks.size ? { open: "" } : {}, - children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })], - }), - ] - : []), - ], - }); -} - -/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */ -export function renderSource( - host: HTMLElement, - lb: LbLogic, - tables: LbTables, - hits: Record, - on: SourceOn, -): void { - const keys = [...new Set(lb.lookups.map((lk) => lk.table))]; - const notes = usedNotes(lb.doc?.row); - host.replaceChildren( - el("div", { - className: "m01-lbsrc", - children: [ - el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }), - el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }), - ...keys.map((k) => - renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on), - ), - ], - }), - ); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.css b/M01_MasterData/LogicBuilder/M01_LB_Steps.css deleted file mode 100644 index 6ae219619..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps.css +++ /dev/null @@ -1,201 +0,0 @@ -.m01-lbsteps { - display: flex; - flex-direction: column; - gap: var(--spacing-16); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m01-lbsteps-banner { - margin: 0; - padding: var(--spacing-8) var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - font-weight: var(--font-weight-medium); -} - -.m01-lbsteps-banner.is-warn { - border-left: 4px solid var(--color-warning); -} - -.m01-lbsteps-banner p { - margin: 0; -} - -.m01-lbsteps-why { - color: var(--color-text-secondary); - font-weight: var(--font-weight-regular); -} - -.m01-lbsteps-sec { - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - padding: var(--spacing-8) var(--spacing-16) var(--spacing-16); -} - -.m01-lbsteps-title { - margin: 0 0 var(--spacing-8); - font-size: var(--text-subheading); - font-weight: var(--font-weight-bold); -} - -.m01-lbsteps-list, -.m01-lbsteps-branches { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - margin: 0; - padding: 0; - list-style: none; -} - -.m01-lbsteps-row { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding-top: var(--spacing-8); - border-top: 1px solid var(--color-border); -} - -.m01-lbsteps-row:first-child { - padding-top: 0; - border-top: 0; -} - -.m01-lbsteps-sentence { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4) var(--spacing-8); - min-width: 0; -} - -.m01-lbsteps-ref, -.m01-lbsteps-cond, -.m01-lbsteps-chips { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4); -} - -.m01-lbsteps-shapes, -.m01-lbsteps-shape { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding-left: var(--spacing-16); - border-left: 2px solid var(--color-border); -} - -.m01-lbsteps-branch { - padding-left: var(--spacing-8); -} - -.m01-lbsteps-order { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.6em; - height: 1.6em; - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - font-weight: var(--font-weight-bold); -} - -.m01-lbsteps-name, -.m01-lbsteps-tag { - font-weight: var(--font-weight-bold); -} - -.m01-lbsteps-chip { - display: inline-block; - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - background: var(--color-surface-raised); -} - -.m01-lbsteps-note { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-lbsteps-value, -.m01-lbsteps-sum { - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - white-space: nowrap; -} - -.m01-lbsteps-amount, -.m01-lbsteps-total { - margin-left: auto; - font-weight: var(--font-weight-bold); - white-space: nowrap; -} - -.m01-lbsteps-amount.is-wait { - opacity: 0.5; - font-weight: var(--font-weight-regular); -} - -.m01-lbsteps-pick { - min-height: 44px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: inherit; - font: inherit; - font-weight: var(--font-weight-medium); - cursor: pointer; -} - -.m01-lbsteps-pick:focus-visible { - outline: 2px solid var(--color-focus-ring); -} - -.m01-lbsteps .ui-input, -.m01-lbsteps .ui-select, -.m01-lbsteps .ui-btn { - min-height: 44px; - font-size: var(--text-body); -} - -.m01-lbsteps-num { - flex: none; - width: 6em; -} - -.m01-lbsteps-text { - flex: none; - width: 9em; -} - -.m01-lbsteps-select { - flex: none; - width: auto; - max-width: 100%; -} - -.m01-lbsteps .ui-input.is-bad { - border-color: var(--color-danger); - border-style: dashed; -} - -.m01-lbsteps-add { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - margin-top: var(--spacing-8); -} - -@media (max-width: 640px) { - .m01-lbsteps-amount, - .m01-lbsteps-total { - margin-left: 0; - } -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts deleted file mode 100644 index 0dca8c47b..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts +++ /dev/null @@ -1,392 +0,0 @@ -/* ============================================================================= - * M01_LB_Steps.ts - * 로직 만들기 판 가운데 문장 보기 — ① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값 - * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · `로직판_A_설계초안.md` 7-1 그대로 · 모두 고르기 · 숫자 칸 · 바뀐 lb 는 on.change 로만 알림 - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { - fetchLogics, - type CalcAnswer, - type CalcLine, - type LogicInput, -} from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { - addCard, - addPercent, - removeCard, - updateCard, - withInputs, - type CostKind, - type EasyCard, - type PickResult, -} from "../EasyCalc/M01_EasyCalc_Model"; -import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; -import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Blocks"; -import { calcBody, calcView } from "./M01_LB_Steps_Calc"; -import { - chip, - logicNames, - lookupValue, - note, - numBox, - parseNum, - refText, - selectBox, - sentence, - span, - textBox, - tx, - valueNote, - type Ctx, - type TEXT, -} from "./M01_LB_Steps_Cell"; -import "./M01_LB_Steps.css"; - -type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; - -let picking = false; -let namesLoading = false; - -/* ------------------------------ ① 입력값 ------------------------------ */ - -function inputView(ctx: Ctx, input: LogicInput, at: number): HTMLElement { - const put = (patch: Partial): void => - ctx.commit(() => { - const inputs = ctx.lb.inputs.map((x, i) => (i === at ? { ...x, ...patch } : x)); - return { ...ctx.lb, inputs, doc: { ...ctx.lb.doc, inputs } }; - }); - const parts: (HTMLElement | string)[] = [span("m01-lbsteps-name", input.이름)]; - if (input.고르기) { - parts.push( - note(tx("PickList")), - el("span", { - className: "m01-lbsteps-chips", - children: input.고르기.map((v) => chip(String(v))), - }), - ); - } else if (input.범위) { - const [lo, hi] = input.범위; - parts.push( - note(tx("Range")), - numBox(ctx, lo, (n) => put({ 범위: [n, hi] })), - "~", - numBox(ctx, hi, (n) => put({ 범위: [lo, n] })), - ); - } else parts.push(note(tx("Number"))); - parts.push( - note(tx("Unit")), - textBox(ctx, input.단위 ?? "", (v) => put({ 단위: v })), - ); - return el("li", { className: "m01-lbsteps-row", children: [sentence(...parts)] }); -} - -/* ------------------------------ ② 표 조회 ------------------------------ */ - -function condView(ctx: Ctx, lk: LbLookup, cond: LbLookup["conds"][number]): HTMLElement { - const from = cond.from; - const set = (next: LbRef | null): void => - ctx.commit(() => setCond(ctx.lb, lk.id, cond.col, next)); - const parts: (HTMLElement | string)[] = [chip(cond.col), tx("LkIs")]; - if (from.k === "input" || from.k === "fixed") { - const options = [ - ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), - { value: "f", text: tx("SrcFixed") }, - { value: "n", text: tx("SrcAny") }, - ]; - const current = from.k === "input" ? `i:${from.name}` : "f"; - parts.push( - selectBox( - ctx, - options, - current, - (v) => - set( - v === "n" - ? null - : v === "f" - ? { k: "fixed", value: "" } - : { k: "input", name: v.slice(2) }, - ), - cond.col, - ), - ); - if (from.k === "fixed") { - parts.push( - textBox(ctx, String(from.value), (v) => set({ k: "fixed", value: parseNum(v) ?? v })), - ); - } - } else parts.push(chip(refText(ctx, from))); - return el("span", { className: "m01-lbsteps-cond", children: parts }); -} - -function lookupView(ctx: Ctx, lk: LbLookup, n: number): HTMLElement { - const name = ctx.on.tableName?.(lk.table) ?? lk.table; - return el("li", { - className: "m01-lbsteps-row", - children: [ - sentence( - span("m01-lbsteps-name", `${tx("LkNum")}${n}`), - chip(name), - tx("LkFrom"), - ...lk.conds.map((c) => condView(ctx, lk, c)), - tx("LkRow"), - chip(lk.col), - valueNote(lookupValue(ctx, lk)), - ), - ], - }); -} - -/* ------------------------------ ④ 항목 연결 ------------------------------ */ - -const BASES: (CostKind | "사람줄합")[] = ["노무비", "재료비", "경비", "사람줄합"]; - -function lineMap(ctx: Ctx): Map { - const out = new Map(); - const ans = ctx.ans; - if (!ans || !ans.ok || !ans.lines) return out; - const cards = ctx.lb.doc.cards; - const part = (c: EasyCard): string => - c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; - const ordered = [ - ...cards.filter((c) => part(c) === "호표"), - ...cards.filter((c) => part(c) === "덧줄"), - ]; - if (ordered.length === ans.lines.length) ordered.forEach((c, i) => out.set(c.id, ans.lines![i])); - return out; -} - -const patchItem = (ctx: Ctx, id: string, patch: Partial): void => - ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) })); - -async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise { - if (picking) return; - picking = true; - try { - const pick = await openEasyPick(kind); - if (!pick) return; - ctx.commit(() => { - if (!card) return { ...ctx.lb, doc: addCard(ctx.lb.doc, pick) }; - const next = updateCard(ctx.lb.doc, card.id, { - name: pick.name, - spec: pick.spec, - unit: pick.unit, - element: pick.element, - }); - return { ...ctx.lb, doc: withInputs(next, pick.needInputs ?? []) }; - }); - } finally { - picking = false; - } -} - -function itemView(ctx: Ctx, card: EasyCard): HTMLElement { - const kind = card.kind as Kind; - const percent = kind === "percent"; - const part = card.source?.part === "호표" ? "수량" : "비율"; - const calc = card.source - ? ctx.lb.calcs.find((c) => c.at.part === part && c.at.index === card.source?.index) - : undefined; - const line = ctx.lines.get(card.id); - const qty: HTMLElement = calc - ? el("span", { - className: "m01-lbsteps-ref", - children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)], - }) - : card.qtyFormula === null - ? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n })) - : chip(tx("Hard")); - const name = - ctx.ro || kind === "locked" || percent - ? span("m01-lbsteps-name", [card.name, card.spec].filter(Boolean).join(" ")) - : (() => { - const b = el("button", { - className: "m01-lbsteps-pick", - text: `${[card.name, card.spec].filter(Boolean).join(" ")} ▾`, - attrs: { type: "button", "aria-label": tx("Repick") }, - }); - b.addEventListener("click", () => void pickItem(ctx, kind as PickResult["kind"], card)); - return b; - })(); - const head: (HTMLElement | string)[] = [span("m01-lbsteps-tag", `【${tx(`Tag_${kind}`)}】`)]; - if (percent) { - const options = BASES.map((v) => ({ value: v, text: tx(`Base_${v}`) })); - head.push( - selectBox( - ctx, - options, - card.percentBase ?? "노무비", - (v) => patchItem(ctx, card.id, { percentBase: v as EasyCard["percentBase"] }), - tx("S4"), - ), - tx("Pre_percent"), - qty, - tx("Post_percent"), - name, - ); - } else { - head.push( - name, - kind === "person" ? tx("Pre_person") : tx("Pre_pick"), - qty, - span("m01-lbsteps-unit", ` ${card.unit}`), - ); - } - head.push( - line - ? span("m01-lbsteps-amount", `= ${formatMoney(line.금액)} ${tx("Won")}`) - : span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`), - ); - if (!ctx.ro) { - head.push( - createButton({ - label: tx("Remove"), - variant: "ghost", - size: "sm", - onClick: () => ctx.commit(() => ({ ...ctx.lb, doc: removeCard(ctx.lb.doc, card.id) })), - }), - ); - } - return el("li", { className: "m01-lbsteps-row", children: [sentence(...head)] }); -} - -function addBar(ctx: Ctx): HTMLElement { - const add = (key: keyof typeof TEXT, run: () => void): HTMLButtonElement => - createButton({ label: tx(key), variant: "ghost", size: "sm", onClick: run }); - const pick = (kind: PickResult["kind"]) => () => void pickItem(ctx, kind); - return el("div", { - className: "m01-lbsteps-add", - children: [ - add("Add_person", pick("person")), - add("Add_material", pick("material")), - add("Add_machine", pick("machine")), - add("Add_logic", pick("logic")), - add("Add_percent", () => - ctx.commit(() => ({ ...ctx.lb, doc: addPercent(ctx.lb.doc, "", "노무비", null, "경비") })), - ), - ], - }); -} - -/* ------------------------------ ⑤ 결과값 ------------------------------ */ - -function resultView(ctx: Ctx): HTMLElement { - const result = ctx.lb.result; - const ans = ctx.ans; - if (result.kind === "값") { - const value = ans?.ok ? ans.result : undefined; - return el("div", { - children: [ - sentence(note(tx("ResultValue")), chip(result.unit), valueNote(value)), - calcBody(ctx, result.calc), - ], - }); - } - const sums = ans?.ok ? ans.sums : undefined; - const parts: (HTMLElement | string)[] = [note(tx("ResultMoney")), chip(result.unit)]; - if (sums) { - for (const k of ["노무비", "재료비", "경비"]) { - if (k in sums) - parts.push( - span("m01-lbsteps-sum", `${tx(`Base_${k as CostKind}`)} ${formatMoney(sums[k])}`), - ); - } - parts.push( - span("m01-lbsteps-total", `${tx("Total")} ${formatMoney(sums["계"] ?? 0)} ${tx("Won")}`), - ); - } else parts.push(span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`)); - return sentence(...parts); -} - -/* ------------------------------ 전체 ------------------------------ */ - -function section(title: string, items: HTMLElement[], extra?: HTMLElement): HTMLElement { - const body = items.length - ? el("ol", { className: "m01-lbsteps-list", children: items }) - : note(tx("None")); - return el("section", { - className: "m01-lbsteps-sec", - children: [ - el("h3", { className: "m01-lbsteps-title", text: title }), - body, - ...(extra ? [extra] : []), - ], - }); -} - -/** 가운데 문장 보기 그리기 — ans 는 칸마다 곁에 보이는 예시값 · null 이면 「계산 중」 */ -export function renderSteps( - host: HTMLElement, - lb: LbLogic, - ans: CalcAnswer | null, - on: { change(lb: LbLogic): void; tableName?(table: string): string | null }, -): void { - const ctx: Ctx = { - lb, - ans, - ro: lb.doc.isBase, - on, - lines: new Map(), - commit: (make) => { - try { - on.change(make()); - } catch { - showToast(tx("Cannot"), "error"); - } - }, - }; - ctx.lines = lineMap(ctx); - host.classList.add("m01-lbsteps"); - const top: HTMLElement[] = []; - if (ctx.ro) top.push(el("p", { className: "m01-lbsteps-banner", text: tx("BaseNote") })); - if (lb.blocked.length) { - top.push( - el("div", { - className: "m01-lbsteps-banner is-warn", - children: [ - el("p", { text: `⚠ ${tx("Blocked")}` }), - ...lb.blocked.map((b) => el("p", { className: "m01-lbsteps-why", text: b })), - ], - }), - ); - } - host.replaceChildren( - ...top, - section( - tx("S1"), - lb.inputs.map((x, i) => inputView(ctx, x, i)), - ), - section( - tx("S2"), - lb.lookups.map((l, i) => lookupView(ctx, l, i + 1)), - ), - section( - tx("S3"), - lb.calcs.map((c, i) => calcView(ctx, c, i + 1)), - ), - section( - tx("S4"), - lb.doc.cards.map((c) => itemView(ctx, c)), - ctx.ro ? undefined : addBar(ctx), - ), - 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_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts deleted file mode 100644 index 213b1ebcd..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* ============================================================================= - * M01_LB_Steps_Calc.ts - * 로직 만들기 판 문장 보기 ③ 계산 — 값 · 곱나눔 · 할증 · 조건 목록 · 최소/최대 · 올림 - * ========================================================================== */ - -import { el } from "@ui/ui_template_elements"; -import type { CondOp, CondRead, CondShape, CondSlot } from "../EasyCalc/M01_EasyCalc_Cond"; -import { setCalc, type LbCalc } from "./M01_LB_Blocks"; -import { - chip, - middleOf, - note, - numBox, - refBox, - selectBox, - sentence, - span, - textBox, - tx, - valueNote, - type Ctx, - type TEXT, -} from "./M01_LB_Steps_Cell"; - -const OPS: [CondOp, keyof typeof TEXT][] = [ - ["==", "OpEq"], - ["!=", "OpNe"], - [">", "OpGt"], - [">=", "OpGe"], - ["<", "OpLt"], - ["<=", "OpLe"], -]; - -function slotBox(ctx: Ctx, slot: CondSlot, put: (s: CondSlot) => void): HTMLElement { - if (slot.kind === "num") return numBox(ctx, slot.value, (n) => put({ kind: "num", value: n })); - if (slot.kind === "text") return textBox(ctx, slot.value, (v) => put({ kind: "text", value: v })); - if (slot.kind === "input") { - const options = ctx.lb.inputs.map((i) => ({ value: i.이름, text: i.이름 })); - return selectBox(ctx, options, slot.name, (v) => put({ kind: "input", name: v }), tx("S1")); - } - return chip(tx(slot.kind === "table" ? "SlotTable" : "SlotCalc")); -} - -function shapeView(ctx: Ctx, shape: CondShape, put: (s: CondShape) => void): HTMLElement { - if (shape.type === "pick") { - const items: (HTMLElement | string)[] = []; - shape.items.forEach((s, i) => { - if (i) items.push(tx("And")); - items.push( - slotBox(ctx, s, (v) => - put({ ...shape, items: shape.items.map((x, j) => (j === i ? v : x)) }), - ), - ); - }); - const fn = [ - { value: "최소", text: tx("Small") }, - { value: "최대", text: tx("Big") }, - ]; - return sentence( - ...items, - selectBox(ctx, fn, shape.fn, (v) => put({ ...shape, fn: v as "최소" | "최대" }), tx("S3")), - ); - } - if (shape.type === "round") { - const fns = [ - { value: "올림", text: tx("Up") }, - { value: "버림", text: tx("Down") }, - { value: "반올림", text: tx("Half") }, - ]; - return sentence( - slotBox(ctx, shape.value, (v) => put({ ...shape, value: v })), - tx("RoundA"), - numBox(ctx, shape.places, (n) => put({ ...shape, places: n, hadPlaces: true })), - tx("RoundB"), - selectBox( - ctx, - fns, - shape.fn, - (v) => put({ ...shape, fn: v as "올림" | "버림" | "반올림" }), - tx("S3"), - ), - ); - } - const ops = OPS.map(([value, key]) => ({ value, text: tx(key) })); - const rows = shape.branches.map((b, i) => { - const edit = (patch: Partial): void => - put({ ...shape, branches: shape.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); - return el("li", { - className: "m01-lbsteps-branch", - children: [ - sentence( - span("m01-lbsteps-order", String(i + 1)), - slotBox(ctx, b.test.left, (v) => edit({ test: { ...b.test, left: v } })), - tx("Subject"), - slotBox(ctx, b.test.right, (v) => edit({ test: { ...b.test, right: v } })), - selectBox( - ctx, - ops, - b.test.op, - (v) => edit({ test: { ...b.test, op: v as CondOp } }), - tx("S3"), - ), - "→", - slotBox(ctx, b.value, (v) => edit({ value: v })), - ), - ], - }); - }); - const last = el("li", { - className: "m01-lbsteps-branch", - children: [ - sentence( - tx("Otherwise"), - slotBox(ctx, shape.otherwise, (v) => put({ ...shape, otherwise: v })), - ), - ], - }); - return el("div", { - className: "m01-lbsteps-shape", - children: [ - note(tx("Cond1")), - el("ol", { className: "m01-lbsteps-branches", children: [...rows, last] }), - ], - }); -} - -function placeText(ctx: Ctx, calc: LbCalc): string { - if (calc.name !== null) return calc.name; - const { part, index } = calc.at; - if (part === "결과") return tx("PlaceResult"); - const card = ctx.lb.doc.cards.find( - (c) => c.source?.index === index && c.source.part === (part === "수량" ? "호표" : "덧줄"), - ); - return `${card?.name ?? ""} ${tx(part === "수량" ? "PlaceQty" : "PlaceRate")}`.trim(); -} - -export function calcBody(ctx: Ctx, calc: LbCalc): HTMLElement { - const put = (patch: Partial): void => ctx.commit(() => setCalc(ctx.lb, calc, patch)); - const refAt = (i: number): HTMLElement => - refBox(ctx, calc.parts[i], calc, (r) => - put({ parts: calc.parts.map((p, j) => (j === i ? r : p)) }), - ); - if (calc.cond && calc.cond.blocks.length) { - const read: CondRead = calc.cond; - return el("div", { - className: "m01-lbsteps-shapes", - children: read.blocks.map((b, bi) => - shapeView(ctx, b.shape, (shape) => - put({ - cond: { ...read, blocks: read.blocks.map((x, j) => (j === bi ? { ...x, shape } : x)) }, - }), - ), - ), - }); - } - if (calc.kind === "값" && calc.parts.length) return sentence(refAt(0)); - if (calc.kind === "할증" && calc.parts.length >= 2) { - return sentence(refAt(0), tx("Mark1"), refAt(1), tx("Mark2")); - } - if ((calc.kind === "곱나눔" || calc.kind === "더하기") && calc.parts.length) { - const ops = calc.kind === "곱나눔" ? ["*", "/"] : ["+", "-"]; - const glyph = (o: string): string => - tx(o === "*" ? "Mul" : o === "/" ? "Div" : o === "+" ? "Plus" : "Minus"); - const out: (HTMLElement | string)[] = [refAt(0)]; - calc.parts.slice(1).forEach((_, i) => { - const op = calc.ops?.[i] ?? ops[0]; - const choices = ops.map((o) => ({ value: o, text: glyph(o) })); - out.push( - selectBox( - ctx, - choices, - op, - (v) => - put({ - ops: calc.parts - .slice(1) - .map((_, j) => (j === i ? v : (calc.ops?.[j] ?? ops[0])) as "*" | "/" | "+" | "-"), - }), - tx("S3"), - ), - refAt(i + 1), - ); - }); - return sentence(...out); - } - return sentence(note(tx("Steps"))); -} - -export function calcView(ctx: Ctx, calc: LbCalc, n: number): HTMLElement { - return el("li", { - className: "m01-lbsteps-row", - children: [ - sentence( - span("m01-lbsteps-name", `${tx("Calc")}${n}`), - chip(placeText(ctx, calc)), - valueNote(middleOf(ctx, calc.name)), - ), - calcBody(ctx, calc), - ], - }); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts deleted file mode 100644 index 35a73f203..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts +++ /dev/null @@ -1,287 +0,0 @@ -/* ============================================================================= - * M01_LB_Steps_Cell.ts - * 로직 만들기 판 문장 보기 공용 — 글 [한, 영] 짝 · 작은 칸(숫자 · 글 · 고르기) · 값이 오는 곳 고르기 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { el } from "@ui/ui_template_elements"; -import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; -import type { LbCalc, LbExpr, LbLogic, LbLookup, LbRef } from "./M01_LB_Blocks"; - -export const TEXT = { - Blocked: ["이 화면에서 읽지 못하는 줄이 있음", "Some lines cannot be read on this screen"], - Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], - BaseNote: ["표준 로직은 읽기만 — 복사해서 고침", "Standard logic is read-only — copy to edit"], - S1: ["① 입력값", "① Input values"], - S2: ["② 표 조회", "② Table lookup"], - S3: ["③ 계산", "③ Calculation"], - S4: ["④ 항목 연결", "④ Items"], - S5: ["⑤ 결과값", "⑤ Result"], - None: ["없음", "none"], - PickList: ["선택 목록", "choose from"], - Number: ["숫자", "number"], - Range: ["범위", "range"], - Unit: ["단위", "unit"], - Eg: ["예: 3", "e.g. 3"], - NotNumber: ["숫자만 넣음", "Numbers only"], - LkFrom: [" 에서 ", " : "], - LkIs: [" 이 ", " is "], - LkRow: [" 인 줄의 ", " in the row, take "], - LkNum: ["표 조회 ", "Lookup "], - SrcInput: ["입력값 · ", "input · "], - SrcCalc: ["계산 · ", "calc · "], - SrcFixed: ["고정값", "fixed value"], - SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"], - Other: ["다른 일위대가", "Other unit price"], - Hard: ["이 화면에서 못 고치는 값", "Value not editable here"], - Mul: ["×", "×"], - Div: ["÷", "÷"], - Plus: ["+", "+"], - Minus: ["−", "−"], - Mark1: ["에 ", ": add "], - Mark2: [" % 를 더 얹음", " % on top"], - Steps: ["여러 단계로 된 계산 — 읽기만", "Multi-step calculation — read only"], - Cond1: ["위에서 처음 맞는 줄의 값을 씀", "The first matching line from the top is used"], - Subject: ["가", "is"], - Otherwise: ["그 외 →", "Otherwise →"], - Big: ["가운데 큰 값", "the larger"], - Small: ["가운데 작은 값", "the smaller"], - And: ["와", "and"], - RoundA: ["을 소수 ", "round to "], - RoundB: [" 자리까지 ", " decimals, "], - Up: ["올림", "round up"], - Down: ["버림", "round down"], - Half: ["반올림", "round off"], - OpEq: ["이면", "equal to"], - OpNe: ["이 아니면", "not equal to"], - OpGt: ["보다 크면", "greater than"], - OpGe: ["이상이면", "at least"], - OpLt: ["보다 작으면", "less than"], - OpLe: ["이하이면", "at most"], - SlotTable: ["표에서 찾은 값", "Value from table"], - SlotCalc: ["계산한 값", "Calculated value"], - PlaceQty: ["수량", "amount"], - PlaceRate: ["비율", "rate"], - PlaceResult: ["결과값", "result"], - Calc: ["계산 ", "Calc "], - Tag_person: ["노무", "Labor"], - Tag_material: ["재료", "Material"], - Tag_machine: ["장비", "Equipment"], - Tag_logic: ["일위대가", "Unit price"], - Tag_percent: ["비율", "Rate"], - Tag_locked: ["기타", "Other"], - Pre_person: [" 이 ", " works "], - Pre_pick: [" 를 ", " uses "], - Pre_percent: [" 의 ", " of "], - Post_percent: [" % ", " % "], - Repick: ["다른 것으로 다시 고르기", "Choose another"], - Remove: ["지움", "Remove"], - Pending: ["계산 중", "calculating"], - Won: ["원", "KRW"], - Add_person: ["+ 노무", "+ Labor"], - Add_material: ["+ 재료", "+ Material"], - Add_machine: ["+ 장비", "+ Equipment"], - Add_logic: ["+ 일위대가", "+ Unit price"], - Add_percent: ["+ 비율", "+ Rate"], - Base_노무비: ["노무비", "labor cost"], - Base_재료비: ["재료비", "material cost"], - Base_경비: ["경비", "expenses"], - Base_사람줄합: ["노무 줄의 합", "sum of labor lines"], - ResultMoney: ["결과 = 금액", "Result = amount"], - ResultValue: [ - "결과 = 값 — 다른 일위대가가 불러 쓸 수 있음", - "Result = value — other unit prices can use it", - ], - Total: ["합계", "Total"], -} as const; - -export const tx = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -export type Ref = LbRef | LbExpr; - -export interface Ctx { - lb: LbLogic; - ans: CalcAnswer | null; - ro: boolean; - on: { change(lb: LbLogic): void; tableName?(table: string): string | null }; - lines: Map; - /** 바꾸기 — 못 하면 쉬운 말 한 줄 */ - commit(make: () => LbLogic): void; -} - -/* ------------------------------ 작은 칸 ------------------------------ */ - -export const span = (cls: string, text: string): HTMLSpanElement => - el("span", { className: cls, text }); -export const chip = (text: string, title?: string): HTMLSpanElement => { - const c = span("m01-lbsteps-chip", text); - if (title) c.title = title; - return c; -}; -export const note = (text: string): HTMLSpanElement => span("m01-lbsteps-note", text); -export const sentence = (...parts: (HTMLElement | string)[]): HTMLElement => - el("div", { className: "m01-lbsteps-sentence", children: parts }); - -export const parseNum = (text: string): number | null => { - const t = text.replace(/[,\s]/g, ""); - return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : null; -}; - -export function numBox(ctx: Ctx, value: number | null, commit: (n: number) => void): HTMLElement { - if (ctx.ro) return chip(value === null ? "" : String(value)); - const input = el("input", { - className: "ui-input m01-lbsteps-num", - attrs: { type: "text", inputmode: "decimal", placeholder: tx("Eg") }, - }); - input.value = value === null ? "" : String(value); - input.addEventListener("change", () => { - const n = parseNum(input.value); - input.classList.toggle("is-bad", n === null); - input.title = n === null ? tx("NotNumber") : ""; - if (n !== null && n !== value) commit(n); - }); - return input; -} - -export function textBox(ctx: Ctx, value: string, commit: (v: string) => void): HTMLElement { - if (ctx.ro) return chip(value); - const input = el("input", { className: "ui-input m01-lbsteps-text", attrs: { type: "text" } }); - input.value = value; - input.addEventListener("change", () => input.value !== value && commit(input.value.trim())); - return input; -} - -export function selectBox( - ctx: Ctx, - options: { value: string; text: string }[], - current: string, - commit: (v: string) => void, - aria: string, -): HTMLElement { - const hit = options.find((o) => o.value === current); - if (ctx.ro) return chip(hit?.text ?? ""); - const select = el("select", { - className: "ui-select m01-lbsteps-select", - attrs: { "aria-label": aria }, - }); - for (const o of options) select.append(el("option", { attrs: { value: o.value }, text: o.text })); - select.value = hit ? current : (options[0]?.value ?? ""); - select.addEventListener("change", () => commit(select.value)); - return select; -} - -export const show = (v: unknown): string => (v === undefined || v === null ? "" : formatNumber(v)); -export const valueNote = (v: unknown): HTMLElement | string => - v === undefined || v === null ? "" : span("m01-lbsteps-value", `= ${show(v)}`); - -/* ------------------------------ 값 찾기 ------------------------------ */ - -export const middleOf = (ctx: Ctx, name: string | null): unknown => - name !== null && ctx.ans?.ok ? ctx.ans.middle[name] : undefined; - -/** 표 조회 값 — 그 값 하나만 이름 붙은 계산이 되어 있을 때만 앎 */ -export function lookupValue(ctx: Ctx, lk: LbLookup): unknown { - const calc = ctx.lb.calcs.find( - (c) => - c.kind === "값" && - c.name !== null && - c.parts.length === 1 && - isRef(c.parts[0], "lookup", lk.id), - ); - return middleOf(ctx, calc?.name ?? null); -} - -export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && r.id === id; - -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": - return r.name; - case "fixed": - return String(r.value); - case "calc": - return r.name; - case "lookup": { - const lk = ctx.lb.lookups.find((l) => l.id === r.id); - return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`; - } - case "logic": - return `${logicNames.get(r.key) ?? tx("Other")}${r.field ? ` · ${r.field}` : ""}`; - case "cost": - return tx(`Base_${r.name}`); - default: - return tx("Hard"); - } -} - -export function refValue(ctx: Ctx, r: Ref): unknown { - if (r.k === "calc") return middleOf(ctx, r.name); - if (r.k === "fixed") return r.value; - if (r.k === "lookup") { - const lk = ctx.lb.lookups.find((l) => l.id === r.id); - return lk ? lookupValue(ctx, lk) : undefined; - } - return undefined; -} - -/** 값이 오는 곳 고르기 — 입력값 · 표 조회 · 다른 계산 · 고정값 */ -export function refBox( - ctx: Ctx, - ref: Ref, - self: LbCalc | null, - pick: (r: LbRef) => void, -): HTMLElement { - const label = refText(ctx, ref); - const editable = ref.k === "input" || ref.k === "calc" || ref.k === "lookup" || ref.k === "fixed"; - if (ctx.ro || !editable) { - return el("span", { - className: "m01-lbsteps-ref", - children: [chip(label), valueNote(refValue(ctx, ref))], - }); - } - const options = [ - ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), - ...ctx.lb.lookups.map((l) => ({ - value: `l:${l.id}`, - text: refText(ctx, { k: "lookup", id: l.id }), - })), - ...ctx.lb.calcs - .filter((c) => c.name !== null && c !== self) - .map((c) => ({ value: `c:${c.name}`, text: `${tx("SrcCalc")}${c.name}` })), - { value: "f", text: tx("SrcFixed") }, - ]; - const current = - ref.k === "input" - ? `i:${ref.name}` - : ref.k === "calc" - ? `c:${ref.name}` - : ref.k === "lookup" - ? `l:${ref.id}` - : "f"; - const toRef = (v: string): LbRef => - v === "f" - ? { k: "fixed", value: 0 } - : v.startsWith("i:") - ? { k: "input", name: v.slice(2) } - : v.startsWith("l:") - ? { k: "lookup", id: v.slice(2) } - : { k: "calc", name: v.slice(2) }; - const parts: (HTMLElement | string)[] = [ - selectBox(ctx, options, current, (v) => pick(toRef(v)), tx("S3")), - ]; - if (ref.k === "fixed") { - parts.push( - numBox(ctx, typeof ref.value === "number" ? ref.value : null, (n) => - pick({ k: "fixed", value: n }), - ), - ); - } else parts.push(valueNote(refValue(ctx, ref))); - return el("span", { className: "m01-lbsteps-ref", children: parts }); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard.css b/M01_MasterData/LogicBuilder/M01_LB_Wizard.css deleted file mode 100644 index 56b85b12e..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard.css +++ /dev/null @@ -1,199 +0,0 @@ -.m01-lbw { - --modal-max: 90vh; - width: min(980px, 96vw); - font-size: var(--text-body-lg, 1.05rem); -} -.m01-lbw__ask { - margin: 0; - font-size: 1.4em; - color: var(--color-text); -} -.m01-lbw__cols { - display: grid; - grid-template-columns: minmax(0, 1.7fr) minmax(220px, 1fr); - gap: var(--spacing-12); - align-items: start; -} -@media (max-width: 760px) { - .m01-lbw__cols { - grid-template-columns: minmax(0, 1fr); - } -} -.m01-lbw__main, -.m01-lbw__side { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} -.m01-lbw__side { - padding: var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); -} -.m01-lbw h5 { - margin: var(--spacing-8) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} -.m01-lbw__muted { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} -.m01-lbw__list, -.m01-lbw__tables, -.m01-lbw__roles, -.m01-lbw__added, -.m01-lbw__notes, -.m01-lbw__stack { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} -.m01-lbw__list { - max-height: 24vh; - overflow: auto; -} -.m01-lbw__row, -.m01-lbw__note, -.m01-lbw__chip, -.m01-lbw__role { - display: flex; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); - border: 2px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} -.m01-lbw__row { - flex-direction: column; - gap: 2px; -} -.m01-lbw__row.is-on, -.m01-lbw__note.is-on, -.m01-lbw__chip.is-on { - border-color: var(--color-accent); - outline: 2px solid var(--color-accent); -} -.m01-lbw__row:hover, -.m01-lbw__note:hover, -.m01-lbw__chip:hover, -.m01-lbw__role:hover, -.m01-lbw__row:focus-visible, -.m01-lbw__chip:focus-visible, -.m01-lbw__role:focus-visible, -.m01-lbw__note:focus-visible { - border-color: var(--color-accent); -} -.m01-lbw__chips { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} -.m01-lbw__chip { - padding: var(--spacing-4) var(--spacing-12); - border-radius: var(--radius-pills); -} -.m01-lbw__rolebox { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: center; -} -.m01-lbw__role { - flex-direction: column; - min-width: 120px; -} -.m01-lbw__role span { - font-size: var(--text-caption); -} -.m01-lbw__role.is-조건 { - border-color: var(--color-accent); -} -.m01-lbw__role.is-고정 { - border-style: dashed; -} -.m01-lbw__role.is-쓰지\ 않음 { - opacity: 0.6; -} -.m01-lbw__line { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; -} -.m01-lbw__form, -.m01-lbw__extras { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); - border: 1px dashed var(--color-border); - border-radius: var(--radius-cards); -} -.m01-lbw__item { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); -} -.m01-lbw__link { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); -} -.m01-lbw__scroll { - overflow: auto; - max-height: 22vh; -} -.m01-lbw__table table { - border-collapse: collapse; - font-size: var(--text-body-sm); -} -.m01-lbw__table th, -.m01-lbw__table td { - padding: 2px 8px; - border: 1px solid var(--color-border); - white-space: nowrap; - text-align: center; -} -.m01-lbw__table th.is-조건 { - background: var(--color-mist-violet, rgba(120, 100, 220, 0.18)); -} -.m01-lbw__table th.is-고정 { - background: rgba(200, 160, 40, 0.2); -} -.m01-lbw__table th.is-결과 { - background: rgba(60, 160, 100, 0.2); -} -.m01-lbw__note-line { - margin: 2px 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} -.m01-lbw__nav { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: flex-end; -} -.m01-lbw__nav > :first-child { - margin-right: auto; -} -.m01-lbw__result { - margin: 0; - color: var(--color-accent); - font-size: 1.15em; - font-weight: 600; -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts deleted file mode 100644 index 9f172557f..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts +++ /dev/null @@ -1,628 +0,0 @@ -/* ============================================================================= - * M01_LB_Wizard.ts - * 로직 만들기 판 — 단계 안내 6단계 · openWizard() → LbLogic | null(취소) · 저장은 Entry 몫 - * ① 어느 품셈 절 ② 표 칸 역할 ③ 결과 단위 ④ 계산(Calc.ts) ⑤ 항목 연결(Calc.ts) ⑥ 이름 · 결과 종류 - * 답은 Build.ts 가 로직 한 줄로 짜고 Blocks.readLogic 이 블록으로 읽음 · 답할 때마다 곁에 예시값 - * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -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"; -import { - buildRow, - colsOf, - trialOf, - unitOfBase, - type Role, - type WAnswers, - 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 = { - Title: ["로직 만들기 — 단계 안내", "Build a logic — step by step"], - Close: ["닫기", "Close"], - Back: ["← 뒤로", "← Back"], - Next: ["다음 →", "Next →"], - Make: ["만들기", "Create"], - StepOf: ["{n} / 6 단계", "Step {n} of 6"], - Q1: ["어느 품셈 절인가요?", "Which section of the standard?"], - Q2: ["표의 칸마다 무엇인가요?", "What is each column?"], - Q3: ["결과는 무슨 단위인가요?", "What unit is the result?"], - Q4: ["더하거나 나눌 것이 있나요?", "Anything to add or divide?"], - Q5: ["값은 어느 항목으로 가나요?", "Where does each value go?"], - Q6: ["이름과 결과 종류", "Name and kind of result"], - Book: ["적용 품셈", "Standard"], - Search: ["절 번호나 이름을 쳐서 찾기 — 예: 13-4-1 메쌓기", "Search — e.g. 13-4-1"], - Loading: ["찾는 중…", "Searching…"], - NoSection: ["찾는 절 없음", "No section"], - SecLine: ["표 {t} 개 · 만든 로직 {l} 개", "{t} tables · {l} logics"], - Tables: ["이 절의 표 — 하나를 고름", "Tables in this section — pick one"], - TableLine: [ - "{b} · 조건 {c} · 결과 {v} · {n} 줄", - "{b} · {c} conditions · {v} results · {n} rows", - ], - Notes: ["주", "Notes"], - Preview: ["원문 표 미리 보기", "Table preview"], - RoleHint: [ - "칸 머리를 누르면 조건 → 고정 → 결과 로 바뀜 · 조건 칸의 선택 목록은 표 줄에서 저절로 만듦", - "Press a header to change its role", - ], - Cond: ["조건", "Condition"], - Fixed: ["고정", "Fixed"], - Result: ["결과", "Result"], - 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}”"], - UnitPick: ["결과단위", "Result unit"], - Name: ["이름", "Name"], - KindMoney: ["1 {p} 당 금액", "Amount per {p}"], - KindValue: ["다른 로직이 쓸 값", "A value other logics use"], - ValueCol: ["내보낼 값(결과 칸)", "Value to give"], - ValueUnit: ["값의 단위", "Unit of the value"], - NeedName: ["이름을 적어 주세요", "Enter a name"], - Sample: ["예시값", "Sample values"], - SampleHint: ["값을 바꾸면 곁의 결과가 바로 바뀜", "Change a value to see the result"], - Waiting: ["계산 중…", "Calculating…"], - AmountPer: ["1 {p} 당 약 {m} 원", "About {m} won per {p}"], - ValueIs: ["{c} = {v} {u}", "{c} = {v} {u}"], - NotYet: ["아직 계산할 것이 없음", "Nothing to calculate yet"], - Fail: ["읽지 못함", "Could not read"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const fmt = (n: number): string => Number(n.toPrecision(6)).toLocaleString("ko-KR"); - -const LBL = { preview: t("Preview"), notes: t("Notes") }; -const BOOKS = ["산림품셈", "건설품셈"]; -const UNITS = ["㎡", "㎥", "m", "km", "ha", "개소", "개", "ton", "본", "100㎡", "100m", "10m"]; - -const emptyTable = (): WTable => ({ - key: "", - 이름: "", - 기준: "", - 출처: "", - 조건: {}, - 값칸: {}, - 줄: [], - 주: [], -}); - -/** 단계 안내 — 끝나면 LbLogic · 취소(닫기 · Esc)는 null */ -export function openWizard(): Promise { - return new Promise((resolve) => { - let settled = false; - const finish = (r: LbLogic | null): void => { - if (!settled) resolve(r); - settled = true; - }; - const a: WAnswers = { - book: BOOKS[0], - section: "", - table: emptyTable(), - roles: {}, - fixed: {}, - options: {}, - resultUnit: "원/㎡", - mods: [], - links: [], - extras: [], - name: "", - kind: "금액", - valueCol: "", - }; - const ctx: WCtx = { a, sectionTables: [], changed: () => runPreview() }; - let step = 1; - let sectionTitle = ""; - let trial: Record = {}; - let linked = false; - const main = el("div", { className: "m01-lbw__main" }); - const side = el("aside", { className: "m01-lbw__side" }); - const nav = el("div", { className: "m01-lbw__nav" }); - const ask = el("h4", { className: "m01-lbw__ask" }); - const sample = el("div", { className: "m01-lbw__sample" }); - const result = el("p", { className: "m01-lbw__result", attrs: { "aria-live": "polite" } }); - side.append( - el("h5", { text: t("Sample") }), - el("p", { className: "m01-lbw__muted", text: t("SampleHint") }), - sample, - result, - ); - - /* ── 예시값 ── */ - const previewRow = () => { - const money = step >= 5 && a.kind === "금액" && a.links.some((l) => l.kind !== "없음"); - const col = a.valueCol || colsOf(a, "결과")[0]; - const row = buildRow({ ...a, kind: money ? "금액" : "값", valueCol: col }); - return { row, money, col }; - }; - let paintedFor = ""; - const runPreview = (): void => { - if (step < 2 || !colsOf(a, "결과").length) { - sample.replaceChildren(); - result.textContent = t("NotYet"); - return; - } - const { row, money, col } = previewRow(); - const defaults = trialOf(a, row); - trial = Object.fromEntries( - row.입력.map((i) => [i.이름, i.이름 in trial ? trial[i.이름] : defaults[i.이름]]), - ); - const sig = JSON.stringify(row.입력); - if (sig !== paintedFor) { - paintedFor = sig; - sample.replaceChildren( - ...row.입력.map((i) => { - const label = el("span", { className: "m01-lbw__muted", text: i.이름 }); - if (i.고르기?.length) { - const f = createSelectField({ - options: i.고르기.map((v) => ({ value: String(v), text: String(v) })), - value: String(trial[i.이름]), - compact: true, - onChange: (v) => ( - (trial[i.이름] = i.고르기!.find((x) => String(x) === v)), - runPreview() - ), - }); - return el("label", { className: "m01-lbw__line", children: [label, f.root] }); - } - const f = createInputField({ - type: "text", - value: String(trial[i.이름] ?? ""), - compact: true, - }); - f.input.addEventListener("input", () => { - const n = Number(f.input.value); - trial[i.이름] = f.input.value.trim() !== "" && Number.isFinite(n) ? n : f.input.value; - runPreview(); - }); - return el("label", { className: "m01-lbw__line", children: [label, f.root] }); - }), - ); - } - result.textContent = t("Waiting"); - calcLater(row, trial, (ans: CalcAnswer | null, plain: string | null) => { - if (!ans?.ok) return void (result.textContent = plain ?? t("Fail")); - 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: Math.round(v).toLocaleString("ko-KR") }) - : t("ValueIs", { c: col, v: fmt(v), u: a.table.값칸[col] ?? "" }); - }); - }; - - /* ── 1단계 절 · 표 ── */ - const stepSection = (): (() => string | null) => { - const bookBox = createSelectField({ - label: t("Book"), - options: BOOKS.map((b) => ({ value: b, text: b })), - value: a.book, - compact: true, - onChange: (v) => ((a.book = v), load()), - }); - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m01-lbw__list" }); - const tables = el("div", { className: "m01-lbw__tables" }); - const preview = el("div", { className: "m01-lbw__preview" }); - let seq = 0; - const load = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") })); - try { - const r = await get<{ sections: SectionItem[] }>("/sections", { - book: a.book, - q: q.input.value.trim(), - limit: "60", - }); - if (mine !== seq) return; - list.replaceChildren( - ...(r.sections.length - ? r.sections.map((s) => { - const b = el("button", { - className: `m01-lbw__row${a.section === s.section && sectionTitle === s.title ? " is-on" : ""}`, - attrs: { type: "button" }, - children: [ - el("strong", { text: `${s.section} ${s.title}` }), - el("span", { - className: "m01-lbw__muted", - text: `${s.chapter} · ${t("SecLine", { t: s.tables, l: s.logics })}`, - }), - ], - }); - b.addEventListener("click", () => void pickSection(s)); - return b; - }) - : [el("p", { className: "m01-lbw__muted", text: t("NoSection") })]), - ); - } catch (e) { - if (mine === seq) showToast(plainError(e), "error"); - } - }; - const pickSection = async (s: SectionItem): Promise => { - a.section = s.section; - sectionTitle = s.title; - a.name = a.name || s.title; - try { - const heads: TableHead[] = []; - for (const group of ["소요량", "계수"]) - heads.push( - ...( - await get<{ tables: TableHead[] }>("/tables", { - group, - section: s.section, - sub: a.book, - size: "100", - }) - ).tables, - ); - ctx.sectionTables = []; - tables.replaceChildren( - el("h5", { text: t("Tables") }), - ...heads.map((h) => { - const b = el("button", { - className: `m01-lbw__row${a.table.key === h.키 ? " is-on" : ""}`, - attrs: { type: "button" }, - children: [ - el("strong", { text: h.이름 }), - el("span", { - className: "m01-lbw__muted", - text: t("TableLine", { - b: h.기준 || "—", - c: Object.keys(h.조건).length, - v: Object.keys(h.값칸).length, - n: h.count, - }), - }), - ], - }); - b.addEventListener("click", () => void pickTable(h, heads)); - return b; - }), - ); - if (heads.length === 1) await pickTable(heads[0], heads); - main.dispatchEvent(new Event("m01lbw")); - } catch (e) { - showToast(plainError(e), "error"); - } - }; - const pickTable = async (h: TableHead, heads: TableHead[]): Promise => { - const [full, opt] = await Promise.all([ - get<{ table: WTable & { 키: string } }>("/table", { file: h.file, key: h.키 }), - get<{ 값: Record }>("/table/options", { - file: h.file, - key: h.키, - }), - ]); - a.table = toTable(full.table, h.file); - a.roles = Object.fromEntries([ - ...Object.keys(a.table.조건).map((c) => [c, "조건" as Role]), - ...Object.keys(a.table.값칸).map((c) => [c, "결과" as Role]), - ]); - a.options = opt.값 ?? {}; - a.fixed = {}; - a.links = []; - a.mods = []; - a.resultUnit = unitOfBase(a.table.기준); - linked = false; - ctx.sectionTables = await Promise.all( - heads - .filter((x) => x.키 !== h.키) - .map(async (x) => - toTable( - ( - await get<{ table: WTable & { 키: string } }>("/table", { - file: x.file, - key: x.키, - }) - ).table, - x.file, - ), - ), - ); - tables - .querySelectorAll(".m01-lbw__row") - .forEach((n, i) => n.classList.toggle("is-on", heads[i]?.키 === h.키)); - preview.replaceChildren(tablePreview(a.table, LBL)); - renderNav(); - }; - let timer = 0; - q.input.addEventListener( - "input", - () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), - ); - main.replaceChildren(bookBox.root, q.root, list, tables, preview); - void load(); - if (a.table.key) preview.replaceChildren(tablePreview(a.table, LBL)); - return () => (a.table.key ? null : t("PickTable")); - }; - - /* ── 2단계 칸 역할 ── */ - const stepRoles = (): (() => string | null) => { - const box = el("div", { className: "m01-lbw__roles" }); - const draw = (): void => { - const cols = [...Object.keys(a.table.조건), ...Object.keys(a.table.값칸)]; - const chips = cols.map((c) => { - const isCond = c in a.table.조건; - const role = a.roles[c]; - const b = el("button", { - className: `m01-lbw__role is-${role}`, - attrs: { type: "button", title: t("RoleHint") }, - children: [ - el("strong", { text: c }), - el("span", { - text: - role === "조건" - ? t("Cond") - : role === "고정" - ? t("Fixed") - : role === "결과" - ? t("Result") - : t("Unused"), - }), - ], - }); - b.addEventListener("click", () => { - a.roles[c] = isCond - ? role === "조건" - ? "고정" - : "조건" - : role === "결과" - ? "쓰지 않음" - : "결과"; - if (a.roles[c] === "고정" && a.fixed[c] === undefined) - a.fixed[c] = a.options[c]?.[0] ?? ""; - ctx.changed(); - draw(); - }); - const extra: HTMLElement[] = []; - if (role === "조건") - extra.push( - el("span", { - className: "m01-lbw__muted", - text: - a.options[c]?.length && a.table.조건[c] !== "범위" - ? `${t("Choices")}: ${a.options[c].join(" · ")}` - : t("Free"), - }), - ); - if (role === "고정") { - const list = a.options[c] ?? []; - if (list.length) - extra.push( - createSelectField({ - options: list.map((v) => ({ value: String(v), text: String(v) })), - value: String(a.fixed[c]), - compact: true, - ariaLabel: t("FixedVal"), - onChange: (v) => ( - (a.fixed[c] = list.find((x) => String(x) === v) ?? v), - ctx.changed() - ), - }).root, - ); - else { - const f = createInputField({ - type: "text", - value: String(a.fixed[c] ?? ""), - compact: true, - }); - f.input.addEventListener( - "input", - () => ( - (a.fixed[c] = - Number.isFinite(Number(f.input.value)) && f.input.value.trim() !== "" - ? Number(f.input.value) - : f.input.value), - ctx.changed() - ), - ); - extra.push(f.root); - } - } - return el("div", { className: "m01-lbw__rolebox", children: [b, ...extra] }); - }); - box.replaceChildren( - el("p", { className: "m01-lbw__muted", text: t("RoleHint") }), - ...chips, - tablePreview(a.table, LBL, a.roles), - ); - renderNav(); - }; - main.replaceChildren(box); - draw(); - return () => (colsOf(a, "결과").length ? null : t("NeedResult")); - }; - - /* ── 3단계 결과 단위 ── */ - const stepUnit = (): (() => string | null) => { - const per = a.resultUnit.replace(/^원\//, ""); - const opts = [...new Set([per, ...UNITS])]; - const box = createSelectField({ - label: t("UnitPick"), - options: opts.map((u) => ({ value: u, text: `원/${u}` })), - value: per, - onChange: (v) => ((a.resultUnit = `원/${v}`), ctx.changed()), - }); - main.replaceChildren( - el("p", { className: "m01-lbw__muted", text: t("BaseUnit", { b: a.table.기준 || "—" }) }), - box.root, - ); - return () => null; - }; - - /* ── 6단계 이름 · 결과 종류 ── */ - const stepName = (): (() => string | null) => { - const name = createInputField({ - type: "text", - label: t("Name"), - value: a.name, - placeholder: "예: 돌쌓기 메쌓기", - }); - name.input.addEventListener("input", () => ((a.name = name.input.value), renderNav())); - const per = a.resultUnit.replace(/^원\//, ""); - const kinds = el("div", { className: "m01-lbw__chips" }); - const valueBox = el("div", { className: "m01-lbw__stack" }); - const drawKind = (): void => { - for (const [k, label] of [ - ["금액", t("KindMoney", { p: per })], - ["값", t("KindValue")], - ] as const) { - const b = el("button", { - className: `m01-lbw__chip${a.kind === k ? " is-on" : ""}`, - attrs: { type: "button" }, - text: label, - }); - b.addEventListener( - "click", - () => ( - (a.kind = k), - (a.valueCol = a.valueCol || colsOf(a, "결과")[0]), - a.kind === "값" && - a.resultUnit.startsWith("원/") && - (a.resultUnit = a.table.값칸[a.valueCol] || ""), - drawKind(), - ctx.changed(), - renderNav() - ), - ); - kinds.append(b); - } - }; - const paint = (): void => { - kinds.replaceChildren(); - drawKind(); - if (a.kind !== "값") return valueBox.replaceChildren(); - const col = createSelectField({ - label: t("ValueCol"), - options: colsOf(a, "결과").map((c) => ({ value: c, text: c })), - value: a.valueCol, - onChange: (v) => ( - (a.valueCol = v), - (a.resultUnit = a.table.값칸[v] || a.resultUnit), - ctx.changed() - ), - }); - const unit = createInputField({ - type: "text", - label: t("ValueUnit"), - value: a.resultUnit, - placeholder: "예: ㎥/시간", - }); - unit.input.addEventListener( - "input", - () => ((a.resultUnit = unit.input.value.trim()), ctx.changed()), - ); - valueBox.replaceChildren(col.root, unit.root); - }; - kinds.addEventListener("click", () => queueMicrotask(paint)); - paint(); - main.replaceChildren(name.root, kinds, valueBox); - return () => (a.name.trim() ? null : t("NeedName")); - }; - - /* ── 틀 ── */ - let valid: () => string | null = () => null; - const draw = (): void => { - ask.textContent = t(`Q${step}` as TKey); - const body = el("div"); - main.replaceChildren(body); - if (step === 1) valid = stepSection(); - else if (step === 2) valid = stepRoles(); - else if (step === 3) valid = stepUnit(); - else if (step === 4) { - stepCalc(ctx, main); - valid = () => null; - } else if (step === 5) { - 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; - renderNav(); - runPreview(); - }; - const renderNav = (): void => { - const why = valid(); - const back = createButton({ - label: t("Back"), - variant: "ghost", - disabled: step === 1, - onClick: () => ((step -= 1), draw()), - }); - const next = createButton({ - label: step === 6 ? t("Make") : t("Next"), - disabled: !!why, - onClick: () => (step === 6 ? make() : ((step += 1), draw())), - }); - nav.replaceChildren( - el("span", { - className: "m01-lbw__muted", - text: `${t("StepOf", { n: step })}${why ? ` · ${why}` : ""}`, - }), - back, - next, - ); - }; - const make = (): void => { - try { - const row = buildRow({ ...a, valueCol: a.valueCol || colsOf(a, "결과")[0] }); - const lb = readLogic(row, { key: null, version: null, isBase: false }); - finish(lb); - closeModal(); - } catch (e) { - showToast(e instanceof Error ? e.message : t("Fail"), "error"); - } - }; - main.addEventListener("m01lbw", renderNav); - let closeModal = (): void => undefined; - const modal = openModal({ - title: t("Title"), - closeLabel: t("Close"), - dialogClass: "m01-lbw", - beforeClose: () => (finish(null), true), - mount: (body, close) => { - closeModal = close; - body.append(ask, el("div", { className: "m01-lbw__cols", children: [main, side] }), nav); - body.addEventListener( - "keydown", - (ev) => ev.altKey && ev.key === "ArrowLeft" && step > 1 && ((step -= 1), draw()), - ); - }, - }); - void modal; - draw(); - }); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts deleted file mode 100644 index e9abc8a54..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts +++ /dev/null @@ -1,270 +0,0 @@ -/* ============================================================================= - * M01_LB_Wizard_Build.ts - * 단계 안내가 쌓은 답 → 로직 한 줄(LogicRow) 짜기 — DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) - * 화면 = `M01_LB_Wizard.ts` · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 글은 마스터 로직과 같은 꼴 - * ========================================================================== */ - -import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; - -type Cell = string | number | [number | null, number | null] | null | undefined; - -/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 */ -export interface WTable { - key: string; - /** 표가 든 파일 */ - file?: string; - 이름: string; - 기준: string; - 출처: string; - 조건: Record; - 값칸: Record; - 범위규칙?: string | null; - 줄: Record[]; - 주: string[]; -} - -export type Role = "조건" | "고정" | "결과" | "쓰지 않음"; - -/** 표 조회 율 — 숫자 · 입력값 · 조건 목록(입력값이 한계를 넘을 때만 다른 표에서 찾음) */ -export type Rate = - | { t: "num"; v: number } - | { t: "input"; name: string } - | { t: "cond"; input: string; op: "<=" | "<"; limit: number; table: WTable; col: string }; - -export type Slot = { t: "num"; v: number } | { t: "input"; name: string }; - -/** 문장 틀 — 할증 · 나누기 · 최소/최대 · 올림 */ -export type Mod = - | { k: "할증"; rate: Rate; src?: string } - | { k: "나누기"; by: Slot; src?: string } - | { k: "최소"; v: number; src?: string } - | { k: "최대"; v: number; src?: string } - | { k: "올림"; fn: "올림" | "버림" | "반올림"; places: number; src?: string }; - -export type LinkKind = "노무" | "재료" | "장비" | "일위대가" | "없음"; - -/** 결과 칸 하나의 항목 연결 */ -export interface Link { - col: string; - kind: LinkKind; - name: string; - spec: string; - unit: string; - element: unknown; - needInputs?: LogicInput[]; -} - -/** 항목 연결 덧줄 — 비율(바탕의 % · 공구손료 등) · 기타(고정 금액) */ -export type Extra = - | { - k: "비율"; - name: string; - base: "노무비" | "재료비" | "경비" | "사람줄합"; - rate: number; - cost: "노무비" | "재료비" | "경비"; - } - | { k: "기타"; name: string; amount: number; cost: "노무비" | "재료비" | "경비" }; - -export interface WAnswers { - book: string; - section: string; - table: WTable; - roles: Record; - /** 고정 칸 값 */ - fixed: Record; - /** 조건 칸 선택 목록(표 줄에서 저절로) */ - options: Record; - /** 결과단위 「원/㎡」 */ - resultUnit: string; - mods: Mod[]; - links: Link[]; - extras: Extra[]; - name: string; - /** 금액 = 1㎡ 당 금액 · 값 = 다른 로직이 쓸 값(결과 식) */ - kind: "금액" | "값"; - valueCol: string; -} - -/** 단계마다 같이 보는 것 — 화면이 바꾸면 changed() 로 알림(예시값 다시 셈) */ -export interface WCtx { - a: WAnswers; - /** 고른 절의 다른 표들(조건 목록 율 찾기용) */ - sectionTables: WTable[]; - changed(): void; -} - -const isWord = (t: string): boolean => /^[\p{L}\p{N}_]+$/u.test(t); -const word = (t: string): string => (isWord(t) ? t : `'${t}'`); -const num = (n: number): string => String(Number(n.toPrecision(12))); -const lit = (v: string | number): string => (typeof v === "number" ? num(v) : `'${v}'`); - -/** 역할별 칸 이름 */ -export const colsOf = (a: Pick, role: Role): string[] => - Object.keys(a.roles).filter((c) => a.roles[c] === role); - -/** 기준 「1㎡」 「100㎡ 당」 「m당」 → 결과단위 「원/㎡」 「원/100㎡」 「원/m」 */ -export function unitOfBase(base: string): string { - const per = base - .replace(/\s*당\s*$/, "") - .replace(/^1(?=\D)/, "") - .trim(); - return `원/${per || "㎡"}`; -} - -/** 표 조회 글 — 조건 칸은 같은 이름 입력값 · 고정 칸은 값 */ -export function findText(a: WAnswers, col: string): string { - const args = [ - ...colsOf(a, "조건").map((c) => `${word(c)}=${word(c)}`), - ...colsOf(a, "고정").map((c) => `${word(c)}=${lit(a.fixed[c] ?? "")}`), - ]; - return `찾기(${[a.table.key, ...args].join(", ")}).${word(col)}`; -} - -/** 율 · 나누는 수 — 식 글 · 새로 필요한 중간 값 */ -function rateText( - rate: Rate, - mids: { 이름: string; 식: string; 출처?: string }[], - src?: string, -): string { - if (rate.t === "num") return num(rate.v); - if (rate.t === "input") return word(rate.name); - const t = rate.table; - let name = rate.col; - while (mids.some((m) => m.이름 === name) || name === rate.input) name += "값"; - const found = `찾기(${t.key}, ${word(rate.input)}=${word(rate.input)}).${word(rate.col)}`; - mids.push({ - 이름: name, - 식: `만약(${word(rate.input)} ${rate.op} ${num(rate.limit)}, 0, ${found})`, - ...(src ? { 출처: src } : {}), - }); - return name; -} - -/** 문장 틀을 차례로 씌운 식 글 — 중간 값은 mids 에 쌓음 */ -export function valueText( - a: WAnswers, - col: string, - mids: { 이름: string; 식: string; 출처?: string }[], - rates: Map, -): string { - let x = findText(a, col); - let atom = true; - const wrap = (): string => (atom ? x : `(${x})`); - for (const m of a.mods) { - if (m.k === "할증") { - if (!rates.has(m)) rates.set(m, rateText(m.rate, mids, m.src)); - x = `${wrap()} * (1 + ${rates.get(m)} / 100)`; - atom = false; - } else if (m.k === "나누기") { - x = `${wrap()} / ${m.by.t === "num" ? num(m.by.v) : word(m.by.name)}`; - atom = false; - } else if (m.k === "최소" || m.k === "최대") { - x = `${m.k}(${x}, ${num(m.v)})`; - atom = true; - } else { - x = `${m.fn}(${x}, ${num(m.places)})`; - atom = true; - } - } - return x; -} - -/** 입력값 모으기 — 표 조건 칸 · 율 · 나누는 수 · 장비 · 일위대가가 받는 값 */ -function inputsOf(a: WAnswers): LogicInput[] { - const out: LogicInput[] = []; - const add = (i: LogicInput): void => { - if (!out.some((x) => x.이름 === i.이름)) out.push(i); - }; - for (const c of colsOf(a, "조건")) { - const list = a.options[c]; - add(list?.length && a.table.조건[c] !== "범위" ? { 이름: c, 고르기: list } : { 이름: c }); - } - for (const m of a.mods) { - if (m.k === "할증" && m.rate.t === "input") add({ 이름: m.rate.name, 단위: "%" }); - if (m.k === "할증" && m.rate.t === "cond") add({ 이름: m.rate.input }); - if (m.k === "나누기" && m.by.t === "input") add({ 이름: m.by.name }); - } - 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 }[] = []; - const rates = new Map(); - const base: LogicRow = { - 키: "", - 원문번호: a.section, - 이름: a.name, - 결과단위: a.resultUnit, - 출처: `${a.book} ${a.section}`, - 소유: "현장", - 입력: [], - 중간: [], - }; - if (a.kind === "값") { - const col = a.valueCol || colsOf(a, "결과")[0]; - const text = col ? valueText(a, col, mids, rates) : "0"; - return { ...base, 입력: inputsOf(a), 중간: mids, 결과: { 식: text } }; - } - const 호표: HoLine[] = a.links - .filter((l) => l.kind !== "없음" && a.roles[l.col] === "결과") - .map((l) => ({ - 종류: l.kind === "노무" ? "인력" : l.kind === "재료" ? "재료" : "로직", - 요소: l.element as string, - 이름: l.name, - ...(l.spec ? { 규격: l.spec } : {}), - 단위: l.unit, - 수량: valueText(a, l.col, mids, rates), - ...(COST[l.kind] ? { 비목: COST[l.kind] } : {}), - })); - const 덧줄 = a.extras.map((e) => - e.k === "비율" - ? { - 이름: 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 }; -} - -/** 예시값 — 첫 표 줄에 걸리도록(선택 칸은 첫 줄 값 · 범위 칸은 그 안 수) · 새 입력값은 율 25 · 나누는 수 1 */ -export function trialOf(a: WAnswers, row: LogicRow): Record { - const first = a.table.줄[0] ?? {}; - const out: Record = {}; - for (const i of row.입력) { - const v = first[i.이름]; - if (Array.isArray(v)) out[i.이름] = v[0] ?? v[1] ?? 1; - else if (v !== undefined && v !== null) out[i.이름] = v; - else if (i.고르기?.length) out[i.이름] = i.고르기[0]; - else { - const cond = a.mods.find( - (m) => m.k === "할증" && m.rate.t === "cond" && m.rate.input === i.이름, - ); - out[i.이름] = - cond && cond.k === "할증" && cond.rate.t === "cond" - ? cond.rate.limit + 1 - : i.단위 === "%" - ? 25 - : 1; - } - } - return out; -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts deleted file mode 100644 index 4410cad55..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts +++ /dev/null @@ -1,558 +0,0 @@ -/* ============================================================================= - * M01_LB_Wizard_Calc.ts - * 단계 안내 4단계(계산 — 주 → 문장 틀) · 5단계(항목 연결) 화면 — `M01_LB_Wizard.ts` 가 부름 - * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -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"; -import { - colsOf, - type Extra, - type Link, - type LinkKind, - type Mod, - type WCtx, -} from "./M01_LB_Wizard_Build"; - -const TEXT = { - NoteHint: [ - "주를 누르면 그 주에 맞는 문장 틀이 뜸 — 안 눌러도 아래에서 바로 고름", - "Press a note to see matching sentences — or pick one below", - ], - NoNote: ["이 표에는 주가 없음", "This table has no notes"], - Frames: ["문장 틀", "Sentence"], - Surcharge: ["할증(%)", "Surcharge (%)"], - Divide: ["나누기", "Divide"], - Min: ["작은 값", "Smaller of"], - Max: ["큰 값", "Larger of"], - Round: ["올림 · 버림", "Round"], - RateType: ["율", "Rate"], - RNum: ["숫자", "Number"], - RInput: ["입력값으로 받음", "Ask as an input"], - RCond: ["조건 목록(표 조회)", "Condition list (table)"], - RName: ["입력값 이름", "Input name"], - Value: ["값", "Value"], - Table: ["찾을 표", "Table"], - ResultCol: ["결과 칸", "Result column"], - Limit: ["이하이면 0 · 넘으면 표에서 찾음", "0 up to the limit · above it use the table"], - DivBy: ["나눌 값", "Divide by"], - DNum: ["숫자", "Number"], - DInput: ["입력값으로 받음", "Ask as an input"], - Places: ["소수 자리", "Decimal places"], - Add: ["넣기", "Add"], - Remove: ["삭제", "Remove"], - Added: ["넣은 문장", "Added"], - None: ["아직 없음", "None yet"], - DSurchargeNum: ["{p} % 를 더함", "add {p} %"], - DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"], - DSurchargeCond: [ - "「{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}"], - DMax: ["{n} 보다 큰 값", "larger of value and {n}"], - DRound: ["{f} — 소수 {n} 자리", "{f} to {n} places"], - Src: ["출처 {s}", "Source {s}"], - Pick5: ["어느 항목으로 보낼까요?", "Which item should each result go to?"], - Labor: ["노무", "Labor"], - Material: ["재료", "Material"], - Machine: ["장비", "Machine"], - Logic: ["일위대가", "Another job"], - Skip: ["연결 안 함", "Skip"], - Auto: ["이름이 같아 저절로 연결", "Matched by name"], - Choose: ["고르기", "Choose"], - Change: ["변경", "Change"], - NotLinked: ["아직 연결 안 됨", "Not linked yet"], - ExtraHead: ["비율 · 기타 항목", "Rate · other items"], - AddRate: ["+ 비율", "+ Rate"], - AddOther: ["+ 기타", "+ Other"], - ItemName: ["이름", "Name"], - Base: ["바탕", "Of"], - RatePct: ["비율 %", "Rate %"], - Amount: ["금액(원)", "Amount (won)"], - Where: ["넣을 자리", "Goes to"], - SumLabor: ["사람 줄 노무비 합", "Labor sum"], - Labor$: ["노무비", "Labor cost"], - Mat$: ["재료비", "Material cost"], - Exp$: ["경비", "Expense"], - RateLine: ["{b} 의 {r} % — {n}", "{r} % of {b} — {n}"], - OtherLine: ["{n} — {a} 원", "{n} — {a} won"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const numOf = (s: string): number | null => - s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null; - -const sel = ( - options: [string, string][], - value: string, - onChange?: (v: string) => void, -): ReturnType => - createSelectField({ - options: options.map(([value, text]) => ({ value, text })), - value, - compact: true, - onChange, - }); - -const row = (label: string, ...kids: HTMLElement[]): HTMLElement => - el("label", { - className: "m01-lbw__line", - children: [el("span", { className: "m01-lbw__muted", text: label }), ...kids], - }); - -/* ── 4단계 계산 ─────────────────────────────────────────────────────────── */ - -const describe = (m: Mod): string => { - const src = m.src ? ` · ${t("Src", { s: m.src })}` : ""; - if (m.k === "할증") { - const r = m.rate; - const text = - r.t === "num" - ? t("DSurchargeNum", { p: r.v }) - : r.t === "input" - ? t("DSurchargeIn", { n: r.name }) - : t("DSurchargeCond", { - i: r.input, - l: r.limit, - o: t(r.op === "<=" ? "OpLe" : "OpLt"), - t: r.table.이름, - c: r.col, - }); - return `${t("Surcharge")} — ${text}${src}`; - } - if (m.k === "나누기") - return `${t("Divide")} — ${m.by.t === "num" ? t("DDivNum", { n: m.by.v }) : t("DDivIn", { n: m.by.name })}${src}`; - if (m.k === "최소" || m.k === "최대") - return `${m.k === "최소" ? t("Min") : t("Max")} — ${t(m.k === "최소" ? "DMin" : "DMax", { n: m.v })}${src}`; - return `${t("Round")} — ${t("DRound", { f: m.fn, n: m.places })}${src}`; -}; - -/** 주 글에서 맞는 문장 틀 추리기 */ -const guess = (note: string): string => - /할증|증가|가산|초과/.test(note) - ? "할증" - : /나누|시공량|당\s*작업/.test(note) - ? "나누기" - : /이상|이하|최대|최소/.test(note) - ? "최대" - : /올림|절상|반올림/.test(note) - ? "올림" - : ""; - -export function stepCalc(ctx: WCtx, host: HTMLElement): void { - const { a } = ctx; - let frame = ""; - let src = ""; - const draw = (): void => { - const notes = a.table.주; - const noteBox = el("div", { className: "m01-lbw__notes" }); - if (!notes.length) noteBox.append(el("p", { className: "m01-lbw__muted", text: t("NoNote") })); - notes.forEach((n, i) => { - const b = el("button", { - className: `m01-lbw__note${src === `주${i}` ? " is-on" : ""}`, - attrs: { type: "button" }, - text: n, - }); - b.addEventListener("click", () => { - src = `주${i}`; - frame = guess(n) || frame; - draw(); - }); - noteBox.append(b); - }); - const noteSrc = (): string | undefined => { - const i = Number(src.slice(1)); - const mark = src && notes[i] ? (notes[i].match(/^[①-⑳]/)?.[0] ?? `${i + 1}`) : ""; - return mark ? `${a.table.출처} 주${mark}` : undefined; - }; - const frames: [string, TKey][] = [ - ["할증", "Surcharge"], - ["나누기", "Divide"], - ["최소", "Min"], - ["최대", "Max"], - ["올림", "Round"], - ]; - const tabs = el("div", { - className: "m01-lbw__chips", - children: frames.map(([k, label]) => { - const b = el("button", { - className: `m01-lbw__chip${frame === k ? " is-on" : ""}`, - attrs: { type: "button" }, - text: t(label), - }); - b.addEventListener("click", () => ((frame = k), draw())); - return b; - }), - }); - const added = el("div", { className: "m01-lbw__added" }); - a.mods.forEach((m, i) => { - const rm = createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => (a.mods.splice(i, 1), ctx.changed(), draw()), - }); - added.append( - el("div", { - className: "m01-lbw__item", - children: [el("span", { text: describe(m) }), rm], - }), - ); - }); - if (!a.mods.length) added.append(el("p", { className: "m01-lbw__muted", text: t("None") })); - host.replaceChildren( - el("p", { className: "m01-lbw__muted", text: t("NoteHint") }), - noteBox, - el("h5", { text: t("Frames") }), - tabs, - frame ? form(frame, noteSrc()) : el("span"), - el("h5", { text: t("Added") }), - added, - ); - }; - - const form = (k: string, from: string | undefined): HTMLElement => { - const box = el("div", { className: "m01-lbw__form" }); - const push = (m: Mod): void => ( - a.mods.push(from ? { ...m, src: from } : m), - (frame = ""), - (src = ""), - ctx.changed(), - draw() - ); - const addBtn = (go: () => void): HTMLElement => createButton({ label: t("Add"), onClick: go }); - if (k === "할증") { - const others = ctx.sectionTables.filter( - (x) => - x.key !== a.table.key && Object.keys(x.조건).length === 1 && Object.keys(x.값칸).length, - ); - const type = sel( - [ - ["num", t("RNum")], - ["input", t("RInput")], - ...(others.length ? ([["cond", t("RCond")]] as [string, string][]) : []), - ], - "num", - () => paint(), - ); - const body = el("div", { className: "m01-lbw__stack" }); - const paint = (): void => { - const num = createInputField({ type: "text", placeholder: "예: 30", compact: true }); - const nm = createInputField({ type: "text", value: "할증률", compact: true }); - const tb = sel( - others.map((x) => [x.key, x.이름]), - others[0]?.key ?? "", - () => paint(), - ); - const tbl = others.find((x) => x.key === tb.select.value) ?? others[0]; - const col = sel( - Object.keys(tbl?.값칸 ?? {}).map((c) => [c, c]), - "", - ); - const lim = createInputField({ type: "text", placeholder: "예: 3", compact: true }); - const op = sel( - [ - ["<=", "≤ (이하)"], - ["<", "< (미만)"], - ], - "<=", - ); - body.replaceChildren( - ...(type.select.value === "num" - ? [row(t("Value"), num.root)] - : type.select.value === "input" - ? [row(t("RName"), nm.root)] - : [ - row(t("Table"), tb.root), - row(t("ResultCol"), col.root), - row(t("Limit"), op.root, lim.root), - ]), - addBtn(() => { - const ty = type.select.value; - const n = numOf(num.input.value); - const l = numOf(lim.input.value); - if (ty === "num" && n !== null) push({ k: "할증", rate: { t: "num", v: n } }); - else if (ty === "input" && nm.input.value.trim()) - push({ k: "할증", rate: { t: "input", name: nm.input.value.trim() } }); - else if (ty === "cond" && tbl && l !== null) - push({ - k: "할증", - rate: { - t: "cond", - input: Object.keys(tbl.조건)[0], - op: op.select.value as "<=" | "<", - limit: l, - table: tbl, - col: col.select.value, - }, - }); - }), - ); - }; - paint(); - box.append(row(t("RateType"), type.root), body); - } else if (k === "나누기") { - const ty = sel( - [ - ["num", t("DNum")], - ["input", t("DInput")], - ], - "num", - () => paint(), - ); - const f = createInputField({ type: "text", placeholder: "예: 8", compact: true }); - const body = el("div", { className: "m01-lbw__stack" }); - const paint = (): void => { - f.input.value = ty.select.value === "input" ? "시공량" : ""; - body.replaceChildren(row(t("DivBy"), f.root)); - }; - paint(); - box.append( - row(t("DivBy"), ty.root), - body, - addBtn(() => { - const n = numOf(f.input.value); - if (ty.select.value === "num" && n !== null && n !== 0) - push({ k: "나누기", by: { t: "num", v: n } }); - else if (ty.select.value === "input" && f.input.value.trim()) - push({ k: "나누기", by: { t: "input", name: f.input.value.trim() } }); - }), - ); - } else if (k === "최소" || k === "최대") { - const f = createInputField({ type: "text", placeholder: "예: 100", compact: true }); - box.append( - row(t(k === "최소" ? "Min" : "Max"), f.root), - addBtn(() => { - const n = numOf(f.input.value); - if (n !== null) push({ k, v: n }); - }), - ); - } else { - const fn = sel( - [ - ["올림", "올림"], - ["버림", "버림"], - ["반올림", "반올림"], - ], - "올림", - ); - const p = createInputField({ type: "text", value: "2", compact: true }); - box.append( - row(t("Round"), fn.root), - row(t("Places"), p.root), - addBtn(() => { - const n = numOf(p.input.value); - if (n !== null && n >= 0) - push({ k: "올림", fn: fn.select.value as "올림", places: Math.floor(n) }); - }), - ); - } - return box; - }; - draw(); -} - -/* ── 5단계 항목 연결 ───────────────────────────────────────────────────── */ - -/** 처음 들어올 때 — 결과 칸 이름이 노임 · 자재 이름과 같으면 저절로 연결 */ -export async function autoLinks(ctx: WCtx): Promise { - const { a } = ctx; - for (const col of colsOf(a, "결과")) { - 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 { - const job = (await fetchPick("job", col)).items.find((i) => i.이름 === col); - if (job) Object.assign(link, { kind: "노무", unit: unit || "인", element: job.ref }); - else { - const mat = (await fetchPick("price", col)).items.find((i) => i.이름 === col); - if (mat) - Object.assign(link, { - kind: "재료", - spec: mat.규격, - unit: unit || mat.단위, - element: { - 구분: (mat as { 구분?: string }).구분 ?? "", - 검색어: `${mat.이름} ${mat.규격}`.trim(), - }, - }); - } - } catch { - /* 못 찾으면 연결 안 함으로 둠 */ - } - a.links = a.links.filter((l) => l.col !== col); - a.links.push(link); - } - ctx.changed(); -} - -const KINDS: [LinkKind, TKey, "person" | "material" | "machine" | "logic"][] = [ - ["노무", "Labor", "person"], - ["재료", "Material", "material"], - ["장비", "Machine", "machine"], - ["일위대가", "Logic", "logic"], -]; - -export function stepLink(ctx: WCtx, host: HTMLElement): void { - const { a } = ctx; - const draw = (): void => { - const rows = colsOf(a, "결과").map((col) => { - const link = a.links.find((l) => l.col === col) ?? { - col, - kind: "없음" as LinkKind, - name: col, - spec: "", - unit: a.table.값칸[col] ?? "", - element: null, - }; - if (!a.links.includes(link)) a.links.push(link); - const pick = async ( - kind: LinkKind, - pk: "person" | "material" | "machine" | "logic", - ): Promise => { - const r = await openEasyPick(pk, { qty: 1 }); - if (!r) return; - Object.assign(link, { - kind, - name: r.name, - spec: r.spec, - unit: a.table.값칸[col] || r.unit, - element: r.element, - needInputs: r.needInputs, - }); - ctx.changed(); - draw(); - }; - const btns = KINDS.map(([kind, label, pk]) => { - const b = el("button", { - className: `m01-lbw__chip${link.kind === kind ? " is-on" : ""}`, - attrs: { type: "button" }, - text: t(label), - }); - b.addEventListener( - "click", - () => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")), - ); - return b; - }); - const none = el("button", { - className: `m01-lbw__chip${link.kind === "없음" ? " is-on" : ""}`, - attrs: { type: "button" }, - text: t("Skip"), - }); - none.addEventListener( - "click", - () => (Object.assign(link, { kind: "없음", element: null }), ctx.changed(), draw()), - ); - const state = link.kind === "없음" ? t("NotLinked") : `${link.name} ${link.spec}`.trim(); - return el("div", { - className: "m01-lbw__link", - children: [ - el("strong", { text: `${col} (${a.table.값칸[col] || "—"})` }), - el("div", { className: "m01-lbw__chips", children: [...btns, none] }), - el("span", { className: "m01-lbw__muted", text: state }), - ], - }); - }); - host.replaceChildren( - el("p", { className: "m01-lbw__muted", text: t("Pick5") }), - ...rows, - extras(ctx, draw), - ); - }; - draw(); -} - -/** 비율 · 기타 덧줄 */ -function extras(ctx: WCtx, redraw: () => void): HTMLElement { - const { a } = ctx; - const costs: [string, string][] = [ - ["노무비", t("Labor$")], - ["재료비", t("Mat$")], - ["경비", t("Exp$")], - ]; - const bases: [string, string][] = [["사람줄합", t("SumLabor")], ...costs]; - const list = a.extras.map((e: Extra, i) => { - const text = - e.k === "비율" - ? t("RateLine", { - b: bases.find(([v]) => v === e.base)?.[1] ?? e.base, - r: e.rate, - n: e.name, - }) - : t("OtherLine", { n: e.name, a: e.amount }); - return el("div", { - className: "m01-lbw__item", - children: [ - el("span", { text }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => (a.extras.splice(i, 1), ctx.changed(), redraw()), - }), - ], - }); - }); - const name = createInputField({ type: "text", placeholder: t("ItemName"), compact: true }); - const base = sel(bases, "사람줄합"); - const val = createInputField({ type: "text", placeholder: t("RatePct"), compact: true }); - const where = sel(costs, "재료비"); - const add = (k: "비율" | "기타"): void => { - const n = numOf(val.input.value); - const nm = name.input.value.trim(); - if (n === null || !nm) return; - const cost = where.select.value as "노무비"; - a.extras.push( - k === "비율" - ? { k, name: nm, base: base.select.value as "노무비", rate: n, cost } - : { k, name: nm, amount: n, cost }, - ); - ctx.changed(); - redraw(); - }; - return el("div", { - className: "m01-lbw__extras", - children: [ - el("h5", { text: t("ExtraHead") }), - ...list, - el("div", { - className: "m01-lbw__form", - children: [ - row(t("ItemName"), name.root), - row(t("Base"), base.root), - row(`${t("RatePct")} / ${t("Amount")}`, val.root), - row(t("Where"), where.root), - el("div", { - className: "m01-lbw__chips", - children: [ - createButton({ label: t("AddRate"), onClick: () => add("비율") }), - createButton({ label: t("AddOther"), variant: "ghost", onClick: () => add("기타") }), - ], - }), - ], - }), - ], - }); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts deleted file mode 100644 index 3f3773190..000000000 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* ============================================================================= - * 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/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index b0daf8a6a..c9faeac41 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -39,12 +39,6 @@ import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; -// 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry"; -// 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry"; -// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createSheetLogicButton } from "./SheetLogic/M01_SL_Entry"; // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry"; import "./M01_MasterData_UI_Logic_Style.css"; @@ -451,12 +445,6 @@ export async function mountM01LogicLab( pending, createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), trialMenu([ - // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createEasyCalcButton(), - // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createLogicBuilderButton(), - // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createSheetLogicButton(), // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 createCostFlowButton(), ]), diff --git a/M01_MasterData/SheetLogic/.gitignore b/M01_MasterData/SheetLogic/.gitignore deleted file mode 100644 index c10c784e3..000000000 --- a/M01_MasterData/SheetLogic/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_data/ diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts deleted file mode 100644 index 1a6dba9e2..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Api.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* ============================================================================= - * M01_SL_Api.ts - * 시트 로직 Api — 목록 · 열기 · 저장(서버가 바깥 값을 다시 넣고 같은 엔진으로 다시 셈) · 지우기 · - * 바깥 값 한 번에 받기(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과) - * 서버 = `M01_SL_Router.py`(`/api/m01/sl/*`) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; -import { ApiError } from "../M01_MasterData_UI_Logic_Api"; -import type { SlOutsideAnswer } from "./M01_SL_Eval"; -import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model"; - -/* 모양 = sub7 Model(SheetLogic · SlSource · SlField) · 답 = Eval(SlOutsideAnswer) · 아래는 Api 몫만. - * 저장 때 서버가 바깥 값을 넣는 칸 = ① Model 규칙(알약 outside 참 · 상자 source · 없으면 표 · 로직 ref) - * ② 저장 몸의 `outside: SlOutsideAt[]`(Item `itemOutsides` — 장비처럼 칸마다 비목이 다른 것) · 값은 늘 마스터에서. */ - -export type { SlField }; - -/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` · 자재 `key` = 키 또는 검색어 묶음 JSON 글 - * (`{구분, 상세구분, 검색어, 규격?}`) · `unit` = 그 줄 단위(주면 기존 로직 재료 줄과 같은 단가) */ -export type SlQuery = { id: string; unit?: string } & SlSource; -/** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */ -export type SlOutside = SlQuery; - -/** 바깥 값 자리 하나 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ -export interface SlOutsideAt { - item: SlQuery; - sheet: string; - cell: string; -} - -/** 목록 한 줄 */ -export interface SlListItem { - key: string; - 이름: string; - 결과단위: string; - /** 마지막 저장 때(ISO) */ - saved: string; -} - -/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */ -export interface SlSaved { - key: string; - logic: SheetLogic; - values: SlOutsideAnswer[]; -} - -async function send(path: string, body?: unknown): Promise { - let response: Response; - try { - response = await fetch(`${API_BASE_URL}/m01/sl${path}`, { - method: body === undefined ? "GET" : "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch { - throw new ApiError(0, ""); - } - const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); - return data; -} - -/** 내 시트 로직 목록(저장 차례) */ -export async function slList(): Promise { - return (await send<{ logics: SlListItem[] }>("/list")).logics; -} - -export async function slGet(key: string): Promise { - return (await send<{ logic: SheetLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)) - .logic; -} - -/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */ -export async function slSave(logic: SheetLogic & { outside?: SlOutsideAt[] }): Promise { - return send("/save", { logic }); -} - -export async function slDelete(key: string): Promise { - await send<{ key: string }>("/delete", { key }); -} - -/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */ -export async function slOutside(items: SlQuery[]): Promise { - return (await send<{ values: SlOutsideAnswer[] }>("/outside", { items })).values; -} - -/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */ -export function slPlain(e: unknown): string { - return plainError(e); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.css b/M01_MasterData/SheetLogic/M01_SL_Diagram.css deleted file mode 100644 index e02858f66..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Diagram.css +++ /dev/null @@ -1,251 +0,0 @@ -/* 시트 로직 — 위 가로 다이어그램 */ -.m01-sldg { - --sldg-in: var(--color-accent); - --sldg-out: #c2610c; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - font-size: var(--text-body-sm); - outline: none; -} - -:root[data-theme="dark"] .m01-sldg { - --sldg-out: #e5a866; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .m01-sldg { - --sldg-out: #e5a866; - } -} - -.m01-sldg__bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - min-height: 2rem; -} - -.m01-sldg__note { - color: var(--color-danger); -} - -.m01-sldg__muted { - margin: 0; - color: var(--color-text-muted); -} - -.m01-sldg__menu { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); -} - -.m01-sldg__menu[hidden] { - display: none; -} - -/* 띠 — 세로는 상자 높이만큼 다 보임(사용자 요청) · 길면 가로로 밀기 */ -.m01-sldg__band { - overflow-x: auto; - overflow-y: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); -} - -.m01-sldg__inner { - position: relative; - display: grid; - grid-template-columns: repeat(5, minmax(11rem, 1fr)); - gap: var(--spacing-24); - min-width: max-content; - padding: var(--spacing-12) var(--spacing-16); -} - -.m01-sldg__lines { - position: absolute; - inset: 0 auto auto 0; - overflow: visible; - pointer-events: none; - z-index: 0; -} - -.m01-sldg__col { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 11rem; -} - -.m01-sldg__col-head { - color: var(--color-text-muted); - font-size: var(--text-caption); - letter-spacing: 0.04em; -} - -/* 상자 */ -.m01-sldg__box { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - cursor: pointer; -} - -.m01-sldg__box:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - -.m01-sldg__box.is-selected { - border: 2px solid var(--color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); -} - -.m01-sldg__box-head { - display: flex; - flex-direction: column; -} - -.m01-sldg__kind { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-sldg__name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.m01-sldg__pills { - display: flex; - justify-content: space-between; - gap: var(--spacing-8); -} - -.m01-sldg__ins, -.m01-sldg__outs { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m01-sldg__outs { - align-items: flex-end; -} - -/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */ -.m01-sldg__pill { - display: inline-flex; - align-items: baseline; - gap: var(--spacing-4); - max-width: 10rem; - padding: 0 var(--spacing-8); - border: 1px solid var(--sldg-in); - border-radius: 999px; - background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface)); - color: var(--sldg-in); - font: inherit; - font-size: var(--text-caption); - cursor: pointer; - white-space: nowrap; -} - -.m01-sldg__pill.is-out { - border-color: var(--sldg-out); - background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface)); - color: var(--sldg-out); - cursor: grab; - touch-action: none; -} - -.m01-sldg__pill.is-empty { - border-style: dashed; - border-color: var(--color-text-muted); - background: transparent; - color: var(--color-text-muted); -} - -.m01-sldg__pill.is-chosen { - box-shadow: 0 0 0 2px currentColor; -} - -.m01-sldg.is-linking .m01-sldg__pill.is-target { - box-shadow: 0 0 0 2px var(--sldg-in); - animation: m01-sldg-blink 1s ease-in-out infinite alternate; -} - -@keyframes m01-sldg-blink { - to { - background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface)); - } -} - -.m01-sldg__pill-name { - overflow: hidden; - text-overflow: ellipsis; -} - -.m01-sldg__pill-value { - color: var(--color-text-secondary); - font-variant-numeric: tabular-nums; -} - -.m01-sldg__pill-value:empty { - display: none; -} - -/* 선 */ -.m01-sldg__edge { - pointer-events: auto; - cursor: pointer; -} - -.m01-sldg__hit { - fill: none; - stroke: transparent; - stroke-width: 12; -} - -.m01-sldg__wire { - fill: none; - stroke: var(--color-text-muted); - stroke-width: 1.5; -} - -.m01-sldg__edge.is-strong .m01-sldg__wire { - stroke: var(--color-accent); - stroke-width: 3; -} - -.m01-sldg__edge.is-dim .m01-sldg__wire { - opacity: 0.3; -} - -.m01-sldg__drag { - fill: none; - stroke: var(--sldg-out); - stroke-width: 2; - stroke-dasharray: 5 4; -} - -@media (prefers-reduced-motion: reduce) { - .m01-sldg.is-linking .m01-sldg__pill.is-target { - animation: none; - } -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts deleted file mode 100644 index f9bbf1801..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts +++ /dev/null @@ -1,513 +0,0 @@ -/* ============================================================================= - * M01_SL_Diagram.ts - * 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」) - * 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) · - * 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG. - * 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect · - * 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」. - * 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; -import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model"; -import "./M01_SL_Diagram.css"; - -const TEXT = { - Stage0: ["입력값", "Inputs"], - Stage1: ["표", "Tables"], - Stage2: ["계산", "Calculations"], - Stage3: ["항목", "Items"], - Stage4: ["결과", "Result"], - K_input: ["입력값", "Input"], - K_const: ["고정값", "Fixed value"], - K_table: ["표", "Table"], - K_calc: ["계산", "Calculation"], - K_item: ["항목", "Item"], - K_logic: ["다른 로직", "Other logic"], - K_result: ["결과", "Result"], - Add: ["+ 상자", "+ Box"], - AddTitle: ["더할 상자 종류", "Kind of box to add"], - Cancel: ["취소", "Cancel"], - Connect: ["잇기", "Connect"], - Disconnect: ["끊기", "Disconnect"], - Remove: ["상자 지우기", "Remove box"], - AskRemove: [ - "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", - "Remove box “{v}”? Its lines go too", - ], - PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"], - Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"], - NoOut: ["내보내는 알약이 없음", "No output pills"], - NoIn: ["받는 알약이 없음", "No input pills"], - SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], - NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"], - NoMatch: [ - "이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요", - "No input pill with the same name — drop on the pill itself", - ], - Taken: [ - "이미 이어진 입력 알약 — 먼저 그 선을 끊으세요", - "That input already has a line — disconnect it first", - ], - From: ["어디서", "From"], - To: ["어디로", "To"], - Value: ["지금 값", "Now"], - LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], -} as const; - -const td = (key: keyof typeof TEXT, fill: Record = {}): string => - TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); - -/** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */ -export const STAGE_OF: Record = { - input: 0, - const: 0, - logic: 0, - table: 1, - calc: 2, - item: 3, - result: 4, -}; - -const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"]; -const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT); - -export interface DiagramOn { - select(id: string | null): void; - /** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */ - connect(from: SlEnd, to: SlEnd): string | null | void; - disconnect(edgeId: string): void; - add(kind: SlKind, stage: number): void; - remove(nodeId: string): void; -} - -export interface DiagramOpts { - /** 상자 id → 알약 이름 → 지금 값 */ - values?: Record>; - /** 고른 상자 id · 선 id */ - selected?: string | null; -} - -/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */ - -/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */ -export function layout(nodes: SlNode[]): SlNode[][] { - const cols: SlNode[][] = [[], [], [], [], []]; - for (const n of nodes) { - const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind]; - cols[Math.min(4, Math.max(0, s ?? 0))].push(n); - } - return cols; -} - -/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */ -export function resolveDrop( - sl: SheetLogic, - from: SlEnd, - target: { node: string; pill: string | null; dir: "in" | "out" | null } | null, -): { to: SlEnd } | { why: string } { - if (!target) return { why: td("NotIn") }; - if (target.node === from.node) return { why: td("SameBox") }; - if (target.dir === "out") return { why: td("NotIn") }; - const node = sl.nodes.find((n) => n.id === target.node); - if (!node?.pillsIn.length) return { why: td("NoIn") }; - const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name; - if (!name) return { why: td("NoMatch") }; - if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name)) - return { why: td("Taken") }; - return { to: { node: node.id, pill: name } }; -} - -/* ── 그리기 ─────────────────────────────────────────────────────── */ - -interface HostState { - selected: string | null; - /** 잇기 중 — 출력 알약 */ - linking: SlEnd | null; - /** 고른 알약(정보 줄) */ - pill: { node: string; pill: string; dir: "in" | "out" } | null; - note: string; - scroll: number; - /** 선 다시 그리기 — 띠 크기 바뀜 */ - watch?: ResizeObserver; -} - -const states = new WeakMap(); - -const showValue = (v: unknown): string => { - if (v === null || v === undefined || v === "") return ""; - if (typeof v === "number") - return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4)); - return String(v).slice(0, 12); -}; - -const edgeTouches = (e: SlEdge, node: string): boolean => - e.from.node === node || e.to.node === node; - -export function renderDiagram( - host: HTMLElement, - sl: SheetLogic, - on: DiagramOn, - opts: DiagramOpts = {}, -): void { - const old = states.get(host); - const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 }; - if (opts.selected !== undefined) st.selected = opts.selected; - states.set(host, st); - const values = opts.values ?? {}; - const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? ""; - const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected }); - const select = (id: string | null): void => { - st.selected = id; - st.pill = null; - st.note = ""; - on.select(id); - redraw(); - }; - const tryConnect = (from: SlEnd, target: Parameters[2]): void => { - const got = resolveDrop(sl, from, target); - st.linking = null; - if ("why" in got) st.note = got.why; - else st.note = on.connect(from, got.to) || ""; - redraw(); - }; - - /* 상자 */ - const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => { - const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name); - const linking = st.linking; - const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir; - const b = el("button", { - className: [ - "m01-sldg__pill", - `is-${dir}`, - wired ? "" : "is-empty", - chosen ? "is-chosen" : "", - linking && dir === "in" && n.id !== linking.node ? "is-target" : "", - ].join(" "), - attrs: { - type: "button", - "data-node": n.id, - "data-pill": name, - "data-dir": dir, - title: unit ? `${name} (${unit})` : name, - }, - children: [ - el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }), - el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }), - ], - }); - b.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking && dir === "in") - return tryConnect(st.linking, { node: n.id, pill: name, dir }); - st.pill = chosen ? null : { node: n.id, pill: name, dir }; - st.linking = null; - st.note = ""; - redraw(); - }); - if (dir === "out") - b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name })); - return b; - }; - - const boxEl = (n: SlNode): HTMLElement => { - const box = el("div", { - className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join( - " ", - ), - attrs: { - "data-node": n.id, - tabindex: "0", - role: "button", - "aria-label": `${kindLabel(n.kind)} · ${n.name}`, - }, - children: [ - el("div", { - className: "m01-sldg__box-head", - children: [ - el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }), - el("strong", { className: "m01-sldg__name", text: n.name }), - ], - }), - el("div", { - className: "m01-sldg__pills", - children: [ - el("div", { - className: "m01-sldg__ins", - children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)), - }), - el("div", { - className: "m01-sldg__outs", - children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)), - }), - ], - }), - ], - }); - box.addEventListener("click", () => { - if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null }); - select(st.selected === n.id ? null : n.id); - }); - box.addEventListener("keydown", (ev) => { - if (ev.target !== box) return; - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - box.click(); - } - }); - return box; - }; - - const cols = layout(sl.nodes); - const inner = el("div", { - className: "m01-sldg__inner", - children: cols.map((list, i) => - el("div", { - className: "m01-sldg__col", - children: [ - el("div", { - className: "m01-sldg__col-head", - text: td(`Stage${i}` as keyof typeof TEXT), - }), - ...list.map(boxEl), - ], - }), - ), - }); - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.classList.add("m01-sldg__lines"); - inner.prepend(svg); - inner.addEventListener("click", (ev) => { - if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) { - st.linking = null; - select(null); - } - }); - - /* 위 줄 — 더하기 · 고른 것 · 할 일 */ - const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } }); - addMenu.append( - el("span", { className: "m01-sldg__muted", text: td("AddTitle") }), - ...KINDS.map((k) => - createButton({ - label: kindLabel(k), - size: "sm", - variant: "ghost", - onClick: () => { - addMenu.hidden = true; - on.add(k, STAGE_OF[k]); - }, - }), - ), - ); - const addBtn = createButton({ - label: td("Add"), - size: "sm", - onClick: () => (addMenu.hidden = !addMenu.hidden), - }); - const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null; - const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null; - const tools: HTMLElement[] = []; - if (st.linking) { - tools.push( - el("span", { text: td("PickTarget", { v: st.linking.pill }) }), - createButton({ - label: td("Cancel"), - size: "sm", - variant: "ghost", - onClick: () => ((st.linking = null), redraw()), - }), - ); - } else if (st.pill) { - const p = st.pill; - const linked = sl.edges.filter((e) => - p.dir === "in" - ? e.to.node === p.node && e.to.pill === p.pill - : e.from.node === p.node && e.from.pill === p.pill, - ); - const where = linked - .map((e) => - p.dir === "in" - ? `${nodeName(e.from.node)} · ${e.from.pill}` - : `${nodeName(e.to.node)} · ${e.to.pill}`, - ) - .join(", "); - tools.push( - el("span", { - text: [ - `${p.pill}`, - `${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`, - where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "", - ] - .filter(Boolean) - .join(" · "), - }), - ); - if (p.dir === "out") - tools.push( - createButton({ - label: td("Connect"), - size: "sm", - onClick: () => { - st.linking = { node: p.node, pill: p.pill }; - st.pill = null; - redraw(); - }, - }), - ); - } else if (selEdge) { - tools.push( - el("span", { - text: td("LineOf", { - a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`, - b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`, - }), - }), - createButton({ - label: td("Disconnect"), - size: "sm", - variant: "danger", - onClick: () => on.disconnect(selEdge.id), - }), - ); - } else if (selNode) { - tools.push( - createButton({ - label: td("Remove"), - size: "sm", - variant: "danger", - onClick: () => void askRemove(selNode), - }), - ); - } - const askRemove = async (n: SlNode): Promise => { - if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id); - }; - - const band = el("div", { className: "m01-sldg__band", children: [inner] }); - const root = el("div", { - className: `m01-sldg${st.linking ? " is-linking" : ""}`, - attrs: { tabindex: "-1" }, - children: [ - el("div", { - className: "m01-sldg__bar", - children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })], - }), - addMenu, - sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("Empty") }), - ], - }); - root.addEventListener("keydown", (ev) => { - if (ev.key === "Escape" && (st.linking || st.pill)) { - st.linking = null; - st.pill = null; - redraw(); - } - if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; - if (selEdge) on.disconnect(selEdge.id); - else if (selNode) void askRemove(selNode); - }); - band.addEventListener("scroll", () => (st.scroll = band.scrollLeft)); - host.replaceChildren(root); - band.scrollLeft = st.scroll; - - /* 선 — 붙인 뒤 알약 자리를 재서 그림 */ - const drawLines = (): void => { - const box = inner.getBoundingClientRect(); - svg.setAttribute("width", String(inner.scrollWidth)); - svg.setAttribute("height", String(inner.scrollHeight)); - svg.replaceChildren(); - const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => { - const pill = inner.querySelector( - `.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`, - ); - if (!pill) return null; - const r = pill.getBoundingClientRect(); - return { - x: (dir === "out" ? r.right : r.left) - box.left, - y: r.top + r.height / 2 - box.top, - }; - }; - for (const e of sl.edges) { - const a = at(e.from, "out"); - const b = at(e.to, "in"); - if (!a || !b) continue; - const dx = Math.max(30, Math.abs(b.x - a.x) / 2); - const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; - const strong = st.selected === e.id || (selNode !== null && edgeTouches(e, selNode.id)); - const dim = st.selected !== null && !strong; - const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); - g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`); - g.setAttribute("data-edge", e.id); - g.setAttribute("data-x1", a.x.toFixed(1)); - g.setAttribute("data-y1", a.y.toFixed(1)); - g.setAttribute("data-x2", b.x.toFixed(1)); - g.setAttribute("data-y2", b.y.toFixed(1)); - for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) { - const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); - p.setAttribute("d", d); - p.setAttribute("class", cls); - g.append(p); - } - g.addEventListener("click", (ev) => { - ev.stopPropagation(); - select(st.selected === e.id ? null : e.id); - }); - svg.append(g); - } - }; - // 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기) - st.watch?.disconnect(); - st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines); - st.watch?.observe(inner); - if (inner.isConnected) drawLines(); - - /* 끌어 잇기 */ - function startDrag(ev: PointerEvent, from: SlEnd): void { - if (ev.button !== 0) return; - const pill = ev.currentTarget as HTMLElement; - const box = inner.getBoundingClientRect(); - const r = pill.getBoundingClientRect(); - const x0 = r.right - box.left; - const y0 = r.top + r.height / 2 - box.top; - let line: SVGPathElement | null = null; - const move = (m: PointerEvent): void => { - const x = m.clientX - box.left; - const y = m.clientY - box.top; - if (!line && Math.hypot(x - x0, y - y0) < 6) return; - if (!line) { - line = document.createElementNS("http://www.w3.org/2000/svg", "path"); - line.setAttribute("class", "m01-sldg__drag"); - svg.append(line); - } - line.setAttribute("d", `M${x0},${y0} L${x},${y}`); - }; - const up = (u: PointerEvent): void => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - if (!line) return; // 끌지 않음 = 누름(click 이 처리) - line.remove(); - const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; - const pillHit = hit?.closest(".m01-sldg__pill"); - const boxHit = hit?.closest(".m01-sldg__box"); - const target = pillHit - ? { - node: pillHit.dataset.node ?? "", - pill: pillHit.dataset.pill ?? null, - dir: (pillHit.dataset.dir as "in" | "out") ?? null, - } - : boxHit - ? { node: boxHit.dataset.node ?? "", pill: null, dir: null } - : null; - // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 - const swallow = (c: MouseEvent): void => c.stopPropagation(); - window.addEventListener("click", swallow, { capture: true, once: true }); - setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); - tryConnect(from, target); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - } -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css deleted file mode 100644 index 0e04f2724..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.css +++ /dev/null @@ -1,175 +0,0 @@ -/* 시트 로직 — 큰 창 · 시작 화면 · 위 띠 · 다이어그램 / 시트 판 / 결과 자리 */ - -/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 */ -.ui-modal.m01-sl { - --modal-max: 92vh; - width: min(1680px, 98vw); - height: var(--modal-max); - font-size: var(--text-body); -} - -/* 세 칸은 내용 높이만큼 — 넘치면 창(모달) 전체만 세로로 밈 */ -.m01-sl .ui-modal__body { - flex: 0 0 auto; - gap: var(--spacing-12); -} - -.m01-sl__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-sl__trial-note { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-sl__big { - min-height: 56px; - padding: 0 var(--spacing-24); - font-size: var(--text-body); -} - -/* 시작 화면 — 왼쪽 새로 · 내 것 · 오른쪽 가져오기 */ -.m01-sl__start { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); - gap: var(--spacing-24); - min-height: 0; - overflow: auto; -} - -.m01-sl__start-col { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -.m01-sl__start-col h4, -.m01-sl__start-col h5 { - margin: var(--spacing-8) 0 0; -} - -.m01-sl__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-sl__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - width: 100%; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.m01-sl__item:hover { - border-color: var(--color-accent); -} - -.m01-sl__item-name { - flex: 1; - min-width: 0; -} - -.m01-sl__item-unit { - color: var(--color-text-muted); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -/* 위 띠 */ -.m01-sl__strip { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); - padding-bottom: var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m01-sl__name { - flex: 1 1 280px; -} - -.m01-sl__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; -} - -/* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */ -.m01-sl__layout { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - grid-template-rows: auto auto; - align-items: start; - grid-template-areas: - "diagram diagram" - "panel result"; - gap: var(--spacing-12); -} - -.m01-sl__diagram { - grid-area: diagram; -} - -.m01-sl__panel { - grid-area: panel; -} - -.m01-sl__result { - grid-area: result; -} - -.m01-sl__part { - display: flex; - flex-direction: column; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); -} - -.m01-sl__part h4 { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m01-sl__part-body { - min-width: 0; - padding: var(--spacing-12); -} - -/* 좁은 화면 — 자리를 위아래로 */ -@media (max-width: 1100px) { - .m01-sl__layout { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto; - grid-template-areas: - "diagram" - "panel" - "result"; - } -} - -.m01-sl__note { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: pre-line; -} - -.m01-sl__note:empty { - display: none; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts deleted file mode 100644 index 978af0f4e..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.ts +++ /dev/null @@ -1,299 +0,0 @@ -/* ============================================================================= - * M01_SL_Entry.ts - * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 잇기 - * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄). - * 고치는 화면은 M01_SL_Entry_Paper.ts · SheetLogic 의 다른 파일은 창을 열 때 import() 로 늦게 불러옴. - * 열기 = slGet → 바깥 값(slOutside → applyOutside) → 고치는 화면 · 가져오기 = 기존 로직 줄 + 품셈 표 → importLogicReport. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { listEasy, openEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; -import type { SlListItem } from "./M01_SL_Api"; -import type { SheetLogic } from "./M01_SL_Model"; -import type { SlImportTable } from "./M01_SL_Import"; -import { fillOutside, loadMods, showPaper, type Mods } from "./M01_SL_Entry_Paper"; -import "./M01_SL_Entry.css"; - -const TEXT = { - Open: ["시트 로직(시험)", "Sheet logic (trial)"], - Title: ["시트 로직", "Sheet logic"], - CloseWindow: ["창 닫기", "Close window"], - Loading: ["불러오는 중", "Loading"], - LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], - OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], - Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], - Start_Mine: ["내 시트 로직", "My sheet logics"], - Start_MineNone: ["아직 없음", "None yet"], - Start_New: ["새로 만들기(빈 판)", "New (blank)"], - Start_Import: ["기존 로직 가져오기", "Import an existing logic"], - Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], - Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], - Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], - Start_None: ["찾은 것 없음", "Nothing found"], - Per: ["{u} 당", "per {u}"], - Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"], - Leave_Ask: [ - "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", - "There are unsaved changes — close without saving?", - ], - Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], -} as const; - -type TextKey = keyof typeof TEXT; - -const te = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -/** 「기존 로직 가져오기」 에 먼저 보이는 시험 로직 여섯(정함 9) */ -const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; -/** 찾은 것 목록에 한 번에 보이는 줄 수 */ -const SHOWN = 50; - -/** 「원/㎡」 → 「1㎡」 · 「100㎡」 은 앞에 1 을 안 붙임 */ -const amountOf = (resultUnit: string): string => { - const unit = resultUnit.replace(/^원\//, ""); - return /^\d/.test(unit) ? unit : `1${unit}`; -}; -const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); - -/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ -export function createSheetLogicButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", onClick: openSheetLogic }); -} - -function openSheetLogic(): void { - let body: HTMLElement; - /** 고치는 화면이 열려 있을 때만 */ - let paper: ReturnType | null = null; - let logics: EasyListItem[] | null = null; - let query = ""; - const mods = loadMods(); - - const mayLeave = async (): Promise => - !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - - /** Ctrl+Z · Ctrl+S — 이 창 안에서만(시트 편집기가 먼저 먹은 키는 그쪽 몫) */ - const onKey = (ev: KeyboardEvent): void => { - if (!paper || !(ev.ctrlKey || ev.metaKey) || ev.defaultPrevented) return; - const key = ev.key.toLowerCase(); - if (key === "z" && !ev.shiftKey) { - ev.preventDefault(); - paper.undo(); - } else if (key === "s") { - ev.preventDefault(); - void paper.save(); - } - }; - - /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ - const unitChoices = (): string[] => { - const count = new Map(); - for (const x of logics ?? []) { - if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); - } - const units = [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - return units.length ? units : ["원/㎡"]; - }; - - /** 고치는 화면 열기 — 바깥 값을 넣은 뒤 · 새로 · 가져오기는 처음부터 「저장 안 함」 */ - const open = async ( - M: Mods, - sl: SheetLogic, - saved: boolean, - notes: string[] = [], - ): Promise => { - let filled = sl; - try { - const got = await fillOutside(M, sl); - filled = got.sl; - if (got.errors.length) notes = [...notes, te("Outside", { v: got.errors.join(" · ") })]; - } catch (e) { - notes = [...notes, te("Outside", { v: M.api.slPlain(e) })]; - } - paper = showPaper(M, body, filled, { - saved, - units: unitChoices(), - notes, - onClose: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }); - }; - - const run = (job: (M: Mods) => Promise): void => { - body.replaceChildren(muted(te("Loading"))); - void mods.then(job).catch((e: unknown) => { - showToast(te("OpenFailed", { v: plainError(e) }), "error"); - void showStart(); - }); - }; - - const openSaved = (key: string): void => run(async (M) => open(M, await M.api.slGet(key), true)); - - const openNew = (): void => - run(async (M) => open(M, M.model.newLogic("", unitChoices()[0]), false)); - - /** 기존 로직 가져오기 — 로직 줄 + 그 줄이 쓰는 품셈 표(열 때 받음) → 시트 로직(저장 전) */ - const doImport = (key: string): void => - run(async (M) => { - const { row } = await openEasy(key); - const lb = await import("../LogicBuilder/M01_LB_Api"); - const keys = [...new Set(JSON.stringify(row).match(/Q[A-Z]\d{6}/g) ?? [])]; - const got = await Promise.all( - keys.map((k) => - lb.fetchLbTable(k).then( - (t) => [k, t.table] as const, - () => null, - ), - ), - ); - // 서버 표 통째(조건 · 값칸 · 줄 포함) — LB Api 타입이 느슨해 Import 모양으로 봄 - const tables = Object.fromEntries(got.filter((x) => x !== null)) as unknown as Record< - string, - SlImportTable - >; - const report = M.imp.importLogicReport(row, { tables }); - await open(M, report.sl, false, report.problems); - }); - - /* ---------- 시작 화면 ---------- */ - - const listButton = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { - const row = el("button", { - className: "m01-sl__item", - attrs: { type: "button", title }, - children: [ - el("span", { className: "m01-sl__item-name", text: name }), - el("span", { className: "m01-sl__item-unit", text: unit }), - ], - }); - row.addEventListener("click", onClick); - return row; - }; - const perText = (u: string): string => (isMoney(u) ? te("Per", { u: amountOf(u) }) : u); - const importRow = (item: EasyListItem): HTMLElement => - listButton(item.name, perText(item.resultUnit), () => doImport(item.key), item.where); - const mineRow = (item: SlListItem): HTMLElement => - listButton(item.이름, perText(item.결과단위), () => openSaved(item.key), item.saved); - - const showStart = async (): Promise => { - paper = null; - const status = el("p", { className: "m01-sl__muted", text: te("Loading") }); - const trialHost = el("div", { className: "m01-sl__list" }); - const foundHost = el("div", { className: "m01-sl__list" }); - const mineHost = el("div", { className: "m01-sl__list", children: [muted(te("Loading"))] }); - void mods - .then((M) => M.api.slList()) - .then( - (mine) => - mineHost.replaceChildren( - ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), - ), - (e: unknown) => mineHost.replaceChildren(muted(te("LoadFailed", { v: plainError(e) }))), - ); - const search = createInputField({ - type: "search", - placeholder: te("Start_Search"), - value: query, - onInput: (v) => { - query = v; - drawFound(); - }, - }); - - const drawFound = (): void => { - if (!logics) return; - const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - const hits = words.length - ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) - : []; - status.textContent = !words.length - ? "" - : !hits.length - ? te("Start_None") - : hits.length > SHOWN - ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) - : ""; - foundHost.replaceChildren(...hits.slice(0, SHOWN).map(importRow)); - }; - - body.replaceChildren( - el("p", { className: "m01-sl__trial-note", text: te("Trial") }), - el("div", { - className: "m01-sl__start", - children: [ - el("div", { - className: "m01-sl__start-col", - children: [ - bigButton(te("Start_New"), openNew), - el("h4", { text: te("Start_Mine") }), - mineHost, - ], - }), - el("div", { - className: "m01-sl__start-col", - children: [ - el("h4", { text: te("Start_Import") }), - el("h5", { text: te("Start_Trial") }), - trialHost, - search.root, - status, - foundHost, - ], - }), - ], - }), - ); - - try { - logics ??= await listEasy(""); - } catch (error) { - status.textContent = te("LoadFailed", { v: plainError(error) }); - return; - } - const byKey = new Map(logics.map((x) => [x.key, x])); - trialHost.replaceChildren( - ...TRIAL_KEYS.flatMap((k) => { - const item = byKey.get(k); - return item ? [importRow(item)] : []; - }), - ); - status.textContent = ""; - drawFound(); - }; - - // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 - openModal({ - title: te("Title"), - closeLabel: te("CloseWindow"), - dialogClass: "m01-sl", - beforeClose: mayLeave, - mount: (host) => { - body = host; - (host.parentElement ?? host).addEventListener("keydown", onKey); - void showStart(); - }, - }); -} - -function bigButton(label: string, onClick: () => void): HTMLButtonElement { - const button = createButton({ label, variant: "filled", onClick }); - button.classList.add("m01-sl__big"); - return button; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts deleted file mode 100644 index 2b4d7bdb2..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ /dev/null @@ -1,469 +0,0 @@ -/* ============================================================================= - * M01_SL_Entry_Paper.ts - * 시트 로직 고치는 화면 — 위 띠 · 위 가로 다이어그램 · 아래 시트 판 + 종류별 손잡이 · 결과 · 잇기 - * 바뀔 때마다 기록(객체 그대로) → evalLogic(화면 즉시) → 저장 slSave(서버가 다시 셈한 값으로 바꿔 보임) - * SheetLogic 의 다른 파일은 Entry 가 import() 로 늦게 불러 `mods` 로 넘김(엔진이 M01 첫 묶음에 안 들어가게). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - createTag, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import type { SlOutsideAt, SlQuery } from "./M01_SL_Api"; -import type { SlEvalResult, SlOutsideValue } from "./M01_SL_Eval"; -import type { SheetLogic, SlKind } from "./M01_SL_Model"; - -/** SheetLogic 의 다른 파일 — 창을 열 때 한 번에 불러옴 */ -export const loadMods = () => - Promise.all([ - import("./M01_SL_Model"), - import("./M01_SL_Eval"), - import("./M01_SL_Api"), - import("./M01_SL_Diagram"), - import("./M01_SL_Panel"), - import("./M01_SL_Table"), - import("./M01_SL_Item"), - import("./M01_SL_Result"), - import("./M01_SL_Import"), - import("../../A00_Common/spreadsheet/spreadsheet_recalc"), - import("./M01_SL_LogicTools"), - ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc, logic]) => ({ - model, - ev, - api, - diagram, - panel, - table, - item, - result, - imp, - recalc, - logic, - })); -export type Mods = Awaited>; - -const TEXT = { - Name: ["이름", "Name"], - NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], - Unit: ["결과 단위", "Result unit"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Close: ["닫기", "Close"], - Unsaved: ["저장 안 함", "Not saved"], - Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], - Diagram: ["상자와 선", "Boxes and lines"], - Panel: ["고른 상자의 시트", "Sheet of the chosen box"], - PickBox: [ - "위에서 상자를 고르면 그 상자의 시트가 여기 보임", - "Choose a box above to see its sheet", - ], - Result: ["결과", "Result"], - NoName: ["이름을 넣어 주세요", "Enter a name"], - SaveAnyway: [ - "고칠 곳이 있음 — {v} · 그래도 저장할까요?", - "There are issues — {v} · Save anyway?", - ], - Saving: ["저장 중", "Saving"], - Saved: ["저장했음", "Saved"], - SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], - Differs: [ - "저장 때 다시 셈한 값 {s} 원 — 화면 값 {c} 원과 다름", - "Recalculated on save: {s} — differs from the screen value {c}", - ], - Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], - Deleted: ["지웠음", "Deleted"], - Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"], - Kind_input: ["입력값", "Input"], - Kind_const: ["고정값", "Fixed value"], - Kind_table: ["표", "Table"], - Kind_calc: ["계산", "Calculation"], - Kind_item: ["항목", "Item"], - Kind_logic: ["다른 로직", "Other logic"], - Kind_result: ["결과", "Result"], -} as const; - -type TextKey = keyof typeof TEXT; - -const tp = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); - -/** 바깥 값 자리 — 항목 단가 · 출처가 있는 알약 · 가져온 로직의 바깥 값(같은 칸은 앞의 것) */ -export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { - const at = new Map(); - const put = (p: SlOutsideAt): void => { - const k = `${p.sheet}!${p.cell.toUpperCase()}`; - // 아직 고르지 않은 상자(빈 번호)는 묻지 않음 - const it = p.item as { key?: string; table?: string; element?: string }; - if (!(it.key ?? it.table ?? it.element)) return; - if (!at.has(k)) at.set(k, p); - }; - M.item.itemOutsides(sl).forEach(put); - // 상자에 출처가 적힌 알약 → 넘길 값을 실은 물음(outsideOf) → 출처를 짐작한 나머지(넘길 값 없음) 차례 - const cells = M.ev.outsideCells(sl); - const fromCell = (explicit: boolean): void => { - for (const c of cells) { - const node = sl.nodes.find((n) => n.id === c.node); - if (node && c.source && !!node.source === explicit) - put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); - } - }; - fromCell(true); - // 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0) - M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put); - fromCell(false); - return [...at.values()]; -} - -/** 열 때 바깥 값 넣기 — 받은 것만 넣고 못 받은 것은 쉬운 말 목록 */ -export async function fillOutside( - M: Mods, - sl: SheetLogic, -): Promise<{ sl: SheetLogic; errors: string[] }> { - const places = outsidePlaces(M, sl); - if (!places.length) return { sl, errors: [] }; - const answers = await M.api.slOutside(places.map((p) => p.item)); - const values: SlOutsideValue[] = []; - const errors: string[] = []; - for (const p of places) { - const node = sl.nodes.find((n) => n.sheetId === p.sheet); - const a = answers.find((x) => x.id === p.item.id); - if (!node) continue; - if (!a?.ok) errors.push(`${node.name} ${p.cell}${a?.reason ? ` — ${a.reason}` : ""}`); - else values.push({ node: node.id, cell: p.cell, value: a.rows ?? a.value ?? null }); - } - return { sl: M.ev.applyOutside(sl, values), errors }; -} - -/* 시트 판 높이 — 그 시트가 쓰는 줄 수 + 두 줄(안쪽 세로 밀기 없게 · 사용자 요청) - * ponytail: 편집기 줄 높이 · 띠 높이를 숫자로 둠 — 편집기 꾸밈이 바뀌면 같이 고침 */ -const ROW_PX = 20; -const CHROME_PX = 26 + 24 + 24 + 20; // 위 띠 · 칸 머리 · 아래 상태줄 · 가로 밀기 막대 -function sheetHeight(sheet: { 칸: Record } | null): number { - const rows = Object.keys(sheet?.칸 ?? {}).reduce( - (most, a1) => Math.max(most, Number(a1.replace(/^[A-Z]+/, "")) || 0), - 0, - ); - return CHROME_PX + (Math.max(rows, 6) + 2) * ROW_PX; -} - -/** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */ -function serverTotal(sl: SheetLogic): number | null { - const box = sl.nodes.find((n) => n.kind === "result"); - const pill = box?.pillsOut.find((p) => p.name === "계"); - if (!box || !pill) return null; - const cell = pill.cell.toUpperCase(); - const got = sl.workbook.계산값?.[box.sheetId]?.[cell]; - if (got && "수" in got) return Number(got.수); - const plain = sl.workbook.시트.find((s) => s.id === box.sheetId)?.칸[cell]?.값; - return typeof plain === "number" ? plain : null; -} - -export interface PaperCtx { - /** 저장해 둔 것을 열었으면 true — 새로 · 가져오기는 처음부터 「저장 안 함」 */ - saved: boolean; - units: string[]; - notes: string[]; - onClose(): void; -} - -/** 고치는 화면 — 돌려준 손잡이로 Entry 가 닫기 물음 · Ctrl+Z · Ctrl+S 를 부름 */ -export function showPaper( - M: Mods, - host: HTMLElement, - first: SheetLogic, - ctx: PaperCtx, -): { dirty(): boolean; save(): Promise; undo(): void } { - let sl = first; - let savedSl: SheetLogic | null = ctx.saved ? first : null; - let stack: SheetLogic[] = []; - let selected: string | null = first.nodes[0]?.id ?? null; - let values: SlEvalResult; - let busy = false; - let notes = ctx.notes; - /** 마지막으로 받은 바깥 값 물음 — 넘길 입력값이 바뀌면 다시 받음 */ - const sigOf = (x: SheetLogic): string => - JSON.stringify(outsidePlaces(M, x).map((p) => [p.sheet, p.cell, p.item])); - let outsideSig = sigOf(first); - let outsideTimer: ReturnType | undefined; - /** 바깥 값 다시 받기 — 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ - const refreshOutside = (): void => { - const sig = sigOf(sl); - if (sig === outsideSig) return; - outsideSig = sig; - clearTimeout(outsideTimer); - outsideTimer = setTimeout(() => { - const from = sl; - void fillOutside(M, from).then(({ sl: next, errors }) => { - if (sl !== from) { - // 그 사이 또 바뀜 — 지금 것으로 다시 받음(옛 답 · 옛 까닭은 버림) - outsideSig = ""; - return refreshOutside(); - } - if (savedSl === from) savedSl = next; - sl = next; - outsideSig = sigOf(next); - notes = errors; - redraw(); - }); - }, 300); - }; - - const unsaved = createTag(tp("Unsaved"), "warning"); - const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } }); - const diagramHost = el("div", { className: "m01-sl__part-body" }); - // 시트 판은 Panel 이 제 상태로 쥠 — 안을 갈아끼우지 않고 숨기기만(갈아끼우면 Panel 이 편집기가 있다고 여김) - const panelHost = el("div"); - const pickHint = muted(tp("PickBox")); - const toolsHost = el("div"); - const resultHost = el("div", { className: "m01-sl__part-body" }); - - const evalNow = (): SlEvalResult => { - try { - return M.ev.evalLogic(sl); - } catch (e) { - const none = { 노무비: null, 재료비: null, 경비: null, 계: null }; - return { pills: {}, pillsIn: {}, result: none, errors: [M.api.slPlain(e)] }; - } - }; - - /** 바뀐 것을 받아 쌓음 — 구조가 바뀌면 비율 바탕을 다시 엮음 */ - const edit = (next: SheetLogic, structural = false): void => { - if (next === sl) return; - stack = [...stack, sl]; - sl = structural ? M.item.refreshBases(next) : next; - redraw(); - }; - /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그리므로 다음 틱에 다시 그림 */ - const editFromDiagram = (next: SheetLogic): void => { - edit(next, true); - setTimeout(redraw); - }; - - const name = createInputField({ - label: tp("Name"), - placeholder: tp("NamePlaceholder"), - value: sl.name, - onInput: (v) => edit({ ...sl, name: v }), - }); - name.root.classList.add("m01-sl__name"); - const units = ctx.units.includes(sl.resultUnit) ? ctx.units : [sl.resultUnit, ...ctx.units]; - const unit = createSelectField({ - label: tp("Unit"), - options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: sl.resultUnit, - onChange: (v) => edit({ ...sl, resultUnit: v }), - }); - - const kindName = (k: SlKind): string => tp(`Kind_${k}` as TextKey); - const diagramOn = { - select: (id: string | null): void => { - selected = id; - drawPanel(); - }, - connect: (from: Parameters[1], to: typeof from): string | null => { - const got = M.model.connect(sl, from, to); - if (got.why) return got.why; - editFromDiagram(got.sl); - return null; - }, - disconnect: (edgeId: string): void => editFromDiagram(M.model.disconnect(sl, edgeId)), - add: (kind: SlKind): void => { - const got = M.model.addNode(sl, kind, kindName(kind)); - selected = got.node.id; - editFromDiagram(got.sl); - }, - remove: (nodeId: string): void => { - if (selected === nodeId) selected = null; - editFromDiagram(M.model.removeNode(sl, nodeId)); - }, - }; - - /** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */ - const loadPrices = (nodeId: string): void => { - void M.item.loadPrices(sl, nodeId).then(({ sl: next, why }) => { - if (why) note.textContent = why; - if (next !== sl) { - sl = next; - redraw(); - } - }); - }; - - const drawPanel = (): void => { - const node = sl.nodes.find((n) => n.id === selected); - if (!node) { - panelHost.hidden = true; - pickHint.hidden = false; - toolsHost.replaceChildren(); - return; - } - panelHost.hidden = false; - pickHint.hidden = true; - toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움 - panelHost.style.setProperty("--sl-sheet-h", `${sheetHeight(M.model.nodeSheet(sl, node.id))}px`); - M.panel.renderPanel(panelHost, sl, node.id, { - change: (next) => edit(next), - pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)), - }); - if (node.kind === "table") - M.table.renderTableTools(toolsHost, sl, node.id, { change: (next) => edit(next, true) }); - else if (node.kind === "item") - M.item.renderItemTools(toolsHost, sl, node.id, { - change: (next) => { - edit(next, true); - loadPrices(node.id); - }, - }); - else if (node.kind === "logic") - M.logic.renderLogicTools(toolsHost, sl, node.id, { - change: (next) => { - edit(next, true); - loadPrices(node.id); - }, - }); - else toolsHost.replaceChildren(); - }; - - const redraw = (): void => { - values = evalNow(); - refreshOutside(); - unsaved.hidden = sl === savedSl; - undo.disabled = !stack.length; - deleteButton.hidden = !sl.key; - const shown = Object.fromEntries( - sl.nodes.map((n) => [n.id, { ...values.pillsIn[n.id], ...values.pills[n.id] }]), - ); - M.diagram.renderDiagram(diagramHost, sl, diagramOn, { values: shown, selected }); - drawPanel(); - M.result.renderResult(resultHost, sl, values, { change: (next) => edit(next) }); - note.textContent = [...notes, ...values.errors].slice(0, 4).join("\n"); - }; - - const save = async (): Promise => { - if (busy) return; - if (!sl.name.trim()) { - note.textContent = tp("NoName"); - return void name.input.focus(); - } - let problems: string[] = []; - try { - problems = M.ev.checkLogic(sl); - } catch { - problems = []; - } - const ask = tp("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); - if (problems.length && !(await showConfirmDialog(ask, tp("Save")))) return; - busy = true; - note.textContent = tp("Saving"); - const screen = values.result.계; - try { - const got = await M.api.slSave({ ...sl, outside: outsidePlaces(M, sl) }); - const server = serverTotal(got.logic); - sl = got.logic; - savedSl = sl; - stack = []; - notes = []; - redraw(); - const differs = server !== null && screen !== null && Math.abs(server - screen) > 0.5; - if (differs) notes = [tp("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; - note.textContent = [...notes, ...values.errors].join("\n"); - showToast(tp("Saved"), "success"); - } catch (e) { - note.textContent = tp("SaveFailed", { v: M.api.slPlain(e) }); - } finally { - busy = false; - } - }; - - const doUndo = (): void => { - const back = stack[stack.length - 1]; - if (!back) return; - stack = stack.slice(0, -1); - sl = back; - name.input.value = sl.name; - unit.select.value = sl.resultUnit; - redraw(); - }; - - const doDelete = async (): Promise => { - if (!sl.key || busy) return; - if (!(await showConfirmDialog(tp("Delete_Ask", { v: sl.name }), tp("Delete")))) return; - try { - await M.api.slDelete(sl.key); - savedSl = sl; - showToast(tp("Deleted"), "success"); - ctx.onClose(); - } catch (e) { - note.textContent = M.api.slPlain(e); - } - }; - - const undo = createButton({ label: tp("Undo"), variant: "ghost", onClick: doUndo }); - const deleteButton = createButton({ - label: tp("Delete"), - variant: "danger", - onClick: () => void doDelete(), - }); - const close = createButton({ label: tp("Close"), variant: "ghost", onClick: ctx.onClose }); - - host.replaceChildren( - el("div", { - className: "m01-sl__strip", - children: [ - name.root, - unit.root, - unsaved, - el("div", { - className: "m01-sl__actions", - children: [ - undo, - createButton({ label: tp("Save"), onClick: () => void save() }), - deleteButton, - close, - ], - }), - ], - }), - el("p", { className: "m01-sl__trial-note", text: tp("Trial") }), - note, - el("div", { - className: "m01-sl__layout", - children: [ - el("section", { - className: "m01-sl__part m01-sl__diagram", - children: [el("h4", { text: tp("Diagram") }), diagramHost], - }), - el("section", { - className: "m01-sl__part m01-sl__panel", - children: [ - el("h4", { text: tp("Panel") }), - el("div", { - className: "m01-sl__part-body", - children: [pickHint, toolsHost, panelHost], - }), - ], - }), - el("section", { - className: "m01-sl__part m01-sl__result", - children: [el("h4", { text: tp("Result") }), resultHost], - }), - ], - }), - ); - redraw(); - if (!sl.name) name.input.focus(); - return { dirty: () => sl !== savedSl, save, undo: doUndo }; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts deleted file mode 100644 index 0dd408093..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Eval.ts +++ /dev/null @@ -1,245 +0,0 @@ -/* ============================================================================= - * M01_SL_Eval.ts - * 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사 - * 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음 - * 엔진을 정적으로 가져옴 — 화면(Entry)은 SheetLogic 을 import() 로 늦게 불러 M01 첫 묶음에 안 들어가게 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { - nodeSheet, - type SheetLogic, - type SlNode, - type SlOutside, - type SlSource, -} from "./M01_SL_Model"; - -/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ -export type SlValue = number | string | boolean | null; - -/** 바깥 값 하나 — 표는 칸 덩어리(cell 이 왼위) */ -export interface SlOutsideValue { - node: string; - cell: string; - value: SlValue | SlValue[][]; -} - -export interface SlEvalResult { - /** 상자 id → 출력 알약 이름 → 값 */ - pills: Record>; - /** 상자 id → 입력 알약 이름 → 값(화면 알약 위 숫자) */ - pillsIn: Record>; - result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null }; - /** 쉬운 말 */ - errors: string[]; -} - -const TEXT = { - /** {box} {cell} {err} */ - CellError: [ - "「{box}」 {cell} 칸 계산이 막힘 — {err}", - "「{box}」 cell {cell} can't be worked out — {err}", - ], - Cycle: ["돌고 도는 참조", "circular reference"], - /** {box} {pill} */ - EmptyIn: [ - "「{box}」의 입력 알약 「{pill}」이 비어 있음", - "Input pill 「{pill}」 of 「{box}」 is empty", - ], - NoOutside: [ - "「{box}」의 바깥 값 「{pill}」을 아직 안 받음", - "Outside value 「{pill}」 of 「{box}」 not filled yet", - ], - NoResult: ["결과 상자가 없음", "There is no result box"], - /** {box} */ - HeadChanged: [ - "「{box}」 표 머리 줄이 바뀜 — 표를 다시 골라야 함", - "Table headers of 「{box}」 changed — choose the table again", - ], - /** {cell} {why} */ - NoAnswer: [ - "{cell} 칸 바깥 값을 못 받음 {why}", - "Couldn't get the outside value for {cell} {why}", - ], - NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"], -} as const; - -const tl = (key: keyof typeof TEXT, fill: Record = {}): string => - Object.entries(fill).reduce( - (s, [k, v]) => s.replace(`{${k}}`, v), - TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, - ); - -/* ── 바깥 값 ────────────────────────────────────────────────────────────── */ - -/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */ -export function outsideCells(sl: SheetLogic): SlOutside[] { - const kindOf = (n: SlNode): SlOutside["kind"] => - n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price"; - const sourceOf = (n: SlNode): SlSource | undefined => - n.source ?? - (n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ?? - (n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined); - return sl.nodes.flatMap((n) => - [...n.pillsIn, ...n.pillsOut] - .filter((p) => p.outside) - .map((p) => { - const source = sourceOf(n); - const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) }; - return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) }; - }), - ); -} - -/** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */ -export interface SlOutsideAnswer { - id: string; - ok: boolean; - value?: number | null; - rows?: (string | number | null)[][]; - reason?: string; -} - -/** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */ -export function answersToValues( - cells: SlOutside[], - answers: SlOutsideAnswer[], - sl?: SheetLogic, -): { values: SlOutsideValue[]; errors: string[] } { - const values: SlOutsideValue[] = []; - const errors: string[] = []; - for (const c of cells) { - const a = answers.find((x) => x.id === c.id); - if (!a || !a.ok) { - errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" })); - continue; - } - const head = sl && a.rows ? headChanged(sl, c, a.rows[0] ?? []) : null; - if (head) errors.push(head); - values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); - } - return { values, errors }; -} - -/** 표 블록 머리 줄이 시트에 있던 것과 다르면 쉬운 말(열 차례가 바뀌면 찾기 판이 엉뚱한 열을 봄) */ -function headChanged( - sl: SheetLogic, - c: SlOutside, - head: (string | number | null)[], -): string | null { - const sheet = nodeSheet(sl, c.node); - const at = parseA1(c.cell); - if (!sheet || !at) return null; - const now = head.map((_, dc) => sheet.칸[toA1(at.r, at.c + dc)]?.값 ?? null); - if (now.every((x) => x === null)) return null; // 처음 채움 - const same = head.length === now.length && head.every((h, i) => h === now[i]); - const box = sl.nodes.find((n) => n.id === c.node)?.name ?? ""; - return same ? null : tl("HeadChanged", { box }); -} - -/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ -function putValue(칸: Record, cell: string, value: SlValue): void { - const style = 칸[cell]?.서식; - if (value === null) { - if (style) 칸[cell] = { 서식: style }; - else delete 칸[cell]; - } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; -} - -/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */ -export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLogic { - const out = structuredClone(sl); - for (const v of values) { - const sheet = nodeSheet(out, v.node); - const at = parseA1(v.cell); - if (!sheet || !at) continue; - if (!Array.isArray(v.value)) { - putValue(sheet.칸, toA1(at.r, at.c), v.value); - continue; - } - v.value.forEach((line, dr) => - line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)), - ); - // 표가 줄었으면 블록 열 아래 남은 옛 줄(값 칸)을 지움 — 수식 칸(찾기 판 · 열쇠)은 블록 열 밖이라 안 건드림 - const width = Math.max(0, ...v.value.map((line) => line.length)); - const last = at.r + v.value.length - 1; - for (const a of Object.keys(sheet.칸)) { - const p = parseA1(a); - const c = sheet.칸[a]; - if (p && p.r > last && p.c >= at.c && p.c < at.c + width && c.식 === undefined) - putValue(sheet.칸, a, null); - } - } - return out; -} - -/* ── 계산 ───────────────────────────────────────────────────────────────── */ - -const fromCalc = (v: CalcValue | undefined): SlValue => { - if (!v) return null; - if ("수" in v) return Number(v.수); - if ("글" in v) return v.글; - if ("참" in v) return v.참; - return v.오류; -}; - -const isNum = (v: SlValue): v is number => typeof v === "number" && Number.isFinite(v); - -/** 화면 계산 */ -export function evalLogic(sl: SheetLogic): SlEvalResult { - const values = recalcWorkbook(sl.workbook); - const read = (n: SlNode, cell: string): SlValue => { - const c = nodeSheet(sl, n.id)?.칸[cell.toUpperCase()]; - if (!c) return null; - if (c.식 !== undefined) return fromCalc(values[n.sheetId]?.[cell.toUpperCase()]); - return c.값 ?? null; - }; - const pills: SlEvalResult["pills"] = {}; - const pillsIn: SlEvalResult["pillsIn"] = {}; - for (const n of sl.nodes) { - pills[n.id] = Object.fromEntries(n.pillsOut.map((p) => [p.name, read(n, p.cell)])); - pillsIn[n.id] = Object.fromEntries(n.pillsIn.map((p) => [p.name, read(n, p.cell)])); - } - const errors: string[] = []; - for (const n of sl.nodes) - for (const [cell, v] of Object.entries(values[n.sheetId] ?? {})) - if ("오류" in v) { - const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; - errors.push(tl("CellError", { box: n.name, cell, err })); - } - const box = sl.nodes.find((n) => n.kind === "result"); - const num = (v: SlValue | undefined): number | null => (isNum(v ?? null) ? (v as number) : null); - const result: SlEvalResult["result"] = box - ? { - 노무비: num(pillsIn[box.id]["노무비"]), - 재료비: num(pillsIn[box.id]["재료비"]), - 경비: num(pillsIn[box.id]["경비"]), - 계: num(pills[box.id]["계"]), - } - : { 노무비: null, 재료비: null, 경비: null, 계: null }; - return { pills, pillsIn, result, errors }; -} - -/** 검사 — 빈 입력 알약 · 받지 않은 바깥 값 · 순환 · 오류 칸 · 결과 상자 없음(쉬운 말 목록) */ -export function checkLogic(sl: SheetLogic): string[] { - const out: string[] = []; - for (const n of sl.nodes) { - const sheet = nodeSheet(sl, n.id); - for (const p of [...n.pillsIn, ...n.pillsOut]) { - const c = sheet?.칸[p.cell.toUpperCase()]; - const empty = !c || (c.식 === undefined && (c.값 === undefined || c.값 === "")); - if (!empty) continue; - if (p.outside) out.push(tl("NoOutside", { box: n.name, pill: p.name })); - // 결과 상자의 비목 알약은 비어도 0 — 안 쓰는 비목이 늘 있음 - else if (n.pillsIn.includes(p) && n.kind !== "result") - out.push(tl("EmptyIn", { box: n.name, pill: p.name })); - } - } - const box = sl.nodes.find((n) => n.kind === "result"); - if (!box) out.push(tl("NoResult")); - else if (!sl.edges.some((e) => e.to.node === box.id)) out.push(tl("NoLine")); - return [...out, ...evalLogic(sl).errors]; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts deleted file mode 100644 index fe9963b40..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Import.ts +++ /dev/null @@ -1,694 +0,0 @@ -/* ============================================================================= - * M01_SL_Import.ts - * 시트 로직 — 기존 로직 가져오기(정함 9) · 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · DOM 없음 - * - * 읽기 = LogicBuilder Blocks(`readLogic`)로 입력값을 받고 · 식은 엔진 문법(`master_formula`) 그대로 읽어 - * 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · - * == → = · != → <> · 그리고/또는 → AND/OR · 찾기 → 표 상자 · 로직 → 다른 로직 상자 · 노임 키 → 바깥 값. - * 다른 상자 값은 받는 상자에 입력 알약 한 칸(「'시트'!칸」 참조 수식) + 선 하나 — 식은 제 상자 칸만 봄. - * - * 상자 시트 꼴 — A 이름 · B 값 · C 설명(1행 머리) · 알약은 B 칸. - * 표 상자 = 조건 알약(B) · 결과 알약(B) · E1 부터 품셈 표(머리 줄 + 줄 · 조건 칸 차례 → 값 칸 차례 · - * 범위 조건은 「이름·아래」 「이름·위」 두 칸) · 그 오른쪽에 「맞음」 칸(줄마다 조건 셈 → 1/0) · - * 결과 = 맞는 줄이 하나일 때 INDEX/MATCH · 아니면 #N/A(엔진 `find` 의 해당 없음 · 중복). - * 빈 조건 칸은 무엇이든 맞음 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하)은 엔진과 같음. - * 바깥 값(노임 · 자재 · 장비 · 로직 · 표)은 알약 `outside` · 실제 값은 열 때 · 저장 때 넣음(`outsideOf`). - * 못 바꾸는 꼴 = 그 칸 #N/A + 칸 메모(원래 글) + 쉬운 말 한 줄(`problems`). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import type { - Cell, - DataValidationKind, - Sheet, - Workbook, -} from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import { readLogic } from "../LogicBuilder/M01_LB_Blocks"; -import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model"; -import { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item"; -import { - parseFormula, - RULES, - tableCols, - tableRows, - type Ast, - type SlImportTable, -} from "./M01_SL_Import_Parse"; - -const TEXT = { - Name: ["이름", "Name"], - Value: ["값", "Value"], - Note: ["설명", "Note"], - Qty: ["수량", "Quantity"], - Price: ["단가", "Unit price"], - Amount: ["금액", "Amount"], - Result: ["결과", "Result"], - Sum: ["항목 합", "Item total"], - Input: ["입력값", "Input"], - Table: ["표", "Table"], - Calc: ["계산", "Calc"], - Item: ["항목", "Item"], - Logic: ["다른 로직", "Other logic"], - Const: ["고정값", "Constant"], - Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], - Match: ["맞음", "Match"], - Pass: ["넘길 값", "Passed value"], - Cant: ["못 바꿈 — ", "Not converted — "], - NoTable: ["품셈 표 「{v}」 를 못 받음", "Table “{v}” was not given"], - NoCol: ["표에 없는 칸 「{v}」", "Column “{v}” is not in the table"], - Rule: ["모르는 범위규칙 「{v}」", "Unknown range rule “{v}”"], - Unknown: ["모르는 이름 「{v}」", "Unknown name “{v}”"], - Hole: ["입력에 따라 바뀌는 요소 「{v}」", "Element changes with an input “{v}”"], - Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"], - FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"], - Parse: ["읽을 수 없는 글", "Text that can't be read"], - PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"], -} as const; -const t = (k: keyof typeof TEXT, v = ""): string => - TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); - -export type { SlImportTable }; - -export interface SlImportOptions { - /** 표 키 → 품셈 표(열 때 받은 것) — 없으면 그 표 상자는 못 바꿈 */ - tables?: Record; - /** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */ - examples?: Record; - /** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */ - names?: Record; -} - -export interface SlImportReport { - sl: SheetLogic; - /** 못 바꾼 꼴(쉬운 말) — 비면 다 바뀜 */ - problems: string[]; -} - -/* ── 짓기 ───────────────────────────────────────────────────────────────── */ - -class Fail extends Error {} -const KEY = /^[A-Z]{2}\d{6}$/; -const COSTS = ["노무비", "재료비", "경비"] as const; -type Cost = (typeof COSTS)[number]; -const lit = (v: unknown): string => - typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; -const cellOf = (v: unknown): Cell | null => - typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? { 값: v } : null; - -/** 표 상자 하나 = 표 + 조건 묶음 하나(같은 표 · 같은 조건이면 결과 알약만 더함) */ -interface TableBox { - node: SlNode; - table: SlImportTable; - /** 결과 칸 이름 → 표 열 글자(범위 칸은 아래 · 위) */ - colOf: Map; - rows: number; - /** 다음 빈 열(맞음 · 결과 셈 칸) */ - free: number; - /** 결과 셈에 쓸 「맞음」 열(엔진 `find` 의 위 끝 고르기까지) */ - hit: string; -} - -class Build { - readonly book: Workbook = { 종류: "통합문서", 판: 1, 열: "sheet-logic", 서식: [{}], 시트: [] }; - readonly nodes: SlNode[] = []; - readonly edges: SlEdge[] = []; - readonly problems: string[] = []; - private readonly next = new Map(); - private readonly sheets = new Map(); - private readonly linked = new Map(); - readonly inputs = new Map(); - readonly mids = new Map(); - readonly tables = new Map(); - readonly logics = new Map(); - readonly consts = new Map(); - readonly specs = new Map(); - /** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */ - readonly names = new Map(); - - constructor(readonly opts: SlImportOptions) { - for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v); - } - - /** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */ - node(kind: SlKind, name: string, stage: number, ref?: string, head = true): SlNode { - const n = this.nodes.length + 1; - const word = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore - const base = kind === "result" ? t("Result") : `${t(word)} · ${name}`.slice(0, 31); - let title = base; - for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++) - title = `${base.slice(0, 28)} ${k}`; - const sheet: Sheet = { - id: `s${n}`, - 이름: title, - 칸: head ? { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } } : {}, - }; - this.book.시트.push(sheet); - this.sheets.set(sheet.id, sheet); - const node: SlNode = { - id: `n${n}`, - kind, - name, - sheetId: sheet.id, - pillsIn: [], - pillsOut: [], - stage, - ...(ref ? { ref } : {}), - }; - this.nodes.push(node); - this.next.set(node.id, 2); - return node; - } - - sheet(node: SlNode): Sheet { - return this.sheets.get(node.sheetId)!; - } - - /** 줄 하나 더함 — A 이름 · B 값(또는 식) · C 설명 → B 칸 주소 */ - row(node: SlNode, label: string, cell: Cell, note = ""): string { - const r = this.next.get(node.id)!; - this.next.set(node.id, r + 1); - const s = this.sheet(node); - s.칸[`A${r}`] = { 값: label }; - s.칸[`B${r}`] = cell; - if (note) s.칸[`C${r}`] = { 값: note }; - return `B${r}`; - } - - pill(node: SlNode, dir: "in" | "out", pill: SlPill): void { - const list = dir === "in" ? node.pillsIn : node.pillsOut; - let name = pill.name; - for (let k = 2; list.some((p) => p.name === name); k++) name = `${pill.name} ${k}`; - list.push({ ...pill, name }); - } - - /** 출력 알약 하나 — 같은 이름이 있으면 그 칸 */ - out(node: SlNode, name: string, cell: Cell, note = ""): string { - const had = node.pillsOut.find((p) => p.name === name); - if (had) return had.cell; - const at = this.row(node, name, cell, note); - this.pill(node, "out", { name, cell: at }); - return at; - } - - /** 다른 상자 출력 알약 → 이 상자 입력 알약 한 칸 + 선 · 이 상자 안 칸 주소 - * `into` = 이미 있는 입력 알약(넘길 값 칸)에 바로 이음 */ - link(to: SlNode, from: SlNode, pill: string, into?: SlPill): string { - const id = `${to.id}<${from.id}:${pill}`; - const had = this.linked.get(id); - if (had && !into) return had; - const src = from.pillsOut.find((p) => p.name === pill)!; - const ref: Cell = { 식: `${quoteSheet(this.sheet(from).이름)}!${src.cell}` }; - let target = into; - if (target) this.sheet(to).칸[target.cell] = ref; - else { - const name = from.kind === "input" || from.kind === "calc" ? pill : `${pill} · ${from.name}`; - this.pill(to, "in", { name, cell: this.row(to, name, ref) }); - target = to.pillsIn[to.pillsIn.length - 1]; - } - this.edges.push({ - id: `e${this.edges.length + 1}`, - from: { node: from.id, pill }, - to: { node: to.id, pill: target.name }, - }); - if (!had) this.linked.set(id, target.cell); - return target.cell; - } - - /** 못 바꾸는 꼴 — 칸 #N/A + 메모(원래 글) + 쉬운 말 */ - cant(node: SlNode, at: string, why: string, src: string): void { - const s = this.sheet(node); - s.칸[at] = { 식: "NA()" }; - (s.comments ??= {})[at] = src; - s.칸[`C${at.slice(1)}`] = { 값: `${t("Cant")}${why}` }; - this.problems.push(`${node.name} — ${why}`); - } -} - -/* ── 식 → 엑셀 ───────────────────────────────────────────────────────────── */ - -interface Scope { - b: Build; - /** 식이 놓일 상자 */ - here: SlNode; - /** 덧줄 — 비목 합(앞 덧줄까지) 을 주는 상자 · 알약 */ - costs?: Record; - /** 덧줄 — 앞 덧줄 이름 · 줄.'이름' */ - extras?: Map; - lines?: Map; -} - -function excel(n: Ast, sc: Scope): string { - const { b, here } = sc; - const wrap = (x: Ast): string => (x.t === "bin" ? `(${excel(x, sc)})` : excel(x, sc)); - switch (n.t) { - case "num": - return n.v; - case "str": - return lit(n.v); - case "neg": - return `-${wrap(n.a)}`; - case "eref": - throw new Fail(t("Hole", n.v)); - case "bin": { - if (n.op === "그리고" || n.op === "또는") - return `${n.op === "그리고" ? "AND" : "OR"}(${excel(n.a, sc)},${excel(n.b, sc)})`; - const op = n.op === "==" ? "=" : n.op === "!=" ? "<>" : n.op; - return `${wrap(n.a)}${op}${wrap(n.b)}`; - } - case "call": { - const items = n.items.map((x) => excel(x, sc)); - if (n.f === "만약") return `IF(${items.join(",")})`; - if (n.f === "최소" || n.f === "최대") - return `${n.f === "최소" ? "MIN" : "MAX"}(${items.join(",")})`; - const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[n.f]!; - return `${fn}(${items[0]},${items[1] ?? "0"})`; - } - case "find": - throw new Fail(t("FindAll")); - case "logic": - return b.link(here, logicBox(b, n), t("Result")); - case "field": { - const a = n.a; - if (a.t === "find") return b.link(here, ...findBox(b, a, n.f)); - if (a.t === "logic") return b.link(here, logicBox(b, a, n.f), n.f); - if (a.t === "name" && a.v === "줄" && sc.lines?.has(n.f)) - return b.link(here, ...sc.lines.get(n.f)!); - throw new Fail(t("Field", `${a.t === "name" ? a.v : ""}.${n.f}`)); - } - case "name": { - const v = n.v; - if (b.inputs.has(v)) return b.link(here, b.inputs.get(v)!, v); - if (b.mids.has(v)) return b.link(here, b.mids.get(v)!, v); - if (sc.extras?.has(v)) return b.link(here, ...sc.extras.get(v)!); - if (sc.costs && (COSTS as readonly string[]).includes(v)) { - const refs = sc.costs[v as Cost].map(([node, pill]) => b.link(here, node, pill)); - return refs.length ? `(${refs.join("+")})` : "0"; - } - if (KEY.test(v)) return b.link(here, constBox(b, v), t("Price")); - throw new Fail(t("Unknown", v)); - } - } -} - -/** 식 글 하나를 상자 칸에 — 못 바꾸면 #N/A + 메모 */ -function put(sc: Scope, label: string, text: string, pill: "out" | null, note = ""): string { - const ast = parseFormula(text); - let cell: Cell; - let why = ""; - try { - if (!ast) throw new Fail(t("Parse")); - cell = ast.t === "num" ? { 값: Number(ast.v) } : { 식: excel(ast, sc) }; - } catch (e) { - if (!(e instanceof Fail)) throw e; - why = e.message; - cell = { 식: "NA()" }; - } - const at = - pill === "out" ? sc.b.out(sc.here, label, cell, note) : sc.b.row(sc.here, label, cell, note); - if (why) sc.b.cant(sc.here, at, why, text); - return at; -} - -/** 엔진 노임 키(식 속 LB000006 등) → 고정값 상자 · 바깥 값 알약 */ -function constBox(b: Build, key: string): SlNode { - const had = b.consts.get(key); - if (had) return had; - const node = b.node("const", b.names.get(key) ?? key, 0, key); - const at = b.row(node, t("Price"), {}, t("Outside")); - b.pill(node, "in", { name: t("Price"), cell: at, outside: true }); - b.pill(node, "out", { name: t("Price"), cell: at }); - b.consts.set(key, node); - return node; -} - -/** 넘길 값(로직 부름 인자 · 표 조건) → 입력 알약 — 글 · 수는 그 칸에 값 · 입력값 · 계산 이름은 선 · 식은 셈 */ -function args(b: Build, node: SlNode, list: [string, Ast][]): void { - for (const [k, v] of list) { - const at = b.row(node, k, {}, t("Pass")); - b.pill(node, "in", { name: k, cell: at }); - const pill = node.pillsIn[node.pillsIn.length - 1]; - const s = b.sheet(node); - try { - if (v.t === "num") s.칸[at] = { 값: Number(v.v) }; - else if (v.t === "str") s.칸[at] = { 값: v.v }; - else if (v.t === "name" && b.inputs.has(v.v)) b.link(node, b.inputs.get(v.v)!, v.v, pill); - else if (v.t === "name" && b.mids.has(v.v)) b.link(node, b.mids.get(v.v)!, v.v, pill); - else s.칸[at] = { 식: excel(v, { b, here: node }) }; - } catch (e) { - if (!(e instanceof Fail)) throw e; - b.cant(node, at, e.message, k); - } - } -} - -/** 다른 로직 상자(식 속 `로직(키, …)`) — 결과 · 비목 칸은 바깥 값 */ -function logicBox(b: Build, n: Extract, field?: string): SlNode { - const sig = n.src.replace(/\s+/g, ""); - let node = b.logics.get(sig); - if (!node) { - if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head)); - node = b.node("logic", b.names.get(n.head) ?? n.head, 1, n.head); - args(b, node, n.args); - b.logics.set(sig, node); - } - const money = ["노무비", "재료비", "경비", "계"]; - for (const name of field && money.includes(field) ? money : [field ?? t("Result")]) - if (!node.pillsOut.some((p) => p.name === name)) { - const at = b.row(node, name, {}, t("Outside")); - b.pill(node, "in", { name, cell: at, outside: true }); - b.pill(node, "out", { name, cell: at }); - } - return node; -} - -/* ── 표 상자 ─────────────────────────────────────────────────────────────── */ - -/** 표 상자 — 같은 표 · 같은 조건이면 결과 알약만 더함 → [상자, 결과 알약 이름] */ -function findBox(b: Build, n: Extract, col: string): [SlNode, string] { - const sig = `${n.head}|${n.args.map(([k]) => k).join(",")}|${n.src.replace(/\s+/g, "").replace(/\)\..*$/, "")}`; - let box = b.tables.get(sig); - if (!box) box = newTable(b, n, sig); - if (!box.colOf.has(col)) throw new Fail(t("NoCol", col)); - if (!box.node.pillsOut.some((p) => p.name === col)) resultCol(b, box, col); - return [box.node, col]; -} - -function newTable(b: Build, n: Extract, sig: string): TableBox { - const table = b.opts.tables?.[n.head]; - if (!table) throw new Fail(t("NoTable", n.head)); - const rule = String(table.범위규칙 ?? "초과·이하") - .split(" · ")[0] - .split(" (")[0]; - for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k)); - if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule)) - throw new Fail(t("Rule", String(table.범위규칙))); - const node = b.node("table", table.이름 || b.names.get(n.head) || n.head, 1, n.head); - const s = b.sheet(node); - // 조건 알약 — 받는 값은 넘긴 식 - const cond = new Map(); - args(b, node, n.args); - for (const [k] of n.args) cond.set(k, node.pillsIn.find((p) => p.name === k)!.cell); - // 표 E1 부터 - const cols = tableCols(table); - const rows = tableRows(table); - // 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움 - b.pill(node, "out", { name: "표", cell: "E1", outside: true }); - rows.forEach((r, i) => - r.forEach((v, j) => { - const c = cellOf(v); - if (c) s.칸[`${colName(4 + j)}${i + 1}`] = c; - }), - ); - const letter = (key: string, part: "v" | "lo" | "hi"): string => - colName(4 + cols.findIndex((c) => c.key === key && c.part === part)); - // 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 20 줄까지 셈 · 「표 줄 수」 칸 밖 줄은 안 맞음 - const N = Math.max(20, table.줄.length); - const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; - const count = b.row(node, "표 줄 수", { - 식: `MAX(${cols.map((_, j) => `COUNTA(${range(colName(4 + j))})`).join(",")})`, - }); - const last = `$B$${count.slice(1)}`; - let free = 4 + cols.length + 1; - // 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로) - const M = colName(free++); - s.칸[`${M}1`] = { 값: t("Match") }; - const top = Math.max( - ...table.줄.flatMap((r) => - Object.keys(table.조건) - .filter((k) => table.조건[k] === "범위" && Array.isArray(r[k])) - .map((k) => (r[k] as (number | null)[])[1] ?? -Infinity), - ), - ); - const closed = String(table.범위규칙 ?? "").includes("이하 포함"); - for (let r = 2; r <= N + 1; r++) { - const parts = [...cond].map(([k, pin]) => { - const x = `$${pin.slice(0, 1)}$${pin.slice(1)}`; - if (table.조건[k] !== "범위") { - const X = `${letter(k, "v")}${r}`; - return `OR(${X}="",${X}=${x})`; - } - const L = `${letter(k, "lo")}${r}`; - const H = `${letter(k, "hi")}${r}`; - const low = rule === "이상·이하" || rule === "이상·미만" ? ">=" : ">"; - const up = - rule === "이상·미만" - ? `OR(${H}="",${x}<${H}${closed ? `,AND(${H}=${top},${x}=${H})` : ""})` - : `OR(${H}="",${x}<=${H})`; - return `AND(OR(${L}="",${x}${low}${L}),${up})`; - }); - const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1"; - s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` }; - } - // 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`) - let hit = M; - const ranged = [...cond.keys()].filter((k) => table.조건[k] === "범위"); - if ((rule === "이하" || rule === "이상·이하") && ranged.length) { - hit = colName(free++); - s.칸[`${hit}1`] = { 값: `${t("Match")} 2` }; - for (let r = 2; r <= N + 1; r++) { - const least = ranged.map((k) => { - const H = letter(k, "hi"); - return `OR(${H}${r}="",${H}${r}=MINIFS(${range(H)},${range(M)},1))`; - }); - s.칸[`${hit}${r}`] = { - 식: `IF(AND(${M}${r}=1,OR(COUNTIF(${range(M)},1)<2,${least.join(",")})),1,0)`, - }; - } - } - const colOf = new Map(); - cols.forEach((c, j) => { - if (c.part === "hi") colOf.set(c.key, { ...colOf.get(c.key)!, hi: colName(4 + j) }); - else if (!colOf.has(c.key)) colOf.set(c.key, { x: colName(4 + j) }); - }); - const box: TableBox = { node, table, colOf, rows: N, free, hit }; - b.tables.set(sig, box); - return box; -} - -/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A · - * 범위 값 칸은 아래 = 위(숫자 하나)일 때만 값 · 아니면 #N/A(엔진 「값이 범위」) */ -function resultCol(b: Build, box: TableBox, col: string): void { - const s = b.sheet(box.node); - const N = box.rows; - const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; - const { x: X, hi } = box.colOf.get(col)!; - const W = colName(box.free++); - s.칸[`${W}1`] = { 값: `${t("Match")} · ${col}` }; - for (let r = 2; r <= N + 1; r++) - s.칸[`${W}${r}`] = { - 식: `IF(AND(${box.hit}${r}=1,OR(COUNTIF(${range(box.hit)},1)<2,${X}${r}<>"")),1,0)`, - }; - const at = (L: string): string => `INDEX(${range(L)},MATCH(1,${range(W)},0))`; - const value = hi ? `IF(${at(X)}=${at(hi)},${at(X)},NA())` : at(X); - b.out(box.node, col, { 식: `IF(COUNTIF(${range(W)},1)=1,${value},NA())` }); -} - -/* ── 상자들 ─────────────────────────────────────────────────────────────── */ - -const example = (spec: LogicInput, given?: number | string): Cell => { - if (given !== undefined) return { 값: given }; - if (spec.고르기?.length) return { 값: spec.고르기[0] }; - if (spec.범위) return { 값: spec.범위[0] }; - return { 값: 0 }; -}; - -function inputBox(b: Build, spec: LogicInput): void { - const node = b.node("input", spec.이름, 0); - const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? ""); - const r = Number(at.slice(1)) - 1; - const whole = { r0: r, c0: 1, r1: r, c1: 1 }; - const rule: DataValidationKind | null = spec.고르기?.length - ? { 종류: "목록", 값들: spec.고르기.map(String) } - : spec.범위 ? { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] } : null; // prettier-ignore - if (rule) b.sheet(node).유효성 = [{ id: "v1", 범위: [whole], 규칙: rule, 어기면: "막음" }]; - const pill = node.pillsOut[0]; - if (spec.단위) pill.unit = spec.단위; - b.inputs.set(spec.이름, node); - b.specs.set(spec.이름, spec); -} - -/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */ -function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | number { - const ast = parseFormula(item.수량); - if (ast?.t === "num") return Number(ast.v); - if (ast?.t === "name" && b.inputs.has(ast.v)) return [b.inputs.get(ast.v)!, ast.v]; - if (ast?.t === "name" && b.mids.has(ast.v)) return [b.mids.get(ast.v)!, ast.v]; - const label = `${name} ${t("Qty")}`; - const node = b.node("calc", label, 2); - put({ b, here: node }, label, item.수량, "out"); - return [node, label]; -} - -/** 항목 틀 — Item `applyItemLayout` 으로 그 상자 시트 · 알약을 채움(Item 이 알아보는 꼴 한 벌) */ -function layout(b: Build, node: SlNode, ref: ItemRef, qty?: number): void { - const sheet = b.sheet(node); - const mini = { key: null, name: "", resultUnit: "", workbook: { ...b.book, 시트: [sheet] }, nodes: [node], edges: [] }; // prettier-ignore - const got = applyItemLayout(mini, node, ref, qty); - sheet.칸 = got.workbook.시트[0].칸; - const 유효성 = got.workbook.시트[0].유효성; - if (유효성) sheet.유효성 = 유효성; - Object.assign(node, got.nodes[0]); -} - -/** 항목 상자(호표 줄) — Item 틀 그대로(node.ref = ItemRef) → 비목 출력 알약들 */ -function itemBox(b: Build, item: HoLine, lines: Map): [SlNode, Cost][] { - const name = item.이름 || String(item.요소); - const el: unknown = item.요소; - const call = typeof el === "string" && el.startsWith("로직(") ? parseFormula(el) : null; - const logic = call?.t === "logic" ? call : null; - const what: ItemRef["what"] = logic - ? logic.args.some(([k]) => k === "기계") ? "장비" : "일위대가" - : item.종류 === "인력" ? "노무" : "재료"; // prettier-ignore - // 넘길 값 — 입력값 이름은 예시값으로(Item 이 ref.inputs 로 바깥 값을 받음) · 글 · 수는 요소 글 안에 그대로 - const need: LogicInput[] = []; - const inputs: Record = {}; - const bad: string[] = []; - for (const [k, v] of logic?.args ?? []) { - if (v.t !== "name") { - if (v.t !== "num" && v.t !== "str") bad.push(k); - continue; - } - const spec = b.specs.get(v.v); - if (!spec) bad.push(k); - else { - need.push({ ...spec, 이름: k }); - inputs[k] = example(spec, b.opts.examples?.[spec.이름]).값; - } - } - const element = what === "재료" && typeof el === "string" ? { 키: el } : el; - const ref: ItemRef = { - what, - element, - ...(item.규격 ? { spec: item.규격 } : {}), - ...(item.단위 ? { unit: item.단위 } : {}), - ...(need.length ? { need, inputs } : {}), - }; - const node = b.node("item", name, 3, JSON.stringify(ref), false); - const outside: Cost[] = what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : [...COSTS]; - const q = qtyFrom(b, item, name); - layout(b, node, ref, typeof q === "number" ? q : undefined); - if (typeof q !== "number") b.link(node, q[0], q[1], node.pillsIn[0]); - const holes = typeof el === "string" ? [el] : Object.values(el ?? {}); - const priceAt = what === "재료" ? "B3" : "B2"; - if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v))) - b.cant(node, priceAt, t("Hole", typeof el === "string" ? el : JSON.stringify(el)), String(el)); - if (bad.length) b.cant(node, priceAt, t("PassCalc"), bad.join(", ")); - lines.set(name, [node, "금액"]); - return outside.map((c): [SlNode, Cost] => [node, c]); -} - -/** 덧줄 — 「노무비 * 3 / 100」 꼴이면 Item 비율 틀 · 아니면 식 그대로 계산하는 항목(금액 · 비목 · 단가 알약) */ -const RATE_RE = /^\(?(노무비|재료비|경비)\)?\*(\d+(?:\.\d+)?)\/100$/; - -function extraBox( - b: Build, - extra: { 이름: string; 식: string; 비목?: string; 출처?: string }, - sc: Omit, -): [SlNode, Cost] { - const cost = ( - (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" - ) as Cost; - const m = RATE_RE.exec(extra.식.replace(/\s+/g, "")); - if (m) { - const base = m[1] as Cost; - const ref: ItemRef = { what: "비율", base, into: cost }; - const node = b.node("item", extra.이름, 3, JSON.stringify(ref), false); - b.sheet(node).칸.B1 = { 값: Number(m[2]) }; // 율 — Item 이 이 칸을 살림 - layout(b, node, ref); - return [node, cost]; - } - const node = b.node("item", extra.이름, 3); - const at = put({ ...sc, here: node }, "금액", extra.식, "out", extra.출처 ?? ""); - b.out(node, "단가", { 식: at }); - b.out(node, cost, { 식: at }); - b.out(node, "사용 수량", { 값: 1 }); - return [node, cost]; -} - -type Cut = { 대상?: string; 자리?: number; 방법?: string }; -const cutText = (row: LogicRow, on: string, x: string): string => { - const rule = row.끝수 as unknown as Cut | null; - if (!rule || typeof rule !== "object" || rule.대상 !== on) return x; - const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[String(rule.방법)]; - return fn ? `${fn}(${x},${rule.자리 ?? 0})` : x; -}; - -/* ── 밖으로 ─────────────────────────────────────────────────────────────── */ - -/** 기존 로직 → 시트 로직 + 못 바꾼 꼴 */ -export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): SlImportReport { - const lb = readLogic(row, { key: row.키 || null, version: null, isBase: true }); - const b = new Build(opts); - for (const spec of lb.inputs) inputBox(b, spec); - for (const m of row.중간 ?? []) b.mids.set(m.이름, b.node("calc", m.이름, 2)); - for (const m of row.중간 ?? []) - put({ b, here: b.mids.get(m.이름)! }, m.이름, m.식, "out", m.출처 ?? ""); - const result = b.node("result", t("Result"), 4); - if (row.결과) { - const at = put({ b, here: result }, t("Result"), row.결과.식, null); - const s = b.sheet(result); - if (s.칸[at].식 && s.칸[at].식 !== "NA()") - s.칸[at] = { 식: cutText(row, "결과", s.칸[at].식!) }; - else if (s.칸[at].값 !== undefined) - s.칸[at] = { 식: cutText(row, "결과", String(s.칸[at].값)) }; - b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 }); - } else { - const lines = new Map(); - for (const h of row.호표 ?? []) - if (typeof h.요소 === "string" && h.이름 && !b.names.has(h.요소)) b.names.set(h.요소, h.이름); - const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines)); - // 항목 합 — 식 덧줄이 「노무비」 등으로 부르는 앞 줄까지의 비목 합 - const sum = b.node("calc", t("Sum"), 3); - const costs = {} as Record; - for (const k of COSTS) { - const refs = outs.filter(([, c]) => c === k).map(([n, c]) => b.link(sum, n, c)); - b.out(sum, k, refs.length ? { 식: `SUM(${refs.join(",")})` } : { 값: 0 }); - costs[k] = [[sum, k]]; - } - const extras = new Map(); - const into: [SlNode, Cost][] = [...outs]; - for (const extra of row.덧줄 ?? []) { - const [node, cost] = extraBox(b, extra, { b, costs, extras, lines }); - extras.set(extra.이름, [node, cost]); - costs[cost] = [...costs[cost], [node, cost]]; - into.push([node, cost]); - } - // 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계 - for (const k of COSTS) { - const at = b.row(result, k, {}); - b.pill(result, "in", { name: k, cell: at }); - const refs = into - .filter(([, c]) => c === k) - .map(([n]) => { - const src = n.pillsOut.find((p) => p.name === k)!; - const to = { node: result.id, pill: k }; - b.edges.push({ id: `e${b.edges.length + 1}`, from: { node: n.id, pill: k }, to }); - return `${quoteSheet(b.sheet(n).이름)}!${src.cell}`; - }); - if (refs.length) b.sheet(result).칸[at] = { 식: refs.join("+") }; - } - const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`)); - b.out(result, "계", { 식: parts.join("+") }, row.결과단위); - } - b.book.활성 = b.book.시트[0]?.id; - const built: SheetLogic = { - key: null, - name: row.이름, - resultUnit: row.결과단위, - workbook: b.book, - nodes: b.nodes, - edges: b.edges, - }; - // 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께) - const sl = refreshBases(built); - return { sl, problems: b.problems }; -} - -/** 기존 로직 → 시트 로직(정함 9) */ -export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLogic { - return importLogicReport(row, opts).sl; -} - -export { outsideOf } from "./M01_SL_Import_Parse"; diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts deleted file mode 100644 index 99a8ee623..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* ============================================================================= - * M01_SL_Import_Parse.ts - * 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로 - * 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 · - * - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null - * 아래 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸 — 바깥 값 「표」 모양과 같음) · - * 바깥 값 자리(`outsideOf`) - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { CalcValues } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { SheetLogic, SlNode } from "./M01_SL_Model"; -import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; -import { itemRefOf, type ItemRef } from "./M01_SL_Item"; - -export type Ast = - | { t: "num"; v: string } - | { t: "str"; v: string } - | { t: "name"; v: string } - | { t: "eref"; v: string } - | { t: "neg"; a: Ast } - | { t: "field"; a: Ast; f: string } - | { t: "call"; f: string; items: Ast[] } - | { t: "find"; head: string; args: [string, Ast][]; src: string } - | { t: "logic"; head: string; args: [string, Ast][]; src: string } - | { t: "bin"; op: string; a: Ast; b: Ast }; - -const CMPS = ["<=", ">=", "==", "!=", "<", ">"]; -const CALLS = ["만약", "최소", "최대", "올림", "버림", "반올림"]; - -export function parseFormula(s: string): Ast | null { - let i = 0; - const fail = (): never => { - throw new Error("parse"); - }; - const ws = (): void => { - while (i < s.length && /\s/.test(s[i])) i++; - }; - const take = (w: string): boolean => { - ws(); - if (!s.startsWith(w, i)) return false; - i += w.length; - return true; - }; - const need = (w: string): void => void (take(w) || fail()); - const word = (): string => { - ws(); - const m = /[\p{L}\p{N}_]+/uy; - m.lastIndex = i; - const got = m.exec(s) ?? fail(); - i = m.lastIndex; - return got[0]; - }; - const quoted = (): string => { - const e = s.indexOf("'", i); - if (e < 0) fail(); - const v = s.slice(i, e); - i = e + 1; - return v; - }; - /** 로직 · 표 머리 글 — 「,」 「)」 에서 멈춤(괄호 · 중괄호 안은 넘김) */ - const head = (): string => { - ws(); - const st = i; - for (let d = 0; i < s.length; i++) { - const c = s[i]; - if ("({".includes(c)) d++; - else if (")}".includes(c)) { - if (!d) break; - d--; - } else if (!d && c === ",") break; - } - return s.slice(st, i).trim(); - }; - const fields = (n: Ast): Ast => { - while (take(".")) n = { t: "field", a: n, f: take("'") ? quoted() : word() }; - return n; - }; - const primary = (): Ast => { - ws(); - const st = i; - if (take("(")) { - const a = expr(); - need(")"); - return fields(a); - } - if (take("'")) return { t: "str", v: quoted() }; - const num = /\d+(?:\.\d+)?/y; - num.lastIndex = i; - const n = num.exec(s); - if (n) { - i = num.lastIndex; - return { t: "num", v: n[0] }; - } - const w = word(); - if (w === "찾기" || w === "로직") { - need("("); - const h = head(); - const args: [string, Ast][] = []; - while (take(",")) { - const k = take("'") ? quoted() : word(); - need("="); - args.push([k, expr()]); - } - need(")"); - const call = { head: h, args, src: s.slice(st, i) }; - return fields(w === "찾기" ? { t: "find", ...call } : { t: "logic", ...call }); - } - if (CALLS.includes(w)) { - need("("); - const items = [expr()]; - while (take(",")) items.push(expr()); - need(")"); - return fields({ t: "call", f: w, items }); - } - if (s[i] === ":") { - for (i++; i < s.length && !/[\s,)*/+-]/.test(s[i]); i++); - return { t: "eref", v: s.slice(st, i) }; - } - return fields({ t: "name", v: w }); - }; - const unary = (): Ast => (take("-") ? { t: "neg", a: unary() } : primary()); - const chain = (next: () => Ast, ops: string[], once = false): Ast => { - let n = next(); - for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) - n = { t: "bin", op, a: n, b: next() }; - return n; - }; - const mul = (): Ast => chain(unary, ["*", "/"]); - const add = (): Ast => chain(mul, ["+", "-"]); - const cmp = (): Ast => chain(add, CMPS, true); - const both = (): Ast => chain(cmp, ["그리고"]); - const expr = (): Ast => chain(both, ["또는"]); - try { - const node = expr(); - ws(); - return i === s.length ? node : null; - } catch { - return null; - } -} - -/* ── 품셈 표 칸 놓기 ─────────────────────────────────────────────────────── */ - -/** 가져올 때 같이 받는 품셈 표 — 서버 `/table` 답의 쓸 칸만 */ -export interface SlImportTable { - 키?: string; - 이름?: string; - 조건: Record; - 값칸: Record; - 범위규칙?: string | null; - 줄: Record[]; -} - -/** 범위 칸 꼬리 — 한 · 영 */ -const LOW_HIGH = currentLanguageIndex === 1 ? ["low", "high"] : ["아래", "위"]; - -export const RULES = ["초과·이하", "이상·이하", "이상·미만", "이하"]; - -/** 표 칸 놓기(서버 `M01_SL_Router._table` 과 같음 · 정함) — 조건 칸 → 값 칸 차례 · 범위(조건 「범위」 · 값 칸에 - * 「20~45」 꼴이 한 줄이라도 있음)는 「이름·아래」 「이름·위」 두 칸 · 범위 칸에 숫자 하나면 두 칸에 같은 수 */ -export function tableCols( - table: SlImportTable, -): { key: string; head: string; part: "v" | "lo" | "hi" }[] { - const out: { key: string; head: string; part: "v" | "lo" | "hi" }[] = []; - const conds = Object.keys(table.조건); - for (const k of [...conds, ...Object.keys(table.값칸)]) { - const split = conds.includes(k) - ? table.조건[k] === "범위" - : table.줄.some((r) => Array.isArray(r[k])); - if (split) { - out.push({ key: k, head: `${k}·${LOW_HIGH[0]}`, part: "lo" }); - out.push({ key: k, head: `${k}·${LOW_HIGH[1]}`, part: "hi" }); - } else out.push({ key: k, head: k, part: "v" }); - } - return out; -} - -/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 「표」 답(`rows`)과 같음 */ -export function tableRows(table: SlImportTable): (string | number | null)[][] { - const cols = tableCols(table); - const one = (v: unknown, part: "v" | "lo" | "hi"): string | number | null => { - if (part !== "v") { - const x = Array.isArray(v) ? v[part === "lo" ? 0 : 1] : v; - return typeof x === "number" ? x : null; - } - if (Array.isArray(v)) return v.map((x) => x ?? "").join(","); - return typeof v === "number" || (typeof v === "string" && v !== "") ? v : null; - }; - return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))]; -} - -/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */ - -/** 넘길 값 칸 표시(C 칸) — `M01_SL_Import` TEXT.Pass 한 · 영 */ -const PASS = ["넘길 값", "Passed value"]; -const COST3 = ["노무비", "재료비", "경비"] as const; - -/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ -function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { - const got = calc?.[sheetId]?.[cell]; - if (got && "수" in got) return Number(got.수); - if (got && "글" in got) return got.글; - if (got && "참" in got) return got.참; - return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; -} - -/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ -export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { - const out: SlOutsideAt[] = []; - for (const node of sl.nodes) { - const passed: Record = {}; - const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - for (const p of node.pillsIn) { - const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; - if (node.kind !== "table" && PASS.includes(String(note))) - passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); - } - const ref = node.ref ?? ""; - const item = itemRefOf(node); - if (item) { - out.push(...itemOutside(node, item)); - continue; - } - for (const p of outside) { - const id = `${node.id}:${p.name}`; - const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) - ? (p.name as SlField) - : undefined; - let item: SlOutside; - if (node.kind === "table") item = { id, kind: "표", table: ref }; - else if ( - node.kind === "logic" || - ref.startsWith("GF") || - ref.startsWith("GC") || - ref.startsWith("GX") - ) - item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; - else if (ref.startsWith("로직(")) - item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; - else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; - else item = { id, kind: "자재", key: ref }; - out.push({ item, sheet: node.sheetId, cell: p.cell }); - } - } - return out; -} - -/** 항목 상자 바깥 값 — Item `itemOutsides` 와 같은 칸 · 자재 검색어 묶음은 key = 묶음 JSON 글(서버가 「{」 로 풂) */ -function itemOutside(node: SlNode, ref: ItemRef): SlOutsideAt[] { - const at = (cell: string, item: SlOutside): SlOutsideAt => ({ item, sheet: node.sheetId, cell }); - const id = (f: string): string => `${node.id}:${f}`; - const el = ref.element; - const inputs = ref.inputs ?? {}; - if (ref.what === "노무") return [at("B2", { id: id("노무비"), kind: "노임", key: String(el) })]; - if (ref.what === "재료") { - const keyed = typeof el === "object" && el !== null && "키" in el; - const key = keyed ? String((el as { 키: unknown }).키) : JSON.stringify(el); - return [at("B3", { id: id("재료비"), kind: "자재", key })]; - } - if (ref.what === "비율") return []; - const text = String(el); - const key = /로직\(\s*([A-Z]{2}\d{6})/.exec(text)?.[1] ?? ""; - return COST3.map((f, i) => - at( - `B${2 + i}`, - ref.what === "장비" - ? { id: id(f), kind: "장비", element: text, inputs, field: f } - : { id: id(f), kind: "로직", key, inputs, field: f }, - ), - ); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.css b/M01_MasterData/SheetLogic/M01_SL_Item.css deleted file mode 100644 index 45f8a6fa5..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Item.css +++ /dev/null @@ -1,76 +0,0 @@ -.m01-slitem__title { - color: var(--color-text-body); - font-size: var(--text-body); -} - -.m01-slitem__body { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m01-slitem__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-slitem__input { - box-sizing: border-box; - width: 120px; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); - text-align: right; -} - -.m01-slitem__input[readonly] { - background: var(--color-surface); -} - -.m01-slitem__chosen { - margin: 0; - color: var(--color-text-body); - font-weight: 600; -} - -.m01-slitem__prices { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-16); -} - -.m01-slitem__price { - display: flex; - flex-direction: column; - color: var(--color-text-secondary); - font-size: var(--text-caption); - font-variant-numeric: tabular-nums; -} - -.m01-slitem__price strong { - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01-slitem__muted { - flex-basis: 100%; - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-slitem__msg { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-caption); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.ts b/M01_MasterData/SheetLogic/M01_SL_Item.ts deleted file mode 100644 index 38337c9ac..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Item.ts +++ /dev/null @@ -1,647 +0,0 @@ -/* ============================================================================= - * M01_SL_Item.ts - * 시트 로직 — 항목 상자 손잡이. 항목 상자를 고르면 시트 판 위에 붙음. - * 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 고르기 → 상자 시트를 항목 틀로 채움 · 알약 정하기 - * 단가 칸 = 바깥 값(노임 · 자재 · 장비 = 기계경비 로직 · 일위대가 = 기존 로직 결과) — Api `slOutside` 로 받아 넣음 - * 수량 칸 = 입력 알약 · 금액 칸 = 수량 × 단가(ROUND 소수 첫째) · 출력 알약 노무비 / 재료비 / 경비 - * 비율 = 「노무비의 [3]%」 꼴 — 바탕 = 다른 항목 상자의 그 비목 합(시트 사이 참조) - * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; -import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { LogicInput } from "../M01_MasterData_UI_Logic_Api"; -import { searchElements } from "../M01_MasterData_UI_Logic_Api"; -import { formatPrice } from "../M01_MasterData_UI_Logic_Money"; -import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; -import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model"; -import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api"; -import { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; -import "./M01_SL_Item.css"; - -const TEXT = { - Title: ["항목", "Item"], - Kind: ["종류", "Kind"], - Labor: ["노무", "Labor"], - Material: ["재료", "Material"], - Machine: ["장비", "Machine"], - Logic: ["일위대가", "Unit-price job"], - Rate: ["비율", "Rate"], - ToolLoss: ["공구손료", "Small tools"], - Pick: ["고르기", "Choose"], - Repick: ["다시 고르기", "Choose again"], - None: ["아직 고르지 않았어요", "Nothing chosen yet"], - Qty: ["수량", "Quantity"], - Price: ["단가", "Unit price"], - PriceLabor: ["노무 단가", "Labor price"], - PriceMat: ["재료 단가", "Material price"], - PriceExp: ["경비 단가", "Expense price"], - Reload: ["단가 다시 가져오기", "Reload prices"], - Loading: ["단가 가져오는 중", "Loading prices"], - Linked: ["다른 상자에서 이어진 값", "Value from another box"], - Split: [ - "장비는 노무비 · 재료비 · 경비 셋으로 나뉘어요", - "Machines split into labor, material and expense", - ], - Formula: ["금액 = 수량 × 단가 (소수 첫째 자리까지)", "Amount = quantity × price (one decimal)"], - RatePct: ["율(%)", "Rate (%)"], - Base: ["무엇의", "Of what"], - Into: ["넣을 자리", "Goes into"], - Fit: ["바탕 맞추기", "Refresh base"], - BaseHint: ["바탕 = 다른 항목 상자의 그 합계", "Base = total of the other item boxes"], - Labor3: ["노무비", "Labor cost"], - Mat3: ["재료비", "Material cost"], - Exp3: ["경비", "Expense"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], - NoPrice: ["단가를 찾지 못했어요", "Price not found"], - Won: ["원", "KRW"], -} as const; - -type TextKey = keyof typeof TEXT; -const ti = (key: TextKey): string => TEXT[key][currentLanguageIndex]; - -export type ItemKind = "노무" | "재료" | "장비" | "일위대가" | "비율"; -export type CostName = "노무비" | "재료비" | "경비"; -const COSTS: CostName[] = ["노무비", "재료비", "경비"]; - -/** 항목 상자가 `node.ref` 에 글(JSON)로 들고 있는 것 */ -export interface ItemRef { - what: ItemKind; - /** 고른 것의 PickResult.element 그대로 */ - element?: unknown; - /** 크기(규격) */ - spec?: string; - /** 수량 단위 */ - unit?: string; - /** 고른 것이 받는 값(장비 지역 · 일위대가 넘길 값)과 지금 값 */ - need?: LogicInput[]; - inputs?: Record; - /** 비율 */ - base?: CostName; - into?: CostName; -} - -/** 칸 자리 — 항목 틀 둘(돈 · 비율) */ -const MONEY = { - qty: "B1", - labor: "B2", - mat: "B3", - exp: "B4", - unit: "B5", - amount: "B6", - cost: { 노무비: "B7", 재료비: "B8", 경비: "B9" }, - used: "B10", -}; -const RATE = { - rate: "B1", - base: "B2", - baseKind: "B3", - amount: "B4", - into: "B5", - cost: { 노무비: "B6", 재료비: "B7", 경비: "B8" }, - used: "B9", - unit: "B10", -}; - -export function itemRefOf(node: SlNode): ItemRef | null { - try { - const r = node.ref ? (JSON.parse(node.ref) as ItemRef) : null; - return r && r.what ? r : null; - } catch { - return null; - } -} - -/** 항목 상자의 비목 칸 자리 — 합계 · 바탕이 같은 자리를 읽음 */ -export const costCell = (ref: ItemRef, cost: CostName): string => - ref.what === "비율" ? RATE.cost[cost] : MONEY.cost[cost]; - -/* ---- 시트 칸 고치기(받은 것은 안 바꿈) ---- */ -const text = (v: string): Cell => ({ 값: v }); -const num = (v: number): Cell => ({ 값: v }); -const calc = (f: string): Cell => ({ 식: f }); - -function editSheet( - sl: SheetLogic, - node: SlNode, - edit: (cells: Record, s: Sheet) => void, -): SheetLogic { - const 시트 = sl.workbook.시트.map((s) => { - if (s.id !== node.sheetId) return s; - const next = { ...s, 칸: { ...s.칸 } }; - edit(next.칸, next); - return next; - }); - return { ...sl, workbook: { ...sl.workbook, 시트 } }; -} -const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => - sl.workbook.시트.find((s) => s.id === node.sheetId); -const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined => - sl.nodes.find((n) => n.id === id); -const withNode = (sl: SheetLogic, node: SlNode): SheetLogic => ({ - ...sl, - nodes: sl.nodes.map((n) => (n.id === node.id ? node : n)), -}); -const listRule = (id: string, cell: string, values: string[]) => ({ - id, - 범위: [{ r0: Number(cell.slice(1)) - 1, c0: 1, r1: Number(cell.slice(1)) - 1, c1: 1 }], - 규칙: { 종류: "목록" as const, 값들: values }, - 어기면: "막음" as const, -}); - -/* ---- 항목 틀 ---- */ -/** 일위대가표가 읽는 출력 알약 이름 */ -export const USED = "사용 수량"; -export const PILL_PRICE = "단가"; -export const PILL_AMOUNT = "금액"; -const PILL_BASE = "바탕 금액"; - -function moneyLayout(ref: ItemRef, qty: Cell | undefined, outside: SlField[]) { - const label = (cell: string, name: string): [string, Cell] => ["A" + cell.slice(1), text(name)]; - const cells: Record = Object.fromEntries([ - label(MONEY.qty, ti("Qty")), - label(MONEY.labor, ti("PriceLabor")), - label(MONEY.mat, ti("PriceMat")), - label(MONEY.exp, ti("PriceExp")), - label(MONEY.unit, PILL_PRICE), - label(MONEY.amount, PILL_AMOUNT), - label(MONEY.cost.노무비, ti("Labor3")), - label(MONEY.cost.재료비, ti("Mat3")), - label(MONEY.cost.경비, ti("Exp3")), - label(MONEY.used, USED), - ]); - if (qty) cells[MONEY.qty] = qty; - // 바깥 값이 아닌 단가 칸은 0 으로 고정 - for (const [cell, field] of [ - [MONEY.labor, "노무비"], - [MONEY.mat, "재료비"], - [MONEY.exp, "경비"], - ] as const) { - if (!outside.includes(field)) cells[cell] = num(0); - } - cells[MONEY.unit] = calc("B2+B3+B4"); - cells[MONEY.amount] = calc("ROUND(B1*B5,1)"); - cells[MONEY.cost.노무비] = calc("ROUND(B1*B2,1)"); - cells[MONEY.cost.재료비] = calc("ROUND(B1*B3,1)"); - cells[MONEY.cost.경비] = calc("ROUND(B1*B4,1)"); - cells[MONEY.used] = calc("B1"); - const unit = ref.unit ?? ""; - const won = ti("Won"); - const out = (name: string, cell: string, u = won): SlPill => ({ name, cell, unit: u }); - const pillsIn: SlPill[] = [ - { name: ti("Qty"), cell: MONEY.qty, unit }, - ...(outside.includes("노무비") - ? [{ name: ti("PriceLabor"), cell: MONEY.labor, unit: won, outside: true }] - : []), - ...(outside.includes("재료비") - ? [{ name: ti("PriceMat"), cell: MONEY.mat, unit: won, outside: true }] - : []), - ...(outside.includes("경비") - ? [{ name: ti("PriceExp"), cell: MONEY.exp, unit: won, outside: true }] - : []), - ]; - const pillsOut: SlPill[] = [ - out(PILL_PRICE, MONEY.unit), - out(PILL_AMOUNT, MONEY.amount), - out(ti("Labor3"), MONEY.cost.노무비), - out(ti("Mat3"), MONEY.cost.재료비), - out(ti("Exp3"), MONEY.cost.경비), - out(USED, MONEY.used, unit), - ]; - return { cells, pillsIn, pillsOut, rules: [] as ReturnType[] }; -} - -function rateLayout(ref: ItemRef, rate: Cell | undefined) { - const base = ref.base ?? "노무비"; - const into = ref.into ?? "재료비"; - const cells: Record = { - A1: text(ti("RatePct")), - B1: rate ?? num(3), - A2: text(PILL_BASE), - B2: num(0), - A3: text(ti("Base")), - B3: text(base), - A4: text(PILL_AMOUNT), - B4: calc("ROUND(B2*B1/100,1)"), - A5: text(ti("Into")), - B5: text(into), - A6: text(ti("Labor3")), - B6: calc('IF(B5="노무비",B4,0)'), - A7: text(ti("Mat3")), - B7: calc('IF(B5="재료비",B4,0)'), - A8: text(ti("Exp3")), - B8: calc('IF(B5="경비",B4,0)'), - A9: text(USED), - B9: calc("B1"), - A10: text(ti("Price")), - B10: calc("B2"), - }; - const pillsIn: SlPill[] = [ - { name: ti("RatePct"), cell: RATE.rate, unit: "%" }, - { name: PILL_BASE, cell: RATE.base, unit: ti("Won") }, - ]; - const pillsOut: SlPill[] = [ - { name: PILL_PRICE, cell: RATE.unit, unit: ti("Won") }, - { name: PILL_AMOUNT, cell: RATE.amount, unit: ti("Won") }, - { name: ti("Labor3"), cell: RATE.cost.노무비, unit: ti("Won") }, - { name: ti("Mat3"), cell: RATE.cost.재료비, unit: ti("Won") }, - { name: ti("Exp3"), cell: RATE.cost.경비, unit: ti("Won") }, - { name: USED, cell: RATE.used, unit: "%" }, - ]; - const rules = [listRule("sl-base", RATE.baseKind, COSTS), listRule("sl-into", RATE.into, COSTS)]; - return { cells, pillsIn, pillsOut, rules }; -} - -/** 어떤 바깥 값이 필요한가 — 노임 = 노무 · 자재 = 재료 · 장비 · 일위대가 = 셋 */ -const outsideFields = (what: ItemKind): SlField[] => - what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : what === "비율" ? [] : COSTS; -const FIELD_CELL: Record = { - 노무비: MONEY.labor, - 재료비: MONEY.mat, - 경비: MONEY.exp, -}; - -/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */ -/** 상자 시트를 항목 틀로 채움 — 가져오기(Import)도 이 틀 · ref 로 맞춤 */ -export function applyItemLayout( - sl: SheetLogic, - node: SlNode, - ref: ItemRef, - qty?: number | null, -): SheetLogic { - return applyLayout(sl, node, ref, qty); -} - -function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic { - const sheet = sheetOf(sl, node); - const oldRef = itemRefOf(node); - const keep = - oldRef && oldRef.what !== "비율" && ref.what !== "비율" ? sheet?.칸[MONEY.qty] : undefined; - const keepRate = - oldRef?.what === "비율" && ref.what === "비율" ? sheet?.칸[RATE.rate] : undefined; - const given: Cell | undefined = qty !== undefined && qty !== null ? num(qty) : undefined; - const lay = - ref.what === "비율" - ? rateLayout(ref, keepRate) - : moneyLayout(ref, given ?? keep, outsideFields(ref.what)); - const next = editSheet(sl, node, (cells, s) => { - for (const k of Object.keys(cells)) delete cells[k]; - Object.assign(cells, lay.cells); - if (lay.rules.length) s.유효성 = lay.rules; - else delete s.유효성; - }); - const names = new Set([...lay.pillsIn.map((p) => p.name), ...lay.pillsOut.map((p) => p.name)]); - const kept = (e: { from: { node: string; pill: string }; to: { node: string; pill: string } }) => - (e.from.node !== node.id || names.has(e.from.pill)) && - (e.to.node !== node.id || names.has(e.to.pill)); - return { - ...withNode(next, { - ...node, - ref: JSON.stringify(ref), - pillsIn: lay.pillsIn, - pillsOut: lay.pillsOut, - stage: 3, - }), - edges: next.edges.filter(kept), - }; -} - -/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(Model `connect`)로 함 - * · 아는 항목 상자(ref 가 이 틀 것)만 바탕이 됨 — 모르는 상자(가져온 것 등)는 읽지도 쓰지도 않음 - * · 비목을 바꾸면 옛 비목 선만 끊음 — 손으로 이은 다른 선 · 그 칸 수식은 그대로 */ -export function refreshBases(sl: SheetLogic): SheetLogic { - let out = sl; - for (const node of sl.nodes) { - const ref = itemRefOf(node); - if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue; - const base = ref.base ?? "노무비"; - const known = (n: SlNode): boolean => { - const r = itemRefOf(n); - return n.id !== node.id && n.kind === "item" && !!r && r.what !== "비율"; - }; - for (const e of out.edges.filter((x) => x.to.node === node.id && x.to.pill === PILL_BASE)) { - const from = out.nodes.find((n) => n.id === e.from.node); - if (from && known(from) && e.from.pill !== base && (COSTS as string[]).includes(e.from.pill)) - out = disconnect(out, e.id); - } - for (const src of out.nodes.filter(known)) { - const there = out.edges.some( - (e) => - e.from.node === src.id && - e.from.pill === base && - e.to.node === node.id && - e.to.pill === PILL_BASE, - ); - if (!there) - out = connect(out, { node: src.id, pill: base }, { node: node.id, pill: PILL_BASE }).sl; - } - } - return out; -} - -/** 이 항목 상자들이 필요로 하는 바깥 값 자리 — Api `slOutside` · 저장이 같은 목록을 씀 */ -export function itemOutsides(sl: SheetLogic): SlOutsideAt[] { - return sl.nodes.flatMap((node) => { - const ref = itemRefOf(node); - const sheet = sheetOf(sl, node); - if (!ref || !sheet) return []; - const items = outsideItems(ref, node.id); - return items.map(({ field, item }) => ({ item, sheet: sheet.id, cell: FIELD_CELL[field] })); - }); -} - -function outsideItems(ref: ItemRef, id: string): { field: SlField; item: SlOutside }[] { - const key = typeof ref.element === "string" ? ref.element : ""; - const name = (f: string): string => `${id}:${f}`; - if (ref.what === "노무") - return [{ field: "노무비", item: { id: name("노무비"), kind: "노임", key } }]; - if (ref.what === "재료") { - const k = (ref.element as { 키?: string } | null)?.키 ?? ""; - return [{ field: "재료비", item: { id: name("재료비"), kind: "자재", key: k } }]; - } - const inputs = ref.inputs ?? {}; - if (ref.what === "장비") { - return COSTS.map((f) => ({ - field: f, - item: { id: name(f), kind: "장비" as const, element: key, inputs, field: f }, - })); - } - if (ref.what === "일위대가") { - const logicKey = key.match(/로직\(\s*([A-Z]{2}\d{6})/)?.[1] ?? ""; - return COSTS.map((f) => ({ - field: f, - item: { id: name(f), kind: "로직" as const, key: logicKey, inputs, field: f }, - })); - } - return []; -} - -/** 단가를 서버에서 받아 단가 칸에 넣음 — 못 구한 것은 까닭을 돌려줌(쉬운 말) */ -export async function loadPrices( - sl: SheetLogic, - nodeId: string, -): Promise<{ sl: SheetLogic; why: string | null }> { - const node = nodeOf(sl, nodeId); - const ref = node && itemRefOf(node); - if (!node || !ref) return { sl, why: null }; - const items = outsideItems(ref, node.id); - if (!items.length) return { sl, why: null }; - const got = await slOutside(items.map((i) => i.item)); - let why: string | null = null; - const next = editSheet(sl, node, (cells) => { - for (const { field, item } of items) { - const v = got.find((g) => g.id === item.id); - if (v?.ok && typeof v.value === "number") cells[FIELD_CELL[field]] = num(v.value); - else why = v?.reason ?? ti("NoPrice"); - } - }); - return { sl: next, why }; -} - -const PICK_KIND = { 노무: "person", 재료: "material", 장비: "machine", 일위대가: "logic" } as const; -const KIND_OF_PICK: Record = { - person: "노무", - material: "재료", - machine: "장비", - logic: "일위대가", -}; - -/** 재료는 검색어 묶음만 오므로 단가를 부를 번호를 찾아 둠 */ -async function materialKey(el_: Record): Promise { - const found = await searchElements("재료", el_["검색어"] ?? ""); - const hit = found.items.find( - (it) => `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() === (el_["검색어"] ?? "").trim(), - ); - return (hit ?? found.items[0])?.ref ?? ""; -} - -/** 고른 것으로 항목 상자를 채움(단가는 따로 loadPrices) */ -export async function applyPick( - sl: SheetLogic, - nodeId: string, - pick: PickResult, -): Promise { - const node = nodeOf(sl, nodeId); - if (!node) return sl; - const what = KIND_OF_PICK[pick.kind]; - let element = pick.element; - if (what === "재료") { - const e = { ...(pick.element as Record) }; - element = { ...e, 키: await materialKey(e) }; - } - const inputs: Record = {}; - for (const n of pick.needInputs ?? []) inputs[n.이름] = n.고르기?.[0] ?? n.범위?.[0]; - const ref: ItemRef = { - what, - element, - spec: pick.spec, - unit: pick.unit, - need: pick.needInputs, - inputs, - }; - const named = withNode(sl, { ...node, name: `${pick.name}${pick.spec ? ` ${pick.spec}` : ""}` }); - return refreshBases(applyLayout(named, nodeOf(named, nodeId)!, ref, pick.qty)); -} - -/* ---- 손잡이 ---- */ -const cellValue = (sl: SheetLogic, node: SlNode, cell: string): Cell | undefined => - sheetOf(sl, node)?.칸[cell]; - -export function renderItemTools( - host: HTMLElement, - sl: SheetLogic, - nodeId: string, - on: { change(sl: SheetLogic): void }, -): void { - const node = nodeOf(sl, nodeId); - const msg = el("p", { className: "m01-slitem__msg" }); - if (!node) { - host.replaceChildren(); - return; - } - const ref = itemRefOf(node); - const emit = (make: () => SheetLogic): void => { - msg.textContent = ""; - try { - on.change(make()); - } catch (e) { - msg.textContent = slPlain(e) || ti("Cant"); - } - }; - const setRef = (patch: Partial): SheetLogic => { - const next: ItemRef = { ...ref!, ...patch }; - return refreshBases(applyLayout(sl, node, next)); - }; - const chosen = (): HTMLElement => - el("p", { - className: "m01-slitem__chosen", - text: ref && ref.what !== "비율" ? node.name : ti("None"), - }); - - const pickAndLoad = async (kind: ItemKind): Promise => { - if (kind === "비율") { - emit(() => { - const named = /^항목 \d+$/.test(node.name) - ? withNode(sl, { ...node, name: ti("ToolLoss") }) - : sl; - return refreshBases( - applyLayout(named, nodeOf(named, node.id)!, { - what: "비율", - base: "노무비", - into: "재료비", - }), - ); - }); - return; - } - const got = await openEasyPick(PICK_KIND[kind]); - if (!got) return; - msg.textContent = ti("Loading"); - try { - const filled = await applyPick(sl, node.id, got); - on.change(filled); - const priced = await loadPrices(filled, node.id); - msg.textContent = priced.why ?? ""; - on.change(priced.sl); - } catch (e) { - msg.textContent = slPlain(e) || ti("Cant"); - } - }; - - const kindField = createSelectField({ - label: ti("Kind"), - options: (["노무", "재료", "장비", "일위대가", "비율"] as ItemKind[]).map((k) => ({ - value: k, - text: ti( - k === "노무" - ? "Labor" - : k === "재료" - ? "Material" - : k === "장비" - ? "Machine" - : k === "비율" - ? "Rate" - : "Logic", - ), - })), - value: ref?.what ?? "노무", - compact: true, - onChange: (v) => void pickAndLoad(v as ItemKind), - }); - - const numberBox = (cell: string, label: string, unit: string): HTMLElement => { - const c = cellValue(sl, node, cell); - const linked = c?.식 !== undefined; - const box = el("input", { - className: "m01-slitem__input", - attrs: { type: "text", inputmode: "decimal", "aria-label": label }, - }); - box.value = linked ? "" : c?.값 === undefined ? "" : String(c.값); - box.readOnly = linked; - box.addEventListener("change", () => { - const raw = box.value.trim(); - const n = Number(raw); - if (raw === "" || Number.isNaN(n)) { - msg.textContent = ti("Bad"); - return; - } - emit(() => editSheet(sl, node, (cells) => (cells[cell] = num(n)))); - }); - return el("label", { - className: "m01-slitem__field", - children: [ - el("span", { text: `${label}${unit ? ` (${unit})` : ""}` }), - box, - ...(linked ? [el("small", { className: "m01-slitem__muted", text: ti("Linked") })] : []), - ], - }); - }; - const costSelect = ( - label: string, - value: CostName, - patch: (c: CostName) => Partial, - ): HTMLElement => { - const f = createSelectField({ - label, - options: COSTS.map((c) => ({ value: c, text: c })), - value, - compact: true, - onChange: (v) => emit(() => setRef(patch(v as CostName))), - }); - return f.root; - }; - - const body: HTMLElement[] = [kindField.root]; - if (ref && ref.what !== "비율") { - const price = (cell: string, label: string): HTMLElement => - el("div", { - className: "m01-slitem__price", - children: [ - el("span", { text: label }), - el("strong", { text: formatPrice(cellValue(sl, node, cell)?.값 ?? 0) }), - ], - }); - body.push( - chosen(), - createButton({ - label: ref.element === undefined ? ti("Pick") : ti("Repick"), - size: "sm", - onClick: () => void pickAndLoad(ref.what), - }), - numberBox(MONEY.qty, ti("Qty"), ref.unit ?? ""), - el("div", { - className: "m01-slitem__prices", - children: [ - price(MONEY.labor, ti("PriceLabor")), - price(MONEY.mat, ti("PriceMat")), - price(MONEY.exp, ti("PriceExp")), - ], - }), - ); - if (ref.element !== undefined) { - body.push( - createButton({ - label: ti("Reload"), - size: "sm", - variant: "ghost", - onClick: () => - void loadPrices(sl, node.id).then( - (r) => { - msg.textContent = r.why ?? ""; - on.change(r.sl); - }, - (e) => (msg.textContent = slPlain(e) || ti("NoPrice")), - ), - }), - ); - } - body.push( - el("p", { - className: "m01-slitem__muted", - text: ref.what === "장비" || ref.what === "일위대가" ? ti("Split") : ti("Formula"), - }), - ); - } else if (ref) { - body.push( - numberBox(RATE.rate, ti("RatePct"), "%"), - costSelect(ti("Base"), ref.base ?? "노무비", (c) => ({ base: c })), - costSelect(ti("Into"), ref.into ?? "재료비", (c) => ({ into: c })), - createButton({ - label: ti("Fit"), - size: "sm", - variant: "ghost", - onClick: () => emit(() => refreshBases(sl)), - }), - el("p", { className: "m01-slitem__muted", text: ti("BaseHint") }), - ); - } else { - body.push(chosen()); - } - host.replaceChildren( - el("strong", { className: "m01-slitem__title", text: `${ti("Title")} · ${node.name}` }), - el("div", { className: "m01-slitem__body", children: body }), - msg, - ); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.css b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css deleted file mode 100644 index b8392d2e8..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_LogicTools.css +++ /dev/null @@ -1,87 +0,0 @@ -.m01-sllogic__title { - color: var(--color-text-body); - font-size: var(--text-body); -} - -.m01-sllogic__chosen { - margin: var(--spacing-4) 0; - color: var(--color-text-body); - font-weight: 600; -} - -.m01-sllogic__search { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - margin: var(--spacing-8) 0; -} - -.m01-sllogic__input { - box-sizing: border-box; - width: 200px; - max-width: 100%; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); -} - -.m01-sllogic__input[readonly] { - background: var(--color-surface); -} - -.m01-sllogic__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - max-height: 240px; - margin: 0 0 var(--spacing-8); - padding: 0; - overflow: auto; - list-style: none; -} - -.m01-sllogic__list .ui-btn { - max-width: 100%; - text-align: left; - white-space: normal; -} - -.m01-sllogic__fields { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-8) 0; - color: var(--color-text-body); -} - -.m01-sllogic__fields > strong, -.m01-sllogic__fields > .m01-sllogic__muted { - flex-basis: 100%; -} - -.m01-sllogic__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-sllogic__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-sllogic__msg { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-caption); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts deleted file mode 100644 index 0ddf8c2b2..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts +++ /dev/null @@ -1,297 +0,0 @@ -/* ============================================================================= - * M01_SL_LogicTools.ts - * 시트 로직 — 「다른 로직」 상자 손잡이. 기존 로직을 찾아 고르면 상자 시트를 그 로직의 틀로 채움 - * 로직의 입력값마다 입력 알약(「넘길 값」 줄 · 예시값) · 결과는 바깥 값 출력 알약 - * 돈 로직 = 노무비 · 재료비 · 경비 · 계 / 돈 아닌 로직(작업량 등) = 결과 - * 틀 모양은 Import 의 다른 로직 상자와 한 벌(`outsideOf` 가 「넘길 값」 칸과 알약 이름으로 물음을 만듦) - * — 상자 `source` 는 안 씀: 한 상자에 비목별 물음이 여럿이라 알약 이름이 비목을 정함. 상자 `ref` = 로직 번호. - * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; -import type { Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; -import { listEasy, openEasy, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; -import { slPlain } from "./M01_SL_Api"; -import { disconnect, renameNode, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; -import "./M01_SL_LogicTools.css"; - -const TEXT = { - Title: ["다른 로직", "Other logic"], - Search: ["로직 이름 찾기", "Find a logic by name"], - Find: ["찾기", "Find"], - None: ["아직 고르지 않았어요", "Nothing chosen yet"], - Chosen: ["고른 로직", "Chosen logic"], - NoHit: ["찾은 로직이 없어요", "No logic found"], - More: ["더 있어요 — 이름을 더 적어 보세요", "More exist — type more of the name"], - Loading: ["가져오는 중", "Loading"], - Inputs: ["넘길 값", "Passed values"], - NoInputs: ["넘길 값이 없는 로직이에요", "This logic takes no values"], - Linked: ["다른 상자에서 이어진 값", "Value from another box"], - Pick: ["고르세요", "Choose"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Hint: [ - "결과는 바깥 값 — 열 때 · 저장 때 넣어요", - "Result is an outside value — filled on open and save", - ], - Result: ["결과", "Result"], - Labor: ["노무비", "Labor cost"], - Material: ["재료비", "Material cost"], - Expense: ["경비", "Expense"], - Sum: ["계", "Total"], - Name: ["이름", "Name"], - Value: ["값", "Value"], - Note: ["설명", "Note"], - Pass: ["넘길 값", "Passed value"], - Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], -} as const; - -type TextKey = keyof typeof TEXT; -const tt = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -const LIMIT = 30; - -/** 로직 결과가 돈인가 */ -const isMoney = (unit: string): boolean => unit.startsWith("원"); - -/** 상자 시트를 다른 로직 틀로 채움 · `trial` = 입력값 예시(열 때 받은 자동값) */ -export function applyLogic( - sl: SheetLogic, - nodeId: string, - row: LogicRow, - trial: Record = {}, -): SheetLogic { - const node = sl.nodes.find((n) => n.id === nodeId); - if (!node) return sl; - if (node.ref === row.키 && node.pillsIn.length) return sl; // 같은 로직을 또 고름 — 그대로 - // 다른 로직을 고르면 옛 선은 끊음(받는 칸 수식도 함께 정리) - let work = sl; - for (const e of sl.edges) - if (e.from.node === nodeId || e.to.node === nodeId) work = disconnect(work, e.id); - for (const name of [row.이름, `${row.이름} 2`, `${row.이름} 3`]) { - const got = renameNode(work, nodeId, name); - if (!got.why) { - work = got.sl; - break; - } - } - const cells: Record = { - A1: { 값: tt("Name") }, - B1: { 값: tt("Value") }, - C1: { 값: tt("Note") }, - }; - const pillsIn: SlPill[] = []; - const pillsOut: SlPill[] = []; - let r = 2; - for (const spec of row.입력 ?? []) { - const at = `B${r}`; - cells[`A${r}`] = { 값: spec.이름 }; - const v = trial[spec.이름]; - if (typeof v === "number" || typeof v === "string") cells[at] = { 값: v }; - cells[`C${r}`] = { 값: tt("Pass") }; - pillsIn.push({ name: spec.이름, cell: at, ...(spec.단위 ? { unit: spec.단위 } : {}) }); - r++; - } - const unit = row.결과단위 ?? ""; - const money = isMoney(unit); - const outs = money ? [tt("Labor"), tt("Material"), tt("Expense"), tt("Sum")] : [tt("Result")]; - for (const name of outs) { - const at = `B${r}`; - cells[`A${r}`] = { 값: name }; - cells[`C${r}`] = { 값: tt("Outside") }; - pillsIn.push({ name, cell: at, outside: true }); - pillsOut.push({ name, cell: at, unit: money ? "원" : unit }); - r++; - } - const 시트 = work.workbook.시트.map((s) => (s.id === node.sheetId ? { ...s, 칸: cells } : s)); - return { - ...work, - workbook: { ...work.workbook, 시트 }, - nodes: work.nodes.map((n) => { - if (n.id !== nodeId) return n; - const next: SlNode = { ...n, ref: row.키, pillsIn, pillsOut, stage: 1 }; - delete next.source; - return next; - }), - }; -} - -/** 그 로직의 입력 설명(고르기 · 범위) — 한 번 받은 것은 다시 안 받음 */ -const rows = new Map(); - -/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */ -export function renderLogicTools( - host: HTMLElement, - sl: SheetLogic, - nodeId: string, - on: { change(sl: SheetLogic): void }, -): void { - const node = sl.nodes.find((n) => n.id === nodeId); - const msg = el("p", { className: "m01-sllogic__msg" }); - if (!node) { - host.replaceChildren(); - return; - } - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - const results = el("ul", { className: "m01-sllogic__list" }); - const fieldsHost = el("div", { className: "m01-sllogic__fields" }); - const box = el("input", { - className: "m01-sllogic__input", - attrs: { type: "text", placeholder: tt("Search"), "aria-label": tt("Search") }, - }); - - const emit = (make: () => SheetLogic): void => { - msg.textContent = ""; - try { - on.change(make()); - } catch (e) { - msg.textContent = slPlain(e); - } - }; - - const choose = async (item: EasyListItem): Promise => { - msg.textContent = tt("Loading"); - try { - const opened = await openEasy(item.key); - rows.set(item.key, opened.row); - msg.textContent = ""; - on.change(applyLogic(sl, node.id, opened.row, opened.trial)); - } catch (e) { - msg.textContent = slPlain(e); - } - }; - - const search = async (): Promise => { - msg.textContent = tt("Loading"); - try { - const found = await listEasy(box.value.trim()); - msg.textContent = ""; - results.replaceChildren( - ...(found.length - ? found.slice(0, LIMIT).map((it) => { - const b = createButton({ - label: `${it.name} · ${it.resultUnit}${it.where ? ` · ${it.where}` : ""}`, - variant: "ghost", - size: "sm", - onClick: () => void choose(it), - }); - return el("li", { children: [b] }); - }) - : [el("li", { className: "m01-sllogic__muted", text: tt("NoHit") })]), - ...(found.length > LIMIT - ? [el("li", { className: "m01-sllogic__muted", text: tt("More") })] - : []), - ); - } catch (e) { - msg.textContent = slPlain(e); - } - }; - box.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") void search(); - }); - - /** 넘길 값 칸 — 고르기가 있으면 고르기 · 아니면 숫자 · 다른 상자에서 이어진 칸은 읽기만 */ - const fieldFor = (pill: SlPill, spec: LogicInput | undefined): HTMLElement => { - const cur = sheet?.칸[pill.cell]; - const linked = cur?.식 !== undefined; - const put = (v: number | string): void => - emit(() => ({ - ...sl, - workbook: { - ...sl.workbook, - 시트: sl.workbook.시트.map((s) => - s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [pill.cell]: { 값: v } } } : s, - ), - }, - })); - const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); - let control: HTMLElement; - if (spec?.고르기?.length && !linked) { - control = createSelectField({ - options: [ - { value: "", text: tt("Pick") }, - ...spec.고르기.map((o) => ({ value: String(o), text: String(o) })), - ], - value: cur?.값 === undefined ? "" : String(cur.값), - compact: true, - ariaLabel: pill.name, - onChange: (v) => { - const hit = spec.고르기?.find((o) => String(o) === v); - if (hit !== undefined) put(hit); - }, - }).root; - } else { - const input = el("input", { - className: "m01-sllogic__input", - attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, - }); - if (spec?.범위) input.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; - input.value = linked || cur?.값 === undefined ? "" : String(cur.값); - input.readOnly = linked; - input.addEventListener("change", () => { - const raw = input.value.trim(); - const n = Number(raw); - if (raw === "") return; - if (Number.isNaN(n)) msg.textContent = tt("Bad"); - else put(n); - }); - control = input; - } - return el("label", { - className: "m01-sllogic__field", - children: [ - label, - control, - ...(linked ? [el("small", { className: "m01-sllogic__muted", text: tt("Linked") })] : []), - ], - }); - }; - - const drawFields = (row: LogicRow | undefined): void => { - const passed = node.pillsIn.filter((p) => !p.outside); - fieldsHost.replaceChildren( - el("strong", { text: tt("Inputs") }), - ...(passed.length - ? passed.map((p) => - fieldFor( - p, - row?.입력?.find((i) => i.이름 === p.name), - ), - ) - : [el("p", { className: "m01-sllogic__muted", text: tt("NoInputs") })]), - el("p", { className: "m01-sllogic__muted", text: tt("Hint") }), - ); - }; - - host.replaceChildren( - el("strong", { className: "m01-sllogic__title", text: `${tt("Title")} · ${node.name}` }), - el("p", { - className: "m01-sllogic__chosen", - text: node.ref ? `${tt("Chosen")}: ${node.name}` : tt("None"), - }), - el("div", { - className: "m01-sllogic__search", - children: [ - box, - createButton({ label: tt("Find"), size: "sm", onClick: () => void search() }), - ], - }), - results, - ...(node.ref ? [fieldsHost] : []), - msg, - ); - if (node.ref) { - const known = rows.get(node.ref); - drawFields(known); - if (!known) { - void openEasy(node.ref).then( - (o) => { - rows.set(o.row.키, o.row); - drawFields(o.row); - }, - () => undefined, - ); - } - } -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts deleted file mode 100644 index a25652913..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ /dev/null @@ -1,538 +0,0 @@ -/* ============================================================================= - * M01_SL_Model.ts - * 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸 - * 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음 - * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 - * - * 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만 - * - * 약속 ① 표 블록 = 서버(SL_Router `_table`) 칸 차례 하나 — 머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 조건이든 값이든 - * 「이름·아래」 「이름·위」 두 칸(숫자 하나면 둘 다 그 수) · 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸 - * 약속 ② 항목 상자 단가 = Item 의 칸별 목록(`itemOutsides`)이 바깥 값 주인 · 서버는 저장 때 그 목록도 받고 같은 칸이면 그쪽이 이김 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands"; -import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; - -/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ -export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result"; - -/** 알약 = 이름 붙은 칸 */ -export interface SlPill { - name: string; - /** 「B3」 */ - cell: string; - unit?: string; - /** 바깥 값 칸 — 열 때 · 저장 때 마스터에서 넣음 */ - outside?: boolean; -} - -/** 상자 = 시트 하나 */ -export interface SlNode { - id: string; - kind: SlKind; - name: string; - sheetId: string; - pillsIn: SlPill[]; - pillsOut: SlPill[]; - /** 표 키 · 요소 · 기존 로직 키 · 내 표 id */ - ref?: string; - /** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */ - source?: SlSource; - /** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */ - stage: number; -} - -/** 선 — 출력 알약 → 입력 알약 */ -export interface SlEdge { - id: string; - from: { node: string; pill: string }; - to: { node: string; pill: string }; -} - -/** 돈 로직 결과에서 꺼낼 칸 */ -export type SlField = "노무비" | "재료비" | "경비" | "계"; - -/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로 - * · 표 = 품셈 표 통째(머리 줄 + 줄 · 열 차례 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) - * 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(pillsOut · outside) — 고정 A1 아님 · Eval 이 그 칸부터 채움 - * · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */ -export type SlSource = - | { kind: "표"; table: string } - | { kind: "노임" | "자재"; key: string } - | { kind: "장비"; element: string; inputs: Record; field?: SlField } - | { kind: "로직"; key: string; inputs: Record; field?: SlField }; - -/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */ -export interface SlOutside { - id: string; - node: string; - cell: string; - kind: "table" | "price" | "logic"; - ref: string; - source?: SlSource; -} - -/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */ -export const RANGE_LOW = "·아래"; -export const RANGE_HIGH = "·위"; -export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH]; - -/** 내 표 — 품셈에 없는 표(사용자가 만듦) */ -export interface SlUserTable { - id: string; - name: string; - cols: string[]; - rows: (number | string | null)[][]; -} - -export interface SheetLogic { - key: string | null; - name: string; - resultUnit: string; - workbook: Workbook; - nodes: SlNode[]; - edges: SlEdge[]; - userTables?: SlUserTable[]; -} - -/** 알약 한쪽 끝 */ -export interface SlEnd { - node: string; - pill: string; -} - -const TEXT = { - Name: ["이름", "Name"], - Value: ["값", "Value"], - Note: ["설명", "Note"], - Sample: ["예시값", "Sample value"], - Fixed: ["늘 이 값", "Always this value"], - Cond: ["조건", "Condition"], - Found: ["결과", "Result"], - ValueA: ["값1", "Value 1"], - ValueB: ["값2", "Value 2"], - Times: ["값1 × 값2", "Value 1 × Value 2"], - Qty: ["수량", "Quantity"], - Price: ["단가", "Unit price"], - Amount: ["금액", "Amount"], - QtyNote: ["선으로 받음", "Comes in by a line"], - PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"], - LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"], - Sum: ["계", "Total"], - PriceLabor: ["노무 단가", "Labor price"], - PriceMat: ["재료 단가", "Material price"], - PriceExp: ["경비 단가", "Expense price"], - Labor3: ["노무비", "Labor cost"], - Mat3: ["재료비", "Material cost"], - Exp3: ["경비", "Expense"], - Won: ["원", "KRW"], - NoPill: ["알약이 없음", "No such pill"], - Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], - Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], - Twice: ["이미 이어져 있음", "Already linked"], - NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"], - NameChar: [ - "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", - "Name can't use [ ] : * ? / \\ or a leading/trailing '", - ], - NameSame: ["같은 이름 상자가 있음", "A box with this name exists"], - NoBox: ["상자가 없음", "No such box"], -} as const; - -const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */ -const COSTS = ["노무비", "재료비", "경비"] as const; - -/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */ -const USED = "사용 수량"; - -/* ── 작은 도움 ──────────────────────────────────────────────────────────── */ - -const STAGE: Record = { - input: 0, - const: 0, - table: 1, - logic: 1, - calc: 2, - item: 3, - result: 4, -}; - -const clone = (v: T): T => structuredClone(v); - -/** 다음 번호 — 있는 id 들(「n3」)보다 하나 큰 것 */ -function nextId(prefix: string, ids: string[]): string { - const nums = ids - .filter((x) => x.startsWith(prefix)) - .map((x) => Number(x.slice(prefix.length))) - .filter((n) => Number.isInteger(n)); - return `${prefix}${Math.max(0, ...nums) + 1}`; -} - -/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */ -const BAD_CHARS = /[[\]:*?/\\]/g; -const cleanName = (name: string): string => - name - .replace(BAD_CHARS, "·") - .replace(/^'+|'+$/g, "") - .trim() - .slice(0, 31) || "상자"; - -/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */ -function uniqueName(book: Workbook, name: string, self?: string): string { - const base = cleanName(name); - const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase())); - if (!used.has(base.toLowerCase())) return base; - for (let n = 2; ; n++) { - const tail = ` (${n})`; - const next = base.slice(0, 31 - tail.length) + tail; - if (!used.has(next.toLowerCase())) return next; - } -} - -const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => { - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - if (!sheet) throw new Error(`없는 시트 ${node.sheetId}`); - return sheet; -}; - -const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined => - sl.nodes.find((n) => n.id === id); - -const pillOf = (node: SlNode, dir: "in" | "out", name: string): SlPill | undefined => - (dir === "in" ? node.pillsIn : node.pillsOut).find((p) => p.name === name); - -/* ── 상자 틀 ─────────────────────────────────────────────────────────────── */ - -type Row = [string, Cell | null, string]; - -/** 종류별 틀 — 1행 머리(이름 · 값 · 설명) · 2행부터 알약 줄 */ -function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; outs: SlPill[] } { - const v = (값: number | string): Cell => ({ 값 }); - const f = (식: string): Cell => ({ 식 }); - switch (kind) { - case "input": - return { rows: [[name, v(0), tl("Sample")]], ins: [], outs: [{ name, cell: "B2" }] }; - case "const": - return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] }; - case "table": - // 빈 시트 — 표를 고르면 Table(sub3)이 A1 블록 · 찾기 판 · 알약을 채움(머리 줄이 표 블록과 겹치지 않게) - return { rows: [], ins: [], outs: [] }; - case "calc": - return { - rows: [ - [tl("ValueA"), null, tl("QtyNote")], - [tl("ValueB"), null, tl("QtyNote")], - [tl("Found"), f("B2*B3"), tl("Times")], - ], - ins: [ - { name: tl("ValueA"), cell: "B2" }, - { name: tl("ValueB"), cell: "B3" }, - ], - outs: [{ name: tl("Found"), cell: "B4" }], - }; - case "item": - // 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`) - return { rows: [], ins: [], outs: [] }; - case "logic": - return { - rows: [[tl("Found"), null, tl("LogicNote")]], - ins: [], - outs: [{ name: tl("Found"), cell: "B2", outside: true }], - }; - case "result": - return { - rows: [ - ["노무비", null, tl("QtyNote")], - ["재료비", null, tl("QtyNote")], - ["경비", null, tl("QtyNote")], - [tl("Sum"), f("SUM(B2:B4)"), ""], - ], - ins: COSTS.map((c, i) => ({ name: c, cell: `B${2 + i}` })), - outs: [{ name: "계", cell: "B5" }], - }; - } -} - -/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움) - * B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */ -function itemSheet(): { cells: Record; rows: Row[]; ins: SlPill[]; outs: SlPill[] } { - const won = tl("Won"); - const labels: [string, string][] = [ - ["A1", tl("Qty")], - ["A2", tl("PriceLabor")], - ["A3", tl("PriceMat")], - ["A4", tl("PriceExp")], - ["A5", tl("Price")], - ["A6", tl("Amount")], - ["A7", tl("Labor3")], - ["A8", tl("Mat3")], - ["A9", tl("Exp3")], - ["A10", USED], - ]; - const cells: Record = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }])); - Object.assign(cells, { - B3: { 값: 0 }, - B4: { 값: 0 }, - B5: { 식: "B2+B3+B4" }, - B6: { 식: "ROUND(B1*B5,1)" }, - B7: { 식: "ROUND(B1*B2,1)" }, - B8: { 식: "ROUND(B1*B3,1)" }, - B9: { 식: "ROUND(B1*B4,1)" }, - B10: { 식: "B1" }, - }); - const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit }); - return { - cells, - rows: [], - ins: [ - { name: tl("Qty"), cell: "B1", unit: "" }, - { name: tl("PriceLabor"), cell: "B2", unit: won, outside: true }, - ], - outs: [ - out(tl("Price"), "B5"), - out(tl("Amount"), "B6"), - out(tl("Labor3"), "B7"), - out(tl("Mat3"), "B8"), - out(tl("Exp3"), "B9"), - out(USED, "B10", ""), - ], - }; -} - -/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ - -/** 빈 시트 로직 */ -export function newLogic(name = "", resultUnit = "원/㎡"): SheetLogic { - return { - key: null, - name, - resultUnit, - workbook: { 종류: "통합문서", 판: 1, 열: "SL", 서식: [{}], 시트: [] }, - nodes: [], - edges: [], - }; -} - -/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */ -export function addNode( - sl: SheetLogic, - kind: SlKind, - name: string, -): { sl: SheetLogic; node: SlNode } { - const out = clone(sl); - const book = out.workbook; - const sheetId = nextId( - "s", - book.시트.map((s) => s.id), - ); - const title = uniqueName(book, name); - const item = kind === "item" ? itemSheet() : null; - const t = item ?? template(kind, title); - const 칸: Record = - item?.cells ?? - (kind === "table" - ? {} - : { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") } }); - t.rows.forEach(([label, cell, note], i) => { - 칸[`A${i + 2}`] = { 값: label }; - if (cell) 칸[`B${i + 2}`] = cell; - if (note) 칸[`C${i + 2}`] = { 값: note }; - }); - book.시트.push({ id: sheetId, 이름: title, 칸 }); - book.활성 ??= sheetId; - const node: SlNode = { - id: nextId( - "n", - out.nodes.map((n) => n.id), - ), - kind, - name: title, - sheetId, - pillsIn: t.ins, - pillsOut: t.outs, - ...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}), - stage: STAGE[kind], - }; - out.nodes.push(node); - return { sl: out, node }; -} - -/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */ -export function removeNode(sl: SheetLogic, id: string): SheetLogic { - const node = nodeOf(sl, id); - if (!node) return sl; - let out = sl; - for (const e of sl.edges.filter((x) => x.from.node === id || x.to.node === id)) - out = disconnect(out, e.id); - out = clone(out); - out.nodes = out.nodes.filter((n) => n.id !== id); - out.workbook.시트 = out.workbook.시트.filter((s) => s.id !== node.sheetId); - if (out.workbook.활성 === node.sheetId) out.workbook.활성 = out.workbook.시트[0]?.id; - return out; -} - -/* ── 선 ─────────────────────────────────────────────────────────────────── */ - -/** 받는 알약 칸 수식 = 그 알약으로 오는 선들의 참조를 「+」 로 · 선이 없으면 칸을 비움 */ -function rewriteTarget(sl: SheetLogic, to: SlEnd): void { - const node = nodeOf(sl, to.node); - const pill = node && pillOf(node, "in", to.pill); - if (!node || !pill) return; - const refs = sl.edges - .filter((e) => e.to.node === to.node && e.to.pill === to.pill) - .map((e) => { - const src = nodeOf(sl, e.from.node); - const out = src && pillOf(src, "out", e.from.pill); - return src && out ? `${quoteSheet(sheetOf(sl, src).이름)}!${out.cell}` : null; - }) - .filter((x): x is string => x !== null); - const sheet = sheetOf(sl, node); - const keepStyle = sheet.칸[pill.cell]?.서식; - if (refs.length) - sheet.칸[pill.cell] = { 식: refs.join("+"), ...(keepStyle ? { 서식: keepStyle } : {}) }; - else if (keepStyle) sheet.칸[pill.cell] = { 서식: keepStyle }; - else delete sheet.칸[pill.cell]; -} - -/** from 상자에서 선을 따라 to 상자에 닿나 */ -function reaches(sl: SheetLogic, from: string, to: string): boolean { - const seen = new Set(); - const walk = (id: string): boolean => { - if (id === to) return true; - if (seen.has(id)) return false; - seen.add(id); - return sl.edges.filter((e) => e.from.node === id).some((e) => walk(e.to.node)); - }; - return walk(from); -} - -/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */ -export function connect( - sl: SheetLogic, - from: SlEnd, - to: SlEnd, -): { sl: SheetLogic; why: string | null } { - const a = nodeOf(sl, from.node); - const b = nodeOf(sl, to.node); - if (!a || !b || !pillOf(a, "out", from.pill) || !pillOf(b, "in", to.pill)) - return { sl, why: tl("NoPill") }; - if (a.id === b.id) return { sl, why: tl("Self") }; - const same = (e: SlEdge): boolean => - e.from.node === from.node && - e.from.pill === from.pill && - e.to.node === to.node && - e.to.pill === to.pill; - if (sl.edges.some(same)) return { sl, why: tl("Twice") }; - if (reaches(sl, b.id, a.id)) return { sl, why: tl("Loop") }; - const out = clone(sl); - out.edges.push({ - id: nextId( - "e", - out.edges.map((e) => e.id), - ), - from: { ...from }, - to: { ...to }, - }); - rewriteTarget(out, to); - return { sl: out, why: null }; -} - -/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */ -export function disconnect(sl: SheetLogic, edgeId: string): SheetLogic { - const edge = sl.edges.find((e) => e.id === edgeId); - if (!edge) return sl; - const out = clone(sl); - out.edges = out.edges.filter((e) => e.id !== edgeId); - rewriteTarget(out, edge.to); - return out; -} - -/* ── 알약 ───────────────────────────────────────────────────────────────── */ - -/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈(이름이 바뀌면 이어진 선도 따라감) */ -export function setPill( - sl: SheetLogic, - nodeId: string, - cell: string, - dir: "in" | "out", - name: string, - extra: Pick = {}, -): SheetLogic { - const out = clone(sl); - const node = nodeOf(out, nodeId); - if (!node) return sl; - const list = dir === "in" ? node.pillsIn : node.pillsOut; - const at = list.findIndex((p) => p.cell === cell || p.name === name); - const pill: SlPill = { name, cell: cell.toUpperCase(), ...extra }; - if (at < 0) { - list.push(pill); - return out; - } - const old = list[at]; - list[at] = pill; - for (const e of out.edges) { - const end = dir === "in" ? e.to : e.from; - if (end.node === nodeId && end.pill === old.name) end.pill = name; - } - // 칸이 바뀌면 선이 가리키던 칸도 새로 씀 - if (old.cell !== pill.cell) { - const linked = out.edges.some((e) => e.to.node === nodeId && e.to.pill === name); - if (dir === "in" && linked) { - delete sheetOf(out, node).칸[old.cell]; - rewriteTarget(out, { node: nodeId, pill: name }); - } else if (dir === "out") - for (const e of out.edges.filter((x) => x.from.node === nodeId && x.from.pill === name)) - rewriteTarget(out, e.to); - } - return out; -} - -/** 알약 빼기 — 이어진 선도 끊음 */ -export function removePill( - sl: SheetLogic, - nodeId: string, - dir: "in" | "out", - name: string, -): SheetLogic { - let out = sl; - for (const e of sl.edges) { - const end = dir === "in" ? e.to : e.from; - if (end.node === nodeId && end.pill === name) out = disconnect(out, e.id); - } - out = clone(out); - const node = nodeOf(out, nodeId); - if (!node) return out; - if (dir === "in") node.pillsIn = node.pillsIn.filter((p) => p.name !== name); - else node.pillsOut = node.pillsOut.filter((p) => p.name !== name); - return out; -} - -/** 상자 · 시트 찾기(Eval · 화면이 씀) */ -export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null { - const node = nodeOf(sl, nodeId); - return node ? sheetOf(sl, node) : null; -} - -/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */ -export function renameNode( - sl: SheetLogic, - nodeId: string, - name: string, -): { sl: SheetLogic; why: string | null } { - const node = nodeOf(sl, nodeId); - if (!node) return { sl, why: tl("NoBox") }; - const next = name.trim(); - if (!next || next.length > 31) return { sl, why: tl("NameLen") }; - if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'")) - return { sl, why: tl("NameChar") }; - if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") }; - const out = clone(sl); - applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next }); - nodeOf(out, nodeId)!.name = next; - return { sl: out, why: null }; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.css b/M01_MasterData/SheetLogic/M01_SL_Panel.css deleted file mode 100644 index cda9d83ea..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Panel.css +++ /dev/null @@ -1,174 +0,0 @@ -.m01-slpanel { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m01-slpanel-head, -.m01-slpanel-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m01-slpanel-bar { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m01-slpanel-cell { - padding: 2px var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - background: var(--color-surface-raised); -} - -.m01-slpanel-note { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-slpanel .ui-btn, -.m01-slpanel .ui-input { - min-height: 44px; - font-size: var(--text-body); -} - -.m01-slpanel-name { - flex: none; - width: 12em; -} - -.m01-slpanel-msg { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-warning); - background: var(--color-surface-raised); - font-weight: var(--font-weight-medium); -} - -.m01-slpanel-sheet { - position: relative; - min-height: 260px; - /* 그 시트가 쓰는 줄 수만큼(Entry 가 --sl-sheet-h 를 정함) — 안쪽 세로 밀기 없음(사용자 요청) */ - height: var(--sl-sheet-h, 260px); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - overflow: hidden; -} - -.m01-slpanel-pills { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.m01-slpanel-pill, -.m01-slpanel-dir { - min-height: 44px; - padding: 0 var(--spacing-12); - border: 2px solid; - border-radius: var(--radius-pills); - background: var(--color-surface); - color: inherit; - font: inherit; - font-weight: var(--font-weight-medium); - cursor: pointer; -} - -.m01-slpanel-dir { - display: inline-flex; - align-items: center; - min-height: 0; - cursor: default; -} - -.m01-slpanel-pill:focus-visible { - outline: 2px solid var(--color-focus-ring); -} - -.m01-slpanel-pill.is-on { - background: var(--color-surface-raised); - font-weight: var(--font-weight-bold); -} - -.is-in { - border-color: #3b82f6; -} - -.is-out { - border-color: #f59e0b; -} - -.m01-slpanel-info { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); -} - -.m01-slpanel-infoline { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.m01-slpanel-infoline > .m01-slpanel-note { - min-width: 6em; -} - -/* 칸 위 알약 표시 — 색 + 글자(입력 · 출력) 둘 다 */ -.m01-slpanel .aislo-cell[data-sl-pill="in"] { - box-shadow: inset 0 0 0 2px #3b82f6; -} - -.m01-slpanel .aislo-cell[data-sl-pill="out"] { - box-shadow: inset 0 0 0 2px #f59e0b; -} - -.m01-slpanel .aislo-cell[data-sl-pill]::after { - content: attr(data-sl-name); - position: absolute; - top: 0; - right: 0; - padding: 0 4px; - border-radius: 0 0 0 6px; - background: var(--color-surface); - color: var(--color-text); - font-size: 10px; - line-height: 14px; - pointer-events: none; -} - -.m01-slpanel .aislo-cell[data-sl-pill="in"]::after { - border: 1px solid #3b82f6; -} - -.m01-slpanel .aislo-cell[data-sl-pill="out"]::after { - border: 1px solid #f59e0b; -} - -.m01-slpanel .aislo-cell[data-sl-lock]::before { - content: "🔒 " attr(data-sl-lock); - position: absolute; - bottom: 0; - left: 0; - padding: 0 4px; - background: var(--color-surface); - color: var(--color-text); - font-size: 10px; - line-height: 14px; - pointer-events: none; -} - -.m01-slpanel .aislo-cell[data-sl-err] { - outline: 2px dashed var(--color-danger); - outline-offset: -2px; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.ts b/M01_MasterData/SheetLogic/M01_SL_Panel.ts deleted file mode 100644 index f2a4c112f..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Panel.ts +++ /dev/null @@ -1,544 +0,0 @@ -/* ============================================================================= - * M01_SL_Panel.ts - * 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 · - * 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄 - * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import type { - CalcValue, - CalcValues, - SpreadsheetHandle, - Workbook, -} from "../../A00_Common/spreadsheet/spreadsheet"; -import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model"; -import "./M01_SL_Panel.css"; - -const TEXT = { - Cell: ["고른 칸", "Selected cell"], - PillIn: ["입력 알약으로", "Make input pill"], - PillOut: ["출력 알약으로", "Make output pill"], - AskName: ["알약 이름", "Pill name"], - Ok: ["정함", "Set"], - Cancel: ["취소", "Cancel"], - Frames: ["틀", "Templates"], - FrameMore: ["할증", "Markup"], - FrameDiv: ["나누기", "Divide"], - FrameIf: ["만약", "If"], - FrameMin: ["최소", "Min"], - FrameMax: ["최대", "Max"], - FrameUp: ["올림", "Round up"], - FrameLook: ["표에서 찾기", "Look up in table"], - FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], - In: ["입력", "In"], - Out: ["출력", "Out"], - Lock: ["선", "Wire"], - Pills: ["알약", "Pills"], - NoPills: [ - "아직 알약이 없음 — 칸을 골라 알약으로 정함", - "No pills yet — pick a cell and make it a pill", - ], - Value: ["값", "Value"], - Unit: ["단위", "Unit"], - From: ["어디서 오나", "Comes from"], - To: ["어디로 가나", "Goes to"], - NoFrom: ["아직 이어지지 않음", "Not connected yet"], - NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"], - Close: ["닫기", "Close"], - ReadOnly: ["표준은 읽기만", "Read only"], - Loading: ["시트를 불러오는 중", "Loading sheet"], - BreakAsk: [ - "이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?", - "This cell receives a wire. Cut the wire and edit it yourself?", - ], - BreakOk: ["선을 끊고 고침", "Cut and edit"], - Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], - Problem: ["⚠ ", "⚠ "], - Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"], - Err_DIV0: ["0으로 나눔", "divides by zero"], - Err_NAME: ["알 수 없는 이름", "unknown name"], - Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], - Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], - Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], - Err_NA: ["찾는 값이 없음", "value not found"], - Err_OTHER: ["수식이 틀림", "formula is wrong"], -} as const; - -const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ -const FRAMES: [keyof typeof TEXT, string][] = [ - ["FrameMore", "=B1*(1+B2/100)"], - ["FrameDiv", "=B1/B2"], - ["FrameIf", '=IF(B3="급",10,0)'], - ["FrameMin", "=MIN(B1,B2)"], - ["FrameMax", "=MAX(B1,B2)"], - ["FrameUp", "=ROUNDUP(B4,3)"], - ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], -]; - -const ERR_KEY: Record = { - "#DIV/0!": "Err_DIV0", - "#NAME?": "Err_NAME", - "#REF!": "Err_REF", - "#VALUE!": "Err_VALUE", - "#CYCLE!": "Err_CYCLE", - "#N/A": "Err_NA", -}; - -type Handlers = { - change(sl: SheetLogic): void; - pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void; -}; -type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet"); - -interface State { - nodeId: string; - sl: SheetLogic; - on: Handlers; - readOnly: boolean; - handle: SpreadsheetHandle | null; - /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ - snap: CalcValues; - /** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */ - json: string; - cell: string; - info: { dir: "in" | "out"; name: string } | null; - asking: "in" | "out" | null; - busy: boolean; - parts: { - cell: HTMLElement; - bar: HTMLElement; - msg: HTMLElement; - sheet: HTMLElement; - pills: HTMLElement; - info: HTMLElement; - }; - observer: MutationObserver | null; - redraw(): void; -} - -const states = new WeakMap(); -let sheetModule: Promise | null = null; -/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ -const loadSheet = (): Promise => - (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); - -const norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null }); -const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId); -const shown = (st: State): Workbook => ({ - ...st.sl.workbook, - 활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성, -}); - -/* ------------------------------ 값 · 말 ------------------------------ */ - -function valueText(v: CalcValue | number | string | boolean | undefined | null): string { - if (v === undefined || v === null) return ""; - if (typeof v !== "object") return String(v); - if ("수" in v) return v.수; - if ("글" in v) return v.글; - if ("참" in v) return v.참 ? "TRUE" : "FALSE"; - return v.오류; -} - -function valueAt(st: State, addr: string): string { - const node = nodeOf(st); - const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId); - const cell = sheet?.칸[addr]; - if (!cell) return ""; - if (cell.식 === undefined) return valueText(cell.값); - return valueText(st.snap[sheet!.id]?.[addr]); -} - -const edgesIn = (st: State, pill: string): SlEdge[] => - st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill); -const edgesOut = (st: State, pill: string): SlEdge[] => - st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill); - -/** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */ -function lockedCells(st: State): Map { - const out = new Map(); - for (const p of nodeOf(st)?.pillsIn ?? []) { - const e = edgesIn(st, p.name)[0]; - if (e) out.set(p.cell, e); - } - return out; -} - -/** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */ -function problems(st: State): Map { - const out = new Map(); - const node = nodeOf(st); - if (!node || !st.handle) return out; - const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId); - const snap = st.snap[node.sheetId] ?? {}; - for (const [addr, v] of Object.entries(snap)) { - if (typeof v === "object" && "오류" in v) { - out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`); - } - } - for (const p of node.pillsIn) { - const filled = sheet?.칸[p.cell]?.식 !== undefined; - if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") { - out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`); - } - } - return out; -} - -/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ -function refreshSnap(st: State): void { - if (!st.handle) return; - st.snap = st.handle.recalc(); - st.redraw(); -} - -/* ------------------------------ 칸 위 표시 ------------------------------ */ - -function applyMarks(st: State): void { - const node = nodeOf(st); - const grid = st.parts.sheet; - if (!node) return; - const pill = new Map(); - for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p }); - for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p }); - const lock = lockedCells(st); - const bad = problems(st); - for (const c of grid.querySelectorAll(".aislo-cell")) { - const addr = c.dataset.addr ?? ""; - const hit = pill.get(addr); - for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a]; - if (hit) { - c.dataset.slPill = hit.dir; - c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`; - } - if (lock.has(addr)) c.dataset.slLock = tp("Lock"); - if (bad.has(addr)) c.dataset.slErr = "1"; - } - const first = [...bad.values()][0]; - st.parts.msg.textContent = first - ? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}` - : ""; - st.parts.msg.hidden = !first; -} - -/* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */ - -function nameDefault(st: State): string { - const row = st.cell.replace(/^[A-Z]+/, ""); - const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId); - const a = sheet?.칸[`A${row}`]; - return a ? valueText(a.값 ?? a.식) : ""; -} - -function drawBar(st: State): void { - const { bar, cell } = st.parts; - cell.textContent = `${tp("Cell")} ${st.cell}`; - const frag: (HTMLElement | string)[] = []; - if (st.readOnly) { - bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") })); - return; - } - const ask = (dir: "in" | "out"): void => { - st.asking = dir; - drawBar(st); - }; - const pillRow = el("div", { - className: "m01-slpanel-row", - children: [ - createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }), - createButton({ - label: tp("PillOut"), - variant: "ghost", - size: "sm", - onClick: () => ask("out"), - }), - ], - }); - frag.push(pillRow); - if (st.asking) { - const dir = st.asking; - const input = el("input", { - className: "ui-input m01-slpanel-name", - attrs: { type: "text", "aria-label": tp("AskName") }, - }); - input.value = nameDefault(st); - const done = (): void => { - const name = input.value.trim(); - if (!name) return; - st.asking = null; - st.on.pill(st.nodeId, st.cell, dir, name); - drawBar(st); - }; - input.addEventListener("keydown", (e) => e.key === "Enter" && done()); - frag.push( - el("div", { - className: "m01-slpanel-row", - children: [ - el("span", { - className: `m01-slpanel-dir is-${dir}`, - text: tp(dir === "in" ? "In" : "Out"), - }), - input, - createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }), - createButton({ - label: tp("Cancel"), - variant: "ghost", - size: "sm", - onClick: () => ((st.asking = null), drawBar(st)), - }), - ], - }), - ); - queueMicrotask(() => input.focus()); - } - frag.push( - el("div", { - className: "m01-slpanel-row", - children: [ - el("span", { className: "m01-slpanel-note", text: tp("Frames") }), - ...FRAMES.map(([key, formula]) => - createButton({ - label: tp(key), - variant: "ghost", - size: "sm", - onClick: () => putFormula(st, formula), - }), - ), - el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }), - ], - }), - ); - bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string")); -} - -function drawPills(st: State): void { - const node = nodeOf(st); - const chips: HTMLElement[] = []; - const add = (dir: "in" | "out", p: SlPill): void => { - const b = el("button", { - className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`, - text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`, - attrs: { type: "button" }, - }); - b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st))); - chips.push(b); - }; - for (const p of node?.pillsIn ?? []) add("in", p); - for (const p of node?.pillsOut ?? []) add("out", p); - st.parts.pills.replaceChildren( - ...(chips.length - ? chips - : [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]), - ); - drawInfo(st); -} - -function drawInfo(st: State): void { - const box = st.parts.info; - const node = nodeOf(st); - const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find( - (x) => x.name === st.info?.name, - ); - if (!st.info || !p || !node) { - box.hidden = true; - box.replaceChildren(); - return; - } - const dir = st.info.dir; - const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? ""; - const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => { - const end = dir === "in" ? e.from : e.to; - return `${nameOf(end.node)} · ${end.pill}`; - }); - const rows: [string, string][] = [ - [tp("Value"), valueAt(st, p.cell)], - [tp("Unit"), p.unit ?? ""], - [ - tp(dir === "in" ? "From" : "To"), - links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"), - ], - ]; - box.hidden = false; - box.replaceChildren( - el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }), - ...rows.map(([k, v]) => - el("div", { - className: "m01-slpanel-infoline", - children: [el("span", { className: "m01-slpanel-note", text: k }), v], - }), - ), - createButton({ - label: tp("Close"), - variant: "ghost", - size: "sm", - onClick: () => ((st.info = null), drawPills(st)), - }), - ); -} - -/* ------------------------------ 편집기 ------------------------------ */ - -/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ -function putFormula(st: State, formula: string): void { - const root = st.parts.sheet; - const bar = root.querySelector(".ss-formula"); - const addr = root.querySelector(".ss-addr"); - if (!bar || !addr || st.readOnly) return; - const at = st.cell; - bar.focus(); - bar.value = formula; - bar.dispatchEvent(new Event("input", { bubbles: true })); - bar.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), - ); - addr.value = at; - addr.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), - ); -} - -/** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */ -async function onBook(st: State, book: Workbook): Promise { - if (st.busy) return; - const prev = st.sl; - const node = nodeOf(st); - const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } }; - const before = prev.workbook.시트.find((s) => s.id === node?.sheetId); - const after = book.시트.find((s) => s.id === node?.sheetId); - const hit = [...lockedCells(st)].find( - ([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]), - ); - if (!hit) { - st.json = norm(book); - st.sl = next; - st.on.change(next); - return void setTimeout(() => refreshSnap(st), 0); - } - st.busy = true; - const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk")); - st.busy = false; - if (!ok) { - st.json = norm(shown(st)); - return st.handle?.setDoc(structuredClone(shown(st))); - } - try { - const cut = disconnect(next, hit[1].id); - const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId); - const mine = after?.칸[hit[0]]; - if (sheet) { - if (mine) sheet.칸[hit[0]] = mine; - else delete sheet.칸[hit[0]]; - } - st.json = norm(cut.workbook); - st.sl = cut; - st.on.change(cut); - setTimeout(() => refreshSnap(st), 0); - } catch { - showToast(tp("Cannot"), "error"); - st.handle?.setDoc(structuredClone(shown(st))); - } -} - -async function mountSheet(st: State): Promise { - const { createSpreadsheet } = await loadSheet(); - if (!st.parts.sheet.isConnected || st.handle) return; - st.parts.sheet.replaceChildren(); - const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌 - st.json = norm(book); - st.handle = createSpreadsheet(st.parts.sheet, book, { - preview: true, - tabs: false, - readOnly: st.readOnly, - onChange: (b) => void onBook(st, b), - onSelect: (s) => { - st.cell = s.칸; - st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`; - if (st.asking) drawBar(st); - }, - }); - st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); - st.observer.observe(st.parts.sheet, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["data-addr"], - }); - refreshSnap(st); -} - -/** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ -export function renderPanel( - host: HTMLElement, - sl: SheetLogic, - nodeId: string, - on: Handlers, - opts: { readOnly?: boolean } = {}, -): void { - const old = states.get(host); - const readOnly = !!opts.readOnly; - if (old && old.nodeId === nodeId && old.readOnly === readOnly) { - old.sl = sl; - old.on = on; - if (old.handle && norm(shown(old)) !== old.json) { - old.json = norm(shown(old)); - old.handle.setDoc(structuredClone(shown(old))); - setTimeout(() => refreshSnap(old), 0); - return; - } - return old.redraw(); - } - if (old) { - old.observer?.disconnect(); - old.handle?.destroy(); - } - const parts: State["parts"] = { - cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }), - bar: el("div", { className: "m01-slpanel-bar" }), - msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }), - sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }), - pills: el("div", { className: "m01-slpanel-pills" }), - info: el("div", { className: "m01-slpanel-info" }), - }; - const st: State = { - nodeId, - sl, - on, - readOnly, - handle: null, - snap: {}, - json: "", - cell: "A1", - info: null, - asking: null, - busy: false, - parts, - observer: null, - redraw: () => { - drawBar(st); - drawPills(st); - applyMarks(st); - }, - }; - states.set(host, st); - const node = nodeOf(st); - host.classList.add("m01-slpanel"); - host.replaceChildren( - el("div", { - className: "m01-slpanel-head", - children: [el("strong", { text: node?.name ?? "" }), parts.cell], - }), - parts.bar, - parts.msg, - parts.sheet, - parts.pills, - parts.info, - ); - parts.msg.hidden = true; - st.redraw(); - void mountSheet(st); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.css b/M01_MasterData/SheetLogic/M01_SL_Result.css deleted file mode 100644 index d173658af..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Result.css +++ /dev/null @@ -1,98 +0,0 @@ -.m01-slresult__big { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); - margin: 0 0 var(--spacing-8); - color: var(--color-text-secondary); -} - -.m01-slresult__big strong { - color: var(--color-text-body); - font-size: 28px; - font-variant-numeric: tabular-nums; -} - -.m01-slresult__costs { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-16); - margin: 0 0 var(--spacing-12); - font-size: var(--text-body-sm); -} - -.m01-slresult__costs dt { - display: inline; - color: var(--color-text-secondary); -} - -.m01-slresult__costs dd { - display: inline; - margin: 0 0 0 var(--spacing-4); - color: var(--color-text-body); - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.m01-slresult__strip { - margin: 0 0 var(--spacing-12); - color: var(--color-text-body); -} - -.m01-slresult__fields { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m01-slresult__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-slresult__input { - box-sizing: border-box; - width: 140px; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); -} - -.m01-slresult__input[readonly] { - background: var(--color-surface); -} - -.m01-slresult__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-slresult__msg { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m01-slresult__problems { - margin: 0 0 var(--spacing-12); - padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-danger); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01-slresult__problems ul { - margin: var(--spacing-4) 0 0; - padding-left: var(--spacing-16); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.ts b/M01_MasterData/SheetLogic/M01_SL_Result.ts deleted file mode 100644 index 07c04fa7c..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Result.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* ============================================================================= - * M01_SL_Result.ts - * 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음) - * · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄 - * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. - * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; -import type { - Cell, - DataValidationRule, - Sheet, -} from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; -import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; -import type { SlEvalResult } from "./M01_SL_Eval"; -import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item"; -import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; -import "./M01_SL_Result.css"; - -const TEXT = { - Per: ["{per} 당", "per {per}"], - Total: ["합계", "Total"], - Value: ["결과값", "Result value"], - Won: ["원", "KRW"], - Labor: ["노무비", "Labor cost"], - Material: ["재료비", "Material cost"], - Expense: ["경비", "Expense"], - Sample: ["예시값", "Sample values"], - SampleHint: [ - "여기서 바꾸면 입력값 상자의 예시값도 바뀌어요", - "Changing here changes the input box too", - ], - Problems: ["고칠 것", "To fix"], - Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], - Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], - Pick: ["고르세요", "Choose"], - RateOf: ["{base}의 %", "% of {base}"], -} as const; - -type TextKey = keyof typeof TEXT; -const tr = (key: TextKey, fill: Record = {}): string => - TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); - -const COSTS = ["노무비", "재료비", "경비"] as const; -const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); - -const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => - sl.workbook.시트.find((s) => s.id === node.sheetId); -const colOf = (cell: string): { r: number; c: number } => ({ - r: Number(cell.replace(/\D/g, "")) - 1, - c: cell.charCodeAt(0) - 65, -}); - -/** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */ -function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] { - return sl.nodes - .filter((n) => n.kind === "item") - .map((n) => { - const ref = itemRefOf(n); - const p = values.pills[n.id] ?? {}; - const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? ""; - const pickName = (name: string): number => n0(p[name]); - return { - 이름: n.name, - 단위: unit, - 수량: pickName(USED), - 단가: pickName(PILL_PRICE), - 금액: pickName(PILL_AMOUNT), - 비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])), - ...(ref?.what === "비율" - ? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } } - : ref?.spec - ? { 품목: { 키: "", 규격: ref.spec } } - : {}), - }; - }); -} - -/** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */ -function emptyInputs(sl: SheetLogic): string[] { - return sl.nodes.flatMap((n) => { - const cells = sheetOf(sl, n)?.칸 ?? {}; - return n.pillsIn - .filter( - (p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])), - ) - .map((p) => tr("Empty", { box: n.name, pill: p.name })); - }); -} - -/** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */ -function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null { - const { r, c } = colOf(cell); - const hit = (sheet.유효성 ?? []).find((v) => - v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1), - ); - return hit?.규칙 ?? null; -} - -function sampleField( - sl: SheetLogic, - node: SlNode, - pill: SlPill, - put: (cell: string, v: number | string) => void, - msg: HTMLElement, -): HTMLElement { - const sheet = sheetOf(sl, node); - const cur: Cell | undefined = sheet?.칸[pill.cell]; - const rule = sheet ? ruleAt(sheet, pill.cell) : null; - const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); - let control: HTMLElement; - if (rule?.종류 === "목록") { - const field = createSelectField({ - options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))], - value: cur?.값 === undefined ? "" : String(cur.값), - compact: true, - ariaLabel: pill.name, - onChange: (v) => { - if (v !== "") put(pill.cell, v); - }, - }); - control = field.root; - } else { - const box = el("input", { - className: "m01-slresult__input", - attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, - }); - if (rule?.종류 === "수범위") { - box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") }); - } - const linked = cur?.식 !== undefined; - box.value = linked || cur?.값 === undefined ? "" : String(cur.값); - box.readOnly = linked; - box.addEventListener("change", () => { - const raw = box.value.trim(); - const n = Number(raw); - if (raw === "" || Number.isNaN(n)) { - msg.textContent = tr("Bad"); - return; - } - put(pill.cell, n); - }); - control = box; - } - return el("label", { className: "m01-slresult__field", children: [label, control] }); -} - -/** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */ -export function renderResult( - host: HTMLElement, - sl: SheetLogic, - values: SlEvalResult, - on: { change(sl: SheetLogic): void }, -): void { - const money = !sl.resultUnit || sl.resultUnit.startsWith("원"); - const { 계 } = values.result; - /** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */ - const plainValue = (): number | null => { - const box = sl.nodes.find((n) => n.kind === "result"); - const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : []; - const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find( - (v) => typeof v === "number" && Number.isFinite(v), - ); - return typeof first === "number" ? first : null; - }; - const perUnit = sl.resultUnit.split("/").slice(1).join("/"); - const perText = !perUnit - ? tr("Total") - : tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); - const shown = money ? 계 : plainValue(); - const big = el("p", { - className: "m01-slresult__big", - children: [ - el("span", { text: money ? perText : tr("Value") }), - el("strong", { - text: - shown === null - ? "—" - : money - ? `${formatMoney(shown)} ${tr("Won")}` - : `${num(shown)} ${sl.resultUnit}`.trim(), - }), - ], - }); - const costs = el("dl", { - className: "m01-slresult__costs", - children: COSTS.flatMap((c, i) => [ - el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }), - el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }), - ]), - }); - - const head = { name: sl.name, resultUnit: sl.resultUnit }; - const problems = [...values.errors, ...emptyInputs(sl)]; - const ans: CalcAnswer = - values.errors.length || 계 === null - ? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" } - : { - ok: true, - lines: linesOf(sl, values), - sums: { - 노무비: n0(values.result.노무비), - 재료비: n0(values.result.재료비), - 경비: n0(values.result.경비), - 계: n0(계), - }, - middle: {}, - }; - const sheetHost = el("div", { className: "m01-slresult__sheet" }); - if (money) renderSheet(sheetHost, head, ans); - - const msg = el("p", { className: "m01-slresult__msg" }); - const put = - (node: SlNode) => - (cell: string, v: number | string): void => { - msg.textContent = ""; - try { - const 시트 = sl.workbook.시트.map((s) => - s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s, - ); - on.change({ ...sl, workbook: { ...sl.workbook, 시트 } }); - } catch { - msg.textContent = tr("Cant"); - } - }; - const fields = sl.nodes - .filter((n) => n.kind === "input") - .flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg))); - const strip = fields.length - ? [ - el("div", { - className: "m01-slresult__strip", - children: [ - el("strong", { text: tr("Sample") }), - el("div", { className: "m01-slresult__fields", children: fields }), - el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }), - msg, - ], - }), - ] - : []; - host.replaceChildren( - big, - ...(money ? [costs] : []), - ...strip, - ...(problems.length - ? [ - el("div", { - className: "m01-slresult__problems", - children: [ - el("strong", { text: tr("Problems") }), - el("ul", { children: problems.map((t) => el("li", { text: t })) }), - ], - }), - ] - : []), - sheetHost, - ); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py deleted file mode 100644 index fffc2daa1..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Router.py +++ /dev/null @@ -1,396 +0,0 @@ -"""시트 로직 시험 길(PLAN 35-5) — 목록 · 열기 · 저장 · 지우기 · 바깥 값. - - GET /api/m01/sl/list - GET /api/m01/sl/get?key=SL-0001 - POST /api/m01/sl/save {logic: SheetLogic} → {key, logic, values: SlOutsideAnswer[]} - POST /api/m01/sl/delete {key} - POST /api/m01/sl/outside {items: [{id, ...SlSource}]} → {values: SlOutsideAnswer[]} - -모양은 sub7 Model(`M01_SL_Model.ts` — SheetLogic · SlSource) · 답은 Eval `SlOutsideAnswer`. -정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 — 저장 때 바깥 값(품셈 표 · 노임 · -자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 우리 스프레드시트 엔진(Node · -`common_util_spreadsheet`)으로 다시 셈 · 브라우저 계산값은 버림. 단가는 `/calc`(`Store.calc`)와 -같은 셈. 자리 = 이 폴더 `_data/`(git 밖) · 키 `SL-0001` 꼴(`_키대장.json` 안 씀) · 마스터 파일은 -안 씀. 권한 = `/api/m01` 과 같음(`main.py` 등록 한 줄). -""" - -from __future__ import annotations - -import json -import os -import re -from datetime import datetime -from pathlib import Path -from typing import Any - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from common_util import common_util_spreadsheet as spreadsheet -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData.M01_MasterData_Router import _num - -cm = store.cm -router = APIRouter(prefix="/api/m01/sl", tags=["M01 SheetLogic (시험)"]) - -DATA = Path(__file__).resolve().parent / "_data" # 시험은 임시 폴더로 바꿈 -_KEY = re.compile(r"^SL-(\d{4,})$") -_CELL = re.compile(r"^([A-Z]+)(\d+)$") - - -class SaveBody(BaseModel): - logic: dict[str, Any] - - -class KeyBody(BaseModel): - key: str - - -class OutsideBody(BaseModel): - items: list[dict[str, Any]] - - -# ── 자리 ────────────────────────────────────────────────────────────── -def _path(key: str) -> Path: - if not _KEY.match(key or ""): - raise store.StoreError(400, f"시트 로직 번호 모양이 아님 「{key}」") - return DATA / f"{key}.json" - - -def _read(key: str) -> dict: - path = _path(key) - if not path.is_file(): - raise store.StoreError(404, f"없는 시트 로직 「{key}」") - return json.loads(path.read_text(encoding="utf-8")) - - -def _next_key() -> str: - used = [int(m.group(1)) for p in DATA.glob("SL-*.json") if (m := _KEY.match(p.stem))] - return f"SL-{max(used, default=0) + 1:04d}" - - -def listing() -> dict: - out = [] - for path in sorted(DATA.glob("SL-*.json")): - logic = json.loads(path.read_text(encoding="utf-8")) - out.append( - { - "key": path.stem, - "이름": logic.get("name") or "", - "결과단위": logic.get("resultUnit") or "", - "saved": logic.get("saved") or "", - } - ) - return {"logics": out} - - -def get(key: str) -> dict: - return {"logic": _read(key)} - - -def delete(key: str) -> dict: - _read(key) - _path(key).unlink() - return {"key": key} - - -# ── 바깥 값 ──────────────────────────────────────────────────────────── -# 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}` -# 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`) -# 노임 · 자재 {kind, key, unit?} → 그 줄 단가(`/calc` 와 같은 셈) · 자재 key = 키 · 검색어 묶음 -# 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1) -# 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움) -RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음 -FIELDS = (*cm.mf.COST_ITEMS, "계") - - -def _row(line: dict, names) -> dict: - """바깥 단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" - return { - "키": "", - "원문번호": "", - "이름": "바깥 값", - "결과단위": "원/식", - "출처": "자체", - "입력": [{"이름": n} for n in names], - "중간": [], - "호표": [line], - "덧줄": [], - "끝수": None, - } - - -def _money(got: dict, field: str | None) -> dict: - if not got["ok"]: - return got - if "result" in got: - return {"ok": True, "value": got["result"]} - pick = field or "계" - if pick not in FIELDS: - return {"ok": False, "reason": f"모르는 칸 「{pick}」"} - return {"ok": True, "value": got["sums"][pick]} - - -def _pick(key: Any) -> dict | None: - """자재 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None.""" - if isinstance(key, dict): - return key - if isinstance(key, str) and key.lstrip().startswith("{"): - try: - got = json.loads(key) - except ValueError: - return None - return got if isinstance(got, dict) else None - return None - - -def _material(cond: dict, unit: str) -> dict: - """검색어 묶음 자재 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산).""" - line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"} - got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None) - if not got["ok"]: - return got - one = got["lines"][0] - if one["단가"] is None: - word = cond.get("검색어") or "" - empty = "값 없음" in str(one.get("까닭") or "") # 품목은 찾았는데 단가 칸이 빔 - why = ( - "단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름" - if empty - else "맞는 자재 없음" - ) - return {"ok": False, "reason": f"「{word}」 {why}"} - picked = one.get("품목") or {} - out = {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None} - return {**out, **({"reason": one["단위경고"]} if one.get("단위경고") else {})} - - -def _price(kind: str, key: Any, unit: str = "") -> dict: - """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로 · 자재는 키 또는 검색어 묶음.""" - cond = _pick(key) if kind == "자재" else None - if cond is not None: - return _material(cond, unit) - key = str(key or "") - try: - brief = store.loaded()[1].get(key) - except cm.mf.FormulaError as e: - return {"ok": False, "reason": str(e)} - group = store.mk.GROUP_OF.get(store.mk.id_of(key)) - want = {"노임": ("인력", "노무비"), "자재": ("재료", "재료비")}[kind] - if group != want[0]: - return {"ok": False, "reason": f"「{key}」 는 {kind} 아님"} - unit = brief.get("단위") - line = {"종류": want[0], "요소": key, "이름": brief.get("이름") or key, "수량": "1"} - got = store.calc("", {}, _row({**line, "단위": unit or "", "비목": want[1]}, ()), None) - if not got["ok"]: - return got - one = got["lines"][0] - out = {"ok": one["단가"] is not None, "value": one["단가"], "name": brief.get("이름")} - return {**out, "unit": unit, **({"reason": one["까닭"]} if one.get("까닭") else {})} - - -def _logic(key: str, inputs: dict, field: str | None) -> dict: - """기존 로직 결과 — 준 입력이 이기고 빈 입력은 자동값으로.""" - try: - auto = store.auto(key)["값"] - except store.StoreError: - auto = {} - return _money(store.calc(key, {**auto, **inputs}), field) - - -def _table(key: str) -> dict: - """품셈 표 통째 → 머리 줄 + 줄 — 표 블록 칸 차례의 정본(브레인 정함 · 화면도 이것을 따름). - - 조건 칸 → 값 칸 · 범위가 있는 칸(범위 조건 · 「20~45」 같은 범위 값)은 「이름·아래」 「이름·위」 - 두 칸 · 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸. - """ - files = store.loaded()[0] - table = next( - ( - t - for d in files.values() - if d.get("그룹") in cm.mf.TABLE_GROUPS - for t in d.get("표") or [] - if str(t.get("키")) == key - ), - None, - ) - if table is None: - return {"ok": False, "reason": f"없는 표 「{key}」"} - conds, lines = table.get("조건") or {}, table.get("줄") or [] - names = [*conds, *(table.get("값칸") or {})] - split = { - n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) - } - head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))] - rows = [] - for r in lines: - line = [] - for n in names: - v = r.get(n) - if n in split: # 범위 칸 — 수 하나면 아래 · 위 같은 수 - line += list(v) if isinstance(v, list) else [v, v] - else: - line.append(None if v == "" else v if not isinstance(v, dict) else str(v)) - rows.append(line) - return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} - - -def one_outside(item: dict) -> dict: - """물음 한 줄 `{id, ...SlSource}` → 답 `SlOutsideAnswer`.""" - kind = item.get("kind") - try: - if kind == "표": - got = _table(str(item.get("table") or "")) - elif kind in ("노임", "자재"): - got = _price(kind, item.get("key"), str(item.get("unit") or "")) - elif kind == "장비": - inputs = item.get("inputs") or {} - line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"} - row = _row({**line, "단위": "시간", "수량": "1"}, inputs) - got = _money(store.calc("", inputs, row, None), item.get("field")) - elif kind == "로직": - got = _logic(str(item.get("key") or ""), item.get("inputs") or {}, item.get("field")) - else: - got = {"ok": False, "reason": f"모르는 바깥 값 「{kind}」"} - except store.StoreError as e: - got = {"ok": False, "reason": str(e.detail)} - return {"id": item.get("id") or "", **got} - - -def outside(items: list[dict]) -> dict: - # ponytail: 하나씩 셈(한 번 0.3초쯤) — 바깥 값이 수십이면 마스터 사본 한 벌로 묶어 셈 - return {"values": [one_outside(i) for i in items]} - - -def _source(node: dict) -> dict | None: - """Eval `outsideCells` 와 같은 규칙 — 상자 source · 없으면 표 · 로직은 ref 로 짐작.""" - if node.get("source"): - return node["source"] - ref = node.get("ref") - if ref and node.get("kind") == "table": - return {"kind": "표", "table": ref} - if ref and node.get("kind") == "logic": - return {"kind": "로직", "key": ref, "inputs": {}} - return None - - -def outside_cells(logic: dict) -> list[tuple[dict, str]]: - """바깥 값 칸 → [({id, ...source, _cell}, 시트)]. - - ① 알약 outside 참 — id 「상자!칸」 · 출처 없으면 뺌(Eval `outsideCells` 규칙) - ② 몸의 `outside: [{item, sheet, cell}]` — Item 항목 단가(칸마다 비목) · 같은 칸은 ② 가 이김 - """ - found: dict[tuple[str, str], dict] = {} - for node in logic.get("nodes") or []: - source = _source(node) - for pill in [*(node.get("pillsIn") or []), *(node.get("pillsOut") or [])]: - if pill.get("outside") and source: - sheet, cell = node.get("sheetId") or "", pill.get("cell") or "" - found[(sheet, cell)] = {"id": f"{node.get('id')}!{cell}", **source} - for at in logic.get("outside") or []: - found[(at.get("sheet") or "", at.get("cell") or "")] = dict(at.get("item") or {}) - return [({**item, "_cell": cell}, sheet) for (sheet, cell), item in found.items()] - - -# ── 저장 ────────────────────────────────────────────────────────────── -def _col(letters: str) -> int: - n = 0 - for ch in letters: - n = n * 26 + ord(ch) - 64 - return n - - -def _letters(n: int) -> str: - out = "" - while n: - n, rest = divmod(n - 1, 26) - out = chr(65 + rest) + out - return out - - -def _put(book: dict, sheet_id: str, cell: str, value: Any) -> None: - """바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠).""" - sheet = next((s for s in book.get("시트") or [] if s.get("id") == sheet_id), None) - if sheet is None: - raise store.StoreError(400, f"없는 시트 「{sheet_id}」") - cells = sheet.setdefault("칸", {}) - keep = {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {} - if value is None and not keep: - cells.pop(cell, None) - else: - cells[cell] = {**({"값": value} if value is not None else {}), **keep} - - -def _fill(book: dict, sheet: str, cell: str, got: dict) -> None: - m = _CELL.match(str(cell or "").upper()) - if not m: - raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」") - col, row = _col(m.group(1)), int(m.group(2)) - if "rows" in got: # 표 — cell 이 왼위 - for dy, line in enumerate(got["rows"]): - for dx, one in enumerate(line): - _put(book, sheet, f"{_letters(col + dx)}{row + dy}", one) - else: - _put(book, sheet, f"{m.group(1)}{row}", got.get("value") if got.get("ok") else None) - - -def save(logic: dict) -> dict: - """SheetLogic 저장 — 바깥 값 다시 넣기 → Node 로 다시 셈(브라우저 계산값 버림) → `_data`.""" - if not isinstance(logic.get("workbook"), dict): - raise store.StoreError(400, "통합문서(workbook)가 없음") - if not str(logic.get("name") or "").strip(): - raise store.StoreError(400, "이름을 넣어 주세요") - key = logic.get("key") or None - if key: - _read(key) # 있는 것만 고침 — 없으면 404 - places = outside_cells(logic) - values = [one_outside({k: v for k, v in item.items() if k != "_cell"}) for item, _ in places] - book = _num(logic["workbook"]) - for (item, sheet), got in zip(places, values): - _fill(book, sheet, item["_cell"], _num(got)) - try: - book = spreadsheet.with_server_values(book) # 브라우저 계산값은 버리고 서버 값 - except spreadsheet.RecalcError as e: - raise store.StoreError(503, str(e)) from e - DATA.mkdir(parents=True, exist_ok=True) - key = key or _next_key() - stamp = datetime.now().isoformat(timespec="seconds") - done = {**logic, "key": key, "workbook": book, "saved": stamp} - tmp = DATA / f".{key}.tmp" - tmp.write_text(json.dumps(done, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") - os.replace(tmp, DATA / f"{key}.json") - return {"key": key, "logic": done, "values": values} - - -# ── 길 ──────────────────────────────────────────────────────────────── -def _call(fn, *args): - try: - return _num(fn(*args)) - except store.StoreError as e: - raise HTTPException(status_code=e.status, detail=e.detail) from e - - -@router.get("/list") -def get_list() -> dict: - return _call(listing) - - -@router.get("/get") -def get_one(key: str) -> dict: - return _call(get, key) - - -@router.post("/save") -def post_save(body: SaveBody) -> dict: - return _call(save, body.logic) - - -@router.post("/delete") -def post_delete(body: KeyBody) -> dict: - return _call(delete, body.key) - - -@router.post("/outside") -def post_outside(body: OutsideBody) -> dict: - return _call(outside, body.items) diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.css b/M01_MasterData/SheetLogic/M01_SL_Table.css deleted file mode 100644 index 582c89597..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Table.css +++ /dev/null @@ -1,138 +0,0 @@ -.m01-sltbl { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - color: var(--color-text); -} -.m01-sltbl__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} -.m01-sltbl__box { - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); -} -.m01-sltbl__box > summary { - cursor: pointer; - font-weight: 600; -} -.m01-sltbl__box[open] > summary { - margin-bottom: var(--spacing-8); -} -.m01-sltbl__box > :not(summary) { - margin-top: var(--spacing-8); -} -.m01-sltbl__info { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} -.m01-sltbl__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - max-height: 22vh; - overflow: auto; -} -.m01-sltbl__row, -.m01-sltbl__role { - display: flex; - flex-direction: column; - gap: 2px; - padding: var(--spacing-8) var(--spacing-12); - border: 2px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} -.m01-sltbl__row:hover, -.m01-sltbl__row:focus-visible, -.m01-sltbl__role:hover, -.m01-sltbl__role:focus-visible { - border-color: var(--color-accent); -} -.m01-sltbl__roles { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} -.m01-sltbl__roles > p { - flex: 1 0 100%; -} -.m01-sltbl__role span { - font-size: var(--text-caption); - color: var(--color-text-secondary); -} -.m01-sltbl__role.is-cond { - border-color: var(--color-accent); -} -.m01-sltbl__role.is-out { - border-style: dashed; - border-color: var(--color-accent); -} -.m01-sltbl__role.is-off { - opacity: 0.6; -} -.m01-sltbl__status { - margin: 0; - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-sm); - font-weight: 600; -} -.m01-sltbl__status.is-ok { - border-left: 4px solid var(--color-accent); -} -.m01-sltbl__status.is-bad { - border-left: 4px solid var(--color-danger); - color: var(--color-danger); -} -.m01-sltbl__scroll { - max-height: 24vh; - overflow: auto; -} -.m01-sltbl__view table { - border-collapse: collapse; - font-size: var(--text-body-sm); -} -.m01-sltbl__view th, -.m01-sltbl__view td { - padding: 2px 8px; - border: 1px solid var(--color-border); - white-space: nowrap; - text-align: center; -} -.m01-sltbl__view tr.is-hit td { - background: rgba(240, 190, 40, 0.35); - font-weight: 700; -} -.m01-sltbl__formula { - display: flex; - flex-direction: column; - gap: 2px; -} -.m01-sltbl__formula code { - overflow-wrap: anywhere; - font-size: var(--text-caption); -} -.m01-sltbl__warn { - margin: 0; - color: var(--color-danger); - font-size: var(--text-body-sm); -} -.m01-sltbl__paste { - width: 100%; - box-sizing: border-box; - font: inherit; -} -.m01-sltbl h5 { - margin: var(--spacing-4) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.ts b/M01_MasterData/SheetLogic/M01_SL_Table.ts deleted file mode 100644 index 97a341af4..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Table.ts +++ /dev/null @@ -1,616 +0,0 @@ -/* ============================================================================= - * M01_SL_Table.ts - * 시트 로직 표 상자 손잡이 — 표 상자를 고르면 시트 판 위에 붙음 - * · 품셈 표 가져오기(절 · 이름 찾기 → 표) — 표 줄은 바깥 값(사본으로 안 들고 있음) - * · 내 표(빈 표 · 엑셀에서 붙여넣기) — 시트에 그대로 - * · 조건 칸 · 결과 칸 정하기 → 입력 알약 · 출력 알약 + 「표에서 찾기」 수식(시트 판에서 고칠 수 있음) - * · 걸린 줄 칠하기 · 맞는 줄 없음 / 둘 이상 한 줄 - * 짜기 = `M01_SL_Table_Build.ts`(DOM 없음) · 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 10 - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { ApiError } from "../M01_MasterData_UI_Logic_Api"; -import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; -import type { SheetLogic, SlNode } from "./M01_SL_Model"; -import { - applyTable, - blockCells, - infoFromSheet, - layoutOf, - parsePaste, - planLookup, - type TblInfo, - type TblRoles, -} from "./M01_SL_Table_Build"; -import "./M01_SL_Table.css"; - -const TEXT = { - NotTable: ["표 상자를 고르면 표 손잡이가 뜸", "Pick a table box to see its tools"], - Import: ["품셈 표 가져오기", "Import a standard table"], - Mine: ["내 표", "My table"], - Book: ["적용 품셈", "Standard"], - Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], - Loading: ["찾는 중…", "Searching…"], - NoSection: ["찾는 절 없음", "No section"], - SecLine: ["표 {t} 개", "{t} tables"], - Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], - TableLine: [ - "{b} · 조건 {c} · 결과 {v} · {n} 줄", - "{b} · {c} conditions · {v} results · {n} rows", - ], - ImportNote: [ - "표 줄은 열 때 · 저장 때 품셈에서 새로 넣음 — 사본으로 안 들고 있음", - "Rows are filled from the standard when opened or saved", - ], - Empty: ["빈 표", "Empty table"], - Paste: ["엑셀에서 붙여넣기", "Paste from Excel"], - Heads: ["머리 칸 이름(쉼표로 나눔) — 예: 경사, 할증", "Column names, comma separated"], - RowCount: ["줄 수", "Rows"], - MakeEmpty: ["빈 표 만들기", "Create"], - PasteHint: [ - "엑셀에서 머리 줄부터 복사해 여기에 붙임(탭으로 나뉜 글)", - "Paste with the header row (tab separated)", - ], - PutIn: ["시트에 넣기", "Put in sheet"], - TableName: ["표 이름", "Table name"], - Need2: ["머리 칸이 둘은 있어야 함", "Need at least two columns"], - NeedRows: ["줄 수를 1 이상으로", "Rows must be 1 or more"], - Source: ["원문번호 {n} · 기준 {b} · 출처 {s}", "No. {n} · base {b} · source {s}"], - Notes: ["주(註)", "Notes"], - Roles: ["칸 역할 — 누르면 바뀜", "Column roles — press to change"], - RoleCond: ["조건 → 입력 알약", "Condition → input pill"], - RoleOut: ["결과 → 출력 알약", "Result → output pill"], - RoleOff: ["쓰지 않음", "Unused"], - Redo: ["역할을 바꾸면 찾기 수식을 다시 씀", "Changing roles rewrites the lookup formulas"], - Found: ["{n} 번째 줄이 걸림", "Row {n} matched"], - NoRow: ["맞는 줄 없음 — 입력 칸 값에 맞는 줄이 표에 없음", "No matching row"], - TwoRows: ["맞는 줄 둘 이상 — 조건 칸을 더 늘리거나 표 줄을 고침", "More than one matching row"], - Err: ["찾기 칸에 오류가 있음", "Error in the lookup cells"], - NoValues: ["표 줄을 아직 못 받음 — 열 때 품셈에서 넣음", "Rows are not loaded yet"], - View: ["걸린 줄 보기", "Matched row"], - Formulas: ["찾기 수식 — 시트 판에서 고침", "Lookup formulas — edit in the sheet"], - Warn: ["알림", "Note"], - Swap: ["다른 표로 바꾸기", "Change table"], - Failed: ["불러오지 못함", "Could not load"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -/* ── 서버 읽기 — 절 · 표 ── */ -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; -} -interface TableHead { - file: string; - 키: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - count: number; -} -interface RawTable { - 키: string; - 원문번호: string; - 이름: string; - 기준: string; - 출처: string; - 조건: Record; - 값칸: Record; - 범위규칙?: string | null; - 줄: TblInfo["rows"]; - 주?: string[]; -} - -const BOOKS = ["산림품셈", "건설품셈"]; -const cache = new Map(); -const fileOf = new Map(); - -const toInfo = (r: RawTable): TblInfo => ({ - key: r.키, - name: r.이름, - base: r.기준 ?? "", - source: r.출처 ?? "", - no: r.원문번호 ?? "", - conds: r.조건, - vals: r.값칸, - rule: r.범위규칙 ?? null, - rows: r.줄, - notes: r.주 ?? [], -}); - -/** 표 키 → 표 한 벌 — 파일은 목록에서 찾음(첫 가져오기 뒤에는 기억) */ -async function loadInfo(key: string): Promise { - const hit = cache.get(key); - if (hit) return hit; - let file = fileOf.get(key); - if (!file) { - const group = /^C[FC]/.test(key) ? "계수" : "소요량"; - const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find( - (x) => x.키 === key, - ); - if (!found) throw new Error(t("Failed")); - file = found.file; - } - const info = toInfo((await get<{ table: RawTable }>("/table", { file, key })).table); - cache.set(key, info); - return info; -} - -/* ── 역할 · 알약 ── */ -const rolesOf = (node: SlNode): TblRoles => ({ - cond: node.pillsIn.map((p) => p.name), - out: [ - ...new Set( - node.pillsOut.filter((p) => !p.outside).map((p) => p.name.replace(/·(아래|위)$/, "")), - ), - ], -}); - -/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */ -function mineInfo(sl: SheetLogic, node: SlNode): TblInfo { - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - const name = node.name.replace(/^표 · /, ""); - const conds = Object.fromEntries(node.pillsIn.map((p) => [p.name, "고르기"])); - return sheet - ? infoFromSheet(sheet, { conds, name }) - : { - key: "", - name, - base: "", - source: "", - no: "", - conds, - vals: {}, - rule: null, - rows: [], - notes: [], - }; -} - -/** userTables 한 줄을 시트 모양으로 맞춤 */ -function syncUser(sl: SheetLogic, id: string, info: TblInfo): SheetLogic { - const cols = layoutOf(info); - const rows = info.rows.map((r) => - cols.map( - (c) => - (Array.isArray(r[c.of]) - ? ((r[c.of] as [number | null, number | null])[c.part === "아래" ? 0 : 1] ?? null) - : (r[c.of] ?? null)) as number | string | null, - ), - ); - const entry = { id, name: info.name, cols: cols.map((c) => c.header), rows }; - const list = (sl.userTables ?? []).filter((u) => u.id !== id); - return { ...sl, userTables: [...list, entry] }; -} - -/** 찾기 판을 임시 통합문서에서 풀어 → 상태 · 걸린 줄(0 부터) · 오류 */ -async function solve( - sl: SheetLogic, - node: SlNode, - info: TblInfo, - withBlock: boolean, -): Promise<{ status: string; idx: number | null }> { - const plan = planLookup(info, rolesOf(node)); - const book = structuredClone(sl.workbook); - const sheet = book.시트.find((s) => s.id === node.sheetId); - if (!sheet) return { status: "", idx: null }; - if (withBlock) Object.assign(sheet.칸, blockCells(info)); - const { createCalcEngine } = await import("../../A00_Common/spreadsheet/spreadsheet_graph"); - const vals = createCalcEngine(book).snapshot()[node.sheetId] ?? {}; - const read = (a: string): string | null => { - const v = vals[a] as Record | undefined; - if (!v) return null; - return String(v["수"] ?? v["글"] ?? v["오류"] ?? ""); - }; - const status = read(plan.statusCell) ?? ""; - const idx = Number(read(plan.idxCell)); - return { status, idx: Number.isFinite(idx) && idx >= 1 ? idx - 1 : null }; -} - -/* ── 화면 ── */ -export function renderTableTools( - host: HTMLElement, - sl: SheetLogic, - nodeId: string, - on: { change(sl: SheetLogic): void }, -): void { - host.className = "m01-sltbl"; - const node = sl.nodes.find((n) => n.id === nodeId); - if (!node || node.kind !== "table") { - host.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("NotTable") })); - return; - } - const mine = node.ref ? sl.userTables?.find((u) => u.id === node.ref) : undefined; - const state = !node.ref ? "empty" : mine ? "mine" : "base"; - const parts: HTMLElement[] = []; - if (state !== "empty") parts.push(infoBox(sl, node, state, on)); - parts.push( - importBox(sl, nodeId, state === "empty", on), - mineBox(sl, nodeId, state === "empty", on), - ); - host.replaceChildren(...parts); -} - -const sub = (title: string, open: boolean, ...kids: HTMLElement[]): HTMLDetailsElement => { - const d = el("details", { - className: "m01-sltbl__box", - children: [el("summary", { text: title }), ...kids], - }); - d.open = open; - return d; -}; - -/** 품셈 표 가져오기 */ -function importBox( - sl: SheetLogic, - nodeId: string, - open: boolean, - on: { change(sl: SheetLogic): void }, -): HTMLElement { - let book = BOOKS[0]; - const bookBox = createSelectField({ - label: t("Book"), - options: BOOKS.map((b) => ({ value: b, text: b })), - value: book, - compact: true, - onChange: (v) => ((book = v), load()), - }); - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m01-sltbl__list" }); - const tables = el("div", { className: "m01-sltbl__list" }); - let seq = 0; - const load = async (): Promise => { - const mineSeq = ++seq; - list.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("Loading") })); - try { - const r = await get<{ sections: SectionItem[] }>("/sections", { - book, - q: q.input.value.trim(), - limit: "60", - }); - if (mineSeq !== seq) return; - list.replaceChildren( - ...(r.sections.length - ? r.sections.map((s) => { - const b = el("button", { - className: "m01-sltbl__row", - attrs: { type: "button" }, - children: [ - el("strong", { text: `${s.section} ${s.title}` }), - el("span", { - className: "m01-sltbl__muted", - text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, - }), - ], - }); - b.addEventListener("click", () => void pickSection(s)); - return b; - }) - : [el("p", { className: "m01-sltbl__muted", text: t("NoSection") })]), - ); - } catch (e) { - if (mineSeq === seq) showToast(plainError(e), "error"); - } - }; - const pickSection = async (s: SectionItem): Promise => { - try { - const heads: TableHead[] = []; - for (const group of ["소요량", "계수"]) - heads.push( - ...( - await get<{ tables: TableHead[] }>("/tables", { - group, - section: s.section, - sub: book, - size: "100", - }) - ).tables, - ); - tables.replaceChildren( - el("h5", { text: t("Tables") }), - ...heads.map((h) => { - const b = el("button", { - className: "m01-sltbl__row", - attrs: { type: "button" }, - children: [ - el("strong", { text: h.이름 }), - el("span", { - className: "m01-sltbl__muted", - text: t("TableLine", { - b: h.기준 || "—", - c: Object.keys(h.조건).length, - v: Object.keys(h.값칸).length, - n: h.count, - }), - }), - ], - }); - b.addEventListener("click", () => void pickTable(h)); - return b; - }), - ); - } catch (e) { - showToast(plainError(e), "error"); - } - }; - const pickTable = async (h: TableHead): Promise => { - try { - fileOf.set(h.키, h.file); - const info = await loadInfo(h.키); - const roles: TblRoles = { cond: Object.keys(info.conds), out: Object.keys(info.vals) }; - on.change( - applyTable(sl, nodeId, info, roles, { ref: h.키, fillBlock: false, outside: true }).sl, - ); - } catch (e) { - showToast(plainError(e), "error"); - } - }; - let timer = 0; - q.input.addEventListener( - "input", - () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), - ); - const box = sub( - t("Import"), - open, - el("p", { className: "m01-sltbl__muted", text: t("ImportNote") }), - bookBox.root, - q.root, - list, - tables, - ); - box.addEventListener("toggle", () => box.open && !list.childElementCount && void load()); - if (open) void load(); - return box; -} - -/** 내 표 — 빈 표 · 붙여넣기 */ -function mineBox( - sl: SheetLogic, - nodeId: string, - open: boolean, - on: { change(sl: SheetLogic): void }, -): HTMLElement { - const name = createInputField({ - type: "text", - label: t("TableName"), - value: "내 표", - compact: true, - }); - const heads = createInputField({ type: "text", placeholder: t("Heads"), compact: true }); - const rows = createInputField({ type: "text", label: t("RowCount"), value: "2", compact: true }); - const paste = el("textarea", { - className: "m01-sltbl__paste", - attrs: { rows: "5", placeholder: t("PasteHint") }, - }); - const put = (info: TblInfo, roles: TblRoles): void => { - const id = `UT${(sl.userTables?.length ?? 0) + 1}`; - const done = applyTable( - sl, - nodeId, - { ...info, name: name.input.value.trim() || "내 표" }, - roles, - { ref: id, fillBlock: true, outside: false }, - ); - on.change(syncUser(done.sl, id, { ...info, name: name.input.value.trim() || "내 표" })); - }; - const makeEmpty = createButton({ - label: t("MakeEmpty"), - onClick: () => { - const cols = heads.input.value - .split(/[,,\t]/) - .map((s) => s.trim()) - .filter(Boolean); - const n = Math.floor(Number(rows.input.value)); - if (cols.length < 2) return heads.setError(t("Need2")); - if (!(n >= 1)) return rows.setError(t("NeedRows")); - const info: TblInfo = { - key: "", - name: "", - base: "", - source: "", - no: "", - conds: { [cols[0]]: "고르기" }, - vals: Object.fromEntries(cols.slice(1).map((c) => [c, ""])), - rule: null, - rows: Array.from({ length: n }, () => ({})), - notes: [], - }; - put(info, { cond: [cols[0]], out: cols.slice(1) }); - }, - }); - const pasteBtn = createButton({ - label: t("PutIn"), - onClick: () => { - const r = parsePaste(paste.value); - if (r.error) return void showToast(r.error, "error"); - put(r.info, { cond: Object.keys(r.info.conds), out: Object.keys(r.info.vals) }); - }, - }); - return sub( - t("Mine"), - open, - name.root, - el("h5", { text: t("Empty") }), - heads.root, - rows.root, - makeEmpty, - el("h5", { text: t("Paste") }), - paste, - pasteBtn, - ); -} - -/** 고른 표 — 머리 · 칸 역할 · 걸린 줄 · 수식 */ -function infoBox( - sl: SheetLogic, - node: SlNode, - state: "base" | "mine", - on: { change(sl: SheetLogic): void }, -): HTMLElement { - const box = el("div", { className: "m01-sltbl__info" }); - const meta = el("p", { className: "m01-sltbl__muted" }); - const notes = el("details", { className: "m01-sltbl__notes" }); - const view = el("div", { className: "m01-sltbl__view" }); - const status = el("p", { className: "m01-sltbl__status", attrs: { "aria-live": "polite" } }); - const roleBox = el("div", { className: "m01-sltbl__roles" }); - const formulas = el("div", { className: "m01-sltbl__formulas" }); - const roles = rolesOf(node); - - const paint = (info: TblInfo): void => { - const withBlock = state === "base"; - meta.textContent = - info.no || info.source - ? t("Source", { n: info.no || "—", b: info.base || "—", s: info.source || "—" }) - : ""; - notes.replaceChildren( - ...(info.notes.length - ? [el("summary", { text: t("Notes") }), ...info.notes.map((n) => el("p", { text: n }))] - : []), - ); - notes.hidden = !info.notes.length; - // 칸 역할 - const all = [ - ...Object.keys(info.conds).map((c) => ({ c, isCond: true })), - ...Object.keys(info.vals).map((c) => ({ c, isCond: false })), - ]; - const cur = (c: string): "cond" | "out" | "off" => - roles.cond.includes(c) ? "cond" : roles.out.includes(c) ? "out" : "off"; - roleBox.replaceChildren( - el("p", { className: "m01-sltbl__muted", text: `${t("Roles")} · ${t("Redo")}` }), - ...all.map(({ c, isCond }) => { - const r = cur(c); - const b = el("button", { - className: `m01-sltbl__role is-${r}`, - attrs: { type: "button" }, - children: [ - el("strong", { text: c }), - el("span", { - text: r === "cond" ? t("RoleCond") : r === "out" ? t("RoleOut") : t("RoleOff"), - }), - ], - }); - b.addEventListener("click", () => { - const next: TblRoles = { - cond: roles.cond.filter((x) => x !== c), - out: roles.out.filter((x) => x !== c), - }; - const to = r === "off" ? (isCond ? "cond" : "out") : "off"; - if (to === "cond") next.cond.push(c); - if (to === "out") next.out.push(c); - const order = Object.keys(info.conds); - next.cond.sort((a, z) => order.indexOf(a) - order.indexOf(z)); - const done = applyTable(sl, node.id, info, next, { - ref: node.ref ?? "", - fillBlock: false, - outside: state === "base", - }); - on.change(state === "mine" ? syncUser(done.sl, node.ref ?? "", info) : done.sl); - }); - return b; - }), - ); - // 수식 보기 - const plan = planLookup(info, roles); - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - const line = (label: string, a1: string): HTMLElement => - el("div", { - className: "m01-sltbl__formula", - children: [ - el("span", { className: "m01-sltbl__muted", text: label }), - el("code", { - text: sheet?.칸[a1]?.식 ? `=${sheet.칸[a1].식}` : String(sheet?.칸[a1]?.값 ?? ""), - }), - ], - }); - formulas.replaceChildren( - el("h5", { text: t("Formulas") }), - ...Object.entries(plan.outCells).map(([c, a1]) => line(`${c} (${a1})`, a1)), - ...(plan.warnings.length - ? [ - el("p", { - className: "m01-sltbl__warn", - text: `${t("Warn")}: ${plan.warnings.join(" · ")}`, - }), - ] - : []), - ); - // 걸린 줄 칠하기 - const cols = layoutOf(info); - const table = el("table"); - table.append(el("tr", { children: cols.map((c) => el("th", { text: c.header })) })); - info.rows.forEach((r, i) => - table.append( - el("tr", { - attrs: { "data-row": String(i) }, - children: cols.map((c) => { - const v = r[c.of]; - const x = Array.isArray(v) ? (c.part === "아래" ? v[0] : v[1]) : v; - return el("td", { text: x === null || x === undefined ? "" : String(x) }); - }), - }), - ), - ); - view.replaceChildren( - el("h5", { text: t("View") }), - el("div", { className: "m01-sltbl__scroll", children: [table] }), - ); - void solve(sl, node, info, withBlock) - .then(({ status: s, idx }) => { - status.className = `m01-sltbl__status is-${s === "찾음" ? "ok" : "bad"}`; - status.textContent = - s === "찾음" && idx !== null - ? t("Found", { n: idx + 1 }) - : s === "맞는 줄 없음" - ? t("NoRow") - : s === "맞는 줄 둘 이상" - ? t("TwoRows") - : s - ? t("Err") - : ""; - table - .querySelectorAll("tr[data-row]") - .forEach((tr) => - tr.classList.toggle( - "is-hit", - idx !== null && s === "찾음" && tr.getAttribute("data-row") === String(idx), - ), - ); - table.querySelector(".is-hit")?.scrollIntoView({ block: "nearest" }); - }) - .catch(() => (status.textContent = t("Err"))); - }; - - box.append(meta, notes, roleBox, status, view, formulas); - if (state === "base") { - const key = node.ref ?? ""; - void loadInfo(key).then(paint, (e) => showToast(plainError(e), "error")); - } else paint(mineInfo(sl, node)); - return box; -} diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts deleted file mode 100644 index 3ec4d3f50..000000000 --- a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts +++ /dev/null @@ -1,480 +0,0 @@ -/* ============================================================================= - * M01_SL_Table_Build.ts - * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 - * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts` - * - * 시트 모양(표 상자 시트 하나): - * A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움 - * 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로) - * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) - * 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A - * ========================================================================== */ - -import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; - -type Rec = Record; - -/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */ -export interface TblInfo { - key: string; - name: string; - /** 기준 「1㎡」 */ - base: string; - source: string; - /** 원문번호 「13-4-1」 */ - no: string; - /** 조건 칸 → 「수」 「고르기」 「범위」 */ - conds: Record; - /** 값 칸 → 단위 */ - vals: Record; - rule: string | null; - rows: Rec[]; - notes: string[]; -} - -/** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */ -export interface TblRoles { - cond: string[]; - out: string[]; -} - -/** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */ -export interface TblCol { - header: string; - of: string; - part: "값" | "아래" | "위"; -} - -export interface TblPlan { - cols: TblCol[]; - /** 블록 안 줄 수(머리 줄 빼고) */ - rowCount: number; - /** 블록 밖에 쓸 칸 — A1 → 칸 */ - cells: Record; - /** 조건 칸 이름 → 입력 칸 */ - inCells: Record; - /** 결과 칸 이름 → 출력 칸 */ - outCells: Record; - idxCell: string; - statusCell: string; - /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */ - helperFrom: number; - warnings: string[]; -} - -/** 열 글자 0 → A */ -export const colName = (c: number): string => { - let s = ""; - for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) - s = String.fromCharCode(65 + ((n - 1) % 26)) + s; - return s; -}; -const colIndex = (letters: string): number => - [...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; -const at = (r: number, c: number): string => `${colName(c)}${r}`; - -/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */ -const marginOf = (n: number): number => Math.max(20, n); -const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; - -/** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */ -export const isSplit = (info: TblInfo, c: string): boolean => - info.conds[c] === "범위" || (c in info.vals && info.rows.some((r) => Array.isArray(r[c]))); - -/** 블록 열 차례 — 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 둘로(서버 `_table` 과 같은 차례) */ -export function layoutOf(info: TblInfo): TblCol[] { - const out: TblCol[] = []; - for (const c of [...Object.keys(info.conds), ...Object.keys(info.vals)]) - if (isSplit(info, c)) - out.push( - { header: `${c}·아래`, of: c, part: "아래" }, - { header: `${c}·위`, of: c, part: "위" }, - ); - else out.push({ header: c, of: c, part: "값" }); - return out; -} - -const cellOf = (v: Rec[string]): Cell | null => - v === null || v === undefined || v === "" - ? null - : typeof v === "number" - ? { 값: v } - : { 값: String(v) }; - -/** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */ -export function blockCells(info: TblInfo): Record { - const cells: Record = {}; - const cols = layoutOf(info); - cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header })); - info.rows.forEach((row, i) => - cols.forEach((col, c) => { - const v = row[col.of]; - // 범위 칸에 숫자 하나면 아래 · 위에 같은 수 - const x = Array.isArray(v) ? (col.part === "아래" ? v[0] : v[1]) : v; - const cell = cellOf(x); - if (cell) cells[at(i + 2, c)] = cell; - }), - ); - return cells; -} - -/** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */ -const sampleOf = (info: TblInfo, c: string): string | number => { - const v = info.rows[0]?.[c]; - if (Array.isArray(v)) - return v[0] !== null && v[1] !== null - ? Number(((v[0] + v[1]) / 2).toFixed(6)) - : (v[1] ?? v[0] ?? 0); - return v === null || v === undefined ? "" : v; -}; - -/** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */ -export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { - const cols = layoutOf(info); - const warnings: string[] = []; - const W = cols.length; - const n = info.rows.length; - const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header)); - const conds = roles.cond.filter((c) => c in info.conds); - const rangeCols = conds.filter((c) => info.conds[c] === "범위"); - const exact = conds.filter((c) => info.conds[c] !== "범위"); - const range = rangeCols[0]; - if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌"); - const blankable = exact.filter((c) => - info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""), - ); - if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원"); - if (range && blankable.length) - warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음"); - const rule = info.rule ?? "초과·이하"; - const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule)); - const R = n + marginOf(n) + 1; - const rng = (L: string): string => `${L}$2:${L}$${R}`; - - // 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 - const variants: string[][] = [[]]; - if (!range) { - const bl = blankable.slice(0, 2); - const subsets = - bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : []; - variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음 - } - const keyCols = exact.length ? variants : []; - const kStart = W + 1; - const kLetters = keyCols.map((_, i) => colName(kStart + i)); - const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열 - const V = L + 1; - const cells: Record = {}; - keyCols.forEach((skip, v) => { - cells[at(1, kStart + v)] = { - 값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건", - }; - const rest = exact.filter((c) => !skip.includes(c)); - for (let r = 2; r <= R; r++) { - const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&'); - if (!skip.length) cells[at(r, kStart + v)] = { 식: key }; - else { - const guard = [ - ...skip.map((c) => `${letter(c)}${r}=""`), - ...rest.map((c) => `${letter(c)}${r}<>""`), - ].join(","); - cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` }; - } - } - }); - - // 찾기 판 - let row = 1; - cells[at(row++, L)] = { - 값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "), - }; - const inCells: Record = {}; - const inputOf: Record = {}; - for (const c of conds) { - cells[at(row, L)] = { 값: c }; - const sample = sampleOf(info, c); - if (sample !== "") cells[at(row, V)] = { 값: sample }; - inCells[c] = at(row, V); - inputOf[c] = at(row, V); - row++; - } - // 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감) - const rowsCell = ((): string => { - cells[at(row, L)] = { 값: "표 줄 수" }; - cells[at(row, V)] = { - 식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`, - }; - return at(row++, V); - })(); - const keyOf = (skip: string[]): string => - exact - .filter((c) => !skip.includes(c)) - .map((c) => inputOf[c]) - .join('&"|"&'); - const idxRow = (label: string, f: string): string => { - cells[at(row, L)] = { 값: label }; - cells[at(row, V)] = { 식: f }; - return at(row++, V); - }; - const up = range ? rng(letter(`${range}·위`)) : ""; - const lo = range ? rng(letter(`${range}·아래`)) : ""; - const x = range ? inputOf[range] : ""; - let idx = ""; - let count = ""; - if (exact.length) { - const keyCell = idxRow("합친 조건", keyOf([])); - const ks = keyCols.map((skip, v) => { - if (v === 0) return keyCell; - cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` }; - cells[at(row, V)] = { 식: keyOf(skip) }; - return at(row++, V); - }); - if (range) { - idx = idxRow( - "걸린 줄", - `MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`, - ); - count = idxRow( - "일치 수", - `IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, - ); - } else { - const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`); - count = idxRow( - "일치 수", - found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"), - ); - const chain = ks - .map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`) - .reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()"); - idx = idxRow("걸린 줄", chain); - } - } else if (range) { - idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`); - const inside = lowBased - ? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`; - count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`); - } else { - warnings.push("조건 칸이 없음 — 찾을 줄이 없음"); - count = idxRow("일치 수", "0"); - idx = idxRow("걸린 줄", "NA()"); - } - if (lowBased && range && exact.length) - warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음"); - const statusCell = idxRow( - "상태", - `IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`, - ); - const outCells: Record = {}; - for (const c of roles.out.filter((o) => o in info.vals)) { - for (const h of isSplit(info, c) ? [`${c}·아래`, `${c}·위`] : [c]) - outCells[h] = idxRow( - h, - `IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(h))},${idx}),NA())`, - ); - } - return { - cols, - rowCount: n, - cells, - inCells, - outCells, - idxCell: idx, - statusCell, - helperFrom: W, - warnings, - }; -} - -/** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */ -export function pillsOf( - info: TblInfo, - plan: TblPlan, - outside: boolean, -): { pillsIn: SlPill[]; pillsOut: SlPill[] } { - const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell })); - const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({ - name, - cell, - unit: info.vals[name.replace(/·(아래|위)$/, "")] || undefined, - })); - if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true }); - return { pillsIn, pillsOut }; -} - -const sheetIndexOf = (sl: SheetLogic, node: SlNode): number => - sl.workbook.시트.findIndex((s) => s.id === node.sheetId); - -/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */ -function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void { - if (sheet.이름 === want) return; - const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름)); - let name = want; - for (let i = 2; taken.has(name); i++) name = `${want} (${i})`; - const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`; - const old = sheet.이름; - const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])]; - for (const s of sl.workbook.시트) - for (const cell of Object.values(s.칸)) - if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`); - sheet.이름 = name; -} - -/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */ -export function applyTable( - sl: SheetLogic, - nodeId: string, - info: TblInfo, - roles: TblRoles, - opts: { ref: string; fillBlock: boolean; outside: boolean }, -): { sl: SheetLogic; plan: TblPlan } { - const next = structuredClone(sl); - const node = next.nodes.find((n) => n.id === nodeId); - if (!node) throw new Error("없는 상자"); - const si = sheetIndexOf(next, node); - if (si < 0) throw new Error("상자의 시트가 없음"); - const plan = planLookup(info, roles); - const sheet: Sheet = next.workbook.시트[si]; - // 이미 있던 입력 칸 내용(예시값 · 선을 그은 참조 수식)은 같은 이름이면 그대로 가져감 - const oldIn = Object.fromEntries(node.pillsIn.map((p) => [p.name, sheet.칸[p.cell]])); - const keep = Object.entries(sheet.칸).filter( - ([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom, - ); - sheet.칸 = Object.fromEntries(keep); - if (opts.fillBlock) { - for (const a of Object.keys(sheet.칸)) delete sheet.칸[a]; - Object.assign(sheet.칸, blockCells(info)); - } - Object.assign(sheet.칸, plan.cells); - for (const [name, cell] of Object.entries(plan.inCells)) - if (oldIn[name]) sheet.칸[cell] = oldIn[name]; - const pills = pillsOf(info, plan, opts.outside); - const gone = (dir: "in" | "out", name: string): boolean => - !(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name); - next.edges = next.edges.filter( - (e) => - !(e.from.node === nodeId && gone("out", e.from.pill)) && - !(e.to.node === nodeId && gone("in", e.to.pill)), - ); - node.name = `표 · ${info.name}`; - renameSheet(next, sheet, node.name); - node.ref = opts.ref; - node.pillsIn = pills.pillsIn; - node.pillsOut = pills.pillsOut; - return { sl: next, plan }; -} - -/** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */ -export function parsePaste(text: string): { info: TblInfo; error: string | null } { - const lines = text - .replace(/\r/g, "") - .split("\n") - .filter((l) => l.trim() !== ""); - const empty: TblInfo = { - key: "", - name: "", - base: "", - source: "", - no: "", - conds: {}, - vals: {}, - rule: null, - rows: [], - notes: [], - }; - if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" }; - const grid = lines.map((l) => l.split("\t").map((c) => c.trim())); - const head = grid[0]; - if (head.some((h) => h === "") || new Set(head).size !== head.length) - return { info: empty, error: "머리 칸 이름이 비었거나 겹침" }; - const num = (s: string): number | null => - s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null; - const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/; - const body = grid.slice(1); - const isRange = (c: number): boolean => - body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) && - body.every((r) => !r[c] || rangeRe.test(r[c])); - const rows: Rec[] = body.map((r) => { - const rec: Rec = {}; - head.forEach((h, c) => { - const s = r[c] ?? ""; - if (isRange(c)) { - const m = rangeRe.exec(s); - rec[h] = m - ? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])] - : null; - } else rec[h] = s === "" ? null : (num(s) ?? s); - }); - return rec; - }); - // 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값 - const conds: Record = {}; - const vals: Record = {}; - head.forEach((h, c) => { - const numeric = body.every((r) => !r[c] || num(r[c]) !== null); - if (isRange(c)) conds[h] = "범위"; - else if (!numeric) conds[h] = "고르기"; - else if (c === 0 && head.length > 1) conds[h] = "수"; - else vals[h] = ""; - }); - if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" }; - return { info: { ...empty, conds, vals, rows }, error: null }; -} - -/** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */ -export function infoFromSheet(sheet: Sheet, prev: Pick): TblInfo { - const heads: string[] = []; - for (let c = 0; ; c++) { - const h = sheet.칸[at(1, c)]?.값; - if (h === undefined || h === "") break; - heads.push(String(h)); - } - let last = 1; - for (const a of Object.keys(sheet.칸)) { - const m = /^([A-Z]+)(\d+)$/.exec(a); - if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2])); - } - const conds: Record = {}; - const vals: Record = {}; - const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = []; - heads.forEach((h, c) => { - const m = /^(.+)·(아래|위)$/.exec(h); - names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c }); - }); - for (const nm of names) { - if (nm.part !== "값") { - if (prev.conds[nm.of]) conds[nm.of] = "범위"; - else vals[nm.of] = ""; - } else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of]; - else vals[nm.of] = ""; - } - const rows: Rec[] = []; - for (let r = 2; r <= last; r++) { - const rec: Rec = {}; - for (const nm of names) { - const v = sheet.칸[at(r, nm.c)]?.값; - const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v; - if (nm.part === "값") rec[nm.of] = x; - else { - const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null]; - prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null; - rec[nm.of] = prevPair; - } - } - rows.push(rec); - } - return { - key: "", - name: prev.name, - base: "", - source: "", - no: "", - conds, - vals, - rule: null, - rows, - notes: [], - }; -} diff --git a/main.py b/main.py index 1d641f20f..a499660bc 100644 --- a/main.py +++ b/main.py @@ -638,16 +638,6 @@ app.include_router(b07_frame_router, dependencies=protected_with_company) # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] app.include_router(m01_master_data_router, dependencies=system_admin_only) -# 로직 만들기 시험 길 — 폴더째 지우면 이 줄도 지움 -app.include_router( - __import__("M01_MasterData.LogicBuilder.M01_LB_Calc", fromlist=["router"]).router, - dependencies=system_admin_only, -) -# 시트 로직 시험 길 — 폴더째 지우면 이 줄도 지움 -app.include_router( - __import__("M01_MasterData.SheetLogic.M01_SL_Router", fromlist=["router"]).router, - dependencies=system_admin_only, -) # 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 줄도 지움 app.include_router( __import__("M02_MasterTemplete.CostFlow.M02_CF_Router", fromlist=["router"]).router, diff --git a/resources/tester/test_m01_easycalc_api.py b/resources/tester/test_m01_easycalc_api.py deleted file mode 100644 index 35015959c..000000000 --- a/resources/tester/test_m01_easycalc_api.py +++ /dev/null @@ -1,349 +0,0 @@ -"""M01 쉬운 단가산출 Api(`M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts`) 시험 — PLAN 34-3b. - -· 서버 길 왕복 — Api 가 부르는 몸 그대로 새로 → 고치기 → 시험 계산 → 지우기(임시 폴더 사본에서만) -· 쓰는 곳 — 담은 조합 · 부르는 내 로직이 지우기를 막음 -· 기계 짝 규칙 · 쉬운 말 바꾸기 — Api 파일의 「순수」 칸을 Node 로 바로 돌림 -견본 로직 줄은 시험 안에 둠 — 단가 값은 사본에서 읽어 비교(진짜 값에 안 기댐). -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from decimal import Decimal -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from M01_MasterData import M01_MasterData_Router as router_module -from M01_MasterData import M01_MasterData_Store as store - -ROOT = Path(__file__).resolve().parents[2] -API = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Api.ts" -REAL = store.FOLDER -OWN = "로직_자체.json" -NO_NODE = shutil.which("node") is None - - -@pytest.fixture -def client(tmp_path: Path, monkeypatch) -> TestClient: - for path in REAL.glob("*.json"): - if path.name == OWN: - continue # 내 로직은 빈 자리에서 시작 - if not path.name.startswith("_") or path.name == store.mk.BOOK.name: - shutil.copy(path, tmp_path / path.name) - monkeypatch.setattr(store, "FOLDER", tmp_path) - app = FastAPI() - app.include_router(router_module.router) - return TestClient(app) - - -def _call(client: TestClient, url: str, body: dict | None = None, want: int = 200) -> dict: - res = client.get(url) if body is None else client.post(url, json=body) - assert res.status_code == want, res.text - return res.json() - - -def _labor() -> tuple[str, Decimal]: - """값이 있는 인력 한 줄(키 · 노임) — 사본에서 읽음.""" - rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] - row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float))) - return row["키"], Decimal(str(row["값"])) - - -def _row(name: str, lines: list[dict], inputs: list | None = None) -> dict: - """쉬운 길이 보내는 최소 줄 — 키 · 갈래 · 소유는 서버가 채움.""" - return { - "키": "", - "원문번호": "", - "이름": name, - "결과단위": "원/㎡", - "출처": "자체", - "입력": inputs or [], - "중간": [], - "호표": lines, - "덧줄": [], - "끝수": None, - } - - -def _person(key: str, qty: str) -> dict: - return { - "종류": "인력", - "요소": key, - "이름": "사람", - "단위": "인", - "수량": qty, - "비목": "노무비", - } - - -def _node(expr: str) -> object: - """Api 파일의 「순수」 칸 + 식 하나를 Node 로 돌려 JSON 답.""" - src = API.read_text(encoding="utf-8") - block = re.search(r"/\* ── 순수 시작[^\n]*\n([\s\S]*?)/\* ── 순수 끝", src) - assert block, "순수 칸 표시를 못 찾음" - script = ( - "const currentLanguageIndex = 0;\n" - + block.group(1).replace("export function", "function") - + f"\nconsole.log(JSON.stringify({expr}));" - ) - result = subprocess.run( - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=60, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -# ── 서버 길 왕복 ─────────────────────────────────────────────────────── -def test_새로_고치기_시험계산_지우기_왕복(client: TestClient) -> None: - real_before = (REAL / OWN).exists() - key_l, price = _labor() - - made = _call( - client, - "/api/m01/logic/new", - {"logic": _row("시험 쉬운 줄", [_person(key_l, "0.5")]), "owner": "현장"}, - ) - key = made["key"] - assert key.startswith("GX") and made["file"] == OWN and made["logic"]["소유"] == "현장" - - # 목록 · 열기 — 내 것은 사본의 자체 파일에서 - listed = _call(client, "/api/m01/logics?q=시험 쉬운 줄")["logics"] - assert [x["file"] for x in listed] == [OWN] and listed[0]["결과단위"].startswith("원") - opened = _call(client, f"/api/m01/logic?key={key}") - assert opened["version"] == made["version"] - - # 고치기 → 시험 계산(저장 전 줄을 그대로 보냄) — 금액 = 수량 × 노임 - row = {**opened["logic"], "호표": [_person(key_l, "2")]} - edited = _call( - client, "/api/m01/logic/edit", {"key": key, "version": opened["version"], "logic": row} - ) - calc = _call(client, "/api/m01/calc", {"key": key, "inputs": {}, "row": edited["logic"]}) - assert calc["ok"] is True - assert Decimal(str(calc["sums"]["노무비"])) == 2 * price == Decimal(str(calc["sums"]["계"])) - - # 저장 안 한 새 줄도 키 "" 로 셈 - draft = _call( - client, - "/api/m01/calc", - {"key": "", "inputs": {}, "row": _row("새 것", [_person(key_l, "1")])}, - ) - assert Decimal(str(draft["sums"]["계"])) == price - - # 낡은 판본 409 · 품셈 기본 고치기 403 - _call( - client, - "/api/m01/logic/edit", - {"key": key, "version": made["version"], "logic": row}, - want=409, - ) - base = next(x for x in _call(client, "/api/m01/logics")["logics"] if x["file"] != OWN) - base_one = _call(client, f"/api/m01/logic?key={base['키']}") - body = {"key": base["키"], "version": base_one["version"], "logic": base_one["logic"]} - _call(client, "/api/m01/logic/edit", body, want=403) - - _call(client, "/api/m01/logic/delete", {"key": key, "version": edited["version"]}) - assert client.get(f"/api/m01/logic?key={key}").status_code == 404 - assert (REAL / OWN).exists() == real_before # 진짜 마스터 폴더는 안 건드림 - - -def test_쓰는_곳_조합과_부르는_로직이_지우기를_막음(client: TestClient) -> None: - key_l, _ = _labor() - a = _call(client, "/api/m01/logic/new", {"logic": _row("바탕 줄", [_person(key_l, "1")])}) - call = {"종류": "로직", "요소": f"로직({a['key']})", "이름": "바탕", "단위": "㎡", "수량": "1"} - b = _call(client, "/api/m01/logic/new", {"logic": _row("부르는 줄", [call])}) - - # 부르는 내 로직 — usesOf 가 훑는 목록(구분 「자체」) · 몸에 「로직(<키>」 - mine = _call(client, "/api/m01/logics?sub=자체")["logics"] - assert {x["키"] for x in mine} == {a["key"], b["key"]} - body = json.dumps(_call(client, f"/api/m01/logic?key={b['key']}")["logic"], ensure_ascii=False) - assert f"로직({a['key']}" in body - - blocked = client.post("/api/m01/logic/delete", json={"key": a["key"], "version": b["version"]}) - assert blocked.status_code == 422, blocked.text - errors = blocked.json()["detail"]["errors"] - if not NO_NODE: # 서버 글 그대로 쉬운 말로 — 키가 안 새어 나감 - plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})") - assert "지울 수 없음" in plain and not re.search(r"[A-Z]{2}\d{6}", plain) - - # 부르는 줄을 지우면 바탕을 지울 수 있음 - _call(client, "/api/m01/logic/delete", {"key": b["key"], "version": b["version"]}) - now = _call(client, f"/api/m01/logic?key={a['key']}")["version"] - - # 담은 조합(마스터 층 파일)도 막음 — /logic/combos 가 그 조합을 줌 - combo_file = store.FOLDER / "일위대가조합.json" - combos = ( - json.loads(combo_file.read_text(encoding="utf-8")) - if combo_file.exists() - else {"그룹": "일위대가조합", "판": "2026", "줄": []} - ) - combos["줄"].append( - {"키": "UA999999", "이름": "시험 조합", "구분": "자체", "상세구분": "", "단위": "㎡", - "담은로직": [{"로직": a["key"], "메모": None}], "본뜬키": None, "출처": "자체", - "소유": "현장", "비고": None} - ) # fmt: skip - combo_file.write_text(json.dumps(combos, ensure_ascii=False, indent=2), encoding="utf-8") - store.cache.stale(["일위대가조합.json"], set()) - used = _call(client, f"/api/m01/logic/combos?key={a['key']}")["combos"] - assert [c["키"] for c in used] == ["UA999999"] - _call(client, "/api/m01/logic/delete", {"key": a["key"], "version": now}, want=422) - - -# ── 기계 짝 규칙 ─────────────────────────────────────────────────────── -def _m(key: str, codes: list[str], *extra: str) -> dict: - inputs = [{"이름": "기계", "고르기": codes}] + [{"이름": n, "고르기": ["가"]} for n in extra] - return {"key": key, "name": key, "resultUnit": "원/시간", "inputs": inputs} - - -FAMILY = [ - _m("M_GENERAL", ["A", "B", "C", "D"], "지역"), - _m("M_BOOST", ["A", "B", "C", "D", "E"], "지역", "보정작업"), - _m("M_DIGGER", ["B"], "지역", "보정작업"), - _m("M_NOOP", ["C"], "지역"), - _m("M_CLAW", ["B"], "집게", "지역", "보정작업"), - _m("M_LOSS", ["A", "F", "G"]), - _m("M_LOSS_BOOST", ["F", "G", "H"], "보정작업"), -] - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_기계_짝_규칙() -> None: - fam = json.dumps(FAMILY, ensure_ascii=False) - picks = _node( - f"['A','B','C','D','E','F','H','Z'].map((c) => pickMachine({fam}, c)?.key ?? null)" - ) - # 일반 > 보정 · 전용(굴착기 · 조종원 없음)이 일반보다 먼저 · 부착 전용은 안 고름 · - # 손료만은 다른 길이 없을 때만 · 손료만 안에서는 보정 아닌 것 먼저 · 없는 기종 null - assert picks == [ - "M_GENERAL", - "M_DIGGER", - "M_NOOP", - "M_GENERAL", - "M_BOOST", - "M_LOSS", - "M_LOSS_BOOST", - None, - ] - - call = _node(f"machineCall({fam}[2], 'B')") - assert call["element"] == "로직(M_DIGGER, 기계='B', 지역=지역, 보정작업=보정작업)" - assert [i["이름"] for i in call["needInputs"]] == ["지역", "보정작업"] - quoted = _node( - "machineCall({key:'K', name:'', resultUnit:'원/시간', inputs:[{이름:'기계'},{이름:'조종 직종'}]}, 'X')" - ) - assert quoted["element"] == "로직(K, 기계='X', '조종 직종'='조종 직종')" - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClient) -> None: - """사본의 기계 시간당 경비 로직으로 짝을 지어 새 줄에 넣음 — 저장 · 계산이 됨(종류 「기계」 꼴 안 만듦).""" - family = [] - for path in store.FOLDER.glob("로직_*건설기계*.json"): - for row in json.loads(path.read_text(encoding="utf-8"))["줄"]: - if row["결과단위"].startswith("원"): - family.append( - { - "key": row["키"], - "name": row["이름"], - "resultUnit": row["결과단위"], - "inputs": row["입력"], - } - ) - if not family: - pytest.skip("사본에 기계 시간당 경비 로직이 없음") - fam = json.dumps(family, ensure_ascii=False) - code = next( - str(c) - for m in family - for i in m["inputs"] - if i["이름"] == "기계" - for c in i.get("고르기", []) - ) - got = _node( - f"(() => {{ const m = pickMachine({fam}, '{code}'); return m && machineCall(m, '{code}'); }})()" - ) - assert got and got["element"].startswith("로직(") - - line = {"종류": "로직", "요소": got["element"], "이름": "기계", "단위": "시간", "수량": "0.5"} - made = _call( - client, "/api/m01/logic/new", {"logic": _row("기계 시험", [line], got["needInputs"])} - ) - trial = {i["이름"]: (i.get("고르기") or [None])[0] for i in got["needInputs"]} - calc = _call(client, "/api/m01/calc", {"key": made["key"], "inputs": trial}) - assert calc["ok"] is True, calc - assert Decimal(str(calc["sums"]["계"])) > 0 and "비목" in calc["lines"][0] - _call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]}) - - -# ── 회사 · 개인 층 조합 ──────────────────────────────────────────────── -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_층_조합_가운데_이_로직을_담은_것만_자리_글과_함께() -> None: - combos = [ - { - "키": "UC3-000001", - "이름": "돌쌓기 한 벌", - "담은로직": [{"로직": "GX000001", "메모": None}], - }, - {"키": "UC3-000002", "이름": "다른 것", "담은로직": [{"로직": "GX000002"}]}, - {"키": "UC3-000003", "이름": "빈 것"}, - { - "키": "UC3-000004", - "이름": "둘째 자리", - "담은로직": [{"로직": "GF000001"}, {"로직": "GX000001"}], - }, - ] - got = _node( - f"holding({json.dumps(combos, ensure_ascii=False)}, 'GX000001', 'company', '산림조합')" - ) - assert got == [ - {"kind": "combo", "where": "회사 · 산림조합", "key": "UC3-000001", "name": "돌쌓기 한 벌"}, - {"kind": "combo", "where": "회사 · 산림조합", "key": "UC3-000004", "name": "둘째 자리"}, - ] - person = _node("holding([{키: 'UP15-000001', 담은로직: [{로직: 'K'}]}], 'K', 'personal', '나')") - assert person == [{"kind": "combo", "where": "개인 · 나", "key": "UP15-000001", "name": ""}] - - -# ── 쉬운 말 ───────────────────────────────────────────────────────────── -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_plainError_상태와_검사글을_쉬운_말로() -> None: - simple = _node("[0, 403, 404, 409, 500].map((s) => plainOf(s, 'x'))") - assert simple[0].startswith("서버와 이어지지") - assert "바탕으로 고치기" in simple[1] and "찾을 수 없음" in simple[2] - assert "다른 곳에서 먼저 고쳤음" in simple[3] and "검사에 걸림" in simple[4] - - errors = [ - "로직_자체.json · GX000001 호표 사람 수량 · 모르는 이름 「면적」", - "로직_자체.json · GX000001 호표 사람 · 비목 「None」", - "로직_자체.json · GX000001 시험 · 「호표」 도 「결과」 도 없음", - "로직_자체.json · GX000001 호표 기계 요소 · 「기계경비」 입력 「지역」 안 줌", - "알 수 없는 글 GX000009 찾기(QF000001).값", - ] - plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})").split("\n") - assert plain[0] == "「면적」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요" - assert "넣을 자리" in plain[1] and "줄이 하나도 없음" in plain[2] - assert plain[3] == "그 밖 2 건" # 셋까지 + 나머지 수 - assert not any(re.search(r"[A-Z]{2}\d{6}|찾기\(", x) for x in plain) - assert "「지역」" in _node( - f"plainOf(422, {json.dumps({'errors': errors[3:4]}, ensure_ascii=False)})" - ) - assert _node("plainOf(400, '로직 줄에 「이름」 이 없음')") == "이름을 넣어 주세요" - assert _node("plainStop('「GX000001」 입력 「돌」 없음')").startswith("이 값으로는") - - -def test_화면_글에_금지_낱말이_없음() -> None: - """설계 1절 8 — 키 · 식 · 호표 · 덧줄 · 비목 · 중간 값 · 끝수 · 정본 · 자체 · 별칭 안 씀(한국어 글).""" - src = API.read_text(encoding="utf-8") - block = re.search(r"const TEXT = \{([\s\S]*?)\} as const", src) - assert block - korean = re.findall(r'\[\s*"([^"]*)",', block.group(1)) - assert len(korean) > 20 - banned = ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭") - for text in korean: - assert not any(word in text for word in banned), text diff --git a/resources/tester/test_m01_easycalc_cond.py b/resources/tester/test_m01_easycalc_cond.py deleted file mode 100644 index 2d74cabf7..000000000 --- a/resources/tester/test_m01_easycalc_cond.py +++ /dev/null @@ -1,198 +0,0 @@ -"""M01 쉬운 단가산출 조건 보고 고치기 시험(34-3c) — 읽기 · 짜기 구역을 떼어 Node 로 돌림 · 화면 조작은 ORCA. - -· 견본 식은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 -· 만약(사슬) · 최소/최대 · 올림 · 식 속 덩어리 · 중간 값 따라가기 · 못 읽는 꼴 · 고친 뒤 짜기 왕복 -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -COND = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Cond.ts" - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -INPUTS = [ - {"이름": "비탈구배", "고르기": ["해당 없음", "1:1.0", "1:1.5"]}, - {"이름": "뒷길이", "단위": "㎝"}, - {"이름": "교환측", "고르기": ["양측", "한측"]}, - {"이름": "층수", "고르기": [1, 2, 3]}, -] -ROW = {"입력": INPUTS, "중간": [{"이름": "석공품", "식": "만약(뒷길이 > 40, 0.4, 0.3)"}]} - - -def _run(body: str, *args: object) -> object: - """읽기 · 짜기 구역 + 시험 글 — `A` = 넘긴 인자 · `out(x)` 로 답""" - src = COND.read_text(encoding="utf-8") - part = src[src.index("/* ── 읽기 · 짜기") : src.index("/* ── 화면")] - script = ( - "type LogicInput = any; type LogicRow = any;\n" - + part - + "\nconst A = JSON.parse(process.argv[1]);\n" - + "const out = (x: unknown) => console.log(JSON.stringify(x));\n" - + body - ) - result = subprocess.run( - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", script, json.dumps(list(args))], - capture_output=True, text=True, encoding="utf-8", timeout=60, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -def _read(formula: str) -> dict | None: - return _run("out(readCond(A[0], A[1]));", formula, ROW) - - -def _edit(formula: str, js: str) -> str: - """읽은 뒤 `js` 로 덩어리(`B` = 첫 덩어리 꼴)를 고치고 다시 짬""" - return _run( - f"const R = readCond(A[0], A[1]); const B = R.blocks[0].shape; {js}; out(writeRead(R));", - formula, - ROW, - ) - - -# ── 읽기 ─────────────────────────────────────────────────────────────────── -def test_만약_한겹_설계값_표() -> None: - f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)" - r = _read(f) - assert r["via"] is None and r["text"] == f and len(r["blocks"]) == 1 - b = r["blocks"][0] - assert b["p"] == [0, len(f)] - assert b["shape"] == { - "type": "if", - "branches": [ - { - "test": { - "left": {"kind": "input", "name": "비탈구배"}, - "op": "==", - "right": {"kind": "text", "value": "해당 없음"}, - }, - "value": {"kind": "num", "value": 0}, - } - ], - "otherwise": {"kind": "table", "src": "찾기(QF000001, 구배=비탈구배).값"}, - } - - -def test_만약_사슬() -> None: - r = _read("만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))") - shape = r["blocks"][0]["shape"] - assert [x["value"] for x in shape["branches"]] == [ - {"kind": "num", "value": 2}, - {"kind": "num", "value": 3.5}, - ] - assert shape["otherwise"] == {"kind": "num", "value": -1} - assert shape["branches"][1]["test"]["right"] == {"kind": "num", "value": 2} - - -def test_최대_올림() -> None: - pick = _read("최대(시공량 * 0.5, 3)")["blocks"][0]["shape"] - assert pick == { - "type": "pick", - "fn": "최대", - "items": [{"kind": "calc", "src": "시공량 * 0.5"}, {"kind": "num", "value": 3}], - } - up = _read("올림(찾기(QC000001).값 / 시공량, 2)")["blocks"][0]["shape"] - assert (up["type"], up["fn"], up["places"], up["hadPlaces"]) == ("round", "올림", 2, True) - assert up["value"] == {"kind": "calc", "src": "찾기(QC000001).값 / 시공량"} - assert _read("버림(뒷길이)")["blocks"][0]["shape"]["hadPlaces"] is False - - -def test_식_속_덩어리와_중간값() -> None: - f = "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)" - r = _read(f) - assert len(r["blocks"]) == 1 and r["blocks"][0]["p"] == [f.index("만약"), len(f)] - many = _read("(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100") - assert [b["shape"]["type"] for b in many["blocks"]] == ["if", "pick"] - via = _read("석공품") - assert via["via"] == "석공품" and via["text"] == "만약(뒷길이 > 40, 0.4, 0.3)" - - -@pytest.mark.parametrize( - "formula", - [ - "만약(뒷길이 > 40 그리고 층수 < 2, 1, 0)", - "찾기(QC000001, 항목=만약(교환측 == '양측', 'a', 'b')).수량", - "찾기(QC000001).값 * 2", - "0.5", - "만약(뒷길이 > 40, 1", - "", - ], - ids=["그리고", "찾기속", "조건없음", "숫자", "괄호깨짐", "빈글"], -) -def test_못_읽는_꼴(formula: str) -> None: - assert _read(formula) is None - - -# ── 짜기 왕복 ─────────────────────────────────────────────────────────────── -@pytest.mark.parametrize( - "formula", - [ - "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)", - "만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))", - "최소(시공량 * 0.5, 3, 뒷길이)", - "반올림(찾기(QC000001).값 / 시공량, 2)", - "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65) * (1 + 할증 / 100)", - "(용접공 * LB000012 + 특별인부 * LB000003) * 만약(교환측 == '양측', 22, 18) / 100", - ], -) -def test_안_고치면_같은_글(formula: str) -> None: - assert _run("out(writeRead(readCond(A[0], A[1])));", formula, ROW) == formula - - -def test_고친_뒤_짜기() -> None: - f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)" - assert ( - _edit(f, "B.branches[0].test.right.value = '1:1.5'; B.branches[0].test.op = '!='") - == "만약(비탈구배 != '1:1.5', 0, 찾기(QF000001, 구배=비탈구배).값)" - ) - assert ( - _edit(f, "B.otherwise = { kind: 'num', value: 0.25 }") - == "만약(비탈구배 == '해당 없음', 0, 0.25)" - ) - assert ( - _edit( - "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)", - "B.branches[0].value.value = 1.1", - ) - == "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1.1, 0.65)" - ) - assert ( - _edit("최대(시공량 * 0.5, 3)", "B.fn = '최소'; B.items[1].value = 4") - == "최소(시공량 * 0.5, 4)" - ) - assert _edit("올림(뒷길이 / 3)", "B.places = 1; B.fn = '반올림'") == "반올림(뒷길이 / 3, 1)" - assert _edit("석공품", "B.branches[0].test.left.name = '층수'; B.otherwise.value = 0.2") == ( - "만약(층수 > 40, 0.4, 0.2)" - ) - - -def test_둘째_덩어리_고침() -> None: - f = "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100" - got = _run( - "const R = readCond(A[0], A[1]); R.blocks[1].shape.items[1].value = 45; out(writeRead(R));", - f, - ROW, - ) - assert got == "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 45)) / 100" - - -# ── 화면 글 ───────────────────────────────────────────────────────────────── -def test_화면_글에_금지_낱말_없음() -> None: - src = COND.read_text(encoding="utf-8") - block = src[src.index("const TEXT = {") : src.index("} as const;")] - korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] - assert len(korean) >= 30 - for text in korean: - for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"): - assert bad not in text, (bad, text) diff --git a/resources/tester/test_m01_easycalc_model.py b/resources/tester/test_m01_easycalc_model.py deleted file mode 100644 index b6f6ac8ee..000000000 --- a/resources/tester/test_m01_easycalc_model.py +++ /dev/null @@ -1,402 +0,0 @@ -"""M01 쉬운 단가산출 Model 시험(34-3b) — Node 로 `M01_EasyCalc_Model.ts` 를 바로 돌림 · 화면 조작은 ORCA. - -· 견본 로직 줄 셋(인력+재료 · 기계+% 덧줄 · 표 찾기 잠금)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 -· 왕복(toDoc → toRow 가 원래 줄과 같음) · 카드 종류 · 잠금 까닭 · 더하기 · 지우기 · 차례 · 숫자로 바꾸기 · 되돌리기 · 검사 -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -MODEL = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Model.ts" - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -# ── 견본 줄 ─────────────────────────────────────────────────────────────── -HEAD = {"원문번호": "", "구분": "자체", "상세구분": "시험", "출처": "자체", "소유": "현장"} - -PERSON_MATERIAL = { - **HEAD, - "키": "GX900001", - "이름": "시험 찰쌓기", - "결과단위": "원/㎡", - "입력": [], - "중간": [], - "호표": [ - { - "종류": "인력", - "요소": "LB000005", - "이름": "석공", - "단위": "인", - "수량": "0.072", - "비목": "노무비", - }, - { - "종류": "재료", - "요소": {"구분": "시멘트", "검색어": "시멘트 40kg"}, - "이름": "시멘트", - "규격": "40kg", - "단위": "포", - "수량": "0.50", - "비목": "재료비", - "출처": "산림품셈 12-1-1", - }, - ], - "덧줄": [], - "끝수": None, - "비고": "견본", -} - -MACHINE_PERCENT = { - **HEAD, - "키": "GX900002", - "이름": "시험 깬돌 쌓기", - "결과단위": "원/㎡", - "입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}], - "중간": [], - "호표": [ - { - "종류": "인력", - "요소": "LB000002", - "이름": "보통인부", - "단위": "인", - "수량": "0.036", - "비목": "노무비", - }, - { - "종류": "로직", - "요소": "로직(GC000270, 기계='0201-0060', 집게='7206-0070', 지역=지역)", - "이름": "굴삭기+집게", - "규격": "0.6㎥", - "단위": "시간", - "수량": "0.27", - }, - { - "종류": "로직", - "요소": "로직(GX000777)", - "이름": "모르터바르기", - "단위": "㎥", - "수량": "0.009", - }, - ], - "덧줄": [ - {"이름": "공구손료", "식": "(0.036 * LB000002) * 2 / 100", "비목": "재료비"}, - {"이름": "잡재료비", "식": "노무비 * 3 / 100", "비목": "재료비", "출처": "산림품셈 1-2-6"}, - ], - "끝수": {"대상": "계", "자리": 0, "방법": "버림"}, -} - -TABLE_LOCKED = { - **HEAD, - "키": "GX900003", - "이름": "시험 메쌓기", - "결과단위": "원/㎡", - "입력": [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "보정", "단위": "배"}], - "중간": [{"이름": "석공품", "식": "찾기(QF000041, 뒷길이=뒷길이).석공"}], - "호표": [ - { - "종류": "인력", - "요소": "LB000005", - "이름": "석공", - "단위": "인", - "수량": "석공품", - "비목": "노무비", - }, - { - "종류": "인력", - "요소": "LB000002", - "이름": "보통인부", - "단위": "인", - "수량": "만약(뒷길이 > 40, 0.4, 0.3)", - "비목": "노무비", - }, - { - "종류": "인력", - "요소": "LB000003", - "이름": "특별인부", - "단위": "인", - "수량": "보정 * 0.1", - "비목": "노무비", - }, - { - "종류": "인력", - "요소": "LB000001", - "이름": "작업반장", - "단위": "인", - "수량": "0.1 * 2", - "비목": "노무비", - }, - ], - "덧줄": [ - { - "이름": "공구손료", - "식": "(석공품 * LB000005 + (만약(뒷길이 > 40, 0.4, 0.3)) * LB000002" - " + (보정 * 0.1) * LB000003 + (0.1 * 2) * LB000001) * 3 / 100", - "비목": "경비", - }, - { - "이름": "잡재료", - "식": "주재료비 * 찾기(QC000589, 구분='잡재료비').값 / 100", - "비목": "재료비", - }, - ], - "끝수": None, -} - -SAMPLES = [PERSON_MATERIAL, MACHINE_PERCENT, TABLE_LOCKED] -META = {"key": "GX900001", "version": "v1", "isBase": False} - - -def _run(body: str, *args: object) -> object: - """견본을 받아 Model 을 돌리는 Node 글 — `M` = Model 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답""" - script = ( - f"const M = await import({json.dumps(MODEL.as_uri())});\n" - "const A = JSON.parse(process.argv[1]);\n" - "const out = (x: unknown) => console.log(JSON.stringify(x));\n" + body - ) - result = subprocess.run( - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", script, json.dumps(list(args))], - capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -def _doc(row: dict) -> dict: - return _run("out(M.toDoc(A[0], A[1]));", row, META) - - -# ── 왕복 ─────────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("row", SAMPLES, ids=["인력재료", "기계덧붙임", "표찾기잠금"]) -def test_왕복_같음(row: dict) -> None: - back = _run("out(M.toRow(M.toDoc(A[0], A[1])));", row, META) - assert back == row - - -def test_새_로직_최소칸() -> None: - row = _run("out(M.toRow(M.newDoc('새 일', 'm')));") - assert row == { - "키": "", - "원문번호": "", - "이름": "새 일", - "결과단위": "원/m", - "출처": "자체", - "입력": [], - "중간": [], - "호표": [], - "덧줄": [], - "끝수": None, - } - - -# ── 카드 바꾸기 ───────────────────────────────────────────────────────────── -def test_카드_종류와_숫자() -> None: - d = _doc(MACHINE_PERCENT) - assert [c["kind"] for c in d["cards"]] == ["person", "machine", "logic", "percent", "percent"] - person, machine, logic, tool, misc = d["cards"] - assert person["qty"] == 0.036 and person["cost"] == "노무비" and person["qtyFormula"] is None - assert machine["cost"] is None and machine["spec"] == "0.6㎥" - assert logic["cost"] is None - assert (tool["percentBase"], tool["qty"], tool["unit"], tool["cost"]) == ( - "사람줄합", - 2, - "%", - "재료비", - ) - assert misc["percentBase"] == "노무비" and misc["qty"] == 3 - assert d["inputs"] == MACHINE_PERCENT["입력"] and d["resultUnit"] == "원/㎡" - - -def test_잠금_까닭_쉬운말() -> None: - d = _doc(TABLE_LOCKED) - kinds = [c["kind"] for c in d["cards"]] - assert kinds == ["person"] * 4 + ["percent", "locked"] - why = [c["lockedWhy"] for c in d["cards"]] - assert why[:4] == [ - "품셈 표에서 찾는 값", - "조건에 따라 달라지는 값", - "설계 때 정하는 값으로 정해짐", - "계산으로 정해지는 값", - ] - assert d["cards"][0]["qtyFormula"] == "석공품" and d["cards"][0]["qty"] is None - assert d["cards"][4]["percentBase"] == "사람줄합" - assert why[5] == "품셈 표에서 찾는 값" - for w in filter(None, why): - for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): - assert bad not in w - - -# ── 카드 조작 ─────────────────────────────────────────────────────────────── -def test_더하기_묶음_뒤_기계는_설계값_더함() -> None: - pick_person = { - "kind": "person", - "name": "석공", - "spec": "", - "unit": "인", - "element": "LB000005", - "qty": 0.5, - } - pick_machine = { - "kind": "machine", - "name": "굴삭기", - "spec": "0.7㎥", - "unit": "시간", - "element": "로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업='해당 없음')", - "qty": 0.3, - "needInputs": [{"이름": "지역", "고르기": ["전국평균"]}], - } - got = _run( - "let d = M.toDoc(A[0], A[1]);\n" - "d = M.addCard(d, A[2]); d = M.addCard(d, A[3]); d = M.addCard(d, A[3]);\n" - "d = M.addPercent(d, '공구손료', '사람줄합', 2, '경비');\n" - "out(M.toRow(d));", - PERSON_MATERIAL, - META, - pick_person, - pick_machine, - ) - ho = got["호표"] - assert [h["이름"] for h in ho] == ["석공", "석공", "시멘트", "굴삭기", "굴삭기"] - assert ho[1] == { - "종류": "인력", - "요소": "LB000005", - "이름": "석공", - "단위": "인", - "수량": "0.5", - "비목": "노무비", - } - assert ho[3]["종류"] == "로직" and "비목" not in ho[3] and ho[3]["규격"] == "0.7㎥" - assert got["입력"] == [{"이름": "지역", "고르기": ["전국평균"]}] - assert got["덧줄"] == [ - {"이름": "공구손료", "식": "(0.072 * LB000005 + 0.5 * LB000005) * 2 / 100", "비목": "경비"} - ] - - -def test_지우기와_사람줄합_다시짬() -> None: - got = _run( - "let d = M.toDoc(A[0], A[1]);\n" - "const extra = M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005', qty:0.1});\n" - "const removed = M.removeCard(extra, d.cards[0].id);\n" - "out([M.toRow(extra), M.toRow(removed), M.toRow(M.removeCard(d, d.cards[0].id))]);", - MACHINE_PERCENT, - META, - ) - two, one, none = got - assert two["덧줄"][0]["식"] == "(0.036 * LB000002 + 0.1 * LB000005) * 2 / 100" - assert two["덧줄"][1] == MACHINE_PERCENT["덧줄"][1] - assert one["덧줄"][0]["식"] == "(0.1 * LB000005) * 2 / 100" - assert [h["이름"] for h in none["호표"]] == ["굴삭기+집게", "모르터바르기"] - assert none["덧줄"][0]["식"] == "(0) * 2 / 100" - - -def test_차례_같은_묶음_안에서만() -> None: - got = _run( - "const d = M.toDoc(A[0], A[1]);\n" - "const ids = d.cards.map((c: any) => c.id);\n" - "const names = (x: any) => x.cards.map((c: any) => c.name);\n" - "out([names(M.moveCard(d, ids[1], 1)), names(M.moveCard(d, ids[0], -1))," - " names(M.moveCard(d, ids[3], 1)), names(M.moveCard(d, ids[0], 1)) ]);", - TABLE_LOCKED, - META, - ) - down, top, add_down, end = got - assert down == ["석공", "특별인부", "보통인부", "작업반장", "공구손료", "잡재료"] - assert top == ["석공", "보통인부", "특별인부", "작업반장", "공구손료", "잡재료"] - assert add_down == top # 사람 묶음 끝 → 덧붙임으로 안 넘어감 - assert end == ["보통인부", "석공", "특별인부", "작업반장", "공구손료", "잡재료"] - - -def test_잠금을_숫자로_바꾸기() -> None: - got = _run( - "const d = M.toDoc(A[0], A[1]);\n" - "const n = M.updateCard(d, d.cards[0].id, { qty: 0.5, qtyFormula: null });\n" - "out([n.cards[0], M.toRow(n), d.cards[0].qtyFormula]);", - TABLE_LOCKED, - META, - ) - card, row, before = got - assert card["lockedWhy"] is None and card["qty"] == 0.5 - assert row["호표"][0]["수량"] == "0.5" - assert before == "석공품" # 받은 문서는 안 바뀜 - # 석공 수량이 바뀌어 사람 줄 합이 다시 짜임 - assert row["덧줄"][0]["식"].startswith("(0.5 * LB000005 + ") - - -def test_되돌리기와_저장안함() -> None: - got = _run( - "const h = new M.EasyHistory();\n" - "const d0 = M.toDoc(A[0], A[1]);\n" - "const log: unknown[] = [];\n" - "h.push(d0); log.push([h.canUndo, h.dirty]);\n" - "const d1 = M.updateCard(d0, d0.cards[0].id, { qty: 0.08 });\n" - "h.push(d1); h.push(d1); log.push([h.canUndo, h.dirty]);\n" - "h.markSaved(); log.push([h.dirty]);\n" - "const back = h.undo(); log.push([back.cards[0].qty, h.canUndo, h.dirty, h.undo()]);\n" - "out(log);", - PERSON_MATERIAL, - META, - ) - assert got == [[False, False], [True, True], [False], [0.072, False, True, None]] - - -# ── 검사 ─────────────────────────────────────────────────────────────────── -def test_검사_쉬운말_한줄() -> None: - got = _run( - "const d = M.toDoc(A[0], A[1]);\n" - "const c = d.cards[0];\n" - "const t = (p: any) => M.checkCard({ ...c, ...p }, d);\n" - "out([t({}), t({ qty: null }), t({ qty: Number.NaN }), t({ qty: 0 }), t({ qty: -1 })," - " t({ name: ' ' }), t({ qty: 12 }), t({ qty: null, qtyFormula: '석공품' })," - " M.checkCard(M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005'," - " qty:0.1}).cards[1], M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인'," - " element:'LB000005', qty:0.1}))," - " M.checkName('시험 찰쌓기', ['시험 찰쌓기']), M.checkName('', []), M.checkName('새것', ['헌것'])]);", - PERSON_MATERIAL, - META, - ) - assert got == [ - None, - "얼마인지 숫자로 넣어야 함", - "얼마인지 숫자로 넣어야 함", - "0 은 안 됨 — 빼려면 지움", - "0 보다 큰 수만 됨", - "이름이 비어 있음", - "1㎡ 에 12인 — 너무 큰 수 아닌지?", - None, - "같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", - "내가 만든 것에 같은 이름이 있음", - "이름이 비어 있음", - None, - ] - - -def test_영어_글() -> None: - got = _run( - "M.setEasyModelLanguage(1);\n" - "const d = M.toDoc(A[0], A[1]);\n" - "out([d.cards[0].lockedWhy, M.checkCard({ ...d.cards[1], qty: 0, qtyFormula: null }, d)]);", - TABLE_LOCKED, - META, - ) - assert got == [ - "Looked up from the standard table", - "0 is not allowed — remove the line instead", - ] - - -def test_화면_글에_금지_낱말_없음() -> None: - src = MODEL.read_text(encoding="utf-8") - block = src[src.index("const TEXT = {") : src.index("} as const;")] - korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] - assert len(korean) == 12 - for text in korean: - for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"): - assert bad not in text, (bad, text) diff --git a/resources/tester/test_m01_easycalc_table.py b/resources/tester/test_m01_easycalc_table.py deleted file mode 100644 index 58ff0f93b..000000000 --- a/resources/tester/test_m01_easycalc_table.py +++ /dev/null @@ -1,155 +0,0 @@ -"""쉬운 단가산출 2단계 — 표 보고 고르기(`M01_EasyCalc_Table.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" / "EasyCalc" / "M01_EasyCalc_Table.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const stub = new Proxy({}, { get: () => () => undefined }); -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => stub); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => { - if (job.op === "read") return m.readFinds(job.formula); - if (job.op === "swap") { - const calls = m.readFinds(job.formula); - const swaps = calls.map((c, i) => { - const s = job.swaps[i]; - return s ? m.writeFind(c.table, s.args ?? c.args, s.col ?? c.col) : null; - }); - return m.replaceFinds(job.formula, calls, swaps); - } - if (job.op === "literal") return m.literal(job.value, job.rule ?? ""); - return null; -}); -process.stdout.write(JSON.stringify(out)); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -MESSANGI = "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)" - - -def test_식_읽기_흔한_꼴(): - [calls] = run([{"op": "read", "formula": MESSANGI}]) - assert len(calls) == 1 - c = calls[0] - assert c["table"] == "QF000421" and c["col"] == "석공" - assert [(a["key"], a["rhs"]) for a in c["args"]] == [ - ("뒷길이", "뒷길이"), - ("돌", "돌"), - ("쌓기", "쌓기"), - ] - assert MESSANGI[c["start"] : c["end"]] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공" - - -def test_식_읽기_따옴표_쉼표_괄호_겹침(): - f = ( - "(찾기(QC000524, 형식=형식).특별인부 - 만약(정밀절단 == '아님', " - "찾기(QC000583, 구분='가, 나 (다)').값, 0)) / 시공량" - ) - [calls] = run([{"op": "read", "formula": f}]) - assert [c["table"] for c in calls] == ["QC000524", "QC000583"] - assert calls[1]["args"] == [{"key": "구분", "rhs": "'가, 나 (다)'"}] - assert calls[1]["col"] == "값" - - -def test_식_읽기_못_읽는_꼴과_없는_꼴(): - out = run( - [ - {"op": "read", "formula": "찾기(QF:{지역}, 구분='a').값"}, # {자리} 낀 키 - {"op": "read", "formula": "찾기(QF000001, 구분='a'"}, # 안 닫힘 - {"op": "read", "formula": "품 * 1.2"}, - {"op": "read", "formula": "'찾기(QF000001, 구분=1).값'"}, # 따옴표 글 안 - ] - ) - assert out[0] is None and out[1] is None - assert out[2] == [] and out[3] == [] - - -def test_새_식_짜기_줄_고정과_값칸_바꾸기(): - [new] = run( - [ - { - "op": "swap", - "formula": MESSANGI, - "swaps": [ - { - "args": [ - {"key": "뒷길이", "rhs": "35"}, - {"key": "돌", "rhs": "'깬잡석'"}, - {"key": "쌓기", "rhs": "쌓기"}, - ], - "col": "보통인부", - } - ], - } - ] - ) - assert new == ( - "찾기(QF000421, 뒷길이=35, 돌='깬잡석', 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)" - ) - - -def test_새_식_짜기_둘째_찾기만_바꿈_나머지_글_그대로(): - f = "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기=공기).손율 / 100" - [new] = run( - [ - { - "op": "swap", - "formula": f, - "swaps": [None, {"args": [{"key": "공기", "rhs": "'1개월'"}]}], - } - ] - ) - assert new == "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기='1개월').손율 / 100" - # 다시 읽어도 같은 모양(왕복) - [calls] = run([{"op": "read", "formula": new}]) - assert calls[1]["args"] == [{"key": "공기", "rhs": "'1개월'"}] - - -def test_칸_값을_식_글로(): - out = run( - [ - {"op": "literal", "value": 25}, - {"op": "literal", "value": "깬잡석"}, - {"op": "literal", "value": "it's"}, # 따옴표 든 글은 못 씀 - {"op": "literal", "value": [20, 40], "rule": "초과·이하"}, - {"op": "literal", "value": [None, 60], "rule": "이하"}, - {"op": "literal", "value": [None, 60], "rule": "이상·미만"}, - {"op": "literal", "value": [80, None], "rule": "초과·이하"}, - {"op": "literal", "value": [80, None], "rule": "이상·미만"}, - ] - ) - assert out == ["25", "'깬잡석'", None, "30", "60", "59", "81", "80"] diff --git a/resources/tester/test_m01_lb_blocks.py b/resources/tester/test_m01_lb_blocks.py deleted file mode 100644 index f9e9f4188..000000000 --- a/resources/tester/test_m01_lb_blocks.py +++ /dev/null @@ -1,309 +0,0 @@ -"""M01 로직 만들기 판 Blocks 시험(35-2a) — Node 로 `M01_LB_Blocks.ts` 를 바로 돌림 · 화면 조작은 ORCA. - -· 견본 로직 줄 여섯(정함 10)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 -· 왕복(readLogic → writeLogic 이 원래 줄과 같음) · 블록 내용 · 조건 바꾸기 · 표 바꾸기 · 계산 바꾸기 · 빈 로직 · 못 읽는 꼴 -· EasyCalc Model · Table · Cond 를 그대로 부름 — 화면 모듈(@ui · 서버 길)은 시험 고리가 빈 것으로 갈음 -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -BLOCKS = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Blocks.ts" - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ──────────── -HOOKS = r""" -import { readFileSync, existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { stripTypeScriptTypes } from "node:module"; -const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks)(\.ts)?$/; -export async function resolve(spec, ctx, next) { - if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { - const u = new URL(spec + ".ts", ctx.parentURL); - if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true }; - } - return next(spec, ctx); -} -export async function load(url, ctx, next) { - if (!url.endsWith(".ts")) return next(url, ctx); - let src = readFileSync(fileURLToPath(url), "utf8"); - src = src.replace(/import\s+"[^"]+\.css";/g, ""); - src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})"); - src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => { - if (from.startsWith(".") && ALLOW.test(from)) return m; - const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); - return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; - }); - return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; -} -""" -STUB = ( - "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : " - "k === 'API_BASE_URL' ? '/api' : () => undefined });\n" -) - - -def _run(body: str, *args: object) -> object: - """`B` = Blocks 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답""" - script = ( - "import { register } from 'node:module';\n" - f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" - + STUB - + f"const B = await import({json.dumps(BLOCKS.as_uri())});\n" - "const A = JSON.parse(process.argv[1]);\n" - "const out = (x) => console.log(JSON.stringify(x));\n" - "const META = { key: 'GX900001', version: 'v1', isBase: false };\n" + body - ) - result = subprocess.run( - ["node", "--no-warnings", "--input-type=module", "-e", script, json.dumps(list(args))], - capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -# ── 견본 줄 여섯(정함 10) ────────────────────────────────────────────────── -H = {"원문번호": "", "출처": "자체", "끝수": None} -GF000208 = { - **H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [], - "호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"}, - "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}], - "덧줄": [], -} # fmt: skip -GF000219 = { - **H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡", - "입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35]}, {"이름": "돌", "고르기": ["깬잡석", "깬돌"]}, - {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "단위": "m"}, - {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}], - "중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}], - "호표": [ - {"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", - "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비"}, - {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", - "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비"}, - ], - "덧줄": [], -} # fmt: skip -GF000025 = { - **H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡", - "입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}], - "중간": [], - "호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", - "수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)", - "비목": "노무비"}], - "덧줄": [], -} # fmt: skip -GF000249 = { - **H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥", - "입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}], - "중간": [{"이름": "작업량", "식": "로직(GF000114)"}], - "호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton", - "단위": "시간", "수량": "1 / 작업량"}], - "덧줄": [], -} # fmt: skip -GF000133 = { - **H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간", - "입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f", "단위": ""}, {"이름": "E", "단위": ""}, {"이름": "Cm", "단위": "분"}], - "중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)", "출처": "건설품셈 공통 8-1-4 주①"}, - "끝수": "소수 3자리 사사오입(건설품셈 공통 8-1-4 주①)", -} # fmt: skip -GC000051 = { - **H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [], - "중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}], - "호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}], - "덧줄": [{"이름": "공구손료 및 경장비", - "식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100", - "비목": "경비", "출처": "건설품셈 공통 2-9-3"}], -} # fmt: skip -SIX = [GF000208, GF000219, GF000025, GF000249, GF000133, GC000051] - - -def _lb(row: dict) -> dict: - return _run("const lb = B.readLogic(A[0], META); out({ ...lb, doc: undefined });", row) - - -# ── 왕복 ─────────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("row", SIX, ids=[r["키"] for r in SIX]) -def test_왕복_같음_다_읽힘(row: dict) -> None: - got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row) - assert got == [row, []] - - -# ── 블록 내용 ─────────────────────────────────────────────────────────────── -def test_메쌓기_표조회_조건목록_할증() -> None: - lb = _lb(GF000219) - assert [(x["id"], x["table"], x["col"]) for x in lb["lookups"]] == [ - ("L1", "QF000422", "증가율"), - ("L2", "QF000421", "석공"), - ("L3", "QF000421", "보통인부"), - ] - assert lb["lookups"][1]["conds"] == [ - {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, - {"col": "돌", "from": {"k": "input", "name": "돌"}}, - {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, - ] - assert lb["lookups"][1]["at"] == [{"part": "수량", "index": 0, "start": 0, "end": 36}] - mid, stone, worker = lb["calcs"] - assert (mid["name"], mid["kind"], mid["at"]) == ( - "증가율", - "조건목록", - {"part": "중간", "index": 0}, - ) - assert mid["cond"]["blocks"][0]["shape"]["type"] == "if" - assert (stone["kind"], stone["parts"]) == ( - "할증", - [{"k": "lookup", "id": "L2"}, {"k": "calc", "name": "증가율"}], - ) - assert worker["parts"][0] == {"k": "lookup", "id": "L3"} - assert lb["result"] == {"kind": "금액", "unit": "원/㎡"} - assert [x["이름"] for x in lb["inputs"]] == ["뒷길이", "돌", "쌓기", "높이", "초과증가율"] - - -def test_떼붙임_다짐_덤프_안전통로() -> None: - turf = _lb(GF000025) - assert turf["lookups"][0]["conds"] == [ - {"col": "구분", "from": {"k": "fixed", "value": "비탈구배 해당 시 품 할증"}} - ] - c = turf["calcs"][0] - assert c["kind"] == "할증" and c["parts"][0] == {"k": "input", "name": "품"} - assert c["parts"][1]["k"] == "expr" and c["cond"]["blocks"][0]["shape"]["type"] == "if" - - tamp = _lb(GF000249) - work, qty = tamp["calcs"] - assert work["kind"] == "값" and work["parts"] == [ - {"k": "logic", "key": "GF000114", "field": None} - ] - assert (qty["kind"], qty["ops"]) == ("곱나눔", ["/"]) - assert qty["parts"] == [{"k": "fixed", "value": 1}, {"k": "calc", "name": "작업량"}] - - dump = _lb(GF000133) - assert dump["result"]["kind"] == "값" and dump["result"]["unit"] == "㎥/시간" - assert dump["result"]["calc"]["kind"] == "올림" and dump["calcs"] == [dump["result"]["calc"]] - - walk = _lb(GC000051) - rate = walk["calcs"][-1] - assert rate["at"] == {"part": "비율", "index": 0} and rate["ops"] == ["*", "/"] - assert rate["parts"] == [ - {"k": "cost", "name": "사람줄합"}, - {"k": "lookup", "id": "L2"}, - {"k": "fixed", "value": 100}, - ] - - fill = _lb(GF000208) - assert fill["lookups"] == [] and fill["calcs"][0]["parts"] == [{"k": "fixed", "value": 1}] - - -# ── 고치기 ───────────────────────────────────────────────────────────────── -def _ho(row: dict) -> list[str]: - return [h["수량"] for h in row["호표"]] - - -def test_조건_바꾸기_빼기_표_바꾸기() -> None: - got = _run( - "let lb = B.readLogic(A[0], META);\n" - "const a = B.writeLogic(B.setCond(lb, 'L2', '돌', { k: 'fixed', value: '깬돌' }));\n" - "const b = B.writeLogic(B.setCond(lb, 'L2', '쌓기', null));\n" - "const c = B.writeLogic(B.setCond(lb, 'L1', '높이', { k: 'fixed', value: 5 }));\n" - "const d = B.writeLogic(B.setLookupTable(lb, 'L3', 'QX000001'));\n" - "out([a, b, c, d]);", - GF000219, - ) - a, b, c, d = got - assert _ho(a) == [ - "찾기(QF000421, 뒷길이=뒷길이, 돌='깬돌', 쌓기=쌓기).석공 * (1 + 증가율 / 100)", - GF000219["호표"][1]["수량"], - ] - assert _ho(b)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)" - assert ( - c["중간"][0]["식"] - == "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=5).증가율, 초과증가율))" - ) - assert ( - _ho(d)[1] == "찾기(QX000001, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)" - ) - assert _ho(d)[0] == GF000219["호표"][0]["수량"] - - -def test_계산_바꾸기() -> None: - got = _run( - "const lb = B.readLogic(A[0], META);\n" - "const [mid, stone] = lb.calcs;\n" - "const r1 = B.writeLogic(B.setCalc(lb, stone, { parts: [stone.parts[0], { k: 'fixed', value: 10 }] }));\n" - "const cond = structuredClone(mid.cond); cond.blocks[0].shape.otherwise = { kind: 'num', value: 90 };\n" - "const r2 = B.writeLogic(B.setCalc(lb, mid, { cond }));\n" - "const r3 = B.writeLogic(B.setCalc(lb, stone, { kind: '값', parts: [{ k: 'fixed', value: 0.3 }] }));\n" - "const r4 = B.writeLogic(B.setCalc(lb, stone, { text: '0.25 * 2' }));\n" - "const after = B.readLogic(r1, META);\n" - "out([r1, r2, r3, r4, after.calcs[1].parts, after.lookups.map((x) => x.id)]);", - GF000219, - ) - r1, r2, r3, r4, parts, ids = got - assert _ho(r1)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 10 / 100)" - assert _ho(r1)[1] == GF000219["호표"][1]["수량"] - assert ( - r2["중간"][0]["식"] - == "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 90))" - ) - assert _ho(r3)[0] == "0.3" and _ho(r4)[0] == "0.25 * 2" - assert parts == [{"k": "lookup", "id": "L2"}, {"k": "fixed", "value": 10}] - assert ids == ["L1", "L2", "L3"] - - -def test_곱나눔_더하기_괄호() -> None: - got = _run( - "const lb = B.readLogic(A[0], META); const q = lb.calcs[1];\n" - "const r1 = B.writeLogic(B.setCalc(lb, q, { parts: [{ k: 'fixed', value: 2 }, q.parts[1]] }));\n" - "const r2 = B.writeLogic(B.setCalc(lb, q, { parts: [q.parts[0], { k: 'expr', text: '작업량 + 1' }] }));\n" - "const r3 = B.writeLogic(B.setCalc(lb, q, { kind: '더하기', ops: ['-'], parts: [{ k: 'fixed', value: 3 }," - " { k: 'expr', text: '작업량 - 1' }] }));\n" - "out([r1.호표[0].수량, r2.호표[0].수량, r3.호표[0].수량, r1.중간]);", - GF000249, - ) - assert got == ["2 / 작업량", "1 / (작업량 + 1)", "3 - (작업량 - 1)", GF000249["중간"]] - - -# ── 빈 로직 · 못 읽는 꼴 ──────────────────────────────────────────────────── -def test_빈_로직() -> None: - got = _run( - "const lb = B.newLogic('새 일', 'm'); out([B.writeLogic(lb), lb.lookups, lb.calcs, lb.result, lb.blocked]);" - ) - row, looks, calcs, result, blocked = got - assert row["이름"] == "새 일" and row["결과단위"] == "원/m" and row["출처"] == "자체" - assert (looks, calcs, result, blocked) == ([], [], {"kind": "금액", "unit": "원/m"}, []) - - -def test_못_읽는_꼴은_쉬운말_왕복은_같음() -> None: - row = { - **GF000219, - "중간": [ - { - "이름": "증가율", - "식": "만약(높이 > 3 그리고 높이 <= 7.5, 찾기(QF000422, 높이=최소(높이, 7.5)).증가율, 0)", - } - ], - } - got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row) - assert got[0] == row - assert got[1] == [ - "증가율 — 조건에 「그리고 · 또는」이 섞임", - "증가율 — 표 조회 조건에 계산이 들어 있음", - ] - - -def test_화면_글에_금지_낱말_없음() -> None: - src = BLOCKS.read_text(encoding="utf-8") - block = src[src.index("const TEXT = {") : src.index("} as const;")] - korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] - assert len(korean) >= 7 - for text in korean: - for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체", "잠금"): - assert bad not in text, (bad, text) diff --git a/resources/tester/test_m01_lb_calc.py b/resources/tester/test_m01_lb_calc.py deleted file mode 100644 index 120101ae8..000000000 --- a/resources/tester/test_m01_lb_calc.py +++ /dev/null @@ -1,181 +0,0 @@ -"""로직 만들기 판 시험 길(`M01_MasterData/LogicBuilder/M01_LB_Calc.py`) 시험 — PLAN 35-2. - -· 고친 표 사본으로 셈하면 값이 바뀌고 · 전역 캐시 · 파일은 그대로 -· 표 없이 부르면 `/calc` 와 같은 값 · 없는 표는 404 -· 사용처 — 표 「용도.로직키」 + 로직 글 대조 -· 권한 — 시스템 관리자 아니면 403 -견본 표 · 로직 줄은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 마스터 값에 안 기댐). -""" - -from __future__ import annotations - -import copy -import json -import shutil -from decimal import Decimal -from pathlib import Path - -import pytest -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - -from common_util.common_util_auth import require_system_admin, verify_session -from M01_MasterData import M01_MasterData_Router as router_module -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData.LogicBuilder import M01_LB_Calc as lb - -REAL = store.FOLDER -OWN = "로직_자체.json" -SAMPLE = "QF999999" # 견본 표 — 사본 폴더에만 있음 -TABLE = { - "키": SAMPLE, - "원문번호": "", - "구분": "", - "상세구분": "", - "이름": "견본 쌓기 표", - "기준": "1㎡", - "출처": "자체", - "용도": {"공종": "견본", "대상": ["인력"], "로직키": []}, - "조건": {"돌": "고르기"}, - "값칸": {"석공": "인"}, - "줄": [{"돌": "깬돌", "석공": 0.5}, {"돌": "견치돌", "석공": 0.7}], - "주": [], -} - - -def _labor() -> tuple[str, Decimal]: - rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] - row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float))) - return row["키"], Decimal(str(row["값"])) - - -def _row(labor: str) -> dict: - """견본 로직 — 입력 「돌」 로 견본 표에서 석공 품을 찾아 노임에 곱함.""" - return { - "키": "", - "원문번호": "", - "이름": "견본 쌓기", - "결과단위": "원/㎡", - "출처": "자체", - "입력": [{"이름": "돌", "고르기": ["깬돌", "견치돌"]}], - "중간": [], - "호표": [ - { - "종류": "인력", - "요소": labor, - "이름": "석공", - "단위": "인", - "수량": f"찾기({SAMPLE}, 돌=돌).석공", - "비목": "노무비", - } - ], - "덧줄": [], - "끝수": None, - } - - -@pytest.fixture -def folder(tmp_path: Path, monkeypatch) -> Path: - for path in REAL.glob("*.json"): - if path.name == OWN: - continue - if not path.name.startswith("_") or path.name == store.mk.BOOK.name: - shutil.copy(path, tmp_path / path.name) - host = sorted(tmp_path.glob("소요량_*.json"))[0] # 견본 표를 사본 소요량 파일 하나에 더함 - data = json.loads(host.read_text(encoding="utf-8")) - data["표"].append(TABLE) - host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - monkeypatch.setattr(store, "FOLDER", tmp_path) - return tmp_path - - -@pytest.fixture -def client(folder: Path) -> TestClient: - app = FastAPI() - app.include_router(router_module.router) - app.include_router(lb.router) - return TestClient(app) - - -def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: - res = client.post(url, json=body) - assert res.status_code == want, res.text - return res.json() - - -def test_고친_표로_값이_바뀌고_캐시와_파일은_그대로(client: TestClient, folder: Path) -> None: - labor, price = _labor() - row = _row(labor) - before = {p.name: p.read_bytes() for p in folder.glob("*.json")} - - plain = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}}) - assert plain["ok"] is True - assert Decimal(str(plain["sums"]["계"])) == Decimal("0.5") * price - - edited = copy.deepcopy(TABLE) - edited["줄"][0]["석공"] = 0.8 # 깬돌 줄 품 0.5 → 0.8 - body = {"row": row, "inputs": {"돌": "깬돌"}, "tables": {SAMPLE: edited}} - changed = _post(client, "/api/m01/lb/calc", body) - assert Decimal(str(changed["sums"]["계"])) == Decimal("0.8") * price - assert changed["lines"][0]["수량"] == 0.8 - - # 전역 캐시 · 파일 · 같은 요청 뒤 표 없는 셈은 그대로 - files = store.loaded()[0].values() - cached = next( - t for d in files if d.get("그룹") == "소요량" for t in d["표"] if t["키"] == SAMPLE - ) - assert cached["줄"][0]["석공"] == Decimal("0.5") - assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before - again = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}}) - assert again == plain - - # 키는 못 바꿈 — 사본 표의 「키」 가 달라도 그 자리 키로 셈 - _post(client, "/api/m01/lb/calc", {**body, "tables": {SAMPLE: {**edited, "키": "QF000001"}}}) - - -def test_표_없이_부르면_calc_와_같고_없는_표는_404(client: TestClient) -> None: - labor, _ = _labor() - row = _row(labor) - for stone in ("깬돌", "견치돌"): - mine = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": stone}}) - theirs = _post(client, "/api/m01/calc", {"key": "", "inputs": {"돌": stone}, "row": row}) - assert mine == theirs - - stop = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "호박돌"}}) - assert stop["ok"] is False and stop == _post( - client, "/api/m01/calc", {"key": "", "inputs": {"돌": "호박돌"}, "row": row} - ) - _post(client, "/api/m01/lb/calc", {"row": row, "tables": {"QF999998": TABLE}}, want=404) - - -def test_사용처_용도와_로직_글(client: TestClient, folder: Path) -> None: - labor, _ = _labor() - made = _post(client, "/api/m01/logic/new", {"logic": _row(labor)}) - first = next( - r["키"] for p in sorted(folder.glob("로직_*.json")) if p.name != OWN - for r in json.loads(p.read_text(encoding="utf-8"))["줄"] - ) # fmt: skip - host = sorted(folder.glob("소요량_*.json"))[0] - data = json.loads(host.read_text(encoding="utf-8")) - next(t for t in data["표"] if t["키"] == SAMPLE)["용도"]["로직키"] = [first, made["key"]] - host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - store.cache.stale([host.name]) - - res = client.get("/api/m01/lb/table-users", params={"key": SAMPLE}) - assert res.status_code == 200, res.text - users = {u["key"]: u["via"] for u in res.json()["users"]} - assert users == {first: "용도", made["key"]: "둘"} - assert client.get("/api/m01/lb/table-users", params={"key": "QF999998"}).status_code == 404 - - -def test_시스템_관리자가_아니면_403(folder: Path) -> None: - app = FastAPI() - app.include_router( - lb.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] - ) - app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} - client = TestClient(app) - assert client.post("/api/m01/lb/calc", json={"row": {}}).status_code == 403 - assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 403 - app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} - assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 200 diff --git a/resources/tester/test_m01_lb_check.py b/resources/tester/test_m01_lb_check.py deleted file mode 100644 index a8fbeb80d..000000000 --- a/resources/tester/test_m01_lb_check.py +++ /dev/null @@ -1,382 +0,0 @@ -"""로직 만들기 판 — 검사(`M01_LB_Check.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_Check.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const stub = new Proxy({}, { get: () => () => undefined }); -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => stub); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -Promise.all( - jobs.map(async (job) => { - if (job.op === "gaps") return m.gaps(job.lb, job.tables); - if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables); - if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values); - if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? ""); - if (job.op === "same") return m.sameAsExpect(job.got, job.expect); - if (job.op === "expect") { - const answers = job.answers; - let i = 0; - return m.runExpect({}, {}, job.rows, async () => answers[i++]); - } - return null; - }), -).then((out) => process.stdout.write(JSON.stringify(out))); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관 -STONE = { - "키": "T1", - "조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, - "값칸": {"석공": "인"}, - "줄": [ - {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15}, - {"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13}, - {"뒷길이": 25, "돌": "호박돌", "석공": 0.10}, - {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22}, - {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복 - ], -} - -# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함) -HEIGHT = { - "키": "T2", - "범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함", - "조건": {"높이": "범위"}, - "값칸": {"증가율": "%"}, - "줄": [ - {"높이": [None, 2], "증가율": 0}, - {"높이": [2, 4], "증가율": 30}, - {"높이": [4, 6], "증가율": 40}, - ], -} - -# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄) -UPTO = { - "키": "T3", - "범위규칙": "이하", - "조건": {"거리": "범위"}, - "값칸": {"값": ""}, - "줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}], -} - -TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO} - - -def lk(id_, table, conds, col): - return {"id": id_, "table": table, "conds": conds, "col": col} - - -LOGIC = { - "inputs": [ - {"이름": "뒷길이", "고르기": [25, 30]}, - {"이름": "돌", "고르기": ["깬잡석", "호박돌"]}, - {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, - {"이름": "높이", "고르기": [1, 3, 6, 7]}, - {"이름": "길이"}, # 숫자 입력 — 선택 목록 없음 - ], - "lookups": [ - lk( - "a", - "T1", - [ - {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, - {"col": "돌", "from": {"k": "input", "name": "돌"}}, - {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, - ], - "석공", - ), - lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"), - lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"), - ], -} - - -def by(gaps, lookup): - return sorted( - ( - (g["kind"], json.dumps(g["conds"], ensure_ascii=False)) - for g in gaps - if g["lookup"] == lookup - ) - ) - - -def test_빈틈_중복_무관_빈칸(): - [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) - got = by(gaps, "a") - # 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합 - # 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님) - # 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜 - # 중복 — 30 · 깬잡석 · 골쌓기(두 줄) - assert got == sorted( - [ - ( - "없음", - json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False), - ), - ( - "없음", - json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False), - ), - ( - "없음", - json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False), - ), - ( - "중복", - json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False), - ), - ] - ) - dup = next(g for g in gaps if g["kind"] == "중복") - assert dup["rows"] == [3, 4] - - -def test_범위_줄_이상미만_마지막칸_이하포함(): - [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) - # 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음 - assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))] - out = run( - [ - {"op": "find", "table": HEIGHT, "conds": {"높이": 2}}, - {"op": "find", "table": HEIGHT, "conds": {"높이": 4}}, - {"op": "find", "table": HEIGHT, "conds": {"높이": 6}}, - ] - ) - assert out == [[1], [2], [2]] - - -def test_범위_이하_위끝_가장_작은_줄(): - out = run( - [ - {"op": "find", "table": UPTO, "conds": {"거리": 5}}, - {"op": "find", "table": UPTO, "conds": {"거리": 15}}, - {"op": "find", "table": UPTO, "conds": {"거리": 25}}, - ] - ) - assert out == [[0], [1], []] - - -def test_셀_수_없는_조회는_건너뜀(): - gaps, skipped = run( - [ - {"op": "gaps", "lb": LOGIC, "tables": TABLES}, - {"op": "skipped", "lb": LOGIC, "tables": TABLES}, - ] - ) - assert by(gaps, "c") == [] - assert skipped == ["c"] - - -def test_예시값으로_걸린_줄(): - [hits] = run( - [ - { - "op": "hits", - "lb": LOGIC, - "tables": TABLES, - "values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15}, - } - ] - ) - assert hits == {"a": "중복", "b": 1, "c": 1} - [hits] = run( - [ - { - "op": "hits", - "lb": LOGIC, - "tables": TABLES, - "values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, - } - ] - ) - assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름 - - -def test_고정값_조건(): - lb = { - "inputs": [], - "lookups": [ - lk( - "f", - "T1", - [ - {"col": "돌", "from": {"k": "fixed", "value": "호박돌"}}, - {"col": "뒷길이", "from": {"k": "fixed", "value": 25}}, - ], - "석공", - ), - lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"), - ], - } - gaps, hits = run( - [ - {"op": "gaps", "lb": lb, "tables": TABLES}, - {"op": "hits", "lb": lb, "tables": TABLES, "values": {}}, - ] - ) - assert gaps == [] - assert hits == {"f": 2, "g": None} - - -def test_기대값_시험표(): - answers = [ - { - "ok": True, - "lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}], - "sums": {"계": 52437.06}, - "middle": {}, - }, - { - "ok": True, - "lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}], - "sums": {"계": 50000}, - "middle": {}, - }, - {"ok": False, "reason": "표에 줄이 없음"}, - ] - rows = [ - {"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}}, - {"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}}, - {"inputs": {"뒷길이": 99}, "expect": {"계": 1}}, - ] - [res] = run([{"op": "expect", "rows": rows, "answers": answers}]) - assert [r["ok"] for r in res] == [True, False, False] - assert res[0]["got"]["석공 금액"] == 52437.06 - assert res[2]["why"] == "표에 줄이 없음" - same = run( - [ - {"op": "same", "got": 52437.4, "expect": 52437}, - {"op": "same", "got": 52437.6, "expect": 52437}, - {"op": "same", "got": 0.264, "expect": 0.26}, - ] - ) - assert same == [True, False, True] - - -SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts" - -RUNNER_UI = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const stub = new Proxy({}, { get: () => () => undefined }); -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => stub); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => { - if (job.op === "parse") return m.parseCell(job.text, job.before); - if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]); - if (job.op === "role") return m.roleOf(job.lookups, job.col); - return null; -}); -process.stdout.write(JSON.stringify(out)); -""" - - -def run_ui(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)], - 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_ui( - [ - {"op": "parse", "text": "0.3", "before": 0.26}, - {"op": "parse", "text": "1,200", "before": 1000}, - {"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음 - {"op": "parse", "text": "3~4", "before": [2, 3]}, - {"op": "parse", "text": "~5", "before": [None, 4]}, - {"op": "parse", "text": "7~", "before": [6, None]}, - {"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음 - {"op": "parse", "text": "깬돌", "before": "깬잡석"}, - {"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로 - {"op": "parse", "text": "", "before": 1}, - ] - ) - assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None] - - -def test_계산에_쓰인_주와_칸_역할(): - [notes] = run_ui( - [ - { - "op": "notes", - "row": { - "중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}], - "비고": "주① 참고", - }, - } - ] - ) - assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]] - lookups = [ - { - "id": "a", - "table": "T1", - "col": "석공", - "conds": [ - {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, - {"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}}, - ], - } - ] - out = run_ui( - [ - {"op": "role", "lookups": lookups, "col": "석공"}, - {"op": "role", "lookups": lookups, "col": "뒷길이"}, - {"op": "role", "lookups": lookups, "col": "돌"}, - {"op": "role", "lookups": lookups, "col": "쌓기"}, - ] - ) - assert out == ["결과", "조건", "고정", None] diff --git a/resources/tester/test_m01_lb_wizard_build.py b/resources/tester/test_m01_lb_wizard_build.py deleted file mode 100644 index 210a11172..000000000 --- a/resources/tester/test_m01_lb_wizard_build.py +++ /dev/null @@ -1,193 +0,0 @@ -"""로직 만들기 판 단계 안내 — 답 → 로직 줄 짜기(`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, "돌": "깬돌"} diff --git a/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py deleted file mode 100644 index 6d62f9a11..000000000 --- a/resources/tester/test_m01_sl_import.py +++ /dev/null @@ -1,333 +0,0 @@ -"""M01 시트 로직 가져오기 시험(35-5 · 정함 9) — Node 로 `M01_SL_Import.ts` 를 바로 돌림. - -· 견본 로직 줄 여섯(정함 9 시험 로직)은 이 시험 안에 둠 — 품셈 표 · 노임 · 자재 · 기존 로직 값은 마스터에서 읽기만 -· 가져온 시트 로직 → 바깥 값 넣기(파이썬 엔진이 마스터에서) → 우리 스프레드시트 엔진(Node 번들)으로 셈 - → 합계 · 비목 = 지금 `/calc`(`M01_LB_Calc.calc` = `/calc` 와 같은 길) · 끝수 차이는 원 미만 -· 마스터 로직 1,469 개(자체 빼고) 모두 가져와 다 바뀌는 수를 세고 · 바뀐 통합문서를 엔진이 다 풂(#NAME? 없음) -· 파일 안 씀 — 마스터는 읽기만 · 넘김은 표준입력 -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.ts" -PARSE = IMPORT.with_name("M01_SL_Import_Parse.ts") - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ──────────── -HOOKS = r""" -import { readFileSync, existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { stripTypeScriptTypes } from "node:module"; -const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.ts)?$/; -export async function resolve(spec, ctx, next) { - if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { - const u = new URL(spec + ".ts", ctx.parentURL); - if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true }; - } - return next(spec, ctx); -} -export async function load(url, ctx, next) { - if (!url.endsWith(".ts")) return next(url, ctx); - let src = readFileSync(fileURLToPath(url), "utf8"); - src = src.replace(/import\s+"[^"]+\.css";/g, ""); - src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})"); - src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => { - if (from.startsWith(".") && ALLOW.test(from)) return m; - const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); - return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; - }); - return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; -} -""" -STUB = ( - "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : " - "() => undefined });\n" -) - - -def _plain(x: object) -> object: - return float(x) if isinstance(x, Decimal) else str(x) - - -def _node(body: str, payload: object) -> object: - """`I` = Import 모듈 · `A` = 넘긴 것(표준입력) · `out(x)` 로 답""" - script = ( - "import { register } from 'node:module';\n" - "import { readFileSync } from 'node:fs';\n" - f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" - + STUB - + f"const I = {{ ...(await import({json.dumps(IMPORT.as_uri())}))," - f" ...(await import({json.dumps(PARSE.as_uri())})) }};\n" - "const A = JSON.parse(readFileSync(0, 'utf8'));\n" - "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body - ) - result = subprocess.run( - ["node", "--no-warnings", "--input-type=module", "-e", script], - input=json.dumps(payload, ensure_ascii=False, default=_plain), - capture_output=True, text=True, encoding="utf-8", timeout=600, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr[-3000:] - return json.loads(result.stdout) - - -# ── 견본 줄 여섯(정함 9) + 넣을 값 ──────────────────────────────────────────── -H = {"원문번호": "", "출처": "자체", "소유": "공용", "끝수": None} -SAMPLES = [ - ( - {**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [], - "호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"}, - "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}], - "덧줄": []}, - {}, - ), - ( - {**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡", - "입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75]}, - {"이름": "돌", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]}, - {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, - {"이름": "높이", "단위": "m"}, {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}], - "중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}], - "호표": [{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "비목": "노무비", - "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"}, - {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비", - "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"}], - "덧줄": []}, - {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "높이": 4.5, "초과증가율": 80}, - ), - ( - {**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡", - "입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}], - "중간": [], - "호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비", - "수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)"}], - "덧줄": []}, - {"품": 2.5, "비탈구배": "평균 비탈구배 20˚~45˚ 이하"}, - ), - ( - {**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥", - "입력": [{"이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기"]}], - "중간": [{"이름": "작업량", "식": "로직(GF000114)"}], - "호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton", - "단위": "시간", "수량": "1 / 작업량"}], - "덧줄": []}, - {"지역": "서울"}, - ), - ( - {**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간", - "입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f"}, {"이름": "E"}, {"이름": "Cm", "단위": "분"}], - "중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)"}}, - {"qt": 6, "f": 1.2, "E": 0.9, "Cm": 25}, - ), - ( - {**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [], - "중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}], - "호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}], - "덧줄": [{"이름": "공구손료 및 경장비", "비목": "경비", - "식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100"}]}, - {}, - ), -] # fmt: skip - - -# ── 마스터(읽기만) ───────────────────────────────────────────────────────── -@pytest.fixture(scope="module") -def master(): - from M01_MasterData import M01_MasterData_Store as store - - cm = store.cm - files = cm.load(folder=store.FOLDER) - tables = { - str(t["키"]): t - for data in files.values() - if data.get("그룹") in cm.mf.TABLE_GROUPS - for t in data.get("표") or [] - } - return cm, files, tables - - -def _tables_of(row: dict, tables: dict) -> dict: - keys = set(re.findall(r"Q[A-Z]\d{6}", json.dumps(row, ensure_ascii=False))) - return {k: tables[k] for k in keys if k in tables} - - -def _names_of(row: dict, master) -> dict: - """다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)""" - cm, files, _ = master - m = cm.mf.Master(files) - keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False))) - return {k: str(m.get(k).get("이름") or k) for k in keys} - - -def _num(v) -> float: - """빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan""" - if v is None: - return 0.0 - return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan") - - -def _fill(sl: dict, places: list, py_lines: list, master) -> None: - """바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`""" - from M01_MasterData.LogicBuilder import M01_LB_Calc as lb - - cm, files, _ = master - m = cm.mf.Master(files) - nodes = {n["id"]: n for n in sl["nodes"]} - for at in places: - item = at["item"] - kind = item["kind"] - if kind == "표": - continue - if kind == "노임": - value = cm.mf.element(m, item["key"], {})[0] - elif kind == "자재": - name = nodes[item["id"].split(":")[0]]["name"] - value = next(x["단가"] for x in py_lines if x["이름"] == name) - elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목 - line = { - "종류": "로직", - "요소": item["element"], - "이름": "장비", - "단위": "시간", - "수량": "1", - } - inputs = item["inputs"] - one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "", - "입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip - value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0) - else: - got = cm.mf.run(m, item["key"], {k: Decimal(str(v)) if isinstance(v, (int, float)) else v - for k, v in item["inputs"].items()}) # fmt: skip - value = got["결과"] if "결과" in got else got[item.get("field") or "계"] - if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0) - continue - sheet = next(s for s in sl["workbook"]["시트"] if s["id"] == at["sheet"]) - sheet["칸"][at["cell"]] = {"값": float(value)} - - -def _result_cells(sl: dict) -> dict: - node = next(n for n in sl["nodes"] if n["kind"] == "result") - # 결과 틀 — 노무비 · 재료비 · 경비 = 입력 알약 · 계(또는 결과) = 출력 알약 - return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsIn"] + node["pillsOut"]} - - -@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES]) -def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, master) -> None: - from common_util.common_util_spreadsheet import recalc_workbooks - from M01_MasterData.LogicBuilder import M01_LB_Calc as lb - - rep = _node( - "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given, names: A.names });" - "out({ ...r, places: I.outsideOf(r.sl) });", - { - "row": row, - "tables": _tables_of(row, master[2]), - "given": given, - "names": _names_of(row, master), - }, - ) - assert rep["problems"] == [] - sl = rep["sl"] - # 모양 — 상자 = 시트 · 선은 있는 상자 · 알약끼리 - sheets = {s["id"] for s in sl["workbook"]["시트"]} - assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"]) - ids = {n["id"]: n for n in sl["nodes"]} - for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴) - if n["kind"] == "table": - assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"] - # 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액) - assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])] - for n in sl["nodes"]: - if n["kind"] == "item" and n.get("ref", "").startswith("{"): - ref = json.loads(n["ref"]) - assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율") - pills = {p["name"]: p["cell"] for p in n["pillsOut"]} - assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6") - for e in sl["edges"]: - assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"]) - assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"]) - - want = lb.calc(row, given, {}) - assert want["ok"], want - # 1차 셈 → 넘길 값(장비 · 로직 인자) 읽기 → 바깥 값 넣기 → 2차 셈 - first = recalc_workbooks([sl["workbook"]])[0] - places = _node("out(I.outsideOf(A.sl, A.calc));", {"sl": sl, "calc": first}) - _fill(sl, places, want.get("lines", []), master) - got = recalc_workbooks([sl["workbook"]])[0] - cells = _result_cells(sl) - read = {k: _num(got[s].get(c)) for k, (s, c) in cells.items()} - print(f"\n{row['키']} 시트 {read} · /calc {want.get('result', want.get('sums'))}") - if "result" in want: - assert abs(read["결과"] - float(want["result"])) < 1e-9, (read, want["result"]) - return - for k in ("노무비", "재료비", "경비", "계"): - diff = abs(read[k] - float(want["sums"][k])) - assert diff < 1, f"{row['키']} {k}: 시트 {read[k]} · /calc {want['sums'][k]}" - - -def test_마스터_1469_가져오기_세기(master) -> None: - from common_util.common_util_spreadsheet import recalc_workbooks - - cm, files, tables = master - rows = [ - r - for name, data in files.items() - if data.get("그룹") == "로직" and name != "로직_자체.json" - for r in data.get("줄") or [] - ] - assert len(rows) == 1469 - got = _node( - "const res = A.rows.map((row) => { try { const r = I.importLogicReport(row, { tables: A.tables });" - "return { key: row.키, problems: r.problems, book: r.problems.length ? null : r.sl.workbook }; }" - "catch (e) { return { key: row.키, problems: ['던짐 ' + e.message], book: null }; } });" - "out(res);", - {"rows": rows, "tables": tables}, - ) - thrown = [g["key"] for g in got if any(p.startswith("던짐") for p in g["problems"])] - assert thrown == [] - full = [g for g in got if not g["problems"]] - # 다 바뀐 것은 엔진이 다 풂 — 모르는 함수 · 이름(#NAME?) 없음 - values = recalc_workbooks([g["book"] for g in full]) - named = [ - g["key"] for g, v in zip(full, values) if "#NAME?" in json.dumps(v, ensure_ascii=False) - ] - assert named == [] - reasons: dict[str, int] = {} - for g in got: - for p in g["problems"]: - why = re.sub(r"「[^」]*」", "「…」", p.split(" — ", 1)[-1]) - reasons[why] = reasons.get(why, 0) + 1 - print( - f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}" - ) - assert len(full) > 1000 - - -def test_표_블록_칸_차례_정함() -> None: - """조건 칸 → 값 칸 · 범위(조건 「범위」 · 값 칸에 범위가 한 줄이라도)는 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸에 같은 수""" - table = { - "조건": {"높이": "범위", "구분": "고르기"}, - "값칸": {"증가율": "%", "단위": "글"}, - "줄": [ - {"높이": [3, 4], "구분": "가", "증가율": 30, "단위": "%"}, - {"높이": 5, "구분": "나", "증가율": [80, 100]}, - {"높이": [7.5, None], "증가율": 50}, - ], - } - rows = _node("out(I.tableRows(A));", table) - assert rows == [ - ["높이·아래", "높이·위", "구분", "증가율·아래", "증가율·위", "단위"], - [3, 4, "가", 30, 30, "%"], - [5, 5, "나", 80, 100, None], - [7.5, None, None, 50, 50, None], - ] diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py deleted file mode 100644 index 81b68cf4c..000000000 --- a/resources/tester/test_m01_sl_model.py +++ /dev/null @@ -1,413 +0,0 @@ -"""M01 시트 로직 Model · Eval 시험(35-5) — Node 로 `M01_SL_Model.ts` · `M01_SL_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. - -· 견본 시트 로직 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 -· 입력값 → 계산 → 항목 → 결과 · 선 잇기 / 끊기 → 값 바뀜 · 순환 막음 · 바깥 값 넣기 · 알약 · 상자 지우기 · 검사 -· 엔진 · Model 은 TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길) -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SL = ROOT / "M01_MasterData" / "SheetLogic" - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -LOADER = r""" -const fs = require("fs"); -const path = require("path"); -const ROOT = process.argv[1]; -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full).exports; - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, - }).outputText; - const mod = { exports: {} }; - cache.set(full, mod); - const req = (r) => { - if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; - if (r.endsWith(".css")) return {}; - if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); - if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`)); - return require(r); - }; - new Function("exports", "module", "require", js)(mod.exports, mod, req); - return mod.exports; -} -const M = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Model.ts")); -const E = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Eval.ts")); -const out = (x) => console.log(JSON.stringify(x)); -const sheet = (sl, id) => M.nodeSheet(sl, id); -const link = (sl, a, ap, b, bp) => { - const r = M.connect(sl, { node: a.id, pill: ap }, { node: b.id, pill: bp }); - if (r.why) throw new Error(r.why); - return r.sl; -}; -""" - - -def _run(body: str) -> object: - result = subprocess.run( - ["node", "-e", LOADER + body, str(ROOT)], - capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout.strip().splitlines()[-1]) - - -# 견본 하나 — 품(입력값) → 보통 인부(항목 · 노무) → 결과 -ONE = r""" -let sl = M.newLogic("시험 하나", "원/㎡"); -let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; -r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002"; -r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; -sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]); -sl = link(sl, pum, "품", man, "수량"); -sl = link(sl, man, "노무비", res, "노무비"); -""" - -# 견본 둘 — 품 · 할증률(고정값) → 계산(곱) → 석공 · 보통인부 두 항목 → 결과 노무비(두 선을 더함) -TWO = r""" -let sl = M.newLogic("시험 둘"); -let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; -r = M.addNode(sl, "const", "할증률"); sl = r.sl; const rate = r.node; -r = M.addNode(sl, "calc", "할증 품"); sl = r.sl; const calc = r.node; -r = M.addNode(sl, "item", "석공"); sl = r.sl; const stone = r.node; -r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node; -r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; -sl = E.applyOutside(sl, [ - { node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 }, - { node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 }, -]); -sl = link(sl, pum, "품", calc, "값1"); -sl = link(sl, rate, "할증률", calc, "값2"); -sl = link(sl, calc, "결과", stone, "수량"); -sl = link(sl, pum, "품", man, "수량"); -sl = link(sl, stone, "노무비", res, "노무비"); -sl = link(sl, man, "노무비", res, "노무비"); -""" - - -def test_입력값_항목_결과_수식과_값() -> None: - got = _run( - ONE - + "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," - " sl.workbook.시트.map(s => s.이름)]);" - ) - qty, total_in, ev, edges, kinds, names = got - assert qty == {"식": "품!B2"} - assert total_in == {"식": "'보통 인부'!B7"} - assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000} - assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5 - assert ev["errors"] == [] - assert edges == [ - {"id": "e1", "from": {"node": "n1", "pill": "품"}, "to": {"node": "n2", "pill": "수량"}}, - { - "id": "e2", - "from": {"node": "n2", "pill": "노무비"}, - "to": {"node": "n3", "pill": "노무비"}, - }, - ] - assert kinds == [["input", 0], ["item", 3], ["result", 4]] - assert names == ["품", "보통 인부", "결과"] - - -def test_계산_두_항목_더함_값바뀜() -> None: - got = _run( - TWO + "const a = E.evalLogic(sl);\n" - "const sl2 = E.applyOutside(sl, [{ node: rate.id, cell: 'B2', value: 1.5 }]);\n" - "out([a.result, a.pills[calc.id], sheet(sl, res.id).칸.B2, E.evalLogic(sl2).result]);" - ) - a, c, cell, b = got - # 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000 - assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000} - assert c == {"결과": 0.26} - assert cell == {"식": "석공!B7+보통인부!B7"} - assert b["계"] == 0.2 * 1.5 * 200000 + 20000 - - -def test_선_끊기_다시_잇기() -> None: - got = _run( - TWO + "const cut = sl.edges.find(e => e.from.node === stone.id).id;\n" - "const a = M.disconnect(sl, cut);\n" - "const b = M.disconnect(M.disconnect(sl, cut), sl.edges.find(e => e.from.node === man.id && e.to.node === res.id).id);\n" - "const c = link(a, stone, '노무비', res, '경비');\n" - "out([sheet(a, res.id).칸.B2, E.evalLogic(a).result, sheet(b, res.id).칸.B2 ?? null, E.checkLogic(b)," - " E.evalLogic(c).result, a.edges.length]);" - ) - cell_a, ra, cell_b, check_b, rc, n_edges = got - assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000 - assert cell_b is None - assert "결과 상자에 이어진 선이 없음" in check_b - assert not any("입력 알약" in x for x in check_b) - assert rc == {"노무비": 20000, "재료비": None, "경비": 52000, "계": 72000} - assert n_edges == 5 - - -def test_순환_같은상자_두번_없는알약_막음() -> None: - got = _run( - TWO - + "let r2 = M.addNode(sl, 'calc', '다른 계산'); const sl3 = r2.sl; const other = r2.node;\n" - "const s4 = link(sl3, calc, '결과', other, '값1');\n" - "const tries = [\n" - " M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).why,\n" - " M.connect(s4, { node: calc.id, pill: '결과' }, { node: calc.id, pill: '값2' }).why,\n" - " M.connect(s4, { node: calc.id, pill: '결과' }, { node: other.id, pill: '값1' }).why,\n" - " M.connect(s4, { node: calc.id, pill: '없음' }, { node: other.id, pill: '값2' }).why,\n" - " M.connect(s4, { node: res.id, pill: '계' }, { node: pum.id, pill: '품' }).why,\n" - "];\n" - "const same = M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).sl === s4;\n" - "out([tries, same]);" - ) - tries, same = got - assert tries == [ - "이으면 돌고 도는 선이 됨", - "같은 상자끼리는 못 이음", - "이미 이어져 있음", - "알약이 없음", - "알약이 없음", - ] - assert same is True - - -def test_엔진_순환은_오류로_알림() -> None: - got = _run(TWO + "sheet(sl, calc.id).칸.B3 = { 식: 'B4' };\nout(E.evalLogic(sl).errors);") - assert got and all("돌고 도는 참조" in e for e in got) - assert got[0].startswith("「할증 품」 ") - - -def test_바깥값_칸목록과_표_덩어리() -> None: - got = _run( - ONE - + "r = M.addNode(sl, 'table', '메쌓기 표'); sl = r.sl; const tb = r.node; tb.ref = 'QF000421';\n" - "sl.nodes.find(n => n.id === tb.id).ref = 'QF000421';\n" - "sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n" - "r = M.addNode(sl, 'logic', '작업량'); sl = r.sl; const lg = r.node;\n" - "sl.nodes.find(n => n.id === lg.id).ref = 'GF000114';\n" - "const cells = E.outsideCells(sl);\n" - "sl = E.applyOutside(sl, [{ node: tb.id, cell: 'E1', value: [['뒷길이', '석공'], [25, 0.15], [30, null]] }," - " { node: lg.id, cell: 'B2', value: 12.5 }]);\n" - "const s = sheet(sl, tb.id).칸;\n" - "out([cells, s.E1, s.F2, s.E3, s.F3 ?? null, E.evalLogic(sl).pills[lg.id], E.checkLogic(sl)]);" - ) - cells, e1, f2, e3, f3, lg, check = got - assert cells == [ - {"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"}, - { - "id": "n4!E1", - "node": "n4", - "cell": "E1", - "kind": "table", - "ref": "QF000421", - "source": {"kind": "표", "table": "QF000421"}, - }, - { - "id": "n5!B2", - "node": "n5", - "cell": "B2", - "kind": "logic", - "ref": "GF000114", - "source": {"kind": "로직", "key": "GF000114", "inputs": {}}, - }, - ] - assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None) - assert lg == {"결과": 12.5} - assert check == [] - - -def test_알약_정하기_이름바꾸면_선이_따라감() -> None: - got = _run( - ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n" - "const e = s2.edges[0].from;\n" - "s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n" - "const moved = sheet(s2, man.id).칸.B1;\n" - "const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n" - "const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n" - "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" - ) - e, moved, n_edges, cleared, ins = got - assert e == {"node": "n1", "pill": "품셈 품"} - assert moved == {"식": "품!D2"} - assert n_edges == 1 and cleared is None - assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]] - - -def test_상자_지우기() -> None: - got = _run( - TWO + "const s2 = M.removeNode(sl, stone.id);\n" - "out([s2.nodes.map(n => n.name), s2.workbook.시트.map(s => s.이름), s2.edges.length," - " sheet(s2, res.id).칸.B2, E.evalLogic(s2).result.계, sl.nodes.length]);" - ) - names, sheets, n_edges, cell, total, before = got - assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names - assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000 - assert before == 6 # 받은 것은 안 바뀜 - - -def test_같은_이름_상자와_새_번호() -> None: - got = _run( - "let sl = M.newLogic(); let r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n" - "r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n" - "sl = M.removeNode(sl, 'n1'); r = M.addNode(sl, 'const', '높이'); sl = r.sl;\n" - "out([sl.nodes.map(n => [n.id, n.name, n.sheetId]), E.checkLogic(sl)]);" - ) - nodes, check = got - assert nodes == [["n2", "높이 (2)", "s2"], ["n3", "높이", "s3"]] - assert check == ["결과 상자가 없음"] - - -def test_화면_글에_금지_낱말_없음() -> None: - for name in ("M01_SL_Model.ts", "M01_SL_Eval.ts"): - src = (SL / name).read_text(encoding="utf-8") - block = src[src.index("const TEXT = {") : src.index("} as const;")] - korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] - assert korean - for text in korean: - for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): - assert bad not in text, (name, bad, text) - - -def test_상자_이름_바꾸기_수식이_따라감() -> None: - got = _run( - TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n" - "const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why," - " M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why," - " M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n" - "out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름," - " sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);" - ) - why, name, sheet_name, cell, total, tries, before = got - assert why is None and name == sheet_name == "석공 (돌쌓기)" - assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000 - assert tries == [ - "같은 이름 상자가 있음", - "이름은 1~31 글자", - "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", - "상자가 없음", - "이름은 1~31 글자", - ] - assert before == "석공" - - -def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None: - got = _run( - "let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;" - " r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;" - " out(sl.workbook.시트.map(s => s.이름));" - ) - assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"] - - -def test_바깥값_답을_넣을값으로_범위머리() -> None: - got = _run( - ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n" - "sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n" - "sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n" - "const cells = E.outsideCells(sl);\n" - "const head = M.rangeHead('높이');\n" - "const got = E.answersToValues(cells, [\n" - " { id: 'n2!B2', ok: true, value: 172068 },\n" - " { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n" - "]);\n" - "const s2 = E.applyOutside(sl, got.values);\n" - "out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);" - ) - head, got_values, f1, g2, total = got - assert head == ["높이·아래", "높이·위"] - assert got_values["errors"] == [] - assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068} - assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034) - missing = _run( - ONE - + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));" - ) - assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]} - - -def test_항목_틀은_Item_노무_틀과_같은_칸() -> None: - got = _run( - "let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');" - " out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);" - ) - cells, ins, outs, ref = got - assert {k: v for k, v in cells.items() if k.startswith("B")} == { - "B3": {"값": 0}, - "B4": {"값": 0}, - "B5": {"식": "B2+B3+B4"}, - "B6": {"식": "ROUND(B1*B5,1)"}, - "B7": {"식": "ROUND(B1*B2,1)"}, - "B8": {"식": "ROUND(B1*B3,1)"}, - "B9": {"식": "ROUND(B1*B4,1)"}, - "B10": {"식": "B1"}, - } - assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [ - "수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량", - ] # fmt: skip - assert ins == [ - {"name": "수량", "cell": "B1", "unit": ""}, - {"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True}, - ] - assert outs == [ - ["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"], - ] # fmt: skip - assert ref == '{"what":"노무"}' - - -# 표 상자 이음매 — Table_Build(sub3) 가 짠 품셈 표 상자에 서버 꼴 블록(머리 줄 + 줄 · A1 부터)을 넣고 찾기 -TABLE = r""" -const T = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Table_Build.ts")); -let sl = M.newLogic("표 시험"); -let r = M.addNode(sl, "table", "메쌓기"); sl = r.sl; const tb = r.node; -const info = { key: "QF000421", name: "메쌓기", base: "1㎡", source: "", no: "13-4-1", - conds: { 뒷길이: "수", 돌: "고르기" }, vals: { 석공: "인" }, rule: null, notes: [], - rows: [{ 뒷길이: 25, 돌: "깬잡석", 석공: 0.15 }, { 뒷길이: 30, 돌: "깬돌", 석공: 0.26 }, - { 뒷길이: 35, 돌: "깬돌", 석공: 0.3 }] }; -sl = T.applyTable(sl, tb.id, info, { cond: ["뒷길이", "돌"], out: ["석공"] }, - { ref: "QF000421", fillBlock: false, outside: true }).sl; -r = M.addNode(sl, "input", "뒷길이"); sl = r.sl; const len = r.node; -r = M.addNode(sl, "input", "돌"); sl = r.sl; const stone = r.node; -sl = E.applyOutside(sl, [{ node: len.id, cell: "B2", value: 35 }, { node: stone.id, cell: "B2", value: "깬돌" }]); -sl = link(sl, len, "뒷길이", sl.nodes.find(n => n.id === tb.id), "뒷길이"); -sl = link(sl, stone, "돌", sl.nodes.find(n => n.id === tb.id), "돌"); -const ROWS = [["뒷길이", "돌", "석공"], [25, "깬잡석", 0.15], [30, "깬돌", 0.26], [35, "깬돌", 0.3]]; -const cells = E.outsideCells(sl); -""" - - -def test_표_상자_바깥값_블록_찾기_줄면_옛줄_지움_머리바뀜_알림() -> None: - got = _run( - TABLE - + "const first = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS }], sl);\n" - "const s1 = E.applyOutside(sl, first.values);\n" - "const shrink = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS.slice(0, 3) }], s1);\n" - "const s2 = E.applyOutside(s1, shrink.values);\n" - "const moved = E.answersToValues(cells, [{ id: cells[0].id, ok: true," - " rows: [['돌', '뒷길이', '석공'], ['깬돌', 30, 0.26]] }], s1);\n" - "out([cells, first.errors, E.evalLogic(s1).pills[tb.id], shrink.errors, sheet(s2, tb.id).칸.A4 ?? null," - " sheet(s2, tb.id).칸.C4 ?? null, E.evalLogic(s2).pills[tb.id], moved.errors]);" - ) - cells, first_err, found, shrink_err, a4, c4, gone, moved_err = got - assert cells == [ - { - "id": "n1!A1", - "node": "n1", - "cell": "A1", - "kind": "table", - "ref": "QF000421", - "source": {"kind": "표", "table": "QF000421"}, - } - ] - assert first_err == [] and found["석공"] == 0.3 and found["표"] == "뒷길이" - assert shrink_err == [] and a4 is None and c4 is None - assert gone["석공"] == "#N/A" - assert moved_err == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"] diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py deleted file mode 100644 index 0d856b566..000000000 --- a/resources/tester/test_m01_sl_router.py +++ /dev/null @@ -1,366 +0,0 @@ -"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5. - -· 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴 -· 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림 -· 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류 -· 권한 없음 403 · 마스터 사본 파일은 안 바뀜 -견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함). -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from decimal import Decimal -from pathlib import Path - -import pytest -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - -from common_util.common_util_auth import require_system_admin, verify_session -from M01_MasterData import M01_MasterData_Router as router_module -from M01_MasterData import M01_MasterData_Store as store -from M01_MasterData.SheetLogic import M01_SL_Router as sl - -ROOT = Path(__file__).resolve().parents[2] -REAL = store.FOLDER -OWN = "로직_자체.json" -SAMPLE = "QF999997" -TABLE = { - "키": SAMPLE, - "원문번호": "", - "구분": "", - "상세구분": "", - "이름": "견본 높이 표", - "기준": "1㎡", - "출처": "자체", - "조건": {"돌": "고르기", "높이": "범위"}, - "범위규칙": "초과·이하", - "값칸": {"석공": "인", "증가율": "%"}, - "줄": [ - {"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]}, - {"돌": "견치돌", "높이": 4, "석공": 0.7}, - ], - "주": [], -} -NO_NODE = shutil.which("node") is None - - -def _labor() -> tuple[str, Decimal]: - rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] - row = next( - r - for r in rows - if r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float)) - ) - return row["키"], Decimal(str(row["값"])) - - -@pytest.fixture -def folder(tmp_path: Path, monkeypatch) -> Path: - master = tmp_path / "master" - master.mkdir() - for path in REAL.glob("*.json"): - if path.name == OWN: - continue - if not path.name.startswith("_") or path.name == store.mk.BOOK.name: - shutil.copy(path, master / path.name) - host = sorted(master.glob("소요량_*.json"))[0] - data = json.loads(host.read_text(encoding="utf-8")) - data["표"].append(TABLE) - host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - monkeypatch.setattr(store, "FOLDER", master) - monkeypatch.setattr(sl, "DATA", tmp_path / "_data") - return master - - -@pytest.fixture -def client(folder: Path) -> TestClient: - app = FastAPI() - app.include_router(router_module.router) - app.include_router(sl.router) - return TestClient(app) - - -def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: - res = client.post(url, json=body) - assert res.status_code == want, res.text - return res.json() - - -def _logic(labor: str, key: str | None = None) -> dict: - """Model SheetLogic — 노무 상자(source 노임 · A1) × 2 = B1 · 표 상자(ref 로 짐작 · A1 부터) - · 출처 없는 바깥 알약은 뺌 · 몸의 outside 목록(Item 꼴)은 C2 에 · 브라우저 계산값은 엉터리.""" - return { - "key": key, - "name": "견본 시트 로직", - "resultUnit": "원/㎡", - "workbook": { - "종류": "통합문서", - "판": 1, - "서식": [{}], - "시트": [ - {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}}, - {"id": "s2", "이름": "표", "칸": {}}, - ], - "계산값": {"s1": {"B1": {"수": "999"}}}, - }, - "nodes": [ - { - "id": "n1", - "kind": "item", - "name": "노무 석공", - "sheetId": "s1", - "stage": 3, - "pillsIn": [{"name": "노임", "cell": "A1", "outside": True}], - "pillsOut": [{"name": "금액", "cell": "B1"}], - "source": {"kind": "노임", "key": labor}, - }, - { - "id": "n2", - "kind": "table", - "name": "견본 표", - "sheetId": "s2", - "stage": 1, - "pillsIn": [], - "pillsOut": [{"name": "표", "cell": "A1", "outside": True}], - "ref": SAMPLE, - }, - { - "id": "n3", - "kind": "calc", - "name": "계산", - "sheetId": "s1", - "stage": 2, - "pillsIn": [], - "pillsOut": [{"name": "x", "cell": "C1", "outside": True}], - }, - ], # fmt: skip - "edges": [ - {"id": "e1", "from": {"node": "n2", "pill": "표"}, "to": {"node": "n1", "pill": "노임"}} - ], - "outside": [ - {"item": {"id": "n1:노무비", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "C2"} - ], - } - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, folder: Path) -> None: - labor, price = _labor() - before = {p.name: p.read_bytes() for p in folder.glob("*.json")} - - saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) - assert saved["key"] == "SL-0001" - book = saved["logic"]["workbook"] - cells = book["시트"][0]["칸"] - assert cells["A1"]["값"] == price == cells["C2"]["값"] # 바깥 값을 마스터에서 다시 넣음(①②) - assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림) - assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음 - table = book["시트"][1]["칸"] - head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1", "F1")] - assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] # A1 부터 - assert ( - "B2" not in table and table["C2"]["값"] == 3 and table["F2"]["값"] == 100 - ) # 빈 끝은 빈 칸 - assert table["B3"]["값"] == table["C3"]["값"] == 4 and "E3" not in table # 수 하나 → 같은 수 - assert saved["logic"]["edges"] == _logic(labor)["edges"] - answers = {v["id"]: v for v in saved["values"]} - assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all( - v["ok"] for v in answers.values() - ) - - # 블록 왼위 = 표 상자 「표」 바깥 알약 칸(고정 A1 아님 — Import 는 E1) - at_e1 = _logic(labor) - at_e1["nodes"][1]["pillsOut"][0]["cell"] = "E1" - second = _post(client, "/api/m01/sl/save", {"logic": at_e1}) - assert second["key"] == "SL-0002" - moved = second["logic"]["workbook"]["시트"][1]["칸"] - assert moved["E1"]["값"] == "돌" and moved["J2"]["값"] == 100 and "A1" not in moved - renamed = {**saved["logic"], "name": "고친 이름"} - assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001" - - listed = client.get("/api/m01/sl/list").json()["logics"] - assert [(x["key"], x["이름"]) for x in listed] == [ - ("SL-0001", "고친 이름"), - ("SL-0002", "견본 시트 로직"), - ] - got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"] - assert got["workbook"]["계산값"] == book["계산값"] - - _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}) - assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404 - _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404) - _post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404) - _post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "name": " "}}, want=400) - assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400 - - assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로 - assert sorted(p.name for p in sl.DATA.iterdir()) == ["SL-0001.json"] - - -def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: - labor, price = _labor() - line = { - "종류": "인력", - "요소": labor, - "이름": "석공", - "단위": "인", - "수량": "품", - "비목": "노무비", - } - row = {"원문번호": "", "이름": "견본 노무", "결과단위": "원/㎡", "출처": "자체", - "입력": [{"이름": "품"}], "중간": [], "호표": [line], "덧줄": [], - "끝수": None} # fmt: skip - made = _post(client, "/api/m01/logic/new", {"logic": row}) - items = [ - {"id": "t", "kind": "표", "table": SAMPLE}, - {"id": "l", "kind": "노임", "key": labor}, - {"id": "g", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}}, - {"id": "g2", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}, "field": "재료비"}, - {"id": "w", "kind": "자재", "key": labor}, - {"id": "x", "kind": "표", "table": "QF999996"}, - {"id": "z", "kind": "모름"}, - ] - got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} - # 정함: 조건 → 값 · 범위가 있는 칸(조건이든 값이든)은 두 칸 · 수 하나면 아래 · 위 같은 수 - assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] - assert got["t"]["rows"][1:] == [ - ["깬돌", None, 3, 0.5, 80, 100], - ["견치돌", 4, 4, 0.7, None, None], - ] - assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"] - assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price - assert got["g2"]["value"] == 0 - for k in ("w", "x", "z"): - assert got[k]["ok"] is False and got[k]["reason"], got[k] - - -def test_시스템_관리자가_아니면_403(folder: Path) -> None: - app = FastAPI() - app.include_router( - sl.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] - ) - app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} - client = TestClient(app) - assert client.get("/api/m01/sl/list").status_code == 403 - assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403 - app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} - assert client.get("/api/m01/sl/list").json() == {"logics": []} - - -def _node_json(script: str) -> object: - result = subprocess.run( - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None: - """서버 표 답(머리 줄 + 줄) = sub3 Table_Build `blockCells` = sub6 Import_Parse `tableRows`.""" - got = _post( - client, "/api/m01/sl/outside", {"items": [{"id": "t", "kind": "표", "table": SAMPLE}]} - ) - rows = got["values"][0]["rows"] - table = json.dumps( - {"조건": TABLE["조건"], "값칸": TABLE["값칸"], "줄": TABLE["줄"]}, ensure_ascii=False - ) - info = json.dumps( - {"conds": TABLE["조건"], "vals": TABLE["값칸"], "rows": TABLE["줄"]}, ensure_ascii=False - ) - - build = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Table_Build.ts").as_uri() - cells = _node_json( - f"import {{ blockCells }} from '{build}';console.log(JSON.stringify(blockCells({info})));" - ) - letters = "ABCDEFGHIJ" - mine = { - f"{letters[c]}{r + 1}": {"값": v} - for r, line in enumerate(rows) - for c, v in enumerate(line) - if v is not None - } - assert cells == mine - - src = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts").read_text(encoding="utf-8") - body = re.search(r"/\*\* 범위 칸 꼬리[\s\S]*?\nexport function tableRows[\s\S]*?\n}\n", src) - assert body, "Import_Parse 의 tableCols · tableRows 를 못 찾음" - script = ( - "const currentLanguageIndex = 0;\n" - + body.group(0).replace("export ", "") - + f"\nconsole.log(JSON.stringify(tableRows({table})));" - ) - result = subprocess.run( - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=60, - ) # fmt: skip - assert result.returncode == 0, result.stderr - assert json.loads(result.stdout) == rows - - -def _material_cond() -> tuple[dict, str]: - """값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음).""" - rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"] - row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분")) - cond = { - "구분": row["구분"], - "상세구분": row["상세구분"], - "검색어": f"{row['이름']} {row['규격'] or ''}".strip(), - } - return cond, row["단위"] - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None: - cond, unit = _material_cond() - line = { - "종류": "재료", - "요소": cond, - "이름": "재료", - "단위": unit, - "수량": "1", - "비목": "재료비", - } - row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체", - "입력": [], - "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip - want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"] - assert want is not None - - text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글 - bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False) - items = [ - {"id": "s", "kind": "자재", "key": text, "unit": unit}, - {"id": "d", "kind": "자재", "key": cond, "unit": unit}, - {"id": "x", "kind": "자재", "key": bad, "unit": unit}, - ] - got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} - assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"] - assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음" - - # 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록) - logic = { - "key": None, - "name": "자재 견본", - "resultUnit": "원/㎡", - "workbook": { - "종류": "통합문서", - "판": 1, - "서식": [{}], - "시트": [{"id": "s1", "이름": "재료", "칸": {}}], - }, - "nodes": [], - "edges": [], - "outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}], - } - saved = _post(client, "/api/m01/sl/save", {"logic": logic}) - assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want - _post(client, "/api/m01/sl/delete", {"key": saved["key"]}) diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py deleted file mode 100644 index f353c54e4..000000000 --- a/resources/tester/test_m01_sl_table_build.py +++ /dev/null @@ -1,276 +0,0 @@ -"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기. - -화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조. -견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -from common_util.common_util_spreadsheet import recalc_workbooks - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_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 === "plan") { - const plan = m.planLookup(job.info, job.roles); - const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells }; - for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v }; - return { cells, plan }; - } - if (job.op === "paste") return m.parsePaste(job.text); - if (job.op === "rename") { - const sl = { key: null, name: "t", resultUnit: "", edges: [], - workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [ - { id: "s1", 이름: "표", 칸: {} }, - { id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] }, - nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; - const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl; - const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? "")); - return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts }; - } - if (job.op === "reapply") { - const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [], - workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] }, - nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; - const opts = { ref: "T1", fillBlock: true, outside: false }; - const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl; - const cell = first.nodes[0].pillsIn[0].cell; - first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" }; - const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl; - return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name }; - } - return null; -}); -process.stdout.write(JSON.stringify(out)); -""" - -R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []} -EXACT = { - **R, - "conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, - "vals": {"석공": "인", "보통": "인"}, - "rows": [ - {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12}, - {"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09}, - {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21}, - {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18}, - {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5}, - ], -} -RATE = { - **R, - "conds": {"높이": "범위"}, - "vals": {"증가율": "%"}, - "rule": "초과·이하", - "rows": [ - {"높이": [3, 4], "증가율": 30}, - {"높이": [4, 5.5], "증가율": 40}, - {"높이": [5.5, None], "증가율": 60}, - ], -} -MIXED = { - **R, - "conds": {"구분": "고르기", "높이": "범위"}, - "vals": {"율": "%"}, - "rule": "초과·이하", - "rows": [ - {"구분": "가", "높이": [0, 2], "율": 1}, - {"구분": "가", "높이": [2, 4], "율": 2}, - {"구분": "나", "높이": [0, 3], "율": 5}, - ], -} -MINE = { - **R, - "conds": {"경사": "고르기"}, - "vals": {"할증": "%"}, - "rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}], -} - - -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 solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict: - """견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}.""" - roles = {"cond": cond or list(info["conds"]), "out": out} - plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0] - book = { - "종류": "통합문서", - "판": 1, - "열": "t", - "서식": [{}], - "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], - "활성": "s1", - } - values = recalc_workbooks([book])[0]["s1"] - p = plan["plan"] - - def got(a1: str): - v = values.get(a1) or {} - return v.get("수", v.get("글", v.get("오류"))) - - res = {c: got(a) for c, a in p["outCells"].items()} - return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res} - - -def test_같음_조건_셋은_열쇠로_찾음(): - r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"]) - assert r["상태"] == "찾음" and r["줄"] == "3" - assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21 - - -def test_빈칸은_무관_줄로_찾음(): - r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"]) - assert r["상태"] == "찾음" and float(r["석공"]) == 0.1 - - -def test_맞는_줄_없음과_둘_이상(): - none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"]) - assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A" - dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"]) - assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A" - - -def test_범위_조건은_아래_위_두_칸으로_초과_이하(): - assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하) - assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40 - assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음 - assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님 - assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음" - - -def test_같음_조건과_범위_조건이_섞임(): - assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2 - assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5 - assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음" - assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음" - - -def test_내_표_경사에서_할증_두_줄(): - assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10 - assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0 - - -def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜(): - r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"]) - assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋 - - -def test_붙여넣기_읽기(): - text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10" - out = run([{"op": "paste", "text": text}])[0] - info = out["info"] - assert out["error"] is None - assert info["conds"] == {"경사": "고르기", "범위": "범위"} - assert info["vals"] == {"할증": ""} - assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10} - assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"] - - -def test_다시_짜도_선_수식은_그대로(): - out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0] - assert out["kept"] == {"식": "'다른 시트'!B2"} - assert out["pills"] == ["경사"] and out["name"] == "표 · 견본" - - -def test_표가_비었어도_수식이_깨지지_않음(): - """내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨.""" - empty = {**MINE, "rows": [{}, {}]} - r = solve(empty, {"경사": "급"}, ["할증"]) - assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A" - - -def test_값_칸_범위도_아래_위_두_칸_숫자_하나는_같은_수(): - """서버 표 블록 차례 — 범위 값은 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸이 같은 수.""" - table = { - **R, - "conds": {"높이": "범위"}, - "vals": {"증가율": "%"}, - "rule": "초과·이하", - "rows": [{"높이": [3, 4], "증가율": [80, 100]}, {"높이": [4, None], "증가율": 30}], - } - low = solve(table, {"높이": 3.5}, ["증가율"]) - assert float(low["증가율·아래"]) == 80 and float(low["증가율·위"]) == 100 - one = solve(table, {"높이": 5}, ["증가율"]) - assert float(one["증가율·아래"]) == 30 and float(one["증가율·위"]) == 30 - - -def test_블록_머리_차례(): - table = { - **R, - "conds": {"가": "고르기", "높이": "범위"}, - "vals": {"율": "%", "폭": "m"}, - "rows": [{"가": "a", "높이": [1, 2], "율": 1, "폭": [20, 45]}], - } - plan = run([{"op": "plan", "info": table, "roles": {"cond": ["가"], "out": ["율"]}}])[0]["plan"] - assert [c["header"] for c in plan["cols"]] == [ - "가", - "높이·아래", - "높이·위", - "율", - "폭·아래", - "폭·위", - ] - - -def test_표가_늘어도_찾기_범위가_따라감(): - """마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다).""" - small = {**RATE, "rows": RATE["rows"][:2]} - extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)] - grown = {**small, "rows": [*small["rows"], *extra]} - roles = {"cond": ["높이"], "out": ["증가율"]} - plan = run( - [{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}] - )[0] - book = { - "종류": "통합문서", - "판": 1, - "열": "t", - "서식": [{}], - "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], - "활성": "s1", - } - values = recalc_workbooks([book])[0]["s1"] - assert values[plan["plan"]["statusCell"]]["글"] == "찾음" - assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72 - - -def test_표_상자에_표_번호가_안_보임_이름으로_보임(): - info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"} - roles = {"cond": list(info["conds"]), "out": ["석공"]} - out = run([{"op": "rename", "info": info, "roles": roles}])[0] - assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"] - assert out["ref"] == "QF000421" # 번호는 상자 ref 에만 - assert "QF000421" not in " ".join(out["texts"]) - assert any("13-4-1" in t and "1㎡" in t for t in out["texts"]) - assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2" - assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1" diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index 1312cd508..2acb47268 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -239,18 +239,10 @@ def test_견본_표_그룹이_마스터와_같음(master) -> None: def test_견본1_원가_calc_와_같음(master) -> None: - from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + from M01_MasterData import M01_MasterData_Store as store # /calc 와 같은 셈 - cm, files = master - row = next( - r - for d in files.values() - if d.get("그룹") == "로직" - for r in d["줄"] - if r["키"] == "GF000219" - ) given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} - want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {}) + want = store.calc("GF000219", {**given, "초과증가율": 80}) assert want["ok"], want got = _run(SAMPLES["masonry"], master)["out-pay"] print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") @@ -258,18 +250,10 @@ def test_견본1_원가_calc_와_같음(master) -> None: def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: - from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + from M01_MasterData import M01_MasterData_Store as store # /calc 와 같은 셈 - cm, files = master - row = next( - r - for d in files.values() - if d.get("그룹") == "로직" - for r in d["줄"] - if r["키"] == "GF000133" - ) given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 - want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {}) + want = store.calc("GF000133", given) assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100