feat(B09): 원가 바탕 단가 — 뼈대 · 노무 · 경비 · 환율 · 자재 대비 · 중기 탭(144-1~4)

- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동
- 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길
- 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록
- 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수)
- 공용 표 = 칸 누름 · 줄로 이동(강조) 더함
- 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 15:01:27 +09:00
co-authored by Claude Opus 5.5
parent c0cf0ab90a
commit e1d83398d4
15 changed files with 2075 additions and 11 deletions
+319
View File
@@ -0,0 +1,319 @@
"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_144_b09_base_calc.py`.
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로:
노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로)
환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
(분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌)
자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값
· 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None ·
「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀).
"""
from __future__ import annotations
import math
from typing import Any
#: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시
EPS = 1e-7
#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료 차례(같은 값이면 앞 칸) ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷
SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가")
#: 자재 줄 사용자 표시
SUPPLY = ("관급", "별도")
def _scaled(value: float, places: int, fn) -> float:
a = abs(value)
if places >= 0:
scale = 10**places
out = fn(a * scale) / scale
else:
unit = 10 ** (-places)
out = fn(a / unit) * unit
return -out if value < 0 else out
def rounddown(value: float, places: int = 0) -> float:
"""엑셀 ROUNDDOWN — 0 쪽으로 버림."""
return _scaled(value, places, lambda x: math.floor(x + EPS))
def roundup(value: float, places: int = 0) -> float:
"""엑셀 ROUNDUP — 0 에서 먼 쪽으로 올림."""
return _scaled(value, places, lambda x: math.ceil(x - EPS))
def round_half(value: float, places: int = 0) -> float:
"""엑셀 ROUND — 반 올림(0 에서 먼 쪽)."""
return _scaled(value, places, lambda x: math.floor(x + 0.5 + EPS))
def num(value: Any) -> float | None:
"""수 · 빈칸 — 수가 아니면 None."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def total(values: list[float | None]) -> float | None:
"""합 — 하나라도 빈칸이면 빈칸(연결 안 된 값은 비움)."""
out = 0.0
for v in values:
if v is None:
return None
out += v
return out
def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]:
return {"tab": tab, "key": key, "name": name, "why": why}
def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None:
"""운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)."""
if day is None or not factor or not factor.get("den"):
return None
return rounddown(day * factor["num"] / factor["den"], 0)
def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
rows = []
for no, row in enumerate(labor, 1):
price = num(row.get("price"))
if price is None:
gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음"))
rows.append(
{
**_head(row, no),
"code": row.get("code"),
"unit": row.get("unit") or "인",
"price": price,
}
)
return rows
def _head(row: dict[str, Any], no: int) -> dict[str, Any]:
return {
"no": no,
"key": row["key"],
"name": row.get("name") or "",
"spec": row.get("spec") or "",
"source": row.get("source") or "",
}
def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
"""경비목록표 — 기계 손료 기준가(천원)."""
rows = []
for no, m in enumerate(machines, 1):
price = num(m.get("price"))
if price is None:
gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음"))
rows.append({**_head(m, no), "unit": "천원", "price": price})
return rows
def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]:
"""출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸."""
best: tuple[float | None, str | None] = (None, None)
for source in SOURCES:
v = num(prices.get(source))
if v is not None and v > 0 and (best[0] is None or v < best[0]):
best = (v, source)
return best
def compare_sheet(
materials: list[dict[str, Any]], user: dict[str, Any], gaps: list
) -> list[dict[str, Any]]:
"""자재단가대비표 — 출처 · 최저 · 적용(사용자 값이 앞섬) · 관급 · 별도 표시."""
rows = []
for no, m in enumerate(materials, 1):
mine = user.get(m["key"]) or {}
prices = m.get("prices") or {}
low, low_from = lowest(prices)
if "applied" in mine:
applied, applied_from = num(mine["applied"]), "사용자"
else:
applied, applied_from = low, low_from
if applied is None:
gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)"))
supply = mine.get("supply") if mine.get("supply") in SUPPLY else None
rows.append(
{
**_head(m, no),
"unit": m.get("unit") or "",
"origin": m.get("origin") or "",
"prices": {s: num(prices.get(s)) for s in SOURCES},
"pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES},
"lowest": low,
"lowest_from": low_from,
"applied": applied,
"applied_from": applied_from if applied is not None else None,
"supply": supply,
}
)
return rows
def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare]
def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]:
if price is None or qty is None:
amount = None
elif pct:
amount = rounddown(price * qty / 100, 1)
else:
amount = rounddown(price * qty, 1)
return {
"name": name,
"spec": spec,
"qty": qty,
"unit": unit,
"kind": kind,
"price": price,
"amount": amount,
"ref": ref,
}
def machine_block(
m: dict[str, Any],
drivers: dict[str, dict[str, Any]],
fuels: dict[str, dict[str, Any]],
gaps: list,
) -> dict[str, Any]:
"""중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합."""
lines = []
loss = num(m.get("loss"))
coef = loss / 10000 if loss is not None else None
price = num(m.get("price"))
lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price,
{"tab": "expense", "key": m["key"]})) # fmt: skip
crew = num(m.get("crew"))
if crew:
code = str(m.get("crew_code") or "")
driver = drivers.get(code) or {}
hour = driver.get("hour")
if hour is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음"))
lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour,
{"tab": "basis", "key": code})) # fmt: skip
lph = num(m.get("fuel_lph"))
if lph:
kind = str(m.get("fuel") or "")
fuel = fuels.get(kind) or {}
fprice = fuel.get("applied")
if fprice is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음"))
fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material",
fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip
lines.append(fuel_line)
misc = num(m.get("misc_pct"))
if misc:
lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"],
None, pct=True)) # fmt: skip
if price is None or coef is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음"))
sums: dict[str, float | None] = {}
for part in ("labor", "material", "expense"):
s = total([ln["amount"] for ln in lines if ln["kind"] == part])
sums[part] = None if s is None else rounddown(s, 0)
return {
"key": m["key"],
"name": m["name"],
"spec": m.get("spec") or "",
"unit": "시간",
"lines": lines,
"labor": sums["labor"],
"material": sums["material"],
"expense": sums["expense"],
"sum": total([sums["labor"], sums["material"], sums["expense"]]),
}
def basis_sheet(
inputs: dict[str, Any], labor_rows: list[dict[str, Any]]
) -> dict[str, list[dict[str, Any]]]:
"""환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만)."""
factor = inputs.get("driver_factor")
by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None}
codes: list[str] = []
for m in inputs.get("machines") or []:
code = str(m.get("crew_code")) if m.get("crew_code") else None
if code and num(m.get("crew")) and code not in codes:
codes.append(code)
drivers = []
for code in codes:
row = by_code.get(code) or {}
day = row.get("price")
drivers.append(
{
"key": code,
"labor_key": row.get("key"),
"name": row.get("name") or f"직종 {code}",
"day": day,
"factor": (factor or {}).get("text") or "",
"hour": driver_hour(day, factor),
}
)
fx = [
{"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "",
"value": num(f.get("value")), "date": f.get("date") or ""}
for f in inputs.get("fx") or []
] # fmt: skip
return {"fx": fx, "drivers": drivers}
def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
"""144 탭 일곱 + 「연결 없음」 목록."""
gaps: list[dict[str, str]] = []
labor_rows = labor_sheet(inputs.get("labor") or [], gaps)
expense = expense_sheet(inputs.get("machines") or [], gaps)
compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps)
basis = basis_sheet(inputs, labor_rows)
drivers = {d["key"]: d for d in basis["drivers"]}
by_key = {row["key"]: row for row in compare}
fuels = {
kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key
}
blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []]
machine = [
{
"no": no,
"key": b["key"],
"name": b["name"],
"spec": b["spec"],
"unit": b["unit"],
"sum": b["sum"],
"labor": b["labor"],
"material": b["material"],
"expense": b["expense"],
}
for no, b in enumerate(blocks, 1)
]
return {
"labor": labor_rows,
"expense": expense,
"basis": basis,
"compare": compare,
"material": material_sheet(compare),
"machine_blocks": blocks,
"machine": machine,
"gaps": gaps,
}