Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-14 06:11:51 +09:00
8 changed files with 938 additions and 8 deletions
+158
View File
@@ -0,0 +1,158 @@
"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (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