무슨 일이 있었나 랩탑 줄의 병합 `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
121 lines
6.1 KiB
Python
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)}
|