- 준공 별도 계산 규칙 미확인(35번 §3) — 규칙을 짓지 않고 기성 누계를 옮김 · 기성 한 장 불변 - 기성 회차가 없으면 준공금액 비움(0 원으로 안 채움) - 기성 계산 조각(progress_for)을 떼어 준공이 같은 길로 받음 - 기성 간접재료비 확인 대기 닫음 — 예정가격작성기준 제17조·제39조② 근거(브레인 판정) - 준공 탭 파일 · 사전 키(등록은 서브) · 시험 2건 · 전체 1730 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
238 lines
9.8 KiB
Python
238 lines
9.8 KiB
Python
"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (PLAN 12장 · 2026-09-14 배정).
|
||
|
||
⚠ 값이 맞는지는 못 잰다 — 기성 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것:
|
||
① ⭐ 제잡비 줄 = 금회직접공사비 × 계약잡비율(도급액 ÷ 계약 직접공사비)
|
||
— 줄마다 밑수×요율 다시 안 셈
|
||
② 계약 · 전회(앞 회차 금회의 합) · 금회 · 누계 네 칸과 각 비율 · 기성(%) · 잔량
|
||
③ 부가세 넷 — 직접입력 · 공급가액 · 재료비 · 재료비+산출경비
|
||
④ 계약 줄(입력) 불변 · 분리발주 폐기물은 제잡비 밖 · 틀린 칸 거름
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import sys
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402
|
||
from B09_Estimation.B09_Estimation_Progress import ( # noqa: E402
|
||
clean_settings,
|
||
completion_sheet,
|
||
progress_sheet,
|
||
)
|
||
|
||
|
||
def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict:
|
||
total = sum(unit) * int(qty)
|
||
return {
|
||
"item_no": item_no,
|
||
"is_group": False,
|
||
"in_bill": True,
|
||
"quantity": qty,
|
||
"contract_unit_material_krw": str(unit[0]),
|
||
"contract_unit_labor_krw": str(unit[1]),
|
||
"contract_unit_expense_krw": str(unit[2]),
|
||
"contract_amount_krw": str(total),
|
||
}
|
||
|
||
|
||
CONTRACT_ROWS = [
|
||
{"item_no": "1", "is_group": True, "in_bill": True},
|
||
_contract_row("1.1", "100", (1000, 2000, 500)), # 350,000
|
||
_contract_row("1.2", "10", (5000, 0, 0)), # 50,000 → 계약 직접공사비 400,000
|
||
]
|
||
|
||
|
||
def _cost(separate_waste: bool = False) -> tuple[SimpleNamespace, CostResult]:
|
||
result = CostResult()
|
||
for key, amount in (
|
||
("indirect_labor_cost", 20000),
|
||
("industrial_accident_insurance", 7000),
|
||
("waste_disposal", 5000),
|
||
("general_overhead", 24000),
|
||
("profit", 30000),
|
||
("vat", 48100),
|
||
):
|
||
result.lines.append(
|
||
CostLine(key, key, "", Decimal(amount), None, Decimal(0), Decimal(amount))
|
||
)
|
||
data = SimpleNamespace(waste_separate_order=separate_waste, indirect_material_krw=Decimal(0))
|
||
return data, result
|
||
|
||
|
||
SETTINGS = {
|
||
"rounds": [
|
||
{"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"},
|
||
{"quantities": {"1.1": "30"}, "vat_mode": "material"},
|
||
]
|
||
}
|
||
|
||
|
||
def _sheet(settings=SETTINGS, round_no=None, separate_waste=True) -> dict:
|
||
data, result = _cost(separate_waste)
|
||
return progress_sheet(CONTRACT_ROWS, data, result, clean_settings(settings)[0], round_no)
|
||
|
||
|
||
def test_제잡비는_금회직접공사비_곱하기_계약잡비율() -> None:
|
||
before = copy.deepcopy(CONTRACT_ROWS)
|
||
sheet = _sheet()
|
||
assert CONTRACT_ROWS == before # 계약 줄 불변
|
||
items = {item["key"]: item for item in sheet["items"]}
|
||
labor = items["indirect_labor_cost"]
|
||
# 도급액 20,000 ÷ 계약 직접 400,000 = 5% · 1회 150,000 → 7,500 · 2회 105,000 → 5,250
|
||
assert (labor["contract_ratio_pct"], labor["previous_krw"], labor["current_krw"]) == (
|
||
"5.000",
|
||
"7500",
|
||
"5250",
|
||
)
|
||
assert (labor["cumulative_krw"], labor["cumulative_pct"]) == ("12750", "63.750")
|
||
# 7,000 × 105,000 ÷ 400,000 = 1,837.5 → 회차마다 원 미만 절사
|
||
assert items["industrial_accident_insurance"]["current_krw"] == "1837"
|
||
assert "waste_disposal" not in items # 분리발주면 도급 밖
|
||
assert sheet["contract_overhead_ratio_pct"] == "20.250" # 81,000 ÷ 400,000
|
||
|
||
|
||
def test_네_칸과_기성율_잔량() -> None:
|
||
sheet = _sheet()
|
||
row = {r["item_no"]: r for r in sheet["rows"]}["1.1"]
|
||
assert (
|
||
row["progress_previous_quantity"],
|
||
row["progress_current_quantity"],
|
||
row["progress_cumulative_quantity"],
|
||
) == ("40", "30", "70")
|
||
assert (row["progress_cumulative_amount_krw"], row["progress_pct"]) == ("245000", "70.000")
|
||
assert row["progress_remaining_quantity"] == "30"
|
||
group = sheet["rows"][0]
|
||
assert group["progress_cumulative_amount_krw"] == "255000" # 245,000 + 1.2 의 10,000
|
||
summary = {s["key"]: s for s in sheet["summary"]}
|
||
assert (summary["direct"]["previous_krw"], summary["direct"]["current_krw"]) == (
|
||
"150000",
|
||
"105000",
|
||
)
|
||
assert summary["direct"]["cumulative_pct"] == "63.750"
|
||
|
||
|
||
def test_부가세_넷() -> None:
|
||
summary = {s["key"]: s for s in _sheet()["summary"]}
|
||
# 1회 공급가액 180,375 × 10% = 18,037 · 2회 재료비 30,000 × 10% = 3,000
|
||
assert (summary["supply"]["previous_krw"], summary["vat"]["previous_krw"]) == (
|
||
"180375",
|
||
"18037",
|
||
)
|
||
assert summary["vat"]["current_krw"] == "3000"
|
||
coop = {"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "forest_coop"}]}
|
||
vat = {s["key"]: s for s in _sheet(coop)["summary"]}["vat"]["current_krw"]
|
||
assert vat == "6000" # (재료비 40,000 + 산출경비 20,000) × 10%
|
||
manual = {
|
||
"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "manual", "vat_manual_krw": "12345"}]
|
||
}
|
||
assert {s["key"]: s for s in _sheet(manual)["summary"]}["vat"]["current_krw"] == "12345"
|
||
|
||
|
||
def test_회차를_고르면_그_앞이_전회() -> None:
|
||
first = _sheet(round_no=1)
|
||
row = {r["item_no"]: r for r in first["rows"]}["1.1"]
|
||
assert (row["progress_previous_quantity"], row["progress_current_quantity"]) == ("0", "40")
|
||
assert first["round"] == 1 and first["round_count"] == 2
|
||
|
||
|
||
def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_제잡비() -> None:
|
||
over = {"rounds": [{"quantities": {"1.2": "12"}, "vat_mode": "supply"}]}
|
||
row = {r["item_no"]: r for r in _sheet(over)["rows"]}["1.2"]
|
||
assert row["progress_remaining_quantity"] == "-2" and "넘음" in row["progress_note"]
|
||
items = {item["key"] for item in _sheet(separate_waste=False)["items"]}
|
||
assert "waste_disposal" in items
|
||
|
||
|
||
def _items(sheet: dict) -> dict:
|
||
return {item["key"]: item for item in sheet["items"]}
|
||
|
||
|
||
def _summary(sheet: dict) -> dict:
|
||
return {s["key"]: s for s in sheet["summary"]}
|
||
|
||
|
||
def test_계약잡비율_직접입력이_이기고_사유가_없으면_거른다() -> None:
|
||
settings = {
|
||
**SETTINGS,
|
||
"rate_overrides": {"indirect_labor_cost": {"rate_pct": "10", "reason": "계약서 제잡비율"}},
|
||
}
|
||
labor = _items(_sheet(settings))["indirect_labor_cost"]
|
||
assert (labor["contract_ratio_pct"], labor["default_ratio_pct"]) == ("10.000", "5.000")
|
||
assert labor["current_krw"] == "10500" and labor["override"]["reason"] == "계약서 제잡비율"
|
||
_, errors = clean_settings({"rate_overrides": {"profit": {"rate_pct": "7", "reason": " "}}})
|
||
assert len(errors) == 1
|
||
|
||
|
||
def test_단일율_곱과_벌어진_차이를_알린다() -> None:
|
||
# 1.2 × 0.1998 = 999원 → 줄별 49 + 17 + 59 + 74 = 199 · 단일율 20.25% 곱 202 → 3원
|
||
sheet = _sheet({"rounds": [{"quantities": {"1.2": "0.1998"}}]})
|
||
assert sheet["single_rate_gap_krw"]["current"] == "3"
|
||
assert any("단일율 곱과 금회 3원" in note for note in sheet["notes"])
|
||
|
||
|
||
def test_공급가액_절사와_총공사비_절사는_이윤에서() -> None:
|
||
first = {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"}
|
||
cut = _sheet({"rounds": [{**first, "supply_cut_krw": "100"}]})
|
||
# 공급가액 180,375 → 180,300 · 이윤 11,250 − 75 · 부가세 18,030
|
||
assert _items(cut)["profit"]["current_krw"] == "11175"
|
||
assert (_summary(cut)["supply"]["current_krw"], _summary(cut)["vat"]["current_krw"]) == (
|
||
"180300",
|
||
"18030",
|
||
)
|
||
total = _sheet({"rounds": [{**first, "total_cut_krw": "1000"}]})
|
||
# 총공사비 198,412 → 412 ÷ 1.1 = 375 을 이윤에서 → 공급가액 180,000 · 부가세 18,000
|
||
assert _summary(total)["total"]["current_krw"] == "198000"
|
||
assert _items(total)["profit"]["current_krw"] == "10875"
|
||
|
||
|
||
def test_이윤금액_직접입력과_사정_칸() -> None:
|
||
entry = {
|
||
"quantities": {"1.1": "40", "1.2": "2"},
|
||
"profit_manual_krw": "10000",
|
||
"assessed": {"1.1": "130000"},
|
||
}
|
||
sheet = _sheet({"rounds": [entry]})
|
||
assert _items(sheet)["profit"]["current_krw"] == "10000"
|
||
assert _summary(sheet)["supply"]["current_krw"] == "179125"
|
||
row = {r["item_no"]: r for r in sheet["rows"]}["1.1"]
|
||
assert row["progress_assessed_krw"] == "130000" # 칸만 — 금액에 안 섞임
|
||
assert row["progress_current_amount_krw"] == "140000"
|
||
|
||
|
||
def test_준공은_마지막_회차_누계를_옮기기만() -> None:
|
||
progress = _sheet()
|
||
before = copy.deepcopy(progress)
|
||
done = completion_sheet(progress)
|
||
assert progress == before # 기성 한 장 불변
|
||
row = {r["item_no"]: r for r in done["rows"]}["1.1"]
|
||
assert (row["contract_amount_krw"], row["completion_amount_krw"]) == ("350000", "245000")
|
||
lines = {line["key"]: line for line in done["lines"]}
|
||
summary = {s["key"]: s for s in progress["summary"]}
|
||
assert lines["total"]["completion_krw"] == summary["total"]["cumulative_krw"]
|
||
assert lines["indirect_labor_cost"]["completion_krw"] == "12750" and done["from_round"] == 2
|
||
|
||
|
||
def test_기성_회차가_없으면_준공금액을_비운다() -> None:
|
||
done = completion_sheet(_sheet({"rounds": []}))
|
||
assert all(line["completion_krw"] is None for line in done["lines"])
|
||
assert done["notes"] and done["from_round"] == 0
|
||
|
||
|
||
def test_틀린_칸은_거른다() -> None:
|
||
_, errors = clean_settings(
|
||
{
|
||
"rounds": [
|
||
{"quantities": {"1.1": "-1"}, "vat_mode": "supply"},
|
||
{"quantities": {}, "vat_mode": "manual", "vat_manual_krw": ""},
|
||
{"quantities": {}, "vat_mode": "없는방식"},
|
||
]
|
||
}
|
||
)
|
||
assert len(errors) == 3
|