feat(B09 불러오기): 경쟁사 공사 통째 불러오기 — 창 한 번 · 일위대가 이 공사 서고 사본 (707-7)

- 불러오기 창 = 불러올 것 체크(일위대가 · 단가산출 / 내역 줄 / 원가계산서 요율 / 단가표 · 기본 다 켬 · .estx · .STC 일위대가 · 요율 흐림 + 까닭)
- [불러오기] 한 번 = 서버 차례 — 일위대가(705-3 고정 줄 · 하위 먼저 · 임시 폴더에서 짓고 서고 사본 precombo@project · 층 쓰기 0) → 내역 줄 → 요율 → 단가표 → 셈
- 내역 단가 ≠ 일위대가표 단가 줄 = 잇지 않고 내역 단가 그대로(경쟁사 금액 재현 · 목록)
- 끝 알림 한 장 — 호표 · 내역 줄 · 직접비 · 총공사비 엑셀 = 우리 · 어긋난 줄 · 다음 걸음 · 줄 어긋남 없으면 알림 한 줄
- 내 자료집에 저장(703-2) = 불러온 하위 호표도 지금 층에 같이 씀(한 번만)
- 시험 test_707_7 10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 19:59:08 +09:00
co-authored by Claude Opus 5.5
parent c3c79c59ca
commit 76dc4a6143
6 changed files with 656 additions and 76 deletions
+96 -19
View File
@@ -6,11 +6,16 @@
(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/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?)
@@ -39,6 +44,7 @@ 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
@@ -147,6 +153,11 @@ def _links(root: Path, found: dict[int, tuple], who: Any) -> dict[int, dict[str,
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}
@@ -155,6 +166,26 @@ def _links(root: Path, found: dict[int, tuple], who: Any) -> dict[int, dict[str,
}
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]]:
"""707-7 — 파일 일위대가 · 단가산출 → 이 공사 서고 사본(층 쓰기 0) →
(줄 연결, 단계 수 · 까닭)."""
if _raw(name):
return {}, {"까닭": f"{Path(name).suffix} 구성 줄은 못 읽음 — 같은 공사 엑셀에서"}
try:
book = bill_hopyo.read(name, data)
except ExcelError as error:
return {}, {"까닭": str(error)}
built = bill_hopyo.build(book, name, tag, who)
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)
return links, {**built["수"], "사본": len(links), "실패": built["실패"], "단가다름": other}
def _numbers(parsed: dict[str, Any]) -> set[int]:
return {
int(h["번"])
@@ -204,6 +235,9 @@ class ApplyBody(BaseModel):
mode: str = "take"
attach: bool = True
drop: bool = False
hopyo: bool = False
rates: bool = False
prices: bool = False
user: dict[str, Any] | None = None
@@ -217,22 +251,52 @@ async def apply_bill(
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())
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"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
links = await asyncio.to_thread(_links, root, found, who_of(session))
steps: dict[str, Any] = {}
if body.hopyo:
args = (root, path.name, data, parsed, body.file_key, who_of(session))
links, steps["일위대가"] = 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,
}
@@ -264,21 +328,28 @@ async def import_rate_sheet(
key, name, data = await _file(file, file_key, root)
draft = _draft(user)
if _raw(name):
raise HTTPException(
400, f"원가계산서 요율은 {Path(name).suffix} 에서 못 읽음 — 같은 공사 엑셀에서 불러옴"
)
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 = await asyncio.to_thread(import_rates.read_rates, name, data)
read = 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())
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 {"file_key": key, "name": name, **{k: read[k] for k in keep}, "check": check,
"patch": {"estimate": estimate}} # fmt: skip
return {**{k: read[k] for k in keep}, "check": check, "patch": {"estimate": estimate}}
@router.post(ROOT + "/prices")
@@ -288,12 +359,19 @@ async def import_price_sheets(
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)
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)
@@ -305,5 +383,4 @@ async def import_price_sheets(
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}
return {"sheets": {k: read[f"{k}_sheet"] for k in import_prices.KINDS}, **out}