"""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 + 할증)) · 커플링은 구조물 집계표 계 그대로 끝수 = 실무 엑셀 꼴 줄마다(레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 ROUNDUP 0 · 131) 구조물 수량 기준 — 일위대가(B09) 재료와 별개 · 두 번 세지 않게 화면 머리에 표시. 단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가 사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재 (키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을 때만 · 콘크리트소요 · 총괄로 끌어옴 = 이름 · 규격 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched`(화면 알림) · 사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김. 사용자 값(`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:{관경}"(커플링) · ""(늘 빈칸 — 막자갈은 채집이라 엑셀도 빈칸) # 끝수 = "r{n}"(ROUND n 자리) · "u{n}"(ROUNDUP n 자리) — 실무 엑셀 G열 꼴(131) # ÷ 는 「/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, str]] = [ ("sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE["sand"], "u0"), ("gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE["gravel"], "u0"), ("rubble", "골재", "막자갈", "㎥", "", 4, "u0"), ("mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, "u0"), ("remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, "r2"), # 철근 ("remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, "r2"), # 무근 ("remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, "r2"), # 무근 ("rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, "r3"), ("rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, "r3"), ("rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, "r3"), ("rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, "r3"), ("rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, "r3"), ("wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", None, "r3"), ("cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE["cement"], "r0"), ("PIPES", "", "", "", "", 0, "u0"), ("sod", "떼", "", "㎡", "m:sod", 10, "u0"), ("stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", None, "u0"), ("stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", None, "u0"), ("revet_block", "호안블럭", "", "㎡", "m:revet_block", None, "u0"), ("flume_400c", "수로관", "400C", "본", "m:flume_400c", None, "u0"), ("steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", None, "u0"), ] 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 round_half(value: float, places: int) -> float: """실무 ROUND — 반 올림(파이썬 round 의 짝수 맞춤 아님) · 떠도는 끝수는 지움.""" scale = 10**places return math.floor(round6(value) * scale + 0.5 + 1e-9) / scale def finish(value: float, rule: str) -> float: """끝수 처리 — "r{n}" = ROUND · "u{n}" = ROUNDUP(n 자리).""" places = int(rule[1:]) return round_half(value, places) if rule[0] == "r" else roundup(value, places) 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], shown: set[str] | None = None, extra: list[dict[str, Any]] | None = None ) -> list[tuple[str, str, str, str]]: """자재 열 — 고정 열은 `shown`(사용자 값 있음 · None = 전부) · 관경 · 사본 자재(`extra`) 뒤.""" 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] elif shown is None or entry[0] in shown: out.append(entry) have = {m[0] for m in out} for m in extra or []: if m["key"] not in have: out.append((m["key"], m["name"], m["spec"], m["unit"])) return out def _norm(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).lower() def matched(name: str, spec: str) -> str | None: """사본 자재 → 2공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다 듦 · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 실무 이름과 다르면 「맞춤 없음」(맞춤 표는 필요할 때).""" text = _norm(f"{name}{spec}") for key, fixed, fixed_spec, _ in MATERIALS: if key != "PIPES" and _norm(fixed) in text and _norm(fixed_spec) in text: return key return None 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 {} snapshot = summary.get("snapshot") or {} linked_units = snapshot.get("units") or {} sizes = diameters(columns) shown = {k for cells in units_in.values() for k, v in cells.items() if v is not None} materials = material_columns(sizes, shown, snapshot.get("materials")) 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 = {**(linked_units.get(cid) or {})} # 사본 자재(143-4) if length: defaults[f"pipe_{length.group(1)}"] = 1.0 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, } ) # 끌어옴(143-4) — 고정 · 관경 열은 제 키 · 사본 자재는 이름 · 규격 맞춤 고정 키로 fixed = {m[0] for m in MATERIALS} | {f"pipe_{d}" for d in sizes} pulled: dict[str, float] = {} unmatched: list[str] = [] for key, name, spec, _ in materials: if key not in sums: continue target = key if key in fixed else matched(name, spec) if target is None: unmatched.append(key) else: pulled[target] = round6(pulled.get(target, 0.0) + sums[key]) quantity = { "materials": [{"key": k, "name": n, "spec": s, "unit": u} for k, n, s, u in materials], "rows": rows, "total": sums, "unmatched": unmatched, } # 콘크리트소요자재집계표 mix_in = overrides.get("mix") or {} concrete_rows = [] concrete_total: dict[str, float] = {} for key, name, spec, source, mix in CONCRETE_ROWS: qty = pulled.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, str]] = [] for entry in TOTAL_ROWS: if entry[0] == "PIPES": plan += [ (f"pipe_{d}", "파형강관", f"Φ{d}mm", "m", f"m:pipe_{d}", 0, "u0") for d in sizes ] plan += [ (f"coupling_{d}", "커플링밴드", f"Φ{d}mm", "개", f"cp:{d}", 0, "u0") for d in sizes ] else: plan.append(entry) total_rows = [] for key, group, spec, unit, source, pct_default, rule in plan: ref, _, divisor = source.partition("/") kind, _, name = ref.partition(":") qty: float | None = None if kind == "m": qty = pulled.get(name) elif kind == "c": qty = concrete_total.get(name) elif kind == "cp": 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 = finish(qty * (1 + (pct or 0) / 100), rule) 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": int(rule[1:]), "rounding": "round" if rule[0] == "r" else "up", "panel": key in PANEL_SURCHARGE, } ) return {"quantity": quantity, "concrete": concrete, "material": {"rows": total_rows}}