feat(B09): 칸 단위 근거 호버·등급색 배선 + 줄 사유 덮어쓰기 버그 수정

① 버그 — BillRow.note 한 칸에 덮어쓰던 13곳·이어 붙이던 7곳을 조각 배열 notes 로
   옮김. 한 줄에 사유가 둘이면 하나가 조용히 사라지던 것이 멈춤. 조각마다 닿는 열
   키를 함께 실어 호버가 그 칸에만 띄우게 함. 화면 비고 칸은 종전과 같은 꼴 유지.
② 사전 — B09_Estimation_Provenance.py 신설(기존 B09 파일이 700줄 초과). 8장 57열.
   이미 있던 formula_text·source_label·base_label 은 그대로 쓰고 없는 것만 새로 적음.
③ 배선 — 라우터 5개 응답에 _with_provenance(), 표 여섯에 markProvenanceCell +
   attachProvenance. 개발환경이 아니면 응답에 칸 자체가 안 생김(로직 보안).
④ 등급색 토글을 탭 줄 끝에. 사전이 안 오면 단추도 안 세움.

⚠ 원가계산서 금액은 같은 열에서 중간줄과 마지막줄의 성격이 갈리는데 등급이 열에
   하나뿐이라 calc 로 두고 rule 에 어느 줄이 final 인지 적음(PLAN 8-36 ① 에 남김).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
This commit is contained in:
2026-09-12 15:50:55 +09:00
co-authored by Claude Opus 5
parent 3e93360285
commit d1a2508053
6 changed files with 766 additions and 73 deletions
+39 -16
View File
@@ -28,6 +28,7 @@ from B09_Estimation.B09_Estimation_Engine_Cost import (
proposed_profit_adjustment,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
from B09_Estimation.B09_Estimation_Provenance import estimation_provenance
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
from B09_Estimation.B09_Estimation_Guards import DoubleCountError
from B09_Estimation.B09_Estimation_Rates import RateLookupError
@@ -47,6 +48,18 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
def _with_provenance(body: dict[str, Any]) -> dict[str, Any]:
"""근거 사전을 응답에 얹는다 — **개발환경이 아니면 칸 자체를 안 만든다.**
⚠ 빈 dict 를 실으면 화면이 「사전이 있는데 비었다」로 읽어 빈 카드를 띄운다.
그래서 `None` 이면 **키를 넣지 않는다**(로직 보안의 문은 서버 쪽 하나뿐이다).
"""
provenance = estimation_provenance()
if provenance is not None:
body["provenance"] = provenance
return body
class CostRequest(BaseModel):
"""원가계산 입력 — 금액은 원 단위."""
@@ -176,7 +189,7 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
body["suggested_profit_adjustment_krw"] = str(
proposed_profit_adjustment(result, payload.target_contract_amount_krw)
)
return JSONResponse(content={"status": "success", **body})
return JSONResponse(content=_with_provenance({"status": "success", **body}))
@router.get("/{project_id}/estimation/items")
@@ -203,11 +216,13 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
try:
build = await _build_for(project_id)
return JSONResponse(
content={
"status": "success",
"summary": build_summary(build),
"rows": list_unit_prices(build),
}
content=_with_provenance(
{
"status": "success",
"summary": build_summary(build),
"rows": list_unit_prices(build),
}
)
)
except Exception:
logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id)
@@ -274,7 +289,9 @@ async def get_base_data_lists(project_id: UUID) -> JSONResponse:
try:
return JSONResponse(
content={"status": "success", **all_lists(await _build_for(project_id))}
content=_with_provenance(
{"status": "success", **all_lists(await _build_for(project_id))}
)
)
except Exception:
logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id)
@@ -689,7 +706,9 @@ async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
try:
return JSONResponse(
content={"status": "success", **detail_of(await _build_for(project_id), code)}
content=_with_provenance(
{"status": "success", **detail_of(await _build_for(project_id), code)}
)
)
except PriceBookError as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
@@ -759,14 +778,18 @@ async def get_bill(project_id: UUID) -> JSONResponse:
)
return JSONResponse(
content={
"status": "success",
"rows": [row.as_dict() for row in result.rows],
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
"summary": bill_summary(result),
"price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []},
}
content=_with_provenance(
{
"status": "success",
"rows": [row.as_dict() for row in result.rows],
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
"summary": bill_summary(result),
"price_basis": (
result.price_basis.as_dict() if result.price_basis else {"entries": []}
),
}
)
)