From 50871e60b9c1c399e715bfd8b3999b5913ae3b5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:01:08 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=97=94=EC=A7=84=20=EC=99=84=EC=84=B1=20=E2=80=94?= =?UTF-8?q?=20=EB=B2=95=EC=A0=95=EA=B2=BD=EB=B9=84=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=C2=B7=EC=9D=B4=EC=A4=91=EA=B3=84=EC=83=81=20=EA=B0=90=EC=8B=9C?= =?UTF-8?q?=C2=B7=EC=9E=AC=ED=98=84=20=EA=B2=80=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `B09_Estimation_Statutory.py` 신설(법정경비 14비목) — 700줄 제한 대비 분리. **비목 목록을 코드에 안 박음**: 그 해 요율 데이터에 변수가 있는 것만 유효(8-13·8-14). - `B09_Estimation_Guards.py` 신설 — 이중계상 거울 테스트 3종을 함수로 둠(8-7 ㉠㉡㉢). 할증 두 번 · 무대 줄 단가 · 배합 두 번 쪼개기를 **수치로 잡아 멈춤**. - 엔진: 이윤 3줄(조정 전·조정액·조정 후) · 관급자재대 천원 올림 · 폐기물처리비 실비 슬롯 · `formula_text`(줄마다 제 산식 — 실무 원문의 A식 복사 오류를 안 따라감) · `proposed_profit_adjustment`(필요액을 보여만 주고 적용은 명시로, ★법대로 8-10). - 요율 로더에 `load_rate_dataset_from_path` 추가 — 옛 연도 재현 검산 전용, 지문이 없어 정본이 아님이 결과에 드러남. - 검산 고정값 두 벌(`tmp/tests`, git 밖): · 2024 요율 → 울진 공통 금액 사슬 **전건 재현**(안전 16,586,996 · 이윤 108,109,955 · 총원가 1,029,117,273 · 총공사비 1,201,879,000) · 현행 요율 → 안전 18,586,091. **같은 입력·같은 코드, 요율만 교체** — 연도 교체 구조 증명 · 거창 A/B min **양방향**(울진 A 채택 · 거창 B 채택) - 자체검증: `pytest tmp/tests/ -q` **68 passed** · `ruff check` 통과 · 파일 최대 441줄. - 잠정 반영(TODO 주석 + 계획서 항목번호): 환경보전비 임도 요율 0.9 % 잠정 · 폐기물처리비 자리 미확정 · 조달수수료 차감은 기본 꺼짐(옛 서류 재현용 옵션). Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Engine_Cost.py | 670 ++++++------------- B09_Estimation/B09_Estimation_Guards.py | 111 +++ B09_Estimation/B09_Estimation_Rates.py | 18 + B09_Estimation/B09_Estimation_Statutory.py | 406 +++++++++++ 4 files changed, 751 insertions(+), 454 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Guards.py create mode 100644 B09_Estimation/B09_Estimation_Statutory.py diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index e5ca8445..ae5fde2d 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -2,86 +2,65 @@ 순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어 **공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5). +법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한). -지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만) +지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인) 1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다. 2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / … - 하나로 뭉치면 틀린다. - 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호). - ⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다 - (실증: 울진 A 채택 / 거창 B 채택). - 4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는 - 관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다. - 5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등). - 6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다. + 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다. + 4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.** + 5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10). + 6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기. + 7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.** """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from B09_Estimation.B09_Estimation_Rates import ( RateDataset, - RateLookupError, - base_amount, flat_rate, load_rate_dataset, - pension_rate_percent, + load_rate_dataset_from_path, rate_percent, select_bracket, ) +from B09_Estimation.B09_Estimation_Statutory import ( + ExpenseContext, + available_items, + statutory_expenses, +) _ZERO = Decimal(0) _HUNDRED = Decimal(100) -_VAT_DIVISOR = Decimal("1.1") -#: 기본으로 켜는 법정경비 비목. 공사마다 다르므로 `CostInput.enabled_items` 로 갈아끼운다. -DEFAULT_STATUTORY_ITEMS: tuple[str, ...] = ( - "industrial_accident_insurance", - "employment_insurance", - "health_insurance", - "long_term_care_insurance", - "national_pension", - "safety_management_cost", - "other_expense", - "environment_preservation", - "retirement_mutual_aid", -) - -#: 켤 수 있으나 기본은 끄는 비목 (공사·발주처에 따라 등장). -OPTIONAL_STATUTORY_ITEMS: tuple[str, ...] = ( - "wage_claim_contribution", - "asbestos_contribution", - "equipment_payment_guarantee", - "subcontract_payment_guarantee", - "performance_guarantee_fee", -) +#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**. +#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다. +DEFAULT_ITEMS = "ALL_AVAILABLE" def floor_won(value: Decimal) -> Decimal: - """원 단위 버림 — 원가계산서 모든 줄의 기본 처리.""" + """원 단위 버림 — 원가계산서 모든 줄의 기본 처리 (PLAN 8-9 규칙 1).""" return value.quantize(Decimal(1), rounding=ROUND_FLOOR) def ceil_thousand(value: Decimal) -> Decimal: - """천원 올림 — 관급자재대 표기.""" + """천원 올림 — 관급자재대 표기 (PLAN 8-9 규칙 7).""" return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000 @dataclass class CostInput: - """원가계산 입력. - - 금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다. - """ + """원가계산 입력. 금액은 전부 원 단위 `Decimal`.""" direct_material_krw: Decimal direct_labor_krw: Decimal direct_expense_krw: Decimal indirect_material_krw: Decimal = _ZERO - #: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다. + #: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다. work_type_indirect_labor: str = "civil" work_type_safety: str = "civil" duration_days: int = 183 @@ -92,31 +71,44 @@ class CostInput: procurement_fee_krw: Decimal = _ZERO include_fee_in_owner_material_total: bool = True - #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다. + #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액. owner_supplied_for_safety_krw: Decimal | None = None - #: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준). + #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준). owner_supplied_includes_vat: bool = True + #: ★ 법대로(8-10) — 조달수수료 차감은 **규정 문구가 아니다.** 기본 꺼짐. + #: 옛 서류(울진 2024) 재현 검산에만 켠다. + deduct_procurement_fee_for_safety: bool = False - #: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피). + #: 규모 구간 판정 기준액. None 이면 직접공사비를 쓴다(추정가격 순환 회피). estimated_price_krw: Decimal | None = None - #: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다. + #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. profit_adjustment_krw: Decimal = _ZERO - #: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값). + #: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히. + #: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」). + #: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐. + waste_disposal_krw: Decimal = _ZERO + + #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). + #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. + #: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다. environment_work_type: str = "civil_road" #: 건설기계대여대금 지급보증 공종. equipment_guarantee_work_type: str = "civil_general" - enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS + #: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」. + enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS - #: 요율 데이터 파일명. 연도를 갈아끼우는 자리. + #: 요율 데이터 파일명. **연도를 갈아끼우는 자리.** rate_file_name: str = "rates_2026.json" + #: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선. + rate_file_path: str | None = None @dataclass class CostLine: - """원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다.""" + """원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다.""" key: str name: str @@ -127,6 +119,21 @@ class CostLine: amount_krw: Decimal note: str = "" + @property + def formula_text(self) -> str: + """화면 `산출근거` 칸 문구 — 줄마다 **제 산식**을 적는다. + + 실무 원문은 안전관리비 A 식을 B 줄에 복사해 둔 오류가 있었다(PLAN 8-13). + """ + if self.rate_percent is None: + return self.base_label + text = f"{self.base_label} × {self.rate_percent}%" + if self.flat_amount_krw: + text += f" + {self.flat_amount_krw:,.0f}" + if self.key == "safety_management_cost_b": + text = f"({text}) × 1.2" + return text + @dataclass class CostResult: @@ -144,336 +151,57 @@ class CostResult: def amount(self, key: str) -> Decimal: return self.line(key).amount_krw - -def _line( - result: CostResult, - *, - key: str, - name: str, - base_label: str, - base: Decimal, - percent: Decimal | None = None, - flat: Decimal = _ZERO, - amount: Decimal | None = None, - note: str = "", -) -> Decimal: - """줄 하나를 계산해 결과에 담고 금액을 돌려준다. 금액은 항상 원 단위 버림.""" - if amount is None: - computed = base * (percent or _ZERO) / _HUNDRED + flat - amount = floor_won(computed) - result.lines.append( - CostLine( - key=key, - name=name, - base_label=base_label, - base_amount_krw=base, - rate_percent=percent, - flat_amount_krw=flat, - amount_krw=amount, - note=note, - ) - ) - return amount + def has(self, key: str) -> bool: + return any(item.key == key for item in self.lines) -def _safety_management_cost( - result: CostResult, - dataset: RateDataset, - data: CostInput, - *, - material_cost: Decimal, -) -> Decimal: - """산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다. - - A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액 - B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2 - 두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. - """ - variable = dataset.variable("rate_safety_pct") - brackets = variable["brackets"] - - owner_supplied = data.owner_supplied_for_safety_krw - if owner_supplied is None: - owner_supplied = data.owner_supplied_material_krw - if data.owner_supplied_includes_vat: - owner_supplied = owner_supplied / _VAT_DIVISOR - - base_with = material_cost + data.direct_labor_krw + owner_supplied - base_without = material_cost + data.direct_labor_krw - - def evaluate( - base: Decimal, *, multiplier: Decimal, label: str - ) -> tuple[Decimal, Decimal, Decimal]: - row = select_bracket( - brackets, - amount_field="target_amount_bracket", - amount=base, - equals={"work_type": data.work_type_safety}, - label=label, - ) - percent = rate_percent(row, label=label) - flat = base_amount(row) - amount = floor_won((base * percent / _HUNDRED + flat) * multiplier) - return amount, percent, flat - - amount_a, percent_a, flat_a = evaluate( - base_with, multiplier=Decimal(1), label="안전관리비 A(관급 포함)" - ) - amount_b, percent_b, flat_b = evaluate( - base_without, multiplier=Decimal("1.2"), label="안전관리비 B(관급 제외 × 1.2)" - ) - - adopted = "A" if amount_a <= amount_b else "B" - - _line( - result, - key="safety_management_cost_a", - name="산업안전보건관리비 A(관급 포함)", - base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)", - base=base_with, - percent=percent_a, - flat=flat_a, - amount=amount_a, - note="채택" if adopted == "A" else "미채택", - ) - _line( - result, - key="safety_management_cost_b", - name="산업안전보건관리비 B(관급 제외 × 1.2)", - base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배", - base=base_without, - percent=percent_b, - flat=flat_b, - amount=amount_b, - note="채택" if adopted == "B" else "미채택", - ) - - adopted_amount = min(amount_a, amount_b) - return _line( - result, - key="safety_management_cost", - name="산업안전보건관리비", - base_label=f"A·B 중 작은 금액 (채택 = {adopted})", - base=base_with if adopted == "A" else base_without, - percent=percent_a if adopted == "A" else percent_b, - amount=adopted_amount, - note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액", - ) +def _load_dataset(data: CostInput) -> RateDataset: + if data.rate_file_path: + return load_rate_dataset_from_path(data.rate_file_path) + return load_rate_dataset(data.rate_file_name) -def _statutory_expenses( - result: CostResult, - dataset: RateDataset, - data: CostInput, - *, - material_cost: Decimal, - total_labor_cost: Decimal, - direct_construction_cost: Decimal, -) -> Decimal: - """법정경비 묶음. `enabled_items` 에 든 줄만 계산한다.""" - enabled = set(data.enabled_items) - total = _ZERO +def _emitter(result: CostResult): + """줄 하나를 계산해 결과에 담고 금액을 돌려주는 함수를 만든다.""" - if "industrial_accident_insurance" in enabled: - total += _line( - result, - key="industrial_accident_insurance", - name="산재보험료", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=flat_rate(dataset, "rate_sanjae"), - ) - - if "employment_insurance" in enabled: - variable = dataset.variable("rate_goyong") - # 고용보험료는 등급(1~7)이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 - # 숫자 구간에 안 걸리므로 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 1.01 %). - row = select_bracket( - variable["brackets"], - amount_field="estimated_amount_bracket", - amount=_scale_reference(data, direct_construction_cost), - residual_label="below_official_threshold", - label="고용보험료", - ) - total += _line( - result, - key="employment_insurance", - name="고용보험료", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=rate_percent(row, label="고용보험료"), - ) - - health_amount = _ZERO - if "health_insurance" in enabled: - health_amount = _line( - result, - key="health_insurance", - name="국민건강보험료", - base_label="직접노무비", - base=data.direct_labor_krw, - percent=flat_rate(dataset, "rate_health"), - ) - total += health_amount - - if "long_term_care_insurance" in enabled: - if "health_insurance" not in enabled: - raise RateLookupError( - "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" + def emit( + *, + key: str, + name: str, + base_label: str, + base: Decimal, + percent: Decimal | None = None, + flat: Decimal = _ZERO, + raw: Decimal | None = None, + amount: Decimal | None = None, + note: str = "", + ) -> Decimal: + if amount is None: + computed = raw if raw is not None else base * (percent or _ZERO) / _HUNDRED + flat + amount = floor_won(computed) + result.lines.append( + CostLine( + key=key, + name=name, + base_label=base_label, + base_amount_krw=base, + rate_percent=percent, + flat_amount_krw=flat, + amount_krw=amount, + note=note, ) - total += _line( - result, - key="long_term_care_insurance", - name="노인장기요양보험료", - base_label="국민건강보험료", - base=health_amount, - percent=flat_rate(dataset, "rate_care"), ) + return amount - if "national_pension" in enabled: - total += _line( - result, - key="national_pension", - name="국민연금보험료", - base_label="직접노무비", - base=data.direct_labor_krw, - percent=pension_rate_percent(dataset, data.pension_year), - ) - - if "safety_management_cost" in enabled: - total += _safety_management_cost(result, dataset, data, material_cost=material_cost) - - if "other_expense" in enabled: - variable = dataset.variable("rate_other_expense") - row = select_bracket( - variable["brackets"], - amount_field="direct_cost_bracket", - amount=direct_construction_cost, - duration_days=data.duration_days, - equals={"work_type": data.work_type_indirect_labor}, - label="기타경비", - ) - total += _line( - result, - key="other_expense", - name="기타경비", - base_label="재료비+노무비(직접+간접)", - base=material_cost + total_labor_cost, - percent=rate_percent(row, label="기타경비"), - ) - - if "environment_preservation" in enabled: - variable = dataset.variable("rate_environment") - threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0))) - if _scale_reference(data, direct_construction_cost) >= threshold: - row = next( - ( - r - for r in variable["all_work_types"] - if r.get("work_type") == data.environment_work_type - ), - None, - ) - if row is None: - raise RateLookupError( - f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}" - ) - total += _line( - result, - key="environment_preservation", - name="환경보전비", - base_label="직접공사비", - base=direct_construction_cost, - percent=rate_percent(row, label="환경보전비"), - note=( - "⚠ 임도 공종 채택값 미확정 — 지식DB " - "`rate_environment.forest_road_selection_status: pending`" - ), - ) - - if "retirement_mutual_aid" in enabled: - variable = dataset.variable("rate_retirement_mutual_aid") - threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0))) - if _scale_reference(data, direct_construction_cost) >= threshold: - total += _line( - result, - key="retirement_mutual_aid", - name="퇴직공제부금비", - base_label="직접노무비", - base=data.direct_labor_krw, - percent=Decimal(str(variable["rate_percent"])), - ) - - if "wage_claim_contribution" in enabled: - total += _line( - result, - key="wage_claim_contribution", - name="임금채권보장기금 부담금", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=flat_rate(dataset, "rate_wage_claim_contribution"), - ) - - if "asbestos_contribution" in enabled: - total += _line( - result, - key="asbestos_contribution", - name="석면피해구제 분담금", - base_label="노무비(직접+간접)", - base=total_labor_cost, - percent=flat_rate(dataset, "rate_asbestos_contribution"), - ) - - if "equipment_payment_guarantee" in enabled: - variable = dataset.variable("rate_equipment_payment_guarantee") - row = next( - ( - r - for r in variable["general_construction"] + variable["specialty_construction"] - if r.get("work_type") == data.equipment_guarantee_work_type - ), - None, - ) - if row is None: - raise RateLookupError( - "건설기계대여대금 지급보증: 공종을 못 찾았습니다 — " - f"{data.equipment_guarantee_work_type}" - ) - total += _line( - result, - key="equipment_payment_guarantee", - name="건설기계대여대금 지급보증수수료", - base_label="직접공사비", - base=direct_construction_cost, - percent=rate_percent(row, label="건설기계대여대금 지급보증수수료"), - ) - - if "subcontract_payment_guarantee" in enabled: - variable = dataset.variable("rate_subcontract_payment_guarantee") - row = select_bracket( - variable["brackets"], - amount_field="estimated_price_bracket", - amount=_scale_reference(data, direct_construction_cost), - label="하도급대금 지급보증수수료", - ) - total += _line( - result, - key="subcontract_payment_guarantee", - name="하도급대금 지급보증수수료", - base_label="직접공사비", - base=direct_construction_cost, - percent=rate_percent(row, label="하도급대금 지급보증수수료"), - ) - - return total + return emit def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal: """규모 구간 판정 기준액. - 조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에 - 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을, - 없으면 **직접공사비**를 기준으로 쓴다. + 조달청 제비율표는 「추정가격」으로 구간을 나누지만 추정가격은 원가 계산 결과에 + 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 명시하면 그 값을, 없으면 + **직접공사비**를 쓴다. """ if data.estimated_price_krw is not None: return data.estimated_price_krw @@ -482,12 +210,15 @@ def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Deci def calculate_cost(data: CostInput) -> CostResult: """공사원가계산서 한 장을 계산한다.""" - dataset = load_rate_dataset(data.rate_file_name) + dataset = _load_dataset(data) result = CostResult(rate_version=dataset.version_stamp) + emit = _emitter(result) + + if data.enabled_items == DEFAULT_ITEMS: + data = replace(data, enabled_items=available_items(dataset)) material_cost = data.direct_material_krw + data.indirect_material_krw - _line( - result, + emit( key="material_cost", name="재료비", base_label="직접재료비+간접재료비", @@ -497,7 +228,7 @@ def calculate_cost(data: CostInput) -> CostResult: direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw - indirect_labor_row = select_bracket( + indirect_row = select_bracket( dataset.variable("rate_indirect_labor")["brackets"], amount_field="direct_cost_bracket", amount=direct_construction_cost, @@ -505,17 +236,15 @@ def calculate_cost(data: CostInput) -> CostResult: equals={"work_type": data.work_type_indirect_labor}, label="간접노무비", ) - indirect_labor = _line( - result, + indirect_labor = emit( key="indirect_labor_cost", name="간접노무비", base_label="직접노무비", base=data.direct_labor_krw, - percent=rate_percent(indirect_labor_row, label="간접노무비"), + percent=rate_percent(indirect_row, label="간접노무비"), ) total_labor_cost = data.direct_labor_krw + indirect_labor - _line( - result, + emit( key="labor_cost", name="노무비", base_label="직접노무비+간접노무비", @@ -523,17 +252,17 @@ def calculate_cost(data: CostInput) -> CostResult: amount=total_labor_cost, ) - statutory = _statutory_expenses( - result, - dataset, - data, + ctx = ExpenseContext( material_cost=material_cost, + direct_labor_cost=data.direct_labor_krw, total_labor_cost=total_labor_cost, direct_construction_cost=direct_construction_cost, + scale_reference=_scale_reference(data, direct_construction_cost), ) + statutory = statutory_expenses(dataset, data, ctx, result, emit) + expense_total = data.direct_expense_krw + statutory - _line( - result, + emit( key="expense", name="경비", base_label="직접경비(산출경비)+법정경비", @@ -542,8 +271,7 @@ def calculate_cost(data: CostInput) -> CostResult: ) net_construction_cost = material_cost + total_labor_cost + expense_total - _line( - result, + emit( key="net_construction_cost", name="순공사원가", base_label="재료비+노무비+경비", @@ -551,15 +279,13 @@ def calculate_cost(data: CostInput) -> CostResult: amount=net_construction_cost, ) - scale = _scale_reference(data, direct_construction_cost) overhead_row = select_bracket( dataset.variable("rate_overhead")["civil_landscape_industrial"], amount_field="estimated_price_bracket", - amount=scale, + amount=ctx.scale_reference, label="일반관리비", ) - overhead = _line( - result, + overhead = emit( key="general_overhead", name="일반관리비", base_label="순공사원가", @@ -567,55 +293,17 @@ def calculate_cost(data: CostInput) -> CostResult: percent=rate_percent(overhead_row, label="일반관리비"), ) - profit_row = select_bracket( - dataset.variable("rate_profit")["brackets"], - amount_field="estimated_price_bracket", - amount=scale, - label="이윤", - ) - profit_base = total_labor_cost + expense_total + overhead - profit_before = floor_won(profit_base * rate_percent(profit_row, label="이윤") / _HUNDRED) - _line( - result, - key="profit_before_adjustment", - name="이윤(조정 전)", - base_label="노무비+경비+일반관리비 (재료비 제외)", - base=profit_base, - percent=rate_percent(profit_row, label="이윤"), - amount=profit_before, - ) - if data.profit_adjustment_krw: - _line( - result, - key="profit_adjustment", - name="이윤 조정액", - base_label="설계자 명시 입력", - base=_ZERO, - amount=-data.profit_adjustment_krw, - note="도급공사비 끝수 맞춤 — 법정 항목 아님", - ) - profit = profit_before - data.profit_adjustment_krw - _line( - result, - key="profit", - name="이윤", - base_label="조정 전 이윤 − 조정액", - base=profit_base, - amount=profit, - ) + profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead) total_cost = net_construction_cost + overhead + profit - _line( - result, + emit( key="total_cost", name="총원가", base_label="순공사원가+일반관리비+이윤", base=total_cost, amount=total_cost, ) - - vat = _line( - result, + vat = emit( key="vat", name="부가가치세", base_label="총원가", @@ -623,8 +311,7 @@ def calculate_cost(data: CostInput) -> CostResult: percent=flat_rate(dataset, "rate_vat"), ) contract_amount = total_cost + vat - _line( - result, + emit( key="contract_amount", name="도급공사비", base_label="총원가+부가가치세", @@ -632,32 +319,14 @@ def calculate_cost(data: CostInput) -> CostResult: amount=contract_amount, ) - owner_total = _ZERO - if data.owner_supplied_material_krw: - raw = data.owner_supplied_material_krw - if data.include_fee_in_owner_material_total: - raw = raw + data.procurement_fee_krw - owner_total = ceil_thousand(raw) - _line( - result, - key="owner_supplied_material_total", - name="관급자재대", - base_label=( - "순자재대+조달수수료 (천원 올림)" - if data.include_fee_in_owner_material_total - else "순자재대 (천원 올림)" - ), - base=raw, - amount=owner_total, - note="총원가 밖 별도 표기", - ) + owner_total = _owner_supplied_line(data, emit) + waste = _waste_line(data, emit) - grand_total = contract_amount + owner_total - _line( - result, + grand_total = contract_amount + owner_total + waste + emit( key="grand_total", name="총공사비", - base_label="도급공사비+관급자재대", + base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""), base=grand_total, amount=grand_total, ) @@ -674,6 +343,99 @@ def calculate_cost(data: CostInput) -> CostResult: "vat": vat, "contract_amount": contract_amount, "owner_supplied_material_total": owner_total, + "waste_disposal": waste, "grand_total": grand_total, } return result + + +def _profit_lines( + dataset: RateDataset, + data: CostInput, + emit, + ctx: ExpenseContext, + net_construction_cost: Decimal, + overhead: Decimal, +) -> Decimal: + """이윤 — 조정 전 / 조정액 / 조정 후 세 줄. 조정은 **명시 입력일 때만**.""" + profit_row = select_bracket( + dataset.variable("rate_profit")["brackets"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + label="이윤", + ) + percent = rate_percent(profit_row, label="이윤") + profit_base = net_construction_cost + overhead - ctx.material_cost + before = emit( + key="profit_before_adjustment", + name="이윤(조정 전)", + base_label="(순공사원가+일반관리비) − 재료비", + base=profit_base, + percent=percent, + ) + if data.profit_adjustment_krw: + emit( + key="profit_adjustment", + name="이윤 조정액", + base_label="설계자 명시 입력", + base=_ZERO, + amount=-data.profit_adjustment_krw, + note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)", + ) + profit = before - data.profit_adjustment_krw + emit( + key="profit", + name="이윤", + base_label="조정 전 이윤 − 조정액", + base=profit_base, + amount=profit, + ) + return profit + + +def _owner_supplied_line(data: CostInput, emit) -> Decimal: + """관급자재대 — 총원가 밖 별도 표기, 천원 올림.""" + if not data.owner_supplied_material_krw: + return _ZERO + raw = data.owner_supplied_material_krw + if data.include_fee_in_owner_material_total: + raw = raw + data.procurement_fee_krw + return emit( + key="owner_supplied_material_total", + name="관급자재대", + base_label=( + "순자재대+조달수수료 (천원 올림)" + if data.include_fee_in_owner_material_total + else "순자재대 (천원 올림)" + ), + base=raw, + amount=ceil_thousand(raw), + note="총원가 밖 별도 표기", + ) + + +def _waste_line(data: CostInput, emit) -> Decimal: + """폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다.""" + if not data.waste_disposal_krw: + return _ZERO + return emit( + key="waste_disposal", + name="폐기물처리비", + base_label="설계자 입력(실비)", + base=data.waste_disposal_krw, + amount=floor_won(data.waste_disposal_krw), + note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)", + ) + + +def proposed_profit_adjustment(result: CostResult, target_contract_amount: Decimal) -> Decimal: + """목표 도급공사비를 맞추려면 이윤을 얼마 깎아야 하는지 **보여만 준다**. + + ★ 법대로(8-10) — 프로그램이 스스로 적용하지 않는다. 설계자가 이 값을 보고 + `CostInput.profit_adjustment_krw` 에 명시로 넣어야 반영된다. + """ + gap = result.totals["contract_amount"] - target_contract_amount + if gap <= 0: + return _ZERO + # 이윤 1원을 깎으면 총원가 1원 + 부가세 0.1원이 줄어든다. + return floor_won(gap / Decimal("1.1")) diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py new file mode 100644 index 00000000..9d69ffbd --- /dev/null +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -0,0 +1,111 @@ +"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종). + +**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안, +「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서 +위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는 +검사**를 두어, 규칙을 어기면 계산이 멈추게 한다. + +세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢) + ㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다 + (품셈 1-3-1 「할증 중복 적용 금지」). + ㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미 + 포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6). + ㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래 + 분해는 일위대가에서 한 번만 한다. +""" + +from __future__ import annotations + +from decimal import Decimal + +_TOLERANCE = Decimal("0.5") + + +class DoubleCountError(AssertionError): + """이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다.""" + + +def check_surcharge_once( + *, + material_summary_total: Decimal, + unit_price_material_total: Decimal, + surcharge_rate_percent: Decimal, + label: str = "자재", +) -> None: + """㉠ 할증이 두 번 붙지 않았는가. + + `material_summary_total` = 자재총괄의 **할증 포함** 합계. + `unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계. + 둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다. + """ + if unit_price_material_total <= 0: + return + expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100)) + if material_summary_total > expected + _TOLERANCE: + raise DoubleCountError( + f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > " + f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) " + f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)." + ) + + +def check_free_haul_not_priced( + *, + haul_rows: list[dict], + equipment_field: str = "equipment", + unit_price_field: str = "unit_price_krw", + free_haul_equipment: str = "free_haul", +) -> None: + """㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가. + + 줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재). + 금지되는 것은 **단가를 붙이는 것**이다. + """ + for row in haul_rows: + if row.get(equipment_field) != free_haul_equipment: + continue + price = Decimal(str(row.get(unit_price_field) or 0)) + if price != 0: + raise DoubleCountError( + f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — " + "소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)." + ) + + +def check_haul_volume_within_cut( + *, + haul_volume_total_m3: Decimal, + total_cut_volume_m3: Decimal, +) -> None: + """㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가. + + 무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다. + """ + if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE: + raise DoubleCountError( + f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 " + f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 " + "(PLAN 8-7 ㉡)." + ) + + +def check_mix_decomposed_once( + *, + cement_total_kg: Decimal, + concrete_volume_m3: Decimal, + cement_per_m3_kg: Decimal, +) -> None: + """㉢ 콘크리트를 두 번 쪼개지 않았는가. + + 시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을 + 일위대가가 또 분해한 것이다. + """ + if concrete_volume_m3 <= 0: + return + expected = concrete_volume_m3 * cement_per_m3_kg + if cement_total_kg > expected + _TOLERANCE: + raise DoubleCountError( + f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f} ㎥ × " + f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 " + "쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)." + ) diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py index b1c0155b..d6197181 100644 --- a/B09_Estimation/B09_Estimation_Rates.py +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -102,6 +102,24 @@ def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset: ) +@lru_cache(maxsize=8) +def load_rate_dataset_from_path(path: str) -> RateDataset: + """매니페스트 밖의 요율 파일을 읽는다 — **옛 연도 재현 검산 전용**. + + 정본 요율은 `load_rate_dataset` 으로만 읽는다. 이 함수는 「2024년 값으로 돌리면 + 그때 서류가 재현되는가」를 시험하려고 두는 것이고, 지문이 없으므로 결과에 + `sha256=""` 로 남아 **정본이 아님이 드러난다**. + """ + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + return RateDataset( + dataset_id=payload.get("dataset_id", ""), + effective_date=payload.get("effective_date", ""), + sha256="", + variables=payload.get("variables", {}), + ) + + def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None: """금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None.""" match = _RE_LT.match(label) diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py new file mode 100644 index 00000000..37a7eebe --- /dev/null +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -0,0 +1,406 @@ +"""B09 원가계산 — 법정경비 묶음 (⑤ 공사원가계산서의 경비 부분). + +`B09_Estimation_Engine_Cost` 가 부르는 하위 모듈. 700줄 제한(CLAUDE.md 4장)에 맞춰 +경비 계산만 떼어 두었다. + +지켜야 할 것 (PLAN 8-9·8-10·8-13·8-14) + - **비목 목록을 코드에 박지 않는다.** 아래 `STATUTORY_ITEMS` 는 「무엇을 어떤 밑수로 + 계산하는가」의 정의일 뿐이고, **그 해에 그 비목이 있는지는 요율 데이터가 정한다** + (해당 요율 변수가 데이터셋에 없으면 그 해에는 없는 비목). + - **밑수가 항목마다 갈린다** — 산재·고용 = 직노+간노 / 건강·연금 = 직노 / + 요양 = 건강보험료 / 안전 = 재료비+직노+관급항 / 기타경비 = 재료비+직노+간노 / + 환경·보증 = 직접공사비. 뭉뚱그리면 틀린다. + - **안전관리비는 A·B 두 값을 다 내고 작은 쪽** (고용노동부 고시 제2025-11호). + ⚠ A 가 항상 작지 않다 — 관급을 넣어 대상액이 5억·50억 경계를 넘으면 뒤집힌다 + (실증: 울진 A 채택 / 거창 B 채택). + - ★ **법대로**(8-10) — 조달수수료 차감은 규정 문구가 아니다. 기본은 **차감하지 않음**. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Callable + +from B09_Estimation.B09_Estimation_Rates import ( + RateDataset, + RateLookupError, + base_amount, + pension_rate_percent, + rate_percent, + select_bracket, +) + +if TYPE_CHECKING: # pragma: no cover - 순환 import 회피용 + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult + +_ZERO = Decimal(0) +_HUNDRED = Decimal(100) +_VAT_DIVISOR = Decimal("1.1") +_SAFETY_B_MULTIPLIER = Decimal("1.2") + +#: 공사이행보증수수료 요율 데이터가 값 대신 들고 있는 식 문자열 형태. +_RE_GUARANTEE_FORMULA = re.compile(r"([0-9.]+)\s*%") + + +@dataclass(frozen=True) +class StatutoryItem: + """법정경비 한 비목의 정의 — 이름·요율 변수·밑수 뽑는 법.""" + + key: str + name: str + rate_variable: str + base_label: str + + +#: 비목 정의. **순서가 곧 원가계산서 줄 순서**다. +STATUTORY_ITEMS: tuple[StatutoryItem, ...] = ( + StatutoryItem( + "industrial_accident_insurance", "산재보험료", "rate_sanjae", "노무비(직접+간접)" + ), + StatutoryItem("employment_insurance", "고용보험료", "rate_goyong", "노무비(직접+간접)"), + StatutoryItem("health_insurance", "국민건강보험료", "rate_health", "직접노무비"), + StatutoryItem("long_term_care_insurance", "노인장기요양보험료", "rate_care", "국민건강보험료"), + StatutoryItem("national_pension", "국민연금보험료", "rate_pension", "직접노무비"), + StatutoryItem( + "safety_management_cost", "산업안전보건관리비", "rate_safety_pct", "A·B 중 작은 금액" + ), + StatutoryItem("other_expense", "기타경비", "rate_other_expense", "재료비+노무비(직접+간접)"), + StatutoryItem("environment_preservation", "환경보전비", "rate_environment", "직접공사비"), + StatutoryItem( + "retirement_mutual_aid", "퇴직공제부금비", "rate_retirement_mutual_aid", "직접노무비" + ), + StatutoryItem( + "wage_claim_contribution", + "임금채권보장기금 부담금", + "rate_wage_claim_contribution", + "노무비(직접+간접)", + ), + StatutoryItem( + "asbestos_contribution", + "석면피해구제 분담금", + "rate_asbestos_contribution", + "노무비(직접+간접)", + ), + StatutoryItem( + "equipment_payment_guarantee", + "건설기계대여대금 지급보증수수료", + "rate_equipment_payment_guarantee", + "직접공사비", + ), + StatutoryItem( + "subcontract_payment_guarantee", + "하도급대금 지급보증수수료", + "rate_subcontract_payment_guarantee", + "직접공사비", + ), + StatutoryItem( + "performance_guarantee_fee", + "공사이행보증수수료", + "rate_performance_guarantee_fee", + "직접공사비 × 공사기간(년)", + ), +) + +_ITEM_BY_KEY = {item.key: item for item in STATUTORY_ITEMS} + + +def available_items(dataset: RateDataset) -> tuple[str, ...]: + """**그 해에 유효한 비목 목록** — 요율 데이터에 그 변수가 있는 것만 (PLAN 8-13). + + 비목 목록을 코드에 박지 않기 위한 자리. 연도별 요율 파일이 갈리면 목록도 따라 갈린다. + """ + return tuple(item.key for item in STATUTORY_ITEMS if item.rate_variable in dataset.variables) + + +def item_name(key: str) -> str: + return _ITEM_BY_KEY[key].name + + +@dataclass +class ExpenseContext: + """법정경비 계산에 필요한 밑수 묶음 — 엔진이 채워 넘긴다.""" + + material_cost: Decimal + direct_labor_cost: Decimal + total_labor_cost: Decimal + direct_construction_cost: Decimal + scale_reference: Decimal + + +def _threshold_met(dataset: RateDataset, variable: str, field: str, amount: Decimal) -> bool: + """적용 하한(추정금액 1억 이상 등)을 넘겼는가.""" + minimum = dataset.variable(variable).get(field) + if minimum is None: + return True + return amount >= Decimal(str(minimum)) + + +def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decimal: + """공사이행보증수수료는 요율 데이터가 값이 아니라 **식 문자열**을 들고 있다. + + 예: `"(direct_cost * 0.0108%) * duration_years"` → 0.0108 을 뽑는다. + 식 모양이 바뀌면 조용히 넘기지 않고 멈춘다. + """ + formula = str(row.get("formula", "")) + match = _RE_GUARANTEE_FORMULA.search(formula) + if not match: + raise RateLookupError(f"{label}: 요율 식에서 백분율을 못 읽었습니다 — {formula!r}") + return Decimal(match.group(1)) + + +def safety_management_cost( + dataset: RateDataset, + data: CostInput, + ctx: ExpenseContext, + emit: Callable[..., Decimal], +) -> Decimal: + """산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다. + + A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액 + B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2 + + 두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨 + 화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식). + """ + variable = dataset.variable("rate_safety_pct") + brackets = variable["brackets"] + + owner_supplied = data.owner_supplied_for_safety_krw + if owner_supplied is None: + owner_supplied = data.owner_supplied_material_krw + if data.deduct_procurement_fee_for_safety: + # ★ 법대로(8-10) — 규정 문구가 아니다. 옛 서류 재현용으로만 켠다. + owner_supplied = owner_supplied - data.procurement_fee_krw + if data.owner_supplied_includes_vat: + owner_supplied = owner_supplied / _VAT_DIVISOR + + base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied + base_without = ctx.material_cost + ctx.direct_labor_cost + + def evaluate( + base: Decimal, multiplier: Decimal, label: str + ) -> tuple[Decimal, Decimal, Decimal]: + row = select_bracket( + brackets, + amount_field="target_amount_bracket", + amount=base, + equals={"work_type": data.work_type_safety}, + label=label, + ) + percent = rate_percent(row, label=label) + flat = base_amount(row) + return (base * percent / _HUNDRED + flat) * multiplier, percent, flat + + raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)") + raw_b, percent_b, flat_b = evaluate( + base_without, _SAFETY_B_MULTIPLIER, "안전관리비 B(관급 제외 × 1.2)" + ) + + # 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고 + # 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식). + from B09_Estimation.B09_Estimation_Engine_Cost import floor_won + + adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B" + + amount_a = emit( + key="safety_management_cost_a", + name="산업안전보건관리비 A(관급 포함)", + base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)", + base=base_with, + percent=percent_a, + flat=flat_a, + raw=raw_a, + note="채택" if adopted == "A" else "미채택", + ) + amount_b = emit( + key="safety_management_cost_b", + name="산업안전보건관리비 B(관급 제외 × 1.2)", + base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배", + base=base_without, + percent=percent_b, + flat=flat_b, + raw=raw_b, + note="채택" if adopted == "B" else "미채택", + ) + return emit( + key="safety_management_cost", + name="산업안전보건관리비", + base_label=f"A·B 중 작은 금액 (채택 = {adopted})", + base=base_with if adopted == "A" else base_without, + percent=percent_a if adopted == "A" else percent_b, + flat=_ZERO, + raw=min(amount_a, amount_b), + note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액", + ) + + +def statutory_expenses( + dataset: RateDataset, + data: CostInput, + ctx: ExpenseContext, + result: CostResult, + emit: Callable[..., Decimal], +) -> Decimal: + """켜진 비목만 순서대로 계산해 합계를 돌려준다.""" + enabled = [key for key in data.enabled_items if key in _ITEM_BY_KEY] + unknown = [key for key in data.enabled_items if key not in _ITEM_BY_KEY] + if unknown: + raise RateLookupError(f"모르는 비목입니다: {unknown}") + + missing = [k for k in enabled if _ITEM_BY_KEY[k].rate_variable not in dataset.variables] + if missing: + raise RateLookupError( + f"이 요율 판({dataset.effective_date})에 없는 비목입니다: " + f"{[item_name(k) for k in missing]}" + ) + + total = _ZERO + health_amount = _ZERO + + for key in enabled: + item = _ITEM_BY_KEY[key] + + if key == "safety_management_cost": + total += safety_management_cost(dataset, data, ctx, emit) + continue + + if key == "long_term_care_insurance": + if "health_insurance" not in enabled: + raise RateLookupError( + "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" + ) + total += emit( + key=key, + name=item.name, + base_label=item.base_label, + base=health_amount, + percent=Decimal(str(dataset.variable(item.rate_variable)["rate_percent"])), + ) + continue + + base, percent, note = _base_and_rate(dataset, data, ctx, item) + if base is None: + continue # 적용 하한 미달 — 줄 자체를 만들지 않는다 + + amount = emit( + key=key, + name=item.name, + base_label=item.base_label, + base=base, + percent=percent, + note=note, + ) + if key == "health_insurance": + health_amount = amount + total += amount + + return total + + +def _base_and_rate( + dataset: RateDataset, + data: CostInput, + ctx: ExpenseContext, + item: StatutoryItem, +) -> tuple[Decimal | None, Decimal, str]: + """비목별 밑수·요율. 밑수가 `None` 이면 적용 대상이 아니다.""" + variable = dataset.variable(item.rate_variable) + key = item.key + + if key in ("industrial_accident_insurance", "wage_claim_contribution", "asbestos_contribution"): + return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), "" + + if key == "employment_insurance": + # 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로 + # 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율). + row = select_bracket( + variable["brackets"], + amount_field="estimated_amount_bracket", + amount=ctx.scale_reference, + residual_label="below_official_threshold", + label=item.name, + ) + return ctx.total_labor_cost, rate_percent(row, label=item.name), "" + + if key == "health_insurance": + return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" + + if key == "national_pension": + return ctx.direct_labor_cost, pension_rate_percent(dataset, data.pension_year), "" + + if key == "other_expense": + row = select_bracket( + variable["brackets"], + amount_field="direct_cost_bracket", + amount=ctx.direct_construction_cost, + duration_days=data.duration_days, + equals={"work_type": data.work_type_indirect_labor}, + label=item.name, + ) + return ctx.material_cost + ctx.total_labor_cost, rate_percent(row, label=item.name), "" + + if key == "environment_preservation": + if not _threshold_met( + dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference + ): + return None, _ZERO, "" + row = next( + ( + r + for r in variable["all_work_types"] + if r.get("work_type") == data.environment_work_type + ), + None, + ) + if row is None: + raise RateLookupError( + f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}" + ) + note = "" + if variable.get("forest_road_selection_status") == "pending": + note = "⚠ 임도 공종 채택값 미확정 (지식DB `forest_road_selection_status: pending`)" + return ctx.direct_construction_cost, rate_percent(row, label=item.name), note + + if key == "retirement_mutual_aid": + if not _threshold_met( + dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference + ): + return None, _ZERO, "" + return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" + + if key == "equipment_payment_guarantee": + rows = variable["general_construction"] + variable["specialty_construction"] + row = next( + (r for r in rows if r.get("work_type") == data.equipment_guarantee_work_type), None + ) + if row is None: + raise RateLookupError( + "건설기계대여대금 지급보증: 공종을 못 찾았습니다 — " + f"{data.equipment_guarantee_work_type}" + ) + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" + + if key == "subcontract_payment_guarantee": + row = select_bracket( + variable["brackets"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + label=item.name, + ) + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" + + if key == "performance_guarantee_fee": + row = select_bracket( + variable["brackets"], + amount_field="direct_cost_bracket", + amount=ctx.direct_construction_cost, + label=item.name, + ) + percent = _guarantee_percent_from_formula(row, label=item.name) + years = Decimal(str(data.duration_days)) / Decimal(365) + note = variable.get("typical_forest_road_applicability", "") + return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else "" + + raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}") From c044e33dabbb13c6b98c09491318adf5895ea7ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:06:03 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(B09):=20=EA=B7=9C=EB=AA=A8=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EC=9D=84=20=EC=B6=94=EC=A0=95=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=88=98=EB=A0=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 근거 = 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 **관급자재로 공급될 부분의 가격을 제외한 금액**」. 우리 계산의 그 값은 **총원가**(부가세 전·관급 밖)임. - 씨앗은 직접공사비로 두되 **거기서 멈추지 않음** — 나온 총원가로 구간을 다시 판정해 구간 지문이 굳을 때까지 되풀이(최대 5회). 진동하면 **높은 쪽 구간 채택**하고 결과 `notes` 에 남김. `estimated_price_krw` 명시 시 반복 없음. - 구간 지문 = 일반관리비·이윤·고용보험·하도급보증 구간 + 적용 하한(환경·퇴직) 충족 여부. - ⚠ 잠재 결함 하나 같이 고침 — 하도급보증 30억 이상 구간이 공종(토목·산업설비/건축)으로 갈려 **구간 2개가 겹쳐 멈추던 자리**. `select_bracket(prefer_suffix=…)` 로 공종을 부르는 쪽이 대게 함 (조용한 기본값 아님). - 자체검증: 32 passed. 씨앗 47억(8 %) → 총원가 50억 초과 → **6.5 % 로 재판정**되는 경계 넘기 사례를 테스트로 박음. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Engine_Cost.py | 111 +++++++++++++++++-- B09_Estimation/B09_Estimation_Rates.py | 8 ++ B09_Estimation/B09_Estimation_Statutory.py | 2 + 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index ae5fde2d..d3b7ec0e 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -79,7 +79,10 @@ class CostInput: #: 옛 서류(울진 2024) 재현 검산에만 켠다. deduct_procurement_fee_for_safety: bool = False - #: 규모 구간 판정 기준액. None 이면 직접공사비를 쓴다(추정가격 순환 회피). + #: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다. + #: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조). + #: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될 + #: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖). estimated_price_krw: Decimal | None = None #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. @@ -96,6 +99,8 @@ class CostInput: environment_work_type: str = "civil_road" #: 건설기계대여대금 지급보증 공종. equipment_guarantee_work_type: str = "civil_general" + #: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축). + subcontract_guarantee_variant: str = "integrated_civil_or_industrial" #: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」. enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS @@ -196,22 +201,104 @@ def _emitter(result: CostResult): return emit -def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal: - """규모 구간 판정 기준액. +#: 규모 구간 수렴 반복 상한. 2~3회면 고정된다. +_SCALE_MAX_PASSES = 5 - 조달청 제비율표는 「추정가격」으로 구간을 나누지만 추정가격은 원가 계산 결과에 - 딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 명시하면 그 값을, 없으면 - **직접공사비**를 쓴다. + +def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple: + """이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문. + + 규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다. """ - if data.estimated_price_krw is not None: - return data.estimated_price_krw - return direct_construction_cost + parts: list[str] = [] + for variable, field, key in ( + ("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"), + ("rate_profit", "estimated_price_bracket", "brackets"), + ("rate_goyong", "estimated_amount_bracket", "brackets"), + ("rate_subcontract_payment_guarantee", "estimated_price_bracket", "brackets"), + ): + if variable not in dataset.variables: + continue + rows = dataset.variable(variable)[key] + try: + row = select_bracket( + rows, + amount_field=field, + amount=amount, + residual_label="below_official_threshold", + label=variable, + ) + except Exception: # noqa: BLE001 - 구간 밖이면 지문에서 뺀다 + parts.append(f"{variable}:none") + continue + parts.append(f"{variable}:{row.get(field)}") + + # 적용 하한(추정금액 1억 이상 등)도 구간과 같은 축이다. + for variable in ("rate_environment", "rate_retirement_mutual_aid"): + if variable not in dataset.variables: + continue + minimum = dataset.variable(variable).get("minimum_estimated_amount_krw") + if minimum is not None: + parts.append(f"{variable}:met={amount >= Decimal(str(minimum))}") + return tuple(parts) def calculate_cost(data: CostInput) -> CostResult: - """공사원가계산서 한 장을 계산한다.""" + """공사원가계산서 한 장을 계산한다. + + **규모 구간은 「추정가격」으로 판정한다** — 국가계약법 시행령 제7조 1호: + 「공사계약의 경우에는 **관급자재로 공급될 부분의 가격을 제외한 금액**」. + 우리 계산에서 그 값은 **총원가**다(부가세 전, 관급자재대는 애초에 총원가 밖). + + 그런데 총원가는 계산 **결과**라 구간 판정에 그대로 쓰면 순환이 된다. 그래서 + 직접공사비를 씨앗으로 한 번 돌린 뒤 **나온 총원가로 구간을 다시 판정**해 + 구간이 고정될 때까지 되풀이한다(최대 `_SCALE_MAX_PASSES` 회). 설계자가 + `estimated_price_krw` 를 명시하면 반복 없이 그 값으로 한 번만 판정한다. + """ dataset = _load_dataset(data) - result = CostResult(rate_version=dataset.version_stamp) + + if data.estimated_price_krw is not None: + return _calculate_with_scale(data, dataset, data.estimated_price_krw, []) + + scale = ( + data.direct_material_krw + + data.indirect_material_krw + + data.direct_labor_krw + + data.direct_expense_krw + ) + seen_signatures: list[tuple] = [] + tried_amounts: list[Decimal] = [] + + for _ in range(_SCALE_MAX_PASSES): + signature = _scale_signature(dataset, scale) + if signature in seen_signatures: + # 구간이 진동한다 — 보수적으로 **높은 쪽**을 잡고 그 사실을 남긴다. + highest = max([*tried_amounts, scale]) + return _calculate_with_scale( + data, dataset, highest, ["규모 구간 진동 — 높은 쪽 구간 채택"] + ) + seen_signatures.append(signature) + tried_amounts.append(scale) + + trial = _calculate_with_scale(data, dataset, scale, []) + estimated_price = trial.totals["total_cost"] + if _scale_signature(dataset, estimated_price) == signature: + return trial + scale = estimated_price + + return _calculate_with_scale( + data, dataset, scale, [f"규모 구간이 {_SCALE_MAX_PASSES}회 안에 안 굳음 — 마지막 값 채택"] + ) + + +def _calculate_with_scale( + data: CostInput, + dataset: RateDataset, + scale: Decimal, + notes: list[str], +) -> CostResult: + """규모 기준액을 못 박고 한 번 계산한다.""" + result = CostResult(rate_version=dataset.version_stamp, notes=list(notes)) emit = _emitter(result) if data.enabled_items == DEFAULT_ITEMS: @@ -257,7 +344,7 @@ def calculate_cost(data: CostInput) -> CostResult: direct_labor_cost=data.direct_labor_krw, total_labor_cost=total_labor_cost, direct_construction_cost=direct_construction_cost, - scale_reference=_scale_reference(data, direct_construction_cost), + scale_reference=scale, ) statutory = statutory_expenses(dataset, data, ctx, result, emit) diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py index d6197181..21ea9d21 100644 --- a/B09_Estimation/B09_Estimation_Rates.py +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -186,6 +186,7 @@ def select_bracket( duration_field: str = "duration_bracket", equals: dict[str, Any] | None = None, residual_label: str | None = None, + prefer_suffix: str | None = None, label: str, ) -> dict[str, Any]: """구간 목록에서 한 행을 고른다. 못 고르면 `RateLookupError` — 기본값으로 안 때운다. @@ -219,6 +220,13 @@ def select_bracket( for key, expected in equals.items(): candidates = [row for row in candidates if row.get(key) == expected] + if len(candidates) > 1 and prefer_suffix is not None and amount_field is not None: + # 같은 금액 구간이 공종으로 갈리는 표가 있다(하도급보증의 `…_integrated_civil…`). + # 부르는 쪽이 공종을 대야 하며, 조용한 기본값이 아니다. + narrowed = [r for r in candidates if str(r.get(amount_field, "")).endswith(prefer_suffix)] + if narrowed: + candidates = narrowed + if not candidates: raise RateLookupError( f"{label}: 조건에 맞는 요율 구간이 없습니다 " diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 37a7eebe..a4f710ad 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -383,10 +383,12 @@ def _base_and_rate( return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" if key == "subcontract_payment_guarantee": + # 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다. row = select_bracket( variable["brackets"], amount_field="estimated_price_bracket", amount=ctx.scale_reference, + prefer_suffix=data.subcontract_guarantee_variant, label=item.name, ) return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" From 2a21f99ba4c72cf7c536590c34731ac686815aef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:11:15 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=9D=BC=EC=9A=B0=ED=84=B0=C2=B7=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=8B=A0=EC=84=A4,=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=AA=85?= =?UTF-8?q?=20=E3=80=8C=EC=9B=90=EA=B0=80=EA=B3=84=EC=82=B0=E3=80=8D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `B09_Estimation_Router.py` 신설 — 무상태 계산 엔드포인트 3종. · `POST …/estimation/cost` 원가계산서 한 장(줄마다 비목·금액·요율·산출근거) · `GET …/estimation/items` 비목 정의 목록 · `POST …/estimation/confirm` stage 6 완료 전이 요율 구간을 못 고르면 500 이 아니라 **422 + 사유**로 알림 (기본값으로 안 때움). `target_contract_amount_krw` 를 주면 필요한 이윤 조정액을 **보여만 줌**(★법대로 8-10). - `B09_Estimation_UI_Page.ts` — 셸에서 실제 화면으로. 3단 레이아웃, 좌측 입력 4군 (공사조건·요율 판 읽기전용·관급자재·이윤 조정) + 우측 탭 8장(원가계산서 활성). 원가계산서 표는 **네 칸 + 비고**이고 **안전관리비 A·B 두 줄이 나란히**, 채택 줄은 강조·미채택 줄은 취소선. - `main.py` — B09 라우터 import·등록 **자기 줄만** 추가. - `ui_template_locale_b2.ts` — B09 키 추가, `B09_Estimation_Title` 을 「설계도서」→**「원가계산」**으로 변경 (PLAN 9-1 사용자 승인). - 자체검증: `tsc --noEmit` 통과 · 백엔드 재시작 후 `openapi.json` 에 3개 경로 등록 확인 · `/api/health` 200. 화면 조작 검증은 다음 단계(브라우저 창이 닫혀 재기동 필요). Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Router.py | 175 +++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 467 ++++++++++++++++++++++- main.py | 2 + ui_template/ui_template_locale_b2.ts | 48 ++- 4 files changed, 684 insertions(+), 8 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Router.py diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py new file mode 100644 index 00000000..25a82e5f --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router.py @@ -0,0 +1,175 @@ +"""B09 원가계산 라우터 — ⑤ 공사원가계산서 계산 결과를 화면에 낸다. + +지금은 **무상태 계산 엔드포인트**다. 순공사비를 받아 원가계산서 한 장을 돌려주고, +저장은 하지 않는다. 프로젝트 저장(채택 단가 스냅샷 `B09_Estimation/v1/`)은 PLAN 9-2 +항목으로 뒤에 붙인다. + +화면이 「비목 · 금액 · 요율 · 산출근거」 네 칸을 다 보이므로 (PLAN 8-13) 줄마다 그 넷을 +그대로 실어 보낸다. 안전관리비는 A·B 두 줄이 나란히 오고 `note` 에 채택 표시가 붙는다. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_Engine_Cost import ( + CostInput, + CostResult, + calculate_cost, + proposed_profit_adjustment, +) +from B09_Estimation.B09_Estimation_Rates import RateLookupError +from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from common_util.common_util_workflow_state import complete_stage +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) + + +class CostRequest(BaseModel): + """원가계산 입력 — 금액은 원 단위.""" + + direct_material_krw: Decimal = Field(default=Decimal(0), ge=0) + direct_labor_krw: Decimal = Field(default=Decimal(0), ge=0) + direct_expense_krw: Decimal = Field(default=Decimal(0), ge=0) + indirect_material_krw: Decimal = Field(default=Decimal(0), ge=0) + + work_type_indirect_labor: str = "civil" + work_type_safety: str = "civil" + duration_days: int = Field(default=183, ge=1) + pension_year: int = 2026 + + owner_supplied_material_krw: Decimal = Field(default=Decimal(0), ge=0) + procurement_fee_krw: Decimal = Field(default=Decimal(0), ge=0) + include_fee_in_owner_material_total: bool = True + owner_supplied_for_safety_krw: Decimal | None = None + owner_supplied_includes_vat: bool = True + deduct_procurement_fee_for_safety: bool = False + + estimated_price_krw: Decimal | None = None + profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0) + waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0) + + environment_work_type: str = "civil_road" + equipment_guarantee_work_type: str = "civil_general" + subcontract_guarantee_variant: str = "integrated_civil_or_industrial" + + rate_file_name: str = "rates_2026.json" + + #: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**. + #: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다. + target_contract_amount_krw: Decimal | None = None + + def to_engine_input(self) -> CostInput: + return CostInput( + direct_material_krw=self.direct_material_krw, + direct_labor_krw=self.direct_labor_krw, + direct_expense_krw=self.direct_expense_krw, + indirect_material_krw=self.indirect_material_krw, + work_type_indirect_labor=self.work_type_indirect_labor, + work_type_safety=self.work_type_safety, + duration_days=self.duration_days, + pension_year=self.pension_year, + owner_supplied_material_krw=self.owner_supplied_material_krw, + procurement_fee_krw=self.procurement_fee_krw, + include_fee_in_owner_material_total=self.include_fee_in_owner_material_total, + owner_supplied_for_safety_krw=self.owner_supplied_for_safety_krw, + owner_supplied_includes_vat=self.owner_supplied_includes_vat, + deduct_procurement_fee_for_safety=self.deduct_procurement_fee_for_safety, + estimated_price_krw=self.estimated_price_krw, + profit_adjustment_krw=self.profit_adjustment_krw, + waste_disposal_krw=self.waste_disposal_krw, + environment_work_type=self.environment_work_type, + equipment_guarantee_work_type=self.equipment_guarantee_work_type, + subcontract_guarantee_variant=self.subcontract_guarantee_variant, + rate_file_name=self.rate_file_name, + ) + + +def _serialize(result: CostResult) -> dict[str, Any]: + """계산 결과를 화면이 그대로 그릴 수 있는 모양으로 편다.""" + return { + "lines": [ + { + "key": line.key, + "name": line.name, + "base_label": line.base_label, + "base_amount_krw": str(line.base_amount_krw), + "rate_percent": (None if line.rate_percent is None else str(line.rate_percent)), + "flat_amount_krw": str(line.flat_amount_krw), + "amount_krw": str(line.amount_krw), + "formula_text": line.formula_text, + "note": line.note, + } + for line in result.lines + ], + "totals": {key: str(value) for key, value in result.totals.items()}, + "rate_version": result.rate_version, + "notes": result.notes, + } + + +@router.post("/{project_id}/estimation/cost") +async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: + """공사원가계산서 한 장을 계산해 돌려준다 (저장 없음).""" + try: + result = calculate_cost(payload.to_engine_input()) + except RateLookupError as error: + # 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다. + logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error) + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 원가계산 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산에 실패했습니다."}, + ) + + body = _serialize(result) + if payload.target_contract_amount_krw is not None: + # 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다. + body["suggested_profit_adjustment_krw"] = str( + proposed_profit_adjustment(result, payload.target_contract_amount_krw) + ) + return JSONResponse(content={"status": "success", **body}) + + +@router.get("/{project_id}/estimation/items") +async def list_items(project_id: UUID) -> JSONResponse: + """비목 정의 목록 — 화면이 무엇을 켜고 끌 수 있는지 알기 위한 것.""" + return JSONResponse( + content={ + "status": "success", + "items": [ + {"key": item.key, "name": item.name, "base_label": item.base_label} + for item in STATUTORY_ITEMS + ], + } + ) + + +@router.post("/{project_id}/estimation/confirm") +async def confirm_estimation(project_id: UUID) -> JSONResponse: + """원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다.""" + pool = get_db_pool() + async with pool.acquire() as connection: + try: + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 6) + await connection.commit() + except Exception: + await connection.rollback() + logger.exception("B09 원가계산 확정 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산 단계 확정에 실패했습니다."}, + ) + return JSONResponse(content={"status": "success", "project_id": str(project_id)}) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 879aef9b..7e0a684c 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -1,26 +1,481 @@ /* ============================================================================= * B09_Estimation_UI_Page.ts - * 로그인 후 09: 6차 워크플로우 (견적·문서) + * 로그인 후 09: 6차 워크플로우 (원가계산) * - * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. + * 화면 규칙 (PLAN 8-13 · 화면 기획) + * - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표. + * - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다. + * 결과 숫자만 보이면 설계자가 검산을 못 한다. + * - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다 + * (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12). + * - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2). + * - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을 + * 보여만 준다 (★법대로 PLAN 8-10). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; -/** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/* ----------------------------------------------------------------------------- + * 타입 — 라우터 응답과 1:1 + * -------------------------------------------------------------------------- */ + +interface CostLineDto { + key: string; + name: string; + base_label: string; + base_amount_krw: string; + rate_percent: string | null; + flat_amount_krw: string; + amount_krw: string; + formula_text: string; + note: string; +} + +interface CostSheetDto { + status: string; + lines: CostLineDto[]; + totals: Record; + rate_version: { dataset_id: string; effective_date: string; sha256: string }; + notes: string[]; + suggested_profit_adjustment_krw?: string; +} + +/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ +interface CostFormState { + direct_material_krw: string; + direct_labor_krw: string; + direct_expense_krw: string; + duration_days: string; + owner_supplied_material_krw: string; + procurement_fee_krw: string; + profit_adjustment_krw: string; + target_contract_amount_krw: string; +} + +const INITIAL_FORM: CostFormState = { + direct_material_krw: "0", + direct_labor_krw: "0", + direct_expense_krw: "0", + duration_days: "183", + owner_supplied_material_krw: "0", + procurement_fee_krw: "0", + profit_adjustment_krw: "0", + target_contract_amount_krw: "", +}; + +/** 총계 성격의 줄 — 표에서 굵게 띄운다. */ +const TOTAL_KEYS = new Set([ + "material_cost", + "labor_cost", + "expense", + "net_construction_cost", + "total_cost", + "contract_amount", + "grand_total", +]); + +/* ----------------------------------------------------------------------------- + * 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지) + * -------------------------------------------------------------------------- */ + +const STYLE_ID = "b09-estimation-styles"; + +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } +.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } +.b09-panel__legend { + font-size: var(--font-size-xs, 12px); letter-spacing: .06em; + color: var(--color-text-secondary); text-transform: uppercase; +} +.b09-panel__readonly { + font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); + display: flex; justify-content: space-between; gap: var(--space-sm, 8px); + border-bottom: 1px solid var(--color-border); padding: 2px 0; +} +.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } +.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } + +.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; } +.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } +.b09-tab { + font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; + border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); +} +.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } +.b09-tab:disabled { cursor: not-allowed; opacity: .55; } + +.b09-sheet { overflow: auto; min-height: 0; flex: 1; } +.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } +.b09-sheet th, .b09-sheet td { + border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; + white-space: nowrap; font-variant-numeric: tabular-nums; +} +.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; } +.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; } +.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } +.b09-sheet tr.is-adopted td { background: var(--color-surface); } +.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } +.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } +`; + document.head.append(style); +} + +/* ----------------------------------------------------------------------------- + * 표 그리기 + * -------------------------------------------------------------------------- */ + +function formatWon(value: string): string { + const n = Number(value); + if (!Number.isFinite(n)) return value; + return n.toLocaleString("ko-KR"); +} + +function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet"; + + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + const headers: Array<[string, boolean]> = [ + [L("B09_Estimation_Col_Item"), true], + [L("B09_Estimation_Col_Amount"), false], + [L("B09_Estimation_Col_Rate"), false], + [L("B09_Estimation_Col_Basis"), true], + [L("B09_Estimation_Col_Note"), true], + ]; + for (const [text, left] of headers) { + const th = document.createElement("th"); + th.textContent = text; + if (left) th.className = "b09-left"; + headRow.append(th); + } + thead.append(headRow); + table.append(thead); + + const tbody = document.createElement("tbody"); + for (const line of sheet.lines) { + const tr = document.createElement("tr"); + if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total"); + if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted"); + if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped"); + + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = line.name; + + const amount = document.createElement("td"); + amount.textContent = formatWon(line.amount_krw); + + const rate = document.createElement("td"); + rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; + + const basis = document.createElement("td"); + basis.className = "b09-left"; + basis.textContent = line.formula_text; + + const note = document.createElement("td"); + note.className = "b09-left"; + note.textContent = line.note; + + tr.append(name, amount, rate, basis, note); + tbody.append(tr); + } + table.append(tbody); + wrap.append(table); + return wrap; +} + +/* ----------------------------------------------------------------------------- + * 좌측 패널 + * -------------------------------------------------------------------------- */ + +interface PanelHandles { + root: HTMLElement; + rateVersionBox: HTMLElement; + hintBox: HTMLElement; +} + +function buildSidePanel( + form: CostFormState, + onRecalc: () => void, + onConfirm: () => void, +): PanelHandles { + const root = document.createElement("div"); + root.className = "b09-panel"; + + const addGroup = ( + legendKey: keyof typeof ui_locales, + fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, + ): void => { + const group = document.createElement("div"); + group.className = "b09-panel__group"; + const legend = document.createElement("span"); + legend.className = "b09-panel__legend"; + legend.textContent = L(legendKey); + group.append(legend); + for (const [field, labelKey] of fields) { + const handle = createInputField({ + label: L(labelKey), + type: "number", + min: 0, + value: form[field], + onInput: (value) => { + form[field] = value; + }, + }); + group.append(handle.root); + } + root.append(group); + }; + + addGroup("B09_Estimation_Group_Condition", [ + ["direct_material_krw", "B09_Estimation_Field_DirectMaterial"], + ["direct_labor_krw", "B09_Estimation_Field_DirectLabor"], + ["direct_expense_krw", "B09_Estimation_Field_DirectExpense"], + ["duration_days", "B09_Estimation_Field_Duration"], + ]); + + // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. + const rateGroup = document.createElement("div"); + rateGroup.className = "b09-panel__group"; + const rateLegend = document.createElement("span"); + rateLegend.className = "b09-panel__legend"; + rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); + const rateVersionBox = document.createElement("div"); + rateGroup.append(rateLegend, rateVersionBox); + root.append(rateGroup); + + addGroup("B09_Estimation_Group_Supplied", [ + ["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"], + ["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"], + ]); + + addGroup("B09_Estimation_Group_Profit", [ + ["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"], + ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], + ]); + + const hintBox = document.createElement("div"); + hintBox.className = "b09-hint"; + root.append(hintBox); + + const actions = document.createElement("div"); + actions.className = "b09-panel__actions"; + actions.append( + createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc }), + createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm }), + ); + root.append(actions); + + return { root, rateVersionBox, hintBox }; +} + +function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { + box.replaceChildren(); + if (!sheet) return; + const rows: Array<[string, string]> = [ + ["적용일", sheet.rate_version.effective_date || "—"], + ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], + ]; + for (const [label, value] of rows) { + const row = document.createElement("div"); + row.className = "b09-panel__readonly"; + const left = document.createElement("span"); + left.textContent = label; + const right = document.createElement("span"); + right.textContent = value; + row.append(left, right); + box.append(row); + } +} + +/* ----------------------------------------------------------------------------- + * 탭 + * -------------------------------------------------------------------------- */ + +const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ + ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], + ["boq", "B09_Estimation_Tab_Boq", false], + ["unit_price", "B09_Estimation_Tab_UnitPrice", false], + ["price_basis", "B09_Estimation_Tab_PriceBasis", false], + ["machine", "B09_Estimation_Tab_Machine", false], + ["duration", "B09_Estimation_Tab_Duration", false], + ["supply", "B09_Estimation_Tab_Supply", false], + ["base_data", "B09_Estimation_Tab_BaseData", false], +]; + +function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { + const bar = document.createElement("div"); + bar.className = "b09-tabs"; + for (const [key, labelKey, enabled] of TAB_KEYS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b09-tab"; + button.dataset.tab = key; + button.textContent = L(labelKey); + button.disabled = !enabled; + if (!enabled) button.title = L("B09_Estimation_Tab_Pending"); + if (key === active) button.classList.add("is-active"); + button.addEventListener("click", () => onSelect(key)); + bar.append(button); + } + return bar; +} + +/* ----------------------------------------------------------------------------- + * API + * -------------------------------------------------------------------------- */ + +function toRequestBody(form: CostFormState): Record { + const num = (value: string): string => (value.trim() === "" ? "0" : value.trim()); + const body: Record = { + direct_material_krw: num(form.direct_material_krw), + direct_labor_krw: num(form.direct_labor_krw), + direct_expense_krw: num(form.direct_expense_krw), + duration_days: Number(num(form.duration_days)), + owner_supplied_material_krw: num(form.owner_supplied_material_krw), + procurement_fee_krw: num(form.procurement_fee_krw), + profit_adjustment_krw: num(form.profit_adjustment_krw), + }; + if (form.target_contract_amount_krw.trim() !== "") { + body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); + } + return body; +} + +async function fetchCostSheet(projectId: string, form: CostFormState): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toRequestBody(form)), + }, + ); + if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`); + return (await response.json()) as CostSheetDto; +} + +async function confirmEstimationStage(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, + { method: "POST", credentials: "include" }, + ); + if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`); +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ + export async function renderB09Estimation(root: HTMLElement): Promise { - await renderPendingWorkflow(root, { + injectStyles(); + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const form: CostFormState = { ...INITIAL_FORM }; + let activeTab = "cost_sheet"; + let sheet: CostSheetDto | null = null; + + const main = document.createElement("div"); + main.className = "b09-main"; + const body = document.createElement("div"); + body.style.flex = "1"; + body.style.minHeight = "0"; + body.style.display = "flex"; + body.style.flexDirection = "column"; + + const drawBody = (): void => { + body.replaceChildren(); + if (activeTab !== "cost_sheet") { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Tab_Pending"); + body.append(empty); + return; + } + if (!sheet) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Btn_Recalc"); + body.append(empty); + return; + } + body.append(buildCostSheetTable(sheet)); + for (const note of sheet.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note; + body.append(line); + } + }; + + const drawTabs = (): void => { + const bar = buildTabs(activeTab, (key) => { + activeTab = key; + drawTabs(); + drawBody(); + }); + const old = main.querySelector(".b09-tabs"); + if (old) old.replaceWith(bar); + else main.prepend(bar); + }; + + const panel = buildSidePanel( + form, + async () => { + if (!projectId) return; + try { + sheet = await fetchCostSheet(projectId, form); + renderRateVersion(panel.rateVersionBox, sheet); + panel.hintBox.textContent = + sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" + ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` + : ""; + drawBody(); + } catch { + showToast(L("B09_Estimation_Calc_Failed"), "error"); + } + }, + async () => { + if (!projectId) return; + try { + await confirmEstimationStage(projectId); + showToast(L("B09_Estimation_Confirm_Success"), "success"); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]); + } catch { + showToast(L("B09_Estimation_Confirm_Failed"), "error"); + } + }, + ); + + main.append(body); + drawTabs(); + drawBody(); + + const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, + leftPanel: panel.root, + mainContent: main, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, }); + root.append(layout.root); } diff --git a/main.py b/main.py index 6088b1f7..410c771d 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router +from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -535,6 +536,7 @@ app.include_router(b06_section_haul_plan_router, dependencies=protected_with_com app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) +app.include_router(b09_estimation_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3e21a402..113316ac 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -610,8 +610,52 @@ export const ui_locales_b2 = { "Failed to confirm the quantity stage.", ], - /* --- B09_Estimation 견적·문서 --- */ - B09_Estimation_Title: ["설계도서", "Design Docs"], + /* --- B09_Estimation 원가계산 --- */ + B09_Estimation_Title: ["원가계산", "Cost Estimate"], + B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], + B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], + B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"], + B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"], + B09_Estimation_Tab_Machine: ["중기", "Equipment"], + B09_Estimation_Tab_Duration: ["공사기간", "Duration"], + B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], + B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], + B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], + B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], + B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"], + B09_Estimation_Group_Profit: ["이윤 조정", "Profit Adjustment"], + B09_Estimation_Field_DirectMaterial: ["직접재료비", "Direct Material"], + B09_Estimation_Field_DirectLabor: ["직접노무비", "Direct Labor"], + B09_Estimation_Field_DirectExpense: ["직접경비", "Direct Expense"], + B09_Estimation_Field_Duration: ["공사기간(일)", "Duration (days)"], + B09_Estimation_Field_WorkType: ["공종", "Work Type"], + B09_Estimation_Field_OwnerMaterial: ["순자재대", "Net Material"], + B09_Estimation_Field_ProcurementFee: ["조달수수료", "Procurement Fee"], + B09_Estimation_Field_ProfitAdjust: ["조정액", "Adjustment"], + B09_Estimation_Field_TargetContract: ["목표 도급공사비", "Target Contract"], + B09_Estimation_Btn_Recalc: ["재계산", "Recalculate"], + B09_Estimation_Btn_Confirm: ["확정", "Confirm"], + B09_Estimation_Col_Item: ["비목", "Item"], + B09_Estimation_Col_Amount: ["금액", "Amount"], + B09_Estimation_Col_Rate: ["요율", "Rate"], + B09_Estimation_Col_Basis: ["산출근거", "Basis"], + B09_Estimation_Col_Note: ["비고", "Note"], + B09_Estimation_Adopted: ["채택", "Adopted"], + B09_Estimation_NotAdopted: ["미채택", "Not adopted"], + B09_Estimation_Suggest_Adjust: [ + "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", + "To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.", + ], + B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."], + B09_Estimation_Confirm_Success: [ + "원가계산 단계를 확정했습니다.", + "Cost estimate stage confirmed.", + ], + B09_Estimation_Confirm_Failed: [ + "원가계산 단계 확정에 실패했습니다.", + "Failed to confirm the cost estimate stage.", + ], + B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], From ec23f87f83705168be626b776e10a4aced90e4c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:16:22 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(B09):=20=EC=82=B0=EC=B6=9C=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EB=AC=B8=EA=B5=AC=20=EA=B4=84=ED=98=B8=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EC=A7=84=ED=96=89=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=EB=8F=84=20=E3=80=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EA=B3=84=EC=82=B0=E3=80=8D=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면 실측에서 드러난 표기 오류 셋 (공용 브라우저 5174 조작 확인): - **이윤 밑수가 뒤바뀌어 읽힘** — `(순공사원가+일반관리비) − 재료비 × 15%` 로 나와 재료비에만 15 %를 곱하는 것처럼 보였음. 밑수가 합·차면 괄호를 씌우도록 고쳐 `((순공사원가+일반관리비) − 재료비) × 15.0%` 로 나옴. - **안전관리비 B 산출근거에 밑수 설명 문장이 통째로 박혀** 식이 안 읽혔음. 밑수 라벨을 「재료비+직접노무비 (관급 제외)」로 줄이고 ×1.2 는 대괄호로 감쌈. - **채택 요약 줄에 요율이 한 번 더 붙던 것** 제거 — 산식은 A·B 줄에 이미 있음. - `WF_Step_Estimation` 라벨도 「설계도서」→「원가계산」 (페이지명과 어긋나 있었음). - `F402` 정리: 구간 지문 루프 변수가 `dataclasses.field` 를 가리던 것 이름 변경. 자체검증 — 브라우저 실측 29줄 렌더 · 요율 판(2026-04-13 / 지문 250b1e4d…) 표시 · 안전관리비 A 채택/B 취소선 · 환경보전비 「미확정」 경고 표시 · 목표 도급공사비 1,192,000,000 입력 시 필요 조정액 **394,456** 제안(적용은 안 함) · `(1,192,433,902 − 1,192,000,000) ÷ 1.1 = 394,456` 손검산 일치. pytest 32 passed · ruff 통과 · tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Engine_Cost.py | 18 ++++++++++++------ B09_Estimation/B09_Estimation_Statutory.py | 2 +- ui_template/ui_template_locale_common.ts | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index d3b7ec0e..765914d1 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -130,13 +130,19 @@ class CostLine: 실무 원문은 안전관리비 A 식을 B 줄에 복사해 둔 오류가 있었다(PLAN 8-13). """ - if self.rate_percent is None: + if self.rate_percent is None or self.key == "safety_management_cost": + # 채택 요약 줄은 요율을 다시 붙이지 않는다 — 산식은 A·B 줄에 이미 있다. return self.base_label - text = f"{self.base_label} × {self.rate_percent}%" + # 밑수가 합·차로 이루어졌으면 괄호를 씌운다. + # 안 씌우면 「(순공사원가+일반관리비) − 재료비 × 15%」처럼 곱하는 대상이 뒤바뀌어 읽힌다. + base = self.base_label + if any(mark in base for mark in ("+", "−", "×")): + base = f"({base})" + text = f"{base} × {self.rate_percent}%" if self.flat_amount_krw: text += f" + {self.flat_amount_krw:,.0f}" if self.key == "safety_management_cost_b": - text = f"({text}) × 1.2" + text = f"[{text}] × 1.2" return text @@ -211,7 +217,7 @@ def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple: 규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다. """ parts: list[str] = [] - for variable, field, key in ( + for variable, bracket_field, key in ( ("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"), ("rate_profit", "estimated_price_bracket", "brackets"), ("rate_goyong", "estimated_amount_bracket", "brackets"), @@ -223,7 +229,7 @@ def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple: try: row = select_bracket( rows, - amount_field=field, + amount_field=bracket_field, amount=amount, residual_label="below_official_threshold", label=variable, @@ -231,7 +237,7 @@ def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple: except Exception: # noqa: BLE001 - 구간 밖이면 지문에서 뺀다 parts.append(f"{variable}:none") continue - parts.append(f"{variable}:{row.get(field)}") + parts.append(f"{variable}:{row.get(bracket_field)}") # 적용 하한(추정금액 1억 이상 등)도 구간과 같은 축이다. for variable in ("rate_environment", "rate_retirement_mutual_aid"): diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index a4f710ad..db4f9f52 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -217,7 +217,7 @@ def safety_management_cost( amount_b = emit( key="safety_management_cost_b", name="산업안전보건관리비 B(관급 제외 × 1.2)", - base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배", + base_label="재료비+직접노무비 (관급 제외)", base=base_without, percent=percent_b, flat=flat_b, diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index dde3aa13..11eb7dfc 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -82,7 +82,7 @@ export const ui_locales_common = { WF_Step_ProfileCross: ["횡단설계", "Cross Design"], WF_Step_DesignDetail: ["상세설계", "Detail Design"], WF_Step_Quantity: ["수량산출", "Quantity"], - WF_Step_Estimation: ["설계도서", "Design Docs"], + WF_Step_Estimation: ["원가계산", "Cost Estimate"], WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"], WF_State_Failed: ["실패", "Failed"], WF_State_Complete: ["완료", "Complete"], From 25f8da652df5e20547454c7d716149390fc7490e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:17:42 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(B08):=20=ED=86=A0=EC=A0=81=ED=91=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=8B=A4=EB=AC=B4=203?= =?UTF-8?q?=EB=8B=A8=20=EB=A8=B8=EB=A6=AC=EA=B8=80=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 8-4b 열 명세를 화면에 세움. 일감 2 의 마지막 덩어리(엔진 → API → 화면). 왜 실무 서식 그대로인가 이 화면의 첫 사용자는 「프로그램이 맞나」를 확인하려는 설계자임. 보기 좋게 재배치하면 실무 산출서와 눈으로 대조를 못 함. 열 순서·머리글 문구를 실무 토적표(= 오솔길 1.BOM 36열)에 맞춤. 소수 자리는 표기 규칙일 뿐 (PLAN 8-16) 서버는 전정밀 값을 주고 자르는 것은 화면뿐임. 실무 시트 관측대로 단면적·체적 2자리 · 보정량계·유용토·차인·누가 1자리 · 거리 정수. 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대라 그 코드를 옮기지 말 것. 구성 좌측 = 산출 조건 읽기 전용(산출법·토량환산계수). 계수는 서버 상수가 유일 정의처라 화면이 값을 다시 적지 않음. 우측 = 시트 탭 + 표. 지금 서는 장은 토적표 하나, 나머지 6장은 차례로 붙임. 표는 한 번만 받아 좌측·우측이 함께 씀(중복 호출 없음). 측점 열·머리글은 sticky — 가로로 굴러도 어느 줄인지 보임. locale 은 B08 키 8개만 추가 (다른 창과 겹치는 파일이라 기존 줄 무접촉). 검증 — 공용 브라우저 실화면. 타입검사 오류 0. 탭 「토적표」, 3단 머리글 3행(9/5/14셀), 본문 65행 x 20열, 합계행 표시. 둘째 줄이 API 값과 일치: NO.1 거리 20 · 절토토사 1.96/22.34/20.10 · 암 2.54/25.43/29.24 · 측구 0.08/2.58/2.33 + 0.10/1.02/1.17 · 보정량계 52.8 · 성토 4.82/48.79 · 유용 48.8 · 차인 4.0 · 누가 4.0. ⚠ 어두운 테마에서 머리글이 묻히던 것을 프로젝트 테마 변수로 바꿔 고침 — 대비 실측 머리글 10.51:1 · 본문 11.32:1 (기준 4.5:1). 조작으로 바꾼 화면은 사용자가 보던 주소로 되돌려 놓음. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 3 +- .../B08_Quantity_UI_EarthworkGrid_Style.ts | 97 ++++++++++++++ B08_Quantity/B08_Quantity_UI_Page.ts | 124 ++++++++++++++++-- ui_template/ui_template_locale_b2.ts | 13 ++ 4 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 36449e77..41e571fc 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -123,8 +123,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; const PAIR_LABELS = ["단면적", "입 적"]; -const flatColumns = (): Column[] => - GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); +const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); /** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */ function stationLabel(chainage: number, interval = 20): string { diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts new file mode 100644 index 00000000..a3577ff0 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * B08_Quantity_UI_EarthworkGrid_Style.ts + * 토적표 그리드 스타일. 한 번만 주입한다. + * + * 실무 산출서와 눈으로 대조되는 것이 이 표의 목적이라, 장식보다 **줄·칸이 또렷한 것**을 + * 우선한다. 숫자는 등폭으로 두어 자릿수가 세로로 맞는다. + * ========================================================================== */ + +const STYLE_ID = "b08-earthwork-grid-style"; + +/* 색은 전부 프로젝트 테마 변수를 쓴다 — 어두운 테마에서 머리글이 묻히지 않아야 한다. + 글자색을 배경과 함께 지정하는 까닭이 그것이다(배경만 주면 상속색이 배경에 잠긴다). */ +const CSS = ` +.b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; } + +.b08-grid__caption { + margin: 0; + font-size: 12px; + color: var(--color-text-secondary); +} + +/* 열이 많아 화면을 넘친다 — 표만 가로로 구르고 페이지 몸통은 안 구른다. */ +.b08-grid__scroll { overflow: auto; flex: 1 1 auto; min-height: 0; } + +.b08-grid__table { + border-collapse: collapse; + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--color-text-body); +} + +.b08-grid__table th, +.b08-grid__table td { + border: 1px solid var(--color-border); + padding: 2px 8px; + text-align: right; +} + +.b08-grid__table thead th { + position: sticky; + top: 0; + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); + text-align: center; + z-index: 1; +} + +/* 측점 열은 왼쪽에 붙어 있어야 가로로 굴러도 어느 줄인지 보인다. */ +.b08-grid__station { + position: sticky; + left: 0; + background: var(--color-surface); + color: var(--color-text); + text-align: left; + font-weight: var(--font-weight-medium, 500); +} + +.b08-grid__table tfoot td { + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); +} + +.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); } + +.b08-quantity__tab { + padding: 4px 12px; + font-size: 13px; + border: 1px solid var(--color-border); + border-bottom: none; + background: var(--color-surface-raised); + color: var(--color-text-secondary); + cursor: pointer; +} + +.b08-quantity__tab.is-active { + background: var(--color-surface); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); +} + +.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; } +.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); } +.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; } +.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } +`; + +/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */ +export function injectEarthworkGridStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.append(style); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index c6463b8d..147265b1 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,16 +2,23 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) * - * ⚠️ 본문 준비 중 — 워크플로우 셸 + 좌측 [확정] 버튼만 구성. + * 우측 = 실무 수량산출서의 시트를 탭으로 옮긴 것. 지금은 **토적표** 한 장이 서 있고 + * 나머지(토적집계·구조물위치·수량집계표·총괄집계·수리계산·운반거리)는 차례로 붙인다. * 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동. - * 수량 본문(B06 종횡단 기반 산출)은 후속 계획에서 구현한다. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, +} from "../A00_Common/b_workflow_nav"; +import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid"; +import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -29,15 +36,46 @@ async function confirmQuantityStage(projectId: string): Promise { } } -/** 좌측 패널: 준비 중 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */ -function buildQuantitySidePanel(projectId: string | null): HTMLElement { +/** 토적표를 받아 온다. 노선을 안 주면 워크플로가 보고 있는 최신 노선으로 나온다. */ +async function fetchEarthworkTable(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/earthwork-table`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`earthwork table failed: ${response.status}`); + return (await response.json()) as EarthworkTable; +} + +/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */ +function field(label: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const amount = document.createElement("span"); + amount.className = "b08-quantity__field-value"; + amount.textContent = value; + row.append(name, amount); + return row; +} + +/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */ +function buildQuantitySidePanel( + projectId: string | null, + table: EarthworkTable | null, +): HTMLElement { const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; - const note = document.createElement("p"); - note.className = "b08-quantity__pending-note"; - note.textContent = L("B08_Quantity_Side_Pending"); - panel.append(note); + // 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다. + panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value"))); + const entries = Object.entries(table?.conversion_factors ?? {}); + if (entries.length) { + panel.append(field(L("B08_Quantity_Side_Factors"), "")); + for (const [kind, value] of entries) { + panel.append(field(kind, String((value as { compacted: number }).compacted))); + } + } const confirmButton = createButton({ label: L("B08_Quantity_Btn_Confirm"), @@ -67,15 +105,77 @@ function buildQuantitySidePanel(projectId: string | null): HTMLElement { return panel; } +/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */ +function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement { + const body = document.createElement("div"); + body.className = "b08-quantity__body"; + + const tabs = document.createElement("div"); + tabs.className = "b08-quantity__tabs"; + const tab = document.createElement("button"); + tab.type = "button"; + tab.className = "b08-quantity__tab is-active"; + tab.textContent = L("B08_Quantity_Tab_Earthwork"); + tabs.append(tab); + body.append(tabs); + + if (failed) { + const message = document.createElement("p"); + message.className = "b08-quantity__message"; + message.textContent = L("B08_Quantity_Grid_Failed"); + body.append(message); + return body; + } + if (!table || !table.rows?.length) { + const message = document.createElement("p"); + message.className = "b08-quantity__message"; + message.textContent = L("B08_Quantity_Grid_Empty"); + body.append(message); + return body; + } + body.append(renderEarthworkGrid(table)); + return body; +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export async function renderB08Quantity(root: HTMLElement): Promise { + injectEarthworkGridStyles(); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - await renderPendingWorkflow(root, { + + // 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다. + let table: EarthworkTable | null = null; + let failed = false; + if (projectId) { + try { + table = await fetchEarthworkTable(projectId); + } catch { + failed = true; + } + } + + let workflowState: Awaited> | undefined; + if (projectId) { + try { + workflowState = await fetchWorkflowState(projectId); + } catch { + /* 조회 실패 시 stages 미전달 → 전체 이동 허용 (다른 워크플로 페이지와 같음) */ + } + } + + const layout = createWorkflowLayout({ title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, - leftPanel: buildQuantitySidePanel(projectId), + leftPanel: buildQuantitySidePanel(projectId, table), + mainContent: buildQuantityBody(table, failed), + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex: number) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, }); + root.append(layout.root); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3e21a402..79f0576e 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -609,6 +609,19 @@ export const ui_locales_b2 = { "수량 단계 확정에 실패했습니다.", "Failed to confirm the quantity stage.", ], + B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], + B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], + B08_Quantity_Grid_Empty: [ + "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", + "No cross-section areas yet. Finish the cross-section design first.", + ], + B08_Quantity_Grid_Failed: [ + "토적표를 불러오지 못했습니다.", + "Failed to load the earthwork table.", + ], + B08_Quantity_Side_Method: ["산출법", "Method"], + B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], + B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], /* --- B09_Estimation 견적·문서 --- */ B09_Estimation_Title: ["설계도서", "Design Docs"],