feat(b09): 총괄 요율 자동 고름 · 사용자 값 앞섬 · 고르는 값 칸(166-1)

- Rates.rate_table: M01 조달청 제비율 압축 표(구간 위 끝 · 토목) → payload rate_table
- Calc_Total 짝(py · ts): pick_rates 자동 고름(직접공사비 × 공기 · 추정가격 · 안전 대상액 + 기초액 · 연도 · 환경 공종) · 사용자 값 앞섬
- 총괄 탭: 추정가격 · 공사기간 · 환경보전비 공종 칸 · 요율 출처 칸(자동 근거 풍선 · 누르면 자동으로)
- Store: environment_kind 칸
- 시험: test_166_1 새 · test_145_4 node 입력을 stdin 으로(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-10 00:06:34 +09:00
co-authored by Claude Opus 5.5
parent 35d6b68c41
commit 58283a578f
9 changed files with 647 additions and 43 deletions
+102 -7
View File
@@ -11,15 +11,20 @@
estimate = 사용자 값(`user.estimate`) — rates {줄 키: %} · safety_base 기초액 ·
profit_adjust 이윤 보정 · vat_adjust 부가세 조정 · indirect_material 간접재료비 ·
scrap 작업설 · procurement_fee_pct 조달수수료율
table = M01 요율 압축 표(`B09_Estimation_Rates.rate_table` · payload `rate_table`) · year = 연금 해
요율 = 사용자 값(`rates`)이 앞섬 · 없으면 표에서 자동(166-1 `pick_rates` — 직접공사비 = 가.순공사비계 ·
공기 · 추정가격 = 사용자 칸 · 안전 대상액 = 재료비 + 직노 + 관급 ÷ 1.1 · 공종 = 토목 · 환경 = 그 밖 기본) ·
안전 기초액 = 사용자 값 ?? 자동 구간 기초액
식 · 끝수 = 엑셀 총괄 49~67 그대로(줄마다 ROUNDDOWN 0 · 밑수 = 앞 줄 버린 값 · 관급 ROUNDUP −3) ·
산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 ·
이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정.
빈칸 규칙 = 요율 · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 ·
빈칸 규칙 = 요율(사용자 · 자동 둘 다 없음) · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 ·
손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸).
"""
from __future__ import annotations
from datetime import date
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total
@@ -65,12 +70,71 @@ def procurement_fee(net: float | None, pct: float | None) -> float | None:
return _pct(net, pct)
def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) -> dict[str, Any]:
def _step(bounds: list[float], value: float) -> int:
"""구간 번호 — 위 끝(그 값 미만) 목록에서 value 가 넘은 개수."""
return sum(1 for b in bounds if value >= b)
def _band(bounds: list[float], i: int, days: bool = False) -> str:
def fmt(v: float) -> str:
return f"{v:g}일" if days else f"{v / 1e8:g}억"
if i == 0:
return f"{fmt(bounds[0])} 미만"
if i == len(bounds):
return f"{fmt(bounds[-1])} 이상"
return f"{fmt(bounds[i - 1])} 이상 {fmt(bounds[i])} 미만"
def _pick(spec: dict[str, Any], ctx: dict[str, Any]) -> tuple[float | None, float | None, str]:
"""표 한 줄 → (자동 율, 기초액, 까닭 글) — 고르는 값이 빈칸이면 율 None."""
if "pct" in spec:
return spec["pct"], None, "단일 요율"
if "price" in spec:
if ctx["price"] is None:
return None, None, "추정가격 빈칸"
i = _step(spec["price"], ctx["price"])
return spec["pcts"][i], None, f"추정가격 {_band(spec['price'], i)}"
if "grid" in spec:
if ctx["direct"] is None or ctx["days"] is None:
return None, None, "직접공사비 · 공기 빈칸"
i, j = _step(spec["direct"], ctx["direct"]), _step(spec["days"], ctx["days"])
why = f"직접공사비 {_band(spec['direct'], i)} · 공기 {_band(spec['days'], j, True)}"
return spec["grid"][i][j], None, why
if "target" in spec:
if ctx["target"] is None:
return None, None, "대상액 빈칸"
i = _step(spec["target"], ctx["target"])
return spec["pcts"][i], spec["bases"][i], f"대상액 {_band(spec['target'], i)}"
if "years" in spec:
pct = spec["years"].get(str(ctx["year"]))
return pct, None, f"{ctx['year']}년" if pct is not None else f"{ctx['year']}년 요율 없음"
if "kinds" in spec:
kind = "road" if ctx["env"] == "road" else "other"
return spec["kinds"].get(kind), None, "도로" if kind == "road" else "그 밖의 토목"
return None, None, ""
def pick_rates(table: dict[str, Any] | None, ctx: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""{줄 키: {rate, base, why}} — 표 없으면 {}(자동 없음)."""
out: dict[str, dict[str, Any]] = {}
for key, spec in (table if isinstance(table, dict) else {}).items():
rate, base, why = _pick(spec, ctx)
note = spec.get("note") or ""
out[key] = {"rate": rate, "base": base, "why": f"{why} · {note}" if note else why}
return out
def compute_total(
sums: dict[str, Any] | None,
estimate: dict[str, Any] | None,
table: dict[str, Any] | None = None,
year: int | None = None,
) -> dict[str, Any]:
"""총괄 제비율 줄 · 소계 · 관급 · 원가계산서 줄 · 구성비."""
sums = sums if isinstance(sums, dict) else {}
est = estimate if isinstance(estimate, dict) else {}
rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {}
rate = {key: num(rates_in.get(key)) for key, *_ in (*RATE_LINES, *TAIL_LINES)}
def hand(key: str) -> float:
return num(est.get(key)) or 0.0
@@ -78,6 +142,38 @@ def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None)
material, labor = num(sums.get("material")), num(sums.get("labor"))
expense, supply_net = num(sums.get("expense")), num(sums.get("supply_net"))
direct = total([material, labor, expense]) # 가. 순공사비계
labor_material = total([labor, material])
target = (
None
if labor_material is None
else labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR
)
auto = pick_rates(
table,
{
"direct": direct,
"target": target,
"price": num(est.get("estimated_price")),
"days": num(est.get("duration_days")),
"year": year or date.today().year,
"env": est.get("environment_kind"),
},
)
rates: dict[str, dict[str, Any]] = {}
for key, *_ in (*RATE_LINES, *TAIL_LINES):
mine, got = num(rates_in.get(key)), auto.get(key) or {}
rates[key] = {
"rate": got.get("rate") if mine is None else mine,
"from": "user"
if mine is not None
else ("auto" if got.get("rate") is not None else None),
"auto": got.get("rate"),
"why": got.get("why") or "",
}
rate = {key: r["rate"] for key, r in rates.items()}
base_in = num(est.get("safety_base"))
safety_base = base_in if base_in is not None else (auto.get("safety") or {}).get("base") or 0.0
rates["safety"]["base"] = safety_base
line: dict[str, float | None] = {}
line["indirect_labor"] = _pct(labor, rate["indirect_labor"])
@@ -89,10 +185,8 @@ def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None)
line["pension"] = _pct(labor, rate["pension"])
# 산업안전보건관리비 — A(관급 순자재대 ÷ 1.1 포함) · B(× 1.2) 중 작은 쪽(관급 없으면 A)
safety_a = safety_b = None
labor_material = total([labor, material])
if labor_material is not None and rate["safety"] is not None:
base = hand("safety_base")
target = labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR
if labor_material is not None and target is not None and rate["safety"] is not None:
base = safety_base
safety_a = rounddown(target * rate["safety"] / 100 + base)
safety_b = rounddown((labor_material * rate["safety"] / 100 + base) * _SAFETY_B)
line["safety"] = None if safety_a is None else min(safety_a, safety_b)
@@ -158,4 +252,5 @@ def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None)
"supply": {"net": supply_net, "fee": fee, "raw": supply_raw},
"cost": cost,
"share": share,
"rates": rates,
}
+91 -8
View File
@@ -6,7 +6,8 @@
* 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`)
* 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) ·
* table = M01 요율 압축 표(payload `rate_table`) · year = 연금 해 — 자동 고르기(166-1) = 파이썬 짝 머리 글
* 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로.
* ========================================================================== */
@@ -51,12 +52,67 @@ const pct = (base: Num, rate: Num): Num =>
/** 조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0) */
export const procurementFee = (net: Num, rate: Num): Num => pct(net, rate);
export function computeTotal(sums: Row | null | undefined, estimate: Row | null | undefined): Row {
/** 구간 번호 — 위 끝(그 값 미만) 목록에서 value 가 넘은 개수 */
const step = (bounds: number[], value: number): number => bounds.filter((b) => value >= b).length;
function band(bounds: number[], i: number, days = false): string {
const fmt = (v: number): string => (days ? `${v}일` : `${v / 1e8}억`);
if (i === 0) return `${fmt(bounds[0])} 미만`;
if (i === bounds.length) return `${fmt(bounds[bounds.length - 1])} 이상`;
return `${fmt(bounds[i - 1])} 이상 ${fmt(bounds[i])} 미만`;
}
/** 표 한 줄 → [자동 율, 기초액, 까닭 글] — 고르는 값이 빈칸이면 율 null */
function pick(spec: Row, ctx: Row): [Num, Num, string] {
if ("pct" in spec) return [spec.pct, null, "단일 요율"];
if ("price" in spec) {
if (ctx.price === null) return [null, null, "추정가격 빈칸"];
const i = step(spec.price, ctx.price);
return [spec.pcts[i], null, `추정가격 ${band(spec.price, i)}`];
}
if ("grid" in spec) {
if (ctx.direct === null || ctx.days === null) return [null, null, "직접공사비 · 공기 빈칸"];
const i = step(spec.direct, ctx.direct);
const j = step(spec.days, ctx.days);
const why = `직접공사비 ${band(spec.direct, i)} · 공기 ${band(spec.days, j, true)}`;
return [spec.grid[i][j], null, why];
}
if ("target" in spec) {
if (ctx.target === null) return [null, null, "대상액 빈칸"];
const i = step(spec.target, ctx.target);
return [spec.pcts[i], spec.bases[i], `대상액 ${band(spec.target, i)}`];
}
if ("years" in spec) {
const pct = spec.years[String(ctx.year)] ?? null;
return [pct, null, pct !== null ? `${ctx.year}년` : `${ctx.year}년 요율 없음`];
}
if ("kinds" in spec) {
const kind = ctx.env === "road" ? "road" : "other";
return [spec.kinds[kind] ?? null, null, kind === "road" ? "도로" : "그 밖의 토목"];
}
return [null, null, ""];
}
/** {줄 키: {rate, base, why}} — 표 없으면 {}(자동 없음) */
export function pickRates(table: Row | null | undefined, ctx: Row): Record<string, Row> {
const out: Record<string, Row> = {};
for (const [key, spec] of Object.entries(table && typeof table === "object" ? table : {})) {
const [rate, base, why] = pick(spec as Row, ctx);
const note = (spec as Row).note || "";
out[key] = { rate, base, why: note ? `${why} · ${note}` : why };
}
return out;
}
export function computeTotal(
sums: Row | null | undefined,
estimate: Row | null | undefined,
table?: Row | null,
year?: number | null,
): Row {
const s: Row = sums && typeof sums === "object" ? sums : {};
const est: Row = estimate && typeof estimate === "object" ? estimate : {};
const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {};
const rate: Record<string, Num> = {};
for (const [key] of [...RATE_LINES, ...TAIL_LINES]) rate[key] = num(ratesIn[key]);
const hand = (key: string): number => num(est[key]) || 0;
const material = num(s.material);
@@ -64,6 +120,34 @@ export function computeTotal(sums: Row | null | undefined, estimate: Row | null
const expense = num(s.expense);
const supplyNet = num(s.supply_net);
const direct = total([material, labor, expense]);
const laborMaterial = total([labor, material]);
const target =
laborMaterial === null ? null : laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR;
const auto = pickRates(table, {
direct,
target,
price: num(est.estimated_price),
days: num(est.duration_days),
year: year || new Date().getFullYear(),
env: est.environment_kind ?? null,
});
const rates: Record<string, Row> = {};
for (const [key] of [...RATE_LINES, ...TAIL_LINES]) {
const mine = num(ratesIn[key]);
const got: Row = auto[key] || {};
const autoRate: Num = got.rate ?? null;
rates[key] = {
rate: mine === null ? autoRate : mine,
from: mine !== null ? "user" : autoRate !== null ? "auto" : null,
auto: autoRate,
why: got.why || "",
};
}
const rate: Record<string, Num> = {};
for (const [key, r] of Object.entries(rates)) rate[key] = r.rate;
const baseIn = num(est.safety_base);
const safetyBase: number = baseIn !== null ? baseIn : (auto.safety || {}).base || 0;
rates.safety.base = safetyBase;
const line: Record<string, Num> = {};
line.indirect_labor = pct(labor, rate.indirect_labor);
@@ -75,10 +159,8 @@ export function computeTotal(sums: Row | null | undefined, estimate: Row | null
line.pension = pct(labor, rate.pension);
let safetyA: Num = null;
let safetyB: Num = null;
const laborMaterial = total([labor, material]);
if (laborMaterial !== null && rate.safety !== null) {
const base = hand("safety_base");
const target = laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR;
if (laborMaterial !== null && target !== null && rate.safety !== null) {
const base = safetyBase;
safetyA = rounddown((target * rate.safety) / 100 + base);
safetyB = rounddown(((laborMaterial * rate.safety) / 100 + base) * SAFETY_B);
}
@@ -144,5 +226,6 @@ export function computeTotal(sums: Row | null | undefined, estimate: Row | null
supply: { net: supplyNet, fee, raw: supplyRaw },
cost,
share,
rates,
};
}
+130 -7
View File
@@ -1,12 +1,16 @@
"""B09 원가 요율 후보(PLAN 145-4) — M01 `요율_조달청제비율.json` 을 읽어 줄마다 후보 글 한 줄.
"""B09 원가 요율(PLAN 145-4 · 166-1) — M01 `요율_조달청제비율.json` 을 읽는 유일한 곳.
후보는 보이기만 — 구간(추정가격 · 공기 · 공종 · 연도)을 고르는 값은 사용자 몫이라 확정하지 않음.
요율 칸은 빈칸에서 시작(사용자 입력 · `user.estimate.rates`).
rate_candidates 줄마다 후보 글 한 줄(출처 풍선)
rate_table 짝 셈(`B09_Estimation_Calc_Total`)이 자동으로 고르는 압축 표 — 총괄 줄 키마다
{rt, pct} 단일 · {price, pcts} 추정가격 구간 · {direct, days, grid} 직접공사비 × 공기 ·
{target, pcts, bases} 안전 대상액 구간 · {years} 연도 · {kinds} 환경 공종
구간 = 위 끝 목록(그 값 미만 · 마지막 = 끝없음) · 공종 = 토목(임도) 고정
"""
from __future__ import annotations
import json
import math
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -27,13 +31,18 @@ def _percents(value: Any) -> list[float]:
return []
@lru_cache(maxsize=1)
def _rows() -> tuple[dict[str, Any], ...]:
try:
return tuple(json.loads(FILE.read_text(encoding="utf-8")).get("줄") or [])
except (OSError, ValueError):
return ()
@lru_cache(maxsize=1)
def rate_candidates() -> dict[str, str]:
"""{RT 키: 「M01 RT… 3.56%」 · 「… 1.01~1.57%(구간 · 고르는 값 사용자)」} — 파일 없으면 {}."""
try:
rows = json.loads(FILE.read_text(encoding="utf-8")).get("줄") or []
except (OSError, ValueError):
return {}
rows = _rows()
out: dict[str, str] = {}
for row in rows:
percents = sorted(set(_percents(row.get("값"))))
@@ -45,3 +54,117 @@ def rate_candidates() -> dict[str, str]:
text = f"{percents[0]:g}~{percents[-1]:g}%(구간 · 고르는 값 사용자)"
out[str(row.get("키"))] = f"M01 {row.get('키')} {text}"
return out
_UNIT = {"million": 10**6, "billion": 10**9, "days": 1}
def _upper(label: Any) -> float | None:
"""구간 이름 → 위 끝(그 값 미만) — 「gte_」 = 끝없음(inf) · 일수는 끝 포함이라 +1 ·
수 없는 이름(below_official_threshold) = None. `billion` = 십억(M01 basis 글)."""
words = str(label).split("_")
if words[0] == "gte":
return math.inf
for i in range(len(words) - 1, 0, -1):
if words[i] in _UNIT and words[i - 1].isdigit():
return int(words[i - 1]) * _UNIT[words[i]] + (words[i] == "days")
return None
def _steps(rows: list[dict[str, Any]], field: str) -> tuple[list[float], list[dict[str, Any]]]:
"""구간 줄 → (위 끝 목록 · 끝없음 뺌, 줄 목록) — 위 끝 차례."""
keyed = sorted(
((u, r) for r in rows if (u := _upper(r.get(field))) is not None), key=lambda p: p[0]
)
return [u for u, _ in keyed[:-1]], [r for _, r in keyed]
def _grid(rows: list[dict[str, Any]]) -> dict[str, Any]:
"""간접노무비 · 기타경비 — 직접공사비 × 공기 × 토목."""
civil = [r for r in rows if r.get("work_type") == "civil"]
d_up = sorted({_upper(r["direct_cost_bracket"]) for r in civil})
t_up = sorted({_upper(r["duration_bracket"]) for r in civil})
grid: list[list[float | None]] = [[None] * len(t_up) for _ in d_up]
for r in civil:
i = d_up.index(_upper(r["direct_cost_bracket"]))
grid[i][t_up.index(_upper(r["duration_bracket"]))] = r["rate_percent"]
return {"direct": d_up[:-1], "days": t_up[:-1], "grid": grid, "note": "토목"}
def _priced(rows: list[dict[str, Any]], field: str, note: str = "") -> dict[str, Any]:
bounds, picked = _steps(rows, field)
return {"price": bounds, "pcts": [r["rate_percent"] for r in picked], "note": note}
@lru_cache(maxsize=1)
def rate_table() -> dict[str, dict[str, Any]]:
"""총괄 줄 키 → 압축 표(머리 글) — 파일 · 줄이 없으면 그 줄 없음(자동 없음 = 빈칸)."""
v = {str(r.get("키")): r.get("값") or {} for r in _rows()}
out: dict[str, dict[str, Any]] = {}
def put(key: str, rt: str, build: Any) -> None:
if rt in v:
try:
out[key] = {"rt": rt, **build(v[rt])}
except (KeyError, TypeError, ValueError, IndexError):
pass # M01 꼴이 바뀌면 그 줄만 자동 없음
for key, rt in (("accident", "RT000001"), ("health", "RT000003"), ("care", "RT000005")):
put(key, rt, lambda x: {"pct": x["rate_percent"]})
put("vat", "RT000019", lambda x: {"pct": x["rate_percent"]})
put(
"machine",
"RT000015",
lambda x: {
"pct": next(
r["rate_percent"]
for r in x["general_construction"]
if r["work_type"] == "civil_general"
),
"note": "종합공사 토목 일반",
},
)
put("employment", "RT000002", lambda x: _priced(x["brackets"], "estimated_amount_bracket"))
put(
"overhead",
"RT000017",
lambda x: _priced(
x["civil_landscape_industrial"], "estimated_price_bracket", "종합(토목) 표"
),
)
put("profit", "RT000018", lambda x: _priced(x["brackets"], "estimated_price_bracket"))
put("indirect_labor", "RT000008", lambda x: _grid(x["brackets"]))
put("other", "RT000009", lambda x: _grid(x["brackets"]))
def safety(x: dict[str, Any]) -> dict[str, Any]:
# ponytail: 관리자 선임 대상(추정 1000억 이상) 구간은 안 고름 — 임도 규모 밖
civil = [
r
for r in x["brackets"]
if r["work_type"] == "civil" and "at_or_above" not in r["target_amount_bracket"]
]
bounds, picked = _steps(civil, "target_amount_bracket")
return {
"target": bounds,
"pcts": [r["rate_percent"] for r in picked],
"bases": [r.get("base_amount_krw", 0) for r in picked],
"note": "토목",
}
put("safety", "RT000006", safety)
put(
"pension",
"RT000004",
lambda x: {"years": {str(r["year"]): r["rate_percent"] for r in x["annual_rates"]}},
)
put(
"environment",
"RT000010",
lambda x: {
"kinds": {
"road" if r["work_type"] == "road" else "other": r["rate_percent"]
for r in x["forest_road_candidates"]
}
},
)
return out
+6 -3
View File
@@ -35,7 +35,7 @@ from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price
from B09_Estimation.B09_Estimation_Calc_Total import compute_total
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Rates import rate_candidates
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
@@ -70,8 +70,10 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
sheets.update(sg)
sheets.update({k: v for k, v in labor.items() if k != "gaps"})
sheets["gaps"] = [*sheets["gaps"], *sg["sg_gaps"], *labor["gaps"]]
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate"))
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력
sheets["total"] = compute_total(
(sheets.get("bill") or {}).get("sums"), user.get("estimate"), rate_table()
)
return sheets
@@ -96,6 +98,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"book": {"made": book.get("made")} if book else None,
"sheets": sheets,
"rate_candidates": rate_candidates(),
"rate_table": rate_table(),
"notes": notes,
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
"summary": compute_summary(sheets, hopyo_lines(sheets)),
+4 -1
View File
@@ -29,6 +29,7 @@ ESTIMATE_FIELDS = (
"estimated_price", # 추정가격(원) — 요율 구간 고름
"duration_days", # 공사기간(일)
"work_type", # 공종(글)
"environment_kind", # 환경보전비 공종 — road 도로 · 그 밖 = 그 밖의 토목(166-1)
"safety_base", # 산업안전보건관리비 기초액(원)
"profit_adjust", # 이윤 보정(원 · 빼는 값)
"vat_adjust", # 부가가치세 조정(원)
@@ -37,6 +38,8 @@ ESTIMATE_FIELDS = (
"procurement_fee_pct", # 조달수수료율(%)
)
TEXT_FIELDS = ("work_type", "environment_kind")
def folder(project_root: str | Path) -> Path:
return Path(project_root).joinpath(*FOLDER)
@@ -94,7 +97,7 @@ def clean_user(raw: Any) -> dict[str, Any]:
if field not in given:
continue
value = given[field]
estimate[field] = (str(value) if value else None) if field == "work_type" else _num(value)
estimate[field] = (str(value) if value else None) if field in TEXT_FIELDS else _num(value)
rates = given.get("rates")
if isinstance(rates, dict):
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
+2 -2
View File
@@ -128,8 +128,8 @@ export function computeSheets(payload: Row, user: Row): Row {
...kept,
...daega,
gaps: [...base.gaps, ...(server.sg_gaps || []), ...daegaGaps],
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
total: computeTotal(base.bill?.sums, user.estimate),
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력
total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table),
};
}
+72 -11
View File
@@ -3,8 +3,10 @@
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
*
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
* 고칠 칸 = 요율(빈칸 시작 · 후보 = M01 조달청 제비율 글) · 손입력(기초액 · 이윤 보정 ·
* 부가세 조정 · 조달수수료율) · 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
* 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* ========================================================================== */
@@ -27,6 +29,8 @@ const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleS
/** 손입력 칸 — 줄 키 → user.estimate 칸 */
const HAND: Record<string, [string, string]> = {
in_price: ["estimated_price", "추정가격(원)"],
in_days: ["duration_days", "공사기간(일)"],
safety: ["safety_base", "기초액(원)"],
profit: ["profit_adjust", "이윤 보정(원 · 뺌)"],
vat: ["vat_adjust", "부가세 조정(원)"],
@@ -36,7 +40,9 @@ const HAND: Record<string, [string, string]> = {
};
const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key));
/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 줄 빈칸 · 손입력 0) */
const ENV_NAME: Record<string, string> = { road: "도로", other: "그 밖의 토목" };
/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 자동 · 손입력 0) */
function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void {
const v = parseNumber(value);
if (v === undefined) return;
@@ -54,6 +60,20 @@ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: st
ctx.setUser({ ...ctx.user, estimate });
}
/** 칸 누름 — 환경 공종 줄 = 도로 ↔ 그 밖 · 사용자 요율 줄의 출처 칸 = 자동으로 되돌림 */
function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: unknown): void {
const estimate: Row = { ...(ctx.user.estimate || {}) };
if (rowKey === "in_env") {
if (estimate.environment_kind === "road") delete estimate.environment_kind;
else estimate.environment_kind = "road";
} else if (column === "candidate" && from === "user") {
const rates: Row = { ...(estimate.rates || {}) };
delete rates[rowKey];
estimate.rates = rates;
} else return;
ctx.setUser({ ...ctx.user, estimate });
}
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
}
@@ -61,11 +81,18 @@ function rateText(formula: string, rate: Num): string {
export const buildTotal: TabBuild = (ctx): TabTable => {
const t: Row = ctx.sheets.total || {};
const est: Row = ctx.user.estimate || {};
const rates: Row = est.rates || {};
const picked: Row = t.rates || {};
const cand: Row = ctx.payload.rate_candidates || {};
const line: Row = t.lines || {};
const sub: Row = t.subtotals || {};
const rate = (key: string): Num => (typeof rates[key] === "number" ? rates[key] : null);
const rate = (key: string): Num => picked[key]?.rate ?? null;
/** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */
const source = (key: string): string => {
const r: Row = picked[key] || {};
if (r.from === "user")
return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`;
return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`;
};
const hand = (key: string): Num => {
const v = est[HAND[key][0]];
return typeof v === "number" ? v : null;
@@ -91,13 +118,36 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), {
rate: rate(key),
candidate: cand[rt] ?? rt,
candidate: source(key),
from: picked[key]?.from ?? null,
rt,
...(HAND[key] ? { hand: hand(key) } : {}),
});
const safety: Row = t.safety || {};
const supply: Row = t.supply || {};
const sums: Row = t.sums || {};
const env = est.environment_kind === "road" ? "road" : "other";
const rows: DataRow[] = [
row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", {
hand: hand("in_price"),
}),
row(
"in_days",
"요율",
"공사기간(일)",
null,
"간접노무비 · 기타경비 구간(직접공사비 = 순공사비계)",
{
hand: hand("in_days"),
},
),
row(
"in_env",
"요율",
`환경보전비 공종 = ${ENV_NAME[env]}`,
null,
`누르면 ${ENV_NAME[env === "road" ? "other" : "road"]}으로`,
),
row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", {
labor: sums.labor ?? null,
material: sums.material ?? null,
@@ -143,17 +193,28 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
qty("rate", "요율(%)", 5),
money("hand", "손입력", undefined, 2),
text("note", "산식", 320),
text("candidate", "요율 후보(확정 아님)", 260),
text("candidate", "요율 출처", 260),
],
rows,
frozen: 2,
hint:
"끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · 요율 = 빈칸 시작 — 빈칸이면 그 줄 · 그 줄을 쓰는 소계 빈칸 · " +
"손입력 = 안전관리비 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · 공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
"손입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " +
"공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
editable: (i, key) =>
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
(key === "hand" && String(rows[i]?.key) in HAND),
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from),
tip: (i, key) => {
const r = rows[i];
if (!r?.rt || (key !== "rate" && key !== "candidate")) return null;
const why = picked[String(r.key)]?.why;
return [why ? `자동 근거 — ${why}` : "", cand[String(r.rt)] ?? r.rt]
.filter(Boolean)
.join(" · ");
},
};
};
@@ -185,11 +246,11 @@ export const buildCost: TabBuild = (ctx): TabTable => {
const t: Row = ctx.sheets.total || {};
const cost: Row = t.cost || {};
const share: Row = t.share || {};
const rates: Row = (ctx.user.estimate || {}).rates || {};
const picked: Row = t.rates || {};
const formula: Record<string, string> = Object.fromEntries(
[...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [
key,
rateText(f, typeof rates[key] === "number" ? rates[key] : null),
rateText(f, picked[key]?.rate ?? null),
]),
);
const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({
@@ -140,8 +140,8 @@ def _run_ts(cases: list) -> list:
script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in (base, calc)) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
"--input-type=module-typescript"],
input=script, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@@ -203,8 +203,8 @@ console.log(JSON.stringify(out));
script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in files) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
"--input-type=module-typescript"],
input=script, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
out = json.loads(result.stdout)
@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""166-1 B09 총괄 요율 자동 — M01 압축 표(`B09_Estimation_Rates.rate_table`) · 구간 고르기 · 사용자 값 앞섬 ·
엑셀 검산(손값 그대로) · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`) · 화면 칸(고르는 값 · 되돌리기).
엑셀 2024 요율은 2026 표와 연도 차로 다름 — 자동값은 꼴(구간 고르기)로만 잼.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
RATES = { # 엑셀 2024 M49~M64
"indirect_labor": 14.5, "accident": 3.56, "employment": 1.01, "health": 3.545,
"care": 12.95, "pension": 4.5, "safety": 1.86, "other": 6, "environment": 0.8,
"machine": 0.4, "overhead": 6, "profit": 15, "vat": 10,
} # fmt: skip
EXCEL = {
"rates": RATES,
"safety_base": 5349000,
"profit_adjust": 905980,
"procurement_fee_pct": 0.54,
}
PICK = {"estimated_price": 1_000_000_000, "duration_days": 183, "procurement_fee_pct": 0.54}
def total(sums, est, year=2026):
return compute_total(sums, est, rate_table(), year)
def rate(out, key):
return out["rates"][key]["rate"]
def test_압축_표_꼴():
t = rate_table()
assert t["indirect_labor"]["direct"] == [10**9, 5 * 10**9, 30 * 10**9, 100 * 10**9] # 십억
assert t["indirect_labor"]["days"] == [184, 366, 1096]
assert t["overhead"]["price"] == [5 * 10**9, 30 * 10**9, 100 * 10**9]
assert t["employment"]["pcts"][0] == 1.01 and t["employment"]["pcts"][-1] == 1.57
assert t["safety"] == {
"rt": "RT000006",
"target": [5 * 10**8, 5 * 10**9],
"pcts": [3.15, 2.53, 2.6],
"bases": [0, 3300000, 0],
"note": "토목",
}
assert t["environment"]["kinds"] == {"road": 0.9, "other": 0.8}
def test_직접공사비_10억_공기_183일_경계():
small = total({"material": 0, "labor": 999_999_999, "expense": 0}, PICK)
assert rate(small, "indirect_labor") == 19.1 and rate(small, "other") == 5.5
assert (
small["rates"]["indirect_labor"]["why"] == "직접공사비 10억 미만 · 공기 184일 미만 · 토목"
)
edge = total({"material": 0, "labor": 1_000_000_000, "expense": 0}, PICK)
assert rate(edge, "indirect_labor") == 18.9 and rate(edge, "other") == 5.8
longer = total(
{"material": 0, "labor": 1_000_000_000, "expense": 0}, {**PICK, "duration_days": 184}
)
assert rate(longer, "indirect_labor") == 19.3
assert longer["rates"]["indirect_labor"]["why"].startswith(
"직접공사비 10억 이상 50억 미만 · 공기 184일 이상"
)
def test_추정가격_50억_경계():
under = total(SUMS, {**PICK, "estimated_price": 4_999_999_999})
assert (rate(under, "overhead"), rate(under, "profit"), rate(under, "employment")) == (
8,
15,
1.01,
)
at = total(SUMS, {**PICK, "estimated_price": 5_000_000_000})
assert (rate(at, "overhead"), rate(at, "profit")) == (6.5, 12)
assert at["rates"]["overhead"]["why"] == "추정가격 50억 이상 300억 미만 · 종합(토목) 표"
def test_안전_5억_경계와_기초액():
small = total(
{"material": 100_000_000, "labor": 399_999_999, "expense": 0, "supply_net": 0}, PICK
)
assert rate(small, "safety") == 3.15 and small["rates"]["safety"]["base"] == 0
# 견본 = 재료비 + 직노 + 관급 ÷ 1.1 = 6.04억 → 5억~50억 2.53 + 3,300,000
out = total(SUMS, PICK)
assert rate(out, "safety") == 2.53 and out["rates"]["safety"]["base"] == 3300000
target = 194234417 + 346800554 + 69474220 / 1.1
assert out["safety"]["a"] == int(target * 2.53 / 100 + 3300000)
mine = total(SUMS, {**PICK, "safety_base": 1000})
assert mine["rates"]["safety"]["base"] == 1000
def test_연금_해_환경_공종_단일():
out = total(SUMS, PICK)
assert rate(out, "pension") == 4.75 and rate(total(SUMS, PICK, 2027), "pension") == 5.0
late = total(SUMS, PICK, 2033)
assert rate(late, "pension") is None and late["subtotals"]["net_cost"] is None
assert late["rates"]["pension"]["why"] == "2033년 요율 없음"
assert rate(out, "environment") == 0.8
assert rate(total(SUMS, {**PICK, "environment_kind": "road"}), "environment") == 0.9
assert (rate(out, "accident"), rate(out, "care"), rate(out, "vat"), rate(out, "machine")) == (
3.56,
13.14,
10,
0.4,
)
assert out["subtotals"]["grand"] is not None and out["rates"]["vat"]["from"] == "auto"
def test_사용자_값이_앞섬_지우면_자동():
out = total(SUMS, {**PICK, "rates": {"indirect_labor": 14.5, "care": None}})
assert out["rates"]["indirect_labor"] == {
"rate": 14.5,
"from": "user",
"auto": 19.1,
"why": "직접공사비 10억 미만 · 공기 184일 미만 · 토목",
}
assert out["lines"]["indirect_labor"] == 50286080 # 엑셀 D49
assert out["rates"]["care"]["from"] == "auto" and rate(out, "care") == 13.14
def test_고르는_값_빈칸이면_자동_없음():
out = total(SUMS, {"procurement_fee_pct": 0.54})
for key in ("indirect_labor", "other", "overhead", "profit", "employment"):
assert rate(out, key) is None and out["rates"][key]["from"] is None
assert out["lines"]["indirect_labor"] is None and out["subtotals"]["net_cost"] is None
assert rate(out, "health") == 3.595 # 단일 요율은 그대로
def test_엑셀_검산_손값이면_그대로():
out = total(SUMS, EXCEL, 2026)
assert out["subtotals"]["grand"] == 1201879000
assert out["lines"]["safety"] == 16586996 and out["lines"]["profit"] == 108109955
assert all(r["from"] == "user" for r in out["rates"].values())
def _node(script: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=script, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
def _source(*names: str) -> str:
return "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / n).read_text(encoding="utf-8")) for n in names
)
CASES = [
[SUMS, PICK, 2026],
[{"material": 0, "labor": 1_000_000_000, "expense": 0}, {**PICK, "duration_days": 184}, 2026],
[SUMS, {**PICK, "estimated_price": 5_000_000_000, "environment_kind": "road"}, 2027],
[{"material": 100_000_000, "labor": 399_999_999, "expense": 0, "supply_net": 0}, PICK, 2026],
[SUMS, {**PICK, "safety_base": 1000, "rates": {"indirect_labor": 14.5, "care": None}}, 2026],
[SUMS, {"procurement_fee_pct": 0.54}, 2033],
[SUMS, EXCEL, 2026],
[None, PICK, 2026],
]
def test_거울_TS_가_같은_값():
body = (
f"\nconst table = {json.dumps(rate_table(), ensure_ascii=False)};"
f"\nconst cases = {json.dumps(CASES, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(cases.map(([s, e, y]) => computeTotal(s, e, table, y))));"
)
out = json.loads(
_node(_source("B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Total.ts") + body)
)
assert out == [total(s, e, y) for s, e, y in CASES]
def test_화면_고르는_값_칸과_되돌리기():
body = f"""
let user = {{ estimate: {{}} }};
const setUser = (u) => (user = u);
const table = {json.dumps(rate_table(), ensure_ascii=False)};
const sums = {json.dumps(SUMS)};
const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate, table, 2026) }},
payload: {{ rate_table: table, rate_candidates: {{}} }}, user, setUser }});
let t = buildTotal(ctx());
const at = (key) => t.rows.findIndex((r) => r.key === key);
const out = {{}};
out.editable = [t.editable(at("in_price"), "hand"), t.editable(at("in_days"), "hand"),
t.editable(at("in_env"), "hand")];
out.blank = t.rows[at("indirect_labor")].rate;
t.onEdit(at("in_price"), "hand", "1,000,000,000");
t = buildTotal(ctx());
t.onEdit(at("in_days"), "hand", "183");
t = buildTotal(ctx());
out.price = user.estimate.estimated_price;
out.auto = [t.rows[at("indirect_labor")].rate, t.rows[at("indirect_labor")].candidate];
out.tip = t.tip(at("indirect_labor"), "rate");
t.onCell(at("in_env"), "name");
t = buildTotal(ctx());
out.env = [user.estimate.environment_kind, t.rows[at("environment")].rate, t.rows[at("in_env")].name];
t.onEdit(at("indirect_labor"), "rate", "14.5");
t = buildTotal(ctx());
out.mine = [t.rows[at("indirect_labor")].rate, t.rows[at("indirect_labor")].candidate];
t.onCell(at("indirect_labor"), "candidate");
t = buildTotal(ctx());
out.back = [t.rows[at("indirect_labor")].rate, "indirect_labor" in user.estimate.rates];
out.contract = t.rows[at("contract")].amount;
console.log(JSON.stringify(out));
"""
names = (
"B09_Estimation_Calc_Base.ts",
"B09_Estimation_Calc_Total.ts",
"B09_Estimation_UI_Tabs.ts",
"B09_Estimation_UI_Tabs_Total.ts",
)
out = json.loads(_node(_source(*names) + body))
assert out["editable"] == [True, True, False]
assert out["blank"] is None # 고르는 값 빈칸 = 간노 자동 없음
assert out["price"] == 1_000_000_000
assert out["auto"] == [19.1, "자동 — 직접공사비 10억 미만 · 공기 184일 미만 · 토목"]
assert "자동 근거" in out["tip"]
assert out["env"] == ["road", 0.9, "환경보전비 공종 = 도로"]
assert out["mine"] == [14.5, "사용자 값 · 누르면 자동 19.1%으로"]
assert out["back"] == [19.1, False]
assert out["contract"] is not None # 요율 모두 자동 — 도급공사비까지 셈(관급은 수수료율 빈칸)