Files
Aislo/B09_Estimation/B09_Estimation_Router_Import.py
T
eomsangdonandClaude Opus 5.5 c33f6837f2 fix(B09 설계내역서): 엑셀 내역 불러오기 화면 검증 흠 — 단가만 빈칸 · 운반 줄 오붙음 · 차례 · 표 높이 (PLAN 707-1)
- 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음)
- 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만
- 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음)
- 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임
- 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:13:34 +09:00

193 lines
8.0 KiB
Python

"""B09 경쟁사 내역 줄 불러오기 길(PLAN 707-1) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본).
POST /api/projects/{id}/estimation/import/bill (multipart: file | file_key · user?)
읽기 + 미리 보기 — 파일 = 프로젝트 `B09_Estimation/v1/import/{표지}/`
(file_key = 표지 · 같은 파일 다시 씀) → {file_key, 파일, 꼴, 시트, 묶음, 항목, 합계,
조달수수료율, 합계줄, 율줄, 같은이름, 남는자동, 이음} ·
user = 화면 캐시(JSON 글 · 없으면 저장 값) · DRM = 400 「읽을 수 없음」
POST /api/projects/{id}/estimation/import/bill/apply {file_key, mode, attach, drop, user?}
짜기(`B09_Estimation_Bill_Import.apply`) →
{user(새 캐시), 알림, 어긋남, 엑셀합계, payload(그 캐시로 셈)} ·
이음 = 읽을 수 있는 층(개인 → 회사 → 시스템)에서 같은 파일 이름으로 들여온 실무자료 사전조합
(본뜬 「HP-… 제N호표」) → 서고 사본(층 쓰기 0 · 프로젝트 사본만) · 대가 N 줄 연결.
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
"""
from __future__ import annotations
import asyncio
import json
import re
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Bill_Import as importer
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete.M02_Item_Snapshot import importer as who_of
router = APIRouter()
PATH = "/{project_id}/estimation/import/bill"
IMPORT = "import"
MAX_BYTES = 50 * 1024 * 1024
_TAG = re.compile(r"^[0-9a-f]{8}$")
_MARK = re.compile(r"^HP-\d{6} 제(\d+)호표$")
def _saved(root: Path, key: str) -> Path:
"""file_key → 둔 파일 — 없으면 404."""
folder = store.folder(root) / IMPORT / key
files = (
[f for f in folder.iterdir() if f.is_file()] if _TAG.match(key) and folder.is_dir() else []
)
if not files:
raise HTTPException(status_code=404, detail="불러온 파일이 없음 — 다시 고름")
return files[0]
def _keep(root: Path, name: str, data: bytes) -> str:
key = importer.tag_of(data)
folder = store.folder(root) / IMPORT / key
folder.mkdir(parents=True, exist_ok=True)
for old in folder.iterdir():
old.unlink()
(folder / (store.safe_name(name) or "내역.xlsx")).write_bytes(data)
return key
def _parse(name: str, data: bytes) -> dict[str, Any]:
try:
return read_bill(name, data)
except BillExcelError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
def _draft(raw: Any) -> dict[str, Any] | None:
if raw in (None, ""):
return None
try:
user = json.loads(raw) if isinstance(raw, str) else raw
except ValueError as error:
raise HTTPException(status_code=400, detail="user 가 JSON 이 아님") from error
return store.clean_user(user) if isinstance(user, dict) else None
def _found(session: dict[str, Any], name: str, numbers: set[int]) -> dict[int, tuple]:
"""대가 번 → (층, 주인, 사전조합 키) — 같은 파일 이름 실무자료 본뜬 · 앞 층이 이김."""
out: dict[int, tuple] = {}
for layer in ("personal", "company", "system"):
try:
got = access.grant(session, layer)
except HTTPException: # 회사 없음 등 — 그 층 건너뜀
continue
for key, origin, _raw in hopyo_build._origins(refs.folder_of(got.layer, got.owner)):
hit = _MARK.match(str(origin.get("키") or ""))
no = int(hit.group(1)) if hit else None
if origin.get("파일") == name and no in numbers and no not in out:
out[no] = (got.layer, got.owner, key)
return out
def _links(root: Path, found: dict[int, tuple], who: Any) -> dict[int, dict[str, Any]]:
folders = {}
for no, (layer, owner, key) in found.items():
try:
folders[no] = library.import_copy(root, "precombo", layer, owner, key, who)["폴더"]
except (LookupError, ValueError): # 원본 없음 · 모양 틀림 = 고정 단가로
continue
libs = (daega_inputs(root).get("libs") or {}) if folders else {}
return {
no: {"tab": "dg", "key": libs[name], "lib": name}
for no, name in folders.items()
if libs.get(name)
}
def _numbers(parsed: dict[str, Any]) -> set[int]:
return {
int(h["번"])
for line in parsed["줄"]
if (h := line.get("호표") or {}).get("종류") == "대가" and "번" in h
}
@router.post(PATH)
async def import_bill(
project_id: UUID,
file: UploadFile | None = File(None),
file_key: str | None = Form(None),
user: str | None = Form(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
root = await _project_root(project_id)
if file is not None:
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)")
parsed = await asyncio.to_thread(_parse, file.filename or "", data) # 읽힌 파일만 둠
file_key = await asyncio.to_thread(_keep, root, file.filename or "", data)
if not file_key:
raise HTTPException(status_code=400, detail="파일을 고름")
path = _saved(root, file_key)
if file is None:
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
return {
"file_key": file_key,
"파일": path.name,
**importer.preview(parsed, rows),
"이음": len(found),
}
class ApplyBody(BaseModel):
file_key: str = Field(min_length=8, max_length=8)
mode: str = "take"
attach: bool = True
drop: bool = False
user: dict[str, Any] | None = None
@router.post(PATH + "/apply")
async def apply_bill(
project_id: UUID, body: ApplyBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
if body.mode not in importer.MODES:
raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나")
root = await _project_root(project_id)
path = _saved(root, body.file_key)
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
rows = (await payload(project_id, root, draft, True))["sheets"]["bill"]["rows"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
links = await asyncio.to_thread(_links, root, found, who_of(session))
made, report = importer.apply(
parsed, draft, rows, body.file_key, body.mode, body.attach, body.drop, links
)
made = store.clean_user(made)
out = await payload(project_id, root, made, True)
lines = out["sheets"]["bill"]["rows"]
return {
"user": made,
"알림": {k: v for k, v in report.items() if k != "줄"},
"어긋남": importer.differences(parsed, report, lines),
"엑셀합계": parsed["합계"],
"payload": out,
}