From b3d69e719786392be40418306881a65d78b63daa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:48:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20Wizard=20=EC=B4=88=EC=95=88=20=E2=80=94=20?= =?UTF-8?q?6=EB=8B=A8=EA=B3=84=20=ED=99=94=EB=A9=B4=20=C2=B7=20=EB=8B=B5?= =?UTF-8?q?=20=E2=86=92=20=EB=A1=9C=EC=A7=81=20=EC=A4=84=20=EC=A7=9C?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/LogicBuilder/M01_LB_Wizard.css | 199 +++++ M01_MasterData/LogicBuilder/M01_LB_Wizard.ts | 698 ++++++++++++++++++ .../LogicBuilder/M01_LB_Wizard_Build.ts | 253 +++++++ .../LogicBuilder/M01_LB_Wizard_Calc.ts | 546 ++++++++++++++ 4 files changed, 1696 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard.ts create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard.css b/M01_MasterData/LogicBuilder/M01_LB_Wizard.css new file mode 100644 index 000000000..56b85b12e --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard.css @@ -0,0 +1,199 @@ +.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 new file mode 100644 index 000000000..0cfceeb6f --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts @@ -0,0 +1,698 @@ +/* ============================================================================= + * M01_LB_Wizard.ts + * 로직 만들기 판 — 단계 안내 6단계 · openWizard() → LbLogic | null(취소) · 저장은 Entry 몫 + * ① 어느 품셈 절 ② 표 칸 역할 ③ 결과 단위 ④ 계산(Calc.ts) ⑤ 항목 연결(Calc.ts) ⑥ 이름 · 결과 종류 + * 답은 Build.ts 가 로직 한 줄로 짜고 Blocks.readLogic 이 블록으로 읽음 · 답할 때마다 곁에 예시값 + * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨 + * ========================================================================== */ + +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 { openModal } from "@ui/ui_template_modal"; +import { ApiError, 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 "./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"], + 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"); + +async function get(path: string, params: Record): Promise { + const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { + credentials: "include", + }); + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); + return data; +} + +interface SectionItem { + section: string; + title: string; + chapter: string; + tables: number; + logics: number; +} +interface TableHead { + file: string; + 키: string; + 이름: string; + 기준: string; + 조건: Record; + 값칸: Record; + count: number; +} + +const 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: fmt(v) }) + : 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)); + 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)); + return () => (a.table.key ? null : t("Tables")); + }; + + /* ── 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, 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) { + stepLink(ctx, main); + if (!linked) + ((linked = true), 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(); + }); +} + +/** 서버 표 답 → WTable */ +function toTable(raw: WTable & { 키: string }, file: string): WTable { + return { + key: raw.키, + file, + 이름: raw.이름, + 기준: raw.기준 ?? "", + 출처: raw.출처 ?? "", + 조건: raw.조건, + 값칸: raw.값칸, + 범위규칙: raw.범위규칙 ?? null, + 줄: raw.줄, + 주: raw.주 ?? [], + }; +} + +const cellText = (v: unknown): string => + Array.isArray(v) + ? v.map((x) => x ?? "").join("~") + : v === null || v === undefined + ? "" + : String(v); + +/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */ +function tablePreview(tb: WTable, roles?: Record): HTMLElement { + const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; + const head = el("tr", { + children: cols.map((c) => + el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }), + ), + }); + const rows = tb.줄 + .slice(0, 6) + .map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) })); + return el("div", { + className: "m01-lbw__table", + children: [ + el("h5", { text: `${t("Preview")} — ${tb.이름} (${tb.기준 || "—"})` }), + el("div", { + className: "m01-lbw__scroll", + children: [el("table", { children: [head, ...rows] })], + }), + ...(tb.줄.length > 6 + ? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })] + : []), + ...(tb.주.length + ? [ + el("h5", { text: t("Notes") }), + ...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })), + ] + : []), + ], + }); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts new file mode 100644 index 000000000..47c8c6364 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts @@ -0,0 +1,253 @@ +/* ============================================================================= + * 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 }); + } + for (const l of a.links) for (const n of l.needInputs ?? []) add(n); + return out; +} + +const COST: Record = { 노무: "노무비", 재료: "재료비" }; + +/** 답 → 로직 줄 */ +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} * ${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 new file mode 100644 index 000000000..45cd91dc0 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts @@ -0,0 +1,546 @@ +/* ============================================================================= + * 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 } 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}” {o} {l} add 0, else “{t}” {c} %", + ], + 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: r.op === "<=" ? "≤" : "<", + 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)) 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.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) => alert(plainError(e)))); + 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("기타") }), + ], + }), + ], + }), + ], + }); +} From c16d7fdda21d2ab79f6007b6282f7a8f3c5de06c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:57:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(m01-lb):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=ED=8C=90=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20Wizard=20=EC=99=84=EC=84=B1=20=E2=80=94=20?= =?UTF-8?q?=ED=91=9C=20=EC=B9=B8=20=EC=97=AD=ED=95=A0=20=C2=B7=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=A5=20=ED=8B=80=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EA=B0=92=20=EC=A2=85=EB=A5=98=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/LogicBuilder/M01_LB_Wizard.ts | 112 ++-------- .../LogicBuilder/M01_LB_Wizard_Build.ts | 21 +- .../LogicBuilder/M01_LB_Wizard_Calc.ts | 24 ++- .../LogicBuilder/M01_LB_Wizard_Parts.ts | 94 +++++++++ resources/tester/test_m01_lb_wizard_build.py | 193 ++++++++++++++++++ 5 files changed, 345 insertions(+), 99 deletions(-) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts create mode 100644 resources/tester/test_m01_lb_wizard_build.py diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts index 0cfceeb6f..9f172557f 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts @@ -6,7 +6,6 @@ * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨 * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, @@ -16,7 +15,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { ApiError, type CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; +import { type CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; import { calcLater, plainError } from "../EasyCalc/M01_EasyCalc_Api"; import { readLogic, type LbLogic } from "./M01_LB_Blocks"; import { autoLinks, stepCalc, stepLink } from "./M01_LB_Wizard_Calc"; @@ -30,6 +29,13 @@ import { type WCtx, type WTable, } from "./M01_LB_Wizard_Build"; +import { + get, + tablePreview, + toTable, + type SectionItem, + type TableHead, +} from "./M01_LB_Wizard_Parts"; import "./M01_LB_Wizard.css"; const TEXT = { @@ -67,6 +73,7 @@ const TEXT = { Unused: ["쓰지 않음", "Unused"], Choices: ["선택 목록", "Choices"], Free: ["숫자 입력", "Number input"], + PickTable: ["표를 하나 골라 주세요", "Pick a table"], NeedResult: ["결과 칸이 하나는 있어야 함", "At least one result column"], FixedVal: ["고정값", "Fixed value"], BaseUnit: ["표 기준 「{b}」", "Table base “{b}”"], @@ -95,32 +102,7 @@ const t = (key: TKey, v: Record = {}): string => const fmt = (n: number): string => Number(n.toPrecision(6)).toLocaleString("ko-KR"); -async function get(path: string, params: Record): Promise { - const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { - credentials: "include", - }); - const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); - return data; -} - -interface SectionItem { - section: string; - title: string; - chapter: string; - tables: number; - logics: number; -} -interface TableHead { - file: string; - 키: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - count: number; -} - +const LBL = { preview: t("Preview"), notes: t("Notes") }; const BOOKS = ["산림품셈", "건설품셈"]; const UNITS = ["㎡", "㎥", "m", "km", "ha", "개소", "개", "ton", "본", "100㎡", "100m", "10m"]; @@ -233,7 +215,7 @@ export function openWizard(): Promise { const per = a.resultUnit.replace(/^원\//, ""); const v = money ? (ans.sums?.["계"] ?? Number(ans.result ?? 0)) : Number(ans.result); result.textContent = money - ? t("AmountPer", { p: per, m: fmt(v) }) + ? t("AmountPer", { p: per, m: Math.round(v).toLocaleString("ko-KR") }) : t("ValueIs", { c: col, v: fmt(v), u: a.table.값칸[col] ?? "" }); }); }; @@ -369,7 +351,7 @@ export function openWizard(): Promise { tables .querySelectorAll(".m01-lbw__row") .forEach((n, i) => n.classList.toggle("is-on", heads[i]?.키 === h.키)); - preview.replaceChildren(tablePreview(a.table)); + preview.replaceChildren(tablePreview(a.table, LBL)); renderNav(); }; let timer = 0; @@ -379,8 +361,8 @@ export function openWizard(): Promise { ); main.replaceChildren(bookBox.root, q.root, list, tables, preview); void load(); - if (a.table.key) preview.replaceChildren(tablePreview(a.table)); - return () => (a.table.key ? null : t("Tables")); + if (a.table.key) preview.replaceChildren(tablePreview(a.table, LBL)); + return () => (a.table.key ? null : t("PickTable")); }; /* ── 2단계 칸 역할 ── */ @@ -471,7 +453,7 @@ export function openWizard(): Promise { box.replaceChildren( el("p", { className: "m01-lbw__muted", text: t("RoleHint") }), ...chips, - tablePreview(a.table, a.roles), + tablePreview(a.table, LBL, a.roles), ); renderNav(); }; @@ -580,9 +562,12 @@ export function openWizard(): Promise { stepCalc(ctx, main); valid = () => null; } else if (step === 5) { - stepLink(ctx, main); - if (!linked) - ((linked = true), void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main))); + if (linked) stepLink(ctx, main); + else { + linked = true; + main.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") })); + void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main)); + } valid = () => null; } else valid = stepName(); side.hidden = step === 1; @@ -641,58 +626,3 @@ export function openWizard(): Promise { draw(); }); } - -/** 서버 표 답 → WTable */ -function toTable(raw: WTable & { 키: string }, file: string): WTable { - return { - key: raw.키, - file, - 이름: raw.이름, - 기준: raw.기준 ?? "", - 출처: raw.출처 ?? "", - 조건: raw.조건, - 값칸: raw.값칸, - 범위규칙: raw.범위규칙 ?? null, - 줄: raw.줄, - 주: raw.주 ?? [], - }; -} - -const cellText = (v: unknown): string => - Array.isArray(v) - ? v.map((x) => x ?? "").join("~") - : v === null || v === undefined - ? "" - : String(v); - -/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */ -function tablePreview(tb: WTable, roles?: Record): HTMLElement { - const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; - const head = el("tr", { - children: cols.map((c) => - el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }), - ), - }); - const rows = tb.줄 - .slice(0, 6) - .map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) })); - return el("div", { - className: "m01-lbw__table", - children: [ - el("h5", { text: `${t("Preview")} — ${tb.이름} (${tb.기준 || "—"})` }), - el("div", { - className: "m01-lbw__scroll", - children: [el("table", { children: [head, ...rows] })], - }), - ...(tb.줄.length > 6 - ? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })] - : []), - ...(tb.주.length - ? [ - el("h5", { text: t("Notes") }), - ...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })), - ] - : []), - ], - }); -} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts index 47c8c6364..e9abc8a54 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts @@ -184,12 +184,25 @@ function inputsOf(a: WAnswers): LogicInput[] { if (m.k === "할증" && m.rate.t === "cond") add({ 이름: m.rate.input }); if (m.k === "나누기" && m.by.t === "input") add({ 이름: m.by.name }); } - for (const l of a.links) for (const n of l.needInputs ?? []) add(n); + if (a.kind === "금액") + for (const l of a.links) + if (l.kind !== "없음" && a.roles[l.col] === "결과") + for (const n of l.needInputs ?? []) add(n); return out; } const COST: Record = { 노무: "노무비", 재료: "재료비" }; +/** 사람 줄 노임 합 글 — 「(수량 * 노임 키 + …)」 · 사람 줄이 없으면 0 */ +const personSum = (ho: HoLine[]): string => { + const terms = ho + .filter((h) => h.종류 === "인력") + .map( + (h) => `${/^[\p{L}\p{N}_.]+$/u.test(h.수량) ? h.수량 : `(${h.수량})`} * ${String(h.요소)}`, + ); + return `(${terms.length ? terms.join(" + ") : "0"})`; +}; + /** 답 → 로직 줄 */ export function buildRow(a: WAnswers): LogicRow { const mids: { 이름: string; 식: string; 출처?: string }[] = []; @@ -222,7 +235,11 @@ export function buildRow(a: WAnswers): LogicRow { })); const 덧줄 = a.extras.map((e) => e.k === "비율" - ? { 이름: e.name, 식: `${e.base} * ${num(e.rate)} / 100`, 비목: e.cost } + ? { + 이름: e.name, + 식: `${e.base === "사람줄합" ? personSum(호표) : e.base} * ${num(e.rate)} / 100`, + 비목: e.cost, + } : { 이름: e.name, 식: num(e.amount), 비목: e.cost }, ); return { ...base, 입력: inputsOf(a), 중간: mids, 호표, 덧줄, 끝수: null }; diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts index 45cd91dc0..4410cad55 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts @@ -5,7 +5,13 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; import { fetchPick } from "../M01_MasterData_Api_Fetch"; import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; @@ -50,9 +56,11 @@ const TEXT = { DSurchargeNum: ["{p} % 를 더함", "add {p} %"], DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"], DSurchargeCond: [ - "「{i}」 가 {l} {o} 이면 0 · 넘으면 「{t}」 의 {c} % 를 더함", - "if “{i}” {o} {l} add 0, else “{t}” {c} %", + "「{i}」 가 {l} {o}이면 0 · 아니면 「{t}」 의 {c} % 를 더함", + "if “{i}” is {l} {o} add 0, else “{t}” {c} %", ], + OpLe: ["이하", "or less"], + OpLt: ["미만", "under"], DDivNum: ["{n} 로 나눔", "divide by {n}"], DDivIn: ["「{n}」 로 나눔(입력값)", "divide by “{n}” (input)"], DMin: ["{n} 보다 작은 값", "smaller of value and {n}"], @@ -127,7 +135,7 @@ const describe = (m: Mod): string => { : t("DSurchargeCond", { i: r.input, l: r.limit, - o: r.op === "<=" ? "≤" : "<", + o: t(r.op === "<=" ? "OpLe" : "OpLt"), t: r.table.이름, c: r.col, }); @@ -371,7 +379,7 @@ export function stepCalc(ctx: WCtx, host: HTMLElement): void { export async function autoLinks(ctx: WCtx): Promise { const { a } = ctx; for (const col of colsOf(a, "결과")) { - if (a.links.some((l) => l.col === col)) continue; + if (a.links.some((l) => l.col === col && l.kind !== "없음")) continue; const unit = a.table.값칸[col] ?? ""; const link: Link = { col, kind: "없음", name: col, spec: "", unit, element: null }; try { @@ -393,6 +401,7 @@ export async function autoLinks(ctx: WCtx): Promise { } catch { /* 못 찾으면 연결 안 함으로 둠 */ } + a.links = a.links.filter((l) => l.col !== col); a.links.push(link); } ctx.changed(); @@ -441,7 +450,10 @@ export function stepLink(ctx: WCtx, host: HTMLElement): void { attrs: { type: "button" }, text: t(label), }); - b.addEventListener("click", () => void pick(kind, pk).catch((e) => alert(plainError(e)))); + b.addEventListener( + "click", + () => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")), + ); return b; }); const none = el("button", { diff --git a/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts new file mode 100644 index 000000000..3f3773190 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Wizard_Parts.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * M01_LB_Wizard_Parts.ts + * 단계 안내 곁부품 — 서버 읽기(절 · 표) · 표 → WTable · 원문 표 미리 보기 · `M01_LB_Wizard.ts` 가 부름 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el } from "@ui/ui_template_elements"; +import { ApiError } from "../M01_MasterData_UI_Logic_Api"; +import type { Role, WTable } from "./M01_LB_Wizard_Build"; + +export async function get(path: string, params: Record): Promise { + const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { + credentials: "include", + }); + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); + return data; +} + +export interface SectionItem { + section: string; + title: string; + chapter: string; + tables: number; + logics: number; +} +export interface TableHead { + file: string; + 키: string; + 이름: string; + 기준: string; + 조건: Record; + 값칸: Record; + count: number; +} + +/** 서버 표 답 → WTable */ +export function toTable(raw: WTable & { 키: string }, file: string): WTable { + return { + key: raw.키, + file, + 이름: raw.이름, + 기준: raw.기준 ?? "", + 출처: raw.출처 ?? "", + 조건: raw.조건, + 값칸: raw.값칸, + 범위규칙: raw.범위규칙 ?? null, + 줄: raw.줄, + 주: raw.주 ?? [], + }; +} + +const cellText = (v: unknown): string => + Array.isArray(v) + ? v.map((x) => x ?? "").join("~") + : v === null || v === undefined + ? "" + : String(v); + +/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */ +export function tablePreview( + tb: WTable, + labels: { preview: string; notes: string }, + roles?: Record, +): HTMLElement { + const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; + const head = el("tr", { + children: cols.map((c) => + el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }), + ), + }); + const rows = tb.줄 + .slice(0, 6) + .map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) })); + return el("div", { + className: "m01-lbw__table", + children: [ + el("h5", { text: `${labels.preview} — ${tb.이름} (${tb.기준 || "—"})` }), + el("div", { + className: "m01-lbw__scroll", + children: [el("table", { children: [head, ...rows] })], + }), + ...(tb.줄.length > 6 + ? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })] + : []), + ...(tb.주.length + ? [ + el("h5", { text: labels.notes }), + ...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })), + ] + : []), + ], + }); +} diff --git a/resources/tester/test_m01_lb_wizard_build.py b/resources/tester/test_m01_lb_wizard_build.py new file mode 100644 index 000000000..210a11172 --- /dev/null +++ b/resources/tester/test_m01_lb_wizard_build.py @@ -0,0 +1,193 @@ +"""로직 만들기 판 단계 안내 — 답 → 로직 줄 짜기(`M01_LB_Wizard_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. +견본 표 · 답은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Wizard_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "unit") return m.unitOfBase(job.base); + if (job.op === "row") return m.buildRow(job.a); + if (job.op === "trial") return m.trialOf(job.a, m.buildRow(job.a)); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + +TABLE = { + "key": "QF900001", + "이름": "견본 쌓기", + "기준": "1㎡", + "출처": "산림품셈 9-9-9", + "조건": {"뒷길이": "수", "돌": "고르기"}, + "값칸": {"석공": "인", "보통인부": "인"}, + "줄": [ + {"뒷길이": 25, "돌": "깬돌", "석공": 0.15, "보통인부": 0.12}, + {"뒷길이": 30, "돌": "깬돌", "석공": 0.26, "보통인부": 0.21}, + ], + "주": ["③ 높이 3m 초과 시 할증"], +} +RATE_TABLE = { + "key": "QF900002", + "이름": "견본 증가율표", + "기준": "", + "출처": "산림품셈 9-9-9 주③", + "조건": {"높이": "범위"}, + "값칸": {"증가율": "%"}, + "줄": [{"높이": [3, 4], "증가율": 30}], + "주": [], +} + + +def answers(**more) -> dict: + base = { + "book": "산림품셈", + "section": "9-9-9", + "table": TABLE, + "roles": {"뒷길이": "조건", "돌": "조건", "석공": "결과", "보통인부": "결과"}, + "fixed": {}, + "options": {"뒷길이": [25, 30], "돌": ["깬돌"]}, + "resultUnit": "원/㎡", + "mods": [], + "links": [ + { + "col": "석공", + "kind": "노무", + "name": "석공", + "spec": "", + "unit": "인", + "element": "LB000033", + }, + { + "col": "보통인부", + "kind": "노무", + "name": "보통인부", + "spec": "", + "unit": "인", + "element": "LB000002", + }, + ], + "extras": [], + "name": "견본", + "kind": "금액", + "valueCol": "", + } + base.update(more) + return base + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_결과단위는_표_기준에서(): + out = run([{"op": "unit", "base": b} for b in ("1㎡", "100㎡ 당", "㎡당", "m당", "")]) + assert out == ["원/㎡", "원/100㎡", "원/㎡", "원/m", "원/㎡"] + + +def test_기본_두_줄(): + row = run([{"op": "row", "a": answers()}])[0] + assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌"] + assert row["입력"][0]["고르기"] == [25, 30] + assert row["중간"] == [] + assert [h["수량"] for h in row["호표"]] == [ + "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공", + "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부", + ] + assert row["호표"][0]["종류"] == "인력" and row["호표"][0]["비목"] == "노무비" + assert row["결과단위"] == "원/㎡" and row["출처"] == "산림품셈 9-9-9" + + +def test_고정칸은_값으로(): + a = answers( + roles={"뒷길이": "고정", "돌": "조건", "석공": "결과", "보통인부": "쓰지 않음"}, + fixed={"뒷길이": 25}, + ) + row = run([{"op": "row", "a": a}])[0] + assert [i["이름"] for i in row["입력"]] == ["돌"] + assert [h["수량"] for h in row["호표"]] == ["찾기(QF900001, 돌=돌, 뒷길이=25).석공"] + + +def test_문장틀_차례대로_씌움(): + cond = { + "t": "cond", + "input": "높이", + "op": "<=", + "limit": 3, + "table": RATE_TABLE, + "col": "증가율", + } + a = answers( + mods=[ + {"k": "할증", "rate": cond, "src": "산림품셈 9-9-9 주③"}, + {"k": "나누기", "by": {"t": "input", "name": "시공량"}}, + {"k": "올림", "fn": "반올림", "places": 3}, + ] + ) + row = run([{"op": "row", "a": a}])[0] + assert row["중간"] == [ + { + "이름": "증가율", + "식": "만약(높이 <= 3, 0, 찾기(QF900002, 높이=높이).증가율)", + "출처": "산림품셈 9-9-9 주③", + } + ] + assert row["호표"][0]["수량"] == ( + "반올림((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)) / 시공량, 3)" + ) + assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌", "높이", "시공량"] + + +def test_비율_덧줄은_사람줄합(): + extras = [{"k": "비율", "name": "공구손료", "base": "사람줄합", "rate": 3, "cost": "재료비"}] + row = run([{"op": "row", "a": answers(extras=extras)}])[0] + assert row["덧줄"] == [ + { + "이름": "공구손료", + "식": "((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공) * LB000033 + (찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부) * LB000002) * 3 / 100", + "비목": "재료비", + } + ] + + +def test_값_종류는_결과_식_하나(): + a = answers(kind="값", valueCol="석공", resultUnit="인/㎡") + row = run([{"op": "row", "a": a}])[0] + assert row["결과"] == {"식": "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공"} + assert "호표" not in row and row["결과단위"] == "인/㎡" + + +def test_예시값은_첫_줄에_걸림(): + out = run([{"op": "trial", "a": answers()}])[0] + assert out == {"뒷길이": 25, "돌": "깬돌"}