- 계약잡비율: 기본 역산값(도급액 ÷ 계약 직접공사비) · 계약서 값 직접입력이 이김(사유 필수) - 줄별 절사 누적으로 단일율 곱과 벌어진 금회·누계 차이를 비고에 적음 - 공급가액 절사(총공사비에서 조정 + 10원~1억) · 총공사비 절사(이윤금액 직접입력 + 10원~1억) — 떨어지는 몫은 설계 원가계산서와 같은 식으로 이윤에서 - 사정: 뜻 미확인이라 칸만 받고 계산에 안 씀 - 시험 4건 보탬 · 전체 1723 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
422 lines
18 KiB
Python
422 lines
18 KiB
Python
"""B09 기성 단계 — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 2026-09-14 배정).
|
|
|
|
근거: STmate 분석 `27_계약_실행_기성_단계.md` §3 (`wM_KanJub_K` 「기성 제잡비 계산서」) ·
|
|
`22_UI_폼_분석.md` §3.5 · `35_형식과_단계의_공통과_차이.md` §3 ·
|
|
원자료 `ui_form_catalog.txt` 133~137행.
|
|
· ⭐ 간접비를 설계처럼 줄마다 밑수 × 요율로 다시 셈하지 **않음** —
|
|
「금회직접공사비 × 계약제잡비율」
|
|
· 계약(도급액) · 전회 · 금회 · 누계 네 칸과 각 비율(전율·금율·누율)
|
|
· 부가세 (0) 직접입력 · (1) 공급가액의 10% · (2) 재료비의 10% · (3) 재료비+산출경비의 10%
|
|
|
|
⚠ **계약을 안 건드린다** — 계약내역 줄(`contract_bill` 결과)과 계약 원가계산서(같은 엔진에 계약
|
|
직접비)를 **읽기만** 해서 파생한다.
|
|
⚠ **값이 맞다가 아니라 구조가 선다까지** — 기성 표본 0건(27번 §9 · 35번 「회차별 값과
|
|
`QTY1_B/N/O/H` 뜻 미확인」). 그 네 칸은 쓰지 않고, 아래는 **구조로 읽은 것**이라 확인 대기:
|
|
① 계약잡비율 = 계약 원가계산서 그 줄 금액 ÷ 계약 직접공사비(격자 `계약잡비율` 열이 줄마다 있음)
|
|
② 회차마다 금회 금액을 원 미만 절사해 쌓고 전회 = 앞 회차 금회의 합 · 누계 = 전회 + 금회
|
|
③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖
|
|
원문 대조: 예정가격작성기준 제39조②(표준시장단가 장) 간접공사비 「1. 간접노무비」~「10.」 —
|
|
간접재료비 없음 · 제17조(원가계산 장)는 간접재료비를 재료비 안에 둠. 원가계산 장에는
|
|
「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님.
|
|
①′ 계약잡비율 직접입력(사유 필수)이 역산값을 이김 — 계약서에 제잡비율이 명시됨(브레인 판정).
|
|
④ 사정 — 「기성내역서(사정)」 열 이름뿐이라 칸만 받고 계산에 안 씀.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
|
|
from B09_Estimation.B09_Estimation_Contract import _group_sums, _money
|
|
from B09_Estimation.B09_Estimation_CostSheet import EXPENSE_ORDER
|
|
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
|
|
CUT_UNITS_KRW,
|
|
cut_gap,
|
|
profit_cut,
|
|
vat_base,
|
|
)
|
|
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
|
|
|
_ZERO = Decimal(0)
|
|
_HUNDRED = Decimal(100)
|
|
|
|
#: 저장 자리 — `estimation` 구획 안 한 칸.
|
|
SETTINGS_KEY = "progress"
|
|
|
|
#: 부가세 — `cmBx_Buga` 표기 그대로. 키는 설계 원가계산서 `VAT_MODES` 와 같은 뜻이면 같은 키.
|
|
VAT_CHOICES: tuple[tuple[str, str], ...] = (
|
|
("manual", "(0) 직접입력"),
|
|
("supply", "(1) 공급가액의 10%"),
|
|
("material", "(2) 재료비의 10%"),
|
|
("forest_coop", "(3) 재료비+산출경비의 10%"),
|
|
)
|
|
#: 화면 표기 「10%」 그대로.
|
|
_VAT_PERCENT = Decimal(10)
|
|
|
|
#: 제잡비 줄 — 간접노무비 ~ 부가세 직전. 설계 원가계산서 서식 차례.
|
|
_ITEM_ORDER: tuple[tuple[str, str], ...] = (
|
|
("indirect_labor_cost", "간접노무비"),
|
|
*EXPENSE_ORDER,
|
|
("general_overhead", "일반관리비"),
|
|
("profit", "이윤"),
|
|
)
|
|
|
|
_COLUMNS = ("previous", "current", "cumulative")
|
|
|
|
#: 절사 — `cmBx_JeolSa`(공급가액)·`cmBx_Tot_Jeol`(총공사비) 9택. 첫 칸(빈 값)은 각각
|
|
#: 「총공사비에서 조정」·「이윤금액 직접입력」. 떨어지는 몫은 설계 원가계산서처럼 **이윤에서** 뺌.
|
|
CUT_CHOICES: tuple[str, ...] = ("", *(str(unit) for unit in CUT_UNITS_KRW))
|
|
_CUT_MAX_PASSES = 3
|
|
|
|
|
|
def _number(value: Any) -> Decimal | None:
|
|
try:
|
|
number = Decimal(str(value))
|
|
except (InvalidOperation, ValueError):
|
|
return None
|
|
return number if number.is_finite() and number >= 0 else None
|
|
|
|
|
|
def _floor(value: Decimal) -> Decimal:
|
|
return value.to_integral_value(rounding=ROUND_FLOOR)
|
|
|
|
|
|
def _pct(part: Decimal, whole: Decimal) -> str | None:
|
|
return str((part / whole * _HUNDRED).quantize(Decimal("0.001"))) if whole else None
|
|
|
|
|
|
def _amounts(raw: dict[str, Any] | None, label: str, errors: list[str]) -> dict[str, str]:
|
|
"""{줄: 0 이상 수} — 빈 칸은 뺌 · 틀린 칸은 까닭."""
|
|
kept: dict[str, str] = {}
|
|
for key, value in (raw or {}).items():
|
|
if value in (None, ""):
|
|
continue
|
|
number = _number(value)
|
|
if number is None:
|
|
errors.append(f"{label} {key} 값이 0 이상 수가 아님 — {value}")
|
|
continue
|
|
kept[str(key)] = str(number)
|
|
return kept
|
|
|
|
|
|
def _optional(value: Any, label: str, errors: list[str]) -> str:
|
|
if value in (None, ""):
|
|
return ""
|
|
if _number(value) is None:
|
|
errors.append(f"{label}이 0 이상 수가 아님 — {value}")
|
|
return ""
|
|
return str(_number(value))
|
|
|
|
|
|
def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
"""회차 목록 · 계약잡비율 직접입력 저장값과 거른 까닭."""
|
|
errors: list[str] = []
|
|
rounds: list[dict[str, Any]] = []
|
|
for number, entry in enumerate(values.get("rounds") or [], start=1):
|
|
entry = entry or {}
|
|
mode = str(entry.get("vat_mode") or "supply")
|
|
if mode not in dict(VAT_CHOICES):
|
|
errors.append(f"{number}회 부가세 방식을 모름 — {mode}")
|
|
mode = "supply"
|
|
manual = _optional(entry.get("vat_manual_krw"), f"{number}회 부가세 직접입력 금액", errors)
|
|
if mode == "manual" and manual == "":
|
|
errors.append(f"{number}회 부가세 직접입력 금액이 비었음")
|
|
cuts = {}
|
|
for key in ("supply_cut_krw", "total_cut_krw"):
|
|
unit = str(entry.get(key) or "")
|
|
cuts[key] = unit if unit in CUT_CHOICES else ""
|
|
rounds.append(
|
|
{
|
|
"quantities": _amounts(entry.get("quantities"), f"{number}회 기성수량", errors),
|
|
"vat_mode": mode,
|
|
"vat_manual_krw": manual,
|
|
**cuts,
|
|
"profit_manual_krw": _optional(
|
|
entry.get("profit_manual_krw"), f"{number}회 이윤금액 직접입력", errors
|
|
),
|
|
"assessed": _amounts(entry.get("assessed"), f"{number}회 사정", errors),
|
|
}
|
|
)
|
|
overrides: dict[str, dict[str, str]] = {}
|
|
for key, entry in (values.get("rate_overrides") or {}).items():
|
|
entry = entry or {}
|
|
rate = _optional(entry.get("rate_pct"), f"{key} 계약잡비율", errors)
|
|
reason = str(entry.get("reason") or "").strip()
|
|
if rate == "":
|
|
continue
|
|
if not reason:
|
|
errors.append(f"{key} 계약잡비율을 고친 사유가 비었음")
|
|
continue
|
|
overrides[str(key)] = {"rate_pct": rate, "reason": reason}
|
|
return {"rounds": rounds, "rate_overrides": overrides}, errors
|
|
|
|
|
|
def _leaf(row: dict[str, Any]) -> bool:
|
|
return (
|
|
not row.get("is_group")
|
|
and row.get("in_bill", True)
|
|
and row.get("contract_amount_krw") is not None
|
|
)
|
|
|
|
|
|
def _unit(row: dict[str, Any]) -> Money3:
|
|
return Money3(
|
|
material=_money(row.get("contract_unit_material_krw")),
|
|
labor=_money(row.get("contract_unit_labor_krw")),
|
|
expense=_money(row.get("contract_unit_expense_krw")),
|
|
)
|
|
|
|
|
|
def cost_items(cost_data: Any, cost_result: Any) -> list[tuple[str, str, Decimal]]:
|
|
"""계약 원가계산서의 제잡비 줄 (키, 이름, 도급액) — 분리발주 폐기물은 도급 밖이라 뺌."""
|
|
items = []
|
|
for key, name in _ITEM_ORDER:
|
|
if not cost_result.has(key):
|
|
continue
|
|
if key == "waste_disposal" and cost_data.waste_separate_order:
|
|
continue
|
|
items.append((key, name, cost_result.amount(key)))
|
|
return items
|
|
|
|
|
|
def contract_ratios(
|
|
items: list[tuple[str, str, Decimal]],
|
|
contract_direct: Decimal,
|
|
overrides: dict[str, dict[str, str]],
|
|
) -> dict[str, tuple[Decimal, Decimal]]:
|
|
"""줄 → (적용 비율, 역산 비율) — 기본은 도급액 ÷ 계약 직접공사비, 직접입력(사유 있음)이 이김."""
|
|
ratios = {}
|
|
for key, _, amount in items:
|
|
derived = amount / contract_direct if contract_direct else _ZERO
|
|
override = overrides.get(key)
|
|
ratios[key] = (
|
|
Decimal(override["rate_pct"]) / _HUNDRED if override else derived,
|
|
derived,
|
|
)
|
|
return ratios
|
|
|
|
|
|
def _round_money(
|
|
rows: list[dict[str, Any]],
|
|
entry: dict[str, Any],
|
|
ratios: dict[str, tuple[Decimal, Decimal]],
|
|
) -> dict[str, Any]:
|
|
"""한 회차의 금회 — 줄 금액 · 직접공사비 · 제잡비 줄 · 공급가액 · 부가세 · 절사 몫."""
|
|
lines: dict[str, Money3] = {}
|
|
direct = Money3()
|
|
for row in rows:
|
|
qty = entry["quantities"].get(str(row.get("item_no")))
|
|
if qty is None or not _leaf(row):
|
|
continue
|
|
lines[str(row["item_no"])] = bill_line(_unit(row), Decimal(qty))
|
|
direct += lines[str(row["item_no"])]
|
|
raw = {key: _floor(direct.total * ratio) for key, (ratio, _) in ratios.items()}
|
|
items = dict(raw)
|
|
notes: list[str] = []
|
|
profit_manual = entry.get("profit_manual_krw") or ""
|
|
if profit_manual and not entry.get("total_cut_krw") and "profit" in items:
|
|
items["profit"] = Decimal(profit_manual)
|
|
notes.append(f"이윤금액 직접입력 {Decimal(profit_manual):,.0f}원")
|
|
|
|
def vat_of(supply: Decimal) -> Decimal:
|
|
if entry["vat_mode"] == "manual":
|
|
return Decimal(entry["vat_manual_krw"] or 0)
|
|
base = vat_base(entry["vat_mode"], supply, direct.material, direct.expense, _ZERO)
|
|
return _floor(base * _VAT_PERCENT / _HUNDRED)
|
|
|
|
def take_from_profit(amount: Decimal, label: str) -> bool:
|
|
if "profit" not in items:
|
|
notes.append(f"{label} {amount:,.0f}원 — 이윤 줄이 없어 못 뺌")
|
|
return False
|
|
items["profit"] -= amount
|
|
return True
|
|
|
|
supply = direct.total + sum(items.values(), _ZERO)
|
|
if entry.get("supply_cut_krw"):
|
|
gap = cut_gap(supply, int(entry["supply_cut_krw"]))
|
|
if gap and take_from_profit(gap, "공급가액 절사"):
|
|
supply -= gap
|
|
notes.append(
|
|
f"공급가액 {int(entry['supply_cut_krw']):,}원 미만 절사 — 이윤에서 {gap:,.0f}원"
|
|
)
|
|
vat = vat_of(supply)
|
|
if entry.get("total_cut_krw"):
|
|
# 설계 원가계산서와 같은 식 — 첫 차례는 공급가액 비례 부가세면 ÷1.1, 그 뒤는 잔차 그대로.
|
|
unit, taken = int(entry["total_cut_krw"]), _ZERO
|
|
for _ in range(_CUT_MAX_PASSES):
|
|
gap = cut_gap(supply + vat, unit)
|
|
if gap == 0:
|
|
break
|
|
step = profit_cut(gap, "grand_total", entry["vat_mode"]) if taken == 0 else gap
|
|
if not take_from_profit(step, "총공사비 절사"):
|
|
break
|
|
taken += step
|
|
supply -= step
|
|
vat = vat_of(supply)
|
|
if taken:
|
|
notes.append(f"총공사비 {unit:,}원 미만 절사 — 이윤에서 {taken:,.0f}원")
|
|
return {
|
|
"lines": lines,
|
|
"direct": direct,
|
|
"raw_items": raw,
|
|
"items": items,
|
|
"supply": supply,
|
|
"vat": vat,
|
|
"notes": notes,
|
|
}
|
|
|
|
|
|
def progress_sheet(
|
|
contract_rows: list[dict[str, Any]],
|
|
cost_data: Any,
|
|
cost_result: Any,
|
|
settings: dict[str, Any],
|
|
round_no: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""기성내역 · 기성 제잡비 계산서 한 장 — `round_no`(1부터) 회차를 금회로. 없으면 마지막 회차."""
|
|
rounds = settings.get("rounds") or []
|
|
current = len(rounds) if round_no is None else max(0, min(round_no, len(rounds)))
|
|
contract_direct = sum(
|
|
(_money(r["contract_amount_krw"]) for r in contract_rows if _leaf(r)), _ZERO
|
|
)
|
|
items = cost_items(cost_data, cost_result)
|
|
overrides = settings.get("rate_overrides") or {}
|
|
ratios = contract_ratios(items, contract_direct, overrides)
|
|
per_round = [_round_money(contract_rows, entry, ratios) for entry in rounds]
|
|
empty = {
|
|
"lines": {},
|
|
"direct": Money3(),
|
|
"raw_items": {},
|
|
"items": {},
|
|
"supply": _ZERO,
|
|
"vat": _ZERO,
|
|
"notes": [],
|
|
}
|
|
before = per_round[: max(current - 1, 0)]
|
|
now = per_round[current - 1] if current else empty
|
|
|
|
def four(pick) -> dict[str, Decimal]:
|
|
"""전회(앞 회차 금회의 합) · 금회 · 누계(전회 + 금회)."""
|
|
previous = sum((pick(r) for r in before), _ZERO)
|
|
return {"previous": previous, "current": pick(now), "cumulative": previous + pick(now)}
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
for source in contract_rows:
|
|
row = dict(source)
|
|
rows.append(row)
|
|
if not _leaf(row):
|
|
continue
|
|
item_no = str(row.get("item_no"))
|
|
qty_col = {
|
|
"previous": sum(
|
|
(Decimal(rounds[i]["quantities"].get(item_no, "0")) for i in range(len(before))),
|
|
_ZERO,
|
|
),
|
|
"current": Decimal(rounds[current - 1]["quantities"].get(item_no, "0"))
|
|
if current
|
|
else _ZERO,
|
|
}
|
|
qty_col["cumulative"] = qty_col["previous"] + qty_col["current"]
|
|
money = {
|
|
"previous": sum((r["lines"].get(item_no, Money3()) for r in before), Money3()),
|
|
"current": now["lines"].get(item_no, Money3()),
|
|
}
|
|
money["cumulative"] = money["previous"] + money["current"]
|
|
contract_qty = _money(row.get("quantity"))
|
|
for col in _COLUMNS:
|
|
row.update(
|
|
{
|
|
f"progress_{col}_quantity": str(qty_col[col]),
|
|
f"progress_{col}_material_krw": str(money[col].material),
|
|
f"progress_{col}_labor_krw": str(money[col].labor),
|
|
f"progress_{col}_expense_krw": str(money[col].expense),
|
|
f"progress_{col}_amount_krw": str(money[col].total),
|
|
}
|
|
)
|
|
row["progress_pct"] = _pct(qty_col["cumulative"], contract_qty)
|
|
row["progress_remaining_quantity"] = str(contract_qty - qty_col["cumulative"])
|
|
row["progress_note"] = (
|
|
"누계 기성수량이 계약 수량을 넘음" if qty_col["cumulative"] > contract_qty else ""
|
|
)
|
|
# 사정 — 뜻 미확인(27번 §3.2 「기성내역서(사정)」 열 이름뿐)이라 칸만 받고 계산에 안 씀.
|
|
row["progress_assessed_krw"] = (
|
|
rounds[current - 1]["assessed"].get(item_no, "") if current else ""
|
|
)
|
|
for col in _COLUMNS:
|
|
_group_sums(rows, f"progress_{col}")
|
|
|
|
contract_items_total = sum((amount for _, _, amount in items), _ZERO)
|
|
item_rows = []
|
|
for key, name, amount in items:
|
|
values = four(lambda r, k=key: r["items"].get(k, _ZERO))
|
|
ratio, derived = ratios[key]
|
|
item_rows.append(
|
|
{
|
|
"key": key,
|
|
"name": name,
|
|
"contract_krw": str(amount),
|
|
"contract_ratio_pct": str((ratio * _HUNDRED).quantize(Decimal("0.001"))),
|
|
"default_ratio_pct": str((derived * _HUNDRED).quantize(Decimal("0.001"))),
|
|
"override": overrides.get(key),
|
|
**{f"{col}_krw": str(values[col]) for col in _COLUMNS},
|
|
**{f"{col}_pct": _pct(values[col], amount) for col in _COLUMNS},
|
|
}
|
|
)
|
|
contract_supply = contract_direct + contract_items_total
|
|
summary = []
|
|
for key, name, contract_amount, pick in (
|
|
("direct", "직접공사비", contract_direct, lambda r: r["direct"].total),
|
|
("items", "제잡비 계", contract_items_total, lambda r: sum(r["items"].values(), _ZERO)),
|
|
("supply", "공급가액", contract_supply, lambda r: r["supply"]),
|
|
("vat", "부가가치세", cost_result.amount("vat"), lambda r: r["vat"]),
|
|
(
|
|
"total",
|
|
"기성금액",
|
|
contract_supply + cost_result.amount("vat"),
|
|
lambda r: r["supply"] + r["vat"],
|
|
),
|
|
):
|
|
values = four(pick)
|
|
summary.append(
|
|
{
|
|
"key": key,
|
|
"name": name,
|
|
"contract_krw": str(contract_amount),
|
|
**{f"{col}_krw": str(values[col]) for col in _COLUMNS},
|
|
**{f"{col}_pct": _pct(values[col], contract_amount) for col in _COLUMNS},
|
|
}
|
|
)
|
|
# 단일율 곱과의 차이 — 줄마다 원 미만 절사해 쌓아서 벌어짐(절사 보정 전 값끼리 댐).
|
|
single = sum((ratio for ratio, _ in ratios.values()), _ZERO)
|
|
raw_sum = four(lambda r: sum(r["raw_items"].values(), _ZERO))
|
|
direct_sum = four(lambda r: r["direct"].total)
|
|
gaps = {
|
|
col: _floor(direct_sum[col] * single) - raw_sum[col] for col in ("current", "cumulative")
|
|
}
|
|
notes = list(now["notes"])
|
|
if any(gaps.values()):
|
|
notes.append(
|
|
f"줄별 절사 누적으로 단일율 곱과 금회 {gaps['current']:,.0f}원 · "
|
|
f"누계 {gaps['cumulative']:,.0f}원 차이"
|
|
)
|
|
if cost_data.indirect_material_krw:
|
|
notes.append(
|
|
f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖"
|
|
"(원자료 「간접노무비부터 부가세 직전」) — 확인 대기"
|
|
)
|
|
unknown = sorted(set(overrides) - set(ratios))
|
|
if unknown:
|
|
notes.append(f"계약잡비율 직접입력이 원가계산서에 없는 줄을 가리킴 — {', '.join(unknown)}")
|
|
return {
|
|
"rows": rows,
|
|
"items": item_rows,
|
|
"summary": summary,
|
|
"single_rate_gap_krw": {col: str(gap) for col, gap in gaps.items()},
|
|
"contract_overhead_ratio_pct": str((single * _HUNDRED).quantize(Decimal("0.001"))),
|
|
"round": current,
|
|
"round_count": len(rounds),
|
|
"vat": rounds[current - 1] if current else None,
|
|
"notes": notes,
|
|
}
|