From 124a216a2ca4d229b784a3857bdd33d56237fc4b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:02:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(m02):=20=EC=82=AC=EC=A0=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=82=98=EB=AC=B4=20=EB=B6=84=EB=A5=98=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=83=88=20=EC=82=AC=EC=A0=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9(149-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나무 = 구조물군 › 공통 + 종류 뼈대 먼저(빈 폴더도) · 목록 밖 값 뒤 · 미분류 맨 뒤 - 폴더 수 = 문서 수 · 문서 든 폴더만 처음 펼침 · 손으로 펴고 접은 것 기억 - 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성 → 상세 엶(못 쓰는 층은 흐림) - 이름 창(nameDialog) 목록표 밖으로 꺼내 함께 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../M02_MasterTemplete_Labor_Text.ts | 1 + .../M02_MasterTemplete_Library_List.ts | 65 ++++++----- .../M02_MasterTemplete_UI_Side_Precombo.ts | 109 ++++++++++++++---- .../tester/helper_m02_precombo_scope.cjs | 4 +- resources/tester/test_m02_precombo_scope.py | 48 +++++++- 5 files changed, 168 insertions(+), 59 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 8088e75fb..d8d0117d3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -143,6 +143,7 @@ const TEXT = { Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"], Lib_None: ["사전조합 없음", "No pre-combination"], Lib_Unsorted: ["미분류", "Unsorted"], + Lib_NewHere: ["여기에 새 사전조합", "New pre-combination here"], Lib_Create: ["생성", "Create"], Lib_Copy: ["복사", "Copy"], Lib_Delete: ["삭제", "Delete"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 9774566c7..d4d6f2a4a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -59,6 +59,41 @@ const money = (v: number | null | undefined): string => v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름); +/** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */ +export const nameDialog = ( + title: string, + start: string, + onOk: (name: string) => Promise, +): void => { + const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); + name.input.value = start; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal", + mount: (box, close) => { + const ok = createButton({ label: L("M02_Create"), variant: "filled" }); + ok.addEventListener("click", async () => { + const text = name.input.value.trim(); + if (!text) return showToast(L("M02_NameNeeded"), "error"); + ok.disabled = true; // 연달아 눌러도 한 번만 + try { + await onOk(text); + close(); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } finally { + ok.disabled = false; + } + }); + name.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) ok.click(); + }); + box.append(name.root, ok); + }, + }); +}; + /** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */ export function mountLibraryList( host: HTMLElement, @@ -142,36 +177,6 @@ export function mountLibraryList( draw(); }; - const nameDialog = (title: string, start: string, onOk: (name: string) => Promise) => { - const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); - name.input.value = start; - openModal({ - title, - closeLabel: L("M02_Close"), - dialogClass: "m02-modal", - mount: (box, close) => { - const ok = createButton({ label: L("M02_Create"), variant: "filled" }); - ok.addEventListener("click", async () => { - const text = name.input.value.trim(); - if (!text) return showToast(L("M02_NameNeeded"), "error"); - ok.disabled = true; // 연달아 눌러도 한 번만 - try { - await onOk(text); - close(); - } catch (error) { - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - } finally { - ok.disabled = false; - } - }); - name.input.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !event.isComposing) ok.click(); - }); - box.append(name.root, ok); - }, - }); - }; - /** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */ const made = (key: string, label: string): void => { showToast(L("M02_Created"), "success"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index b81254066..a109149ba 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -2,13 +2,18 @@ * M02_MasterTemplete_UI_Side_Precombo.ts * 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류). + * 폴더 = 분류 뼈대 고정(149-1 · 구조물군 › 공통 + 종류 · 빈 폴더도 · 문서 수 · 문서 든 폴더만 펼침) · + * 문서 옮김 = 상세 머리에서 분류 바꿈 · 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성. * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree"; -import { listTemplates, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { listTemplates, newLibrary, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { nameDialog } from "./M02_MasterTemplete_Library_List"; +import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import type { Root } from "./M02_MasterTemplete_Layer"; import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -78,12 +83,18 @@ interface Row { 상세구분?: string | null; } -/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */ +type Classes = { name: string; details: string[] }[]; + +/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) · + * `classes`(149-1) = 분류 뼈대 먼저(그 차례 · 빈 묶음도) → 목록 밖 값은 뒤에 가나다 */ export function groupRows( rows: Row[], unsorted: string, + classes: Classes = [], ): { 구분: string; 상세: Map }[] { - const out = new Map>(); + const out = new Map>( + classes.map((c) => [c.name, new Map(c.details.map((d) => [d, []]))]), + ); for (const row of rows) { const top = row.구분 || ""; const sub = row.구분 ? row.상세구분 || "" : ""; @@ -93,18 +104,30 @@ export function groupRows( } const order = (a: string, b: string): number => !a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true }); - return [...out.keys()].sort(order).map((top) => ({ - 구분: top || unsorted, - 상세: new Map([...out.get(top)!].sort(([a], [b]) => order(a, b))), - })); + const rank = (list: string[], key: string): number => + list.includes(key) ? list.indexOf(key) : list.length; + const tops = classes.map((c) => c.name); + return [...out.keys()] + .sort((a, b) => rank(tops, a) - rank(tops, b) || order(a, b)) + .map((top) => { + const subs = classes.find((c) => c.name === top)?.details ?? []; + return { + 구분: top || unsorted, + 상세: new Map( + [...out.get(top)!].sort(([a], [b]) => rank(subs, a) - rank(subs, b) || order(a, b)), + ), + }; + }); } export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const barHost = el("div"); const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); const leaves = new Map(); - const closed = new Set(); // 접은 폴더 — 다시 그려도 그대로 + const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 let rows: Row[] = []; + let classes: Promise | null = null; // 분류 뼈대(구조물정의 · 한 번 받음 · 못 받으면 다음에 다시) + let shape: Classes = []; let seq = 0; const open = async (row: Row): Promise => { @@ -123,37 +146,67 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { count: null, run: () => void open(row), }); - const folder = (id: string, label: string, children: TreeNode[]): TreeNode => ({ - id, - label, - count: children.length, - folder: true, - open: !closed.has(id), - children, - }); + /** 폴더 메뉴 — [여기에 새 사전조합] = 그 구분 · 상세구분으로 생성 → 나무 다시 · 상세 엶 */ + const make = (구분: string, 상세구분: string): void => { + const at = opt.scope(); + nameDialog(tc("Lib_NewHere"), "", async (name) => { + const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] }; + const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner); + showToast(L("M02_Created"), "success"); + await refresh(); + await open({ 이름: got.이름, 종류: "precombo", 제목: name }); + }); + }; + const docs = (node: TreeNode): number => + node.run ? 1 : (node.children ?? []).reduce((n, c) => n + docs(c), 0); + const folder = ( + id: string, + label: string, + children: TreeNode[], + at: [string, string], + ): TreeNode => { + const count = children.reduce((n, c) => n + docs(c), 0); + return { + id, + label, + count, + folder: true, + open: toggled.get(id) ?? count > 0, + children, + menu: () => [ + { + label: tc("Lib_NewHere"), + run: () => make(...at), + disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), + }, + ], + }; + }; const draw = (): void => { leaves.clear(); - if (!rows.length) + if (!rows.length && !shape.length) return void treeHost.replaceChildren( el("p", { className: "m02-side__empty", text: tc("Lib_None") }), ); - const nodes = groupRows(rows, tc("Lib_Unsorted")).map((g) => - folder( + const unsorted = tc("Lib_Unsorted"); + const nodes = groupRows(rows, unsorted, shape).map((g) => { + const top = g.구분 === unsorted ? "" : g.구분; + return folder( `sub:${g.구분}`, g.구분, [...g.상세].flatMap(([sub, list]) => - sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf))] : list.map(leaf), + sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf), [top, sub])] : list.map(leaf), ), - ), - ); + [top, top ? ([...g.상세.keys()][0] ?? "") : ""], + ); + }); treeHost.replaceChildren( ...renderTree(nodes, (node, caret, depth, onClick) => { const row = createTreeRow(node, caret, depth, () => { onClick(); if (!node.children?.length) return; - if (row.querySelector(".ui-tree__caret")?.textContent === "▸") closed.add(node.id); - else closed.delete(node.id); + toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); }); if (node.run) { row.dataset.precomboKey = node.id.slice(4); @@ -178,14 +231,20 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const at = opt.scope(); barHost.replaceChildren(opt.bar()); treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") })); + const shapeAt = (classes ??= fetchStructureDef().then(precomboClasses, () => { + classes = null; + return []; // 못 받으면 문서 값만으로 묶음(지난 모양) + })); let got: Row[] = []; try { got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo"); } catch { got = []; // 못 받으면 빈 나무(목록표에서 까닭) } + const cls = await shapeAt; if (mine !== seq) return; rows = got; + shape = cls; draw(); }; diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs index 58423a827..e9b06ac38 100644 --- a/resources/tester/helper_m02_precombo_scope.cjs +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -1,7 +1,7 @@ /* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · * `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 · * `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 · - * stdin = {rows, def?} · stdout = {urls, groups, classes} JSON. */ + * `classes`(149-1 분류 뼈대) · stdin = {rows, def?, classes?} · stdout = {urls, groups, classes} JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -45,7 +45,7 @@ global.fetch = async (url) => { await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사 await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); - const groups = side.groupRows(input.rows, "미분류").map((g) => ({ + const groups = side.groupRows(input.rows, "미분류", input.classes ?? []).map((g) => ({ 구분: g.구분, 상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]), })); diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 13ef7a69b..11583f7b0 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -20,10 +20,10 @@ def _text(name: str) -> str: return (M02 / name).read_text(encoding="utf-8") -def _run(rows): +def _run(rows, classes=None): proc = subprocess.run( ["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")], - input=json.dumps({"rows": rows}, ensure_ascii=False), + input=json.dumps({"rows": rows, "classes": classes}, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", @@ -86,3 +86,47 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): assert "getActiveOwner" not in lib and lib.count("opt.owner") >= 6 detail = _text("M02_MasterTemplete_Labor_Detail.ts") assert "opt.owner !== undefined" in detail + + +def test_분류_뼈대_폴더_고정_빈_폴더도_목록_밖_값_뒤_미분류_맨_뒤(): + """149-1 — 분류 차례대로 빈 폴더도 · 목록 밖 지금 값은 뒤 · 미분류는 문서 있을 때만 맨 뒤""" + classes = [ + {"name": "A 횡단배수", "details": ["공통", "배관", "BOX암거"]}, + {"name": "C 사면안정", "details": ["공통", "돌쌓기"]}, + ] + rows = [ + {"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": ""}, + {"이름": "SC-000002", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"}, + {"이름": "SC-000003", "종류": "precombo", "구분": "건설품셈 공통", "상세구분": "07장"}, + {"이름": "SC-000004", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "옛 종류"}, + ] + groups = _run(rows, classes)["groups"] + assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "건설품셈 공통", "미분류"] + assert groups[0]["상세"] == [ + ["공통", []], + ["배관", []], + ["BOX암거", []], + ["옛 종류", ["SC-000004"]], + ] + assert groups[1]["상세"] == [["공통", []], ["돌쌓기", ["SC-000002"]]] + assert groups[3]["상세"] == [["", ["SC-000001"]]] + # 문서 없으면 미분류 폴더 없음 · 뼈대는 그대로 + empty = _run([], classes)["groups"] + assert [g["구분"] for g in empty] == ["A 횡단배수", "C 사면안정"] + + +def test_나무_폴더_문서수_처음_펼침_폴더_메뉴_생성(): + side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + assert "groupRows(rows, unsorted, shape)" in side + assert "fetchStructureDef().then(precomboClasses" in side + assert "const count = children.reduce((n, c) => n + docs(c), 0);" in side # 수 = 문서 수 + assert "open: toggled.get(id) ?? count > 0," in side # 문서 든 폴더만 펼침 + assert "if (!rows.length && !shape.length)" in side # 뼈대 있으면 「없음」 아님 + assert 'label: tc("Lib_NewHere")' in side and "run: () => make(...at)" in side + assert 'disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly")' in side + head = "const make = (구분: string, 상세구분: string): void => {" + make = side.split(head)[1].split("\n };")[0] + assert "const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] };" in make + assert 'newLibrary(at.layer, "precombo", { 문서 }, at.owner)' in make + lib = _text("M02_MasterTemplete_Library_List.ts") + assert "export const nameDialog = (" in lib From 549e0fea69be374426ea2347c5341288d22d237c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:03:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EF=BB=BFfeat(m02):=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EB=AA=A9=EB=A1=9D=ED=91=9C=20=EB=AF=B8?= =?UTF-8?q?=EB=B6=84=EB=A5=98=20=ED=91=9C=EA=B8=B0=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EC=83=88=20=EB=B6=84=EB=A5=98?= =?UTF-8?q?=20=EA=B1=B0=EB=A6=84(149-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록표 빈 구분 = 「미분류」(나무와 같은 말) - [로직 추가] 고르기 창 구분 거름 = 단가산출 로직 품셈 분류 + 사전조합 새 분류(구조물군 › 공통 + 종류) - 일위조합 줄 작은 글 = 새 분류 · 빈 값은 미분류 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../M02_MasterTemplete_Labor_Pick.ts | 18 ++++++++++++++---- .../M02_MasterTemplete_Library_List.ts | 2 +- resources/tester/test_m02_precombo_scope.py | 13 +++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index d684978b9..7dafc26ec 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -2,7 +2,7 @@ * M02_MasterTemplete_Labor_Pick.ts * [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) · * 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} · - * 프로젝트 · 카드엔 서고 줄 없음 + * 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -20,6 +20,8 @@ import { import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { ownOwner } from "./M02_MasterTemplete_Layer"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; @@ -54,6 +56,7 @@ const LAYER_TEXT = { export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void { const subs: SubBrief[] = []; + let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") }); const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); @@ -113,7 +116,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra key: x.row.이름, tag: tc(x.kind), name: x.row.제목 || x.row.이름, - sub: [x.row.이름, `${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim()] + sub: [x.row.이름, `${x.row.구분 || tc("Lib_Unsorted")} ${x.row.상세구분 ?? ""}`.trim()] .filter(Boolean) .join(" · "), unit: x.row.단위 ?? "", @@ -158,9 +161,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra }), ), ); - /** 구분 → 상세구분 — 로직 구분에 서고 줄(승격 · 조합) 것을 더함 · 같은 값은 한 칸 */ + /** 구분 → 상세구분 — 로직 품셈 구분 + 사전조합 새 분류 + 서고 줄 지금 값 · 같은 값은 한 칸 */ const subMap = (): Map> => { - const m = new Map(subs.map((s) => [s.name, new Set(s.details)])); + const m = new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)])); for (const { row } of libRows) { if (!row.구분) continue; if (!m.has(row.구분)) m.set(row.구분, new Set()); @@ -194,6 +197,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra layerBar.style.display = ""; drawLayers(); void loadLib(); + void fetchStructureDef().then( + (def) => { + classes = precomboClasses(def); + drawSubs(); + }, + () => undefined, // 못 받으면 서고 줄 지금 값만 + ); }); const dialog = el("div", { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index d4d6f2a4a..003678691 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -144,7 +144,7 @@ export function mountLibraryList( pick, ...[ row.이름, - row.구분 ?? "", + row.구분 || tc("Lib_Unsorted"), // 빈 구분 = 미분류(149-2 · 나무와 같은 말) row.상세구분 ?? "", row.제목 ?? "", row.단위 ?? "", diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 11583f7b0..c41363ee6 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -130,3 +130,16 @@ def test_나무_폴더_문서수_처음_펼침_폴더_메뉴_생성(): assert 'newLibrary(at.layer, "precombo", { 문서 }, at.owner)' in make lib = _text("M02_MasterTemplete_Library_List.ts") assert "export const nameDialog = (" in lib + + +def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름(): + """149-2 — 목록표 빈 구분 = 미분류 · 고르기 창 거름 = 품셈 구분 + 사전조합 새 분류""" + lib = _text("M02_MasterTemplete_Library_List.ts") + assert 'row.구분 || tc("Lib_Unsorted"),' in lib + pick = _text("M02_MasterTemplete_Labor_Pick.ts") + assert "new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]))" in pick + assert "classes = precomboClasses(def);" in pick + assert 'x.row.구분 || tc("Lib_Unsorted")' in pick # 서고 줄 작은 글 = 새 분류 · 빈 값 미분류 + # 거름은 칸 값 그대로 — 품셈 구분을 고르면 서고 줄 빠지고 새 분류를 고르면 로직 줄 빠짐 + assert "(!sub.select.value || x.row.구분 === sub.select.value)" in pick + assert "(!sub.select.value || x.구분 === sub.select.value)" in pick From 8ad8516717d8fd047df409fc2aaee13f969af615 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:05:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(m02):=20=E3=80=8C=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=8A=B9=EA=B2=A9=E3=80=8D=20=ED=99=94=EB=A9=B4=20=EA=B8=80=20?= =?UTF-8?q?=E2=86=92=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=ED=99=94=E3=80=8D(149-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로직 줄 구분 칸 · 드롭다운 글 = 일위대가화 · 저장 키 `승격` 그대로 - B09 안내 · 빈 표 글은 155(데스크탑 보조) 몫 — 안 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts | 2 +- resources/tester/test_m02_labor_promote_flag.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index d8d0117d3..5b544b2cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -90,7 +90,7 @@ const TEXT = { Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], // 구분(50-4) Kind_Logic: ["단가산출", "Logic"], - Kind_Promote: ["단가승격", "Promoted"], + Kind_Promote: ["일위대가화", "Unit-price item"], Kind_Combo: ["일위조합", "Combination"], Labor_LineDetail: ["상세 — {value}", "Detail — {value}"], Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"], diff --git a/resources/tester/test_m02_labor_promote_flag.py b/resources/tester/test_m02_labor_promote_flag.py index cbb3ac888..9eaac351c 100644 --- a/resources/tester/test_m02_labor_promote_flag.py +++ b/resources/tester/test_m02_labor_promote_flag.py @@ -88,3 +88,16 @@ def test_화면_정리도_참만_통과(): ) rows = json.loads(proc.stdout)["담은로직"] assert [r.get("승격") for r in rows] == [True, None, None] + + +def test_화면_글은_일위대가화_저장_키는_승격(): + """149-3 — 구분 칸 · 드롭다운 글 = 「일위대가화」 · 저장 키 `승격` 그대로""" + from pathlib import Path + + m02 = Path(HERE).parents[1] / "M02_MasterTemplete" + text = (m02 / "M02_MasterTemplete_Labor_Text.ts").read_text("utf-8") + assert 'Kind_Promote: ["일위대가화", "Unit-price item"],' in text + assert "단가승격" not in text + rows = (m02 / "M02_MasterTemplete_Labor_Rows.ts").read_text("utf-8") + assert 'tc("Kind_Promote")' in rows + assert "승격?: boolean;" in (m02 / "M02_MasterTemplete_Labor_Api.ts").read_text("utf-8") From 651dc4b818c3d8fa0063e67ad6f6ad125da90a14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:11:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(m02):=20%=20=EB=8D=A7=EC=A4=84=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=20=E2=80=94=20=EC=9C=A8=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=EB=8D=A7=EC=B9=B8=20=C2=B7=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=96=89=20=C2=B7=20M01=20=EC=8B=9C=ED=97=98=EA=B3=84=EC=82=B0?= =?UTF-8?q?(149-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 `M02_Labor_Doc.pct_extras` = 식 「X * Y / 100」 덧줄에 율(Y) · 규격(노무비의 % · 인건비의 % · 줄 이름의 %) · `run_line` 에서 부름 - M01 `/calc` 도 같은 도우미(늦은 부름) - 화면 `pctView` 한 벌 = 이름 (규격) · 수량 = 율 · 단위 % — M02 하위 행 · M01 시험계산 표 - 금액 · 엔진 줄(식 · 1) 그대로 · 금액 식 덧줄(기계손료 등) 그대로 · 마스터 안 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- M01_MasterData/M01_MasterData_Router.py | 18 +++++- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 11 ++++ .../M01_MasterData_UI_Logic_Calc.ts | 7 ++- M02_MasterTemplete/M02_Labor_Doc.py | 51 +++++++++++++++- .../M02_MasterTemplete_Labor_Rows.ts | 16 +++-- resources/tester/test_149_4_pct_extra.py | 59 +++++++++++++++++++ 6 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_149_4_pct_extra.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 5b0aaa349..cd241f9b7 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -234,7 +234,23 @@ def post_calc(body: CalcBody) -> dict: if got is not None: return got with store.cm.mf.mp.choosing(body.고름): - return _call(store.calc, body.key, body.inputs, body.row, body.file) + got = _call(store.calc, body.key, body.inputs, body.row, body.file) + return _pct(got, body) + + +def _pct(got: dict, body: CalcBody) -> dict: + """% 덧줄 표기(149-4) — 줄에 율 · 규격 덧칸(M02 일위대가 하위 행과 같은 도우미 · 금액 그대로).""" + if not got.get("lines"): + return got + from M02_MasterTemplete import M02_Labor_Doc as labor_doc # M02 가 M01 을 부름 — 늦게 + + whole = store.loaded()[1] + try: + logic = body.row or whole.logic(body.key) + except store.cm.mf.FormulaError: + return got + lines = labor_doc.pct_extras(whole, logic, got["lines"], got.get("middle") or {}) + return {**got, "lines": lines} @router.post("/text") diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index c61432a3f..0dcc8e932 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -92,7 +92,18 @@ export interface CalcLine { 단위경고?: string; /** 재료 줄이 잡은 품목 — 키 · 이름 · 규격 · 단위 · 값 칸 */ 품목?: { 키: string; 이름?: string; 규격?: string; 단위?: string; 값칸?: string }; + /** % 덧줄(149-4 · 서버 `M02_Labor_Doc.pct_extras`) — 율 · 밑수 글(「노무비의 %」 · 복합 = 빈 글) */ + 율?: number; + 규격?: string; } + +/** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */ +export const pctView = ( + x: Pick, +): { name: string; qty: number; unit: string } => + x.율 == null + ? { name: x.이름, qty: x.수량, unit: x.단위 } + : { name: x.규격 ? `${x.이름} (${x.규격})` : x.이름, qty: x.율, unit: "%" }; export type CalcAnswer = | { ok: true; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 98b79eb28..b5b2da676 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -7,6 +7,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { fetchAuto, + pctView, runCalc, runText, searchElements, @@ -226,8 +227,8 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): children: [ el("span", { text: line.출처 - ? `${line.이름} (${line.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` - : line.이름, + ? `${pctView(line).name} (${line.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` + : pctView(line).name, }), ...(line.단위경고 ? [ @@ -239,7 +240,7 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): : []), ], }), - el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }), + el("td", { text: `${formatNumber(pctView(line).qty)} ${pctView(line).unit}` }), el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }), ], }), diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index eb96ae447..faa87a732 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -18,6 +18,8 @@ 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. 승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. +% 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 · +단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수. """ from __future__ import annotations @@ -442,7 +444,54 @@ def run_line( # 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4) money = {k: got[k] for k in (*COSTS, "계")} mats = logic_mats(whole, logic, got["줄"], picks) - return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None} + lines = pct_extras(whole, logic, got["줄"], got.get("중간") or {}) + return {"기준": money, "줄": lines, "재료": mats, "까닭": None} + + +def pct_extras(whole: Any, logic: dict, lines: list[dict], env: dict) -> list[dict]: + """% 덧줄(149-4) — 덧줄 식이 「X * Y / 100」 이면 그 엔진 줄(호표 수 뒤 같은 차례)에 덧칸 + `율` = Y(엔진 마지막 env 로 셈) · `규격` = X 이름(「노무비의 %」 · 인력 줄 합 「인건비의 %」 · + `줄.'이름'` · 중간값 「이름의 %」 · 그 밖 복합 = 빈 글). 다른 덧줄 · 흐름 로직 · 못 셈 = 그대로.""" + if isinstance(logic.get("흐름"), dict): + return lines + out, head = list(lines), len(logic.get("호표") or []) + for n, add in enumerate(logic.get("덧줄") or []): + if head + n >= len(out): + break + try: + node = mf.parse(add.get("식") or "") + except mf.FormulaError: + continue + if not ( + node[0] == "bin" + and node[1] == "/" + and node[3] == ("num", Decimal(100)) + and node[2][0] == "bin" + and node[2][1] == "*" + ): + continue # 금액 식(기계손료 등) · 조건부 % = 「식」 그대로 + base, part = node[2][2], node[2][3] + try: + rate = mf.evaluate(part, dict(env), whole) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + continue + if isinstance(rate, Decimal): + out[head + n] = {**out[head + n], "율": rate, "규격": _pct_base(base)} + return out + + +def _pct_base(node: tuple) -> str: + """% 덧줄 밑수 이름 — B09 대가(`B09_Estimation_Labor_Source._base_of`)와 같은 말.""" + if node[0] == "name": + return f"{node[1]}의 %" # 노무비 · 재료비 · 경비 · 중간값 이름 + if node[0] == "field" and node[1] == ("name", "줄"): + return f"{node[2]}의 %" + parts = list(mf.walk(node)) + if any(x[0] == "ref" and str(x[1]).startswith("LB") for x in parts) and not any( + x[0] == "name" and x[1] in COSTS for x in parts + ): + return "인건비의 %" + return "" def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index adb93289c..ba1edf466 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { pctView } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { @@ -278,12 +279,15 @@ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean) cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null, }; }) - : (line?.줄 ?? []).map((x) => ({ - name: x.출처 ? `${x.이름} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : x.이름, - qty: x.수량, - unit: x.단위, - cost: { ...x.비목, 계: x.금액 } as Record, - })); + : (line?.줄 ?? []).map((x) => { + const shown = pctView(x); // % 덧줄 = 율 · % · 규격(149-4) + return { + name: x.출처 ? `${shown.name} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : shown.name, + qty: shown.qty, + unit: shown.unit, + cost: { ...x.비목, 계: x.금액 } as Record, + }; + }); const blank = (className: string): HTMLElement => el("td", { className, text: "" }); const none = wait ? "…" : (line?.까닭 ?? "—"); return (items.length ? items : [null]).map((x) => diff --git a/resources/tester/test_149_4_pct_extra.py b/resources/tester/test_149_4_pct_extra.py new file mode 100644 index 000000000..d794487a0 --- /dev/null +++ b/resources/tester/test_149_4_pct_extra.py @@ -0,0 +1,59 @@ +"""% 덧줄 표기(PLAN 149-4) — 식 「X * Y / 100」 덧줄 = 덧칸 율 · 규격 · 금액 · 단위 · 수량 그대로. + +- M02 `run_line` → 큰돌쌓기 찰쌓기 제잡비 설치 9 · 안 함 3 · 「노무비의 %」 +- 인력 줄 합 밑수 = 「인건비의 %」 · 호표 줄 밑수 = 「그 줄 이름의 %」 · 금액 식(기계손료) = 덧칸 없음 +- M01 시험계산(`post_calc`) 도 같은 도우미 · 화면 두 곳은 `pctView` 한 벌 +""" + +from decimal import Decimal +from pathlib import Path + +from M01_MasterData import M01_MasterData_Router as m01_router +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor + +ROOT = Path(__file__).resolve().parents[2] + + +def _extra(code: str, given: dict | None = None, name: str | None = None) -> dict: + got = labor.run_line(m01.loaded()[1], code, given or {}) + assert got["까닭"] is None + lines = [x for x in got["줄"] if x.get("단위") == "식" and (name is None or x["이름"] == name)] + assert lines, code + return {**lines[-1], "_기준": got["기준"]} + + +def test_큰돌쌓기_찰쌓기_제잡비_율_설치_9_안함_3_금액_그대로(): + for pipe, rate in (("설치", 9), ("설치 안 함", 3)): + x = _extra("GF000227", {"물빼기파이프": pipe}, "제잡비") + assert x["율"] == Decimal(rate) and x["규격"] == "노무비의 %" + assert x["단위"] == "식" and x["수량"] == 1 # 엔진 줄은 그대로 · 표기는 화면 + labor_sum = x["_기준"]["노무비"] + assert abs(x["금액"] - labor_sum * rate / 100) < Decimal("0.01") + + +def test_인건비_호표줄_밑수_금액식은_덧칸_없음(): + x = _extra("GC000198") + assert x["율"] == 2 and x["규격"] == "인건비의 %" + x = _extra("GF000002", name="잡품") + assert x["규격"] == "휘발유(체인톱)의 %" and x["율"] > 0 + mach = _extra("GC000267", name="기계손료") + assert "율" not in mach and "규격" not in mach + mat = _extra("GC000267", name="잡재료비") + assert mat["규격"] == "재료비의 %" + + +def test_M01_시험계산도_율_규격(): + given = {"물빼기파이프": "설치", "직경": "60", "지역": "전국평균", "보정작업": "해당 없음"} + got = m01_router.post_calc(m01_router.CalcBody(key="GF000227", inputs=given)) + extra = [x for x in got["lines"] if x["이름"] == "제잡비"][0] + assert extra["율"] == 9 and extra["규격"] == "노무비의 %" + + +def test_화면_두_곳_pctView_한_벌(): + api = (ROOT / "M01_MasterData/M01_MasterData_UI_Logic_Api.ts").read_text("utf-8") + assert "x.율 == null" in api and 'qty: x.율, unit: "%"' in api + rows = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts").read_text("utf-8") + assert "const shown = pctView(x);" in rows and "qty: shown.qty," in rows + calc = (ROOT / "M01_MasterData/M01_MasterData_UI_Logic_Calc.ts").read_text("utf-8") + assert "formatNumber(pctView(line).qty)" in calc