Files
Aislo/resources/tester/test_121_b08_material_sheets.py
T
eomsangdonandClaude Opus 5.5 449a8cfe9b feat(B08): 총괄자재 · 콘크리트소요자재 · 자재수량 집계표 세 표(121-1)
- 실무 엑셀(4.2 수량산출(2공구)) 꼴 · 구조물 탭 아래 공용 표 · 구조물 수량 C = 구조물 집계표 계
- 단위수량 · ㎥당 배합 · 할증 % 고칠 칸 · 조작 중 TS 짝 계산 · [저장] 때 서버 정본 · 설정 quantity › material_sheets
- 공용 표 고칠 칸(editable · onEdit) · 끝 0 지움(trim)
- 시험 test_121(엑셀 66행 · 11행 · 총괄 G열 · 거울 · 저장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-08 18:35:33 +09:00

259 lines
12 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.
"""121-1 B08 총괄자재 · 콘크리트소요자재 · 자재수량 집계표 — 2공구 꼴 견본 수치 · 거울 · 저장.
견본 = 실무 `4.2 수량산출(2공구).xlsx` 의 구조물 집계표 계 · 단위수량
(기슭막이 · 돌붙임 · 관공 · 돌집수정)
→ 자재수량집계표 66행 계 · 콘크리트소요자재집계표 11행 · 총괄자재수량집계표 G열과 맞춤.
거울 = `B08_Quantity_Material_Calc.ts` 를 node 로 돌려 파이썬 짝과 같은 결과.
"""
import asyncio
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Router_Material as router_mod # noqa: E402
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
def _col(cid, group, label, total):
return cid, {"id": cid, "group": group, "label": label}, total
# (열, 계, 단위수량) — 실무 엑셀 구조물집계표 16행 · 자재수량집계표 단위수량 줄
SAMPLE = [
(_col("pipe:revet|돌쌓기(찰)|2.0", ["흙막이", "찰쌓기", "H=2.0"], "m", 20),
{"remicon_25_18_80": 0.31, "concrete_machine": 0.42, "mortar_1_3": 0.019,
"stone_buy_45": 1.92, "bed_stone": 0.31, "rubble": 0.75}),
(_col("pipe:revet|돌쌓기(메)|1.5", ["흙막이", "메쌓기", "H=1.5"], "m", 20),
{"remicon_25_18_80": 0.31, "stone_pick_45": 0.54, "bed_stone": 0.24, "rubble": 0.56}),
(_col("pipe:revet|돌쌓기(메)|2.0", ["흙막이", "메쌓기", "H=2.0"], "m", 60),
{"remicon_25_18_80": 0.31, "stone_pick_45": 0.72, "bed_stone": 0.31, "rubble": 0.75}),
(_col("pipe:chute|찰붙임", ["돌붙임", "찰붙임"], "㎡", 20),
{"concrete_machine": 0.16, "mortar_1_3": 0.009, "stone_buy_35": 0.71, "bed_stone": 0.12}),
(_col("pipe:chute|메붙임", ["돌붙임", "메붙임"], "㎡", 90),
{"stone_pick_35": 0.27, "bed_stone": 0.12}),
(_col("pipe:len|800", ["관 공", "Φ800m/m", "관매설"], "m", 84), None),
(_col("pipe:cp|800", ["관 공", "Φ800m/m", "커플링"], "ea", 9), None),
(_col("pipe:basin|800|U", ["관 공", "Φ800m/m", "관보호공"], "돌집수정ㄷ형", 2),
{"concrete_machine": 4.03, "mortar_1_3": 0.157, "stone_pick_35": 1.71,
"stone_pick_45": 13.8, "bed_stone": 2.54, "rubble": 3.75}),
(_col("pipe:basin|800|L", ["관 공", "Φ800m/m", "관보호공"], "돌집수정ㄴ형", 3),
{"concrete_machine": 2.69, "mortar_1_3": 0.096, "stone_pick_35": 1.71,
"stone_pick_45": 7.6, "bed_stone": 1.53, "rubble": 2.08}),
(_col("pipe:len|1000", ["관 공", "Φ1000m/m", "관매설"], "m", 16), None),
(_col("pipe:cp|1000", ["관 공", "Φ1000m/m", "커플링"], "ea", 2), None),
(_col("pipe:len|1200", ["관 공", "Φ1200m/m", "관매설"], "m", 22), None),
(_col("pipe:cp|1200", ["관 공", "Φ1200m/m", "커플링"], "ea", 3), None),
] # fmt: skip
SUMMARY = {
"columns": [col for (_, col, _), _ in SAMPLE],
"total": {cid: total for (cid, _, total), _ in SAMPLE},
}
UNITS = {cid: units for (cid, _, _), units in SAMPLE if units}
#: 실무 엑셀 ㎥당 배합(모래 · 자갈 ㎥ 은 엑셀 값) — 엑셀 계와 맞춰 보기
EXCEL_MIX = {"c40": {"sand": 0.48, "gravel": 0.65}}
def _over(**parts):
return clean_overrides({"units": UNITS, **parts})
def _run_ts(summary, overrides):
calc = ROOT / "B08_Quantity" / "B08_Quantity_Material_Calc.ts"
body = (
f"\nconst s = {json.dumps(summary, ensure_ascii=False)};"
f"\nconst o = {json.dumps(overrides, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeMaterialSheets(s, o)));"
)
script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _total_rows(sheets):
return {row["key"]: row for row in sheets["material"]["rows"]}
def test_자재수량_계가_엑셀_66행과_같다():
sheets = compute(SUMMARY, _over())
total = sheets["quantity"]["total"]
assert total["remicon_25_18_80"] == 31 # H66
assert total["concrete_machine"] == 27.73 # I66
assert total["mortar_1_3"] == 1.162 # R66
assert total["stone_buy_35"] == 14.2 and total["stone_buy_45"] == 38.4 # W66 · X66
assert total["stone_pick_35"] == 32.85 and total["stone_pick_45"] == 104.4 # Y66 · Z66
assert total["bed_stone"] == 52.47 and total["rubble"] == 84.94 # AA66 · AB66
assert total["pipe_800"] == 84 and total["pipe_1000"] == 16 and total["pipe_1200"] == 22
# 수량 = C × 단위수량 · 커플링은 자재 줄이 아님 · 관보호공 모양은 규격으로
rows = {row["id"]: row for row in sheets["quantity"]["rows"]}
assert "pipe:cp|800" not in rows
revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
assert revet["qty"] == 20 and revet["amounts"]["stone_buy_45"] == 38.4
assert revet["amounts"]["sod"] is None # 빈 단위수량 = 빈칸(0 아님)
basin = rows["pipe:basin|800|U"]
assert (basin["name"], basin["spec"], basin["unit"]) == (
"관 공",
"Φ800m/m 관보호공 돌집수정ㄷ형",
"개소",
)
keys = [m["key"] for m in sheets["quantity"]["materials"]]
assert keys.index("rebar_d25") + 1 == keys.index("pipe_800")
assert keys.index("pipe_1200") + 1 == keys.index("mortar_1_3")
def test_콘크리트소요_엑셀_배합이면_엑셀_계():
sheets = compute(SUMMARY, _over(mix=EXCEL_MIX))
total = sheets["concrete"]["total"]
assert total["cement"] == 9549.41 # E11
assert total["sand"] == 14.5886 # G11
assert total["gravel"] == 18.0245 # I11
rows = _total_rows(sheets)
assert rows["sand"]["amount"] == 16 and rows["gravel"]["amount"] == 19 # G4 · G5
assert rows["cement"]["qty"] == 238.73525 and rows["cement"]["amount"] == 244 # G18
def test_기본_배합_원문값():
sheets = compute(SUMMARY, _over())
per = {row["key"]: row["per"] for row in sheets["concrete"]["rows"]}
assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612} # 775/1700 · 1101/1800
assert per["c25"] == {"cement": 346, "sand": 0.487, "gravel": 0.562}
assert per["c40_plain"] == {"cement": None, "sand": None, "gravel": None} # 근거 없음
assert per["m12"]["cement"] == 680 and per["m13"] == {
"cement": 510,
"sand": 1.1,
"gravel": None,
}
amounts = {row["key"]: row for row in sheets["concrete"]["rows"]}
assert amounts["c40"]["qty"] == 27.73 and amounts["m13"]["qty"] == 1.162
assert amounts["c25"]["qty"] is None # 끌어올 열 없음
assert amounts["c40"]["amounts"]["cement"] == 8956.79 # 323 × 27.73
def test_총괄_할증_올림_관_커플링():
rows = _total_rows(compute(SUMMARY, _over()))
# 파형강관 = 구조물 집계표 관매설 계 · 커플링 = 집계표 계 그대로 · 관급 할증 0
assert [rows[f"pipe_{d}"]["amount"] for d in (800, 1000, 1200)] == [84, 16, 22]
assert [rows[f"coupling_{d}"]["amount"] for d in (800, 1000, 1200)] == [9, 2, 3]
assert rows["pipe_800"]["surcharge"] == 0
# 레미콘 25-18 = 올림(31 × 1.02) — 엑셀은 ROUND 2 (31.62) · 지시 = 올림
assert rows["remicon_25_18_80"]["surcharge"] == 2 and rows["remicon_25_18_80"]["amount"] == 32
# 깬돌 = 할증 빈칸 · 올림(14.2 → 15 · 38.4 → 39) = 엑셀 G26 · G27
assert rows["stone_buy_35"]["amount"] == 15 and rows["stone_buy_45"]["amount"] == 39
assert rows["stone_buy_35"]["surcharge"] is None
# 끌어올 값이 없는 줄 = 빈칸
assert rows["rebar_d13"]["qty"] is None and rows["rebar_d13"]["amount"] is None
assert rows["sod"]["surcharge"] == 10 and rows["cement"]["surcharge"] == 2
def test_사용자_값이_기본을_이김():
over = _over(
surcharge={"stone_buy_45": 5, "pipe_800": None},
mix={"c40_plain": {"cement": 312}},
units={**UNITS, "pipe:len|800": {"pipe_800": None}, "pipe:chute|메붙임": {"sod": 1}},
)
sheets = compute(SUMMARY, over)
rows = _total_rows(sheets)
assert rows["stone_buy_45"]["amount"] == 41 # 올림(38.4 × 1.05 = 40.32)
assert rows["pipe_800"]["qty"] is None # 관매설 단위수량을 비우면 파형강관도 빈칸
assert rows["sod"]["qty"] == 90 and rows["sod"]["amount"] == 99 # 떼 10 %
plain = next(r for r in sheets["concrete"]["rows"] if r["key"] == "c40_plain")
assert plain["per"]["cement"] == 312
def test_다듬기():
out = clean_overrides(
{"units": {"a": {"x": "1", "y": True, "z": None, "w": 2}, "b": "깨짐"},
"mix": {"c40": {"cement": float("nan")}}, "surcharge": {"sand": 7, "bad": "x"}, "etc": 1}
) # fmt: skip
assert out == {"units": {"a": {"z": None, "w": 2.0}}, "mix": {}, "surcharge": {"sand": 7.0}}
assert clean_overrides(None) == {"units": {}, "mix": {}, "surcharge": {}}
def test_거울_TS와_파이썬이_같다():
for over in (
_over(),
_over(mix=EXCEL_MIX, surcharge={"sand": 12, "pipe_1000": 3}),
clean_overrides({}),
):
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
empty = {"columns": [], "total": {}}
assert _run_ts(empty, clean_overrides({})) == compute(empty, clean_overrides({}))
def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch):
async def summary(_project_id):
return {**SUMMARY, "rows": [], "notes": []}
async def project_root(_project_id):
return tmp_path
monkeypatch.setattr(router_mod, "structure_summary", summary)
monkeypatch.setattr(router_mod, "_project_root", project_root)
first = asyncio.run(router_mod.material_sheets("p"))
assert first["overrides"] == {"units": {}, "mix": {}, "surcharge": {}}
assert _total_rows(first["sheets"])["pipe_800"]["amount"] == 84
body = {"overrides": {"units": UNITS, "surcharge": {"sand": 12}}}
saved = asyncio.run(router_mod.save_material_sheets("p", body))
assert saved["sheets"] == compute(SUMMARY, clean_overrides(body["overrides"]))
stored = json.loads((tmp_path / "project_settings.json").read_text(encoding="utf-8"))
sheet = stored["quantity"]["material_sheets"]
assert sheet["surcharge"] == {"sand": 12} and sheet["mix"] == {} # 기본값은 안 씀
# 지운 칸은 지워짐(통째 갈아 끼움)
asyncio.run(router_mod.save_material_sheets("p", {"overrides": {"units": {}}}))
again = asyncio.run(router_mod.material_sheets("p"))
assert again["overrides"] == {"units": {}, "mix": {}, "surcharge": {}}
def test_화면_연결():
page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
assert "renderMaterialSheet(body, projectId, picked)" in page
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
assert "computeMaterialSheets(payload.source, overrides)" in ui # 조작 중 = 짝 계산
assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음
assert "일위대가 재료와 별개" in ui
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "include_router(b08_material_router" in main
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
assert '"b08-materials": { bucket: "draft", scope: "project" }' in state
def test_공용_표_고칠_칸():
table = ROOT / "ui_template" / "ui_template_data_table.ts"
body = """
const html = renderDataTableHtml({
columns: [
{ key: "a", label: "A" },
{ key: "b", label: "B", kind: "number", decimals: 3, trim: true },
],
rows: [{ a: "x", b: 0.31 }, { a: "y", b: 6.2 }],
total: { a: "계", b: 6.51 },
editable: (i, key) => i === 0 && key === "b",
});
console.log(JSON.stringify(html));
"""
script = IMPORTS.sub("", table.read_text(encoding="utf-8")) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
html = json.loads(result.stdout)
assert '<input class="ui-data-table__input" data-i="0" data-k="b" value="0.31"' in html
assert html.count("<input") == 1 # 둘째 줄 · 합계 줄은 읽기만
assert '<td data-num="">6.2</td>' in html # 끝 0 지움(6.200 아님)