feat(B09): 일위대가 탭 — 목록표+본표 2단, 원천 표시, 층 파고들기

**API 2종** (`B09_Estimation_Router.py`)
- `GET …/estimation/unit-prices` — **목록표** + **산출 요약**. 요약을 같이 보내는 까닭은
  사용자가 「무엇이 안 선 상태인가」를 화면에서 알아야 하기 때문임(자재 카탈로그
  미확보로 구조물 계열이 안 섬).
- `GET …/estimation/unit-prices/{code}` — **본표**. 줄마다 원천(자재5·노임6·기계경비105·
  일위대가103·단가산출104)과 **파고들기 가능 여부**가 붙음. 없는 코드는 404.
- 품셈 3 MB 를 요청마다 다시 안 읽게 `cached_build()` 로 한 번만 조립.

**화면** (`B09_Estimation_UI_Page.ts`)
- 일위대가 탭 활성화. **목록표(위) + 본표(아래) 2단** — 9-3 「제목+상세 한 쌍」이 화면에도
  그대로 섬.
- 본표 줄마다 `원천(번호)` 표시, **기계 줄을 누르면 그 시간당 사용료 본표로 파고듦**
  (거기서 취득가·연료·조종원까지 보임). 값을 못 믿을 때 사람이 하는 일이 이것임.
- **재료·노무·경비 3분할 + 합계 줄**, `TC = NC + GC + JC` 성립 여부를 화면 문구로 냄.
- **산출 요약을 화면에 표시** — 자재가 없어 구조물 계열이 못 선다는 것을 그 자리에 적음.

locale 은 **B09 키만** 추가(16줄), 공용 파일 다른 줄 무수정.

⚠ 화면 조작 검증은 다음 단계 — `tsc` 는 통과했고(남은 오류 1건은 메인 창 B08 파일),
백엔드 재시작·클릭 검증은 이어서 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:13:20 +09:00
co-authored by Claude Opus 5
parent 856be1f45b
commit 6c4d0251db
4 changed files with 467 additions and 3 deletions
+131 -2
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from functools import lru_cache
from B09_Estimation.B09_Estimation_Guards import check_surcharge_once
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
@@ -177,8 +178,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
"""
master = load_work_item_master()
if axis is None:
axis = build_resource_axis(load_work_item_master(), load_combined_catalog())
axis = build_resource_axis(master, load_combined_catalog())
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
build = UnitPriceBuild()
wages = load_operator_wages()
@@ -197,7 +201,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
continue
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
build.book.add_title(
PriceTitle(code=title_code, kind=PriceKind.UNIT_PRICE, name=work_item_code, unit=unit)
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=names.get(work_item_code) or work_item_code,
spec=work_item_code,
unit=unit,
)
)
added = 0
for row in rows:
@@ -230,3 +240,122 @@ def verify_surcharge_once(
surcharge_rate_percent=surcharge_rate_percent,
label=code,
)
#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축).
SOURCE_INDEX: dict[PriceKind, int] = {
PriceKind.MATERIAL: 5,
PriceKind.LABOR: 6,
PriceKind.MACHINE_BASE: 105,
PriceKind.MACHINE_HOURLY: 105,
PriceKind.UNIT_PRICE: 103,
PriceKind.PRICE_BASIS: 104,
PriceKind.LUMPSUM: 0,
}
SOURCE_LABEL: dict[PriceKind, str] = {
PriceKind.MATERIAL: "자재",
PriceKind.LABOR: "노임",
PriceKind.MACHINE_BASE: "기계경비",
PriceKind.MACHINE_HOURLY: "기계경비",
PriceKind.UNIT_PRICE: "일위대가",
PriceKind.PRICE_BASIS: "단가산출",
PriceKind.LUMPSUM: "일식·견적",
}
#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다.
DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS})
@lru_cache(maxsize=1)
def cached_build() -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다."""
return build_unit_prices()
def build_summary(build: UnitPriceBuild) -> dict:
"""산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다."""
kinds: dict[str, int] = {}
for title in build.book.titles.values():
kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
return {
"titles": len(build.book.titles),
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
"skipped_work_items": len(build.skipped),
"incomplete_machines": len(build.incomplete_machines),
"kinds": kinds,
# ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
"notes": [
"자재 카탈로그가 아직 없어 **구조물 계열 일위대가가 서지 않습니다** — "
"지금 선 것은 노무·기계 성분뿐입니다(연료만 자재로 섭니다).",
"사급 잡자재 단가는 미결입니다 — 값을 지어내지 않고 비워 둡니다.",
],
}
def list_unit_prices(build: UnitPriceBuild) -> list[dict]:
"""목록표 — 「무엇이 있나」 한 줄씩."""
rows: list[dict] = []
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.UNIT_PRICE:
continue
money = build.book.resolve(code)
rows.append(
{
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"material": str(money.material),
"labor": str(money.labor),
"expense": str(money.expense),
"total": str(money.total),
}
)
return rows
def detail_of(build: UnitPriceBuild, code: str) -> dict:
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
title = build.book.title(code)
money = build.book.resolve(code)
rows: list[dict] = []
for detail in build.book.details.get(code, []):
child = build.book.title(detail.ref_code)
unit_money = build.book.resolve(detail.ref_code)
line = unit_money.scaled(detail.quantity)
rows.append(
{
"ref_code": detail.ref_code,
"name": child.name,
"spec": child.spec,
"unit": child.unit,
"source_index": SOURCE_INDEX.get(child.kind, 0),
"source_label": SOURCE_LABEL.get(child.kind, ""),
"drillable": child.kind in DRILLABLE_KINDS,
"quantity": str(detail.quantity),
"unit_material": str(unit_money.material),
"unit_labor": str(unit_money.labor),
"unit_expense": str(unit_money.expense),
"unit_total": str(unit_money.total),
"material": str(line.material),
"labor": str(line.labor),
"expense": str(line.expense),
"total": str(line.total),
"note": detail.note,
}
)
return {
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"kind": title.kind.value,
"material": str(money.material),
"labor": str(money.labor),
"expense": str(money.expense),
"total": str(money.total),
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
"sum_matches": money.total == money.material + money.labor + money.expense,
"rows": rows,
}