Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-12 15:24:45 +09:00
7 changed files with 669 additions and 2 deletions
+163
View File
@@ -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()})
@@ -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_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_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes 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 ( from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS, CONCRETE_PLACING_METHODS,
ROCK_METHODS, ROCK_METHODS,
@@ -176,6 +177,11 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table["settings"] = settings table["settings"] = settings
table["project_root_known"] = project_root is not None table["project_root_known"] = project_root is not None
table["route_id"] = route_id table["route_id"] = route_id
# 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라
# 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다.
provenance = quantity_provenance()
if provenance is not None:
table["provenance"] = provenance
return JSONResponse(content=table) return JSONResponse(content=table)
+31 -2
View File
@@ -14,6 +14,13 @@
* 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것. * 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것.
* ========================================================================== */ * ========================================================================== */
import {
attachProvenance,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
/** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */ /** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */
export interface EarthworkRow { export interface EarthworkRow {
chainage_m: number; chainage_m: number;
@@ -134,6 +141,8 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>; conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */ /** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[]; conversion_factor_pumsem_ranges?: PumsemRange[];
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
provenance?: ProvenancePayload;
} }
/** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */ /** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */
@@ -334,7 +343,11 @@ function buildHead(): HTMLTableSectionElement {
return head; return head;
} }
function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement { function buildBody(
rows: EarthworkRow[],
slope?: SlopeTable,
sheet?: ProvenanceSheet,
): HTMLTableSectionElement {
const body = document.createElement("tbody"); const body = document.createElement("tbody");
const columns = flatColumns(); const columns = flatColumns();
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row])); 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 = td.textContent =
index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits); index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits);
if (index === 0) td.className = "b08-grid__station"; 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); tr.append(td);
}); });
// 줄마다 갈리는 사유(측구 안분 폴백 등)는 열 사전이 못 든다 — 줄에 실어 카드가 덧붙게 한다.
if (row.notes?.length) tr.dataset.provNotes = row.notes.join("\n");
const slopeRow = slopeByChainage.get(row.chainage_m); const slopeRow = slopeByChainage.get(row.chainage_m);
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b). // 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
if (slopeRow?.unclosed) tr.classList.add("is-unclosed"); if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
@@ -458,11 +477,21 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
scroller.className = "b08-grid__scroll"; scroller.className = "b08-grid__scroll";
const element = document.createElement("table"); const element = document.createElement("table");
element.className = "b08-grid__table"; element.className = "b08-grid__table";
const sheet = table.provenance?.sheets?.earthwork;
element.append( element.append(
buildHead(), buildHead(),
buildBody(table.rows, table.slope), buildBody(table.rows, table.slope, sheet),
buildFoot(table.totals, table.slope), 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) { if (table.slope) {
const notice = buildUnclosedNotice(table.slope, element); const notice = buildUnclosedNotice(table.slope, element);
+6
View File
@@ -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 { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible"; import { attachCollapsible } from "@ui/ui_template_collapsible";
import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections"; import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections";
import { createProvenanceToggle } from "@ui/ui_template_provenance";
import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { import {
fetchWorkflowState, fetchWorkflowState,
@@ -779,6 +780,11 @@ function buildQuantityBody(
return element; return element;
}; };
// 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다.
if ((table as unknown as { provenance?: unknown } | null)?.provenance) {
tabs.append(createProvenanceToggle(body));
}
if (failed) { if (failed) {
body.append(tabs, message(L("B08_Quantity_Grid_Failed"))); body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
return body; return body;
+120
View File
@@ -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)}
+81
View File
@@ -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 중 작은 쪽"
+262
View File
@@ -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<string, ProvenanceColumn>;
}
/** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */
export interface ProvenancePayload {
sheets: Record<string, ProvenanceSheet>;
}
/** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */
const TIER_LABELS: Record<string, string> = {
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<HTMLElement>("[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);
}