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); +}