From 7e2338a8070dad17cd8f6adbc02d89f9b24bdcf8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 09:42:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=99=BC=EC=AA=BD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=E3=80=8C=EC=9C=A0=EA=B0=80=20=C2=B7=20=EC=A0=84?= =?UTF-8?q?=EB=A0=A5=E3=80=8D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=AC=EB=A3=8C=20=EB=8B=A4=EC=9D=8C=20=EB=94=B0?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EC=97=90=EC=84=9C=EB=8A=94=20=EB=BA=8C=20?= =?UTF-8?q?=C2=B7=20=EC=84=9C=EB=B2=84=20=EC=9E=AC=EB=A3=8C=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= 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_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/M01_MasterData_UI_Side.ts | 67 +++++++++++++++++------- resources/tester/test_m01_side_fuel.py | 44 ++++++++++++++++ 2 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_m01_side_fuel.py diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index cdacbb622..b3e4c612f 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -7,7 +7,7 @@ import { createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { t as L } from "@ui/ui_template_locale"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; @@ -125,11 +125,13 @@ export interface FilterArgs { restore?: boolean; } -const MATERIAL_NAMES: Record = { 유가전력: "유가·전력" }; -/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ +/** 유가 · 전력 — 서버로는 재료 그룹의 한 파일(재료_유가전력.json) · 왼쪽 패널에서는 재료 다음 따로 한 컨테이너 */ const FUEL = "유가전력"; +const FUEL_GROUP = "유가 · 전력"; +const FUEL_TITLE = ["유가 · 전력", "Fuel · power"] as const; const MATERIAL = "자재품목"; -const MATERIAL_ORDER = ["자재품목", "유가전력", "품셈재료"]; +/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ +const MATERIAL_ORDER = ["자재품목", "품셈재료"]; /** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */ export const fileLabel = (file: string): string => @@ -145,8 +147,11 @@ const GROUP_TITLE = { 요율: "M01_GroupRate", } as const; -type Group = keyof typeof GROUP_TITLE; -const GROUPS = Object.keys(GROUP_TITLE) as Group[]; +type Group = keyof typeof GROUP_TITLE | typeof FUEL_GROUP; +const GROUPS: Group[] = ["인력", "재료", FUEL_GROUP, "기계", "소요량", "계수", "환율", "요율"]; +const titleOf = (group: Group): string => + group === FUEL_GROUP ? FUEL_TITLE[currentLanguageIndex === 1 ? 1 : 0] : L(GROUP_TITLE[group]); +const isFuel = (file: FileInfo): boolean => fileLabel(file.file) === FUEL; const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; @@ -158,7 +163,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const buttons = new Map(); // 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로 const kinds = new Map(); - /** 재료 그룹 안 파일별 구분 목록(자재품목·유가전력 각자) — file 이름이 열쇠 */ + /** 재료 그룹 안 파일별 구분 목록(자재품목 · 유가전력 각자) — file 이름이 열쇠 */ const materialKinds = new Map(); const states = new Map(); const runs = new Map void>(); @@ -303,8 +308,15 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si return i < 0 ? MATERIAL_ORDER.length : i; }; + /** 컨테이너에 보일 파일 — 재료는 유가 · 전력 파일을 빼고 · 유가 · 전력은 그 파일 하나(둘 다 서버 재료 그룹) */ + const shown = (group: Group): FileInfo[] => { + if (group === FUEL_GROUP) return (lists.get("재료") ?? []).filter(isFuel); + const list = lists.get(group) ?? []; + return group === "재료" ? list.filter((f) => !isFuel(f)) : list; + }; + const drawGroup = (group: Group): void => { - const files = lists.get(group) ?? []; + const files = shown(group); const body = bodies.get(group); if (!body) return; const open = (file: FileInfo, sub: string, label: string, detail = "", state = ""): void => @@ -324,6 +336,20 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si 소요량: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, 계수: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, }; + if (group === FUEL_GROUP && one) { + body.replaceChildren( + filter({ + id: `${group}|`, + store: `m01.filter.재료.${FUEL}`, + subs: materialKinds.get(one.file) ?? [], + total: one.rows, + subLabel: L("M01_FuelSub"), + detailLabel: L("M01_FuelDetail"), + onPick: (sub, detail, label) => open(one, sub, label, detail), + }), + ); + return; + } const drop = dropped[group]; if (drop && one) { body.replaceChildren( @@ -346,8 +372,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si .sort((a, b) => order(a.file) - order(b.file)) .flatMap((f) => { const name = fileLabel(f.file); - const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; - if (group === "재료" && (name === FUEL || name === MATERIAL)) + const label = name; + if (group === "재료" && name === MATERIAL) return [ filter({ id: `${group}\n${f.file}`, @@ -355,8 +381,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si subs: materialKinds.get(f.file) ?? [], total: f.rows, title: label, - subLabel: name === FUEL ? L("M01_FuelSub") : L("M01_LaborSub"), - detailLabel: name === FUEL ? L("M01_FuelDetail") : L("M01_LaborDetail"), + subLabel: L("M01_LaborSub"), + detailLabel: L("M01_LaborDetail"), onPick: (sub, detail, tail) => open(f, sub, sub || detail ? `${label} · ${tail}` : label, detail), }), @@ -379,7 +405,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si /** 처음 펼칠 때 열 줄 — 재료는 차례 첫 파일(자재품목) */ const firstRun = (group: Group): string => { if (group !== "재료") return `${group}|`; - const first = [...(lists.get(group) ?? [])].sort((a, b) => order(a.file) - order(b.file))[0]; + const first = [...shown(group)].sort((a, b) => order(a.file) - order(b.file))[0]; return first ? `${group} ${first.file}` @@ -387,6 +413,8 @@ ${first.file}` }; const refresh = async (group: string): Promise => { + // 유가 · 전력 컨테이너는 서버 재료 그룹을 받아 그 파일만 + if (group === FUEL_GROUP) return (await refresh("재료")).filter(isFuel); const list = await fetchFiles(group); if (list[0] && (group === "인력" || group === "기계")) { const got = await fetchSubsAll({ file: list[0].file }); @@ -403,6 +431,7 @@ ${first.file}` } lists.set(group as Group, list); drawGroup(group as Group); + if (group === "재료") drawGroup(FUEL_GROUP); return list; }; @@ -420,7 +449,7 @@ ${first.file}` ? null : el("div", { className: "m01-side__items ui-sidebar-list" }); if (body) bodies.set(group, body); - const root = section(L(GROUP_TITLE[group]), body); + const root = section(titleOf(group), body); if (!body) { root.classList.add("m01-side__leaf"); buttons.set(`${group}\n`, root); @@ -428,10 +457,10 @@ ${first.file}` const one = lists.get(group)?.[0]; if (!one) return; setActive(`${group}\n`); - onOpen({ group, file: one, sub: "", detail: "", label: L(GROUP_TITLE[group]) }); + onOpen({ group, file: one, sub: "", detail: "", label: titleOf(group) }); }); } - if (FILTERED.includes(group) || group === "재료") { + if (FILTERED.includes(group) || group === "재료" || group === FUEL_GROUP) { root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (!root.classList.contains("is-collapsed")) return; // 닫을 때는 그대로 둠 restoring = true; @@ -458,12 +487,14 @@ ${first.file}` children: [...groups, logic], }); attachCollapsible(root); - void Promise.all(GROUPS.map(refresh)).catch((error) => + void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) => showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), ); const setOnPicked = (fn: () => void): void => { onPicked = fn; }; - const files = (group: string): FileInfo[] => lists.get(group as Group) ?? []; + // 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만 + const files = (group: string): FileInfo[] => + group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []); return { root, logicHost, refresh, files, setActive, setOnPicked, filter }; } diff --git a/resources/tester/test_m01_side_fuel.py b/resources/tester/test_m01_side_fuel.py new file mode 100644 index 000000000..15a8e8b7f --- /dev/null +++ b/resources/tester/test_m01_side_fuel.py @@ -0,0 +1,44 @@ +"""38-2 왼쪽 패널 「유가 · 전력」 컨테이너 — 재료 다음 따로 · 재료 컨테이너에서는 뺌 · 서버 재료 그룹 그대로.""" + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SIDE = (ROOT / "M01_MasterData/M01_MasterData_UI_Side.ts").read_text(encoding="utf-8") + + +def _array(name: str) -> list[str]: + body = re.search(rf"const {name}(?:: \w+\[\])? = \[(.*?)\]", SIDE, re.S).group(1) + return [x.strip().strip('"') for x in body.split(",") if x.strip()] + + +def test_fuel_container_right_after_material(): + groups = _array("GROUPS") + assert groups[groups.index("재료") + 1] == "FUEL_GROUP" + assert 'const FUEL_GROUP = "유가 · 전력";' in SIDE + + +def test_material_container_has_no_fuel(): + assert _array("MATERIAL_ORDER") == ["자재품목", "품셈재료"] + assert "list.filter((f) => !isFuel(f))" in SIDE + + +def test_fuel_container_reads_server_material_group(): + # 서버로는 재료 그룹을 받아 유가전력 파일만 · 첫 받기에서 따로 부르지 않음 + assert 'if (group === FUEL_GROUP) return (await refresh("재료")).filter(isFuel);' in SIDE + assert "GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)" in SIDE + # 거름 저장 열쇠 그대로 — 전에 고른 구분이 이어짐 + assert "store: `m01.filter.재료.${FUEL}`" in SIDE + + +def test_fuel_file_still_in_material_group(): + data = json.loads( + (ROOT / "resources/master_data/재료_유가전력.json").read_text(encoding="utf-8") + ) + assert data + # 흐름도 넣을 값 판 · 단가산출 로직 요소 고르기는 서버 재료 그룹을 그대로 씀 + panel = (ROOT / "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts").read_text( + encoding="utf-8" + ) + assert '유가전력: { group: "재료", part: "유가전력" }' in panel