From 704a10b648f89ac2c25006cdebd837a43772ac3c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 21:38:19 +0900 Subject: [PATCH] =?UTF-8?q?knowledge(=EC=A0=9C=EB=B9=84=EC=9C=A8):=20?= =?UTF-8?q?=EC=9A=94=EC=9C=A8=20=EC=98=86=EC=97=90=20=EB=B0=91=EC=88=98?= =?UTF-8?q?=C2=B7=EC=B2=98=EB=A6=AC=20=EA=B7=9C=EC=B9=99=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A4=EC=9D=8C=20+=20=EC=BD=94=EB=93=9C=EC=99=80=20?= =?UTF-8?q?=EB=A7=9E=EB=8C=80=EB=8A=94=20=EA=B1=B0=EC=9A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요율 숫자만으로는 그 데이터를 보고 금액을 되짚을 수 없었음. 밑수를 무엇으로 잡고 어떻게 자르는지가 코드에만 있어, 데이터를 갈아 끼운 사람이 규칙이 바뀐 줄 모름. - 빠져 있던 밑수 한 칸(건설기계대여대금 지급보증수수료)을 채움. - 처리 규칙을 데이터에 실음 — 자르는 법(줄 버림·관급자재대 천원 올림) · 안전관리비 A·B 두 식과 1.2·1.1 · 조달수수료 차감 없음 · 이윤 밑수 · 규모 구간 판정. - 거울 시험 6개 — 한쪽만 바뀌면 멈춤. 금액은 안 바뀜(1208 통과·실패 0). Co-Authored-By: Claude Opus 5 (1M context) --- .../data_cost_input_value/_manifest.json | 4 +- .../data_cost_input_value/rates_2026.json | 42 ++++- .../tester/test_b09_rate_rules_mirror.py | 149 ++++++++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_b09_rate_rules_mirror.py diff --git a/resources/data_cost_input_value/_manifest.json b/resources/data_cost_input_value/_manifest.json index 8c9ab0c9..71b93fe7 100644 --- a/resources/data_cost_input_value/_manifest.json +++ b/resources/data_cost_input_value/_manifest.json @@ -83,8 +83,8 @@ "file": "rates_2026.json", "dataset_id": "rates", "effective_date": "2026-04-13", - "sha256": "250b1e4d7fc07f0410a57a61a1f01e77a8514e8404670be6e9837e856b01e2c2", - "size_bytes": 37150 + "sha256": "ac0328df68c70b7c0643bf1b9b1ad69e6125bf2ae3cbfda9825519600fde5a87", + "size_bytes": 39623 } ], "deferred": [ diff --git a/resources/data_cost_input_value/rates_2026.json b/resources/data_cost_input_value/rates_2026.json index 51acb709..81cfd084 100644 --- a/resources/data_cost_input_value/rates_2026.json +++ b/resources/data_cost_input_value/rates_2026.json @@ -204,7 +204,8 @@ "work_type": "special_construction", "rate_percent": 1.78 } - ] + ], + "base_rule": "min_of_two_formulas" }, "rate_safety_base": { "embedded_in": "rate_safety_pct.brackets", @@ -1091,7 +1092,9 @@ "work_type": "other", "rate_percent": 0.1 } - ] + ], + "base": "direct_construction_cost", + "base_note": "코드(`B09_Estimation_Statutory.STATUTORY_ITEMS`)가 직접공사비로 계산하고 있는 것과 맞춘 표기." }, "rate_performance_guarantee_fee": { "base": "direct_construction_cost", @@ -1183,5 +1186,40 @@ "rate_percent": 10.0, "base": "construction_cost_before_vat" } + }, + "processing_rules": { + "note": "요율만으로는 금액을 되짚을 수 없다 — 밑수를 어떻게 만들고 어떻게 자르는지가 함께 있어야 한다. 여기 값은 코드가 실제로 쓰는 규칙과 같아야 하며, 어긋나면 거울 시험(resources/tester/test_b09_rate_rules_mirror.py)이 멈춘다.", + "rounding": { + "line_amount": "floor_won", + "owner_supplied_material_total": "ceil_thousand", + "why": "원가계산서 모든 줄은 원 단위 버림(ROUNDDOWN), 관급자재대만 천원 올림(ROUNDUP)." + }, + "safety_management_cost": { + "adopt": "min_of_A_and_B", + "formula_a": "(재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액", + "formula_b": "((재료비 + 직접노무비) × 요율 + 기초액) × 1.2", + "b_multiplier": 1.2, + "owner_supplied_vat_divisor": 1.1, + "why": "고용노동부 고시 제2025-11호. ⚠ A 가 항상 작지 않다 — 관급을 넣어 대상액이 5억·50억 경계를 넘으면 뒤집힌다(울진 A 채택 / 거창 B 채택). 관급금액이 부가세 포함이면 1.1 로 나눠 부가세 제외 기준으로 맞춘다." + }, + "owner_supplied_material": { + "total": "ceil_thousand(순자재대 + 조달수수료)", + "procurement_fee_deduction": false, + "safety_base_uses": "순자재대(수수료 제외)", + "why": "★법대로 — 조달수수료를 어디선가 빼는 처리는 없다. 실무 서류의 「관급자재대」는 이미 순자재대+수수료를 천원 올림한 값이라 그대로 순자재대 칸에 넣으면 안 된다." + }, + "profit": { + "base": "net_construction_cost + overhead - material_cost", + "adjustment": "설계자가 명시로 넣을 때만. 프로그램이 역산해 채우지 않는다." + }, + "performance_guarantee_fee": { + "base": "direct_construction_cost", + "multiplier": "공사기간(년)" + }, + "scale_bracket": { + "reference": "estimated_price_krw", + "fallback": "직접공사비를 씨앗으로 반복 수렴", + "why": "국가계약법 시행령 제7조 1호 — 추정가격은 관급자재 부분을 제외한 금액." + } } } diff --git a/resources/tester/test_b09_rate_rules_mirror.py b/resources/tester/test_b09_rate_rules_mirror.py new file mode 100644 index 00000000..b7d7961a --- /dev/null +++ b/resources/tester/test_b09_rate_rules_mirror.py @@ -0,0 +1,149 @@ +"""제비율 데이터의 **밑수·처리 규칙**이 코드와 같은가 — 거울 시험 (2026-09-09). + +요율 숫자만 데이터에 두면 **그 데이터만 보고는 금액을 되짚을 수 없다.** 밑수를 무엇으로 +잡고 어떻게 자르는지가 코드에만 있으면, 데이터를 갈아 끼운 사람이 규칙이 바뀐 줄 모른다. + +그래서 `rates_2026.json` 에 밑수(`base`)와 `processing_rules` 를 함께 싣고, **여기서 코드와 +맞대 본다.** 한쪽만 바뀌면 이 시험이 멈춘다. + +⚠ 겨누는 것 넷 + ① 비목마다 데이터가 적은 밑수와 **코드가 실제로 쓰는 밑수**가 같은가 + ② 안전관리비 두 식(A·B)의 배수 1.2 · 관급 부가세 제수 1.1 이 데이터와 같은가 + ③ 자르는 법 — 줄은 원 단위 버림, 관급자재대만 천원 올림 + ④ 밑수 칸이 **빠진 요율이 없는가** (빠지면 그 비목만 데이터로 못 되짚음) +""" + +from __future__ import annotations + +import json +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import ( # noqa: E402 + CostInput, + ceil_thousand, + floor_won, +) +from B09_Estimation.B09_Estimation_Rates import load_rate_dataset # noqa: E402 +from B09_Estimation.B09_Estimation_Statutory import ( # noqa: E402 + _SAFETY_B_MULTIPLIER, + _VAT_DIVISOR, + STATUTORY_ITEMS, + ExpenseContext, + _base_and_rate, +) + +RATES = json.loads( + (ROOT / "resources" / "data_cost_input_value" / "rates_2026.json").read_text(encoding="utf-8") +) + +#: 밑수 이름 → 그 밑수를 만드는 식. 값은 서로 겹치지 않는 소수를 써서 **합이 유일**하게 한다. +MATERIAL = Decimal(1_000_003) +DIRECT_LABOR = Decimal(10_000_019) +TOTAL_LABOR = Decimal(100_000_007) +DIRECT_CONSTRUCTION = Decimal(1_000_000_007) + +_CONTEXT = ExpenseContext( + material_cost=MATERIAL, + direct_labor_cost=DIRECT_LABOR, + total_labor_cost=TOTAL_LABOR, + direct_construction_cost=DIRECT_CONSTRUCTION, + scale_reference=DIRECT_CONSTRUCTION, +) + +_BASE_VALUE = { + "total_labor_cost": TOTAL_LABOR, + "direct_labor_cost": DIRECT_LABOR, + "direct_construction_cost": DIRECT_CONSTRUCTION, + "material_cost_plus_total_labor_cost": MATERIAL + TOTAL_LABOR, +} + + +def _dataset(): + return load_rate_dataset() + + +def test_비목마다_데이터와_코드의_밑수가_같다() -> None: + """① 데이터가 적은 밑수로 실제 금액이 서는가 — 이름만 맞고 계산이 다르면 헛것이다.""" + dataset = _dataset() + data = CostInput( + direct_material_krw=MATERIAL, + direct_labor_krw=DIRECT_LABOR, + direct_expense_krw=Decimal(0), + ) + checked = 0 + for item in STATUTORY_ITEMS: + if item.key == "performance_guarantee_fee": + continue # 밑수에 공사기간(년)을 곱하는 자리 — 아래 시험에서 따로 본다 + declared = RATES["variables"][item.rate_variable].get("base") + if declared not in _BASE_VALUE: + continue # 밑수가 식으로 갈리는 비목(안전관리비·보증수수료)은 아래 시험에서 본다 + base, _percent, _note = _base_and_rate(dataset, data, _CONTEXT, item) + assert base == _BASE_VALUE[declared], f"{item.key}: 데이터 {declared} 와 코드가 다름" + checked += 1 + assert checked >= 8 + + +def test_공사이행보증은_기간을_곱한다() -> None: + """데이터가 `multiplier: 공사기간(년)` 이라 적은 자리 — 곱이 실제로 붙는가.""" + dataset = _dataset() + rules = RATES["processing_rules"]["performance_guarantee_fee"] + assert rules["base"] == "direct_construction_cost" and "공사기간" in rules["multiplier"] + item = next(i for i in STATUTORY_ITEMS if i.key == "performance_guarantee_fee") + one_year = CostInput( + direct_material_krw=MATERIAL, + direct_labor_krw=DIRECT_LABOR, + direct_expense_krw=Decimal(0), + duration_days=365, + ) + two_years = CostInput( + direct_material_krw=MATERIAL, + direct_labor_krw=DIRECT_LABOR, + direct_expense_krw=Decimal(0), + duration_days=730, + ) + base_one, _, _ = _base_and_rate(dataset, one_year, _CONTEXT, item) + base_two, _, _ = _base_and_rate(dataset, two_years, _CONTEXT, item) + assert base_one is not None and base_two is not None + assert base_two == base_one * 2 + + +def test_안전관리비_두_수가_데이터와_같다() -> None: + """② 1.2(B 배수)·1.1(관급 부가세 제수)이 코드에만 있으면 데이터로 못 되짚는다.""" + safety = RATES["processing_rules"]["safety_management_cost"] + assert Decimal(str(safety["b_multiplier"])) == _SAFETY_B_MULTIPLIER + assert Decimal(str(safety["owner_supplied_vat_divisor"])) == _VAT_DIVISOR + assert safety["adopt"] == "min_of_A_and_B" + assert RATES["variables"]["rate_safety_pct"]["base_rule"] == "min_of_two_formulas" + + +def test_자르는_법이_데이터와_같다() -> None: + """③ 줄은 버림, 관급자재대만 천원 올림.""" + rounding = RATES["processing_rules"]["rounding"] + assert rounding["line_amount"] == "floor_won" + assert rounding["owner_supplied_material_total"] == "ceil_thousand" + assert floor_won(Decimal("1999.9")) == Decimal(1999) + assert ceil_thousand(Decimal(1)) == Decimal(1000) + + +def test_밑수_칸이_빠진_요율이_없다() -> None: + """④ 하나라도 비면 그 비목만 데이터로 못 되짚는다.""" + without_base = [ + name + for name, value in RATES["variables"].items() + if isinstance(value, dict) + and "base" not in value + and not {"base_without_owner_supplied_material", "embedded_in"} & set(value) + ] + assert without_base == [], without_base + + +def test_조달수수료를_어디서도_빼지_않는다() -> None: + """★법대로 — 차감 처리는 없다. 데이터에도 그 사실을 못 박는다.""" + owner = RATES["processing_rules"]["owner_supplied_material"] + assert owner["procurement_fee_deduction"] is False + assert "순자재대" in owner["safety_base_uses"]