Files
Aislo/B09_Estimation/B09_Estimation_Router_Import.py
T
eomsangdonandClaude Opus 5.5 c93554d331 fix(B09 불러오기): 코드로만 가리킨 내역 줄도 일위대가에 이음 · 끝수 줄 꼬리표 · 요율 없음 총공사비 숨김 (707-7)
- 영월 꼴(STmate 엑셀 내역 줄 = 호표 코드만) = 일위대가표 같은 코드 호표 번 채움 → 서고 사본 21 · 이음 34
- 이은 줄 차가 비목마다 1 원/단위 안 = 「끝수」(STmate 하위 호표 반올림) · 끝 알림 한 줄 「엑셀 합계와 차 n 원 = 하위 호표 반올림 끝수 m 줄」(브레인 정함 = 끝수 줄도 이음)
- 요율 못 읽은 공사(.estx · .STC) = 총공사비 칸 안 보임
- 시험 test_707_7 11(영월 끝수 · 일위대가 시트 없음 까닭)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:21:15 +09:00

389 lines
18 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, hopyo, rates, prices, user?}
통째 불러오기(707-7) 차례 — 일위대가 · 단가산출(hopyo · 엑셀만 ·
`B09_Estimation_Bill_Hopyo` = 이 공사 서고 사본 · 층 쓰기 0) →
내역 줄 짜기(`B09_Estimation_Bill_Import.apply`) →
요율(rates · 707-2) → 단가표(prices · 707-3) → 그 캐시로 셈 →
{user(새 캐시), 알림, 어긋남, 엑셀합계, 단계 {일위대가, 요율, 단가표}(수 · 못 읽음 = 까닭),
합계 {엑셀직접, 우리직접, 엑셀총, 우리총}, payload} ·
hopyo 거짓 = 이음은 읽을 수 있는 층(개인 → 회사 → 시스템)에서 같은 파일 이름으로 들여온
실무자료 사전조합(본뜬 「HP-… 제N호표」) → 서고 사본 · 대가 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`) — 같은 파일을 다시 올리지 않음 ·
.estx(EST 원본 · 707-5 `B09_Estimation_Estx`) · .STC(STmate 원본 · 707-6 `B09_Estimation_Stc`) =
이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · 요율 = 400 「같은 공사 엑셀에서」 ·
미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) ·
`알림` = 안 읽은 몫(다공구 · 변경 차수)
화면은 `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_Hopyo as bill_hopyo
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_Estx as estx
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_Stc as stc
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
_RAW = {".estx": estx, ".stc": stc}
def _raw(name: str) -> Any:
"""원본 파일 읽기 모듈(.estx · .STC) — 엑셀이면 None."""
return _RAW.get(Path(name).suffix.lower())
def _parse(name: str, data: bytes) -> dict[str, Any]:
try:
return (raw.read_bill if (raw := _raw(name)) else read_bill)(name, data)
except BillExcelError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None:
"""원본 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `UNKNOWN`)."""
if not (raw := _raw(name)):
return None
got = raw.read_hopyo(name, data)
codes = {(k, x["코드"]) for k, v in got.items() for x in v}
hits = [h for line in parsed["줄"] if (h := line.get("호표"))]
return {**{k: len(v) for k, v in got.items()},
"가리킴": sum((h["종류"], h["코드"]) in codes for h in hits)} # fmt: skip
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
return _linked(root, folders)
def _linked(root: Path, folders: dict[int, str]) -> dict[int, dict[str, Any]]:
"""대가 번 → 서고 사본 폴더 → 줄 연결 {tab dg, key 블록 id, lib 폴더}."""
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 _own_links(
root: Path, name: str, data: bytes, parsed: dict[str, Any], tag: str, who: Any
) -> tuple[dict[int, dict[str, Any]], dict[str, Any], dict[str, Any]]:
"""707-7 — 파일 일위대가 · 단가산출 → 이 공사 서고 사본(층 쓰기 0) →
(줄 연결, 단계 수 · 까닭, 내역 — 코드로만 가리킨 줄에 호표 번 채움)."""
if _raw(name):
return {}, {"까닭": f"{Path(name).suffix} 구성 줄은 못 읽음 — 같은 공사 엑셀에서"}, parsed
try:
book = bill_hopyo.read(name, data)
except ExcelError as error:
return {}, {"까닭": str(error)}, parsed
built = bill_hopyo.build(book, name, tag, who)
parsed = bill_hopyo.numbered(parsed, book)
numbers, other = bill_hopyo.agree(parsed, book)
folders = {
no: got for no in sorted(numbers) if (got := bill_hopyo.copy(root, built, tag, no, who))
}
links = _linked(root, folders)
step = {**built["수"], "사본": len(links), "실패": built["실패"], "단가다름": other}
return links, step, parsed
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)
data = path.read_bytes()
if file is None:
parsed = await asyncio.to_thread(_parse, path.name, data)
hopyo = await asyncio.to_thread(_hopyo, path.name, data, parsed)
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),
"호표": hopyo,
"알림": parsed.get("알림") or [],
}
class ApplyBody(BaseModel):
file_key: str = Field(min_length=8, max_length=8)
mode: str = "take"
attach: bool = True
drop: bool = False
hopyo: bool = False
rates: bool = False
prices: 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)
data = path.read_bytes()
parsed = await asyncio.to_thread(_parse, path.name, data)
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
rows = (await payload(project_id, root, draft, True))["sheets"]["bill"]["rows"]
steps: dict[str, Any] = {}
if body.hopyo:
args = (root, path.name, data, parsed, body.file_key, who_of(session))
links, steps["일위대가"], parsed = await asyncio.to_thread(_own_links, *args)
else:
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)
excel_grand = None
if body.rates:
try:
got = await asyncio.to_thread(_rate_patch, path.name, data, made["estimate"])
made = store.clean_user({**made, **got["patch"]})
excel_grand = got["check"]["excel_grand"]
steps["요율"] = {"줄": len(got["rates"]), "다름": got["check"]["differ"]}
except HTTPException as error:
steps["요율"] = {"까닭": error.detail}
if body.prices:
try:
got = await _price_patch(project_id, root, path.name, data, made)
made = store.clean_user({**made, **got["patch"]})
steps["단가표"] = {k: got["counts"].get(k) for k in import_prices.KINDS}
except HTTPException as error:
steps["단가표"] = {"까닭": error.detail}
out = await payload(project_id, root, made, True)
lines = out["sheets"]["bill"]["rows"]
total = out["sheets"].get("total") or {}
return {
"user": made,
"알림": {k: v for k, v in report.items() if k != "줄"},
"어긋남": importer.differences(parsed, report, lines),
"엑셀합계": parsed["합계"],
"단계": steps,
"합계": {
"엑셀직접": parsed["합계"].get("직접"),
"우리직접": (total.get("sums") or {}).get("direct"),
"엑셀총": excel_grand,
"우리총": (total.get("subtotals") or {}).get("grand"),
},
"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)
if _raw(name):
raise HTTPException(400, _RAW_RATES.format(Path(name).suffix))
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
"estimate"
]
return {"file_key": key, "name": name, **await asyncio.to_thread(_rate_patch, name, data, mine)}
_RAW_RATES = "원가계산서 요율은 {} 에서 못 읽음 — 같은 공사 엑셀에서 불러옴"
def _rate_patch(name: str, data: bytes, mine: dict[str, Any]) -> dict[str, Any]:
"""요율(707-2) → {kind, sheet, rows, rates, notes, check, patch{estimate}} · 못 읽음 = 400."""
if _raw(name):
raise HTTPException(400, _RAW_RATES.format(Path(name).suffix))
try:
read = import_rates.read_rates(name, data)
except ExcelError as error:
raise HTTPException(400, str(error)) from error
check = 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 {**{k: read[k] for k in keep}, "check": check, "patch": {"estimate": estimate}}
@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]:
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)
return {"file_key": key, "name": name, **await _price_patch(project_id, root, name, data, mine)}
async def _price_patch(
project_id: UUID, root: Path, name: str, data: bytes, mine: dict[str, Any]
) -> dict[str, Any]:
"""단가표(707-3) → {sheets, counts, linked, missing, patch} · 못 읽음 = 400."""
from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만
try:
reader = raw.read_prices if (raw := _raw(name)) else import_prices.read_prices
read = await asyncio.to_thread(reader, name, data)
except (ExcelError, BillExcelError) 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)
return {"sheets": {k: read[f"{k}_sheet"] for k in import_prices.KINDS}, **out}