From 1405d02296ddda54361aabce1f96475c387aa198 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:47:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EC=88=98=EB=9F=89=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=9C=84=20=ED=83=AD=20=ED=8B=80=20+=201=ED=83=AD?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A7=91=EA=B3=84=ED=91=9C(?= =?UTF-8?q?=EC=A4=84=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=97=B4=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A1=B0=ED=95=A9=20=C2=B7=20=EC=B9=B8=20=EB=B9=84?= =?UTF-8?q?=EC=9B=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탭 넷(구조물 집계표 · 구조물도 · 토적표 · 수리집수면적표) — 1탭만 내용 · 나머지 빈 탭 - 줄 = 구조물 · 배관이 놓인 측점(같은 측점 한 줄 · 정렬) · 열 = 저장된 summary_item + group_items(같은 층 · 주인 · 키 한 열) - 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) · M02 에 없으면 빨강 「M02 에 없음」 - 읽기만 — structures.json · pipe_points.json GET · 옛 B08 코드 안 가져옴 - 항목 고르기 목록 읽기(loadRows) 내보냄 · 시험 test_61_1_b08_summary_table.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B08_Quantity/B08_Quantity_Summary_Data.ts | 94 +++++++++++++ B08_Quantity/B08_Quantity_UI_Page.css | 69 ++++++++++ B08_Quantity/B08_Quantity_UI_Page.ts | 66 ++++++++- B08_Quantity/B08_Quantity_UI_Summary.ts | 125 ++++++++++++++++++ .../tester/test_61_1_b08_summary_table.py | 72 ++++++++++ ui_template/ui_template_item_picker.ts | 2 +- ui_template/ui_template_locale_b4.ts | 17 +++ 7 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Summary_Data.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Page.css create mode 100644 B08_Quantity/B08_Quantity_UI_Summary.ts create mode 100644 resources/tester/test_61_1_b08_summary_table.py diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts new file mode 100644 index 000000000..24c0df084 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * B08_Quantity_Summary_Data.ts + * 구조물 집계표의 줄 · 열 고르기(화면 없음 · PLAN 61-1). + * + * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) + * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 + * + * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. + * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. + * ========================================================================== */ + +import { + readGroupItems, + readSummaryItem, + type SummaryItem, +} from "../common_util/common_util_summary_item"; + +/** 구조물 · 관 지점 한 건 — 필요한 칸만. 구간형은 기준점(`chainage_m`) · 없으면 시점. */ +export interface PlacedItem { + chainage_m?: number | null; + start_m?: number | null; + summary_item?: unknown; + group_items?: unknown; +} + +/** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */ +export interface ComboLabel { + group: string; + name: string; +} + +export interface ComboColumn { + /** `{층}|{주인}|{키}` — 항목 고르기 `rowId` 와 같은 꼴 */ + id: string; + item: SummaryItem; + group: string; + name: string; + /** M02 에 그 조합이 없음 */ + missing: boolean; +} + +export const comboId = (item: SummaryItem): string => + `${item.layer}|${item.owner ?? ""}|${item.key}`; + +function stationOf(thing: PlacedItem): number | null { + const value = thing.chainage_m ?? thing.start_m; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** 놓인 측점 — 1cm 안이면 같은 측점 · 오름차순. */ +export function summaryStations(things: readonly PlacedItem[]): number[] { + const seen = new Map(); + for (const thing of things) { + const station = stationOf(thing); + if (station !== null) seen.set(Math.round(station * 100), station); + } + return [...seen.values()].sort((a, b) => a - b); +} + +/** 저장된 조합 열 — 구분 · 이름 차례(같은 구분은 붙어 머리 칸을 합침). */ +export function summaryColumns( + things: readonly PlacedItem[], + describe: (item: SummaryItem) => ComboLabel | null, +): ComboColumn[] { + const found = new Map(); + for (const thing of things) { + const own = readSummaryItem(thing.summary_item); + for (const item of [...(own ? [own] : []), ...Object.values(readGroupItems(thing.group_items))]) + if (!found.has(comboId(item))) found.set(comboId(item), item); + } + const columns = [...found].map(([id, item]): ComboColumn => { + const label = describe(item); + return label + ? { id, item, ...label, missing: false } + : { id, item, group: "", name: item.name || item.key, missing: true }; + }); + return columns.sort( + (a, b) => + a.group.localeCompare(b.group, "ko") || + a.name.localeCompare(b.name, "ko") || + a.id.localeCompare(b.id), + ); +} + +/** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */ +export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> { + const spans: Array<[string, number]> = []; + for (const column of columns) { + const last = spans[spans.length - 1]; + if (last && last[0] === column.group) last[1] += 1; + else spans.push([column.group, 1]); + } + return spans; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css new file mode 100644 index 000000000..6fd399917 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -0,0 +1,69 @@ +/* B08 수량산출 — 위 탭 · 구조물 집계표(PLAN 61-1). 색은 테마 변수만(밝음 · 어두움 같이). */ + +.b08-quantity { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.b08-quantity__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +/* 집계표 — 칸마다 테두리 · 머리 가운데 · 측점 열은 가로로 밀어도 붙어 있음 */ +.b08-summary__wrap { + max-height: 70vh; + overflow: auto; +} + +.b08-summary__table { + width: auto; + min-width: 100%; +} + +.b08-summary__table th, +.b08-summary__table td { + padding: var(--spacing-4, 4px) var(--spacing-8); + border: 1px solid var(--color-border); + white-space: nowrap; +} + +.b08-summary__table thead th { + position: sticky; + top: 0; + z-index: 1; + text-align: center; + background: var(--color-surface-raised, var(--color-surface)); + color: var(--color-text); +} + +/* 머리 둘째 줄은 첫 줄 아래에 붙음 */ +.b08-summary__table thead tr + tr th { + top: calc(1lh + 2 * var(--spacing-4, 4px) + 1px); +} + +.b08-summary__table .b08-summary__station { + position: sticky; + left: 0; + z-index: 2; + text-align: center; + background: var(--color-surface-raised, var(--color-surface)); + color: var(--color-text); +} + +.b08-summary__table thead .b08-summary__station { + z-index: 3; +} + +.b08-summary__table td { + min-width: 80px; + background: var(--color-surface); +} + +.b08-summary__name.is-missing { + color: var(--color-danger); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index fe35956c7..51b04b5d4 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -1,29 +1,81 @@ /* ============================================================================= * B08_Quantity_UI_Page.ts - * 로그인 후 08: 5차 워크플로우 (수량 산출) — **빈 화면** (PLAN 7-3) + * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * - * - 옛 수량 산출 코드는 통째로 `old_code/B08_Quantity/` 에 있음(지우지 않음). - * - 다시 짤 때까지 메뉴와 주소만 남김 — 제목·단계 막대만 뜨고 표·계산은 없음. + * 탭 = 구조물 집계표 · 구조물도 · 토적표 · 수리집수면적표 — 차례대로 만듦. + * 지금은 1탭(구조물 집계표 · 61-1)만 내용 · 나머지는 빈 탭. + * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. * ========================================================================== */ +import "@ui/ui_template_general_layout.css"; +import "./B08_Quantity_UI_Page.css"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { createButton } from "@ui/ui_template_elements"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +const TABS = [ + { id: "summary", label: "B08_Quantity_Tab_StructureSummary" }, + { id: "sheet", label: "B08_Quantity_Tab_StructureSheet" }, + { id: "earthwork", label: "B08_Quantity_Tab_Earthwork" }, + { id: "hydrology", label: "B08_Quantity_Tab_Hydrology" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */ +function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void { + const view = document.createElement("div"); + host.replaceChildren(view); + if (tab === "summary" && projectId) { + void renderStructureSummary(view, projectId); + return; + } + const pending = document.createElement("p"); + pending.className = "ui-general-block__empty"; + pending.textContent = L("B08_Quantity_Tab_Pending"); + view.append(pending); +} + export function renderB08Quantity(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - const note = document.createElement("p"); - note.className = "b08-quantity__rebuilding"; - note.textContent = L("B08_Quantity_Rebuilding"); + const tabBar = document.createElement("div"); + tabBar.className = "b08-quantity__tabs"; + tabBar.setAttribute("role", "tablist"); + const panel = document.createElement("div"); + panel.className = "b08-quantity__panel"; + panel.setAttribute("role", "tabpanel"); + + const select = (tab: TabId): void => { + tabBar.replaceChildren( + ...TABS.map(({ id, label }) => { + const button = createButton({ + label: L(label), + variant: id === tab ? "filled" : "ghost", + size: "sm", + onClick: () => id !== tab && select(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === tab)); + button.dataset.b08Tab = id; + return button; + }), + ); + renderTab(panel, tab, projectId); + }; + const body = document.createElement("div"); - body.append(note); + body.className = "b08-quantity"; + body.append(tabBar, panel); + select("summary"); const layout = createWorkflowLayout({ title: L("B08_Quantity_Title"), diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts new file mode 100644 index 000000000..2dc9f171e --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -0,0 +1,125 @@ +/* ============================================================================= + * B08_Quantity_UI_Summary.ts + * 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 칸은 아직 비움. + * + * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) + * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; +import { pathOf, rowId } from "@ui/ui_template_item_picker_data"; +import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { stationLabel } from "../common_util/common_util_station"; +import { summaryTooltip } from "../common_util/common_util_summary_item"; +import { + comboId, + groupSpans, + summaryColumns, + summaryStations, + type ComboColumn, + type ComboLabel, +} from "./B08_Quantity_Summary_Data"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ +async function stationInterval(projectId: string): Promise { + const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); + return latest.route_params?.station_interval_m || 20; +} + +function cell(tag: "th" | "td", text = "", className = ""): HTMLTableCellElement { + const node = document.createElement(tag); + node.textContent = text; + if (className) node.className = className; + return node; +} + +function note(text: string): HTMLElement { + const node = document.createElement("p"); + node.className = "ui-general-block__empty"; + node.textContent = text; + return node; +} + +function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement { + const head = cell("th", L("B08_Quantity_Summary_Station"), "b08-summary__station"); + head.rowSpan = 2; + const groups = document.createElement("tr"); + groups.append(head); + for (const [group, span] of groupSpans(columns)) { + const th = cell("th", group || L("B08_Quantity_Summary_MissingGroup"), "b08-summary__group"); + th.colSpan = span; + groups.append(th); + } + const names = document.createElement("tr"); + for (const column of columns) { + const th = cell("th", column.name, "b08-summary__name"); + th.dataset.b08Combo = column.id; + th.title = summaryTooltip(column.item); + if (column.missing) th.classList.add("is-missing"); + names.append(th); + } + const thead = document.createElement("thead"); + thead.append(groups, names); + + const tbody = document.createElement("tbody"); + for (const station of stations) { + const tr = document.createElement("tr"); + tr.dataset.b08Station = String(station); + tr.append(cell("th", stationLabel(station, interval), "b08-summary__station")); + for (const _ of columns) tr.append(cell("td")); + tbody.append(tr); + } + const table = document.createElement("table"); + table.className = "ui-general-block__table b08-summary__table"; + table.dataset.b08Summary = "table"; + table.append(thead, tbody); + const wrap = document.createElement("div"); + wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; + wrap.append(table); + return wrap; +} + +/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ +export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { + host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); + try { + const [structures, pickerRows, pipes, interval] = await Promise.all([ + fetchStructures(projectId), + loadPickerRows(), + // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 + fetchDetailPipePoints(projectId).then( + (response) => response.pipe_points, + () => [], + ), + stationInterval(projectId).catch(() => 20), + ]); + const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); + const describe = (item: Parameters[0]): ComboLabel | null => { + const row = m02.get(comboId(item)); + if (!row) return null; + const [group = "", ...rest] = pathOf(row); + return { group, name: rest.join(" ") || group || row.key }; + }; + const things = [...structures.structures, ...pipes]; + const stations = summaryStations(things); + const columns = summaryColumns(things, describe); + const parts: HTMLElement[] = []; + if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); + parts.push( + stations.length + ? buildTable(stations, columns, interval) + : note(L("B08_Quantity_Summary_NoStation")), + ); + host.replaceChildren(...parts); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`)); + } +} diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py new file mode 100644 index 000000000..bc78cef14 --- /dev/null +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -0,0 +1,72 @@ +"""B08 1탭 구조물 집계표 줄 · 열 고르기 (PLAN 61-1). + +`B08_Quantity_Summary_Data.ts` 를 `common_util_summary_item.ts` 와 이어 붙여 node 로 돌림. +줄 = 놓인 측점(같은 측점 한 줄 · 정렬) · 열 = 저장된 조합(본체 + 그룹 · 같은 키 한 열 · 구분 차례). +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _run(body: str): + sources = [ + ROOT / "common_util" / "common_util_summary_item.ts", + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts", + ] + script = "\n".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in sources) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _item(key: str, layer: str = "system", owner=None) -> dict: + return {"layer": layer, "owner": owner, "key": key, "name": f"저장 {key}"} + + +THINGS = [ + {"chainage_m": 120.004, "summary_item": _item("SS2")}, + {"chainage_m": 40.0, "summary_item": _item("SS1"), "group_items": {"g1": _item("SS3")}}, + {"chainage_m": 120.0, "summary_item": _item("SS1")}, # 같은 측점 · 같은 조합 + {"chainage_m": None, "start_m": 80.0}, # 구간형 옛 저장분 = 시점 + {"chainage_m": 10.0, "summary_item": {"key": "깨짐"}}, # 모양 틀림 = 미지정 + {"chainage_m": 60.0, "summary_item": _item("SS9", "company", "3")}, # M02 에 없음 +] +M02 = {"SS1": ("기슭막이", "메쌓기"), "SS2": ("배수관", "파형관"), "SS3": ("기슭막이", "찰쌓기")} + + +def test_줄_열_머리(): + out = _run( + f"\nconst things = {json.dumps(THINGS, ensure_ascii=False)};" + f"\nconst m02 = {json.dumps(M02, ensure_ascii=False)};" + "\nconst describe = (item) => item.layer === 'system' && m02[item.key]" + " ? { group: m02[item.key][0], name: m02[item.key][1] } : null;" + "\nconst columns = summaryColumns(things, describe);" + "\nconsole.log(JSON.stringify({ stations: summaryStations(things)," + " columns: columns.map((c) => [c.id, c.group, c.name, c.missing])," + " spans: groupSpans(columns) }));" + ) + assert out["stations"] == [10.0, 40.0, 60.0, 80.0, 120.0] + assert out["columns"] == [ + ["company|3|SS9", "", "저장 SS9", True], + ["system||SS1", "기슭막이", "메쌓기", False], + ["system||SS3", "기슭막이", "찰쌓기", False], + ["system||SS2", "배수관", "파형관", False], + ] + assert out["spans"] == [["", 1], ["기슭막이", 2], ["배수관", 1]] + + +def test_빈_목록(): + out = _run( + "\nconsole.log(JSON.stringify([summaryStations([])," + " summaryColumns([{ chainage_m: 5 }], () => null), groupSpans([])]));" + ) + assert out == [[], [], []] diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 98a4e504b..e3ba0935b 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -71,7 +71,7 @@ function reasonOf(body: unknown, status: number): string { } /** `GET /api/m02/items?all=1` = `[{layer, owner, items}]` — 줄마다 `layer` · `owner` 를 붙여 하나로 폄 */ -async function loadRows(): Promise { +export async function loadRows(): Promise { const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" }); const body = (await response.json().catch(() => null)) as | { layer: PickerLayer; owner: string | null; items: PickerRow[] }[] diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 2a8405ae2..9f0145a56 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -17,4 +17,21 @@ export const ui_locales_b4 = { "제안값 넣기 — KDS 표준치수 토사 0.5 m", "Insert suggested value — KDS standard soil 0.5 m", ], + B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"], + B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."], + B08_Quantity_Summary_Station: ["측점", "Station"], + B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"], + B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"], + B08_Quantity_Summary_Failed: [ + "구조물 집계표를 불러오지 못했습니다", + "Failed to load the structure summary", + ], + B08_Quantity_Summary_NoCombo: [ + "저장된 구조물 조합이 없습니다 — B05 · B06 패널에서 조합을 지정 · 저장하세요.", + "No saved structure combinations — assign and save them in the B05 · B06 panel.", + ], + B08_Quantity_Summary_NoStation: [ + "놓인 구조물 · 배관이 없습니다.", + "No placed structures or pipes.", + ], } as const;