feat(b08): 자재수량 → 총괄 · 콘크리트소요 · 채집석 공제 엑셀 흐름(160-3 · 4 · 5)

- 160-3 맞춤 없는 사본 자재 = 총괄 끝 줄(이름 · 규격 · 단위 그대로 · 알림에 이름 목록) · 맞춤 = 이름 · 규격 · 단위 · 기계 주연료 · 할증(ℓ) 뺌 · 참조 줄(승격 · 사전조합) 안 재료 셈 · 고른 품목 = MT 코드 · 파이썬 · TS 짝
- 160-4 콘크리트(기계) · 모르터 1:3 열 늘 보임(손 단위수량 → 콘크리트소요) · 엑셀 손값과 다른 배합 · 할증은 표 아래 알림 한 줄
- 160-5 채집석 공제(돌 채집 둘 · 고임돌 · 막자갈 합) = 총괄 줄 · 토공 잔토처리 = ROUND 0(잔토 − 공제) · 공제 줄 보임 · TS 짝
- 시험 test_160_3 · 4 · 5 새 · test_143_3 · 143_4 · 69 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-09 22:53:18 +09:00
co-authored by Claude Opus 5.5
parent 774560d480
commit c6bb3cc5b6
13 changed files with 542 additions and 51 deletions
@@ -26,5 +26,5 @@ def test_자재_셋_값_없는_줄_숨김():
def test_표_넷_공용_카드_안():
assert "createCard({ body: [toolbar, table.root] })" in MATERIAL
assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4)
assert "createCard({ body: parts })" in SUMMARY
@@ -53,7 +53,16 @@ def test_사본_자재가_열과_단위수량_기본():
over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}})
sheets = compute(SUMMARY, over)
keys = [m["key"] for m in sheets["quantity"]["materials"]]
assert keys == ["pipe_1000", "sod", "MT000001", "고임돌|㎥", "야면석|㎥"] # 고정 열 = 손 값만
# 고정 열 = 손 값만 · 콘크리트소요 원천 둘은 늘(160-4)
assert keys == [
"concrete_machine",
"pipe_1000",
"mortar_1_3",
"sod",
"MT000001",
"고임돌|㎥",
"야면석|㎥",
]
rows = {row["id"]: row for row in sheets["quantity"]["rows"]}
revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
assert revet["units"]["MT000001"] == 0.31 and revet["amounts"]["MT000001"] == 3.1
@@ -62,6 +71,13 @@ def test_사본_자재가_열과_단위수량_기본():
assert rows[LEN]["units"]["pipe_1000"] == 1 # 관매설 → 파형강관 1 그대로
# 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음
assert sheets["quantity"]["unmatched"] == ["야면석|㎥"]
last = sheets["material"]["rows"][-1] # 맞춤 없음 = 총괄 끝 줄 그대로(160-3)
assert (last["key"], last["group"], last["unit"], last["qty"]) == (
"야면석|㎥",
"야면석",
"㎥",
15,
)
total = {row["key"]: row for row in sheets["material"]["rows"]}
assert total["remicon_25_18_80"]["qty"] == 3.1
assert total["pipe_1000"]["qty"] == 20
@@ -0,0 +1,128 @@
"""160-3 B08 자재수량 → 총괄 — 맞춤 없는 사본 자재 = 총괄 끝 줄 · 맞춤은 규격 · 단위까지 ·
기계 주연료 · 할증(ℓ) 뺌 · 참조 줄(승격 · 사전조합) 안 재료 셈 · 고른 품목 = MT 코드 · 거울.
"""
from __future__ import annotations
import asyncio
import json
from decimal import Decimal
from B08_Quantity import B08_Quantity_Router_Material as router
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, matched
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete.M02_Labor_Ref import ref_id
from resources.tester.test_121_b08_material_sheets import _run_ts
REVET = "pipe:revet|돌쌓기(찰)|2.0"
SUMMARY = {
"columns": [{"id": REVET, "group": ["흙막이", "찰쌓기", "H=2.0"], "label": "m"}],
"total": {REVET: 10},
"snapshot": {
"materials": [
{"key": "MT1", "name": "레미콘", "spec": "25-18-80", "unit": "m3"},
{"key": "MT2", "name": "이형철근", "spec": "D13", "unit": "ton"}, # 단위 다름
{"key": "부직포|㎡", "name": "토목용부직포", "spec": "", "unit": "㎡"},
],
"units": {REVET: {"MT1": 0.31, "MT2": 0.002, "부직포|㎡": 2.871}},
},
}
def test_맞춤은_이름_규격_단위():
assert matched("레미콘", "25-18-80", "㎥") == "remicon_25_18_80"
assert matched("레미콘", "25-18-80", "M3") == "remicon_25_18_80" # m3 = ㎥
assert matched("이형철근", "D13", "ton") is None # kg ↔ ton 은 맞춤 아님
assert matched("호안블럭", "", "㎥") is None # 규격 빈 고정 열도 단위는 봄
def test_맞춤_없는_자재는_총괄_끝_줄_그대로():
sheets = compute(SUMMARY, clean_overrides({"surcharge": {"부직포|㎡": 10}}))
assert sheets["quantity"]["unmatched"] == ["MT2", "부직포|㎡"]
rows = sheets["material"]["rows"]
tail = {row["key"]: row for row in rows[-2:]}
steel, cloth = tail["MT2"], tail["부직포|㎡"]
assert (steel["group"], steel["spec"], steel["unit"], steel["qty"]) == (
"이형철근",
"D13",
"ton",
0.02,
)
assert steel["surcharge"] is None and steel["amount"] == 0.02 # 할증 빈칸 · 끝수 없음
assert cloth["qty"] == 28.71 and cloth["amount"] == 31.581 # 손 할증 10 %
total = {row["key"]: row for row in rows}
assert total["remicon_25_18_80"]["qty"] == 3.1 # 맞춤 = 고정 줄로
def test_거울_TS와_파이썬이_같다():
over = clean_overrides({"surcharge": {"부직포|㎡": 10}})
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch):
async def summary(_project_id):
return {**SUMMARY, "notes": []}
monkeypatch.setattr(router, "structure_summary", summary)
monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"])
got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({})))
assert got["notes"] == [
"2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡"
]
SC = {"층": "project", "주인": None, "키": "SC-000001"}
SP = {"층": "project", "주인": None, "키": "SP-000001"}
def test_참조_줄_재료_연료_뺌_고른_품목(tmp_path, monkeypatch):
"""사전조합(로직 L1 손 2 · 알약 배 10 + 승격) · 승격(L2 · 배 4) 안 재료 · 연료(ℓ) 뺌."""
fake = {
"줄": [
{"수량": 2, "배": 1, "참조": SC},
{"수량": 3, "배": 1, "참조": SP},
{
"수량": 4,
"배": 2,
"재료": [
{"이름": "주연료 할증", "단위": "ℓ", "수량": 5},
{"이름": "페인트", "단위": "ℓ", "수량": 1},
{"이름": "레미콘", "단위": "㎥", "수량": 0.5, "품목": {"키": "MT9"}},
],
},
],
"자재": [],
}
price = {"묶음": {"줄": [{"키": "MT9", "이름": "레디믹스트 레미콘", "규격": "25-18-80"}]}}
frozen = {
ref_id(SC): {
"담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP}],
"알약": [{"로직": ["L1"], "배": 10}],
},
ref_id(SP): {"담은로직": [{"로직": "L2"}], "배": 4},
}
mats = {
"L1": [{"이름": "모래", "단위": "㎥", "수량": Decimal("5")}],
"L2": [{"이름": "돌", "단위": "㎥", "수량": 8}, {"이름": "경유", "단위": "ℓ", "수량": 3}],
}
(tmp_path / "단가.json").write_text(json.dumps(price, ensure_ascii=False), "utf-8")
(tmp_path / "일위대가.json").write_text(
json.dumps({"참조사본": frozen}, ensure_ascii=False), "utf-8"
)
monkeypatch.setattr(copies, "calc", lambda root, folder: fake)
monkeypatch.setattr(copies, "folder_of", lambda root, folder: tmp_path)
monkeypatch.setattr(item_prices, "load_prices", lambda bundle: "whole")
monkeypatch.setattr(labor, "run_line", lambda whole, code, *rest: {"재료": mats[code]})
meta: dict = {}
got = router._folder_units("r", "f", meta)
# 사전조합 1 단위 = 모래 5 ÷ 10 × 2 + 돌 2 · 승격 1 단위 = 돌 8 ÷ 4 · 로직 줄 = × 4 ÷ 2
assert got == {"모래|㎥": 2.0, "돌|㎥": 4.0 + 6.0, "페인트|ℓ": 2.0, "MT9": 1.0}
assert meta["MT9"] == {
"key": "MT9",
"name": "레디믹스트 레미콘",
"spec": "25-18-80",
"unit": "㎥",
}
assert matched("레디믹스트 레미콘", "25-18-80", "㎥") == "remicon_25_18_80"
@@ -0,0 +1,47 @@
"""160-4 B08 콘크리트(기계) · 모르터 1:3 열 = 사본 없어도 늘 보임 + 손 단위수량 → 콘크리트소요
(엑셀 I66 · R66 꼴 · 레미콘은 안 거침) → 총괄 시멘트 · 모래 · 자갈 — 엑셀 2공구 검산.
"""
from __future__ import annotations
from pathlib import Path
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute
from resources.tester.test_121_b08_material_sheets import (
EXCEL_MIX,
EXCEL_PANEL,
SUMMARY,
UNITS,
_run_ts,
)
ROOT = Path(__file__).resolve().parents[2]
def test_두_열은_사본_없어도_늘_보임():
bare = {"columns": SUMMARY["columns"], "total": SUMMARY["total"]}
keys = [m["key"] for m in compute(bare, clean_overrides({}))["quantity"]["materials"]]
assert keys == ["concrete_machine", "pipe_800", "pipe_1000", "pipe_1200", "mortar_1_3"]
def test_엑셀_검산_시멘트_모래_자갈_레미콘():
"""손 단위수량(엑셀 자재수량집계표) · 엑셀 배합 · 엑셀 할증 → 총괄 G18 244 · G4 16 · G5 19."""
over = clean_overrides({"units": UNITS, "mix": EXCEL_MIX})
sheets = compute(SUMMARY, over, EXCEL_PANEL)
concrete = {row["key"]: row for row in sheets["concrete"]["rows"]}
assert concrete["c40"]["qty"] == 27.73 and concrete["m13"]["qty"] == 1.162 # I66 · R66
assert [concrete[k]["qty"] for k in ("c25", "c40_plain", "m12")] == [None] * 3 # 두 열에서만
assert sheets["concrete"]["total"] == {"cement": 9549.41, "sand": 14.5886, "gravel": 18.0245}
total = {row["key"]: row for row in sheets["material"]["rows"]}
assert total["cement"]["amount"] == 244
assert total["sand"]["amount"] == 16 and total["gravel"]["amount"] == 19
assert total["remicon_25_18_80"]["amount"] == 31.62 # 레미콘 = 총괄로 곧장
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
def test_엑셀_손값과_다른_상수는_알림만():
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
assert "2공구 엑셀 손값과 다름" in ui and "b08MaterialExcel" in ui
# 기본 배합은 품셈 근거 그대로(124)
per = {r["key"]: r["per"] for r in compute(SUMMARY, clean_overrides({}))["concrete"]["rows"]}
assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612}
@@ -0,0 +1,98 @@
"""160-5 B08 채집석 공제 — 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68) → 총괄 줄 · 토공집계표
잔토처리에서 뺌(엑셀 토적집계표 H32) · 잔토처리 끝수 = ROUND 0 그대로(브레인 정함) · 거울.
"""
from __future__ import annotations
import asyncio
import json
import subprocess
from pathlib import Path
from B08_Quantity import B08_Quantity_Router_Material as router
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
excel_round,
haul_lists,
summary_rows,
)
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute
from resources.tester.test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts
ROOT = Path(__file__).resolve().parents[2]
BALANCE = 994.4747340425532 # 엑셀 사토운반본선!K19
TOTALS = {
"cut_soil_volume_m3": 0.0,
"cut_rock_volume_m3": 0.0,
"ditch_soil_volume_m3": 0.0,
"ditch_rock_volume_m3": 0.0,
"adjusted_total_m3": 0.0,
"fill_volume_m3": 0.0,
"diverted_m3": 0.0,
"balance_m3": BALANCE,
"benching_area_m2": 0.0,
}
def _deduct_row(sheets):
return next(row for row in sheets["material"]["rows"] if row["key"] == "picked_deduct")
def test_총괄_채집석_공제_엑셀_Y68():
over = clean_overrides({"units": UNITS})
sheets = compute(SUMMARY, over)
row = _deduct_row(sheets)
# 32.85 + 104.4 + 52.47 + 84.94 = 274.66 · 끝수 없음 · 할증 없음
assert (row["group"], row["unit"], row["qty"], row["amount"]) == (
"채집석 공제",
"㎥",
274.66,
274.66,
)
assert row["surcharge"] is None
keys = {r["key"] for r in sheets["material"]["rows"] if r["qty"] is not None}
assert not {"stone_pick_35", "stone_pick_45", "bed_stone"} & keys # 채집재는 총괄로 안 감
assert _run_ts(SUMMARY, over) == sheets
assert _deduct_row(compute(SUMMARY, clean_overrides({})))["qty"] is None # 채집재 없으면 빈칸
def test_잔토처리에서_뺌_엑셀_사토():
rows = summary_rows(TOTALS, haul_lists(None), 90, 274.66)
value = {r["group"]: r["value"] for r in rows}
assert excel_round(BALANCE - 274.66, 2) == 719.81 # 엑셀 토적집계표 F32
assert value["잔토처리"] == 720 # ROUND 0 그대로
assert value["채집석 공제"] == 274.66
assert {r["group"]: r["value"] for r in summary_rows(TOTALS, haul_lists(None))}[
"잔토처리"
] == 994
def test_거울_TS_잔토처리():
rows = summary_rows(TOTALS, haul_lists(None), 90, 274.66)
calc = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8")
script = calc + (
f"\nconst rows = {json.dumps(rows, ensure_ascii=False)};"
f"\nconsole.log(JSON.stringify(recountAdjustedSummary(rows, {json.dumps(TOTALS)}, 0)));"
)
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
assert json.loads(result.stdout) == json.loads(json.dumps(rows))
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, "structure_summary", summary)
monkeypatch.setattr(router, "_project_root", project_root)
assert asyncio.run(router.picked_deduction("p", tmp_path)) is None
asyncio.run(router.save_material_sheets("p", {"overrides": {"units": UNITS}}))
assert asyncio.run(router.picked_deduction("p", tmp_path)) == 274.66
earth = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8")
assert 'summary_rows(table["totals"], haul, soft, deduct)' in earth
@@ -202,8 +202,9 @@ def test_토공집계표_식() -> None:
assert notes[group] == "비율 미정"
assert value[("표토제거", "", "")] is None
assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows}
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7
assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8
# + 채집석 공제(160-5 · 공제 없으면 빈칸)
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 8
assert len(rows) == 26 # 시트 C5:C37 33 − 구조물 쪽 8 + 채집석 공제
def test_토적표_머리_글자는_사용자_견본대로():