"""778-4 단가산출(SK) 율 줄 — 고정 비율 더한줄 · 기준 = 비목 · 묶음 · 줄 목록 · 율 위 율 (요구 623 · 분석 621-D 2-4 · 5-2) · M01 읽기만 · 쓰는 일 없음. - 서버 셈(`M02_Sangeun_Calc._rated`) = 손셈 · 묶음 인력 = 인력 줄만(기계 노무 섞인 블록 = 노무비 합과 다름) - 밑 공용 함수 `M02_Labor_Fixed.rate_base` ↔ `Labor_Calc.ts` `rateBase` 거울 · 일위대가 비율 줄 settle 거울(1e-9) - 검사 — 자기 · 뒤 율 줄 · 꼴 틀린 열쇠 · 모르는 기준 막음 · 율 줄 없는 문서 = 엔진 한 번(값 그대로) - B09 — 율 기준 → pct 밑수(`part:` · `bundle:` · `rows:`) · 파이썬 = TS · SK 블록 밑수 꼴 - 굳힘 — 바탕 없는 SK 율 줄 = 한 줄로 안 펼침 · 겹침(`compose`) 열쇠 새 차례 """ from __future__ import annotations import json import os import re import subprocess from decimal import Decimal from pathlib import Path import pytest from B09_Estimation import B09_Estimation_Labor_Source as source from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Library_Flat as flat from M02_MasterTemplete import M02_Sangeun_Calc as calc from M02_MasterTemplete import M02_Sangeun_Doc as sd ROOT = Path(__file__).resolve().parents[2] HERE = os.path.dirname(os.path.abspath(__file__)) IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) SUB = "GC000999" # 원/주 · 조경공 · 보통인부 + 고소작업차 로직 줄 + 덧줄 공구손료 3 % MACHINE = "GC000268" # 기계경비(손료 보정 없는 기종) — 노무비 몫 있음 def _whole(): return m01.loaded()[1] def _rate(basis, q=2, part="경비", name="공구손료"): fx = {"이름": name, "단가": {}, "이음": {}, "비율": {"기준": basis, "비목": part}} return {"고정": fx, "수량": q} def _doc(adds, **more): return { "키": "SK-000001", "이름": "율 시험", "단위": "㎥", "바탕": None, "더한줄": adds, **more, } def _run(doc): whole = _whole() return labor._json(calc.run(sd.clean(doc, None, whole), whole)) LABOR = [ {"종류": "인력", "요소": "LB000002", "수량": 0.5}, # 보통인부 {"종류": "인력", "요소": "LB000003", "수량": 0.2}, # 특별인부 {"종류": "로직", "로직": MACHINE, "입력값": {}, "수량": 0.29}, ] # ── 서버 셈 ────────────────────────────────────────────────────────────────── def test_묶음_인력은_인력_줄만_노무비와_다름(): got = _run(_doc([*LABOR, _rate("인력"), _rate("노무비", 3, name="노무 율")])) by = {x["cid"]: x for x in got["줄"]} people = sum(Decimal(str(by[f"add:#{k}"]["금액"])) for k in (0, 1)) mach = Decimal(str(by["add:#2"]["비목"]["노무비"])) assert mach > 0 # 기계 노무 섞인 블록 tool, cost = by["add:#3"], by["add:#4"] assert tool["금액"] == pytest.approx(float(people * 2 / 100), abs=0.1) assert cost["금액"] == pytest.approx(float((people + mach) * 3 / 100), abs=0.1) assert tool["율"] == 2 and tool["규격"] == "인력의 %" and tool["묶음"] == "경비" assert tool["비목"]["경비"] == pytest.approx(tool["금액"]) assert got["짝잃음"] == [] def test_줄_목록_절_소계와_율_위_율(): adds = [*LABOR, _rate("인력"), _rate(["add:#0", "add:#3"], 10, "재료비", "율 위 율")] adds.append(_rate(["add:#0", "add:#1"], 5, name="절 소계")) got = _run(_doc(adds)) by = {x["cid"]: x for x in got["줄"]} first = Decimal(str(by["add:#0"]["금액"])) tool = (first + Decimal(str(by["add:#1"]["금액"]))) * 2 / 100 assert by["add:#4"]["금액"] == pytest.approx(float((first + tool) * 10 / 100), abs=0.1) assert by["add:#4"]["비목"]["재료비"] == pytest.approx(by["add:#4"]["금액"]) part = first + Decimal(str(by["add:#1"]["금액"])) assert by["add:#5"]["금액"] == pytest.approx(float(part * 5 / 100), abs=0.1) def test_줄_목록_비목_몫_절_노무비(): adds = [*LABOR, _rate(["add:#0", "add:#2@노무비"], 6, name="제잡비")] by = {x["cid"]: x for x in _run(_doc(adds))["줄"]} want = Decimal(str(by["add:#0"]["금액"])) + Decimal(str(by["add:#2"]["비목"]["노무비"])) assert by["add:#3"]["금액"] == pytest.approx(float(want * 6 / 100), abs=0.1) with pytest.raises(sd.DocError, match="자기"): sd.clean(_doc([LABOR[0], _rate(["add:#1@노무비"])]), None, _whole()) def test_바탕_SK_율_줄과_바탕_덧줄(): whole = _whole() doc = {**_doc([_rate("인력", 2)]), "바탕": {"로직": SUB}, "단위": "주"} got = labor._json(calc.run(sd.clean(doc, None, whole), whole)) bare = labor.run_line(whole, SUB, {}) people = sum(Decimal(str(bare["줄"][k]["금액"])) for k in (0, 1)) rate = next(x for x in got["줄"] if x["cid"] == "add:#0") assert rate["금액"] == pytest.approx(float(people * 2 / 100), abs=0.1) assert got["기준"]["경비"] == pytest.approx( float(Decimal(str(bare["기준"]["경비"])) + Decimal(str(rate["금액"]))), abs=1 ) def test_없는_기준_줄_짝잃음(): got = _run(_doc([LABOR[0], _rate(["add:#0", "labor:LB000099#0"])])) assert got["짝잃음"] == [{"cid": "add:#1", "까닭": "율 기준 줄 없음 「labor:LB000099#0」"}] got = _run(_doc([LABOR[0], _rate([])])) # 기준 줄을 지운 뒤 = 밑 0 · 알림 assert got["짝잃음"] == [{"cid": "add:#1", "까닭": "율 기준 줄 없음 「」"}] assert got["줄"][1]["금액"] == 0 def test_율_줄_없으면_엔진_한_번(monkeypatch): calls = [] real = labor.run_line monkeypatch.setattr(labor, "run_line", lambda *a, **k: calls.append(1) or real(*a, **k)) _run(_doc(LABOR)) assert len(calls) == 1 _run(_doc([*LABOR, _rate("인력")])) assert len(calls) == 3 @pytest.mark.parametrize( "adds, why", [ ([LABOR[0], _rate(["add:#1"])], "자기"), ([LABOR[0], _rate(["add:#2"]), _rate("인력")], "뒤 율"), ([LABOR[0], _rate(["보통인부"])], "꼴 아님"), ([LABOR[0], _rate("합계")], "비율"), ], ) def test_검사_막음(adds, why): with pytest.raises(sd.DocError, match=why): sd.clean(_doc(adds), None, _whole()) # ── 밑 공용 함수 거울 ─────────────────────────────────────────────────────── def _node(payload: dict): got = subprocess.run( ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], input=json.dumps(payload, ensure_ascii=False, default=float), capture_output=True, text=True, encoding="utf-8", check=True, ) # fmt: skip return json.loads(got.stdout) ROWS = [ {"키": "a", "묶음": "인력", "비목": {"노무비": 1000.5, "재료비": 0, "경비": 0}}, {"키": "b", "묶음": "장비", "비목": {"노무비": 300.25, "재료비": 12.5, "경비": 700}}, {"키": "c", "묶음": "재료", "비목": {"노무비": 0, "재료비": 4000, "경비": None}}, {"키": "d", "묶음": None, "비목": {"노무비": 10, "재료비": 20, "경비": 30}}, {"키": "r", "묶음": None, "비목": {"노무비": 0, "재료비": 0, "경비": 55.5}, "율": True}, ] @pytest.mark.parametrize( "basis", [ "노무비", "재료비", "경비", "인력", "재료", "장비", ["a", "c"], ["a", "r"], ["zz"], ["a", "b@노무비"], ["b@경비", "c@재료비", "c@재료비"], ["a@없는칸"], ], # fmt: skip ) def test_밑_파이썬과_TS_같음(basis): py = fixed.rate_base(basis, json.loads(json.dumps(ROWS))) assert _node({"밑": {"기준": basis, "줄": ROWS}}) == pytest.approx(float(py), rel=1e-9) def _fx(name, price, link=None, rate=None): fx = {"이름": name, "단위": "식", "단가": price, "이음": link or {}} return fx | ({"비율": rate} if rate else {}) def test_일위대가_비율_줄_묶음_목록_settle_거울(): plain = _fx("손 노임", {"노무비": 50000}) tool = _fx("공구손료", {}, rate={"기준": "인력", "비목": "경비"}) doc = labor.clean( { "담은로직": [ {"고정": _fx("보통인부", {"노무비": 1}, {"노임": "LB000002"}), "수량": {"손": 0.5}}, {"고정": plain, "수량": {"손": 0.3}}, {"고정": tool, "수량": {"손": 2}}, {"고정": _fx("목록 율", {}, rate={"기준": [fixed.fixed_id(tool)], "비목": "재료비"}), "수량": {"손": 10}}, {"고정": _fx("노무 율", {}, rate={"기준": "노무비", "비목": "경비"}), "수량": {"손": 3}}, ], "자재": [], } ) # fmt: skip answer = labor.calc(doc, {}) py = labor.settle(answer, doc, {}) ts = _node({"답": answer, "문서": doc, "값": {}}) _same(json.loads(json.dumps(labor._json(py), default=float)), ts) lines = py["줄"] people = Decimal(str(lines[0]["반영"]["노무비"])) assert lines[2]["기준"]["경비"] == people # 인력 = 노임 이음 줄만(손 노임 뺌) assert lines[3]["기준"]["재료비"] == lines[2]["반영"]["계"] # 율 위 율 assert lines[4]["기준"]["경비"] == people + Decimal(str(lines[1]["반영"]["노무비"])) def _same(a, b) -> None: if isinstance(a, dict): assert set(a) == set(b), set(a) ^ set(b) for k in a: _same(a[k], b[k]) elif isinstance(a, list): assert len(a) == len(b) for x, y in zip(a, b): _same(x, y) elif isinstance(a, (int, float)) and not isinstance(a, bool): assert b == pytest.approx(a, rel=1e-9, abs=1e-9) else: assert a == b # ── B09 ───────────────────────────────────────────────────────────────────── def test_B09_율_기준_밑수_꼴(): rate = {"이름": "공구손료", "비율": {"기준": "인력", "비목": "경비"}} assert source.fixed_line(rate, 2)["base"] == "bundle:labor" rate["비율"]["기준"] = "노무비" assert source.fixed_line(rate, 2)["base"] == "part:labor" rate["비율"]["기준"] = ["x", "y"] assert source.fixed_line(rate, 2)["kind"] == "blank" # 일위대가 블록 = 열쇠 차례 모름 assert source.fixed_line(rate, 2, {"y": 4, "x": 1})["base"] == "rows:1,4" rate["비율"]["기준"] = ["x", "y@노무비", "z"] assert source.fixed_line(rate, 2, {"y": 4, "x": 1})["base"] == "rows:1,4@labor" def test_B09_SK_블록_율_줄_pct(): whole = _whole() adds = [{"종류": "인력", "요소": "LB000003", "수량": 0.1}, _rate("인력")] adds.append(_rate(["labor:LB000002#0", "add:#1"], 5, "재료비", "목록")) doc = sd.clean({**_doc(adds), "바탕": {"로직": SUB}, "단위": "주"}, None, whole) build = source.Builder() bid = build.sangeun(whole, {"층": "project", "키": "SK-000001"}, {**doc, "굳힌줄": {}}, {}) lines = build.blocks[bid]["lines"] pct = {ln["name"]: ln for ln in lines if ln["kind"] == "pct"} assert pct["공구손료"]["base"] == "bundle:labor" and pct["공구손료"]["qty"] == 2 assert pct["목록"]["base"] == "rows:1,4" and pct["목록"]["part"] == "material" assert [ln["kind"] for ln in lines[:5]] == ["labor", "labor", "machine", "labor", "pct"] BLOCK = { "id": "DGR", "cover": "S:r", "kind": "조합", "name": "율 블록", "spec": "", "unit": "㎥", "cite": "", "origin": {}, "lines": [ {"kind": "labor", "key": "L1", "name": "보통인부", "qty": 0.5}, {"kind": "fixed", "name": "특별인부", "qty": 0.2, "own": {"labor": 100000.0}, "link": {"labor": "L9", "material": None, "machine": None}}, {"kind": "machine", "key": "E1", "name": "굴착기", "qty": 0.29}, {"kind": "material", "key": "M1", "name": "시멘트", "qty": 3}, {"kind": "pct", "name": "공구손료", "part": "expense", "base": "bundle:labor", "qty": 2}, {"kind": "pct", "name": "율 위 율", "part": "material", "base": "rows:3,4", "qty": 10}, {"kind": "pct", "name": "장비 율", "part": "expense", "base": "bundle:machine", "qty": 1}, {"kind": "pct", "name": "절 노무", "part": "expense", "base": "rows:0,2@labor", "qty": 6}, ], } # fmt: skip BOOKS = { "labor": [{"key": "L1", "price": 172068}], "material": [{"key": "M1", "price": 5000}], "machine": [{"key": "E1", "labor": 30000, "material": 5000, "expense": 70000}], } def _ts_labor(daega, sheets): base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) code = 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)};" "\nconsole.log(JSON.stringify(computeLabor(d, s, {}, {})));" ) got = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", base + code + body], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert got.returncode == 0, got.stderr return json.loads(got.stdout) def test_B09_bundle_rows_파이썬과_TS_같음_손셈(): lines = [{**ln, "cid": f"{ln['kind']}:{ln['name']}#0"} for ln in BLOCK["lines"]] daega = {"blocks": [{**BLOCK, "lines": lines}], "owners": {}} py = json.loads(json.dumps(compute_labor(daega, BOOKS, {}), ensure_ascii=False)) _same(py, _ts_labor(daega, BOOKS)) got = {ln["name"]: ln for ln in py["daega_blocks"][0]["lines"]} people = 86034.0 + 20000.0 # 보통인부 + 노임 이음 고정 줄 assert got["공구손료"]["amounts"]["expense"] == pytest.approx(people * 0.02, abs=0.1) tool = got["공구손료"]["amounts"]["expense"] assert got["율 위 율"]["amounts"]["material"] == pytest.approx((15000 + tool) * 0.1, abs=0.1) mach = got["굴착기"]["amount"] assert got["장비 율"]["amounts"]["expense"] == pytest.approx(mach * 0.01, abs=0.1) labor_part = got["굴착기"]["amounts"]["labor"] assert got["절 노무"]["amounts"]["expense"] == pytest.approx( (86034.0 + labor_part) * 0.06, abs=0.1 ) assert py["gaps"] == [] # ── 굳힘 ──────────────────────────────────────────────────────────────────── def test_바탕_없는_SK_율_줄은_한_줄로_안_펼침(): whole = _whole() y = sd.clean(_doc([LABOR[0], _rate("인력")]), None, whole) with pytest.raises(sd.DocError, match="율 줄"): flat.sk_adds(y, Decimal(2), whole, {}) plain = sd.clean(_doc([LABOR[0]]), None, whole) assert flat.sk_adds(plain, Decimal(2), whole, {})[0]["수량"] == 1 # 율 없으면 그대로 def test_겹침_율_열쇠_새_차례(): whole = _whole() under = sd.clean( {**_doc([LABOR[0], _rate(["add:#0"])]), "바탕": {"로직": SUB}, "단위": "주"}, None, whole ) seen = sd.patched(whole.logic(SUB), under) view = dict(zip(seen["정체"], sd.line_ids(seen["줄"]))) # 아래 차례 → 위 문서 cid over = {"더한줄": [LABOR[1], _rate(["add:#0", view["add:#1"], view["add:#0"]], 3)]} out = flat.compose(under, over, whole) keys = [((a.get("고정") or {}).get("비율") or {}).get("기준") for a in out["더한줄"]] assert keys == [None, ["add:#0"], None, ["add:#2", "add:#1", "add:#0"]] # ── 화면 — 더한줄 지움 · 되살림 때 율 기준 열쇠 옮김(`logic/M02_Sangeun_Undo.ts`) ────────── def test_화면_더한줄_지움_되살림_율_열쇠(): code = IMPORTS.sub( "", (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Undo.ts").read_text("utf-8") ) doc = _doc([LABOR[0], LABOR[1], _rate(["add:#1@노무비", "add:#0", "labor:LB000038#0"])]) body = ( f"\nconst doc = {json.dumps(doc, ensure_ascii=False)};" "\nconst key = () => doc.더한줄.at(-1).고정.비율.기준;" "\nforgetAdd(doc, 0, ''); const a = key(); reviveAdd(doc, 0);" "\nconsole.log(JSON.stringify([a, key()]));" ) got = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", code + body], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert got.returncode == 0, got.stderr assert json.loads(got.stdout) == [ ["add:#0@노무비", "labor:LB000038#0"], # 지운 줄 열쇠 버림 · 뒤 줄 당김(비목 꼬리 그대로) ["add:#1@노무비", "labor:LB000038#0"], # 되살림 = 뒤 줄 밂 ]