diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index d8fcc2277..ed8512694 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -569,7 +569,7 @@ } /* 상자 안 작은 단추 템플릿(54-41) — 높이 = 숫자 칸 [-][+](`--b05-control-h`) · 글 · 안 여백도 그 높이에 · - * 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 제외 */ + * 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 아래 205-1 */ .b05-structure__group-body button:not(.b05-structure__step), .b05-structure__group > legend > button { box-sizing: border-box; @@ -580,6 +580,24 @@ line-height: 1; } +/* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) · + * 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */ +.b05-structure-section .b05-route__irregular-actions { + position: sticky; + bottom: 0; + z-index: 1; + background: var(--color-surface-raised); +} + +.b05-structure-section .b05-route__irregular-actions > button { + box-sizing: border-box; + height: var(--b05-control-h); + min-height: 0; + padding-block: 0; + font-size: var(--text-caption); + line-height: 1; +} + /* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */ .b05-structure__widening[hidden] { display: none; diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 1bc4121df..3249f58da 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -121,7 +121,11 @@ class _SectionGeometry: # ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은 # **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면 # 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존). - if section_mode == "both_fill": + # ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1) — 입력 `section_mode` 는 옛 저장값일 수 + # 있다(계획고를 올려 우 절토 → 양측 성토 · 입력은 right_cut 그대로). 입력으로 가르면 옛 + # 측구 결과가 선택으로 둔갑해 양측 성토 단면에 측구가 섰다. ⚠ TS 짝 `SectionGeometry`. + both_fill = self.left_role == "fill" and self.right_role == "fill" + if both_fill: auto_ditch = False elif ground_at is not None: ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent @@ -136,10 +140,10 @@ class _SectionGeometry: # 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다. if choice is not None and bool(choice) == auto_ditch: choice = None - # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice + # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + self.has_ditch = auto_ditch if (choice is None or both_fill) else choice #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. - self.ditch_choice = choice + self.ditch_choice = None if both_fill else choice # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 7bc478776..0c7e07f68 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -388,8 +388,10 @@ export function buildDesignControls( groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에. - const rockCut = isRock(state.ground) && state.mode !== "both_fill"; - const hasDitch = state.mode !== "both_fill"; + // 숨김은 「막힘 + 그려진 측구 없음」일 때만(206-1) — 측구가 그려져 있으면 단추를 보여 끌 수 있게. + const ditchDrawn = design?.ditch_enabled === true; + const rockCut = isRock(state.ground) && (state.mode !== "both_fill" || ditchDrawn); + const hasDitch = state.mode !== "both_fill" || ditchDrawn; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. // 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다. const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true; diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 2ceacd67b..0ab8bcb08 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -41,10 +41,13 @@ function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChip }); } -export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { +export function createFillSlopeNotice( + onPick: (stationId: string) => void, + selected: () => string | null, +): FillSlopeNotice { const root = document.createElement("div"); root.className = "b06-section__notice-bar"; - const chips = createNoticeChips(onPick); + const chips = createNoticeChips(onPick, selected); root.append(chips.root); return { diff --git a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts index 648236959..54760e806 100644 --- a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts +++ b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts @@ -3,6 +3,8 @@ * 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버. * 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 · * 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로). + * ⭐ 207-1 — 내용 칸은 줄 아래 카드 위에 겹쳐 뜸(흐름 밖 · CSS) · 측점 누르면 접힘 · + * 열 때 지금 고른 측점(`selected`) 단추 강조. * 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만. * ========================================================================== */ @@ -29,7 +31,10 @@ export interface NoticeChips { update: (items: NoticeChipItem[]) => void; } -export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips { +export function createNoticeChips( + onPick: (stationId: string) => void, + selected: () => string | null = () => null, +): NoticeChips { const root = document.createElement("div"); root.className = "b06-section__notice-blocks"; const chips = document.createElement("div"); @@ -57,12 +62,14 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh ); panel.hidden = !open; panel.classList.toggle("is-info", open?.tone === "info"); + const picked = open ? selected() : null; panel.replaceChildren( ...(open?.rows ?? []).map((row) => { const button = document.createElement("button"); button.type = "button"; button.textContent = row.text; button.dataset.stationId = row.stationId; + button.classList.toggle("is-selected", row.stationId === picked); return button; }), ); @@ -77,7 +84,10 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh }); panel.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; - if (id) onPick(id); + if (!id) return; + openKey = null; + render(); + onPick(id); }); attachHoverInfo( chips, diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index bfda291de..105bcddb9 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -106,6 +106,8 @@ interface StaleCandidate { geometry_preset?: string; two_stage_slope?: boolean; surface_drop_m?: number; + section_mode?: string; + ditch_enabled?: boolean; } | null; } @@ -120,6 +122,8 @@ function isStaleSection( // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + // 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈. + if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true; if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); if (planned === undefined) return false; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a67009b38..bc95835f8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -124,8 +124,9 @@ export function createSectionView( } = { balloonScope: "-" }; // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로. - const fillSlopeNotice = createFillSlopeNotice((id) => - selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), + const fillSlopeNotice = createFillSlopeNotice( + (id) => (selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true)), + () => selectedStationId, ); // 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림 const columnsField = createInputField({ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 8a0d5fdd7..494c58df0 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -172,9 +172,10 @@ } .b06-section__notice-chip { - padding: 0 var(--spacing-8); + padding: 2px var(--spacing-8); border: 1px solid currentColor; - border-radius: var(--radius-sm); + /* 둥근 사각(207-1) — 공용 단추 반경 · 알약 아님 */ + border-radius: var(--radius-buttons); background: transparent; color: var(--color-warning); font-size: var(--text-caption); @@ -193,14 +194,24 @@ font-weight: 600; } -/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */ +/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 칸 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게. + 207-1 — 줄 아래 카드 위에 겹쳐 뜸(흐름 밖) · 열고 닫아도 줄 높이 · 카드 자리 그대로 */ .b06-section__notice-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1; display: flex; flex-wrap: wrap; gap: var(--spacing-4); max-height: 30vh; overflow: auto; - padding-top: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); color: var(--color-warning); } @@ -213,6 +224,13 @@ white-space: nowrap; } +/* 지금 고른 측점(207-1) — 카드 고름 색과 같음 */ +.b06-section__notice-list > button.is-selected { + outline: 2px solid var(--color-danger); + outline-offset: -1px; + font-weight: 600; +} + .b06-cross-card { cursor: pointer; transition: diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 420100cc8..e7acf989c 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) note: row.note || " ", })), frozen: 1, + mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) caption: "토공집계표", fillViewport: true, }); diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 0f148cd02..53d2223ed 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -109,6 +109,8 @@ interface Table { editable: (index: number, key: string) => boolean; /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ target: (index: number, key: string) => [Record | Cells, string, string | null]; + /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; } function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { @@ -218,10 +220,10 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows, total } = s.concrete; const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 - const rowsOut: DataRow[] = keep.map((at, i) => { + const rowsOut: DataRow[] = keep.map((at) => { const row = rows[at]; const out: DataRow = { - name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name, + name: row.name, // 위 줄과 같으면 세로 병합(201-1) spec: row.spec, qty: row.qty, }; @@ -256,6 +258,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { rows: rowsOut, total: totalRow, frozen: 2, + mergeSame: ["name"], editable: (_i, key) => key.startsWith("per:"), target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], }; @@ -275,10 +278,10 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { numberColumn({ key: "amount", label: "합계", width: 96 }), { key: "note", label: "비고", width: 96 }, ], - rows: keep.map((at, i) => { + rows: keep.map((at) => { const row = rows[at]; return { - group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group, + group: row.group, // 위 줄과 같으면 세로 병합(201-1) spec: row.spec, unit: row.unit, qty: row.qty, @@ -295,6 +298,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { }), total: null, frozen: 2, + mergeSame: ["group"], editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, target: (i) => [o.surcharge, rows[keep[i]].key, null], @@ -364,6 +368,7 @@ export async function renderMaterialSheet( rows: built.rows, total: built.total, frozen: built.frozen, + mergeSame: built.mergeSame, empty: "", caption: BASIS, fillViewport: true, diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py new file mode 100644 index 000000000..b1c1a16c6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -0,0 +1,134 @@ +"""B09 설계내역서 연결 — 서고 구조물조합 · 일위대가 사전조합 → 프로젝트 사본(PLAN 204-2). + +자리 = 프로젝트 `B09_Estimation/v1/library/{폴더}/` · 폴더 = `{종류}@{층}@{주인}@{키}` +(주인 `/` → `-` · 마스터 `-`) — 같은 것을 다시 고르면 새로 안 들여오고 있는 사본(같은 호표): + combo 구조물조합 = B08 구조물 사본과 한 꼴(`M02_Item_Snapshot.build_snapshot` — 집계 · + 도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값) + precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} · + `단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌) +사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) · +대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · +단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · +[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`). +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Store import folder as store_folder +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of +from M02_MasterTemplete.M02_Item_Source import ( + LABOR_FILE, + PRICE_FILE, + item_name, + labor_doc_rows, + price_bundle, + read_json, +) + +LIBRARY = "library" +KINDS = ("combo", "precombo") +#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) +NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$") + + +def library_dir(project_root: str | Path) -> Path: + return store_folder(project_root) / LIBRARY + + +def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str: + """고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError.""" + name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}" + if not NAME.match(name): + raise ValueError(f"서고 항목 모양이 틀림 「{name}」") + return name + + +def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: + ref = {"층": layer, "주인": owner, "키": key} + doc = refs.read_ref(ref) + if doc is None or doc.get("종류") != refs.TITLE["precombo"]: + raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음") + line = {"참조": ref, "수량": {"손": 1}} + labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]}) + return { + LABOR_FILE: labor, + PRICE_FILE: price_bundle(labor, refs.folder_of(layer, owner)), + "_원본": {"참조": ref, "name": doc.get("이름") or key, "단위": doc.get("단위") or ""}, + } + + +def import_copy( + project_root: str | Path, + kind: str, + layer: str, + owner: str | None, + key: str, + who: dict[str, Any] | None = None, +) -> dict[str, Any]: + """서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) · + 원본이 없으면 LookupError · 모양이 틀리면 ValueError.""" + if kind not in KINDS: + raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나") + name = folder_name(kind, layer, owner, key) + target = library_dir(project_root) / name + if isinstance(read_json(target / MANIFEST_NAME), dict): + return {"폴더": name, "만듦": False} + if kind == "combo": + item = {"layer": layer, "owner": owner, "key": key} + item["name"] = item_name(item) + files, origin = build_snapshot(item), origin_of(item) + else: + files = _precombo_files(layer, owner, key) + origin = files.pop("_원본") + if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 + shutil.rmtree(target) + target.mkdir(parents=True) + for file, content in files.items(): + atomic_write_json(target / file, content) + stamp = now_text() + manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who} + atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더 + return {"폴더": name, "만듦": True} + + +def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]: + """다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례.""" + base = library_dir(project_root) + out = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + manifest = read_json(child / MANIFEST_NAME) + if child.is_dir() and NAME.match(child.name) and isinstance(manifest, dict): + out.append((child, manifest)) + return out + + +def used_folders(user: dict[str, Any]) -> set[str]: + """설계내역서 덮개 줄이 잇는 서고 사본 폴더.""" + rows = ((user or {}).get("bill") or {}).get("rows") or [] + return { + str(ref["lib"]) + for row in rows + if isinstance(row, dict) + and isinstance(ref := row.get("ref"), dict) + and ref.get("tab") == "dg" + and ref.get("lib") + } + + +def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]: + """[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름.""" + keep = used_folders(user) + base = library_dir(project_root) + gone = [] + for child in sorted(base.iterdir()) if base.is_dir() else []: + if child.is_dir() and NAME.match(child.name) and child.name not in keep: + shutil.rmtree(child) + gone.append(child.name) + return gone diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index c9f4db36e..30b69f9c4 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -2,7 +2,7 @@ ⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · - `test_184_2_b09_bill_free.py`(자유 편집). + `test_184_2_b09_bill_free.py`(자유 편집) · `test_204_1_b09_bill_drag.py`(묶음 자동). 실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔): 1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액) @@ -33,7 +33,9 @@ `rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) = 기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) · 원천이 안 낸 키 = 안 보임 · - 부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 · + 부모 못 찾음 · 자재 줄(`mat:`) · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 · + 항목 줄에 자식이 생김 = 저절로 묶음(204 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음 — + 자식이 다 빠지면 다시 항목) · 관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래. 옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` · `after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2). @@ -331,7 +333,7 @@ def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tup def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: - """부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 · + """부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 · 관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section (깊이 5 넘음 = 레벨 4 조상 아래).""" by_key = {row["key"]: row for row in order} @@ -353,7 +355,7 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: up = by_key.get(row.get("parent") or "") if row["key"] == "sec:G": row["parent"] = None - elif up is None or up["kind"] == "item" or up is row: + elif up is None or up["key"].startswith("mat:") or up is row: row["parent"] = row["_home"] if row["_home"] in by_key else None for row in order: if loops(row): @@ -383,6 +385,11 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: row["section"] = chain[-1]["section"] if chain else None rows.append(row) stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], []))) + parents = {row["parent"] for row in rows} + for row in rows: # 자식이 생긴 항목 = 저절로 묶음(204) + if row["kind"] == "item" and row["key"] in parents: + row.update({"kind": "group", "qty": None, "ref": None, "_gaps": []}) + row.update({f"price_{p}": None for p in (*PARTS, "sum")}) return rows diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 249a23463..3a5702b8e 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -3,7 +3,8 @@ * B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임). * * ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · - * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집). + * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집) · + * `test_204_1_b09_bill_drag.py`(묶음 자동). * * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 @@ -304,7 +305,8 @@ function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] { return out; } -/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) → +/** 부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) → + * 자식이 생긴 항목 = 저절로 묶음(204 · 자식이 다 빠지면 다시 항목) → * 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */ function tree(order: Row[]): Row[] { const byKey = new Map(); @@ -325,7 +327,7 @@ function tree(order: Row[]): Row[] { for (const row of order) { const up = byKey.get(row.parent ?? ""); if (row.key === "sec:G") row.parent = null; - else if (!up || up.kind === "item" || up === row) row.parent = home(row); + else if (!up || up.key.startsWith("mat:") || up === row) row.parent = home(row); } for (const row of order) { if (!loops(row)) continue; @@ -354,6 +356,13 @@ function tree(order: Row[]): Row[] { rows.push(row); for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]); } + const parents = new Set(rows.map((row) => row.parent)); + for (const row of rows) { + // 자식이 생긴 항목 = 저절로 묶음(204) + if (row.kind !== "item" || !parents.has(row.key)) continue; + Object.assign(row, { kind: "group", qty: null, ref: null, _gaps: [] }); + for (const p of [...PARTS, "sum"]) row[`price_${p}`] = null; + } return rows; } diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index cb49e0f09..578f7dc74 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -26,6 +26,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -282,6 +283,19 @@ class Builder: } ) + def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: + """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" + if manifest.get("종류") != "precombo": + return self.combo(folder) + labor = read_json(folder / LABOR_FILE) + ref = (manifest.get("원본") or {}).get("참조") + copies = (labor or {}).get("참조사본") or {} + doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None + if not isinstance(doc, dict): + return None + whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + return self.precombo(whole, ref, doc, copies, 1) + def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" @@ -327,9 +341,11 @@ def _plain(value: Any) -> Any: def daega_inputs(project_root: str | Path) -> dict[str, Any]: - """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것. + """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 · + 설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}. - 차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례. + 차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — + 호표 번호 = 이 차례. """ base = structures_dir(project_root) build = Builder() @@ -343,6 +359,15 @@ def daega_inputs(project_root: str | Path) -> dict[str, Any]: bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음 if bid: owners[owner] = bid + libs: dict[str, str] = {} + for folder, manifest in library.copies(project_root): + try: + bid = build.library(folder, manifest) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + bid = None + if bid: + libs[folder.name] = bid order = {"조합": 0, "사전조합": 1, "품셈": 2} blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]]) - return _plain({"blocks": blocks, "owners": owners}) + out = {"blocks": blocks, "owners": owners} + return _plain({**out, "libs": libs} if libs else out) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index ff2427dd3..868e8e7e3 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -67,13 +67,19 @@ def used_keys(folder: Path) -> list[str]: def linked_resource_keys(project_root: str | Path) -> list[str]: - """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음.""" + """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · + 겹침 없음.""" + from B09_Estimation.B09_Estimation_Bill_Library import copies + base = structures_dir(project_root) + folders = [ + base / linked["폴더"] + for _owner, linked in sorted(linked_snapshots(project_root).items()) + if "일위대가" in linked.get("부분", []) + ] out: list[str] = [] - for _owner, linked in sorted(linked_snapshots(project_root).items()): - if "일위대가" not in linked.get("부분", []): - continue - for key in used_keys(base / linked["폴더"]): + for folder in [*folders, *(f for f, _ in copies(project_root))]: + for key in used_keys(folder): if key not in out: out.append(key) return out diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 87badd217..e8dd8d916 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -15,6 +15,7 @@ from __future__ import annotations +import json from datetime import datetime from decimal import Decimal from typing import Any @@ -197,6 +198,23 @@ def refresh_book(keys: list[str]) -> dict[str, Any]: } +def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: + """203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None).""" + if book is None: + return "단가 아직 안 굳힘" + fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌 + rows = book.get("rows") or {} + new = sum(key not in rows for key in fresh["rows"]) + changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) + parts = [ + f"새 단가 줄 {new}" if new else "", + f"M01 에서 바뀐 단가 {changed}" if changed else "", + "운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", + "환율 바뀜" if book.get("fx") != fresh["fx"] else "", + ] + return " · ".join(p for p in parts if p) or None + + def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]: """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘.""" if book is None: diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b4a7d7185..62ccb65e8 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -6,6 +6,10 @@ [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/library {kind, layer, owner, key} + 설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로 + (있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} · + 층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`) 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · @@ -20,11 +24,13 @@ from pathlib import Path from typing import Any from uuid import UUID -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Depends, HTTPException +from pydantic import BaseModel from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store @@ -38,6 +44,9 @@ from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete.M02_Item_Snapshot import importer router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -121,6 +130,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, + # 203-1 [업데이트] 배지 까닭(지금 M01 과 다른 것 한 줄 · 같으면 None) + "stale": await asyncio.to_thread(prices.book_stale, book, keys), "sheets": sheets, "rate_candidates": rate_candidates(), "rate_table": rate_table(), @@ -139,7 +150,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]: @router.put("/{project_id}/estimation") async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) - await asyncio.to_thread(store.write_user, root, body.get("user")) + user = await asyncio.to_thread(store.write_user, root, body.get("user")) + await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) @@ -156,5 +168,31 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: return await payload(project_id, root) +class LibraryBody(BaseModel): + kind: str + layer: str + owner: str | None = None + key: str + + +@router.post("/{project_id}/estimation/library") +async def import_library( + project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + got = access.grant(session, body.layer, body.owner) # 못 읽는 층 = 403 · 회사 없음 = 409 + root = await _project_root(project_id) + try: + made = await asyncio.to_thread( + library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session) + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + out = await payload(project_id, root) + key = ((out["inputs"].get("daega") or {}).get("libs") or {}).get(made["폴더"]) + return {**made, "key": key, "payload": out} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index da7557fbd..0f1c5862a 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -95,12 +95,14 @@ def _text(value: Any) -> str: def _clean_ref(ref: Any) -> dict[str, Any] | None: - """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름).""" + """손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) · + dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움).""" if not isinstance(ref, dict): return None tab = ref.get("tab") if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]: - return {"tab": tab, "key": ref["key"]} + lib = ref.get("lib") if tab == "dg" else None + return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})} if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]: given = ref.get("given") if isinstance(ref.get("given"), dict) else {} picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts new file mode 100644 index 000000000..5bb00b5e9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B09_Estimation_UI_Check.ts + * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용). + * + * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) + * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 + * 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; + +export interface CheckItem { + link: Link; + tab: string; + name: string; + why: string; +} + +/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */ +export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } { + const gaps: Row[] = sheets.gaps || []; + const items = gaps.map((g) => ({ + link: { tab: g.tab as TabId, key: g.key }, + tab: tabName(g.tab), + name: String(g.name ?? ""), + why: String(g.why ?? ""), + })); + const notes: string[] = [...(payload.notes || [])]; + const missing: string[] = payload.inputs?.missing || []; + if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); + return { items, notes }; +} + +/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ +export function refreshTip(payload: Row | null): string { + const made = payload?.book?.made; + const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘"; + const stale = payload?.stale; + return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; +} + +export interface CheckPanel { + /** 묶음 안 연결 점검 자리 */ + list: HTMLElement; + draw: (payload: Row, sheets: Row) => void; +} + +export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel { + let payload: Row | null = null; + let items: CheckItem[] = []; + attachHoverInfo(refresh, () => refreshTip(payload)); + const list = document.createElement("div"); + list.className = "b09-check"; + attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); + + const draw = (next: Row, sheets: Row): void => { + payload = next; + refresh.classList.toggle("is-pending", Boolean(next.stale)); + const made = checkItems(sheets, next); + items = made.items; + const head = document.createElement("p"); + head.className = "b09-check__head"; + head.textContent = `연결 없음 ${items.length}곳`; + const rows = document.createElement("div"); + rows.className = "ui-sidebar-list"; + rows.append( + ...items.map((item, i) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "ui-sidebar-row"; + row.dataset.i = String(i); + const name = document.createElement("span"); + name.textContent = item.name; + const tab = document.createElement("span"); + tab.className = "b09-check__tab"; + tab.textContent = item.tab; + row.append(name, tab); + row.addEventListener("click", () => goto(item.link)); + return row; + }), + ); + const notes = made.notes.map((text) => { + const p = document.createElement("p"); + p.className = "b09-check__note"; + p.textContent = text; + return p; + }); + list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes); + }; + return { list, draw }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 3b0313338..9e41af3dc 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -66,44 +66,33 @@ font-size: var(--text-caption); } -/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */ -.b09-estimation .ui-data-table__table tbody tr.is-lv1 td { - font-size: var(--text-body); -} - -.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td { - font-size: var(--text-body-sm); -} - -.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td { - font-size: 13px; -} - +/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */ .b09-estimation .ui-data-table__table tbody tr.is-group td { font-weight: var(--font-weight-bold); } -.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child { - padding-left: calc(var(--spacing-8) + 1em); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child { - padding-left: calc(var(--spacing-8) + 2em); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child { - padding-left: calc(var(--spacing-8) + 3em); -} - -.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child { - padding-left: calc(var(--spacing-8) + 4em); -} - /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); } +/* 200-2 설계내역서 글 칸 왼쪽 맞춤(수량만 오른쪽) */ +.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) { + text-align: left; +} + +/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */ +.b09-estimation__level { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.b09-estimation__level .b09-estimation__hint { + margin: 0; +} + /* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ .b09-estimation .ui-data-table__table tbody tr.is-pick td { --pick-t: inset 0 0 0 0 transparent; @@ -135,20 +124,51 @@ font-weight: var(--font-weight-bold); } -/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */ -.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child { - padding-left: var(--spacing-8); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent { - padding-left: calc(var(--spacing-8) + 1em); -} - -.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent { - padding-left: calc(var(--spacing-8) + 2em); -} - /* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); } + +/* 203-1 좌측 「데이터 점검」 — [업데이트] 필요 = 단추 위 점 배지(B05 [3D 업데이트] 꼴) */ +.b09-options .ui-btn.is-pending { + position: relative; + border-color: var(--color-warning); +} + +.b09-options .ui-btn.is-pending::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-warning); +} + +/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ +.b09-check__head, +.b09-check__note { + margin: var(--spacing-8) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b09-check__tab { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */ +.b09-estimation__drop { + position: fixed; + z-index: 1000; + height: 0; + border-top: 2px solid var(--color-primary); + pointer-events: none; +} + +.b09-estimation__drop.is-bad { + border-top: 2px dashed var(--color-danger); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 8b06c0a4d..c3c67a6c6 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -4,7 +4,7 @@ * * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + - * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ @@ -20,7 +20,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { createDataTable, type DataTable } from "@ui/ui_template_data_table"; +import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; @@ -31,9 +31,9 @@ import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; +import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { - buildCheck, rowIndex, TABS, type Link, @@ -52,6 +52,7 @@ import { } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { buildSumExpense, buildSumLabor, @@ -69,7 +70,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = {}; const BUILDERS: Partial> = { - check: buildCheck, dg_list: buildDaegaList, dg: buildDaegaBlocks, bill: buildBill, @@ -151,7 +151,7 @@ export function renderB09Estimation(root: HTMLElement): void { let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; - let current: TabId = "check"; + let current: TabId = TABS[0][0]; // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` const bars = createTabBars((tab) => show(tab)); @@ -162,11 +162,10 @@ export function renderB09Estimation(root: HTMLElement): void { body.className = "b09-estimation"; body.append(bars.top, bars.sub, panel); - const made = document.createElement("p"); - made.className = "b09-estimation__hint"; - const recompute = (): void => { - if (payload) sheets = computeSheets(payload, user); + if (!payload) return; + sheets = computeSheets(payload, user); + check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지 }; const setUser = (next: Row): void => { @@ -210,6 +209,8 @@ export function renderB09Estimation(root: HTMLElement): void { ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, cellClass: spec.cellClass, + level: spec.level, + mergeSame: spec.mergeSame, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); @@ -223,7 +224,7 @@ export function renderB09Estimation(root: HTMLElement): void { if (spec.choices) made.root.addEventListener("click", (event) => { const cell = (event.target as Element).closest("tr[data-i] td"); - const column = cell ? spec.columns[cell.cellIndex] : undefined; + const column = cell ? spec.columns[cellColumn(cell)] : undefined; const items = cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key); if (!cell || !items?.length) return; @@ -235,7 +236,7 @@ export function renderB09Estimation(root: HTMLElement): void { attachHoverInfo( made.root, (cell) => { - const column = spec.columns[(cell as HTMLTableCellElement).cellIndex]; + const column = spec.columns[cellColumn(cell as HTMLTableCellElement)]; return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key); }, "tr[data-i] td", @@ -259,8 +260,11 @@ export function renderB09Estimation(root: HTMLElement): void { }), ); } - const table = makeTable(); - panel.replaceChildren(table.root, ...(hint ? [hint] : [])); // 202-1 안내 줄 = 표 아래 + // 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래 + const bill = (spec as BillTable).menu ? (spec as BillTable) : null; + const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined); + const level = bill ? levelBar(table.root, bill) : null; + panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : [])); if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); @@ -272,9 +276,6 @@ export function renderB09Estimation(root: HTMLElement): void { const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; user = draft ?? next.user ?? {}; recompute(); - made.textContent = next.book?.made - ? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로` - : "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘"; show(current); }; @@ -303,12 +304,13 @@ export function renderB09Estimation(root: HTMLElement): void { }); save.dataset.b09Save = ""; const refresh = createButton({ - label: "단가 갱신", + label: "업데이트", variant: "ghost", onClick: () => void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), }); refresh.dataset.b09Refresh = ""; + const check = createCheckPanel(refresh, (link) => show(link.tab, link.key)); // 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역 const reset = createButton({ label: "내역 초기화", @@ -328,8 +330,8 @@ export function renderB09Estimation(root: HTMLElement): void { section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; - heading.textContent = "원가 단가"; - section.append(heading, made, refresh); + heading.textContent = "데이터 점검"; + section.append(heading, refresh, check.list); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(save, reset); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 8f68f8ead..1a3405e96 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -2,17 +2,17 @@ * B09_Estimation_UI_Tabs.ts * B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례). * - * 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 + * 탭 = 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 * 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`) - * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 + * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴만 + * 연결 점검(옛 첫 탭) = 좌측 「데이터 점검」 묶음(203-1 `…_UI_Check`) * ========================================================================== */ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; -import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; export type TabId = - | "check" | "cost" | "total" | "bill" @@ -34,7 +34,6 @@ export type TabId = /** 엑셀 탭 차례 — [id, 탭 이름] */ export const TABS: Array<[TabId, string]> = [ - ["check", "연결 점검"], ["cost", "공사원가계산서"], ["total", "총괄설계내역서"], ["bill", "설계내역서"], @@ -59,7 +58,6 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ - ["연결 점검", ["check"]], ["공사원가계산서", ["cost"]], ["총괄설계내역서", ["total"]], ["설계내역서", ["bill"]], @@ -112,10 +110,20 @@ export interface TabTable { cellClass?: (index: number, key: string) => string | null | undefined; /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ choices?: (index: number, key: string) => MapContextMenuItem[] | null; + /** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */ + level?: DataTableOptions["level"]; + /** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; } export type TabBuild = (ctx: TabContext) => TabTable | string; +/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ +export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ + key, + of: (i) => (top(i) ? 1 : 2), +}); + /* ── 열 꼴 ────────────────────────────────────────────────────────────── */ export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({ @@ -156,36 +164,3 @@ export function parseNumber(value: string): number | null | undefined { export function rowIndex(rows: DataRow[], key: string): number { return rows.findIndex((row) => row.key === key); } - -/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */ - -export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => { - const gaps: Row[] = sheets.gaps || []; - const rows: DataRow[] = gaps.map((g, i) => ({ - no: i + 1, - key: `${g.tab}|${g.key}|${i}`, - tab: tabName(g.tab), - name: g.name, - why: g.why, - })); - const notes: string[] = [...(payload.notes || [])]; - const missing: string[] = payload.inputs?.missing || []; - if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); - return { - columns: [ - qty("no", "번호", 0), - text("tab", "탭", 150), - text("name", "명칭", 260), - text("why", "까닭", 320), - ], - rows, - frozen: 1, - hint: - `연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` + - (notes.length ? ` · ${notes.join(" · ")}` : ""), - link: (i) => { - const g = gaps[i]; - return g ? { tab: g.tab as TabId, key: g.key } : null; - }, - }; -}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index e16abf10f..fdd74a838 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -19,6 +19,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { + blockLevel, money, parseNumber, qty, @@ -51,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`, + `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`, + `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`, ); function madeText(payload: Row): string { @@ -102,6 +103,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, + mergeSame: ["part"], hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", link: (i) => { const row = rows[i]; @@ -344,7 +346,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null); rows.push({ key: `${b.key}|${ln.name}`, - name: ` ${ln.name}`, + name: ln.name, spec: ln.spec, qty: ln.qty, unit: ln.unit, @@ -361,7 +363,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { } rows.push({ key: `${b.key}|합계`, - name: " 합 계", + name: "합 계", sum: b.sum, labor: b.labor, material: b.material, @@ -388,5 +390,6 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { frozen: 1, hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", link: (i) => links[i] ?? null, + level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index c14b71c14..314939f03 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -7,7 +7,7 @@ * 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개 * 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값 * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) - * 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) + * 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level` * 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴 * ========================================================================== */ @@ -16,6 +16,7 @@ import type { Row } from "./B09_Estimation_Calc_Base"; import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs"; import { billMenu, + dropMove, editCell, linkable, openLinkMenu, @@ -93,9 +94,11 @@ export const buildBill: TabBuild = (ctx): BillTable => { const r = rows[i]; if (!r) return null; const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; - return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" "); + return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null; }, + level: { key: "name", of: (i) => rows[i]?.level as number | undefined }, menu: (i) => billMenu(ctx, raw, i), + drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts new file mode 100644 index 000000000..786ccc285 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Drag.ts + * B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림. + * + * 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음 + * 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만 + * 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed) + * 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름) + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; + +export interface DropAt { + parent: string | null; + before: string | null; + level: number; +} + +const holds = (row: DataRow): boolean => row.key !== "fee" && !String(row.key).startsWith("mat:"); + +/** 틈 gap(0 ~ 줄 수 · 그 줄 앞) · 바라는 레벨 → 놓을 자리 · 같은 자리 · 못 놓음 = null */ +export function dropAt(rows: DataRow[], from: number, gap: number, want: number): DropAt | null { + const row = rows[from]; + if (!row) return null; + const lv = (r: DataRow | undefined): number => Number(r?.level ?? 1); + let end = from + 1; + while (end < rows.length && lv(rows[end]) > lv(row)) end += 1; + if (gap > from && gap < end) return null; // 자기 자손 안 + const rest = [...rows.slice(0, from), ...rows.slice(end)]; + const g = gap <= from ? gap : gap - (end - from); + const up = rest[g - 1]; + const down = rest[g]; + const max = up ? lv(up) + (holds(up) ? 1 : 0) : 1; + const min = Math.max(down ? lv(down) : 1, lv(row) === 1 ? 1 : 2); + if (min > max) return null; + const level = Math.min(max, Math.max(min, want)); + if (level === lv(row) && (gap === from || gap === end)) return null; // 제자리 + let parent: DataRow | undefined = up; + const byKey = new Map(rest.map((r) => [String(r.key), r])); + while (parent && lv(parent) >= level) parent = byKey.get(String(parent.parent ?? "")); + return { + parent: parent ? String(parent.key) : null, + before: down && lv(down) === level ? String(down.key) : null, + level, + }; +} + +/** 표 root 에 줄 끌기를 붙임 — 놓으면 done(끈 줄 키) 뒤 drop 실행 */ +export function attachDrag(root: HTMLElement, table: BillTable, done: (key: string) => void): void { + let endAt = 0; + // 끌고 난 누름 = 줄 고름 아님 + root.addEventListener( + "click", + (event) => { + if (Date.now() - endAt < 300) event.stopPropagation(); + }, + true, + ); + root.addEventListener("pointerdown", (down) => { + const target = down.target as Element; + if (down.button !== 0 || !table.drop || target.closest("input,textarea,select,button,a")) + return; + const tr = target.closest("tr[data-i]"); + if (!tr || !root.contains(tr)) return; + const from = Number(tr.dataset.i); + const step = parseFloat(getComputedStyle(tr).fontSize) || 14; + const line = document.createElement("div"); + line.className = "b09-estimation__drop"; + let on = false; + let run: (() => void) | null = null; + let seen = ""; + const move = (event: PointerEvent): void => { + const dx = event.clientX - down.clientX; + if (!on && Math.abs(dx) + Math.abs(event.clientY - down.clientY) < 6) return; + if (!on) { + on = true; + document.body.append(line); + document.body.style.userSelect = "none"; + window.getSelection()?.removeAllRanges(); + } + const trs = table.rows.map((_r, i) => root.querySelector(`tr[data-i="${i}"]`)); + let gap = trs.length; + let y = trs.at(-1)?.getBoundingClientRect().bottom ?? 0; + for (const [i, el] of trs.entries()) { + const box = el?.getBoundingClientRect(); + if (box && event.clientY < box.top + box.height / 2) { + [gap, y] = [i, box.top]; + break; + } + } + const want = Number(table.rows[from]?.level ?? 1) + Math.round(dx / step); + const at = dropAt(table.rows, from, gap, want); + const key = `${gap}|${at?.level}`; + if (key !== seen) { + seen = key; + run = at ? table.drop!(from, at.parent, at.before) : null; + } + const box = root.getBoundingClientRect(); + const left = box.left + ((at?.level ?? want) - 1) * step + 8; + Object.assign(line.style, { + top: `${y - 1}px`, + left: `${left}px`, + width: `${Math.max(box.right - left, 40)}px`, + }); + line.classList.toggle("is-bad", !run); + document.body.style.cursor = run ? "grabbing" : "not-allowed"; + }; + const stop = (event: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + line.remove(); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + if (!on) return; + endAt = Date.now(); + if (event.type === "pointerup" && run) { + done(String(table.rows[from].key)); + run(); + } + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 105b3ff80..d8ea35242 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -11,6 +11,8 @@ * 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비) * 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기 * 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) + * 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 · + * 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -24,7 +26,11 @@ import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ -export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] }; +export type BillTable = TabTable & { + menu?: (index: number) => MapContextMenuItem[]; + /** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */ + drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null; +}; const NEW_NAME = "새 줄"; const NEW_SECTION = "새 공종"; @@ -47,6 +53,8 @@ interface Work { /** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */ const fixed = (key: string): boolean => key === "fee" || key === "sec:G"; +/** 자식을 둘 수 있는 줄 — 자재 · 조달수수료 빼고(항목은 저절로 묶음) */ +const canHold = (key: string): boolean => key !== "fee" && !key.startsWith("mat:"); /** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */ export const linkable = (row: DataRow | undefined): boolean => @@ -148,6 +156,11 @@ function attempt( const before = w.bill.rows.map((e) => ({ ...e })); if (change(w) === false) return null; w.bill.rows = tidy(w.bill.rows); + // 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) — 자식이 생긴 항목 = 셈이 묶음으로 + const had = new Set(before.map((e) => e.parent)); + const has = new Set(w.bill.rows.map((e) => e.parent)); + for (const e of w.bill.rows) + if (e.kind === "group" && e.parent && had.has(e.key) && !has.has(e.key)) delete e.kind; return fits(w, before) ? () => put(ctx, w.bill) : null; } @@ -273,7 +286,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex add(2, "들여쓰기 →", (w, e) => { const sib = siblings(w, e.parent); const up = sib[sib.indexOf(e) - 1]; - if (!up || !isGroup(w, up.key)) return false; + if (!up || !canHold(up.key)) return false; e.parent = up.key; w.bill.rows.push(pull(w.bill.rows, key)); }); @@ -315,6 +328,28 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex return out; } +/** 끌어 놓기(204) — 자손째 parent 아래 before 앞으로 · 지킬 것(깊이 · 자재 돈 갈래)은 메뉴와 같음 */ +export function dropMove( + ctx: TabContext, + rows: DataRow[], + i: number, + parent: string | null, + before: string | null, +): (() => void) | null { + const key = String(rows[i]?.key); + if (!rows[i] || fixed(key) || (parent !== null && !canHold(parent))) return null; + return attempt(ctx, rows, (w) => { + const e = entryOf(w, key); + if (!e || (parent !== null && !entryOf(w, parent))) return false; + if (parent !== null && chain(w.bill.rows, parent).includes(e)) return false; // 자기 자손 아래 + if (parent === null) delete e.parent; + else e.parent = parent; + pull(w.bill.rows, key); + const at = before === null ? -1 : find(w.bill.rows, before); + w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, e); + }); +} + let shared: MapContextMenu | null = null; /** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ let lastCell: Element | null = null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts new file mode 100644 index 000000000..6d9cb5125 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -0,0 +1,138 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Level.ts + * B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄. + * + * 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음 + * 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) + * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) + * 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄) + * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; + +/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */ +const ACTS: [string, string][] = [ + ["→ 들여쓰기", "들여쓰기 →"], + ["← 내어쓰기", "내어쓰기 ←"], + ["↑ 위로", "위로 ↑"], + ["↓ 아래로", "아래로 ↓"], +]; + +const GUIDE = + "레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기"; +const GUIDE_MORE = + "레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " + + "들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " + + "내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " + + "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + + "끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " + + "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + + "묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭"; + +let picked: string | null = null; +/** 지금 그린 도구 줄 — 키가 부름 */ +let live: { + root: HTMLElement; + bar: HTMLElement; + run: (label: string) => void; + sync: () => void; +} | null = null; +let keysOn = false; + +/** 고른 줄 class — Shell 줄 class 곁값 */ +export const pickedClass = (row: DataRow | undefined): string | null => + row && picked !== null && String(row.key) === picked ? "is-focus" : null; + +/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */ +export function menuActs(table: BillTable, index: number): Map void> { + const items = index >= 0 && table.menu ? table.menu(index) : []; + return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x))); +} + +/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */ +export function keyAct( + e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean }, + editing: boolean, +): string | null { + if (editing || e.ctrlKey) return null; + if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1]; + if (e.shiftKey) return null; + const arrow = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].indexOf(e.key); + // 방향키(204) — 그냥 · Alt 같음(Alt+←→ = 브라우저 뒤로 · 앞으로라 안 받음) + if (arrow < 0 || (e.altKey && arrow < 2)) return null; + return ACTS[arrow][1]; +} + +const editing = (at: Element | null): boolean => + /^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") || + !!(at as HTMLElement | null)?.isContentEditable; + +function listenKeys(): void { + if (keysOn) return; + keysOn = true; + document.addEventListener("keydown", (event) => { + if (!live?.root.isConnected || picked === null) return; + const at = document.activeElement; + if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return; + // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 + if (event.key === "Escape") { + if (!editing(at)) { + picked = null; + return live.sync(); + } + (at as HTMLElement).blur(); + return live.root.focus(); + } + const label = keyAct(event, editing(at)); + if (!label) return; + event.preventDefault(); + live.run(label); + }); +} + +/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */ +export function levelBar(root: HTMLElement, table: BillTable): HTMLElement { + const index = (): number => + picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked); + const run = (label: string): void => menuActs(table, index()).get(label)?.(); + const buttons = ACTS.map(([text, label]) => { + const button = createButton({ + label: text, + variant: "ghost", + size: "sm", + onClick: () => run(label), + }); + button.dataset.b09Level = label; + return button; + }); + const guide = document.createElement("span"); + guide.className = "b09-estimation__hint"; + guide.textContent = GUIDE; + attachHoverInfo(guide, GUIDE_MORE); + const bar = document.createElement("div"); + bar.className = "b09-estimation__level"; + bar.append(...buttons, guide); + const sync = (): void => { + const acts = menuActs(table, index()); + ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); + for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus"); + root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus"); + }; + root.addEventListener("click", (event) => { + const tr = (event.target as Element).closest("tr[data-i]"); + const row = tr ? table.rows[Number(tr.dataset.i)] : undefined; + if (!row) return; + picked = String(row.key); + sync(); + }); + attachDrag(root, table, (key) => (picked = key)); + sync(); + live = { root, bar, run, sync }; + listenKeys(); + return bar; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 4bd17684e..0e384d428 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -1,14 +1,19 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Bill_Link.ts * B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · - * M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기. + * 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새 + * 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · + * 연결 풀기. * * 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름) - * 로직 고르기 · 입력값 = M02 창 재사용 + * 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 · + * 층 읽기 권한 = 서버(`M02_Layer_Access`) * ========================================================================== */ +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { createFindList } from "@ui/ui_template_find_list"; +import { openItemPicker } from "@ui/ui_template_item_picker"; import { openModal } from "@ui/ui_template_modal"; import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input"; @@ -86,6 +91,63 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void ); } +/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만 + * 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */ +async function importLib( + ctx: TabContext, + apply: ApplyRef, + pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string }, +): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) return; + showToast("서고에서 들여오는 중…", "info"); + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pick), + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦"); + const { user: _user, ...rest } = got.payload as Row; + Object.assign(ctx.payload, rest); + const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); + apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + showToast( + got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함", + "success", + ); + } catch (error) { + showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error"); + } +} + +/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */ +function pickCombo(ctx: TabContext, apply: ApplyRef): void { + void openItemPicker({ current: null, canCopy: false }).then((got) => { + if (got.action !== "apply") return; + const { layer, owner, key } = got.item; + void importLib(ctx, apply, { kind: "combo", layer, owner, key }); + }); +} + +/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */ +function pickPrecombo(ctx: TabContext, apply: ApplyRef): void { + openLogicPicker(() => undefined, { + layer: "personal", + owner: null, + libOnly: true, + title: "일위대가 사전조합 고르기", + pick: (ref) => + void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }), + }); +} + /** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] { const more: Leaf[] = []; @@ -95,6 +157,8 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], + ["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)], + ["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)], [ "단가산출 로직(M01 · 새 산근)", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index e50dfece3..50b500e2f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -12,6 +12,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + blockLevel, money, qty, text, @@ -25,7 +26,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun"; const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; /** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ -const CHILD = " ㄴ "; +const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1) const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ @@ -122,7 +123,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { titles.add(rows.length); rows.push({ key: `${b.id}|합계`, - name: " 합 계", + name: "합 계", sum: b.sum, labor: b.labor, material: b.material, @@ -154,6 +155,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), + level: blockLevel("name", (i) => titles.has(i)), tip: (i: number, key: string) => key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1cf80dc9f..1776afdf3 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Sangeun.ts * B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun` - * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈). + * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈). * * 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록 * 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 · @@ -13,6 +13,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + blockLevel, money, qty, text, @@ -99,7 +100,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { null, true, ); - head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` })); + head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: line })); (b.lines as Row[]).forEach((ln, n) => { const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null; const kind = @@ -115,8 +116,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { push( { key: `${b.key}|${n}${part ? `|${part}` : ""}`, - kind: k === 0 ? kind : "", - text: ` ${texts[k] ?? fallback}`, + kind, // 같은 공종 줄 = 세로 병합(201-1) + text: texts[k] ?? fallback, sum: PARTS.some((p) => at(p) === null) ? null : PARTS.reduce((s, p) => s + (at(p) as number), 0), @@ -162,6 +163,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { notes(sheets), link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), + level: blockLevel("text", (i) => titles.has(i)), + mergeSame: ["kind"], tip: (i: number, key: string) => key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null, }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 27d21fa0b..5fd4fba08 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -24,7 +24,7 @@ const HINT = /** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ const hint = (payload: Row, extra = ""): string => { const n = (payload.summary || {}).unlinked || 0; - return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`; + return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`; }; /** 줄이 없을 때 표 위 맨 앞 안내 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 0614ea532..96a3691aa 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -8,8 +8,8 @@ * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · * 엑셀에 없는 현행 비목 = 줄만(빈칸) * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 - * 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 · - * 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title) + * 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → + * 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title) * 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3 * ========================================================================== */ @@ -77,13 +77,12 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un ctx.setUser({ ...ctx.user, estimate }); } -/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */ -const levelClass = (r: DataRow | undefined): string | null => - r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null; -const indentClass = - (first: string, name: string) => - (_i: number, key: string): string | null => - key === name ? "is-indent" : key === first ? "is-flat" : null; +/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */ +const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null); +const nameLevel = (rows: DataRow[]) => ({ + key: "name", + of: (i: number) => rows[i]?.level as number | undefined, +}); function rateText(formula: string, rate: Num): string { return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); @@ -210,7 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rows, frozen: 2, rowClass: (i) => levelClass(rows[i]), - cellClass: indentClass("part", "name"), + level: nameLevel(rows), + mergeSame: ["part"], hint: "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + @@ -290,7 +290,8 @@ export const buildCost: TabBuild = (ctx): TabTable => { rows, frozen: 3, rowClass: (i) => levelClass(rows[i]), - cellClass: indentClass("group", "name"), + level: nameLevel(rows), + mergeSame: ["group", "part"], hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 717791ba8..597cb086c 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -132,8 +132,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]: def build_source(item: dict[str, Any]) -> dict[str, Any]: """원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError.""" - from M02_MasterTemplete import M02_Item_Prices as prices - root = layer_root(item) row = row_of(read_json(root / layers.COMBO_FILE), item["key"]) if row is None: @@ -153,6 +151,14 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: files[LABOR_FILE] = labor + files[PRICE_FILE] = price_bundle(labor, root) + return files + + +def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: + """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · + 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" + from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Labor_Ref as refs logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] @@ -164,9 +170,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 wanted = logic_keys + [code for code in materials if code] - files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} if mine := own.bundle( [c for c in materials if own.is_own(c)], root ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 - files[PRICE_FILE][own.BUNDLE] = mine - return files + bundle[own.BUNDLE] = mine + return bundle diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 7dafc26ec..a7cc104cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -34,6 +34,9 @@ export interface LibraryPick { layer: Layer; owner: string | null; pick: (ref: LaborRef, name: string) => void; + /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ + libOnly?: boolean; + title?: string; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -213,7 +216,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra el("div", { className: "m01-logic__section-head", children: [ - el("h3", { text: tc("Pick_Title") }), + el("h3", { text: lib?.title ?? tc("Pick_Title") }), createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), @@ -226,14 +229,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); finder.search.focus(); - void Promise.all([fetchLogicSubs(), fetchLogics()]) - .then(([s, l]) => { - subs.push(...s); - all = l; - drawSubs(); - find(); - }) - .catch((error) => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), - ); + if (!lib?.libOnly) + void Promise.all([fetchLogicSubs(), fetchLogics()]) + .then(([s, l]) => { + subs.push(...s); + all = l; + drawSubs(); + find(); + }) + .catch((error) => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), + ); } diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 80630a777..b67b2d67e 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -188,8 +188,11 @@ export class SectionGeometry { // `hasDitch`** 로 가른다. // ⚠ 옛 저장분은 `ditchEnabled` 로 온다. 그 값은 **자동값과 다를 때만 뜻이 있다** — // 같으면 자동이 그렇게 냈던 것이고, 다르면 사용자가 일부러 바꾼 것이다. + // ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1 · 짝: 파이썬) — 입력 `sectionMode` 는 옛 + // 저장값일 수 있다. 입력으로 가르면 옛 측구 결과가 선택으로 둔갑해 양측 성토에 측구가 섰다. + const bothFill = this.leftRole === "fill" && this.rightRole === "fill"; let autoDitch: boolean; - if (params.sectionMode === "both_fill") { + if (bothFill) { autoDitch = false; } else if (groundAt !== null) { const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; @@ -206,10 +209,10 @@ export class SectionGeometry { // 「자동으로 되돌리기」 단추가 없고, 원래 값으로 다시 누른 것을 선택으로 굳히면 // **다시 같은 병**이 된다. if (choice !== null && Boolean(choice) === autoDitch) choice = null; - // 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - this.hasDitch = choice === null || params.sectionMode === "both_fill" ? autoDitch : choice; + // 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + this.hasDitch = choice === null || bothFill ? autoDitch : choice; /** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */ - this.ditchChoice = choice; + this.ditchChoice = bothFill ? null : choice; // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. diff --git a/docs/raw/요구_1010_2/조사_AB_제비율대조.md b/docs/raw/요구_1010_2/조사_AB_제비율대조.md new file mode 100644 index 000000000..104228015 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AB_제비율대조.md @@ -0,0 +1,79 @@ +# 조사 AB — 제비율 대조 (요구 58 · 조사만) + +대상 = B09 총괄설계내역서 · 공사원가계산서 탭의 요율 줄 ↔ 원문 조달청 제비율표(2026.04.13판, 토목 갈래) · 예정가격작성기준 별표2 · 실무 엑셀(기번3 2024 · 소광 2025 · 거창 2026). + +## 1. 한눈 결론 + +- 요율 값 자체는 맞음 — 프로그램이 자동으로 고르는 13줄(간접노무비~부가세)의 값 · 밑수 · 구간이 원문표와 모두 같음. +- 큰 누락 넷 — 퇴직공제부금비 · 임금채권부담금 · 석면분담금 · 하도급대금지급보증수수료. 줄 이름만 있고 늘 빈칸이며 소계에도 안 들어감(M01 표엔 값이 있는데 셈이 안 씀). +- 총괄 줄 자체가 없는 것 둘 — 폐기물처리비(실무 거창 · 소광에 있고, 09-07 사용자 확정 「필요」) · 공사손해보험료(원문 서식에 있음, 가입 대상일 때만). +- 조건이 다른 것 — 연금 요율을 「오늘 날짜의 해」로 고름(설계 연도 칸 없음) · 조달수수료율 자동값 없음(빈칸 = 0) · 적용 조건(1억 이상 등) 확인 없음. +- 실무 엑셀 차례는 기번3(2024) 꼴과 같음. 2026 실무(거창)에 비하면 퇴직공제 · 공사이행보증 · 폐기물처리비 줄이 빠짐. + +## 2. 대조 표 + +프로그램 근거 약칭 — `CT` = `B09_Estimation/B09_Estimation_Calc_Total.py`(짝 `.ts` 같은 줄 구성) · `RT` = `B09_Estimation/B09_Estimation_Rates.py` · `UI` = `B09_Estimation/B09_Estimation_UI_Tabs_Total.ts` · `M01` = `resources/master_data/요율_조달청제비율.json` · 원문 = `현행_20260413.md` 줄. + +| 원문 비목 | 원문 요율 · 밑수 · 조건 | 프로그램 | 프로그램 근거 | 메모 | +|---|---|---|---|---| +| 간접노무비 (원문 8-31) | 직노 × 18.9~21.0% · 직접공사비 5구간 × 공기 4구간 · 토목 | 있음 · 같음 | CT:34,179 · RT:136 | 직접공사비 = 가.순공사비계 · 공기 = 사용자 칸(일) · 183/365/1095일 경계 맞음 | +| 산재보험료 (원문 88) | 노무비 × 3.56% · 모든 건설공사 | 있음 · 같음 | CT:35,181 · RT:112 | 밑수 = 직노 + 간노 | +| 고용보험료 (원문 91-105) | 노무비 × 1.01~1.57% · **추정금액** 등급 | 있음 · 밑수 조금 다름 | CT:36,182 · RT:127 | 등급을 **추정가격** 칸으로 고름. 임도 규모는 둘 다 7등급 이하 1.01% → 금액 차 없음 | +| 건강보험료 (원문 85) | 직노 × 3.595% · 공기 1개월 이상 | 있음 · 같음 | CT:38,183 · RT:112 | 1개월 조건 확인 없음(임도는 늘 넘음) | +| 노인장기요양 (원문 86) | 건강보험료 × 13.14% | 있음 · 같음 | CT:39,184 · RT:112 | | +| 연금보험료 (원문 87) | 직노 × 4.75%(2026) · 해마다 오름(2027 5.00 …) | 있음 · **조건 다름** | CT:40,185,158 · RT:155-159 | 해 = 오늘 날짜의 해 → 2026 설계를 2027년에 다시 [저장]하면 5.00%로 바뀜 | +| 산업안전보건관리비 (원문 107-125) | (재료비 + 직노 + 도급자설치 관급) × 3.15% / 2.53% + 330만 / 2.60% · 관급 있으면 A · B(× 1.2) 작은 쪽 · 총공사금액 2천만 이상 | 있음 · 조건 일부 다름 | CT:40,186-192 · RT:139-154 | B안도 A 대상액 구간의 요율 · 기초액을 씀(고시는 「관급 뺀 대상액으로 산출」 → 5억 · 50억 경계 근처에서만 차이) · 2천만 조건 확인 없음 · 1000억↑ 구간은 뺌(임도 밖) | +| 기타경비 (원문 33-58) | (재료비 + 노무비) × 5.5~8.1% · 직접공사비 × 공기 | 있음 · 같음 | CT:41,193 · RT:137 | | +| 환경보전비 (원문 127-143) | 직접공사비 × 0.9(도로) / 0.8(그 밖의 토목) · 추정금액 1억 이상 | 있음 · 조건 확인 없음 | CT:42,194 · RT:160-169 · UI:156-162 | 기본 = 그 밖 0.8 · 누르면 도로 0.9(지식DB도 미확정) · 1억 미만 공사에도 붙음 | +| 건설기계대여대금 지급보증 (원문 171-173) | 직접공사비 × 0.4%(종합 토목) · 전문 0.1~0.68% | 있음 · 같음 | CT:43,195 · RT:115-126 | 거창 실무 0.1%(전문) — 사용자 칸으로 바꿀 수 있음 | +| **퇴직공제부금비** (원문 152-154) | 직노 × 2.3% · 추정금액 1억 이상 | **줄만 · 늘 빈칸 · 소계 안 듦** | CT:46-52 · UI:169-173 · M01:1027 | ★ 누락. 거창 2026 실무 계상(5,603,907원). 원가계산서 탭엔 줄도 없음(UI:236-257) | +| **임금채권부담금** (원문 150) | 노무비 × 0.09% · 모든 건설공사 | **줄만** | CT:48 · M01:1028 | ★ 누락 (2026.03.13 추가 항목) | +| **석면분담금** (원문 149) | 노무비 × 0.006% · 모든 건설공사 | **줄만** | CT:49 · M01:1029 | ★ 누락 (〃) | +| **하도급대금지급보증수수료** (원문 158-169) | 직접공사비 × 0.081%(추정가격 50억 미만) … · 전문공사 제외 | **줄만** | CT:50 · M01:1031 | ★ 누락. 임도를 종합공사로 내면 붙음 | +| 공사이행보증수수료 (원문 175-185) | 직공비 구간식 × 공기(년) · 추정가격 300억 이상 등 의무 대상만 | 줄만 | CT:51 · M01:1110 | 임도는 통상 비대상 → 빈칸이 맞음. 원가계산서 탭엔 줄 없음 | +| 일반관리비 (원문 60-70) | (재 + 노 + 경) × 8.0%(50억 미만) / 6.5 / 5.0 / 4.5 · 추정가격 | 있음 · 같음 | CT:55,197 · RT:128-134 | 원문 md 표는 병합 칸이라 「5억~50억」이 비어 보이나 실무 「26. 1. 1.」 시트로 50억 미만 = 8.0 확인 → M01 해석 맞음 | +| 이윤 (원문 72-79) | (노 + 경 + 일반관리비) × 15 / 12 / 10 / 9% · 추정가격 · 상한 15%(예정가격작성기준 §21) | 있음 · 같음 | CT:56,199-200 · RT:135 | 사용자가 15% 넘게 적어도 막지 않음 | +| 부가가치세 | 총원가 × 10% | 있음 · 같음 | CT:57,202-203 · RT:114 | | +| 조달수수료 (조달수수료 고시 22-33) | 관급 순자재대 × 0.54%(단가계약 · 10억 이하) | 있음 · **자동값 없음** | CT:206-210 · UI:40 | 빈칸 시작 = 0 → 관급이 있어도 수수료 0. M01에 줄 없음 | +| 공사손해보험료 (예정가격작성기준 §22 · 별표2) | 가입 대상 총원가(+ 관급) × 발주기관이 정한 율 | **없음** | — | 조달청 제비율표 밖 · 가입 대상일 때만 · 실무 셋 모두 없음 | +| 폐기물처리비 (실무 거창 · 소광) | 요율 아님 · 실비 | **없음** | — | 거창 = 관급 뒤(총원가 밖) · 소광 = 이윤 뒤(총원가 안) — 자리 갈림 | +| 간접재료비 · 작업설 | 손입력 | 있음 | UI:41-42 | | + +### 2-2. 실무 엑셀 차례와 비교 + +| 실무 | 경비 줄 차례 | 프로그램과 차이 | +|---|---|---| +| 기번3 2024 총괄 49~67 (STmate) | 간노 · 산재 · 고용 · 건강 · 요양 · 연금 · 안전 · 기타경비 · 환경 · 건설기계 → 나.소계 · 일반관리비 · 이윤 · 부가세 · 관급 | **같음** (CT:33-58이 이 차례를 그대로 따름) | +| 소광 2025 원가계산서 (STmate) | 기번3과 같음 + 이윤 뒤 「폐기물처리」 | 폐기물처리 줄 없음 | +| 거창 2026 원가계산서 (26.1.1판) | … 연금 · 요양 · **퇴직공제** · 건설기계 · 안전(A · B 두 줄) · 환경 · **공사이행보증** · 기타경비 · … 관급 뒤 **폐기물처리비** | 퇴직공제 값 · 공사이행보증 줄(원가계산서) · 폐기물처리비 · 안전 비채택 줄(프로그램은 산식 글로만 A · B 표시 UI:191-193) | + +- 원문 4종(퇴직공제 · 임금채권 · 석면 · 하도급)은 실무 셋 중 거창에 퇴직공제만 있음 — 실무를 따라도, 원문을 따라도 프로그램에서 빠진 상태. + +## 3. 누락 · 다름 목록과 고칠 거리 + +| # | 무엇 | 왜 문제 | 고칠 거리 | 크기 | +|---|---|---|---|---| +| A | 법정 비목 4개 셈 안 함 (퇴직공제 · 임금채권 · 석면 · 하도급) | 원문상 모든(또는 1억 이상) 임도에 붙는 돈이 빠짐. 직노 2.4억 공사(거창)면 퇴직공제만 약 560만 원 + 일반관리비 · 이윤 · 부가세 몫 | CURRENT_ONLY → RATE_LINES로 옮김 · `rate_table` 에 4줄(값 · 밑수는 M01에 이미 있음) · 원가계산서 줄 · 짝 TS · 거울 시험 | 중 | +| B | 폐기물처리비 줄 없음 | 사용자가 「필요」로 확정한 줄. 실무 두 벌에 있음 | 손입력 줄 하나 · **자리(총원가 안/밖) 사용자 협의 먼저** | 소~중 | +| C | 조달수수료율 자동값 없음 | 관급자재가 있는데 칸을 안 채우면 관급자재대가 수수료만큼 적게 나옴 | M01에 조달수수료 줄(0.54%) 추가 후 자동 · 또는 기본값 | 소 | +| D | 연금 요율 해 = 오늘 해 | 해를 넘겨 다시 저장하면 요율이 저절로 바뀜 | 프로젝트 기준 연도(설계 · 입찰 연도) 칸으로 고름 | 소 | +| E | 적용 조건 확인 없음 (환경보전비 · 퇴직공제 추정금액 1억 · 안전 2천만 · 건강 · 연금 1개월) | 소규모 공사에서 0 이어야 할 줄이 붙음 | M01 조건 칸(이미 있음 M01:113,954,1027)을 셈이 읽음 | 소 | +| F | 안전관리비 B안 구간 | 관급을 넣어 대상액이 5억 · 50억을 넘을 때 B안 요율이 원문과 다를 수 있음 | B안은 관급 뺀 대상액으로 구간 다시 고름 | 소 | +| G | 공사손해보험료 줄 없음 | 가입 대상 공사일 때 넣을 자리 없음 | 손입력 줄(율 · 기본 빈칸) | 소 | +| H | 고용보험 등급 밑수 = 추정가격 | 원문은 추정금액. 임도 규모에선 금액 차 없음 | 메모만 · 또는 추정금액 칸 | 소 | +| I | M01 처리규칙 글 「추정가격 빈칸이면 직접공사비로 수렴」 ↔ 코드는 빈칸이면 자동 없음(CT:93-95) | 글과 코드가 어긋남 → 추정가격을 안 적으면 일반관리비 · 이윤 · 고용 빈칸 | 글을 고치거나 수렴을 넣음(협의) | 소 | +| J | 이윤 15% · 일반관리비 상한 확인 없음 | 사용자 값이 상한을 넘어도 그대로 | 넘으면 출처 칸에 경고 글 | 소 | + +- 요율 값 오류 · 밑수 오류는 못 찾음 (자동 고르는 13줄 전부 원문과 같음). +- 원문은 조달청 발주 기준(「참고자료」 문구) — 지자체 · 산림청 발주용 별도 제비율은 지식DB에 없음. 지식DB는 근거 · 후보 가이드 — 위 고칠 거리의 값 · 자리는 사용자 협의로 확정. + +## 4. 근거 문서 + +- 원문 제비율 — `resources/knowledge/original/행정규칙/토목공사원가계산 제비율 적용기준/현행_20260413.md` (원본 xlsx 같은 폴더 `첨부/`) +- 원가 서식 · 비목 정의 — `resources/knowledge/original/행정규칙/(계약예규) 예정가격작성기준/현행_20260825.md` §19~22 · `별표/별표2_공사원가계산서.pdf` +- 안전관리비 A · B — `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 및 사용기준/현행_20250212.md` 44줄 +- 조달수수료 — `resources/knowledge/original/행정규칙/조달수수료 고시/현행_20250701.md` 22-33줄 +- 지식DB 정리 — `resources/knowledge/technical_info/01_임도/05_원가정보/제비율.md` · `원가계산_체계.md` · `original/실무문서/_종합비교/01_원가_제비율.md` +- 실무 엑셀 — 기번3 `2024년 간선임도(기번3-울진.대흥)(공통)/…xlsx` (공사원가계산서 · 총괄설계내역서 49~67) · 소광 `25년 산불진화임도(기번8)(울진소광)/…xlsx` (공사원가계산서) · 거창 `01.산불거창(거창북상병곡)/01.내역서/…xlsx` (원가계산서 · 「26. 1. 1.」 시트) +- 이전 결정 — `docs/raw/plans/2026-09-08_plan_B08_실무자료_해독기록.md` 8-13 · 8-14 (퇴직공제 · 공사이행보증 · 폐기물처리비 「필요」 사용자 확정 · 법정 4종 발견) +- 프로그램 — `B09_Estimation/B09_Estimation_Calc_Total.py` · `.ts` · `B09_Estimation_Rates.py` · `B09_Estimation_UI_Tabs_Total.ts` · `B09_Estimation_Store.py` 41-52 · `resources/master_data/요율_조달청제비율.json` diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 5c652cd11..7b4409486 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -1,6 +1,6 @@ """144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기. -- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김) +- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 좌측 「데이터 점검」 묶음 203-1 · INITIAL 안 옮김) - 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함 - 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸 @@ -45,8 +45,7 @@ def test_탭_차례가_엑셀과_같다(): source.index("export const TABS") : source.index("];", source.index("export const TABS")) ] names = re.findall(r'\["\w+", "([^"]+)"\]', block) - assert names[0] == "연결 점검" - assert names[1:] == EXCEL_TABS + assert names == EXCEL_TABS # 203-1 「연결 점검」 탭 = 좌측 묶음으로 옮김 def test_길_셋(): diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py index 4f58a2581..a59f43219 100644 --- a/resources/tester/test_155_b09_tabs.py +++ b/resources/tester/test_155_b09_tabs.py @@ -52,16 +52,18 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ(): out = _node( f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});" "\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name)," - " cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));" + " cls: t.rows.map((_, i) => t.rowClass(i) ?? null)," + " lv: [t.level.key, ...t.rows.map((_, i) => t.level.of(i))] }));" ) assert out["names"] == [ "제 1 호표 찰쌓기", - " ㄴ 건설표준품셈 7-1-2", - " ㄴ 석공", - " ㄴ 뒷채움", - " 합 계", - ] + "ㄴ 건설표준품셈 7-1-2", + "ㄴ 석공", + "ㄴ 뒷채움", + "합 계", + ] # 201-1 들여쓰기 = 공용 표 level(앞 빈칸 없앰) assert out["cls"] == ["is-title", None, None, None, "is-title"] + assert out["lv"] == ["name", 1, 2, 2, 2, 1] SG = { @@ -92,12 +94,12 @@ def test_155_5_단가산출근거_엑셀_꼴_칸(): rows = out["rows"] assert [(r.get("kind"), r["text"]) for r in rows] == [ ("제 1 호표", "뒷채움 / m3"), - (None, " 산림품셈 13-3-2 적용"), - (None, " 규격 : 150 mm"), - ("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"), - ("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"), - ("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"), - ("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"), + (None, "산림품셈 13-3-2 적용"), + (None, "규격 : 150 mm"), + ("노무", "보통인부 : 165,545 * 0.018 = 2,979.8"), + ("중기", "굴착기 노무비 : 55,700 * 0.07 = 3,899.0"), + ("중기", "굴착기 재료비 : 7,695 * 0.07 = 538.6"), # 201-1 같은 공종 = 세로 병합 + ("중기", "굴착기 경비 : 13,041 * 0.07 = 912.8"), ("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"), ] assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8] diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 9b2c75113..45a41bbca 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -229,8 +229,9 @@ console.log(JSON.stringify(out)); assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"] assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑", - "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip + "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) above = out["above"] assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"] diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index 1752de606..12f27f1d5 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -1,6 +1,6 @@ -"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. +"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. -- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 +- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 - 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) """ @@ -18,7 +18,6 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" # 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 EXPECTED = [ - ["연결 점검", ["check"]], ["공사원가계산서", ["cost"]], ["총괄설계내역서", ["total"]], ["설계내역서", ["bill"]], @@ -31,7 +30,7 @@ EXPECTED = [ ] -def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): +def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩(): out = run( [TABS], "\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t)," @@ -39,11 +38,11 @@ def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): ) assert out["groups"] == EXPECTED flat = [t for _, tabs in out["groups"] for t in tabs] - assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19 + assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 18 # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 firsts = list(dict.fromkeys(out["of"])) assert firsts == [g[0] for g in EXPECTED] - assert len(firsts) == 10 + assert len(firsts) == 9 def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): @@ -61,26 +60,26 @@ const view = () => ({ }); bars.draw('dg'); const a = view(); -bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 bars.sub.children[0].click(); // 일위대가목록표 bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 const b = view(); bars.draw('sum_material'); bars.draw('dg'); -bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) -bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭 bars.draw('cost'); const c = view(); -bars.top.children[0].click(); // 연결 점검 = 단독 위 탭 +bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭 bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 console.log(JSON.stringify({ a, b, c, picks })); """, ) a, b, c = out["a"], out["b"], out["c"] - assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [ - "연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가" + assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [ + "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출" ] # fmt: skip - assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5 + assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5 assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] assert not a["blank"] and a["role"] == "tablist" @@ -89,11 +88,11 @@ console.log(JSON.stringify({ a, b, c, picks })); ["자재단가대비표", "true", "compare"], ["재료비수량금액집계표", "false", "sum_material"], ] # fmt: skip - assert b["top"][8][2] == "true" and b["top"][4][2] == "false" + assert b["top"][7][2] == "true" and b["top"][3][2] == "false" # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 - assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None - assert out["picks"] == ["dg_list", "sum_material", "machine", "check"] + assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None + assert out["picks"] == ["dg_list", "sum_material", "machine", "total"] def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙(): diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index f9f68a211..cccc9ecf4 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -281,6 +281,7 @@ const out = { connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect), link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")], cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))), + lv: [table.level.key, ...["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.level.of(at(k)))], edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")], }; table.onCell(at("hand:a"), "connect"); @@ -309,9 +310,20 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): # 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음 assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 assert out["cls"] == [ - "is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip + "is-title", + "is-group is-hand", + "is-hand", + None, + ] # fmt: skip 201-1 레벨 = 공용 표 level + assert out["lv"] == ["name", 1, 3, 4, 3] assert out["edit"] == [True, False] # 묶음 줄 = 이름만 - picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] + picks = [ + "일위대가 호표", + "단가산출 호표", + "서고 구조물조합(마스터 · 회사 · 개인)", + "서고 일위대가 사전조합(마스터 · 회사 · 개인)", + "단가산출 로직(M01 · 새 산근)", + ] # 204-2 # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index c9062f9a3..653d8a64d 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -125,10 +125,13 @@ def test_메뉴_모든_줄(): # 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음) assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip + # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓", - "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", + "줄 지우기"] # fmt: skip assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑", - "아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip + "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", + "줄 지우기"] # fmt: skip assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음 diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index e6011ee2b..6608e4ae1 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS. +"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 1단 굵게 · 공용 표 level 옵션(201-1). 레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음). 총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3. -들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로. +들여쓰기 = 공용 표 level { key: 명칭 칸 · of = 줄 level }(201-1 · 옛 is-lvN · is-indent · is-flat 갈음). """ import json @@ -20,7 +20,7 @@ SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply BODY = f""" const user = {{ estimate: {{}} }}; const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; -const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]); +const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]); console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }})); """ @@ -42,39 +42,35 @@ def _run() -> dict: def test_총괄_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]} top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"] - assert all(rows[k] == (1, "is-lv1 is-title") for k in top) + assert all(rows[k] == (1, "is-title") for k in top) for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat", "supply"]: # fmt: skip - assert rows[k] == (2, "is-lv2"), k + assert rows[k] == (2, None), k assert {lv for lv, _ in rows.values()} == {1, 2} def test_원가계산서_레벨(): rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]} for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]: - assert rows[k] == (1, "is-lv1 is-title"), k + assert rows[k] == (1, "is-title"), k for k in ["material", "labor", "expense_sum"]: - assert rows[k] == (2, "is-lv2"), k + assert rows[k] == (2, None), k for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]: - assert rows[k] == (3, "is-lv3"), k + assert rows[k] == (3, None), k -def test_칸_class_명칭만_들여씀(): +def test_명칭_칸만_들여씀_공용_level(): out = _run() for tab in ("total", "cost"): - for _k, _lv, _cls, name, first, amount in out[tab]: - assert (name, first, amount) == ("is-indent", "is-flat", None) + for _k, lv, _cls, key, of, cell in out[tab]: + assert (key, of, cell) == ("name", lv, None) # 칸 class(is-indent · is-flat) 없음 -def test_CSS_레벨_규칙(): +def test_CSS_옛_레벨_규칙_없음_공용_표로(): css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8") + assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css + common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8") assert re.search( - r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css + r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);", + common, ) - for lv, em in ((2, 1), (3, 2)): - assert re.search( - rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);", - css, - ) - # 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지) - assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css) diff --git a/resources/tester/test_196_1_b06_notice_chips.py b/resources/tester/test_196_1_b06_notice_chips.py index 7027d4783..82ea5dbb6 100644 --- a/resources/tester/test_196_1_b06_notice_chips.py +++ b/resources/tester/test_196_1_b06_notice_chips.py @@ -144,9 +144,10 @@ def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_ """ bar.update(items(["a", "b"], [], [])); chip("fill").click(); -panel.children[1].click(); +panel.children[1].click(); // 207-1 — 고르면 내용 칸 접힘 chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님 out.picks = picks.slice(); +chip("fill").click(); bar.update(items(["a", "b", "x"], [], [])); out.kept = view(); bar.update(items([], ["p"], [])); @@ -165,7 +166,7 @@ out.gone = view(); def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전(): src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") assert " s.add(c), remove: (c) => s.delete(c) }; } + append(...kids) { for (const k of kids) { k.parent = this; this.kids.push(k); } } + addEventListener(type, f) { (this.on[type] ??= []).push(f); } + fire(type, ev) { for (const f of this.on[type] ?? []) f(ev); } + contains(x) { for (let n = x; n; n = n.parent) if (n === this) return true; return false; } + all() { return this.kids.flatMap((k) => [k, ...k.all()]); } + closest() { for (let n = this; n; n = n.parent) if (n.tagName === "TR") return n; return null; } + querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); } + querySelector(sel) { const i = sel.match(/data-i="(-?\\d+)"/)[1]; return this.all().find((n) => n.dataset.i === i) ?? null; } + focus() { doc.activeElement = this; } + blur() { doc.activeElement = doc.body; } +} +const doc = new FakeEl("#document"); +doc.body = new FakeEl("body"); +doc.activeElement = doc.body; +doc.createElement = (t) => new FakeEl(t); +globalThis.document = doc; +const hovers = []; +function createButton(o) { + const b = new FakeEl("button"); + b.label = o.label; b.variant = o.variant; + b.addEventListener("click", o.onClick); + return b; +} +function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); } + +let user = {}; +let table, root, bar; +const draw = () => { + table = buildBill({ sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }); + if (root) root.isConnected = false; + if (root?.contains(doc.activeElement)) doc.activeElement = doc.body; // 지운 요소 초점 = body + root = new FakeEl("div"); + table.rows.forEach((r, i) => { + const tr = new FakeEl("tr"); tr.dataset.i = String(i); + const td = new FakeEl("td"); const input = new FakeEl("input"); + td.append(input); tr.append(td); root.append(tr); + }); + bar = levelBar(root, table); +}; +const at = (key) => table.rows.findIndex((r) => r.key === key); +const tr = (key) => root.kids[at(key)]; +const pick = (key) => root.fire("click", { target: tr(key).kids[0] }); +const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON"); +const off = () => buttons().map((b) => b.disabled); +const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]); +const key = (k, more = {}) => { + let stopped = false; + doc.fire("keydown", { key: k, shiftKey: false, altKey: false, ...more, preventDefault: () => (stopped = true) }); + return stopped; +}; +const out = {}; +draw(); +out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]); +out.guide = bar.kids.at(-1).textContent; +out.hover = hovers.length; +out.none = off(); +pick("earth:4"); +out.earth4 = off(); +out.focus = [tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")]), + pickedClass(table.rows[at("earth:5")])]; +buttons()[3].fire("click"); +out.down = order(); +user = {}; draw(); +out.kept = [off(), tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")])]; +root.focus(); +out.tabStop = key("Tab"); +out.tabNoop = order(); +out.shiftTab = [key("Tab", { shiftKey: true }), order()]; +user = {}; draw(); +pick("earth:5"); +root.focus(); +out.altUp = [key("ArrowUp", { altKey: true }), order()]; +user = {}; draw(); pick("earth:4"); +out.altDown = [key("ArrowDown", { altKey: true }), order()]; +user = {}; draw(); pick("earth:4"); +doc.activeElement = tr("earth:4").kids[0].kids[0]; +out.editing = [key("Tab", { shiftKey: true }), key("ArrowDown", { altKey: true }), order()]; +out.esc = [key("Escape"), doc.activeElement === root]; +out.afterEsc = [key("Tab", { shiftKey: true }), order().length > 0]; +user = {}; draw(); pick("earth:4"); +doc.activeElement = new FakeEl("input"); +out.outside = [key("Tab", { shiftKey: true }), order()]; +root.focus(); +key("Escape"); +out.unpick = [off(), tr("earth:4").cls.has("is-focus"), key("Tab")]; +out.keyAct = [ + keyAct({ key: "Tab", shiftKey: false, altKey: false }, false), + keyAct({ key: "Tab", shiftKey: true, altKey: false }, false), + keyAct({ key: "ArrowUp", shiftKey: false, altKey: true }, false), + keyAct({ key: "ArrowDown", shiftKey: false, altKey: true }, false), + keyAct({ key: "Tab", shiftKey: false, altKey: false }, true), + keyAct({ key: "ArrowUp", shiftKey: false, altKey: false }, false), + keyAct({ key: "Tab", shiftKey: false, altKey: false, ctrlKey: true }, false), +]; +console.log(JSON.stringify(out)); +""" + +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", + "UI_Tabs_Bill_Level"] # fmt: skip +IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓" + + +def _bar(): + body = BAR_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + return json.loads(_node(_ts(SRC) + body)) + + +def _keys(order): + return [k for k, _ in order] + + +def test_단추_넷_고른_줄_못_하면_흐림(): + out = _bar() + assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"], + ["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip + assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"] + assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히 + assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림 + # earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음) + assert out["earth4"] == [True, False, True, False] + assert out["focus"] == [True, "is-focus", None] + # [↓ 아래로] = 메뉴 「아래로 ↓」 와 같은 결과 + keys = _keys(out["down"]) + assert keys.index("earth:5") + 1 == keys.index("earth:4") + # 다시 그려도 고른 줄 · 단추 상태 남음 + assert out["kept"] == [[True, False, True, False], True, "is-focus"] + + +def test_키_셋_편집_중엔_안_먹음(): + out = _bar() + assert out["tabStop"] is True and out["tabNoop"] == [] # 못 하는 들여쓰기 = 바뀜 없음 + stopped, order = out["shiftTab"] + assert stopped and ("earth:4", "sec:1") in [tuple(x) for x in order] # 내어쓰기 + for name in ("altUp", "altDown"): + stopped, order = out[name] + keys = _keys(order) + assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name + # 글 칸 편집 중 = 키 안 먹음(기본 동작 그대로) · Esc = 편집 끝(표에 초점) → 키 먹음 + assert out["editing"] == [False, False, []] + assert out["esc"] == [False, True] + assert out["afterEsc"] == [True, True] + # 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작 + assert out["outside"] == [False, []] + assert out["unpick"] == [[True] * 4, False, False] + # 그냥 ↑ = 옮기기(204 방향키 · test_204_1) + assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None] + + +def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + bill = '.b09-estimation [data-b09-table="bill"]' + # 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌) + assert "is-lv" not in css and "font-size: 13px" not in css + src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") + assert 'level: { key: "name", of: (i) => rows[i]?.level' in src + # 글 칸 왼쪽 맞춤(수량만 오른쪽) + assert re.search( + re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;", + css, + ) + assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css) + + +def test_셸_배선(): + src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src + assert "...(level ? [level] : []), table.root" in src + lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py new file mode 100644 index 000000000..dbcf7093d --- /dev/null +++ b/resources/tester/test_201_1_table_level_merge.py @@ -0,0 +1,135 @@ +"""201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표. + +- level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음 +- mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 · + 합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호) +- 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +JS = r""" +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const file = path.join(process.argv[1], "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p))); +const m = box.exports; +const body = (html) => html.split("")[1].split("")[0].split("").filter(Boolean); +const columns = [ + { key: "g", label: "구분" }, { key: "s", label: "규격" }, { key: "n", label: "명칭" }, + { key: "v", label: "값", kind: "number" }, +]; +const rows = [ + { g: "흙깍기", s: "가", n: "a", v: 1 }, + { g: "흙깍기", s: "가", n: "b", v: 1 }, + { g: "흙깍기", s: "나", n: "c", v: 1 }, + { g: "운반", s: "나", n: "d", v: 2 }, + { g: "운반", s: " ", n: "e", v: 2 }, + { g: "", s: " ", n: "f", v: 2 }, + { g: "", s: "다", n: "g", v: 3 }, +]; +const total = { g: "계", v: 12 }; +const out = {}; +out.plain = m.renderDataTableHtml({ columns, rows, total }); +const merged = { columns, rows, total, mergeSame: ["g", "s"] }; +out.merged = body(m.renderDataTableHtml(merged)); +out.mergedFoot = m.renderDataTableHtml(merged).split("")[1]; +out.spans = m.mergeSpans(merged, 0, rows.length); +out.win = m.mergeSpans(merged, 1, 4); +out.edit = m.mergeSpans({ ...merged, editable: (i, k) => i === 1 && k === "g" }, 0, 3); +out.level = body(m.renderDataTableHtml({ + columns, rows: rows.slice(0, 3), level: { key: "n", of: (i) => [1, 2, null][i] }, + editable: (i, k) => i === 1 && k === "n", +})); +out.cell = [m.cellColumn({ dataset: { c: "3" }, cellIndex: 1 }), m.cellColumn({ dataset: {}, cellIndex: 1 })]; +console.log(JSON.stringify(out)); +""" + + +def _run() -> dict: + result = subprocess.run( + ["node", "-e", JS, str(ROOT)], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_옵션_없으면_무변(): + html = _run()["plain"] + assert "rowspan" not in html.split("")[1] + assert "data-c=" not in html and "--dt-lv" not in html and "ui-data-table__lv" not in html + + +def test_mergeSame_같은_값만_rowspan(): + out = _run() + # 구분: 흙깍기 3 · 운반 2 · 빈 값 2(안 합침) / 규격: 가 2(흙깍기 안) · 나 = 구분이 갈려 따로 · 빈칸 글 안 합침 + assert [s[0] for s in out["spans"]] == [3, 0, 0, 2, 0, 1, 1] + assert [s[1] for s in out["spans"]] == [2, 0, 1, 1, 1, 1, 1] + assert all(s[2] == 1 and s[3] == 1 for s in out["spans"]) # 지정 안 한 열 = 그대로 + rows = out["merged"] + assert '흙깍기' in rows[0].replace(' class="is-merged"', "") + assert 'class="is-merged"' in rows[0] + assert ( + rows[1].count("b' in rows[1] + ) # 합쳐진 칸 빠짐 · 열 번호 + assert '2' in rows[4] # 숫자 값 그대로 + assert "rowspan" not in out["mergedFoot"] # 합계 줄 안 합침 + # 그린 구간(1~3줄) 안에서만 · 고칠 칸 안 합침 + assert out["win"] == [[2, 1, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1]] + assert [s[0] for s in out["edit"]] == [1, 1, 1] + assert out["cell"] == [3, 1] + + +def test_level_보기_전용_들여쓰기(): + rows = _run()["level"] + assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"] + assert 'a' in rows[0] + assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만 + assert '' in rows[1] # 글 상자 칸도 + assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만) + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);" + assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css) + assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css) + assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css) + + +#: 적용 표 — [파일, 들어 있어야 할 글] +APPLIED = [ + ("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'), + ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'), + ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'), + ("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"), + ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'level: blockLevel("text"'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'), + ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'), + ("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"), +] + + +def test_B08_B09_적용(): + for path, text in APPLIED: + assert text in (ROOT / path).read_text("utf-8"), (path, text) + # 옛 글 빈칸 지우기 · 앞 빈칸 들여쓰기는 없앰(병합 · level 로 갈음) + mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8") + assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat + labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8") + assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor + # 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림) + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 8847b62e6..a4db1829a 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -104,7 +104,10 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "panel.replaceChildren(table.root, ...(hint ? [hint] : []))" in shell + assert ( + "panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))" + in shell + ) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침 assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) assert "setTools: bars.setTools" in shell diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py new file mode 100644 index 000000000..2beecbcb5 --- /dev/null +++ b/resources/tester/test_203_1_b09_data_check.py @@ -0,0 +1,112 @@ +"""203-1 B09 좌측 「데이터 점검」 묶음(사용자 60번). + +- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버) +- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending) +- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +CHECK = "B09_Estimation/B09_Estimation_UI_Check.ts" +SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + + +def test_묶음_이름_단추_글_설명_없음(): + assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL + assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL + # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 + assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL + assert "section.append(heading, refresh, check.list);" in SHELL + assert "check.draw(payload, sheets);" in SHELL + + +def test_연결_점검_탭_없음_첫_탭_공사원가계산서(): + out = run( + [TABS], + "\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n)," + " groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));", + ) + assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"] + assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" + assert "let current: TabId = TABS[0][0];" in SHELL + assert "buildCheck" not in SHELL and "check:" not in SHELL + + +def test_좌측_목록_이동_호버_배지(): + out = run( + [TABS, CHECK], + """ +const moves = []; +const refresh = document.createElement("button"); +const panel = createCheckPanel(refresh, (link) => moves.push(link)); +const sheets = { gaps: [ + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, +] }; +const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2", + notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } }; +panel.draw(payload, sheets); +const view = () => ({ + pending: refresh.classList.contains("is-pending"), + texts: panel.list.children.map((c) => c.text), + rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]), +}); +const a = view(); +const rowsBox = panel.list.children[1]; +rowsBox.children[1].click(); +const tipRefresh = hoverCalls.find((h) => h.root === refresh).text(); +const rowHover = hoverCalls.find((h) => h.root === panel.list); +const tipRow = rowHover.text(rowsBox.children[0]); +panel.draw({ book: null, stale: null }, { gaps: [] }); +const b = view(); +const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" }); +console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty })); +""", + ) + a, b = out["a"], out["b"] + assert a["pending"] and not b["pending"] + assert a["texts"][0] == "연결 없음 2곳" + assert a["rows"] == [ + ["0", "ui-sidebar-row", "찰쌓기일위대가표"], + ["1", "ui-sidebar-row", "모래재료비목록표"], + ] + assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] + assert out["moves"] == [{"tab": "material", "key": "MT9"}] + assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" + assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] + assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] + assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == [] + assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"] + + +def test_배지_조건_서버_book_stale(): + keys = ["EQ000004"] # 불도저 · 조종원 · 경유(test_144 와 같은 견본) + book = json.loads(json.dumps(prices.refresh_book(keys), ensure_ascii=False)) + assert prices.book_stale(book, keys) is None # 방금 굳힌 책 = 업데이트 필요 없음 + assert prices.book_stale(None, keys) == "단가 아직 안 굳힘" + first, *rest = list(book["rows"]) + old = {**book, "rows": {k: book["rows"][k] for k in rest}, "fx": []} + old["rows"][rest[0]] = {**old["rows"][rest[0]], "price": -1} + old["driver_factor"] = {"num": 1, "den": 1, "text": "1/1"} + assert prices.book_stale(old, keys) == ( + "새 단가 줄 1 · M01 에서 바뀐 단가 1 · 운전사 계수 바뀜 · 환율 바뀜" + ) + router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8") + assert '"stale": await asyncio.to_thread(prices.book_stale, book, keys),' in router + + +def test_배지_꼴_경고_색_토큰(): + css = (ROOT / "B09_Estimation/B09_Estimation_UI_Page.css").read_text("utf-8") + block = css[css.index(".b09-options .ui-btn.is-pending::after") :] + block = block[: block.index("}")] + assert "position: absolute" in block and "border-radius: 50%" in block + assert "background: var(--color-warning)" in block diff --git a/resources/tester/test_204_1_b09_bill_drag.py b/resources/tester/test_204_1_b09_bill_drag.py new file mode 100644 index 000000000..20367728b --- /dev/null +++ b/resources/tester/test_204_1_b09_bill_drag.py @@ -0,0 +1,242 @@ +"""204-1 B09 설계내역서 — 방향키 · 끌기 레벨 · 이동 · 자손 함께 · 묶음 자동 · 합 전달. + +- 방향키 = 고른 줄(편집 중 아님) → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로) · Alt+←→ 안 받음 +- 끌기 자리 = `dropAt`(틈 · 바라는 레벨 → 부모 · 앞 줄) · + 놓기 = 표 drop(`dropMove` · 메뉴와 같은 지킬 것) +- 묶음 자동 = 셈(짝 `Calc_Bill` · 거울) — 자식이 생긴 항목 = 묶음(자손 합 · 위로 전달) · + 자식이 빠지면 다시 항목(제 수량 · 단가) · 자식이 다 빠진 새 묶음 = 항목(화면 · 맨 위 공종 빼고) +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_2_b09_bill_free import ( # noqa: E402 + BASE, + BOOKS, + DG, + INPUTS, + _calc, + _freeze, + _put, + _rows, +) +from test_184_2_b09_bill_free import _node as _node_calc # noqa: E402 +from test_184_3_b09_bill_menu import ROWS, _node, _row, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", + "UI_Tabs_Bill_Level"] # fmt: skip + + +def _order(bill): + return [(e["key"], e.get("parent"), e.get("kind")) for e in (bill or {}).get("rows", [])] + + +# --- 방향키 ----------------------------------------------------------------------------- + +KEYS_JS = """ +const out = {}; +const press = (pickKey, k, more = {}) => { + user = {}; draw(); pick(pickKey); root.focus(); + return [key(k, more), order()]; +}; +out.right = press("earth:5", "ArrowRight"); +out.left = press("earth:4", "ArrowLeft"); +out.up = press("earth:5", "ArrowUp"); +out.down = press("earth:4", "ArrowDown"); +out.altLeft = press("earth:4", "ArrowLeft", { altKey: true }); +user = {}; draw(); pick("earth:5"); +doc.activeElement = tr("earth:5").kids[0].kids[0]; +out.editing = [key("ArrowRight"), key("ArrowUp"), order()]; +out.guide = bar.kids.at(-1).textContent; +console.log(JSON.stringify(out)); +""" + + +def test_방향키_넷_편집_중엔_안_먹음(): + head = BAR_JS[: BAR_JS.index("const out = {};")] + body = head.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + KEYS_JS + out = json.loads(_node(_ts(SRC) + body)) + stopped, order = out["right"] # → = 위 항목 아래로(earth:4 = 저절로 묶음) + assert stopped and ["earth:5", "earth:4"] in order + stopped, order = out["left"] # ← = 1.1 밖으로(1 아래) + assert stopped and ["earth:4", "sec:1"] in order + for name in ("up", "down"): # ↑↓ = 같은 부모 안 자리 바꿈 + stopped, order = out[name] + keys = [k for k, _ in order] + assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name + assert out["altLeft"] == [False, []] # Alt+← = 브라우저 뒤로 — 안 받음 + assert out["editing"] == [False, False, []] # 글 칸 편집 중 = 글 칸 커서 + assert "←→" in out["guide"] and "↑↓" in out["guide"] and "끌기" in out["guide"] + + +# --- 끌기 자리 · 놓기 ------------------------------------------------------------------------ + +DROP_JS = """ +const start = __USER__; +let user = start; +const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }; +const table = buildBill(ctx); +const at = (k) => table.rows.findIndex((r) => r.key === k); +const order = (b) => (b?.rows ?? []).map((e) => [e.key, e.parent ?? null, e.kind ?? null]); +const out = { at: {}, drop: {} }; +for (const [name, k, gap, want] of __ATS__) out.at[name] = dropAt(table.rows, at(k), gap, want); +for (const [name, k, parent, before] of __DROPS__) { + user = start; + const run = table.drop(at(k), parent, before); + if (run) run(); + out.drop[name] = run ? order(user.bill) : null; +} +console.log(JSON.stringify(out)); +""" + +#: ROWS + 맨 위 새 공종(hand:n · 자식 hand:k) — 4 자재대 뒤 +ROWS2 = [*ROWS[:9], _row("hand:n", None, 1, "group", no="5"), _row("hand:k", "hand:n", 2), + *ROWS[9:]] # fmt: skip + + +def _drag(ats=(), drops=(), rows=ROWS2, user=None): + body = ( + DROP_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)) + .replace("__USER__", json.dumps(user or {}, ensure_ascii=False)) + .replace("__ATS__", json.dumps([list(a) for a in ats], ensure_ascii=False)) + .replace("__DROPS__", json.dumps([list(d) for d in drops], ensure_ascii=False)) + ) + return json.loads(_node(_ts(SRC) + body)) + + +def test_끌기_자리_레벨(): + # 차례 0 sec:1 · 1 sec:1.2 · 2 earth:4 · 3 earth:5 · 4 hand:g · 5 hand:a · 6 hand:b · 7 sec:4 · + # 8 mat:M1 · 9 hand:n · 10 hand:k · 11 sec:G · 12 mat:M2 · 13 fee + out = _drag( + ats=[ + ("오른쪽", "earth:5", 3, 4), # 제자리 + 한 칸 오른쪽 = 위 항목 아래로 + ("제자리", "earth:5", 3, 3), + ("제자리끝", "earth:5", 4, 3), + ("자손안", "hand:g", 5, 3), + ("위로", "hand:g", 2, 3), + ("멀리오른쪽", "hand:g", 2, 9), # 레벨 = 위 줄 + 1 까지 + ("자재아래", "earth:5", 9, 3), # 자재 줄 = 자식 못 둠 → 형제로 + ("맨위안됨", "earth:5", 7, 1), # 항목 = 맨 위(1) 못 감 → 2 + ("맨위공종", "sec:4", 0, 1), + ] + )["at"] + assert out["오른쪽"] == {"parent": "earth:4", "before": None, "level": 4} + assert out["제자리"] is None and out["제자리끝"] is None and out["자손안"] is None + assert out["위로"] == {"parent": "sec:1.2", "before": "earth:4", "level": 3} + assert out["멀리오른쪽"] == out["위로"] + assert out["자재아래"] == {"parent": "sec:4", "before": None, "level": 2} + assert out["맨위안됨"] == {"parent": "sec:1", "before": None, "level": 2} + assert out["맨위공종"] == {"parent": None, "before": "sec:1", "level": 1} + + +def test_놓기_자손_함께_묶음_자동_막음(): + out = _drag( + drops=[ + ("자손", "hand:g", "sec:1.2", "earth:4"), + ("항목아래", "earth:5", "earth:4", None), + ("하나빠짐", "hand:a", "sec:1.2", "hand:b"), + ("맨위남음", "hand:k", "sec:4", None), + ("관급밖", "mat:M2", "sec:1", None), + ("수수료", "fee", "sec:1", None), + ("관급제목", "sec:G", None, "sec:1"), + ("자재아래", "earth:5", "mat:M1", None), + ("자기자손", "hand:g", "hand:a", None), + ] + )["drop"] + keys = [k for k, *_ in out["자손"]] + # 묶음 통째 — 자식 hand:a 는 부모 키 그대로 · 차례 = hand:g 바로 뒤 + assert keys[keys.index("hand:g") + 1] == "hand:a" and keys.index("hand:g") < keys.index( + "earth:4" + ) + assert ["hand:a", "hand:g", None] in out["자손"] + assert ["earth:5", "earth:4", None] in out["항목아래"] # 저장 = 부모만(묶음 = 셈이 정함) + # 자식이 다 빠진 새 묶음 = 항목(kind 지움) · 맨 위 공종 = 묶음 그대로 + assert ["hand:g", "sec:1.2", None] in out["하나빠짐"] + assert ["hand:n", None, "group"] in out["맨위남음"] + for name in ("관급밖", "수수료", "관급제목", "자재아래", "자기자손"): + assert out[name] is None, name + + +def test_깊이_5_넘는_놓기_막음(): + deep = [ + _row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"), + _row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"), + _row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5), + _row("hand:y", "sec:1.2", 3), + ] # fmt: skip + out = _drag(drops=[("깊음", "hand:g", "hand:y", None), ("됨", "hand:z", "hand:y", None)], + rows=deep)["drop"] # fmt: skip + assert out["깊음"] is None + assert ["hand:z", "hand:y", None] in out["됨"] + + +# --- 셈 — 묶음 ↔ 항목 자동 · 합 전달 · 거울 ----------------------------------------------------- + +HANDS = [ + {"key": "hand:a", "parent": "sec:3", "name": "a", "unit": "㎡", "qty": 2, "ref": DG}, + {"key": "hand:c", "parent": "hand:a", "name": "c", "unit": "㎡", "qty": 1, "ref": DG}, +] + + +def _bills(): + frozen = _freeze(_calc()) + under = _put(frozen, "earth:1", parent="earth:4") # 1.1 규준틀 → 절토(토사) 아래 + hands = {**frozen, "rows": frozen["rows"] + HANDS} + alone = {**frozen, "rows": frozen["rows"] + HANDS[:1]} + mat = _put(frozen, "earth:5", parent="mat:b08:sand") + return frozen, under, hands, alone, mat + + +def test_자식_생긴_항목_묶음_합_전달_빠지면_다시_항목(): + plain = _calc() + frozen, under, hands, alone, mat = _bills() + base = _rows(plain) + rows = _rows(_calc(under)) + e1, e4 = rows["earth:1"], rows["earth:4"] + assert (e4["kind"], e4["auto"], e4["qty"], e4["ref"], e4["price_sum"]) == ( + "group", True, None, None, None) # fmt: skip + assert (e1["parent"], e1["level"], e4["level"]) == ("earth:4", 4, 3) + # 묶음 금액 = 자손 합 → 1.2 · 1 로 전달 · 제 금액은 빠짐 + assert e4["sum"] == e1["sum"] == base["earth:1"]["sum"] + assert rows["sec:1.2"]["sum"] == e1["sum"] and rows["sec:1.1"]["sum"] == 0 + assert rows["sec:1"]["sum"] == base["sec:1"]["sum"] - base["earth:4"]["sum"] + gaps = _calc(under)["bill"]["gaps"] + assert not [g for g in gaps if "earth:4" in json.dumps(g, ensure_ascii=False)] + # 자식이 빠지면(원래 부모로) = 다시 항목 · 제 수량 · 단가 · 표 그대로 + assert _calc(_put(under, "earth:1", parent="sec:1.1"))["bill"] == plain["bill"] + # 새 항목(수량 2 · 대가3) — 자식이 생기면 묶음(자식 합) · 빠지면 제 값 다시 + rows = _rows(_calc(hands)) + assert (rows["hand:a"]["kind"], rows["hand:a"]["sum"], rows["hand:c"]["level"]) == ( + "group", 1230, 3) # fmt: skip + a = _rows(_calc(alone))["hand:a"] + assert (a["kind"], a["qty"], a["sum"]) == ("item", 2, 2460) + # 저장 다듬기 = 자식 있는 새 항목도 제 수량 · 연결 지킴(묶음은 셈이 정함) + kept = {r["key"]: r for r in store.clean_bill(hands)["rows"]}["hand:a"] + assert (kept.get("kind"), kept["qty"], kept["ref"]) == (None, 2, DG) + # 자재 줄 = 부모 못 됨 → 기본 부모 + rows = _rows(_calc(mat)) + assert rows["earth:5"]["parent"] == "sec:1.2" and rows["mat:b08:sand"]["kind"] == "item" + + +def test_거울_TS_가_같은_값(): + calc = _ts(["Calc_Base", "Calc_Bill"]) + users = [{**BASE, "bill": b} for b in _bills()] + body = ( + f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" + f"\nconst us = {json.dumps(users, ensure_ascii=False)};" + f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));" + ) + got = json.loads(_node_calc(calc + body)) + assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py new file mode 100644 index 000000000..c89d23564 --- /dev/null +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -0,0 +1,244 @@ +"""204-2 B09 설계내역서 연결 고르기 — 프로젝트 호표 + 서고(마스터 · 회사 · 개인) +구조물조합 · 사전조합. + +- 서고 고름 = 프로젝트 사본 들여옴(`B09_Estimation/v1/library/{폴더}`) + 새 대가 호표(`libs`) + + 줄 연결 `{tab: dg, key: 블록 id, lib: 폴더}` · 다시 고르면 같은 사본(만듦 거짓 · 같은 호표) +- 사본 단가 묶음(로직 · 사전조합 자재 · 고른 자재) · 원본을 지워도 그대로 · 단가 수요 키에 듦 +- [저장] = 줄이 안 잇는 사본 지움 · 덮개 저장 꼴이 `lib` 를 지킴 +- 층 권한 = `M02_Layer_Access`(남의 회사 403 · 회사 없는 개인 409) +- 화면 — 연결 메뉴 출처 다섯 · 사전조합 창(서고 줄만) → 들여오기 → 원가 자료 바꿔 끼움 · 줄 연결 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from config import config_system # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 +from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡ +NET = "GC000337" # 낙석방지망 — 고름 4 = NET_ITEM +NET_ITEM = "MT006697" +MAT = "MT000008" +COMBO = "SS000011" # 견본 관공 관매설(옛 UA 조합 줄) +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = copy_master_sample(tmp_path / "master") + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + lines = [ + {"로직": STONE, "수량": {"손": 2}}, + {"로직": NET, "수량": {"손": 1}, "고름": {"4": NET_ITEM}}, + ] + with store.at(system): + key = refs.create( + "precombo", + {"이름": "서고 조합", "단위": "㎡", "담은로직": lines, + "자재": [{"코드": MAT, "수량": {"손": 3}}]}, + )["이름"] # fmt: skip + company = layers.company_dir(7) + company.mkdir(parents=True) + with store.at(company): + other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + project = tmp_path / "storage" / "7" / "1" / "proj" + project.mkdir(parents=True) + return {"system": system, "key": key, "company": other, "root": project} + + +def _block(root, folder): + got = daega_inputs(root) + bid = got["libs"][folder] + return got, next(b for b in got["blocks"] if b["id"] == bid) + + +def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사용(world): + root = world["root"] + made = library.import_copy(root, "precombo", "system", None, world["key"]) + folder = made["폴더"] + assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True} + copy = library.library_dir(root) / folder + assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"} + got, block = _block(root, folder) + assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡") + kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]] + assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)] + assert got["owners"] == {} # 구조물 사본 없어도 호표 + # 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음) + bundle = json.loads((copy / "단가.json").read_text("utf-8")) + text = json.dumps(bundle, ensure_ascii=False) + assert all(code in text for code in (STONE, NET, MAT, NET_ITEM)) + keys = linked_resource_keys(root) + assert MAT in keys and any(k.startswith("LB") for k in keys) # 단가 수요에 듦 + # 다시 고름 = 새로 안 들여옴 · 같은 호표 + again = library.import_copy(root, "precombo", "system", None, world["key"]) + assert again == {"폴더": folder, "만듦": False} + assert _block(root, folder)[1]["id"] == block["id"] + # 원본을 지워도 굳힌 사본 그대로 + (world["system"] / "precombo" / f"{world['key']}.json").unlink() + assert _block(root, folder)[1]["id"] == block["id"] + + +def test_구조물조합_고름_사본_조합_호표(world): + root = world["root"] + made = library.import_copy(root, "combo", "system", None, COMBO) + copy = library.library_dir(root) / made["폴더"] + names = {p.name for p in copy.iterdir()} + assert {"집계.json", "일위대가.json", "단가.json", "입력값.json", "manifest.json"} <= names + manifest = json.loads((copy / "manifest.json").read_text("utf-8")) + assert manifest["종류"] == "combo" and manifest["원본"]["key"] == COMBO + assert manifest["원본"]["경로"] # 가져온 때 경로 + _, block = _block(root, made["폴더"]) + assert block["kind"] == "조합" and block["lines"] + assert block["origin"] == {"layer": "system", "owner": None, "key": COMBO} + + +def test_없는_것_모양_틀림(world): + root = world["root"] + with pytest.raises(LookupError): + library.import_copy(root, "precombo", "system", None, "SC-999999") + with pytest.raises(LookupError): + library.import_copy(root, "combo", "system", None, "SS999999") + with pytest.raises(ValueError): + library.import_copy(root, "precombo", "system", None, "../x") + with pytest.raises(ValueError): + library.import_copy(root, "other", "system", None, world["key"]) + assert library.copies(root) == [] + + +def test_저장_때_안_잇는_사본_지움_lib_지킴(world): + root = world["root"] + a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"] + ref = {"tab": "dg", "key": "DG:x", "lib": a} + user = store_b09.clean_user( + {"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}} + ) + assert user["bill"]["rows"][0]["ref"] == ref # 저장 꼴이 lib 를 지킴 + assert library.prune(root, user) == [b] + assert [f.name for f, _ in library.copies(root)] == [a] + assert library.prune(root, {}) == [a] and library.copies(root) == [] + + +def _call(monkeypatch, root, session, **body): + async def fake_root(_): + return root + + async def fake_payload(_, at): + return {"inputs": {"daega": daega_inputs(at)}, "sheets": {}} + + monkeypatch.setattr(router, "_project_root", fake_root) + monkeypatch.setattr(router, "payload", fake_payload) + req = router.LibraryBody(**body) + return asyncio.run(router.import_library("00000000-0000-0000-0000-000000000001", req, session)) + + +def test_층_권한_길(world, monkeypatch): + root = world["root"] + mine = {"user_id": 1, "company_id": 7, "role": "USER", "name": "갑"} + got = _call(monkeypatch, root, mine, kind="precombo", layer="company", key=world["company"]) + assert got["만듦"] and got["폴더"] == f"precombo@company@7@{world['company']}" + assert got["key"] == got["payload"]["inputs"]["daega"]["libs"][got["폴더"]] + assert json.loads((library.library_dir(root) / got["폴더"] / "manifest.json").read_bytes())[ + "가져온이" + ] == {"id": 1, "이름": "갑"} + other = {"user_id": 2, "company_id": 8, "role": "USER"} + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, other, kind="precombo", layer="company", owner="7", + key=world["company"]) # fmt: skip + assert error.value.status_code == 403 + lonely = {"user_id": 3, "company_id": None, "role": "USER"} + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, lonely, kind="precombo", layer="personal", key=world["key"]) + assert error.value.status_code == 409 + with pytest.raises(HTTPException) as error: + _call(monkeypatch, root, lonely, kind="precombo", layer="system", key="SC-999999") + assert error.value.status_code == 404 + got = _call(monkeypatch, root, lonely, kind="precombo", layer="system", key=world["key"]) + assert got["만듦"] and got["key"] # 마스터 = 누구나 읽음 + + +# ── 화면 ────────────────────────────────────────────────────────────── +LINK_JS = """ +const toasts = []; +const sent = []; +globalThis.showToast = (m, k) => toasts.push([k, m]); +globalThis.API_BASE_URL = "/api"; +globalThis.CURRENT_PROJECT_ID_KEY = "pid"; +globalThis.localStorage = { getItem: () => "P1" }; +globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]); + lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); }; +globalThis.openItemPicker = async () => ({ action: "apply", + item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } }); +const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new", + payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } }, + sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } }; +globalThis.fetch = async (url, init) => { sent.push([url, JSON.parse(init.body)]); + return { ok: true, status: 200, json: async () => answer }; }; +const ctx = { sheets: {}, payload: { inputs: { old: 1 } }, user: { keep: 1 }, setUser() {} }; +const applied = []; +const apply = (ref, from) => applied.push([ref, from && from.name]); +const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); +const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; +picks.find((x) => x[0].startsWith("서고 일위대가"))[1](); +await new Promise((r) => setTimeout(r, 0)); +picks.find((x) => x[0].startsWith("서고 구조물"))[1](); +await new Promise((r) => setTimeout(r, 0)); +await new Promise((r) => setTimeout(r, 0)); +Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); +console.log(JSON.stringify(out)); +""" + + +def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): + src = IMPORTS.sub( + "", (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=src + LINK_JS, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + out = json.loads(result.stdout) + assert out["picks"] == [ + "일위대가 호표", + "단가산출 호표", + "서고 구조물조합(마스터 · 회사 · 개인)", + "서고 일위대가 사전조합(마스터 · 회사 · 개인)", + "단가산출 로직(M01 · 새 산근)", + ] + assert out["more"] == ["연결 풀기"] + pre, (url, body) = out["sent"][0], out["sent"][1] + assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 + assert url == "/api/projects/P1/estimation/library" + assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} + assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, + "key": "SS000011"} # fmt: skip + ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"} + assert out["applied"][0] == [ref, "회사 조합"] + # 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음 + assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}} + assert out["toasts"][-1][0] == "success" diff --git a/resources/tester/test_205_1_structure_actions_height.py b/resources/tester/test_205_1_structure_actions_height.py new file mode 100644 index 000000000..9d3ae79b4 --- /dev/null +++ b/resources/tester/test_205_1_structure_actions_height.py @@ -0,0 +1,50 @@ +"""205-1 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄 — 단추 높이 = 상자 안 작은 단추 · 늘 보임. + +- 높이 = `--b05-control-h`(상자 안 단추 · 입력 칸과 같은 값) · 상자 이름표 [삭제]와 같은 높이 식 +- 줄 = 스크롤 영역 아래 끝에 붙음(sticky · bottom 0) · 단추 · 줄에 hidden · display none · visibility · + 호버 조건이 없음 +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B05_Profile/B05_Profile_UI_Style_Structures.css").read_text(encoding="utf-8") +FORM = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Form.ts").read_text(encoding="utf-8") +SECTION = ".b05-structure-section .b05-route__irregular-actions" + + +def _block(selector: str) -> str: + m = re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + assert m, selector + return m.group(1) + + +def _decl(block: str, prop: str) -> str: + m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block) + assert m, prop + return m.group(1).strip() + + +def test_buttons_same_height_as_inner_buttons(): + outer = _block(SECTION + " > button") + inner = re.search(r"\.b05-structure__group > legend > button\s*\{([^}]*)\}", CSS).group(1) + assert _decl(outer, "height") == "var(--b05-control-h)" + for prop in ("height", "box-sizing", "padding-block", "font-size", "line-height"): + assert _decl(outer, prop) == _decl(inner, prop), prop + assert re.search(r"--b05-control-h:\s*26px;", CSS) + + +def test_row_always_visible(): + row = _block(SECTION) + assert _decl(row, "position") == "sticky" + assert _decl(row, "bottom") == "0" + assert "background" in row + for text in (_block(SECTION), _block(SECTION + " > button")): + assert not re.search(r"display\s*:\s*none|visibility|opacity|overflow", text) + assert not re.search(re.escape(SECTION) + r"[^{]*(:hover|:focus-within|\[hidden\])", CSS) + + +def test_form_never_hides_buttons(): + assert "removeButton.hidden" not in FORM and "resetButton.hidden" not in FORM + assert "actions.hidden" not in FORM diff --git a/resources/tester/test_206_1_both_fill_no_ditch.py b/resources/tester/test_206_1_both_fill_no_ditch.py new file mode 100644 index 000000000..9e5d603a4 --- /dev/null +++ b/resources/tester/test_206_1_both_fill_no_ditch.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +"""PLAN 206-1 — 양측 성토 측점에 측구가 남는 흠(검증_sub1 No.1+0 사본 재현). + +원인 — 측구 판정의 「양측 성토 = 측구 없음」이 **입력** `section_mode` 를 봤다. 입력은 옛 저장값 +(초기값 right_cut)이고, 계획고를 +1.98 m 올려 지형 판정(역할)은 양측 성토가 됐는데 입력이 그대로라 +옛 결과 `ditch_enabled=True` 가 사용자 선택으로 둔갑(ditch_choice=True)해 측구가 섰다. +좌 · 우를 누르면 카드가 판정값 both_fill 을 입력으로 다시 보내 그때서야 사라졌다. +고침 — 판정된 역할(양쪽 성토)로 가름 · 파이썬 · TS 짝 같은 규칙 · 옛 저장분은 낡음으로 다시 셈. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 + +# 검증_sub1(7e420ed6) 노선 230 · No.1+0(20 m) 지반선 사본 — -20 ~ +20 m · 0.5 m 간격 +_Z = [ + 885.0166, 884.8327, 884.6401, 884.4294, 884.2270, 883.7564, 883.2482, 882.8840, 882.5737, + 882.3786, 882.2795, 881.9692, 881.3788, 880.7761, 880.1219, 879.4944, 879.1738, 878.8550, + 878.6597, 878.4670, 878.1648, 877.8416, 877.5119, 877.1749, 876.8564, 876.5584, 876.2612, + 875.9646, 875.6791, 875.4169, 875.1405, 874.7286, 874.3079, 874.0026, 873.7065, 873.4074, + 873.1107, 872.7726, 872.4127, 872.1306, 871.9252, 871.7424, 871.6078, 871.3723, 870.7716, + 870.1964, 869.8586, 869.5135, 869.2563, 869.0105, 868.5769, 868.0912, 867.7274, 867.4369, + 867.1615, 866.9033, 866.6590, 866.4489, 866.2281, 865.9711, 865.7139, 865.4391, 865.1650, + 864.8953, 864.6289, 864.3219, 863.9996, 863.6703, 863.3343, 863.0443, 862.8261, 862.5776, + 862.2364, 861.9035, 861.6491, 861.3932, 861.1137, 860.8302, 860.6107, 860.4051, 860.2006, +] # fmt: skip +SAMPLES = [{"offset_m": -20.0 + 0.5 * i, "elevation_m": z} for i, z in enumerate(_Z)] +ELEV = 873.900731 # 지금 계획고(초기값 871.9252 에서 +1.98 m) +# 그 프로젝트 저장분과 같은 입력 — 옛 단면유형 · 옛 측구 결과 +STORED = { + "ground_type": "ripping_rock", + "ditch_side": "right", + "ditch_type": "standard", + "rock_boundary_offset_m": -0.5, + "two_stage_slope": True, +} + + +_GROUP = { + "road_width_m": 3, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "fill_slope_ratio": 1.2, +} +# 그 프로젝트 종단 options 의 표준 횡단면 +STANDARD = { + "soil": {**_GROUP, "cross_slope_pct": {"min": 3, "max": 5}, "cut_slope_ratio": 1}, + "rock": { + **_GROUP, + "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, + "cross_slope_pct": {"min": 3, "max": 5}, + "cut_slope_ratio": 0.4, + }, + "paved": { + **_GROUP, + "cross_slope_pct": {"min": 1.5, "max": 2}, + "cut_slope_ratio": 1, + "pavement_thickness_m": 0.2, + }, +} + + +def _design(section_mode: str, **kwargs): + return compute_cross_design( + SAMPLES, ELEV, section_mode=section_mode, standard=STANDARD, **STORED, **kwargs + ) + + +@pytest.mark.parametrize( + "stale", + [ + {"ditch_enabled": True}, # 옛 결과만(초기값 → 계획고 변경 재계산) + {"ditch_enabled": True, "ditch_choice": True}, # 둔갑한 선택이 저장된 뒤(지금 DB) + ], +) +@pytest.mark.parametrize("mode", ["right_cut", "both_cut", "left_cut"]) +def test_사본_양측_성토면_옛_입력이어도_측구_없음(mode: str, stale: dict) -> None: + design = _design(mode, **stale) + assert design["section_mode"] == "both_fill" + assert design["ditch_enabled"] is False, "양측 성토인데 측구가 섰다" + assert design["ditch_area_m2"] == 0.0 + assert design["ditch"] == {"type": "none"} + assert design["ditch_choice"] is None, "옛 결과가 선택으로 남았다" + + +def test_사본_옛_입력과_바른_입력이_같은_형상() -> None: + """좌 · 우를 눌러야만 사라지던 것 — 이제 입력이 옛 값이어도 처음부터 같은 형상 · 면적.""" + stale = _design("right_cut", ditch_enabled=True, ditch_choice=True) + fresh = _design("both_fill") + for key in ("design_line", "fill_area_m2", "cut_area_m2", "ditch_area_m2"): + assert stale[key] == fresh[key], key + assert fresh["fill_area_m2"] == pytest.approx(34.0513, abs=1e-4) + # 우 노견 바깥에 측구 꼭짓점(-2.3 · -2.6 · -2.9 m = 상단 0.9 · 저폭 0.3 사다리꼴)이 없다 + offsets = {p["offset_m"] for p in fresh["design_line"]} + assert not offsets & {-2.3, -2.6, -2.9} + + +def test_한쪽이라도_절토면_측구_그대로() -> None: + """양측 성토 판정은 역할로만 — 우 절토 지형은 옛 입력 both_fill 이어도 측구 섬.""" + design = _design("both_fill", ditch_enabled=False) + assert design["section_mode"] == "both_fill" # 이 지형은 양측 성토 + cut = compute_cross_design(SAMPLES, ELEV - 2.5, section_mode="both_fill", **STORED) + assert cut["section_mode"] in ("right_cut", "both_cut") + assert cut["ditch_enabled"] is True and cut["ditch_side"] == "right" + + +def test_TS_짝도_같은_값(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """거울 — 같은 사본을 TS `computeCrossDesign` 에 넣어 파이썬과 같은지(측구 없음까지).""" + import test_b06_cross_design_mirror as mirror + + cases = [ + { + "name": f"206-1 사본 {mode}", + "samples": SAMPLES, + "design_elevation_m": ELEV, + "options": { + "section_mode": mode, + **STORED, + "ditch_enabled": True, + "ditch_choice": True, + }, + } + for mode in ("right_cut", "both_fill") + ] + monkeypatch.setattr(mirror, "CASES", cases) + py, ts = mirror._python_results(), mirror._ts_results(tmp_path) + problems = [] + for case, left, right in zip(cases, py, ts): + problems.extend(mirror._diff(left, right, case["name"])) + assert not problems, "\n".join(problems[:20]) + assert all(item["ditch_enabled"] is False for item in ts) + + +def _src(rel: str) -> str: + return (PROJECT_ROOT / rel).read_text(encoding="utf-8") + + +def test_옛_저장분은_낡음으로_다시_셈() -> None: + """DB 에 남은 양측 성토 + 측구 결과 → B05 · B06 진입 때 브라우저가 다시 셈(공용 낡음 판정).""" + common = _src("B06_Section/B06_Section_UI_Section_Common.ts") + assert 'design.section_mode === "both_fill" && design.ditch_enabled === true' in common + + +def test_측구가_그려져_있으면_단추를_숨기지_않음() -> None: + """179-2 숨김은 「막힘 + 그려진 측구 없음」일 때만 — 그려진 측구는 끌 수 있어야 함.""" + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + assert "const ditchDrawn = design?.ditch_enabled === true;" in cd + assert 'const hasDitch = state.mode !== "both_fill" || ditchDrawn;' in cd + assert '(state.mode !== "both_fill" || ditchDrawn)' in cd # 측구형식도 같은 규칙 diff --git a/resources/tester/test_207_1_b06_notice_overlay.py b/resources/tester/test_207_1_b06_notice_overlay.py new file mode 100644 index 000000000..08b45a0f9 --- /dev/null +++ b/resources/tester/test_207_1_b06_notice_overlay.py @@ -0,0 +1,115 @@ +"""207-1 B06 알림 칩 다듬기 — 둥근 사각 · 내용 칸 겹쳐 띄움 · 고르면 접힘 · 고른 측점 강조. + +동작은 `B06_Section_UI_Cross_Notice_Chips.ts` 를 Node 로 그대로 돌림(196-1 가짜 DOM 재사용). +모양 · 흐름 밖(겹침) · 줄 높이만 재는 scroll-margin 은 CSS · 부르는 쪽 원문 대조. +""" + +import json +import subprocess + +from test_196_1_b06_notice_chips import B06, PRELUDE + +CSS = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + + +def _rule(selector: str) -> str: + body = CSS[CSS.index(selector + " {") :] + return body[: body.index("}")] + + +def _node(body: str): + script = ( + PRELUDE + + """ +const sel = { id: null }; +const picked = []; +const bar2 = mod.exports.createNoticeChips((id) => { picked.push(id); sel.id = id; }, () => sel.id); +const [chips2, panel2] = bar2.root.children; +const chip2 = (key) => chips2.children.find((c) => c.dataset.noticeKey === key); +const marks = () => panel2.children.map((b) => [b.dataset.stationId, b.classList.contains("is-selected")]); +""" + + body + + "\nconsole.log(JSON.stringify(out));" + ) + got = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_칩은_둥근_사각_공용_단추_반경_알약_아님(): + chip = _rule(".b06-section__notice-chip") + assert "border-radius: var(--radius-buttons)" in chip + assert "radius-pills" not in chip and "radius-sm" not in chip + + +def test_내용_칸은_흐름_밖_카드_위에_겹침(): + panel = _rule(".b06-section__notice-list") + assert "position: absolute" in panel and "top: 100%" in panel + assert "z-index" in panel and "box-shadow: var(--shadow-lg)" in panel + assert "background: var(--color-surface-raised)" in panel + # 기준 = 고정 줄(sticky 는 absolute 의 기준 상자) · 그 사이에 기준을 바꾸는 position 없음 + assert "position: sticky" in _rule(".b06-section__notice-bar") + assert "position" not in _rule(".b06-section__notice-blocks") + + +def test_열고_닫아도_카드_자리_그대로_scroll_margin은_칩_줄_높이만(): + view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + # 줄 높이 = 고정 줄 offsetHeight — 흐름 밖 내용 칸은 높이에 안 듦 + assert '"--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`' in view + assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS + # 내용 칸만 흐름 밖 — 칩 줄은 흐름 안 + assert "position" not in _rule(".b06-section__notice-chips") + + +def test_측점_고르면_그_카드로_가며_내용_칸_접힘(): + got = _node( + """ +bar2.update(items(["a", "b"], ["p"], [])); +chip2("fill").click(); +out.before = panel2.hidden; +panel2.children[1].click(); +out.picked = picked.slice(); +out.after = panel2.hidden; +out.rows = panel2.children.length; +out.expanded = chips2.children.map((c) => c.getAttribute("aria-expanded")); +""" + ) + assert got["before"] is False + assert got["picked"] == ["b"] + assert got["after"] is True and got["rows"] == 0 + assert got["expanded"] == ["false", "false"] + + +def test_고른_측점이_있으면_열_때_그_단추_강조_없으면_강조_없음(): + got = _node( + """ +bar2.update(items(["a", "b", "c"], ["b"], [])); +chip2("fill").click(); +out.none = marks(); +chip2("fill").click(); +sel.id = "b"; // 측점 횡단도를 고른 상태 +chip2("fill").click(); +out.fill = marks(); +chip2("pave").click(); +out.pave = marks(); +sel.id = "zz"; // 목록에 없는 측점 +chip2("fill").click(); +out.absent = marks(); +""" + ) + assert got["none"] == [["a", False], ["b", False], ["c", False]] + assert got["fill"] == [["a", False], ["b", True], ["c", False]] + assert got["pave"] == [["b", True]] + assert got["absent"] == [["a", False], ["b", False], ["c", False]] + + +def test_고른_측점_넘김_부르는_쪽_이음과_강조_색(): + notice = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") + assert "createNoticeChips(onPick, selected)" in notice + view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8") + assert "() => selectedStationId," in view + mark = _rule(".b06-section__notice-list > button.is-selected") + assert "var(--color-danger)" in mark # 카드 고름 색과 같음 + assert "border-color: var(--color-danger)" in _rule(".b06-cross-card--selected") diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 151858314..d5169229f 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -234,6 +234,7 @@ const camel = { two_stage_slope: "twoStageSlope", cut_slope_ratio: "cutSlopeRatio", ditch_enabled: "ditchEnabled", + ditch_choice: "ditchChoice", surface_drop_m: "surfaceDropM", plan_radius_m: "planRadiusM", curve_outer_side: "curveOuterSide", diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index af0a5e26d..787d9e90f 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -172,3 +172,24 @@ outline: 2px solid var(--color-focus-ring); outline-offset: -2px; } + +/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */ +.ui-data-table__table td.ui-data-table__lv { + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + text-align: left; +} + +.ui-data-table__table td.ui-data-table__lv.is-edit { + padding-left: 0; +} + +.ui-data-table__table td.ui-data-table__lv .ui-data-table__input { + padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em); + text-align: left; +} + +/* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */ +.ui-data-table__table td.is-merged { + vertical-align: top; + padding-top: calc((var(--dt-row-h) - 1.3em) / 2); +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 33656ca10..7f0b93d82 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -11,6 +11,9 @@ * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) + * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) + * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 + * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -65,6 +68,10 @@ export interface DataTableOptions { cellClass?: (index: number, key: string) => string | null | undefined; /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ fillViewport?: boolean; + /** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */ + level?: { key: string; of: (index: number) => number | null | undefined }; + /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ + mergeSame?: string[]; } export interface DataTable { @@ -145,15 +152,21 @@ function rowHtml( focus = false, extra: string | null | undefined = null, cellClass?: (key: string) => string | null | undefined, + more: RowMore = {}, ): string { let tr = ""; + const { lv, spans } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; + const span = spans?.[j] ?? 1; + if (!span) continue; // 위 칸에 합쳐짐 + const lvc = lv?.key === key ? " ui-data-table__lv" : ""; + const c = spans ? ` data-c="${j}"` : ""; if (edit !== null && editable?.(edit, key)) { const v = r[key]; const raw = v === null || v === undefined ? "" : String(v); const tip = esc(cols[j].title ?? cols[j].label); - tr += ``; + tr += ``; continue; } const [txt, empty] = formatCell(r[key], fmts[j], blank); @@ -163,24 +176,68 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + lvc.trim(), + span > 1 && "is-merged", ] .filter(Boolean) .join(" "); const attrs = (cls ? ` class="${cls}"` : "") + (frz ? ` style="left:${left[j]}px"` : "") + - (fmts[j] ? ' data-num=""' : ""); + (fmts[j] ? ' data-num=""' : "") + + (span > 1 ? ` rowspan="${span}"` : "") + + c; tr += `${esc(txt)}`; } const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra] .filter(Boolean) .join(" "); - const data = index !== null ? ` data-i="${index}"` : ""; + const data = + (index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : ""); return cls || data ? `${tr}` : `${tr}`; } +/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */ +interface RowMore { + lv?: { key: string; n: number }; + spans?: number[]; +} + +/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ +export function cellColumn(cell: HTMLTableCellElement): number { + return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex; +} + +/** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */ +export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] { + const cols = o.columns; + const keys = new Set(o.mergeSame ?? []); + const out = Array.from({ length: to - from }, () => cols.map(() => 1)); + // 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고) + const shown = (i: number, j: number): string | null => { + const key = cols[j].key; + if (o.editable?.(i, key)) return null; + const [txt] = formatCell(o.rows[i][key], null, ""); + return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침 + }; + for (let i = from + 1; i < to; i++) { + let joined = true; + for (let j = 0; j < cols.length; j++) { + if (!keys.has(cols[j].key)) continue; + const v = shown(i, j); + joined = joined && v !== null && v === shown(i - 1, j); + if (!joined) continue; + let top = i - 1; + while (!out[top - from][j]) top--; + out[top - from][j]++; + out[i - from][j] = 0; + } + } + return out; +} + /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ export interface RowWindow { from: number; @@ -216,7 +273,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin let body = win ? pad(from * win.rowH, cols.length) : ""; const blank = o.empty ?? EMPTY; const pick = o.onRowClick !== undefined || o.onCellClick !== undefined; - for (let i = from; i < to; i++) + const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null; + for (let i = from; i < to; i++) { + const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0; body += rowHtml( cols, fmts, @@ -230,7 +289,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin o.focus === i, o.rowClass?.(i), o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, + { + ...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}), + ...(spans ? { spans: spans[i - from] } : {}), + }, ); + } if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.caption)}` : ""; @@ -342,7 +406,7 @@ export function createDataTable(opts: DataTableOptions): DataTable { const row = (event.target as Element).closest("tr[data-i]"); if (!row || !root.contains(row)) return; const cell = (event.target as Element).closest("td"); - const column = cell ? state.columns[cell.cellIndex] : undefined; + const column = cell ? state.columns[cellColumn(cell)] : undefined; if (column) state.onCellClick?.(Number(row.dataset.i), column.key); state.onRowClick?.(Number(row.dataset.i)); });