diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 78b52bec9..4b31102a2 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -54,6 +54,7 @@ from M02_MasterTemplete.M02_Item_Source import ( labor_doc_rows, price_bundle, read_json, + version_of, ) LIBRARY = "library" @@ -100,6 +101,12 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: } +def source_version(files: dict[str, Any]) -> str: + """서고 사본 원본판(719-1) = 일위대가.json 지문 — 굳힌 SC · SK 문서 + 풀어 굳힌 SK 줄(바탕 + M01 줄 · 바탕판) · 단가는 뺌(단가 바뀜은 [업데이트] 단가 굳힘 몫) · 값 덮기 전에 셈.""" + return version_of(files.get(LABOR_FILE)) + + def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]: """서고 원본 → (사본 파일들, 원본 표지).""" if kind not in KINDS: @@ -247,7 +254,7 @@ def import_copy( if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict): return {"폴더": name, "만듦": False} manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who} - _write(project_root, name, files, manifest, values) + _write(project_root, name, files, {**manifest, "원본판": source_version(files)}, values) return {"폴더": name, "만듦": True} @@ -293,7 +300,8 @@ def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | N got = _build(kind, layer, owner, key) except (LookupError, ValueError): return None - got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}) + made = {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None} + got = (got[0], {**made, "원본판": source_version(got[0])}) files, manifest = got values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist) name = base + (f"~{tag(values)}" if values else "") diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py new file mode 100644 index 000000000..af0c86cda --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -0,0 +1,103 @@ +"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) — +산근(단산) 블록 = 바탕 없는 단가산출. + +블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가 +(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 · +서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · +구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, +확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_time import utc_now_text +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs + +ORIGIN = "실무자료" +KIND = "sangeun" + + +def mark(key: str, no: int) -> str: + return f"{key} 단산{no}" + + +def made(folder: str | Path, key: str | None = None) -> dict[str, str]: + """이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만).""" + out = {} + for path in sorted((Path(folder) / KIND).glob("SK-*.json")): + try: + got = json.loads(path.read_bytes()).get("본뜬") + except (OSError, ValueError, AttributeError): + continue + if isinstance(got, dict) and got.get("층") == ORIGIN: + if key is None or str(got.get("키") or "").startswith(f"{key} "): + out[str(got.get("키"))] = path.stem + return out + + +def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]: + name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}" + unit = str(line.get("단위") or block.get("단위") or "").strip() or "식" + spec = str(line.get("규격") or "").strip() + price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()} + fx = { + "이름": name, + "규격": spec, + "단위": unit, + "단가": price, + "이음": dict.fromkeys(fixed.LINKS), + "출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))}, + } + return { + "종류": "단가산출", + "이름": name, + "단위": unit, + "구분": ORIGIN, + "상세구분": str(hp.get("파일") or ""), + "본뜬": { + "층": ORIGIN, + "키": mark(hp["키"], no), + "파일": hp.get("파일"), + "원본": list(refs.here()), + "확인": {"누가": who, "때": stamp}, + }, + "바탕": None, + "입력값": {}, + "열린입력": [], + "고름": {}, + "자재표시": {}, + "덮개": {}, + "더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}], + "뺀줄": [], + } + + +def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]: + """[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 → + `{블록, 만듦 [{번, 이름, 키}], 그대로}`.""" + hp = hopyo_store.read(folder, key) + blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None} + calls: dict[int, dict] = {} + for h in hp.get("호표") or []: + for line in h.get("줄") or []: + ref = line.get("참조") or {} + no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0 + if no in blocks and no not in calls: + calls[no] = {**line, "_호표": h.get("번")} + have, stamp = made(folder, key), utc_now_text() + out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0} + for no in sorted(calls): + if mark(key, no) in have: + out["그대로"] += 1 + continue + doc = _doc(calls[no], blocks[no], no, hp, stamp, who) + got = refs.create(KIND, doc) + out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]}) + return out diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 8094536f2..a74ea02db 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -44,7 +44,7 @@ def _texts(row: dict) -> list[str]: out.append(row["결과"]["식"]) for item in row.get("호표", []): out.append(item["수량"]) - if not isinstance(item.get("요소"), dict): + if isinstance(item.get("요소"), str): # 단가산출 참조 · 고정 더한줄 = 요소 없음(719-1) out.append(item["요소"]) out += [extra["식"] for extra in row.get("덧줄", [])] return out @@ -200,6 +200,10 @@ class _Collector: return self.logics.add(key) self.keys.add(key) + self.row(row) + + def row(self, row: dict) -> None: + """로직 줄 하나가 닿는 것 — 마스터 로직 · 굳힌 단가산출 줄(719-1 · 마스터 밖 줄).""" if isinstance(row.get("흐름"), dict): # 흐름 로직 — 옛 칸 대신 흐름이 쓰는 것을 모음 self.flow(row) return @@ -221,12 +225,17 @@ class _Collector: self.pick(row, item["요소"], item.get("단위")) -def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]: - """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" +def collect( + logic_keys: list[str], materials: list[str] | None = None, extra: list[dict] | None = None +) -> dict[str, Any]: + """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}` · + `extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음).""" files, whole = master() collector = _Collector(whole) for key in logic_keys: collector.logic(key) + for row in extra or []: + collector.row(row) for code in materials or []: collector.add(code) out: dict[str, Any] = {} diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py new file mode 100644 index 000000000..0b5962009 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -0,0 +1,102 @@ +"""프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(PLAN 719-1 · 요구서 내역12 원칙 3 · 4장 14). + +사본을 만들 때(구조물 사본 `M02_Item_Source.build_source` · B09 서고 사본 `Bill_Library`) 굳힌 +참조사본 안 SK 문서마다 고친 줄(`M02_Sangeun_Calc.resolve` = 바탕 사슬 + 덮개 · 더한줄 · 뺀줄)을 +풀어 사본 `단가.json` 로직 줄 하나로 굳힘 — 사본 SK 문서는 그 줄만 바탕으로 가리킴(덮개 0 · +연결 끊김). 셈은 그대로 `M02_Sangeun_Calc` 한 벌(사본 색인 = 사본 `단가.json`). + +굳힌 키 = SK 참조 이름표(`ref_id` = 층 · 주인 · 키)에서 정해지는 `GX9#####`(해시 · 겹치면 +이름표 차례로 다음 번호) — 같은 SK = 같은 굳힌 키([업데이트] 때 B09 덮개 cover · cid · 입력값 · +고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만. +사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합), +열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [], +굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}. +단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침. +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +#: 사본 `단가.json` 안 굳힌 SK 줄 파일(`master_formula.Master` 가 그룹 「로직」 으로 읽음) +FILE = "로직_단가산출.json" +ROW = "굳힌줄" +_HEAD, _LOW, _SPAN = "GX", 900000, 99999 # GX900000 ~ GX999998(999999 = M01 저장 전 초안) + + +def frozen_key(rid: str, taken: set[str]) -> str: + """SK 이름표 → 굳힌 로직 키 — 해시 자리 · 이미 쓴 키면 다음 번호(제자리 `taken` 에 더함).""" + at = int(hashlib.sha256(rid.encode("utf-8")).hexdigest(), 16) % _SPAN + while (key := f"{_HEAD}{_LOW + at:06d}") in taken: + at = (at + 1) % _SPAN + taken.add(key) + return key + + +def is_sangeun(doc: Any) -> bool: + return isinstance(doc, dict) and doc.get("종류") == sd.TITLE + + +def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """참조사본 `{ref_id: 문서}` → SK 를 풀어 굳힌 참조사본(새 묶음 · 받은 것 안 고침) · + 못 푼 SK(없는 바탕 · 순환) = 그대로(사본 셈이 까닭을 냄).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc)) + if not sks: + return copies + whole = m01.loaded()[1] + + def read(ref: dict) -> dict | None: + return copies.get(refs.ref_id(ref)) + + out, taken = dict(copies), set() + for rid in sks: + doc = copies[rid] + try: + got = calc.resolve(doc, whole, read, (rid,)) + except sd.DocError: + continue + key = frozen_key(rid, taken) + row = prices.to_json({**got["줄"], "키": key}) + ids = sd.line_ids(row) + frozen = {k: doc.get(k) for k in ("종류", "키", "이름", "단위", "구분", "상세구분")} + frozen |= { + "바탕": {"로직": key}, + "입력값": prices.to_json(got["입력값"]), + "열린입력": list(doc.get("열린입력") or []), + "고름": {ids[int(n)]: mt for n, mt in got["고름"].items()}, + "자재표시": {ids[int(n)]: one for n, one in got["자재표시"].items()}, + "덮개": {}, + "더한줄": [], + "뺀줄": [], + ROW: row, + "원본": doc, + "바탕판": got["바탕판"], + "짝잃음": got["짝잃음"], + } + if got["켜쌓기율"] is not None: + frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) + out[rid] = frozen + return out + + +def rows(copies: dict[str, Any]) -> list[dict[str, Any]]: + """굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" + return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + + +def materials(copies: dict[str, Any]) -> list[str]: + """굳힌 SK 고른 자재 키 — 단가 묶음에 담음.""" + return [ + str(mt) + for doc in copies.values() + if is_sangeun(doc) + for mt in (doc.get("고름") or {}).values() + ] diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 47a0196c9..489ad8ea0 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -5,7 +5,7 @@ `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 · 규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4) + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) + - 참조사본(단가승격 · 사전조합을 값으로 굳힘) · + 참조사본(단가승격 · 사전조합을 값으로 굳힘 · 단가산출 SK = 풀어 굳힌 로직 줄 719-1) · `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) `원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. 프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. @@ -93,11 +93,12 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None: def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]: """일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 — 복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로).""" + from M02_MasterTemplete import M02_Item_Sangeun as sk from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Ref as refs rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)} - copies = refs.frozen(doc) + copies = sk.freeze(refs.frozen(doc)) # 단가산출(SK) = 풀어 굳힌 로직 줄(719-1) return {**rows, "참조사본": copies} if copies else rows @@ -162,11 +163,14 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Sangeun as sk from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs + copies = (labor or {}).get("참조사본") or {} + sk_rows = sk.rows(copies) # 굳힌 단가산출 줄(719-1) — 그 줄이 닿는 마스터 줄도 모음 logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] - logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직 + logic_keys += refs.frozen_logics(copies) # 굳힌 단가승격 로직 doc = (labor or {}).get("줄") or {} materials = [str(m.get("코드")) for m in doc.get("자재") or []] materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 @@ -174,8 +178,13 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계 + materials += sk.materials(copies) wanted = logic_keys + [code for code in materials if code] - bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + bundle = ( + prices.to_json(prices.collect(logic_keys, materials, sk_rows)) if wanted or sk_rows else {} + ) + if sk_rows: + bundle[sk.FILE] = {"그룹": "로직", "원문": "자체", "줄": sk_rows} if mine := own.bundle( [c for c in materials if own.is_own(c)], root ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5931b2b67..0e8da10b7 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -3,7 +3,8 @@ 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + -사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + +서고 나무 자유 폴더(158-1 · 716-4 `{종류}-tree` · `M02_Precombo_Folders` · 읽기 = 층 읽기 · +고침 = 층 쓰기) + 일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · 338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · 올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) · @@ -32,6 +33,7 @@ from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink +from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -46,6 +48,9 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( _call, _root, ) +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( + PasteBody as TreePasteBody, +) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) @@ -130,25 +135,44 @@ def new_library( return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) -@router.get("/layers/{layer}/precombo-tree") -def precombo_tree( - layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리).""" - return _call(_root(session, layer, owner), pre_folders.view) - - -@router.post("/layers/{layer}/precombo-tree") -def edit_precombo_tree( +@router.get("/layers/{layer}/{kind}-tree") +def library_tree( layer: Layer, + kind: Literal["precombo", "sangeun"], + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """서고 나무 폴더(일위대가 · 단가산출) — `{판, 폴더: [경로 …], 자리: {키: 경로}}` · + 자리 없는 문서 = 뿌리.""" + return _call(_root(session, layer, owner), pre_folders.view, kind) + + +@router.post("/layers/{layer}/{kind}-tree") +def edit_library_tree( + layer: Layer, + kind: Literal["precombo", "sangeun"], body: TreeBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 · - 판 다르면 409.""" + """새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(폴더 = 안에 든 문서째 · 쓰는 곳 있으면 409) · + 여럿 = `대상` → 새 나무 · 판 다르면 409.""" folder = _root(session, layer, owner, write=True) - return _call(folder, pre_folders.edit, body.model_dump()) + return _call(folder, pre_folders.edit, body.model_dump(), kind) + + +@router.post("/layers/{layer}/{kind}-tree/paste") +def paste_library_tree( + layer: Layer, + kind: Literal["precombo", "sangeun"], + body: TreePasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """복사한 층(읽기 권한) 문서 · 폴더 → 이 층(쓰기 권한) `경로` 아래 새 키 → 새 나무 + 알림.""" + target = _root(session, layer, owner, write=True) + source = _root(session, body.from_layer, body.from_owner) + return _call(target, pre_folders.paste, source, body.대상, body.경로, kind) @router.get("/layers/{layer}/materials") @@ -206,7 +230,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: when = str(old.get("끝낸때") or "")[:10] raise store.StoreError( 409, - f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음", + f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})" + " — 다시 만들지 않음", ) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): return {**same, "이어서": True} @@ -465,6 +490,7 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: made = sorted( {sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)} ) + made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK) if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None: try: got = cache_path(folder, key) @@ -477,6 +503,16 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: return {"지움": key, "만든": len(made)} +@router.post("/layers/{layer}/hopyo/{key}/sangeun") +def hopyo_make_sangeun( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) → + `{블록, 만듦, 그대로}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_sangeun.build, folder, key, _who(session)) + + class PasteBody(_Strict): """엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) · `줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함).""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6da5621f7..205097490 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -150,7 +150,7 @@ export type LibraryBody = { 문서: Record } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } }; export const newLibrary = ( layer: Layer, - kind: "precombo", + kind: LibraryKind, body: LibraryBody, owner?: string | null, ): Promise => @@ -159,25 +159,49 @@ export const newLibrary = ( body: JSON.stringify(body), }); -/** 사전조합 나무 자유 폴더(158-1) — 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) */ +/** 서고 나무 자유 폴더(158-1 · 716-4) — 일위대가(precombo) · 단가산출(sangeun) 따로 · + * 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) · 지우기 = 안에 든 문서째(쓰는 곳 있으면 409) */ +export type LibraryKind = "precombo" | "sangeun"; export interface PrecomboTree { 판: string; 폴더: string[][]; 자리: Record; + 알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] }; } -export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise => - call(owned(layer, `/layers/${layer}/precombo-tree`, owner)); +const libTree = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string => + owned(layer, `/layers/${layer}/${kind}-tree${tail}`, owner); +export const fetchPrecomboTree = ( + layer: Layer, + owner: string | null, + kind: LibraryKind = "precombo", +): Promise => call(libTree(kind, layer, owner)); export const editPrecomboTree = ( layer: Layer, edit: Pick & { op: "add-folder" | "rename" | "move" | "delete"; + 대상?: TreeRef[]; }, 판: string, owner: string | null, + kind: LibraryKind = "precombo", ): Promise => - call(owned(layer, `/layers/${layer}/precombo-tree`, owner), { + call(libTree(kind, layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); +/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */ +export const pasteLibraryTree = ( + kind: LibraryKind, + layer: Layer, + owner: string | null, + clip: TreeClip, + dest: string[], +): Promise => + call(libTree(kind, layer, owner, "/paste"), { method: "POST", - body: JSON.stringify({ ...edit, 판 }), + body: JSON.stringify({ + from_layer: clip.from.layer, + from_owner: clip.from.owner, + 대상: clip.refs, + 경로: dest, + }), }); /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index f76885502..302eccf0e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -1,11 +1,12 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Wizard.ts * 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋: - * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림 + * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림 * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서 * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → - * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 - * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록 + * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · + * 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음) + * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록 * 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김. * ========================================================================== */ @@ -23,6 +24,7 @@ import { type HopyoFile, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; const LAYERS: { layer: ManageLayer; text: string }[] = [ @@ -35,9 +37,6 @@ const STEPS = [ { value: "2", label: "② 보기 · 확인 · 만들기" }, { value: "3", label: "③ 끝내기" }, ]; -/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */ -export const SANGEUN_READY = false; -const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬"; type Got = HopyoFile & { 이어서?: boolean }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -70,6 +69,7 @@ export function buildHopyoWizard(): HTMLElement { }); let layers: ManageLayer[] = []; let pick: ManageLayer = "personal"; + let wantSangeun = false; // 결과 「단가산출」 체크 const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); async function first(): Promise { @@ -112,15 +112,17 @@ export function buildHopyoWizard(): HTMLElement { }); seg.root.dataset.hopyoLayer = ""; const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true }); - const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY }); - sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY; + const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { + checked: wantSangeun, + onChange: (on) => (wantSangeun = on), + }); + sangeun.root.dataset.hopyoSangeun = ""; const busy = el("div", { className: "m02-hopyo" }); body.replaceChildren( createText("결과 층", "muted", 14), el("div", { className: "ui-toolbar", children: [seg.root] }), createText("결과", "muted", 14), el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }), - ...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]), el("div", { className: "ui-toolbar", children: [upload, input] }), createPanelHead({ title: "작업 중", level: 4, divider: true }).root, busy, @@ -169,6 +171,14 @@ export function buildHopyoWizard(): HTMLElement { ) .catch((error: unknown) => showToast(why(error), "error")), }); + const sangeun = createButton({ + label: "단가산출 만들기", + variant: "filled", + onClick: () => void makeSangeun(), + }); + sangeun.title = + "엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)"; + sangeun.dataset.hopyoSangeunMake = ""; const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() }); finish.dataset.hopyoFinish = ""; const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); @@ -177,22 +187,44 @@ export function buildHopyoWizard(): HTMLElement { body.replaceChildren( createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14), createText( - "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감", + "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감", "hint", ), - el("div", { className: "ui-toolbar", children: [view, finish, back] }), + el("div", { + className: "ui-toolbar", + children: [view, ...(wantSangeun ? [sangeun] : []), finish, back], + }), ); + async function makeSangeun(): Promise { + sangeun.disabled = true; + try { + const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>( + `/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`, + { method: "POST" }, + ); + const kind = got.블록 ? "success" : "warning"; + showToast( + `단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`, + kind, + ); + } catch (error) { + showToast(why(error), "error"); + } finally { + sangeun.disabled = false; + } + } + async function end(): Promise { steps.set("3"); const ok = await showConfirmDialog( - `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`, + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`, "끝내기", ); if (!ok) return steps.set("2"); try { const got = (await deleteHopyo(scope, file.키)) as { 만든?: number }; - showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); + showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); } catch (error) { showToast(why(error), "error"); return steps.set("2"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts new file mode 100644 index 000000000..a2180e8f8 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -0,0 +1,451 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side_Library.ts + * 좌측 서고 칸(716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 두 칸이 같이 쓰는 층 단추 + 그 층 나무. + * 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`). + * 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버 + * (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] · + * [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기. + * 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · + * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). + * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). + * ========================================================================== */ + +import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { t as L } from "@ui/ui_template_locale"; +import { + createTreeRow, + openTreeMenu, + renderTree, + type TreeMenuItem, + type TreeNode, +} from "@ui/ui_template_tree"; +import { + StaleError, + editPrecomboTree, + fetchPrecomboTree, + listTemplates, + newLibrary, + pasteLibraryTree, + type Layer, + type LibraryKind, + type PrecomboTree, +} from "./M02_MasterTemplete_Api_Fetch"; +import { nameDialog } from "./M02_MasterTemplete_Library_List"; +import type { Root } from "./M02_MasterTemplete_Layer"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; +import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; +import { + clipOf, + keepClip, + pickClick, + refOf, + type Target, +} from "./M02_MasterTemplete_TreePanel_Pick"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +/** 서고 칸 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ +export interface LibraryScope { + root: Root; + layer: Layer; + owner: string | null; + canWrite: boolean; +} + +export interface LibraryOptions { + kind: LibraryKind; + scope: () => LibraryScope; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ + bar: () => HTMLElement; + confirmLeave: () => Promise; + onOpen: (sel: Selection | null) => void; + getOpen: () => Selection | null; + /** 그 층 목록표 고름 — 문서 열기의 바탕(종류 · 층 · 주인 · 권한) */ + list: (scope: LibraryScope) => Selection; + /** [여기에 새 …] 문서 몸 */ + blank: (name: string) => Record; + /** 빈 나무 글 · 새 문서 메뉴 글 */ + text: { none: string; newHere: string }; + /** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */ + more?: (key: string, name: string) => TreeMenuItem[]; +} + +export interface LibraryHandle { + body: HTMLElement; + /** 고름 표시만 */ + paint: () => void; + /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ + refresh: () => Promise; +} + +export interface LibraryRow { + 이름: string; + 종류: string; + 제목?: string | null; + 구분?: string | null; + 상세구분?: string | null; +} + +export interface LibraryFolder { + path: string[]; + folders: LibraryFolder[]; + docs: LibraryRow[]; +} + +/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ +export function buildLibraryFolders( + rows: LibraryRow[], + view: Pick, +): LibraryFolder { + const root: LibraryFolder = { path: [], folders: [], docs: [] }; + const at = (path: string[]): LibraryFolder => + path.reduce((node, name, i) => { + let next = node.folders.find((f) => f.path[i] === name); + if (!next) { + next = { path: path.slice(0, i + 1), folders: [], docs: [] }; + node.folders.push(next); + } + return next; + }, root); + view.폴더.forEach(at); + for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); + return root; +} + +/** 폴더 안 문서 수(아래 폴더까지) */ +export const docCount = (node: LibraryFolder): number => + node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); + +type TreeBody = Parameters[1]; +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { + const kind = opt.kind; + const clipKind = `library:${kind}`; + const barHost = el("div"); + const treeHost = el("div", { + className: "ui-tree m02-tree__body", + attrs: { "data-library-tree": kind, tabindex: "0" }, + }); + const leaves = new Map(); + const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 + const targets = new Map(); + const counts = new Map(); + let picked = new Set(); + let anchor: string | null = null; + let rows: LibraryRow[] = []; + let view: PrecomboTree | null = null; + let seq = 0; + let busy = false; + + const open = async (row: LibraryRow): Promise => { + const at = opt.scope(); + if (!(await opt.confirmLeave())) return; + opt.onOpen({ + ...opt.list(at), + name: row.이름, + label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, + }); + }; + + /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(같은 이름 · 쓰는 곳) 그대로 */ + const apply = async (run: (판: string) => Promise): Promise => { + if (!view || busy) return; + busy = true; + try { + view = await run(view.판); + const gone = view.알림?.지운문서 ?? []; + if (gone.length) { + rows = rows.filter((r) => !gone.includes(r.이름)); + const cur = opt.getOpen(); + if (cur && cur.kind === kind && gone.includes(cur.name)) opt.onOpen(null); + } + if (view.알림?.만든문서?.length) await refresh(true); + else draw(); + showToast(L("M02_TreeDone"), "success"); + } catch (error) { + if (error instanceof StaleError && error.message === "HTTP 409") { + await refresh(); + showToast(L("M02_TreeStale"), "error"); + } else { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + } finally { + busy = false; + } + }; + const edit = async (body: TreeBody): Promise => { + const at = opt.scope(); + for (const path of [body.경로, body.새경로]) + path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); + await apply((판) => editPrecomboTree(at.layer, body, 판, at.owner, kind)); + }; + + /** [여기에 새 …] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ + const make = (path: string[]): void => { + const at = opt.scope(); + nameDialog(opt.text.newHere, "", async (name) => { + const got = await newLibrary(at.layer, kind, { 문서: opt.blank(name) }, at.owner); + showToast(L("M02_Created"), "success"); + await refresh(); + if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); + await open({ 이름: got.이름, 종류: kind, 제목: name }); + }); + }; + const addFolder = async (parent: string[]): Promise => { + const got = await askText(L("M02_TreeAddFolder"), [ + { key: "name", label: L("M02_TreeFolderName") }, + ]); + if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); + }; + const rename = async (path: string[]): Promise => { + const was = path[path.length - 1]; + const got = await askText(L("M02_TreeRename"), [ + { key: "name", label: L("M02_TreeFolderName"), value: was }, + ]); + if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); + }; + + /* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */ + const pickedTargets = (): Target[] => + [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); + /** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */ + const remove = async (list: Target[]): Promise => { + if (!list.length || !opt.scope().canWrite) return; + const [one] = list; + const ask = + list.length > 1 + ? L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length)) + : one.kind === "item" + ? L("M02_TreeDeleteItem").replace("{value}", one.item.이름) + : L("M02_TreeDeleteFolder") + .replace("{value}", one.path[one.path.length - 1]) + .replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0)); + if (!(await showConfirmDialog(ask, L("M02_Delete")))) return; + await edit({ op: "delete", 대상: list.map(refOf) }); + }; + const copy = (list: Target[]): void => { + if (!list.length) return; + const at = opt.scope(); + keepClip({ kind: clipKind, from: { layer: at.layer, owner: at.owner }, refs: list.map(refOf) }); + showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); + }; + const paste = async (dest: string[]): Promise => { + const got = clipOf(clipKind); + const at = opt.scope(); + if (!got || !at.canWrite) return; + toggled.set(folderIdOf(dest), true); + await apply(() => pasteLibraryTree(kind, at.layer, at.owner, got, dest)); + }; + + const menuAt = (path: string[]): TreeMenuItem[] => { + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + const item = (label: string, run: () => void): TreeMenuItem => ({ + label, + run, + disabledReason: off, + }); + const got = clipOf(clipKind); + const target: Target = { kind: "folder", path }; + return [ + item(L("M02_TreeAddFolder"), () => void addFolder(path)), + item(opt.text.newHere, () => make(path)), + ...(path.length + ? [ + item(L("M02_TreeRename"), () => void rename(path)), + item(L("M02_Delete"), () => void remove([target])), + { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, + ] + : []), + ...(got + ? [item(L("M02_TreePaste").replace("{n}", String(got.refs.length)), () => void paste(path))] + : []), + ]; + }; + /** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(복사 · 지우기) · 아니면 그 줄 하나 */ + const menuFor = (id: string, single: () => TreeMenuItem[]): TreeMenuItem[] => { + if (!picked.has(id)) setPicked(new Set([id]), id); + const list = pickedTargets(); + if (list.length < 2) return single(); + const n = String(list.length); + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + return [ + { label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) }, + { + label: L("M02_TreeDeleteMany").replace("{n}", n), + run: () => void remove(list), + disabledReason: off, + }, + ]; + }; + + const leaf = (row: LibraryRow, folder: string[]): TreeNode => { + const id = `doc:${row.이름}`; + const label = row.제목 || row.이름; + const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; + const target: Target = { kind: "item", item }; + targets.set(id, target); + const single = (): TreeMenuItem[] => [ + { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, + { + label: L("M02_Delete"), + run: () => void remove([target]), + disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), + }, + ...(opt.more?.(row.이름, label) ?? []), + ]; + const menu = () => menuFor(id, single); + return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; + }; + const folderNode = (node: LibraryFolder): TreeNode => { + const id = folderIdOf(node.path); + const count = docCount(node); + targets.set(id, { kind: "folder", path: node.path }); + counts.set(id, count); + return { + id, + label: node.path[node.path.length - 1], + count, + folder: true, + open: toggled.get(id) ?? count > 0, + children: kids(node), + menu: () => menuFor(id, () => menuAt(node.path)), + }; + }; + const kids = (node: LibraryFolder): TreeNode[] => [ + ...node.folders.map(folderNode), + ...node.docs.map((row) => leaf(row, node.path)), + ]; + + /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 고른 것 전부 · 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 */ + const drag = attachTreeDrag({ + body: treeHost, + targetOf: (id) => targets.get(id), + listFor: (id) => + picked.size > 1 && picked.has(id) + ? pickedTargets() + : targets.has(id) + ? [targets.get(id)!] + : null, + move: (list, dest) => { + const from = (t: Target): string[] => + t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); + const moving = list.filter((t) => !samePath(from(t), dest)); + if (moving.length) void edit({ op: "move", 대상: moving.map(refOf), 새경로: dest }); + }, + }); + + const visibleIds = (): string[] => + [...treeHost.querySelectorAll("[data-node]")] + .filter((b) => !b.closest("[hidden]")) + .map((b) => b.dataset.node ?? ""); + const setPicked = (next: Set, at: string | null): void => { + picked = next; + anchor = at; + for (const row of treeHost.querySelectorAll("[data-node]")) + row.classList.toggle("is-picked", picked.size > 1 && picked.has(row.dataset.node ?? "")); + }; + + const draw = (): void => { + leaves.clear(); + targets.clear(); + counts.clear(); + const tree = buildLibraryFolders(rows, view ?? { 폴더: [], 자리: {} }); + if (!tree.docs.length && !tree.folders.length) + return void treeHost.replaceChildren(createEmpty({ text: opt.text.none, small: true })); + const canWrite = opt.scope().canWrite; + treeHost.replaceChildren( + ...renderTree(kids(tree), (node, caret, depth, onClick) => { + const row = createTreeRow(node, caret, depth, () => { + onClick(); + if (!node.children?.length) return; + toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); + }); + row.dataset.node = node.id; + // Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture) + row.addEventListener( + "click", + (event) => { + const mod = { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey }; + const got = pickClick(picked, anchor, node.id, mod, visibleIds()); + if (got.handled) event.stopImmediatePropagation(); + setPicked(got.picked, got.anchor); + }, + { capture: true }, + ); + if (node.run) { + row.dataset.libraryKey = node.id.slice(4); + leaves.set(node.id.slice(4), row); + } + if (canWrite) drag.arm(row, node.id); + return row; + }), + ); + setPicked(new Set([...picked].filter((id) => targets.has(id))), anchor); + paint(); + }; + + // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 문서 · 붙여넣기) + treeHost.addEventListener("contextmenu", (event) => { + if (!view || (event.target as HTMLElement).closest("[data-node]")) return; + event.preventDefault(); + openTreeMenu(event, menuAt([])); + }); + // 키 — Delete = 고른 것(없으면 초점 줄) 지움 · Ctrl+C 복사 · Ctrl+V = 초점 폴더 안(아니면 뿌리) + treeHost.addEventListener("keydown", (event) => { + const row = (event.target as HTMLElement).closest("[data-node]"); + const id = row?.dataset.node ?? ""; + const target = targets.get(id); + const list = picked.has(id) ? pickedTargets() : target ? [target] : []; + if (event.key === "Delete" && list.length) { + event.preventDefault(); + void remove(list); + } else if (isCtrlKey(event, "c") && list.length) { + event.preventDefault(); + copy(list); + } else if (isCtrlKey(event, "v")) { + event.preventDefault(); + void paste(target?.kind === "folder" ? target.path : []); + } + }); + + const paint = (): void => { + const cur = opt.getOpen(); + const at = opt.scope(); + const mine = + cur && cur.kind === kind && cur.layer === at.layer && (cur.owner ?? null) === at.owner; + for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); + }; + + /** `keep` = 나무 자리 그대로 두고 목록만 다시(붙여넣기 뒤 새 문서) */ + const refresh = async (keep = false): Promise => { + const mine = ++seq; + const at = opt.scope(); + barHost.replaceChildren(opt.bar()); + if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); + const [got, tree] = await Promise.all([ + // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 + listTemplates(at.layer, null, at.owner).then( + (list): LibraryRow[] => list.filter((r) => r.종류 === kind), + (): LibraryRow[] => [], + ), + keep && view + ? Promise.resolve(view) + : fetchPrecomboTree(at.layer, at.owner, kind).catch(() => null), + ]); + if (mine !== seq) return; + rows = got; + view = tree; + draw(); + }; + + const body = el("div", { + className: "m02-side__items ui-sidebar-list", + attrs: { "data-library": kind }, + children: [barHost, treeHost], + }); + return { body, paint, refresh: () => refresh() }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 38125c298..b8a79ae16 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,44 +1,22 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · - * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 일위대가 나무. - * 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — - * 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 일위대가] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽 - * 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김. + * 좌측 「일위대가」 칸(50-3 ③ · 51-4 · 134-3) — 서고 칸(`UI_Side_Library` · 716-4)에 일위대가 몫만 줌: + * 종류 precombo · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(페이지 층과 따로). * 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관. * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { - createTreeRow, - openTreeMenu, - renderTree, - type TreeMenuItem, - type TreeNode, -} from "@ui/ui_template_tree"; -import { - StaleError, - editPrecomboTree, - fetchPrecomboTree, - listTemplates, - newLibrary, - type Kind, - type Layer, - type PrecomboTree, -} from "./M02_MasterTemplete_Api_Fetch"; -import { nameDialog } from "./M02_MasterTemplete_Library_List"; -import type { Root } from "./M02_MasterTemplete_Layer"; +import type { TreeMenuItem } from "@ui/ui_template_tree"; +import type { Kind } from "./M02_MasterTemplete_Api_Fetch"; import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; -import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; -import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; -import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; -import { createEmpty } from "@ui/ui_template_empty"; +import { + buildLibrarySection, + type LibraryHandle, + type LibraryScope, +} from "./M02_MasterTemplete_UI_Side_Library"; export type PrecomboKind = Extract; @@ -60,12 +38,7 @@ export const precomboClasses = ( })); /** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ -export interface PrecomboScope { - root: Root; - layer: Layer; - owner: string | null; - canWrite: boolean; -} +export type PrecomboScope = LibraryScope; /** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ export const precomboList = (scope: PrecomboScope): Selection => ({ @@ -89,257 +62,13 @@ export interface PrecomboOptions { more?: (key: string, name: string) => TreeMenuItem[]; } -export interface PrecomboHandle { - body: HTMLElement; - /** 고름 표시만 */ - paint: () => void; - /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ - refresh: () => Promise; -} +export type PrecomboHandle = LibraryHandle; -interface Row { - 이름: string; - 종류: string; - 제목?: string | null; - 구분?: string | null; - 상세구분?: string | null; -} - -export interface PrecomboFolder { - path: string[]; - folders: PrecomboFolder[]; - docs: Row[]; -} - -/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ -export function buildPrecomboFolders( - rows: Row[], - view: Pick, -): PrecomboFolder { - const root: PrecomboFolder = { path: [], folders: [], docs: [] }; - const at = (path: string[]): PrecomboFolder => - path.reduce((node, name, i) => { - let next = node.folders.find((f) => f.path[i] === name); - if (!next) { - next = { path: path.slice(0, i + 1), folders: [], docs: [] }; - node.folders.push(next); - } - return next; - }, root); - view.폴더.forEach(at); - for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); - return root; -} - -/** 폴더 안 문서 수(아래 폴더까지) */ -export const docCount = (node: PrecomboFolder): number => - node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); - -type TreeBody = Parameters[1]; -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); - -export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - const barHost = el("div"); - const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); - const leaves = new Map(); - const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 - const targets = new Map(); - let rows: Row[] = []; - let view: PrecomboTree | null = null; - let seq = 0; - let busy = false; - - const open = async (row: Row): Promise => { - const at = opt.scope(); - if (!(await opt.confirmLeave())) return; - opt.onOpen({ - ...precomboList(at), - name: row.이름, - label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, - }); - }; - - /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */ - const edit = async (body: TreeBody): Promise => { - const at = opt.scope(); - if (!view || busy) return; - busy = true; - try { - view = await editPrecomboTree(at.layer, body, view.판, at.owner); - for (const path of [body.경로, body.새경로]) - path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); - draw(); - showToast(L("M02_TreeDone"), "success"); - } catch (error) { - if (error instanceof StaleError && error.message === "HTTP 409") { - await refresh(); - showToast(L("M02_TreeStale"), "error"); - } else { - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - } - } finally { - busy = false; - } - }; - - /** [여기에 새 일위대가] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ - const make = (path: string[]): void => { - const at = opt.scope(); - nameDialog(tc("Lib_NewHere"), "", async (name) => { - const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; - const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner); - showToast(L("M02_Created"), "success"); - await refresh(); - if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); - await open({ 이름: got.이름, 종류: "precombo", 제목: name }); - }); - }; - const addFolder = async (parent: string[]): Promise => { - const got = await askText(L("M02_TreeAddFolder"), [ - { key: "name", label: L("M02_TreeFolderName") }, - ]); - if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); - }; - const rename = async (path: string[]): Promise => { - const was = path[path.length - 1]; - const got = await askText(L("M02_TreeRename"), [ - { key: "name", label: L("M02_TreeFolderName"), value: was }, - ]); - if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); - }; - /** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */ - const remove = async (path: string[], count: number): Promise => { - if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error"); - const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]); - if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path }); - }; - - const menuAt = (path: string[], count: number): TreeMenuItem[] => { - const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); - const item = (label: string, run: () => void): TreeMenuItem => ({ - label, - run, - disabledReason: off, - }); - return [ - item(L("M02_TreeAddFolder"), () => void addFolder(path)), - item(tc("Lib_NewHere"), () => make(path)), - ...(path.length - ? [ - item(L("M02_TreeRename"), () => void rename(path)), - item(L("M02_Delete"), () => void remove(path, count)), - ] - : []), - ]; - }; - - const leaf = (row: Row, folder: string[]): TreeNode => { - const id = `doc:${row.이름}`; - const label = row.제목 || row.이름; - const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; - targets.set(id, { kind: "item", item }); - const menu = () => opt.more?.(row.이름, label) ?? []; - return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; - }; - const folderNode = (node: PrecomboFolder): TreeNode => { - const id = folderIdOf(node.path); - const count = docCount(node); - targets.set(id, { kind: "folder", path: node.path }); - return { - id, - label: node.path[node.path.length - 1], - count, - folder: true, - open: toggled.get(id) ?? count > 0, - children: kids(node), - menu: () => menuAt(node.path, count), - }; - }; - const kids = (node: PrecomboFolder): TreeNode[] => [ - ...node.folders.map(folderNode), - ...node.docs.map((row) => leaf(row, node.path)), - ]; - - /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */ - const drag = attachTreeDrag({ - body: treeHost, - targetOf: (id) => targets.get(id), - listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null), - move: ([t], dest) => { - const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); - if (samePath(from, dest)) return; - void edit( - t.kind === "item" - ? { op: "move", 키: t.item.key, 새경로: dest } - : { op: "move", 경로: t.path, 새경로: dest }, - ); - }, +export const buildPrecomboSection = (opt: PrecomboOptions): PrecomboHandle => + buildLibrarySection({ + ...opt, + kind: "precombo", + list: precomboList, + blank: (name) => ({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }), + text: { none: tc("Lib_None"), newHere: tc("Lib_NewHere") }, }); - - const draw = (): void => { - leaves.clear(); - targets.clear(); - const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} }); - if (!tree.docs.length && !tree.folders.length) - return void treeHost.replaceChildren(createEmpty({ text: tc("Lib_None"), small: true })); - const canWrite = opt.scope().canWrite; - treeHost.replaceChildren( - ...renderTree(kids(tree), (node, caret, depth, onClick) => { - const row = createTreeRow(node, caret, depth, () => { - onClick(); - if (!node.children?.length) return; - toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); - }); - row.dataset.node = node.id; - if (node.run) { - row.dataset.precomboKey = node.id.slice(4); - leaves.set(node.id.slice(4), row); - } - if (canWrite) drag.arm(row, node.id); - return row; - }), - ); - paint(); - }; - - // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 일위대가) - treeHost.addEventListener("contextmenu", (event) => { - if (!view || (event.target as HTMLElement).closest("[data-node]")) return; - event.preventDefault(); - openTreeMenu(event, menuAt([], 0)); - }); - - const paint = (): void => { - const cur = opt.getOpen(); - const at = opt.scope(); - const mine = - cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner; - for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); - }; - - const refresh = async (): Promise => { - const mine = ++seq; - const at = opt.scope(); - barHost.replaceChildren(opt.bar()); - treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); - const [got, tree] = await Promise.all([ - // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 - listTemplates(at.layer, null, at.owner).then( - (list): Row[] => list.filter((r) => r.종류 === "precombo"), - (): Row[] => [], - ), - fetchPrecomboTree(at.layer, at.owner).catch(() => null), - ]); - if (mine !== seq) return; - rows = got; - view = tree; - draw(); - }; - - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - attrs: { "data-precombo": "" }, - children: [barHost, treeHost], - }); - return { body, paint, refresh }; -} diff --git a/M02_MasterTemplete/M02_Precombo_Folders.py b/M02_MasterTemplete/M02_Precombo_Folders.py index 110768d11..9e418fe33 100644 --- a/M02_MasterTemplete/M02_Precombo_Folders.py +++ b/M02_MasterTemplete/M02_Precombo_Folders.py @@ -1,32 +1,42 @@ -"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유). +"""M02 서고 나무 = 자유 폴더(PLAN 158-1 · 716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 한 벌. -자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — -층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 · -폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · -문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409). +자리 = 층 뿌리 `{종류}_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — 종류 · 층(마스터 · 회사 · +개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는 +문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면 +409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) — +모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음). +붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 · +못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`). 이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌. """ from __future__ import annotations import json +from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers -FILE = "precombo_폴더.json" OPS = ("add-folder", "rename", "move", "delete") +USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴 -def _keys() -> set[str]: - """지금 층에 있는 사전조합 키(파일 이름).""" - return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")} +def file_of(kind: str) -> str: + return f"{kind}_폴더.json" -def _read() -> tuple[dict[str, Any], str]: - path = store.root() / FILE +def _keys(kind: str, root: Path | None = None) -> set[str]: + """그 층에 있는 서고 키(파일 이름).""" + return {p.stem for p in ((root or store.root()) / kind).glob("[!._]*.json")} + + +def _read(kind: str, root: Path | None = None) -> tuple[dict[str, Any], str]: + path = (root or store.root()) / file_of(kind) if not path.is_file(): return {"폴더": [], "자리": {}}, "" raw = path.read_bytes() @@ -45,29 +55,38 @@ def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]: return out +def _places(doc: dict[str, Any], keys: set[str]) -> dict[str, list[str]]: + return {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + + def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]: - places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + places = _places(doc, keys) return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}} -def view() -> dict[str, Any]: - doc, 판 = _read() - return view_of(doc, 판, _keys()) +def view(kind: str = "precombo") -> dict[str, Any]: + doc, 판 = _read(kind) + return view_of(doc, 판, _keys(kind)) def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]: return [*new, *path[len(old) :]] if tree._under(path, old) else path +def _out(folders: list[list[str]], places: dict[str, list[str]]) -> dict[str, Any]: + return {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} + + def apply( doc: dict[str, Any], body: dict[str, Any], keys: set[str] ) -> tuple[dict[str, Any], dict[str, Any]]: - """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장은 `edit`).""" + """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장 · 문서 파일 지우기는 `edit`). + 지우기 알림 `지운문서` = 지울 문서 키(폴더 안 · 아래 폴더까지).""" op = body.get("op") if op not in OPS: raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") folders = _folders(doc, keys) - places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + places = _places(doc, keys) where = tree._path(body.get("경로")) key = str(body.get("키") or "") note: dict[str, Any] = {"op": op} @@ -78,6 +97,8 @@ def apply( if not path and what: raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음") + if key and op in ("move", "delete") and key not in keys: + raise tree.TreeError(404, f"없는 문서 「{key}」") if op == "add-folder": need(where, "") made = [*where, tree._name(body.get("이름"))] @@ -86,12 +107,13 @@ def apply( folders.append(made) note["경로"] = made elif op == "move" and key: - if key not in keys: - raise tree.TreeError(404, f"없는 사전조합 「{key}」") to = tree._path(body.get("새경로"), "새경로") need(to, "") places[key] = to note["경로"] = to + elif op == "delete" and key: + places.pop(key, None) + note["지운문서"] = [key] elif op in ("rename", "move"): need(where, "바꿀" if op == "rename" else "옮길") if op == "rename": @@ -109,28 +131,136 @@ def apply( folders = [_shift(f, where, new) for f in folders] places = {k: _shift(p, where, new) for k, p in places.items()} note["경로"] = new - else: # delete — 빈 폴더만 + else: # delete — 안에 든 문서째(716-4) need(where, "지울") inside = sorted(k for k, p in places.items() if tree._under(p, where)) - if inside: - raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움") folders = [f for f in folders if not tree._under(f, where)] + places = {k: p for k, p in places.items() if k not in inside} note["경로"] = where - out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} - return out, note + note["지운문서"] = inside + return _out(folders, places), note + + +def apply_many( + doc: dict[str, Any], body: dict[str, Any], keys: set[str] +) -> tuple[dict[str, Any], dict[str, Any]]: + """`대상` 이 있으면 옮기기 · 지우기를 하나씩 차례로(하나라도 막히면 아무것도 안 함) · + 없으면 `apply`.""" + targets = body.get("대상") + if targets is None: + return apply(doc, body, keys) + op = body.get("op") + if op not in ("move", "delete") or not targets: + raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") + note: dict[str, Any] = {"op": op, "지운문서": [], "바뀐이름": []} + for one in targets: + if not isinstance(one, dict): + raise tree.TreeError(422, "「대상」 은 {경로, 키?} 목록") + part = { + "op": op, + "경로": one.get("경로"), + "키": one.get("키"), + "새경로": body.get("새경로"), + } + if op == "delete" and not part["키"] and not tree._path(part["경로"]): + continue # 맨 위는 못 지움 + try: + doc, got = apply(doc, part, keys) + except tree.TreeError as e: + if e.status == 404 and op == "delete": + continue # 고른 폴더 안의 것 — 앞에서 이미 지움 + raise + note["지운문서"] += [k for k in got.get("지운문서", []) if k not in note["지운문서"]] + note["바뀐이름"] += got.get("바뀐이름", []) + return doc, note + + +def _refuse_used(kind: str, gone: list[str]) -> None: + """지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 — + 모든 층 훑음(프로젝트 뺌).""" + layer, owner = refs.here() + rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone} + mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone} + found = [] + for what_kind, what in USERS: + for at, path in layers.layer_files(what_kind): + if path.resolve() in mine: + continue + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids}) + if hit: + name = doc.get("일위대가번") or doc.get("이름") or path.stem + found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}") + if found: + raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found)) # ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임 # (키 재사용을 막을 때 함께 고침) -def edit(body: dict[str, Any]) -> dict[str, Any]: - """지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409.""" - doc, 판 = _read() +def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]: + """지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409.""" + doc, 판 = _read(kind) if 판 != str(body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": 판}) - keys = _keys() + raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판}) + keys = _keys(kind) try: - out, note = apply(doc, body, keys) + out, note = apply_many(doc, body, keys) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e - atomic_write_json(store.root() / FILE, out) - return {**view(), "알림": note} + gone = note.get("지운문서") or [] + if gone: + _refuse_used(kind, gone) # 모두 검사 뒤 한 번에 + for key in gone: + (store.root() / kind / f"{key}.json").unlink(missing_ok=True) + atomic_write_json(store.root() / file_of(kind), out) + return {**view(kind), "알림": note} + + +# ponytail: 붙여넣기 도중 막히면 앞서 만든 사본은 뿌리에 남음 — 한 번에 되돌림이 필요하면 그때 +def paste(source: Path, picks: list[dict[str, Any]], dest: Any, kind: str) -> dict[str, Any]: + """복사한 층(`source`) 고른 것 `[{경로, 키?}]` → 지금 층 `dest` 아래 새 키(본뜬 = 원본) · + 폴더는 안의 것 · 아래 폴더까지 · 같은 폴더 이름 「(1)」 → 새 나무 + 알림 `만든문서`.""" + src = refs.where(source) + src_keys = _keys(kind, source) + src_doc, _ = _read(kind, source) + src_folders, src_places = _folders(src_doc, src_keys), _places(src_doc, src_keys) + doc, _ = _read(kind) + keys = _keys(kind) + folders, places = _folders(doc, keys), _places(doc, keys) + try: + to = tree._path(dest) + if to and to not in folders: + raise tree.TreeError(404, f"없는 폴더 「{' / '.join(to)}」") + except tree.TreeError as e: + raise store.StoreError(e.status, e.detail) from e + + def copy(key: str) -> str: + ref = {"층": src[0], "주인": src[1], "키": key} + got = refs.read_ref(ref) + if got is None: + raise store.StoreError(404, f"없는 문서 「{key}」") + return refs.create(kind, refs.carry(refs.origin_mark(got, ref), src))["이름"] + + made: list[str] = [] + for pick in picks: + key = str(pick.get("키") or "") if isinstance(pick, dict) else "" + path = tree._clean(pick.get("경로")) if isinstance(pick, dict) else [] + if key: + if key not in src_keys: + raise store.StoreError(404, f"없는 문서 「{key}」") + made.append(copy(key)) + places[made[-1]] = to + continue + if not path or path not in src_folders: + raise store.StoreError(404, f"없는 폴더 「{' / '.join(path)}」") + top = tree.unique_path([*to, path[-1]], lambda p: p in folders) + folders += [[*top, *f[len(path) :]] for f in src_folders if tree._under(f, path)] + for k, p in sorted(src_places.items()): + if tree._under(p, path): + made.append(copy(k)) + places[made[-1]] = [*top, *p[len(path) :]] + atomic_write_json(store.root() / file_of(kind), _out(folders, places)) + return {**view(kind), "알림": {"op": "paste", "경로": to, "만든문서": made}} diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs index c6a04c4c4..97287d1fa 100644 --- a/resources/tester/helper_m02_precombo_scope.cjs +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -1,5 +1,5 @@ /* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · - * `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 · + * `M02_MasterTemplete_UI_Side_Library.ts` `buildLibraryFolders`(158-1 자유 폴더 · 716-4) 를 트랜스파일해 Node 로 돌림 · * `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 · * stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */ const fs = require("fs"); @@ -31,6 +31,7 @@ global.fetch = async (url) => { const side = loadTs( path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"), ); + const lib = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Library.ts")); api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중 await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람 await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 @@ -38,11 +39,11 @@ global.fetch = async (url) => { await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); const shape = (f) => ({ path: f.path, - count: side.docCount(f), + count: lib.docCount(f), docs: f.docs.map((r) => r.이름), folders: f.folders.map(shape), }); - const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); + const tree = shape(lib.buildLibraryFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5 process.stdout.write(JSON.stringify({ urls, tree, classes })); })(); diff --git a/resources/tester/test_716_4_library_folders.py b/resources/tester/test_716_4_library_folders.py new file mode 100644 index 000000000..08e79a5e3 --- /dev/null +++ b/resources/tester/test_716_4_library_folders.py @@ -0,0 +1,119 @@ +"""서고 나무 폴더(PLAN 716-4) — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기. + +임시 storage. + +`/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 · +폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · +지우는 것끼리 참조는 됨) · +`대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본. +""" + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from M02_MasterTemplete import M02_Precombo_Folders as pre +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_precombo_folders import Tree, client # noqa: F401 — 같은 임시 층 + +MINE = {"층": "personal", "주인": "7/42"} +SK = "/api/m02/layers/personal/library/sangeun" + + +def _sk(c: TestClient, name: str, **doc) -> str: + body = {"문서": {"이름": name, "단위": "㎡", "더한줄": [], **doc}} + res = c.post(SK, json=body) + assert res.status_code == 200, res.text + return res.json()["이름"] + + +def _files(kind: str) -> list[str]: + return sorted(p.stem for p in (layers.personal_dir(7, 42) / kind).glob("*.json")) + + +def _sk_tree(c: TestClient) -> Tree: + t = Tree(c) + t.url = "/api/m02/layers/personal/sangeun-tree" + t.now = c.get(t.url).json() + return t + + +def test_종류마다_따로_자리_파일(client: TestClient): # noqa: F811 + _sk(client, "갑") + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="단가") + sk.do(op="move", 키="SK-000001", 새경로=["단가"]) + assert Tree(client).now["폴더"] == [] # 일위대가 나무는 그대로 + root = layers.personal_dir(7, 42) + assert (root / pre.file_of("sangeun")).is_file() + assert not (root / pre.file_of("precombo")).exists() + assert client.get("/api/m02/layers/personal/labor-tree").status_code == 422 # 모르는 종류 + + +def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨(client: TestClient): # noqa: F811 + a = _sk(client, "바탕") + b = _sk(client, "위", 바탕={"참조": {**MINE, "키": a}}) + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="묶음") + sk.do(op="add-folder", 경로=["묶음"], 이름="안") + sk.do(op="move", 키=a, 새경로=["묶음", "안"]) + sk.do(op="move", 키=b, 새경로=["묶음"]) + got = sk.do(op="delete", 경로=["묶음"]) + assert sorted(got["알림"]["지운문서"]) == [a, b] + assert _files("sangeun") == [] and sk.now["폴더"] == [] + + +def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811 + a = _sk(client, "바탕") + body = {"문서": {"이름": "쓰는 일위대가", "담은로직": [{"참조": {**MINE, "키": a}}]}} + assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200 + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="묶음") + sk.do(op="move", 키=a, 새경로=["묶음"]) + detail = sk.do(409, op="delete", 경로=["묶음"])["detail"] + assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail + assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]] + + +def test_여럿_옮기기_지우기(client: TestClient): # noqa: F811 + t = Tree(client) # 개인 층 일위대가 SC-000001~3 + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=[], 이름="나") + t.do( + op="move", 대상=[{"키": "SC-000001"}, {"키": "SC-000002"}, {"경로": ["나"]}], 새경로=["가"] + ) + assert t.now["폴더"] == [["가"], ["가", "나"]] + assert t.now["자리"] == {"SC-000001": ["가"], "SC-000002": ["가"]} + got = t.do(op="delete", 대상=[{"경로": ["가"]}, {"키": "SC-000001"}, {"키": "SC-000003"}]) + assert sorted(got["알림"]["지운문서"]) == ["SC-000001", "SC-000002", "SC-000003"] + assert _files("precombo") == [] and t.now["폴더"] == [] + + +def test_복사_붙여넣기_새_키_같은_이름_폴더는_1(client: TestClient): # noqa: F811 + t = Tree(client) + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=["가"], 이름="속") + t.do(op="move", 키="SC-000001", 새경로=["가", "속"]) + url = "/api/m02/layers/personal/precombo-tree/paste" + picks = [{"경로": ["가"]}, {"경로": [], "키": "SC-000002"}] + res = client.post(url, json={"from_layer": "personal", "대상": picks, "경로": ["가"]}) + assert res.status_code == 200, res.text + got = res.json() + assert got["알림"]["만든문서"] == ["SC-000004", "SC-000005"] + assert ["가", "가"] in got["폴더"] and ["가", "가", "속"] in got["폴더"] + assert got["자리"]["SC-000004"] == ["가", "가", "속"] and got["자리"]["SC-000005"] == ["가"] + again = client.post(url, json={"from_layer": "personal", "대상": [{"경로": ["가"]}]}).json() + assert ["가(1)"] in again["폴더"] # 같은 이름 = 「(1)」 + doc = client.get("/api/m02/layers/personal/templates/precombo/SC-000005").json()["문서"] + assert doc["이름"] == "나" and doc["본뜬"]["키"] == "SC-000002" + bad = client.post(url, json={"from_layer": "personal", "대상": [{"키": "SC-000099"}]}) + assert bad.status_code == 404 + + +@pytest.mark.parametrize("kind", ["precombo", "sangeun"]) +def test_순수_지우기_알림(kind: str, tmp_path: Path): + doc = {"폴더": [["가"], ["가", "나"]], "자리": {"X-1": ["가", "나"], "X-2": []}} + out, note = pre.apply(doc, {"op": "delete", "경로": ["가"]}, {"X-1", "X-2"}) + assert out == {"폴더": [], "자리": {}} and note["지운문서"] == ["X-1"] + assert pre.file_of(kind) == f"{kind}_폴더.json" diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index c16492b5e..72f75bb11 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -45,7 +45,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] - assert "일위대가 68" in block.json()["detail"] and _keys(client) == [] + assert "단가산출 68" in block.json()["detail"] and _keys(client) == [] assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before @@ -65,3 +65,34 @@ def test_옛_HP_목록에서만_숨김(client): assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == [] assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로 assert old.is_file() and _keys(client) == ["HP-000008"] + + +def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client): + folder = layers.personal_dir(7, 42) + assert _post(client).json()["키"] == "HP-000001" + url = URL.format("personal") + "/HP-000001/sangeun" + got = client.post(url) + assert got.status_code == 200, got.text + got = got.json() + assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0 + docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))] + assert len(docs) == got["블록"] + one = docs[0] + assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산") + assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"] + hp = hs.read(folder, "HP-000001") + no = int(one["본뜬"]["키"].rsplit("단산", 1)[1]) + line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로 + x + for h in hp["호표"] + for x in h["줄"] + if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no + ) + price = one["더한줄"][0]["고정"]["단가"] + assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price) + again = client.post(url).json() # 다시 = 그대로(겹침 없음) + assert again["만듦"] == [] and again["그대로"] == got["블록"] + res = client.delete(URL.format("personal") + "/HP-000001").json() + assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록 + assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음 + assert _post(client).status_code == 409 diff --git a/resources/tester/test_719_1_sk_freeze.py b/resources/tester/test_719_1_sk_freeze.py new file mode 100644 index 000000000..bcaf387f8 --- /dev/null +++ b/resources/tester/test_719_1_sk_freeze.py @@ -0,0 +1,120 @@ +"""719-1 프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(`M02_Item_Sangeun` · `M02_Item_Source` · +`M02_Item_Prices` · `B09_Estimation_Bill_Library`) — 임시 storage(견본 층 · 실제 M01 읽기만). + +구조물 사본 · B09 서고 사본 셈 = M02 층 SK 셈 · 굳힌 줄은 사본 `단가.json` 로직 줄 · 원본 SK 를 +지워도 같은 값 · 같은 SK = 같은 굳힌 키(다시 복사 · SK 고친 뒤도) · 원본 층 지문 그대로 · +서고 manifest 원본판 = SK 바뀌면 다름. +""" + +# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀 +import hashlib +import json + +import pytest + +from B09_Estimation import B09_Estimation_Bill_Library as library +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Item_Sangeun as sk +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_715_sangeun_store import PCT, _sk +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +COVER = {"labor:LB000036#0": {"qty": 0.5}} +ADD = [{"종류": "인력", "요소": "LB000002", "수량": 0.1}] + + +def _print(folder): + """층 폴더 지문 — 파일 이름 · 바이트.""" + out = hashlib.sha256() + for path in sorted(p for p in folder.rglob("*") if p.is_file()): + out.update(str(path.relative_to(folder)).encode() + path.read_bytes()) + return out.hexdigest() + + +def _top(ref, qty=2): + with store.at(ref["folder"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref["ref"], "수량": {"손": qty}}]}) + return doc, labor.calc(doc, {})["줄"][0] + + +def _mine(world): + key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개=COVER, 더한줄=ADD) + return key, {"folder": world["personal"], "ref": {**MINE, "키": key}} + + +def test_구조물_사본_굳힌_줄_값_같음_원본_지워도(world): + key, ref = _mine(world) + doc, live = _top(ref) + assert not live["까닭"] and live["기준"]["계"] > 0 + before = _print(world["personal"]) + rows = source.labor_doc_rows(doc) + bundle = source.price_bundle(rows, world["personal"]) + assert _print(world["personal"]) == before # 원본 층 안 건드림 + rid = refs.ref_id(ref["ref"]) + frozen = rows["참조사본"][rid] + fk = frozen["바탕"]["로직"] + assert fk == sk.frozen_key(rid, set()) and fk.startswith("GX9") + assert frozen["덮개"] == {} and frozen["더한줄"] == [] and frozen["뺀줄"] == [] + assert frozen["원본"]["덮개"]["labor:LB000036#0"]["qty"] == 0.5 # 고친 근거 + assert [r["키"] for r in bundle[sk.FILE]["줄"]] == [fk] + json.dumps(rows["참조사본"], ensure_ascii=False) # 사본 파일로 쓸 수 있는 꼴(Decimal 없음) + (world["personal"] / "sangeun" / f"{key}.json").unlink() # 원본이 사라져도 사본만 봄 + got = prices.calc(rows, json.loads(json.dumps(bundle, ensure_ascii=False)), {}, {}, None) + line = got["줄"][0] + assert not line["까닭"] + for part in ("노무비", "재료비", "경비", "계"): + assert line["기준"][part] == pytest.approx(live["기준"][part], rel=1e-9) + assert line["반영"]["계"] == pytest.approx(live["반영"]["계"], rel=1e-9) + + +def test_같은_SK_같은_굳힌_키_다른_SK_다른_키(world): + key, ref = _mine(world) + doc, _ = _top(ref) + rid = refs.ref_id(ref["ref"]) + first = source.labor_doc_rows(doc)["참조사본"][rid]["바탕"]["로직"] + with store.at(world["personal"]): # SK 를 고쳐도 굳힌 키 그대로(B09 덮개 짝 지킴) + now = store.read("sangeun", key) + store.write("sangeun", key, now["판"], {**now["문서"], "덮개": {}}) + again = source.labor_doc_rows(doc)["참조사본"][rid] + assert again["바탕"]["로직"] == first and again["원본"]["덮개"] == {} + other = refs.ref_id({"층": "company", "주인": "7", "키": key}) # 같은 번호 다른 층 + assert sk.frozen_key(other, set()) != first + taken = {first} + assert sk.frozen_key(rid, taken) != first and len(taken) == 2 # 겹치면 다음 번호 + + +def test_B09_서고_사본_값_같음_원본판(world, tmp_path): + _, ref = _mine(world) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]}, + )["이름"] + _, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1) + before = _print(world["personal"]) + root = tmp_path / "proj" + made = library.import_copy(root, "precombo", "personal", "7/42", sc) + assert made["만듦"] and _print(world["personal"]) == before + folder = library.library_dir(root) / made["폴더"] + read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")} + got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0] + assert not got["까닭"] + assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9) + mark = read["manifest.json"]["원본판"] + built = library._build("precombo", "personal", "7/42", sc)[0] + assert library.source_version(built) == mark # 다시 모아도 같은 판 + again = library.import_copy(root, "precombo", "personal", "7/42", sc) + assert again == {"폴더": made["폴더"], "만듦": False} + with store.at(world["personal"]): # SK 고침 → 원본판 다름 + now = store.read("sangeun", ref["ref"]["키"]) + store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}}) + assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark + + +def test_SK_없는_사본_그대로(): + """SK 없는 참조사본 = 받은 묶음 그대로(기존 사본 · 공사 금액 전후 같음).""" + copies = {"x": {"종류": "사전조합", "담은로직": []}} + assert sk.freeze(copies) is copies and sk.rows(copies) == [] and sk.materials(copies) == [] diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 1714b7fe0..892adfe59 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -338,9 +338,10 @@ def test_좌측_판_자리와_권한(): side = _text("M02_MasterTemplete_UI_Side.ts") assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] - # 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 불러오기 + # 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 일위대가 불러오기 › 도면 양식 order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),") assert [host.index(k) for k in order] == sorted(host.index(k) for k in order) + assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?") assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") @@ -355,7 +356,7 @@ def test_좌측_판_자리와_권한(): assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만 assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인 - assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬 + assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크 def test_로직_추가_한_곳과_파일_규칙(): diff --git a/resources/tester/test_m02_precombo_folders.py b/resources/tester/test_m02_precombo_folders.py index 3c19a088f..b3b4e98c2 100644 --- a/resources/tester/test_m02_precombo_folders.py +++ b/resources/tester/test_m02_precombo_folders.py @@ -1,7 +1,7 @@ """일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1 · `M02_Precombo_Folders`) — 임시 storage. 새 폴더(깊이 자유) · 문서 옮기기(파일은 제자리 · 자리만) · 폴더 이름 바꾸기 · -옮기기(아래 자리 따라감) · 지우기(빈 폴더만 · 문서 있으면 409 까닭) · 판 409 · +옮기기(아래 자리 따라감) · 지우기(안에 든 문서째 · 716-4) · 판 409 · 층(마스터 · 회사 · 개인)마다 따로 · 문서 수(양식 목록) 그대로 · 쓰기 권한 = 층 규칙(마스터 403). """ @@ -88,7 +88,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"], "SC-000002": ["옹벽"]} t.do(op="move", 키="SC-000002", 새경로=[]) # 뿌리로 = 자리 지움 assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"]} - assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 사전조합") + assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 문서") assert t.do(404, op="move", 키="SC-000001", 새경로=["없음"]) personal = layers.personal_dir(7, 42) assert sorted(p.name for p in (personal / "precombo").iterdir()) == [ @@ -97,7 +97,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te "SC-000003.json", ] assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] # 자리 파일은 목록에 안 섞임 - assert (personal / pre.FILE).is_file() + assert (personal / pre.file_of("precombo")).is_file() def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: TestClient): @@ -117,17 +117,16 @@ def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: assert t.now["알림"]["바뀐이름"] == ["나(1)"] and t.now["자리"]["SC-000001"] == ["나(1)"] -def test_지우기는_빈_폴더만_문서가_있으면_까닭(client: TestClient): +def test_지우기는_안에_든_문서째(client: TestClient): t = Tree(client) t.do(op="add-folder", 경로=[], 이름="가") t.do(op="add-folder", 경로=["가"], 이름="나") t.do(op="add-folder", 경로=["가", "나"], 이름="다") t.do(op="move", 키="SC-000003", 새경로=["가", "나", "다"]) - assert "사전조합 1개" in t.do(409, op="delete", 경로=["가"])["detail"] - t.do(op="move", 키="SC-000003", 새경로=[]) - t.do(op="delete", 경로=["가"]) # 빈 아래 폴더까지 + got = t.do(op="delete", 경로=["가"]) # 아래 폴더 · 문서까지(716-4) + assert got["알림"]["지운문서"] == ["SC-000003"] assert t.now["폴더"] == [] and t.now["자리"] == {} - assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] + assert _docs(client) == ["SC-000001", "SC-000002"] def test_판이_다르면_409_고침_안_함(client: TestClient): diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 081da91c9..1271ecae6 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -84,7 +84,8 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") assert "PRECOMBO_TITLE" not in side # 716-3 — 제목은 locale 키 「일위대가」 assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side - assert "listTemplates(at.layer, null, at.owner)" in side + lib_side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌 + assert "listTemplates(at.layer, null, at.owner)" in lib_side main = _text("M02_MasterTemplete_UI_Main.ts") assert ( "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);" @@ -100,23 +101,26 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): - side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + pre_side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌 for gone in ("groupRows", "fetchStructureDef", "Lib_Unsorted", "shape"): - assert gone not in side, gone - assert "fetchPrecomboTree(at.layer, at.owner)" in side - assert "editPrecomboTree(at.layer, body, view.판, at.owner)" in side + assert gone not in side and gone not in pre_side, gone + assert "fetchPrecomboTree(at.layer, at.owner, kind)" in side + assert "editPrecomboTree(at.layer, body, 판, at.owner, kind)" in side menu = side.split("const menuAt = (")[1].split("\n };")[0] - for label in ('L("M02_TreeAddFolder")', 'tc("Lib_NewHere")', 'L("M02_TreeRename")'): + for label in ('L("M02_TreeAddFolder")', "opt.text.newHere", 'L("M02_TreeRename")'): assert label in menu, label - assert 'L("M02_Delete"), () => void remove(path, count)' in menu + assert 'item(L("M02_Delete"), () => void remove([target]))' in menu assert 'opt.scope().canWrite ? undefined : tc("ReadOnly")' in menu # 읽기만 층 = 흐림 remove = side.split("const remove = async (")[1].split("\n };")[0] - assert 'if (count) return void showToast(tc("Lib_FolderFull")' in remove # 빈 폴더만 + assert "showConfirmDialog(ask" in remove and 'op: "delete", 대상:' in remove # 안에 든 것째 + assert 'newHere: tc("Lib_NewHere")' in pre_side + assert '({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] })' in pre_side make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0] - assert 'const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };' in make + assert "{ 문서: opt.blank(name) }" in make assert 'if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });' in make assert "attachTreeDrag({" in side and "if (canWrite) drag.arm(row, node.id);" in side - assert "openTreeMenu(event, menuAt([], 0));" in side # 빈 자리 = 뿌리 메뉴 + assert "openTreeMenu(event, menuAt([]));" in side # 빈 자리 = 뿌리 메뉴 assert "open: toggled.get(id) ?? count > 0," in side lib = _text("M02_MasterTemplete_Library_List.ts") assert "export const nameDialog = (" in lib