Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2

# Conflicts:
#	B04_PreProcess/B04_PreProcess_Router_Basins.py
#	B08_Quantity/B08_Quantity_Router_Summary.py
This commit is contained in:
2026-10-09 15:54:20 +09:00
25 changed files with 762 additions and 122 deletions
@@ -104,7 +104,8 @@ def test_자재수량_계가_엑셀_66행과_같다():
assert "pipe:cp|800" not in rows
revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
assert revet["qty"] == 20 and revet["amounts"]["stone_buy_45"] == 38.4
assert revet["amounts"]["sod"] is None # 빈 단위수량 = 빈칸(0 아님)
assert revet["amounts"]["rubble"] == 15 and revet["amounts"]["stone_pick_45"] is None # 빈칸
assert "sod" not in revet["amounts"] # 2공구 고정 열 = 사용자 값 · 사본 있을 때만(143-4)
basin = rows["pipe:basin|800|U"]
assert (basin["name"], basin["spec"], basin["unit"]) == (
"관 공",
@@ -112,7 +113,7 @@ def test_자재수량_계가_엑셀_66행과_같다():
"개소",
)
keys = [m["key"] for m in sheets["quantity"]["materials"]]
assert keys.index("rebar_d25") + 1 == keys.index("pipe_800")
assert keys.index("concrete_machine") + 1 == keys.index("pipe_800")
assert keys.index("pipe_1200") + 1 == keys.index("mortar_1_3")
@@ -42,3 +42,10 @@ def test_이름표_단추는_작은_단추_틀_흐림은_글에만():
assert "height: var(--b05-control-h)" in small and "padding-block: 0" in small
legend = _rule(".b05-structure__group > legend {")
assert "opacity" not in legend and "currentColor 80%" in legend
def test_M02_미리보기_이름표_단추도_작은_단추_틀():
css = (ROOT / "M02_MasterTemplete" / "StructOptions" / "M02_SO_Style.css").read_text("utf-8")
at = css.index(".m02-sod__block .b05-structure__group > legend > button")
rule = css[at : css.index("}", at)]
assert "height: 26px" in rule and "padding-block: 0" in rule
@@ -0,0 +1,117 @@
"""143-4 사본 일위대가 자재 → B08 자재수량집계표 단위수량 재연결.
- 서버 `snapshot_units` — 칸(측점 · 시설)마다 그 칸을 낸 조합의 연결된 사본 셈(자재 · 로직 재료) ×
칸 값 ÷ 열 계 · 사본 없거나 줄 수량 빈칸이면 빈칸(안 담음)
- 짝 계산 `compute` — 열 = 사본 자재 + 관경 · 2공구 고정 열은 사용자 값 있을 때만 · 손 값이 이김 ·
관매설 → 파형강관 1 그대로 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤 · 안 맞으면 `unmatched`
- 거울 = TS 짝(`B08_Quantity_Material_Calc.ts`)이 같은 답
"""
from __future__ import annotations
import json
from B08_Quantity import B08_Quantity_Router_Material as router
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_Item_Snapshot as snap
from resources.tester.test_121_b08_material_sheets import _run_ts
from resources.tester.test_item_copy_complete import ( # noqa: F401 — 고정물
FOLDER,
PIPE,
_pipe,
_save,
master_copy,
world,
)
COMBO = "system||SS000011"
LEN = "pipe:len|1000"
SUMMARY = {
"columns": [
{"id": LEN, "group": ["관 공", "Φ1000m/m", "관매설"], "label": "m"},
{"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"], "label": "m"},
{"id": "pipe:chute|메붙임", "group": ["돌붙임", "메붙임"], "label": "㎡"},
],
"total": {LEN: 20, "pipe:revet|돌쌓기(찰)|2.0": 10, "pipe:chute|메붙임": 5},
"snapshot": {
"materials": [
{"key": "MT000001", "name": "레미콘", "spec": "25-18-80", "unit": "㎥"},
{"key": "고임돌|㎥", "name": "고임돌", "spec": "채집", "unit": "㎥"},
{"key": "야면석|㎥", "name": "야면석", "spec": "", "unit": "㎥"},
],
"units": {
"pipe:revet|돌쌓기(찰)|2.0": {"MT000001": 0.31, "고임돌|㎥": 0.2, "야면석|㎥": 1.5},
"pipe:chute|메붙임": {"고임돌|㎥": 0.12},
},
},
}
def test_사본_자재가_열과_단위수량_기본():
over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}})
sheets = compute(SUMMARY, over)
keys = [m["key"] for m in sheets["quantity"]["materials"]]
assert keys == ["pipe_1000", "sod", "MT000001", "고임돌|㎥", "야면석|㎥"] # 고정 열 = 손 값만
rows = {row["id"]: row for row in sheets["quantity"]["rows"]}
revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
assert revet["units"]["MT000001"] == 0.31 and revet["amounts"]["MT000001"] == 3.1
assert revet["units"]["pipe_1000"] is None # 사본에 없는 자재 = 빈칸
assert rows["pipe:chute|메붙임"]["units"]["고임돌|㎥"] == 0.5 # 손 값이 이김
assert rows[LEN]["units"]["pipe_1000"] == 1 # 관매설 → 파형강관 1 그대로
# 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음
assert sheets["quantity"]["unmatched"] == ["야면석|㎥"]
total = {row["key"]: row for row in sheets["material"]["rows"]}
assert total["remicon_25_18_80"]["qty"] == 3.1
assert total["pipe_1000"]["qty"] == 20
concrete = {row["key"]: row for row in sheets["concrete"]["rows"]}
assert concrete["c40"]["qty"] is None # 콘크리트(기계) 사본 없음 = 빈칸
def test_거울_TS와_파이썬이_같다():
over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}})
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
bare = {k: v for k, v in SUMMARY.items() if k != "snapshot"}
assert _run_ts(bare, clean_overrides({})) == compute(bare, clean_overrides({}))
def test_서버_사본_셈_자재_x_칸값_나누기_열계(world): # noqa: F811
"""일위대가 자재(MT 코드 · 손 수량 2) = 단위수량 2 · 사본 없는 시설 칸은 안 셈."""
root = world["root"]
_save(root, [_pipe(), {**_pipe(None), "chainage_m": 80.0}])
folder = root / "templates/structures" / FOLDER
labor = json.loads((folder / "일위대가.json").read_text("utf-8"))
labor["줄"] = {
"종류": "일위대가",
"열": "x",
"단위": "m",
"담은로직": [{"로직": "GF000176"}], # 줄 수량 빈칸 — 로직 재료 안 셈
"자재": [{"코드": "MT003810", "수량": {"손": 2}}],
}
(folder / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8")
summary = {
"linked": snap.linked_snapshots(root),
"sources": [[LEN, 12.0, FOLDER, [COMBO]], [LEN, 8.0, "pipe@80.0", [COMBO]]],
"total": {LEN: 20.0},
}
got = router.snapshot_units(root, summary)
assert got["units"] == {LEN: {"MT003810": 1.2}} # 12 × 2 ÷ 20 · 80 m 관은 사본 없음
assert [m["key"] for m in got["materials"]] == ["MT003810"]
assert got["materials"][0]["name"] != "MT003810" # 이름 = 사본 단가 묶음 품목 이름
def test_로직_재료는_재료_x_줄수량_나누기_배(monkeypatch):
fake = {
"줄": [
{"수량": 4, "배": 10, "재료": [{"이름": "돌", "단위": "㎥", "수량": 5}]},
{"수량": None, "배": 1, "재료": [{"이름": "돌", "단위": "㎥", "수량": 9}]}, # 빈칸
{"수량": 1, "배": 1, "재료": [{"이름": "파형강관", "단위": "m", "수량": 1}]}, # 관 몫
],
"자재": [],
}
monkeypatch.setattr(copies, "calc", lambda root, folder: fake)
monkeypatch.setattr(copies, "folder_of", lambda root, folder: copies.Path("없음"))
meta: dict = {}
assert router._folder_units("r", "f", meta) == {"돌|㎥": 2.0}
assert meta == {"돌|㎥": {"key": "돌|㎥", "name": "돌", "spec": "", "unit": "㎥"}}
@@ -83,6 +83,17 @@ def test_save_then_read_roundtrip(client):
assert read.json()["structures"][0]["structure_id"]
def test_save_response_carries_snapshot_failed(client, monkeypatch):
"""142-2 — 못 만든 구조물 조합 사본을 저장 응답에 실음(화면 [저장] 알림) · 로그인 세션 없으면
가져온이 없음."""
from M02_MasterTemplete import M02_Item_Snapshot as snap
seen = []
monkeypatch.setattr(snap, "safe_sync", lambda root, who: seen.append(who) or ["st9"])
saved = client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
assert saved.json()["snapshot_failed"] == ["st9"] and seen == [None]
def test_stale_revision_returns_409(client):
client.put(f"/api/projects/{PROJECT_ID}/route/structures", json=_point_payload())
conflict = client.put(
@@ -135,6 +135,8 @@ def test_지정하면_새_파일까지_복사(world):
"구조물": ["pipe@40.0"],
"고침": False,
"값있음": False,
"가져온이": None,
"부분": ["도면", "면적", "일위대가"], # 142-2 — 사본이 가진 부분
}
]
@@ -428,6 +430,9 @@ def test_재적용은_시트_고침을_덮고_알림_입력값은_살림(world):
assert "메모" not in copies.read_file(root, FOLDER, "면적")["내용"]
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values
assert copies.reapply(root, FOLDER)["덮임"] == []
who = {"id": 42, "이름": "재적용"} # 142-2 — [재적용]이 가져온이를 새로 적음
copies.reapply(root, FOLDER, who)
assert copies.list_items(root)[0]["가져온이"] == who
def test_개인에_저장하면_개인_층에_항목(world):
@@ -453,6 +458,19 @@ def test_개인에_저장하면_개인_층에_항목(world):
assert _save_all(root, [_wall("b1", made["summary_item"])], [])["만듦"] == ["b1"]
def test_개인에_저장은_일위대가_문서도_가져감_142_2(world):
root = world["root"]
_save(root, [_pipe()])
copy = root / "templates/structures" / FOLDER
doc = {"종류": "일위대가", "열": "old", "단위": "m", "담은로직": [{"로직": "GF000176"}]}
labor = {**_read(copy / "일위대가.json"), "줄": doc}
(copy / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8")
made = copies.save_as(root, FOLDER, 3, 12, ["관공"], "문서 담음")
got = _read(layers.personal_dir(3, 12) / "labor" / f"{made['열']}.json")
assert got["열"] == made["열"] and got["일위대가번"] # 번호 · 열은 새 열 것
assert [line["로직"] for line in got["담은로직"]] == ["GF000176"]
def test_프로젝트에서_새_항목(world):
root = world["root"]
_save_all(root, [_wall("c1")], [])
+25
View File
@@ -496,3 +496,28 @@ def test_서고_구분_상세구분_사전조합은_고른_값_옛_문서는_읽
rows = [{"종류": "precombo", "이름": sc}]
refs.summaries(world["system"], rows)
assert (rows[0]["구분"], rows[0]["상세구분"], rows[0]["연결"]) == ("가", "나", None)
def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, monkeypatch):
"""프로젝트 참조사본 사전조합 = 가져온 때 알약(배 10) — 그 뒤 M01 이 바뀌어도 사본 셈 그대로 ·
층 문서(알약 없음)는 지금 M01 을 읽음."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Item_Source as source
sc = _combo(world["system"], [{"로직": TEN}], "열배")
ref = {**SYS, "키": sc}
with store.at(world["system"]):
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]})
rows = source.labor_doc_rows(doc)
kept = rows["참조사본"][refs.ref_id(ref)]["알약"]
assert [(p["로직"], p.get("배")) for p in kept] == [([TEN], 10)]
assert "알약" not in _read(world["system"], "precombo", sc) # 층 문서는 그대로
bundle = prices.to_json(prices.collect([TEN]))
before = prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"]
changed = {"물량": [{"키": "x|㎡", "이름": "x", "단위": "㎡", "로직": [TEN]}]} # 배 1 로 바뀜
monkeypatch.setattr(combo_store, "combo_pills", lambda row: changed)
assert prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] == pytest.approx(before)
with store.at(world["system"]):
live = labor.calc(doc, {})["줄"][0]["기준"]["계"]
assert live == pytest.approx(before * 10) # 층 셈은 지금 M01(배 1)
@@ -95,3 +95,30 @@ def test_채움은_정의된_그룹마다_한_건_더(world, monkeypatch):
items = fill.collect_items(root)
assert [i["복사본"] for i in items] == ["st0", "st0__g1", "st1", "st1__g1"]
assert items[1]["그룹"] == "기슭막이" and items[1]["summary_item"] == RV
def test_그룹_정의도_사본_값_정의가_바뀌거나_사라져도_그대로(world, monkeypatch):
"""142-2 — 그룹 연결 사본 manifest `그룹` = 가져온 때 정의(이름) · 이름을 바꾸거나 그룹을
지워도 프로젝트 집계 줄은 사본 이름 그대로 · 연결 없는 구조물은 지금 정의만."""
root, system = world["root"], world["system"]
_define(system, monkeypatch, {"masonry_dry": [{"id": "g1", "이름": "기슭막이", "옵션": []}]})
linked = {**_dry(MS), "group_items": {"g1": RV}}
bare = {**_dry(None), "chainage_m": 125, "start_m": 120, "end_m": 130}
_save(root, [linked, bare], [])
manifest = json.loads((_items(root) / "st0__g1/manifest.json").read_text("utf-8"))
assert manifest["그룹"] == {"id": "g1", "name": "기슭막이"}
_define(system, monkeypatch, {"masonry_dry": [{"id": "g1", "이름": "바뀐 이름", "옵션": []}]})
items = fill.collect_items(root)
assert [(i["복사본"], i.get("그룹")) for i in items] == [
("st0", None),
("st0__g1", "기슭막이"),
("st1", None),
("st1__g1", "바뀐 이름"),
]
_define(system, monkeypatch, {})
items = fill.collect_items(root)
assert [(i["복사본"], i.get("그룹")) for i in items] == [
("st0", None),
("st0__g1", "기슭막이"),
("st1", None),
]
@@ -218,6 +218,55 @@ def test_원본에_없는_항목은_못만듦으로_남김(world):
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["st0"]
def _manifest(root: Path, folder: str) -> dict[str, Any]:
return json.loads((_items(root) / folder / "manifest.json").read_text("utf-8"))
def test_관_옮겨_저장해도_같은_사본_이름만_바꿈(world):
"""142-2 — 같은 항목 관을 옮기면 폴더 이름만(가져온날 · 고침 · 입력값 그대로 · 원본을 다시
안 읽음) · 같은 항목 관 둘 = 가까운 측점끼리 · 다른 항목 자리는 새로."""
root, system = world["root"], world["system"]
_save(root, [], [_pipe(PIPE), {**_pipe(PIPE), "chainage_m": 100.0}])
for folder in ("pipe@40.0", "pipe@100.0"):
manifest = {**_manifest(root, folder), "고침": True, "가져온날": f"옛날-{folder}"}
(_items(root) / folder / "manifest.json").write_text(json.dumps(manifest), "utf-8")
(_items(root) / "pipe@40.0/입력값.json").write_text('{"L": {"x": {"값": 7}}}', "utf-8")
(system / layers.COMBO_FILE).unlink() # 원본이 사라져도 이름만 바꾸므로 못만듦 없음
moved = [{**_pipe(PIPE), "chainage_m": 98.0}, {**_pipe(PIPE), "chainage_m": 41.5}]
result = _save(root, [], moved)
assert sorted(result["이름바꿈"]) == ["pipe@100.0→pipe@98.0", "pipe@40.0→pipe@41.5"]
assert result["만듦"] == result["지움"] == result["못만듦"] == []
assert sorted(p.name for p in _items(root).iterdir()) == ["pipe@41.5", "pipe@98.0"]
near = _manifest(root, "pipe@41.5")
assert near["가져온날"] == "옛날-pipe@40.0" and near["고침"] is True
assert near["구조물"] == ["pipe@41.5"]
assert json.loads((_items(root) / "pipe@41.5/입력값.json").read_text("utf-8"))["L"]["x"]
assert _manifest(root, "pipe@98.0")["가져온날"] == "옛날-pipe@100.0"
def test_관_옮김_다른_항목이면_이름_안_바꿈(world):
root = world["root"]
_save(root, [], [_pipe(PIPE)])
result = _save(root, [], [{**_pipe(MS), "chainage_m": 41.5}])
assert "이름바꿈" not in result
assert result["만듦"] == ["pipe@41.5"] and result["지움"] == ["pipe@40.0"]
assert _manifest(root, "pipe@41.5")["원본"]["key"] == MS["key"]
def test_못만듦은_저장_응답으로_가져온이는_manifest_에(world):
"""142-2 — `safe_sync` 가 못 만든 폴더를 돌려줌(저장 응답 → [저장] 알림) · 가져온이 =
저장한 세션 · 미리 가져온 사본(가져온이 없음)에도 [저장] 때 채움."""
root = world["root"]
_save(root, [_dry({**MS, "key": "SS999999"}), _dry(MS)], [])
who = snap.importer({"user_id": 42, "name": "검증"})
assert who == {"id": 42, "이름": "검증"} and snap.importer(None) is None
assert snap.safe_sync(root, who) == ["st0"]
assert _manifest(root, "st1").get("가져온이") == who # 앞 저장(가져온이 없음) 사본도 채움
shutil.rmtree(_items(root) / "st1")
snap.safe_sync(root, who)
assert _manifest(root, "st1")["가져온이"] == who
# ── 채움 ─────────────────────────────────────────────