From 9d42cb065bce33a6853de39aaa94d1e29160efac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:36:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B8=20(43-9a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copy 길 pair=true — 같은 원본 열의 면적산출(시트 · 알약 연결) + 일위대가(담은로직 · 입력값 · 수량 · 자재) 함께 덮어씀 · 도면 그대로 - 면적 먼저(일위대가 계산값이 새 칸 값 읽음) · 원본 면적 없음 · 실무문서 = 일위대가만 - 권한 · 남의 층 감사 기록은 기존 _root 그대로 - 화면 함수 copyLaborAreaFrom(열, 원본, from) — 카드 잇기는 43-9b - 새 항목 칸 셋 자동 생성은 기존 집계표 저장 길 확인 · 시험 test_m02_pair_copy Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 11 +++ .../M02_MasterTemplete_Router_Items.py | 13 ++- .../M02_MasterTemplete_Store.py | 19 ++++ resources/tester/test_m02_pair_copy.py | 92 +++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_m02_pair_copy.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 03b7a9d51..02125837d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -224,16 +224,27 @@ export const copyPartFrom = async ( column: string, source: string, from: { layer: Layer | "practice"; owner: string | null } | null, + pair = false, ): Promise => { const q = new URLSearchParams({ from: source }); if (from) q.set("from_layer", from.layer); if (from?.owner) q.set("from_owner", from.owner); + if (pair) q.set("pair", "true"); const part = kind === "structure" ? "structures" : kind; await call(owned(activeLayer, `/layers/${activeLayer}/${part}/${enc(column)}/copy?${q}`), { method: "POST", }); }; +/** 일위대가 · 면적산출 함께 가져오기(43-9a · 카드 [가져오기]) — 원본 열의 면적산출(시트 · 알약 연결)과 + * 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 · + * 원본에 면적산출이 없으면(실무문서) 일위대가만 · 인자는 `copyPartFrom` 과 같음 */ +export const copyLaborAreaFrom = ( + column: string, + source: string, + from: { layer: Layer | "practice"; owner: string | null } | null, +): Promise => copyPartFrom("labor", column, source, from, true); + /** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */ export const clearPart = ( kind: "structure" | "labor" | "area", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 3b709f945..0a0ad7976 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -363,15 +363,22 @@ def post_copy( owner: str | None = None, from_layer: Literal["system", "company", "personal", "practice"] | None = None, from_owner: str | None = None, + pair: bool = False, session: Session = Depends(verify_session), ): """항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) · - 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만).""" + 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만) · + `pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}` · 실무문서는 일위대가만).""" folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind if from_layer == "practice": - if part != "labor" or not access.is_system_admin(session): + if part not in (("labor", "area") if pair else ("labor",)) or not access.is_system_admin( + session + ): raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만") - return _call(folder, _copy_practice, column, source) + got = _call(folder, _copy_practice, column, source) + return {"labor": got} if pair else got origin = _root(session, from_layer, from_owner) if from_layer else None + if pair and part != "structure": + return _call(folder, store.copy_pair, column, source, origin) return _call(folder, store.copy_from, part, column, source, origin) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 6fcb2f702..95a05a85a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -397,6 +397,25 @@ def copy_from( return info +def copy_pair( + column: str, + source: str, + source_root: Path | None = None, + labor_body: dict[str, Any] | None = None, +) -> dict[str, Any]: + """일위대가 ↔ 면적산출 함께 가져오기(43-9a) — 같은 원본 열의 면적산출(시트 · 알약 `연결`)과 + 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 · + 원본에 면적산출이 없으면(실무문서 `labor_body` 포함) 일위대가만 · 면적 먼저(일위대가 계산값이 + 새 면적 칸 값을 읽음 · 면적 계산이 막히면 일위대가도 안 씀).""" + with at(source_root or root()): + has_area = labor_body is None and _path("area", source).is_file() + out: dict[str, Any] = {} + if has_area: + out["area"] = copy_from("area", column, source, source_root) + out["labor"] = copy_from("labor", column, source, source_root, labor_body) + return out + + def prune_removed_structures() -> list[str]: """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 — 번호는 비워 둠(다른 열 번호를 안 당김) · 옛 `inputs/` 는 안 건드림.""" diff --git a/resources/tester/test_m02_pair_copy.py b/resources/tester/test_m02_pair_copy.py new file mode 100644 index 000000000..de344497f --- /dev/null +++ b/resources/tester/test_m02_pair_copy.py @@ -0,0 +1,92 @@ +"""일위대가 ↔ 면적산출 함께 가져오기(PLAN 43-9a) · 새 항목 = 칸 셋 자동 생성. + +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage (`test_m02_labor_copy` 의 world 를 씀). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_labor_copy import ( + TEN, + URL, + _as, + world, # noqa: F401 — 고정물 +) + + +def _doc(folder: Path, kind: str, cid: str) -> dict: + return json.loads((folder / kind / f"{cid}.json").read_bytes()) + + +def _link_rv(system: Path) -> None: + """원본 rv — 일위대가 수량은 알약 연결 · 면적산출 B6 = 5 에 그 알약.""" + with store.at(system): + got = store.read("labor", "rv") + rows = [{"로직": TEN, "입력값": {}, "수량": None}] + store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": rows}) + pill = labor.pills(store.read("labor", "rv")["문서"])[0]["키"] + area = store.read("area", "rv") + sheet = area["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", "rv", area["판"], area["문서"]) + + +def test_새_항목_칸_셋_빈_문서_자동_같은_뒷자리(world): # noqa: F811 + personal = world["personal"] + numbers = [_doc(personal, k, "a")[key] for k, key in store._KEY.items()] + assert [n[:2] for n in numbers] == ["SD", "SU", "SA"] + assert len({n[2:] for n in numbers}) == 1 + assert _doc(personal, "labor", "a")["담은로직"] == [] + + +def test_일위대가_면적산출_함께_덮어씀_도면은_그대로(world): # noqa: F811 + system, personal = world["system"], world["personal"] + _link_rv(system) + before = {k: _doc(personal, k, "a") for k in ("structure", "labor", "area")} + for kind in ("labor", "area"): # 어느 카드에서 불러도 같음 + url = URL.format("personal", kind, "a") + "?from=rv&from_layer=system&pair=true" + got = _as(world).post(url) + assert got.status_code == 200 and set(got.json()) == {"area", "labor"} + area, doc = _doc(personal, "area", "a"), _doc(personal, "labor", "a") + assert area["시트"][0]["연결"][0]["칸"] == "B6" and area["면적번"] == before["area"]["면적번"] + assert (doc["담은로직"][0]["로직"], doc["일위대가번"]) == (TEN, before["labor"]["일위대가번"]) + line = doc["계산값"]["줄"][0] + assert (line["수량"], line["수량출처"]) == (5, "칸") # 새 면적 칸 값으로 셈 + assert _doc(personal, "structure", "a") == before["structure"] + + +def test_pair_없으면_예전처럼_한_칸만(world): # noqa: F811 + system, personal = world["system"], world["personal"] + _link_rv(system) + url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system" + assert _as(world).post(url).status_code == 200 + assert "연결" not in _doc(personal, "area", "a")["시트"][0] + + +def test_실무문서는_일위대가만_면적_그대로(world): # noqa: F811 + system = world["system"] + _link_rv(system) + area = _doc(system, "area", "rv") + for kind in ("labor", "area"): + url = URL.format("system", kind, "rv") + "?from=UA000008&from_layer=practice&pair=true" + assert _as(world).post(url).status_code == 403 # 시스템 관리자만 + got = _as(world, "SYSTEM_ADMIN").post(url) + assert got.status_code == 200 and set(got.json()) == {"labor"} + assert len(_doc(system, "labor", "rv")["담은로직"]) == 14 + assert _doc(system, "area", "rv") == area + + +def test_원본에_면적산출_없으면_일위대가만(world): # noqa: F811 + system, personal = world["system"], world["personal"] + (system / "area" / "rv.json").unlink() + area = _doc(personal, "area", "a") + url = URL.format("personal", "area", "a") + "?from=rv&from_layer=system&pair=true" + got = _as(world).post(url) + assert got.status_code == 200 and set(got.json()) == {"labor"} + assert _doc(personal, "area", "a") == area + assert _doc(personal, "labor", "a")["담은로직"][0]["로직"] == TEN