From ad1d104776c535793147335cc754ac8ed17c76a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:07:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-cf):=20=EC=9E=85=EB=A0=A5=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=ED=8C=90=20InputPanel=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=E2=80=94=20=EA=B7=B8=EB=A3=B9=20=EC=9A=94=EC=86=8C=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EB=B3=80=EC=88=98=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9A=94=EC=86=8C?= 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 --- .../CostFlow/M02_CF_InputPanel.css | 145 ++++++ .../CostFlow/M02_CF_InputPanel.ts | 418 ++++++++++++++++++ .../CostFlow/M02_CF_InputPanel_Build.ts | 283 ++++++++++++ .../CostFlow/M02_CF_InputPanel_Table.ts | 323 ++++++++++++++ .../CostFlow/M02_CF_InputPanel_Vars.ts | 201 +++++++++ .../tester/test_m02_cf_inputpanel_build.py | 206 +++++++++ 6 files changed, 1576 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts create mode 100644 resources/tester/test_m02_cf_inputpanel_build.py diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css new file mode 100644 index 000000000..902b91046 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css @@ -0,0 +1,145 @@ +.m02-cfip { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + color: var(--color-text); + font-size: var(--text-body-lg, 1.05rem); +} +.m02-cfip h4, +.m02-cfip h5 { + margin: 0; + color: var(--color-text-secondary); +} +.m02-cfip h4 { + font-size: 1.15em; + color: var(--color-text); +} +.m02-cfip__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} +.m02-cfip__unit { + margin: 0; + font-weight: 600; +} +.m02-cfip__body, +.m02-cfip__chosen, +.m02-cfip__find, +.m02-cfip__stack, +.m02-cfip__list { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} +.m02-cfip__list { + max-height: 28vh; + overflow: auto; +} +.m02-cfip__chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} +.m02-cfip__chip, +.m02-cfip__row { + 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; + cursor: pointer; +} +.m02-cfip__chip { + border-radius: var(--radius-pills); + padding: var(--spacing-4) var(--spacing-12); +} +.m02-cfip__chip.is-on { + border-color: var(--color-accent); + outline: 2px solid var(--color-accent); +} +.m02-cfip__chip:hover, +.m02-cfip__chip:focus-visible, +.m02-cfip__row:hover, +.m02-cfip__row:focus-visible { + border-color: var(--color-accent); +} +.m02-cfip__row { + display: flex; + flex: 1 1 auto; + gap: var(--spacing-8); + align-items: center; + justify-content: space-between; + text-align: left; +} +.m02-cfip__rowbox { + display: flex; + gap: var(--spacing-8); + align-items: center; +} +.m02-cfip__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} +.m02-cfip__bar > :last-child { + flex: 1 1 180px; +} +.m02-cfip__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); +} +.m02-cfip__name { + flex: 1 1 auto; + min-width: 0; +} +.m02-cfip__value { + color: var(--color-accent); +} +.m02-cfip__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-size: var(--text-caption); +} +.m02-cfip__new, +.m02-cfip__form { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); +} +.m02-cfip__new > summary { + cursor: pointer; + font-weight: 600; +} +.m02-cfip__preview table { + border-collapse: collapse; + font-size: var(--text-body-sm); +} +.m02-cfip__preview th, +.m02-cfip__preview td { + padding: 2px 8px; + border: 1px solid var(--color-border); + white-space: nowrap; + text-align: center; +} +.m02-cfip__preview tr.is-hit td { + background: rgba(240, 190, 40, 0.35); + font-weight: 700; +} +.m02-cfip__preview { + max-height: 24vh; + overflow: auto; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts new file mode 100644 index 000000000..f6d357350 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts @@ -0,0 +1,418 @@ +/* ============================================================================= + * M02_CF_InputPanel.ts + * 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) · + * 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위 + * M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/cf/user-elements` = 시험 폴더 `_data`) + * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { + fetchFiles, + fetchRows, + fetchSubs, + type SubInfo, +} from "../../M01_MasterData/M01_MasterData_Api_Fetch"; +import { cfPlain, cfUserElementAdd, cfUserElements, cfValues, type CfQuery } from "./M02_CF_Api"; +import type { CfBox, CfGroup, CfLogic } from "./M02_CF_Model"; +import { + GROUPS, + M01_OF, + USER_PREFIX, + addElement, + elementOf, + machineFields, + queryOf, + removeElement, + setKind, + valueOf, + type M01Row, +} from "./M02_CF_InputPanel_Build"; +import { renderTablePick } from "./M02_CF_InputPanel_Table"; +import { renderVariables } from "./M02_CF_InputPanel_Vars"; +import "./M02_CF_InputPanel.css"; + +const TEXT = { + Kind: ["무엇을 넣을까요?", "What goes in?"], + Variable: ["변수", "Variable"], + 인력: ["인력", "Labor"], + 재료: ["재료", "Material"], + 유가전력: ["유가·전력", "Fuel · power"], + 기계: ["기계", "Machine"], + 소요량: ["소요량", "Quantity table"], + 계수: ["계수", "Factor table"], + 환율: ["환율", "Exchange rate"], + 요율: ["요율", "Rate"], + Chosen: [ + "넣은 요소 — 알약마다 이름 · 값 · 단위", + "Chosen elements — name · value · unit per pill", + ], + NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"], + Remove: ["빼기", "Remove"], + Put: ["넣기", "Add"], + Search: ["이름으로 찾기", "Search by name"], + AllSub: ["전체", "All"], + SubLabel: ["구분", "Group"], + DetailLabel: ["상세구분", "Detail"], + Loading: ["찾는 중…", "Searching…"], + NoRow: ["찾는 요소 없음", "Nothing found"], + Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], + Mine: ["내가 만든 것", "Mine"], + NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"], + NewName: ["이름", "Name"], + NewSpec: ["규격", "Spec"], + NewUnit: ["단위", "Unit"], + NewValue: ["값(숫자)", "Value (number)"], + Make: ["만들어 넣기", "Create and add"], + NeedName: ["이름을 적어 주세요", "Enter a name"], + NeedValue: ["값은 숫자로", "Value must be a number"], + BoxUnit: ["상자 단위", "Box unit"], + Field: ["값 칸", "Value"], + NoValue: ["—", "—"], + Failed: ["불러오지 못함", "Could not load"], + Pills: ["알약 {n} 개", "{n} pills"], + NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"], +} 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(8)).toLocaleString("ko-KR"); + +/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */ +const plain = (e: unknown): string => { + try { + return cfPlain(e); + } catch { + return e instanceof Error ? e.message : t("Failed"); + } +}; + +const fileCache = new Map(); +/** 그룹의 M01 파일 이름 */ +async function fileOf(group: CfGroup): Promise { + const hit = fileCache.get(group); + if (hit) return hit; + const { group: g, part } = M01_OF[group]; + const files = await fetchFiles(g); + const f = files.find((x) => !part || x.file.includes(part)) ?? files[0]; + fileCache.set(group, f.file); + return f.file; +} + +/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */ +export function renderInputPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, +): void { + host.className = "m02-cfip"; + const box = lg.boxes.find((b) => b.id === boxId); + if (!box || box.kind !== "input") { + host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") })); + return; + } + const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; + const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력"); + const body = el("div", { className: "m02-cfip__body" }); + + // ① 갈래 — 그룹 여덟 + 변수 + const kinds = el("div", { + className: "m02-cfip__chips", + attrs: { role: "tablist", "aria-label": t("Kind") }, + children: [...GROUPS, "변수" as const].map((k) => { + const b = el("button", { + className: `m02-cfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`, + attrs: { type: "button", role: "tab" }, + text: k === "변수" ? t("Variable") : t(k), + }); + b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k))); + return b; + }), + }); + const unit = el("p", { + className: "m02-cfip__unit", + text: `${t("BoxUnit")}: ${box.unit || "-"}`, + }); + host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); + + if (kind === "변수") renderVariables(body, lg, boxId, on); + else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); + else renderGroup(body, lg, box, kind, on); +} + +/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */ +function renderGroup( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): void { + const chosen = el("div", { className: "m02-cfip__chosen" }); + const find = el("div", { className: "m02-cfip__find" }); + host.replaceChildren(chosen, find, newForm(lg, box, group, on)); + drawChosen(chosen, lg, box, group, on); + void drawFind(find, lg, box, group, on); +} + +/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ +function drawChosen( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): void { + const els = box.input?.elements ?? []; + const values = new Map(); + host.replaceChildren( + el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }), + ...(els.length + ? els.map((e) => { + const val = el("strong", { className: "m02-cfip__value", text: t("NoValue") }); + values.set(e.ref, val); + return el("div", { + className: "m02-cfip__item", + children: [ + el("span", { className: "m02-cfip__name", text: e.name }), + val, + el("span", { className: "m02-cfip__muted", text: e.unit || "-" }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(removeElement(lg, box.id, e.ref)), + }), + ], + }); + }) + : [el("p", { className: "m02-cfip__muted", text: t("NoneChosen") })]), + ); + if (!els.length) return; + const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }) as unknown as CfQuery); + void cfValues(items).then( + (answers) => + answers.forEach((a) => { + const node = values.get(a.id); + if (node && a.ok && a.value !== null && a.value !== undefined) + node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value); + }), + () => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」 + ); +} + +/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */ +async function drawFind( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): Promise { + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m02-cfip__list" }); + const count = el("span", { className: "m02-cfip__muted" }); + const bar: HTMLElement[] = []; + let file = ""; + let sub = ""; + let detail = ""; + let subs: SubInfo[] = []; + const all = { value: "", text: t("AllSub") }; + const subBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("SubLabel"), + onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()), + }); + const detailBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("DetailLabel"), + onChange: (v) => ((detail = v), void load()), + }); + const fillDetail = (): void => { + const d = subs.find((s) => s.name === sub)?.details ?? []; + detailBox.root.hidden = !d.length; + detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], ""); + }; + fillDetail(); + let seq = 0; + const load = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") })); + try { + const word = q.input.value.trim(); + const [page, users] = await Promise.all([ + fetchRows(file, 1, 40, word, false, sub, detail), + cfUserElements() + .then((u) => + u.filter((x) => (x.group as string) === group || (x.group as string) === "사용자"), + ) + .catch(() => []), + ]); + if (mine !== seq) return; + const mineRows: M01Row[] = users + .filter((u) => !word || `${u.이름} ${u.규격}`.includes(word)) + .map((u) => ({ + 키: u.key, + 이름: u.이름, + 규격: u.규격, + 단위: u.단위, + 값: u.값, + __mine: true, + })); + const rows = [...mineRows, ...(page.rows as M01Row[])]; + count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length }); + list.replaceChildren( + ...(rows.length + ? rows.map((r) => rowOf(r)) + : [el("p", { className: "m02-cfip__muted", text: t("NoRow") })]), + ); + } catch (e) { + if (mine === seq) + list.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) })); + } + }; + const rowOf = (r: M01Row): HTMLElement => { + const fields = group === "기계" ? machineFields(r) : []; + const field = createSelectField({ + options: fields.map((f) => ({ value: f, text: f })), + value: fields.includes("가격") ? "가격" : (fields[0] ?? ""), + compact: true, + ariaLabel: t("Field"), + }); + const pick = (): CfLogic => + addElement(lg, box.id, elementOf(group, r, field.select.value || "가격")); + const v = valueOf(group, r, field.select.value || "가격"); + const unitText = group === "기계" ? "" : String(r["단위"] ?? ""); + const b = el("button", { + className: "m02-cfip__row", + attrs: { type: "button" }, + children: [ + el("span", { + className: "m02-cfip__name", + children: [ + el("strong", { text: String(r["이름"] ?? "") }), + el("span", { className: "m02-cfip__muted", text: ` ${r["규격"] ?? ""}` }), + ...(r["__mine"] ? [el("span", { className: "m02-cfip__tag", text: t("Mine") })] : []), + ], + }), + el("span", { + className: "m02-cfip__muted", + text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(), + }), + ], + }); + b.addEventListener("click", () => on.change(pick())); + return el("div", { + className: "m02-cfip__rowbox", + children: [b, ...(fields.length ? [field.root] : [])], + }); + }; + try { + file = await fileOf(group); + subs = await fetchSubs({ file }).catch(() => []); + subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], ""); + subBox.root.hidden = !subs.length; + bar.push(subBox.root, detailBox.root); + } catch (e) { + host.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) })); + return; + } + let timer = 0; + q.input.addEventListener( + "input", + () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), + ); + host.replaceChildren( + el("div", { className: "m02-cfip__bar", children: [...bar, q.root] }), + count, + list, + ); + void load(); + void USER_PREFIX; +} + +/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */ +function newForm( + lg: CfLogic, + box: CfBox, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): HTMLElement { + const name = createInputField({ type: "text", label: t("NewName"), compact: true }); + const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true }); + const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true }); + const value = createInputField({ type: "text", label: t("NewValue"), compact: true }); + const go = createButton({ + label: t("Make"), + onClick: () => { + const n = + value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, "")); + if (!name.input.value.trim()) return name.setError(t("NeedName")); + if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue")); + name.setError(undefined); + value.setError(undefined); + cfUserElementAdd({ + group, + 이름: name.input.value.trim(), + 규격: spec.input.value.trim(), + 단위: unit.input.value.trim(), + 값: n, + }).then( + (made) => { + const el1 = elementOf(group, { + 키: made.key, + 이름: made.이름, + 규격: made.규격, + 단위: made.단위, + 값: made.값, + }); + const withUser: CfLogic = { + ...lg, + userElements: [ + ...lg.userElements, + { + id: made.key, + group, + name: made.이름, + spec: made.규격, + unit: made.단위, + value: made.값, + }, + ], + }; + on.change(addElement(withUser, box.id, el1)); + }, + (e) => showToast(plain(e), "error"), + ); + }, + }); + const d = el("details", { + className: "m02-cfip__new", + children: [ + el("summary", { text: t("NewHead") }), + name.root, + spec.root, + unit.root, + value.root, + go, + ], + }); + return d; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts new file mode 100644 index 000000000..d811a65ef --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts @@ -0,0 +1,283 @@ +/* ============================================================================= + * M02_CF_InputPanel_Build.ts + * 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic) + * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_CF_InputPanel.ts` + * ========================================================================== */ + +import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_CF_Model"; + +/** 입력 상자가 고르는 그룹 — 화면 차례 */ +export const GROUPS: CfGroup[] = [ + "인력", + "재료", + "유가전력", + "기계", + "소요량", + "계수", + "환율", + "요율", +]; + +/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */ +export const M01_OF: Record = { + 인력: { group: "인력", part: "" }, + 재료: { group: "재료", part: "자재품목" }, + 유가전력: { group: "재료", part: "유가전력" }, + 기계: { group: "기계", part: "" }, + 소요량: { group: "소요량", part: "" }, + 계수: { group: "계수", part: "" }, + 환율: { group: "환율", part: "" }, + 요율: { group: "요율", part: "" }, +}; + +/** M01 줄 한 개 — 파일 그대로(한글 칸) */ +export type M01Row = Record; + +/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */ +export const USER_PREFIX = "UE-"; + +/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */ +export const MACHINE_SEP = "#"; +export const machineFields = (row: M01Row): string[] => + row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : []; + +const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); +const dictUnit = (row: M01Row, field: string): string => + row["단위"] && typeof row["단위"] === "object" + ? text((row["단위"] as Record)[field]) + : ""; + +/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */ +export function lowest(row: M01Row): number | null { + const found = ["물가자료", "유통물가", "물가정보", "거래가격"] + .map((k) => row[k]) + .filter((v): v is number => typeof v === "number"); + return found.length ? Math.min(...found) : null; +} + +/** 줄 옆에 보일 값 — 모르면 null */ +export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null { + if (group === "재료") return lowest(row); + if (group === "기계") + return typeof (row["값"] as Record | undefined)?.[field] === "number" + ? (row["값"] as Record)[field] + : null; + if (group === "요율") { + const v = row["값"] as Record | undefined; + return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null; + } + return typeof row["값"] === "number" ? row["값"] : null; +} + +/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */ +export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef { + const key = text(row["키"]); + const spec = text(row["규격"]); + if (key.startsWith(USER_PREFIX)) + return { + ref: key, + name: [text(row["이름"]), spec].filter(Boolean).join(" "), + spec, + unit: text(row["단위"]) || "-", + }; + const named = [text(row["이름"]), spec].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게) + if (group === "기계") + return { + ref: `${key}${MACHINE_SEP}${field}`, + name: `${named} · ${field}`, + spec, + unit: dictUnit(row, field) || "-", + }; + if (group === "요율") + return { + ref: key, + name: text(row["이름"]), + spec: "", + unit: valueOf(group, row) === null ? "-" : "%", + }; + return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" }; +} + +/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */ +export function pillNames(box: CfBox): string[] { + const i = box.input; + if (!i) return []; + if (i.table) return ["표"]; + return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); +} + +/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */ +export function queryOf(group: CfGroup, ref: string): Record { + if (ref.startsWith(USER_PREFIX)) return { group: "사용자", key: ref }; + if (group === "기계") { + const [key, field] = ref.split(MACHINE_SEP); + return { group, key, field: field || "가격" }; + } + return { group, key: ref }; +} + +/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ +export function unitOfInput(input: CfInput): string { + if (input.table) return "-"; + const units = ( + input.mode === "variable" + ? input.variables.map((v) => v.unit) + : input.elements.map((e) => e.unit) + ).map((u) => u || "-"); + return units.length && units.every((u) => u === units[0]) ? units[0] : "-"; +} + +/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkVariable(v: CfVariable, others: CfVariable[]): string | null { + const name = v.name.trim(); + if (!name) return "이름을 적어 주세요"; + if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음"; + if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상"; + if (v.type === "number") { + if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함"; + if ( + typeof v.example === "number" && + v.range && + (v.example < v.range[0] || v.example > v.range[1]) + ) + return "예시값이 범위 밖"; + if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자"; + } + if ( + v.type === "choice" && + v.example !== null && + !(v.choices ?? []).map(String).includes(String(v.example)) + ) + return "예시값이 선택 목록에 없음"; + return null; +} + +/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */ + +type Pills = (box: CfBox) => string[]; +const defaultPills: Pills = pillNames; + +const touch = ( + lg: CfLogic, + id: string, + fn: (b: CfBox) => CfBox, + pills: Pills = defaultPills, +): CfLogic => { + const old = lg.boxes.find((b) => b.id === id); + if (!old) return lg; + const next = fn(old); + const boxes = lg.boxes.map((b) => + b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b, + ); + // 알약이 없어진 선은 지움 + const keep = new Set(pills(next)); + const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name)); + return { ...lg, boxes, links }; +}; + +const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; + +/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ +export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => { + const cur = inputOf(b); + if (kind === "변수") + return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } }; + const same = cur.mode === "group" && cur.group === kind; + return { + ...b, + input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] }, + }; + }, + pills, + ); +} + +/** 요소 넣기 — 같은 요소는 한 번만 */ +export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => { + const cur = inputOf(b); + return cur.elements.some((e) => e.ref === el.ref || e.name === el.name) + ? b + : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; + }, + pills, + ); +} + +export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => ({ + ...b, + input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) }, + }), + pills, + ); +} + +/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */ +export function setTable( + lg: CfLogic, + id: string, + table: { key: string; row?: number } | null, + name?: string, + pills?: Pills, +): CfLogic { + const next = touch( + lg, + id, + (b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }), + pills, + ); + return name + ? { + ...next, + boxes: next.boxes.map((b) => + b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b, + ), + } + : next; +} + +/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */ +export function putVariable( + lg: CfLogic, + id: string, + v: CfVariable, + oldName?: string, + pills?: Pills, +): CfLogic { + return touch( + lg, + id, + (b) => { + const cur = inputOf(b); + const vars = + oldName === undefined + ? [...cur.variables, v] + : cur.variables.map((x) => (x.name === oldName ? v : x)); + return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } }; + }, + pills, + ); +} + +export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => ({ + ...b, + input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) }, + }), + pills, + ); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts new file mode 100644 index 000000000..7f50c0590 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts @@ -0,0 +1,323 @@ +/* ============================================================================= + * M02_CF_InputPanel_Table.ts + * 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나) + * `M02_CF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래 + * ========================================================================== */ + +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 type { CfGroup, CfLogic } from "./M02_CF_Model"; +import { setTable } from "./M02_CF_InputPanel_Build"; + +const TEXT = { + Book: ["적용 품셈", "Standard"], + Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], + Loading: ["찾는 중…", "Searching…"], + NoSection: ["찾는 절 없음", "No section"], + SecLine: ["표 {t} 개", "{t} tables"], + Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], + NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"], + TableLine: [ + "{b} · 조건 {c} · 결과 {v} · {n} 줄", + "{b} · {c} conditions · {v} results · {n} rows", + ], + Chosen: ["고른 표", "Chosen table"], + Whole: [ + "표 통째 — 함수 상자가 조건에 맞는 줄을 찾음", + "Whole table — a function box looks it up", + ], + OneRow: ["줄 하나 고정", "Fix one row"], + RowNo: ["몇 번째 줄", "Row number"], + Preview: ["미리 보기", "Preview"], + Notes: ["주(註)", "Notes"], + Remove: ["표 빼기", "Remove table"], + Change: ["다른 표 고르기", "Pick another 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 Error(String(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; + 조건: Record; + 값칸: Record; + 줄: Record[]; + 주?: string[]; +} + +const BOOKS = ["산림품셈", "건설품셈"]; +const files = new Map(); +const tables = new Map(); + +/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */ +export async function loadTable(group: CfGroup, key: string): Promise { + const hit = tables.get(key); + if (hit) return hit; + let file = files.get(key); + if (!file) { + 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 raw = (await get<{ table: RawTable }>("/table", { file, key })).table; + tables.set(key, raw); + return raw; +} + +const cell = (v: unknown): string => + Array.isArray(v) + ? v.map((x) => x ?? "").join("~") + : v === null || v === undefined + ? "" + : String(v); + +/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */ +function previewOf(tb: RawTable, row?: number): HTMLElement { + const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; + const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1)); + return el("div", { + className: "m02-cfip__preview", + children: [ + el("table", { + children: [ + el("tr", { children: cols.map((c) => el("th", { text: c })) }), + ...body.map((r, i) => + el("tr", { + className: i === row ? "is-hit" : "", + children: cols.map((c) => el("td", { text: cell(r[c]) })), + }), + ), + ], + }), + ...(tb.줄.length > body.length + ? [el("p", { className: "m02-cfip__muted", text: `… ${tb.줄.length} 줄` })] + : []), + ...(tb.주?.length + ? [ + el("details", { + children: [ + el("summary", { text: t("Notes") }), + ...tb.주.map((n) => el("p", { text: n })), + ], + }), + ] + : []), + ], + }); +} + +/** 소요량 · 계수 표 고르기 화면 */ +export function renderTablePick( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + on: { change(lg: CfLogic): void }, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + const cur = box?.input?.table; + if (cur) return void showChosen(host, lg, boxId, group, cur, on); + 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: "m02-cfip__list" }); + const tableList = el("div", { className: "m02-cfip__list" }); + let seq = 0; + const load = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") })); + try { + const r = await get<{ sections: SectionItem[] }>("/sections", { + 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: "m02-cfip__row", + attrs: { type: "button" }, + children: [ + el("strong", { text: `${s.section} ${s.title}` }), + el("span", { + className: "m02-cfip__muted", + text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, + }), + ], + }); + b.addEventListener("click", () => void pickSection(s)); + return b; + }) + : [el("p", { className: "m02-cfip__muted", text: t("NoSection") })]), + ); + } catch (e) { + if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error"); + } + }; + const pickSection = async (s: SectionItem): Promise => { + try { + const heads = ( + await get<{ tables: TableHead[] }>("/tables", { + group, + section: s.section, + sub: book, + size: "100", + }) + ).tables; + tableList.replaceChildren( + el("h5", { text: t("Tables") }), + ...(heads.length + ? heads.map((h) => { + const b = el("button", { + className: "m02-cfip__row", + attrs: { type: "button" }, + children: [ + el("strong", { text: h.이름 }), + el("span", { + className: "m02-cfip__muted", + text: t("TableLine", { + b: h.기준 || "—", + c: Object.keys(h.조건).length, + v: Object.keys(h.값칸).length, + n: h.count, + }), + }), + ], + }); + b.addEventListener("click", () => { + files.set(h.키, h.file); + void loadTable(group, h.키).then( + (tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)), + (e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"), + ); + }); + return b; + }) + : [el("p", { className: "m02-cfip__muted", text: t("NoTable") })]), + ); + } catch (e) { + showToast(e instanceof Error ? e.message : t("Failed"), "error"); + } + }; + let timer = 0; + q.input.addEventListener( + "input", + () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), + ); + host.replaceChildren(bookBox.root, q.root, list, tableList); + void load(); +} + +/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */ +function showChosen( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + cur: { key: string; row?: number }, + on: { change(lg: CfLogic): void }, +): void { + const body = el("div", { className: "m02-cfip__stack" }); + host.replaceChildren(body); + void loadTable(group, cur.key).then( + (tb) => { + const whole = cur.row === undefined; + const modeWhole = createButton({ + label: t("Whole"), + variant: whole ? "filled" : "ghost", + onClick: () => on.change(setTable(lg, boxId, { key: cur.key })), + }); + const modeOne = createButton({ + label: t("OneRow"), + variant: whole ? "ghost" : "filled", + onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })), + }); + const rowBox = createSelectField({ + label: t("RowNo"), + options: tb.줄.map((r, i) => ({ + value: String(i), + text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)] + .slice(0, 4) + .map((c) => cell(r[c])) + .join(" / ")}`, + })), + value: String(cur.row ?? 0), + compact: true, + onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })), + }); + body.replaceChildren( + el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }), + el("div", { className: "m02-cfip__chips", children: [modeWhole, modeOne] }), + ...(whole ? [] : [rowBox.root]), + el("h5", { text: t("Preview") }), + previewOf(tb, cur.row), + el("div", { + className: "m02-cfip__chips", + children: [ + createButton({ + label: t("Change"), + variant: "ghost", + onClick: () => on.change(setTable(lg, boxId, null)), + }), + ], + }), + ); + }, + (e) => + body.replaceChildren( + el("p", { + className: "m02-cfip__muted", + text: e instanceof Error ? e.message : t("Failed"), + }), + ), + ); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts new file mode 100644 index 000000000..463f91f52 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * M02_CF_InputPanel_Vars.ts + * 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값 + * 변수마다 출력 알약 하나 · `M02_CF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import type { CfLogic, CfVariable } from "./M02_CF_Model"; +import { checkVariable, putVariable, removeVariable } from "./M02_CF_InputPanel_Build"; + +const TEXT = { + List: ["변수 — 알약마다 하나", "Variables — one pill each"], + None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"], + Name: ["이름", "Name"], + Type: ["갈래", "Type"], + Number: ["숫자", "Number"], + Choice: ["선택", "Choice"], + Text: ["글", "Text"], + Unit: ["단위", "Unit"], + Min: ["범위 아래", "Range from"], + Max: ["범위 위", "Range to"], + Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"], + Example: ["예시값", "Example"], + Add: ["변수 넣기", "Add variable"], + Save: ["고치기 끝", "Save change"], + Edit: ["고치기", "Edit"], + Remove: ["삭제", "Remove"], + Cancel: ["취소", "Cancel"], + Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"], + Range: ["범위 {a}~{b}", "range {a}~{b}"], +} 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; + +/** 변수 한 줄 글 */ +export function variableLine(v: CfVariable): string { + const base = t("Line", { + n: v.name, + u: v.unit || "-", + e: v.example === null ? "—" : String(v.example), + }); + const range = + v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : ""; + const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : ""; + return `${base}${range}${choices}`; +} + +export function renderVariables( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + const vars = box?.input?.variables ?? []; + let editing: CfVariable | null = null; + + const form = (): HTMLElement => { + const cur = editing; + const name = createInputField({ + type: "text", + label: t("Name"), + value: cur?.name ?? "", + placeholder: "예: 면적", + compact: true, + }); + const type = createSelectField({ + label: t("Type"), + options: [ + { value: "number", text: t("Number") }, + { value: "choice", text: t("Choice") }, + { value: "text", text: t("Text") }, + ], + value: cur?.type ?? "number", + compact: true, + onChange: () => paint(), + }); + const unit = createInputField({ + type: "text", + label: t("Unit"), + value: cur?.unit ?? "", + placeholder: "예: ㎡", + compact: true, + }); + const lo = createInputField({ + type: "text", + label: t("Min"), + value: cur?.range ? String(cur.range[0]) : "", + compact: true, + }); + const hi = createInputField({ + type: "text", + label: t("Max"), + value: cur?.range ? String(cur.range[1]) : "", + compact: true, + }); + const choices = createInputField({ + type: "text", + label: t("Choices"), + value: (cur?.choices ?? []).join(", "), + compact: true, + }); + const example = createInputField({ + type: "text", + label: t("Example"), + value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example), + compact: true, + }); + const extra = el("div", { className: "m02-cfip__stack" }); + const paint = (): void => { + const ty = type.select.value; + extra.replaceChildren( + ...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []), + ); + }; + paint(); + const submit = (): void => { + const ty = type.select.value as CfVariable["type"]; + const ex = example.input.value.trim(); + const v: CfVariable = { + name: name.input.value.trim(), + type: ty, + unit: unit.input.value.trim(), + example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex, + ...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null + ? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] } + : {}), + ...(ty === "choice" + ? { + choices: choices.input.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } + : {}), + }; + const why = checkVariable( + v, + vars.filter((x) => x.name !== cur?.name), + ); + name.setError(why ?? undefined); + if (why) return; + on.change(putVariable(lg, boxId, v, cur?.name)); + }; + const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit }); + const cancel = cur + ? createButton({ + label: t("Cancel"), + variant: "ghost", + onClick: () => ((editing = null), draw()), + }) + : null; + return el("div", { + className: "m02-cfip__form", + children: [ + name.root, + type.root, + unit.root, + extra, + example.root, + el("div", { className: "m02-cfip__chips", children: cancel ? [ok, cancel] : [ok] }), + ], + }); + }; + + const draw = (): void => { + const list = vars.length + ? vars.map((v) => + el("div", { + className: "m02-cfip__item", + children: [ + el("span", { text: variableLine(v) }), + createButton({ + label: t("Edit"), + variant: "ghost", + size: "sm", + onClick: () => ((editing = v), draw()), + }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(removeVariable(lg, boxId, v.name)), + }), + ], + }), + ) + : [el("p", { className: "m02-cfip__muted", text: t("None") })]; + host.replaceChildren(el("h5", { text: t("List") }), ...list, form()); + }; + draw(); +} diff --git a/resources/tester/test_m02_cf_inputpanel_build.py b/resources/tester/test_m02_cf_inputpanel_build.py new file mode 100644 index 000000000..646ec4bd5 --- /dev/null +++ b/resources/tester/test_m02_cf_inputpanel_build.py @@ -0,0 +1,206 @@ +"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`M02_CF_InputPanel_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 줄 · 상자는 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M02_MasterTemplete" / "CostFlow" / "M02_CF_InputPanel_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((j) => { + if (j.op === "fn") return m[j.name](...j.args); + let lg = j.lg; + for (const [fn, ...args] of j.steps || []) lg = m[fn](lg, ...args); + return lg; +}); +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) + + +def logic() -> dict: + plain = {"color": "#fff", "unit": "-", "x": 0, "y": 0} + return { + "key": None, + "name": "견본", + "resultUnit": "원/㎡", + "userElements": [], + "boxes": [ + { + "id": "b1", + "kind": "input", + "name": "입력", + **plain, + "input": {"mode": "variable", "elements": [], "variables": []}, + }, + {"id": "b2", "kind": "function", "name": "함수", **plain}, + ], + "links": [], + } + + +def call(name: str, *args): + return run([{"op": "fn", "name": name, "args": list(args)}])[0] + + +def edit(steps: list, lg: dict | None = None) -> dict: + return run([{"op": "lg", "lg": lg or logic(), "steps": steps}])[0] + + +LABOR = {"키": "LB000033", "이름": "석공", "규격": "", "단위": "원/일", "값": 268908} +LABOR2 = {"키": "LB000002", "이름": "보통인부", "규격": "", "단위": "원/일", "값": 172068} +MAT = { + "키": "MT1", + "이름": "각형흄관", + "규격": "D=300mm", + "단위": "M", + "물가자료": 30000, + "물가정보": 29600, +} +MACHINE = { + "키": "EQ000001", + "이름": "불도저", + "규격": "7ton", + "단위": {"가격": "천원", "손료계수": "10^-7/시간"}, + "값": {"가격": 76547, "손료계수": 1811}, +} +RATE_ONE = {"키": "RT1", "이름": "산재보험료", "값": {"rate_percent": 3.56}} +RATE_BRACKET = {"키": "RT2", "이름": "고용보험료", "값": {"brackets": []}} + + +def test_요소는_이름과_규격으로_알약_이름이_됨(): + labor = call("elementOf", "인력", LABOR) + assert labor == {"ref": "LB000033", "name": "석공", "spec": "", "unit": "원/일"} + got = call("elementOf", "재료", MAT) + assert got["name"] == "각형흄관 D=300mm" and got["unit"] == "M" + + +def test_기계는_값_칸을_골라_요소가_됨(): + assert call("machineFields", MACHINE) == ["가격", "손료계수"] + got = call("elementOf", "기계", MACHINE, "손료계수") + assert got["ref"] == "EQ000001#손료계수" and got["unit"] == "10^-7/시간" + assert got["name"].endswith("· 손료계수") + assert call("queryOf", "기계", "EQ000001#손료계수") == { + "group": "기계", + "key": "EQ000001", + "field": "손료계수", + } + + +def test_값_읽기_재료는_낮은_값_요율은_비율(): + assert call("valueOf", "재료", MAT) == 29600 + assert call("valueOf", "요율", RATE_ONE) == 3.56 + assert call("valueOf", "요율", RATE_BRACKET) is None + assert call("elementOf", "요율", RATE_ONE)["unit"] == "%" + assert call("elementOf", "요율", RATE_BRACKET)["unit"] == "-" + assert call("valueOf", "기계", MACHINE, "가격") == 76547 + + +def test_사용자_요소는_사용자_그룹으로_물음(): + assert call("queryOf", "인력", "UE-0001") == {"group": "사용자", "key": "UE-0001"} + row = {"키": "UE-0001", "이름": "특수공", "규격": "", "단위": "원/일", "값": 300000} + assert call("elementOf", "인력", row)["unit"] == "원/일" + assert call("elementOf", "요율", {**row, "단위": "%"})["unit"] == "%" + + +def test_상자_단위는_같으면_그_단위_여럿이면_하이픈(): + one = edit([["setKind", "b1", "인력"], ["addElement", "b1", call("elementOf", "인력", LABOR)]]) + assert one["boxes"][0]["unit"] == "원/일" + two = edit([["addElement", "b1", call("elementOf", "인력", LABOR2)]], one) + assert two["boxes"][0]["unit"] == "원/일" and len(two["boxes"][0]["input"]["elements"]) == 2 + oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} + assert edit([["addElement", "b1", oil]], two)["boxes"][0]["unit"] == "-" + + +def test_같은_요소_이름은_한_번만(): + el = call("elementOf", "인력", LABOR) + lg = edit( + [ + ["setKind", "b1", "인력"], + ["addElement", "b1", el], + ["addElement", "b1", el], + ["addElement", "b1", {**el, "ref": "다른"}], + ] + ) + assert len(lg["boxes"][0]["input"]["elements"]) == 1 + + +def test_요소를_빼면_그_알약에서_나간_선도_지움(): + el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) + base = edit([["setKind", "b1", "인력"], ["addElement", "b1", el1], ["addElement", "b1", el2]]) + base["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "석공"}, "to": {"box": "b2", "name": "a"}}, + {"id": "l2", "from": {"box": "b1", "name": "보통인부"}, "to": {"box": "b2", "name": "b"}}, + ] + out = edit([["removeElement", "b1", "LB000033"]], base) + assert [x["id"] for x in out["links"]] == ["l2"] + assert [e["name"] for e in out["boxes"][0]["input"]["elements"]] == ["보통인부"] + + +def test_갈래를_바꾸면_고른_것을_비움_표는_요소와_함께_못_둠(): + el = call("elementOf", "인력", LABOR) + lg = edit([["setKind", "b1", "인력"], ["addElement", "b1", el], ["setKind", "b1", "재료"]]) + assert lg["boxes"][0]["input"]["group"] == "재료" + assert lg["boxes"][0]["input"]["elements"] == [] + t = edit([["setKind", "b1", "소요량"], ["setTable", "b1", {"key": "QF000421"}, "메쌓기(인력)"]]) + assert t["boxes"][0]["input"]["table"] == {"key": "QF000421"} + assert t["boxes"][0]["name"] == "메쌓기(인력)" and t["boxes"][0]["unit"] == "-" + row = edit([["setTable", "b1", {"key": "QF000421", "row": 3}]], t) + assert row["boxes"][0]["input"]["table"] == {"key": "QF000421", "row": 3} + assert call("pillNames", row["boxes"][0]) == ["표"] + + +def test_변수_넣기_고치기_삭제_알약_이름(): + v = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} + lg = edit([["setKind", "b1", "변수"], ["putVariable", "b1", v]]) + box = lg["boxes"][0] + assert box["input"]["mode"] == "variable" and box["unit"] == "㎡" + assert call("pillNames", box) == ["면적"] + renamed = edit([["putVariable", "b1", {**v, "name": "연장", "unit": "m"}, "면적"]], lg) + assert call("pillNames", renamed["boxes"][0]) == ["연장"] and renamed["boxes"][0]["unit"] == "m" + gone = edit([["removeVariable", "b1", "연장"]], renamed) + assert gone["boxes"][0]["input"]["variables"] == [] and gone["boxes"][0]["unit"] == "-" + + +def test_변수_검사는_쉬운_말(): + ok = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} + assert call("checkVariable", ok, []) is None + assert call("checkVariable", {**ok, "name": " "}, []) == "이름을 적어 주세요" + assert call("checkVariable", ok, [ok]) == "같은 이름의 변수가 이미 있음" + assert call("checkVariable", {**ok, "range": [9, 1]}, []) == "범위는 아래가 위보다 작아야 함" + assert call("checkVariable", {**ok, "example": 5000}, []) == "예시값이 범위 밖" + many = {**ok, "type": "choice", "choices": ["가"]} + assert call("checkVariable", many, []) == "선택 목록은 둘 이상" + bad = {**ok, "type": "choice", "choices": ["가", "나"], "example": "다"} + assert call("checkVariable", bad, []) == "예시값이 선택 목록에 없음"