Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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 "")
|
||||
|
||||
@@ -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
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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) — 원본 목록이 바뀌어도 사본 그대로
|
||||
|
||||
@@ -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) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) ·
|
||||
`줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함)."""
|
||||
|
||||
@@ -150,7 +150,7 @@ export type LibraryBody =
|
||||
{ 문서: Record<string, unknown> } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } };
|
||||
export const newLibrary = (
|
||||
layer: Layer,
|
||||
kind: "precombo",
|
||||
kind: LibraryKind,
|
||||
body: LibraryBody,
|
||||
owner?: string | null,
|
||||
): Promise<TemplateInfo> =>
|
||||
@@ -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<string, string[]>;
|
||||
알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] };
|
||||
}
|
||||
export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise<PrecomboTree> =>
|
||||
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<PrecomboTree> => call(libTree(kind, layer, owner));
|
||||
export const editPrecomboTree = (
|
||||
layer: Layer,
|
||||
edit: Pick<TreeEdit, "경로" | "이름" | "새경로" | "키"> & {
|
||||
op: "add-folder" | "rename" | "move" | "delete";
|
||||
대상?: TreeRef[];
|
||||
},
|
||||
판: string,
|
||||
owner: string | null,
|
||||
kind: LibraryKind = "precombo",
|
||||
): Promise<PrecomboTree> =>
|
||||
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<PrecomboTree> =>
|
||||
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) ---
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
|
||||
@@ -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<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 그 층 목록표 고름 — 문서 열기의 바탕(종류 · 층 · 주인 · 권한) */
|
||||
list: (scope: LibraryScope) => Selection;
|
||||
/** [여기에 새 …] 문서 몸 */
|
||||
blank: (name: string) => Record<string, unknown>;
|
||||
/** 빈 나무 글 · 새 문서 메뉴 글 */
|
||||
text: { none: string; newHere: string };
|
||||
/** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */
|
||||
more?: (key: string, name: string) => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export interface LibraryHandle {
|
||||
body: HTMLElement;
|
||||
/** 고름 표시만 */
|
||||
paint: () => void;
|
||||
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<PrecomboTree, "폴더" | "자리">,
|
||||
): 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<typeof editPrecomboTree>[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<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
const counts = new Map<string, number>();
|
||||
let picked = new Set<string>();
|
||||
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<void> => {
|
||||
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<PrecomboTree>): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<HTMLElement>("[data-node]")]
|
||||
.filter((b) => !b.closest("[hidden]"))
|
||||
.map((b) => b.dataset.node ?? "");
|
||||
const setPicked = (next: Set<string>, at: string | null): void => {
|
||||
picked = next;
|
||||
anchor = at;
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>("[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<HTMLElement>("[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<void> => {
|
||||
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() };
|
||||
}
|
||||
@@ -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<Kind, "precombo">;
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
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<PrecomboTree, "폴더" | "자리">,
|
||||
): 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<typeof editPrecomboTree>[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<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
let rows: Row[] = [];
|
||||
let view: PrecomboTree | null = null;
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const open = async (row: Row): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 }));
|
||||
})();
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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_로직_추가_한_곳과_파일_규칙():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user