Merge remote-tracking branch 'origin/sub_laptop_4' into sub_desktop_1
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
@@ -71,8 +71,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
|
||||
|
||||
|
||||
@@ -105,6 +107,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)),
|
||||
|
||||
@@ -31,6 +31,7 @@ ESTIMATE_FIELDS = (
|
||||
"estimated_price", # 추정가격(원) — 요율 구간 고름
|
||||
"duration_days", # 공사기간(일)
|
||||
"work_type", # 공종(글)
|
||||
"environment_kind", # 환경보전비 공종 — road 도로 · 그 밖 = 그 밖의 토목(166-1)
|
||||
"safety_base", # 산업안전보건관리비 기초액(원)
|
||||
"profit_adjust", # 이윤 보정(원 · 빼는 값)
|
||||
"vat_adjust", # 부가가치세 조정(원)
|
||||
@@ -39,6 +40,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)
|
||||
@@ -146,7 +149,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()}
|
||||
|
||||
@@ -137,8 +137,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 # 요율 모두 자동 — 도급공사비까지 셈(관급은 수수료율 빈칸)
|
||||
Reference in New Issue
Block a user