- 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음) - 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만 - 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음) - 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임 - 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
8.0 KiB
Python
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,
|
|
}
|