- 탭 = 기번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
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""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,
|
||
}
|