diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cca13de9d..9f9e3ae48 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -62,6 +62,8 @@ export const STATE_REGISTRY = { /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, + /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ + "m02-card-pills": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 87ec2cc7b..d452f6b95 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; import type { ConditionOptions } from "./spreadsheet_condition"; import type { LinkOptions, Links } from "./spreadsheet_links"; +import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; @@ -48,6 +49,7 @@ import type { export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types"; export type { LinkItem, LinkOptions } from "./spreadsheet_links"; +export type { LinkInfo } from "./spreadsheet_link_set"; export interface SpreadsheetOptions { /** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */ @@ -76,6 +78,9 @@ export interface SpreadsheetHandle { recalc(): CalcValues; /** 알약 키 → 연결 칸 값(빈 칸 0) */ getLinkedValues(): Record; + /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · + * 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo): boolean; destroy(): void; } @@ -288,6 +293,14 @@ export function createSpreadsheet( } return out; }, + setLink(key, cell, info) { + if (readOnly) return false; + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + if (command === false) return false; + if (command) dispatch(command); + loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts new file mode 100644 index 000000000..8f740992c --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * spreadsheet_link_set.ts (주인 D) + * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · + * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * ========================================================================== */ + +import { parseA1, toA1 } from "./spreadsheet_address"; +import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types"; + +/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */ +export interface LinkInfo { + 이름?: string; + 단위?: string; + 자재?: LinkMaterial; +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +export function linkCommand( + book: Workbook, + sheetId: string, + key: string, + cell: string | null, + info: LinkInfo = {}, +): Command | null | false { + const at = cell === null ? null : parseA1(cell.trim().toUpperCase()); + if (cell !== null && !at) return false; + const a1 = at ? toA1(at.r, at.c) : null; + const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key); + const home = book.시트.find((s) => s.id === sheetId); + if (a1 && !home) return false; + if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 + const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null; + if (one) { + const 이름 = info.이름 ?? old?.이름; + const 단위 = info.단위 ?? old?.단위; + const 자재 = info.자재 ?? old?.자재; + if (이름) one.이름 = 이름; + if (단위) one.단위 = 단위; + if (자재) one.자재 = 자재; + } + const commands: Command[] = []; + for (const s of book.시트) { + const was = s.연결 ?? []; + const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1)); + const next = s === home && one ? [...rest, one] : rest; + if (next.length === was.length && next.every((l, i) => l === was[i])) continue; + const copy = structuredClone(s); + if (next.length) copy.연결 = next; + else delete copy.연결; + commands.push({ 종류: "시트통째", 시트: copy }); + } + if (!commands.length) return null; + return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; +} diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index c704de769..cca474f97 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -217,7 +217,37 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) out += cm.check_links(whole) - return out + cm.mcb.check_all(files, whole) + return out + cm.mcb.check_all(files, whole) + labor_errors(whole) + + +def _labor_files() -> list[tuple[str, Path]]: + """모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일).""" + from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 + + root = layers.storage_root() + found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))] + found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))] + personal = sorted(root.glob("*/*/templates/labor/*.json")) + return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal] + + +def labor_errors(whole) -> list[str]: + """일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림. + 전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌.""" + out = [] + for where, path in _labor_files(): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem + for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []: + key = str(one.get("로직") or "") if isinstance(one, dict) else "" + try: + whole.logic(key) + except cm.mf.FormulaError: + out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") + return out def warm(folder: Path) -> threading.Thread: diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index eb07d9209..c8eed5a3d 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -150,7 +150,9 @@ def combo(key: str) -> dict: } -_PER = re.compile(r"원\s*/\s*(\d*\.?\d*)\s*([^\s·(]+)") +_PER = re.compile( + r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)" +) # 「원/1,000본」 = 배 1000 _TAIL = re.compile(r"\s*\([^()]*\)\s*$") @@ -185,7 +187,7 @@ def combo_pills(row: dict) -> dict: (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} ) pill["로직"].append(code) - times = per[0][0] + times = per[0][0].replace(",", "") if len(per) == 1 and times and float(times) != 1: pill["배"] = float(times) if "." in times else int(times) return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []} diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index e6f336de4..3514c37be 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -53,6 +53,7 @@ KINDS = { "입력연결": LINK_FILE, "입력값": VALUES_FILE, "고른자재": PICKS_FILE, + "일위대가": LABOR_FILE, # 읽기만 — 일위대가는 원본 층에서 고침(39-1c) } #: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출) SKELETON = {"도면": "structure", "면적": "area"} @@ -169,6 +170,8 @@ def _check_links(content: Any) -> None: def check(kind: str, content: Any) -> None: + if kind == "일위대가": + raise ValueError("일위대가는 원본(마스터 · 회사 · 개인) 층에서 고침 — 프로젝트는 넣을 값만") if kind == "입력값": _check_values(content) elif kind == "고른자재": @@ -251,7 +254,10 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]: before = {name: read_json(target / file) for name, file in KINDS.items()} files = build_snapshot(manifest["원본"]) labor = files.get(LABOR_FILE) or {} - files[VALUES_FILE] = _kept_values(values, labor) + seeded = files[VALUES_FILE] # 새 원본의 첫 값 — 프로젝트에서 넣은 값이 같은 키면 이김 + kept_values = _kept_values(values, labor) + for logic, inputs in kept_values.items(): + seeded[logic] = {**seeded.get(logic, {}), **inputs} files[PICKS_FILE] = _kept_picks(picks, labor) for old in target.glob("*.json"): # 원본에서 빠진 파일(도면 없앰 등)은 지움 if old.name != MANIFEST_NAME and old.name not in files: @@ -266,7 +272,7 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]: source = version_of(source_files(files)) stamp = now_text() # 원본을 다시 가져왔으니 시트 고침은 없음 — 살린 입력값 · 고른 자재는 `값있음` 으로 따로 알림 - kept = bool(files[VALUES_FILE] or files[PICKS_FILE]) + kept = bool(kept_values or files[PICKS_FILE]) fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp, "고침": False} fresh["값있음"] = kept fresh.pop("고친날", None) @@ -341,4 +347,5 @@ def calc(project_root: str | Path, folder: str) -> dict[str, Any]: read_json(target / PRICE_FILE) or {}, read_json(target / VALUES_FILE) or {}, read_json(target / PICKS_FILE) or {}, + read_json(target / KINDS["면적"]), ) diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index c7ab8fee0..a5ae96b66 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -8,9 +8,12 @@ 원문번호 틀의 `{입력}` 은 그 입력의 고르기 값으로 모두 끼워 모음(고르기가 없으면 그 테이블 전부 · 자재품목만은 너무 커서 뺌). 줄은 마스터 파일 차례 그대로 담음 — 고르기의 「첫 줄」 이 같게. +일위대가 자재(MT · 39-1c)도 그 줄(+ 준용 · 연결)을 담음. + `단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가 그대로 읽는 꼴. -계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` 만 씀 — 마스터 현재값을 읽지 않음. +계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터 +현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`). """ from __future__ import annotations @@ -200,12 +203,14 @@ class _Collector: self.pick(row, item["요소"], item.get("단위")) -def collect(logic_keys: list[str]) -> dict[str, Any]: - """담은 로직의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" +def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]: + """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" files, whole = master() collector = _Collector(whole) for key in logic_keys: collector.logic(key) + for code in materials or []: + collector.add(code) out: dict[str, Any] = {} for name, data in files.items(): slot = m01.items_key(data) @@ -256,9 +261,17 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) def calc( - labor: dict[str, Any], prices: dict[str, Any], values: dict[str, Any], picks: dict[str, Any] + labor: dict[str, Any], + prices: dict[str, Any], + values: dict[str, Any], + picks: dict[str, Any], + area: Any = None, ) -> dict[str, Any]: - """복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합.""" + """복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합 + (수량 1 기준). + + 일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 — + 수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌).""" snapshot = load_prices(prices) gaps = missing_inputs(snapshot, labor, values) blocked = {gap["로직"] for gap in gaps} @@ -293,4 +306,33 @@ def calc( "계": got["계"], } ) - return to_json({"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}) + out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck} + return to_json({**out, **_reflected(snapshot, labor, lines, area)}) + + +def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict: + """줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것.""" + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + doc = labor.get("줄") or {} + pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []} + base = [] + for line in lines: + pill = pill_of.get(line["로직"]) or {} + money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None + head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1} + base.append({**head, "분모": pill.get("단위"), "기준": money}) + mats = [] + for one in doc.get("자재") or []: + code = str(one.get("코드") or "") + try: + price, _ = mm.element(snapshot, code, {}, one.get("단위") or None) + except mf.FormulaError: + price = None + mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price}) + settled = labor_doc.settle( + to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area) + ) + for line, done in zip(lines, settled["줄"]): + line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")}) + return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]} diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 3b9dfd3d9..1a32321ed 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -6,7 +6,8 @@ [저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤: - 항목을 지정한 구조물마다 원본 층에서 내용을 복사(`M02_Item_Source.build_source`) + 프로젝트 값 - `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · `manifest.json` + `입력값.json`(첫 값 = 일위대가 문서 입력값 · 확인 거짓 · 자동값은 안 적음) · `고른자재.json` · + `manifest.json` - 이미 있는 복사본은 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌거나 지워져도 그대로 ([재적용]은 사용자가) - 지정을 다른 항목으로 바꾸면 새로 가져옴 · 구조물을 지우거나 지정을 풀면 복사본을 지움 — @@ -30,6 +31,7 @@ from common_util.common_util_json import atomic_write_json from common_util.common_util_summary_item import snapshot_folder_name from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Item_Source import ( + LABOR_FILE, LINK_FILE, PRICE_FILE, build_source, @@ -85,9 +87,22 @@ def origin_of(item: dict[str, Any]) -> dict[str, Any]: return {**item, "경로": path} +def seed_values(labor: Any) -> dict[str, Any]: + """일위대가 문서 입력값 → 프로젝트 입력값 첫 값 `{로직: {입력: {값, 확인: false}}}`(39-1c).""" + doc = labor.get("줄") if isinstance(labor, dict) else None + out: dict[str, Any] = {} + for line in (doc or {}).get("담은로직") or []: + given = line.get("입력값") if isinstance(line, dict) else None + if isinstance(given, dict) and given and line.get("로직"): + out[str(line["로직"])] = {k: {"값": v, "확인": False} for k, v in given.items()} + return out + + def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: - """원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" - return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}} + """원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면 + LookupError.""" + files = build_source(item) + return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}} def source_files(files: dict[str, Any]) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 8e7d1d64e..aee15cf09 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -2,9 +2,10 @@ 모으는 것 = `{파일 이름: 내용}`: `집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · - `일위대가.json` UA 줄 + 담은 로직 줄(M01 시스템) · + `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄) + + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) · `입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) · - `단가.json` 계산에 닿는 마스터 줄 전부 + `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) `원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. 프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. 원본 층 자리 = `M02_Template_Layers` 경로 · 일위대가 · 단가는 M01 시스템 것. @@ -22,6 +23,7 @@ from M02_MasterTemplete import M02_Template_Layers as layers TABLE_NAME = "구조물집계표" #: 열 id → 스냅샷 파일(구조물 도면 · 면적산출) PART_FILES = (("structure", "도면.json"), ("area", "면적.json")) +LABOR_KIND = "labor" # 열 id → 일위대가 문서(39-1c) — 스냅샷은 `LABOR_FILE` 에 감싸서 LINK_FILE = "입력연결.json" PRICE_FILE = "단가.json" LABOR_FILE = "일위대가.json" @@ -79,15 +81,27 @@ def version_of(content: Any) -> str: def labor_rows(ua_key: str) -> dict[str, Any] | None: - """조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 - `빠진로직`).""" - from M01_MasterData import M01_MasterData_Store as m01 + """옛 조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄 — labor 문서가 없는 열 + (옮기기 전).""" from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos ua = combos.find(ua_key) - if ua is None: - return None - wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")] + return None if ua is None else logic_rows(ua) + + +def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]: + """일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 — + 복사본 계산이 마스터를 안 읽게).""" + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + return {**logic_rows(doc), "알약": labor_doc.pills(doc)} + + +def logic_rows(row: dict[str, Any]) -> dict[str, Any]: + """`{줄: row, 로직: 담은 로직 줄(M01 시스템), 빠진로직: 없는 로직 키}`.""" + from M01_MasterData import M01_MasterData_Store as m01 + + wanted = [str(entry.get("로직")) for entry in row.get("담은로직") or [] if entry.get("로직")] found: dict[str, dict[str, Any]] = {} raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음 for key in wanted: @@ -97,11 +111,11 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None: continue if file not in raw: raw[file] = read_json(Path(m01.FOLDER) / file) - row = row_of(raw[file], key) - if row is not None: - found[key] = row + got = row_of(raw[file], key) + if got is not None: + found[key] = got return { - "줄": ua, + "줄": row, "로직": [found[key] for key in wanted if key in found], "빠진로직": [key for key in wanted if key not in found], } @@ -137,10 +151,16 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: part = read_json(root / kind / f"{column_id}.json") if column_id else None if part is not None: files[name] = part - labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None + doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None + if isinstance(doc, dict): + labor = labor_doc_rows(doc) + else: + labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: files[LABOR_FILE] = labor files[LINK_FILE] = input_links(root, column_id) logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] - files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys)) if logic_keys else {} + materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []] + wanted = logic_keys + [code for code in materials if code] + files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} return files diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py new file mode 100644 index 000000000..60be6edbe --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -0,0 +1,309 @@ +"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). + +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}], +자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). +수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` +(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). +금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 +반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. +짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — +거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +""" + +from __future__ import annotations + +import json +from decimal import Decimal, InvalidOperation +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mf = m01.cm.mf +mm = mf.mp.mm +COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 +MATERIAL_HEAD = "자재|" +_MAT_COLS = ("코드", "이름", "규격", "단위") + + +def _bad(text: str) -> store.StoreError: + return store.StoreError(400, text) + + +def _number(raw: Any) -> Decimal | None: + """칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌).""" + if raw is None or isinstance(raw, bool): + return None + try: + got = Decimal(str(raw).replace(",", "").strip()) + except InvalidOperation: + return None + return got if got.is_finite() else None + + +def _specs(logic: dict) -> list[dict]: + """로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」.""" + if isinstance(logic.get("흐름"), dict): + return mf.inputs_of(logic) + return list(logic.get("입력") or []) + + +def _qty(value: Any, where: str) -> dict | None: + if value is None: + return None + hand = _number(value.get("손")) if isinstance(value, dict) else None + if hand is None: + raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}") + return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)} + + +# ── 정리 · 검사 ─────────────────────────────────────────────────────── +def clean(doc: Any) -> dict[str, Any]: + """서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 · + 모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것.""" + if not isinstance(doc, dict): + raise _bad("일위대가 문서가 묶음 아님") + whole = m01.loaded()[1] + lines, seen = [], set() + for at, one in enumerate(doc.get("담은로직") or [], start=1): + where = f"담은 로직 {at}째" + if not isinstance(one, dict): + raise _bad(f"{where} · 묶음 아님") + code = str(one.get("로직") or "") + try: + logic = whole.logic(code) + except mf.FormulaError as e: + raise _bad(f"{where} · {e}") from e + if code in seen: + raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」") + seen.add(code) + names = {str(spec.get("이름")) for spec in _specs(logic)} + given = one.get("입력값") or {} + if not isinstance(given, dict): + raise _bad(f"{where} · 입력값이 묶음 아님") + memo = one.get("메모") + lines.append( + { + "로직": code, + "메모": str(memo) if memo else None, + "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, + "수량": _qty(one.get("수량"), where), + } + ) + mats, codes = [], set() + for at, one in enumerate(doc.get("자재") or [], start=1): + where = f"자재 {at}째" + code = str(one.get("코드") or "") if isinstance(one, dict) else "" + try: + row = whole.get(code) if code[:2] == mm.MARKET else None + except mf.FormulaError: + row = None + if row is None: + raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」") + if code in codes: + raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」") + codes.add(code) + mats.append( + { + "코드": code, + **{k: row.get(k) or "" for k in _MAT_COLS[1:]}, + "수량": _qty(one.get("수량"), where), + } + ) + out = { + "종류": "일위대가", + "판": doc.get("판") or 1, + "열": doc.get("열"), + "단위": str(doc["단위"]) if doc.get("단위") else None, + "본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None, + "담은로직": lines, + "자재": mats, + } + if doc.get("일위대가번"): + out["일위대가번"] = doc["일위대가번"] + return out + + +def from_combo(key: str) -> dict[str, Any]: + """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — + 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). + 없는 조합은 404.""" + from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + + row = combos.find(key) + if row is None: + raise store.StoreError(404, f"없는 일위대가 조합 「{key}」") + layer, owner = combos.where(key) or (None, None) + return { + "단위": row.get("단위"), + "본뜬": {"층": layer, "주인": owner, "키": key}, + "담은로직": [ + {"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None} + for one in row.get("담은로직") or [] + if isinstance(one, dict) + ], + "자재": [], + } + + +# ── 알약 · 면적 값 ──────────────────────────────────────────────────── +def material_key(code: str) -> str: + return f"{MATERIAL_HEAD}{code}" + + +def pills(doc: dict) -> list[dict[str, Any]]: + """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」.""" + logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + mats = [ + { + "키": material_key(m["코드"]), + "이름": m.get("이름") or m["코드"], + "단위": m.get("단위") or "", + "자재": {k: m.get(k) or "" for k in _MAT_COLS}, + } + for m in doc.get("자재") or [] + ] + return [*logic, *mats] + + +def area_values(area: Any) -> dict[str, Decimal]: + """면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만.""" + out: dict[str, Decimal] = {} + if not isinstance(area, dict): + return out + calc = area.get("계산값") or {} + for sheet in area.get("시트") or []: + cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {} + for link in sheet.get("연결") or []: + at = link.get("칸") + raw = (got.get(at) or {}).get("수") + if raw is None: + raw = (cells.get(at) or {}).get("값") + value = _number(raw) + if value is not None and link.get("키"): + out[str(link["키"])] = value + return out + + +def _area_of(column: Any) -> dict[str, Decimal]: + """지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것.""" + try: + path = store._path("area", str(column or "")) + except store.StoreError: + return {} + return area_values(json.loads(path.read_bytes())) if path.is_file() else {} + + +# ── 계산 ────────────────────────────────────────────────────────────── +def _money(value: Any) -> dict[str, Decimal] | None: + if not isinstance(value, dict): + return None + return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")} + + +def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None: + """반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None.""" + if qty is None: + return None + return {k: base[k] * qty / times for k in (*COSTS, "계")} + + +def _sum(rows: list[dict | None]) -> dict[str, Decimal]: + return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")} + + +def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]: + """(수량, 출처) — 손 · 칸(알약 키) · 없음.""" + if isinstance(hand, dict) and _number(hand.get("손")) is not None: + return _number(hand["손"]), "손" + if key and key in given: + return given[key], "칸" + return None, "없음" + + +def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: + """기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시 + (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비).""" + have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} + hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []} + mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} + zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) + lines, mats, bases, reflects = [], [], [], [] + for line in result.get("줄") or []: + qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have) + base = _money(line.get("기준")) + done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None + bases.append(base) + reflects.append(done) + lines.append({**line, "수량": qty, "수량출처": source, "반영": done}) + for one in result.get("자재") or []: + qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) + price = _number(one.get("기준단가")) + cost = price * qty if price is not None and qty is not None else None + bases.append({**zero, "재료비": price, "계": price} if price is not None else None) + reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) + mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) + return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) + + +def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: + """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — + 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" + whole = m01.loaded()[1] + by_logic = { + code: pill + for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + for code in pill["로직"] + } + lines = [] + for one in doc.get("담은로직") or []: + code = one["로직"] + pill = by_logic.get(code) or {} + line = { + "로직": code, + "알약": pill.get("키"), + "분모": pill.get("단위"), + "배": pill.get("배") or 1, + } + try: + logic = whole.logic(code) + auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} + got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})}) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__}) + continue + if "결과" in got: # 돈 아닌 로직 — 반영 없음 + lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) + continue + lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) + mats = [] + for one in doc.get("자재") or []: + try: + price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) + except mf.FormulaError as e: + price, why = None, str(e) + else: + why = None if price is not None else "단가 없음" + mats.append( + {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} + ) + have = _area_of(doc.get("열")) if given is None else given + return settle(_json({"줄": lines, "자재": mats}), doc, have) + + +def _json(value: Any) -> Any: + """Decimal → JSON 수(정수면 int).""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_json(v) for v in value] + if isinstance(value, dict): + return {k: _json(v) for k, v in value.items()} + return value + + +def server(doc: Any) -> dict[str, Any]: + """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림).""" + got = clean(doc) + return {**got, "계산값": calc(got)} diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py new file mode 100644 index 000000000..068c64b33 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -0,0 +1,94 @@ +"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김). + +담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은 +그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 = +`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과 +옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + +_SPEC = ("이름", "설명", "단위", "고르기", "범위") + + +def _num(value: Any) -> Any: + """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_num(v) for v in value] + if isinstance(value, dict): + return {k: _num(v) for k, v in value.items()} + return value + + +def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: + """([로직 줄 …], 빠진 로직 키) — 키 차례 그대로.""" + whole = m01.loaded()[1] + found, missing = [], [] + for code in codes: + try: + found.append(whole.logic(code)) + except combo_store.cm.mf.FormulaError: + missing.append(code) + return found, missing + + +def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: + """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" + if not ua: + return None, [], [] + row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c) + if row is None: + return None, [], [] + found, missing = _logics(combo_store.mcb.logic_keys(row)) + return row, found, missing + + +def _listed(logics: list[dict]) -> list[dict[str, Any]]: + """로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`.""" + whole = m01.loaded()[1] + out = [] + for logic in logics: + auto = m01.mau.values(whole, logic) + specs = [] + for spec in logic.get("입력") or []: + one = {k: spec[k] for k in _SPEC if k in spec} + one.setdefault("설명", None) + one.setdefault("단위", None) + specs.append({**one, "자동값": auto.get(spec["이름"])}) + out.append( + { + "로직": logic.get("키"), + "이름": logic.get("이름"), + "결과단위": logic.get("결과단위"), + "입력": specs, + } + ) + return out + + +def logic_inputs(codes: list[str]) -> dict[str, Any]: + """일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)""" + found, missing = _logics(list(dict.fromkeys(c for c in codes if c))) + return _num({"로직": _listed(found), "빠진로직": missing}) + + +def inputs_of(ua: str | None) -> dict[str, Any]: + """옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음.""" + row, logics, missing = combo_logics(ua) + return _num( + { + "일위대가": ua or None, + "이름": row.get("이름") if row else None, + "로직": _listed(logics), + "빠진로직": missing, + } + ) diff --git a/M02_MasterTemplete/M02_Labor_Migrate.py b/M02_MasterTemplete/M02_Labor_Migrate.py new file mode 100644 index 000000000..37527b31b --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Migrate.py @@ -0,0 +1,90 @@ +"""옛 일위대가 조합 → 구조물 열마다 일위대가 문서 — 한 번 돌리는 도구(PLAN 39-1b · 설계_정함 1장). + +층마다 구조물 열마다 `labor/<열id>.json`: + - 열 `일위대가` 키가 있으면 그 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · + 수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사 + - 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리) + - 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로 +옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9). + + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층 + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층 + [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 + + +def _filled(doc: dict) -> bool: + return bool(doc.get("담은로직") or doc.get("자재")) + + +def migrate(folder: Path, dry: bool = False) -> dict[str, list[str]]: + """`folder` 층 하나 — `{만듦, 옮김, 그대로, 못함}`(열 id · 못함은 까닭 붙임).""" + out: dict[str, list[str]] = {"만듦": [], "옮김": [], "그대로": [], "못함": []} + with store.at(folder): + for col in store._structure_columns(): + cid, key = col["id"], str(col.get("일위대가") or "") + path = store._path("labor", cid) + have = json.loads(path.read_bytes()) if path.is_file() else None + done = have is not None and (have.get("본뜬") or {}).get("키") == key + if have is not None and (_filled(have) or not key or done): + out["그대로"].append(cid) + continue + if not key: + out["만듦"].append(cid) + if not dry: + store._ensure("labor", cid) + continue + try: + body = labor.from_combo(key) + except store.StoreError as e: + out["못함"].append(f"{cid} · {e.detail}") + continue + out["옮김"].append(f"{cid} ← {key}") + if dry: + continue + store._ensure("labor", cid) # 번호(SU · 같은 뒷자리) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], **body}) + return out + + +def layer_folders(storage: bool) -> list[Path]: + """시스템 층 하나 · 또는 storage 회사 · 개인 층(구조물집계표가 있는 것 · 프로젝트 빼고).""" + if not storage: + return [layers.system_dir()] + root = layers.storage_root() + table = Path("table") / f"{store.STRUCTURE_TABLE}.json" + found = [ + *root.glob(f"*/{layers.TEMPLATES_DIRNAME}"), + *root.glob(f"*/*/{layers.TEMPLATES_DIRNAME}"), + ] + return sorted(p for p in found if (p / table).is_file()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--storage", action="store_true", help="회사 · 개인 층(storage)") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + report: dict[str, Any] = {} + for folder in layer_folders(args.storage): + report[str(folder)] = migrate(folder, args.dry_run) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py new file mode 100644 index 000000000..233b1ae30 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -0,0 +1,64 @@ +"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`. + +문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · +여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌). +⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) + + +class CalcBody(BaseModel): + """`doc` = 저장 전 화면 문서(없으면 저장본) · `수량` = {알약 키: 값}(없으면 저장된 면적산출).""" + + doc: dict[str, Any] | None = None + 수량: dict[str, Any] | None = None + + +def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]: + got = sent if sent is not None else store.read("labor", column)["문서"] + return labor.clean({**got, "열": column}) + + +def _calc(column: str, body: CalcBody) -> dict[str, Any]: + doc = _doc(column, body.doc) + return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)} + + +def _pills(column: str) -> dict[str, Any]: + return {"열": column, "알약": labor.pills(_doc(column, None))} + + +@router.post("/layers/{layer}/labor/{column}/calc") +def calc_labor( + layer: Layer, + column: str, + body: CalcBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """줄마다 기준 · 반영 · 합 + 알약(저장 안 함 · 화면은 최신 응답만).""" + return _call(_root(session, layer, owner), _calc, column, body) + + +@router.get("/layers/{layer}/labor/{column}/pills") +def labor_pills( + layer: Layer, + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본).""" + return _call(_root(session, layer, owner), _pills, column) diff --git a/M02_MasterTemplete/M02_Labor_Values.css b/M02_MasterTemplete/M02_Labor_Values.css new file mode 100644 index 000000000..dca291790 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.css @@ -0,0 +1,99 @@ +/* 넣을 값 칸(39-3 · 옛 선택 입력 CSS 에서 옮김) — 프로젝트 값 카드 · 일위대가 입력 모달이 같이 씀. 색은 테마 토큰. */ + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ +.m02-inputval__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputval { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputval__places, +.m02-inputval__missing { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-inputval__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputval__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__control { + flex: 1 1 6rem; + min-width: 0; + max-width: 24rem; +} + +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ +.m02-inputval__value.is-auto, +.m02-inputval__control.is-auto, +.m02-inputval__control::placeholder { + color: var(--color-text-secondary); + font-style: italic; + opacity: 1; +} + +.m02-inputval__state { + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputval__state.is-confirmed { + color: var(--color-text); + font-weight: 600; +} + +.m02-inputval__error { + flex: 1 0 100%; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-inputval__control[aria-invalid="true"] { + border-color: var(--color-danger); +} diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts new file mode 100644 index 000000000..a7453b93a --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -0,0 +1,316 @@ +/* ============================================================================= + * M02_Labor_Values.ts + * 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌: + * 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder). + * · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀) + * · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch"; +import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; +import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model"; +import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; + +const fill = (text: string, values: Record): string => + text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); + +const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; + +const autoValue = (input: ItemInput): string | number | null => { + const auto = input.자동값; + return typeof auto === "string" || typeof auto === "number" ? auto : null; +}; + +export interface ValueControl { + control: HTMLInputElement | HTMLSelectElement; + /** 범위 밖 글(빈 글이면 비움) */ + error: HTMLElement; + /** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */ + wasBad: boolean; +} + +/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */ +export function createValueControl( + input: ItemInput, + value: string | number | null, + onChange: (value: string | number | null, bad: boolean) => void, +): ValueControl { + const auto = autoValue(input); + const range = rangeOf(input); + const choices = choicesOf(input); + const control = ( + choices.length + ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) + : el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: range ? "number" : "text", + ...(range?.min !== undefined ? { min: String(range.min) } : {}), + ...(range?.max !== undefined ? { max: String(range.max) } : {}), + ...(range ? { step: "any" } : {}), + placeholder: auto === null ? "" : String(auto), + }, + }) + ) as HTMLInputElement | HTMLSelectElement; + if (control instanceof HTMLSelectElement) { + control.append( + el("option", { + attrs: { value: "" }, + text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, + }), + ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ); + } + const empty = value === null || value === ""; + control.value = empty ? "" : String(value); + control.classList.toggle("is-auto", empty); + const error = el("span", { className: "m02-inputval__error" }); + const out: ValueControl = { control, error, wasBad: false }; + control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => { + const raw = control.value; + const bad = isOutside(input, raw); + control.setAttribute("aria-invalid", bad ? "true" : "false"); + error.textContent = bad + ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) + : ""; + control.classList.toggle("is-auto", raw === ""); + onChange(storedOf(input, raw), bad); + }); + return out; +} + +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── + * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 + * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 + * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. + * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ + +export interface InputsValueData { + list: ItemInputs; + link: InputLinks | null; + values: ValueDoc; +} + +export interface InputsValueOptions { + mode: "view" | "value"; + /** mode "view" — [선택 입력 수정] */ + onEdit?: () => void; + /** mode "value" — 값이 바뀔 때마다 */ + onChange?: (values: ValueDoc) => void; +} + +export interface InputsValueHandle { + root: HTMLElement; + /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ + set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; + /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ + loading: () => () => void; + /** 지금 값(확인한 것 · 값이 든 것만) */ + getValues: () => ValueDoc; + /** 범위 밖 값이 없나 */ + isValid: () => boolean; + /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ + setEditLabel: (label: string) => void; +} + +const VALUE_TEXT = { + title: "선택 입력", + missing: "빠진 로직 {value}", + edit: "선택 입력 수정", + none: "선택으로 이은 입력이 없음", + auto: "자동값", + unconfirmed: "확인 안 함", + confirmed: "✓ 확인함", + confirmBox: "확인", + summary: "선택 {value}", + places: "면적산출 알약 {area} · 재료산출 알약 {material}", + noList: "입력 목록을 못 읽음 — {value}", +}; + +type ValueEntry = ValueDoc[string][string]; + +export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { + let data: InputsValueData | null = null; + let values: ValueDoc = {}; + let invalid = 0; + + const summary = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); + edit.hidden = opts.mode !== "view"; + if (opts.onEdit) edit.addEventListener("click", opts.onEdit); + const places = el("p", { className: "m02-inputval__places" }); + const body = el("div", { className: "m02-struct__sheet m02-inputval" }); + // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 + const shell = cardShell({ + title: VALUE_TEXT.title, + sub: [summary], + body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), + stateHost: body, + }); + shell.tools.append(edit); + const root = shell.root; + + const groups = (): ReturnType => + data ? pickedGroups(data.list, data.link) : []; + const one = (logic: string, name: string): ValueEntry => + values[logic]?.[name] ?? { 값: null, 확인: false }; + const isEmpty = (value: string | number | null): boolean => value === null || value === ""; + const put = (logic: string, name: string, next: ValueEntry): void => { + (values[logic] ??= {})[name] = next; + }; + const autoOf = autoValue; + + const paintSummary = (): void => { + let total = 0; + let confirmed = 0; + for (const { logic, inputs } of groups()) + for (const input of inputs) { + total += 1; + if (one(logic.로직, input.이름).확인) confirmed += 1; + } + summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; + }; + + /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ + const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { + const stored = one(logic.로직, input.이름); + const auto = autoOf(input); + const state = el("span", { className: "m02-inputval__state" }); + const paintState = (): void => { + const now = one(logic.로직, input.이름); + state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; + state.classList.toggle("is-confirmed", now.확인); + }; + if (opts.mode === "view") { + const text = isEmpty(stored.값) ? auto : stored.값; + const shown = el("span", { + className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, + text: text === null ? "—" : String(text), + attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [shown, state] }); + } + + const emit = (): void => { + paintState(); + paintSummary(); + opts.onChange?.(values); + }; + const range = rangeOf(input); + const field = createValueControl(input, stored.값, (value, bad) => { + if (bad !== field.wasBad) invalid += bad ? 1 : -1; + field.wasBad = bad; + // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) + put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad }); + box.checked = value !== null && !bad; + emit(); + }); + const { control, error } = field; + const box = el("input", { + attrs: { + type: "checkbox", + title: VALUE_TEXT.confirmBox, + "aria-label": VALUE_TEXT.confirmBox, + }, + }) as HTMLInputElement; + box.checked = stored.확인; + // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 + box.addEventListener("change", () => { + const now = one(logic.로직, input.이름); + if (box.checked && isEmpty(now.값) && auto !== null) { + control.value = String(auto); + control.classList.remove("is-auto"); + put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); + } else if (box.checked && isEmpty(now.값)) { + box.checked = false; // 받아들일 값이 없음 + } else { + put(logic.로직, input.이름, { ...now, 확인: box.checked }); + } + emit(); + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); + }; + + const paint = (note?: string, retry?: () => void): void => { + invalid = 0; + paintSummary(); + if (!data) { + places.textContent = ""; + if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); + return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); + } + const seats = [...seatsOf(data.link).values()]; + const area = seats.filter((seat) => seat === "면적").length; + const material = seats.filter((seat) => seat === "재료").length; + places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; + const shown = groups(); + const missing = data.list.빠진로직.length + ? [ + el("p", { + className: "m02-inputval__missing", + text: fill(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }), + }), + ] + : []; + if (!shown.length) { + body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); + return; + } + body.replaceChildren( + ...shown.map(({ logic, inputs }) => + el("section", { + className: "m02-inputval__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputval__logic-name", + text: logic.이름 || logic.로직, + attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, + }), + ...inputs.map((input) => + el("div", { + className: "m02-inputval__row", + attrs: { "data-input": inputKey(logic.로직, input.이름) }, + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: input.설명 ? { title: input.설명 } : {}, + }), + valueCell(logic, input), + ], + }), + ), + ], + }), + ), + ...missing, + ); + }; + + return { + root, + set: (next, note, retry) => { + data = next; + values = next ? structuredClone(next.values) : {}; + paint(note, retry); + }, + loading: () => shell.loading(), + getValues: () => { + // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) + const out: ValueDoc = {}; + for (const [logic, byName] of Object.entries(values)) + for (const [name, entry] of Object.entries(byName)) + if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; + return out; + }, + isValid: () => invalid === 0, + setEditLabel: (label) => { + (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; + }, + }; +} diff --git a/M02_MasterTemplete/M02_Labor_Values_Model.ts b/M02_MasterTemplete/M02_Labor_Values_Model.ts new file mode 100644 index 000000000..046ee7cc8 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values_Model.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * M02_Labor_Values_Model.ts + * 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김. + * 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 · + * 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림). + * ========================================================================== */ + +import type { ItemInput } from "./M02_MasterTemplete_Api_Fetch"; + +const text = (value: unknown): string => + value === null || value === undefined + ? "" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + +/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ +export function valueHint(input: ItemInput): string { + if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + const range = input.범위; + if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; + if (range && typeof range === "object") { + const r = range as Record; + const lo = r.최소 ?? r.min; + const hi = r.최대 ?? r.max; + if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; + } + return text(range); +} + +/** 자동값 글 — 없으면 빈 글 */ +export const autoText = (input: ItemInput): string => text(input.자동값); + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ +export type ValueDoc = Record< + string, + Record +>; + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ +export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; + if (raw && typeof raw === "object") { + const r = raw as Record; + return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; + } + return null; +} + +/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const object = + typeof one === "object" && one !== null ? (one as Record) : null; + const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; + return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + }); +} + +/** 범위 밖 글인가 — 빈 글 · 범위 없는 입력은 아님 */ +export function isOutside(input: ItemInput, raw: string): boolean { + const range = rangeOf(input); + if (!range || raw.trim() === "") return false; + const value = Number(raw); + return ( + !Number.isFinite(value) || + (range.min !== undefined && value < range.min) || + (range.max !== undefined && value > range.max) + ); +} + +/** 칸 글 → 저장 값 — 빈 글 = null(자동값) · 범위 = 수 · 고르기 = 원래 모양 · 그 밖 = 글 */ +export function storedOf(input: ItemInput, raw: string): string | number | null { + if (raw === "") return null; + if (rangeOf(input)) return Number(raw); + return choicesOf(input).find((c) => c.값 === raw)?.raw ?? raw; +} + +/* ── 일위대가 입력값(39-3) — 로직 한 줄의 `{입력 이름: 값}` ───────────────────────────── */ + +export type LaborValues = Record; + +/** 로직 입력 목록에 있는 이름 · 빈 값 아닌 것만 남김 + 사라진 이름(로직 입력이 바뀌어 버려질 값) */ +export function keepKnown( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { kept: LaborValues; lost: string[] } { + const names = new Set(inputs.map((i) => i.이름)); + const kept: LaborValues = {}; + const lost: string[] = []; + for (const [name, value] of Object.entries(values ?? {})) { + if (!names.has(name)) lost.push(name); + else if (value !== "" && value !== null && value !== undefined) kept[name] = value; + } + return { kept, lost }; +} + +/** 줄 아래 작은 글 — 「지역 서울 · 규격 25mm」(넣은 값만 · 입력 차례) + 빈 값 수(자동값도 없는 입력) */ +export function valueLine( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { text: string; empty: number } { + const parts: string[] = []; + let empty = 0; + for (const input of inputs) { + const value = values?.[input.이름]; + if (value !== undefined && value !== "") { + parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`); + } else if (autoText(input) === "") empty += 1; + } + return { text: parts.join(" · "), empty }; +} diff --git a/M02_MasterTemplete/M02_Layer_Access.py b/M02_MasterTemplete/M02_Layer_Access.py index fe1b71430..2a0cc5dc8 100644 --- a/M02_MasterTemplete/M02_Layer_Access.py +++ b/M02_MasterTemplete/M02_Layer_Access.py @@ -1,13 +1,12 @@ """M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀 (PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`. +「회사 C 를 맡음」(`manages` · 39-6) = SYSTEM_ADMIN · 또는 C 의 회사 관리자(ADMIN · 마스터). system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN - company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) · - 남의 회사 = SYSTEM_ADMIN 읽기만 - personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 · - 다른 회사 = SYSTEM_ADMIN 읽기만 -owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) · -읽기로 남의 층에 뼈대를 안 만듦. + company {C} 읽기 = C 사람 · SYSTEM_ADMIN · 수정 = C 를 맡은 사람 + personal {C}/{U} 본인 = 읽기 · 수정 · 남 = C 를 맡은 사람만 읽기 · 수정(일반 = 403) +owner 글 없음 = 자기 층 · 남의 층 읽기는 그 층 폴더가 있을 때만(없으면 404) · +읽기로 남의 층에 뼈대를 안 만듦(쓰기는 부르는 쪽이 소속을 본 뒤 `folder(…, create=True)`). """ from __future__ import annotations @@ -33,6 +32,14 @@ def is_company_admin(session: dict[str, Any]) -> bool: return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) +def manages(session: dict[str, Any], company: Any) -> bool: + """회사 `company` 를 맡음 — 그 회사 층 · 그 회사 모든 개인 층을 보고 고침.""" + if is_system_admin(session): + return True + own = session.get("company_id") + return own is not None and str(own) == str(company) and is_company_admin(session) + + @dataclass(frozen=True) class Grant: """층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨).""" @@ -63,15 +70,14 @@ def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Gran raise HTTPException(status_code=409, detail=NO_COMPANY) if not _SHAPE[layer].match(who): raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」") + company = who.split("/")[0] if who == own: - return Grant(layer, who, layer == "personal" or is_company_admin(session), True) - same_company = who.split("/")[0] == str(session.get("company_id")) - colleague = layer == "personal" and same_company and is_company_admin(session) - if not (is_system_admin(session) or colleague): + return Grant(layer, who, layer == "personal" or manages(session, company), True) + if not manages(session, company): raise HTTPException( status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음" ) - return Grant(layer, who, False, False) + return Grant(layer, who, True, False) def require( @@ -82,16 +88,15 @@ def require( if write and not got.write: if layer == "system": detail = "마스터 템플릿은 시스템 관리자만 고침." - elif got.mine: - detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." else: - detail = "남의 층은 읽기만." + detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." raise HTTPException(status_code=403, detail=detail) return got -def folder(got: Grant) -> Path: - """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦).""" +def folder(got: Grant, create: bool = False) -> Path: + """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함 · `create` = 쓰기(부르는 쪽이 소속을 + 본 뒤 뼈대를 만듦).""" if got.owner is None: raise HTTPException(status_code=409, detail=NO_COMPANY) try: @@ -102,6 +107,6 @@ def folder(got: Grant) -> Path: path = layers.personal_dir(company, user) except ValueError as error: raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error - if not got.mine and not path.is_dir(): + if not (got.mine or create) and not path.is_dir(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return path diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index d8e5b4aa0..c5454c831 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -8,8 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · - * 「면적산출」 = area(구조물집계표 열마다 한 통합문서) */ -export type Kind = "table" | "drawing" | "structure" | "area"; + * 「면적산출」 = area(구조물집계표 열마다 한 통합문서) · + * 「일위대가」 = labor(열마다 문서 · 39장) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "labor"; export interface TemplateInfo { 종류: Kind; @@ -152,6 +153,8 @@ export const createStructure = (column: string): Promise => export interface StructureNumbers { 도번: string; 면적번: string; + /** 일위대가 `SU-######`(39장) */ + 일위대가번: string; } /** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ @@ -168,8 +171,6 @@ export interface StructureCombo extends StructureNumbers { 분류3: string | null; 단위: string | null; 일위대가: string | null; - /** 자재 조합 키 `UM` */ - 자재조합: string | null; 바인딩: unknown; 펼침: unknown; } @@ -241,6 +242,20 @@ export const copyStructure = async (column: string, from: string): Promise await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); }; +/** 면적산출 알약 하나 — 로직 물량 「이름|단위」(`로직` · `배`) · 자재 「자재|MT키」(`자재`) */ +export interface LaborPill { + 키: string; + 이름: string; + 단위: string; + 로직?: string[]; + 배?: number; + 자재?: { 코드: string; 이름: string; 규격: string; 단위: string }; +} + +/** 그 열 일위대가의 면적산출 알약(저장본 · PLAN 39-1a pills 길) — 지금 층 */ +export const fetchLaborPills = (column: string): Promise<{ 열: string; 알약: LaborPill[] }> => + call(owned(activeLayer, `/layers/${activeLayer}/labor/${enc(column)}/pills`)); + /** 다른 열(`from`) 면적산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 면적번 · 열은 그대로 */ export const copyArea = async (column: string, from: string): Promise => { await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); @@ -445,9 +460,11 @@ export const pasteTree = ( export interface LayerOwner { owner: string; name: string; + /** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */ + 비어있음?: boolean; } -/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */ +/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */ export const fetchOwners = ( layer: "company" | "personal", company: string | null = null, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts new file mode 100644 index 000000000..9bd76f293 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaLink.ts + * 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶. + * 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기. + * 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라 + * 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장]. + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; + +const TEXT = { + title: "칸 지정 — {value}", + close: "닫기", + now: "지금 연결", + none: "연결 없음", + picked: "지금 고른 칸으로 ({value})", + address: "칸 주소", + hint: "예 B6", + link: "잇기", + unlink: "풀기", + bad: "칸 주소가 틀림 — 예 B6", + readOnly: "읽기만 — 고칠 수 없음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaLinkOptions { + /** 줄 이름(모달 제목) */ + label: string; + /** 알약 키 */ + key: string; + /** 연결에 같이 적을 이름 · 단위 · 자재 */ + info: LinkInfo; + sheet: SpreadsheetHandle; + /** 시트에서 지금 고른 칸(A1) */ + picked: () => string; + readOnly: boolean; +} + +/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */ +function linkedText(opts: AreaLinkOptions): string | null { + const link = opts.sheet + .getDoc() + .시트.flatMap((s) => s.연결 ?? []) + .find((l) => l.키 === opts.key); + if (!link) return null; + const value = opts.sheet.getLinkedValues()[opts.key]; + return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim(); +} + +export function openAreaLink(opts: AreaLinkOptions): void { + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-area-link", + mount: (body) => { + const now = el("p", { className: "m02-area-link__now" }); + const paint = (): void => { + const text = linkedText(opts); + now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; + unlink.disabled = opts.readOnly || !text; + }; + const done = (cell: string | null): boolean => { + if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + close(); + return true; + }; + const here = opts.picked(); + const pick = createButton({ + label: fill(TEXT.picked, here), + variant: "filled", + size: "sm", + onClick: () => void done(here), + }); + const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint }); + const link = createButton({ + label: TEXT.link, + variant: "ghost", + size: "sm", + onClick: () => { + if (!done(address.input.value)) address.setError(TEXT.bad); + }, + }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => void done(null), + }); + address.input.addEventListener("input", () => address.setError(undefined)); + address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click()); + pick.disabled = link.disabled = address.input.disabled = opts.readOnly; + paint(); + body.append( + now, + ...(opts.readOnly + ? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })] + : []), + el("div", { className: "m02-area-link__row", children: [pick, unlink] }), + el("div", { className: "m02-area-link__row", children: [address.root, link] }), + ); + if (!opts.readOnly) address.input.focus(); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py deleted file mode 100644 index 85b5d499f..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py +++ /dev/null @@ -1,98 +0,0 @@ -"""일위대가 조합 층 사이 복사(PLAN 30-3) — 읽을 수 있는 층의 조합 · 폴더를 쓸 수 있는 층에 붙여넣기. - -정함(30장): 복사본 = 대상 층의 새 키(마스터 UA · 회사 UC · 개인 UP) · 본뜬키 = 원본 키 · -같은 이름이면 「이름 (2)」 · 담은 로직 그대로 · 폴더 복사는 안의 조합 · 빈 폴더까지. -붙는 자리 = `경로`(대상 폴더 · 깊이 2 안) — 고른 조합은 그 폴더에 · 고른 폴더는 그 아래 한 단으로. -마스터에 붙이면 M01 저장 길(대장 UA) · 본뜬키는 UA 만(마스터 틀) — 다른 층 원본이면 비움. -권한은 라우터 몫(원본 층 읽기 · 대상 층 쓰기) · 대상 층 = `store.at(대상)`. -""" - -from __future__ import annotations - -from typing import Any - -from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos -from M02_MasterTemplete import M02_MasterTemplete_Store as store - - -def _free_name(name: str, taken: set[str]) -> str: - out, n = name, 2 - while out in taken: - out, n = f"{name} ({n})", n + 1 - taken.add(out) - return out - - -def _paths(value: Any, what: str) -> list[list[str]]: - if not isinstance(value, list) or not all(isinstance(p, list) for p in value): - raise store.StoreError(422, f"「{what}」 이 폴더 경로 목록이 아님") - return [[str(x) for x in p] for p in value] - - -def picked(source: dict, keys: list[str], folders: list[list[str]], where: list[str]): - """(붙일 조합 [(원본 줄, 새 경로)], 새 빈 폴더) — 깊이 2 넘으면 422 · 없는 키 · 폴더 404.""" - have = combos.folders_of(source) - rows = {str(r.get("키")): r for r in source.get("줄") or []} - out: dict[str, tuple[dict, list[str]]] = {} - made: list[list[str]] = [] - for folder in folders: - if folder not in have: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(folder)}」") - cut = len(folder) - 1 # 고른 폴더부터 대상 아래로 - made += [where + f[cut:] for f in have if f[: len(folder)] == folder] - for key, row in rows.items(): - path = combos.path_of(row) - if path[: len(folder)] == folder: - out[key] = (row, where + path[cut:]) - for key in keys: - if key not in rows: - raise store.StoreError(404, f"없는 조합 「{key}」") - out.setdefault(key, (rows[key], list(where))) - if any(len(p) > combos.DEPTH for p in [*made, *(p for _, p in out.values())]): - raise store.StoreError(422, f"폴더는 {combos.DEPTH} 단까지 — 더 얕은 폴더에 붙여 넣음") - return list(out.values()), made - - -def paste(source: dict, body: dict) -> dict[str, Any]: - """`source` = 원본 층 표(부르는 쪽이 원본 층에서 읽음) · `body` = `{키?, 폴더?, 경로?, 판}` → - `{…대상 층 목록, 새키: [원본 → 새 키]}`.""" - keys = [str(k) for k in body.get("키") or []] - folders = _paths(body.get("폴더") or [], "폴더") - where = [str(x) for x in body.get("경로") or []] - if not keys and not folders: - raise store.StoreError(422, "붙여 넣을 조합 · 폴더가 없음") - doc, have = combos._load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [combos.FILE], "판": have}) - if where and where not in combos.folders_of(doc): - raise store.StoreError(404, f"없는 폴더 「{' › '.join(where)}」") - items, made = picked(source, keys, folders, where) - taken = {str(r.get("이름") or "").strip() for r in doc["줄"]} - master = combos.heads()[-1] == combos.MASTER - rows = [] - for row, path in items: - one = {k: row.get(k) for k in combos.mcb.COLS if k != "키"} - one["이름"] = _free_name(str(row.get("이름") or "").strip(), taken) - one["본뜬키"] = str(row.get("키")) - if master and not one["본뜬키"].startswith(combos.MASTER): - one["본뜬키"] = None # 마스터 틀은 UA 계보만 - combos._set_path(one, path) - rows.append(one) - folders_after = combos.folders_of({**doc, "폴더": [*combos.folders_of(doc), *made]}) - if master: - new = [combos.create(one)["키"] for one in rows] # M01 저장 길(검사 · 대장) — 하나씩 - _, version = combos._load() - combos._write_master_folders(folders_after, version) - else: - number = int(doc.get("다음") or 1) - fixed = [] - for at, one in enumerate(rows): - fixed.append(combos._clean(one, f"{combos.heads()[-1]}{number + at:06d}")) - new_doc = {**doc, "다음": number + len(fixed), "폴더": folders_after} - new_doc["줄"] = [*doc["줄"], *fixed] - for one in fixed: - combos._check(new_doc, one) - combos._save(new_doc, have) - new = [one["키"] for one in fixed] - pairs = [{"원본": str(row.get("키")), "새키": key} for (row, _), key in zip(items, new)] - return {**combos.listing(), "새키": pairs} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 52a846549..935336213 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -7,13 +7,12 @@ 개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######` 회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀). 지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫. -나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함). +목록 `폴더` = [구분] · [구분, 상세구분](읽기만 · 나무 고침 · 붙여넣기 길은 39-2 에서 지움). """ from __future__ import annotations import json -import os import re from datetime import date from pathlib import Path @@ -127,18 +126,13 @@ def _find(doc: dict, key: str) -> dict[str, Any]: return row -# ── 나무 폴더 ───────────────────────────────────────────────────────── +# ── 폴더(읽기만) ─────────────────────────────────────────────────────── def path_of(row: dict) -> list[str]: """조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리.""" a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분")) return [a, b] if a and b else [a] if a else [] -def _set_path(row: dict, path: list[str]) -> None: - row["구분"] = path[0] if path else None - row["상세구분"] = path[1] if len(path) > 1 else None - - def folders_of(doc: dict) -> list[list[str]]: """폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지.""" out: list[list[str]] = [] @@ -190,12 +184,6 @@ def pills(key: str) -> dict[str, Any]: return combo_store.combo_pills(_find(_load()[0], key)) -def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]: - """저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키.""" - row = _find(_load()[0], combo) if isinstance(combo, str) else combo - return _m01(combo_store.combo_preview, row, inputs) - - # ── 쓰기(회사 · 개인) ────────────────────────────────────────────────── def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: """서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든).""" @@ -289,6 +277,21 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]: return {"키": key, "판": _save(doc, version), "combo": fixed} +def drop_practice(version: str) -> dict[str, Any]: + """실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만.""" + if heads()[-1] != MASTER: + raise store.StoreError(400, "실무문서는 마스터 층에만 있음") + doc, have = _load() + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + keys = [str(r.get("키")) for r in doc.get("줄") or []] + _refuse_used(keys) + if keys: + _m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have) + return {**listing(), "지운조합": keys} + + def delete(key: str, version: str) -> dict[str, Any]: """지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음).""" if heads()[-1] == MASTER: @@ -297,128 +300,3 @@ def delete(key: str, version: str) -> dict[str, Any]: _find(doc, key) _refuse_used([key]) return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)} - - -# ── 나무 고침 ───────────────────────────────────────────────────────── -def _moved(path: list[str], old: list[str], new: list[str]) -> list[str]: - return new + path[len(old) :] if path[: len(old)] == old else path - - -def _folder(folders: list[list[str]], path: list[str] | None, what: str) -> list[str]: - path = [str(x) for x in path or []] - if not path or path not in folders: - raise store.StoreError(404, f"{what} 폴더 「{' › '.join(path)}」 없음") - return path - - -def _fresh(name: Any) -> str: - text = str(name or "").strip() - if not text or "›" in text or len(text) > mcb.MAX_이름: - raise store.StoreError(422, "폴더 이름 — 빈 이름 · 「›」 · 너무 긴 이름은 못 씀") - return text - - -def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: - """나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫.""" - op, key = body.get("op"), body.get("키") - folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []] - gone: list[str] = [] - target = [str(x) for x in body.get("새경로") or []] - if op in ("move", "add-folder") and target and target not in folders: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(target)}」") - if op == "add-folder": - made = [*[str(x) for x in body.get("경로") or []], _fresh(body.get("이름"))] - if len(made) > DEPTH or (made[:-1] and made[:-1] not in folders): - raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 있는 폴더 아래에만") - if made in folders: - raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」") - folders.append(made) - elif op == "rename" and key: - _find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름")) - elif op == "move" and key: - _set_path(_find({"줄": rows}, str(key)), target) - elif op == "delete" and key: - _find({"줄": rows}, str(key)) - gone = [str(key)] - elif op in ("rename", "move", "delete"): - old = _folder(folders, body.get("경로"), "고칠") - if op == "delete": - gone = [str(r["키"]) for r in rows if path_of(r)[: len(old)] == old] - folders = [f for f in folders if f[: len(old)] != old] - else: - new = [*old[:-1], _fresh(body.get("이름"))] if op == "rename" else [*target, old[-1]] - deepest = max(len(f) for f in folders if f[: len(old)] == old) - if new[: len(old)] == old or deepest - len(old) + len(new) > DEPTH: - raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 제 안으로 못 옮김") - if new in folders: - raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(new)}」") - folders = [_moved(f, old, new) for f in folders] - for r in rows: - if path_of(r)[: len(old)] == old: - _set_path(r, _moved(path_of(r), old, new)) - else: - raise store.StoreError( - 422, f"모르는 나무 고침 「{op}」 — add-folder · rename · move · delete" - ) - rows = [r for r in rows if str(r.get("키")) not in gone] - return {**doc, "폴더": folders, "줄": rows}, gone - - -def edit_tree(body: dict) -> dict[str, Any]: - """폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · - 조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422.""" - return edit_tree_many({"판": body.get("판"), "ops": [body]}) - - -def edit_tree_many(body: dict) -> dict[str, Any]: - """나무 고침 여럿을 한 번에(PLAN 30-3 · 여러 개 옮기기 · 지우기) — `{판, ops: [고침 …]}` · - 차례대로 적용 · 하나라도 막히면 아무것도 안 씀 · 저장 한 번.""" - doc, have = _load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": have}) - ops = body.get("ops") - if not isinstance(ops, list) or not ops: - raise store.StoreError(422, "나무 고침 목록(ops)이 비었음") - new, gone = doc, [] - for op in ops: - new, got = _apply(new, op if isinstance(op, dict) else {}) - gone += got - _refuse_used(gone) - renamed = [str(op["키"]) for op in ops if op.get("op") == "rename" and op.get("키")] - commit(doc, have, new, gone, renamed) - return {**listing(), "지운조합": gone} - - -def commit(doc: dict, have: str, new: dict, gone: list[str], checked: list[str] = ()) -> None: - """고친 표 한 벌 저장 — 회사 · 개인 = 파일 한 번(`checked` 조합은 이름 검사) · - 마스터 = 줄은 M01 저장 길 · 머리 `폴더` 따로.""" - if heads()[-1] != MASTER: - for key in checked: - if key in {str(r.get("키")) for r in new["줄"]}: - if errors := _name_errors(new, _find(new, key)): - raise store.StoreError(422, {"errors": errors}) - _save(new, have) - return - was = {str(r["키"]): r for r in doc["줄"]} - changes = [{"op": "delete", "key": k} for k in gone] + [ - {"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r - ] - version = _m01(combo_store._write, changes, have)["version"] if changes else have - _write_master_folders(new["폴더"], version) - - -def _write_master_folders(folders: list[list[str]], version: str) -> None: - """마스터 조합 파일 머리 `폴더` — 줄은 안 건드림(줄 고침은 M01 저장 길).""" - path = Path(m01.FOLDER) / FILE - with m01._LOCK: - doc, have = m01.read(FILE) - if have != version: - raise store.StoreError(409, {"stale": [FILE], "판": have}) - if doc.get("폴더") == folders: - return - head = {k: v for k, v in doc.items() if k != "줄"} - text = m01.dump({**head, "폴더": folders, "줄": doc.get("줄") or []}) - temporary = path.with_name(f".{FILE}.tmp") - temporary.write_text(text, encoding="utf-8", newline="\n") - os.replace(temporary, path) - m01.cache.stale([FILE]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css index d90721bea..fff107c95 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -103,101 +103,3 @@ .m02-inputs__seat:disabled { opacity: 0.7; } - -/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ -.m02-inputval__wrap { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: var(--m02-struct-body-min, 16rem); - min-width: 0; - gap: var(--spacing-8); -} - -.m02-inputval { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - overflow: auto; - min-height: 0; - min-width: 0; -} - -.m02-inputval__places, -.m02-inputval__missing { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-inputval__logic { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-inputval__logic-name { - margin: 0; - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__row { - display: grid; - grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); - gap: var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__name { - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__cell { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__control { - flex: 1 1 6rem; - min-width: 0; - max-width: 24rem; -} - -/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ -.m02-inputval__value.is-auto, -.m02-inputval__control.is-auto, -.m02-inputval__control::placeholder { - color: var(--color-text-secondary); - font-style: italic; - opacity: 1; -} - -.m02-inputval__state { - color: var(--color-text-secondary); - font-size: var(--text-caption); - white-space: nowrap; -} - -.m02-inputval__state.is-confirmed { - color: var(--color-text); - font-weight: 600; -} - -.m02-inputval__error { - flex: 1 0 100%; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m02-inputval__control[aria-invalid="true"] { - border-color: var(--color-danger); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py index 91bc32d87..d6c130984 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -14,30 +14,18 @@ M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · from __future__ import annotations import json -from decimal import Decimal from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json -from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics PLACES = ("면적", "재료", "선택", None) PILL_HEAD = "입력" -_SPEC = ("이름", "설명", "단위", "고르기", "범위") - - -def _num(value: Any) -> Any: - """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" - if isinstance(value, Decimal): - return int(value) if value == value.to_integral_value() else float(value) - if isinstance(value, list): - return [_num(v) for v in value] - if isinstance(value, dict): - return {k: _num(v) for k, v in value.items()} - return value def pill_key(logic: str, name: str) -> str: @@ -56,55 +44,10 @@ def _combo_row(ua: str) -> dict[str, Any] | None: return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c) -def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: - """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" - if not ua: - return None, [], [] - row = _combo_row(str(ua)) - if row is None: - return None, [], [] - whole = m01.loaded()[1] - found, missing = [], [] - for one in combo_store.mcb.items(row): - code = str(one.get("로직") or "") if isinstance(one, dict) else "" - try: - found.append(whole.logic(code)) - except combo_store.cm.mf.FormulaError: - missing.append(code) - return row, found, missing - - def inputs(key: str) -> dict[str, Any]: """25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`. - 일위대가 없는 항목은 로직 빈 목록.""" - ua = _item(key).get("일위대가") - row, logics, missing = combo_logics(ua) - whole = m01.loaded()[1] - out = [] - for logic in logics: - auto = m01.mau.values(whole, logic) - specs = [] - for spec in logic.get("입력") or []: - one = {k: spec[k] for k in _SPEC if k in spec} - one.setdefault("설명", None) - one.setdefault("단위", None) - specs.append({**one, "자동값": auto.get(spec["이름"])}) - out.append( - { - "로직": logic.get("키"), - "이름": logic.get("이름"), - "결과단위": logic.get("결과단위"), - "입력": specs, - } - ) - return _num( - { - "일위대가": ua or None, - "이름": row.get("이름") if row else None, - "로직": out, - "빠진로직": missing, - } - ) + 일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌).""" + return labor_inputs.inputs_of(_item(key).get("일위대가")) # ── 항목 연결 파일 ───────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 8b31ae48f..2b9b8a143 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -10,7 +10,7 @@ * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, @@ -18,28 +18,21 @@ import { getActiveLayer, readInputLinks, saveInputLinks, - type InputLinks, type InputSeat, - type ItemInput, type ItemInputs, - type ItemLogic, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, - choicesOf, countOf, inputKey, linesOf, - pickedGroups, pillsOf, - rangeOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, - type ValueDoc, } from "./M02_MasterTemplete_Inputs_Model"; import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; import "./M02_MasterTemplete_Inputs.css"; @@ -288,286 +281,10 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro } } -/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── - * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 - * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 - * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. - * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ - -export interface InputsValueData { - list: ItemInputs; - link: InputLinks | null; - values: ValueDoc; -} - -export interface InputsValueOptions { - mode: "view" | "value"; - /** mode "view" — [선택 입력 수정] */ - onEdit?: () => void; - /** mode "value" — 값이 바뀔 때마다 */ - onChange?: (values: ValueDoc) => void; -} - -export interface InputsValueHandle { - root: HTMLElement; - /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ - set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; - /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ - loading: () => () => void; - /** 지금 값(확인한 것 · 값이 든 것만) */ - getValues: () => ValueDoc; - /** 범위 밖 값이 없나 */ - isValid: () => boolean; - /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ - setEditLabel: (label: string) => void; -} - -const VALUE_TEXT = { - edit: "선택 입력 수정", - none: "선택으로 이은 입력이 없음", - auto: "자동값", - unconfirmed: "확인 안 함", - confirmed: "✓ 확인함", - confirmBox: "확인", - outOfRange: "범위 {value} 밖", - summary: "선택 {value}", - places: "면적산출 알약 {area} · 재료산출 알약 {material}", - noList: "입력 목록을 못 읽음 — {value}", -}; - -type ValueEntry = ValueDoc[string][string]; - -export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { - let data: InputsValueData | null = null; - let values: ValueDoc = {}; - let invalid = 0; - - const summary = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); - edit.hidden = opts.mode !== "view"; - if (opts.onEdit) edit.addEventListener("click", opts.onEdit); - const places = el("p", { className: "m02-inputval__places" }); - const body = el("div", { className: "m02-struct__sheet m02-inputval" }); - // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 - const shell = cardShell({ - title: TEXT.title, - sub: [summary], - body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), - stateHost: body, - }); - shell.tools.append(edit); - const root = shell.root; - - const groups = (): ReturnType => - data ? pickedGroups(data.list, data.link) : []; - const one = (logic: string, name: string): ValueEntry => - values[logic]?.[name] ?? { 값: null, 확인: false }; - const isEmpty = (value: string | number | null): boolean => value === null || value === ""; - const put = (logic: string, name: string, next: ValueEntry): void => { - (values[logic] ??= {})[name] = next; - }; - const autoOf = (input: ItemInput): string | number | null => { - const auto = input.자동값; - return typeof auto === "string" || typeof auto === "number" ? auto : null; - }; - - const paintSummary = (): void => { - let total = 0; - let confirmed = 0; - for (const { logic, inputs } of groups()) - for (const input of inputs) { - total += 1; - if (one(logic.로직, input.이름).확인) confirmed += 1; - } - summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; - }; - - /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ - const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { - const stored = one(logic.로직, input.이름); - const auto = autoOf(input); - const state = el("span", { className: "m02-inputval__state" }); - const paintState = (): void => { - const now = one(logic.로직, input.이름); - state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; - state.classList.toggle("is-confirmed", now.확인); - }; - if (opts.mode === "view") { - const text = isEmpty(stored.값) ? auto : stored.값; - const shown = el("span", { - className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [shown, state] }); - } - - const emit = (): void => { - paintState(); - paintSummary(); - opts.onChange?.(values); - }; - const range = rangeOf(input); - const choices = choicesOf(input); - const control = ( - choices.length - ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) - : el("input", { - className: "ui-input m02-inputval__control", - attrs: { - type: range ? "number" : "text", - ...(range?.min !== undefined ? { min: String(range.min) } : {}), - ...(range?.max !== undefined ? { max: String(range.max) } : {}), - ...(range ? { step: "any" } : {}), - placeholder: auto === null ? "" : String(auto), - }, - }) - ) as HTMLInputElement | HTMLSelectElement; - const error = el("span", { className: "m02-inputval__error" }); - const box = el("input", { - attrs: { - type: "checkbox", - title: VALUE_TEXT.confirmBox, - "aria-label": VALUE_TEXT.confirmBox, - }, - }) as HTMLInputElement; - - if (control instanceof HTMLSelectElement) { - control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`, - }), - ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), - ); - } - control.value = isEmpty(stored.값) ? "" : String(stored.값); - control.classList.toggle("is-auto", isEmpty(stored.값)); - box.checked = stored.확인; - - const outside = (raw: string): boolean => { - if (!range || raw.trim() === "") return false; - const value = Number(raw); - return ( - !Number.isFinite(value) || - (range.min !== undefined && value < range.min) || - (range.max !== undefined && value > range.max) - ); - }; - let wasBad = false; - const changed = (): void => { - const raw = control.value; - const bad = outside(raw); - if (bad !== wasBad) invalid += bad ? 1 : -1; - wasBad = bad; - control.setAttribute("aria-invalid", bad ? "true" : "false"); - error.textContent = bad - ? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) - : ""; - control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = - raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw); - // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) - put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); - box.checked = raw !== "" && !bad; - emit(); - }; - control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); - // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 - box.addEventListener("change", () => { - const now = one(logic.로직, input.이름); - if (box.checked && isEmpty(now.값) && auto !== null) { - control.value = String(auto); - control.classList.remove("is-auto"); - put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); - } else if (box.checked && isEmpty(now.값)) { - box.checked = false; // 받아들일 값이 없음 - } else { - put(logic.로직, input.이름, { ...now, 확인: box.checked }); - } - emit(); - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); - }; - - const paint = (note?: string, retry?: () => void): void => { - invalid = 0; - paintSummary(); - if (!data) { - places.textContent = ""; - if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); - return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); - } - const seats = [...seatsOf(data.link).values()]; - const area = seats.filter((seat) => seat === "면적").length; - const material = seats.filter((seat) => seat === "재료").length; - places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; - const shown = groups(); - const missing = data.list.빠진로직.length - ? [ - el("p", { - className: "m02-inputval__missing", - text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }), - }), - ] - : []; - if (!shown.length) { - body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); - return; - } - body.replaceChildren( - ...shown.map(({ logic, inputs }) => - el("section", { - className: "m02-inputval__logic", - attrs: { "data-logic": logic.로직 }, - children: [ - el("h4", { - className: "m02-inputval__logic-name", - text: logic.이름 || logic.로직, - attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, - }), - ...inputs.map((input) => - el("div", { - className: "m02-inputval__row", - attrs: { "data-input": inputKey(logic.로직, input.이름) }, - children: [ - el("span", { - className: "m02-inputval__name", - text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, - attrs: input.설명 ? { title: input.설명 } : {}, - }), - valueCell(logic, input), - ], - }), - ), - ], - }), - ), - ...missing, - ); - }; - - return { - root, - set: (next, note, retry) => { - data = next; - values = next ? structuredClone(next.values) : {}; - paint(note, retry); - }, - loading: () => shell.loading(), - getValues: () => { - // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) - const out: ValueDoc = {}; - for (const [logic, byName] of Object.entries(values)) - for (const [name, entry] of Object.entries(byName)) - if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; - return out; - }, - isValid: () => invalid === 0, - setEditLabel: (label) => { - (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; - }, - }; -} +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */ +export { + createInputsValueCard, + type InputsValueData, + type InputsValueHandle, + type InputsValueOptions, +} from "./M02_Labor_Values"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts index a6ab719f2..bbdb8fc53 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Inputs_Model.ts - * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글. + * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록. + * 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄). * 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` · * 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음. * ========================================================================== */ @@ -78,62 +79,11 @@ export function pillsOf( return out; } -const text = (value: unknown): string => - value === null || value === undefined - ? "" - : typeof value === "object" - ? JSON.stringify(value) - : String(value); - -/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ -export function valueHint(input: ItemInput): string { - if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); - const range = input.범위; - if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; - if (range && typeof range === "object") { - const r = range as Record; - const lo = r.최소 ?? r.min; - const hi = r.최대 ?? r.max; - if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; - } - return text(range); -} - -/** 자동값 글 — 없으면 빈 글 */ -export const autoText = (input: ItemInput): string => text(input.자동값); +/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */ +export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model"; /* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */ -/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ -export type ValueDoc = Record< - string, - Record ->; - -const number = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ -export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { - const raw = input.범위; - if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; - if (raw && typeof raw === "object") { - const r = raw as Record; - return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; - } - return null; -} - -/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ -export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { - return (input.고르기 ?? []).map((one) => { - const object = - typeof one === "object" && one !== null ? (one as Record) : null; - const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; - return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; - }); -} - /** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */ export function pickedGroups( list: ItemInputs, diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index b7460ac25..883f16b4c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -13,6 +13,7 @@ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { LinkOptions, SpreadsheetHandle, @@ -60,6 +61,13 @@ const TEXT = { readFailed: "{value} 파일을 못 읽음 — {why}", discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", sheetHelp: "단축키 — 칸을 고르고 바로 침", + pills: "알약", + pillsTip: "아래 알약 줄 보임 · 숨김", + take: "복사해 가져오기", + takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", + blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -76,6 +84,11 @@ const SHEET_KEYS: [string, string][] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */ +const emptyBook = (book: Workbook): boolean => + (book.시트 ?? []).every( + (s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식), + ); const fill = (text: string, value: string): string => text.replace("{value}", value); /** 스프레드시트는 면적산출 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); @@ -97,6 +110,8 @@ export interface ItemCardsOptions { onDirty?: (dirty: boolean) => void; /** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; + /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ + onImport?: (kind: EditKind) => void; } /** 카드 스프레드시트를 다루는 손잡이 — 알약이 씀 */ @@ -149,6 +164,8 @@ interface Card { saver: SaveControl; help: HTMLElement; edit: HTMLButtonElement; + /** 「복사해 가져오기」 — 찬 카드만 */ + take: HTMLButtonElement; no: HTMLElement; name: HTMLElement; } @@ -160,6 +177,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { let docVersion: string | null = null; let preview: DrawingCardHandle | null = null; let seq = 0; + /** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */ + const started = new Set(); const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); @@ -187,8 +206,44 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { size: "sm", }); edit.addEventListener("click", () => opts.onEdit(kind)); - shell.tools.append(help, saver.button, edit); - return { kind, shell, saver, help, edit, no, name }; + const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); + take.title = fill(TEXT.takeTip, PART_WORD[kind]); + take.addEventListener("click", () => opts.onImport?.(kind)); + shell.tools.append(help, saver.button, take, edit); + return { kind, shell, saver, help, edit, take, no, name }; + }; + + /** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */ + const blank = (c: Card, empty: boolean): boolean => { + const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind); + c.take.hidden = show || !opts.editable() || !opts.onImport; + return show; + }; + /** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */ + const startBox = (c: Card, paint: () => void): HTMLElement => { + const fresh = createButton({ + label: TEXT.makeNew, + variant: "ghost", + size: "sm", + onClick: () => { + started.add(c.kind); + paint(); + }, + }); + const copy = createButton({ + label: TEXT.makeCopy, + variant: "ghost", + size: "sm", + onClick: () => opts.onImport?.(c.kind), + }); + copy.hidden = !opts.onImport; + return el("div", { + className: "m02-struct__start", + children: [ + el("p", { className: "m02-struct__empty", text: TEXT.blank }), + el("div", { className: "m02-struct__start-row", children: [fresh, copy] }), + ], + }); }; const previewHost = el("div", { className: "m02-struct__preview" }); @@ -219,6 +274,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return card; }; const sheets = [sheetCard("area")]; + // 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임) + const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" }); + pills.title = TEXT.pillsTip; + const paintPills = (shown: boolean): void => { + pills.setAttribute("aria-pressed", String(shown)); + sheets[0].host.classList.toggle("is-pills-off", !shown); + }; + pills.addEventListener("click", () => { + const shown = pills.getAttribute("aria-pressed") !== "true"; + writeStateRaw("m02-card-pills", String(shown)); + paintPills(shown); + }); + paintPills(readStateRaw("m02-card-pills") === "true"); + sheets[0].shell.tools.prepend(pills); const cards: Card[] = [info, ...sheets]; const defaults: Record = { structure: TEXT.edit, @@ -241,6 +310,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.saver.button.hidden = c.help.hidden = !can; c.edit.hidden = !opts.editable(); } + pills.hidden = !sheetEdit; }; const drop = (): void => { @@ -307,6 +377,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { * 그 사이 열이 바뀌면 버림 */ const paintSheet = async (c: SheetCard, mine: number): Promise => { if (!c.doc) return; + if (blank(c, emptyBook(c.doc))) + return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq))); + c.host.replaceChildren(); try { const { createSpreadsheet } = await loadSpreadsheet(); if (mine !== seq || !c.doc) return; @@ -327,14 +400,21 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]); void paintSheet(c, mine); } + paintDrawing(); + }; + + /** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */ + const paintDrawing = (): void => { if (!doc) return; previewHost.replaceChildren(); + if (blank(info, !doc.도면?.entities?.length)) + return void previewHost.append(startBox(info, paintDrawing)); // 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`) preview = mountDrawingCard(previewHost, { doc, version: docVersion, source: opts.source(), - column: at, + column: column ?? "", editable: opts.editable(), saver: info.saver, }); @@ -363,6 +443,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + if (col.id !== column) started.clear(); column = col.id; label = col.label; for (const c of cards) c.name.textContent = label; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts new file mode 100644 index 000000000..4d015ca6b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -0,0 +1,199 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemList.ts + * 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나 + * 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 + * (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음). + * 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다. + * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). + * ========================================================================== */ + +import { createInputField, el } from "@ui/ui_template_elements"; +import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; +import "../M01_MasterData/M01_MasterData_UI_Style.css"; +import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; +import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model"; + +/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */ +export type ListKind = "structure" | "area" | "labor" | "item"; + +/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */ +export interface ItemRow extends TreeItem { + 도번?: string | null; + 면적번?: string | null; + 수정일?: Partial>; + labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; +} + +export interface Col { + head: string; + get: (row: R) => string; +} + +const TEXT = { + search: "찾기", + none: "항목이 없음", + noMatch: "맞는 항목이 없음", + loading: "불러오는 중", + failed: "목록을 못 받음", +}; + +const SIZE = 30; +const DASH = "—"; +const day = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const money = (value: number | null | undefined): string => + typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; +/** 가장 늦게 고친 날 — 항목 통째 */ +const latest = (row: ItemRow): string => + day( + Object.values(row.수정일 ?? {}) + .sort() + .pop(), + ); + +const NAME: Col = { head: "이름", get: (r) => leafName(r) }; +const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; +const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; +const changed = (part: "structure" | "area" | "labor"): Col => ({ + head: "고친 날", + get: (r) => day(r.수정일?.[part]), +}); + +/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */ +export const ITEM_COLS: Record[]> = { + structure: [ + { head: "도번", get: (r) => r.도번 || DASH }, + NAME, + FOLDER, + UNIT, + changed("structure"), + ], + area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")], + labor: [ + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + NAME, + FOLDER, + UNIT, + { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, + { head: "합계", get: (r) => money(r.labor?.합계) }, + changed("labor"), + ], + item: [ + NAME, + FOLDER, + UNIT, + { head: "도번", get: (r) => r.도번 || DASH }, + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + { head: "면적번", get: (r) => r.면적번 || DASH }, + { head: "고친 날", get: latest }, + ], +}; + +/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ +export function pageOf( + rows: R[], + cols: Col[], + query: string, + page: number, + size = SIZE, +): { shown: R[]; page: number; total: number } { + const hits = rows.filter((r) => + matchText( + cols.map((c) => c.get(r)), + query, + ), + ); + const last = Math.max(1, Math.ceil(hits.length / size)); + const at = Math.min(Math.max(1, page), last); + return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length }; +} + +/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */ +export const loadItems = async (layer: Layer, owner: string | null): Promise => + (await fetchTree(layer, owner)).items as ItemRow[]; + +/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */ +export function buildAllItems( + kind: ListKind, + layer: Layer, + owner: string | null, + onOpen: (row: ItemRow) => void, +): HTMLElement { + const list = buildItemList(); + loadItems(layer, owner).then( + (rows) => list.show(rows, ITEM_COLS[kind], onOpen), + (error: unknown) => + list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`), + ); + return list.root; +} + +export interface ItemListHandle { + root: HTMLElement; + /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ + show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ + message: (text?: string) => void; +} + +export function buildItemList(): ItemListHandle { + let draw = (): void => {}; + let page = 1; + const searchField = createInputField({ type: "search", placeholder: TEXT.search }); + const search = searchField.input; + const grid = el("div", { className: "m01-master__grid-wrap" }); + let pager = buildPager(0, SIZE, 1, () => {}); + const note = (text: string): void => { + grid.replaceChildren(el("p", { className: "m01-master__empty", text })); + }; + + const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + page = 1; + draw = (): void => { + const got = pageOf(rows, cols, search.value, page); + page = got.page; + const next = buildPager(got.total, SIZE, page, (p) => { + page = p; + draw(); + }); + pager.replaceWith(next); + pager = next; + if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none); + const body = got.shown.map((row) => { + const tr = el("tr", { + className: "m01-master__table-row", + children: cols.map((c) => { + const text = c.get(row); + return el("td", { text, attrs: { title: text } }); + }), + }); + tr.addEventListener("click", () => onPick(row)); + return tr; + }); + grid.replaceChildren( + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })], + }), + el("tbody", { children: body }), + ], + }), + ); + }; + draw(); + }; + + search.addEventListener("input", () => { + page = 1; + draw(); + }); + const root = el("div", { + className: "m02-itemlist", + children: [buildHeadFilters([searchField.root, pager]), grid], + }); + // 처음 = 빈 표 자리(줄이 오면 `show`) + note(TEXT.loading); + return { root, show, message: (text = TEXT.loading) => note(text) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css new file mode 100644 index 000000000..a6713f59b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -0,0 +1,35 @@ +/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */ +.m02-itempick { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick .m01-master__grid-wrap { + max-height: min(60vh, calc(100vh - 300px)); +} + +.ui-modal.m02-itempick__dialog { + width: min(960px, 94vw); +} + +/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */ +.m02-itempick__two { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-itempick__buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts new file mode 100644 index 000000000..4fd1b1215 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -0,0 +1,255 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemPick.ts + * 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`). + * 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀. + * 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈 + * (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로. + * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). + * ========================================================================== */ + +import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { fetchSessionUser, type SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + buildItemList, + ITEM_COLS, + loadItems, + type Col, + type ItemRow, + type ListKind, +} from "./M02_MasterTemplete_ItemList"; +import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; +import { + entryFromHash, + ownOwner, + readableSources, + viewCompany, + type Source, +} from "./M02_MasterTemplete_Layer"; +import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; +import "./M02_MasterTemplete_TreePanel.css"; +import "./M02_MasterTemplete_ItemPick.css"; + +const TEXT = { + close: "닫기", + person: "사람", + noPeople: "다른 사람이 없음", + empty: "비어 있음", + failed: "목록을 못 받음 — {value}", + makeNew: "새로 만들기", + makeCopy: "복사 생성", +}; + +/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ +export interface From { + layer: Layer; + owner: string | null; + label: string; + practice: boolean; +} + +export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary }; + +/** 고른 것 이름 — 확인 글에 씀 */ +export const pickedName = (p: Picked): string => + "row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`; + +const PRACTICE_COLS: Col[] = [ + { head: "키", get: (c) => c.키 }, + { head: "이름", get: (c) => c.이름 }, + { head: "구분", get: (c) => c.구분 ?? "" }, + { head: "상세구분", get: (c) => c.상세구분 ?? "" }, + { head: "단위", get: (c) => c.단위 ?? "" }, + { head: "담은 로직 수", get: (c) => String(c.count) }, +]; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */ +const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음; + +export interface PickerOptions { + user: SessionUser | null; + /** 볼 회사(`viewCompany`) */ + company: string | null; + /** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */ + kind: ListKind; + /** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */ + skip?: (from: From, row: ItemRow) => boolean; + onPick: (picked: Picked) => void; +} + +/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */ +export function buildPicker(opt: PickerOptions): { root: HTMLElement } { + const { tabs, first } = readableSources(opt.user, opt.company, { + practice: opt.kind === "labor", + }); + const list = buildItemList(); + const people = createSelectField({ options: [], compact: true, label: TEXT.person }); + people.root.hidden = true; + let owners: { owner: string; name: string }[] | null = null; + let seq = 0; + + const buttons = tabs.map((tab) => { + const b = el("button", { + className: "m02-layers__btn", + text: tab.label, + attrs: { type: "button", "data-source": tab.id }, + }); + b.addEventListener("click", () => void show(tab)); + return b; + }); + + async function show(tab: Source): Promise { + const mine = ++seq; + buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id)); + people.root.hidden = tab.id !== "people"; + list.message(); + try { + if (tab.id === "practice") { + const combos = await fetchCombos(); + if (mine !== seq) return; + const from = { layer: tab.layer, owner: null, label: tab.label, practice: true }; + return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo })); + } + let owner = tab.owner; + let label = tab.label; + if (tab.id === "people") { + // 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람 + const me = ownOwner("personal", opt.user); + owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me); + if (mine !== seq) return; + if (!owners.length) return list.message(TEXT.noPeople); + if (!people.select.value) { + people.setOptions( + owners.map((o) => ({ + value: o.owner, + text: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name, + })), + (owners.find((o) => !empty(o)) ?? owners[0]).owner, + ); + } + owner = people.select.value; + const who = owners.find((o) => o.owner === owner); + label = `${tab.label} · ${who?.name ?? owner}`; + if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음 + } + const rows = await loadItems(tab.layer, owner); + if (mine !== seq) return; + const from = { layer: tab.layer, owner, label, practice: false }; + list.show( + rows.filter((row) => !opt.skip?.(from, row)), + ITEM_COLS[opt.kind], + (row) => opt.onPick({ from, row }), + ); + } catch (error) { + if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); + } + } + + people.select.addEventListener("change", () => { + const tab = tabs.find((t) => t.id === "people"); + if (tab) void show(tab); + }); + const start = tabs.find((t) => t.id === first) ?? tabs[0]; + if (start) void show(start); + return { + root: el("div", { + className: "m02-itempick", + children: [ + el("div", { className: "m02-layers__row", children: [...buttons, people.root] }), + list.root, + ], + }), + }; +} + +export interface TwoWayOptions { + /** 위 안내 한 줄 */ + note: string; + /** [새로 만들기] — 지금 길(창 · 빈 문서) */ + onNew: () => void; + /** [복사 생성] 뒤 고를 목록 */ + picker: Omit; + /** 줄 누름 → 확인 글(취소면 그대로 고름) */ + ask: (picked: Picked) => string; + /** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */ + onCopy: (picked: Picked) => Promise; +} + +/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */ +export function buildTwoWay(opt: TwoWayOptions): HTMLElement { + const fresh = createButton({ label: TEXT.makeNew, variant: "filled" }); + const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); + const below = el("div", { className: "m02-itempick__below" }); + let busy = false; + fresh.addEventListener("click", opt.onNew); + copy.addEventListener("click", () => { + if (below.firstChild) return; + copy.classList.add("is-active"); + const picker = buildPicker({ + ...opt.picker, + onPick: (picked) => + void (async () => { + if (busy || !(await showConfirmDialog(opt.ask(picked)))) return; + busy = true; // 연달아 눌러도 한 번만 + try { + await opt.onCopy(picked); + } finally { + busy = false; + } + })(), + }); + below.append(picker.root); + }); + return el("div", { + className: "m02-itempick__two", + children: [ + el("p", { className: "m02-itempick__note", text: opt.note }), + el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }), + below, + ], + }); +} + +/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +export function pickFrom( + opt: Omit & { title: string; ask?: (picked: Picked) => string }, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Picked | null): void => { + if (!done) resolve(value); + done = true; + }; + openModal({ + title: opt.title, + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-itempick__dialog", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const picker = buildPicker({ + ...opt, + onPick: (picked) => + void (async () => { + if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return; + finish(picked); + close(); + })(), + }); + body.append(picker.root); + }, + }); + }); +} + +/** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */ +export async function pickHere( + opt: Omit & { + title: string; + ask?: (picked: Picked) => string; + }, +): Promise { + const user = await fetchSessionUser().catch(() => null); + return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 40558d197..7619ac4ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from datetime import datetime from pathlib import Path from typing import Any @@ -75,10 +76,47 @@ def items(folder: Path) -> list[dict[str, Any]]: "일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None, **{k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번")}, } - out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names}) + out.append( + { + "key": r.get("키"), + **{k: r.get(k) for k in ITEM_FIELDS}, + "names": names, + **_list_extras(Path(folder), str(r.get("열"))), + } + ) return out +def _list_extras(folder: Path, col: str) -> dict[str, Any]: + """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + stamps: dict[str, str] = {} + for kind in ("structure", "area", "labor"): + path = folder / kind / f"{col}.json" + if path.is_file(): + stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat( + timespec="seconds" + ) + labor = None + path = folder / "labor" / f"{col}.json" + if path.is_file(): + try: + doc = json.loads(path.read_bytes()) + except ValueError: + doc = {} + total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") + try: + total = None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + total = None # 수 아닌 값(옛 꼴)은 「—」 + labor = { + "번호": doc.get("일위대가번"), + "로직수": len(doc.get("담은로직") or []), + "합계": total, + } + return {"수정일": stamps, "labor": labor} + + def _fresh_id(want: str, used: set[str]) -> str: out, n = want, 2 while out in used: @@ -93,8 +131,9 @@ def copy_item( origin: dict[str, Any], path: list[str] | None = None, ) -> dict[str, Any]: - """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · - 도면 · 면적산출 내용(알약 연결 포함) · 항목 연결 복사 · + """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 · + 도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 내용(알약 연결 포함) · + 항목 연결 복사 · 새 번호 · 새 SS 키 · 열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 · 붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`.""" @@ -115,7 +154,12 @@ def copy_item( table = store.read("table", store.STRUCTURE_TABLE) doc = table["문서"] new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}) - made = {**json.loads(json.dumps(col)), "id": new_id, "본뜬": origin} + made = { + **json.loads(json.dumps(col)), + "id": new_id, + "일위대가": None, # 정본 = 일위대가 문서(아래 복사) + "본뜬": origin, + } made.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김 if path: made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움 @@ -126,10 +170,10 @@ def copy_item( have = store.read(kind, new_id)["문서"] body = {k: v for k, v in got.items() if k != "계산값"} body = {**body, "열": new_id, number: have.get(number)} - if kind in store.SHEETS: - body = store._server_values(body) + body = store._server(kind, body) with store._LOCK: atomic_write_json(store._path(kind, new_id), body) + store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) return {"key": ss["키"], "열": new_id, "이름": ss["이름"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index f6791ee2e..a5a234504 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -21,3 +21,118 @@ .m02-labor__list tbody tr:hover { background: var(--color-mist-violet); } + +/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */ +.m02-labrow { + width: 100%; +} + +.m02-labrow__row.is-open { + cursor: pointer; +} + +.m02-labrow__row.is-open:hover { + background: var(--color-mist-violet); +} + +.m02-labrow__name { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 12rem; +} + +.m02-labrow__key, +.m02-labrow__note, +.m02-labrow__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-labrow__empty { + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labrow__money, +.m02-labrow__num { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-labrow__money { + vertical-align: top; +} + +.m02-labrow__base { + display: block; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labrow__applied { + display: block; +} + +.m02-labrow__sum td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + +.m02-labrow__move { + white-space: nowrap; +} + +/* 입력 모달 */ +.m02-labin { + width: min(40rem, calc(100vw - 32px)); +} + +.m02-labin__qty { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m02-labin__qty-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-labin__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labin__lost { + margin: 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labin__foot { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + margin-top: var(--spacing-12); +} + +/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */ +.m02-labview { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-labview__title { + margin: 0; + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts deleted file mode 100644 index 985d1a1f8..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor.ts - * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추 - * (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` · - * 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`). - * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). - * ========================================================================== */ - -import { el, showConfirmDialog } from "@ui/ui_template_elements"; -import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; -import { - editComboTree, - editComboTreeMany, - fetchComboList, - pasteCombos, - type ComboList, - type ComboTreeEdit, - type LaborScope, -} from "./M02_MasterTemplete_Labor_Api"; -import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit"; -import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; - -/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */ -function treeError(error: unknown): Error { - if (!(error instanceof ApiError)) - return error instanceof Error ? error : new Error(String(error)); - const d = error.detail as { errors?: string[]; stale?: unknown } | string | null; - const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`; - return error.status === 409 ? new StaleError(text) : new Error(text); -} - -export interface LaborLibraryOptions { - scope: LaborScope; - canEdit: boolean; - /** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */ - copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[]; - /** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */ - reveal: () => Promise; -} - -export interface LaborLibrary { - /** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */ - root: HTMLElement; - /** 좌측 패널 나무 */ - tree: HTMLElement; - /** 나무 맨 위 더하기 「+」(층 단추 옆) */ - add: HTMLElement; - /** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */ - back: () => Promise; - refresh: () => Promise; - /** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */ - tools: HTMLElement | null; - /** 메인 칸 「저장 안 한 고침」 손잡이 */ - bind: (setDirty: (dirty: boolean) => void) => void; -} - -export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { - const { scope } = opt; - let setDirty: (dirty: boolean) => void = () => {}; - let keys = new Set(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나 - const took = (list: ComboList): ComboList => { - keys = new Set(list.줄.map((c) => c.키)); - editor.setList(list.줄); - return list; - }; - const editor = createLaborEditor({ - scope, - canEdit: opt.canEdit, - onDirty: (dirty) => setDirty(dirty), - onSaved: async (key) => { - await tree.refresh(); - tree.select(key); - }, - }); - const tree = createTreePanel({ - layer: scope.layer, - owner: scope.owner, - canEdit: opt.canEdit, - maxDepth: 2, - item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) }, - api: { - fetch: async () => { - return toView(took(await fetchComboList(scope))); - }, - edit: async (_layer, edit, 판) => { - const body = { ...edit, 판 } as ComboTreeEdit; - const got = await editComboTree(body, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), noteOf(edit, got.지운조합)); - }, - // 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장 - many: async (_layer, body, 판) => { - const ops = body.대상.map((ref) => ({ - op: body.op, - ...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }), - ...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}), - })); - const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), { op: body.op, 지운항목: got.지운조합 }); - }, - // 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로 - paste: async (layer, owner, clip, dest) => { - const to = { layer, owner } as LaborScope; - const pick = { - 키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])), - 폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])), - }; - const got = await fetchComboList(to) - .then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)) - .catch((error: unknown) => { - throw treeError(error); - }); - const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null); - return toView(took(here ? got : await fetchComboList(scope))); - }, - }, - clipKind: "일위대가", - copyTo: opt.copyTo, - onPick: async (item) => { - await opt.reveal(); - tree.select(item.key); - void editor.open(item.key); - }, - // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) - settle: async () => { - if (!editor.isDirty()) return "clean"; - if (!(await showConfirmDialog(tc("BackAsk")))) return false; - editor.showList(); - return "changed"; - }, - // 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록 - onChanged: async () => { - const open = editor.current(); - if (!open) return; - if (keys.has(open)) await editor.open(open); - else editor.showList(); - }, - }); - // 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름 - tree.search - .querySelector("input") - ?.addEventListener("input", (event) => - editor.setQuery((event.target as HTMLInputElement).value), - ); - return { - root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }), - tree: tree.root, - add: tree.add, - back: async () => { - await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움 - if (editor.current() === null) return; - if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return; - editor.showList(); - }, - refresh: async () => { - const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6) - try { - await tree.refresh(); - } finally { - stop(); - } - }, - tools: editor.newButton, - bind: (fn) => (setDirty = fn), - }; -} - -export interface LaborSectionOptions { - /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ - show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void; - /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ - confirmLeave: () => Promise; - /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ - onOpen: () => void; - /** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */ - make: (reveal: () => Promise) => LaborLibrary; - /** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */ - bar?: (add: HTMLElement, back: () => void) => HTMLElement; - /** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */ - wrap?: (tree: HTMLElement) => HTMLElement; -} - -export interface LaborSection { - title: string; - body: HTMLElement; - open: () => Promise; - /** 다른 화면을 열었음 — 고름 표시만 지움 */ - clear: () => void; -} - -/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */ -export function buildLaborSection(opt: LaborSectionOptions): LaborSection { - let opened = false; - const mark = (on: boolean): void => { - opened = on; - }; - - async function open(): Promise { - if (opened || !(await opt.confirmLeave())) return; - library.bind(opt.show(tc("Library"), library.root, library.tools)); - opt.onOpen(); - mark(true); - await library.refresh(); - } - // 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음 - const library = opt.make(open); - void library.refresh(); - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - children: [ - ...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []), - opt.wrap ? opt.wrap(library.tree) : library.tree, - ], - }); - return { title: tc("Title"), body, open, clear: () => mark(false) }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 4dfb87e84..447e53967 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,7 +12,13 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc"; +import { + getActiveLayer, + getActiveOwner, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; /** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ export interface ComboLogic { @@ -56,28 +62,6 @@ export interface ComboOne { combo: Combo; 줄: ComboLine[]; } -export interface PreviewLine { - 차례: number; - 로직: string; - 이름: string; - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} -export interface PreviewAnswer { - 줄: PreviewLine[]; - 노무비: number; - 재료비: number; - 경비: number; - 계: number; - 멈춤: string[]; -} - /** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */ export interface LaborScope { layer: Exclude; @@ -95,15 +79,6 @@ export interface ComboLayerList extends ComboList { layer: LaborScope["layer"]; owner: string | null; } -export interface ComboTreeEdit { - op: "add-folder" | "rename" | "move" | "delete"; - 경로?: string[]; - 이름?: string; - 새경로?: string[]; - 키?: string; - 판: string; -} - async function request(url: string, method = "GET", body?: unknown): Promise { const response = await fetch(`${API_BASE_URL}/m02${url}`, { method, @@ -197,43 +172,6 @@ export const deleteCombo = ( scope: LaborScope = SYSTEM_SCOPE, ): Promise => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE"); -/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */ -export const editComboTree = ( - body: ComboTreeEdit, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/tree"), "POST", body); - -/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */ -export const editComboTreeMany = ( - ops: Omit[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/tree-many"), "POST", { 판, ops }); - -/** 층 사이 복사(PLAN 30-3) — `from` 층의 조합 · 폴더를 이 층 `경로` 폴더에 새 키로 */ -export const pasteCombos = ( - from: LaborScope, - pick: { 키?: string[]; 폴더?: string[][] }, - 경로: string[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/paste"), "POST", { - from_layer: from.layer, - from_owner: from.owner, - ...pick, - 경로, - 판, - }); - -/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ -export const previewCombo = ( - combo: Combo | string, - inputs: Record>, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/preview"), "POST", { combo, inputs }); - /* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ export const fetchLibLogics = (): Promise => request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); @@ -248,3 +186,117 @@ export const fetchLibAuto = (key: string): Promise> => request<{ 값?: Record }>(`/logic-lib/auto?key=${enc(key)}`) .then((d) => d.값 ?? {}) .catch(() => ({})); + +/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ + +/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ +export type LaborQty = { 손: number } | null; +/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */ +export interface LaborRow { + 로직: string; + 메모: string | null; + 입력값: Record; + 수량: LaborQty; +} +export interface LaborMat { + 코드: string; + 이름: string | null; + 규격: string | null; + 단위: string | null; + 수량: LaborQty; +} +export interface LaborDoc { + 종류: "일위대가"; + 판?: number; + 열: string; + 일위대가번?: string; + 단위: string | null; + 본뜬?: { 층: string; 주인: string | null; 키: string } | null; + 담은로직: LaborRow[]; + 자재: LaborMat[]; + 계산값?: unknown; +} +/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ +export function normalizeLabor(doc: unknown, column: string): LaborDoc { + const d = (doc && typeof doc === "object" ? doc : {}) as Partial; + return { + ...d, + 종류: "일위대가", + 열: d.열 ?? column, + 단위: d.단위 ?? null, + 담은로직: (d.담은로직 ?? []).map((r) => ({ + 로직: r.로직, + 메모: r.메모 ?? null, + 입력값: r.입력값 ?? {}, + 수량: r.수량 ?? null, + })), + 자재: (d.자재 ?? []).map((m) => ({ + 코드: m.코드, + 이름: m.이름 ?? null, + 규격: m.규격 ?? null, + 단위: m.단위 ?? null, + 수량: m.수량 ?? null, + })), + }; +} + +/** 면적산출 문서의 알약 → {칸, 값}(식 칸 = 서버 `계산값` · 그 밖 = 칸 값 · 수만) — 서버 `area_values` 와 같은 규칙 */ +export function areaLinks(area: unknown): Map { + const out = new Map(); + const doc = (area && typeof area === "object" ? area : {}) as { + 시트?: { + id?: string; + 칸?: Record; + 연결?: { 키?: string; 칸?: string }[]; + }[]; + 계산값?: Record>; + }; + for (const sheet of doc.시트 ?? []) { + const solved = doc.계산값?.[sheet.id ?? ""] ?? {}; + for (const link of sheet.연결 ?? []) { + if (!link.키 || !link.칸) continue; + const raw = solved[link.칸]?.수 ?? sheet.칸?.[link.칸]?.값; + const n = + raw === null || raw === undefined || raw === "" + ? NaN + : Number(String(raw).replace(/,/g, "")); + out.set(link.키, { 칸: link.칸, 값: Number.isFinite(n) ? n : null }); + } + } + return out; +} + +/** calc 답 모양 · 수량 · 반영 짝 셈 = `M02_MasterTemplete_Labor_Calc.ts`(sub7 · ↔ `M02_Labor_Doc.py`) */ +export type { + CalcLine as LaborCalcLine, + CalcMaterial as LaborCalcMat, + LaborCalc, + Money as LaborCost, +} from "./M02_MasterTemplete_Labor_Calc"; + +/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ +const laborAt = (layer: Layer, column: string, tail: string): string => { + const owner = layer === getActiveLayer() ? getActiveOwner() : null; + const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; + return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; +}; + +/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = {알약 키: 칸 값} 바꿔 끼우기 */ +export const calcLabor = ( + layer: Layer, + column: string, + doc: LaborDoc | null, + 수량?: Record, +): Promise => + request(laborAt(layer, column, "/calc"), "POST", { + ...(doc ? { doc } : {}), + ...(수량 ? { 수량 } : {}), + }); + +/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */ +export const fetchLogicInputs = ( + keys: string[], +): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> => + keys.length + ? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`) + : Promise.resolve({ 로직: [], 빠진로직: [] }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts new file mode 100644 index 000000000..dcff3da3c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -0,0 +1,113 @@ +/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함. + * + * 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 — + * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. + * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 + * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */ + +export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; +export type Money = Record<(typeof MONEY_KEYS)[number], number>; +export type QtySource = "손" | "칸" | "없음"; +/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */ +export type Qty = { 손: number } | null; + +export interface CalcLine { + 로직: string; + 알약?: string | null; + 분모?: string | null; + 배: number; + 기준: Money | null; + 수량?: number | null; + 수량출처?: QtySource; + 반영?: Money | null; + 까닭?: string | null; + 결과?: unknown; +} + +export interface CalcMaterial { + 코드: string; + 알약?: string | null; + 기준단가: number | null; + 수량?: number | null; + 수량출처?: QtySource; + 반영?: number | null; + 까닭?: string | null; +} + +export interface LaborCalc { + 줄: CalcLine[]; + 자재: CalcMaterial[]; + 기준합?: Money; + 반영합?: Money; +} + +/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ +export interface LaborQtyDoc { + 담은로직?: { 로직: string; 수량?: Qty }[]; + 자재?: { 코드: string; 수량?: Qty }[]; +} + +function num(raw: unknown): number | null { + if (raw === null || raw === undefined || typeof raw === "boolean") return null; + const n = Number(String(raw).replace(/,/g, "").trim()); + return String(raw).trim() !== "" && Number.isFinite(n) ? n : null; +} + +function zero(): Money { + return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 }; +} + +function pick( + hand: Qty | undefined, + key: string | null | undefined, + given: Record, +): [number | null, QtySource] { + const own = hand ? num(hand.손) : null; + if (own !== null) return [own, "손"]; + if (key && key in given) return [given[key], "칸"]; + return [null, "없음"]; +} + +/** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */ +export function reflect(base: Money, qty: number | null, times: number): Money | null { + if (qty === null) return null; + const out = zero(); + for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times; + return out; +} + +/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */ +export function settle( + result: LaborCalc, + doc: LaborQtyDoc, + given: Record, +): LaborCalc { + const have: Record = {}; + for (const [k, raw] of Object.entries(given)) { + const v = num(raw); + if (v !== null) have[k] = v; + } + const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null])); + const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); + const base = zero(); + const done = zero(); + const add = (sum: Money, m: Money | null): void => { + if (m) for (const k of MONEY_KEYS) sum[k] += m[k]; + }; + const 줄 = result.줄.map((line) => { + const [qty, source] = pick(hands.get(line.로직), line.알약, have); + const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null; + add(base, line.기준); + add(done, own); + return { ...line, 수량: qty, 수량출처: source, 반영: own }; + }); + const 자재 = result.자재.map((one) => { + const [qty, source] = pick(mhands.get(one.코드), one.알약, have); + const price = num(one.기준단가); + const cost = price !== null && qty !== null ? price * qty : null; + if (price !== null) add(base, { ...zero(), 재료비: price, 계: price }); + if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost }); + return { ...one, 수량: qty, 수량출처: source, 반영: cost }; + }); + return { 줄, 자재, 기준합: base, 반영합: done }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts new file mode 100644 index 000000000..33f599bfe --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -0,0 +1,324 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Detail.ts + * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 + * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. + * · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장] + * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 + * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { ROUTES } from "@config/config_frontend"; +import { navigateTo } from "../A00_Common/router"; +import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; +import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { + areaLinks, + calcLabor, + fetchLogicInputs, + normalizeLabor, + type LaborCalc, + type LaborDoc, + type LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { + buildLaborDiagram, + diagramTitle, + renderComboDiagram, +} from "./M02_MasterTemplete_Labor_Diagram"; +import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { keepKnown } from "./M02_Labor_Values_Model"; +import { pickMaterial } from "./M02_Material_Pick"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; +import "./M02_MasterTemplete_Labor.css"; + +export interface LaborDetailOptions { + layer: Layer; + column: string; + readOnly: boolean; + /** 저장 안 한 고침이 생기거나 사라짐 */ + onChanged: (dirty: boolean) => void; + /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; +} + +export interface LaborDetailHandle { + getDoc: () => LaborDoc; + destroy: () => void; +} + +export function mountLaborDetail( + host: HTMLElement, + doc: unknown, + opt: LaborDetailOptions, +): LaborDetailHandle { + const cur = normalizeLabor(doc, opt.column); + const saved = JSON.stringify(cur); + const edit = !opt.readOnly; + const logics = new Map(); + let calc: LaborCalc | null = null; + let fail: string | null = null; + let seq = 0; + let timer = 0; + let alive = true; + /** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */ + let links = new Map(); + const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); + const given = (): Record => + Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); + + const logicHost = el("div"); + const matHost = el("div"); + const dgHost = el("div"); + const dgBox = el("details", { + className: "m01lab__box m01-logic__section m02-labor__dg", + attrs: { open: "" }, + children: [el("summary", { text: diagramTitle() }), dgHost], + }); + + const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); + const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); + + const drawDg = (): void => { + if (!dgBox.open) return; + const data = buildLaborDiagram({ + rows: cur.담은로직, + logics: [...logics.values()], + calc, + mats: cur.자재.length, + }); + renderComboDiagram(dgHost, data, { + focusInput: (key) => { + const i = cur.담은로직.findIndex((r) => r.로직 === key); + if (i >= 0) openRow(i); + }, + // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) + openLogic: (key) => { + setPendingLogicKey(key); + navigateTo(ROUTES.M01_MASTER_DATA); + }, + }); + }; + dgBox.addEventListener("toggle", drawDg); + + const draw = (): void => { + logicHost.replaceChildren( + logicTable( + { rows: cur.담은로직, logics, calc, fail, cells: cells() }, + // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) + edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + ), + ); + matHost.replaceChildren( + matTable( + { mats: cur.자재, calc, fail, cells: cells() }, + edit ? { open: openMat, remove: removeMat } : { open: openMat }, + ), + ); + drawDg(); + }; + + /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + calc = null; + fail = null; + draw(); + try { + const got = await calcLabor(opt.layer, opt.column, cur); + if (!alive || mine !== seq) return; + calc = got; + } catch (error) { + if (!alive || mine !== seq) return; + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + draw(); + }, 150); + }; + + /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ + const changed = (qtyOnly = false): void => { + opt.onChanged(JSON.stringify(cur) !== saved); + if (qtyOnly && calc) { + calc = settle(calc, cur, given()); + draw(); + } else recalc(); + }; + + /** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */ + const linkedText = (pill: string | null | undefined, unit: string): string | null => { + const at = pill ? links.get(pill) : undefined; + if (!at) return null; + return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; + }; + + /** 아직 안 받은 로직 입력 목록만 받음 */ + const loadLogics = async (): Promise => { + const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + if (!want.length) return; + try { + const got = await fetchLogicInputs(want); + for (const l of got.로직) logics.set(l.로직, l); + } catch (error) { + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + }; + + function move(i: number, d: number): void { + const j = i + d; + if (j < 0 || j >= cur.담은로직.length) return; + [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; + changed(); + } + function removeRow(i: number): void { + cur.담은로직.splice(i, 1); + changed(); + } + function removeMat(i: number): void { + cur.자재.splice(i, 1); + changed(); + } + + function openRow(i: number): void { + const row: LaborRow = cur.담은로직[i]; + const logic = logics.get(row.로직) ?? null; + const line = lineOf(row.로직); + openLaborInput({ + title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), + logic, + values: row.입력값, + memo: row.메모, + seat: { + qty: row.수량, + linked: linkedText(line?.알약, line?.분모 ?? ""), + unit: line?.분모 ?? "", + }, + readOnly: !edit, + goArea: opt.goArea, + done: (next) => { + const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값); + row.입력값 = values; + row.메모 = next.메모; + row.수량 = next.수량; + changed(qtyOnly); + }, + }); + } + + function openMat(i: number): void { + const mat = cur.자재[i]; + const line = matOf(mat.코드); + openQtyModal( + `${mat.코드} ${mat.이름 ?? ""}`.trim(), + { + qty: mat.수량, + linked: linkedText(line?.알약, mat.단위 ?? ""), + unit: mat.단위 ?? "", + }, + !edit, + opt.goArea, + (qty) => { + mat.수량 = qty; + changed(true); + }, + ); + } + + const addLogic = createButton({ + label: tc("AddLogic"), + variant: "filled", + size: "sm", + onClick: () => + openLogicPicker((x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(() => changed()); + }), + }); + const addMat = createButton({ + label: tc("Labor_AddMat"), + variant: "filled", + size: "sm", + onClick: () => + pickMaterial((m) => { + if (cur.자재.some((x) => x.코드 === m.코드)) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }), + }); + + const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); + unit.input.value = cur.단위 ?? ""; + unit.input.readOnly = !edit; + unit.input.maxLength = 20; + unit.input.setAttribute("data-labor-field", "단위"); + unit.input.addEventListener("input", () => { + cur.단위 = unit.input.value.trim() || null; + opt.onChanged(JSON.stringify(cur) !== saved); + }); + const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); + origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.readOnly = true; + + const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [el("h3", { text: title }), ...(edit && button ? [button] : [])], + }), + body, + ], + }); + + host.replaceChildren( + el("div", { + className: "m01-logic__editor m01-combo m02-labor__edit", + attrs: { "data-labor": opt.column }, + children: [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })], + }), + section(tc("Logics_Title"), addLogic, logicHost), + section(tc("Labor_Mats"), addMat, matHost), + dgBox, + ], + }), + ); + draw(); + // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 + void Promise.all([ + loadLogics(), + readTemplate(opt.layer, "area", opt.column, null).then( + (got) => (links = areaLinks(got.문서)), + () => undefined, + ), + ]).then(() => { + if (alive) recalc(); + }); + + return { + getDoc: () => cur, + destroy: () => { + alive = false; + window.clearTimeout(timer); + host.replaceChildren(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css index fa21deb41..67a16d23a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -309,3 +309,13 @@ cursor: pointer; font-weight: var(--font-weight-bold); } + +/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */ +.m02-labordg__pill.is-auto .m02-labordg__pill-value { + font-style: italic; + color: var(--color-text-secondary); +} + +.m02-labordg__card.is-material { + cursor: default; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 0348ae7b4..fb987d593 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -1,8 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Diagram.ts * 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값) - * 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) - * 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT + * 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) + * 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) · + * 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -29,7 +30,9 @@ const TEXT = { "누르면 이 로직을 단가산출 로직 화면에서 엶", "Open this logic in the unit-price logic screen", ], - Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"], + Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"], + Mats: ["자재목록", "Materials"], + Auto: ["자동값", "auto"], Same: ["같은 이름의 넣을 값", "Same input name"], Won: ["원", "KRW"], Title: ["다이어그램", "Diagram"], @@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number]; export interface DiagramInput { name: string; unit: string; - /** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */ + /** 넣은 값 — 빈 글 = 값 없음 */ value: string; + /** 넣은 값이 없어 자동값을 보임 */ + auto?: boolean; } /** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */ @@ -64,6 +69,10 @@ export interface DiagramLogic { unit: string; inputs: DiagramInput[]; out: DiagramOut; + /** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */ + basis?: string; + /** 자재목록 카드 — 누름 없음 */ + material?: boolean; } export interface DiagramData { @@ -73,53 +82,86 @@ export interface DiagramData { export interface DiagramHandlers { /** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */ openLogic(key: string): void; - /** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */ + /** 넣을 값 알약 누름 — 그 로직 입력 모달 */ focusInput(key: string, name: string): void; } -/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */ +/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */ -interface PreviewLineLike { - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} +type Money = { 노무비: number; 재료비: number; 경비: number; 계: number }; -export interface DiagramSource { +export interface LaborDiagramSource { /** 담은 차례대로 */ - rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[]; - /** 로직 키 → 입력 이름 → 넣은 값 */ - values: Record>; - /** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */ - lines: PreviewLineLike[] | null; + rows: { 로직: string; 입력값: Record }[]; + /** 로직 입력 목록(`/logic-lib/inputs`) */ + logics: { + 로직: string; + 이름: string; + 결과단위?: string; + 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; + }[]; + /** calc 답 · 아직이면 null */ + calc: { + 줄: { + 로직: string; + 기준: Money | null; + 반영?: Money | null; + 결과?: unknown; + 까닭?: string | null; + }[]; + 자재: { 기준단가: number | null; 반영?: number | null }[]; + } | null; + /** 담은 자재 수 — 0 이면 자재 카드 없음 */ + mats: number; } -const outOf = (l: PreviewLineLike | undefined): DiagramOut => { - if (!l) return null; - if (!l.ok) return { 멈춤: l.까닭 ?? "" }; - if (l.갈래 === "결과") return { 값: l.결과 ?? 0 }; - return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 }; -}; +const autoOf = (v: unknown): string => + typeof v === "string" || typeof v === "number" ? String(v) : ""; -export function buildDiagramData(src: DiagramSource): DiagramData { - return { - logics: src.rows.map((r, i) => ({ - key: r.key, - name: r.name, - unit: r.unit, - inputs: r.inputs.map((x) => ({ - name: x.name, - unit: x.unit, - value: src.values[r.key]?.[x.name] ?? "", - })), - out: outOf(src.lines?.[i]), - })), - }; +export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { + const logics: DiagramLogic[] = src.rows.map((r) => { + const def = src.logics.find((l) => l.로직 === r.로직); + const line = src.calc?.줄.find((l) => l.로직 === r.로직); + let out: DiagramOut = null; + let basis: string | undefined; + if (line?.까닭) out = { 멈춤: line.까닭 }; + else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 }; + else if (line && (line.반영 || line.기준)) { + out = { ...((line.반영 ?? line.기준) as Money) }; + basis = line.반영 ? "반영" : "기준"; + } + return { + key: r.로직, + name: def?.이름 ?? r.로직, + unit: def?.결과단위 ?? "", + inputs: (def?.입력 ?? []).map((x) => { + const mine = r.입력값[x.이름]; + const has = mine !== undefined && mine !== ""; + return { + name: x.이름, + unit: x.단위 ?? "", + value: has ? String(mine) : autoOf(x.자동값), + ...(has || !autoOf(x.자동값) ? {} : { auto: true }), + }; + }), + out, + ...(basis ? { basis } : {}), + }; + }); + if (src.mats > 0) { + const mats = src.calc?.자재 ?? null; + const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0; + logics.push({ + key: "", + name: `${td("Mats")} ${src.mats}`, + unit: "", + inputs: [], + out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null, + ...(mats ? { basis: mats.every((m) => typeof m.반영 === "number") ? "반영" : "기준" } : {}), + material: true, + }); + } + return { logics }; } /* ── 글 ─────────────────────────────────────────────────────────────────── */ @@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o; const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0); function summary(l: DiagramLogic): string { - if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; + if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim(); if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`; return td("Wait"); @@ -360,8 +402,12 @@ export function renderComboDiagram( ? l.inputs.map((x) => { const empty = x.value.trim() === ""; const pill = el("button", { - className: `m02-labordg__pill${empty ? " is-empty" : ""}`, - attrs: { type: "button", "data-name": x.name, title: td("Goto") }, + className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`, + attrs: { + type: "button", + "data-name": x.name, + title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"), + }, children: [ el("span", { className: "m02-labordg__pill-name", text: x.name }), el("span", { @@ -376,8 +422,12 @@ export function renderComboDiagram( : [], }); const card = el("button", { - className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`, - attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") }, + className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`, + attrs: { + type: "button", + style: `grid-column:3;grid-row:${row}`, + ...(l.material ? {} : { title: td("Open") }), + }, children: [ el("span", { className: "m02-labordg__card-key", text: l.key }), el("strong", { className: "m02-labordg__card-name", text: l.name }), @@ -385,7 +435,7 @@ export function renderComboDiagram( el("span", { className: "m02-labordg__card-out", text: summary(l) }), ], }); - card.addEventListener("click", () => on.openLogic(l.key)); + if (!l.material) card.addEventListener("click", () => on.openLogic(l.key)); const mine = bins.map((b, c) => { const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b); sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index f09b181de..dfa1780f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Edit.ts * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 - * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). + * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 · + * 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감. * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ @@ -12,10 +13,7 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { ROUTES } from "@config/config_frontend"; -import { navigateTo } from "../A00_Common/router"; import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { deleteCombo, editCombo, @@ -26,9 +24,7 @@ import { type ComboSummary, type LaborScope, } from "./M02_MasterTemplete_Labor_Api"; -import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { matchText } from "./M02_MasterTemplete_TreePanel_Model"; import { loadingError, showLoading } from "@ui/ui_template_loading"; @@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }); return f.root; }; - const preview = buildPreview(() => combo); - // 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면 - const dgHost = el("div"); - const dgBox = el("details", { - className: "m01lab__box m01-logic__section m02-labor__dg", - attrs: { open: "" }, - children: [el("summary", { text: diagramTitle() }), dgHost], - }); - const drawDg = (): void => { - if (dgBox.open) - renderComboDiagram(dgHost, preview.data(), { - focusInput: preview.focusInput, - openLogic: async (logic) => { - if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return; - setPendingLogicKey(logic); - navigateTo(ROUTES.M01_MASTER_DATA); - }, - }); - }; - let dgTimer = 0; - preview.onUpdate(() => { - window.clearTimeout(dgTimer); - dgTimer = window.setTimeout(drawDg, 120); - }); - dgBox.addEventListener("toggle", drawDg); const table = el("div", { className: "m01-master__grid-wrap" }); const drawTable = (): void => { const move = (i: number, d: number): void => { @@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }), ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]), ); - void preview.redraw(); }; const add = createButton({ @@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { table, ], }), - dgBox, - preview.root, ); drawTable(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts new file mode 100644 index 000000000..13afe0850 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -0,0 +1,246 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Input.ts + * 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량 + * (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`). + * [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborQty } from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createValueControl } from "./M02_Labor_Values"; +import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; +import "./M02_MasterTemplete_Labor.css"; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface QtySeat { + /** 지금 수량 */ + qty: LaborQty; + /** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */ + linked: string | null; + /** 손 입력 단위 표기(분모 · 자재 단위) */ + unit: string; +} + +interface QtyPart { + root: HTMLElement; + /** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */ + read: () => LaborQty | undefined; +} + +/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ +function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { + const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; + const radio = (value: "cell" | "hand"): HTMLInputElement => { + const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; + input.disabled = readOnly; + return input; + }; + const cell = radio("cell"); + const hand = radio("hand"); + const number = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "number", step: "any", "data-labor-qty": "hand" }, + }) as HTMLInputElement; + number.readOnly = readOnly; + if (seat.qty) number.value = String(seat.qty.손); + (seat.qty ? hand : cell).checked = true; + number.disabled = !hand.checked; + for (const r of [cell, hand]) + r.addEventListener("change", () => { + number.disabled = !hand.checked; + if (hand.checked) number.focus(); + }); + const go = goArea + ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) + : null; + const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => + el("label", { + className: "m02-labin__qty-line", + children: [input, el("span", { text: label }), ...rest], + }); + const root = el("section", { + className: "m02-labin__qty", + children: [ + el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }), + line( + cell, + tc("In_QtyCell"), + el("small", { + className: "m02-labin__muted", + text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), + }), + ...(go ? [go] : []), + ), + line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), + ], + }); + return { + root, + read: () => { + if (!hand.checked) return null; + const v = Number(number.value); + return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined; + }, + }; +} + +function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { + return el("div", { + className: "m02-labin__foot", + children: readOnly + ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })] + : [ + createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }), + createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + ], + }); +} + +export interface LaborInputResult { + 입력값: LaborValues; + 메모: string | null; + 수량: LaborQty; +} + +export interface LaborInputOptions { + /** 「GF000219 메쌓기」 */ + title: string; + /** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */ + logic: ItemLogic | null; + values: LaborValues; + memo: string | null; + seat: QtySeat; + readOnly: boolean; + /** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; + done: (next: LaborInputResult) => void; +} + +/** 담은 로직 줄 입력 모달 */ +export function openLaborInput(opt: LaborInputOptions): void { + const inputs: ItemInput[] = opt.logic?.입력 ?? []; + const { kept, lost } = keepKnown(inputs, opt.values); + const draft: LaborValues = { ...kept }; + const bad = new Set(); + openModal({ + title: fill(tc("In_Title"), opt.title), + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const rows = inputs.map((input) => { + const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => { + if (isBad) bad.add(input.이름); + else bad.delete(input.이름); + if (value === null) delete draft[input.이름]; + // 고르기 · 범위 없는 칸도 수로 읽히면 수로 둠(M01 넣을 값과 같게) + else + draft[input.이름] = + typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) + ? Number(value) + : value; + }); + field.control.disabled = opt.readOnly; + field.control.setAttribute("data-labor-input", input.이름); + const hint = valueHint(input); + return el("div", { + className: "m02-inputval__row", + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") }, + }), + el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }), + ], + }); + }); + const memo = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "text", maxlength: "200", "data-labor-memo": "" }, + }) as HTMLInputElement; + memo.value = opt.memo ?? ""; + memo.readOnly = opt.readOnly; + const qty = qtyPart( + opt.seat, + opt.readOnly, + opt.goArea && + (() => { + close(); + opt.goArea?.(); + }), + ); + const ok = (): void => { + if (bad.size) return showToast(tc("In_Bad"), "error"); + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 }); + close(); + }; + body.append( + el("div", { + className: "m02-inputval", + children: [ + ...(lost.length + ? [ + el("p", { + className: "m02-labin__lost", + text: fill(tc("Labor_Lost"), lost.join(", ")), + }), + ] + : []), + ...(rows.length + ? rows + : [el("p", { className: "m02-labin__muted", text: tc("In_None") })]), + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text: tc("In_Memo") }), + memo, + ], + }), + qty.root, + ], + }), + footer(opt.readOnly, ok, close), + ); + }, + }); +} + +/** 자재 줄 수량 모달 */ +export function openQtyModal( + title: string, + seat: QtySeat, + readOnly: boolean, + goArea: (() => void) | null, + done: (qty: LaborQty) => void, +): void { + openModal({ + title, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const qty = qtyPart( + seat, + readOnly, + goArea && + (() => { + close(); + goArea(); + }), + ); + const ok = (): void => { + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + done(수량); + close(); + }; + body.append(qty.root, footer(readOnly, ok, close)); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts deleted file mode 100644 index 68cc72e01..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음 - * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) - * ========================================================================== */ - -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { - fetchLibAuto as fetchAuto, - fetchLibLogic as fetchLogic, - previewCombo, - type Combo, -} from "./M02_MasterTemplete_Labor_Api"; -import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram"; -import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; - -const COSTS = ["노무비", "재료비", "경비"] as const; -const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { - 노무비: "Prev_Labor", - 재료비: "Prev_Material", - 경비: "Prev_Expense", -}; - -const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ -const fmt = formatMoney; - -/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ -const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); - -/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */ -function inputCell( - input: LogicInput, - key: string, - values: Record>, - onEdit: () => void, - onInput: () => void, -): HTMLElement { - const mine = (values[key] ??= {}); - const value = mine[input.이름] ?? initial(input); - mine[input.이름] = value; - const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); - const field = choices - ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) - : createInputField({ type: "text", label: input.이름, compact: true }); - const control = "select" in field ? field.select : field.input; - control.value = value; - control.setAttribute("data-input", `${key}|${input.이름}`); - const edit = (): void => { - mine[input.이름] = control.value; - onInput(); - }; - control.addEventListener("input", edit); - control.addEventListener("change", () => { - edit(); - onEdit(); // 바꾸면 다시 미리 보기 계산 - }); - return field.root; -} - -export interface PreviewHandle { - root: HTMLElement; - /** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */ - redraw: () => Promise; - /** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */ - data: () => DiagramData; - /** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */ - onUpdate: (fn: () => void) => () => void; - /** 그 입력 칸으로 가서 깜빡임 */ - focusInput: (key: string, name: string) => void; -} - -export function buildPreview(combo: () => Combo): PreviewHandle { - const values: Record> = {}; - /** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */ - const inputs = new Map(); - /** 로직마다 자동값은 한 번만 받음 */ - const asked = new Set(); - /** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */ - const meta = new Map(); - let lines: Parameters[0]["lines"] = null; - const listeners = new Set<() => void>(); - const notify = (): void => listeners.forEach((f) => f()); - const form = el("div", { className: "m01-logic__stack" }); - const out = el("div", { - className: "m01-master__grid-wrap", - attrs: { "data-combo": "preview" }, - }); - const rowOf = (cells: string[], note = "", bold = false): HTMLElement => - el("tr", { - className: bold ? "m01lab__subtotal" : "", - children: cells.map((t, i) => { - const text = i === 0 && note ? `${t} — ${note}` : t; - return el("td", { text, attrs: { title: text } }); - }), - }); - - const run = async (): Promise => { - out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); - lines = null; - notify(); - const send: Record> = {}; - for (const [key, defs] of inputs) { - send[key] = {}; - for (const i of defs) { - const raw = values[key]?.[i.이름] ?? initial(i); - if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) - send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); - } - } - let got; - try { - got = await previewCombo(combo(), send); - lines = got.줄; - notify(); - } catch (error) { - out.replaceChildren( - el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), - ); - return; - } - const trs = got.줄.map((l) => - rowOf( - [ - `${l.로직} ${l.이름}`, - fmt(l.노무비 ?? 0), - fmt(l.재료비 ?? 0), - fmt(l.경비 ?? 0), - fmt(l.계 ?? 0), - ], - l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`, - ), - ); - trs.push( - rowOf( - [tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)], - "", - true, - ), - ); - const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")]; - out.replaceChildren( - el("table", { - className: "m01-master__grid", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: trs }), - ], - }), - ); - }; - - const button = el("button", { - className: "ui-btn ui-btn--filled", - text: tc("Prev_Run"), - attrs: { type: "button", "data-combo": "run" }, - }); - button.addEventListener("click", () => void run()); - - const redraw = async (): Promise => { - const blocks: HTMLElement[] = []; - inputs.clear(); - for (const row of combo().담은로직) { - try { - const [one, auto] = await Promise.all([ - fetchLogic(row.로직), - asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직), - ]); - asked.add(row.로직); - const mine = (values[row.로직] ??= {}); - for (const i of one.logic.입력) { - const got = (auto as Record)[i.이름]; - if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue; - mine[i.이름] = String(got); - } - inputs.set(row.로직, one.logic.입력); - meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" }); - blocks.push( - el("div", { - className: "m01lab__info", - children: [ - el("strong", { - className: "m01lab__cell--wide", - text: `${row.로직} ${one.logic.이름}`, - }), - ...one.logic.입력.map((i) => - inputCell(i, row.로직, values, () => void run(), notify), - ), - ], - }), - ); - } catch { - /* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */ - } - } - form.replaceChildren(...blocks); - notify(); - if (blocks.length) - await run(); // 열자마자 한 번 미리 보기 계산 - else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); - }; - - const root = el("section", { - className: "m01lab__box m01-logic__section", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [el("h3", { text: tc("Prev_Title") })], - }), - el("h4", { text: tc("Prev_Inputs") }), - form, - button, - out, - ], - }); - const data = (): DiagramData => - buildDiagramData({ - rows: combo().담은로직.map((r) => ({ - key: r.로직, - name: meta.get(r.로직)?.name ?? r.로직, - unit: meta.get(r.로직)?.unit ?? "", - inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })), - })), - values, - lines, - }); - const focusInput = (key: string, name: string): void => { - const box = root.querySelector(`[data-input="${CSS.escape(`${key}|${name}`)}"]`); - if (!box) return; - box.scrollIntoView({ block: "center", behavior: "smooth" }); - box.focus({ preventScroll: true }); - box.classList.add("m02-labor__flash"); - window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600); - }; - const onUpdate = (fn: () => void): (() => void) => { - listeners.add(fn); - return () => listeners.delete(fn); - }; - return { root, redraw, data, onUpdate, focusInput }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts new file mode 100644 index 000000000..9e3bc3fbe --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -0,0 +1,287 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Rows.ts + * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. + * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · + * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. + * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { + LaborCalc, + LaborCalcLine, + LaborCalcMat, + LaborCost, + LaborMat, + LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; +import { valueLine } from "./M02_Labor_Values_Model"; + +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +}; + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */ +export function qtyText( + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, + unit: string, + cells: ReadonlyMap = new Map(), +): string { + if (!line || line.수량 === null || line.수량 === undefined) return ""; + const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; + if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; + const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); + return `${tc("Labor_Qty")} ${cell} = ${v}`; +} + +/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ +function moneyCell( + base: number | null | undefined, + applied: number | null | undefined, + wait: boolean, +): HTMLElement { + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + return el("td", { + className: "m02-labrow__money", + children: [ + el("small", { + className: "m02-labrow__base", + text: show(base), + attrs: { title: tc("Labor_Base") }, + }), + el("span", { + className: "m02-labrow__applied", + text: show(applied), + attrs: { title: tc("Labor_Applied") }, + }), + ], + }); +} + +const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap", + children: [ + el("table", { + className: "ui-general-block__table m02-labrow", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ...(foot.length ? [el("tfoot", { children: foot })] : []), + ], + }), + ], + }); + +/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ +export interface RowHandlers { + open: (i: number) => void; + move?: (i: number, d: number) => void; + remove?: (i: number) => void; +} + +export interface LogicTableData { + rows: LaborRow[]; + logics: Map; + /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ + calc: LaborCalc | null; + fail?: string | null; + /** 알약 키 → 이은 면적산출 칸(「B6」) */ + cells?: ReadonlyMap; +} + +/** 담은 로직 표 — 보기 갈래는 `on` 없이 */ +export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { + const wait = !data.calc && !data.fail; + const { move, remove } = on ?? {}; + const tools = !!(move && remove); + const lineOf = (key: string): LaborCalcLine | undefined => + data.calc?.줄.find((l) => l.로직 === key); + const trs = data.rows.map((row, i) => { + const logic = data.logics.get(row.로직); + const line = lineOf(row.로직); + const unit = logic?.결과단위 ?? ""; + const values = valueLine(logic?.입력 ?? [], row.입력값); + const qty = qtyText(line, line?.분모 ?? "", data.cells); + const note = [values.text, qty].filter(Boolean).join(" · "); + const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); + const small = (label: string, run: () => void): HTMLElement => + createButton({ + label, + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + run(); + }, + }); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-logic": row.로직 }, + children: [ + el("td", { text: String(i + 1) }), + el("td", { + className: "m02-labrow__name", + children: [ + el("span", { className: "m02-labrow__key", text: row.로직 }), + el("strong", { text: logic?.이름 ?? row.로직 }), + ...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []), + ...(values.empty + ? [ + el("small", { + className: "m02-labrow__empty", + text: tc("Labor_Empty").replace("{n}", String(values.empty)), + }), + ] + : []), + ...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []), + ...(why + ? [ + el("small", { + className: "m02-labrow__empty", + text: `${tc("Labor_Calc_Fail")}: ${why}`, + }), + ] + : []), + ], + }), + el("td", { text: unit }), + ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)), + ...(move && remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + small(tc("Remove"), () => remove(i)), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => + part?.[c] ?? null; + const foot = data.rows.length + ? [ + el("tr", { + className: "m02-labrow__sum", + children: [ + el("td", { text: "" }), + el("td", { text: tc("Prev_Sum") }), + el("td", { text: "" }), + ...COSTS.map((c) => + moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait), + ), + ...(tools ? [el("td", { text: "" })] : []), + ], + }), + ] + : []; + const heads = [ + tc("Col_Order"), + tc("Col_Name"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(HEAD[c])), + ...(tools ? [tc("Col_Move")] : []), + ]; + return data.rows.length + ? table(heads, trs, foot) + : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); +} + +export interface MatTableData { + mats: LaborMat[]; + calc: LaborCalc | null; + fail?: string | null; + cells?: ReadonlyMap; +} + +/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +export function matTable(data: MatTableData, on?: Omit): HTMLElement { + const wait = !data.calc && !data.fail; + const remove = on?.remove; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + const trs = data.mats.map((mat, i) => { + const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); + const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { text: mat.코드 }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: mat.이름 ?? "" }), + ...(line?.까닭 + ? [el("small", { className: "m02-labrow__empty", text: line.까닭 })] + : []), + ], + }), + el("td", { text: mat.규격 ?? "" }), + el("td", { text: mat.단위 ?? "" }), + el("td", { + className: "m02-labrow__num", + text: qty, + attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) }, + }), + el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), + el("td", { className: "m02-labrow__num", text: show(line?.반영) }), + ...(remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); + remove(i); + }, + }), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Labor_Unit"), + tc("Labor_Qty"), + tc("Labor_BasePrice"), + tc("Labor_AppliedSum"), + ...(remove ? [tc("Col_Move")] : []), + ]; + return data.mats.length + ? table(heads, trs, []) + : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 962d3eab9..5521c55d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -65,16 +65,48 @@ const TEXT = { Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], - Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"], - Prev_Inputs: ["넣을 값", "Inputs"], - Prev_Run: ["미리 보기 계산", "Run preview"], Prev_Labor: ["노무비", "Labor"], Prev_Material: ["재료비", "Material"], Prev_Expense: ["경비", "Expense"], Prev_Sum: ["합계", "Total"], - Prev_Row: ["로직", "Logic"], - Prev_Fail: ["못 셈", "Failed"], - Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"], + // 일위대가 상세(39-3) — 구조물 열마다 하나 + Labor_Title: ["일위대가", "Unit price"], + Labor_Unit: ["단위", "Unit"], + Labor_Origin: ["본뜬 곳", "Copied from"], + Labor_Mats: ["자재목록", "Materials"], + Labor_NoMat: ["담은 자재 없음", "No materials yet"], + Labor_AddMat: ["자재 추가", "Add material"], + Labor_Qty: ["수량", "Quantity"], + Labor_Base: ["기준", "Base"], + Labor_Applied: ["반영", "Applied"], + Labor_BasePrice: ["기준 단가", "Base price"], + Labor_AppliedSum: ["반영 금액", "Applied amount"], + Labor_Calc_Wait: ["계산 중…", "Calculating…"], + Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], + Labor_Empty: ["빈 값 {n}", "{n} empty"], + Labor_Lost: [ + "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", + "Inputs no longer in the logic (dropped on save) — {value}", + ], + Labor_Code: ["코드", "Code"], + Labor_Spec: ["규격", "Spec"], + Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"], + Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], + In_Title: ["넣을 값 — {value}", "Inputs — {value}"], + In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], + In_Memo: ["메모", "Memo"], + In_Qty: ["수량", "Quantity"], + In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyHand: ["손 입력", "Manual"], + In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], + In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], + In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"], + In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"], + In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"], + In_Ok: ["확인", "OK"], + In_Cancel: ["취소", "Cancel"], + In_Close: ["닫기", "Close"], + In_Loading: ["입력 목록 받는 중…", "Loading inputs…"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts deleted file mode 100644 index d18017cae..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Tree.ts - * 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림. - * 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키). - * ========================================================================== */ - -import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch"; -import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api"; - -/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */ -export const pathOf = (c: ComboSummary): string[] => - c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : []; - -/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */ -export const toView = (list: ComboList, note?: TreeNote): TreeView => ({ - 판: list.판, - 폴더: list.폴더, - items: list.줄.map((c) => ({ - key: c.키, - 열: c.키, - 경로: [...pathOf(c), c.이름], - 이름: c.이름, - 단위: c.단위, - })), - ...(note ? { 알림: note } : {}), -}); - -/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */ -export function noteOf(edit: TreeEdit, gone: string[]): TreeNote { - const at = edit.경로 ?? []; - const last = at[at.length - 1] ?? ""; - if (edit.키 && edit.op !== "delete") { - const where = edit.op === "move" ? (edit.새경로 ?? []) : at; - return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone }; - } - const path = - edit.op === "add-folder" - ? [...at, edit.이름 ?? ""] - : edit.op === "rename" - ? [...at.slice(0, -1), edit.이름 ?? ""] - : edit.op === "move" - ? [...(edit.새경로 ?? []), last] - : undefined; - return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts new file mode 100644 index 000000000..35d805140 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_View.ts + * 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영). + * 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`). + * · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만) + * · `setQty({알약 키: 칸 값})` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 바로 곱함 + * (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」) + * · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + areaLinks, + calcLabor, + fetchLogicInputs, + normalizeLabor, + type LaborCalc, + type LaborDoc, +} from "./M02_MasterTemplete_Labor_Api"; +import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; + +export interface LaborViewRow { + kind: "logic" | "material"; + /** 로직 키 · 자재 코드 */ + key: string; + /** 면적산출 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — calc 전이면 null */ + pill: string | null; + name: string; + unit: string; +} + +export interface LaborViewHandle { + root: HTMLElement; + /** 그 열 일위대가를 읽어 그림 — `수량` = 저장 안 한 면적 칸 값(없으면 저장된 면적산출) */ + show: (layer: Layer, column: string | null, 수량?: Record) => Promise; + /** 면적 칸 값이 바뀜(알약 값 전부) — 받은 기준에 바로 곱함(서버 안 부름) */ + setQty: (수량: Record) => void; + /** 지금 문서(없으면 null) */ + doc: () => LaborDoc | null; + destroy: () => void; +} + +export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle { + const root = el("div", { className: "m02-labview" }); + const logics = new Map(); + let doc: LaborDoc | null = null; + let calc: LaborCalc | null = null; + let fail: string | null = null; + let cells = new Map(); + let seq = 0; + + const paint = (): void => { + if (!doc) return; + const d = doc; + const pick = (row: LaborViewRow): (() => void) | undefined => + onRow ? () => onRow(row) : undefined; + const logicOpen = (i: number): void => { + const row = d.담은로직[i]; + const line = calc?.줄.find((l) => l.로직 === row.로직); + pick({ + kind: "logic", + key: row.로직, + pill: line?.알약 ?? null, + name: logics.get(row.로직)?.이름 ?? row.로직, + unit: line?.분모 ?? "", + })?.(); + }; + const matOpen = (i: number): void => { + const mat = d.자재[i]; + const line = calc?.자재.find((m) => m.코드 === mat.코드); + pick({ + kind: "material", + key: mat.코드, + pill: line?.알약 ?? null, + name: mat.이름 ?? mat.코드, + unit: mat.단위 ?? "", + })?.(); + }; + root.replaceChildren( + el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }), + logicTable( + { rows: d.담은로직, logics, calc, fail, cells }, + onRow ? { open: logicOpen } : undefined, + ), + el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }), + matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined), + ); + }; + + const show = async ( + layer: Layer, + column: string | null, + 수량?: Record, + ): Promise => { + const mine = ++seq; + doc = null; + calc = null; + fail = null; + if (!column) return void root.replaceChildren(); + root.replaceChildren(el("p", { className: "m02-labrow__muted", text: tc("Loading") })); + try { + const got = await readTemplate(layer, "labor", column, null); + if (mine !== seq) return; + doc = normalizeLabor(got.문서, column); + const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + const [inputs, area] = await Promise.all([ + fetchLogicInputs(want), + readTemplate(layer, "area", column, null).then( + (a) => a.문서, + () => null, + ), + ]); + if (mine !== seq) return; + for (const l of inputs.로직) logics.set(l.로직, l); + cells = new Map([...areaLinks(area)].map(([k, v]) => [k, v.칸])); + paint(); // 금액 「…」 + calc = await calcLabor(layer, column, null, 수량); + } catch (error) { + if (mine !== seq) return; + if (!doc) { + root.replaceChildren( + el("p", { + className: "m02-labrow__empty", + text: error instanceof Error ? error.message : tc("Load_Failed"), + }), + ); + return; + } + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + if (mine === seq) paint(); + }; + + return { + root, + show, + setQty: (수량) => { + if (!doc || !calc) return; + calc = settle(calc, doc, 수량); + paint(); + }, + doc: () => doc, + destroy: () => { + seq += 1; + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 742f8f20a..c1860e584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -79,7 +79,8 @@ export interface LayerAccess { needCompany: boolean; } -/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */ +/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) · + * 읽을 수 있나는 서버가 가름 */ export function accessFor( layer: ManageLayer, owner: string | null, @@ -87,10 +88,86 @@ export function accessFor( ): LayerAccess { if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false }; const own = ownOwner(layer, user); - if (owner && owner !== own) return { canWrite: false, needCompany: false }; + if (owner && owner !== own) { + return { canWrite: manages(owner.split("/")[0], user), needCompany: false }; + } if (!own) return { canWrite: false, needCompany: true }; - const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master; - return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; + return { + canWrite: layer === "personal" || manages(ownOwner("company", user), user), + needCompany: false, + }; +} + +/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ +export function manages(company: string | null, user: SessionUser | null): boolean { + if (!company || !user) return false; + if (user.role === "SYSTEM_ADMIN") return true; + const companyAdmin = user.role === "ADMIN" || !!user.is_master; + return companyAdmin && company === ownOwner("company", user); +} + +/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */ +export interface Source { + id: "master" | "company" | "personal" | "people" | "practice"; + layer: ManageLayer; + /** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */ + owner: string | null; + label: string; +} + +/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭. + * 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */ +export function readableSources( + user: SessionUser | null, + company: string | null, + opt: { practice?: boolean } = {}, +): { tabs: Source[]; first: Source["id"] } { + const isSystem = user?.role === "SYSTEM_ADMIN"; + const own = ownOwner("personal", user); + const tabs: Source[] = [ + { id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") }, + ...(company + ? [ + { + id: "company" as const, + layer: "company" as const, + owner: company === ownOwner("company", user) ? null : company, + label: L("M02_PickerCompany"), + }, + ] + : []), + ...(own + ? [ + { + id: "personal" as const, + layer: "personal" as const, + owner: null, + label: L("M02_PickerPersonal"), + }, + ] + : []), + ...(manages(company, user) + ? [ + { + id: "people" as const, + layer: "personal" as const, + owner: null, + label: L("M02_SourcePeople"), + }, + ] + : []), + ...(opt.practice && isSystem + ? [ + { + id: "practice" as const, + layer: "system" as const, + owner: null, + label: L("M02_SourcePractice"), + }, + ] + : []), + ]; + return { tabs, first: isSystem || !own ? "master" : "personal" }; } /** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Practice.ts b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts new file mode 100644 index 000000000..f3b72ceac --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * M02_MasterTemplete_Practice.ts + * 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) — + * 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) · + * [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인). + * 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; +import { + fetchCombo, + fetchComboList, + type ComboList, + type ComboOne, +} from "./M02_MasterTemplete_Labor_Api"; + +export interface PracticeSectionOptions { + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface PracticeSection { + title: string; + body: HTMLElement; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + clear: () => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */ +function reasonOf(detail: unknown, status: number): string { + if (typeof detail === "string") return detail; + const d = detail as { errors?: string[]; stale?: unknown } | null; + if (d?.errors?.length) return d.errors.join(" · "); + return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`; +} + +/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */ +async function dropPractice(판: string): Promise { + const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, { + method: "DELETE", + credentials: "include", + }); + if (res.ok) return; + const data = (await res.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(reasonOf(data.detail, res.status)); +} + +const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-practice__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +const cells = (values: string[]): HTMLElement[] => + values.map((v) => el("td", { text: v, attrs: { title: v } })); + +export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection { + const row = el("button", { + className: "m02-side__item ui-sidebar-row", + text: L("M02_PracticeRow"), + attrs: { type: "button", "data-practice-row": "" }, + }); + const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); + const view = el("div", { className: "m02-practice" }); + let opened = false; + let nav = 0; // 늦게 온 응답 버림 + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + row.addEventListener("click", () => void open()); + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + opt.show(L("M02_PracticeTitle"), view); + opt.onOpen(); + mark(true); + await drawList(); + } + + async function drawList(): Promise { + const my = ++nav; + const stop = showLoading(view); + let list: ComboList; + try { + list = await fetchComboList(); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList())); + return; + } finally { + stop(); + } + if (my !== nav) return; + const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" }); + drop.disabled = !list.줄.length; + drop.addEventListener("click", () => void dropAll(list, drop)); + const rows = list.줄.map((c) => { + const tr = el("tr", { + className: "m02-practice__row", + attrs: { "data-practice-key": c.키 }, + children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]), + }); + tr.addEventListener("click", () => void drawOne(c.키)); + return tr; + }); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [ + el("span", { + className: "m02-practice__note", + text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)), + }), + drop, + ], + }), + rows.length + ? table( + [ + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeGroup"), + L("M02_PracticeUnit"), + L("M02_PracticeLogics"), + ], + rows, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeEmpty") }), + ); + } + + /** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */ + async function drawOne(key: string): Promise { + const my = ++nav; + const stop = showLoading(view); + let one: ComboOne; + try { + one = await fetchCombo(key); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key))); + return; + } finally { + stop(); + } + if (my !== nav) return; + const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" }); + back.addEventListener("click", () => void drawList()); + const c = one.combo; + const info = [ + [L("M02_PracticeKey"), c.키], + [L("M02_PracticeGroup"), c.구분 ?? ""], + [L("M02_PracticeUnit"), c.단위 ?? ""], + ].map(([k, v]) => el("span", { className: "m02-practice__note", text: `${k} ${v}` })); + const lines = one.줄.map((x) => + el("tr", { + children: cells([ + String(x.차례), + x.로직, + x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""), + x.결과단위 ?? "", + x.메모 ?? "", + ]), + }), + ); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [back, el("strong", { text: c.이름 }), ...info], + }), + ...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []), + lines.length + ? table( + [ + L("M02_PracticeOrder"), + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeUnit"), + L("M02_PracticeMemo"), + ], + lines, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeNoLogic") }), + ); + } + + /** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */ + async function dropAll(list: ComboList, button: HTMLButtonElement): Promise { + const n = String(list.줄.length); + if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete")))) + return; + if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return; + button.disabled = true; + try { + await dropPractice(list.판); + showToast(L("M02_PracticeDropped").replace("{n}", n), "success"); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + await drawList(); + } + + return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..55965704d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -5,25 +5,35 @@ company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######) personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######) 저장소 = `M02_MasterTemplete_Combo_Store`. -담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 +담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. +39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). """ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access -from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"]) + +def _door(request: Request, session: Session = Depends(verify_session)) -> None: + """일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + if "/logic-lib/" not in request.url.path and not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.") + + +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)] +) class ComboBody(BaseModel): @@ -31,43 +41,6 @@ class ComboBody(BaseModel): 판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409 -class ComboTreeBody(BaseModel): - """`{op: add-folder|rename|move|delete, 경로?, 이름?, 새경로?, 키?, 판}` — 폴더 깊이 2.""" - - op: str - 경로: list[str] | None = None - 이름: str | None = None - 새경로: list[str] | None = None - 키: str | None = None - 판: str - - -class TreeManyBody(BaseModel): - """여러 개 옮기기 · 지우기(PLAN 30-3) — `ops` = 나무 고침(`ComboTreeBody` 모양) · 저장 한 번.""" - - 판: str - ops: list[dict[str, Any]] - - -class PasteBody(BaseModel): - """층 사이 복사(PLAN 30-3) — 원본 층(`from_layer` · `from_owner`)의 조합 `키` · 폴더 `폴더` → - 이 층 `경로` 폴더에 새 키로. `판` = 이 층 조합 표 판.""" - - from_layer: Layer - from_owner: str | None = None - 키: list[str] = [] - 폴더: list[list[str]] = [] - 경로: list[str] = [] - 판: str = "" - - -class PreviewBody(BaseModel): - """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" - - combo: dict[str, Any] | str - inputs: dict[str, dict[str, Any]] = {} - - @router.get("/combos/all") def get_all(session: Session = Depends(verify_session)) -> list[dict]: """고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층.""" @@ -134,18 +107,6 @@ def post_combo( return _call(folder, combos.create, body.combo, body.판 or None) -@router.put("/layers/{layer}/combos/{key}") -def put_combo( - layer: Layer, - key: str, - body: ComboBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit, key, body.combo, body.판 or "") - - @router.delete("/layers/{layer}/combos/{key}") def delete_combo( layer: Layer, @@ -157,50 +118,13 @@ def delete_combo( return _call(_root(session, layer, owner, write=True), combos.delete, key, 판) -@router.post("/layers/{layer}/combos/tree") -def post_tree( - layer: Layer, - body: ComboTreeBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree, body.model_dump()) - - -@router.post("/layers/{layer}/combos/tree-many") -def post_tree_many( - layer: Layer, - body: TreeManyBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree_many, body.model_dump()) - - -@router.post("/layers/{layer}/combos/paste") -def post_paste( - layer: Layer, - body: PasteBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """원본 층 읽기 권한 · 이 층 쓰기 권한(M02_Layer_Access) — 마스터 붙이기 = 시스템 관리자.""" - source = _call(_root(session, body.from_layer, body.from_owner), lambda: combos._load()[0]) - folder = _root(session, layer, owner, write=True) - return _call(folder, combo_copy.paste, source, body.model_dump()) - - -@router.post("/layers/{layer}/combos/preview") -def post_preview( - layer: Layer, - body: PreviewBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한.""" - return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs) +@router.delete("/practice") +def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict: + """실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409.""" + if not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만") + return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) # ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ── @@ -230,3 +154,9 @@ def get_logic(key: str) -> dict: def get_logic_auto(key: str) -> dict: """견본 입력 — 미리 보기용 자동값.""" return _read(m01.auto, key) + + +@router.get("/logic-lib/inputs", dependencies=[Depends(verify_session)]) +def get_logic_inputs(keys: str = "") -> dict: + """일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`).""" + return _read(labor_inputs.logic_inputs, keys.split(",")) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 23d4412ab..1c66d3f49 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -10,13 +10,20 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) from __future__ import annotations +import logging +from collections.abc import Callable, Coroutine +from contextvars import ContextVar from pathlib import Path from typing import Any, Literal -from fastapi import APIRouter, Depends, HTTPException, Query +import anyio.from_thread +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.routing import APIRoute from pydantic import BaseModel +from common_util.common_util_audit import record_audit from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool from M02_MasterTemplete import M02_Item_Paste as paste_store from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch @@ -29,7 +36,33 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import ( UseComboBody, ) -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) +logger = logging.getLogger(__name__) +#: 이 요청이 쓴 남의 층(`others_write`) — 길이 성공해 답한 뒤에만 시스템 로그(`NotedRoute`) +_WRITES: ContextVar[list[tuple[dict, access.Grant]] | None] = ContextVar("m02_writes", default=None) + + +class NotedRoute(APIRoute): + """남의 층 쓰기 기록 = 성공한 쓰기만(39-1d) — 막히거나 실패한 부름(예외 · 4xx · 5xx)은 뺌.""" + + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def run(request: Request) -> Response: + notes: list[tuple[dict, access.Grant]] = [] + token = _WRITES.set(notes) + try: + response = await handler(request) + finally: + _WRITES.reset(token) + if response.status_code < 400: + for session, got in notes: + await _note(session, got) + return response + + return run + + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"], route_class=NotedRoute) Layer = Literal["system", "company", "personal"] Session = dict[str, Any] @@ -71,14 +104,65 @@ class CopyItemBody(BaseModel): key: str +async def _member(owner: str) -> bool: + """층 주인이 있나 — 회사 `{C}` = 그 회사 · 개인 `{C}/{U}` = U 가 C 소속.""" + company, _, user = owner.partition("/") + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + if user: + await cursor.execute( + "SELECT 1 FROM users WHERE id = %s AND company_id = %s AND deleted_at IS NULL", + (int(user), int(company)), + ) + else: + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (int(company),) + ) + return await cursor.fetchone() is not None + + +async def _note(session: Session, got: access.Grant) -> None: + """남의 층 쓰기 한 줄(39-6 · 시스템 로그) — 기록이 못 돼도 쓰기는 막지 않음.""" + try: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await record_audit( + cursor, + actor_id=int(session["user_id"]), + action="M02_OTHER_LAYER_WRITE", + resource_type=f"m02_{got.layer}", + resource_ref=got.owner, + ) + await connection.commit() + except Exception: + logger.exception("M02 남의 층 쓰기 기록 실패: %s %s", got.layer, got.owner) + + +async def others_write(session: Session, got: access.Grant, folder: Path) -> None: + """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 시스템 로그는 길이 성공한 + 뒤(`NotedRoute` · 그 밖의 길은 바로).""" + if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): + if not await _member(str(got.owner)): + raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님") + items_store.ensure(folder) + pending = _WRITES.get() + if pending is None: + await _note(session, got) + else: + pending.append((session, got)) + + def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: - """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦.""" + """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦 · + 남의 층 쓰기(맡은 회사 · 39-6)는 `others_write`.""" got = access.require(session, layer, owner, write=write) if layer == "system": return store.FOLDER - folder = access.folder(got) + folder = access.folder(got, create=write) if got.mine: items_store.ensure(folder) + elif write: + anyio.from_thread.run(others_write, session, got, folder) elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return folder @@ -299,15 +383,33 @@ def post_use_combo( return _call(folder, branch.use_combo, column, body.종류, body.키, body.판) +def _copy_practice(column: str, key: str) -> dict[str, Any]: + """실무문서 조합(마스터 UA) → 이 열 일위대가 문서(입력값 빈 값 · 39-1b).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + if not key.startswith("UA"): + raise store.StoreError(400, f"실무문서 조합 키가 아님 「{key}」") + return store.copy_from("labor", column, key, body=labor.from_combo(key)) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, - kind: Literal["structures", "area"], + kind: Literal["structures", "labor", "area"], column: str, source: str = Query(alias="from"), owner: str | None = None, + from_layer: Literal["system", "company", "personal", "practice"] | None = None, + from_owner: str | None = None, session: Session = Depends(verify_session), ): + """항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) · + 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만).""" folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind - return _call(folder, store.copy_from, part, column, source) + if from_layer == "practice": + if part != "labor" or not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만") + return _call(folder, _copy_practice, column, source) + origin = _root(session, from_layer, from_owner) if from_layer else None + return _call(folder, store.copy_from, part, column, source, origin) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 3a27f09d2..52667b2fe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,13 +27,16 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"]) +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Layers"], route_class=items_router.NotedRoute +) Layer = Literal["system", "company", "personal", "project"] @@ -164,8 +167,10 @@ def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int: async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None: + """남의 개인 양식 = 그 회사를 맡은 사람만(39-6 · 층 길과 한 규칙) · 그 회사 사람이어야 함.""" if user_id == session.get("user_id"): return + access.grant(session, "personal", f"{company_id}/{user_id}") if await _user_company(user_id) != company_id: raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.") @@ -192,7 +197,12 @@ async def _layer_dir( who = session["user_id"] if user_id is None else user_id owner = str(company_id) if layer == "company" else f"{company_id}/{who}" got = access.require(session, layer, owner, write=write) - return _ensured(access.folder(got)) if got.mine else access.folder(got) + folder = access.folder(got, create=write) + if got.mine: + return _ensured(folder) + if write: # 남의 층 쓰기(맡은 회사 · 39-6) — 구조물 길과 한 벌 + await items_router.others_write(session, got, folder) + return folder def _ensured(folder: Path) -> Path: @@ -214,9 +224,9 @@ async def list_owners( company: int | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> list[dict[str, Any]]: - """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌). - 회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 · - 회사 관리자는 자기 회사만).""" + """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]`. + 회사 = SYSTEM_ADMIN 만 · 층 자리가 있는 것만(자기 회사는 늘) · + 개인 = 그 회사를 맡은 사람(39-6)만 · 회사 사람 전부(나 맨 위 · 집계표 없으면 `비어있음`).""" if layer == "company": if not access.is_system_admin(session): raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.") @@ -225,12 +235,16 @@ async def list_owners( target = session.get("company_id") if company is None else company if target is None: raise HTTPException(status_code=409, detail=access.NO_COMPANY) - mine = str(target) == str(session.get("company_id")) - if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))): + if not access.manages(session, target): raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.") me = session.get("user_id") users = await _company_users(int(target)) - rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me] + users.sort(key=lambda u: u["user_id"] != me) # 나 맨 위 + return [ + {"owner": owner, "name": u["name"], "비어있음": _empty(owner)} + for u in users + for owner in [f"{target}/{u['user_id']}"] + ] found = [] for owner, name in rows: got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer)) @@ -242,6 +256,13 @@ async def list_owners( return found +def _empty(owner: str) -> bool: + """개인 층에 집계표가 없음 — 사람 나무 「비어 있음」(폴더를 안 만듦).""" + company, user = owner.split("/") + table = layers.personal_dir(company, user) / "table" / f"{store.STRUCTURE_TABLE}.json" + return not table.is_file() + + @router.get("/layers/{layer}/templates") async def list_layer( layer: Layer, @@ -519,7 +540,11 @@ async def list_sources( ) return rows - users = await _company_users(company_id) + users = [ # 남의 개인 = 그 회사를 맡은 사람만(39-6) + u + for u in await _company_users(company_id) + if u["user_id"] == session.get("user_id") or access.manages(session, company_id) + ] projects = await _company_projects(company_id) return { "system": await asyncio.to_thread(layers.list_templates, layers.system_dir()), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 0cb83b5ee..36dffd44e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -64,11 +64,12 @@ IN_USE = { # (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면 PARTS = ( ("structure", "도번", "SD", None), + ("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장) ("area", "면적번", "SA", "면적산출"), ) _KEY = {kind: key for kind, key, _, _ in PARTS} SHEETS = ("area",) # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음 -_CODE = re.compile(r"^S[DA](\d{8}|-\d{6})$") +_CODE = re.compile(r"^S[DAU](\d{8}|-\d{6})$") _OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음 _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties @@ -153,8 +154,9 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출을 번호와 같이 만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). 통합문서(area)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 - (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘. - 번호 칸(도번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음). + (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는 + `M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도). + 번호 칸(도번 · 일위대가번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음). 표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이 원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로). `screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로 @@ -164,8 +166,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> origins = _take_origins(doc) if kind == "table" else {} if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) - if kind in SHEETS: - doc = _server_values(doc) + doc = _server(kind, doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): @@ -177,10 +178,13 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> doc = {**doc, _KEY[kind]: number} if number else doc atomic_write_json(path, doc) info = _info(kind, path) + if kind == "area": + _refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도 if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: _ensure(part, column, source) + _refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 _copy_inputs(source, column) backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() @@ -280,6 +284,17 @@ def _retired() -> list[str]: def _blank(kind: str, column: str) -> dict[str, Any]: """빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 / 면적산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음).""" + if kind == "labor": + return { + "종류": "일위대가", + "판": 1, + "열": column, + "단위": None, + "본뜬": None, + "담은로직": [], + "자재": [], + "계산값": {}, + } if kind == "structure": return { "양식": "구조물도면", @@ -309,9 +324,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool: base = _blank(kind, column) if source and _path(kind, source).is_file(): got = json.loads(_path(kind, source).read_bytes()) - base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column} - if kind in SHEETS: - base = _server_values(base) + base = _server(kind, {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}) with _LOCK: if path.is_file(): return False @@ -321,7 +334,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool: def _take_origins(doc: Any) -> dict[str, str]: """표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) · - 그 열에 일위대가 키가 비었으면 원본 열 것을 받음.""" + 일위대가는 원본 열 문서를 새 번호로 복사(`_ensure` · 39-1b — 조합 키는 안 받음).""" cols = ( [c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else [] ) @@ -334,34 +347,47 @@ def _take_origins(doc: Any) -> dict[str, str]: if (root() / "structure" / f"{col['id']}.json").is_file(): continue out[col["id"]] = source - col["일위대가"] = col.get("일위대가") or by_id[source].get("일위대가") return out def create_structure(column: str) -> dict[str, Any]: """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409. - 면적산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리).""" + 일위대가 · 면적산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리).""" if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") if not _ensure("structure", column): raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") - for kind in SHEETS: - _ensure(kind, column) + for kind in _KEY: + _ensure(kind, column) # 도면은 위에서 만듦 · 있는 것은 그대로 return read("structure", column) -def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: +def copy_from( + kind: str, + column: str, + source: str, + source_root: Path | None = None, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: """다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은 - 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂.""" + 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 · + `source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌 + (실무문서 조합 → 일위대가 · 39-1b).""" if kind not in _KEY: raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] - doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} - if kind in SHEETS: - doc = _server_values({k: v for k, v in doc.items() if k != "계산값"}) + if body is None and source_root is not None: + with at(source_root): + body = read(kind, source)["문서"] + body = read(kind, source)["문서"] if body is None else body + doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}} + doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}) with _LOCK: atomic_write_json(_path(kind, column), doc) - return _info(kind, _path(kind, column)) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + return info def _copy_inputs(source: str, column: str) -> None: @@ -454,6 +480,30 @@ def renumber() -> dict[str, list[str]]: return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made} +def _server(kind: str, doc: Any) -> Any: + """서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`).""" + if kind in SHEETS: + return _server_values(doc) + if kind == "labor": + from M02_MasterTemplete import M02_Labor_Doc as labor + + return labor.server(doc) + return doc + + +def _refresh_labor(column: str) -> None: + """그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠.""" + path = _path("labor", column) + if not path.is_file(): + return + try: + doc = _server("labor", json.loads(path.read_bytes())) + except StoreError: + return + with _LOCK: + atomic_write_json(path, doc) + + def _server_values(doc: Any) -> dict[str, Any]: try: return spreadsheet.with_server_values(doc) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index b44d120e2..fb4c86d4a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -196,6 +196,22 @@ color: var(--color-text-secondary); } +/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */ +.m02-struct__start { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-8); + padding-bottom: var(--spacing-16); +} + +.m02-struct__start-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding: 0 var(--spacing-16); +} + /* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */ .m02-pick__list { display: flex; @@ -247,3 +263,14 @@ .m02-struct__sheet-grid .ss-links__list { max-height: 8rem; } + +/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */ +.m02-struct__sheet.is-pills-off .ss-links { + display: none; +} + +/* [알약] 켬 — 눌린 단추를 강조색으로(밝음 · 어두움 같은 토큰) */ +.m02-struct__card-tools .ui-btn[aria-pressed="true"] { + border-color: var(--color-accent); + color: var(--color-accent); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 2a348521f..5ccf1bb76 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -23,13 +23,14 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; -import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; +import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; import { copyArea, copyStructure, + fetchLaborPills, fetchStructureNumbers, getActiveLayer, - readTemplate, + getActiveOwner, saveTemplate, type StructureNumbers, type TreeEdit, @@ -44,7 +45,8 @@ import { type SheetKind, } from "./M02_MasterTemplete_ItemCards"; import { layerSource } from "./M02_MasterTemplete_ItemSource"; -import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; +import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick"; +import { createInputsCard, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards"; @@ -63,10 +65,10 @@ const TEXT = { overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", + acrossLater: "다른 곳(층 · 사람)에서 가져오기는 서버 길이 선 뒤 — 지금은 같은 곳만", copyFailed: "복사 실패 — {value}", - noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름", - noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음", - inputsFailed: "일위대가 입력값을 못 받음 — {value}", + noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", + pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; @@ -270,26 +272,26 @@ export const columnHead = (col: StructureColumn): string => export const structureLabel = (number: string, col: StructureColumn): string => [number, columnHead(col)].filter(Boolean).join(" "); -/** 면적산출 편집 화면 위 알약 막대 — 그 열 일위대가 입력값(물량). +/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」 + * (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽). * 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */ export async function pillsFor( _kind: SheetKind, // 종류는 면적산출 하나 — 부르는 쪽 모양 그대로 column: string, ): Promise<{ links: LinkOptions; notice?: string }> { - const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력 - const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice }); - const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch( - () => null, - ); - const cols = (table?.문서 as { 열?: { id: string; 일위대가?: string | null }[] } | null)?.열; - const combo = cols?.find((c) => c.id === column)?.일위대가; - if (!combo) return none(TEXT.noPrice); + const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice }); try { - const got = await fetchComboInputs(combo); - const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own]; - return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo)); + const { 알약 } = await fetchLaborPills(column); + const 항목 = 알약.map((p) => ({ + 키: p.키, + 이름: p.이름, + 단위: p.단위, + ...(p.자재 ? { 자재: p.자재 } : {}), + 고정: true, + })); + return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills); } catch (error) { - return none(fill(TEXT.inputsFailed, why(error))); + return none(fill(TEXT.pillsFailed, why(error))); } } @@ -356,6 +358,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], extraCards: [inputs.root], + // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 + onImport: (kind) => void pickCopy(kind, shown ?? undefined), // 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 sheetEdit: { can: () => opts.isAdmin, @@ -399,37 +403,31 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane return got ? got.key : null; }; - /** 다른 열의 도면 · 면적산출을 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */ - const pickCopy = async ( - kind: EditKind, - colId: string, - columns: StructureColumn[], - ): Promise => { - const field = PART_FIELD[kind]; + /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · + * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ + const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise => { const word = PART_WORD[kind]; - const copy = COPY[kind]; - const to = columns.find((c) => c.id === colId); - const toNo = numbers[colId]?.[field]; + const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); - const items = columns - .filter((c) => c.id !== colId && numbers[c.id]?.[field]) - .map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) })); - const from = await choose( - `${fill(TEXT.pickCopy, word)} — ${toNo} ${columnHead(to)}`, - items, - false, - ); - const src = columns.find((c) => c.id === from); - if (!from || !src) return; - const ask = TEXT.overwrite - .replace("{from}", `${numbers[from][field]} ${columnHead(src)}`) - .replace("{what}", word) - .replace("{to}", `${toNo} ${columnHead(to)}`); - if (!(await showConfirmDialog(ask))) return; + const target = `${toNo} ${columnHead(to)}`; + const here = { layer: getActiveLayer(), owner: getActiveOwner() }; + const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; + const picked = await pickHere({ + title: `${fill(TEXT.pickCopy, word)} — ${target}`, + kind, + skip: (from, row) => same(from) && row.열 === to.id, + ask: (p) => + TEXT.overwrite + .replace("{from}", `${p.from.label} ${pickedName(p)}`) + .replace("{what}", word) + .replace("{to}", target), + }); + if (!picked || !("row" in picked)) return; try { - await copy(colId, from); + if (!same(picked.from)) throw new Error(TEXT.acrossLater); // 층 넘는 복사 길(39-1b) 뒤에 이음 + await COPY[kind](to.id, picked.row.열); showToast(TEXT.copied, "success"); - if (column === colId) void load(shown); + if (column === to.id) void load(shown); } catch (error) { showToast(fill(TEXT.copyFailed, why(error)), "error"); } @@ -442,7 +440,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane ): Promise => { if (!opts.isAdmin || !colId) return null; if (row === "s:price") return pickPrice(colId, columns); - if (ROW_KIND[row]) await pickCopy(ROW_KIND[row], colId, columns); + if (ROW_KIND[row]) + await pickCopy( + ROW_KIND[row], + columns.find((c) => c.id === colId), + ); return null; }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 426b52b8d..774500c80 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -35,6 +35,7 @@ import { type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; +import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { @@ -81,18 +82,12 @@ export interface TreePanelOptions { dest: string[], ) => Promise; }; - /** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */ - clipKind?: string; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; - /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ - copyTo?: { layer: Layer; owner: string | null; label: string }[]; + /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ + copyTo?: CopyTarget[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; - /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ - maxDepth?: number; - /** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */ - item?: { add: string; name: string; onAdd?: (path: string[]) => void }; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ @@ -113,6 +108,14 @@ export interface TreePanelHandle { select: (colId: string | null) => void; /** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */ newItem: () => void; + /** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */ + folder: () => string[]; + /** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */ + copyIn: ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ) => Promise; /** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */ editFromTable: (body: TreeEdit, colId: string | null) => Promise; } @@ -123,7 +126,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const owner = opt.owner ?? null; const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree }; - const clipKind = opt.clipKind ?? "구조물"; + const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임 let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; @@ -213,13 +216,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(target.kind === "folder" ? [ - ...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth - ? [] - : [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]), - { - label: opt.item?.add ?? L("M02_TreeAddItem"), - run: () => void addItem(target.path), - }, + { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, + { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, ] : []), ...(isTop @@ -428,8 +426,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; const made = note?.열; - const col = - typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null; + // 붙여넣기로 새 열이 하나면(복사 생성) 그 열 + const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null; + const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); @@ -466,7 +465,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function addItem(parent: string[]): Promise { if (!(await ready())) return; - if (opt.item?.onAdd) return opt.item.onAdd(parent); // 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2) const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : []; const got = await askNewItem({ @@ -490,10 +488,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const got = await askText(L("M02_TreeRename"), [ { key: "name", - label: - target.kind === "item" - ? (opt.item?.name ?? L("M02_TreeItemName")) - : L("M02_TreeFolderName"), + label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"), value: nameOf(target), }, ]); @@ -548,18 +543,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); } - /** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */ - async function copyTo( - list: Target[], - to: { layer: Layer; owner: string | null; label: string }, - ): Promise { + /** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */ + async function copyTo(list: Target[], to: CopyTarget): Promise { if (!api.paste || !list.length) return; const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) }; try { - await api.paste(to.layer, to.owner, clip, []); - const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, ""); + const dest = await askCopyFolder(api.fetch, to, list.length); + if (!dest) return; + await api.paste(to.layer, to.owner, clip, dest); + const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); showToast( - L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where), + L("M02_TreeCopiedTo") + .replace("{n}", String(list.length)) + .replace("{layer}", where) + .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), "success", ); } catch (error) { @@ -658,13 +655,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { }); /** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */ - const newItem = (): void => { + const folder = (): string[] => { const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined; - void addItem(at?.kind === "folder" ? at.path : []); + return at?.kind === "folder" ? at.path : []; + }; + const newItem = (): void => void addItem(folder()); + + const copyIn = async ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ): Promise => { + const run = api.paste; + if (!run || !opt.canEdit || !(await ready())) return; + await apply(() => + run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + ); }; const editFromTable = async (body: TreeEdit, colId: string | null): Promise => { if (opt.canEdit && (await ready())) await edit(body, colId); }; - return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable }; + return { + root, + search: search.root, + add: tools, + refresh, + select, + newItem, + folder, + copyIn, + editFromTable, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts new file mode 100644 index 000000000..38b03c5f6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -0,0 +1,26 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_CopyTo.ts + * 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null. + * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. + * ========================================================================== */ + +import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; + +export interface CopyTarget { + layer: Layer; + owner: string | null; + /** 메뉴 글 — `{n}` = 개수 */ + label: string; +} + +export async function askCopyFolder( + fetchTree: (layer: Layer, owner: string | null) => Promise, + to: CopyTarget, + n: number, +): Promise { + const view = await fetchTree(to.layer, to.owner).catch(() => null); + const folders = view ? listFolders(buildFolders(view)) : []; + return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index bd748d131..4c4ba094e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -4,8 +4,8 @@ * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) - * 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무 - * (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만 + * 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무 + * (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로 * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ @@ -18,6 +18,7 @@ import { accessFor, buildNeedCompany, layerOfRoot, + manages, ownOwner, type Root, } from "./M02_MasterTemplete_Layer"; @@ -40,9 +41,9 @@ export interface LayerBarOptions { export const companyOwner = (company: string | null, user: SessionUser | null): string | null => company && company !== ownOwner("company", user) ? company : null; -/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */ +/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */ export const seesOthers = (company: string | null, user: SessionUser | null): boolean => - user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user); + manages(company, user); export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; @@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { } function button(root: Root, owner: string | null, people = false): HTMLButtonElement { - const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite; + const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite; const name = L( root === "master" ? "M02_PickerSystem" @@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement { box.replaceChildren( ...rows.flatMap((one) => { const current = one.owner === opt.active; - const node = { id: one.owner, label: one.name, count: null, folder: true }; + const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name; + const node = { id: one.owner, label, count: null, folder: true }; const row = createTreeRow(node, current ? "▾" : "▸", 0, () => { if (!current) return opt.go(one.owner); wrap.hidden = !wrap.hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 837758a78..8bf07f719 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -57,6 +57,7 @@ type Mods = Record Promise>; const NUMBER_FIELD: Partial> = { structure: "도번", area: "면적번", + labor: "일위대가번", }; // 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음 // 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를 @@ -141,7 +142,8 @@ export function createMain( ...(extras.top ? [extras.top] : []), el("div", { className: "m02-main__head", - children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], + // M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂) + children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], }), ...(extras.search ? [extras.search] : []), notice, @@ -372,6 +374,20 @@ export function createMain( editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy }; return; } + if (at.kind === "labor") { + // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 + const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + editor = mountLaborDetail(host, doc, { + layer: at.layer, + column: at.name, + readOnly, + onChanged: (d) => (dirty = d && !readOnly), + goArea: async () => { + if (await confirmLeave()) void open({ ...at, kind: "area" }); + }, + }); + return; + } if (at.kind === "area") { // 면적산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을 // 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 2be94a20d..efc277f22 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, @@ -27,7 +27,6 @@ import { type OpenTarget, type Root, } from "./M02_MasterTemplete_Layer"; -import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { headRenameEdit, pickCombo, @@ -42,9 +41,22 @@ import { seesOthers, } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; -import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; +import { buildAllItems } from "./M02_MasterTemplete_ItemList"; +import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; +const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안"; +const NEW_ASK = + "{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름"; + +/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ +const ALL_TITLE = { + structure: "M02_KindStructure", + labor: "M02_KindLabor", + area: "M02_KindArea", +} as const satisfies Record; + export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const entry = entryFromHash(); @@ -69,12 +81,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { const to = layerOfRoot(one); - if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return []; + const toOwner = one === "company" ? companyOwner(company, user) : null; + if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; - return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }]; + return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; }); const tree = createTreePanel({ layer, @@ -86,14 +101,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - // 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 · - // 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로) - let view: OpenTarget = entry.open; - const mark = (open: OpenTarget): void => { - view = open; - const query = new URLSearchParams(entryQuery({ ...entry, open })); - history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); - }; const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; @@ -102,9 +109,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void): HTMLElement => createLayerBar({ user, @@ -127,16 +134,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void Promise.all([side.refresh(), tree.refresh()]), - () => { - side.paint(); - // 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음) - if (main.current() && view === "일위대가") mark("구조물조합"); - }, + () => side.paint(), { top: readOnlyBar?.root, search: tree.search, - // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 나무 「항목 추가」와 같은 창 - ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {}), + // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) + ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); @@ -150,6 +153,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop"); + main.showView( + L("M02_TreeNewItem"), + buildTwoWay({ + note: NEW_NOTE.replace("{value}", where()), + onNew: () => tree.newItem(), + picker: { user, company, kind: "item" }, + ask: (picked) => + NEW_ASK.replace("{name}", pickedName(picked)) + .replace("{from}", picked.from.label) + .replace("{to}", where()), + onCopy: async (picked) => { + if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder()); + }, + }), + ); + }; // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { layer, @@ -168,19 +191,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - labor: ({ onOpen }) => - buildLaborSection({ - show: main.showView, - confirmLeave: main.confirmLeave, - onOpen: () => { - onOpen(); - mark("일위대가"); - }, - bar: (add, back) => layerBar("일위대가", add, back), - wrap: (panel) => peopleTree("일위대가", panel), - make: (reveal) => - buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }), - }), + // 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`) + onAll: (kind) => + void main.showView( + `${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`, + buildAllItems(kind, layer, owner, (row) => { + void main.open({ + layer, + projectId: null, + kind: kind as Kind, + name: row.열, + label: row.이름, + }); + }), + ), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); @@ -200,8 +224,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */ -type ColumnKind = "structure" | "area"; -const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [ - { kind: "structure", key: "도번", label: () => L("M02_KindStructure") }, - { kind: "area", key: "면적번", label: () => L("M02_KindArea") }, +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */ +type ColumnKind = "structure" | "labor" | "area"; +const COLUMN_KINDS: { + kind: ColumnKind; + key: keyof StructureNumbers; + label: () => string; + all: boolean; +}[] = [ + { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, + { kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true }, + { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, ]; +/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */ +export type AllKind = "structure" | "labor" | "area"; /** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */ const STUB_DRAWING = { format: 6, @@ -72,10 +82,10 @@ export interface SideOptions { confirmLeave: () => Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; - /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ - labor?: (hooks: { onOpen: () => void }) => LaborSection; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; + /** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */ + onAll?: (kind: AllKind) => void; } export interface SideHandle { @@ -83,7 +93,7 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; - /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ + /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ reveal: (target: "옵션" | "도면" | "일위대가") => void; } @@ -103,6 +113,8 @@ export function buildSide(opt: SideOptions): SideHandle { let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; let numbers: Record = {}; + /** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */ + let allPicked: AllKind | null = null; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => @@ -134,9 +146,8 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (labor?.clear(), paint()), + onOpen: () => (practice?.clear(), (allPicked = null), paint()), }); - const labor = opt.labor?.({ onOpen: () => (options.clear(), paint()) }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { @@ -144,12 +155,14 @@ export function buildSide(opt: SideOptions): SideHandle { }); return sec; }; - // 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · + const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])); + // 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · // 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6) const summary = section( L("M02_KindTable"), el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }), - ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), + el("hr", { className: "m02-side__rule" }), + ...columnSections, ); summary.classList.remove("is-collapsed"); summary.classList.add("m02-side__summary"); @@ -164,17 +177,24 @@ export function buildSide(opt: SideOptions): SideHandle { 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; + // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 + const practice = + layer === "system" && opt.isAdmin + ? buildPracticeSection({ + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (options.clear(), (allPicked = null), paint()), + }) + : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ - ...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []), summary, ...(extras ? [extras.옵션, extras.도면] : []), + ...(practice ? [section(practice.title, practice.body)] : []), ], }); attachCollapsible(listHost); - // 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침) - labor?.body.closest("section")?.classList.remove("is-collapsed"); const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); @@ -214,9 +234,23 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 · 면적산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { + /** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */ + function allRow(kind: AllKind): HTMLElement { + const b = el("button", { + className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`, + text: L("M02_ItemsAll"), + attrs: { type: "button", "data-all": kind }, + }); + b.addEventListener("click", () => void selectAll(kind)); + return b; + } + + /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { + const { kind, key } = k; + const head = k.all ? [allRow(kind as AllKind)] : []; body.replaceChildren( + ...head, ...(columns.length ? columns.map((col) => { const num = numbers[col.id]?.[key] || L("M02_StructureNone"); @@ -231,8 +265,7 @@ export function buildSide(opt: SideOptions): SideHandle { : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } - const paintColumns = (): void => - COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); + const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k)); /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { @@ -243,11 +276,21 @@ export function buildSide(opt: SideOptions): SideHandle { /** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음 * (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */ function paint(): void { + if (opt.getOpen()) allPicked = null; paintList(); paintColumns(); paintButtons(); if (opt.getOpen()) options.clear(); - if (opt.getOpen()) labor?.clear(); + if (opt.getOpen()) practice?.clear(); + } + + async function selectAll(kind: AllKind): Promise { + if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; + options.clear(); + practice?.clear(); + allPicked = kind; + opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 + paint(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) @@ -372,9 +415,12 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { + // 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」 if (target === "일위대가") { - labor?.body.closest("section")?.classList.remove("is-collapsed"); - return void labor?.open(); + columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove( + "is-collapsed", + ); + return void selectAll("labor"); } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 02fb72880..63caa709f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -15,6 +15,53 @@ margin-left: var(--spacing-8); } +/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */ +.m02-side__rule { + margin: var(--spacing-8) 0; + border: 0; + border-top: 1px solid var(--color-border); +} + +/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */ +.m02-practice { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-practice__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-practice__bar > button:last-child:not(:first-child) { + margin-left: auto; +} + +.m02-practice__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-practice__memo { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + +.m02-practice__row { + cursor: pointer; +} + +.m02-practice__row:hover { + background: var(--color-mist-violet); +} + .m02-side__row { display: flex; flex-wrap: wrap; @@ -42,7 +89,7 @@ overflow-wrap: anywhere; /* 번호 + 이름 — 잘리지 않게 줄바꿈 */ margin: 0; color: var(--color-text); - font-size: var(--text-body); + font-size: 18px; /* M01 머리 제목과 같음(39-5) */ } .m02-main__head .ui-btn { @@ -207,14 +254,6 @@ font-size: 11px; } -/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */ -.m02-labor { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - .m02-main__head + .m02-tree__search { margin-bottom: var(--spacing-8); } diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 8c12c9a27..ec27ca3eb 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -9,7 +9,9 @@ POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림) POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감) POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력) -종류 = 도면 · 면적 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용). + 일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합 +종류 = 도면 · 면적 · 입력연결 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) · +`refresh` = 옛 이름(재적용). ⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404). """ @@ -31,7 +33,7 @@ from M02_MasterTemplete.M02_Item_Source import item_name router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"]) -Kind = Literal["도면", "면적", "입력연결", "입력값", "고른자재"] +Kind = Literal["도면", "면적", "입력연결", "입력값", "고른자재", "일위대가"] class WriteBody(BaseModel): diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index c5577c752..8fc899387 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -27,7 +27,8 @@ from config import config_system LAYERS = ("system", "company", "personal", "project") # area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) -KINDS = ("table", "drawing", "structure", "area") +# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`) +KINDS = ("table", "drawing", "structure", "labor", "area") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -168,6 +169,7 @@ _SKELETON = { "table": ("열",), "drawing": ("entities",), "structure": ("도면.entities",), + "labor": ("담은로직",), "area": ("시트",), } diff --git a/main.py b/main.py index bd3266c1c..ed3216512 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router +from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router @@ -649,6 +650,7 @@ app.include_router(m02_layers_router, dependencies=protected) app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) +app.include_router(m02_labor_router, dependencies=protected) # 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서. app.include_router(m02_project_items_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/master_template/labor/item1.json b/resources/master_template/labor/item1.json new file mode 100644 index 000000000..b9889e9e3 --- /dev/null +++ b/resources/master_template/labor/item1.json @@ -0,0 +1,340 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item1", + "단위": "개소", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000008" + }, + "담은로직": [ + { + "로직": "GC000198", + "메모": "레미콘타설(장비) 무근,소형", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000214", + "메모": "유로폼 설치 및 해체 보통", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000206", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000207", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000218", + "메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000225", + "메모": "깬잡석찰붙임 L3=30cm내외", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000156", + "메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000226", + "메모": "큰돌메쌓기 뒷길이 0.6~0.8m", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000214", + "메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000222", + "메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000677", + "메모": "모르터바르기 1:3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000221", + "메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000088", + "메모": "구조물터파기(토사) 굴착기0.6m3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000113", + "메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000001", + "계산값": { + "줄": [ + { + "로직": "GC000198", + "알약": "레디믹스트콘크리트 타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 58122.782608695656, + "재료비": 0, + "경비": 1162.455652173913, + "계": 59285.23826086956 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000214", + "알약": "유로폼 설치 및 해체|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51524.36363636364, + "재료비": 0, + "경비": 1545.730909090909, + "계": 53070.09454545454 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000206", + "알약": "철근 현장가공|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 217028.66666666666, + "재료비": 0, + "경비": 19532.58, + "계": 236561.24666666667 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000207", + "알약": "철근 현장조립|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 574487.6470588235, + "재료비": 11765, + "경비": 11489.75294117647, + "계": 597742.4 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000218", + "알약": "기초다짐 및 뒷채움|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 12869.246375, + "재료비": 1530.2328689, + "경비": 1315.382, + "계": 15714.8612439 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000225", + "알약": "돌붙임 찰붙임|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 49447.04583333333, + "재료비": 4806.2270256, + "경비": 6425.98, + "계": 60679.25285893333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000156", + "알약": "기계비빔타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 144348.25222222222, + "재료비": 1647.113, + "경비": 378, + "계": 146373.36522222223 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000226", + "알약": "큰돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 10, + "기준": { + "노무비": 819958.01, + "재료비": 132344.5084416, + "경비": 111948.7001, + "계": 1064251.2185416 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000214", + "알약": "야면석 채집|㎡ · ㎥", + "분모": "㎡ · ㎥", + "배": 1, + "기준": { + "노무비": 18927.48, + "재료비": 0, + "경비": 0, + "계": 18927.48 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000222", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51101.38458333333, + "재료비": 6772.4108088, + "경비": 9054.79, + "계": 66928.58539213333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000677", + "알약": "모르타르 배합|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 113564.88, + "재료비": 166136, + "경비": 0, + "계": 279700.88 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000221", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 52917.12, + "재료비": 1469.88, + "경비": 0, + "계": 54387 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000088", + "알약": "터파기(기계) 보통토사|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 843.1458333333334, + "재료비": 627.50883, + "경비": 423.15714285714284, + "계": 1893.811806190476 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000113", + "알약": "되메우기|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2660.6419100706057, + "재료비": 416.1508405797101, + "경비": 391.04953017996496, + "계": 3467.8422808302807 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 2167800.6667278423, + "재료비": 327515.0318154797, + "경비": 163667.5782754784, + "계": 2658983.2768188003 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/master_template/labor/item2.json b/resources/master_template/labor/item2.json new file mode 100644 index 000000000..a59dd165c --- /dev/null +++ b/resources/master_template/labor/item2.json @@ -0,0 +1,11 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item2", + "단위": null, + "본뜬": null, + "담은로직": [], + "자재": [], + "계산값": {}, + "일위대가번": "SU-000002" +} diff --git a/resources/master_template/labor/item3.json b/resources/master_template/labor/item3.json new file mode 100644 index 000000000..b512db52a --- /dev/null +++ b/resources/master_template/labor/item3.json @@ -0,0 +1,98 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item3", + "단위": "식", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000001" + }, + "담은로직": [ + { + "로직": "GF000219", + "메모": "메쌓기 — 돌쌓기 인력", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000119", + "메모": "노체포설 — 굴착기 0.7㎥", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000137", + "메모": "운반 — 중기운반 트레일러(20ton)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000003", + "계산값": { + "줄": [ + { + "로직": "GF000219", + "알약": "돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 79279.668, + "재료비": 0, + "경비": 0, + "계": 79279.668 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000119", + "알약": "노체포설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2433.321308321308, + "재료비": 1077.3082366522367, + "경비": 1012.3273551844981, + "계": 4522.956900158043 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000137", + "알약": "중기운반 트레일러|회", + "분모": "회", + "배": 1, + "기준": { + "노무비": 38181.82530864197, + "재료비": 32481.682622277778, + "경비": 13280.479259259258, + "계": 83943.98719017902 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 119894.81461696328, + "재료비": 33558.99085893002, + "경비": 14292.806614443756, + "계": 167746.61209033706 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 55ad7a213..fcdf04a53 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -4,6 +4,7 @@ * ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403) * ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음) * ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음 + * ④ 일위대가(39-1c): 복사본 요약 · 원본 층 계산 요약 · 넣을 값 = 담은 로직 입력 전부 * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. */ const fs = require("fs"); @@ -66,6 +67,21 @@ globalThis.fetch = async (url, init = {}) => { const method = init.method ?? "GET"; const body = init.body ? JSON.parse(init.body) : undefined; state.calls.push({ method, url, body }); + if (url.startsWith("/api/m02/logic-lib/inputs?keys=")) { + state.logicKeys = decodeURIComponent(url.split("=")[1]).split(","); + return reply(200, { 로직: INPUTS.로직, 빠진로직: [] }); + } + if (/^\/api\/m02\/layers\/system\/labor\/rv\/calc$/.test(url)) { + return reply(200, { + 줄: [ + { 로직: "GA1", 까닭: null }, + { 로직: "GA2", 까닭: "입력 없음" }, + ], + 자재: [{ 코드: "MT000008" }], + 기준합: { 계: 500 }, + 반영합: { 계: 1500 }, + }); + } if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) { state.inputsHits += 1; return reply(200, INPUTS); @@ -97,7 +113,13 @@ globalThis.fetch = async (url, init = {}) => { const folder = body.structure.replace("#", "n"); state.folders[folder] = { info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, - parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, + parts: { + 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, + 입력값: null, + 일위대가: { + 줄: { 일위대가번: "SU-000007", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }], 자재: [{}] }, + }, + }, }; return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" }); } @@ -132,6 +154,8 @@ globalThis.fetch = async (url, init = {}) => { { 로직: "GA2", 입력: "지역" }, ], 멈춤: ["GA1"], + 기준합: { 계: 100 }, + 반영합: { 계: 300 }, }); if (method === "GET") { const content = item.parts[part] ?? null; @@ -226,6 +250,18 @@ async function main() { out.calc = await items.calc(info.폴더); out.calcMissing = await items.calc("없는폴더"); + // 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부 + out.laborCopy = await project.laborOf(row(), items, info.폴더); + out.laborOrigin = await project.laborOf(row(), null, null); + out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null); + const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} }); + out.laborValues = { + keys: state.logicKeys, + link: valueData.link.줄.map((l) => `${l.로직}|${l.입력}|${l.자리}`), + shown: model.pickedGroups(valueData.list, valueData.link).length, + values: valueData.values, + }; + // 입력 목록 — 같은 항목은 한 번만 받음 const first = await project.loadInputs(row()); await project.loadInputs(row()); diff --git a/resources/tester/helper_m02_inputs.cjs b/resources/tester/helper_m02_inputs.cjs index 6f014a6a1..1e14e5c27 100644 --- a/resources/tester/helper_m02_inputs.cjs +++ b/resources/tester/helper_m02_inputs.cjs @@ -4,13 +4,18 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; +// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일 +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const local = (request) => + request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request); + new Function("exports", "module", "require", js)(box.exports, box, local); + return box.exports; +} +const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts")); // 같은 이름(높이)의 입력이 두 로직에 있음 const list = { diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs new file mode 100644 index 000000000..875074b62 --- /dev/null +++ b/resources/tester/helper_m02_item_list.cjs @@ -0,0 +1,65 @@ +/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 + * JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete"); +const load = (name, stubs) => { + const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const need = (id) => (id in stubs ? stubs[id] : {}); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +}; +const model = load("M02_MasterTemplete_TreePanel_Model.ts", {}); +const m = load("M02_MasterTemplete_ItemList.ts", { + "./M02_MasterTemplete_TreePanel_Model": model, +}); + +const row = (열, 경로, extra = {}) => ({ + key: `SS-${열}`, + 열, + 경로, + 이름: 경로.join(" "), + 단위: "m", + 도번: `SD-${열}`, + 면적번: `SA-${열}`, + 수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" }, + labor: null, + ...extra, +}); +const rows = [ + row("c1", ["기슭막이", "메쌓기", "1.2m"], { + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, + }), + row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), + row("c3", ["옹벽"], { 도번: "", 수정일: {} }), +]; +const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r))); +const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`])); + +process.stdout.write( + JSON.stringify({ + heads: Object.fromEntries( + Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]), + ), + cells: { + structure: cells("structure"), + area: cells("area"), + labor: cells("labor"), + item: cells("item"), + }, + find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열), + findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열), + findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1), + page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))( + m.pageOf(many, m.ITEM_COLS.structure, "", 3), + ), + pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, + pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + }), +); diff --git a/resources/tester/helper_m02_labor_calc.cjs b/resources/tester/helper_m02_labor_calc.cjs new file mode 100644 index 000000000..939a86397 --- /dev/null +++ b/resources/tester/helper_m02_labor_calc.cjs @@ -0,0 +1,21 @@ +/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "M02_MasterTemplete", + "M02_MasterTemplete_Labor_Calc.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값))); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts new file mode 100644 index 000000000..ce15d5b73 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -0,0 +1,92 @@ +/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; +import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +const book = (): Workbook => + ({ + 종류: "통합문서", + 판: 1, + 열: "t", + 서식: [{}], + 시트: [ + { + id: "s1", + 이름: "면적산출", + 칸: { B1: { 값: 12.5 } }, + 연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }], + }, + { id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] }, + ], + 활성: "s1", + }) as unknown as Workbook; +const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null); +const run = (b: Workbook, c: Command | null | false) => { + if (!c) return null; + return applyCommand(b, c).undo; +}; + +// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음 +let b = book(); +const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" }; +let undo = run( + b, + linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }), +); +eq("새 연결", b.시트[0].연결?.[1], { + 키: "자재|MT000008", + 칸: "D2", + 이름: "치즐", + 단위: "EA", + 자재: mt, +}); +// 되돌리기 = 처음 그대로 +run(b, undo); +eq("되돌림", links(b), links(book())); + +// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); +eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); + +// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); +eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); + +// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 +b = book(); +undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1")); +eq("시트 넘어 옮김", links(b), [ + [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "치기|㎥", 칸: "A1", 이름: "치기" }, + ], + null, +]); +run(b, undo); +eq("시트 넘어 되돌림", links(b), links(book())); + +// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", null)); +eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]); + +// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false +b = book(); +eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null); +eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null); +eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false); +eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); +eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); +eq("문서 그대로", links(b), links(book())); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/spreadsheet/test_spreadsheet_store.py b/resources/tester/spreadsheet/test_spreadsheet_store.py index 3b3a185df..f1d81d820 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_store.py +++ b/resources/tester/spreadsheet/test_spreadsheet_store.py @@ -72,7 +72,11 @@ def test_새_구조물_도면은_면적_진짜_빈_시트_같은_8자리(client) assert doc[key] == f"{head}-000002" assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음 numbers = client.get("/api/m02/structures/numbers").json() - assert numbers["pv_b"] == {"도번": "SD-000002", "면적번": "SA-000002"} + assert numbers["pv_b"] == { + "도번": "SD-000002", + "일위대가번": "SU-000002", + "면적번": "SA-000002", + } @pytest.mark.parametrize(("kind", "key", "head"), [("area", "면적번", "SA")]) diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index f5a58dfe4..a5b0bf6fa 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -82,7 +82,7 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out): assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply" assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로 assert out["reapply"]["info"]["마스터바뀜"] is False - assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} + assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300} assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 @@ -137,14 +137,51 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): def test_선택_입력_카드는_sub2_파일_한_벌(out): - """두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일).""" + """두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은 + 값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄.""" assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists() inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8") + values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8") assert "export function createInputsCard(" in inputs # M02 자리 고르기(link) - assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value) + assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value) + assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): - text = (UI / name).read_text(encoding="utf-8") - assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name + text = (UI / name).read_text(encoding="utf-8") # 모달은 값 카드를 바로 받음(39-1c) + assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name + + +def test_일위대가_요약_복사본_원본_넣을_값_전부(out): + """39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc · + 넣을 값 = 담은 로직 입력 전부.""" + assert out["laborCopy"] == { + "번호": "SU-000007", + "로직": ["GA1", "GA2"], + "자재": 1, + "base": 100, + "reflected": 300, + "gaps": 2, + "stuck": 1, + } + assert out["laborOrigin"] == { + "번호": None, + "로직": ["GA1", "GA2"], + "자재": 1, + "base": 500, + "reflected": 1500, + "gaps": 0, + "stuck": 1, + } + assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404) + values = out["laborValues"] + assert values["keys"] == ["GA1", "GA2"] + assert values["link"] == ["GA1|지역|선택", "GA1|소량감률|선택", "GA2|지역|선택"] + assert values["shown"] == 2 and values["values"] == {"GA1": {}} + + +def test_모달_카드_차례_도면_일위대가_면적(): + detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") + assert 'import("./ui_template_item_picker_labor")' in detail + assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail def test_모달은_원본_층에_쓰는_길을_안_부름(): @@ -183,6 +220,8 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_ItemSource.ts", M02 / "M02_MasterTemplete_Inputs.ts", M02 / "M02_MasterTemplete_Inputs_Model.ts", + M02 / "M02_Labor_Values.ts", + M02 / "M02_Labor_Values_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index 6c793c1e2..0b747fe4d 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -25,6 +25,7 @@ from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) @@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None: # ── 미리 보기 ───────────────────────────────────────────────────────── +# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름 +def _preview(body: dict) -> dict: + got = combo_store.combo_preview(body["combo"], body["inputs"]) + return json.loads(json.dumps(got, default=str, ensure_ascii=False)) + + def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: key = _make(client)["key"] each = {k: _inputs(k) for k in SAMPLE} @@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 for name in ("노무비", "재료비", "경비", "계") } - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each}) + got = _preview({"combo": key, "inputs": each}) assert got["멈춤"] == [], got["멈춤"] assert [one["로직"] for one in got["줄"]] == list(SAMPLE) for name in ("노무비", "재료비", "경비", "계"): @@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: before = _list(client) row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} - got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) + got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}}) assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 assert _list(client) == before # 아무것도 안 생김 @@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하 def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: key = _make(client, keys=(STACK, LAYER))["key"] only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only}) + got = _preview({"combo": key, "inputs": only}) stuck = [one for one in got["줄"] if not one["ok"]] assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] assert len(got["멈춤"]) == 1 @@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: before = _get(client, "/api/m01/logic", key=STACK) key = _make(client)["key"] - _post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) + _preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) after = _get(client, "/api/m01/logic", key=STACK) assert after["version"] == before["version"] and after["logic"] == before["logic"] diff --git a/resources/tester/test_m01_make.py b/resources/tester/test_m01_make.py index 3ccb36cc2..c0847a8bd 100644 --- a/resources/tester/test_m01_make.py +++ b/resources/tester/test_m01_make.py @@ -176,3 +176,46 @@ def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0 listed = _get(client, "/api/m01/logics", owner="공용")["logics"] assert made["key"] in [x["키"] for x in listed] + + +def test_일위대가가_담은_로직은_못_지운다(client: TestClient, tmp_path: Path, monkeypatch) -> None: + """39-1d — 로직 지우기 검사 = 모든 층(마스터 · 회사 · 개인) labor 담은로직 + 실무문서(마스터 + 일위대가 조합) · 쓰는 곳을 알림 · 다 빼면 지워짐(임시 storage · 임시 마스터).""" + import json + + from config import config_system + from M02_MasterTemplete import M02_Template_Layers as layers + + system, storage = tmp_path / "system", tmp_path / "storage" + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) + key = made["key"] + docs = { + "회사 3": storage / "3/templates/labor/item1.json", + "개인 3/15": storage / "3/15/templates/labor/item2.json", + "마스터": system / "labor/item3.json", + } + for at, path in enumerate(docs.values()): + path.parent.mkdir(parents=True) + doc = {"일위대가번": f"SU-00000{at}", "담은로직": [{"로직": SAMPLE}, {"로직": key}]} + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + combos = store.FOLDER / store.mcb.FILE + raw = json.loads(combos.read_text(encoding="utf-8")) + raw["줄"].append({**raw["줄"][0], "키": "UA999999", "담은로직": [{"로직": key, "메모": None}]}) + combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + + def delete(want: int) -> dict: + version = _get(client, "/api/m01/logic", key=key)["version"] + return _post(client, "/api/m01/logic/delete", {"key": key, "version": version}, want) + + errors = " ".join(delete(422)["detail"]["errors"]) + for where in docs: + assert f"일위대가 {where} 「SU-" in errors and key in errors # 어디서 쓰는지 알림 + assert store.mcb.FILE in errors # 실무문서(마스터 일위대가 조합) + for path in docs.values(): # 일위대가에서 뺌 — 실무문서만 남음 + path.write_text(json.dumps({"담은로직": [{"로직": SAMPLE}]}), encoding="utf-8") + assert "일위대가 " not in " ".join(delete(422)["detail"]["errors"]) + raw["줄"].pop() + combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + assert delete(200)["file"] == make.OWN # 쓰는 곳 0 = 지워짐 diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index eb46f3327..5f74cbd3b 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -73,3 +73,30 @@ def test_저장_안_한_카드_고침_물음(): assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main + + +def test_면적산출_카드_알약_줄_켬끔_기본_숨김(): + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김 + assert 'writeStateRaw("m02-card-pills", String(shown));' in cards + assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards + assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음) + css = _text(M02 / "M02_MasterTemplete_Structure.css") + assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css) + state = _text(ROOT / "A00_Common" / "b_page_state.ts") + assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state + # 상세(전체 편집기)는 켬끔 없음 — 늘 보임 + assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts") + + +def test_면적산출_알약은_일위대가_pills_길(): + """39-4 D7 — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 · 선택 입력 알약 빠짐.""" + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + pills = struct.split("export async function pillsFor")[1].split("async function cardLinks")[0] + area = pills # 재료산출 갈래는 없앰 — 몸통이 면적산출 알약 + assert 'kind === "material"' not in pills + assert "await fetchLaborPills(column)" in area + assert "고정: true" in area and "...(p.자재 ? { 자재: p.자재 } : {})" in area + assert "inputPills" not in area and "fetchComboInputs" not in struct + api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + assert "/layers/${activeLayer}/labor/${enc(column)}/pills" in api diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 8501c9945..4972c8711 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,12 +40,12 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, edit);" in cards + assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5) assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards - inputs = _text("M02_MasterTemplete_Inputs.ts") - assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드 + inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts") + assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로) assert "shell.fail(" in inputs and "shell.loading()" in inputs assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음 drawing = _text("M02_MasterTemplete_DrawingCard.ts") diff --git a/resources/tester/test_m02_combo_branch.py b/resources/tester/test_m02_combo_branch.py index 091b9a316..2dbb38789 100644 --- a/resources/tester/test_m02_combo_branch.py +++ b/resources/tester/test_m02_combo_branch.py @@ -190,7 +190,7 @@ def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_ assert client.put(TABLE, json={"판": table["판"], "문서": doc}).status_code == 200 saved = next(c for c in client.get(TABLE).json()["문서"]["열"] if c["id"] == "c9") assert "본뜬열" not in saved # 뗌 - assert saved["일위대가"] == ua + assert not saved.get("일위대가") # 열 키는 안 받음 — 일위대가는 labor 문서로 복사(39-1b) copy = client.get("/api/m02/templates/structure/c9").json()["문서"] assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "c9" numbers = client.get("/api/m02/structures/numbers").json() diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 7796b80d3..f9bdf8e14 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -4,8 +4,7 @@ - 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA. - 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙). - 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409. - - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409). - - 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과. + - 나무 고침 · 층 사이 붙여넣기 길은 없음(PLAN 39-2 · 일위대가는 구조물 열마다). - 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에. - 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음. 정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본. @@ -54,9 +53,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: app.include_router(items_router.router) app.include_router(combos_router.router) app.dependency_overrides[verify_session] = lambda: session + # 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙 + app.dependency_overrides[combos_router._door] = lambda: None return {"client": TestClient(app), "session": session, "storage": storage} +def test_조합_길은_시스템_관리자만(world): + """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직은 로그인만.""" + world["client"].app.dependency_overrides.pop(combos_router._door) + for role in ("USER", "ADMIN"): + client = _as(world, role) + assert client.get(URL.format("system")).status_code == 403 + assert client.get("/api/m02/combos/all").status_code == 403 + assert client.get("/api/m02/combos/key/UA000001").status_code == 403 + assert _new(client, "personal", _combo("내 것"), 403) is not None + assert client.get("/api/m02/logic-lib/logics").status_code == 200 + world["session"]["is_master"] = True + assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도 + world["session"]["is_master"] = False + admin = _as(world, "SYSTEM_ADMIN") + assert admin.get(URL.format("system")).status_code == 200 + assert admin.get(URL.format("company")).status_code == 200 + + def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: world["session"].update(role=role, user_id=user) return world["client"] @@ -78,13 +97,6 @@ def _list(client: TestClient, layer: str, **params) -> dict: return res.json() -def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict: - body.setdefault("판", _list(client, layer)["판"]) - res = client.post(URL.format(layer) + "/tree", json=body) - assert res.status_code == want, res.text - return res.json() - - # ── 키 · 자리 ───────────────────────────────────────────────────────── def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world): boss = _as(world, "ADMIN") @@ -129,66 +141,12 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world): branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key)) assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보 url = f"{URL.format('personal')}/{first['키']}" - assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409 - now = _list(me, "personal")["판"] - res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now}) - assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B - - -# ── 나무 ───────────────────────────────────────────────────────────── -def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): - me = _as(world) - key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"] - _tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기") - _tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기") - _tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째") - _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기") - _tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"]) - got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"]) - row = next(r for r in got["줄"] if r["키"] == key) - assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기") - got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축") - assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]] - assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축" - _tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"]) - got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[]) - assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음 - row = next(r for r in got["줄"] if r["키"] == key) - assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) - _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") - _new(me, "personal", _combo("다른 것")) - got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌") - assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌" assert ( - "이미 있음" - in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0] - ) - other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것") - _tree(me, "personal", op="delete", 키=other) - got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) - assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] - - -def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world): - me = _as(world) - key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"] - tree = me.post( - "/api/m02/layers/personal/tree", - json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)}, - ).json() - column = tree["알림"]["열"] - use = me.post( - f"/api/m02/layers/personal/structures/{column}/use-combo", - json={"종류": "일위대가", "키": key}, - ) - assert use.status_code == 200, use.text - got = _tree(me, "personal", 409, op="delete", 경로=["석축"]) - assert got["detail"]["users"] == ["옹벽 찰쌓기"] - res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]}) - assert res.status_code == 409 - assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음 + me.put(url, json={"combo": _combo("원본 고침")}).status_code == 405 + ) # 조합 고치기 은퇴(39-1d) +# ── 마스터 사본 ─────────────────────────────────────────────────────── def _판(client: TestClient) -> str: return client.get("/api/m02/layers/personal/tree").json()["판"] @@ -208,28 +166,6 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return folder -def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): - admin = _as(world, "SYSTEM_ADMIN") - before = _list(admin, "system") - first = before["줄"][0]["키"] - got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본") - assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본" - before = _list(admin, "system") - old = next(r["구분"] for r in before["줄"] if r["구분"]) # 정본 폴더 이름에 기대지 않음 - assert [old] in before["폴더"] - _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") - got = _tree(admin, "system", op="rename", 경로=[old], 이름="시험 폴더") - assert ["시험 폴더"] in got["폴더"] and ["빈 폴더"] in got["폴더"] - assert [old] not in got["폴더"] - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert ["빈 폴더"] in doc["폴더"] - assert sum(r["구분"] == "시험 폴더" for r in doc["줄"]) == len( - [r for r in before["줄"] if r["구분"] == old] - ) - assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로 - _tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘") - - # ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ───────────────────────────────── def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world): boss = _as(world, "ADMIN") @@ -316,81 +252,10 @@ def test_담을_로직_읽기는_로그인이면_됨(world): assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 -# ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ───────────────────────────── -def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict: - body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "") - res = client.post(URL.format(layer) + "/paste", json=body, params=params) - assert res.status_code == want, res.text - return res.json() - - -def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world): - user = _as(world) - master = _list(user, "system")["줄"][:3] - keys = [r["키"] for r in master] - got = _paste(user, "personal", {"from_layer": "system", "키": keys}) - assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"] - rows = {r["키"]: r for r in got["줄"]} - for pair, src in zip(got["새키"], master): - one = rows[pair["새키"]] - assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"]) - assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위 - again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]}) - assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)") - _paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음 - _paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403) - _paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404) - stale = {"from_layer": "system", "키": keys, "판": "낡은판"} - assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409 - - -def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world): - admin = _as(world, "ADMIN") - for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])): - _new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1])) - _tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳") - body = {"from_layer": "personal", "폴더": [["석축"]]} - got = _paste(admin, "company", body) - assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"] - assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]] - by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]} - assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기") - _tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳") - deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]} - assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"] - one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]} - got = _paste(admin, "company", one) # 한 단 폴더는 들어감 - assert ["다른 곳", "찰쌓기"] in got["폴더"] - - -def test_여러_개_옮기기_지우기_한_번에(world): - me = _as(world) - keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")] - _tree(me, "personal", op="add-folder", 경로=[], 이름="석축") - 판 = _list(me, "personal")["판"] - ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]] - res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}) - assert res.status_code == 200, res.text - assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None] - bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]} - assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404 - assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀 - 판 = _list(me, "personal")["판"] - ops = [{"op": "delete", "키": k} for k in keys[1:]] - got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json() - assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1] - stale = {"판": "낡은판", "ops": ops} - assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409 - - -def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy): +# ── 나무 길 없음(PLAN 39-2) ──────────────────────────────────────────── +def test_나무_고침_붙여넣기_길은_없음(world): + """PLAN 39-2 — 일위대가 조합 나무 길(`/tree` · `/tree-many` · `/paste`)을 지움.""" admin = _as(world, "SYSTEM_ADMIN") - up = _new(admin, "personal", _combo("개인 것"))["키"] - before = len(_list(admin, "system")["줄"]) - got = _paste(admin, "system", {"from_layer": "personal", "키": [up]}) - new = got["새키"][0]["새키"] - assert new.startswith("UA") and len(got["줄"]) == before + 1 - row = next(r for r in got["줄"] if r["키"] == new) - assert row["본뜬키"] is None and row["이름"] == "개인 것" - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert m01.cm.check_form(combos.FILE, doc) == [] + for tail in ("/tree", "/tree-many", "/paste"): + res = admin.post(URL.format("personal") + tail, json={"판": ""}) + assert res.status_code in (404, 405), tail diff --git a/resources/tester/test_m02_item_keys.py b/resources/tester/test_m02_item_keys.py index 2e25025ca..85006a9f7 100644 --- a/resources/tester/test_m02_item_keys.py +++ b/resources/tester/test_m02_item_keys.py @@ -26,7 +26,7 @@ from resources.tester.test_m02_structure import write_sample_table from resources.tester.test_m02_tree import Tree, _app ROOT = Path(__file__).resolve().parents[2] -NEW = re.compile(r"^S[DAM]-\d{6}$") +NEW = re.compile(r"^S[DAMU]-\d{6}$") @pytest.fixture() @@ -164,7 +164,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): doc = json.loads(path.read_bytes()) path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8") old = {cid: _numbers(system, cid) for cid in store._column_ids()} - assert old["rv"] == {"SD01010100", "SA01010100"} + assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100"} first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1 with store.at(system): got = store.read("table", store.STRUCTURE_TABLE) @@ -191,7 +191,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"}) store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음 - assert _numbers(system, "y") == {f"SD{last}", f"SA{last}"} + assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}"} def test_층마다_번호는_따로_파일은_열_id_로(world): diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py new file mode 100644 index 000000000..ca3f2b4cb --- /dev/null +++ b/resources/tester/test_m02_item_labor.py @@ -0,0 +1,207 @@ +"""프로젝트 복사본 · 일위대가 문서(PLAN 39-1c · 임시 저장소 · 임시 마스터 사본). + +- 층 `labor/<열>.json` 이 있으면 스냅샷 `일위대가.json` = {줄: 문서, 로직, 빠진로직, 알약} · + 단가 묶음에 자재 MT +- `입력값.json` 첫 값 = 문서 입력값(확인 거짓) · 재적용은 프로젝트에서 넣은 값이 이김 +- 계산 = 수량(손 · 면적 알약 칸) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 기준 단가 × 수량 · + 반영합 = 손셈 · + 기준 · 반영이 원본 층 문서 계산(`M02_Labor_Doc.calc`)과 같음 +- 일위대가 쓰기는 400(읽기만) +""" + +from __future__ import annotations + +import json +import shutil +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest + +from config import config_system +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE + +MASTER = config_system.PROJECT_ROOT / "resources/master_data" +PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} +COLUMN = "pp_len" +FOLDER = "pipe@40.0" +LOGICS = ("GF000176", "GC000088", "GF000113") +MATERIAL = "MT000008" +HAND = 3 # GC000088 손 수량 +AREA_QTY = 7.5 # 자재 · GF000113 은 면적 알약 칸 값 +MAT_QTY = 4 + + +@pytest.fixture(scope="module") +def master_copy(tmp_path_factory) -> Path: + target = tmp_path_factory.mktemp("master_data") + for path in MASTER.glob("*.json"): + shutil.copy2(path, target / path.name) + return target + + +def _read(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write(path: Path, content: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(content, ensure_ascii=False), encoding="utf-8") + + +def _first_input(code: str) -> tuple[str, Any]: + whole = m01.loaded()[1] + name, value = next(iter(m01.mau.values(whole, whole.logic(code)).items())) + return name, int(value) if isinstance(value, Decimal) and value % 1 == 0 else value + + +@pytest.fixture +def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]: + system = tmp_path / "master" + system.mkdir() + for name in (layers.COMBO_FILE,): + shutil.copy2(SAMPLE / name, system / name) + for kind in ("table", "structure", "area"): + shutil.copytree(SAMPLE / kind, system / kind) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(m01, "FOLDER", master_copy) + storage = tmp_path / "storage" + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + root = storage / "3" / "12" / "proj" + (root / "B05_Profile/route").mkdir(parents=True) + (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) + + name, value = _first_input("GC000088") + doc = labor_doc.clean( + { + "열": COLUMN, + "단위": "m", + "담은로직": [ + {"로직": LOGICS[0]}, + {"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}}, + {"로직": LOGICS[2]}, + ], + "자재": [{"코드": MATERIAL}], + } + ) + _write(system / "labor" / f"{COLUMN}.json", doc) + pills = {p["키"]: p for p in labor_doc.pills(doc)} + area_pill = next(k for k, p in pills.items() if LOGICS[2] in p.get("로직", [])) + area = _read(system / "area" / f"{COLUMN}.json") + sheet = area["시트"][0] + sheet["칸"] = {"A1": {"값": AREA_QTY}, "A2": {"값": MAT_QTY}} + sheet["연결"] = [ + {"키": area_pill, "칸": "A1"}, + {"키": labor_doc.material_key(MATERIAL), "칸": "A2"}, + ] + _write(system / "area" / f"{COLUMN}.json", area) + return { + "root": root, + "doc": doc, + "input": (name, value), + "pills": pills, + "area_pill": area_pill, + } + + +def _save(root: Path) -> dict[str, list[str]]: + _write(root / "B05_Profile/route/structures.json", {"revision": 1, "structures": []}) + pipe = {"chainage_m": 40.0, "source": "user", "summary_item": PIPE} + _write(root / "B04_PreProcess/drainage/edits/pipe_points.json", {"points": [pipe]}) + return snap.sync_snapshots(root) + + +def _fill_auto(root: Path) -> None: + """빠진 입력을 자동값으로(확인 참) — 문서 입력값은 그대로 둠.""" + current = copies.read_file(root, FOLDER, "입력값") + values = current["내용"] + whole = m01.loaded()[1] + for code in LOGICS: + for key, auto in m01.mau.values(whole, whole.logic(code)).items(): + mine = values.setdefault(code, {}) + if key not in mine and auto is not None: + mine[key] = {"값": labor_doc._json(auto), "확인": True} + copies.write_file(root, FOLDER, "입력값", current["판"], values) + + +def test_스냅샷_일위대가는_문서_알약_자재_단가(world): + root = world["root"] + assert _save(root)["만듦"] == [FOLDER] + folder = root / "templates/structures" / FOLDER + labor = _read(folder / "일위대가.json") + assert labor["줄"] == world["doc"] + assert [row["키"] for row in labor["로직"]] == list(LOGICS) and labor["빠진로직"] == [] + assert {p["키"] for p in labor["알약"]} == set(world["pills"]) + keys = { + row["키"] for data in _read(folder / "단가.json").values() for row in data.get("줄", []) + } + assert MATERIAL in keys and set(LOGICS) <= keys + + +def test_입력값_첫_값은_문서_입력값(world): + root = world["root"] + _save(root) + name, value = world["input"] + values = _read(root / "templates/structures" / FOLDER / "입력값.json") + assert values == {LOGICS[1]: {name: {"값": value, "확인": False}}} + + +def test_재적용은_프로젝트_값이_이김(world): + root = world["root"] + _save(root) + name, _ = world["input"] + current = copies.read_file(root, FOLDER, "입력값") + mine = {LOGICS[1]: {name: {"값": 99, "확인": True}}} + copies.write_file(root, FOLDER, "입력값", current["판"], mine) + got = copies.reapply(root, FOLDER) + assert got["값있음"] is True + assert _read(root / "templates/structures" / FOLDER / "입력값.json") == mine + + +def test_계산_반영_합은_손셈_원본과_같음(world): + root = world["root"] + _save(root) + _fill_auto(root) + got = copies.calc(root, FOLDER) + assert got["멈춤"] == [], got["줄"] + lines = {line["로직"]: line for line in got["줄"]} + hand, area = lines[LOGICS[1]], lines[LOGICS[2]] + assert (hand["수량"], hand["수량출처"]) == (HAND, "손") + assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸") + assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None + for line in (hand, area): + expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"])) + assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect) + (mat,) = got["자재"] + assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] + assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY) + total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"] + assert got["반영합"]["계"] == pytest.approx(total) + # 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것 + values = _read(root / "templates/structures" / FOLDER / "입력값.json") + doc = { + **world["doc"], + "담은로직": [ + {**one, "입력값": {k: v["값"] for k, v in values.get(one["로직"], {}).items()}} + for one in world["doc"]["담은로직"] + ], + } + given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY} + origin = labor_doc.calc(doc, given) + assert got["반영합"] == pytest.approx(origin["반영합"]) + assert got["기준합"] == pytest.approx(origin["기준합"]) + + +def test_일위대가_쓰기는_막힘(world): + root = world["root"] + _save(root) + current = copies.read_file(root, FOLDER, "일위대가") + assert current["내용"]["줄"]["열"] == COLUMN + with pytest.raises(ValueError, match="원본"): + copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"]) diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py new file mode 100644 index 000000000..ccdfa1f68 --- /dev/null +++ b/resources/tester/test_m02_item_list.py @@ -0,0 +1,96 @@ +"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) · +서버 목록 칸(임시 storage). + +① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 · + 고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽. +② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 · + 합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +from resources.tester import test_m02_tree as base +from resources.tester.test_m02_tree import Tree, _as + +world = base.world # 같은 임시 storage 판 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_item_list.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_종류마다_칸(ts): + assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["labor"] == [ + "일위대가번", + "이름", + "폴더", + "단위", + "담은 로직 수", + "합계", + "고친 날", + ] + assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), []) + + +def test_칸_값_빈_번호_합계_날짜(ts): + s, labor = ts["cells"]["structure"], ts["cells"]["labor"] + assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] + assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) + assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 + assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 + + +def test_찾기_쪽(ts): + assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) + assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 + assert ts["findNone"] == {"shown": [], "page": 1, "total": 0} + assert ts["page3"] == {"page": 3, "total": 65, "n": 5} + assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 + + +def test_서버_목록_칸_수정일_labor(world): + t = Tree(_as(world), "personal") + t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") + t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m") + root = world["storage"] / "7" / "42" / "templates" + ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"]) + assert set(ditch["수정일"]) >= {"structure", "area"} + assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO + assert ( + ditch["labor"]["로직수"] == 0 and ditch["labor"]["합계"] is None + ) # 새 열 = 빈 일위대가(39-1b) + (root / "labor").mkdir(exist_ok=True) + doc = { + "일위대가번": "SU-000001", + "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], + "계산값": {"반영합": {"계": 12345.6}}, + } + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") + rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert "labor" in rows[ditch["열"]]["수정일"] + assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + doc["계산값"] = {"반영합": {"계": "글"}} + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert again[ditch["열"]]["labor"]["합계"] is None diff --git a/resources/tester/test_m02_labor_calc_mirror.py b/resources/tester/test_m02_labor_calc_mirror.py new file mode 100644 index 000000000..ab8a53680 --- /dev/null +++ b/resources/tester/test_m02_labor_calc_mirror.py @@ -0,0 +1,71 @@ +"""일위대가 수량 · 반영 · 합 거울 시험 — `M02_Labor_Doc.settle`(파이썬) ↔ +`M02_MasterTemplete_Labor_Calc.ts` `settle`(TS · Node) 가 같은 답(CLAUDE.md 5장 ①). + +기준은 서버가 한 번 셈(`calc`) · 그 답에 다른 알약 값을 넣어 둘이 같은지 봄. +""" + +import json +import os +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEN, THOUSAND, PLAIN, MAT = "GC000421", "GF000017", "GC000422", "MT000008" + + +def _ts(answer: dict, doc: dict, values: dict) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], + input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _same(a, b) -> None: + if isinstance(a, dict): + assert set(a) == set(b) + for k in a: + _same(a[k], b[k]) + elif isinstance(a, list): + assert len(a) == len(b) + for x, y in zip(a, b): + _same(x, y) + elif isinstance(a, (int, float)) and not isinstance(a, bool): + assert b == pytest.approx(a, rel=1e-9) + else: + assert a == b + + +@pytest.mark.parametrize( + "values", + [ + {}, # 수량 없음 — 반영 null · 손만 + {"사석 수상고르기|㎡": 2.5, f"자재|{MAT}": "3"}, + {"사석 수상고르기|㎡": "1,200", "나무식재|본": 7, f"자재|{MAT}": "글"}, # 쉼표 · 수 아님 + ], +) +def test_파이썬과_TS_가_같은_답(values): + doc = labor.clean( + { + "열": "rv", + "담은로직": [ + {"로직": TEN, "수량": None}, + {"로직": THOUSAND, "수량": {"손": 4}}, + {"로직": PLAIN, "수량": None}, + ], + "자재": [{"코드": MAT, "수량": None}], + } + ) + answer = labor.calc(doc, {}) + keys = {line["알약"] for line in answer["줄"]} + assert {"사석 수상고르기|㎡", "나무식재|본"} <= keys # 알약 키가 시험 값과 맞음 + py = labor.settle(answer, doc, values) + _same(py, _ts(answer, doc, values)) + assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시 diff --git a/resources/tester/test_m02_labor_copy.py b/resources/tester/test_m02_labor_copy.py new file mode 100644 index 000000000..56ca1b727 --- /dev/null +++ b/resources/tester/test_m02_labor_copy.py @@ -0,0 +1,134 @@ +"""일위대가 복사(PLAN 39-1b B5 · B6) — 항목 하나 복사(같은 층 · 다른 층 · 실무문서 조합) · +구조물 조합 복사 · 분류 더하기 = 새 일위대가번(조합 키는 안 받음). + +시스템 층 = 견본 표(임시 폴더) · 조합 · 로직은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/copy" + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + for cid in ("rv", "c1"): + store.create_structure(cid) + got = store.read("labor", "rv") + logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}] + store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system, "personal": personal} + + +def _as(world: dict, role: str = "USER") -> TestClient: + world["session"]["role"] = role + return world["client"] + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world): + system = world["system"] + before = _labor(system, "c1") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", "labor", "c1") + "?from=rv") + assert got.status_code == 200 + c1 = _labor(system, "c1") + assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"]) + assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN) + assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈 + + +def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world): + client, personal = _as(world), world["personal"] + url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system" + for _ in range(2): # 저장된 데이터도 다시 바꿈 + assert client.post(url).status_code == 200 + assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN + area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system" + assert client.post(area).status_code == 200 + + +def test_권한_없는_원본_층_403_없는_원본_404(world): + client = _as(world) + other = URL.format("personal", "labor", "a") + "?from=x&from_layer=personal&from_owner=7/99" + assert client.post(other).status_code == 403 + missing = URL.format("personal", "labor", "a") + "?from=없는열&from_layer=system" + assert client.post(missing).status_code == 404 + # 시스템 층 쓰기는 시스템 관리자만 + assert client.post(URL.format("system", "labor", "c1") + "?from=rv").status_code == 403 + + +def test_실무문서_조합에서_일위대가로_시스템_관리자만(world): + url = URL.format("system", "labor", "c1") + "?from=UA000008&from_layer=practice" + assert _as(world).post(url).status_code == 403 + admin = _as(world, "SYSTEM_ADMIN") + assert admin.post(url).status_code == 200 + c1 = _labor(world["system"], "c1") + assert c1["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert len(c1["담은로직"]) == 14 and all(x["입력값"] == {} for x in c1["담은로직"]) + area = URL.format("system", "area", "c1") + "?from=UA000008&from_layer=practice" + assert admin.post(area).status_code == 403 # 실무문서는 일위대가로만 + bad = URL.format("system", "labor", "c1") + "?from=UP1-000001&from_layer=practice" + assert admin.post(bad).status_code == 400 + + +def test_구조물_조합_복사는_새_일위대가번_조합_키는_안_받음(world): + system, personal = world["system"], world["personal"] + with store.at(system): + key = next(r["키"] for r in store.read_combos()["줄"] if r["열"] == "rv") + body = {"from_layer": "system", "key": key} + made = _as(world).post("/api/m02/layers/personal/items/copy", json=body).json() + got = _labor(personal, made["열"]) + src = _labor(system, "rv") + assert got["담은로직"] == src["담은로직"] and got["일위대가번"] != src["일위대가번"] + assert got["일위대가번"].startswith("SU-") + table = json.loads((personal / "table" / f"{store.STRUCTURE_TABLE}.json").read_bytes()) + assert next(c for c in table["열"] if c["id"] == made["열"]).get("일위대가") is None + + +def test_분류_더하기_복사도_새_번호로_일위대가_문서(world): + system = world["system"] + with store.at(system): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + rv = next(c for c in doc["열"] if c["id"] == "rv") + doc["열"].append({**rv, "id": "rv2", "본뜬열": "rv", "일위대가": None}) + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + new, src = _labor(system, "rv2"), _labor(system, "rv") + assert new["담은로직"] == src["담은로직"] and new["일위대가번"] != src["일위대가번"] diff --git a/resources/tester/test_m02_labor_diagram.py b/resources/tester/test_m02_labor_diagram.py index bc78539fe..9e07ca7b4 100644 --- a/resources/tester/test_m02_labor_diagram.py +++ b/resources/tester/test_m02_labor_diagram.py @@ -1,4 +1,4 @@ -"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력. +"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력. 화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 · 금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠. @@ -39,7 +39,7 @@ function load(f) { } const m = load(file); const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j)))); +process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j)))); """ @@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list: return json.loads(done.stdout) -ROWS = [ +LOGICS = [ { - "key": "GC000001", - "name": "돌쌓기", - "unit": "원/㎡", - "inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}], + "로직": "GC000001", + "이름": "돌쌓기", + "결과단위": "원/㎡", + "입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}], }, - { - "key": "GF000002", - "name": "작업량", - "unit": "㎥/시간", - "inputs": [{"name": "지역", "unit": ""}], - }, - {"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []}, + {"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]}, + {"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []}, ] +ROWS = [ + {"로직": "GC000001", "입력값": {"높이": 4}}, + {"로직": "GF000002", "입력값": {"지역": "부산"}}, + {"로직": "GC000003", "입력값": {}}, +] +MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} +APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450} -def test_돈_값_멈춤_세_갈래(): - lines = [ - {"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150}, - {"ok": True, "갈래": "결과", "결과": 8.946}, - {"ok": False, "까닭": "표에 맞는 줄이 없음"}, - ] - (got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}]) +def _line(key: str, **more) -> dict: + return {"로직": key, "기준": None, "반영": None, "까닭": None, **more} + + +def test_돈_값_멈춤_세_갈래_반영이_있으면_반영(): + calc = { + "줄": [ + _line("GC000001", 기준=MONEY, 반영=APPLIED), + _line("GF000002", 결과=8.946), + _line("GC000003", 까닭="표에 맞는 줄이 없음"), + ], + "자재": [], + } + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}]) outs = [x["out"] for x in got["logics"]] - assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} + assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영" assert outs[1] == {"값": 8.946} assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"} -def test_넣은_값은_로직별_빈_값은_빈_글(): - (got,) = run( - [ - { - "rows": ROWS, - "values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}}, - "lines": None, - } - ] - ) +def test_수량_없으면_기준(): + calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []} + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준" + + +def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글(): + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) first, second, third = got["logics"] - assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")] + assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")] + assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0] assert first["inputs"][0]["unit"] == "m" - assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")] + assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")] assert third["inputs"] == [] def test_아직_안_셌으면_출력_없음(): - (got,) = run([{"rows": ROWS, "values": {}, "lines": None}]) + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) assert [x["out"] for x in got["logics"]] == [None, None, None] assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"] + + +def test_자재목록은_카드_하나_재료비로(): + calc = { + "줄": [_line("GC000001", 기준=MONEY)], + "자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}], + } + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}]) + card = got["logics"][-1] + assert card["material"] is True and card["inputs"] == [] + assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200} + assert card["basis"] == "기준" # 반영 없는 자재가 있음 + (none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert not any(x.get("material") for x in none["logics"]) diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py new file mode 100644 index 000000000..9f2cd52b1 --- /dev/null +++ b/resources/tester/test_m02_labor_doc.py @@ -0,0 +1,242 @@ +"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값. + +로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에. +""" + +import json + +import pytest + +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" # 원/10㎡ — 배 10 +THOUSAND = "GF000017" # 원/1,000본 — 배 1000 +PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직 +STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율 +MAT = "MT000008" # 자재품목 · 물가정보 83000 + + +def _doc(**extra): + return {"열": "rv", "담은로직": [], "자재": [], **extra} + + +def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것(): + got = labor.clean( + _doc( + 담은로직=[ + {"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None} + ], + 자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}], + 단위="개소", + ) + ) + assert got["담은로직"] == [ + {"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None} + ] + assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2} + assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None) + + +@pytest.mark.parametrize( + "bad", + [ + _doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번 + _doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직 + _doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님 + _doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림 + _doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님 + _doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번 + "문서 아님", + ], +) +def test_틀린_문서는_400(bad): + with pytest.raises(store.StoreError) as e: + labor.clean(bad) + assert e.value.status == 400 + + +def test_빈_일위대가도_됨(): + got = labor.server(_doc()) + assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0 + + +def test_PER_천_단위_쉼표는_배(): + pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"] + assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10} + + +def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음(): + doc = labor.clean( + _doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}]) + ) + got = labor.calc(doc, {}) + ten, plain = got["줄"] + assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손") + for k in ("노무비", "재료비", "경비", "계"): + assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10) + assert plain["기준"] is None and plain["반영"] is None and "결과" in plain + assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"]) + + +def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): + doc = labor.clean( + _doc( + 담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}], + 자재=[{"코드": MAT, "수량": None}], + ) + ) + keys = {p["키"] for p in labor.pills(doc)} + ten_key = next(k for k in keys if k.endswith("|㎡")) + thousand_key = next(k for k in keys if k.endswith("|본")) + assert f"자재|{MAT}" in keys + got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"}) + ten, thousand = got["줄"] + assert (ten["수량"], ten["수량출처"]) == (2.5, "칸") + assert (thousand["수량"], thousand["수량출처"]) == (1, "손") + mat = got["자재"][0] + assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000) + nothing = labor.calc(doc, {})["줄"][0] + assert (nothing["수량출처"], nothing["반영"]) == ("없음", None) + + +def test_면적_값_식_칸은_계산값_그_밖은_칸_값(): + area = { + "시트": [ + { + "id": "s1", + "칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}}, + "연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}], + } + ], + "계산값": {"s1": {"D2": {"수": "7.5"}}}, + } + got = labor.area_values(area) + assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5} + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_구조물_만들면_빈_일위대가_같은_뒷자리(system): + doc = store.read("labor", "rv")["문서"] + assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:] + assert doc["담은로직"] == [] and doc["자재"] == [] + + +def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system): + have = store.read("labor", "rv") + sent = { + **have["문서"], + "일위대가번": "SU-999999", + "담은로직": [{"로직": TEN, "수량": None}], + "계산값": {"브라우저": "값"}, + } + store.write("labor", "rv", have["판"], sent) + doc = store.read("labor", "rv")["문서"] + assert doc["일위대가번"] == have["문서"]["일위대가번"] + assert doc["계산값"]["줄"][0]["수량출처"] == "없음" + pill = labor.pills(doc)[0]["키"] + area = store.read("area", "rv") + sheet = area["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", "rv", area["판"], area["문서"]) + line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0] + assert (line["수량"], line["수량출처"]) == (5, "칸") + assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10) + + +def test_틀린_일위대가_저장은_400_파일_그대로(system): + have = store.read("labor", "rv") + with pytest.raises(store.StoreError) as e: + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]}) + assert e.value.status == 400 + assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == [] + + +# ── 길(`M02_Labor_Router`) ───────────────────────────────────────────── +@pytest.fixture() +def client(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from config import config_system + from M02_MasterTemplete import M02_Labor_Router as labor_router + from M02_MasterTemplete import M02_Template_Layers as layers + + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + app = FastAPI() + app.include_router(labor_router.router) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, + "company_id": 7, + "role": "USER", + "is_master": False, + } + return TestClient(app) + + +def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client): + url = "/api/m02/layers/system/labor/rv/calc" + sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]} + got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json() + assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000) + assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"} + saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가 + assert saved["줄"] == [] and saved["알약"] == [] + assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []} + + +def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client): + url = "/api/m02/layers/{}/labor/{}/calc" + bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}} + assert client.post(url.format("system", "rv"), json=bad).status_code == 400 + assert client.post(url.format("system", "없는열"), json={}).status_code == 404 + other = client.post(url.format("personal", "rv") + "?owner=7/99", json={}) + assert other.status_code == 403 + + +def test_로직_지우기_검사는_모든_층_일위대가를_봄(tmp_path, monkeypatch): + from config import config_system + from M01_MasterData import M01_MasterData_Store_Cache as cache + + system, storage = tmp_path / "system", tmp_path / "storage" + for folder, code in ( + (system / "labor", STONE), + (storage / "7" / "templates" / "labor", TEN), # 회사 층 + (storage / "7" / "42" / "templates" / "labor", STONE), # 개인 층 + (storage / "7" / "42" / "p1" / "templates" / "labor", STONE), # 프로젝트 — 안 봄 + ): + folder.mkdir(parents=True) + body = {"담은로직": [{"로직": code}]} + (folder / "a.json").write_text(json.dumps(body), encoding="utf-8") + from M02_MasterTemplete import M02_Template_Layers as layers + + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) # 검사는 39-1d 한 벌(층 자리 = layers) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + + class Gone: # STONE 을 지운 마스터 + def logic(self, code): + if code == STONE: + raise cache.cm.mf.FormulaError("없음") + + got = cache.labor_errors(Gone()) + assert len(got) == 2 and all(STONE in x for x in got) + assert any("마스터" in x for x in got) and any("개인 7/42" in x for x in got) diff --git a/resources/tester/test_m02_labor_migrate.py b/resources/tester/test_m02_labor_migrate.py new file mode 100644 index 000000000..1098822af --- /dev/null +++ b/resources/tester/test_m02_labor_migrate.py @@ -0,0 +1,119 @@ +"""옛 일위대가 조합 → 열마다 일위대가 문서 옮기기(`M02_Labor_Migrate` · PLAN 39-1b). + +시스템 층 = 견본 표(임시 폴더) · 조합은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +import json +from pathlib import Path + +import pytest + +from config import config_system +from M02_MasterTemplete import M02_Labor_Migrate as migrate +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SHARED = "UP42-000001" + + +def _point(folder: Path, keys: dict[str, str]) -> None: + path = folder / "table" / f"{store.STRUCTURE_TABLE}.json" + doc = json.loads(path.read_bytes()) + for col in doc["열"]: + if col["id"] in keys: + col["일위대가"] = keys[col["id"]] + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + _point(system, {"rv": "UA000008", "c1": "UA000008", "pv_b": "UA999999"}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + doc["열"] = [ + {"id": "a", "머리": ["돌", "가"], "꼴": "수", "일위대가": SHARED}, + {"id": "b", "머리": ["돌", "나"], "꼴": "수", "일위대가": SHARED}, + ] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + combo = { + "그룹": "일위대가조합", + "다음": 2, + "줄": [ + { + "키": SHARED, + "이름": "개인 조합", + "단위": "개소", + "담은로직": [{"로직": "GF000219", "메모": "메쌓기"}], + } + ], + } + (personal / combos.FILE).write_text(json.dumps(combo, ensure_ascii=False), encoding="utf-8") + return {"system": system, "personal": personal, "storage": storage} + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_시스템_층_열마다_복사_같은_조합도_따로_없는_조합은_못함(world): + system = world["system"] + got = migrate.migrate(system) + assert got["옮김"] == ["rv ← UA000008", "c1 ← UA000008"] + assert got["못함"] == ["pv_b · 없는 일위대가 조합 「UA999999」"] + rv, c1 = _labor(system, "rv"), _labor(system, "c1") + assert rv["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert rv["담은로직"] == c1["담은로직"] and len(rv["담은로직"]) == 14 + assert all(one["입력값"] == {} and one["수량"] is None for one in rv["담은로직"]) + assert rv["일위대가번"] != c1["일위대가번"] # 열마다 자기 번호 + with store.at(system): # 나중에 만든 도면도 같은 뒷자리 + store.create_structure("rv") + doc = json.loads((system / "structure" / "rv.json").read_bytes()) + assert doc["도번"][2:] == rv["일위대가번"][2:] + assert rv["계산값"]["줄"] # 서버가 계산값까지 + assert _labor(system, "ms")["담은로직"] == [] # 키 없는 열 = 빈 일위대가 + + +def test_개인_층_여러_열이_쓰던_조합은_열마다_본뜬은_그_층(world): + personal = world["personal"] + assert migrate.layer_folders(True) == [personal] + got = migrate.migrate(personal) + assert got["옮김"] == [f"a ← {SHARED}", f"b ← {SHARED}"] + a, b = _labor(personal, "a"), _labor(personal, "b") + assert a["본뜬"] == b["본뜬"] == {"층": "personal", "주인": "7/42", "키": SHARED} + assert (a["단위"], a["담은로직"][0]["메모"]) == ("개소", "메쌓기") + assert a["일위대가번"] != b["일위대가번"] + + +def test_다시_돌려도_같음_사람이_고친_문서는_그대로(world): + system = world["system"] + migrate.migrate(system) + before = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert migrate.migrate(system)["옮김"] == [] + after = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert before == after + with store.at(system): # 사람이 비운 문서도 다시 안 채움 + got = store.read("labor", "rv") + store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": []}) + assert "rv" in migrate.migrate(system)["그대로"] + assert _labor(system, "rv")["담은로직"] == [] + + +def test_미리_보기는_파일을_안_씀(world): + system = world["system"] + got = migrate.migrate(system, dry=True) + assert "rv ← UA000008" in got["옮김"] + assert not list((system / "labor").glob("*.json")) diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 9268309c8..e77d15a6d 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -1,124 +1,55 @@ -"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정. +"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · +일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA). -못박는 것 - - 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키. - - 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 · - 지우기는 고름 없음. +옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음. """ -import json -import shutil -import subprocess from pathlib import Path -import pytest - ROOT = Path(__file__).resolve().parents[2] -SCRIPT = r""" -const fs = require("fs"); -const ts = require(process.argv[1] + "/config/node_modules/typescript"); -const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts"; -const src = fs.readFileSync(process.argv[1] + file, "utf8"); -const js = ts.transpileModule(src, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; -const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" }); -const list = { - 판: "v1", - 폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]], - 줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null), - row("UP1-000003", "다", null, "무시")], -}; -const view = m.toView(list); -const notes = [ - { op: "add-folder", 경로: ["석축"], 이름: "메쌓기" }, - { op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" }, - { op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] }, - { op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] }, - { op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" }, - { op: "delete", 경로: ["석축"], 키: "UP1-000002" }, - { op: "delete", 경로: ["빈"] }, -].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : [])); -console.log(JSON.stringify({ view, notes })); -""" +M02 = ROOT / "M02_MasterTemplete" -@pytest.fixture(scope="module") -def got(): - if shutil.which("node") is None: - pytest.skip("node 가 없음") - proc = subprocess.run( - ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) +def _text(name: str) -> str: + return (M02 / name).read_text("utf-8") -def test_조합이_나무_항목으로(got): - items = got["view"]["items"] - assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]] - assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"] - assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]] - assert "알림" not in got["view"] +def test_일위대가_나무_파일과_이음이_없음(): + assert not (M02 / "M02_MasterTemplete_Labor.ts").exists() + assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists() + page = _text("M02_MasterTemplete_UI_Page.ts") + assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page + side = _text("M02_MasterTemplete_UI_Side.ts") + assert "LaborSection" not in side and "labor?." not in side -def test_나무_고침_알림(got): - add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"] - assert add["경로"] == ["석축", "메쌓기"] and "열" not in add - assert rename["경로"] == ["석축", "찰"] - assert move["경로"] == ["찰쌓기"] - assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"] - assert rename_item["경로"] == ["석축", "나나"] - assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"] - assert "경로" not in delete_folder +def test_나무_부품에_일위대가_전용_칸_없음(): + panel = _text("M02_MasterTemplete_TreePanel.ts") + for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"): + assert gone not in panel, gone -def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): - """브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) · - 일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시 - `open=일위대가`(다시 그리지 않고 주소만).""" - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길 - assert ( - 'bar: (add, back) => layerBar("일위대가", add, back),' in page - and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - ) - labor = page.split("buildLaborSection({")[1].split("make:")[0] - assert 'mark("일위대가")' in labor # 여는 순간 해시 open - assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림) - assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page +def test_하위_차례_전체_항목_이름(): + side = _text("M02_MasterTemplete_UI_Side.ts") + kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] + order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] + assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 + assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds + # 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길 + assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side + api = _text("M02_MasterTemplete_Api_Fetch.ts") + assert '| "labor";' in api and "일위대가번: string;" in api + assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 + assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side + assert 'return void selectAll("labor");' in side # 해시 open=일위대가 + locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") + assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale + assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale + css = _text("M02_MasterTemplete_UI_Style.css") + assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css -def test_일위대가_나무에_여러_개_복사_붙여넣기_연결(): - """PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 · - 붙임 종류는 따로(구조물과 안 섞임).""" - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor - assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기 - assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층 - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록 - - -def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표(): - """PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선). - 머리 줄도 공용 표 모양.""" - edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8") - assert ( - "newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit - ) - assert ( - 'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit - ) # 목록 안 옛 자리 - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor - main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") - assert ( - 'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))' - in main - ) - css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8") - assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css +def test_층_단추는_구조물_조합_한_벌(): + page = _text("M02_MasterTemplete_UI_Page.ts") + assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 + assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py new file mode 100644 index 000000000..1757129cb --- /dev/null +++ b/resources/tester/test_m02_labor_values.py @@ -0,0 +1,101 @@ +"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델). + +못박는 것 + - 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」. + - 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`). + - 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 · + 모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만). +화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router + +ROOT = Path(__file__).resolve().parents[2] +A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비 + + +@pytest.fixture() +def client() -> TestClient: + app = FastAPI() + app.include_router(combos_router.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"} + return TestClient(app) + + +def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None: + res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"}) + assert res.status_code == 200, res.text + got = res.json() + assert [x["로직"] for x in got["로직"]] == [B, A] + assert got["빠진로직"] == ["GF999999"] + one = got["로직"][1] + assert one["이름"] and one["결과단위"] + assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"]) + # 옛 선택 입력 목록과 같은 몸 + assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"] + + +def test_빈_키는_빈_목록(client: TestClient) -> None: + got = client.get("/api/m02/logic-lib/inputs").json() + assert got == {"로직": [], "빠진로직": []} + + +SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; +const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 }; +const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] }; +const size = { 이름: "규격", 고르기: [25, 40] }; +const area = { 이름: "지역" }; +const inputs = [height, kind, size, area]; +console.log(JSON.stringify({ + stored: [ + m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"), + m.storedOf(size, "40"), m.storedOf(area, "서울"), + ], + outside: [ + m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""), + m.isOutside(height, "abc"), m.isOutside(area, "9"), + ], + known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }), + line: m.valueLine(inputs, { 높이: 2, 규격: 40 }), + lineEmpty: m.valueLine(inputs, {}), +})); +""" + + +def test_값_셈() -> None: + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + out = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + assert got["stored"] == [None, 2.5, "막돌", 40, "서울"] + assert got["outside"] == [True, False, False, True, False] + assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]} + assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음 + assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님 diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py index 42e5189b3..3f0ac4938 100644 --- a/resources/tester/test_m02_layer_access.py +++ b/resources/tester/test_m02_layer_access.py @@ -16,6 +16,7 @@ from fastapi import HTTPException from config import config_system from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers @@ -55,16 +56,16 @@ def test_권한_표(): } assert got == { "일반": ["읽기", "읽기", "403", "수정", "403", "403"], - "회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"], - "마스터": ["읽기", "수정", "403", "수정", "읽기", "403"], - "시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"], + "회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6) + "마스터": ["읽기", "수정", "403", "수정", "수정", "403"], + "시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음 } # 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409 assert access.grant(USER, "personal", "7/42").write is True assert _answer(SYSADMIN, "personal", "9") == "400" assert _answer({**USER, "company_id": None}, "company", None) == "409" with pytest.raises(HTTPException) as caught: - access.require(ADMIN, "personal", "7/43", write=True) + access.require(USER, "company", None, write=True) assert caught.value.status_code == 403 @@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: monkeypatch.setattr(layers_router, "_company_users", company_users) monkeypatch.setattr(layers_router, "_companies", companies) + notes: list[tuple[int, str, str | None]] = [] + + async def member(owner: str) -> bool: + company, _, user = owner.partition("/") + return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9) + + async def note(session: dict[str, Any], got: Any) -> None: + notes.append((session["user_id"], got.layer, got.owner)) + + monkeypatch.setattr(items_router, "_member", member) + monkeypatch.setattr(items_router, "_note", note) session: dict[str, Any] = dict(USER) - return {"client": _app(session), "session": session, "storage": storage} + return {"client": _app(session), "session": session, "storage": storage, "notes": notes} def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): @@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): assert client.get(tree).status_code == 200 assert client.get(table).status_code == 200 # 양식 길도 같은 규칙 body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]} - assert client.post(tree, json=body).status_code == 403 # 읽기만 + assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6) + assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄 assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200 session.update(USER) assert client.get(tree).status_code == 403 assert client.get(table).status_code == 403 + assert ( + client.post(tree, json={**body, "이름": "몰래"}).status_code == 403 + ) # 일반 = 남 개인 막힘 session.update(SYSADMIN) assert client.get(tree).status_code == 200 - assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만 + body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]} + assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나 + + +def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]): + """39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 · + 읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP).""" + client, session, storage = world["client"], world["session"], world["storage"] + session.update(ADMIN) + add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""} + assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404 + assert not (storage / "7/44").exists() + assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403 + assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦 + assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403 + first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add) + assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침 + assert (storage / "7/44/templates/table/구조물집계표.json").is_file() + add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"] + assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200 + session.update(SYSADMIN) + assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403 + assert not (storage / "5").exists() # 없는 회사 + add["판"] = "" + assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409 + assert (storage / "9/templates/table/구조물집계표.json").is_file() + assert [n[2] for n in world["notes"]] == ["7/44"] # 성공한 쓰기만(39-1d) · 409 · 403 은 안 남김 def test_남의_층_목록(world: dict[str, Any]): @@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]): people = "/api/m02/layers/personal/owners" assert client.get(people).status_code == 403 # 일반 사용자 assert client.get("/api/m02/layers/company/owners").status_code == 403 + (storage / "7/43/templates/table").mkdir(parents=True) + (storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8") session.update(ADMIN) - assert client.get(people).json() == [ - {"owner": "7/43", "name": "u43"} - ] # 본인 · 44(자리 없음) 뺌 + assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시 + {"owner": "7/42", "name": "u42", "비어있음": True}, + {"owner": "7/43", "name": "u43", "비어있음": False}, + {"owner": "7/44", "name": "u44", "비어있음": True}, + ] + assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦 assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람 session.update(SYSADMIN) assert client.get("/api/m02/layers/company/owners").json() == [ {"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도 {"owner": "9", "name": "c9"}, ] - assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}] + assert client.get(f"{people}?company=9").json() == [ + {"owner": "9/90", "name": "u90", "비어있음": True} + ] diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index a170e6516..1f95ee5fe 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -26,11 +26,12 @@ def _text(path: Path) -> str: @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_층별_고칠_권한(): - """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만.""" + """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침.""" text = _text(LAYER) own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M) found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M) - assert own and found, "ownOwner · accessFor 를 못 찾음" + manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M) + assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음" script = ( 'type ManageLayer = "system" | "company" | "personal";\n' "interface SessionUser { id: string; role?: string; company_id?: number | null;" @@ -38,6 +39,8 @@ def test_층별_고칠_권한(): "interface LayerAccess { canWrite: boolean; needCompany: boolean }\n" + own.group(0).replace("export ", "") + found.group(0).replace("export ", "") + + "\n" + + manages.group(0).replace("export ", "") + "\nconst cases = JSON.parse(process.argv[1]);" + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));" ) @@ -54,7 +57,8 @@ def test_층별_고칠_권한(): ["personal", None, user], ["personal", None, admin], ["personal", None, orphan], ["personal", None, sysadmin], ["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin], - ["personal", "9/90", sysadmin], + ["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin], + ["company", "9", admin], ] # fmt: skip result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -69,7 +73,8 @@ def test_층별_고칠_권한(): ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만 ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만 ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내 - ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만 + ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침 + ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403) ] # fmt: skip @@ -87,6 +92,55 @@ def test_주소_인자를_라우트에서_뗌(): assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_가져올_곳_탭(): + """39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 · + 일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터).""" + text = _text(LAYER) + parts = [ + re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M), + re.search(r"^export function manages[\s\S]*?^}", text, re.M), + re.search(r"^export interface Source[\s\S]*?^}", text, re.M), + re.search(r"^export function readableSources[\s\S]*?^}", text, re.M), + ] + assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음" + script = ( + 'type ManageLayer = "system" | "company" | "personal";\n' + "interface SessionUser { id: string; role?: string; company_id?: number | null;" + " is_master?: boolean }\n" + "const L = (key: string): string => key;\n" + + "\n".join(p.group(0).replace("export ", "") for p in parts if p) + + "\nconst cases = JSON.parse(process.argv[1]);" + + "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {" + + "\n const got = readableSources(u, c, { practice });" + + "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];" + + "\n})));" + ) + sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1} + admin = {"id": "5", "role": "ADMIN", "company_id": 3} + master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True} + user = {"id": "7", "role": "USER", "company_id": 3} + orphan = {"id": "8", "role": "USER", "company_id": None} + cases = [ + [sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False], + [user, "3", True], [orphan, None, False], + ] # fmt: skip + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(cases)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄 + ["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님 + ["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음 + ["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자 + ["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음 + ["master", ["master"]], # 회사 없음 + ] # fmt: skip + + @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_볼_회사는_시스템_관리자만_고름(): """29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 · @@ -132,11 +186,13 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰 assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무 assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기 - assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots + assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6) + assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침 + assert 'L("M02_PeopleEmpty")' in roots assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page - assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page + assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움 panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( "api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel @@ -219,17 +275,20 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): - """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · - 저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · - 끌어다 옮기기는 쓸 수 있는 층만.""" + """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · + 30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page - assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page - labor = _text(M02 / "M02_MasterTemplete_Labor.ts") - assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor + # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 + assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page + assert "(to === layer && toOwner === here)) return [];" in page + copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts") + assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to + assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text( + M02 / "M02_MasterTemplete_TreePanel.ts" + ) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") @@ -242,27 +301,24 @@ def test_구조물_조합_제목_줄_새_항목(): main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main assert ( - "children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]" + "children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]" in main ) assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert ( - "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {})" - in page - ) + assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {})" in page panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") - assert 'void addItem(at?.kind === "folder" ? at.path : []);' in panel + assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder()) + assert "const newItem = (): void => void addItem(folder());" in panel assert ".m02-main__new {\n margin-left: auto;" in _text( M02 / "M02_MasterTemplete_UI_Style.css" ) def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움(): - """31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸 + """31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로).""" side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") - assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side assert 'summary.classList.remove("is-collapsed");' in side drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") mark = drag.split("const dropMark = ")[1].split("};")[0] diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index ab7551876..1bd3308f5 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -67,7 +67,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world): rows = client.get("/api/m02/items").json() ss = store.read_combos()["줄"] assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001" - assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS} + assert set(rows[0]) == { + "key", + "names", + "수정일", # 목록 칸(39-5) + "labor", + *items_router.items_store.ITEM_FIELDS, + } assert set(rows[0]["names"]) == {"일위대가", "도번", "면적번"} assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos() # 시스템 층 쓰기는 SYSTEM_ADMIN 만 @@ -154,7 +160,8 @@ def test_회사_항목을_개인으로_복사(world): made = got.json() assert made["열"] == "cw" and made["key"] == "SP42-000001" # 응답 = 개인 층 새 키(바로 고름) mine_rows = user.get("/api/m02/items?layer=personal").json() - assert mine_rows[0]["key"] == made["key"] and mine_rows[0]["names"]["일위대가"] == ua_name + assert mine_rows[0]["key"] == made["key"] + assert mine_rows[0]["names"]["일위대가"] is None # 조합 키는 안 받음 — 일위대가 문서로(39-1b) mine = "/api/m02/layers/personal" copy = user.get(f"{mine}/templates/structure/cw").json()["문서"] assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw" diff --git a/resources/tester/test_m02_practice.py b/resources/tester/test_m02_practice.py new file mode 100644 index 000000000..4466a0ac5 --- /dev/null +++ b/resources/tester/test_m02_practice.py @@ -0,0 +1,59 @@ +"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기. + +못박는 것 + - `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로. + - 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만. +정본(마스터)은 읽기만 · 쓰기는 임시 사본. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _drop(client, 판: str, want: int) -> dict: + res = client.delete("/api/m02/practice", params={"판": 판}) + assert res.status_code == want, res.text + return res.json() + + +def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811 + admin = _as(world, "SYSTEM_ADMIN") + before = _list(admin, "system") + assert before["줄"] # 사본에 옛 조합이 있음 + for role in ("USER", "ADMIN"): + _drop(_as(world, role), before["판"], 403) + admin = _as(world, "SYSTEM_ADMIN") + _drop(admin, "옛판", 409) + used = before["줄"][0]["키"] + monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else []) + got = _drop(admin, before["판"], 409) + assert "항목 하나" in json.dumps(got, ensure_ascii=False) + assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움 + monkeypatch.setattr(combo_store, "combo_users", lambda k: []) + got = _drop(admin, before["판"], 200) + assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"]) + doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) + assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == [] + again = _list(admin, "system") + assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로 + + +def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만(): + side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8") + assert 'layer === "system" && opt.isAdmin' in side + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래 + view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8") + assert view.count("showConfirmDialog(") == 2 + assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음 diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index da92265cb..403c78937 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -64,8 +64,12 @@ def client(tmp_path, monkeypatch): def _num(code: str) -> dict: - """두 번호 — 같은 8자리.""" - return {"도번": f"SD{code}", "면적번": f"SA{code}"} + """열 번호 — 같은 뒷자리(일위대가번 SU · 39장).""" + return { + "도번": f"SD{code}", + "일위대가번": f"SU{code}", + "면적번": f"SA{code}", + } def test_새로_도번_자동_409_없는_열(client): diff --git a/resources/tester/test_m02_structure_combo.py b/resources/tester/test_m02_structure_combo.py index 56ceb47b5..87246122b 100644 --- a/resources/tester/test_m02_structure_combo.py +++ b/resources/tester/test_m02_structure_combo.py @@ -86,7 +86,7 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지 assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True) # 번호는 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA 가 섬 numbers = client.get("/api/m02/structures/numbers").json() - assert {k: rows["new1"][k] for k in ("도번", "면적번")} == numbers["new1"] + assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번")} == numbers["new1"] assert rows["new1"]["도번"].startswith("SD") # 또 저장해도(바뀐 것 없음) 키 · 다음 그대로 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 8d37c651b..943b45132 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,17 +120,15 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식 - order = [ - "...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]" - " : []),", - " summary,", - " ...(extras ? [extras.옵션, extras.도면] : []),", - ] - assert "\n".join(order) in side - # 일위대가를 열면 이 줄 고름 지움 / 이 줄을 열면 일위대가 고름 지움 - assert "onOpen: () => (options.clear(), paint())" in side - assert "onOpen: () => (labor?.clear(), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서 + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert ( + order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice") + ) + # 실무문서를 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 실무문서 · … 지움(자재 조합 없앰) + assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (practice?.clear(), (allPicked = null), paint())" in side + assert "matset" not in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index d05b30a4d..99585c6a5 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -46,7 +46,7 @@ def test_소스가_700줄을_넘지_않음(): [ ( "M02_MasterTemplete_Api_Fetch.ts", - ["createStructure", "fetchStructureNumbers", "readTemplate"], + ["createStructure", "fetchStructureNumbers"], # readTemplate 은 39-4 에서 뺌 PANEL, ), # 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함 @@ -77,8 +77,11 @@ def test_sub2_가_부르는_이름과_모양(): rf"export const {name} = async \(column: string, from: string\): Promise", body ), name assert "fetchStructureNumbers = (): Promise>" in body - assert re.search(r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*\}", body) - assert re.search(r'export type Kind = [^;]*"area";', body) + assert re.search( + r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}", + body, + ) + assert re.search(r'export type Kind = [^;]*"area" \| "labor";', body) # 재료산출 없앰 def test_집계표만_컨테이너를_붙임(): @@ -152,9 +155,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만 - # 구조물 도면 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰 + # 구조물 도면 · 일위대가 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰 · + # 층 단추 · 나무와 하위 사이 수평선(39-2) summary = side.split("const summary = section(")[1].split(");")[0] - assert "COLUMN_KINDS.map" in summary and "matset" not in side + assert ( + summary.index("opt.tree") + < summary.index("m02-side__rule") + < summary.index("...columnSections") + ) + assert "matset" not in side + assert "const columnSections = COLUMN_KINDS.map(" in side assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) @@ -182,7 +192,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" side = _text(SIDE) assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 - assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side) + assert re.search( + r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*" + r"paintList\(\);\s*paintColumns\(\);", + side, + ) # 양식이 열리면 「전체 항목」 고름도 지움(39-2) for fn in ("async function select(", "async function selectColumn("): body = side.split(fn)[1].split("\n }\n")[0] assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 @@ -190,8 +204,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): # 옵션 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 옵션 고름 지움 assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN)) assert "if (opt.getOpen()) options.clear();" in side.split("function paint(): void {")[1] - # 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리) - assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts")) + # 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움) + assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts") def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): @@ -211,8 +225,8 @@ def test_표_도구_막대에_줄_더하기_줄_지우기_손_입력_열_없음( def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): main = _text(MAIN) - assert "children: [title, back, slot, reload, save, " in main # 제목 바로 오른쪽 - assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 면적산출만 + assert "children: [back, title, slot, reload, save, " in main # M01 꼴 — 제목 앞(39-5) + assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 일위대가 · 면적산출만 body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body assert "sheet?.selectColumn(column)" in body diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 92c9f1bc6..4724c5ab2 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -13,6 +13,7 @@ from fastapi.testclient import TestClient from common_util import common_util_storage from common_util.common_util_auth import verify_session from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.helper_m02_master_sample import sample_table @@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company" -def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None: +def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴( + world: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: client, session = world["client"], world["session"] + notes: list[str] = [] + + async def note(_session: dict[str, Any], got: Any) -> None: + notes.append(got.owner) + + monkeypatch.setattr(items_router, "_note", note) body = {"to": "personal", "종류": "table", "이름": "구조물집계표"} assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200 mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}" assert client.get(mine).status_code == 200 session.update(user_id=43) theirs = mine + "&user_id=42" - # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만 + # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6) assert client.get(theirs).status_code == 403 session["role"] = "ADMIN" assert client.get(theirs).status_code == 200 @@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42", json={"판": None, "문서": {"열": []}}, ) - assert put.status_code == 403 - assert not (world["storage"] / "7/42/templates/table/내표.json").exists() + assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그 + assert (world["storage"] / "7/42/templates/table/내표.json").is_file() + assert notes == ["7/42"] session["role"] = "USER" # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 put = client.put( @@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor got = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} ) - assert got.status_code == 200 + assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6) + session["role"] = "ADMIN" + got = client.post( + f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} + ) + assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴 assert client.get(mine.replace(P1, P_OTHER)).status_code == 403 far = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90} @@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str sources = client.get(f"/api/m02/projects/{P1}/sources").json() assert [row["project_id"] for row in sources["project"]] == [P2] assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"} + for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6) + layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []}) + assert [row["user_id"] for row in sources_of(client)] == [42] + world["session"]["role"] = "ADMIN" + assert [row["user_id"] for row in sources_of(client)] == [42, 43] + + +def sources_of(client: TestClient) -> list[dict[str, Any]]: + return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"] def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None: diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py new file mode 100644 index 000000000..13e3ba179 --- /dev/null +++ b/resources/tester/test_spreadsheet_set_link.py @@ -0,0 +1,68 @@ +"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4). + +순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 — +새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소. +손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_setLink_순수_길(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 13 + + +def test_손잡이_setLink_몸(): + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src + body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert "if (readOnly) return false;" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) + assert "loadLinks();" in body diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 805e228a6..efe0fa5d9 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -269,3 +269,18 @@ .ui-modal.ui-item-ask { width: min(28rem, 92vw); } + +/* 일위대가 카드(39-1c) — 담은 로직 · 자재 수 · 기준 합 · 반영 합 글 */ +.ui-item-picker__labor-line { + margin: 0 0 var(--spacing-4); + color: var(--color-text-body); + font-size: 13px; +} + +.ui-item-picker__labor-line.is-strong { + font-weight: var(--font-weight-medium); +} + +.ui-item-picker__labor-line.is-warn { + color: var(--color-danger); +} diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index b98b60ce8..4e0e70360 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,8 +1,9 @@ /* ============================================================================= * ui_template_item_picker_detail.ts - * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 셋**(도면 · 면적산출 · 선택 입력). - * 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 - * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 · + * 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드 + * (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). + * 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값). * * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 @@ -12,15 +13,12 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; -import { - readInputLinks, - readTemplate, - type InputLinks, -} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; -import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; -import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values"; +import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; +import type { LaborCardHandle } from "./ui_template_item_picker_labor"; import { askFields } from "./ui_template_item_picker_ask"; import { folderLines, @@ -32,7 +30,8 @@ import { type PickerRow, } from "./ui_template_item_picker_data"; import { - loadInputs, + laborOf, + laborValueData, PART_OF, projectItems, sameItem, @@ -115,6 +114,7 @@ const TEXT = { 도면: "도면", 면적: "면적산출", 입력연결: "선택 입력 자리", + 일위대가: "일위대가", } as Record, }; @@ -134,6 +134,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let infos: ProjectItemInfo[] | null = null; let cards: ItemCardsHandle | null = null; let inputs: InputsValueHandle | null = null; + let labor: LaborCardHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; @@ -294,34 +295,36 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { opts.onEdit({ kind, row, info, items, projectId: opts.projectId }); } - async function paintInputs(row: PickerRow, mine: number): Promise { - if (!inputs) return; - const stop = inputs.loading(); // 카드 안 「불러오는 중」(M02 31-7) + /** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */ + async function paintLabor(row: PickerRow, mine: number): Promise { + if (!inputs || !labor) return; + const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7) try { - const list = await loadInputs(row); - let link: InputLinks | null; - let values: ValueDoc = {}; - if (info && items) { - // 복사본 것(가져온 때 그대로) — 값도 복사본에서 - link = (await items.read(info.폴더, "입력연결")) as InputLinks | null; - values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; - } else { - link = await readInputLinks(layerOf(row), row.key).catch(() => null); - } - stop(); - if (mine === seq) inputs.set({ list, link, values }); + const copy = info && items ? info.폴더 : null; + const summary = await laborOf(row, copy ? items : null, copy); + const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {}; + const data = summary ? await laborValueData(summary.로직, values) : null; + stops.forEach((stop) => stop()); + if (mine !== seq) return; + labor.set(summary); + inputs.set(data); } catch (error) { - stop(); - if (mine === seq) inputs.set(null, why(error), () => void paintInputs(row, seq)); + stops.forEach((stop) => stop()); + if (mine !== seq) return; + const retry = (): void => void paintLabor(row, seq); + labor.set(null, why(error), retry); + inputs.set(null, why(error), retry); } } async function ensureCards(): Promise { - if (cards && inputs) return; - const [cardModule, inputModule] = await Promise.all([ + if (cards && inputs && labor) return; + const [cardModule, inputModule, laborModule] = await Promise.all([ import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), + import("../M02_MasterTemplete/M02_Labor_Values"), + import("./ui_template_item_picker_labor"), ]); + labor = laborModule.createLaborCard(); inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, @@ -330,6 +333,8 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { warnOnFail: () => false, extraCards: [inputs.root], }); + // 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에 + cards.root.insertBefore(labor.root, cards.root.children[1] ?? null); cardsHost.replaceChildren(cards.root); } @@ -342,6 +347,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { cardsHost.replaceChildren(); cards = null; inputs = null; + labor = null; return; } paintHead(row); @@ -358,14 +364,14 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { other = own && !info ? own : null; source = makeSource(row); await ensureCards(); - if (mine !== seq || !cards || !inputs) return; + if (mine !== seq || !cards || !inputs || !labor) return; const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label }); inputs.setEditLabel(label ?? "선택 입력 수정"); cards.root.hidden = false; paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); - void paintInputs(row, mine); + void paintLabor(row, mine); })(); } diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index 7c68863b7..b3474a8c8 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -11,12 +11,12 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_el import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing"; -import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; +import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values"; import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import type { EditRequest } from "./ui_template_item_picker_detail"; import { itemName } from "./ui_template_item_picker_data"; -import { loadInputs, PART_OF } from "./ui_template_item_picker_project"; +import { laborValueData, loadInputs, PART_OF } from "./ui_template_item_picker_project"; export interface EditView { root: HTMLElement; @@ -137,15 +137,18 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi }); } + /** 넣을 값 — 복사본 일위대가 담은 로직 입력 전부(39-1c) */ async function mountInputs(): Promise { - const [{ createInputsValueCard }, list, link] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"), - loadInputs(row), - linkOf(), + const [{ createInputsValueCard }, labor, values] = await Promise.all([ + import("../M02_MasterTemplete/M02_Labor_Values"), + items.read(info.폴더, "일위대가") as Promise<{ + 줄?: { 담은로직?: { 로직?: string }[] }; + } | null>, + items.read(info.폴더, "입력값") as Promise, ]); - const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {}; + const logics = (labor?.줄?.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean); card = createInputsValueCard({ mode: "value", onChange: () => setDirty(true) }); - card.set({ list, link, values }); + card.set(await laborValueData(logics, values ?? {})); body.replaceChildren( el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }), ); diff --git a/ui_template/ui_template_item_picker_labor.ts b/ui_template/ui_template_item_picker_labor.ts new file mode 100644 index 000000000..69f14eaf1 --- /dev/null +++ b/ui_template/ui_template_item_picker_labor.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * ui_template_item_picker_labor.ts + * 항목 고르기 모달 「일위대가」 카드(계획서 39-1c) — 담은 로직 수 · 자재 수 · 기준 합 · 반영 합 글. + * 보기만 · 고치기는 원본 층 일위대가 상세. 일위대가 보기 부품(39-3 C)이 오면 몸을 그것으로 바꿈. + * 자료 = `ui_template_item_picker_project.laborOf`(복사본이면 복사본 계산 · 아니면 원본 층 계산). + * 상세(`ui_template_item_picker_detail.ts`)가 카드를 받을 때 함께 `import()`. + * ========================================================================== */ + +import { el } from "./ui_template_elements"; +import { cardNote, cardShell } from "../M02_MasterTemplete/M02_MasterTemplete_CardShell"; +import type { LaborSummary } from "./ui_template_item_picker_project"; + +const TEXT = { + title: "일위대가", + none: "일위대가 없음", + failed: "일위대가를 못 읽음 — {value}", + logics: "담은 로직 {value}개", + materials: "자재 {value}개", + base: "기준 합 {value}원", + reflected: "반영 합 {value}원", + gaps: "빠진 입력 {value}개", + stuck: "멈춘 로직 {value}개", +}; + +const fill = (text: string, value: string | number): string => + text.replace("{value}", String(value)); +const won = (value: number | null): string => + value === null ? "—" : Math.round(value).toLocaleString(); + +export interface LaborCardHandle { + root: HTMLElement; + loading: () => () => void; + /** `null` = 일위대가 없음 · `note` 가 있으면 못 읽은 까닭 */ + set: (summary: LaborSummary | null, note?: string, retry?: () => void) => void; +} + +export function createLaborCard(): LaborCardHandle { + const number = el("span", { className: "m02-struct__no" }); + const body = el("div", { className: "m02-struct__sheet ui-item-picker__labor" }); + const shell = cardShell({ title: TEXT.title, sub: [number], body }); + const line = (text: string, className = ""): HTMLElement => + el("p", { className: `ui-item-picker__labor-line ${className}`.trim(), text }); + + return { + root: shell.root, + loading: () => shell.loading(), + set: (summary, note, retry) => { + number.textContent = summary?.번호 ?? ""; + if (note && retry) return shell.fail(fill(TEXT.failed, note), retry); + if (!summary) return void body.replaceChildren(cardNote(TEXT.none)); + body.replaceChildren( + line(`${fill(TEXT.logics, summary.로직.length)} · ${fill(TEXT.materials, summary.자재)}`), + line(fill(TEXT.base, won(summary.base))), + line(fill(TEXT.reflected, won(summary.reflected)), "is-strong"), + ...(summary.gaps ? [line(fill(TEXT.gaps, summary.gaps), "is-warn")] : []), + ...(summary.stuck ? [line(fill(TEXT.stuck, summary.stuck), "is-warn")] : []), + ); + }, + }; +} diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 6651261cc..f0608a2d3 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -9,19 +9,23 @@ * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} - * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) + * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합) * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` + * 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs` * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchItemInputs, + type InputLinks, type ItemInputs, } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; /** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ -export type ProjectPart = "도면" | "면적" | "입력연결" | "입력값" | "고른자재"; +export type ProjectPart = "도면" | "면적" | "입력연결" | "입력값" | "고른자재" | "일위대가"; /** 원본 종류 → 복사본 부분 */ export const PART_OF: Record<"structure" | "area", ProjectPart> = { @@ -64,11 +68,13 @@ export interface CreatedItem { summary_item: { layer: string; owner: string | null; key: string; name: string }; } -/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ +/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 · 일위대가 기준합 · 반영합 계 */ export interface ProjectCalc { total: number; gaps: number; stuck: number; + base: number | null; + reflected: number | null; } interface Reply { @@ -215,11 +221,19 @@ export function projectItems(projectId: string): ProjectItems { calc: async (folder) => { const reply = await call(`${base}/${enc(folder)}/calc`, post({})); if (reply.status !== 200) return null; - const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] }; + const got = reply.body as { + 계?: number; + 빠진입력?: unknown[]; + 멈춤?: unknown[]; + 기준합?: { 계?: number }; + 반영합?: { 계?: number }; + }; return { total: Number(got.계 ?? 0), gaps: got.빠진입력?.length ?? 0, stuck: got.멈춤?.length ?? 0, + base: got.기준합?.계 ?? null, + reflected: got.반영합?.계 ?? null, }; }, }; @@ -240,3 +254,67 @@ export function loadInputs(row: PickerRow): Promise { made.catch(() => inputsCache.delete(id)); return made; } + +/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */ + +/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */ +export interface LaborSummary { + 번호: string | null; + /** 담은 로직 키(차례 그대로) — 넣을 값 목록을 받는 데도 씀 */ + 로직: string[]; + 자재: number; + base: number | null; + reflected: number | null; + gaps: number; + stuck: number; +} + +interface LaborCopy { + 줄?: { 일위대가번?: string; 담은로직?: { 로직?: string }[]; 자재?: unknown[] }; +} + +/** 그 항목의 일위대가 — 복사본(`folder`)이 있으면 그것 · 없으면 원본 층 열 · 일위대가가 없으면 null */ +export async function laborOf( + row: PickerRow, + items: ProjectItems | null, + folder: string | null, +): Promise { + if (items && folder) { + const copy = (await items.read(folder, "일위대가")) as LaborCopy | null; + if (!copy?.줄) return null; + const got = await items.calc(folder); + return { + 번호: copy.줄.일위대가번 ?? null, + 로직: (copy.줄.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean), + 자재: copy.줄.자재?.length ?? 0, + base: got?.base ?? null, + reflected: got?.reflected ?? null, + gaps: got?.gaps ?? 0, + stuck: got?.stuck ?? 0, + }; + } + const got = await calcLabor(layerOf(row), row.열, null).catch(() => null); + if (!got) return null; + return { + 번호: null, + 로직: got.줄.map((line) => line.로직), + 자재: got.자재.length, + base: got.기준합?.계 ?? null, + reflected: got.반영합?.계 ?? null, + gaps: 0, + stuck: got.줄.filter((line) => line.까닭).length, + }; +} + +/** 넣을 값 카드 자료 — 담은 로직 입력 전부(자리 「선택」 으로 감싸 값 카드가 다 보임) */ +export async function laborValueData( + logics: string[], + values: ValueDoc, +): Promise<{ list: ItemInputs; link: InputLinks; values: ValueDoc }> { + const got = await fetchLogicInputs(logics); + const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 }; + const 줄 = list.로직.flatMap((logic) => + logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })), + ); + return { list, link: { 판: null, 줄 }, values }; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 1fd8dce53..3eba5f311 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,7 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_CardUnitCombo: ["일위대가 조합", "Unit cost combos"], + B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"], B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], @@ -100,6 +100,9 @@ export const ui_locales_m2 = { M02_RootShared: ["회사 공용", "Company shared"], M02_RootReadOnly: ["읽기", "read-only"], M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], + M02_SourcePeople: ["다른 사람", "Others"], + M02_SourcePractice: ["실무문서", "Practice documents"], + M02_PeopleEmpty: ["비어 있음", "empty"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", @@ -139,10 +142,10 @@ export const ui_locales_m2 = { M02_NewItemPickCombo: ["고르기", "Pick"], M02_TreePickCombo: ["일위대가 고르기", "Pick unit price"], M02_TableFold: ["표 접기 · 펴기", "Fold or unfold the table"], - M02_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"], - M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"], - M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"], - M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"], + M02_TreeCopyToMaster: ["마스터로 복사… ({n}개)", "Copy to master… ({n})"], + M02_TreeCopyToCompany: ["회사로 복사… ({n}개)", "Copy to company… ({n})"], + M02_TreeCopyToPersonal: ["개인으로 복사… ({n}개)", "Copy to personal… ({n})"], + M02_TreeCopiedTo: ["{n}개를 {layer} 「{folder}」에 복사함", "Copied {n} to {layer} “{folder}”"], M02_TreeFolderName: ["폴더 이름", "Folder name"], M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], @@ -169,4 +172,32 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_KindLabor: ["일위대가", "Unit costs"], + M02_ItemsAll: ["전체 항목", "All items"], + M02_PracticeTitle: ["실무문서", "Practice documents"], + M02_PracticeRow: ["실무문서 목록", "Practice document list"], + M02_PracticeCount: [ + "옛 마스터 일위대가 조합 {n}개 — 보기 · 복사 원본만(고칠 수 없음)", + "{n} old master unit-cost combos — view and copy source only", + ], + M02_PracticeEmpty: ["실무문서 없음", "No practice documents"], + M02_PracticeKey: ["키", "Key"], + M02_PracticeGroup: ["구분", "Group"], + M02_PracticeUnit: ["단위", "Unit"], + M02_PracticeLogics: ["로직 수", "Logics"], + M02_PracticeOrder: ["차례", "No."], + M02_PracticeMemo: ["메모", "Memo"], + M02_PracticeNoLogic: ["담은 로직 없음", "No logics"], + M02_PracticeBack: ["목록으로", "Back to list"], + M02_PracticeDrop: ["통째 지우기", "Delete all"], + M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], + M02_PracticeDropAsk2: [ + "되돌릴 수 없음 — 정말 통째로 지울까요?", + "This cannot be undone — really delete all?", + ], + M02_PracticeDropped: ["실무문서 {n}개를 지움", "Deleted {n} practice documents"], + M02_PracticeStale: [ + "그 사이 실무문서가 바뀜 — 목록을 다시 받음", + "Practice documents changed — reloaded", + ], } as const;