- 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
415 lines
18 KiB
Python
415 lines
18 KiB
Python
"""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 × (중기 시간)
|