From f49b55a3d8220cf8b15ae3d0305f2f2b3763af2a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:08:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(718-1):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=3D=20?= =?UTF-8?q?=ED=95=9C=EC=8B=9C=20=EB=A7=88=EB=B2=95=EC=82=AC=20=E2=80=94=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=B8=B5=20=EA=B3=A0=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?[=EB=81=9D=EB=82=B4=EA=B8=B0]=20=EB=95=8C=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=97=91=EC=85=80=20=EA=B2=B9=EC=B9=A8=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 「일위대가 실무자료」 → 「일위대가 불러오기」(locale M02_HopyoImport) · 옛 파일 목록 걷고 [불러오기] → 본 칸 마법사(새 M02_MasterTemplete_Hopyo_Wizard.ts) - 마법사 = ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과 체크(일위대가 · 단가산출은 715-2 뒤) · 엑셀 고르기 · 작업 중 이어서 → ② 엑셀 보기 창(모두 고정으로 들여오기 · 구조물 조합으로 그대로) → ③ 끝내기 - 서버: 올림 지문(sha256) · 작업 중 같은 엑셀 = 이어서 · 끝낸 같은 엑셀 409 · 지우기 = 끝내기(HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 있으면 지문 기록 · 별칭 장부 남음) · HP 번 다시 안 씀 · 목록 = 지문 있는 것만(옛 HP · 붙여넣기 HP 파일 그대로 숨김) - 시험: 새 지킴 test_718_1_hopyo_import(임시 storage) · 낡은 시험 고침 — 같은 엑셀 다시 올림이 새 키를 내던 가정(excel · grid · flow) · 붙여넣기 HP 목록 가정(705_2) · 좌측 판 글 대조(screen) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Store.py | 54 ++++- M02_MasterTemplete/M02_Labor_Router.py | 40 +++- .../M02_MasterTemplete_Hopyo_Side.ts | 133 +++-------- .../M02_MasterTemplete_Hopyo_Wizard.ts | 206 ++++++++++++++++++ resources/tester/test_705_2_hopyo_paste.py | 6 +- resources/tester/test_718_1_hopyo_import.py | 67 ++++++ resources/tester/test_m02_hopyo_excel.py | 4 +- resources/tester/test_m02_hopyo_flow.py | 8 +- resources/tester/test_m02_hopyo_grid.py | 6 +- resources/tester/test_m02_hopyo_screen.py | 44 ++-- ui_template/ui_template_locale_m2.ts | 1 + 11 files changed, 421 insertions(+), 148 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts create mode 100644 resources/tester/test_718_1_hopyo_import.py diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index d55b7ae7a..8fd3e0add 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -4,10 +4,14 @@ 파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######` (층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`. 층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫. +한시 불러오기(718-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있는 것(작업 중)만 · 옛 HP 는 +파일 그대로 목록에서만 숨김 · 끝낸 기록 `실무자료/불러옴.json` +`{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}` · 번 = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀). """ from __future__ import annotations +import hashlib import json import re import threading @@ -21,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store DIRNAME = "실무자료" MEMORY = "맞춤" GRID = "격자" +DONE = "불러옴" NO_GRID = "격자 없음 — 다시 올리면 보임" _KEY = re.compile(r"^HP-(\d{6})$") # ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 @@ -58,16 +63,33 @@ def brief(doc: dict[str, Any]) -> dict[str, Any]: } +def fingerprint(data: bytes) -> str: + """올린 엑셀 지문 — 같은 파일 다시 올림을 알아봄.""" + return hashlib.sha256(data).hexdigest() + + def rows(folder: str | Path) -> list[dict[str, Any]]: - """층 실무자료 목록(키 차례).""" + """층 실무자료 목록(키 차례) — 작업 중(지문 있음)만 · 옛 HP 는 숨김.""" out = [] for path in sorted(_dir(folder).glob("HP-*.json")): doc = _load(path) - if isinstance(doc, dict) and _KEY.match(path.stem): - out.append(brief(doc)) + if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문"): + out.append({**brief(doc), "지문": doc["지문"]}) return out +def done(folder: str | Path) -> dict[str, Any]: + """끝낸 기록 `{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}`.""" + doc = _load(_dir(folder) / f"{DONE}.json") + doc = doc if isinstance(doc, dict) else {} + marks = doc.get("지문") + return {"번": int(doc.get("번") or 0), "지문": marks if isinstance(marks, dict) else {}} + + +def _put_done(folder: str | Path, got: dict[str, Any]) -> None: + atomic_write_json(_dir(folder) / f"{DONE}.json", got) + + def read(folder: str | Path, key: str) -> dict[str, Any]: doc = _load(_path(folder, key)) if not isinstance(doc, dict): @@ -81,25 +103,30 @@ def save( parsed: dict[str, Any], who: Any, grid: dict[str, Any] | None = None, + mark: str | None = None, ) -> dict[str, Any]: - """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄.""" + """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문.""" with _LOCK: have = [ int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem)) ] - key = f"HP-{max(have, default=0) + 1:06d}" + log = done(folder) + log["번"] = max(*have, log["번"], 0) + 1 + key = f"HP-{log['번']:06d}" doc = { "키": key, "파일": name, "올린때": utc_now_text(), "올린이": who, + **({"지문": mark} if mark else {}), **parsed, } if grid is not None: book = {**grid["책"], "열": key} atomic_write_json(_grid_path(folder, key), {**grid, "책": book}) atomic_write_json(_path(folder, key), doc) - return brief(doc) + _put_done(folder, log) + return {**brief(doc), **({"지문": mark} if mark else {})} def grid(folder: str | Path, key: str) -> dict[str, Any]: @@ -110,11 +137,22 @@ def grid(folder: str | Path, key: str) -> dict[str, Any]: return doc -def delete(folder: str | Path, key: str) -> None: +def delete(folder: str | Path, key: str, made: list[str] | None = None) -> None: + """HP · 격자 지움 · 만든 것(`made` SC 키)이 있으면 지문을 끝낸 기록에 남김(다시 올림 막음).""" path = _path(folder, key) with _LOCK: - if not path.is_file(): + doc = _load(path) + if not isinstance(doc, dict): raise store.StoreError(404, f"없는 실무자료 「{key}」") + if made and doc.get("지문"): + log = done(folder) + log["지문"][doc["지문"]] = { + "파일": doc.get("파일"), + "키": key, + "끝낸때": utc_now_text(), + "만든": made, + } + _put_done(folder, log) path.unlink() _grid_path(folder, key).unlink(missing_ok=True) diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 25ed692aa..590dfdeee 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -6,7 +6,9 @@ 사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + 일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · 338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · -올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`). +올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) · +718-1 한시 불러오기 = 올림 지문(끝낸 같은 엑셀 409 · 작업 중 같은 엑셀 = 그 파일 `이어서`) · +지우기 = [끝내기](HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가가 있으면 지문 기록 · 별칭 장부 남음). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ @@ -198,6 +200,16 @@ def _who(session: Session) -> dict[str, Any]: def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: + mark = hopyo_store.fingerprint(data) + if old := hopyo_store.done(store.root())["지문"].get(mark): + made = len(old.get("만든") or []) + when = str(old.get("끝낸때") or "")[:10] + raise store.StoreError( + 409, + f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음", + ) + if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): + return {**same, "이어서": True} try: parsed = hopyo_excel.read_book(name, data) except hopyo_excel.ExcelError as error: @@ -206,7 +218,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = hopyo_grid.read(name, data, parsed) except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None - row = hopyo_store.save(store.root(), name, parsed, who, grid) + row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) return row @@ -441,10 +453,28 @@ def hopyo_ledger_write( def hopyo_delete( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: - """실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로).""" + """실무자료 지우기 = 불러오기 [끝내기](718-1) — HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 + (이 층 확인된 본뜬 「키 제N호표」)는 그대로 · 있으면 지문 기록(같은 엑셀 다시 올림 막음) · + 별칭 장부 그대로 → `{지움, 만든}`.""" folder = _root(session, layer, owner, write=True) - _call(folder, hopyo_store.delete, folder, key) - return {"지움": key} + return _call(folder, _finish, folder, key) + + +def _finish(folder: Path, key: str) -> dict[str, Any]: + head = f"{key} 제" + made = sorted( + {sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)} + ) + if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None: + try: + got = cache_path(folder, key) + except (OSError, store.StoreError): + got = None + # ponytail: 뒤 셈이 도는 중이면 끝낸 뒤 캐시를 다시 쓸 수 있음 — tmp 캐시라 둠 + for old in got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json") if got else []: + old.unlink(missing_ok=True) + hopyo_store.delete(folder, key, made) + return {"지움": key, "만든": len(made)} class PasteBody(_Strict): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index f0d733669..ff2948e83 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -1,21 +1,19 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Side.ts - * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · - * [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + - * 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) · - * 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 · - * 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`). + * 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) → + * 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] 때 올린 파일 지움) · + * 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) · + * 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층). * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { pageScope } from "./M02_MasterTemplete_Hopyo_Api"; import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger"; -import { createEmpty } from "@ui/ui_template_empty"; - -export const HOPYO_TITLE = "일위대가 실무자료"; +import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard"; export interface HopyoSectionOptions { - /** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */ + /** 이 층 쓰기 권한 — 별칭 장부 고침 */ canWrite: boolean; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; @@ -30,122 +28,48 @@ export interface HopyoSection { body: HTMLElement; /** 다른 화면이 열렸으면 고름 표시를 지움 */ paint: () => void; - refresh: () => Promise; } -const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const WIZARD = "불러오기"; export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { + const title = L("M02_HopyoImport"); const scope = pageScope(); - const list = el("div", { className: "m02-side__items ui-sidebar-list" }); - const input = el("input", { - attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, - }) as HTMLInputElement; - input.hidden = true; - // 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스 - const upload = createButton({ - label: "파일 업로드", + const start = createButton({ + label: "불러오기", variant: "filled", - disabled: !opt.canWrite, - onClick: () => input.click(), + onClick: () => void showView(WIZARD), }); - upload.classList.add("m02-layers__btn"); - upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음"; + start.classList.add("m02-layers__btn"); + start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내면 올린 파일 지움)"; + start.dataset.hopyoWizardOpen = ""; const ledger = el("button", { className: "m02-side__item ui-sidebar-row", text: LEDGER_TITLE, attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" }, }); - ledger.addEventListener("click", () => void showLedger()); + ledger.addEventListener("click", () => void showView(LEDGER_TITLE)); const body = el("div", { className: "m02-hopyo-side", - children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger], + children: [el("div", { className: "m02-side__row", children: [start] }), ledger], }); - let files: HopyoFile[] = []; let open: { key: string; view: HTMLElement } | null = null; - let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일 - let seq = 0; - input.addEventListener("change", () => { - const file = input.files?.[0]; - input.value = ""; - if (!file) return; - upload.disabled = true; - uploadHopyo(scope, file) - .then(async (got) => { - const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : ""; - showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); - await refresh(); - await showSheet(got); - }) - .catch((error) => showToast(why(error), "error")) - .finally(() => (upload.disabled = !opt.canWrite)); - }); - - function paintList(): void { + const paintList = (): void => { ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE); - list.replaceChildren( - ...(files.length - ? files.map((f) => { - const row = el("button", { - className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`, - text: `${f.파일} · 호표 ${f.호표수}`, - attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 }, - }); - row.addEventListener("click", () => void showSheet(f)); - return row; - }) - : [createEmpty({ text: "올린 실무자료 없음", small: true })]), - ); - } + }; - /** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */ - async function showSheet(file: HopyoFile): Promise { - const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => { - showToast(why(error), "error"); - return null; - }); - if (!sheet) return; - sheetKey = file.키; - paintList(); - sheet.openHopyoSheet({ - scope, - key: file.키, - 파일: file.파일, - // 사전조합은 어느 화면 층에서든 로그인한 사람 개인 층에 만듦 — 회사 · 마스터는 구조물 카드 [가져오기] - target: { layer: "personal", owner: null }, - canWrite: true, - onClose: () => { - if (sheetKey === file.키) sheetKey = null; - paintList(); - }, - // [삭제]는 실무자료 파일이 있는 화면 층 쓰기 권한 때만 - onDeleted: opt.canWrite ? () => void refresh() : undefined, - }); - } - - async function showLedger(): Promise { - if (open?.key === LEDGER_TITLE && open.view.isConnected) return; + async function showView(key: string): Promise { + if (open?.key === key && open.view.isConnected) return; if (!(await opt.confirmLeave())) return; - const view = buildHopyoLedger({ scope, canWrite: opt.canWrite }); - opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view); + const view = + key === WIZARD ? buildHopyoWizard() : buildHopyoLedger({ scope, canWrite: opt.canWrite }); + opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view); opt.onOpen(); - open = { key: LEDGER_TITLE, view }; + open = { key, view }; paintList(); } - async function refresh(): Promise { - const mine = ++seq; - try { - const got = await listHopyo(scope); - if (mine === seq) files = got; - } catch (error) { - if (mine === seq) files = []; - showToast(why(error), "error"); - } - if (mine === seq) paintList(); - } - const paint = (): void => { if (open && !open.view.isConnected) { open = null; @@ -153,6 +77,5 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { } }; - void refresh(); - return { title: HOPYO_TITLE, body, paint, refresh }; + return { title, body, paint }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts new file mode 100644 index 000000000..f76885502 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -0,0 +1,206 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Wizard.ts + * 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋: + * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림 + * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서 + * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → + * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 + * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록 + * 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김. + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createPanelHead } from "@ui/ui_template_panel_head"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + deleteHopyo, + listHopyo, + uploadHopyo, + type HopyoFile, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; + +const LAYERS: { layer: ManageLayer; text: string }[] = [ + { layer: "personal", text: "개인" }, + { layer: "company", text: "회사" }, + { layer: "system", text: "마스터" }, +]; +const STEPS = [ + { value: "1", label: "① 층 · 엑셀" }, + { value: "2", label: "② 보기 · 확인 · 만들기" }, + { value: "3", label: "③ 끝내기" }, +]; +/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */ +export const SANGEUN_READY = false; +const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬"; + +type Got = HopyoFile & { 이어서?: boolean }; +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const textOf = (layer: string): string => LAYERS.find((x) => x.layer === layer)?.text ?? layer; + +/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */ +export async function writableLayers(): Promise { + const user = await fetchSessionUser().catch(() => null); + return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite); +} + +export function buildHopyoWizard(): HTMLElement { + const steps = createSegment({ + options: STEPS.map((s) => ({ ...s, disabled: true })), + value: "1", + }); + steps.root.dataset.hopyoSteps = ""; + const body = el("div", { className: "m02-hopyo" }); + const root = el("div", { + className: "m02-hopyo", + attrs: { "data-hopyo-wizard": "" }, + children: [ + createText( + "한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내면 올린 파일 지움 · 별칭 장부는 남음", + "hint", + ), + el("div", { className: "ui-toolbar", children: [steps.root] }), + body, + ], + }); + let layers: ManageLayer[] = []; + let pick: ManageLayer = "personal"; + const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); + + async function first(): Promise { + steps.set("1"); + layers = await writableLayers(); + if (!layers.includes(pick)) pick = layers[0] ?? "personal"; + const input = el("input", { + attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, + }) as HTMLInputElement; + input.hidden = true; + const upload = createButton({ + label: "엑셀 고르기", + variant: "filled", + disabled: !layers.length, + onClick: () => input.click(), + }); + upload.title = layers.length ? "설계내역서 엑셀(일위대가 시트)" : "쓸 수 있는 층 없음"; + input.addEventListener("change", () => { + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + upload.disabled = true; + uploadHopyo(scopeOf(pick), file) + .then((got: Got) => { + if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info"); + else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success"); + second(pick, got); + }) + .catch((error) => showToast(why(error), "error")) + .finally(() => (upload.disabled = !layers.length)); + }); + const seg = createSegment({ + options: LAYERS.map((x) => ({ + value: x.layer, + label: x.text, + disabled: !layers.includes(x.layer), + })), + value: pick, + onChange: (v) => (pick = v as ManageLayer), + }); + seg.root.dataset.hopyoLayer = ""; + const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true }); + const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY }); + sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY; + const busy = el("div", { className: "m02-hopyo" }); + body.replaceChildren( + createText("결과 층", "muted", 14), + el("div", { className: "ui-toolbar", children: [seg.root] }), + createText("결과", "muted", 14), + el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }), + ...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]), + el("div", { className: "ui-toolbar", children: [upload, input] }), + createPanelHead({ title: "작업 중", level: 4, divider: true }).root, + busy, + ); + const rows = ( + await Promise.all( + layers.map((layer) => + listHopyo(scopeOf(layer)) + .then((list) => list.map((f) => ({ layer, f }))) + .catch(() => []), + ), + ) + ).flat(); + busy.replaceChildren( + ...(rows.length + ? rows.map(({ layer, f }) => { + const row = createButton({ + label: `${f.파일} · 호표 ${f.호표수} · ${textOf(layer)} — 이어서`, + variant: "ghost", + onClick: () => second(layer, f), + }); + row.dataset.hopyoKey = f.키; + return row; + }) + : [createEmpty({ text: "작업 중인 불러오기 없음", small: true })]), + ); + } + + function second(layer: ManageLayer, file: HopyoFile): void { + steps.set("2"); + const scope = scopeOf(layer); + const view = createButton({ + label: "엑셀 보기 · 확인 · 만들기", + variant: "filled", + onClick: () => + void import("./M02_MasterTemplete_Hopyo_Sheet") + .then((m) => + m.openHopyoSheet({ + scope, + key: file.키, + 파일: file.파일, + target: scope, + canWrite: true, + onDeleted: () => void first(), + }), + ) + .catch((error: unknown) => showToast(why(error), "error")), + }); + const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() }); + finish.dataset.hopyoFinish = ""; + const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); + back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서"; + const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : ""; + body.replaceChildren( + createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14), + createText( + "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감", + "hint", + ), + el("div", { className: "ui-toolbar", children: [view, finish, back] }), + ); + + async function end(): Promise { + steps.set("3"); + const ok = await showConfirmDialog( + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`, + "끝내기", + ); + if (!ok) return steps.set("2"); + try { + const got = (await deleteHopyo(scope, file.키)) as { 만든?: number }; + showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); + } catch (error) { + showToast(why(error), "error"); + return steps.set("2"); + } + await first(); + } + } + + void first(); + return root; +} diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index f6b21445e..c89f9522a 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -94,8 +94,10 @@ def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag): assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"]) assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"] assert master_hash() == before - rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"] - assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기" + # 718-1 — 붙여넣기 실무자료 = 지문 없음 → 불러오기 목록엔 숨김 · 파일은 남음 + assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == [] + doc = client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").json() + assert doc["파일"].startswith("붙여넣기") and doc["시트"] == "붙여넣기" def _block(rows, no): diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py new file mode 100644 index 000000000..c16492b5e --- /dev/null +++ b/resources/tester/test_718_1_hopyo_import.py @@ -0,0 +1,67 @@ +"""일위대가 불러오기 한시(PLAN 718-1) — 올림 지문 · [끝내기] 지움 · 겹침 막음. + +같은 엑셀 작업 중 다시 올림 = 그 파일 이어서(새 키 없음) · [끝내기] = HP · 격자 · 맞춤셈 캐시 0 · +만든 일위대가 · 별칭 장부 그대로 · 끝낸 같은 엑셀 다시 올림 409 · 만든 것 없이 지움 = 다시 올림 됨 · +옛 HP(지문 없음) = 파일 그대로 목록에서만 숨김 · 키 번 = 지운 뒤에도 다시 안 씀. 층 = 임시 폴더. +""" + +import json + +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_hopyo_excel import NORTH, URL, _post, client # noqa: F401 + +ALL = "/api/m02/layers/personal/hopyo-build-all" + + +def _keys(client): + return [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] + + +def test_이어서_끝내기_지움_겹침_막음(client): + folder = layers.personal_dir(7, 42) + first = _post(client).json() + assert first["키"] == "HP-000001" and len(first["지문"]) == 64 + again = _post(client).json() + assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음 + assert _keys(client) == ["HP-000001"] + made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json() + assert len(made["만듦"]) == 68 + hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음 + cache = fit.cache_path(folder, "HP-000001") + cache.write_text("{}", encoding="utf-8") + old_cache = cache.with_name("_".join(cache.name.split("_")[:2]) + "_옛판_1.json") + old_cache.write_text("{}", encoding="utf-8") + sc_before = sorted((folder / "precombo").glob("SC-*.json")) + + res = client.delete(URL.format("personal") + "/HP-000001") + assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68} + assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0 + assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json")) + assert not cache.exists() and not old_cache.exists() + assert hs.memory(folder) == {"장부 줄": {"갈래": "안 씀"}} + assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before # 만든 일위대가 그대로 + + block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 + assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] + assert "일위대가 68" in block.json()["detail"] and _keys(client) == [] + assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before + + +def test_만든_것_없이_지움_다시_올림_키_번_안_되씀(client): + assert _post(client).json()["키"] == "HP-000001" + assert client.delete(URL.format("personal") + "/HP-000001").json()["만든"] == 0 + got = _post(client) # 만든 것 없음 = 막지 않음 · 번은 이어서 + assert got.status_code == 200 and got.json()["키"] == "HP-000002" + + +def test_옛_HP_목록에서만_숨김(client): + folder = layers.personal_dir(7, 42) / hs.DIRNAME + folder.mkdir(parents=True) + old = folder / "HP-000007.json" + old.write_text(json.dumps({"키": "HP-000007", "파일": "옛.xls", "호표": []}), encoding="utf-8") + assert _keys(client) == [] + assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == [] + assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로 + assert old.is_file() and _keys(client) == ["HP-000008"] diff --git a/resources/tester/test_m02_hopyo_excel.py b/resources/tester/test_m02_hopyo_excel.py index 3622b9d39..0fc51df6b 100644 --- a/resources/tester/test_m02_hopyo_excel.py +++ b/resources/tester/test_m02_hopyo_excel.py @@ -179,8 +179,10 @@ def test_올리기_목록_읽기_지우기(client): assert (folder / hs.DIRNAME / "HP-000001.json").is_file() assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임 assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo")) - assert _post(client).json()["키"] == "HP-000002" + again = _post(client).json() # 718-1 — 작업 중 같은 엑셀 = 그 파일 이어서 + assert again["키"] == "HP-000001" and again["이어서"] is True assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200 + assert _post(client).json()["키"] == "HP-000002" # 지운 번 다시 안 씀 assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"] assert client.get(URL.format("personal") + "/HP-000001").status_code == 404 assert client.get(URL.format("personal") + "/../x").status_code == 404 diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index c51d83430..3e79e1655 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -105,8 +105,8 @@ def test_끝까지_확인한_줄만_별칭(client): keys = {r["열쇠"]: r for r in rows} assert {p["로직"] for p in keys[rebar["맞춤"]["열쇠"]]["짝"]} == {"GC000206", "GC000207"} # 다시 올림 = 확인한 줄은 별칭 · 체크 · 만든 것이 보임 - _up(client, "HP-000002") - again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000002", no)["줄"]} + assert _post(client).json()["이어서"] is True # 718-1 — 같은 엑셀 = 그 파일 이어서(새 키 없음) + again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000001", no)["줄"]} assert again["레미콘타설(장비사용)"]["상태"] == "별칭" and again["레미콘타설(장비사용)"]["체크"] assert again["철근가공조립(현장)"]["상태"] == "별칭" assert again["철근가공조립(현장)"]["대상"][0]["출처"] == { @@ -138,7 +138,9 @@ def test_막는_길(client): "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []}, ) - assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3) + assert ( + empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 + ) # 빈 몸 = 모두 고정(705-3) money = client.post( "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]}, diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 4db5efaa0..39ee22da2 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -84,13 +84,15 @@ def test_올리기_격자_200_404_지우기(client): path.unlink() # 옛 파일(324) = 격자 없음 old = client.get(GRID.format("personal", "HP-000001")) assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임" - assert _post(client).status_code == 200 + assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200 + assert _post(client).status_code == 200 # 718-1 — 같은 엑셀은 지운 뒤 새 번 assert client.get(GRID.format("personal", "HP-000002")).status_code == 200 assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200 assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists() assert client.get(GRID.format("personal", "HP-000002")).status_code == 404 assert client.get(GRID.format("personal", "../x")).status_code == 404 - assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘 + gone = client.get(URL.format("personal") + "/HP-000001/3") # 길 안 부딪힘(718-1 지운 뒤 = 없음) + assert gone.status_code == 404 and "없는 실무자료" in gone.json()["detail"] assert master_hash() == before diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 3aca5dae7..bf8781d47 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -29,6 +29,7 @@ NEW = ( "Hopyo_CheckRows", "Hopyo_Choose", "Hopyo_Ledger", + "Hopyo_Wizard", ) ROW = { "층": "system", @@ -301,7 +302,7 @@ def test_옛_즉시_확정과_바로_만들기_없음(): "근거 맞음 모두 확정", '"확정"', '"사전조합 만들기"', - "불러오기", + '"불러오기" 옛 단추', # 718-1 — 「불러오기」 = 칸 이름 · 마법사 단추라 낱말은 뺌 ): assert label not in every, label users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) @@ -342,20 +343,18 @@ def test_좌측_판_자리와_권한(): assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") - # 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기 - up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0] - assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up - assert "size:" not in up and "엑셀 올리기" not in hopyo_side - assert "buildHopyoView" not in hopyo_side - assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side - assert ( - "onClose:" in hopyo_side - and "onDeleted: opt.canWrite ? () => void refresh() : undefined," in hopyo_side - ) - assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side - assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side + # 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음 + assert 'L("M02_HopyoImport")' in hopyo_side and "일위대가 실무자료" not in hopyo_side + start = hopyo_side.split("const start = createButton({")[1].split("start.title")[0] + assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start + assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side + assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부] - assert hopyo_side.index("list, ledger]") > 0 + wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts") + assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard + assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만 + assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인 + assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬 def test_로직_추가_한_곳과_파일_규칙(): @@ -379,9 +378,9 @@ def test_로직_추가_한_곳과_파일_규칙(): def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' - side = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert "await showSheet(got);" in side # 올린 직후 자동 - assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0] + side = _text("M02_MasterTemplete_Hopyo_Wizard.ts") + assert "second(pick, got);" in side # 올린 직후 ② 걸음 + assert lazy in side.split("function second(")[1].split("async function end(")[0] check = _text("M02_MasterTemplete_Hopyo_Check.ts") assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check assert "엑셀에서 보기" not in check @@ -517,6 +516,7 @@ def test_명사형_단추와_별칭_장부_풀이_359(): assert f"label: {label}" in ledger, label assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger") + mine += ("Hopyo_Wizard",) every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine) for gone in ( "고친 것 버림", @@ -532,8 +532,8 @@ def test_명사형_단추와_별칭_장부_풀이_359(): def test_372_2_저장은_개인_층에_삭제는_화면_층_권한(): - side = _text("M02_MasterTemplete_Hopyo_Side.ts") - open_ = side.split("sheet.openHopyoSheet({")[1].split("});")[0] - assert 'target: { layer: "personal", owner: null },' in open_ - assert "canWrite: true," in open_ # 만들기 = 개인 층 · 화면 층 무관 - assert "onDeleted: opt.canWrite ? () => void refresh() : undefined," in open_ + """718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음.""" + side = _text("M02_MasterTemplete_Hopyo_Wizard.ts") + open_ = side.split("m.openHopyoSheet({")[1].split("}),")[0] + assert "target: scope," in open_ and "canWrite: true," in open_ + assert "onDeleted: () => void first()," in open_ diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 3a5811a1d..42d417fa3 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -188,4 +188,5 @@ export const ui_locales_m2 = { "그 사이 실무문서가 바뀜 — 목록을 다시 받음", "Practice documents changed — reloaded", ], + M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], } as const;