diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py new file mode 100644 index 00000000..d6abd37b --- /dev/null +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -0,0 +1,271 @@ +"""B09 원가계산서 화면 자료 — STmate 「제잡비 계산」(wM_KanJub) **일반형식** 서식 차례 (PLAN 12장). + +⚠ 화면만 닮는다 — **값은 우리 엔진**(`Engine_Cost.calculate_cost`)이 낸다. 여기는 + ① 엔진 줄을 STmate 서식 차례·표기(나./ㄱ./1))로 늘어놓고 + ② 기준 입력판 칸(번호·제목은 DFM `TWM_KANJUB` 그대로)의 선택지를 요율 데이터에서 뽑고 + ③ 프로젝트 저장값을 `CostInput` 으로 옮기는 일만 한다. 계산을 두 벌로 짜지 않는다. +⚠ 형식은 「일반」만 — 수공·실적일반·실적수공은 항목·밑수·요율이 달라(35번 문서) 차례로 세움. +""" + +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult +from B09_Estimation.B09_Estimation_Rates import RateDataset + +_ZERO = Decimal(0) + +#: 저장 자리 — `estimation` 구획 안 한 칸. +SETTINGS_KEY = "cost_sheet" + +#: 기준 입력판 — DFM 번호·제목 그대로. 엔진이 받는 칸만 연다(나머지 번호는 형식이 서면 채움). +#: (칸 이름, DFM 제목, 요율 변수 경로, 한글 이름표) +WORK_TYPE_FIELDS: tuple[tuple[str, str], ...] = ( + ("work_type_indirect_labor", "2.공사의종류(주)"), + ("work_type_safety", "7.산업안전보건관리비"), + ("environment_work_type", "8.환 경 보 전 비"), + ("equipment_guarantee_work_type", " 9.건설기계대여금 지급수수료"), +) +AMOUNT_FIELDS: tuple[tuple[str, str], ...] = ( + ("owner_supplied_material_krw", "6.관/사급내용 — 관급자재대(순자재대)"), + ("procurement_fee_krw", "6.관/사급내용 — 조달수수료"), + ("indirect_material_krw", "- 간 접 재 료 비"), + ("profit_adjustment_krw", "이윤 보정액"), +) + +#: 공종 이름표 — 조달청 제비율 적용기준(현행 2026-04-13) 표기. +WORK_TYPE_LABELS: dict[str, str] = { + "civil": "토목", + "landscape": "조경", + "industrial_facilities_civil": "산업설비(토목)", + "building": "건축공사", + "heavy_construction": "중건설공사", + "special_construction": "특수건설공사", + "civil_road": "토목 — 도로(교량·터널·활주로 등)", + "civil_plant": "토목 — 플랜트(발전소·쓰레기소각장 등)", + "civil_subway": "토목 — 지하철", + "civil_railway": "토목 — 철도", + "civil_water_and_sewer": "토목 — 상하수도", + "civil_port": "토목 — 항만", + "civil_port_with_silt_screen": "토목 — 항만(오탁방지막·준설토방지막 설치)", + "civil_dam": "토목 — 댐", + "civil_land_development": "토목 — 택지개발", + "civil_other": "토목 — 그 밖의 토목공사(하천 등)", + "building_housing_redevelopment": "건축 — 주택(재개발·재건축)", + "building_new_housing": "건축 — 주택(신축)", + "building_other": "건축 — 그 밖의 건축", + "civil_general": "일반건설 — 토목", + "industrial_facilities": "일반건설 — 산업설비", +} +#: 산업안전보건관리비 공종은 「토목공사」 표기(간접노무비 「토목」과 키가 같아 따로 둠). +SAFETY_LABELS = {"civil": "토목공사"} + + +def _unique(values: list[str]) -> list[str]: + return list(dict.fromkeys(values)) + + +def field_options(dataset: RateDataset) -> dict[str, list[dict[str, str]]]: + """기준 입력판 고르개 선택지 — **요율 데이터에 있는 공종만**(코드에 목록을 박지 않음).""" + variables = dataset.variables + sources = { + "work_type_indirect_labor": [ + row["work_type"] for row in variables["rate_indirect_labor"]["brackets"] + ], + "work_type_safety": [row["work_type"] for row in variables["rate_safety_pct"]["brackets"]], + "environment_work_type": [ + row["work_type"] for row in variables["rate_environment"]["all_work_types"] + ], + "equipment_guarantee_work_type": [ + row["work_type"] + for row in variables["rate_equipment_payment_guarantee"]["general_construction"] + ], + } + options: dict[str, list[dict[str, str]]] = {} + for key, values in sources.items(): + labels = {**WORK_TYPE_LABELS, **(SAFETY_LABELS if key == "work_type_safety" else {})} + options[key] = [{"value": v, "label": labels.get(v, v)} for v in _unique(values)] + return options + + +def _decimal(value: Any) -> Decimal: + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError): + return _ZERO + return number if number >= 0 else _ZERO + + +def clean_settings(values: dict[str, Any], dataset: RateDataset) -> dict[str, Any]: + """저장할 값 — 데이터에 없는 공종·음수·빈칸은 버린다(버린 칸은 엔진 기본값이 섬).""" + options = field_options(dataset) + cleaned: dict[str, Any] = {} + for key, _ in WORK_TYPE_FIELDS: + value = str(values.get(key) or "") + if value in {option["value"] for option in options[key]}: + cleaned[key] = value + for key, _ in AMOUNT_FIELDS: + if values.get(key) not in (None, ""): + cleaned[key] = str(_decimal(values.get(key))) + try: + days = int(values.get("duration_days")) + except (TypeError, ValueError): + days = 0 + if days > 0: + cleaned["duration_days"] = days + return cleaned + + +def cost_input( + direct: dict[str, Decimal], + stored: dict[str, Any], + waste_krw: Decimal = _ZERO, + waste_separate_order: bool = False, +) -> CostInput: + """내역서 직접비 + 저장값 → 엔진 입력. 저장 안 한 칸은 **엔진 기본값**.""" + kwargs: dict[str, Any] = {key: stored[key] for key, _ in WORK_TYPE_FIELDS if stored.get(key)} + kwargs.update( + {key: _decimal(stored[key]) for key, _ in AMOUNT_FIELDS if stored.get(key) is not None} + ) + if stored.get("duration_days"): + kwargs["duration_days"] = int(stored["duration_days"]) + return CostInput( + direct_material_krw=direct["material"], + direct_labor_krw=direct["labor"], + direct_expense_krw=direct["expense"], + waste_disposal_krw=waste_krw, + waste_separate_order=waste_separate_order, + **kwargs, + ) + + +#: 경비 세목 — STmate 일반형식 서식 차례·표기. 키는 우리 엔진 줄. +EXPENSE_ORDER: tuple[tuple[str, str], ...] = ( + ("industrial_accident_insurance", "산재보험료"), + ("employment_insurance", "고용보험료"), + ("health_insurance", "건강보험료"), + ("long_term_care_insurance", "장기요양보험료"), + ("national_pension", "연금보험료"), + ("retirement_mutual_aid", "퇴직공제부금비"), + ("safety_management_cost", "산업안전보건관리비"), + ("other_expense", "기타 경비"), + ("performance_guarantee_fee", "공사이행 수수료"), + ("subcontract_payment_guarantee", "하도대금 수수료"), + ("environment_preservation", "환 경 보 전 비"), + ("equipment_payment_guarantee", "건설기계 대여금"), + ("asbestos_contribution", "석면분담금"), + ("wage_claim_contribution", "임금채권부담금"), + ("waste_disposal", "폐기물처리비"), +) + + +def _row( + mark: str, label: str, amount: Decimal, level: int, key: str, result: CostResult | None = None +) -> dict[str, Any]: + line = result.line(key) if result is not None and result.has(key) else None + return { + "key": key, + "mark": mark, + "label": label, + "level": level, + "formula": line.formula_text if line else "", + "rate_percent": None + if line is None or line.rate_percent is None + else str(line.rate_percent), + "amount_krw": str(amount), + "note": line.note if line else "", + "total": level == 0, + } + + +def sheet_rows(result: CostResult, data: CostInput) -> list[dict[str, Any]]: + """일반형식 서식 줄 — 나.순공사원가 … 바.총공사비. 엔진에 없는 줄은 안 세움.""" + amount = result.amount + rows = [ + _row( + "나.", + "순공사원가(ㄱ+ㄴ+ㄷ)", + amount("net_construction_cost"), + 0, + "net_construction_cost", + result, + ), + _row("ㄱ.", "재 료 비", amount("material_cost"), 1, "material_cost", result), + _row("ㄴ.", "노 무 비", amount("labor_cost"), 1, "labor_cost", result), + _row("1)", "직접노무비", data.direct_labor_krw, 2, "direct_labor"), + _row("2)", "간접노무비", amount("indirect_labor_cost"), 2, "indirect_labor_cost", result), + _row("ㄷ.", "경 비", amount("expense"), 1, "expense", result), + _row("1)", "내역서경비", data.direct_expense_krw, 2, "direct_expense"), + ] + number = 2 + for key, label in EXPENSE_ORDER: + if not result.has(key) or (key == "waste_disposal" and data.waste_separate_order): + continue + rows.append(_row(f"{number})", label, amount(key), 2, key, result)) + if key == "safety_management_cost": + # STmate 도 두 비교 줄을 나란히 둠 — 작은 쪽 채택. + for sub in ("safety_management_cost_a", "safety_management_cost_b"): + if result.has(sub): + rows.append(_row("", result.line(sub).name, amount(sub), 3, sub, result)) + number += 1 + overhead = amount("general_overhead") + rows.append(_row("5)", "일반관리비", overhead, 1, "general_overhead", result)) + rows.append( + { + **_row("다.", "소 계", amount("net_construction_cost") + overhead, 0, "subtotal"), + "formula": "나.순공사원가 + 5)일반관리비", + } + ) + if result.has("profit_adjustment"): + rows.append( + _row( + "", + "이윤(보정 전)", + amount("profit_before_adjustment"), + 2, + "profit_before_adjustment", + result, + ) + ) + rows.append( + _row("", "이윤 보정액", amount("profit_adjustment"), 2, "profit_adjustment", result) + ) + profit_row = _row("6)", "이윤", amount("profit"), 1, "profit", result) + if result.has("profit_before_adjustment"): + before = result.line("profit_before_adjustment") + profit_row.update(formula=before.formula_text, rate_percent=str(before.rate_percent)) + rows.append(profit_row) + rows.append(_row("라.", "공급가액", amount("total_cost"), 0, "total_cost", result)) + rows.append(_row("", "부가가치세", amount("vat"), 1, "vat", result)) + rows.append(_row("마.", "도급공사비", amount("contract_amount"), 0, "contract_amount", result)) + if result.has("owner_supplied_material_total"): + rows.append( + _row( + "", + "관급자재대", + amount("owner_supplied_material_total"), + 1, + "owner_supplied_material_total", + result, + ) + ) + if data.waste_separate_order and result.has("waste_disposal"): + rows.append( + _row( + "", "폐기물처리비(분리발주)", amount("waste_disposal"), 1, "waste_disposal", result + ) + ) + rows.append(_row("바.", "총공사비", amount("grand_total"), 0, "grand_total", result)) + return rows + + +def status_line(result: CostResult, data: CostInput) -> dict[str, str]: + """아래 상태줄 — DFM `직재: / 직노: / 산경: / 이윤:` 차례.""" + return { + "직재": str(data.direct_material_krw), + "직노": str(data.direct_labor_krw), + "산경": str(data.direct_expense_krw), + "이윤": str(result.amount("profit")), + } diff --git a/B09_Estimation/B09_Estimation_RateTable.py b/B09_Estimation/B09_Estimation_RateTable.py new file mode 100644 index 00000000..3ae2192b --- /dev/null +++ b/B09_Estimation/B09_Estimation_RateTable.py @@ -0,0 +1,235 @@ +"""B09 제비율 요율표 화면 자료 — STmate 「원가계산기준」(wM_KAN_RATE) 첫 탭 차례 (PLAN 12장). + +⚠ 요율 데이터(`rates_<연도>.json`)를 **읽어 보이기만** 한다 — 묶음 제목·산식 표기는 DFM + `TWM_KAN_RATE` 첫 탭(☞ 공사원가계산 제잡비율) 차례를 따르고, 값·구간은 우리 데이터 그대로. +⚠ 수정은 아직 없음 — 엔진이 프로젝트 덮어쓰기 요율을 받는 칸이 서야 열 수 있음. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_CostSheet import SAFETY_LABELS, WORK_TYPE_LABELS +from B09_Estimation.B09_Estimation_Rates import RateDataset, _bracket_bounds, _duration_bounds + +_EOK = Decimal(100_000_000) + + +def _won(amount: Decimal) -> str: + """원 → 「50억」·「5천만」 표기.""" + if amount >= _EOK: + return f"{amount / _EOK:g}억" + return f"{amount / 10_000_000:g}천만" + + +def amount_label(label: str) -> str: + """구간 키 → 사람 표기. 모르는 키는 그대로(지어내지 않음).""" + bounds = _bracket_bounds(label) + if bounds is None: + return label + low, high = bounds + if low == 0: + return f"{_won(high)} 미만" + if high == Decimal("Infinity"): + return f"{_won(low)} 이상" + (" " + label.split("_", 3)[-1] if label.count("_") > 2 else "") + return f"{_won(low)}~{_won(high)} 미만" + + +def duration_label(label: str) -> str: + bounds = _duration_bounds(label) + if bounds is None: + return label + low, high = bounds + if low == 0: + return f"{high}일 이하" + if high >= 10**9: + return f"{low}일 이상" + return f"{low}~{high}일" + + +def _type(value: str, safety: bool = False) -> str: + return {**WORK_TYPE_LABELS, **(SAFETY_LABELS if safety else {})}.get(value, value) + + +def _pivot(rows: list[dict[str, Any]], row_keys: tuple[str, ...], col_key: str, safety=False): + """세로 줄을 (구간…) × 공종 표로 편다 — DFM 표 모양(행 구간 · 열 공종).""" + columns = list(dict.fromkeys(str(row[col_key]) for row in rows)) + grid: dict[tuple[str, ...], dict[str, Any]] = {} + for row in rows: + grid.setdefault(tuple(str(row[k]) for k in row_keys), {})[str(row[col_key])] = row + return columns, grid + + +def rate_sections(dataset: RateDataset) -> list[dict[str, Any]]: + """묶음 목록 `{title, formula, columns, rows}` — DFM 첫 탭 위→아래 차례.""" + v = dataset.variables + sections: list[dict[str, Any]] = [] + + overhead = v["rate_overhead"]["civil_landscape_industrial"] + profit = { + row["estimated_price_bracket"]: row["rate_percent"] for row in v["rate_profit"]["brackets"] + } + sections.append( + { + "title": "일반관리비 · 이윤", + "formula": "일반관리비 (재+노+경) × 율 · 이윤 (노+경+일) × 율", + "columns": ["공사규모(추정가격)", "일반관리비(%)", "이윤(%)"], + "rows": [ + [ + amount_label(row["estimated_price_bracket"]), + row["rate_percent"], + profit.get(row["estimated_price_bracket"], ""), + ] + for row in overhead + ], + } + ) + for variable, title, formula in ( + ("rate_indirect_labor", "간접노무비", "(직접노무비) × 율(%)"), + ("rate_other_expense", "기타경비", "(재료비+노무비) × 율(%)"), + ): + columns, grid = _pivot( + v[variable]["brackets"], ("direct_cost_bracket", "duration_bracket"), "work_type" + ) + sections.append( + { + "title": title, + "formula": formula, + "columns": ["공사규모(직접공사비)", "공사기간", *(_type(c) for c in columns)], + "rows": [ + [ + amount_label(size), + duration_label(days), + *(cells[c]["rate_percent"] if c in cells else "" for c in columns), + ] + for (size, days), cells in grid.items() + ], + } + ) + sections.append( + { + "title": "고용보험료", + "formula": "(노무비) × 율", + "columns": ["등급", "추정금액", "요율(%)"], + "rows": [ + [row["grade"], amount_label(row["estimated_amount_bracket"]), row["rate_percent"]] + for row in v["rate_goyong"]["brackets"] + ], + } + ) + columns, grid = _pivot( + v["rate_safety_pct"]["brackets"], ("target_amount_bracket",), "work_type" + ) + sections.append( + { + "title": "산업안전보건관리비", + "formula": "(재료비+직접노무비+관급자재) × 율 + 기초액", + "columns": ["대상액", *(f"{_type(c, safety=True)}(%)" for c in columns), "기초액(원)"], + "rows": [ + [ + amount_label(size), + *(cells[c]["rate_percent"] if c in cells else "" for c in columns), + next( + ( + cells[c].get("base_amount_krw") + for c in columns + if cells.get(c, {}).get("base_amount_krw") + ), + "", + ), + ] + for (size,), cells in grid.items() + ], + } + ) + sections.append( + { + "title": "환 경 보 전 비", + "formula": "(재료비+직접노무비+산출경비) × 율", + "columns": ["구 분", "요율(%)"], + "rows": [ + [_type(row["work_type"]), row["rate_percent"]] + for row in v["rate_environment"]["all_work_types"] + ], + } + ) + sections.append( + { + "title": "공사이행보증수수료", + "formula": "[추가금액 + (직접공사비-기준금액) × 율] × 공기(년)", + "columns": ["공사규모(직접공사비)", "수수료 산식"], + "rows": [ + [ + amount_label(row["direct_cost_bracket"]), + str(row.get("formula", "")) + .replace("direct_cost", "직공비") + .replace("duration_years", "공기(년)"), + ] + for row in v["rate_performance_guarantee_fee"]["brackets"] + ], + } + ) + sections.append( + { + "title": "건설하도급대금지급보증서발급수수료", + "formula": "(직접공사비) × 율", + "columns": ["공사규모", "요율(%)"], + "rows": [ + [amount_label(row["estimated_price_bracket"]), row["rate_percent"]] + for row in v["rate_subcontract_payment_guarantee"]["brackets"] + ], + } + ) + equipment = v["rate_equipment_payment_guarantee"] + sections.append( + { + "title": "건설기계대여대금 지급보증서 발급금액", + "formula": "(직접공사비) × 율", + "columns": ["구 분", "요율(%)"], + "rows": [ + *( + [_type(row["work_type"]), row["rate_percent"]] + for row in equipment["general_construction"] + ), + *( + ["전문건설 — " + row["work_type"], row["rate_percent"]] + for row in equipment["specialty_construction"] + ), + ], + } + ) + pension = next( + ( + row["rate_percent"] + for row in v["rate_pension"]["annual_rates"] + if row["year"] == int(str(dataset.version_stamp.get("effective_date", "0"))[:4] or 0) + ), + "", + ) + sections.append( + { + "title": "퇴직부금비 · 법 정 부 담 금 · 보험료", + "formula": "", + "columns": ["항목", "산식", "요율(%)"], + "rows": [ + [ + "퇴직공제부금비", + "(직접노무비) × 율", + v["rate_retirement_mutual_aid"]["rate_percent"], + ], + ["석면분담금", "(노무비) × 율", v["rate_asbestos_contribution"]["rate_percent"]], + [ + "임금채권부담금", + "(노무비) × 율", + v["rate_wage_claim_contribution"]["rate_percent"], + ], + ["산재보험료", "(노무비) × 율", v["rate_sanjae"]["rate_percent"]], + ["건강보험료", "(직접노무비) × 율", v["rate_health"]["rate_percent"]], + ["장기요양보험료", "(건강보험료) × 율", v["rate_care"]["rate_percent"]], + ["연금보험료", "(직접노무비) × 율", pension], + ["부가가치세", "(공급가액) × 율", v["rate_vat"]["rate_percent"]], + ], + } + ) + return sections diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py new file mode 100644 index 00000000..c4cd62d6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -0,0 +1,158 @@ +"""B09 원가계산서 · 제비율 요율표 탭 API (PLAN 12장 · 랩탑 메인). + +⚠ 옛 `/estimation/cost`(수기 입력)는 그대로 둔다 — 새 탭은 **프로젝트 값**으로 선다: + 직접비 = 예산내역서(`/estimation/bill`) 합계 · 기준 입력 = `estimation.cost_sheet` 저장값 · + 폐기물처리비 = B08 「임목폐기물 처리」 톤 × 수동 처리단가(분리발주 여부도 B08 산출 조건). +⚠ 화면이 값을 만들지 않는다 — 서식 줄·금액·산식은 전부 여기서 낸다. +""" + +from __future__ import annotations + +import json +import logging +import math +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_CostSheet import ( + AMOUNT_FIELDS, + SETTINGS_KEY, + WORK_TYPE_FIELDS, + clean_settings, + cost_input, + field_options, + sheet_rows, + status_line, +) +from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost +from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset +from B09_Estimation.B09_Estimation_RateTable import rate_sections +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Cost Sheet"]) + +TREE_WASTE_ITEM = "임목폐기물 처리" + + +async def _root(project_id: UUID) -> str | None: + from B09_Estimation.B09_Estimation_Router import _project_root_of + + return await _project_root_of(project_id) + + +def _waste(bill: dict[str, Any], quantity: dict[str, Any]) -> tuple[Decimal, bool, str]: + """(금액, 분리발주, 사유) — 톤은 내역서 제외 줄에서, 단가는 B08 수동 입력에서.""" + tons = next( + ( + Decimal(str(row.get("quantity") or 0)) + for row in bill.get("excluded") or [] + if row.get("name") == TREE_WASTE_ITEM + ), + Decimal(0), + ) + price = quantity.get("tree_waste_unit_price_krw_per_ton") + separate = bool(quantity.get("waste_separate_order")) + if tons <= 0: + return Decimal(0), separate, "임목폐기물 톤이 아직 안 섬(수량산출 산출 조건)" + if not price: + return Decimal(0), separate, f"임목폐기물 {tons:,.2f}톤 — 처리단가 없음(수동 입력 전)" + amount = Decimal(math.floor(tons * Decimal(str(price)))) + return amount, separate, f"임목폐기물 {tons:,.2f}톤 × {price:,.0f}원 — ⚠ 수동 단가(미확정)" + + +@router.get("/{project_id}/estimation/cost-sheet") +async def get_cost_sheet(project_id: UUID) -> JSONResponse: + """원가계산서(일반형식) 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.""" + from B09_Estimation.B09_Estimation_Router import get_bill + from common_util.common_util_project_settings import estimation_settings, quantity_settings + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + response = await get_bill(project_id) + bill = json.loads(bytes(response.body).decode("utf-8")) + if response.status_code != 200 or "summary" not in bill: + # 내역서가 안 서면 원가계산서도 못 섬 — 사유를 그대로 넘긴다. + return JSONResponse(status_code=response.status_code or 502, content=bill) + summary = bill["summary"] + place = OutputPlace.RESOURCE_SUMMARY + direct = { + part: round_at(Decimal(str(summary.get(f"direct_{part}_krw") or 0)), place) + for part in ("material", "labor", "expense") + } + stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {}) + waste, separate, waste_note = _waste(bill, quantity_settings(root)) + data = cost_input(direct, stored, waste, separate) + try: + dataset = load_rate_dataset(data.rate_file_name) + result = calculate_cost(data) + except RateLookupError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + return JSONResponse( + content={ + "status": "success", + "form": "일반", + "rows": sheet_rows(result, data), + "status_line": status_line(result, data), + "settings": { + **{key: getattr(data, key) for key, _ in WORK_TYPE_FIELDS}, + **{key: str(getattr(data, key)) for key, _ in AMOUNT_FIELDS}, + "duration_days": data.duration_days, + }, + "stored": stored, + "fields": { + "work_types": [{"key": k, "label": label} for k, label in WORK_TYPE_FIELDS], + "amounts": [{"key": k, "label": label} for k, label in AMOUNT_FIELDS], + "options": field_options(dataset), + }, + "waste_note": waste_note, + "bill_missing_count": len(summary.get("missing") or []), + "bill_unconfirmed_count": int(summary.get("unconfirmed_count") or 0), + "rate_version": result.rate_version, + "notes": result.notes, + } + ) + + +@router.put("/{project_id}/estimation/cost-sheet") +async def put_cost_sheet(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """기준 입력 저장 — `estimation.cost_sheet` 한 칸만 통째로 갈아 끼움.""" + from common_util.common_util_project_settings import save_section + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + cleaned = clean_settings(body, load_rate_dataset()) + try: + save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,)) + except Exception: + logger.exception("B09 원가계산서 기준 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산서 기준을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "stored": cleaned}) + + +@router.get("/{project_id}/estimation/rate-table") +async def get_rate_table(project_id: UUID) -> JSONResponse: + """제비율 요율표 — 지금 판의 요율 데이터를 DFM 첫 탭 묶음 차례로(보기 전용).""" + dataset = load_rate_dataset() + return JSONResponse( + content={ + "status": "success", + "rate_version": dataset.version_stamp, + "sections": rate_sections(dataset), + } + ) diff --git a/B09_Estimation/B09_Estimation_UI_Shell_Types.ts b/B09_Estimation/B09_Estimation_UI_Shell_Types.ts new file mode 100644 index 00000000..c2d67b50 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Shell_Types.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B09_Estimation_UI_Shell_Types.ts + * B09 원가계산 화면 틀 ↔ 탭 파일 계약 (PLAN 12장 · 2026-09-14 브레인 판정) + * + * - 탭마다 파일 하나: `B09_Estimation_UI_Tab_<이름>.ts` 가 `B09Tab` 하나를 내보냄. + * - 틀(`B09_Estimation_UI_Shell.ts`)은 **등록 한 줄씩**만 가짐. 등록 권한 = 랩탑_서브. + * - 틀은 탭을 고를 때마다 `body`·`panel` 을 비우고 `render(ctx, arg)` 를 부름. + * 탭의 자료·상태는 탭 파일이 스스로 들고 있음(다시 그릴 때 다시 받을지도 탭이 정함). + * - [재계산]·[확정]·[저장] 같은 단추는 탭이 자기 `panel`·`body` 에 둠 — 틀에 훅 없음. + * - ⚠ 화면이 값을 만들지 않음 — 표시·입력만. 계산은 서버. + * ========================================================================== */ + +export interface B09TabContext { + /** 지금 프로젝트 — 없으면 탭이 「프로젝트를 고르세요」 식으로 비워 둠. */ + projectId: string | null; + /** 탭 본문 자리 — 고를 때마다 비워서 줌. */ + body: HTMLElement; + /** 좌측 칸 자리 — 고를 때마다 비워서 줌. 안 쓰면 비워 둠. */ + panel: HTMLElement; + /** 페이지 뿌리 — 근거 토글(`createProvenanceToggle`) 따위가 씀. */ + root: HTMLElement; + /** 다른 탭으로 들어가기 — 내역 줄 → 일위대가 호표 → 단가산출근거처럼. `arg` 는 받는 탭이 읽음. */ + open: (key: string, arg?: string) => void; +} + +export interface B09Tab { + /** 탭 id — 틀 안에서 유일(`cost_sheet` · `boq` · `unit_price` · `price_basis` …). */ + key: string; + /** 탭 제목 — 언어 바뀜을 따르게 함수로. */ + label: () => string; + /** 고를 때마다 불림. `arg` = 다른 탭이 `open(key, arg)` 로 넘긴 값(없으면 undefined). */ + render: (ctx: B09TabContext, arg?: string) => void; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts new file mode 100644 index 00000000..d28d2c6f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -0,0 +1,347 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_CostSheet.ts + * 원가계산서 탭 — STmate 「제잡비 계산」(wM_KanJub) 일반형식을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 기준 입력판: 번호·제목은 DFM `TWM_KANJUB` 그대로. 엔진이 받는 칸만 엶. + * - 본문 = 서식(점선 라벨 + 오른쪽 금액) · 아래 형식 탭(일반형식만 켜짐) · 상태줄(직재·직노·산경·이윤). + * - ⚠ 값은 서버(`/estimation/cost-sheet`)가 냄 — 여기는 그리기·입력만(지침 5장). + * - 입력은 캐시(모듈 안)에만 쌓이고 [저장]에서 정본으로 감 · 자동저장 없음. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface SheetRow { + key: string; + mark: string; + label: string; + level: number; + formula: string; + rate_percent: string | null; + amount_krw: string; + note: string; + total: boolean; +} + +interface FieldOption { + value: string; + label: string; +} + +interface CostSheetDto { + status: string; + message?: string; + rows: SheetRow[]; + status_line: Record; + settings: Record; + fields: { + work_types: { key: string; label: string }[]; + amounts: { key: string; label: string }[]; + options: Record; + }; + waste_note: string; + bill_missing_count: number; + bill_unconfirmed_count: number; + rate_version: { dataset_id: string; effective_date: string }; + notes: string[]; +} + +/** DFM 기준 입력판 첫 칸 「제잡비계산형식」 선택지 그대로. 지금은 「일반 형식」만 엶. */ +const FORMS = [ + "일반 형식", + "토지개발공사 형식", + "인천상수도 형식", + "수자원공사 형식", + "지역난방공사 형식", + "고용개선지원(서울)", +]; +/** DFM 본문 아래 탭 이름 그대로. */ +const FORM_TABS = ["일반형식", "수공형식", "실적일반", "실적수공"]; + +const STYLE_ID = "b09-cost-sheet-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09cs { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09cs__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; } +.b09cs__title { font-weight: 600; } +.b09cs__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09cs__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09cs__sheet { flex: 1; overflow: auto; min-height: 0; border: 1px solid var(--color-border); padding: 8px 12px; } +.b09cs__line { display: flex; align-items: baseline; gap: 6px; font-size: 13px; line-height: 1.9; } +.b09cs__mark { flex: 0 0 2.4em; text-align: right; } +.b09cs__label { flex: 1; min-width: 0; display: flex; gap: 4px; overflow: hidden; white-space: nowrap; } +.b09cs__label::after { content: ""; flex: 1; border-bottom: 1px dotted var(--color-border); margin-bottom: 4px; } +.b09cs__formula { color: var(--color-text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; } +.b09cs__amount { flex: 0 0 11em; text-align: right; font-variant-numeric: tabular-nums; } +.b09cs__line--total { font-weight: 600; } +.b09cs__line--total .b09cs__amount { flex-basis: 12.5em; } +.b09cs__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } +.b09cs__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } +.b09cs__tab[aria-selected="true"] { font-weight: 600; background: var(--color-surface, #fff); } +.b09cs__status { display: flex; gap: 16px; font-size: 12px; font-variant-numeric: tabular-nums; } +.b09cs__panel { display: flex; flex-direction: column; gap: 6px; } +.b09cs__field { display: flex; flex-direction: column; gap: 2px; font-size: 12px; } +.b09cs__field select, .b09cs__field input { width: 100%; } +.b09cs__group { font-size: 12px; font-weight: 600; margin-top: 6px; } +.b09cs__buttons { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +`; + document.head.append(style); +} + +function won(value: string): string { + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +/** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */ +const drafts = new Map>(); + +async function fetchSheet(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`, + { credentials: "include" }, + ); + const body = (await response.json()) as CostSheetDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveSheet(projectId: string, values: Record): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); +} + +function selectField( + label: string, + value: string, + options: { value: string; label: string; disabled?: boolean }[], + onChange: (value: string) => void, +): HTMLElement { + const wrap = el("label", "b09cs__field"); + wrap.append(el("span", "", label)); + const select = el("select"); + for (const option of options) { + const node = el("option", "", option.label); + node.value = option.value; + node.disabled = Boolean(option.disabled); + select.append(node); + } + select.value = value; + select.addEventListener("change", () => onChange(select.value)); + wrap.append(select); + return wrap; +} + +function numberField(label: string, value: string, onInput: (value: string) => void): HTMLElement { + const wrap = el("label", "b09cs__field"); + wrap.append(el("span", "", label)); + const input = el("input"); + input.type = "number"; + input.min = "0"; + input.value = value; + input.addEventListener("input", () => onInput(input.value)); + wrap.append(input); + return wrap; +} + +function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): void { + const projectId = ctx.projectId as string; + const draft = + drafts.get(projectId) ?? + Object.fromEntries(Object.entries(sheet.settings).map(([k, v]) => [k, String(v)])); + drafts.set(projectId, draft); + const box = el("div", "b09cs__panel"); + box.append(el("div", "b09cs__group", "기준 입력")); + box.append( + selectField( + "제잡비계산형식 :", + FORMS[0], + FORMS.map((name, i) => ({ + value: name, + label: i ? `${name} (준비 중)` : name, + disabled: i > 0, + })), + () => undefined, + ), + ); + const workType = (key: string) => sheet.fields.work_types.find((f) => f.key === key); + const typeSelect = (key: string) => { + const field = workType(key); + if (!field) return; + box.append( + selectField(field.label + " :", draft[key] ?? "", sheet.fields.options[key] ?? [], (v) => { + draft[key] = v; + }), + ); + }; + typeSelect("work_type_indirect_labor"); + box.append( + numberField("3.공사의기간(일) :", draft.duration_days ?? "", (v) => { + draft.duration_days = v; + }), + ); + for (const field of sheet.fields.amounts.slice(0, 2)) { + box.append( + numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), + ); + } + typeSelect("work_type_safety"); + typeSelect("environment_work_type"); + typeSelect("equipment_guarantee_work_type"); + box.append(el("div", "b09cs__group", "계산/인쇄 설정")); + for (const field of sheet.fields.amounts.slice(2)) { + box.append( + numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), + ); + } + + const buttons = el("div", "b09cs__buttons"); + buttons.append( + createButton({ + label: "제비율표", + variant: "outlined", + onClick: () => ctx.open("rate_table"), + }), + createButton({ + label: "다시 계산", + variant: "outlined", + onClick: () => { + drafts.delete(projectId); + reload(); + }, + }), + createButton({ + label: "저장", + onClick: async () => { + try { + await saveSheet(projectId, draft); + drafts.delete(projectId); + showToast("원가계산서 기준 저장", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + box.append(buttons); + ctx.panel.append(box); +} + +function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void { + const wrap = el("div", "b09cs"); + const head = el("div", "b09cs__head"); + head.append( + el("span", "b09cs__title", "제잡비 계산 — 일반형식"), + el( + "span", + "b09cs__meta", + `요율 판 ${sheet.rate_version.effective_date} · 직접비는 설계내역서 합계`, + ), + ); + if (sheet.bill_missing_count || sheet.bill_unconfirmed_count) { + head.append( + el( + "span", + "b09cs__warn", + `⚠ 내역서 단가 없는 줄 ${sheet.bill_missing_count} · 미확정 ${sheet.bill_unconfirmed_count}건 — 직접비가 덜 섬`, + ), + ); + } + if (sheet.waste_note) head.append(el("span", "b09cs__meta", `폐기물처리비: ${sheet.waste_note}`)); + wrap.append(head); + + const body = el("div", "b09cs__sheet"); + for (const row of sheet.rows) { + const line = el("div", `b09cs__line${row.total ? " b09cs__line--total" : ""}`); + line.style.paddingLeft = `${row.level * 1.2}em`; + line.append(el("span", "b09cs__mark", row.mark)); + const label = el("span", "b09cs__label"); + label.append(el("span", "", row.label)); + if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`)); + label.title = [row.formula, row.note].filter(Boolean).join(" · "); + line.append(label, el("span", "b09cs__amount", won(row.amount_krw))); + body.append(line); + } + wrap.append(body); + + const tabs = el("div", "b09cs__tabs"); + FORM_TABS.forEach((name, i) => { + const tab = el("button", "b09cs__tab", name); + tab.setAttribute("aria-selected", String(i === 0)); + tab.disabled = i > 0; + if (i > 0) tab.title = "준비 중 — 일반형식부터 세움"; + tabs.append(tab); + }); + wrap.append(tabs); + + const status = el("div", "b09cs__status"); + for (const [name, value] of Object.entries(sheet.status_line)) { + status.append(el("span", "", `${name}: ${won(value)}`)); + } + wrap.append(status); + for (const note of sheet.notes) wrap.append(el("div", "b09cs__meta", note)); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09cs__meta", "프로젝트를 고르세요")); + return; + } + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…")); + ctx.panel.replaceChildren(); + fetchSheet(ctx.projectId as string) + .then((sheet) => { + ctx.body.replaceChildren(); + drawPanel(ctx, sheet, load); + drawBody(ctx, sheet); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09cs__warn", + `원가계산서를 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +export const costSheetTab: B09Tab = { + key: "cost_sheet", + label: () => L("B09_Estimation_Tab_CostSheet"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts b/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts new file mode 100644 index 00000000..22bf6fb8 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_RateTable.ts + * 제비율 요율표 탭 — STmate 「원가계산기준」(wM_KAN_RATE)을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` (지금은 잠김 — 덮어쓰기 칸 전). + * - 본문 = 첫 탭 「☞ 공사원가계산 제잡비율」 묶음 차례 · 아래 탭 넷(첫 탭만 켜짐). + * - ⚠ 요율은 서버(`/estimation/rate-table`)가 요율 데이터에서 그대로 냄 — 보기 전용. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface RateSection { + title: string; + formula: string; + columns: string[]; + rows: (string | number)[][]; +} + +interface RateTableDto { + status: string; + message?: string; + rate_version: { dataset_id: string; effective_date: string; sha256: string }; + sections: RateSection[]; +} + +/** DFM 아래 탭 — 화면에 보이는 묶음 제목 그대로. */ +const RATE_TABS = [ + "☞ 공사원가계산 제잡비율", + "표준시장단가 제잡비율", + "☞ 행정자치부 적용 제잡비율", + "☞ 실적공사비 적용시 제경비율 조정계수", +]; + +const STYLE_ID = "b09-rate-table-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09rt { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09rt__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09rt__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09rt__section { border: 1px solid var(--color-border); padding: 6px 8px; } +.b09rt__title { font-weight: 600; font-size: 13px; } +.b09rt__formula { font-size: 12px; color: var(--color-text-secondary); margin-left: 6px; font-weight: normal; } +.b09rt__table { border-collapse: collapse; font-size: 12px; margin-top: 4px; } +.b09rt__table th, .b09rt__table td { border: 1px solid var(--color-border); padding: 2px 8px; } +.b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; } +.b09rt__table td:first-child, .b09rt__table td.b09rt__text { text-align: left; } +.b09rt__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } +.b09rt__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } +.b09rt__tab[aria-selected="true"] { font-weight: 600; } +.b09rt__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +async function fetchRates(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, + { credentials: "include" }, + ); + const body = (await response.json()) as RateTableDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +function drawPanel(ctx: B09TabContext): void { + const box = el("div", "b09rt__panel"); + const pick = el("label", "b09rt__panel"); + pick.append(el("span", "", "수정기준선택 ☞")); + const select = el("select"); + for (const [i, name] of ["기본제비율", "표준시장", "행자부기준"].entries()) { + const option = el("option", "", i ? `${name} (준비 중)` : name); + option.disabled = i > 0; + select.append(option); + } + pick.append(select); + const edit = createButton({ label: "수 정", variant: "outlined", disabled: true }); + edit.title = "요율 수정은 원가계산이 프로젝트 요율을 받는 칸이 선 뒤에 열림"; + box.append( + pick, + edit, + el("span", "b09rt__meta", "요율은 조달청 제비율 판 그대로 — 지금은 보기 전용"), + createButton({ + label: "원가계산서", + variant: "outlined", + onClick: () => ctx.open("cost_sheet"), + }), + ); + ctx.panel.append(box); +} + +function drawBody(ctx: B09TabContext, data: RateTableDto): void { + const wrap = el("div", "b09rt"); + wrap.append( + el( + "div", + "b09rt__meta", + `원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})`, + ), + ); + const scroll = el("div", "b09rt__scroll"); + for (const section of data.sections) { + const box = el("div", "b09rt__section"); + const title = el("div", "b09rt__title", section.title); + if (section.formula) title.append(el("span", "b09rt__formula", section.formula)); + box.append(title); + const table = el("table", "b09rt__table"); + const head = el("tr"); + for (const column of section.columns) head.append(el("th", "", column)); + table.append(head); + for (const row of section.rows) { + const tr = el("tr"); + for (const cell of row) { + tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell))); + } + table.append(tr); + } + box.append(table); + scroll.append(box); + } + wrap.append(scroll); + const tabs = el("div", "b09rt__tabs"); + RATE_TABS.forEach((name, i) => { + const tab = el("button", "b09rt__tab", name); + tab.setAttribute("aria-selected", String(i === 0)); + tab.disabled = i > 0; + if (i > 0) tab.title = "준비 중"; + tabs.append(tab); + }); + wrap.append(tabs); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요")); + return; + } + drawPanel(ctx); + ctx.body.append(el("div", "b09rt__meta", "요율표 받는 중…")); + fetchRates(ctx.projectId) + .then((data) => { + ctx.body.replaceChildren(); + drawBody(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09rt__meta", + `요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); +} + +export const rateTableTab: B09Tab = { + key: "rate_table", + label: () => L("B09_Estimation_Tab_RateTable"), + render, +}; diff --git a/main.py b/main.py index 98f805d1..3f0792d6 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_r from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -636,6 +637,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_structure_sheet_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) +app.include_router(b09_cost_sheet_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_b09_cost_sheet_layout.py b/resources/tester/test_b09_cost_sheet_layout.py new file mode 100644 index 00000000..d3e947d2 --- /dev/null +++ b/resources/tester/test_b09_cost_sheet_layout.py @@ -0,0 +1,115 @@ +"""원가계산서 탭(일반형식) — STmate 서식 차례로 우리 엔진 줄을 늘어놓는가 (PLAN 12장). + +⚠ 금액을 박지 않는다 — 차례·표기·자리(경비 안/총원가 밖)와 「줄 = 엔진 값」만 잰다. +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_CostSheet import ( # noqa: E402 + clean_settings, + cost_input, + field_options, + sheet_rows, +) +from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import load_rate_dataset # noqa: E402 +from B09_Estimation.B09_Estimation_RateTable import amount_label, rate_sections # noqa: E402 + +DIRECT = { + "material": Decimal(30_000_000), + "labor": Decimal(40_000_000), + "expense": Decimal(10_000_000), +} + + +def _sheet(waste: Decimal = Decimal(0), separate: bool = False, stored: dict | None = None): + data = cost_input(DIRECT, stored or {}, waste, separate) + return sheet_rows(calculate_cost(data), data), calculate_cost(data) + + +def test_큰_묶음이_STmate_차례로_선다() -> None: + rows, _ = _sheet() + marks = [ + row["mark"] + for row in rows + if row["mark"] in ("나.", "ㄱ.", "ㄴ.", "ㄷ.", "다.", "라.", "마.", "바.") + ] + assert marks == ["나.", "ㄱ.", "ㄴ.", "ㄷ.", "다.", "라.", "마.", "바."] + + +def test_줄_금액은_엔진_값_그대로() -> None: + rows, result = _sheet() + by_key = {row["key"]: row for row in rows} + for key in ("net_construction_cost", "general_overhead", "profit", "total_cost", "grand_total"): + assert Decimal(by_key[key]["amount_krw"]) == result.amount(key) + assert Decimal(by_key["subtotal"]["amount_krw"]) == result.amount( + "net_construction_cost" + ) + result.amount("general_overhead") + + +def test_경비_세목은_2번부터_차례로_번호() -> None: + rows, _ = _sheet() + expense = [row for row in rows if row["level"] == 2 and row["mark"].endswith(")")] + numbers = [ + int(row["mark"][:-1]) + for row in expense + if row["key"] not in ("direct_labor", "indirect_labor_cost") + ] + assert numbers[0] == 1 and numbers[1:] == list(range(2, len(numbers) + 1)) + + +def test_폐기물은_기본_경비_안_분리발주면_도급공사비_뒤() -> None: + rows, _ = _sheet(Decimal(1_000_000)) + keys = [row["key"] for row in rows] + assert keys.index("waste_disposal") < keys.index("general_overhead") + rows, _ = _sheet(Decimal(1_000_000), separate=True) + keys = [row["key"] for row in rows] + assert keys.index("waste_disposal") > keys.index("contract_amount") + + +def test_저장값은_데이터에_있는_공종만_받는다() -> None: + dataset = load_rate_dataset() + cleaned = clean_settings( + { + "work_type_safety": "civil", + "environment_work_type": "moon_base", + "duration_days": "200", + "owner_supplied_material_krw": "-5", + "profit_adjustment_krw": "", + }, + dataset, + ) + assert cleaned == { + "work_type_safety": "civil", + "duration_days": 200, + "owner_supplied_material_krw": "0", + } + assert {o["value"] for o in field_options(dataset)["work_type_indirect_labor"]} == { + "civil", + "landscape", + "industrial_facilities_civil", + } + + +def test_저장한_기간이_간접노무비_구간을_바꾼다() -> None: + short = {r["key"]: r for r in _sheet(stored={"duration_days": 100})[0]} + long = {r["key"]: r for r in _sheet(stored={"duration_days": 800})[0]} + assert ( + short["indirect_labor_cost"]["rate_percent"] != long["indirect_labor_cost"]["rate_percent"] + ) + + +def test_요율표_구간_표기와_묶음() -> None: + assert amount_label("lt_5_billion") == "50억 미만" + assert amount_label("5_to_30_billion") == "50억~300억 미만" + titles = [section["title"] for section in rate_sections(load_rate_dataset())] + assert titles[0] == "일반관리비 · 이윤" and "산업안전보건관리비" in titles + for section in rate_sections(load_rate_dataset()): + assert all(len(row) == len(section["columns"]) for row in section["rows"]), section["title"] diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 0dcfca77..6d822373 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -17,6 +17,8 @@ * 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다. * ========================================================================== */ +// b4 = B09 원가계산서·제비율(랩탑 메인 벌). 다른 벌과 줄이 겹치지 않게 맨 앞에 둠. +import { ui_locales_b4 } from "./ui_template_locale_b4"; import { ui_locales_common } from "./ui_template_locale_common"; import { ui_locales_a } from "./ui_template_locale_a"; import { ui_locales_b1 } from "./ui_template_locale_b1"; @@ -49,6 +51,7 @@ export function t(key: keyof typeof ui_locales): string { /** 분할된 사전 4종을 합성한 단일 사전. 키는 파일 간 중복되지 않는다. */ export const ui_locales = { + ...ui_locales_b4, ...ui_locales_common, ...ui_locales_a, ...ui_locales_b1, diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts new file mode 100644 index 00000000..d0d3d7d6 --- /dev/null +++ b/ui_template/ui_template_locale_b4.ts @@ -0,0 +1,11 @@ +/* ============================================================================= + * ui_template_locale_b4.ts + * B09 원가계산서·제비율 요율표 탭 사전 (랩탑 메인 벌 · 2026-09-14 브레인 배정) + * + * b2 가 700줄을 넘어 벌을 나눔 — b3 은 랩탑_서브(내역서·일위대가·단가산출근거) 벌. + * 형식: 키 → [한국어, 영어]. + * ========================================================================== */ + +export const ui_locales_b4 = { + B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"], +} as const;