- 계약내역·계약 원가계산서(같은 엔진에 계약 직접비)를 읽기만 해 파생 — 계약 불변 - 제잡비 줄마다 계약잡비율 = 도급액 ÷ 계약 직접공사비 (구조로 읽음, 확인 대기) - 회차 목록 저장 · 기성(%) · 잔량 · 계약 수량 넘으면 알림 - 부가세: 직접입력 / 공급가액 / 재료비 / 재료비+산출경비 (설계 vat_base 같이 씀) - 원가계산서 조립·계약본 받기 조각을 떼어 기성이 같은 길로 받음 - 기성 탭 파일 · 사전 키(등록은 서브) · 시험 6건 · 전체 1717 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
159 lines
6.3 KiB
Python
159 lines
6.3 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 clean_settings, progress_sheet # noqa: E402
|
||
|
||
|
||
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 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
|