"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`. 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + 사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + 일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · 338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · 올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations import importlib import inspect from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Material_Own as own from M02_MasterTemplete import M02_Precombo_Folders as pre_folders from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( Layer, Session, TreeBody, _call, _root, ) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) class CalcBody(BaseModel): """`doc` = 저장 전 화면 문서(없으면 저장본) · `수량` = {알약 키: 값}(없으면 저장된 면적산출).""" doc: dict[str, Any] | None = None 수량: dict[str, Any] | None = None def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]: got = sent if sent is not None else store.read("labor", column)["문서"] return labor.clean({**got, "열": column}) def _calc(column: str, body: CalcBody) -> dict[str, Any]: doc = _doc(column, body.doc) return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)} def _pills(column: str) -> dict[str, Any]: return {"열": column, "알약": labor.pills(_doc(column, None))} @router.post("/layers/{layer}/labor/{column}/calc") def calc_labor( layer: Layer, column: str, body: CalcBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """줄마다 기준 · 반영 · 합 + 알약(저장 안 함 · 화면은 최신 응답만).""" return _call(_root(session, layer, owner), _calc, column, body) @router.get("/layers/{layer}/labor/{column}/pills") def labor_pills( layer: Layer, column: str, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본).""" return _call(_root(session, layer, owner), _pills, column) class LibraryBody(BaseModel): """새로 = `문서` · 복사 = `본뜬` {층, 주인, 키}(원본 읽기 권한 · 못 읽는 참조는 같이 복사).""" 문서: dict[str, Any] | None = None 본뜬: dict[str, Any] | None = None def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]: return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source)) @router.post("/layers/{layer}/library/{kind}") def new_library( layer: Layer, kind: Literal["precombo"], body: LibraryBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """서고 새로 · 복사 — 키는 서버가 그 층 다음 번호(SP · SC) · 답 `이름` = 새 키.""" folder = _root(session, layer, owner, write=True) if body.본뜬 is None: return _call(folder, refs.create, kind, body.문서 or {}) src = body.본뜬 if src.get("층") not in ("system", "company", "personal"): raise HTTPException(status_code=400, detail="본뜬 층이 틀림") got = access.grant(session, src["층"], src.get("주인")) # 원본 읽기 권한 ref = {"층": got.layer, "주인": got.owner, "키": str(src.get("키") or "")} if refs.kind_of(ref["키"]) != kind: raise HTTPException(status_code=400, detail="본뜬 종류가 틀림") doc = refs.read_ref(ref) if refs._KEY.match(ref["키"]) else None if doc is None: raise HTTPException(status_code=404, detail=f"없는 {refs.TITLE[kind]} 「{ref['키']}」") return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) @router.get("/layers/{layer}/precombo-tree") def precombo_tree( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리).""" return _call(_root(session, layer, owner), pre_folders.view) @router.post("/layers/{layer}/precombo-tree") def edit_precombo_tree( layer: Layer, body: TreeBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 · 판 다르면 409.""" folder = _root(session, layer, owner, write=True) return _call(folder, pre_folders.edit, body.model_dump()) @router.get("/layers/{layer}/materials") def own_materials( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """개인 자재 목록(148-2) — 개인 층만 · 회사 · 마스터 = 빈 목록.""" if layer != "personal": return {"줄": []} return {"줄": own.rows(_root(session, layer, owner))} @router.post("/layers/{layer}/materials") def new_material( layer: Layer, body: dict[str, Any], owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """신규 자재(148-2) — `{이름, 규격, 단위, 값}` → 새 줄(품번 `PM` + 6자리 서버 일련) · 개인 층만.""" if layer != "personal": raise HTTPException(status_code=400, detail="신규 자재는 개인 층에서만") folder = _root(session, layer, owner, write=True) return _call(folder, own.add, folder, body) # ── 일위대가 실무자료(324 · 338) ─────────────────────────────────────── HOPYO_MAX_BYTES = 20 * 1024 * 1024 def _later(module: str, name: str): """맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로).""" try: got = importlib.import_module(f"M02_MasterTemplete.{module}") except ImportError: return None return getattr(got, name, None) if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None: _warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠 def _who(session: Session) -> dict[str, Any]: return {"id": session.get("user_id"), "이름": session.get("name")} def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: try: parsed = hopyo_excel.read_book(name, data) except hopyo_excel.ExcelError as error: raise store.StoreError(400, str(error)) from error try: grid = hopyo_grid.read(name, data, parsed) except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None row = hopyo_store.save(store.root(), name, parsed, who, grid) if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None: ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림) return row @router.get("/layers/{layer}/hopyo") def hopyo_list( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`.""" folder = _root(session, layer, owner) return {"줄": hopyo_store.rows(folder)} @router.get("/layers/{layer}/hopyo-picks") def hopyo_picks( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합).""" folder = _root(session, layer, owner) return {"줄": _call(folder, _picks, folder)} def _picks(folder: Path) -> list[dict[str, Any]]: rows, here, done = hopyo_match.picks(folder), refs.where(folder), {} for row in rows: src = (row["층"], row.get("주인")) if src not in done: done[src] = hopyo_build.confirmed(folder, src) key = done[src].get(hopyo_build.mark(row["키"], row["번"])) row["확인"] = {"층": here[0], "주인": here[1], "키": key} if key else None return rows @router.post("/layers/{layer}/hopyo") def hopyo_upload( layer: Layer, file: UploadFile = File(...), owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · 엉터리 · 시트 없음 400.""" folder = _root(session, layer, owner, write=True) data = file.file.read(HOPYO_MAX_BYTES + 1) if len(data) > HOPYO_MAX_BYTES: raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)") name = Path(file.filename or "실무자료").name[:120] return _call(folder, _upload, name, data, _who(session)) @router.get("/layers/{layer}/hopyo/{key}") def hopyo_read( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합.""" folder = _root(session, layer, owner) return _call(folder, hopyo_match.view, folder, key) @router.get("/layers/{layer}/hopyo-grid/{key}") def hopyo_grid_read( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` · 옛 파일 404.""" folder = _root(session, layer, owner) return _call(folder, hopyo_store.grid, folder, key) @router.get("/layers/{layer}/hopyo/{key}/{no}") def hopyo_one( layer: Layer, key: str, no: int, owner: str | None = None, to: Layer | None = None, to_owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 · 호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만).""" folder = _root(session, layer, owner) if to is None or "to" not in inspect.signature(hopyo_match.view).parameters: return _call(folder, hopyo_match.view, folder, key, no) target = _root(session, to, to_owner) return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target)) class GuessBody(BaseModel): """`차례` = 호표 안 줄(0부터) · `대상` = {로직} | {자재}.""" 차례: int 대상: dict[str, Any] @router.post("/layers/{layer}/hopyo/{key}/{no}/guess") def hopyo_guess( layer: Layer, key: str, no: int, body: GuessBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """고른 대상 미리 채움(저장 안 함) — 규격 → 입력값 · 환산.""" folder = _root(session, layer, owner) return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상) class _Strict(BaseModel): model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈) class BuildTarget(_Strict): """대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫.""" 로직: str | None = None 자재: str | None = None 호표: int | None = None 입력값: dict[str, Any] = Field(default_factory=dict) 배: float = 1 몫: float | None = None class BuildLine(_Strict): """확인한 줄 — `차례` = 호표 안 줄(0부터) · `대상` 목록 또는 `뺌` 까닭.""" 차례: int 대상: list[BuildTarget] = Field(default_factory=list) 뺌: Literal["우리에 없음", "안 씀"] | None = None class BuildWhole(_Strict): 대상: list[BuildTarget] class BuildBody(_Strict): """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` · 직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀).""" 층: Layer 주인: str | None = None 키: str 번: int 새로: bool = False 줄: list[BuildLine] = Field(default_factory=list, max_length=500) 통째: BuildWhole | None = None @router.post("/layers/{layer}/hopyo-build") def hopyo_make( layer: Layer, body: BuildBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 → `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409.""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) lines = [one.model_dump(exclude_none=True) for one in body.줄] whole = body.통째.model_dump(exclude_none=True) if body.통째 else None args = (folder, source, body.키, body.번, lines, whole, _who(session)) return _call(folder, hopyo_build.build, *args) @router.get("/layers/{layer}/hopyo-ledger") def hopyo_ledger_read( layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}` (고침 = 이 층 · 쓰기 권한).""" folder = _root(session, layer, owner) writable = access.grant(session, layer, owner).write return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)} class LedgerBody(_Strict): """`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사.""" 줄: dict[str, Any] @router.put("/layers/{layer}/hopyo-ledger") def hopyo_ledger_write( layer: Layer, body: LedgerBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`.""" folder = _root(session, layer, owner, write=True) return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session)) @router.delete("/layers/{layer}/hopyo/{key}") def hopyo_delete( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로).""" folder = _root(session, layer, owner, write=True) _call(folder, hopyo_store.delete, folder, key) return {"지움": key}