Files
Aislo/resources/tester/test_145_2_b09_labor_calc.py
T
eomsangdonandClaude Opus 5.5 3bd20530bf fix(B09): 내역 구조물 줄 ref = 대가 블록 — 집계표 역전개 · 이동 이음 · 한 바퀴 시험(145-2)
- daega_price / daegaPrice 에 블록 key · split_by_block(파이썬 · TS) = 대가 단가가 있으면 ref {tab: dg, key}
- 한 바퀴 시험 = 설계내역서 → 대가 → 산근(145-1) → 중기 → 집계표(145-5) · 연결 없음 0

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

415 lines
18 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.
"""145-2 B09 일위대가표 · 일위대가목록표 — 엑셀 검산 대조 ·
거울(`B09_Estimation_Calc_Labor.py` ↔ `.ts`) ·
견본 사본 → 대가 블록(`B09_Estimation_Labor_Source`).
엑셀 견본 = 기번3 공통 xlsx 「일위대가표」 같은 입력:
대가1 모르터바르기 109,259 · 대가2 파형강관부설 D800 16,001(15,002 · 584 · 415 · 공구손료 2 %) ·
대가9 찰붙임 52,943(42,556 · 3,842 · 6,545 · 중기 줄) ·
대가14 돌붙임(채집) 조합 101,522(77,755 · 9,519 · 14,248 · 산근 11 · 10 · 30 + 대가 9 · 1 겹)
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
from M02_MasterTemplete import M02_Item_Snapshot as snapshots # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
# ── 엑셀 같은 입력 ─────────────────────────────────────────────────────────
SHEETS = {
"labor": [
{"key": "L00017", "price": 165545}, # 보통인부
{"key": "L00032", "price": 243168}, # 배관공(수도)
{"key": "L00005", "price": 258935}, # 석공
],
"material": [
{"key": "M00101", "price": 120, "supply": "별도"}, # 시멘트 별도계상 → 대가 안 0원
{"key": "M00103", "price": 25000, "supply": "별도"}, # 모래
{"key": "M00222", "price": 99000, "supply": "관급"}, # 파형강관(800)
],
"machine": [
{"key": "X00044", "labor": 47231, "material": 7784, "expense": 9994},
{"key": "X00020", "labor": 55700, "material": 18296, "expense": 31168},
],
"gaps": [],
}
SANGEUN = {
"SG11": {"labor": 18289, "material": 5101, "expense": 7008},
"SG10": {"labor": 18084, "material": 2498, "expense": 4234},
"SG30": {"labor": 66071, "material": 1008, "expense": 304},
}
def _l(kind, key, qty, name="", **extra):
return {"kind": kind, "key": key, "qty": qty, "name": name or key, **extra}
def _b(bid, kind, name, lines):
return {
"id": bid,
"kind": kind,
"name": name,
"spec": "",
"unit": "",
"cite": "",
"lines": lines,
}
EXCEL = {
"blocks": [
_b("DG14", "조합", "돌붙임(채집)", [
_l("sangeun", "SG11", 1), _l("daega", "DG9", 1), _l("sangeun", "SG10", 0.15),
_l("sangeun", "SG30", 0.2), _l("daega", "DG1", 0.009),
]),
_b("DG1", "품셈", "모르터바르기", [
_l("material", "M00101", 510), _l("material", "M00103", 1.1),
_l("labor", "L00017", 0.66),
]),
_b("DG2", "품셈", "파형강관부설", [
_l("material", "M00222", 1), _l("labor", "L00032", 0.04), _l("labor", "L00017", 0.02),
{"kind": "pct", "name": "공구손료", "part": "material", "base": "lines:labor",
"qty": 2},
_l("machine", "X00044", 0.0416),
]),
_b("DG9", "품셈", "찰붙임", [
_l("labor", "L00005", 0.1), _l("labor", "L00017", 0.03), _l("machine", "X00020", 0.21),
]),
],
"owners": {"pipe@100.0": "DG14"},
} # fmt: skip
def _block(out, bid):
return next(b for b in out["daega_blocks"] if b["id"] == bid)
def _sums(block):
return (block["sum"], block["labor"], block["material"], block["expense"])
def test_엑셀_대가_블록_검산():
out = compute_labor(EXCEL, SHEETS, SANGEUN)
assert _sums(_block(out, "DG1")) == (109259, 109259, 0, 0)
assert _sums(_block(out, "DG2")) == (16001, 15002, 584, 415)
assert _sums(_block(out, "DG9")) == (52943, 42556, 3842, 6545)
assert _sums(_block(out, "DG14")) == (101522, 77755, 9519, 14248)
pct = next(ln for ln in _block(out, "DG2")["lines"] if ln["kind"] == "pct")
assert (
pct["prices"]["material"] == pytest.approx(13037.6) and pct["amounts"]["material"] == 260.7
)
mortar = _block(out, "DG14")["lines"][-1]
assert mortar["ref_no"] == "대가2" and mortar["amounts"]["labor"] == 983.3 # 109,259 × 0.009
assert _block(out, "DG1")["lines"][0]["amounts"]["material"] == 0 # 별도계상 = 대가 안 0원
# 목록표 = 블록 합계 · 내역서가 읽는 대가 단가
assert out["daega"][0] == {
"no": 1, "key": "DG14", "kind": "조합", "name": "돌붙임(채집)", "spec": "", "unit": "",
"sum": 101522, "labor": 77755, "material": 9519, "expense": 14248,
} # fmt: skip
assert daega_price(out, "pipe@100.0") == {
"no": "대가1",
"key": "DG14",
"labor": 77755,
"material": 9519,
"expense": 14248,
"sum": 101522,
}
assert daega_price(out, "pipe@999.0") is None
assert out["gaps"] == []
def _blank_cases():
broken = json.loads(json.dumps(EXCEL))
broken["blocks"][2]["lines"][1]["key"] = "L09999" # 노임 없음 → 그 블록 · 부른 블록 빈칸 아님
broken["blocks"].append(_b("DGX", "조합", "순환", [_l("daega", "DGY", 1)]))
broken["blocks"].append(_b("DGY", "조합", "순환2", [_l("daega", "DGX", 1)]))
broken["blocks"].append(_b("DGZ", "품셈", "수량 없음", [_l("labor", "L00017", None)]))
return broken
def test_빈칸_규칙():
out = compute_labor(_blank_cases(), SHEETS, {})
assert _block(out, "DG2")["sum"] is None and _block(out, "DG2")["labor"] is None
assert _block(out, "DG14")["sum"] is None # 산근 단가 없음(145-1 전)
assert _block(out, "DGZ")["sum"] is None
assert _block(out, "DGX")["sum"] is None or _block(out, "DGY")["sum"] is None # 순환 = 빈칸
whys = {g["why"] for g in out["gaps"]}
assert {"노임 단가 없음", "산근 단가 없음(145-1)", "수량 없음", "% 밑수 없음"} <= whys
def _ts(daega, sheets, sangeun):
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
calc = IMPORTS.sub(
"", (ROOT / "B09_Estimation/B09_Estimation_Calc_Labor.ts").read_text("utf-8")
)
body = (
f"\nconst d = {json.dumps(daega, ensure_ascii=False)};"
f"\nconst s = {json.dumps(sheets, ensure_ascii=False)};"
f"\nconst g = {json.dumps(sangeun, ensure_ascii=False)};"
"\nconst out = computeLabor(d, s, g);"
"\nconsole.log(JSON.stringify({out, price: daegaPrice(out, 'pipe@100.0')}));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", base + calc + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _same(a, b):
"""JSON 거울 비교 — 수는 값으로(0 · 0.0 같음)."""
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return abs(a - b) < 1e-9
return a == b
@pytest.mark.parametrize("case", ["excel", "blank"])
def test_거울_파이썬_TS(case):
daega, sangeun = (EXCEL, SANGEUN) if case == "excel" else (_blank_cases(), {})
py = json.loads(json.dumps(compute_labor(daega, SHEETS, sangeun), ensure_ascii=False))
ts = _ts(daega, SHEETS, sangeun)
assert _same(py, ts["out"])
assert _same(daega_price(py, "pipe@100.0"), ts["price"])
# ── 견본 사본 → 대가 블록 ───────────────────────────────────────────────────
STONE = {"뒷길이": 45, "돌": "깬돌", "쌓기": "켜쌓기", "높이": 2}
LINES = [
{"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기(인력 줄)
{"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설(중기 줄)
{"로직": "GF000143", "수량": {"손": 1}}, # 케이블 크레인 운반대(재료 · 재료비 % 줄)
]
SC = {
"참조|system||SC-T": {
"이름": "사전조합T",
"단위": "m3",
"담은로직": [{"로직": "GF000219", "입력값": STONE, "수량": {"손": 3}}],
}
}
def _item(key):
return {"layer": "system", "owner": None, "key": key, "name": f"조합 {key}"}
def _values(given):
return {"GF000219": {k: {"값": v} for k, v in given.items()}}
def _snap(root, owner, key, lines, values, copies=None):
folder = snapshots.structures_dir(root) / snapshots.folder_name(owner)
folder.mkdir(parents=True)
write = lambda name, data: (folder / name).write_text( # noqa: E731
json.dumps(data, ensure_ascii=False), encoding="utf-8"
)
codes = [one["로직"] for one in lines if "로직" in one]
for doc in (copies or {}).values():
codes += [one["로직"] for one in doc["담은로직"] if "로직" in one]
write(snapshots.MANIFEST_NAME, {"원본": {**_item(key), "경로": ["기슭막이", key]}})
write("단가.json", item_prices.to_json(item_prices.collect(codes)))
doc = {"종류": "일위대가", "단위": "m", "담은로직": lines, "자재": []}
write("일위대가.json", {"줄": doc, **({"참조사본": copies} if copies else {})})
write("입력값.json", values)
def _project(root: Path, points) -> Path:
shutil.rmtree(root, ignore_errors=True)
edits = root / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True)
rows = [{"chainage_m": c, "options": {}, "summary_item": _item(k), "source": "user"}
for c, k in points] # fmt: skip
(edits / "pipe_points.json").write_text(json.dumps({"points": rows}), encoding="utf-8")
route = root / "B05_Profile/route"
route.mkdir(parents=True)
(route / "structures.json").write_text('{"revision": 1, "structures": []}', encoding="utf-8")
return root
@pytest.fixture(scope="module")
def project(tmp_path_factory) -> Path:
root = _project(
tmp_path_factory.mktemp("p145") / "proj",
[(100, "SS000901"), (200, "SS000901"), (300, "SS000901"), (400, "SS000902"),
(500, "SS000903")],
) # fmt: skip
_snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE))
_snap(root, "pipe@200.0", "SS000901", LINES, _values(STONE)) # 같은 원본 + 같은 입력값
_snap(root, "pipe@300.0", "SS000901", LINES, _values({**STONE, "높이": 5})) # 입력값 다름
promoted = [
{"로직": "GF000219", "승격": True, "수량": {"손": 1}},
{"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}},
]
_snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC)
# 500 = 지정만 있고 사본 없음 → 없음
return root
def test_견본_사본_대가_블록(project):
got = daega_inputs(project)
owners = got["owners"]
assert set(owners) == {"pipe@100.0", "pipe@200.0", "pipe@300.0", "pipe@400.0"}
assert (
owners["pipe@100.0"] == owners["pipe@200.0"] != owners["pipe@300.0"]
) # 같은 원본+입력 = 한 호표
blocks = {b["id"]: b for b in got["blocks"]}
assert [b["kind"] for b in got["blocks"]] == ["조합"] * 3 + ["사전조합"] + ["품셈"] * 4
a = blocks[owners["pipe@100.0"]]
assert [(ln["kind"], ln["qty"]) for ln in a["lines"]] == [
("daega", 2),
("daega", 0.5),
("daega", 1),
]
stone, digger, crane = (blocks[ln["key"]] for ln in a["lines"])
assert [(ln["kind"], ln["key"]) for ln in stone["lines"]] == [
("labor", "LB000033"),
("labor", "LB000002"),
]
assert stone["cite"] == "산림품셈 13-4-1" and stone["unit"] == "㎡"
assert digger["lines"][0]["kind"] == "machine" and digger["lines"][0]["key"].startswith("EQ")
pct = crane["lines"][-1]
assert pct["kind"] == "pct" and pct["base"] == "part:material" and pct["qty"] == 20
b = blocks[owners["pipe@400.0"]]
assert b["lines"][0]["kind"] == "sangeun" and b["lines"][0]["key"].startswith("SG:GF000219:")
sc = blocks[b["lines"][1]["key"]]
assert sc["kind"] == "사전조합" and b["lines"][1]["qty"] == 4
assert (
sc["lines"][0]["key"] == stone["id"] and sc["lines"][0]["qty"] == 3
) # 겹 대가 = 같은 품셈
def test_견본_사본_끝까지_셈(project):
keys = linked_resource_keys(project)
inputs = prices.build_inputs(None, keys)
inputs["daega"] = daega_inputs(project)
base = compute_base(inputs, {})
out = compute_labor(inputs["daega"], base)
owner = inputs["daega"]["owners"]["pipe@100.0"]
combo = _block(out, owner)
assert combo["sum"] is not None and combo["no"] == 1
# 조합 줄 = 부른 품셈 합계 × 수량(ROUNDDOWN 1) · 합계 = ROUNDDOWN 0
for ln in combo["lines"]:
child = _block(out, ln["key"])
assert ln["prices"]["labor"] == child["labor"]
price = daega_price(out, "pipe@100.0")
assert price["no"] == "대가1" and price["sum"] == combo["sum"]
assert daega_price(out, "pipe@500.0") is None # 사본 없음 = 없음
assert [g for g in out["gaps"] if g["tab"] == "dg" and g["key"] == owner] == []
def test_사본_없으면_빈_표(tmp_path):
root = _project(tmp_path / "empty", [(100, "SS000901")])
got = daega_inputs(root)
assert got == {"blocks": [], "owners": {}}
out = compute_labor(got, {"gaps": []})
assert out["daega"] == [] and out["daega_blocks"] == [] and out["daega_owners"] == {}
def _block_lines(blocks):
"""145-5 집계표 이음 꼴 — 짝 = `B09_Estimation_Summary.block_lines`(들어오면 그것을 씀)."""
try:
from B09_Estimation.B09_Estimation_Summary import block_lines
except ImportError:
kinds = {"labor": "labor", "material": "material", "expense": "expense",
"machine": "machine", "sangeun": "hopyo", "daega": "hopyo"} # fmt: skip
return {
str(b["key"]): {
"lines": [
{"kind": kinds[ln["kind"]], "key": ln["ref"]["key"], "qty": ln["qty"]}
for ln in b["lines"]
if ln["kind"] in kinds
and (ln.get("ref") or {}).get("key")
and ln["qty"] is not None
]
}
for b in blocks
}
return block_lines(blocks)
def test_집계표_이음_꼴():
"""블록 key = 대가 id(= daega_owners 값 · 내역서 대가 ref) · 줄 ref.key · 1 단위당 qty."""
out = compute_labor(EXCEL, SHEETS, SANGEUN)
lines = _block_lines(out["daega_blocks"])
assert set(lines) == {"DG14", "DG1", "DG2", "DG9"}
assert out["daega_owners"]["pipe@100.0"] in lines
assert lines["DG14"]["lines"] == [
{"kind": "hopyo", "key": "SG11", "qty": 1},
{"kind": "hopyo", "key": "DG9", "qty": 1},
{"kind": "hopyo", "key": "SG10", "qty": 0.15},
{"kind": "hopyo", "key": "SG30", "qty": 0.2},
{"kind": "hopyo", "key": "DG1", "qty": 0.009},
]
assert {"kind": "machine", "key": "X00044", "qty": 0.0416} in lines["DG2"]["lines"]
assert all(ln["kind"] != "pct" for ln in lines["DG2"]["lines"]) # % 줄 = 자원 아님
def test_한_바퀴_내역_대가_산근_중기_집계표(tmp_path):
"""이음 끝 확인 — 설계내역서 구조물 줄(B08 열 owners) → 대가 블록마다 나눔 → 대가 줄 →
산근(145-1 · 승격 줄) → 중기 → 집계표(145-5) 역전개 · 짝 셈 한 벌 = Router.compute_sheets."""
from B09_Estimation.B09_Estimation_Calc_Base import rounddown
from B09_Estimation.B09_Estimation_Router import compute_sheets
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
root = _project(tmp_path / "loop", [(100, "SS000901"), (400, "SS000902")])
_snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE))
promoted = [
{"로직": "GF000119", "승격": True, "수량": {"손": 1}}, # 노체포설 = 산근 · 중기 줄
{"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}},
]
_snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC)
inputs = prices.build_inputs(None, linked_resource_keys(root))
inputs["daega"] = daega_inputs(root)
inputs["hopyo"] = {}
inputs["bill"] = {
"items": [
{"key": "st:revet", "section": "2.1", "name": "돌기슭막이", "spec": "", "unit": "m",
"qty": 10, "ref": {"tab": "dg_list", "key": "revet"}, "note": "",
"owners": {"pipe@100.0": 4, "pipe@400.0": 6}},
]
} # fmt: skip
sheets = compute_sheets(root, inputs, {})
owners = sheets["daega_owners"]
rows = [r for r in sheets["bill"]["rows"] if r.get("kind") == "item"]
assert [r["ref"] for r in rows] == [
{"tab": "dg", "key": owners["pipe@100.0"]},
{"tab": "dg", "key": owners["pipe@400.0"]},
]
for row in rows:
block = _block(sheets, row["ref"]["key"])
assert row["price_labor"] == block["labor"] and block["sum"] is not None
assert row["labor"] == rounddown(row["qty"] * block["labor"], 0)
# 산근 이음 — 대가 줄 키 = 145-1 산근 키 · 단가가 들어옴
combo = _block(sheets, owners["pipe@400.0"])
sg_line = combo["lines"][0]
sg_row = next(r for r in sheets["sg_list"] if r["key"] == sg_line["key"])
assert sg_line["kind"] == "sangeun" and sg_line["prices"]["labor"] == sg_row["labor"]
# 집계표 역전개 — 대가 · 사전조합 · 품셈 · 산근 끝까지
summary = compute_summary(sheets, hopyo_lines(sheets))
assert summary["unlinked"] == 0
labor = {r["key"]: r["qty"] for r in summary["sum_labor"]}
assert labor["LB000033"] == 25.6 # 4 × 2 × 0.32 + 6 × (4 × 3 × 0.32)
machine = {r["key"]: r["qty"] for r in summary["sum_machine"]}
assert machine.get("EQ000020", 0) > 0.08 # 품셈 4 × 0.5 × 0.0412 + 산근 6 × (중기 시간)