Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/{폴더}/{종류} {종류, 판, 내용}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user