From c0f3d21908e474e57186b61b63fba3d9017ef246 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:47:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(713-3):=20M01=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=AC=B6=EC=9D=8C=20=EC=A0=91=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EA=B0=99=EC=9D=80=20=EC=A0=88=20=C2=B7=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EB=9F=89=20=EB=A1=9C=EC=A7=81=EC=9D=80=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=A4=84=20=EB=B0=91=EC=9C=BC=EB=A1=9C=20=EC=A0=91?= =?UTF-8?q?=EC=9D=8C(=EC=A0=91=EA=B8=B0=20=EC=88=9C=EC=88=98=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20logic/list=5Ffold)=20=C2=B7=20=EC=B0=BE=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=EC=97=94=20=ED=8E=BC=EC=B9=A8=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=E3=80=8D=20=EC=97=B4=20=3D=20=EB=B9=84?= =?UTF-8?q?=EA=B3=A0=EA=B0=80=20=EA=B0=80=EB=A6=AC=ED=82=A4=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A7=81=ED=81=AC(=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=B0=96)=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EB=8D=A7=EC=B9=B8?= =?UTF-8?q?=20=EC=9D=B8=EC=9E=AC=EA=B2=BD=20=EC=A4=84=20=EC=88=98(712-1=20?= =?UTF-8?q?master=5Fbundle)=20=C2=B7=20=EC=A7=80=ED=82=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Store_List.py | 13 ++- .../M01_MasterData_UI_Logic_List.ts | 104 ++++++++++++++---- .../M01_MasterData_UI_Logic_Text.ts | 7 ++ .../logic/M01_MasterData_logic_list_fold.ts | 64 +++++++++++ resources/tester/test_713_3_list.py | 45 +++++++- 5 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 M01_MasterData/logic/M01_MasterData_logic_list_fold.ts diff --git a/M01_MasterData/M01_MasterData_Store_List.py b/M01_MasterData/M01_MasterData_Store_List.py index 18a0ba796..fdcc738e0 100644 --- a/M01_MasterData/M01_MasterData_Store_List.py +++ b/M01_MasterData/M01_MasterData_Store_List.py @@ -5,16 +5,20 @@ - `작업량` = 돈 아닌 로직(`결과` 칸) · `부모` = 그 작업량을 부르는 로직(목록에서 그 밑으로 접음). - `부르는수` · `불리는수` = 로직끼리 부르기(기계경비 포함 · `Store_Cache.counted`). - `관련` = 비고 글의 절 번호(「1-5-6(주③)」 · 「별도」 참조)가 가리키는 로직 — 계산 밖 링크. +- `인재경` = 호표 · 덧줄 줄 수 `{인력, 재료, 경비, 하위}`(712-1 `master_bundle.bundle` 한 벌). """ from __future__ import annotations +import importlib import re from collections import Counter, defaultdict from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Cache as cache +mbd = importlib.import_module("master_bundle") # `store` 가 scripts 를 길에 둔 뒤 + _REF = re.compile( r"(?:(산림품셈|건설품셈)\s*(공통|토목|건축|기계설비|유지관리)?\s*)?(? list[str]: return list(out) +def _bundles(whole, row: dict) -> dict[str, int]: + got = Counter(mbd.bundle(whole, x)[0] for x in [*row.get("호표", []), *row.get("덧줄", [])]) + return {name: got.get(name, 0) for name in mbd.BUNDLES} + + def extras() -> dict[str, dict]: """로직 키 → 덧칸 — 목록 전체로 셈(거른 목록이어도 묶음 수 · 부르는 수는 전체 기준).""" - files, _ = store.loaded() + files, whole = store.loaded() rows = [ (name, data, row) for name, data in files.items() @@ -77,6 +86,7 @@ def extras() -> dict[str, dict]: "부르는수": len(calls.get(key, ())), "불리는수": called.get(key, 0), "관련": _related(row, _book(data), by_section), + "인재경": _bundles(whole, row), } return out @@ -92,5 +102,6 @@ def attach(logics: list[dict]) -> list[dict]: "부르는수": 0, "불리는수": 0, "관련": [], + "인재경": dict.fromkeys(mbd.BUNDLES, 0), } return [{**one, **more.get(str(one.get("키")), blank)} for one in logics] diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index ef0508908..50c8f34b2 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -3,13 +3,18 @@ * 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기 * 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음) * 「별칭」 열(208-1) — 이름 옆 · 고치면 `onAlias`(캐시에 쌓고 [저장] 때 나감) · 찾기는 공용 `matchLogic` + * 묶음 접기(713-3) — 같은 절 · 작업량 로직은 머리 줄 밑으로 접음(`logic/…_list_fold`) · 찾는 중엔 펼침 + * 「관련」 열 = 비고가 가리키는 로직 링크(계산 밖) · 덧칸은 서버 `/logics` 가 줌(정본 그대로) * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createIconButton } from "@ui/ui_template_button"; import { createInputField, el } from "@ui/ui_template_elements"; import { aliasText, matchLogic } from "@ui/ui_template_logic_find"; import { buildPager } from "./M01_MasterData_UI_Cells"; import { pageKeepFirst, watchFit } from "@ui/ui_template_pager"; import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; +import { foldRows, type FoldRow } from "./logic/M01_MasterData_logic_list_fold"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Head.css"; @@ -28,6 +33,18 @@ export interface ListItem { source: string; blocked: boolean; reasons: string[]; + /** 묶음 열쇠(파일 + 원문번호) · 작업량이면 부르는 로직 · 비고가 가리키는 로직(713-3) */ + group?: string; + parents?: string[]; + related?: string[]; +} + +/** `/logics` 덧칸(713-3 · `M01_MasterData_Store_List`) */ +interface ListExtra { + 묶음?: string; + 작업량?: boolean; + 부모?: string[]; + 관련?: string[]; } export interface ListHandle { @@ -52,6 +69,7 @@ const COLS = [ "별칭", "결과 단위", "출처", + tx("List_Related"), "", ] as const; @@ -66,6 +84,7 @@ export function buildList( const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias; let page = 1; let size = DEFAULT_SIZE; + const open = new Set(); // 펼친 머리 줄 키 const pick = { sub: "", detail: "" }; const searchField = createInputField({ type: "search", placeholder: tx("List_Search") }); const search = searchField.input; @@ -86,9 +105,10 @@ export function buildList( 별칭: aliasOf(x), }).ok, ); - page = Math.min(page, Math.max(1, Math.ceil(shown.length / size))); - const rows = shown.slice((page - 1) * size, page * size).map(line); - pager.replaceWith((pager = buildPager(shown.length, size, page, go))); + const folded = foldRows(shown, open, q.trim() !== ""); + page = Math.min(page, Math.max(1, Math.ceil(folded.length / size))); + const rows = folded.slice((page - 1) * size, page * size).map(line); + pager.replaceWith((pager = buildPager(folded.length, size, page, go))); grid.replaceChildren( el("table", { className: "ui-table m01-master__grid", @@ -105,7 +125,7 @@ export function buildList( page = p; draw(); }; - const line = (item: ListItem): HTMLElement => { + const line = ({ item, depth, hidden, open: isOpen }: FoldRow): HTMLElement => { const mark = marks.get(item.id); const state = [ item.blocked ? `⛔ ${tx("List_Blocked")}` : "", @@ -119,9 +139,13 @@ export function buildList( const tr = el("tr", { className: `m01-master__table-row${mark ? " is-changed" : ""}`, children: [ - ...[item.key, item.number, item.sub, item.detail, item.name].map(cell), + keyCell(item, hidden, isOpen), + ...[item.number, item.sub, item.detail].map(cell), + cell(depth ? `└ ${item.name}` : item.name), aliasCell(item, mark === "deleted"), - ...[item.unit, item.source, state].map(cell), + ...[item.unit, item.source].map(cell), + relatedCell(item), + cell(state), ], }); if (item.blocked) tr.title = item.reasons.join("\n"); @@ -129,6 +153,42 @@ export function buildList( return tr; }; + /** 키 칸 — 묶음 머리면 접기 단추 + 접힌 줄 수 · 단추는 로직을 안 엶 */ + const keyCell = (item: ListItem, hidden?: number, isOpen?: boolean): HTMLElement => { + if (!hidden) return el("td", { text: item.key, attrs: { title: item.key } }); + const toggle = createIconButton({ + icon: isOpen ? "chevronDown" : "chevronRight", + title: isOpen ? tx("List_Unfold") : tx("List_Fold", { n: hidden }), + size: 18, + pressed: Boolean(isOpen), + onClick: (e) => { + e.stopPropagation(); + if (open.has(item.key)) open.delete(item.key); + else open.add(item.key); + draw(); + }, + }); + const count = createBadge(`+${hidden}`, "neutral", { size: 18 }); + return el("td", { children: [toggle, item.key, " ", count], attrs: { title: item.key } }); + }; + + /** 관련 칸 — 비고가 가리키는 로직 키 링크(계산 밖) · 누르면 그 로직이 열림 */ + const relatedCell = (item: ListItem): HTMLElement => { + const links = (item.related ?? []).map((key) => + createButton({ + label: key, + variant: "link", + size: 18, + onClick: (e) => { + e.stopPropagation(); + const found = items.find((x) => x.key === key); + if (found) onOpen(found); + }, + }), + ); + return el("td", { children: links, attrs: { title: tx("List_RelatedTip") } }); + }; + /** 별칭 칸 — 눌러도 로직이 안 열림 · 고친 글은 「a, b」 꼴로 */ const aliasCell = (item: ListItem, locked: boolean): HTMLElement => { const input = el("input", { @@ -172,19 +232,25 @@ export function buildList( return { root, setItems: (logics) => { - items = logics.map((x) => ({ - id: logicId(x.구분, x.키), - sub: x.구분, - detail: x.상세구분, - key: x.키, - number: x.원문번호, - name: x.이름, - alias: x.별칭 ?? "", - unit: x.결과단위, - source: x.출처, - blocked: x.blocked, - reasons: x.reasons, - })); + items = logics.map((one) => { + const x = one as LogicSummary & ListExtra; + return { + id: logicId(x.구분, x.키), + sub: x.구분, + detail: x.상세구분, + key: x.키, + number: x.원문번호, + name: x.이름, + alias: x.별칭 ?? "", + unit: x.결과단위, + source: x.출처, + blocked: x.blocked, + reasons: x.reasons, + group: x.묶음 ? `${x.file}|${x.묶음}` : "", + parents: x.작업량 ? (x.부모 ?? []) : [], + related: x.관련 ?? [], + }; + }); draw(); }, setMarks: (next, more, edited) => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 9c69ff747..7008f7ad5 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -17,6 +17,13 @@ const TEXT = { List_Edited: ["고침", "Edited"], List_New: ["새것", "New"], List_Deleted: ["지움", "Deleted"], + List_Fold: ["{n} 줄 펼침 — 같은 절 · 작업량", "Show {n} more — same section · output"], + List_Unfold: ["접기", "Fold"], + List_Related: ["관련", "Related"], + List_RelatedTip: [ + "비고가 가리키는 로직 — 계산 밖 · 누르면 열림", + "Logic named in the note — not in the calc · click to open", + ], Bar_Back: ["← 로직 목록", "← Logics"], Bar_Delete: ["로직 삭제", "Delete logic"], Bar_Save: ["저장", "Save"], diff --git a/M01_MasterData/logic/M01_MasterData_logic_list_fold.ts b/M01_MasterData/logic/M01_MasterData_logic_list_fold.ts new file mode 100644 index 000000000..71647e313 --- /dev/null +++ b/M01_MasterData/logic/M01_MasterData_logic_list_fold.ts @@ -0,0 +1,64 @@ +/* ============================================================================= + * M01_MasterData_logic_list_fold.ts + * 로직 목록 묶음 접기 — 순수 계산(화면 · fetch 없음 · PLAN 713-3) + * 같은 절 묶음(서버 `묶음`) = 첫 줄만 보이고 나머지는 접힘 · 작업량 로직(`부모` 있음) = 부르는 로직 밑으로 + * 찾는 글이 있으면 접지 않음(찾은 줄이 숨지 않게) · 정본은 그대로 — 보이는 차례만 정함 + * ========================================================================== */ + +export interface FoldItem { + key: string; + /** 묶음 열쇠(파일 + 원문번호) — 빈 글 = 묶음 없음 */ + group?: string; + /** 작업량 로직이면 부르는 로직 키들 */ + parents?: string[]; +} + +export interface FoldRow { + item: T; + /** 0 = 맨 줄 · 1 = 펼친 묶음 · 작업량 줄 */ + depth: 0 | 1; + /** 묶음 머리 · 작업량을 거느린 줄 — 접힌 줄 수 */ + hidden?: number; + open?: boolean; +} + +/** `open` = 펼친 머리 줄 키 · `flat` = 접지 않음(찾는 중) */ +export function foldRows( + items: T[], + open: ReadonlySet, + flat: boolean, +): FoldRow[] { + if (flat) return items.map((item) => ({ item, depth: 0 })); + const present = new Set(items.map((x) => x.key)); + const isChild = (x: T): boolean => (x.parents ?? []).some((p) => present.has(p)); + const kids = new Map(); // 머리 키 → 거느린 줄(묶음 나머지 + 작업량) + const heads = new Map(); // 묶음 열쇠 → 머리 키 + for (const x of items) { + if (isChild(x)) { + const parent = (x.parents ?? []).find((p) => present.has(p)) as string; + kids.set(parent, [...(kids.get(parent) ?? []), x]); + continue; + } + if (!x.group) continue; + const head = heads.get(x.group); + if (head === undefined) heads.set(x.group, x.key); + else kids.set(head, [...(kids.get(head) ?? []), x]); + } + const out: FoldRow[] = []; + const shown = new Set(); + const push = (x: T, depth: 0 | 1): void => { + if (shown.has(x.key)) return; + shown.add(x.key); + const under = kids.get(x.key) ?? []; + const isOpen = open.has(x.key); + out.push( + under.length ? { item: x, depth, hidden: under.length, open: isOpen } : { item: x, depth }, + ); + if (isOpen) for (const k of under) push(k, 1); + }; + for (const x of items) { + if (isChild(x) || (x.group && heads.get(x.group) !== x.key)) continue; + push(x, 0); + } + return out; +} diff --git a/resources/tester/test_713_3_list.py b/resources/tester/test_713_3_list.py index bb81e5d3a..ba54f8850 100644 --- a/resources/tester/test_713_3_list.py +++ b/resources/tester/test_713_3_list.py @@ -5,7 +5,7 @@ from __future__ import annotations from M01_MasterData import M01_MasterData_Router as router from M01_MasterData import M01_MasterData_Store_List as store_list -COLS = {"묶음", "묶음수", "작업량", "부모", "부르는수", "불리는수", "관련"} +COLS = {"묶음", "묶음수", "작업량", "부모", "부르는수", "불리는수", "관련", "인재경"} def test_목록_줄_수_그대로_덧칸_다_있음() -> None: @@ -24,9 +24,52 @@ def test_묶음_작업량_부르기() -> None: assert more["GC000268"]["불리는수"] >= 300 # 기계경비 로직 assert sum(1 for v in more.values() if v["부르는수"]) == 506 assert more["GC000294"]["관련"] == ["GC000292"] # 비고 「1-5-6(주③)」 + # 인재경 줄 수 = 712-1 묶음 파생 · 못 가른 「하위」 = 복합 하위 로직 부르기 31(조사 12-B ③4) + assert sum(v["인재경"]["하위"] for v in more.values()) == 31 + assert more["GF000101"]["인재경"] == {"인력": 1, "재료": 0, "경비": 1, "하위": 0} def test_관련은_해석_글과_넓은_절을_뺌() -> None: more = store_list.extras() assert all(len(v["관련"]) <= store_list._WIDE for v in more.values()) assert not any(k in v["관련"] for k, v in more.items()) # 자기 자신 없음 + + +def test_화면_접기_같은_절_작업량_찾는_중엔_펼침() -> None: + import json + import shutil + import subprocess + from pathlib import Path + + import pytest + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + root = Path(__file__).resolve().parents[2] + url = (root / "M01_MasterData/logic/M01_MasterData_logic_list_fold.ts").as_uri() + src = f""" +import {{ foldRows }} from "{url}"; +const items = [ + {{ key: "A", group: "f|9-12" }}, {{ key: "W", parents: ["A"] }}, + {{ key: "B", group: "f|9-12" }}, {{ key: "C", group: "f|9-12" }}, {{ key: "D" }}, +]; +const keys = (rows) => rows.map((r) => [r.item.key, r.depth, r.hidden ?? 0]); +console.log(JSON.stringify({{ + shut: keys(foldRows(items, new Set(), false)), + open: keys(foldRows(items, new Set(["A"]), false)), + flat: keys(foldRows(items, new Set(), true)).length, +}})); +""" + done = subprocess.run( + ["node", "--experimental-strip-types", "--input-type=module", "-e", src], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=root, + ) + assert done.returncode == 0, done.stderr + got = json.loads(done.stdout) + assert got["shut"] == [["A", 0, 3], ["D", 0, 0]] # 묶음 나머지 둘 + 작업량 하나 접힘 + assert got["open"] == [["A", 0, 3], ["W", 1, 0], ["B", 1, 0], ["C", 1, 0], ["D", 0, 0]] + assert got["flat"] == 5