Files
Aislo/B08_Quantity/B08_Quantity_Material_Calc.py
T

303 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B08 자재 집계표 셋 계산(PLAN 121-1) — 화면 없음 · 저장 안 함.
⚠ 짝 = `B08_Quantity_Material_Calc.ts`(같은 식 · 같은 상수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_121_b08_material_sheets.py`.
실무 엑셀(`4.2 수량산출(2공구).xlsx`) 꼴 · 끌어오는 차례:
구조물 집계표 계(98 `_summary` total) = 구조물 수량 C
→ 자재수량집계표: 줄마다 단위수량(사용자 입력 · 기본 빈칸) · 수량 = C × 단위수량 · 계
→ 콘크리트소요자재집계표: 시멘트 · 모래 · 자갈 = ㎥당 배합 × 콘크리트 · 모르터 ㎥
→ 총괄자재수량집계표: 합계 = 올림(수량 × (1 + 할증)) · 커플링은 구조물 집계표 계 그대로
구조물 수량 기준 — 일위대가(B09) 재료와 별개 · 두 번 세지 않게 화면 머리에 표시.
사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만:
{"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}},
"surcharge": {총괄 줄: %}}
키가 있으면 그 값(null = 빈칸) · 없으면 아래 기본값.
좌측 패널 할증(`panel` = quantity › material_surcharge_pct) = 총괄 모래 · 자갈 · 시멘트 줄.
"""
from __future__ import annotations
import math
import re
from typing import Any
#: 자재수량집계표 자재 열 — 실무 엑셀 F~AD · [키, 자재, 규격, 단위]
# 파형강관은 프로젝트 관경마다 `pipe_{관경}`
MATERIALS: list[tuple[str, str, str, str]] = [
("remicon_25_24_15", "레미콘", "25-24-15", "㎥"),
("remicon_25_21_80", "레미콘", "25-21-80", "㎥"),
("remicon_25_18_80", "레미콘", "25-18-80", "㎥"),
("concrete_machine", "콘크리트", "기계", "㎥"),
("rebar_d13", "이형철근", "D13", "kg"),
("rebar_d16", "이형철근", "D16", "kg"),
("rebar_d19", "이형철근", "D19", "kg"),
("rebar_d22", "이형철근", "D22", "kg"),
("rebar_d25", "이형철근", "D25", "kg"),
("PIPES", "", "", ""),
("mortar_1_3", "모르터", "1:3", "㎥"),
("revet_block", "호안블럭", "", "㎡"),
("wire_mesh", "와이어메쉬", "", "㎡"),
("flume_400c", "수로관", "400C", "본"),
("steel_grating", "스틸그레이팅", "", "EA"),
("stone_buy_35", "돌(구입)", "L3:35", "ton"),
("stone_buy_45", "돌(구입)", "L3:45", "ton"),
("stone_pick_35", "돌(채집)", "L3:35", "㎥"),
("stone_pick_45", "돌(채집)", "L3:45", "㎥"),
("bed_stone", "고임돌", "채집", "㎥"),
("rubble", "막자갈", "채집", "㎥"),
("mixed_aggregate", "혼합골재", "", "㎥"),
("sod", "떼", "", "㎡"),
]
#: 콘크리트소요자재집계표 줄 — [키, 구분, 규격, 끌어올 자재 열, ㎥당 시멘트 kg · 모래 · 자갈 ㎥]
# 콘크리트 = 산림품셈 12-1-3 주⑤ QF000353 (B) 배합
# (25 mm 346 · 828 · 1,011 kg / 40 mm 323 · 775 · 1,101 kg)
# 골재 kg → ㎥ = 건설품셈 공통 1-2-4 CC000337 건조 위끝
# (모래 1,700 · 자갈 1,800 kg/㎥ · GF000156 기본)
# 무근 = 근거 없음(실무 312 kg) → 빈칸
# 모르터 = 건설품셈 건축 9-1-1 QC001347(1:2 = 680 kg · 0.98 ㎥ / 1:3 = 510 kg · 1.10 ㎥)
CONCRETE_ROWS: list[tuple[str, str, str, str | None, dict[str, float | None]]] = [
("c25", "콘크리트", "Φ25mm", None, {"cement": 346, "sand": 0.487, "gravel": 0.562}),
(
"c40",
"콘크리트",
"Φ40mm",
"concrete_machine",
{"cement": 323, "sand": 0.456, "gravel": 0.612},
),
("c40_plain", "콘크리트", "Φ40mm(무근)", None, {"cement": None, "sand": None, "gravel": None}),
("m12", "모르터", "1:2", None, {"cement": 680, "sand": 0.98, "gravel": None}),
("m13", "모르터", "1:3", "mortar_1_3", {"cement": 510, "sand": 1.10, "gravel": None}),
]
MIX_FIELDS = ("cement", "sand", "gravel")
#: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 올림 소수 자리]
# 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계)
# · "cp:{관경}"(커플링)
# ÷ 는 「/1000」(kg → ton) · 「/40」(kg → 40 kg 대)
# 할증 = 건설품셈 공통 1-3-1(지식DB 01_임도/04_수량분석정보/수량산출_일반.md 3절)
# · 관급 파형강관 0
# 모래 · 자갈 · 시멘트 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 테이블 사용자 값은 무시)
# 기본 = 1-3-1 현장비빔(기타) 잔골재 12 · 굵은골재 5 · 시멘트 3 · 파일에 안 쓰고 읽을 때 채움
PANEL_KEY = "material_surcharge_pct" # 프로젝트 설정 quantity 칸
PANEL_SURCHARGE: dict[str, float] = {"sand": 12, "gravel": 5, "cement": 3}
TOTAL_ROWS: list[tuple[str, str, str, str, str, float | None, int]] = [
("sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE["sand"], 0),
("gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE["gravel"], 0),
("mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, 0),
("remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, 0), # 철근
("remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, 0), # 무근
("remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, 0), # 무근
("rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, 3),
("rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, 3),
("rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, 3),
("rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, 3),
("rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, 3),
("wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", None, 0),
("cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE["cement"], 0),
("PIPES", "", "", "", "", 0, 0),
("sod", "떼", "", "㎡", "m:sod", 10, 0),
("stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", None, 0),
("stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", None, 0),
("revet_block", "호안블럭", "", "㎡", "m:revet_block", None, 0),
("flume_400c", "수로관", "400C", "본", "m:flume_400c", None, 0),
("steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", None, 0),
]
LENGTH_ID = re.compile(r"^pipe:len\|(\d+)$")
COUPLING_ID = re.compile(r"^pipe:cp\|(\d+)$")
UNITS = ("m", "㎡", "㎥", "ea", "개", "개소", "EA")
def round6(value: float) -> float:
"""소수 6 자리 반 올림 — 떠도는 끝수만 지움 · 화면 짝과 같은 식."""
return math.floor(value * 1e6 + 0.5) / 1e6
def roundup(value: float, places: int) -> float:
"""실무 ROUNDUP — 떠도는 끝수(1e-9)는 올리지 않음."""
scale = 10**places
return math.ceil(round6(value) * scale - 1e-9) / scale
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def clean_overrides(raw: Any) -> dict[str, Any]:
"""저장 값 다듬기 — 두 겹(units · mix) · 한 겹(surcharge) · 잎은 수 또는 null."""
raw = raw if isinstance(raw, dict) else {}
out: dict[str, Any] = {"units": {}, "mix": {}, "surcharge": {}}
for part in ("units", "mix"):
for row, cells in (raw.get(part) or {}).items():
if isinstance(cells, dict):
kept = {
str(k): _num(v) for k, v in cells.items() if v is None or _num(v) is not None
}
if kept:
out[part][str(row)] = kept
for key, value in (raw.get("surcharge") or {}).items():
if value is None or _num(value) is not None:
out["surcharge"][str(key)] = _num(value)
return out
def panel_surcharge(raw: Any) -> dict[str, float]:
"""좌측 패널 할증 % — 저장된 칸 · 없거나 수가 아니면 기본."""
raw = raw if isinstance(raw, dict) else {}
out: dict[str, float] = {}
for key, default in PANEL_SURCHARGE.items():
value = _num(raw.get(key))
out[key] = default if value is None else value
return out
def _pick(table: dict[str, Any], key: str, default: Any) -> Any:
return table[key] if key in table else default
def diameters(columns: list[dict[str, Any]]) -> list[int]:
"""구조물 집계표 관 열(관매설 · 커플링)의 관경 — 오름차순."""
found = set()
for column in columns:
match = LENGTH_ID.match(column["id"]) or COUPLING_ID.match(column["id"])
if match:
found.add(int(match.group(1)))
return sorted(found)
def material_columns(sizes: list[int]) -> list[tuple[str, str, str, str]]:
out: list[tuple[str, str, str, str]] = []
for entry in MATERIALS:
if entry[0] == "PIPES":
out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes]
else:
out.append(entry)
return out
def _structure_row(column: dict[str, Any]) -> tuple[str, str, str]:
"""집계표 열 머리 → (구분, 규격, 단위) — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에."""
group = [str(g) for g in column.get("group") or []]
label = str(column.get("label") or "")
name = group[0] if group else ""
spec = group[1:]
if label in UNITS:
return name, " ".join(spec), label
return name, " ".join([*spec, label]), "개소"
def compute(
summary: dict[str, Any], overrides: dict[str, Any], panel: Any = None
) -> dict[str, Any]:
"""구조물 집계표 결과(98 `_summary` 꼴: columns · total) + 사용자 값 + 좌측 패널 할증 → 세 표."""
columns = summary.get("columns") or []
total = summary.get("total") or {}
units_in = overrides.get("units") or {}
sizes = diameters(columns)
materials = material_columns(sizes)
keys = [m[0] for m in materials]
# 자재수량집계표 — 집계표 열마다 한 줄(커플링은 총괄로 바로)
rows = []
sums: dict[str, float] = {}
for column in columns:
cid = column["id"]
if COUPLING_ID.match(cid):
continue
name, spec, unit = _structure_row(column)
qty = _num(total.get(cid))
length = LENGTH_ID.match(cid)
defaults = {f"pipe_{length.group(1)}": 1.0} if length else {}
given = units_in.get(cid) or {}
units = {k: _num(_pick(given, k, defaults.get(k))) for k in keys}
amounts: dict[str, float | None] = {}
for k in keys:
u = units[k]
amounts[k] = round6(qty * u) if qty is not None and u is not None else None
if amounts[k] is not None:
sums[k] = round6(sums.get(k, 0.0) + amounts[k])
rows.append(
{
"id": cid,
"name": name,
"spec": spec,
"unit": unit,
"qty": qty,
"units": units,
"amounts": amounts,
}
)
quantity = {
"materials": [{"key": k, "name": n, "spec": s, "unit": u} for k, n, s, u in materials],
"rows": rows,
"total": sums,
}
# 콘크리트소요자재집계표
mix_in = overrides.get("mix") or {}
concrete_rows = []
concrete_total: dict[str, float] = {}
for key, name, spec, source, mix in CONCRETE_ROWS:
qty = sums.get(source) if source else None
given = mix_in.get(key) or {}
per = {f: _num(_pick(given, f, mix[f])) for f in MIX_FIELDS}
out = {}
for f in MIX_FIELDS:
out[f] = round6(qty * per[f]) if qty is not None and per[f] is not None else None
if out[f] is not None:
concrete_total[f] = round6(concrete_total.get(f, 0.0) + out[f])
concrete_rows.append(
{"key": key, "name": name, "spec": spec, "qty": qty, "per": per, "amounts": out}
)
concrete = {"rows": concrete_rows, "total": concrete_total}
# 총괄자재수량집계표
surcharge_in = {**(overrides.get("surcharge") or {}), **panel_surcharge(panel)}
couplings = {
int(m.group(1)): _num(total.get(c["id"]))
for c in columns
if (m := COUPLING_ID.match(c["id"]))
}
plan: list[tuple[str, str, str, str, str, float | None, int]] = []
for entry in TOTAL_ROWS:
if entry[0] == "PIPES":
plan += [(f"pipe_{d}", "파형강관", f"Φ{d}mm", "m", f"m:pipe_{d}", 0, 0) for d in sizes]
plan += [
(f"coupling_{d}", "커플링밴드", f"Φ{d}mm", "개", f"cp:{d}", 0, 0) for d in sizes
]
else:
plan.append(entry)
total_rows = []
for key, group, spec, unit, source, pct_default, places in plan:
ref, _, divisor = source.partition("/")
kind, _, name = ref.partition(":")
if kind == "m":
qty = sums.get(name)
elif kind == "c":
qty = concrete_total.get(name)
else:
qty = couplings.get(int(name))
if qty is not None and divisor:
qty = round6(qty / float(divisor))
pct = _num(_pick(surcharge_in, key, pct_default))
amount = roundup(qty * (1 + (pct or 0) / 100), places) if qty is not None else None
total_rows.append(
{
"key": key,
"group": group,
"spec": spec,
"unit": unit,
"qty": qty,
"surcharge": pct,
"amount": amount,
"places": places,
"panel": key in PANEL_SURCHARGE,
}
)
return {"quantity": quantity, "concrete": concrete, "material": {"rows": total_rows}}