diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index 5f211bd52..597b5cbf7 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -95,12 +95,15 @@ def source_changed(manifest: dict[str, Any]) -> bool | None: return version_of(source) != manifest.get("원본판") -def list_items(project_root: str | Path) -> list[dict[str, Any]]: +def list_items(project_root: str | Path, only: str | None = None) -> list[dict[str, Any]]: + """복사본 목록 — `only` = 그 폴더 하나만(고르기 모달 · 원본 대조를 그 하나만 · 156-2).""" base = _migrated(project_root) out = [] for child in sorted(base.iterdir()) if base.is_dir() else []: if not child.is_dir() or not FOLDER_PATTERN.match(child.name): continue + if only is not None and child.name != only: + continue if not (child / MANIFEST_NAME).is_file(): continue complete_old(child) diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 3b98e61b7..8094536f2 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -50,6 +50,24 @@ def _texts(row: dict) -> list[str]: return out +#: 재료 후보 찾기 답 — (마스터 색인, {조건 글: 키}) · 색인이 바뀌면(마스터 저장) 새로 · +#: 복사본 목록이 복사본마다 단가 묶음을 다시 모아도 같은 조건은 한 번만 찾음(156-2) +_FOUND: tuple[Any, dict[str, list[str]]] = (None, {}) + + +def _candidates(whole: Any, cond: dict) -> list[str]: + """`mf.candidates(whole, cond)` 를 마스터 색인 하나에 한 번만 — 답은 마스터와 조건만 따름.""" + global _FOUND + seen, found = _FOUND + if seen is not whole: + found = {} + _FOUND = (whole, found) + key = json.dumps(cond, ensure_ascii=False, sort_keys=True, default=str) + if key not in found: + found[key] = list(mf.candidates(whole, cond, None, None)) + return found[key] + + class _Collector: def __init__(self, whole: Any): self.whole = whole @@ -125,7 +143,7 @@ class _Collector: self.add(str(one["대표"])) if not one.get("구분") and not one.get("검색어"): continue - for key in mf.candidates(self.whole, one, None, None): + for key in _candidates(self.whole, one): self.add(key) def flow(self, row: dict) -> None: diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 2688b5aa0..30d53ed22 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -107,10 +107,16 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]: 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 그대로 담음 + # 로직 키 → 파일 이름만(`m01.logic` 은 검사 · 단가 미리보기까지 돌아 느림 · 156-2) + files, _ = m01.loaded() + where = { + str(one.get("키")): name + for name, data in m01._logic_files(files) + for one in data.get("줄") or [] + } for key in wanted: - try: - file = m01.logic(key)["file"] - except m01.StoreError: + file = where.get(key) + if file is None: continue if file not in raw: raw[file] = read_json(Path(m01.FOLDER) / file) diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index d2b37c796..6f9f780d7 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -1,7 +1,8 @@ """프로젝트 구조물 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 26-1 · 26-2b). 폴더 = 구조물 id(`structure_id` · 관 지점은 `pipe@측점`) — 같은 항목을 고른 구조물도 복사본은 따로. - GET …/items 목록(원본 · 가져온날 · 마스터바뀜 · 원본없음) + GET …/items[?folder=] 목록(원본 · 가져온날 · 마스터바뀜 · 원본없음) · + folder = 그 폴더 하나만(고르기 모달) POST …/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} POST …/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) · 회사 없음 409 GET …/items/{폴더}/{종류} {종류, 판, 내용} @@ -104,9 +105,12 @@ async def _run(fn, *args): @router.get("/{project_id}/items") async def list_items( - project_id: str, session: dict[str, Any] = Depends(verify_session) + project_id: str, + folder: str | None = None, + session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - return {"항목": await _run(copies.list_items, await _root(session, project_id))} + root = await _root(session, project_id) + return {"항목": await _run(copies.list_items, root, folder)} @router.post("/{project_id}/items") diff --git a/resources/tester/test_156_2_picker_speed.py b/resources/tester/test_156_2_picker_speed.py new file mode 100644 index 000000000..297323367 --- /dev/null +++ b/resources/tester/test_156_2_picker_speed.py @@ -0,0 +1,128 @@ +"""구조물 조합 고르기 모달 로딩 속도 (PLAN 156-2 · 임시 저장소 · 임시 마스터 사본). + +- 목록 `?folder=` = 그 복사본 하나만(원본 대조도 그 하나만) · 값은 통째 목록의 그 줄과 같음 +- 원본 대조(`build_source`)는 재료 후보 찾기를 같은 조건마다 한 번만 · + `m01.logic`(검사 · 단가 미리보기) 안 부름 +- 값은 그대로 — 기억을 비우고 다시 모아도 단가 묶음 · 원본판이 같음 +- 모달 쪽 — 목록은 그 폴더만 · 복사본 부분 · 셈은 한 번 보이는 동안 한 번만 +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes +from M02_MasterTemplete import M02_Project_Items_Router as items_router +from M02_MasterTemplete.M02_Item_Source import build_source, version_of +from resources.tester.test_item_copy_complete import ( # noqa: F401 — 고정물 + PIPE, + _save, + master_copy, + world, +) + +UI = Path(__file__).resolve().parents[2] / "ui_template" +WALL = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} + + +def _two(root: Path) -> None: + """관 지점 둘 — 같은 항목 하나 · 다른 항목 하나.""" + _save( + root, + [ + {"chainage_m": 40.0, "source": "user", "summary_item": PIPE}, + {"chainage_m": 80.0, "source": "user", "summary_item": WALL}, + ], + ) + + +def test_목록_folder_는_그_하나만_값은_통째와_같음(world, monkeypatch): # noqa: F811 + root = world["root"] + _two(root) + whole = {row["폴더"]: row for row in copies.list_items(root)} + assert set(whole) == {"pipe@40.0", "pipe@80.0"} + calls: list[Any] = [] + real = copies.source_changed + monkeypatch.setattr(copies, "source_changed", lambda m: calls.append(m) or real(m)) + assert copies.list_items(root, "pipe@80.0") == [whole["pipe@80.0"]] + assert len(calls) == 1 # 원본 대조 = 그 하나만 + assert copies.list_items(root, "없음") == [] + + +def test_길_folder_거름(world, monkeypatch): # noqa: F811 + root = world["root"] + _two(root) + + async def fake_row(project_id: str) -> dict[str, Any]: + return {"company_id": 3, "storage_path": str(root)} + + monkeypatch.setattr(layer_routes, "_project_row", fake_row) + monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path) + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3} + client = TestClient(app) + base = "/api/projects/p1/items" + assert len(client.get(base).json()["항목"]) == 2 + got = client.get(base, params={"folder": "pipe@40.0"}).json()["항목"] + assert [row["폴더"] for row in got] == ["pipe@40.0"] + + +def test_원본_대조는_같은_후보_찾기를_한_번만(world, monkeypatch): # noqa: F811 + found: list[str] = [] + real = prices.mf.candidates + monkeypatch.setattr( + prices.mf, "candidates", lambda w, c, *a: found.append(repr(c)) or real(w, c, *a) + ) + monkeypatch.setattr(prices, "_FOUND", (None, {})) + + def no_logic(key: str) -> dict: + raise AssertionError("m01.logic 은 검사 · 단가 미리보기까지 돌아 느림 — 파일 이름만 찾음") + + monkeypatch.setattr(m01, "logic", no_logic) + first = build_source(PIPE) + once = len(found) + assert once and len(set(found)) == once # 같은 조건은 한 번만 + again = build_source(PIPE) + assert len(found) == once # 두 번째 대조 = 후보 찾기 안 함 + assert version_of(again) == version_of(first) + + +def test_기억을_비워도_단가_묶음_원본판_같음(world, monkeypatch): # noqa: F811 + root = world["root"] + _two(root) + before = {row["폴더"]: row["원본판"] for row in copies.list_items(root)} + monkeypatch.setattr(prices, "_FOUND", (None, {})) + for folder, item in (("pipe@40.0", PIPE), ("pipe@80.0", WALL)): + assert version_of(build_source(item)) == before[folder] + assert not any(row["마스터바뀜"] for row in copies.list_items(root)) + + +def test_목록_시간_상한(world): # noqa: F811 + """복사본 둘 · 두 번째 목록(기억 있음)은 0.5초 안 — 옛 길은 항목마다 2~5초.""" + root = world["root"] + _two(root) + copies.list_items(root) + start = time.perf_counter() + copies.list_items(root) + assert time.perf_counter() - start < 0.5 + + +def test_모달은_그_폴더만_읽고_부분_셈은_한_번(): + detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") + assert "infos ??= await items.list(folder);" in detail + assert "copyReads.set(part" in detail and "(copyCalc ??= " in detail + assert "items.read(" not in detail and "items?.calc(" not in detail # 다 한 번 부름 길로 + show = detail[detail.index("function show(") :] + assert "copyReads = new Map();" in show and "copyCalc = null;" in show # 보일 때마다 새로 + project = (UI / "ui_template_item_picker_project.ts").read_text(encoding="utf-8") + assert "folder ? `${base}?folder=${enc(folder)}` : base" in project diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index a6e00779f..91320af14 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -176,7 +176,8 @@ def test_모달_일위대가_카드_읽기_전용(): """142-1 — 모달 자료 자리엔 쓰는 길(`write`)이 없고 카드는 그때 보기만(원본 층 [저장]을 안 엶).""" detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8") body = detail[detail.index("const makeSource") : detail.index("const badge")] - assert "write" not in body and "labor: {" in body and "items.calcLabor(at)" in body + assert "write" not in body and "labor: {" in body and "calc: () => calcCopy(at)," in body + assert "(copyCalc ??= (items as ProjectItems).calcLabor(at))" in detail # 한 번 셈(156-2) cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") assert "const laborWritable = (): boolean => opts.editable() && !!opts.source().write;" in cards assert "readOnly: !laborWritable()," in cards diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index 7e4389ef6..bdea143d2 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -33,9 +33,12 @@ import { projectItems, sameItem, structureFolder, + summarize, + type CopyCalc, type CreatedItem, type ProjectItemInfo, type ProjectItems, + type ProjectPart, withCopyValues, } from "./ui_template_item_picker_project"; @@ -131,6 +134,17 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; let other: ProjectItemInfo | null = null; + /** 한 번 보이는 동안 복사본 부분 · 셈은 한 번만 부름(카드 · 일위대가 · 상태 줄이 같은 것을 씀 · 156-2) — + * 새로 보일 때(`show`) 비움 */ + let copyReads = new Map>(); + let copyCalc: Promise | null = null; + const readCopy = (at: string, part: ProjectPart): Promise => { + let got = copyReads.get(part); + if (!got) copyReads.set(part, (got = (items as ProjectItems).read(at, part))); + return got; + }; + const calcCopy = (at: string): Promise => + (copyCalc ??= (items as ProjectItems).calcLabor(at)); const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; const folder = opts.structureId ? structureFolder(opts.structureId) : null; /** 복사본을 고칠 수 있나 — 프로젝트 안 · 구조물이 있을 때 */ @@ -163,20 +177,20 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { layer: "project", projectId: opts.projectId, read: async (part) => { - const doc = await items.read(at, PART_OF[part]); + const doc = await readCopy(at, PART_OF[part]); if (doc == null) throw new Error(TEXT.noCopy); return { 문서: doc }; }, labor: { read: async () => { const [labor, values, picks] = await Promise.all([ - items.read(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>, - items.read(at, "입력값") as Promise, - items.read(at, "고른자재") as Promise | null>, + readCopy(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>, + readCopy(at, "입력값") as Promise, + readCopy(at, "고른자재") as Promise | null>, ]); return { 문서: labor?.줄 ? withCopyValues(labor.줄, values, picks) : {} }; }, - calc: () => items.calcLabor(at), + calc: () => calcCopy(at), }, }; } @@ -222,7 +236,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { /** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */ async function paintCalc(from: ProjectItemInfo, mine: number): Promise { - const got = await items?.calc(from.폴더).catch(() => null); + const got = await calcCopy(from.폴더).then(summarize, () => null); if (!got || mine !== seq) return; const total = Math.round(got.total).toLocaleString(); status.append( @@ -319,6 +333,8 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { function show(row: PickerRow | null): void { const mine = ++seq; current = row; + copyReads = new Map(); + copyCalc = null; if (!row) { head.replaceChildren(el("p", { className: "ui-item-picker__hint", text: opts.hint })); status.hidden = true; @@ -329,7 +345,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { paintHead(row); void (async () => { try { - if (items && folder) infos ??= await items.list(); + if (items && folder) infos ??= await items.list(folder); } catch (error) { if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); infos = []; diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index bcf7b5cf0..0b800712d 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -3,7 +3,7 @@ * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * * 구조물 복사본(sub6 · 26-1 · 26-2b) — 폴더 = 구조물 id(같은 항목을 고른 구조물도 복사본은 따로) - * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]} + * GET /api/projects/{id}/items[?folder=] → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]} · folder = 그 하나만 * POST /api/projects/{id}/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} * POST /api/projects/{id}/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) → {…, summary_item} * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) @@ -112,8 +112,8 @@ async function call(path: string, init: RequestInit = {}): Promise { const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); export interface ProjectItems { - /** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */ - list: () => Promise; + /** 프로젝트에 가져온 항목 목록 — 서버가 항목마다 원본을 다시 모아 대조 · `folder` = 그 폴더 하나만(고르기 모달 · 156-2) */ + list: (folder?: string) => Promise; /** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */ read: (folder: string, part: ProjectPart) => Promise; /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ @@ -142,6 +142,15 @@ export interface ProjectItems { const clients = new Map(); +/** 복사본 셈 답 → 모달이 보이는 것(계 · 빈 입력 수 · 못 돈 줄 수) */ +export const summarize = (got: CopyCalc): ProjectCalc => ({ + total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0), + gaps: got.빠진입력?.length ?? 0, + stuck: (got.줄 ?? []).filter((line) => line.까닭).length, + base: got.기준합?.계 ?? null, + reflected: got.반영합?.계 ?? null, +}); + /** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */ export function projectItems(projectId: string): ProjectItems { const kept = clients.get(projectId); @@ -161,8 +170,8 @@ export function projectItems(projectId: string): ProjectItems { }; const client: ProjectItems = { - list: async () => { - const reply = await call(base); + list: async (folder) => { + const reply = await call(folder ? `${base}?folder=${enc(folder)}` : base); const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목; if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply)); return rows; @@ -231,14 +240,7 @@ export function projectItems(projectId: string): ProjectItems { }, calc: async (folder) => { const got = await calcCopy(folder).catch(() => null); - if (!got) return null; - return { - total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0), - gaps: got.빠진입력?.length ?? 0, - stuck: (got.줄 ?? []).filter((line) => line.까닭).length, - base: got.기준합?.계 ?? null, - reflected: got.반영합?.계 ?? null, - }; + return got ? summarize(got) : null; }, calcLabor: (folder) => calcCopy(folder), link: async (copy, facility, item) => {