From 6e8de27966f4c61f0559cfe7e99977bee0b96d31 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 15:16:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B08):=20=ED=86=A0=EC=A0=81=ED=91=9C=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=ED=98=B8=EB=B2=84=EC=99=80=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EB=93=B1=EA=B8=89=20=EB=B6=84=EB=A5=98=20=E2=80=94?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=ED=99=98=EA=B2=BD=20=EC=A0=84=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면에 뜬 숫자가 어디서 와서 어떻게 나왔는지 가릴 길이 없어 개발·검산이 막히던 것을 엶(PLAN 8-36 ①②③④). 칸에 마우스를 올리면 등급·값·식·원천·자리가 카드로 뜨고, 토글을 켜면 등급색이 칸 왼쪽 띠로 붙음. - `common_util/common_util_provenance.py` — 등급 상수 여덟과 `ColumnProvenance`. 사람이 고르는 여섯(입력·측량·기준·계산·최종·막힘)에 `excluded`(일부러 안 셈, 채우면 이중계상)와 `unclassified`(판단 미룸)를 더함. `excluded` 는 데스크탑 보조의 B09 조사에서 나온 것으로 `blocked` 와 뜻이 정반대라 갈라 둠. 값 안에 선택이 숨은 열을 위해 `rule`(채택 규칙) 칸도 둠. - ⚠ 로직 보안 — `provenance_payload()` 가 개발환경이 아니면 `None` 을 돌려주고 라우터가 응답에서 칸을 통째로 뺌. 화면에서 숨기는 것이 아니라 안 보내는 것임. 문의 정본은 `is_dev_environment()` 하나로 통일. - `ui_template/ui_template_provenance.ts` — 호버 카드·등급색·토글 한 벌. B09 도 같이 씀. - `B08_Quantity/B08_Quantity_Provenance.py` — 토적표 20열 사전. 사전은 **열 단위**이고 줄마다 갈리는 사유만 줄 쪽에서 얹음(칸마다 지으면 한 장이 6천 칸이라 응답이 붐). - 토적표에는 `final` 열이 하나도 없어 억지로 붙이지 않음 — 중간 장부이고 내역서로 나가는 값은 토공집계표에서 섬. 자체검증 — 시험 8개 추가(`test_b08_provenance.py`). 값어치는 첫 번째에 있음: 사전 열 이름이 전부 실제 `EarthworkRow` 에 있는가(엔진이 이름을 갈면 사전만 옛것으로 남는데 화면에서는 카드가 그냥 안 떠 눈에 안 띔). 배포환경 `None` 도 시험으로 박음. 브라우저(ORCA 5173, 실제 사전을 물려 토적표를 그림) — 표시된 칸 40개(2줄×20열) · 등급 갈래 survey 12 / calc 28 · 카드에 「절토 토사 보정량 · 계산 · 값 45.00 · 식 절토 토사 입적 × 토량환산계수(다짐) · 자리 EarthworkTable.py:210」 · 토글에 배경이 투명 → 초록 7 % 로 바뀌고 왼쪽 띠 inset 3px. 줄 사유가 그 줄 모든 칸에 뜨던 것을 사유가 닿는 열(`ditch_*`)에만 뜨게 고침 — 「절토 보정량」 카드에 「측구 가름값이…」 가 떠서 읽는 사람을 속이던 자리임. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk --- B08_Quantity/B08_Quantity_Provenance.py | 163 +++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 6 + B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 33 ++- B08_Quantity/B08_Quantity_UI_Page.ts | 6 + common_util/common_util_provenance.py | 120 ++++++++ resources/tester/test_b08_provenance.py | 81 ++++++ ui_template/ui_template_provenance.ts | 262 ++++++++++++++++++ 7 files changed, 669 insertions(+), 2 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Provenance.py create mode 100644 common_util/common_util_provenance.py create mode 100644 resources/tester/test_b08_provenance.py create mode 100644 ui_template/ui_template_provenance.ts diff --git a/B08_Quantity/B08_Quantity_Provenance.py b/B08_Quantity/B08_Quantity_Provenance.py new file mode 100644 index 00000000..a33e3379 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Provenance.py @@ -0,0 +1,163 @@ +"""B08 수량 화면의 **근거 사전** — 어느 숫자가 어디서 와서 어떻게 나왔나 (PLAN 8-36 ④). + +⚠⚠ **개발 전용.** 사전은 `provenance_payload()` 를 거쳐 나가고, 개발환경이 아니면 `None` + 이라 응답에 칸 자체가 안 생긴다. 화면에서 숨기는 것이 아니라 **안 보내는 것**이다. + +왜 이 파일인가 + 「식」과 「원천」의 정답은 값을 낳는 엔진이 안다. 화면 TS 에 손으로 적어 두면 엔진을 + 고칠 때 설명만 옛것으로 남는다. 엔진 옆(같은 폴더)에 두어 같이 눈에 들어오게 한다. + +⚠ **열 단위로 적는다.** 토적표 한 장이 30열 × 200줄 = 6천 칸이라 칸마다 지으면 응답이 + 붐는다. 줄마다 갈리는 것(측구 안분 폴백 사유 등)은 줄이 이미 `notes` 로 들고 있고, + 화면이 그것을 카드에 덧붙인다. + +⚠ **토적표에는 `final`(최종) 열이 없다 — 억지로 붙이지 않았다.** + 이 표는 중간 장부다. 내역서로 나가는 값은 **토공집계표**에서 선다. 여섯 등급을 + 한 장에 다 채우려고 아무 열에나 `final` 을 붙이면 「분류가 있다」는 거짓만 남는다. + 이 어긋남은 등급을 고칠 근거이므로 PLAN 8-36 ① 에 그대로 남긴다. +""" + +from __future__ import annotations + +from typing import Any + +from common_util.common_util_provenance import ( + TIER_CALC, + TIER_SURVEY, + ColumnProvenance, + provenance_payload, + sheet_provenance, +) + +#: 토량환산계수가 어디서 오는지 — 여러 열이 같은 문장을 쓰므로 한 벌로 둔다. +_FACTOR_SOURCE = ( + "토량환산계수(다짐) — 기본값 `config_system_design.EARTHWORK_CONVERSION_FACTORS`, " + "프로젝트가 고른 값이 있으면 산출 조건 패널의 값" +) + +#: 단면적 넷의 공통 원천. B06 이 낸 설계 단면을 **그대로** 읽는다(여기서 다시 안 짓는다). +_SECTION_SOURCE = "B06 횡단 설계가 낸 측점별 단면적" + +#: 평균단면적법 한 줄. 신규 문서 5장 「다. 공사수량의 산출」. +_MEAN_AREA = "(앞 측점 단면적 + 이 측점 단면적) ÷ 2 × 두 측점 사이 거리" + + +def _area(key: str, label: str, extra: str = "") -> ColumnProvenance: + """단면적 열 — B06 설계값을 그대로 옮긴 자리라 식이 없다.""" + return ColumnProvenance( + key=key, + label=label, + tier=TIER_SURVEY, + formula="설계가 낸 값을 그대로 읽음 (여기서 다시 계산하지 않음)", + source=_SECTION_SOURCE + (f" · {extra}" if extra else ""), + code="B08_Quantity_Engine_EarthworkTable.py:StationArea.from_design", + ) + + +def _volume(key: str, label: str, area_label: str) -> ColumnProvenance: + return ColumnProvenance( + key=key, + label=label, + tier=TIER_CALC, + formula=_MEAN_AREA.replace("단면적", area_label), + source="첫 측점은 앞이 없어 비어 있음 (실무 토적표도 첫 줄 체적이 빈칸)", + code="B08_Quantity_Engine_EarthworkTable.py:200 mean_volume", + ) + + +def _adjusted(key: str, label: str, volume_label: str) -> ColumnProvenance: + return ColumnProvenance( + key=key, + label=label, + tier=TIER_CALC, + formula=f"{volume_label} × 토량환산계수(다짐)", + source=_FACTOR_SOURCE, + code="B08_Quantity_Engine_EarthworkTable.py:210", + ) + + +def earthwork_sheet() -> dict[str, Any]: + """토적표 한 장의 사전. 열 키는 화면 `EarthworkRow` 와 같은 낱말이라야 한다.""" + return sheet_provenance( + [ + ColumnProvenance( + key="chainage_m", + label="측점", + tier=TIER_SURVEY, + formula="노선 시점에서 잰 이정(m). 화면은 NO.n+m 으로 적음", + source="B05 종단이 놓은 측점 배치", + code="B08_Quantity_Engine_EarthworkTable.py:StationArea.chainage_m", + ), + ColumnProvenance( + key="distance_m", + label="거리", + tier=TIER_CALC, + formula="이 측점 이정 − 앞 측점 이정", + source="B05 종단 측점 배치. 첫 줄은 앞이 없어 0", + code="B08_Quantity_Engine_EarthworkTable.py:195", + ), + _area("cut_soil_area_m2", "절토 토사 단면적"), + _volume("cut_soil_volume_m3", "절토 토사 입적", "절토 토사 단면적"), + _adjusted("cut_soil_adjusted_m3", "절토 토사 보정량", "절토 토사 입적"), + _area("cut_rock_area_m2", "절토 암석 단면적", "암 갈래는 측점의 `cut_rock_kind`"), + _volume("cut_rock_volume_m3", "절토 암석 입적", "절토 암석 단면적"), + _adjusted("cut_rock_adjusted_m3", "절토 암석 보정량", "절토 암석 입적"), + _area( + "ditch_soil_area_m2", + "측구터파기 토사 단면적", + "지반 유형·암반 경계선으로 B06 이 가른 값. 가름이 없는 옛 저장분만 " + "절토 토사:암 면적비로 안분하고 그 줄에 사유가 남음", + ), + _volume("ditch_soil_volume_m3", "측구터파기 토사 입적", "측구 토사 단면적"), + _adjusted("ditch_soil_adjusted_m3", "측구터파기 토사 보정량", "측구 토사 입적"), + _area( + "ditch_rock_area_m2", + "측구터파기 암석 단면적", + "위와 같은 가름값. 0.0 은 설계가 낸 「없음」이고 값 없음과 다름", + ), + _volume("ditch_rock_volume_m3", "측구터파기 암석 입적", "측구 암석 단면적"), + _adjusted("ditch_rock_adjusted_m3", "측구터파기 암석 보정량", "측구 암석 입적"), + ColumnProvenance( + key="adjusted_total_m3", + label="보정량계", + tier=TIER_CALC, + formula="절토 토사 보정량 + 절토 암석 보정량 + 측구 토사 보정량 + 측구 암석 보정량", + source="네 보정량의 합. 성토에 쓸 수 있는 양으로 환산한 뒤의 값", + code="B08_Quantity_Engine_EarthworkTable.py:215", + ), + _area("fill_area_m2", "성토 단면적"), + _volume("fill_volume_m3", "성토 입적", "성토 단면적"), + ColumnProvenance( + key="diverted_m3", + label="유용토", + tier=TIER_CALC, + formula="min(보정량계, 성토 입적)", + source="그 측점에서 절취분과 성토분이 서로 만나는 몫", + code="B08_Quantity_Engine_EarthworkTable.py:222", + ), + ColumnProvenance( + key="balance_m3", + label="차인토량", + tier=TIER_CALC, + formula="보정량계 − 성토 입적", + source="양수면 남는 흙(사토), 음수면 모자란 흙(객토)", + code="B08_Quantity_Engine_EarthworkTable.py:223", + ), + ColumnProvenance( + key="cumulative_m3", + label="누가토량", + tier=TIER_CALC, + formula="첫 줄부터 이 줄까지 차인토량을 더해 온 값", + source="유토곡선(mass haul)의 세로축이 되는 값", + code="B08_Quantity_Engine_EarthworkTable.py:225", + ), + ] + ) + + +def quantity_provenance() -> dict[str, Any] | None: + """B08 응답에 실을 사전 — **개발환경이 아니면 `None`.** + + 시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다. + """ + return provenance_payload({"earthwork": earthwork_sheet()}) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index d3f99c10..ad881d51 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -40,6 +40,7 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_pr from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes +from B08_Quantity.B08_Quantity_Provenance import quantity_provenance from common_util.common_util_project_settings import ( CONCRETE_PLACING_METHODS, ROCK_METHODS, @@ -176,6 +177,11 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: table["settings"] = settings table["project_root_known"] = project_root is not None table["route_id"] = route_id + # 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라 + # 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다. + provenance = quantity_provenance() + if provenance is not None: + table["provenance"] = provenance return JSONResponse(content=table) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index ca459800..ac19b7b3 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -14,6 +14,13 @@ * 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것. * ========================================================================== */ +import { + attachProvenance, + markProvenanceCell, + type ProvenancePayload, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; + /** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */ export interface EarthworkRow { chainage_m: number; @@ -134,6 +141,8 @@ export interface EarthworkTable { conversion_factor_choices?: Record; /** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */ conversion_factor_pumsem_ranges?: PumsemRange[]; + /** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */ + provenance?: ProvenancePayload; } /** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */ @@ -334,7 +343,11 @@ function buildHead(): HTMLTableSectionElement { return head; } -function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement { +function buildBody( + rows: EarthworkRow[], + slope?: SlopeTable, + sheet?: ProvenanceSheet, +): HTMLTableSectionElement { const body = document.createElement("tbody"); const columns = flatColumns(); const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row])); @@ -346,9 +359,15 @@ function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionEl td.textContent = index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits); if (index === 0) td.className = "b08-grid__station"; + // 근거 호버·등급색은 **사전이 왔을 때만** 붙는다(개발환경). + const columnProvenance = sheet?.columns[column.key]; + if (columnProvenance) markProvenanceCell(td, column.key, columnProvenance.tier); tr.append(td); }); + // 줄마다 갈리는 사유(측구 안분 폴백 등)는 열 사전이 못 든다 — 줄에 실어 카드가 덧붙게 한다. + if (row.notes?.length) tr.dataset.provNotes = row.notes.join("\n"); + const slopeRow = slopeByChainage.get(row.chainage_m); // 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b). if (slopeRow?.unclosed) tr.classList.add("is-unclosed"); @@ -458,11 +477,21 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table"; + const sheet = table.provenance?.sheets?.earthwork; element.append( buildHead(), - buildBody(table.rows, table.slope), + buildBody(table.rows, table.slope, sheet), buildFoot(table.totals, table.slope), ); + // 사전이 없으면 아무 일도 안 한다 — 빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다. + // 줄 사유는 **그 사유가 닿는 열에만** 붙인다. 줄에 달렸다고 십몇 칸에 다 띄우면 + // 「절토 보정량」 카드에 「측구 가름값…」 이 떠서 읽는 사람을 속인다(2026-09-12 실측). + // ⚠ 지금 줄 사유는 **측구 안분 폴백 하나뿐**이라 여기서 열 이름으로 가른다. + // 사유가 늘면 엔진이 「어느 열에 닿는 사유인가」를 같이 내는 쪽이 맞다. + attachProvenance(element, sheet, (cell, columnKey) => { + if (!columnKey.startsWith("ditch_")) return []; + return (cell.closest("tr")?.dataset.provNotes ?? "").split("\n").filter(Boolean); + }); if (table.slope) { const notice = buildUnclosedNotice(table.slope, element); diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 3e6b092a..d4c548c2 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -13,6 +13,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections"; +import { createProvenanceToggle } from "@ui/ui_template_provenance"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -779,6 +780,11 @@ function buildQuantityBody( return element; }; + // 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다. + if ((table as unknown as { provenance?: unknown } | null)?.provenance) { + tabs.append(createProvenanceToggle(body)); + } + if (failed) { body.append(tabs, message(L("B08_Quantity_Grid_Failed"))); return body; diff --git a/common_util/common_util_provenance.py b/common_util/common_util_provenance.py new file mode 100644 index 00000000..3f513cf5 --- /dev/null +++ b/common_util/common_util_provenance.py @@ -0,0 +1,120 @@ +"""화면에 뜬 숫자가 **어디서 와서 어떻게 계산됐는지**를 적어 두는 한 벌. + +왜 서버가 드나 (CLAUDE.md 5장 · PLAN 8-36 ④) + 「어디서 와서 어떻게 계산됐나」의 정답은 **엔진이 안다.** 이 설명을 화면 TS 에 손으로 + 적어 두면 엔진을 고칠 때 설명만 옛것으로 남아, 맞는 값 옆에 틀린 근거가 붙는다. + 그래서 사전은 값을 낳는 쪽(서버)이 들고, 화면은 **그리기만** 한다. + +⚠ **칸마다 만들지 않는다 — 열 단위다.** + 토적표 한 장이 30열 × 200줄 = 6천 칸이다. 칸마다 설명을 지으면 응답이 수십 배로 붐는데, + 정작 설명이 갈리는 것은 **열**이지 칸이 아니다. 줄마다 갈리는 것(폴백 안분 사유 등)은 + 이미 줄이 `notes` 로 들고 있으니 화면이 그것만 덧붙인다. 보는 사람 눈에는 그대로 + **칸 단위**로 뜬다. + +⚠⚠ **로직 보안 — 배포에서는 아예 안 실어 보낸다.** + 화면에서 숨기는 것만으로는 막히지 않는다. API 를 직접 부르면 그대로 나온다. + 그래서 `provenance_payload()` 가 **개발환경이 아니면 `None`** 을 돌려주고, 라우터는 + 그 `None` 을 응답에서 통째로 뺀다. 화면 쪽 `import.meta.env.DEV` 는 보조일 뿐이다. + 문의 정본은 `common_util_dev_unlock.is_dev_environment()` 하나로 통일한다 — + 개발용 문이 두 벌이 되면 한쪽만 닫히는 날이 온다. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +from common_util.common_util_dev_unlock import is_dev_environment + +#: 출처 등급 (PLAN 8-36 ①) — 사람이 고르는 여섯 + 사전이 쓰는 둘(`excluded`·`unclassified`). **키는 영문 고정** — 화면·서버가 같은 낱말을 써야 하고, +#: 사람이 읽는 이름은 화면 locale 이 맡는다(번역이 서버 값을 흔들면 안 된다). +#: +#: ⚠ 등급에 **안 맞는 열이 나오면 억지로 끼우지 말 것.** 그 어긋남이 등급을 고칠 근거다. +#: 맞는 등급이 없으면 `UNCLASSIFIED` 로 두고 계획서에 남긴다 — 조용히 아무 등급이나 +#: 붙이면 「분류가 있다」는 거짓만 남는다. +TIER_INPUT = "input" # 사용자가 화면에 직접 넣은 값 +TIER_SURVEY = "survey" # 앞 단계(B05 종단·B06 횡단)가 낳은 값 +TIER_STANDARD = "standard" # 법·품셈·단가판이 정한 고정값 +TIER_CALC = "calc" # 위 셋으로 만든 중간값 +TIER_FINAL = "final" # 내역서·원가계산서로 나가는 값 +TIER_BLOCKED = "blocked" # 근거가 없어 값을 **못** 세운 자리 — 근거가 오면 채워질 자리 +#: ⚠ `EXCLUDED` 는 `BLOCKED` 와 **뜻이 정반대**다(2026-09-12 데스크탑 보조 B09 조사 ㉱). +#: 내역서의 「우리 줄이 아닌 것」·검산용 제외 줄·이중계상이 되는 자리는 **못 세운 것이 아니라 +#: 세면 안 되는 것**이다. 둘을 같은 등급으로 두면 사용자가 「빈 칸을 채워야 겠다」고 움직이고, +#: 그것이 곧 이중계상이다(PLAN 8-7). +TIER_EXCLUDED = "excluded" # 일부러 안 세는 자리 — 채우면 이중계상 +TIER_UNCLASSIFIED = "unclassified" # 어느 등급에도 안 맞아 **판단을 미룬** 자리 + +TIERS: tuple[str, ...] = ( + TIER_INPUT, + TIER_SURVEY, + TIER_STANDARD, + TIER_CALC, + TIER_FINAL, + TIER_BLOCKED, + TIER_EXCLUDED, + TIER_UNCLASSIFIED, +) + + +@dataclass(frozen=True) +class ColumnProvenance: + """열 하나의 「무엇이고 · 어디서 왔고 · 어떻게 나왔나」. + + `formula` 는 **사람이 읽는 한 줄**이지 실행되는 식이 아니다 — 코드를 그대로 베끼면 + 읽는 사람이 못 읽고, 코드가 바뀌면 또 어긋난다. 「입적 × 토량환산계수」처럼 적는다. + + `code` 는 `파일:줄` 이고 **개발환경에서만 화면에 뜬다.** 줄 번호는 쉽게 밀리므로 + 함수 이름을 같이 적어 두면 밀려도 찾을 수 있다. + """ + + key: str + label: str + tier: str + formula: str = "" + source: str = "" + #: 고르는 자리의 **채택 규칙**. 안전관리비 A·B 중 작은 쪽·자재단가 다섯 중 적용처럼 + #: **값 안에 선택이 숨은** 열이 있다(2026-09-12 B09 조사 ㉰). 그 열은 `calc` 로만 적으면 + #: 「왜 그것을 골랐나」가 사라진다. 후보값은 줄마다 달라지므로 여기엔 **규칙만** 적고 + #: 실제 후보값은 줄 쪽으로 내려보낸다. + rule: str = "" + code: str = "" + + def as_dict(self) -> dict[str, str]: + body: dict[str, str] = {"label": self.label, "tier": self.tier} + if self.formula: + body["formula"] = self.formula + if self.source: + body["source"] = self.source + if self.rule: + body["rule"] = self.rule + if self.code: + body["code"] = self.code + return body + + +def sheet_provenance(columns: Iterable[ColumnProvenance]) -> dict[str, Any]: + """한 장(시트)의 사전. 열 키로 찾아 쓰게 dict 로 편다. + + ⚠ 같은 키를 두 번 적으면 **뒤엣것이 앞엣것을 조용히 덮는다.** 열이 늘 때 실수하기 + 쉬운 자리라 여기서 막고 이름을 알려 준다. + """ + body: dict[str, dict[str, str]] = {} + for column in columns: + if column.key in body: + raise ValueError(f"사전에 같은 열 키가 둘 있습니다: {column.key}") + if column.tier not in TIERS: + raise ValueError(f"모르는 등급입니다: {column.key} → {column.tier}") + body[column.key] = column.as_dict() + return {"columns": body} + + +def provenance_payload(sheets: Mapping[str, dict[str, Any]]) -> dict[str, Any] | None: + """응답에 실을 사전 — **개발환경이 아니면 `None`.** + + 라우터는 `None` 이면 그 칸을 응답에서 아예 뺀다(빈 dict 를 실으면 「사전이 있는데 + 비었다」로 읽혀 화면이 빈 카드를 띄운다). + """ + if not is_dev_environment(): + return None + return {"sheets": dict(sheets)} diff --git a/resources/tester/test_b08_provenance.py b/resources/tester/test_b08_provenance.py new file mode 100644 index 00000000..3f6bcd95 --- /dev/null +++ b/resources/tester/test_b08_provenance.py @@ -0,0 +1,81 @@ +"""B08 근거 사전 — 사전이 **엔진과 어긋나지 않는지** 지키는 시험 (PLAN 8-36 ④). + +이 시험의 값어치는 마지막 것 하나에 있다: **사전에 적은 열 이름이 실제 토적표 줄에 있는가.** +엔진이 열을 바꾸거나 이름을 갈면 사전만 옛것으로 남아, 맞는 값 옆에 틀린 근거가 붙는다. +그 어긋남은 화면에서 눈에 안 띄므로(카드가 그냥 안 뜬다) 여기서 잡는다. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from B08_Quantity.B08_Quantity_Engine_EarthworkTable import EarthworkRow +from B08_Quantity.B08_Quantity_Provenance import earthwork_sheet, quantity_provenance +from common_util import common_util_provenance as provenance_module +from common_util.common_util_provenance import ( + TIERS, + ColumnProvenance, + provenance_payload, + sheet_provenance, +) + + +def test_토적표_사전이_엔진_열과_같은_이름을_쓴다(): + """사전 열 키가 전부 `EarthworkRow` 에 있어야 한다 — **이 시험이 사전의 존재 이유다.**""" + row_fields = {field.name for field in dataclasses.fields(EarthworkRow)} + dictionary = earthwork_sheet()["columns"] + 낯선_키 = sorted(set(dictionary) - row_fields) + assert not 낯선_키, f"사전에 있는데 토적표 줄에 없는 열: {낯선_키}" + + +def test_사전_등급이_전부_아는_값이다(): + for key, body in earthwork_sheet()["columns"].items(): + assert body["tier"] in TIERS, f"{key} 의 등급이 모르는 값: {body['tier']}" + + +def test_사전_열마다_이름과_식이_비어_있지_않다(): + """빈 카드는 「설명이 있다」는 거짓만 남긴다 — 적을 것이 없으면 열을 아예 안 넣는다.""" + for key, body in earthwork_sheet()["columns"].items(): + assert body.get("label"), f"{key} 에 이름이 없음" + assert body.get("formula"), f"{key} 에 식이 없음" + + +def test_같은_열을_두_번_적으면_막는다(): + with pytest.raises(ValueError): + sheet_provenance( + [ + ColumnProvenance(key="a", label="가", tier="calc", formula="x"), + ColumnProvenance(key="a", label="나", tier="calc", formula="y"), + ] + ) + + +def test_모르는_등급을_적으면_막는다(): + with pytest.raises(ValueError): + sheet_provenance([ColumnProvenance(key="a", label="가", tier="없는등급")]) + + +def test_배포환경에서는_사전을_아예_안_보낸다(monkeypatch): + """⚠ 로직 보안 — 화면에서 숨기는 것이 아니라 **응답에 안 싣는 것**이 문이다.""" + monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: False) + assert provenance_payload({"earthwork": earthwork_sheet()}) is None + assert quantity_provenance() is None + + +def test_개발환경에서는_시트가_실린다(monkeypatch): + monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: True) + payload = quantity_provenance() + assert payload is not None + assert "earthwork" in payload["sheets"] + + +def test_고르는_자리의_채택_규칙은_적었을_때만_실린다(): + """`rule` 은 안전관리비처럼 **값 안에 선택이 숨은** 열에만 붙는다(B09 조사 ㉯).""" + 없는_것 = ColumnProvenance(key="a", label="가", tier="calc", formula="x").as_dict() + assert "rule" not in 없는_것 + 있는_것 = ColumnProvenance( + key="b", label="나", tier="calc", formula="x", rule="A·B 중 작은 쪽" + ).as_dict() + assert 있는_것["rule"] == "A·B 중 작은 쪽" diff --git a/ui_template/ui_template_provenance.ts b/ui_template/ui_template_provenance.ts new file mode 100644 index 00000000..be53625e --- /dev/null +++ b/ui_template/ui_template_provenance.ts @@ -0,0 +1,262 @@ +/* ============================================================================= + * ui_template_provenance.ts + * 표 칸에 마우스를 올리면 **그 숫자가 어디서 와서 어떻게 나왔는지**를 띄우는 한 벌. + * B08 수량·B09 원가가 같이 쓴다 (PLAN 8-36 ②③). + * + * ⚠⚠ **개발 전용 — 사용자에게 보이지 않는다 (로직 보안).** + * 사전은 서버가 개발환경에서만 실어 보낸다(`common_util_provenance.provenance_payload`). + * 사전이 안 오면 이 모듈은 **아무것도 하지 않는다** — 화면에서 숨기는 것이 아니라 + * 애초에 들고 있지 않은 것이 요점이다. 그래서 낱말도 번역하지 않는다(안 나간다). + * + * ⚠ 겉보기는 **칸 단위**지만 사전은 **열 단위**다. 칸마다 사전을 만들면 토적표 한 장이 + * 6천 칸이라 응답이 붐는다. 줄마다 갈리는 것(폴백 사유 등)은 부르는 쪽이 + * `resolveExtra` 로 얹는다. + * ========================================================================== */ + +/** 열 하나의 사전. 서버 `ColumnProvenance.as_dict()` 와 1:1. */ +export interface ProvenanceColumn { + label: string; + tier: string; + formula?: string; + source?: string; + /** 고르는 자리의 채택 규칙(「안전관리비 A·B 중 작은 쪽」 따위). 없으면 칸이 안 뜨다. */ + rule?: string; + code?: string; +} + +/** 한 장(시트)의 사전. */ +export interface ProvenanceSheet { + columns: Record; +} + +/** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */ +export interface ProvenancePayload { + sheets: Record; +} + +/** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */ +const TIER_LABELS: Record = { + input: "입력", + survey: "측량", + standard: "기준", + calc: "계산", + final: "최종", + blocked: "막힘", + excluded: "제외", + unclassified: "미분류", +}; + +/** 색칠을 켤지 — **포트별로 갈리는 sessionStorage** 에 둔다(창마다 취향이 다르다). */ +const TINT_KEY = "aislo.provenance.tint"; + +const STYLE_ID = "ui-provenance-style"; +const CARD_ID = "ui-provenance-card"; + +/** 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. */ +export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void { + cell.dataset.provCol = columnKey; + if (tier) cell.dataset.provTier = tier; +} + +export function isProvenanceTinted(): boolean { + try { + return sessionStorage.getItem(TINT_KEY) === "on"; + } catch { + return false; + } +} + +function setTinted(root: HTMLElement, on: boolean): void { + root.classList.toggle("is-prov-tinted", on); + try { + sessionStorage.setItem(TINT_KEY, on ? "on" : "off"); + } catch { + /* 저장이 막힌 창에서도 화면은 돌아야 한다 — 이번 화면에서만 켜진다. */ + } +} + +/** + * 색칠 토글 단추. **사전이 없으면 만들지 않는다**(부르는 쪽이 `payload` 를 보고 거른다). + * + * ⚠ 여섯 색이 늘 켜져 있으면 표가 알록달록해 실무 시트와 눈으로 대조를 못 한다. + * 그래서 **평소엔 꺼 두고** 이 단추로만 켠다. + */ +export function createProvenanceToggle(root: HTMLElement): HTMLElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-prov-toggle"; + const paint = (): void => { + const on = root.classList.contains("is-prov-tinted"); + button.textContent = on ? "등급색 끄기" : "등급색 켜기"; + button.classList.toggle("is-on", on); + }; + setTinted(root, isProvenanceTinted()); + paint(); + button.addEventListener("click", () => { + setTinted(root, !root.classList.contains("is-prov-tinted")); + paint(); + }); + return button; +} + +function line(card: HTMLElement, name: string, value: string): void { + if (!value) return; + const row = document.createElement("div"); + row.className = "ui-prov-card__row"; + const key = document.createElement("span"); + key.className = "ui-prov-card__key"; + key.textContent = name; + const body = document.createElement("span"); + body.className = "ui-prov-card__value"; + body.textContent = value; + row.append(key, body); + card.append(row); +} + +function card(): HTMLElement { + let element = document.getElementById(CARD_ID); + if (!element) { + element = document.createElement("div"); + element.id = CARD_ID; + element.className = "ui-prov-card"; + document.body.append(element); + } + return element; +} + +/** 카드를 마우스 옆에 둔다 — 화면 밖으로 나가면 반대쪽으로 접는다. */ +function place(element: HTMLElement, x: number, y: number): void { + element.style.visibility = "hidden"; + element.style.display = "block"; + const box = element.getBoundingClientRect(); + const left = x + 16 + box.width > window.innerWidth ? x - 16 - box.width : x + 16; + const top = y + 16 + box.height > window.innerHeight ? y - 16 - box.height : y + 16; + element.style.left = `${Math.max(4, left)}px`; + element.style.top = `${Math.max(4, top)}px`; + element.style.visibility = "visible"; +} + +/** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */ +function fill(target: HTMLElement, column: ProvenanceColumn, value: string, extra: string[]): void { + target.replaceChildren(); + const head = document.createElement("div"); + head.className = "ui-prov-card__head"; + const title = document.createElement("span"); + title.textContent = column.label; + const badge = document.createElement("span"); + badge.className = "ui-prov-card__badge"; + badge.dataset.provTier = column.tier; + badge.textContent = TIER_LABELS[column.tier] ?? column.tier; + head.append(title, badge); + target.append(head); + + line(target, "값", value); + line(target, "식", column.formula ?? ""); + line(target, "원천", column.source ?? ""); + line(target, "채택", column.rule ?? ""); + line(target, "자리", column.code ?? ""); + for (const note of extra) line(target, "줄 사유", note); +} + +/** + * 표에 호버를 붙인다. 칸에 심어 둔 `data-prov-col` 로 사전을 찾는다. + * + * `resolveExtra` — 줄마다 갈리는 사유(폴백 안분 등)를 얹고 싶을 때 부르는 쪽이 준다. + * 사전에 없는 열은 **아무 일도 안 한다** — 빈 카드를 띄우면 「설명이 있다」는 거짓이 남는다. + */ +export function attachProvenance( + root: HTMLElement, + sheet: ProvenanceSheet | undefined, + resolveExtra?: (cell: HTMLElement, columnKey: string) => string[], +): void { + if (!sheet) return; + injectProvenanceStyles(); + const hide = (): void => { + const element = document.getElementById(CARD_ID); + if (element) element.style.display = "none"; + }; + root.addEventListener("mouseover", (event) => { + const cell = (event.target as HTMLElement).closest("[data-prov-col]"); + if (!cell || !root.contains(cell)) return; + const column = sheet.columns[cell.dataset.provCol ?? ""]; + if (!column) return hide(); + const target = card(); + fill( + target, + column, + cell.textContent?.trim() ?? "", + resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [], + ); + place(target, (event as MouseEvent).clientX, (event as MouseEvent).clientY); + }); + root.addEventListener("mousemove", (event) => { + const element = document.getElementById(CARD_ID); + if (!element || element.style.display === "none") return; + place(element, (event as MouseEvent).clientX, (event as MouseEvent).clientY); + }); + root.addEventListener("mouseleave", hide); + root.addEventListener("mouseout", (event) => { + const next = (event as MouseEvent).relatedTarget as HTMLElement | null; + if (!next || !next.closest?.("[data-prov-col]")) hide(); + }); +} + +const CSS = ` +/* 등급색 — 평소엔 꺼져 있고 토글로만 켠다. 칸 왼쪽 얇은 띠 + 아주 옅은 배경이라 + 숫자 읽기를 방해하지 않는다. 색은 테마 변수를 섞어 어두운 테마에서도 맞는다. */ +.is-prov-tinted [data-prov-tier="input"] { box-shadow: inset 3px 0 0 var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); } +.is-prov-tinted [data-prov-tier="survey"] { box-shadow: inset 3px 0 0 var(--color-info, #3b82f6); background: color-mix(in srgb, var(--color-info, #3b82f6) 7%, transparent); } +.is-prov-tinted [data-prov-tier="standard"] { box-shadow: inset 3px 0 0 var(--color-text-secondary); background: color-mix(in srgb, var(--color-text-secondary) 7%, transparent); } +.is-prov-tinted [data-prov-tier="calc"] { box-shadow: inset 3px 0 0 var(--color-success, #16a34a); background: color-mix(in srgb, var(--color-success, #16a34a) 7%, transparent); } +.is-prov-tinted [data-prov-tier="final"] { box-shadow: inset 3px 0 0 var(--color-warning, #c08a3e); background: color-mix(in srgb, var(--color-warning, #c08a3e) 10%, transparent); } +.is-prov-tinted [data-prov-tier="blocked"] { box-shadow: inset 3px 0 0 var(--color-danger, #dc2626); background: color-mix(in srgb, var(--color-danger, #dc2626) 8%, transparent); } +.is-prov-tinted [data-prov-tier="excluded"] { box-shadow: inset 3px 0 0 var(--color-muted, #9ca3af); background: repeating-linear-gradient(135deg, transparent, transparent 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 10px); } +.is-prov-tinted [data-prov-tier="unclassified"] { box-shadow: inset 3px 0 0 var(--color-border); } + +.ui-prov-toggle { + font-size: 11px; + padding: 2px 8px; + color: var(--color-text-secondary); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills, 999px); + cursor: pointer; +} +.ui-prov-toggle.is-on { color: var(--color-accent); border-color: var(--color-accent); } + +.ui-prov-card { + display: none; + position: fixed; + z-index: 9999; + max-width: 26rem; + padding: 8px 10px; + font-size: 11.5px; + line-height: 1.5; + color: var(--color-text); + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards, 8px); + box-shadow: 0 6px 18px rgb(0 0 0 / 18%); + pointer-events: none; +} +.ui-prov-card__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-weight: 600; } +.ui-prov-card__badge { flex: 0 0 auto; padding: 0 6px; font-size: 10.5px; font-weight: 500; border-radius: var(--radius-pills, 999px); border: 1px solid currentColor; } +.ui-prov-card__badge[data-prov-tier="input"] { color: var(--color-accent); } +.ui-prov-card__badge[data-prov-tier="survey"] { color: var(--color-info, #3b82f6); } +.ui-prov-card__badge[data-prov-tier="standard"] { color: var(--color-text-secondary); } +.ui-prov-card__badge[data-prov-tier="calc"] { color: var(--color-success, #16a34a); } +.ui-prov-card__badge[data-prov-tier="final"] { color: var(--color-warning, #c08a3e); } +.ui-prov-card__badge[data-prov-tier="blocked"] { color: var(--color-danger, #dc2626); } +.ui-prov-card__badge[data-prov-tier="excluded"] { color: var(--color-muted, #9ca3af); } +.ui-prov-card__row { display: flex; gap: 8px; } +.ui-prov-card__key { flex: 0 0 2.4rem; color: var(--color-text-secondary); } +.ui-prov-card__value { min-width: 0; white-space: pre-wrap; } +`; + +export function injectProvenanceStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.append(style); +} From c308348b9afe07f880079df786424fa13675a51b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 15:23:51 +0900 Subject: [PATCH 2/2] auto: 2026-09-12 15:23 (EOMSANGDON-HOME) --- .../2026-09-12_B09_데이터_원천_조사.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md diff --git a/docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md b/docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md new file mode 100644 index 00000000..c532aac6 --- /dev/null +++ b/docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md @@ -0,0 +1,428 @@ +# B09 원가계산 화면 — 데이터 원천 조사표 + +**날짜** 2026-09-12 · **창** 데스크탑 보조(`sub_desktop_1`) · **근거** PLAN.md 8-36 ⑥ 앞작업 +(데스크탑 메인 창 요청, 병렬 조사) + +**무엇을 적었나** — B09 화면에 **실제로 뜨는 열마다** 넷을 적음. +① 열 이름(화면에 보이는 그대로) ② 원천(`파일:줄`) ③ 식(사람이 읽는 한 줄) ④ 등급 후보. + +**등급 여섯** — `입력`(사용자가 넣음) · `측량`(앞 단계가 낳음) · `기준`(법·품셈·단가판, 고정) · +`계산`(중간값) · `최종`(내역서·원가로 나가는 값) · `막힘`(근거가 없어 못 세움). + +⚠ **여섯에 안 맞는 열은 억지로 끼우지 않고 그대로 적었음.** 6장에 모아 둠 — 이 조사의 값어치는 +거기에 있음. + +--- + +## 0. 탭 구성 — 열 개 중 아홉이 살아 있음 + +`B09_Estimation_UI_Page.ts:610` `TAB_KEYS` · 이름은 `ui_template/ui_template_locale_b2.ts:776` + +| 탭 키 | 화면 이름 | 그리는 자리 | 살아 있나 | +| --- | --- | --- | --- | +| `cost_sheet` | 공사원가계산서 | `B09_Estimation_UI_Page.ts:250` | ○ | +| `boq` | 설계내역서 | `B09_Estimation_UI_Page.ts:911` | ○ | +| `unit_price` | 일위대가 | `B09_Estimation_UI_Page.ts:307`·`366` | ○ | +| `price_basis` | 단가산출근거 | `B09_Estimation_UI_Page.ts:1053` | ○ | +| `machine` | 중기 | `B09_Estimation_UI_BaseData.ts:156`·`292` | ○ | +| `duration` | 공사기간 | — | **✕ 막힘(버튼이 눌리지 않음)** | +| `supply` | 관급·사급 | `B09_Estimation_UI_Page.ts:1113` | ○ | +| `base_data` | 기초자료 | `B09_Estimation_UI_BaseData.ts:132`·`712`·`945` | ○ | +| `design_doc` | 설계서 구성 | `B09_Estimation_UI_BaseData.ts:222` | ○ | +| `basis_sheet` | 산출기초 | `B09_Estimation_UI_BaseData.ts:389` | ○ | + +--- + +## 1. 공사원가계산서 (`cost_sheet`) + +**표 뼈대** `B09_Estimation_UI_Page.ts:250` · 열 정의 `:257` · 줄을 낳는 곳 +`B09_Estimation_Engine_Cost.py:252 calculate_cost` + +### 1-1. 열 다섯 + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 비목 | `B09_Estimation_Engine_Cost.py:115 CostLine.name` · 법정경비 이름은 `B09_Estimation_Statutory.py:58 STATUTORY_ITEMS` | 비목 이름표(고정) | `기준` | +| 금액 | `B09_Estimation_Engine_Cost.py:124 CostLine.amount_krw` (`:175 _emitter` 가 채움) | `밑수 × 요율% + 정액` 을 원 단위 버림(`:44 floor_won`) | `계산` / 마지막 줄은 `최종` | +| 요율 | `B09_Estimation_Engine_Cost.py:122 rate_percent` ← `B09_Estimation_Rates.py:92 load_rate_dataset` 의 `rates_2026.json` | 금액·공사기간 구간으로 고름(`B09_Estimation_Rates.py:180 select_bracket`) | `기준` | +| 산출근거 | `B09_Estimation_Engine_Cost.py:128 formula_text` | `"{밑수:,} × {요율}%"` (+ 정액이 있으면 `+ 정액`) | `계산` — **이미 있는 것** | +| 비고 | `B09_Estimation_Engine_Cost.py:125 note` | 채택·미채택 표시 등 자유문 | 여섯 밖 → 6장 ㉮ | + +### 1-2. 줄(비목)마다의 밑수·등급 + +`emit(key=...)` 순서대로. 밑수 이름은 `B09_Estimation_Statutory.py:52 base_label` 이 들고 있음. + +| 화면 줄 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 재료비 | `Engine_Cost.py:314` | 직접재료비(사용자 입력) | `입력` | +| 간접노무비 | `Engine_Cost.py:332` | 직접노무비 × `rate_indirect`% | `계산` | +| 노무비 | `Engine_Cost.py:340` | 직접노무비 + 간접노무비 | `계산` | +| 산재보험료 | `Statutory.py:60` | 노무비(직접+간접) × `rate_sanjae`% | `계산` | +| 고용보험료 | `Statutory.py:63` | 노무비(직접+간접) × `rate_goyong`% | `계산` | +| 국민건강보험료 | `Statutory.py:64` | 직접노무비 × `rate_health`% | `계산` | +| 노인장기요양보험료 | `Statutory.py:65` | **국민건강보험료** × `rate_care`% (앞 줄이 밑수) | `계산` | +| 국민연금보험료 | `Statutory.py:66` | 직접노무비 × `rate_pension`% | `계산` | +| 산업안전보건관리비 | `Statutory.py:67` · 셈은 `Statutory.py:154 safety_management_cost` | **A·B 중 작은 값** (A=요율식, B=대상액×1.2, `Statutory.py:42 _SAFETY_B_MULTIPLIER`) | `계산` — **식이 「×%」가 아님, 6장 ㉯** | +| 기타경비 | `Statutory.py:71` | (재료비+노무비) × `rate_other_expense`% | `계산` | +| 환경보전비 | `Statutory.py:72` | 직접공사비 × `rate_environment`% | `계산` | +| 퇴직공제부금비 | `Statutory.py:73` | 직접노무비 × `rate_retirement_mutual_aid`% | `계산` | +| 임금채권보장기금 부담금 | `Statutory.py:76` | 노무비(직접+간접) × 율 | `계산` | +| 석면피해구제 분담금 | `Statutory.py:82` | 노무비(직접+간접) × 율 | `계산` | +| 건설기계대여대금 지급보증수수료 | `Statutory.py:88` | 직접공사비 × 율 | `계산` | +| 하도급대금 지급보증수수료 | `Statutory.py:94` | 직접공사비 × 율 | `계산` | +| 공사이행보증수수료 | `Statutory.py:100` | **직접공사비 × 공사기간(년)** × 율 — 기간이 식에 듦 | `계산` (기간은 `입력`) | +| 경비 | `Engine_Cost.py:358` | 직접경비 + 법정경비 합 | `계산` | +| 순공사원가 | `Engine_Cost.py:367` | 재료비+노무비+경비 | `계산` | +| 일반관리비 | `Engine_Cost.py:381` | 순공사원가 × `rate_overhead`% | `계산` | +| 이윤(조정 전) | `Engine_Cost.py:462` | (노무비+경비+일반관리비) × `rate_profit`% | `계산` | +| 이윤 조정 | `Engine_Cost.py:470` | 사용자가 넣은 조정액 | `입력` | +| 이윤 | `Engine_Cost.py:479` | 조정 전 + 조정액 | `계산` | +| 총원가 | `Engine_Cost.py:392` | 순공사원가 + 일반관리비 + 이윤 | `최종` | +| 부가가치세 | `Engine_Cost.py:399` | 총원가 × 10% (`Statutory.py:41 _VAT_DIVISOR` 와 짝) | `계산` | +| 도급금액 | `Engine_Cost.py:407` | 총원가 + 부가세, **천원 단위 올림**(`Engine_Cost.py:49 ceil_thousand`) | `최종` | +| 관급자재 대금 | `Engine_Cost.py:496 _owner_supplied_line` | 사용자 입력(총원가 **밖**) | `입력` | +| 폐기물 처리비 | `Engine_Cost.py:514 _waste_line` | 사용자 입력 | `입력` | +| 총계 | `Engine_Cost.py:419` | 도급금액 + 관급 + 폐기물 | `최종` | + +### 1-3. 요율 판 (좌측 패널 「요율 판」 상자) + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 적용일 | `B09_Estimation_UI_Page.ts:592` ← `rate_version.effective_date` | `rates_2026.json` 의 기준일 | `기준` | +| 지문 | 같은 자리 `rate_version.sha256` 앞 8자 | 파일 해시 | 여섯 밖 → 6장 ㉰ | + +--- + +## 2. 설계내역서 (`boq`) + +**표 뼈대** `B09_Estimation_UI_Page.ts:944` (머리글이 `innerHTML` 문자열로 박혀 있음) · +줄을 낳는 곳 `B09_Estimation_BillOfQuantities_Rows.py` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| No. | `BillOfQuantities_Rows.py:139 item_no` | 마스터 목차 번호 | `기준` | +| 공종 | `:148 name` ← B08 `HandoffWorkItem.name` (없으면 마스터 이름) | 그대로 | `기준` | +| 규격 | `:149 spec` (갈래가 붙으면 `:211` 에서 `spec + variant_value`) | 그대로 / 갈래 이어붙임 | `기준` | +| 단위 | `:150 unit` | 그대로 | `기준` | +| 수량 | `:151 quantity` ← **B08 이 넘긴 값** · 표시는 `UI_Page.ts:793 formatQuantity` | 그대로(반영률은 **B08 이 이미 곱함**, `:157` 주석) | `측량` | +| 단가 | `:290~ unit_price_krw` ← `unit_prices.book` 의 일위대가 합계 | 일위대가 본표 합계 | `계산` | +| 금액 | 같은 곳 `amount_krw` | 수량 × 단가 | `최종` | +| 비고 | `:155`·`:172`·`:180`·`:236`·`:245`·`:267` 등 여러 자리에서 이어 붙임 | 반영률·갈래 근거·막힘 사유를 `/` 로 이음 | 여섯 밖 → 6장 ㉮ | + +**표 밖에 붙는 줄들** (모두 `UI_Page.ts:974~1050`) + +| 화면에 뜨는 것 | 원천 | 등급 후보 | +| --- | --- | --- | +| 내역서 합계 | `bill.summary.body_total_krw` | `최종` | +| 「금액을 못 세운 줄」 세 갈래 | `BillOfQuantities_Rows.py:190`·`:241`·`:259`·`:280` 의 `result.missing` | `막힘` | +| ├ 사용자가 넣으면 풀림 | `blocked_kind == "input_missing"` | `막힘`(원인 `입력`) | +| ├ 우리가 만들어야 함 | `blocked_kind` 그 밖 | `막힘` | +| └ 여기서 안 세는 줄 | `blocked_kind == "not_our_row"` | 여섯 밖 → 6장 ㉱ | +| 검산용 줄(제외) | `:110 _excluded_row` — 수량만 보이고 **단가를 안 붙임** | 여섯 밖 → 6장 ㉱ | +| 자재 줄 | `:385 _material_row` | `측량` | + +--- + +## 3. 일위대가 (`unit_price`) + +### 3-1. 목록표 — `UI_Page.ts:307` · 열 정의 `:321` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 명칭 | `B09_Estimation_UnitPrice_View.py:149 list_unit_prices` | 단가판 제목 | `기준` | +| 단위 | 같은 곳 | 단가판 단위 | `기준` | +| 재료비·노무비·경비 | 같은 곳 ← `PriceBook.resolve` | 성분별 합 | `계산` | +| 합계 | 같은 곳 | 재료비+노무비+경비 | `계산` | + +### 3-2. 본표 — `UI_Page.ts:366` · 열 정의 `:412` · 서버 `UnitPrice_View.py:171 detail_of` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 명칭 | `UnitPrice_View.py:171~` (제잡비는 `:196`, 공구손료는 `:236`) | 성분 이름 | `기준` | +| 규격 | 같은 곳 | 성분 규격 / `"노무비의 N%"` 꼴 | `기준` | +| 원천 | `UnitPrice_View.py:257 source_label` ← `B09_Estimation_UnitPrice.py:1026 SOURCE_LABEL` | 자재·노임·기계경비·일위대가·단가산출·일식견적 중 하나 + 순번 | `기준` — **이미 있는 것** | +| 단위 | `UnitPrice_View.py:259 부근` | 그대로 | `기준` | +| 수량 | `:259 quantity` | 품셈 소요량 | `기준` | +| 재료비·노무비·경비 | `:264~266` | 단위값 × 수량, 성분별로 자름 | `계산` | +| 합계 | `:267` 주석 | **자른 성분 셋의 합** (전정밀 합과 끝자리가 다름 — 정상) | `계산` | + +**표 밖에 붙는 줄** — 「일부만 선 단가」 알림(`UI_Page.ts:387 unattached_note`)은 `막힘`, +「반올림 차」(`UI_Page.ts:398 precise_total`)는 여섯 밖 → 6장 ㉲. + +--- + +## 4. 단가산출근거 (`price_basis`) + +**목록** `UI_Page.ts:1069` · **본문** `UI_Page.ts:1097` · 서버 `B09_Estimation_PriceBasis.py:74 build_price_basis` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 번호 | `PriceBasis.py:31 PriceBasisEntry.number` | 차례 매김 | `기준` | +| 공종 | 같은 곳 `name`+`spec` | 이어 붙임 | `기준` | +| 단위 | 같은 곳 `unit` | 그대로 | `기준` | +| 단가 | 같은 곳 `unit_price_krw` | 한 층 아래 일위대가의 합계 | `계산` | +| (본문) 참조 | `UI_Page.ts:1105 ref_code` | 한 층 아래 코드를 가리킴 | 여섯 밖 → 6장 ㉳ | + +⚠ **이 탭은 내역서(`bill`)를 먼저 불러야 뜸** (`UI_Page.ts:1054`). 안 불러오면 안내문만 뜸. + +--- + +## 5. 나머지 여섯 탭 + +### 5-1. 중기 (`machine`) + +**중기목록표** `B09_Estimation_UI_BaseData.ts:164` · 서버 `B09_Estimation_Lists.py:105 machine_list` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 코드번호·명칭·규격·단위 | `Lists.py:105` ← 기계 카탈로그 | 그대로 | `기준` | +| 합계 | `Lists.py:105 total_krw` | 노무비+재료비+경비 | `계산` | +| 노무비 | 같은 곳 | 조종원 노임 ÷ 8 × 16/12 × 25/20 (`B09_Estimation_MachineCost.py:74 OPERATOR_ALLOWANCE_FACTOR`) | `계산` | +| 재료비 | 같은 곳 | 주연료 + 잡재료(주연료의 %) | `계산` | +| 경비 | 같은 곳 | 시간당 손료 | `계산` | +| 비고 | 같은 곳 | 자유문 | 여섯 밖 → 6장 ㉮ | + +**각종 중기경비계산서** `UI_BaseData.ts:292 machineExpenseSheet` · 서버 +`B09_Estimation_MachineExpenseSheet.py:81 machine_expense_sheets` + +| 줄 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| ① 취득가격(천원) | `MachineExpenseSheet.py:81~` | 카탈로그 값 | `기준` | +| ① 내용시간 / 연간표준가동시간 | 같은 곳 | 품셈 값 | `기준` | +| ① 상각비·정비비·관리비 계수(10⁻⁷) | 같은 곳 | 셋을 더해 손료계수 | `기준` | +| ① 시간당 손료 | 같은 곳 | 취득가격 × 손료계수 | `계산` | +| ② 주연료 × 유가 | 같은 곳 · 유가는 `B09_Estimation_Lists_Sources.py:137 base_reference_data` | 주연료(L/hr) × 경유 단가 | `계산`(유가는 `기준`, 지역 선택은 `입력`) | +| ② 잡재료(주연료의 %) | 같은 곳 | 주연료비 × % | `계산` | +| ② 조종원 일당 → 시간당 | `MachineCost.py:49`·`:74` | 일당 ÷ 8 × 1.667 | `계산` | +| ③ 재료비/노무비/경비·합계 | 같은 곳 | ①+② 를 성분으로 가름 | `계산` | +| ⚠ 못 채운 자리(`gaps`) | `MachineExpenseSheet.py:81~` | — | `막힘` | + +### 5-2. 관급·사급 (`supply`) + +`UI_Page.ts:1146` · 서버 `B09_Estimation_MaterialSheet.py:117 build_material_sheet` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 자재·규격·단위 | `MaterialSheet.py:47 MaterialSheetRow` | 그대로 | `기준` | +| 수량 | 같은 곳 `total_amount` | B08 수량 × 할증 | `측량` | +| 단가 | 같은 곳 `unit_price_krw` | 단가판 값 | `기준` | +| 금액 | 같은 곳 `amount_krw` | 수량 × 단가 | `최종` | +| 비고 | 같은 곳 `note` | 자유문 | 여섯 밖 → 6장 ㉮ | + +세 무리(사급 / 관급 / 안 갈린 것)는 `UI_Page.ts:1136` 에서 가름. **관급은 총원가 밖**, +**안 갈린 것은 어느 합계에도 안 듦** → 여섯 밖 → 6장 ㉱. + +### 5-3. 기초자료 (`base_data`) + +**목록표 셋** `UI_BaseData.ts:132` · 열 `:111 catalogTable` · 서버 `B09_Estimation_Lists.py:50 catalog_list` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 코드번호·명칭·규격·단위 | `Lists.py:50` | 단가판 그대로 | `기준` | +| 단가 | 같은 곳 | 단가판 그대로 | `기준` | +| 비고 | 같은 곳 | 자유문 | 여섯 밖 → 6장 ㉮ | + +⚠ **재료비목록표가 거의 비어 있음** (`UI_BaseData.ts:143` 경고문) — 사급 자재 카탈로그가 안 섬 → `막힘`. + +**자재단가대비표** `UI_BaseData.ts:533 comparisonTable` · 서버 +`B09_Estimation_Lists_Sources.py:79 material_price_comparison` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 코드번호·명칭·규격·단위 | `Lists_Sources.py:79` | 그대로 | `기준` | +| (원천마다) 단가 | 같은 곳 `slots[].price_krw` | 물가지·견적 등 원천별 값 | `기준` | +| (원천마다) 페이지 | 같은 곳 `slots[].source_note` | 쪽수·출처 문구 | `기준` — **이미 있는 것** | +| 적용 | 같은 곳 `adopted_price_krw`·`adopted_slot` | 다섯 중 채택한 하나 | `계산` | +| 비고 | 같은 곳 `note` | 자유문 | 여섯 밖 → 6장 ㉮ | + +**환율및기초자료** `UI_BaseData.ts:607 baseReferenceSections` · 서버 `Lists_Sources.py:137` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| ① 환율 | `Lists_Sources.py:137` | 안내문뿐(값 없음) | `막힘` | +| ② 코드번호·직종 | 같은 곳 | 노임판 그대로 | `기준` | +| ② 일당 | 같은 곳 `day_wage_krw` | 공표 노임 | `기준` | +| ② 시간당 | 같은 곳 `hourly_krw` | 일당 ÷ 8 (**자르지 않고 소수 그대로**, `UI_BaseData.ts:634` 주석) | `계산` | +| ② 산식 | 같은 곳 `formula` | 사람이 읽는 한 줄 | `계산` — **이미 있는 것** | +| ③ 경유 단가·적용 범위·기준일·자료 | 같은 곳 `fuel` | 공시가(전국 또는 시도) | `기준`(범위 선택은 `입력`) | + +**산출 조건** `UI_BaseData.ts:945 drawFactorChoices` · 서버 `B09_Estimation_FactorChoices.py:111 scan_range_factors` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 계수 이름·품셈 범위 | `FactorChoices.py:62 RangeFactor` | 품셈이 준 범위 | `기준` | +| 고른 값(상·중·하) | `FactorChoices.py:57 CHOICE_KEYS`·`:58 DEFAULT_CHOICE` | 사용자 선택(기본 `mid`) | `입력` | +| 근거 문구 | `FactorChoices.py:162 BASIS_NOTES` | 고정 문구 | `기준` | + +### 5-4. 설계서 구성 (`design_doc`) + +`UI_BaseData.ts:222` · 서버 `B09_Estimation_DesignDocIndex.py:164 design_doc_index` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 차례·이름 | `DesignDocIndex.py:38 DESIGN_DOC_ITEMS` | 법이 정한 목차 | `기준` | +| 상태 | `DesignDocIndex.py:25~27` (있음/반쪽/없음) | 우리가 내는 것과 맞대 봄 | 여섯 밖 → 6장 ㉴ | +| 누가 만드나 | `DesignDocIndex.py:29~30` | 프로그램 / 설계자·발주청 | 여섯 밖 → 6장 ㉴ | +| 어디서 나오나 | 같은 곳 `where` | 화면·파일 이름 | 여섯 밖 → 6장 ㉴ | +| 비고 | 같은 곳 `note` | 자유문 | 여섯 밖 → 6장 ㉮ | + +⚠ **이 표는 프로젝트 값이 아님** — 「우리가 무엇을 내는가」의 표(`UI_Page.ts:1219` 주석). + +### 5-5. 산출기초 (`basis_sheet`) + +`UI_BaseData.ts:389` · 서버 `B09_Estimation_BasisSheet.py:148 basis_sheet` + +| 절 | 열 이름 | 원천 | 등급 후보 | +| --- | --- | --- | --- | +| ① 어느 판으로 계산했나 | 자료·파일·기준일·지문 | `BasisSheet.py:44 dataset_versions` | `기준` (지문은 6장 ㉰) | +| ② 무엇을 골랐나 | 항목·고른 값 | `BasisSheet.py:77 chosen_conditions` | `입력` | +| ③ 공종마다 무엇을 근거로 | 코드·공종·단위·근거 | `BasisSheet.py:106 work_item_basis` | `기준` — **이미 있는 것** | +| ④ 못 채운 자리 | 갈래·코드·사유 | `BasisSheet.py:134 open_gaps` | `막힘` | + +⚠ **여기서 값을 다시 계산하지 않음 — 모으기만 함** (`UI_BaseData.ts:359` 주석). 즉 **③ 이 이미 +사전의 절반**임. + +### 5-6. 공사기간 (`duration`) — 통째로 막힘 + +`TAB_KEYS` 에서 `enabled=false` (`UI_Page.ts:616`). 버튼이 눌리지 않고 「준비 중」만 뜸. +그런데 **공사이행보증수수료 식에 공사기간(년)이 이미 들어감**(`Statutory.py:100`) — +값은 좌측 패널 「공사 조건 · 공사기간」 입력칸에서 옴. → `막힘` 이되 원인은 `입력`. + +--- + +## 6. 여섯에 안 맞는 것 — **이 조사의 알맹이** + +억지로 안 끼우고 그대로 적음. ㉮~㉴ 일곱 갈래. + +### ㉮ 「비고」 — 등급을 못 붙이는 열 (거의 모든 표에 있음) + +내역서·중기·자재대·기초자료·설계서 구성이 모두 「비고」를 가짐. 안에 들어 있는 것이 섞여 있음 — +**갈래 판정 근거**(`BillOfQuantities_Rows.py:155`), **반영률 안내**(`:157`·`:165`), +**주의 문구**(`:180` `ⓘ`), **막힘 사유**(`:186`). 같은 칸에 **원천이 다른 글**이 들어감. + +→ **제안** — 비고는 등급을 붙일 열이 아니라 **다른 열의 근거를 담는 그릇**임. 등급 여섯에 넣지 말고 +호버 카드의 「곁말」 자리로 빼는 것이 맞아 보임. 지금처럼 `/` 로 이어 붙이면 **호버에서 갈라 보일 +수 없음** — 서버가 조각 배열로 보내야 함. + +### ㉯ 「A·B 중 작은 값」 — `계산` 이 못 담는 식 + +산업안전보건관리비(`Statutory.py:154`)는 두 길로 셈해 **작은 쪽**을 씀. `formula_text` 도 이 줄만 +`× %` 꼴을 안 씀(`Engine_Cost.py:133` 이 이 키를 따로 뺌). + +→ **제안** — `계산` 안에 「**고름**」 성격이 숨어 있음. 이런 줄은 호버에 **두 후보값과 왜 그것을 +골랐나**를 함께 보여야 함. 등급을 늘릴 것인지, `계산` 의 하위 성질로 둘 것인지 결정 필요. +같은 성격이 하나 더 있음 — 자재단가대비표의 「적용」(원천 다섯 중 하나 채택, `Lists_Sources.py:79`). + +### ㉰ 「지문(sha256)」 — 값이 아니라 **재현성 표식** + +요율 판 지문(`UI_Page.ts:594`)과 산출기초 ①(`BasisSheet.py:44`)에 나옴. 사용자가 넣은 것도, +계산한 것도, 법이 정한 것도 아님. + +→ **제안** — 등급을 붙이지 말고 **모든 칸의 호버 카드에 공통으로 따라붙는 꼬리표**로 두는 편이 +나아 보임(「이 값은 어느 판·어느 지문으로 섰나」). + +### ㉱ 「여기서 안 세는 줄」 — `막힘` 과 뜻이 정반대 + +셋이 같은 성격임 — 내역서의 `not_our_row`(`UI_Page.ts:1015`), 검산용 제외 줄 +(`BillOfQuantities_Rows.py:110`), 자재대의 「안 갈린 것」(`UI_Page.ts:1139`). +**못 세운 것이 아니라 세면 안 되는 것**임. `막힘` 에 넣으면 사용자가 채우려 들고 **그것이 곧 +이중계상**임(화면 주석이 이미 그렇게 경고함, `UI_Page.ts:1010`). + +→ **제안** — 일곱째 등급 「**제외**」가 필요해 보임. 아니면 `막힘` 을 「못 세움 / 안 셈」 둘로 가름. +⚠ **이것이 이번 조사에서 가장 확실한 어긋남**임 — B08 토적표에도 같은 성격의 줄이 있을 것임. + +### ㉲ 「반올림 차」 — 값이 아니라 **표시의 성질** + +일위대가 본표의 `precise_total ≠ total`(`UI_Page.ts:398`), 내역서의 자릿수 안내 +(`UI_Page.ts:981`), 노임 시간당을 안 자르는 규칙(`UI_BaseData.ts:634`). + +→ **제안** — 등급이 아니라 **칸마다의 「자른 자리」 표기**로 다루는 것이 맞아 보임 +(호버에 「표시 N자리 · 계산 전정밀」). + +### ㉳ 「참조(ref_code)」 — 값이 아니라 **한 층 아래로 가는 길** + +단가산출근거의 참조(`UI_Page.ts:1105`), 일위대가 본표의 파고들기(`UnitPrice.py:1037 DRILLABLE_KINDS`). + +→ **제안** — 이것이 PLAN 8-36 ⑤ 의 「원천은 글로만 적음」과 같은 자리임. **B09 안에서는 이미 +눌러서 내려갈 수 있음** — 화면 밖(B05·B06)으로 나갈 때만 글로만 적으면 됨. + +### ㉴ 설계서 구성표 — **프로젝트 값이 아닌 표** + +「상태 / 누가 만드나 / 어디서 나오나」 세 열은 프로젝트 숫자가 아니라 **우리 개발 상태**임. + +→ **제안** — 이 탭은 사전·호버 대상에서 **아예 빼는 것**이 맞아 보임. 등급을 억지로 붙이면 +사전이 「프로그램 진척표」까지 떠안게 됨. + +--- + +## 7. 있는 것 / 없는 것 + +**이미 있어 그대로 쓸 것 (넷)** + +| 무엇 | 어디 | 덮는 범위 | +| --- | --- | --- | +| `formula_text` | `Engine_Cost.py:128` | 원가계산서 줄 전부 — **식** | +| `source_label`·`source_index` | `UnitPrice_View.py:256` ← `UnitPrice.py:1026` | 일위대가 본표 줄 — **원천** | +| `base_label` | `Statutory.py:52` | 법정경비 14 줄 — **밑수 이름** | +| 산출기초 ③ `work_item_basis` | `BasisSheet.py:106` | 공종마다의 근거 문구 | +| (덤) 자재단가대비표 `source_note` | `Lists_Sources.py:79` | 자재 단가의 쪽수·출처 | + +**없어서 새로 만들어야 할 것** + +| 무엇 | 어느 표가 비었나 | +| --- | --- | +| **식** | 내역서(수량·단가·금액) · 중기목록표 · 자재대 · 기초자료 목록표 셋 | +| **원천(`파일:줄`)** | 전부 없음 — 지금은 어느 표도 「어느 코드가 이 값을 냈나」를 안 들고 있음 | +| **등급** | 전부 없음 | +| **밑수 이름** | 법정경비 밖 — 일반관리비·이윤·부가세는 `formula_text` 안에 숫자로만 들어감 | +| **칸 단위 곁말** | 비고를 `/` 로 이어 붙이는 자리 전부(㉮) — 조각 배열로 바꿔야 함 | + +--- + +## 8. 다음에 할 것 + +1. **㉱(제외 등급)를 먼저 결정** — 등급 여섯의 밑동이 바뀌는 자리라 나머지보다 앞섬. + B08 토적표 쪽에도 같은 성격 줄이 있는지 맞대 볼 것. +2. ㉮(비고 그릇)는 **서버가 조각 배열로 보내도록** 바꾸는 일이 딸림 — 사전 뼈대와 같이 설계할 것. +3. ㉴(설계서 구성표)는 사전 대상에서 빼는 것으로 정리하면 표 하나가 통째로 줄어듦. +4. 사전 파일은 `B09_*` 700줄 넘은 파일들에 넣지 말고 **새 파일**로 뺄 것 + (PLAN.md 8-36 끝 ⚠ 그대로). + +--- + +## 9. 줄 사유 → 닿는 열 지도 (㉮ 배선용, 2026-09-12 추가) + +데스크탑 메인 창 당부 ② — **줄 사유는 그 사유가 닿는 열에만 붙일 것**(B08 에서 「절토 보정량」 +카드에 「측구 가름값이…」 가 떠서 읽는 사람을 속인 사고). `resolveExtra` 에서 열 키로 거르려면 +**어느 조각이 어느 열 것인지**를 먼저 못 박아야 함. 내역서 `note` 가 지금 `/` 로 이어 붙이는 +조각 전부를 그 임자 열에 갈라 놓음. + +| 조각 | 만드는 자리 | 닿는 열 | 지금 꼴 | +| --- | --- | --- | --- | +| 갈래 판정 근거(`spec_class_basis`) | `BillOfQuantities_Rows.py:154` | **규격** | `/` 로 이음 | +| 주의 문구 `ⓘ`(막힘 아님 — 기본값으로 섰다는 알림) | `:180` | **규격** | `/` 로 이음 | +| 관경이 표 밖(`pipe_diameter_note`) | `:228` | **규격** | `/` 로 이음 | +| 반영률 적용 후 수량(단일 율) | `:157` | **수량** | 덮어씀 | +| 반영률이 갈래마다 다름 | `:165` | **수량** | 덮어씀 | +| 묶음 조각이 덜 참 · 묶음 N조각 합계 | `:86`·`:106` | **수량** | 덮어씀 | +| 수량이 미확정 산식 위에 섬(`pending_formula_note`) | `:335` | **수량** | `/` 로 이음 | +| 막힘 사유(`blocked_kind` 있음) | `:186` | **단가** | 덮어씀 | +| 일위대가가 한 층 아래에 있음(후보 N건) | `:224` | **단가** | 덮어씀 | +| 성분이 빠져 못 세움 | `:236` | **단가** | 덮어씀 | +| 일위대가가 아직 없음 | `:239` | **단가** | 덮어씀 | +| 밑수(기준 수량) 못 찾음 — 곱하지 않음 | `:257` | **단가** | 덮어씀 | +| 단가가 일부만 섬(붙은 몫 N%) | `:278` | **단가** | 덮어씀 | +| 기준 단위가 표에 없음 — 같다고 보고 곱함 | `:297` | **단가** | `/` 로 이음 | +| 원문엔 있는데 단가에 못 실린 몫(`known_gap_note`) | `:341` | **단가** | `/` 로 이음 | +| 단산 참조번호(`entry.label`, 「단산 46」) | `BillOfQuantities.py:517` | **단가** | `/` 로 **앞에** 붙임 | +| 단위 불일치 — 곱하면 틀리므로 비워 둠 | `:314` | **금액** | 덮어씀 | +| 검산용 줄 — 금액을 안 매김 | `:170` | **줄 전체**(`excluded`) | 덮어씀 | +| 관급·사급이 안 갈림 | `:398` | **줄 전체**(`excluded`) | 덮어씀 | +| 관급/사급 자재 단가 미확보 | `:411`·`:418` | **단가** | 덮어씀 | + +⚠ **덮어쓰는 자리가 더 많음** — 지금은 앞 조각을 지우고 새로 적는 곳이 대부분이라, 한 줄에 +사유가 둘이면 **하나가 조용히 사라짐**. 조각 배열로 바꿀 때 이 자리들도 함께 `append` 로 +고쳐야 함(단순히 `/` 를 배열로 바꾸는 것만으로는 안 됨). + +⚠ **화면 `비고` 칸은 그대로 둘 것** — 조각 배열은 호버 카드용으로 따로 실음. 지금 비고를 +없애면 토글을 끈 사용자가 사유를 못 봄.