feat(b09): 원가계산서(일반형식)·제비율 요율표 탭 파일 — STmate 배치 · 값은 우리 엔진
- 서버: /estimation/cost-sheet(GET·PUT) · /estimation/rate-table — 새 라우터 파일
- 직접비 = 설계내역서 합계 · 기준 입력 = estimation.cost_sheet · 폐기물처리비 = 임목폐기물 톤 × 수동 단가
- 서식 줄: 나.순공사원가 … 바.총공사비 (DFM TWM_KANJUB 차례·표기) · 경비 세목 번호 차례
- 기준 입력판: 형식(일반만) · 2.공사의종류 · 3.공사기간 · 6.관/사급 · 7.산업안전 · 8.환경보전 · 9.건설기계 — 선택지는 요율 데이터에서
- 요율표: 첫 탭 묶음 10개(보기 전용 · 수정은 잠김)
- 탭 계약 파일은 서브 314c0040 과 같은 내용(blob 동일) · 사전은 새 벌 b4
- 시험: 서식 차례·엔진 값 일치·폐기물 자리·저장값 거름·구간 표기
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -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")),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
settings: Record<string, string | number>;
|
||||||
|
fields: {
|
||||||
|
work_types: { key: string; label: string }[];
|
||||||
|
amounts: { key: string; label: string }[];
|
||||||
|
options: Record<string, FieldOption[]>;
|
||||||
|
};
|
||||||
|
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<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
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<string, Record<string, string>>();
|
||||||
|
|
||||||
|
async function fetchSheet(projectId: string): Promise<CostSheetDto> {
|
||||||
|
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<string, string>): Promise<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
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<RateTableDto> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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_Material import router as b08_material_router
|
||||||
from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_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 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_audit import note_api_call, record_call_burst
|
||||||
from common_util.common_util_auth import (
|
from common_util.common_util_auth import (
|
||||||
require_company,
|
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_material_router, dependencies=protected_with_company)
|
||||||
app.include_router(b08_structure_sheet_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_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)
|
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
* 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다.
|
* 이 파일의 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_common } from "./ui_template_locale_common";
|
||||||
import { ui_locales_a } from "./ui_template_locale_a";
|
import { ui_locales_a } from "./ui_template_locale_a";
|
||||||
import { ui_locales_b1 } from "./ui_template_locale_b1";
|
import { ui_locales_b1 } from "./ui_template_locale_b1";
|
||||||
@@ -49,6 +51,7 @@ export function t(key: keyof typeof ui_locales): string {
|
|||||||
|
|
||||||
/** 분할된 사전 4종을 합성한 단일 사전. 키는 파일 간 중복되지 않는다. */
|
/** 분할된 사전 4종을 합성한 단일 사전. 키는 파일 간 중복되지 않는다. */
|
||||||
export const ui_locales = {
|
export const ui_locales = {
|
||||||
|
...ui_locales_b4,
|
||||||
...ui_locales_common,
|
...ui_locales_common,
|
||||||
...ui_locales_a,
|
...ui_locales_a,
|
||||||
...ui_locales_b1,
|
...ui_locales_b1,
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user