diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 722b87ef3..03f62c17c 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -326,6 +326,14 @@ def refuse_used(folder: Path, kind: str, key: str) -> None: # ── 층 사이 복사 ────────────────────────────────────────────────────── +def origin_mark(doc: dict[str, Any], ref: Ref) -> dict[str, Any]: + """복사 문서의 `본뜬` — 실무자료 본뜬(키 · 전국자재 …)은 지키고 원본 참조는 `복사원본` 칸에 둠.""" + own = doc.get("본뜬") + if isinstance(own, dict) and own.get("층") == "실무자료": + return {**doc, "본뜬": {**own, "복사원본": ref}} + return {**doc, "본뜬": ref} + + def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: """복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로 같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈. 원본 층 읽기 권한은 부르는 쪽 몫.""" @@ -341,7 +349,7 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: got = read_ref(ref) if got is None: raise _bad(f"없는 참조 「{ref['키']}」") - body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1), "본뜬": ref} + body = origin_mark({**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}, ref) made = create(kind_of(ref["키"]), body) done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]} return done[ref_id(ref)] diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index e0ad37249..7e278e7c4 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -97,7 +97,7 @@ class LibraryBody(BaseModel): def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]: - return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source)) + return refs.create(kind, refs.carry(refs.origin_mark(doc, ref), source)) @router.post("/layers/{layer}/library/{kind}") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 687db5ab1..6702b1564 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -127,9 +127,9 @@ export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] return out; } -/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */ +/** 단가산출 행 → B ~ F 열 범위 A1(`B4:F9`) */ export const rowsText = (행: number | [number, number]): string => - typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`; + typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`; export function openHopyoSheet(opt: HopyoSheetOptions): void { let sheet: SpreadsheetHandle | null = null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 8e6585aa8..72d9c4a88 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -111,12 +111,15 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { scope, key: file.키, 파일: file.파일, - canWrite: opt.canWrite, + // 사전조합은 어느 화면 층에서든 로그인한 사람 개인 층에 만듦 — 회사 · 마스터는 구조물 카드 [가져오기] + target: { layer: "personal", owner: null }, + canWrite: true, onClose: () => { if (sheetKey === file.키) sheetKey = null; paintList(); }, - onDeleted: () => void refresh(), + // [삭제]는 실무자료 파일이 있는 화면 층 쓰기 권한 때만 + onDeleted: opt.canWrite ? () => void refresh() : undefined, }); } diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index d36d273b2..33ac9f123 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -287,7 +287,7 @@ def test_엑셀_보기_창_행과_호표(): assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) assert sheet["last"] == ["AA", "A"] - assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 + assert sheet["rows"] == ["B4345:F4345", "B93:F101"] # 단가산출 행 B~F 열 # ── 소스 ────────────────────────────────────────────────────────────── @@ -348,7 +348,10 @@ def test_좌측_판_자리와_권한(): assert "size:" not in up and "엑셀 올리기" not in hopyo_side assert "buildHopyoView" not in hopyo_side assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side - assert "onClose:" in hopyo_side and "onDeleted: () => void refresh()," in hopyo_side + assert ( + "onClose:" in hopyo_side + and "onDeleted: opt.canWrite ? () => void refresh() : undefined," in hopyo_side + ) assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부] @@ -524,3 +527,11 @@ def test_명사형_단추와_별칭_장부_풀이_359(): 'label: "지우기"', ): assert gone not in every, gone + + +def test_372_2_저장은_개인_층에_삭제는_화면_층_권한(): + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + open_ = side.split("sheet.openHopyoSheet({")[1].split("});")[0] + assert 'target: { layer: "personal", owner: null },' in open_ + assert "canWrite: true," in open_ # 만들기 = 개인 층 · 화면 층 무관 + assert "onDeleted: opt.canWrite ? () => void refresh() : undefined," in open_ diff --git a/resources/tester/test_m02_hopyo_seam.py b/resources/tester/test_m02_hopyo_seam.py index ad7c77fac..01e8b0895 100644 --- a/resources/tester/test_m02_hopyo_seam.py +++ b/resources/tester/test_m02_hopyo_seam.py @@ -53,7 +53,7 @@ def test_실제_응답_화면_도우미(client): assert states == {"맞음": 122, "다른 점": 86, "후보": 60} # 268 줄 · 셈 끝 assert got["count"]["top"] == 9 # 「후보」 줄 1순위 근거 맞음(근거 맞는 줄은 거의 숫자로 풀림) assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄 - assert got["span"] == "B20:O24" and got["rows"] == "4229:4244" + assert got["span"] == "B20:O24" and got["rows"] == "B4229:F4244" by = {x["이름"]: x for x in got["lines"]} dig = by["구조물터파기(인력100%)"] assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행" diff --git a/resources/tester/test_m02_labor_national.py b/resources/tester/test_m02_labor_national.py index 80f18a17c..3bf5cc93a 100644 --- a/resources/tester/test_m02_labor_national.py +++ b/resources/tester/test_m02_labor_national.py @@ -120,3 +120,15 @@ def test_확인_창_세_칸_만들기_값(client): assert seoul["대상"][0]["세칸"]["자재구분"] == "사급" # 고른 자재 이름(-사급) 먼저 rows = (M02 / "M02_MasterTemplete_Hopyo_CheckRows.ts").read_text(encoding="utf-8") assert "...markCells(p?.세칸 ??" in rows + + +def test_복사_때_실무자료_본뜬과_전국자재_지킴(): + from M02_MasterTemplete import M02_Labor_Ref as refs + + ref = {"층": "personal", "주인": "7/42", "키": "SC-000001"} + mine = {"층": "실무자료", "키": "HP-000001 제1호표", "전국자재": [REMICON]} + kept = refs.origin_mark({"본뜬": mine}, ref)["본뜬"] + assert kept["키"] == "HP-000001 제1호표" and kept["전국자재"] == [REMICON] # 덮지 않음 + assert kept["복사원본"] == ref # 원본 참조는 다른 칸 + assert refs.origin_mark({"본뜬": None}, ref)["본뜬"] == ref # 보통 문서 = 옛 그대로 + assert refs.origin_mark({"본뜬": {"층": "system", "키": "A"}}, ref)["본뜬"] == ref