Files
Aislo/B09_Estimation/B09_Estimation_BillRates.py
T
eomsangdonandClaude Opus 5 b9d0ac0866 feat(b09): 2차 편집 ④ 내역 할증율·일괄보정 — 범위(선택 줄·내역서 전체) · 비목별 절사/반올림 · 소수 0~4 · 줄 ↺ · 할증 전부 되돌리기
- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로
- 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:06:40 +09:00

134 lines
5.7 KiB
Python

"""B09 원가계산 — 내역 **할증율·일괄보정** (PLAN 12장 2차 ④ · STmate `wBoqRate`·`wM_Rate`).
사용자가 고친 값(`estimation.edits.bill_rates`)을 내역 줄 금액에 얹는다 — 프로젝트 단위.
칸 "<단가 코드 또는 명칭>|<규격>" = 그 줄 하나(선택 항목만 — 고른 줄마다 한 칸)
"*" = 내역서 전체
값 {"all": "10"} 재·노·경 같은 비율(%)
{"material": "5", "labor": "0", "expense": "3"} 비목별(%)
+ "rounding": {"material": {"mode": "floor"|"round", "digits": 0~4}, …} 비목별 절사/반올림 · 소수 자리
차례 줄 칸이 있으면 줄 칸, 없으면 "*"
⚠ 성분 단가 × (1 + 율) → 고른 자리로 절사/반올림(안 고르면 원 미만 절사 — 호표 성분 소계 규칙) →
줄 금액은 종전대로 성분마다 절사(명세 7장 · 내역 줄 자리 규칙은 안 바꿈).
⚠ 할증은 **금액만** 움직임 — 수량·자원 집계표 수량은 그대로.
⚠ 되돌리기 — 칸을 지우면 계산값으로. 여러 줄을 한꺼번에 바꾸므로 화면이 「할증 전부 되돌리기」도 줌.
"""
from __future__ import annotations
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import Money3
ALL_ROWS = "*"
PARTS = ("material", "labor", "expense")
_LABELS = {"material": "재", "labor": "노", "expense": "경"}
_MODES = {"floor": ROUND_FLOOR, "round": ROUND_HALF_UP}
_DEFAULT_ROUNDING = {"mode": "floor", "digits": 0}
def rate_key(row: Any) -> str:
"""줄 칸 이름 — 단가 코드(없으면 명칭) + 규격. 내역이 다시 서도 같은 줄이면 같은 이름."""
return f"{row.price_code or row.name}|{row.spec}"
def parse_rate(value: Any) -> tuple[dict[str, Decimal], dict[str, dict[str, Any]]] | None:
"""저장본 한 칸 → (성분별 율 %, 성분별 자리). 모양이 틀리면 `None`."""
if not isinstance(value, dict):
return None
try:
if "all" in value:
rates = dict.fromkeys(PARTS, Decimal(str(value["all"])))
else:
rates = {part: Decimal(str(value.get(part) or 0)) for part in PARTS}
except (InvalidOperation, ValueError):
return None
if any(not rate.is_finite() or rate <= -100 or rate > 1000 for rate in rates.values()):
return None
raw = value.get("rounding") or {}
if isinstance(raw, dict) and "mode" in raw:
raw = dict.fromkeys(PARTS, raw)
rounding: dict[str, dict[str, Any]] = {}
for part in PARTS:
chosen = raw.get(part) if isinstance(raw, dict) else None
chosen = chosen if isinstance(chosen, dict) else _DEFAULT_ROUNDING
mode, digits = str(chosen.get("mode") or "floor"), chosen.get("digits", 0)
if mode not in _MODES or not str(digits).isdigit() or not 0 <= int(digits) <= 4:
return None
rounding[part] = {"mode": mode, "digits": int(digits)}
return rates, rounding
def _round(value: Decimal, rule: dict[str, Any]) -> Decimal:
return value.quantize(Decimal(1).scaleb(-rule["digits"]), rounding=_MODES[rule["mode"]])
def apply_bill_rates(rows: list[Any], raw: dict[str, Any] | None, bill_line: Any) -> None:
"""금액이 선 내역 줄에 율을 얹음 — `bill_line` 은 줄 금액 규칙(순환 import 를 피해 받음)."""
for row in rows:
row.rate_key = rate_key(row)
if not raw:
return
for row in rows:
if row.is_group or row.amount_krw is None or row.unit_material_krw is None:
continue
source = row.rate_key if row.rate_key in raw else ALL_ROWS if ALL_ROWS in raw else None
parsed = parse_rate(raw.get(source)) if source else None
if not parsed:
continue
rates, rounding = parsed
before = {
"material": row.unit_material_krw,
"labor": row.unit_labor_krw,
"expense": row.unit_expense_krw,
}
unit = Money3(
**{
part: _round(before[part] * (1 + rates[part] / 100), rounding[part])
for part in PARTS
}
)
line = bill_line(unit, row.quantity)
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = (
unit.material,
unit.labor,
unit.expense,
)
row.unit_price_krw = unit.total
row.amount_krw = line.total
row.material_krw, row.labor_krw, row.expense_krw = line.material, line.labor, line.expense
same = len(set(rates.values())) == 1
text = (
f"{rates['material']}%"
if same
else " · ".join(f"{_LABELS[part]} {rates[part]}%" for part in PARTS)
)
row.rate = {
"source": source,
**{part: str(rates[part]) for part in PARTS},
"was_unit_price_krw": str(sum(before.values(), Decimal(0))),
}
row.add_note(
"unit_price_krw",
f"할증 {text} (사용자{' · 내역서 전체' if source == ALL_ROWS else ''})",
)
def validate_rate(value: Any) -> dict[str, Any]:
"""저장 전 검사 — 받을 수 있는 모양(글)으로. 틀리면 `ValueError`."""
parsed = parse_rate(value)
if parsed is None:
raise ValueError(
"할증율은 -100 보다 크고 1000 이하인 수(%) · 자리는 절사/반올림 × 소수 0~4 여야 합니다"
)
rates, rounding = parsed
body: dict[str, Any] = (
{"all": str(rates["material"])}
if isinstance(value, dict) and "all" in value
else {part: str(rates[part]) for part in PARTS}
)
body["rounding"] = rounding
return body