- 격자 합계 줄 규격 칸 = 금액 칸처럼 소수 한 자리 - 규격 이름 · 열 이름 칸 6em — 좁은 카드에서 한 줄 - 임시 storage 시험 — 사본 셈 반영합 = 층 문서 그 열 셈(B08) · 대가 줄 금액 = 열 수량 × 단가(B09) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
12 KiB
Python
250 lines
12 KiB
Python
"""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
|