- 영월 꼴(STmate 엑셀 내역 줄 = 호표 코드만) = 일위대가표 같은 코드 호표 번 채움 → 서고 사본 21 · 이음 34 - 이은 줄 차가 비목마다 1 원/단위 안 = 「끝수」(STmate 하위 호표 반올림) · 끝 알림 한 줄 「엑셀 합계와 차 n 원 = 하위 호표 반올림 끝수 m 줄」(브레인 정함 = 끝수 줄도 이음) - 요율 못 읽은 공사(.estx · .STC) = 총공사비 칸 안 보임 - 시험 test_707_7 11(영월 끝수 · 일위대가 시트 없음 까닭) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
389 lines
18 KiB
Python
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}
|