- 서버 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
370 lines
17 KiB
Python
370 lines
17 KiB
Python
"""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}}
|