perf(m02): 구조물 조합 고르기 모달 로딩 — 목록 그 폴더만 · 후보 찾기 기억 · 사본 읽기 · 셈 한 번(156-2)

- 까닭: 복사본 목록이 사본마다 원본 단가 묶음을 다시 모음(자재 후보 찾기 같은 조건 되풀이 · m01.logic 검사)
- 목록 ?folder= = 그 복사본 하나만 원본 대조 · 모달은 고르는 구조물 폴더만
- 재료 후보 찾기 = 마스터 색인 하나에 같은 조건 한 번 · 로직 파일 이름만 찾음
- 모달 한 번 보이는 동안 사본 부분 · 셈 한 번만(카드 · 일위대가 · 상태 줄 공유)
- 사본 7개 모달 열기 13.4초 → 처음 0.43초 · 다시 0.06초 · 원본판 · 단가 묶음 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-09 20:51:44 +09:00
co-authored by Claude Opus 5.5
parent 6b0c1a889f
commit bd963e383e
8 changed files with 207 additions and 29 deletions
+4 -1
View File
@@ -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)
+19 -1
View File
@@ -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:
+9 -3
View File
@@ -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)
@@ -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/{폴더}/{종류} {종류, 판, 내용}
@@ -103,9 +104,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")
+128
View File
@@ -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
+2 -1
View File
@@ -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
+23 -7
View File
@@ -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<ProjectPart, Promise<unknown | null>>();
let copyCalc: Promise<CopyCalc> | null = null;
const readCopy = (at: string, part: ProjectPart): Promise<unknown | null> => {
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> =>
(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<ValueDoc | null>,
items.read(at, "고른자재") as Promise<Record<string, unknown> | null>,
readCopy(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>,
readCopy(at, "입력값") as Promise<ValueDoc | null>,
readCopy(at, "고른자재") as Promise<Record<string, unknown> | 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<void> {
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 = [];
+15 -13
View File
@@ -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<Reply> {
const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) });
export interface ProjectItems {
/** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */
list: () => Promise<ProjectItemInfo[]>;
/** 프로젝트에 가져온 항목 목록 — 서버가 항목마다 원본을 다시 모아 대조 · `folder` = 그 폴더 하나만(고르기 모달 · 156-2) */
list: (folder?: string) => Promise<ProjectItemInfo[]>;
/** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */
read: (folder: string, part: ProjectPart) => Promise<unknown | null>;
/** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */
@@ -142,6 +142,15 @@ export interface ProjectItems {
const clients = new Map<string, ProjectItems>();
/** 복사본 셈 답 → 모달이 보이는 것(계 · 빈 입력 수 · 못 돈 줄 수) */
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) => {