From 65e3c919ca265dccabaa781dcd80d3f8122ffe2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:42:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-df):=20=ED=91=9C=EC=A4=80=20=ED=91=9C?= =?UTF-8?q?=20=ED=8C=90=20=C2=B7=20standardsList=20=E2=80=94=20=EB=91=90?= =?UTF-8?q?=20=ED=91=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=B9=B8=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=98=EA=B8=B0=20=C2=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=EC=B2=98=EC=9D=8C=20=EA=B0=92=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_DF_Standards.ts — renderStandards(host, on) · standardsList()(단위 고르기 · 끝수 제안 · 동기) · loadStandards · 서버 GET/PUT/DELETE /api/m02/df/standards - 시험 test_m02_df_standards.py 에 standardsList 목록(단위 반올림 · 금액 지위 → 자리 · 버림) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- .../DataFlow/M02_DF_Standards.css | 57 +++ .../DataFlow/M02_DF_Standards.ts | 350 ++++++++++++++++++ resources/tester/test_m02_df_standards.py | 65 ++++ 3 files changed, 472 insertions(+) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Standards.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Standards.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.css b/M02_MasterTemplete/DataFlow/M02_DF_Standards.css new file mode 100644 index 000000000..066d8dc0f --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Standards.css @@ -0,0 +1,57 @@ +/* 단가산출 흐름도 — 표준 표 판(M02_DF_Standards.ts) · 단추 · 칸 크기는 공용 그대로(덮지 않음) */ +.m02-df-std { + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); +} + +.m02-df-std__head { + display: flex; + align-items: center; + gap: var(--spacing-8, 8px); +} + +.m02-df-std__head h3 { + margin: 0 auto 0 0; +} + +.m02-df-std__state, +.m02-df-std__hint, +.m02-df-std__line { + color: var(--color-text-muted, #6b7280); +} + +.m02-df-std__hint { + margin: 0; +} + +.m02-df-std__scroll { + overflow-x: auto; +} + +.m02-df-std__table { + border-collapse: collapse; + width: 100%; +} + +.m02-df-std__table th, +.m02-df-std__table td { + border: 1px solid var(--color-border, #e5e7eb); + padding: 2px 6px; + text-align: left; + white-space: nowrap; +} + +.m02-df-std__table td[tabindex] { + cursor: pointer; +} + +.m02-df-std__table th { + background: var(--color-surface-muted, rgba(127, 127, 127, 0.08)); +} + +.m02-df-std__notes { + margin: 0; + padding-left: 0; + list-style: none; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts new file mode 100644 index 000000000..a94582b9b --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts @@ -0,0 +1,350 @@ +/* ============================================================================= + * M02_DF_Standards.ts + * 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12) + * 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침) + * 서버 = `GET · PUT · DELETE /api/m02/df/standards` → {unit, money, saved} · 고친 것은 `_data`(마스터에 안 씀) + * `renderStandards(host, on)` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로) + * `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것) + * 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import unitDefault from "./_standard/단위표준.json"; +import moneyDefault from "./_standard/금액단위표준.json"; +import "./M02_DF_Standards.css"; + +const TEXT = { + Title: ["표준 표", "Standards"], + UnitTitle: ["설계서의 단위 및 소수의 표준", "Units and decimals in design documents"], + MoneyTitle: ["금액의 단위표준", "Money rounding units"], + Source: ["원문 · 산림품셈 1-2-2", "Source · Forest standard 1-2-2"], + Hint: [ + "칸을 눌러 고침 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 고친 것은 이 시험판에만 저장", + "Click a cell to edit · decimals “-” = 0 · unit “-” = none · edits are saved in this trial only", + ], + Item: ["종목", "Item"], + SpecUnit: ["규격 단위", "Size unit"], + SpecPlaces: ["규격 소수자리", "Size decimals"], + QtyUnit: ["단위수량 단위", "Quantity unit"], + QtyPlaces: ["단위수량 소수자리", "Quantity decimals"], + Note: ["비고", "Note"], + Line: ["원문 줄", "Source line"], + Unit: ["단위", "Unit"], + Step: ["지위", "Step"], + Save: ["저장", "Save"], + Reset: ["처음 값으로", "Back to original"], + ResetAsk: [ + "고친 표준 표를 지우고 원문 처음 값으로 되돌릴까요?", + "Discard edits and go back to the original values?", + ], + Saved: ["표준 표를 저장했어요", "Standards saved"], + Restored: ["처음 값으로 되돌렸어요", "Back to the original values"], + Unsaved: ["저장 안 함", "Not saved"], + Edited: ["고친 표", "Edited"], + Original: ["원문 처음 값", "Original"], + Failed: [ + "표준 표를 못 가져왔어요 — 처음 값을 보여 줘요", + "Could not load — showing the original", + ], + SaveFailed: ["저장하지 못했어요", "Could not save"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Merged: ["위 종목과 한 칸(원문 병합)", "Same cell as the item above (merged in source)"], +} as const; +const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0]; + +/* ── 모양 ─────────────────────────────────────────────────────────────── */ + +export interface UnitStdRow { + 종목: string; + "규격 단위": string | null; + "규격 소수자리": number | null; + "단위수량 단위": string | null; + "단위수량 소수자리": number | null; + 비고: string; + "원문 줄": number; + 병합?: boolean; +} +export interface UnitStd { + 이름: string; + 출처: string; + 원문: string; + 바꿈?: string; + 줄: UnitStdRow[]; + 주: string[]; +} +export interface MoneyStdRow { + 종목: string; + 단위: string; + 지위: number; + 비고: string; + "원문 줄": number; +} +export interface MoneyStd { + 이름: string; + 출처: string; + 원문: string; + 줄: MoneyStdRow[]; +} +export interface Standards { + unit: UnitStd; + money: MoneyStd; + /** `_data` 에 고친 것이 있음 */ + saved: boolean; +} + +/** 단위 고르기 · 끝수 제안이 쓰는 한 줄 */ +export interface StandardItem { + 종목: string; + 갈래: "규격" | "단위수량" | "금액"; + 단위: string; + /** 규격 · 단위수량 = 소수자리 · 금액 = 지위에서 나온 자리(1,000 원 → -3 · 0.1 원 → 1) */ + 소수자리: number; + /** 금액만 — 지위(원) */ + 지위?: number; + 방법: "반올림" | "버림"; +} + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +const ORIGINAL: Standards = { + unit: unitDefault as UnitStd, + money: moneyDefault as MoneyStd, + saved: false, +}; +let current: Standards = clone(ORIGINAL); + +/* ── 목록(동기) ───────────────────────────────────────────────────────── */ + +/** 지위 → 소수자리 — 1,000 원 → -3 · 1 원 → 0 · 0.1 원 → 1 */ +const placesOf = (step: number): number => -Math.round(Math.log10(step)); + +/** 단위 표준(주 ① 반올림) 줄마다 규격 · 단위수량 둘 + 금액(미만 버림) — 단위가 비었거나 「없음」 이면 뺌 */ +export function standardsList(std: Standards = current): StandardItem[] { + const out: StandardItem[] = []; + for (const r of std.unit.줄) { + for (const [갈래, unit, places] of [ + ["규격", r["규격 단위"], r["규격 소수자리"]], + ["단위수량", r["단위수량 단위"], r["단위수량 소수자리"]], + ] as const) { + if (!unit || unit === "없음") continue; + out.push({ 종목: r.종목, 갈래, 단위: unit, 소수자리: places ?? 0, 방법: "반올림" }); + } + } + for (const r of std.money.줄) + out.push({ + 종목: r.종목, + 갈래: "금액", + 단위: r.단위, + 소수자리: placesOf(r.지위), + 지위: r.지위, + 방법: "버림", + }); + return out; +} + +/** 지금 표(판이 서버 값을 받았으면 그것) — 사본 */ +export const standardsNow = (): Standards => clone(current); + +/* ── 서버 ─────────────────────────────────────────────────────────────── */ + +async function call(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { + method, + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as Standards & { detail?: unknown }; + if (!response.ok) throw new Error(String(data.detail ?? response.statusText)); + return data; +} + +/** 서버 표 받기 — 못 받으면 처음 값 */ +export async function loadStandards(): Promise { + current = await call("GET"); + return clone(current); +} + +/* ── 판 ───────────────────────────────────────────────────────────────── */ + +export interface StandardsOn { + /** 저장 · 되돌리기 뒤 — 단위 고르기 등이 새 목록을 다시 읽게 */ + change?(std: Standards): void; +} + +type UnitKey = "규격 단위" | "규격 소수자리" | "단위수량 단위" | "단위수량 소수자리" | "비고"; +type MoneyKey = "단위" | "지위" | "비고"; + +/** 칸 글 → 값 — 소수자리 · 지위는 수(「-」 = 0 · 빈 칸 = null) · 단위는 글(「-」 = 없음 · 빈 칸 = null) */ +function parseCell(key: string, text: string): string | number | null | undefined { + const t = text.trim(); + if (key === "비고") return t; + if (key.includes("소수자리") || key === "지위") { + if (t === "") return key === "지위" ? undefined : null; + if (t === "-") return 0; + const n = Number(t.replace(/,/g, "")); + return Number.isFinite(n) ? n : undefined; + } + if (t === "-") return "없음"; + return t || null; +} + +const show = (v: unknown): string => + v === null || v === undefined ? "" : typeof v === "number" ? v.toLocaleString() : String(v); + +/** 시험판 안 「표준 표」 판 */ +export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void { + let work = clone(current); + let dirty = false; + const state = el("span", { className: "m02-df-std__state" }); + const save = createButton({ label: tx("Save"), size: "sm", onClick: () => void doSave() }); + const reset = createButton({ + label: tx("Reset"), + size: "sm", + variant: "ghost", + onClick: () => void doReset(), + }); + const body = el("div", { className: "m02-df-std__body" }); + + const mark = (): void => { + state.textContent = dirty ? tx("Unsaved") : work.saved ? tx("Edited") : tx("Original"); + save.disabled = !dirty; + }; + + /** 칸 하나 — 누르면 입력 칸 · Enter / 칸 밖 = 끝 · Esc = 취소 */ + const cell = (value: unknown, commit: (text: string) => boolean, title = ""): HTMLElement => { + const td = el("td", { + text: show(value), + attrs: { tabindex: "0", ...(title ? { title } : {}) }, + }); + const edit = (): void => { + if (td.querySelector("input")) return; + const input = el("input", { className: "ui-input ui-input--sm" }); + input.value = show(value).replace(/,/g, ""); + td.replaceChildren(input); + input.focus(); + let done = false; + const finish = (keep: boolean): void => { + if (done) return; + done = true; + if (keep && !commit(input.value)) { + showToast(tx("Bad"), "error"); + done = false; + input.focus(); + return; + } + draw(); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") finish(true); + if (ev.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); + }; + td.addEventListener("click", edit); + td.addEventListener("keydown", (ev) => ev.key === "Enter" && edit()); + return td; + }; + + const table = (heads: string[], rows: HTMLElement[][]): HTMLElement => + el("div", { + className: "m02-df-std__scroll", + children: [ + el("table", { + className: "m02-df-std__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], + }), + el("tbody", { children: rows.map((cells) => el("tr", { children: cells })) }), + ], + }), + ], + }); + + const put = (row: T, key: string, text: string): boolean => { + const v = parseCell(key, text); + if (v === undefined) return false; + (row as Record)[key] = v; + dirty = true; + return true; + }; + + const draw = (): void => { + const unitKeys: UnitKey[] = ["규격 단위", "규격 소수자리", "단위수량 단위", "단위수량 소수자리", "비고"]; // prettier-ignore + const unitRows = work.unit.줄.map((r) => [ + el("td", { text: r.종목, attrs: r.병합 ? { title: tx("Merged") } : {} }), + ...unitKeys.map((k) => cell(r[k], (t) => put(r, k, t))), + el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }), + ]); + const moneyKeys: MoneyKey[] = ["단위", "지위", "비고"]; + const moneyRows = work.money.줄.map((r) => [ + el("td", { text: r.종목 }), + ...moneyKeys.map((k) => cell(r[k], (t) => put(r, k, t))), + el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }), + ]); + body.replaceChildren( + el("h4", { text: tx("UnitTitle") }), + table( + [tx("Item"), tx("SpecUnit"), tx("SpecPlaces"), tx("QtyUnit"), tx("QtyPlaces"), tx("Note"), tx("Line")], // prettier-ignore + unitRows, + ), + el("ol", { + className: "m02-df-std__notes", + children: work.unit.주.map((n) => el("li", { text: n })), + }), + el("h4", { text: tx("MoneyTitle") }), + table([tx("Item"), tx("Unit"), tx("Step"), tx("Note"), tx("Line")], moneyRows), + ); + mark(); + }; + + const take = (std: Standards): void => { + current = std; + work = clone(std); + dirty = false; + draw(); + on.change?.(clone(std)); + }; + + async function doSave(): Promise { + save.disabled = true; + try { + take(await call("PUT", { unit: work.unit, money: work.money })); + showToast(tx("Saved"), "success"); + } catch { + showToast(tx("SaveFailed"), "error"); + mark(); + } + } + + async function doReset(): Promise { + if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return; + try { + take(await call("DELETE")); + } catch { + take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값 + } + showToast(tx("Restored"), "success"); + } + + host.replaceChildren( + el("div", { + className: "m02-df-std", + children: [ + el("div", { + className: "m02-df-std__head", + children: [el("h3", { text: tx("Title") }), state, reset, save], + }), + el("p", { className: "m02-df-std__hint", text: `${tx("Source")} · ${tx("Hint")}` }), + body, + ], + }), + ); + draw(); + void loadStandards().then( + (std) => take(std), + () => showToast(tx("Failed"), "info"), + ); +} diff --git a/resources/tester/test_m02_df_standards.py b/resources/tester/test_m02_df_standards.py index 26af3dd7b..2aae5fe0d 100644 --- a/resources/tester/test_m02_df_standards.py +++ b/resources/tester/test_m02_df_standards.py @@ -78,3 +78,68 @@ def test_금액_다섯_줄() -> None: ] assert all(r["단위"] == "원" and r["비고"] == "미만버림" for r in rows) assert all(_cells(r["원문 줄"])[0] == r["종목"] for r in rows) + + +# ── standardsList(단위 고르기 · 끝수 제안이 씀) — Node 로 `M02_DF_Standards.ts` 를 바로 돌림 ────────── +HOOKS = r""" +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\s+"[^"]+\.css";/g, ""); + src = src.replace(/import\s+(\w+)\s+from\s+"\.\/_standard\/([^"]+)\.json";/g, "const $1 = globalThis.__std[\"$2\"];"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" + + +def _node(body: str) -> object: + import shutil + import subprocess + + assert shutil.which("node") + script = ( + "import { register } from 'node:module';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : k === 'API_BASE_URL' ? '/api' : () => undefined });\n" # noqa: E501 + f"globalThis.__std = {json.dumps({'단위표준': UNIT, '금액단위표준': MONEY}, ensure_ascii=False)};\n" + f"const S = await import({json.dumps((STD.parent / 'M02_DF_Standards.ts').as_uri())});\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-2000:] + return json.loads(result.stdout) + + +def test_standardsList_목록() -> None: + got = _node("out(S.standardsList());") + units = [ + (r["종목"], g, u, p) + for r in UNIT["줄"] + for g, u, p in [ + ("규격", r["규격 단위"], r["규격 소수자리"]), + ("단위수량", r["단위수량 단위"], r["단위수량 소수자리"]), + ] + if u and u != "없음" + ] + assert [ + (x["종목"], x["갈래"], x["단위"], x["소수자리"]) for x in got if x["갈래"] != "금액" + ] == [(n, g, u, p or 0) for n, g, u, p in units] + assert all(x["방법"] == "반올림" for x in got if x["갈래"] != "금액") + money = [x for x in got if x["갈래"] == "금액"] + assert [(x["지위"], x["소수자리"], x["방법"]) for x in money] == [ + (1000, -3, "버림"), + (1, 0, "버림"), + (1, 0, "버림"), + (1, 0, "버림"), + (0.1, 1, "버림"), + ]