Files
Aislo/B09_Estimation/B09_Estimation_Progress.py
T
eomsangdonandClaude Opus 5 1edde503fe feat(b09): 기성 단계 1벌 — 금회직접공사비 × 계약잡비율 · 계약/전회/금회/누계와 각 율 · 부가세 넷
- 계약내역·계약 원가계산서(같은 엔진에 계약 직접비)를 읽기만 해 파생 — 계약 불변
- 제잡비 줄마다 계약잡비율 = 도급액 ÷ 계약 직접공사비 (구조로 읽음, 확인 대기)
- 회차 목록 저장 · 기성(%) · 잔량 · 계약 수량 넘으면 알림
- 부가세: 직접입력 / 공급가액 / 재료비 / 재료비+산출경비 (설계 vat_base 같이 씀)
- 원가계산서 조립·계약본 받기 조각을 떼어 기성이 같은 길로 받음
- 기성 탭 파일 · 사전 키(등록은 서브) · 시험 6건 · 전체 1717 통과

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

287 lines
12 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) — 간접재료비·관급·분리발주 폐기물은 밖
"""
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 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")
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 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 {}
quantities: dict[str, str] = {}
for item_no, raw in (entry.get("quantities") or {}).items():
if raw in (None, ""):
continue
qty = _number(raw)
if qty is None:
errors.append(f"{number}{item_no} 기성수량이 0 이상 수가 아님 — {raw}")
continue
quantities[str(item_no)] = str(qty)
mode = str(entry.get("vat_mode") or "supply")
if mode not in dict(VAT_CHOICES):
errors.append(f"{number}회 부가세 방식을 모름 — {mode}")
mode = "supply"
manual = entry.get("vat_manual_krw")
if mode == "manual" and _number(manual) is None:
errors.append(f"{number}회 부가세 직접입력 금액이 0 이상 수가 아님 — {manual}")
rounds.append(
{
"quantities": quantities,
"vat_mode": mode,
"vat_manual_krw": str(_number(manual)) if _number(manual) is not None else "",
}
)
return {"rounds": rounds}, 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 _round_money(
rows: list[dict[str, Any]],
entry: dict[str, Any],
items: list[tuple[str, str, Decimal]],
contract_direct: 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"])]
item_amounts = {
key: _floor(direct.total * amount / contract_direct) if contract_direct else _ZERO
for key, _, amount in items
}
supply = direct.total + sum(item_amounts.values(), _ZERO)
if entry["vat_mode"] == "manual":
vat = Decimal(entry["vat_manual_krw"] or 0)
else:
base = vat_base(entry["vat_mode"], supply, direct.material, direct.expense, _ZERO)
vat = _floor(base * _VAT_PERCENT / _HUNDRED)
return {
"lines": lines,
"direct": direct,
"items": item_amounts,
"supply": supply,
"vat": vat,
}
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)
per_round = [_round_money(contract_rows, entry, items, contract_direct) for entry in rounds]
empty = {"lines": {}, "direct": Money3(), "items": {}, "supply": _ZERO, "vat": _ZERO}
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 ""
)
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))
item_rows.append(
{
"key": key,
"name": name,
"contract_krw": str(amount),
"contract_ratio_pct": _pct(amount, contract_direct),
**{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},
}
)
notes = []
if cost_data.indirect_material_krw:
notes.append(
f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖"
"(원자료 「간접노무비부터 부가세 직전」) — 확인 대기"
)
return {
"rows": rows,
"items": item_rows,
"summary": summary,
"contract_overhead_ratio_pct": _pct(contract_items_total, contract_direct),
"round": current,
"round_count": len(rounds),
"vat": rounds[current - 1] if current else None,
"notes": notes,
}