Files
Aislo/common_util/common_util_provenance.py
T
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:18:57 +09:00

121 lines
6.1 KiB
Python

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