- 단가 책 = 자재만(자재단가대비표 · 4 자재대 · 관급자재대) · 노무 · 기계 · 운전사 계수(CC000336 세 줄) · 연료 · 환율 줄 걷음 - 바탕 셈(py · ts 짝) 운전사 시간 노임 · 중기사용료(손료 · 운전사 · 연료 · 잡품 재현) · 경비목록표 · 노임 · 기계 덮개 걷음 - 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(프로젝트 사본 M02 값) 나열 · 다시 안 셈(price_lists) - 경비목록표 · 중기사용료 · 환율및기초자료 탭 = 「—」 한 줄 + 까닭 한 줄(탭은 둠) - [단가 갱신] = 프로젝트 사본 자원 단가 줄(단가.json LB · MT · EQ · MO · FX)을 지금 M01 로 + 자재 단가 책(새 파일 B09_Estimation_Copy_Prices.py · 사용자가 누를 때만) · 미리 보기 = 덮개로 같은 셈(파일 안 씀) - 산근 · 대가 단가 고친 칸 걷음(품만) · 단가표 불러오기(707-3) 노임 · 기계 덮개 걷음(자재만) - 낡은 시험은 C 걸음에서 정리 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.5 KiB
Python
201 lines
8.5 KiB
Python
"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||
|
||
⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||
거울 시험 `resources/tester/test_144_b09_base_calc.py`.
|
||
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
|
||
|
||
실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로:
|
||
노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 `inputs.labor` ·
|
||
`inputs.machines` 로 넣어 줌 · `B09_Estimation_Supply.price_lists`) 나열 — 다시 안 셈(841-1)
|
||
경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · B09 가 기계 시간당 · 운전사 노임을
|
||
다시 셈하던 곳 · 값 = M02 단가산출 그대로) — 빈 표
|
||
자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌 · 견적 포함 = 엑셀 MIN) 자동 ·
|
||
적용 차례 = 고른 출처(pick · 견적이 높아도) › 사용자 적용 단가 › 최저
|
||
· 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`)
|
||
· 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · 로직 자재 = M02 자재표시 · B08 파형강관 = 관급)
|
||
→ 재료비목록표 단가 = 적용 단가
|
||
재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0)
|
||
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 =
|
||
입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 ·
|
||
없는 키 빈칸) ·
|
||
`material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열)
|
||
빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None ·
|
||
「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Any
|
||
|
||
#: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시
|
||
EPS = 1e-7
|
||
|
||
#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸)
|
||
# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 · 견적 = 사용자 손입력(엑셀 M/N 열)
|
||
SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적")
|
||
|
||
#: 자재 줄 사용자 표시
|
||
SUPPLY = ("관급", "별도")
|
||
_PARTS = ("labor", "material", "expense")
|
||
|
||
|
||
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 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 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 {}), "견적": mine.get("quote")}
|
||
pages = {**(m.get("pages") or {}), "견적": mine.get("quote_page")}
|
||
low, low_from = lowest(prices)
|
||
if mine.get("pick") in SOURCES:
|
||
applied, applied_from = num(prices.get(mine["pick"])), mine["pick"]
|
||
elif "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 = next((v for v in (mine.get("supply"), m.get("supply")) if v in SUPPLY), None)
|
||
if mine.get("supply") == "사급": # 841-1 사용자가 기본 표시(M02 자재표시)를 끔
|
||
supply = 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: pages.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,
|
||
"pick": mine.get("pick") if mine.get("pick") in SOURCES else None,
|
||
"supply": supply,
|
||
"qty": num(m.get("qty")),
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def material_sheet(
|
||
compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None
|
||
) -> list[dict[str, Any]]:
|
||
"""재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 ·
|
||
총액 = ROUND(수량 × 단가, 0)."""
|
||
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
|
||
rows = []
|
||
for row in compare:
|
||
price = row["applied"]
|
||
q = row["qty"] if qty_of is None else num(qty_of.get(row["key"]))
|
||
amount = None if price is None or q is None else round_half(q * price, 0)
|
||
rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount})
|
||
return rows
|
||
|
||
|
||
def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||
"""144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표."""
|
||
gaps: list[dict[str, str]] = []
|
||
compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps)
|
||
machine = [
|
||
{**_head(m, no), "unit": "시간", **{p: num(m.get(p)) for p in _PARTS},
|
||
"sum": total([num(m.get(p)) for p in _PARTS])}
|
||
for no, m in enumerate(inputs.get("machines") or [], 1)
|
||
] # fmt: skip
|
||
return {
|
||
"labor": labor_sheet(inputs.get("labor") or [], gaps),
|
||
"expense": [],
|
||
"basis": {"fx": [], "drivers": []},
|
||
"compare": compare,
|
||
"material": material_sheet(compare, inputs.get("material_qty")),
|
||
"machine_blocks": [],
|
||
"machine": machine,
|
||
"gaps": gaps,
|
||
}
|