메인 창이 B09 를 읽기 전용으로 교차검토해 낸 지적을 처리 제잡비 밑수 — **사람 품(직접노무비)만**으로 바꿈. 기계 안의 조종원 노임은 안 셈 - 근거 셋을 주석에 인용: 산림품셈 13-6-2 [주]③ 「노무비의 합계액」 · 건설품셈 제8장 「잡재료 등 손료 : **직접노무비**에 …」 · 같은 장에서 기계를 넣을 때는 「노무비, 기계손료 및 운전경비의 합」이라 **따로 적음** - 뜻으로도 그쪽 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람 품에 비례해 얹는 자리인데 그 표엔 굴착기가 이미 본 자원으로 서 있음 - 찰쌓기 60~80 기준 ㎡당 2,565.90 → **2,098.46** (제잡비 붙는 17줄 전부 걸림) - ⚠ 잠정 — 사용자 확정 대기. 조종원 포함이면 약 +22 % 안 불리던 가드 둘 (「있다」와 「돈다」는 다름) - ㉥ 물빼기 파이프 — **조판에서 실제로 부름**. 지금은 늘 아랫단이라 안 걸리되 설계 조건이 실리는 날 그 값만 바꾸면 바로 걸림 - ㉢ 배합 분해 — 부를 자리가 아직 없음. **그 사실과 부를 위치를 코드에 적음** 700줄 제한 (CLAUDE.md 4장) — 셋을 나눔 - `_UnitPrice_View`(화면용 조회) · `_ResourceAxis_Sources`(자료 적재·셀 파싱) · `_BillOfQuantities_Rows`(줄 만들기). 가르는 금을 각 파일 머리말에 적음 - 부르는 쪽이 어디서 오는지 신경 쓰지 않게 재수출 물결표 목록을 `RANGE_DASHES` 한 곳으로 모음 — 네 파일에 따로 적혀 서로 달랐음 (지금 물리는 것은 없었으나 같은 목록이 네 벌이면 언젠가 하나만 고쳐짐) 검증: pytest 206 통과, 700줄 초과 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
12 KiB
Python
259 lines
12 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 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,
|
|
# ⚠ 지금 상태를 화면에 그대로 알린다 (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)
|
|
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": str(amount),
|
|
"total": _money_text(amount),
|
|
"source": "품셈 [주]",
|
|
"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} 본표")
|
|
return {
|
|
"code": code,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"unit": title.unit,
|
|
"kind": title.kind.value,
|
|
"material": str(summed["material"]),
|
|
"labor": str(summed["labor"]),
|
|
"expense": str(summed["expense"]),
|
|
"total": str(summed["total"]),
|
|
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
|
|
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
|
|
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
|
|
"precise_total": _money_text(money.total),
|
|
"rows": rows,
|
|
}
|