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:
2026-09-14 01:32:30 +09:00
co-authored by Claude Opus 5
parent 509660708b
commit cb33e81d98
10 changed files with 1357 additions and 0 deletions
+271
View File
@@ -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")),
}
+235
View File
@@ -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,
};
+2
View File
@@ -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"]
+3
View File
@@ -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,
+11
View File
@@ -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;