- 실무 엑셀(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
259 lines
12 KiB
Python
259 lines
12 KiB
Python
"""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 아님)
|