- 길 — guess 몸 대상 list | dict · to = 만들 층 - 줄 맞춤 — 섞인 줄 1위 대상에 몫 · 배(고임돌 GF000211 × 0.1) · 장비 품 비교 열쇠 「+」(화면과 같음) · guess 가 사용자 배 그대로 - 화면 — 품 비교 장비 표시 열쇠를 갈래 묶음 이름으로 - 시험 — client 고정물 맞춤셈 캐시 = 임시 폴더 · 뒤 셈 안 돌림(tmp/m02_hopyo 안 남음 · 결과 흔들림 없음) · 끝까지(북부청 올림 → 셈 → 보기 → 화면 몸 → 만들기 → 장부 → 다시 올림 별칭 · 몸 없음 400 · hopyo-memory 404 · 영월 STmate) · 실물 대역 화면 시험 · 이음매 268 줄 맞음 122 · 다른 점 86 · 후보 60 · 후보 1순위 근거 맞음 123 → 9 - 응답 대역 JSON 실물로 다시 뽑음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
417 lines
16 KiB
Python
417 lines
16 KiB
Python
"""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
|
|
대상: list[dict[str, Any]] | 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,
|
|
to: Layer | None = None,
|
|
to_owner: str | None = None,
|
|
session: Session = Depends(verify_session),
|
|
) -> dict:
|
|
"""미리 셈(저장 안 함 · 설계 3-4) — 고른 대상 · 옵션으로 엔진 한 번 → `{대상, 품, 글}` ·
|
|
`to` = 만들 층(없으면 파일 층)."""
|
|
folder = _root(session, layer, owner)
|
|
target = _root(session, to, to_owner) if to is not None else None
|
|
args = (folder, key, no, body.차례, body.대상)
|
|
return _call(folder, lambda: hopyo_match.guess(*args, to=target))
|
|
|
|
|
|
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}
|