"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 · 열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본). 임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만). """ from __future__ import annotations import json import shutil from pathlib import Path from typing import Any import pytest from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from common_util.common_util_summary_item import parse_summary_item from config import config_system from M02_MasterTemplete import M02_Item_Snapshot as snap from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.helper_m02_master_sample import SAMPLE MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} SPEC = { "이름": "H", "열": ["1.5", "2.5"], "기본": "1.5", "수량": {"GF000219": {"1.5": 2, "2.5": 3}}, } LABOR = { "종류": "일위대가", "열": "ms", "단위": "m", "담은로직": [ {"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량 {"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통 ], "자재": [], "규격": SPEC, } # ── summary_item spec ────────────────────────────────────────────────────── def test_summary_item_spec_있음_없음(): assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"} assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로 with pytest.raises(ValueError, match="모르는 칸"): parse_summary_item({**MS, "열": "2.5"}) for bad in ("", "x" * 21, 2.5): with pytest.raises(ValueError, match="spec"): parse_summary_item({**MS, "spec": bad}) # ── 굳히기 ───────────────────────────────────────────────────────────────── def test_굳히기_고른_열_지운_열_옛_문서(): got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5") assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5} assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}} assert "계산값" not in got assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본 old = {k: v for k, v in LABOR.items() if k != "규격"} assert spec.freeze(old, "2.5") is old # ── 임시 저장소 사본 ─────────────────────────────────────────────────────── @pytest.fixture def world(tmp_path: Path, monkeypatch) -> dict[str, Any]: system = tmp_path / "master" system.mkdir() shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE) for kind in ("table", "structure", "area"): shutil.copytree(SAMPLE / kind, system / kind) (system / "labor").mkdir() (system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8") monkeypatch.setattr(layers, "SYSTEM_ROOT", system) storage = tmp_path / "storage" monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) root = storage / "3" / "12" / "proj" (root / "B05_Profile/route").mkdir(parents=True) (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) return {"system": system, "root": root} def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]: rows = [ { "structure_id": f"st{n}", "type_id": "masonry_dry", "placement": "interval", "chainage_m": 85 + n * 100, "start_m": 80 + n * 100, "end_m": 90 + n * 100, "options": {"height_m": 2.0, "length_m": 10}, "summary_item": {**MS, **({"spec": s} if s else {})}, } for n, s in enumerate(specs) ] (root / "B05_Profile/route/structures.json").write_text( json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8" ) (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( '{"points": []}', encoding="utf-8" ) return snap.sync_snapshots(root) def _block(root: Path, owner: str) -> tuple[dict, dict]: got = daega_inputs(root) return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner]) def test_사본이_고른_열을_굳힘_manifest_규격(world): root = world["root"] assert _save(root, ["2.5"])["만듦"] == ["st0"] folder = snap.structures_dir(root) / "st0" doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"] assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}] manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8")) assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5" assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로 def test_열_바꿈_뒤_덮개_남음(world): root = world["root"] _save(root, ["1.5"]) _, before = _block(root, "st0") cids = [ln["cid"] for ln in before["lines"]] assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5] cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침 got = _save(root, ["2.5"]) assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본 after_all, after = _block(root, "st0") assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids sheets = {"labor": [], "material": [], "machine": [], "gaps": []} out = compute_labor(after_all, sheets, {}, cover) lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"] assert lines[0]["qty"] == 3 # 새 열 수량 assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로 assert out["daega_orphans"] == [] def test_B07_한_장_판정_항목_더하기_열(world): root = world["root"] _save(root, ["1.5", "1.5", "2.5"]) ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")] assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로 def test_열_지움_쓰는_사본_찾기(world): root = world["root"] _save(root, ["2.5", None]) assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"] assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == [] assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == [] def test_쓰는_열_지움_409_안_쓰는_열_됨(world): from M02_MasterTemplete import M02_MasterTemplete_Store as store root = world["root"] _save(root, ["2.5"]) path = world["system"] / "labor" / "ms.json" with store.at(world["system"]): with pytest.raises(store.StoreError) as got: spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms") assert got.value.status == 409 and "proj/st0" in str(got.value.detail) spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀 spec.guard(path, {"규격": SPEC}, "ms") # ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ───────────────────── def test_격자_규격_열_수량_칸_열마다(): from resources.tester import test_704_3_hopyo_grid as g files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0", "수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}] out = g._node(files, ( f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());" "\nconst grid = hopyoGrid([b]);" "\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group])," " rows: grid.rows, specs: b.specs }));" ), g.STUBS) # fmt: skip cols = [c for c in out["cols"] if c[0].startswith("qty")] assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]] line = out["rows"][1] # 첫 구성 줄 assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09 mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌") assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량 t15, t20 = out["specs"]["totals"] assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합 assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖 def test_고르기_창_spec_돌려줌(): import subprocess data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts" body = ( data.read_text("utf-8") + """ const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"], labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } }; const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } }; const plain = { key: "SS000001", 열: "rv" }; const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" }; console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"), startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)])); """ ) got = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], input=body, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert got.returncode == 0, got.stderr two, single, plain, *starts = json.loads(got.stdout) assert two == {"layer": "system", "owner": None, "key": "SS000022", "name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로 assert "spec" not in plain # 규격 없는 옛 항목 assert starts == ["1.5", "2.0", "2.0", None] def test_B08_B09_금액_열_수량_곱하기_단가(world): """B08 = 사본 1 단위 값(사본 셈 반영합) = 층 문서 그 열 셈 · B09 = 대가 줄 금액 = 열 수량 × 단가.""" from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor root = world["root"] _save(root, ["2.5"]) folder = snap.structures_dir(root) / "st0" read = lambda name: json.loads((folder / name).read_text(encoding="utf-8")) # noqa: E731 copy = item_prices.calc(read("일위대가.json"), read("단가.json"), read("입력값.json"), {}, None) layer = labor.clean(LABOR) want = labor.settle(labor.calc(layer, {}), layer, {}, "2.5")["반영합"] assert copy["반영합"]["계"] == pytest.approx(want["계"]) # B08 1 단위 값 = 2.5 열 first = labor.settle(labor.calc(layer, {}), layer, {}, "1.5")["반영합"] assert want["계"] != pytest.approx(first["계"]) got, block = _block(root, "st0") price = {ln["key"]: {"labor": 1000.0, "material": 0.0, "expense": 0.0} for ln in block["lines"]} sheets = {"labor": [], "material": [], "machine": [], "gaps": []} out = compute_labor(got, sheets, price, {}) lines = next(b for b in out["daega_blocks"] if b["id"] == block["id"])["lines"] assert [ln["amounts"]["labor"] for ln in lines] == [3000, 500] # 3 × 1000 · 공통 0.5 × 1000