- 틀 B09_Estimation_UI_Shell: 탭 줄과 등록만 · 탭마다 파일 하나(계약 _Shell_Types) - 설계내역서: 실무 열(합계/노무/재료/경비 단가·금액) · 머리글 접기·레벨 고르개 · 줄 누르면 제 N 호표 · 단산 N 단추 · 미확정 빨간 테두리 - 일위대가·단가산출근거: 목록표(내역에 처음 쓰인 차례) + 본표 · 줄 누르면 하위 호표·산근·중기로 · 자취 눌러 되돌아감 · Q 식 글자 그대로 - 옛 탭 여섯(원가계산서·중기·관급사급·기초자료·설계서 구성·산출기초)은 옛 코드 그대로 이어 붙임 — 새 탭 파일이 서면 등록 한 줄씩 바꿈 - 본표 합계 줄 = 호표 성분 소계 원 미만 절사 값 · 비율 줄 금액도 0.1원 절사 표시 - 사전 ui_template_locale_b3 새 벌(b2 700줄 넘음) - 검증: ORCA 검증 프로젝트 — 본체 122,848,989 · 지장목제거 865·15,460,837 · 제 9 호표 합계 1,708 = 산근 8호표 합계 1,708 · 옛 탭 여섯 다 뜸 · 접기 53→51·레벨1 11줄 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
324 lines
15 KiB
Python
324 lines
15 KiB
Python
"""B09 원가계산 — 일위대가 **화면용 조회** (요약·목록·본표).
|
|
|
|
조립(`B09_Estimation_UnitPrice`)과 **보여주기**를 갈라 둔 파일이다. 700줄 제한(CLAUDE.md
|
|
4장)에 걸려 나눴고, 가르는 금은 「값을 만드는가 / 만든 값을 화면 모양으로 옮기는가」다.
|
|
|
|
⚠ 단수 처리는 **여기서** 한다 — 계산 함수 안에서 자르지 않는다
|
|
(`B09_Estimation_Rounding` 머리말). 일위대가 금액란은 0.1원 버림이다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from decimal import Decimal
|
|
|
|
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
|
from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog
|
|
from B09_Estimation.B09_Estimation_PriceBook import TRUNCATED_KINDS, PriceKind
|
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
|
from B09_Estimation.B09_Estimation_UnitPrice import (
|
|
DRILLABLE_KINDS,
|
|
SOURCE_INDEX,
|
|
SOURCE_LABEL,
|
|
SUSPICIOUSLY_HIGH_KRW,
|
|
SUSPICIOUSLY_LOW_KRW,
|
|
UnitPriceBuild,
|
|
)
|
|
from B09_Estimation.B09_Estimation_Guards import check_column_sums
|
|
|
|
_ZERO = Decimal(0)
|
|
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
|
|
|
|
|
|
def _plain(text: str) -> str:
|
|
"""화면용 평문 — 마크다운 강조 표시를 벗긴다."""
|
|
return _RE_EMPHASIS.sub(lambda match: match.group(1), text)
|
|
|
|
|
|
def _status_notes() -> list[str]:
|
|
"""화면에 낼 「지금 무엇이 안 선 상태인가」.
|
|
|
|
자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가
|
|
거의 없다는 것이 이 자리의 진짜 공백이다.
|
|
"""
|
|
from B09_Estimation.B09_Estimation_MaterialCatalog import (
|
|
catalog_summary,
|
|
load_material_catalog,
|
|
)
|
|
|
|
summary = catalog_summary(load_material_catalog())
|
|
# 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다.
|
|
return [
|
|
_plain(note)
|
|
for note in [
|
|
f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — "
|
|
"나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 "
|
|
"철근·레미콘·아스콘은 원천에서 빠져 있습니다.",
|
|
"**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — "
|
|
"유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 "
|
|
"일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)",
|
|
(
|
|
f"관급 자재 **설치 주체가 미지정**"
|
|
f"({summary['owner_supplied_install_unspecified']:,}건)이라 "
|
|
"안전관리비 대상액에 자동으로 넣지 않습니다."
|
|
),
|
|
]
|
|
]
|
|
|
|
|
|
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
|
|
# ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다.
|
|
# 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다
|
|
# (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07).
|
|
totals = sorted(
|
|
build.book.resolve(code).total
|
|
for code, title in build.book.titles.items()
|
|
if title.kind is PriceKind.UNIT_PRICE
|
|
)
|
|
stats: dict[str, str] = {}
|
|
low: list[dict[str, str]] = []
|
|
if totals:
|
|
stats = {
|
|
"min": _money_text(totals[0]),
|
|
"median": _money_text(totals[len(totals) // 2]),
|
|
"max": _money_text(totals[-1]),
|
|
}
|
|
# ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라
|
|
# 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다.
|
|
low = [
|
|
{"code": code, "name": title.name, "total": _money_text(money)}
|
|
for code, title in build.book.titles.items()
|
|
if title.kind is PriceKind.UNIT_PRICE
|
|
and code[2:].split("#")[0] not in build.partial_ratio
|
|
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
|
|
]
|
|
|
|
# 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다.
|
|
high = [
|
|
{"code": code, "name": title.name, "total": _money_text(money)}
|
|
for code, title in build.book.titles.items()
|
|
if title.kind is PriceKind.UNIT_PRICE
|
|
and not title.unit
|
|
and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW
|
|
]
|
|
|
|
return {
|
|
"titles": len(build.book.titles),
|
|
"unit_price_totals": stats,
|
|
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
|
|
"suspiciously_low": low,
|
|
# 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다.
|
|
"blocked_items": len(build.partial_ratio),
|
|
# 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
|
|
"unknown_basis_high": high,
|
|
"unknown_basis": sum(
|
|
1
|
|
for code, title in build.book.titles.items()
|
|
if title.kind is PriceKind.UNIT_PRICE and not title.unit
|
|
),
|
|
"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,
|
|
# ⚠ 표본이 얇은 노임이 내역에 실렸으면 **화면이 말해야 한다** — 금액은 그대로 서고
|
|
# 「이 단가는 조사현장이 적다」만 알린다(지식DB `노임단가_적용 §2-3`
|
|
# 「단가 채택 시 플래그 유지 필요」, 2026-09-09에 이음).
|
|
"labor_reliability": [
|
|
{"code": code, "name": name, "flag": flag, "why": why}
|
|
for code, (name, flag, why) in sorted(build.labor_reliability.items())
|
|
],
|
|
# ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
|
|
"notes": _status_notes(),
|
|
}
|
|
|
|
|
|
def _money_text(value: Decimal) -> str:
|
|
"""화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2).
|
|
|
|
계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다
|
|
(`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다).
|
|
"""
|
|
return str(round_at(value, OutputPlace.UNIT_PRICE_ROW))
|
|
|
|
|
|
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": _money_text(money.material),
|
|
"labor": _money_text(money.labor),
|
|
"expense": _money_text(money.expense),
|
|
"total": _money_text(money.total),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
|
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
|
|
title = build.book.title(code)
|
|
money = build.book.resolve(code)
|
|
# 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다.
|
|
work_item_code = code[2:].split("#")[0] if code.startswith("B-") else ""
|
|
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
|
|
|
unattached = list(build.unattached.get(work_item_code, []))
|
|
rows: list[dict] = []
|
|
for detail in build.book.details.get(code, []):
|
|
if detail.percent_of_labor is not None:
|
|
# 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야
|
|
# 화면 합계와 실제 단가가 어긋나지 않는다.
|
|
# 밑수는 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다
|
|
# (근거 인용 셋은 `PriceBook.resolve` 의 같은 자리 주석).
|
|
labor_so_far = sum(
|
|
(
|
|
Decimal(str(row_item["labor"]))
|
|
for row_item in rows
|
|
if row_item.get("kind") == PriceKind.LABOR.value
|
|
),
|
|
_ZERO,
|
|
)
|
|
amount = labor_so_far * detail.percent_of_labor / Decimal(100)
|
|
rows.append(
|
|
{
|
|
"code": detail.ref_code,
|
|
"name": "제잡비",
|
|
"spec": f"노무비의 {detail.percent_of_labor}%",
|
|
"unit": "%",
|
|
"quantity": str(detail.percent_of_labor),
|
|
"material": "0",
|
|
"labor": "0",
|
|
"expense": _money_text(amount),
|
|
"total": _money_text(amount),
|
|
"source": "품셈 [주]",
|
|
"drillable": False,
|
|
"note": detail.note,
|
|
}
|
|
)
|
|
continue
|
|
|
|
if detail.percent_of_material is not None:
|
|
# 공구손료·잡재료 — 지금까지 쌓인 **주재료비**의 %가 재료비로 붙는다
|
|
# (산림품셈 1-2-6). 밑수는 **자재 줄만** — 하위 일위대가가 품고 온 재료비는
|
|
# 그쪽에서 이미 셌다(`PriceBook.resolve` 의 같은 자리와 한 규칙).
|
|
material_so_far = sum(
|
|
(
|
|
Decimal(str(row_item["material"]))
|
|
for row_item in rows
|
|
if row_item.get("kind") == PriceKind.MATERIAL.value
|
|
),
|
|
_ZERO,
|
|
)
|
|
amount = material_so_far * detail.percent_of_material / Decimal(100)
|
|
# 중기 호표의 잡품(주연료비 × 율)도 같은 비율 줄 — 이름표만 가름(품셈 제8장).
|
|
misc = detail.note.startswith("잡품")
|
|
rows.append(
|
|
{
|
|
"code": detail.ref_code,
|
|
"name": "잡품" if misc else "공구손료·잡재료",
|
|
"spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%",
|
|
"unit": "%",
|
|
"quantity": str(detail.percent_of_material),
|
|
"material": _money_text(amount),
|
|
"labor": "0",
|
|
"expense": "0",
|
|
"total": _money_text(amount),
|
|
"source": "품셈 제8장" if misc else "품셈 1-2-6",
|
|
"drillable": False,
|
|
"note": detail.note,
|
|
}
|
|
)
|
|
continue
|
|
|
|
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,
|
|
# 제잡비 밑수를 가릴 때 쓴다 — 사람 품(`labor`)만 센다.
|
|
"kind": child.kind.value,
|
|
"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": _money_text(unit_money.material),
|
|
"unit_labor": _money_text(unit_money.labor),
|
|
"unit_expense": _money_text(unit_money.expense),
|
|
"unit_total": _money_text(unit_money.total),
|
|
"material": _money_text(line.material),
|
|
"labor": _money_text(line.labor),
|
|
"expense": _money_text(line.expense),
|
|
# 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다.
|
|
# 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다.
|
|
"total": str(
|
|
round_at(line.material, OutputPlace.UNIT_PRICE_ROW)
|
|
+ round_at(line.labor, OutputPlace.UNIT_PRICE_ROW)
|
|
+ round_at(line.expense, OutputPlace.UNIT_PRICE_ROW)
|
|
),
|
|
"note": detail.note,
|
|
}
|
|
)
|
|
# 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」
|
|
# (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다.
|
|
summed = {
|
|
key: sum((Decimal(r[key]) for r in rows), Decimal(0))
|
|
for key in ("material", "labor", "expense", "total")
|
|
}
|
|
# ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
|
|
# 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
|
|
check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
|
|
# 합계 줄 — 호표 안에서 자르는 층(X·D·B)은 **성분 소계 원 미만 절사** 값(실무 표 합계 줄 · 명세 7장).
|
|
shown = (
|
|
{"material": money.material, "labor": money.labor, "expense": money.expense}
|
|
if title.kind in TRUNCATED_KINDS
|
|
else {key: summed[key] for key in ("material", "labor", "expense")}
|
|
)
|
|
return {
|
|
"code": code,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"unit": title.unit,
|
|
"kind": title.kind.value,
|
|
"material": str(shown["material"]),
|
|
"labor": str(shown["labor"]),
|
|
"expense": str(shown["expense"]),
|
|
"total": str(sum(shown.values(), Decimal(0))),
|
|
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
|
|
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
|
|
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
|
|
"precise_total": _money_text(money.total),
|
|
"rows": rows,
|
|
# ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다.
|
|
# 안 보이면 조용히 싼 단가가 내역서에 그대로 든다.
|
|
"unattached": unattached,
|
|
# ⚠ 원문에는 있는데 못 실린 몫 — 이름을 못 찾은 줄과 **다른 갈래**다.
|
|
"known_gap_note": known_gap_note(work_item_code),
|
|
"unattached_note": (
|
|
f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — "
|
|
f"{', '.join(unattached[:4])}"
|
|
+ (" 등" if len(unattached) > 4 else "")
|
|
+ ". 자재·기계 카탈로그가 서면 채워집니다. 그때까지 이 단가는 "
|
|
"**붙은 줄만의 값**입니다."
|
|
)
|
|
if unattached
|
|
else "",
|
|
}
|