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

This commit is contained in:
2026-09-30 07:47:31 +09:00
8 changed files with 683 additions and 151 deletions
+202 -20
View File
@@ -1,4 +1,6 @@
"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 임시 저장소 · 임시 마스터 사본).
"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 26-2b · 임시 저장소 · 임시 마스터 사본).
복사본은 구조물마다(`templates/structures/{구조물 id}/` · 관은 `pipe@측점`).
- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값
@@ -32,7 +34,8 @@ from M02_MasterTemplete import M02_Template_Layers as layers
REAL = config_system.PROJECT_ROOT / "resources/master_template"
MASTER = config_system.PROJECT_ROOT / "resources/master_data"
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
FOLDER = "system_none_SS000011"
FOLDER = "pipe@40.0" # 관 지점 복사본 폴더 = 구조물 id(26-2b)
PIPE_PATH = ["관공", "(관종·관경마다)", "관매설"]
LABOR_PRICE = "LB000002" # 보통인부 — 되메우기 · 파형강관 부설 호표가 씀
@@ -82,7 +85,7 @@ def _read(path: Path) -> Any:
def _auto_values(root: Path) -> dict[str, Any]:
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
labor = _read(root / "templates/items" / FOLDER / "일위대가.json")
labor = _read(root / "templates/structures" / FOLDER / "일위대가.json")
_, whole = m01.loaded()
out: dict[str, Any] = {}
for row in labor["로직"]:
@@ -104,7 +107,7 @@ def _fill_values(root: Path) -> dict[str, Any]:
def test_지정하면_새_파일까지_복사(world):
root = world["root"]
assert _save(root, [_pipe()])["만듦"] == [FOLDER]
folder = root / "templates/items" / FOLDER
folder = root / "templates/structures" / FOLDER
assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음
assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {}
price = _read(folder / "단가.json")
@@ -118,7 +121,7 @@ def test_지정하면_새_파일까지_복사(world):
{
"폴더": FOLDER,
"이름": "관공 관매설",
"원본": PIPE,
"원본": {**PIPE, "경로": PIPE_PATH},
"원본판": manifest["원본판"],
"가져온날": manifest["가져온날"],
"마스터바뀜": False,
@@ -221,7 +224,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
def test_새로_가져오면_없는_키는_버림(world):
root = world["root"]
_save(root, [_pipe()])
folder = root / "templates/items" / FOLDER
folder = root / "templates/structures" / FOLDER
(folder / "입력값.json").write_text(
json.dumps(
{
@@ -267,31 +270,37 @@ def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world):
copies.read_file(root, "../../etc", "입력값")
def test_고친_복사본은_구조물에서_빠져도_안_지움(world):
def test_고친_복사본은_구조물에서_빠지면_지난에_남김(world):
root = world["root"]
_save(root, [_pipe()])
assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움(지금 규칙)
assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움
assert not (root / "templates/structures/_지난").exists()
_save(root, [_pipe()])
current = copies.read_file(root, FOLDER, "입력값")
copies.write_file(root, FOLDER, "입력값", current["판"], {"GF000176": {"관경": {"값": 600}}})
assert _save(root, [_pipe(None)])["지움"] == []
assert copies.list_items(root)[0]["구조물"] == [] and copies.list_items(root)[0]["고침"]
assert _save(root, [_pipe(None)])["지움"] == [FOLDER]
assert copies.list_items(root) == []
past = list((root / "templates/structures/_지난").iterdir())
assert len(past) == 1 and past[0].name.startswith("pipe@40.0_")
assert _read(past[0] / "입력값.json") == {"GF000176": {"관경": {"값": 600}}}
def test_미리_가져오기(world):
root = world["root"]
_save(root, [])
made = copies.prefetch(root, PIPE)
made = copies.prefetch(root, FOLDER, PIPE)
assert made["폴더"] == FOLDER and made["만듦"] is True
assert copies.prefetch(root, PIPE)["만듦"] is False # 있으면 그대로
assert copies.list_items(root)[0]["구조물"] == []
assert _save(root, [])["지움"] == [FOLDER] # 안 고쳤으면 [저장] 때 지움
assert copies.prefetch(root, FOLDER, PIPE)["만듦"] is False # 같은 항목이면 그대로
assert copies.list_items(root)[0]["구조물"] == [FOLDER]
assert _save(root, [])["지움"] == [FOLDER] # [저장] 뒤에도 안 쓰면 지움
with pytest.raises(ValueError):
copies.prefetch(root, "../x", PIPE)
def test_옛_복사본은_읽을_때_채움(world):
root = world["root"]
_save(root, [_pipe()])
folder = root / "templates/items" / FOLDER
folder = root / "templates/structures" / FOLDER
for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"):
(folder / name).unlink()
manifest = _read(folder / "manifest.json")
@@ -331,12 +340,185 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400
made = client.post(base, json={"layer": "system", "owner": None, "key": "SS000022"}).json()
assert made["폴더"] == "system_none_SS000022" and made["만듦"] is True
wall = {"structure": "st9", "layer": "system", "owner": None}
assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400
assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400
made = client.post(base, json={**wall, "key": "SS000022"}).json()
assert made["폴더"] == "st9" and made["만듦"] is True
names = {i["폴더"]: i["이름"] for i in client.get(base).json()["항목"]}
assert names["system_none_SS000022"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름
assert client.post(base, json={"layer": "system", "key": "SS999999"}).status_code == 404
assert names["st9"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름(경로 이음)
assert client.post(base, json={**wall, "key": "SS999999"}).status_code == 404
assert client.post(f"{base}/{FOLDER}/reapply").json()["덮임"] == []
session["company_id"] = 4
assert client.get(base).status_code == 403
assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403
# ── 구조물마다 복사본 (26-2b) ─────────────────────────
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
def _wall(sid: str, summary: dict | None = MS) -> dict[str, Any]:
return {
"structure_id": sid,
"type_id": "masonry_dry",
"placement": "interval",
"chainage_m": 85,
"start_m": 80,
"end_m": 90,
"options": {"height_m": 2.0, "length_m": 10},
"summary_item": summary,
}
def _save_all(root: Path, walls: list[dict], pipes: list[dict]) -> dict[str, list[str]]:
(root / "B05_Profile/route/structures.json").write_text(
json.dumps({"revision": 1, "structures": walls}, ensure_ascii=False), encoding="utf-8"
)
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
json.dumps({"points": pipes}, ensure_ascii=False), encoding="utf-8"
)
return snap.sync_snapshots(root)
def _memo(root: Path, folder: str, text: str) -> None:
"""면적 시트에 표식 한 칸 — 한쪽만 고쳤는지 보려는 것(계산값 풀이는 건너뜀)."""
area = copies.read_file(root, folder, "면적")
body = {**area["내용"], "메모": text}
copies.write_file(root, folder, "면적", area["판"], body, recalc=lambda doc: doc)
def test_같은_항목_구조물_둘은_따로(world):
root = world["root"]
second = {**_pipe(), "chainage_m": 60.0}
made = _save_all(root, [_wall("a1"), _wall("a2")], [_pipe(), second])["만듦"]
assert made == ["a1", "a2", FOLDER, "pipe@60.0"]
_memo(root, "a1", "a1 만 고침")
assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침"
assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로
_fill_values(root) # pipe@40.0 에만 값
assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0
assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자
flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)}
assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False}
def test_원본_항목을_지워도_복사본_계산_그대로(world):
root, system = world["root"], world["system"]
_save(root, [_pipe()])
_fill_values(root)
before = copies.calc(root, FOLDER)
combo = _read(system / layers.COMBO_FILE)
combo["줄"] = [r for r in combo["줄"] if r["키"] != PIPE["key"]]
(system / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8")
assert copies.calc(root, FOLDER) == before
row = copies.list_items(root)[0]
assert row["원본없음"] is True and row["마스터바뀜"] is False
assert _save(root, [_pipe()])["만듦"] == [] # 다시 저장해도 복사본 그대로
with pytest.raises(LookupError):
copies.reapply(root, FOLDER) # 원본이 없으면 재적용 못 함(404)
def test_재적용은_시트_고침을_덮고_알림_입력값은_살림(world):
root = world["root"]
_save(root, [_pipe()])
values = _fill_values(root)
_memo(root, FOLDER, "x")
assert copies.reapply(root, FOLDER)["덮임"] == ["면적"]
assert "메모" not in copies.read_file(root, FOLDER, "면적")["내용"]
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values
assert copies.reapply(root, FOLDER)["덮임"] == []
def test_개인에_저장하면_개인_층에_항목(world):
root = world["root"]
_save(root, [_pipe()])
rows = [{"로직": "GF000176", "입력": "관경", "자리": "선택"}]
current = copies.read_file(root, FOLDER, "입력연결")
copies.write_file(root, FOLDER, "입력연결", current["판"], {"판": 3, "줄": rows})
made = copies.save_as(root, FOLDER, 3, 12, ["관공", "우리 현장"], "파형강관 매설")
assert made["summary_item"] == {
"layer": "personal",
"owner": "3/12",
"key": made["key"],
"name": "관공 우리 현장 파형강관 매설",
}
folder = layers.personal_dir(3, 12)
row = next(r for r in _read(folder / layers.COMBO_FILE)["줄"] if r["키"] == made["key"])
assert [row["종류"], row["분류1"], row["분류2"]] == ["관공", "우리 현장", "파형강관 매설"]
assert row["일위대가"] == "UA000036" and row["단위"] == "m"
copy = root / "templates/structures" / FOLDER
drawing = _read(folder / "structure" / f"{made['열']}.json")
assert drawing["도면"] == _read(copy / "도면.json")["도면"]
assert _read(folder / "inputs" / f"{made['열']}.json") == {"판": 1, "줄": rows}
assert not list(folder.rglob("단가.json")) # 단가 묶음은 안 가져감
# 개인 층 항목을 다른 구조물에 지정하면 그 내용으로 복사본이 섬
assert _save_all(root, [_wall("b1", made["summary_item"])], [])["만듦"] == ["b1"]
def test_프로젝트에서_새_항목(world):
root = world["root"]
_save_all(root, [_wall("c1")], [])
made = copies.new_item(root, "c1", 3, 12, ["신규 공사"], "돌붙임", "㎡")
assert made["폴더"] == "c1" and made["summary_item"]["layer"] == "personal"
origin = _read(root / "templates/structures/c1/manifest.json")["원본"]
assert origin["key"] == made["key"] and origin["경로"] == ["신규 공사", "돌붙임"]
assert copies.list_items(root)[0]["이름"] == "신규 공사 돌붙임"
# 화면이 새 항목을 지정해 [저장] 하면 그대로 남음
assert _save_all(root, [_wall("c1", made["summary_item"])], [])["만듦"] == []
with pytest.raises(ValueError):
copies.new_item(root, "c2", 3, 12, [], "", None) # 경로 · 이름 빈 값
def test_옛_항목_폴더는_쓰는_구조물마다_옮겨_받음(world):
root = world["root"]
_save_all(root, [_wall("d1"), _wall("d2")], [])
base = root / "templates/structures"
old = root / "templates/items/system_none_SS000022"
old.parent.mkdir(parents=True)
shutil.move(str(base / "d1"), str(old))
shutil.rmtree(base / "d2")
manifest = _read(old / "manifest.json")
manifest.update({"구조물": ["d1", "d2"], "고침": True})
(old / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8")
(old / "입력값.json").write_text(json.dumps({"GF": {"a": {"값": 1}}}), "utf-8")
assert [i["폴더"] for i in copies.list_items(root)] == ["d1", "d2"]
assert not (root / "templates/items").exists()
for sid in ("d1", "d2"):
assert _read(base / sid / "입력값.json") == {"GF": {"a": {"값": 1}}} # 고친 값 유지
assert _read(base / sid / "manifest.json")["구조물"] == [sid]
assert _save_all(root, [_wall("d1"), _wall("d2")], [])["만듦"] == []
def test_지정을_바꾸면_고친_옛_복사본은_지난에(world):
root = world["root"]
_save_all(root, [_wall("e1")], [])
_memo(root, "e1", "y")
rv = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이"}
result = _save_all(root, [_wall("e1", rv)], [])
assert result["만듦"] == ["e1"] and result["지움"] == ["e1"]
assert _read(root / "templates/structures/e1/manifest.json")["원본"]["key"] == "SS000001"
past = list((root / "templates/structures/_지난").iterdir())
assert len(past) == 1 and _read(past[0] / "면적.json")["메모"] == "y"
def test_새_항목_길_회사_없으면_409(world, monkeypatch):
root = world["root"]
_save_all(root, [_wall("f1")], [])
async def fake_row(project_id: str) -> dict[str, Any]:
return {"company_id": None, "storage_path": str(root)}
monkeypatch.setattr(layer_routes, "_project_row", fake_row)
monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path)
app = FastAPI()
app.include_router(items_router.router)
session = {"role": "SYSTEM_ADMIN", "company_id": None, "user_id": 12}
app.dependency_overrides[verify_session] = lambda: session
client = TestClient(app)
body = {"structure": "f1", "경로": ["새"], "이름": "것"}
assert client.post("/api/projects/p1/items/new", json=body).status_code == 409
save_as = client.post("/api/projects/p1/items/f1/save-as", json={"경로": ["a"], "이름": "b"})
assert save_as.status_code == 409
@@ -82,7 +82,8 @@ def _pipe(summary: dict | None) -> dict[str, Any]:
def _items(root: Path) -> Path:
return root / "templates" / "items"
"""구조물 복사본 뿌리(26-2b) — 구조물마다 폴더(`structure_id` · 관은 `pipe@측점`)."""
return root / "templates" / "structures"
# ── 칸 모양 ──────────────────────────────────────────
@@ -126,12 +127,8 @@ def test_옛_파일은_칸_없음_그대로_읽고_씀():
def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
root = world["root"]
result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)])
assert result == {
"만듦": ["system_none_SS000011", "system_none_SS000022"],
"지움": [],
"못만듦": [],
}
folder = _items(root) / "system_none_SS000022"
assert result == {"만듦": ["pipe@40.0", "st0"], "지움": [], "못만듦": [], "옮겨받음": []}
folder = _items(root) / "st0"
got = {path.name for path in folder.iterdir()}
assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got
total = json.loads((folder / "집계.json").read_text(encoding="utf-8"))
@@ -139,15 +136,12 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8"))
assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing
manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8"))
assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
pipe = json.loads(
(_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8")
)
assert manifest["원본"] == {**MS, "경로": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"]}
assert manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
pipe = json.loads((_items(root) / "pipe@40.0/manifest.json").read_text(encoding="utf-8"))
assert pipe["구조물"] == ["pipe@40.0"]
# 일위대가 UA 줄 + 담은 로직 줄(M01 시스템)
labor = json.loads(
(_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8")
)
labor = json.loads((_items(root) / "pipe@40.0/일위대가.json").read_text(encoding="utf-8"))
assert labor["줄"]["키"] == "UA000036"
assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [
entry["로직"] for entry in labor["줄"]["담은로직"]
@@ -157,7 +151,7 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
def test_원본을_바꿔도_프로젝트_값은_그대로(world):
root, system = world["root"], world["system"]
_save(root, [_dry(MS)], [])
before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
before = (_items(root) / "st0/집계.json").read_text(encoding="utf-8")
table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8"))
for column in table["열"]:
if column["id"] == "ms":
@@ -166,7 +160,7 @@ def test_원본을_바꿔도_프로젝트_값은_그대로(world):
json.dumps(table, ensure_ascii=False), encoding="utf-8"
)
assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀
after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
after = (_items(root) / "st0/집계.json").read_text(encoding="utf-8")
assert after == before
doc = fill.fill_table(root, table)
heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")]
@@ -174,16 +168,20 @@ def test_원본을_바꿔도_프로젝트_값은_그대로(world):
def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world):
"""같은 항목을 고른 구조물 둘 = 폴더 둘 · 한쪽만 바꾸면 그쪽만 새로 · 해제 · 삭제면 지움."""
root = world["root"]
_save(root, [_dry(MS), _dry(MS)], [])
manifest = _items(root) / "system_none_SS000022/manifest.json"
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"]
# 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦
assert sorted(p.name for p in _items(root).iterdir()) == ["st0", "st1"]
result = _save(root, [_dry(RV), _dry(MS)], [])
assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == []
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"]
assert result["만듦"] == ["st0"] and result["지움"] == ["st0"] # 안 고쳤으니 옛 것은 지움
assert json.loads((_items(root) / "st0/manifest.json").read_text("utf-8"))["원본"]["key"] == (
"SS000001"
)
assert json.loads((_items(root) / "st1/manifest.json").read_text("utf-8"))["원본"]["key"] == (
"SS000022"
)
# 해제 · 삭제
assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"]
assert _save(root, [_dry(None)], [])["지움"] == ["st0", "st1"]
assert list(_items(root).iterdir()) == []
@@ -197,8 +195,8 @@ def test_회사_층_항목은_회사_폴더에서_복사(world):
row["분류3"] = "회사 것"
(company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8")
owned = {**MS, "layer": "company", "owner": "3"}
assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"]
total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8"))
assert _save(root, [_dry(owned)], [])["만듦"] == ["st0"]
total = json.loads((_items(root) / "st0/집계.json").read_text("utf-8"))
assert total["줄"]["분류3"] == "회사 것"
# 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열
doc = fill.fill_table(
@@ -210,7 +208,7 @@ def test_회사_층_항목은_회사_폴더에서_복사(world):
def test_원본에_없는_항목은_못만듦으로_남김(world):
gone = {**MS, "key": "SS999999"}
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"]
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["st0"]
# ── 채움 ─────────────────────────────────────────────
@@ -245,4 +243,4 @@ def test_스냅샷이_없으면_알림(world):
def test_초기화는_templates_를_안_건드리고_복제는_통째로():
assert not any(tree.startswith("templates") for tree in initial._FILE_TREES)
tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8")
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함)
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함)