- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로 - 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
134 lines
5.7 KiB
Python
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
|