화면에 뜬 숫자가 어디서 와서 어떻게 나왔는지 가릴 길이 없어 개발·검산이 막히던 것을 엶(PLAN 8-36 ①②③④). 칸에 마우스를 올리면 등급·값·식·원천·자리가 카드로 뜨고, 토글을 켜면 등급색이 칸 왼쪽 띠로 붙음. - `common_util/common_util_provenance.py` — 등급 상수 여덟과 `ColumnProvenance`. 사람이 고르는 여섯(입력·측량·기준·계산·최종·막힘)에 `excluded`(일부러 안 셈, 채우면 이중계상)와 `unclassified`(판단 미룸)를 더함. `excluded` 는 데스크탑 보조의 B09 조사에서 나온 것으로 `blocked` 와 뜻이 정반대라 갈라 둠. 값 안에 선택이 숨은 열을 위해 `rule`(채택 규칙) 칸도 둠. - ⚠ 로직 보안 — `provenance_payload()` 가 개발환경이 아니면 `None` 을 돌려주고 라우터가 응답에서 칸을 통째로 뺌. 화면에서 숨기는 것이 아니라 안 보내는 것임. 문의 정본은 `is_dev_environment()` 하나로 통일. - `ui_template/ui_template_provenance.ts` — 호버 카드·등급색·토글 한 벌. B09 도 같이 씀. - `B08_Quantity/B08_Quantity_Provenance.py` — 토적표 20열 사전. 사전은 **열 단위**이고 줄마다 갈리는 사유만 줄 쪽에서 얹음(칸마다 지으면 한 장이 6천 칸이라 응답이 붐). - 토적표에는 `final` 열이 하나도 없어 억지로 붙이지 않음 — 중간 장부이고 내역서로 나가는 값은 토공집계표에서 섬. 자체검증 — 시험 8개 추가(`test_b08_provenance.py`). 값어치는 첫 번째에 있음: 사전 열 이름이 전부 실제 `EarthworkRow` 에 있는가(엔진이 이름을 갈면 사전만 옛것으로 남는데 화면에서는 카드가 그냥 안 떠 눈에 안 띔). 배포환경 `None` 도 시험으로 박음. 브라우저(ORCA 5173, 실제 사전을 물려 토적표를 그림) — 표시된 칸 40개(2줄×20열) · 등급 갈래 survey 12 / calc 28 · 카드에 「절토 토사 보정량 · 계산 · 값 45.00 · 식 절토 토사 입적 × 토량환산계수(다짐) · 자리 EarthworkTable.py:210」 · 토글에 배경이 투명 → 초록 7 % 로 바뀌고 왼쪽 띠 inset 3px. 줄 사유가 그 줄 모든 칸에 뜨던 것을 사유가 닿는 열(`ditch_*`)에만 뜨게 고침 — 「절토 보정량」 카드에 「측구 가름값이…」 가 떠서 읽는 사람을 속이던 자리임. 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)}
|