- 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>
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""B09 경쟁사 결과물 불러오기 길 — 내역 줄(707-1) · 요율(707-2) · 기초 단가표(707-3) · 저장 안 함
|
|
(화면 캐시로 → [저장] 때 서버 정본).
|
|
|
|
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 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_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()
|
|
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 _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,
|
|
}
|
|
|
|
|
|
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),
|
|
file_key: str | None = Form(None),
|
|
user: str | None = Form(None),
|
|
) -> dict[str, Any]:
|
|
root = await _project_root(project_id)
|
|
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"
|
|
]
|
|
try:
|
|
read = await asyncio.to_thread(import_rates.read_rates, name, data)
|
|
except ExcelError as error:
|
|
raise HTTPException(400, str(error)) from error
|
|
check = await asyncio.to_thread(import_rates.check, read, mine, rate_table())
|
|
estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}}
|
|
keep = ("kind", "sheet", "rows", "rates", "notes")
|
|
return {"file_key": key, "name": name, **{k: read[k] for k in keep}, "check": check,
|
|
"patch": {"estimate": estimate}} # fmt: skip
|
|
|
|
|
|
@router.post(ROOT + "/prices")
|
|
async def import_price_sheets(
|
|
project_id: UUID,
|
|
file: UploadFile | None = File(None),
|
|
file_key: str | None = Form(None),
|
|
user: str | None = Form(None),
|
|
) -> dict[str, Any]:
|
|
from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만
|
|
|
|
root = await _project_root(project_id)
|
|
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:
|
|
read = await asyncio.to_thread(import_prices.read_prices, name, data)
|
|
except ExcelError as error:
|
|
raise HTTPException(400, str(error)) from error
|
|
keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부
|
|
b08 = await main._b08_for(project_id, [], True)
|
|
book = await asyncio.to_thread(store.read_book, root)
|
|
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
|
base = compute_base(inputs, {}) # 덮개 뺀 이 공사 단가 책
|
|
out = import_prices.propose(read, base, mine)
|
|
sheets = {k: read[f"{k}_sheet"] for k in import_prices.KINDS}
|
|
return {"file_key": key, "name": name, "sheets": sheets, **out}
|