Files
Aislo/B08_Quantity/B08_Quantity_Material_Calc.py
T
eomsangdonandClaude Opus 5.5 8d4f4e8718 feat(B08): 사본 일위대가 자재 → 자재수량집계표 단위수량 재연결(143-4)
- 서버 snapshot_units: 칸마다 그 칸을 낸 조합의 연결된 사본 셈(자재 MT 코드 · 로직 재료 이름|단위) × 칸 값 ÷ 열 계
- 짝 계산(py · ts): 자재 열 = 사본 자재 + 관경 · 2공구 고정 열은 손 값 있을 때만 · 손 값 이김 · 관매설 → 파형강관 1 그대로
- 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤 · 안 맞는 사본 자재는 알림 「맞춤 없음」
- 구조물 집계표 결과에 칸 출처(sources) · 채집석 공제는 그대로 남김
- 시험: test_143_4 새로(서버 셈 · 로직 재료 · 거울) · test_121 기대 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-09 15:25:05 +09:00

370 lines
17 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 + 할증)) · 커플링은 구조물 집계표 계 그대로
끝수 = 실무 엑셀 꼴 줄마다(레미콘 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}}