merge(707): sub_laptop_8(sub5 707-1 내역 줄 불러오기) 합침 — 불러오기 길 · 화면 한 파일 · file_key 하나 (PLAN 707-1 · 707-2 · 707-3)
- Router_Import = import/bill · bill/apply(sub5) + import/rates · import/prices · 올린 파일 = 프로젝트 import/{표지} 한 자리
- UI_Import = [엑셀 내역 불러오기] + [엑셀에서 요율 · 단가표 불러오기] · 마지막 표지 함께 씀
- test_145_3 · test_166_2 TS 거울 = 글 stdin(Calc_Base · Calc_Bill 길어져 node -e 32 K 넘김)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,99 +1,230 @@
|
||||
"""B09 경쟁사 결과물 불러오기 길 — 요율(707-2) · 기초 단가표(707-3) · 저장 안 함(제안 → 화면 캐시).
|
||||
"""B09 경쟁사 결과물 불러오기 길 — 내역 줄(707-1) · 요율(707-2) · 기초 단가표(707-3) · 저장 안 함
|
||||
(화면 캐시로 → [저장] 때 서버 정본).
|
||||
|
||||
POST /api/projects/{id}/estimation/import/rates form: file | file_key · user?(화면 캐시 JSON)
|
||||
원가계산서 요율 → {file_key, name, kind, rows, rates, notes, check, patch{estimate}}
|
||||
POST /api/projects/{id}/estimation/import/prices form: file | file_key · user?
|
||||
노임 · 자재 · 기계 단가표 → 이 공사 단가 책에 이음 →
|
||||
{file_key, name, counts, linked, missing, patch{labor_cover, machine_cover, materials}}
|
||||
|
||||
올린 파일 = 서버 임시 폴더에 내용 지문(`file_key`)으로 한 시간 둠 — 같은 파일을 요율 · 단가표 ·
|
||||
내역 줄(707-1 · sub5)이 다시 올리지 않고 `file_key` 로 씀(`load` · `remember`).
|
||||
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈(5장).
|
||||
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 줄 연결.
|
||||
POST /api/projects/{id}/estimation/import/rates (multipart: file | file_key · user?)
|
||||
원가계산서 요율(707-2) → {file_key, name, kind, rows, rates, notes, check, patch{estimate}}
|
||||
POST /api/projects/{id}/estimation/import/prices (multipart: file | file_key · user?)
|
||||
노임 · 자재 · 기계 단가표(707-3) → 이 공사 단가 책에 이음 →
|
||||
{file_key, name, sheets, counts, linked, missing,
|
||||
patch{labor_cover, machine_cover, materials}}
|
||||
file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 ·
|
||||
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈.
|
||||
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
|
||||
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_Import_Prices as import_prices
|
||||
from B09_Estimation import B09_Estimation_Import_Rates as import_rates
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
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_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_table
|
||||
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_Hopyo_Excel import ExcelError
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer as who_of
|
||||
|
||||
router = APIRouter()
|
||||
PATH = "/{project_id}/estimation/import"
|
||||
KEEP_S = 3600
|
||||
_KEY = re.compile(r"^[0-9a-f]{24}$")
|
||||
ROOT = "/{project_id}/estimation/import"
|
||||
PATH = ROOT + "/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 _dir() -> Path:
|
||||
return Path(tempfile.gettempdir()) / "aislo_b09_import"
|
||||
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 load(file_key: str) -> tuple[str, bytes]:
|
||||
"""지문 → (파일 이름, 내용) — 없거나 지난 것 = 404."""
|
||||
if not _KEY.match(file_key or ""):
|
||||
raise HTTPException(400, "file_key 꼴이 아님")
|
||||
data, name = _dir() / f"{file_key}.bin", _dir() / f"{file_key}.name"
|
||||
if not data.is_file() or time.time() - data.stat().st_mtime > KEEP_S:
|
||||
raise HTTPException(404, "올린 파일이 지났음 — 다시 올림")
|
||||
return name.read_text(encoding="utf-8"), data.read_bytes()
|
||||
|
||||
|
||||
async def remember(upload: UploadFile) -> tuple[str, str, bytes]:
|
||||
"""올린 파일 → (지문, 이름, 내용) · 한 시간 지난 파일 지움."""
|
||||
folder = _dir()
|
||||
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.glob("*.bin"):
|
||||
if time.time() - old.stat().st_mtime > KEEP_S:
|
||||
old.unlink(missing_ok=True)
|
||||
old.with_suffix(".name").unlink(missing_ok=True)
|
||||
temp = folder / f"up-{time.time_ns()}.part"
|
||||
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:
|
||||
await save_upload_stream(upload, temp)
|
||||
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(400, str(error)) from error
|
||||
data = temp.read_bytes()
|
||||
key = hashlib.sha256(data).hexdigest()[:24]
|
||||
temp.replace(folder / f"{key}.bin")
|
||||
name = store.safe_name(upload.filename or "") or "불러온 파일.xlsx"
|
||||
(folder / f"{key}.name").write_text(name, encoding="utf-8")
|
||||
return key, name, data
|
||||
raise HTTPException(status_code=400, detail="user 가 JSON 이 아님") from error
|
||||
return store.clean_user(user) if isinstance(user, dict) else None
|
||||
|
||||
|
||||
async def _file(file: UploadFile | None, file_key: str | None) -> tuple[str, str, bytes]:
|
||||
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:
|
||||
return await remember(file)
|
||||
if file_key:
|
||||
return (file_key, *load(file_key))
|
||||
raise HTTPException(400, "file 또는 file_key 가 필요함")
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
def _draft(user: str | None) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw = json.loads(user) if user else None
|
||||
except ValueError as error:
|
||||
raise HTTPException(400, "user 가 JSON 이 아님") from error
|
||||
return store.clean_user(raw) if isinstance(raw, dict) else None
|
||||
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 + "/rates")
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
async def _file(
|
||||
file: UploadFile | None, file_key: str | None, root: Path
|
||||
) -> tuple[str, str, bytes]:
|
||||
"""올린 파일 · 표지 → (표지, 이름, 내용) — 올린 파일은 내역 줄 길과 같은 자리에 둠."""
|
||||
if file is not None:
|
||||
data = await file.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)")
|
||||
key = await asyncio.to_thread(_keep, root, file.filename or "", data)
|
||||
return key, store.safe_name(file.filename or "") or "내역.xlsx", data
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="file 또는 file_key 가 필요함")
|
||||
path = _saved(root, file_key)
|
||||
return file_key, path.name, path.read_bytes()
|
||||
|
||||
|
||||
@router.post(ROOT + "/rates")
|
||||
async def import_rate_sheet(
|
||||
project_id: UUID,
|
||||
file: UploadFile | None = File(None),
|
||||
@@ -101,7 +232,7 @@ async def import_rate_sheet(
|
||||
user: str | None = Form(None),
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
key, name, data = await _file(file, file_key)
|
||||
key, name, data = await _file(file, file_key, root)
|
||||
draft = _draft(user)
|
||||
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
|
||||
"estimate"
|
||||
@@ -117,7 +248,7 @@ async def import_rate_sheet(
|
||||
"patch": {"estimate": estimate}} # fmt: skip
|
||||
|
||||
|
||||
@router.post(PATH + "/prices")
|
||||
@router.post(ROOT + "/prices")
|
||||
async def import_price_sheets(
|
||||
project_id: UUID,
|
||||
file: UploadFile | None = File(None),
|
||||
@@ -127,7 +258,7 @@ async def import_price_sheets(
|
||||
from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만
|
||||
|
||||
root = await _project_root(project_id)
|
||||
key, name, data = await _file(file, file_key)
|
||||
key, name, data = await _file(file, file_key, root)
|
||||
draft = _draft(user)
|
||||
mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user