diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 3e917b35d..3d5db856b 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -13,6 +13,7 @@ * M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더 * 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로 * [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장) + * 저장 전 문서(`memory` · 836-5 · 불러오기 미리 보기) = 빈 키 + 서버에 문서째(`본`) 읽음 · 왼쪽 줄 쌓기는 보기만(inert) · 디스크 쓰기 0 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -73,6 +74,8 @@ export interface StackScreenOptions { page?: () => void; /** 페이지 머리 칸(808-1 · 요구 701-3) — 있으면 [단가 갱신] · [재생성] 등 화면 단추를 여기에(화면 위 단추 줄 대신) */ tools?: HTMLElement; + /** 저장 전 SK 문서(836-5 · 불러오기 미리 보기) — 빈 키일 때 이 문서를 서버가 읽어 줌(파일 안 읽음 · 안 씀) */ + memory?: () => Record | null; } type Docs = Record; @@ -125,14 +128,21 @@ export function createStackScreen(opt: StackScreenOptions) { let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`) const blocks = new Set(); // 풀린 공식 묶음 + const mem = (): Record | null => (cp && !key ? (opt.memory?.() ?? null) : null); const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { - const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, { - method: body === undefined ? "GET" : "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - signal, - }); + const saved = mem(); // 저장 전 문서 = 길 키 「-」 · 몸 `본` + const r = await fetch( + `${API_BASE_URL}${cp ? cp.route(saved ? "-" : key, path) : opt.base + path}`, + { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...(body === undefined + ? {} + : { body: JSON.stringify(saved ? { ...(body as object), 본: saved } : body) }), + signal, + }, + ); const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown }; if (!r.ok) { const why = data.detail; @@ -345,9 +355,9 @@ export function createStackScreen(opt: StackScreenOptions) { ); opt.tools?.replaceChildren(...tools); const items = stackOf(doc); - root.dataset.edit = edit() ? "1" : ""; + root.dataset.edit = edit() || mem() ? "1" : ""; left.replaceChildren( - ...(edit() + ...(edit() || mem() ? [ buildEdit({ doc, @@ -367,6 +377,7 @@ export function createStackScreen(opt: StackScreenOptions) { ] : []), ); + left.inert = !!mem(); // 저장 전 문서 = 보기만(고침은 저장 뒤) right.replaceChildren( items.length || doc.결과 ? buildOut(doc, answer, { @@ -493,6 +504,26 @@ export function createStackScreen(opt: StackScreenOptions) { [cut, mark, version] = [{}, undefined, ""]; draw(); if (!next) { + if (mem()) { + // 저장 전 문서(836-5) — 서버가 이 문서째 읽어 줌 + const mine = ++seq; + try { + const got = await call("/read", {}); + if (mine !== seq || key !== next) return; + take(got); + recalc(); + return draw(); + } catch (e) { + if (mine === seq) + head.replaceChildren( + el("p", { + className: "ui-text ui-text--muted", + text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`, + }), + ); + return; + } + } if (cp) return void right.replaceChildren( el("p", { className: "ui-text ui-text--muted", text: lt("Unsaved_New") }), diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py index 4f3a99c89..13db5efbd 100644 --- a/M02_MasterTemplete/M02_Doc_Router.py +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -4,6 +4,7 @@ POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?} → {…, 전, 후, 바뀐자원?, 버림?} POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴 + POST /api/m02/layers/{층}/sangeun/-/doc/read 저장 전 문서 읽기(층 읽기 · 836-5) {본} → 읽기 꼴 · 미리 셈 몸에도 `본` 가능 GET /api/m02/layers/{층}/sangeun/{키}/doc/machine?no= 기계 창(827-1 · 층 읽기) → {사본, 지금, 새값} 옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765). ⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). @@ -29,6 +30,7 @@ class DocBody(BaseModel): 할일: str | None = None 판: str | None = None 단가기준: str | None = None # 가져온 식만(807-1 · 엑셀 | 우리) + 본: dict[str, Any] | None = None # 저장 전 SK 문서째(836-5 · 파일 안 읽음 · 안 씀) @router.get("/layers/{layer}/sangeun/{key}/doc") @@ -49,6 +51,16 @@ def machine_doc( return _call(_root(session, layer, owner), path.machine, key, no) +@router.post("/layers/{layer}/sangeun/-/doc/read") +def read_memory_doc( + layer: Layer, + body: DocBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + return _call(_root(session, layer, owner), path.read_memory, body.본) + + @router.post("/layers/{layer}/sangeun/{key}/doc/preview") def preview_doc( layer: Layer, @@ -57,7 +69,7 @@ def preview_doc( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - folder = _root(session, layer, owner, write=True) + folder = _root(session, layer, owner, write=body.본 is None) # 본 = 안 씀 → 읽기 권한이면 됨 return _call(folder, path.preview, key, body.model_dump(exclude_none=True)) diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index e740a5101..9b23fb38d 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -9,6 +9,7 @@ M01 수식 문서 길과 같은 꼴: 읽기 {문서, 셈} · 미리 셈 {문서} 단가갱신 = 값 줄만 지금 M01(문서 그대로). 가져온 식 SK(807-1) = 가져온 줄 문서 · 셈 = 단가기준(엑셀 값 · 연결 값) · 부름 · 끊음 없음 (형제 SK 는 셈 때 읽음) · 단가갱신 = 문서 그대로 · 바탕 참조 SK = 값 사본 없음(400). +저장 전 문서(836-5 · 불러오기 미리 보기 835-1) = 몸 `본`(그 SK 문서째) — 파일 안 읽음 · 안 씀 · 읽기 · 미리 셈만(`read_memory`). """ from __future__ import annotations @@ -100,6 +101,20 @@ def read(key: str) -> dict[str, Any]: return {**answer(current(got["문서"]), got["문서"]), "판": got["판"]} +def read_memory(sk: Any) -> dict[str, Any]: + """저장 전 문서 읽기(836-5) — 몸 `본` 그대로 셈 · {read 꼴 · 판 = 빈 글} · 디스크 쓰기 0.""" + doc = _mem(sk) + return {**answer(current(doc), doc), "판": ""} + + +def _mem(raw: Any) -> dict: + if not isinstance(raw, dict): + raise _bad("본 · 묶음 아님") + if len(json.dumps(raw, ensure_ascii=False, default=str).encode("utf-8")) > MAX: + raise _bad("본 · 너무 큼") + return raw + + # ── 몸 검사 ─────────────────────────────────────────────────────────── def _doc(raw: Any, key: str, where: str) -> dict: if not isinstance(raw, dict): @@ -148,7 +163,11 @@ def preview(key: str, body: dict) -> dict[str, Any]: def _preview(key: str, body: dict) -> dict[str, Any]: - doc = _file(key)["문서"] + if body.get("본") is not None: # 저장 전 문서(836-5) — 파일 아님 + doc = _mem(body["본"]) + key = str(doc.get("키") or "") + else: + doc = _file(key)["문서"] mine = _basis(doc, body) before = current(doc) job = body.get("할일") or None diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 7407270bb..fb7a2c50a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -116,6 +116,11 @@ export function mountSangeunDetail( scope, onSaved: (v) => void (refreshing = formulaSaved(v)), page: opt.source ? () => report() : undefined, // 804-8 페이지 [저장] · [되돌림] + // 836-5 저장 전 가져온 식(불러오기 미리 보기) = 그 문서째 수식 보기 + memory: () => + doc.가져온 && !/^SK-(?!0{6})\d{6}$/.test(doc.키 ?? "") + ? (doc as Record) + : null, }); /** 수식 탭 저장 = 파일 판 · `수식` 칸이 바뀜 — 페이지 판 · 이 화면 문서 `수식` 을 새로(상세 탭 셈이 씀) */ async function formulaSaved(version: string): Promise { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index da4bf9e81..0875e1fb0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -20,6 +20,8 @@ export interface SkEquationOptions { onSaved: (version: string) => void; /** 페이지가 저장 · 되돌림을 맡음(804-8) — 수식 고침 있음이 바뀔 때 부름 */ page?: () => void; + /** 저장 전 불러온 단가산출 문서(836-5 · 미리 보기 835-1) — 있으면 그 문서째 수식 보기 · 없으면 null */ + memory?: () => Record | null; } /** SK 수식 길 — 꼬리 물음(`/table?key=…`)에 주인 물음을 이어 붙임 */ @@ -56,6 +58,7 @@ export function createSangeunTabs( scope: opt.scope, // 804-4 다른 단가산출 · 일위대가 복사 page: opt.page, tools, + memory: opt.memory, }); const show = (doc: boolean): void => { stack.root.hidden = tools.hidden = !doc; @@ -68,7 +71,7 @@ export function createSangeunTabs( host: el("div", { className: "m02-sk__views", children: [stack.root] }), draw: (k: string) => { // 문서 없는 줄(일위대가 로직 줄 모달 등 · 페이지 아님) = 단가산출 상세 몸만(808-1 · 수식은 문서가 있어야) - const doc = !!k || !!opt.page; + const doc = !!k || !!opt.page || !!opt.memory?.(); show(doc); if (doc) stack.draw(k); }, diff --git a/resources/tester/test_802_4b2_m02_screen.py b/resources/tester/test_802_4b2_m02_screen.py index c86cd5ff8..716f61dcc 100644 --- a/resources/tester/test_802_4b2_m02_screen.py +++ b/resources/tester/test_802_4b2_m02_screen.py @@ -52,7 +52,7 @@ def test_길_앞머리_M02_공용화면(): assert "/m02/layers/${scope.layer}/sangeun/${encodeURIComponent(key)}/doc${tail}" in tabs assert "admin: opt.edit" in tabs # 층 쓰기 권한 = 고침 scr = _src("A00_Common/line_stack_screen.ts") # 804-6 수식 탭 = 줄 쌓기 - assert "cp ? cp.route(key, path) : opt.base + path" in scr + assert 'cp ? cp.route(saved ? "-" : key, path) : opt.base + path' in scr # 836-5 저장 전 문서 = 길 키 「-」 assert "{ 문서: doc, 끊음: cut, 판: version }" in scr # 저장 몸 detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") assert ( diff --git a/resources/tester/test_806_1_labor_view.py b/resources/tester/test_806_1_labor_view.py index 271195ff3..9472964a1 100644 --- a/resources/tester/test_806_1_labor_view.py +++ b/resources/tester/test_806_1_labor_view.py @@ -137,4 +137,4 @@ def test_808_1_모달_머리_일원화(): assert "children: [tabs.tools]" in detail and "tabs.picker" not in detail assert r"/^SK-(?!0{6})\d{6}$/" in detail tabs = _src("M02_MasterTemplete_Sangeun_Tabs.ts") - assert "const doc = !!k || !!opt.page;" in tabs # 836-3 문서 없는 줄 = 상세 몸만 + assert "const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs # 836-3 · 836-5 문서 없는 줄 = 상세 몸만 diff --git a/resources/tester/test_836_5_memory_doc.py b/resources/tester/test_836_5_memory_doc.py new file mode 100644 index 000000000..d87536010 --- /dev/null +++ b/resources/tester/test_836_5_memory_doc.py @@ -0,0 +1,73 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""836-5 저장 전 단가산출(불러오기 미리 보기)도 수식 보기 — 임시 storage · M01 그대로. + +① 미리 보기 문서째 수식 길 읽기(`Sk_Path.read_memory`) = 디스크 쓰기 0 · 금액 = 저장한 SK 를 읽은 값 · 미리 셈(`본`)도 쓰기 0 +② 화면 — 줄 쌓기가 빈 키 + 저장 전 문서를 서버에 문서째 읽힘 · 왼쪽은 보기만 · 탭은 그 문서가 있을 때만 수식 +실무 엑셀 없으면 ① skip. +""" + +from __future__ import annotations + +from pathlib import Path + +import helper_m02_hopyo_golden as golden +import pytest + +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] + + +def _files(folder: Path) -> dict[str, int]: + return {str(p.relative_to(folder)): p.stat().st_mtime_ns for p in folder.rglob("*") if p.is_file()} # fmt: skip + + +@pytest.mark.parametrize( + ("tag", "no"), [("봉화", 3), ("북부청임도", 2)] +) # STmate 원파일 · 엑셀(EST) +def test_memory_doc_read_writes_nothing_same_as_saved(world, tag, no) -> None: + src = golden.FILES.get(tag) + if src is None or not src.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + folder, data = world["personal"], src.read_bytes() + with store.at(folder): + parsed = hx.read_book(src.name, data) + row = hs.save(folder, src.name, parsed, {"이름": "sub1임시"}, None, hs.fingerprint(data)) + fh.keep(folder, row["키"], src.name, data) + with store.at(folder): + seen = fh.draft(folder, row["키"], no) + before = _files(folder) + got = path.read_memory(seen["문서"]) # 저장 전 문서째 + asked = path.preview("-", {"본": seen["문서"], "문서": got["문서"]}) # 미리 셈 + assert _files(folder) == before # 읽기 · 미리 셈 = 디스크 쓰기 0 + assert got["판"] == "" and got["셈"] and asked["셈"] == got["셈"] + made = fh.make(folder, row["키"], no, doc=seen["문서"]) # [저장] + saved = path.read(made["이름"]) + assert saved["셈"] == got["셈"] # 금액 = 저장한 SK 와 같음 + with pytest.raises(store.StoreError): + path.read_memory("문서 아님") + + +def test_screen_memory_doc_stack_view() -> None: + stack = (ROOT / "A00_Common" / "line_stack_screen.ts").read_text(encoding="utf-8") + assert "memory?: () => Record | null;" in stack + assert 'cp.route(saved ? "-" : key, path)' in stack and "본: saved" in stack + assert 'call("/read", {})' in stack and "left.inert = !!mem();" in stack + tabs = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_Tabs.ts").read_text( + encoding="utf-8" + ) + assert ( + "const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs + and "memory: opt.memory," in tabs + ) + detail = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_Detail.ts").read_text( + encoding="utf-8" + ) + assert "doc.가져온 &&" in detail and "memory: () =>" in detail + router = (ROOT / "M02_MasterTemplete" / "M02_Doc_Router.py").read_text(encoding="utf-8") + assert '"/layers/{layer}/sangeun/-/doc/read"' in router and "write=body.본 is None" in router