diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 23afc7b27..2e0051722 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -632,7 +632,9 @@ def pipe_summary( notes = [] missing = sheet.unlinked | {key for key, found in others.items() if not found} if missing: - notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 노랑 램프 줄을 눌러 조합 연결") + notes.append( + f"구조물 조합 없는 시설 {len(missing)}곳 — 노랑 ● 줄을 눌러 구조물 조합 고르기" + ) if no_length: notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") if no_basin: diff --git a/B09_Estimation/B09_Estimation_Past.py b/B09_Estimation/B09_Estimation_Past.py new file mode 100644 index 000000000..96973070c --- /dev/null +++ b/B09_Estimation/B09_Estimation_Past.py @@ -0,0 +1,139 @@ +"""B09 재료 창고 「지난 공사」(PLAN 703-1) — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본. + +목록 = 그 공사 구조물 사본(`S:` · 일위대가 있는 것) · 서고 사본(`L:` · 사전조합 · 구조물조합) · + 고친 칸 수 = 그 공사 저장 덮개(블록 cover — 조합 `S:`/`L:폴더` · 사전조합 `C:참조` · 짝 + `Labor_Source.Builder`) · 같은 원본 · 같은 값 · 같은 덮개 = 한 줄 +가져옴 = 사본 폴더 통째 → 이 공사 `library/{종류}@{층}@{주인}@{키}~{공사 · 사본 해시}`(구조물 사본 = + 서고 구조물조합과 한 꼴) · manifest `가져온곳` = 그 공사 · 사본 · 다시 고르면 같은 사본 · + 덮개 = 그 공사 저장 칸 + 이 공사 블록 cover(조합 = `L:새 폴더` · 사전조합 = 같은 `C:참조`) → + 화면 캐시가 그 cover 가 비었을 때만 얹음([저장] 때 남음) · + 단가 = 이 공사 단가 책(셈이 이 공사 것) · 권한 = 부르는 쪽(같은 회사). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Store as store +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete.M02_Item_Snapshot import ( + FOLDER_PATTERN, + MANIFEST_NAME, + PICKS_FILE, + VALUES_FILE, + linked_snapshots, + now_text, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json + + +def _covers(root: Path) -> dict[str, Any]: + return store.read_user(root).get("daega_cover") or {} + + +def _cells(cover: Any) -> dict[str, Any]: + """덮개 칸만(복사본 표지 `@copy` 뺌).""" + return {k: v for k, v in (cover or {}).items() if not k.startswith("@")} + + +def cover_of(head: str, folder: Path, kind: str) -> str: + """사본 폴더의 대가 블록 cover — 사전조합 = 참조(공사 안 같은 사전조합 한 덮개).""" + if kind != "precombo": + return f"{head}:{folder.name}" + ref = ((read_json(folder / MANIFEST_NAME) or {}).get("원본") or {}).get("참조") or {} + return f"C:{refs.ref_id(ref)}" + + +def _row(folder: Path, key: str, kind: str, cover: dict[str, Any]) -> dict[str, Any]: + manifest = read_json(folder / MANIFEST_NAME) or {} + origin = manifest.get("원본") or {} + path = origin.get("경로") or [] + if kind == "precombo": + name, spec, unit = origin.get("name") or folder.name, "", origin.get("단위") or "" + else: # 짝 `Labor_Source.Builder.combo` 이름 · 규격 · 단위 + labor = read_json(folder / LABOR_FILE) or {} + name = origin.get("name") or " ".join(path) or folder.name + spec, unit = " › ".join(path), (labor.get("줄") or {}).get("단위") or "" + cells = _cells(cover.get(cover_of(key[0], folder, kind))) + sig = library.tag( + { + "원본": {k: origin.get(k) for k in ("layer", "owner", "key", "참조")}, + "값": [read_json(folder / f) for f in (VALUES_FILE, PICKS_FILE)], + "덮개": cells, + } + ) + kind_text = "사전조합" if kind == "precombo" else "구조물 조합" + return {"cover": key, "name": name, "spec": spec, "unit": unit, "kind": kind_text, + "covers": len(cells), "sig": sig} # fmt: skip + + +def rows_of(root: str | Path) -> list[dict[str, Any]]: + """한 공사 가져올 일위대가 — 구조물 사본 → 서고 사본 · 같은 것 한 줄.""" + root = Path(root) + cover = _covers(root) + out: list[dict[str, Any]] = [] + for info in sorted(linked_snapshots(root).values(), key=lambda i: i["폴더"]): + if "일위대가" in info.get("부분", []): + folder = structures_dir(root) / info["폴더"] + out.append(_row(folder, f"S:{folder.name}", "combo", cover)) + for folder, manifest in library.copies(root): + out.append(_row(folder, f"L:{folder.name}", str(manifest.get("종류")), cover)) + seen: set[str] = set() + return [r for r in out if not (r["sig"] in seen or seen.add(r["sig"]))] + + +def _source(other: Path, cover_key: str) -> tuple[Path, str, str, str | None, str]: + """덮개 열쇠 → (사본 폴더, 종류, 층, 주인, 키) · 틀린 모양 ValueError · 없음 LookupError.""" + head, _, rest = cover_key.partition(":") + if head == "S" and FOLDER_PATTERN.match(rest): + folder = structures_dir(other) / rest + origin = (read_json(folder / MANIFEST_NAME) or {}).get("원본") or {} + kind, layer, owner, key = ( + "combo", + origin.get("layer"), + origin.get("owner"), + origin.get("key"), + ) + elif head == "L" and library.NAME.match(rest): + folder = library.library_dir(other) / rest + kind, layer, owner, key = rest.split("~")[0].split("@") + owner = None if owner == "-" else owner.replace("-", "/") + else: + raise ValueError(f"가져올 일위대가 모양이 틀림 「{cover_key}」") + if not isinstance(read_json(folder / MANIFEST_NAME), dict) or not layer or not key: + raise LookupError(f"지난 공사에 그 일위대가가 없음 「{cover_key}」") + return folder, kind, str(layer), owner, str(key) + + +def import_past( + root: str | Path, + other: str | Path, + other_ref: dict[str, Any], + cover_key: str, + who: dict[str, Any] | None = None, +) -> dict[str, Any]: + """지난 공사 사본 하나 → 이 공사 서고 사본 — `{폴더, 만듦, 덮개, 덮개자리}` · + 덮개 = 그 공사 저장 칸 · 덮개자리 = 이 공사 블록 cover.""" + src, kind, layer, owner, key = _source(Path(other), cover_key) + tag = {"공사": str(other_ref.get("project_id")), "사본": cover_key} + name = library.folder_name(kind, layer, owner, key, tag) + target = library.library_dir(root) / name + cells = _cells(_covers(Path(other)).get(cover_of(cover_key[0], src, kind))) + mine = cover_of("L", src, kind) if kind == "precombo" else f"L:{name}" + out = {"폴더": name, "덮개": cells, "덮개자리": mine} + if isinstance(read_json(target / MANIFEST_NAME), dict): + return {**out, "만듦": False} + if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 + shutil.rmtree(target) + shutil.copytree(src, target, ignore=shutil.ignore_patterns(MANIFEST_NAME)) + old = read_json(src / MANIFEST_NAME) or {} + manifest = {k: old[k] for k in ("원본", "값", "원값") if k in old} + manifest.update({"종류": kind, "가져온날": now_text(), "가져온이": who}) + manifest["가져온곳"] = {**other_ref, "사본": cover_key} + atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더 + return {**out, "만듦": True} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 96ca599a0..d15bd3353 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -35,6 +35,12 @@ 「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합 (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 + GET /api/projects/{id}/estimation/past + 재료 창고 「지난 공사」(703-1) — 같은 회사 다른 공사의 일위대가 목록(`B09_Estimation_Past`) + POST /api/projects/{id}/estimation/past {project_id, cover} + 지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} · + 덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) · + 같은 회사만(남의 회사 403) 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · @@ -66,6 +72,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milesto from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Daega_Personal as personal +from B09_Estimation import B09_Estimation_Past as past from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun @@ -82,10 +89,12 @@ from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session from common_util.common_util_project_settings import reset_section +from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root +from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import _company_projects, _project try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이) from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft @@ -488,6 +497,63 @@ async def save_personal( } +def _past_rows(projects: list[dict[str, Any]], here: str) -> list[dict[str, Any]]: + out = [] + for other in projects: + if other["project_id"] == here or not other.get("storage_path"): + continue + try: + rows = past.rows_of(Path(resolve_stored_project_path(other["storage_path"]))) + except (ValueError, OSError): # 못 읽는 공사 = 건너뜀 + continue + ref = {"project_id": other["project_id"], "project": other["name"]} + out += [{**ref, **row} for row in rows] + return out + + +@router.get("/{project_id}/estimation/past") +async def past_list( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + here = await _project(session, str(project_id)) + projects = await _company_projects(int(here["company_id"])) + return {"rows": await asyncio.to_thread(_past_rows, projects, str(project_id))} + + +class PastBody(BaseModel): + project_id: str = Field(min_length=1, max_length=64) + cover: str = Field(min_length=3, max_length=200) + + +@router.post("/{project_id}/estimation/past") +async def past_import( + project_id: UUID, body: PastBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + here = await _project(session, str(project_id)) + if body.project_id == str(project_id): + raise HTTPException(status_code=400, detail="이 공사 것은 「이 공사」 탭에서 고름") + other = await _project(session, body.project_id) + if other["company_id"] != here["company_id"]: + raise HTTPException(status_code=403, detail="같은 회사 공사에서만 가져옴") + ref = {"project_id": body.project_id, "프로젝트": other.get("name")} + try: + made = await asyncio.to_thread( + past.import_past, here["root"], other["root"], ref, body.cover, importer(session) + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + out = await payload(project_id, here["root"]) + daega = out["inputs"].get("daega") or {} + key = (daega.get("libs") or {}).get(made["폴더"]) + # 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록(다른 cover) — 덮개 안 옮김(그 블록 덮개 그대로) + cover = next((b["cover"] for b in daega.get("blocks") or [] if b["id"] == key), None) + if cover != made.pop("덮개자리"): + made["덮개"] = {} + return {**made, "key": key, "cover": cover, "payload": out} + + # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 19d56c11f..430a73b10 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -30,7 +30,7 @@ export interface CheckItem { why: string; } -const BLANK_WHY = "금액 모름 — 단가연결 칸 램프 · 이은 호표의 빈 줄을 확인"; +const BLANK_WHY = "금액 모름 — 단가연결 칸 ● · 이은 호표의 빈 줄을 확인"; /** 빈 줄(설계내역서 줄마다 하나 · 까닭 모음) · 그 밖 확인 줄(탭 · 키마다 하나) · 알림 글 */ export function checkItems( @@ -175,7 +175,7 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi const made = checkItems(sheets, next); items = [...made.items, ...made.others]; const blank = [ - head(`빈 줄 ${made.items.length}`), + head(`금액 빈 줄 ${made.items.length}`), ...(made.items.length ? [list(made.items, 0)] : []), ]; const others = made.others.length diff --git a/B09_Estimation/B09_Estimation_UI_Dock.ts b/B09_Estimation/B09_Estimation_UI_Dock.ts index 7d7b19796..9276e8487 100644 --- a/B09_Estimation/B09_Estimation_UI_Dock.ts +++ b/B09_Estimation/B09_Estimation_UI_Dock.ts @@ -21,7 +21,7 @@ import { export type LinkMode = "popup" | "link"; -const LINK_LABEL: Record = { link: "하이퍼링크", popup: "팝업" }; +const LINK_LABEL: Record = { link: "탭 이동", popup: "곁 창" }; /** 행 누름 길 — 모르는 값 · 못 읽음 = 팝업 */ export function linkMode(): LinkMode { diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts index cb83320de..6727552f1 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -86,7 +86,7 @@ async function preview(code: string, given: Row, rate: unknown, picks: unknown): const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text); const KIND_TEXT: Record = { logic: "단가산출", - precombo: "일위대가 사전조합", + precombo: "일위대가(자료집)", combo: "구조물 조합", dg: "일위대가 호표", sg: "단가산출 호표", diff --git a/B09_Estimation/B09_Estimation_UI_Live.ts b/B09_Estimation/B09_Estimation_UI_Live.ts index 8c3bcccdd..ecf65c5de 100644 --- a/B09_Estimation/B09_Estimation_UI_Live.ts +++ b/B09_Estimation/B09_Estimation_UI_Live.ts @@ -97,7 +97,10 @@ export function createLiveCalc( if (mine !== seq || me.signal.aborted) return; ctrl = null; failedSig = sig; - showToast(`새 산근 단가를 못 받음 — [저장] 때 다시 셈 · ${String(error)}`, "warning"); + showToast( + `새 단가산출 단가를 못 받음 — [저장] 때 다시 셈 · ${String(error)}`, + "warning", + ); settle(null, user); }, ); diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 82bc79315..88d873c46 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -274,3 +274,30 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { .b09-popup .ui-data-table { max-height: none; } + +/* 703-1 재료 창고 — 오른쪽 서랍 몸(탭 · 찾기 목록 · 안내) 세로 쌓음 · 목록만 스크롤 */ +.b09-store { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; +} + +.b09-store .ui-find { + flex: 1 1 auto; + min-height: 0; +} + +/* 703-1 표 칸 + 오른쪽 창고 — 창고가 DOM 앞(표 높이 셈) · 보이는 차례는 표 먼저 */ +.b09-estimation__work { + display: flex; + flex: 1 1 auto; + flex-direction: column; + order: -1; + min-width: 0; +} + +.ui-side-panels-row > .ui-side-panels.b09-store__side { + flex: none; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index b1550bc15..25811e184 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -13,12 +13,13 @@ import "@ui/ui_template_general_layout.css"; import "./B09_Estimation_UI_Page.css"; -import { API_BASE_URL } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createText } from "@ui/ui_template_text"; import { createBanner } from "@ui/ui_template_banner"; import { createButton, + el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, @@ -31,6 +32,7 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { moveStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { navigateTo } from "../A00_Common/router"; import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; @@ -290,7 +292,12 @@ export function renderB09Estimation(root: HTMLElement): void { const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래 // 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위 const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : []; - panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])); + const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])]; + const side = bill?.side?.(table.root); // 703-1 재료 창고 = 표 오른쪽 서랍 + const main = side ? el("div", { className: "b09-estimation__work", children: body }) : null; + // 서랍 = DOM 앞(표 높이 셈 `spaceBelow` 가 뒤 형제만 봄) · 보이는 차례 = CSS order + const row = main && el("div", { className: "ui-side-panels-row", children: [side!, main] }); + panel.replaceChildren(...banner, ...(row ? [row] : body)); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; @@ -375,7 +382,7 @@ export function renderB09Estimation(root: HTMLElement): void { }); save.dataset.b09Save = ""; // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) - const section = createPanelCard("데이터 점검", true); // 311 기본 접힘 + const section = createPanelCard("내역 점검", true); // 311 기본 접힘 section.append(refresh, check.list); // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][<][>][초기화] const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); @@ -414,6 +421,18 @@ export function renderB09Estimation(root: HTMLElement): void { if (go) void moveStage(projectId, goNext, [{ name: "원가 계산 단계 완료", run: complete }]); }); }); + // 703-4 진행 판 · ☰ 서랍(같은 단계 막대) 끝 = M02 「내 자료집」(일위대가 · 구조물 조합) 길 + layout.root.querySelector(".ui-workflow-layout__steps.is-vertical")?.append( + createButton({ + label: "내 자료집(일위대가 · 구조물 조합)", + variant: "pill", + onClick: () => + void askProjectLeave().then((go) => { + if (go) + navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open: "일위대가조합", root: "personal" }); + }), + }), + ); root.append(layout.root); show(current); // 572-2 떠남 물음 — 고침 = 세션 칸(불러온 뒤) · 저장 = 단계 이동과 같은 길 diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts new file mode 100644 index 000000000..81c62c5c6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * B09_Estimation_UI_Store.ts + * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사). + * + * 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 · + * 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결) + * 연결 = 단가연결 모달과 같은 길(`…_Bill_Link` · 702-3 즉시 금액) — 호표 = 연결 · 조합 · 사전조합 = 사본 들여옴 · + * 품셈 = 넣을 값 창(대표 규격 채움) · 지난 공사 = 서버가 이 공사 서고 사본으로(`…_Past` · 덮개째 · + * 단가 = 이 공사 단가 책) → 받은 덮개를 그 cover 가 비었을 때만 화면 캐시에 얹음 + * 틀 = 공용 오른쪽 서랍(`ui_template_side_panels` · 접고 펴기 · 폭 끌기 · 좁은 화면 = 아래로) + * 창고 하나 = 페이지 한 벌(다시 그려도 찾기 글 · 탭 · 받은 목록 남음) · 이 공사 = 그릴 때마다 새로 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataRow } from "@ui/ui_template_data_table"; +import { el, showToast } from "@ui/ui_template_elements"; +import { createFindList, type FindRow } from "@ui/ui_template_find_list"; +import { loadRows } from "@ui/ui_template_item_picker"; +import { itemName } from "@ui/ui_template_item_picker_data"; +import { matchLogic } from "@ui/ui_template_logic_find"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createTabs } from "@ui/ui_template_tabs"; +import { createText } from "@ui/ui_template_text"; +import { currentProjectId } from "../A00_Common/b_project_switch"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + fetchLibrary, + fetchStandard, + fetchStandardSheet, + type StandardRow, +} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet"; +import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import type { TabContext } from "./B09_Estimation_UI_Tabs"; +import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; +import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; + +type Source = "here" | "mine" | "company" | "poomsem" | "past"; +const SOURCES: Array<[Source, string]> = [ + ["here", "이 공사"], + ["mine", "내 것"], + ["company", "회사"], + ["poomsem", "품셈"], + ["past", "지난 공사"], +]; +/** 한 번에 그리는 줄 — 품셈 1만 줄 · 찾기로 좁힘 */ +const SHOWN = 200; +const DRAG = "text/x-b09-store"; + +interface Item { + tag: string; + name: string; + sub: string; + unit: string; + hit: (q: string) => boolean; + go: (ctx: TabContext, apply: ApplyRef) => void; + sheet?: (put: () => void) => void; +} + +const words = (q: string): string[] => q.toLowerCase().split(/\s+/).filter(Boolean); +const has = (text: string) => (q: string) => words(q).every((w) => text.toLowerCase().includes(w)); +const onPayload = (ctx: TabContext) => (p: Row) => Object.assign(ctx.payload, p); + +function hereItems(ctx: TabContext): Item[] { + const of = (tab: "dg" | "sg", list: Row[] | undefined): Item[] => + (list || []).map((r) => { + const tag = tab === "dg" ? `일위대가 ${r.no}호` : `단가산출 ${r.no}호`; + const name = String(r.name || ""); + return { + tag, + name, + sub: String(r.spec || ""), + unit: String(r.unit || ""), + hit: has(`${tag} ${name} ${r.spec || ""}`), + go: (_c, apply) => apply({ tab, key: r.key }, r), + }; + }); + return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)]; +} + +/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */ +async function layerItems(layer: "personal" | "company"): Promise { + const owner = ownOwner(layer, await fetchSessionUser().catch(() => null)); + const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer); + const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : []; + return [ + ...combos.map((r) => { + const name = itemName(r); + const pick = { kind: "combo" as const, layer, owner: r.owner ?? null, key: r.key }; + return { + tag: "구조물 조합", + name, + sub: "", + unit: String(r.단위 || ""), + hit: has(`구조물 조합 ${name}`), + go: (ctx: TabContext, apply: ApplyRef) => + void importLib({ apply, onPayload: onPayload(ctx) }, pick), + }; + }), + ...pres.map((r) => { + const name = r.제목 || r.이름; + const pick = { kind: "precombo" as const, layer, owner, key: r.이름 }; + return { + tag: "일위대가", + name, + sub: [r.구분, r.상세구분].filter(Boolean).join(" "), + unit: String(r.단위 || ""), + hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), + go: (ctx: TabContext, apply: ApplyRef) => + void importLib({ apply, onPayload: onPayload(ctx) }, pick), + }; + }), + ]; +} + +/** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 · + * 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */ +function askStandard(apply: ApplyRef, r: StandardRow): void { + const open = (given: Row): void => askInputs(apply, r.키, r.이름, { given }); + fetchStandardSheet(r).then( + (sheet) => open((sheet as { 입력?: Row }).입력 ?? r.바꿈), + () => open(r.바꿈), + ); +} + +/** 품셈 = 704-1 기준 목록(품명 · 규격 · 금액 · 실무 낱말 찾기) · 연결 = 넣을 값 창(대표 규격 채움) */ +async function poomsemItems(): Promise { + const list = await fetchStandard(); + return list.줄.map((r) => ({ + tag: r.공종 || "품셈", + name: r.이름, + sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`] + .filter(Boolean) + .join(" · "), + unit: r.단위, + hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok, + go: (_c, apply) => askStandard(apply, r), + sheet: (put) => openStandardSheet(r, put), + })); +} + +async function call(path: string, body?: Row): Promise { + const projectId = currentProjectId(); + if (!projectId) throw new Error("프로젝트 없음"); + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, { + method: body ? "POST" : "GET", + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...(body && { body: JSON.stringify(body) }), + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +/** 지난 공사 가져옴 — 이 공사 서고 사본 · 덮개 = 그 cover 가 비었을 때만 얹음 */ +async function importPast(ctx: TabContext, apply: ApplyRef, r: Row): Promise { + showToast("지난 공사에서 가져오는 중…", "info"); + try { + const got = await call("past", { project_id: r.project_id, cover: r.cover }); + if (!got.key) throw new Error("사본은 들여왔으나 일위대가를 못 만듦"); + onPayload(ctx)(got.payload); + const from = ((got.payload.sheets?.daega as Row[]) || []).find((x) => x.key === got.key); + const cells: Row = got.덮개 || {}; + const n = Object.keys(cells).length; + let note = ""; + if (n && got.cover) { + const all: Row = { ...(ctx.user.daega_cover || {}) }; + if (Object.keys(all[got.cover] || {}).length) note = " · 이 공사에 고친 칸이 있어 그대로 둠"; + else { + all[got.cover] = cells; + ctx.user = { ...ctx.user, daega_cover: all }; // 아래 연결이 같은 캐시에 씀 + note = ` · 고친 칸 ${n} 함께 옮김`; + } + } + apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); + showToast(`「${r.project}」 일위대가로 연결함${note} — [저장]해야 남음`, "success"); + } catch (error) { + showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error"); + } +} + +async function pastItems(): Promise { + const got = await call("past"); + return ((got.rows as Row[]) || []).map((r) => ({ + tag: String(r.project || ""), + name: String(r.name || ""), + sub: [r.kind, r.covers ? `고친 칸 ${r.covers}` : ""].filter(Boolean).join(" · "), + unit: String(r.unit || ""), + hit: has(`${r.project} ${r.name} ${r.spec} ${r.kind}`), + go: (ctx, apply) => void importPast(ctx, apply, r), + })); +} + +const LOAD: Record, () => Promise> = { + mine: () => layerItems("personal"), + company: () => layerItems("company"), + poomsem: poomsemItems, + past: pastItems, +}; + +/** 페이지 한 벌 창고 */ +let store: { + root: HTMLElement; + bind: (ctx: TabContext, raw: DataRow[], table: HTMLElement) => void; +} | null = null; + +function makeStore(): NonNullable { + let ctx: TabContext | null = null; + let raw: DataRow[] = []; + let source: Source = "here"; + const got: Partial> = {}; + let shown: Item[] = []; + const finder = createFindList({ placeholder: "품명 · 규격 · 실무 낱말로 찾기" }); + const tabs = createTabs({ + tabs: SOURCES.map(([key, label]) => ({ key, label })), + value: source, + kind: "pill", + onChange: (key) => { + source = key as Source; + draw(); + }, + }); + const guide = createText( + "두 번 누름 = 고른 내역 줄에 단가연결 · 끌어서 내역 줄에 놓기 = 그 줄에 연결 · 우클릭 = 계산서 보기(품셈)", + ); + + /** 그 줄에 연결 — 줄 = 고른 내역 줄(index 없음) · 연결 못 하는 줄이면 알림 */ + const linkTo = (item: Item, at?: number): void => { + if (!ctx) return; + const key = pickedKey(); + const i = at ?? raw.findIndex((r) => String(r.key) === key); + const apply = i >= 0 ? applyAt(ctx, raw, i) : null; + if (!apply) + return void showToast("내역 항목 줄을 먼저 누름(자재 · 묶음 줄은 연결 없음)", "info"); + item.go(ctx, apply); + }; + + const draw = (): void => { + const now = source === "here" ? (ctx ? hereItems(ctx) : []) : got[source]; + if (source !== "here" && now === undefined) void load(source); + if (!Array.isArray(now)) { + finder.hint.textContent = now instanceof Error ? `못 읽음 — ${now.message}` : "읽는 중…"; + return finder.setRows([]); + } + const q = finder.search.value.trim(); + const found = now.filter((x) => x.hit(q)); + shown = found.slice(0, SHOWN); + const more = found.length > SHOWN ? ` · 앞 ${SHOWN} 줄만 — 찾기로 좁힘` : ""; + finder.hint.textContent = found.length + ? `${found.length} / ${now.length}${more}` + : now.length + ? "맞는 것 없음" + : source === "past" + ? "가져올 지난 공사 일위대가 없음(같은 회사 공사만)" + : "비었음"; + finder.setRows( + shown.map((x, i) => ({ + key: String(i), + tag: x.tag, + name: x.name, + sub: x.sub, + unit: x.unit, + onPick: () => linkTo(x), + })), + ); + for (const row of finder.root.querySelectorAll("[data-pick]")) { + row.draggable = true; + row.addEventListener("dragstart", (ev) => ev.dataTransfer?.setData(DRAG, row.dataset.pick!)); + } + for (const [key] of SOURCES) { + const list = key === "here" ? (ctx ? hereItems(ctx) : []) : got[key]; + tabs.setBadge(key, Array.isArray(list) ? list.length : 0); + } + }; + + const load = async (key: Exclude): Promise => { + got[key] = "loading"; + try { + got[key] = await LOAD[key](); + } catch (error) { + got[key] = error instanceof Error ? error : new Error(String(error)); + } + draw(); + }; + + finder.search.addEventListener("input", draw); + // 한 번 누름 = 고름만(두 번째 누름 · 키보드 = 연결) + finder.root.addEventListener( + "click", + (ev) => { + if ((ev.target as Element).closest("[data-pick]") && (ev as MouseEvent).detail === 1) + ev.stopPropagation(); + }, + true, + ); + finder.root.addEventListener("contextmenu", (ev) => { + const row = (ev.target as Element).closest("[data-pick]"); + const item = row ? shown[Number(row.dataset.pick)] : undefined; + if (!item) return; + ev.preventDefault(); + openMenuAt(ev.clientX, ev.clientY, [ + ["고른 내역 줄에 연결", () => linkTo(item)], + ...(item.sheet + ? [["계산서 보기", () => item.sheet?.(() => linkTo(item))] as [string, () => void]] + : []), + ]); + }); + + const body = el("div", { + className: "b09-store", + attrs: { "data-b09-store": "" }, + children: [tabs.root, finder.root, guide], + }); + const side = createSidePanels({ + prefKey: "b09-store", + panels: [{ id: "store", title: "재료 창고", body }], + defaultWidth: 360, + }); + + /** 내역 표 줄에 놓기 — 표를 다시 그리면 새 표에 다시 붙임 */ + const dropOn = (table: HTMLElement): void => { + let over: HTMLElement | null = null; + const mark = (tr: HTMLElement | null): void => { + over?.classList.remove("is-focus"); + over = tr; + over?.classList.add("is-focus"); + }; + table.addEventListener("dragover", (ev) => { + if (!ev.dataTransfer?.types.includes(DRAG)) return; + ev.preventDefault(); + mark((ev.target as Element).closest("tr[data-i]")); + }); + table.addEventListener("dragleave", (ev) => { + if (!table.contains(ev.relatedTarget as Node)) mark(null); + }); + table.addEventListener("drop", (ev) => { + const at = ev.dataTransfer?.getData(DRAG); + const tr = (ev.target as Element).closest("tr[data-i]"); + mark(null); + if (!at || !tr) return; + ev.preventDefault(); + const item = shown[Number(at)]; + if (item) linkTo(item, Number(tr.dataset.i)); + }); + }; + + side.root.classList.add("b09-store__side"); + return { + root: side.root, + bind: (next, rows, table) => { + ctx = next; + raw = rows; + dropOn(table); + draw(); + }, + }; +} + +/** 설계내역서 표 오른쪽 창고 — 표(root)에 놓기 붙이고 창고 서랍을 돌려줌 */ +export function attachBillStore(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { + store ??= makeStore(); + store.bind(ctx, raw, table); + return store.root; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 7a116957a..1a0c479a3 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -65,7 +65,7 @@ export const blankRows = (sheets: Row): Row[] => /** 「빈 줄 n · 미완성」 띠 글(701-3) — 빈 줄 0 = null */ export const unfinished = (sheets: Row): string | null => { const n = blankRows(sheets).length; - return n ? `빈 줄 ${n} · 미완성 — 금액은 아는 줄까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : null; + return n ? `금액 빈 줄 ${n} · 미완성 — 금액은 아는 줄까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : null; }; /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 1091fc393..f11a5f6e6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -54,18 +54,18 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, + `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, + `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [업데이트]`, ); function madeText(payload: Row): string { - return payload.book?.made ? `${dateText(String(payload.book.made))} 굳힘` : "아직 안 굳힘"; + return payload.book?.made ? `${dateText(String(payload.book.made))} 정함` : "아직 안 정함"; } export const buildBasis: TabBuild = ({ sheets }): TabTable => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index e53db9eb4..2f02ab6c2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -11,6 +11,7 @@ * 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴 * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 + * 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -25,6 +26,7 @@ import { type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold"; +import { attachBillStore } from "./B09_Estimation_UI_Store"; const TEXT_KEYS = ["name", "spec", "unit"]; /** 폴더 줄 수식 칸 — 자손 합(셈) */ @@ -67,9 +69,9 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" + + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 ● 을 누르면 일위대가 · 단가산출 연결" + (direct.blank - ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만` + ? ` · 금액 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : ""), editOnDouble: true, editable: (i, key) => { @@ -128,6 +130,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개 + side: (table) => attachBillStore(ctx, raw, table), // 703-1 오른쪽 재료 창고 onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index ca6bf6e32..1f102dad6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -34,6 +34,8 @@ export type BillTable = TabTable & { drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null; /** 줄 펼침(702-1) — 표 root 를 받아 표 아래 자리를 돌려줌 */ fold?: (table: HTMLElement) => HTMLElement; + /** 재료 창고(703-1) — 표 root 를 받아(놓기) 오른쪽 서랍을 돌려줌 */ + side?: (table: HTMLElement) => HTMLElement; }; const NEW_NAME = "새 줄"; @@ -222,6 +224,10 @@ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef { }; } +/** 재료 창고(703-1) — 그 줄에 연결 적용 · 연결 없는 줄(자재 · 묶음 · 조달수수료) = null */ +export const applyAt = (ctx: TabContext, rows: DataRow[], i: number): ApplyRef | null => + linkable(rows[i]) ? applyRef(ctx, rows, i) : null; + /** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */ function linkFor(ctx: TabContext, rows: DataRow[], i: number) { const row = rows[i]; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts index b0ec94253..95b7fef78 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -91,7 +91,7 @@ async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; g async function savePersonal(ctx: TabContext, block: Row): Promise { let name = await askText({ title: "내 자료집에 저장", - message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지", + message: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지", value: block.name || "", okLabel: "저장", }); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index f0b333bd9..656772592 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -45,6 +45,9 @@ let live: { } | null = null; let keysOn = false; +/** 고른 줄 키(703-1 재료 창고 두 번 누름 = 이 줄에 연결) */ +export const pickedKey = (): string | null => picked; + /** 고른 줄 class — Shell 줄 class 곁값 */ export const pickedClass = (row: DataRow | undefined): string | null => row && picked !== null && String(row.key) === picked ? "is-focus" : null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 4f5451c58..1e358ccf5 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -41,21 +41,21 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row (got) => { const logic = got.로직[0] ?? null; openLaborInput({ - title: `${code} ${name}`.trim(), + title: name || code, // 703-4 번호 = 알림 글 끝(자세히) logic, values: old?.given || {}, memo: null, rate: old?.rate ?? null, readOnly: false, okLabel: "연결", - notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 연결 뒤 서버가 바로 셈", + notice: `새 단가산출(산근) = 단가산출근거 끝 호표 · 금액은 연결 뒤 바로 셈 · 번호 ${code}`, done: (next) => { const unit = String(logic?.결과단위 || "") .replace(/^원\//, "") .replace(/^[\d.]+/, ""); const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 }; apply({ ...ref, picks: old?.picks ?? null }, { name, unit }); - showToast("연결함 — 새 산근 단가 셈 중", "success"); + showToast("연결함 — 단가 셈 중", "success"); }, }); }, @@ -194,7 +194,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise { ((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({ kind: tab, key: String(r.key), - tag: tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`, + tag: tab === "dg" ? `일위대가 ${r.no}호` : `단가산출 ${r.no}호`, name: String(r.name || ""), sub: String(r.spec || ""), unit: String(r.unit || ""), @@ -206,7 +206,7 @@ export async function openPriceLink(o: PriceLinkOpts): Promise { key: `${r.layer}:${r.owner ?? ""}:${r.key}`, tag: "구조물 조합", name: itemName(r), - sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`, + sub: LAYER_TEXT[r.layer ?? "system"], // 703-4 번호 숨김 unit: String(r.단위 || ""), onPick: () => pick({ @@ -225,12 +225,12 @@ export async function openPriceLink(o: PriceLinkOpts): Promise { extra: [...combos, ...hop("dg"), ...hop("sg")], kindFilter: [ { value: "logic", text: "단가산출" }, - { value: "precombo", text: "일위대가 사전조합" }, + { value: "precombo", text: "일위대가(자료집)" }, { value: "combo", text: "구조물 조합" }, ...(o.hopyo ? [ - { value: "dg", text: "일위대가 호표" }, - { value: "sg", text: "단가산출 호표" }, + { value: "dg", text: "이 공사 일위대가" }, + { value: "sg", text: "이 공사 단가산출" }, ] : []), ], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 1cc4f2af6..ecefe2180 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -39,7 +39,7 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); /** 단추 글 = 그 보기 이름(304-1 좌측 스위치) */ -export const viewLabel = (view: GroupView): string => (view === "cards" ? "박스보기" : "호표보기"); +export const viewLabel = (view: GroupView): string => (view === "cards" ? "카드 보기" : "표 보기"); type Saved = Record | null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 074f3bc64..11bbd0ade 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -25,7 +25,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun"; import { HOPYO_TAG, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid"; const EMPTY = - "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; + "일위대가 없음 — 이 공사에 고른 구조물 조합이 없음 · B05 · B06 에서 구조물 조합을 고르고 [저장] · 또는 설계내역서 오른쪽 재료 창고"; const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : ""); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 489268092..3a70481f6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -28,7 +28,7 @@ const hint = (sheets: Row, payload: Row): string => { const fixed = Number((payload.summary || {}).fixed) || 0; return [ HINT, - ...(n ? [`빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)`] : []), + ...(n ? [`금액 빈 줄 ${n} — 집계에서 빠짐(왼쪽 내역 점검)`] : []), ...(fixed ? [`고정 줄 ${fixed} — 이은 단가 없어 집계에서 빠짐`] : []), ].join(" · "); }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 1fb329ba6..0c761f055 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -100,7 +100,7 @@ function billRows(bill: Row | undefined): DataRow[] { material: r.material ?? null, expense: r.expense ?? null, }); - const blank = direct.blank ? ` · 빈 줄 ${direct.blank} — 아는 금액만` : ""; + const blank = direct.blank ? ` · 금액 빈 줄 ${direct.blank} — 아는 금액만` : ""; return [ ...top.map((r: Row) => ({ key: `bill:${r.key}`, @@ -243,8 +243,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => { money("expense", "경비"), qty("rate", "요율(%)", 5), money("hand", "수기 입력", undefined, 2), - text("note", "산식", 320), - text("candidate", "요율 출처", 260), + text("note", "계산식", 320), + text("candidate", "요율 근거", 260), ], rows, total: grand, @@ -253,7 +253,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { level: nameLevel(rows), mergeSame: ["part"], banner: unfinished(ctx.sheets) ?? undefined, - hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", + hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 근거 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || (key === "hand" && String(rows[i]?.key) in HAND), diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts index e68bc0e6a..521cf8436 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts @@ -160,7 +160,7 @@ export function createSchedulePanel(user: () => Row, setUser: (next: Row) => voi holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)"; holidayBox.append(holidayCaption, ...checks); - const section = createPanelCard("공기 옵션", true); // 311 기본 접힘 + const section = createPanelCard("공사기간 조건", true); // 311 기본 접힘 section.dataset.b09SchedulePanel = ""; section.style.display = "none"; // 원천(서버 공기 자료)이 오면 보임 · hidden 은 공용 카드 flex 가 덮음 section.append( diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts index 45f700bdf..6f5e83d41 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -263,7 +263,7 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { frozen: 2, mergeSame: ["label"], rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), - hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공사기간 조건」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", tip: (i, key) => { const m = months[monthOf(key)]; if (!m || rows[i].key !== "b") return null; diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 360925cb0..07b794fff 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -154,7 +154,7 @@ def test_사본_없는_시설_알림_응답에_사본(root): out = _run(root) # 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳 assert not any("wing" in c["id"] for c in out["columns"]) - assert "조합 사본 없는 시설 7곳 — 노랑 램프 줄을 눌러 조합 연결" in out["notes"] + assert "구조물 조합 없는 시설 7곳 — 노랑 ● 줄을 눌러 구조물 조합 고르기" in out["notes"] assert not any("관 연장 없음" in n for n in out["notes"]) assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"} diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index 068632436..d08829249 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -63,7 +63,7 @@ def test_157_2_두_보기_같은_값(): def test_157_2_단추_바꿈(): out = _node("\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')]));") - assert out == ["호표보기", "박스보기"] # 304-1 단추 글 = 그 보기 이름 + assert out == ["표 보기", "카드 보기"] # 304-1 단추 글 = 그 보기 이름 def test_157_2_기억_실패_때_카드(): diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 6b9129080..1feb0f283 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -298,7 +298,7 @@ table.onCell(at("earth:4"), "connect"); table.onCell(at("hand:c"), "connect"); // 연결 없음 = 단가연결 창 바로(메뉴 없음) await new Promise((r) => setTimeout(r, 0)); out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]); -picked.rows.find((r) => r.tag === "대가3").onPick(); +picked.rows.find((r) => r.tag === "일위대가 3호").onPick(); out.picked = [picked.title, picked.kinds, picked.rows.map((r) => r.tag)]; out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref; console.log(JSON.stringify(out)); @@ -329,8 +329,8 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): # 연결 없는 줄 = 단가연결 창 바로(종류 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘) unlink = [10, 40, ["단가연결", "-", "단가연결 풀기"]] assert out["menus"] == [unlink, unlink] - kinds = ["단가산출", "일위대가 사전조합", "구조물 조합", "일위대가 호표", "단가산출 호표"] - assert out["picked"] == ["단가연결", kinds, ["대가3"]] + kinds = ["단가산출", "일위대가(자료집)", "구조물 조합", "이 공사 일위대가", "이 공사 단가산출"] + assert out["picked"] == ["단가연결", kinds, ["일위대가 3호"]] assert out["ref"] == DG diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index c3ccb5dd5..23e35fbb5 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -202,9 +202,9 @@ def test_셸_배선(): ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert ( - "panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))" - in src - ) # 203 레벨 안내 + 탭 안내 = 한 영역 + "const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src + and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src + ) # 203 레벨 안내 + 탭 안내 = 한 영역 · 703-1 창고 있으면 표 칸 + 오른쪽 서랍 lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수 assert "b09-estimation__level" not in lvl diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index a953cf527..5d89460f6 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -106,7 +106,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위 + "const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in shell + and "panel.replaceChildren(...banner, ...(row ? [row] : body));" + in shell # 701-3 띠 = 표 위 ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래) assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index 10103fa8a..ecf77a12a 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -24,7 +24,7 @@ SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") def test_묶음_이름_단추_글_설명_없음(): assert ( - 'createPanelCard("데이터 점검", true)' in SHELL and "원가 단가" not in SHELL + 'createPanelCard("내역 점검", true)' in SHELL and "원가 단가" not in SHELL ) # 269 공용 카드 assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 @@ -95,7 +95,7 @@ console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector a, b = out["a"], out["b"] assert a["pending"] and not b["pending"] # 빈 줄 = 직접공사 항목 중 금액 모름(관급 · 제목 · 아는 줄 빼고) · 같은 줄 까닭은 하나로 - assert a["texts"][0] == "빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳" + assert a["texts"][0] == "금액 빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳" assert a["rows"] == [ ["0", "ui-sidebar-row", "제근설계내역서"], ["1", "ui-sidebar-row", "옹벽설계내역서"], @@ -109,7 +109,7 @@ console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] - assert b["texts"] == ["빈 줄 0"] and b["rows"] == [] + assert b["texts"] == ["금액 빈 줄 0"] and b["rows"] == [] assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index bcba88818..e0cfa1ebf 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -231,7 +231,7 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): pre, (url, body) = out["sent"][0], out["sent"][1] kinds = ["logic", "precombo", "combo", "dg", "sg"] assert pre == ["pre", "personal", kinds, "단가연결"] # 층 셋 단추 · 종류 고르기 - assert out["combo"] == [["구조물 조합", "관매설", "마스터 · SS000011"]] + assert out["combo"] == [["구조물 조합", "관매설", "마스터"]] # 703-4 번호 숨김 assert url == "/api/projects/P1/estimation/library" assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py index aa6a57a0f..76f31cbce 100644 --- a/resources/tester/test_304_b09_view_setting.py +++ b/resources/tester/test_304_b09_view_setting.py @@ -87,7 +87,7 @@ def test_좌측_스위치_누름(): "\nconsole.log(JSON.stringify({ name: row.dataset.b09Switch, labels, a, b, c: [redraws, pressed()]," " view: groupView(), kept: Object.fromEntries(kept) }));", ) - assert out["name"] == "view" and out["labels"] == ["박스보기", "호표보기"] + assert out["name"] == "view" and out["labels"] == ["카드 보기", "표 보기"] assert out["a"] == ["true", "false"] # 기본 = 박스보기 assert out["b"] == [0, ["true", "false"]] assert out["c"] == [1, ["false", "true"]] and out["view"] == "table" diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 6a6ff2864..ca9729091 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -100,8 +100,8 @@ def test_B07_첫_장은_표지_기억_있으면_그_장(): def test_B09_기본_접힘_좌측은_한_번만_세움(): shell = _read("B09_Estimation/B09_Estimation_UI_Shell.ts") - assert 'createPanelCard("데이터 점검", true)' in shell - assert 'createPanelCard("공기 옵션", true)' in _read( + assert 'createPanelCard("내역 점검", true)' in shell + assert 'createPanelCard("공사기간 조건", true)' in _read( "B09_Estimation/draft/B09_Draft_Schedule_Panel.ts" ) # 다시 그림(show) 은 가운데 판만 바꿈 — 좌측 카드는 쪽 그릴 때 한 번 diff --git a/resources/tester/test_701_3_b09_cost_unfinished.py b/resources/tester/test_701_3_b09_cost_unfinished.py index fdc18a385..943b0e089 100644 --- a/resources/tester/test_701_3_b09_cost_unfinished.py +++ b/resources/tester/test_701_3_b09_cost_unfinished.py @@ -92,5 +92,5 @@ def test_띠_글_빈_줄_0_이면_없음(): ) # fmt: skip assert result.returncode == 0, result.stderr banner, none = json.loads(result.stdout) - assert banner.startswith("빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner + assert banner.startswith("금액 빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner assert none is None diff --git a/resources/tester/test_703_1_b09_past.py b/resources/tester/test_703_1_b09_past.py new file mode 100644 index 000000000..36ea3bbfc --- /dev/null +++ b/resources/tester/test_703_1_b09_past.py @@ -0,0 +1,147 @@ +"""703-1 B09 재료 창고 「지난 공사」 — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본(덮개째). + +- 목록 = 그 공사 서고 사본 · 구조물 사본 · 고친 칸 수(그 공사 저장 덮개) · 같은 것 한 줄 +- 가져옴 = 사본 폴더 통째 → `library/…~해시` · 가져온곳 · 덮개 = 그 공사 칸 · 다시 = 같은 사본 +- 새 호표(`libs`) · 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록 cover +- 모양 틀림 400 · 없음 404 · 다른 회사 403 · 이 공사 400 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Past as past # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from config import config_system # noqa: E402 +from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 +from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡ + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = copy_master_sample(tmp_path / "master") + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + key = refs.create( + "precombo", + {"이름": "지난 조합", "단위": "㎡", "담은로직": [{"로직": STONE, "수량": {"손": 2}}]}, + )["이름"] + a, b = (tmp_path / "storage" / "7" / "1" / n for n in ("past", "here")) + a.mkdir(parents=True) + b.mkdir(parents=True) + folder = library.import_copy(a, "precombo", "system", None, key)["폴더"] + ref = {"층": "system", "주인": None, "키": key} + cover = { + f"C:{refs.ref_id(ref)}": {"sangeun:GF#0": {"qty": 5}}, + "K:x": {"@copy": {"from": "S:y"}}, + } + store_b09.write_user(a, {"daega_cover": cover}) + return {"key": key, "a": a, "b": b, "folder": folder} + + +def test_목록_서고_사본_고친_칸_수_같은_것_한_줄(world): + a = world["a"] + rows = past.rows_of(a) + assert [(r["cover"], r["name"], r["unit"], r["kind"], r["covers"]) for r in rows] == [ + (f"L:{world['folder']}", "지난 조합", "㎡", "사전조합", 1) + ] + # 같은 원본 · 같은 값 · 덮개 없음 = 한 줄로 셈(값 해시 다른 사본) + library.import_copy(a, "precombo", "system", None, world["key"], haul={"given": {}}) + assert len(past.rows_of(a)) == 1 + + +def test_가져옴_사본_통째_덮개째_새_호표_다시는_같은_사본(world): + a, b, folder = world["a"], world["b"], world["folder"] + ref = {"project_id": "P-A", "프로젝트": "지난"} + made = past.import_past(b, a, ref, f"L:{folder}", {"이름": "나"}) + name = made["폴더"] + assert made["만듦"] and made["덮개"] == {"sangeun:GF#0": {"qty": 5}} + assert name.startswith(f"precombo@system@-@{world['key']}~") and library.NAME.match(name) + copy = library.library_dir(b) / name + src = library.library_dir(a) / folder + for f in ("일위대가.json", "단가.json"): + assert (copy / f).read_bytes() == (src / f).read_bytes() + manifest = json.loads((copy / "manifest.json").read_text("utf-8")) + assert manifest["가져온곳"] == {**ref, "사본": f"L:{folder}"} and manifest["종류"] == "precombo" + got = daega_inputs(b) + bid = got["libs"][name] + block = next(x for x in got["blocks"] if x["id"] == bid) + # 사전조합 덮개 = 참조(공사 안 같은 사전조합 한 덮개) — 덮개자리 = 그 cover + assert (block["name"], block["cover"]) == ("지난 조합", made["덮개자리"]) + assert made["덮개자리"] == "C:참조|system||" + world["key"] + again = past.import_past(b, a, ref, f"L:{folder}") + assert (again["폴더"], again["만듦"]) == (name, False) + + +def test_같은_꼴_블록이_먼저_있으면_그_cover(world): + a, b = world["a"], world["b"] + mine = library.import_copy(b, "precombo", "system", None, world["key"])["폴더"] + name = past.import_past(b, a, {"project_id": "P-A"}, f"L:{world['folder']}")["폴더"] + got = daega_inputs(b) + assert got["libs"][name] == got["libs"][mine] + block = next(x for x in got["blocks"] if x["id"] == got["libs"][name]) + assert block["cover"].startswith("C:") # 사전조합 = 같은 참조 한 덮개(화면이 빈 때만 얹음) + + +def test_구조물조합_사본_덮개자리_새_폴더(world): + a, b = world["a"], world["b"] + folder = library.import_copy(a, "combo", "system", None, "SS000011")["폴더"] + store_b09.write_user(a, {"daega_cover": {f"L:{folder}": {"x#0": {"qty": 2}}}}) + row = next(r for r in past.rows_of(a) if r["cover"] == f"L:{folder}") + assert (row["kind"], row["covers"]) == ("구조물 조합", 1) + made = past.import_past(b, a, {"project_id": "P-A"}, f"L:{folder}") + assert made["덮개자리"] == f"L:{made['폴더']}" and made["덮개"] == {"x#0": {"qty": 2}} + got = daega_inputs(b) + block = next(x for x in got["blocks"] if x["id"] == got["libs"][made["폴더"]]) + assert block["cover"] == made["덮개자리"] + + +def test_모양_틀림_없음(world): + a, b = world["a"], world["b"] + for bad in ("L:../x", "X:y", "S:../../z"): + with pytest.raises(ValueError): + past.import_past(b, a, {}, bad) + with pytest.raises(LookupError): + past.import_past(b, a, {}, "S:nope") + with pytest.raises(LookupError): + past.import_past(b, a, {}, "L:precombo@system@-@SC-000999") + + +def test_길_다른_회사_403_이_공사_400(world, monkeypatch): + rows = { + "here": {"company_id": 7, "root": world["b"]}, + "far": {"company_id": 8, "root": world["a"]}, + } + + async def project(_session, pid): + return {"name": pid, **rows["here" if pid.startswith("0") else "far"]} + + monkeypatch.setattr(router, "_project", project) + pid = router.UUID("00000000-0000-0000-0000-000000000001") + body = router.PastBody(project_id="far", cover=f"L:{world['folder']}") + with pytest.raises(HTTPException) as e: + asyncio.run(router.past_import(pid, body, {"user_id": 1})) + assert e.value.status_code == 403 + same = router.PastBody(project_id=str(pid), cover="L:x") + with pytest.raises(HTTPException) as e: + asyncio.run(router.past_import(pid, same, {"user_id": 1})) + assert e.value.status_code == 400 + assert not library.library_dir(world["b"]).exists() # 아무것도 안 씀 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 8758bc4ef..856ce208f 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -31,7 +31,7 @@ export const ui_locales_b4 = { B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"], B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"], B08_Quantity_Sheets_InputsOnly: [ - "일위대가는 넣을 값만 이 사본에서 고침 — 담은로직 · 자재는 원본 층(M02)에서 고침", + "이 공사에서는 일위대가 옵션 값만 고침 — 품 · 자재 구성은 내 자료집(M02) · 품 · 수량은 원가계산 설계내역서 줄 펼침에서", "Only inputs are edited in this copy — logic lines and materials are edited in the source layer (M02)", ], B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"], @@ -40,11 +40,11 @@ export const ui_locales_b4 = { "Failed to load structure sheets", ], B08_Quantity_Drawing_Missing: [ - "사본 없음 — B05 · B06 좌측 패널에서 조합 연결 · [저장] 뒤 보입니다.", + "구조물 조합 없음 — B05 · B06 왼쪽 패널에서 구조물 조합을 고르고 [저장] 하면 보임", "No project copy — link the combination in the B05 · B06 panel and save.", ], B08_Quantity_Drawing_NoDrawing: [ - "이 조합 사본은 도면이 없습니다 — M02 구조물 조합에서 그린 뒤 [재적용].", + "이 구조물 조합에는 도면이 없음 — 내 자료집(M02) 구조물 조합에서 그린 뒤 [재적용]", "This copy has no drawing — draw it in M02 structure combinations, then reapply.", ], B08_Quantity_Summary_Station: ["측점", "Station"], @@ -66,22 +66,22 @@ export const ui_locales_b4 = { B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"], B08_Quantity_Summary_LampTip: ["구조물 조합 미연결", "Structure combination unlinked"], B08_Quantity_Summary_LampOk: ["모두 연결됨", "All linked"], - B08_Quantity_Link_Title: ["조합 연결", "Link combinations"], + B08_Quantity_Link_Title: ["구조물 조합 고르기", "Link combinations"], B08_Quantity_Link_Body: ["본체", "Body"], B08_Quantity_Link_Linked: ["연결됨", "Linked"], B08_Quantity_Link_Pick: ["선택", "Select"], B08_Quantity_Link_Saved: ["조합을 연결했습니다.", "Combination linked."], B08_Quantity_Link_Failed: ["조합을 연결하지 못했습니다", "Failed to link the combination"], B08_Quantity_Link_CopyFailed: [ - "연결은 저장했으나 사본을 못 만듦", + "골랐으나 이 공사 사본을 못 만듦", "Linked, but the project copy could not be made", ], B08_Quantity_Link_Unlink: ["연결 끊기", "Unlink"], B08_Quantity_Link_UnlinkAsk: [ - "이 칸의 조합 연결을 끊습니다 — 프로젝트 사본은 남습니다.", + "이 칸의 구조물 조합을 풂 — 이 공사 사본은 남음", "Unlink this slot — the project copy is kept.", ], - B08_Quantity_Link_Unlinked: ["조합 연결을 끊었습니다.", "Combination unlinked."], + B08_Quantity_Link_Unlinked: ["구조물 조합을 풀었음", "Combination unlinked."], B08_Quantity_Link_UnlinkFailed: ["연결을 끊지 못했습니다", "Failed to unlink"], B08_Quantity_Link_Update: ["업데이트", "Update"], B08_Quantity_Link_UpdateTip: [