"""화면에 뜬 숫자가 **어디서 와서 어떻게 계산됐는지**를 적어 두는 한 벌. 왜 서버가 드나 (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)}