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
+160
View File
@@ -0,0 +1,160 @@
"""B09 경쟁사 공사 통째 불러오기(PLAN 707-7) — 엑셀 일위대가 · 단가산출 → 이 공사 서고 사본
(층 쓰기 0).
705-3 [모두 고정으로 들여오기] 그대로(`M02_Hopyo_Build.Builder` · 하위 호표 먼저 · 고정 줄 ·
빠짐 0) —
층 대신 임시 폴더(`store.at` · 층 = 「project」)에서 만들고 호표마다 서고 사본으로 굳힘:
폴더 `precombo@project@-@{표지}-{번}` · 원본 참조 `{층 project, 주인 표지, 키 SC-{번}}` ·
참조사본 = 그 호표 + 닿는 하위 호표(주인 = 표지 → 다른 파일과 안 섞임) ·
단가 묶음 = 서고 사본과 한 길.
읽기 = `M02_Hopyo_Excel.read_book`(엑셀만 — .estx · .STC 구성 줄은 못 읽음) ·
원본이 없으니 [업데이트] 대상 아님.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as mstore
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, now_text
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
PRICE_FILE,
logic_rows,
price_bundle,
read_json,
)
LAYER = "project"
SUM_KEYS = ("품셈", "고정", "호표참조", "빠짐", "이음", "이음후보")
def read(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 일위대가 시트 → 읽은 결과 — 시트 없음 · DRM = `hopyo_excel.ExcelError`."""
return hopyo_excel.read_book(name, data)
_PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"}
def agree(bill: dict[str, Any], book: dict[str, Any]) -> tuple[set[int], list[dict[str, Any]]]:
"""내역 줄 대가 번 가운데 내역 단가 = 일위대가표 호표 금액(비목마다 1 원 안)인 번만 이음 ·
다른 줄 = 내역 단가 그대로(경쟁사 금액 재현) →
(이을 번, 다른 줄 [{행, 이름, 번, 내역, 일위대가}])."""
units = {int(h["번"]): h.get("금액") or {} for h in book.get("호표") or []}
good: set[int] = set()
bad: list[dict[str, Any]] = []
for line in bill["줄"]:
hopyo = line.get("호표") or {}
no = int(hopyo["번"]) if hopyo.get("종류") == "대가" and "번" in hopyo else None
if no is None or no not in units:
continue
price = line.get("단가") or {}
if all(abs((price.get(p) or 0) - (units[no].get(k) or 0)) < 1 for p, k in _PARTS.items()):
good.add(no)
continue
each = sum(price.get(p) or 0 for p in _PARTS)
sheet = sum(units[no].get(k) or 0 for k in _PARTS.values())
bad.append(
{"행": line["행"], "이름": line["이름"], "번": no, "내역": each, "일위대가": sheet}
)
return good - {x["번"] for x in bad}, bad
def _key(no: int) -> str:
return f"SC-{no:06d}"
def _owned(doc: dict[str, Any], tag: str) -> dict[str, Any]:
"""하위 호표 참조 주인 = 표지(임시 층 `project` 주인 없음 → 파일마다 가름)."""
lines = [
{**one, "참조": {**one["참조"], "주인": tag}}
if isinstance(one, dict) and (one.get("참조") or {}).get("층") == LAYER
else one
for one in doc.get("담은로직") or []
]
return {**doc, "담은로직": lines}
def build(parsed: dict[str, Any], name: str, tag: str, who: Any) -> dict[str, Any]:
"""호표 모두 → `{문서 {번: 사전조합}, 실패,
수 {호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보}}`."""
units = {int(h["번"]): h for h in parsed.get("호표") or []}
docs: dict[int, dict[str, Any]] = {}
sums = dict.fromkeys(SUM_KEYS, 0)
sums |= {"호표": len(units), "줄": sum(len(h.get("줄") or []) for h in units.values())}
failed = []
with tempfile.TemporaryDirectory() as scratch, mstore.at(scratch):
made_dir = Path(scratch) / "precombo"
made_dir.mkdir()
key = hopyo_store.save(scratch, name, parsed, who)["키"]
for no in hb._order(units):
try:
made = hb.Builder(Path(scratch), Path(scratch), key, no, who).build({}, [], False)
except mstore.StoreError as e:
failed.append({"번": no, "까닭": str(e.detail)})
continue
doc = {**made["문서"], "종류": refs.TITLE["precombo"], "키": _key(no)}
# 다음 호표의 `confirmed` 가 하위로 찾음(본뜬 확인 · 같은 원본 층)
(made_dir / f"{_key(no)}.json").write_text(
json.dumps(doc, ensure_ascii=False, default=float), "utf-8"
)
docs[no] = _owned(json.loads(json.dumps(doc, default=float)), tag)
for k in SUM_KEYS:
sums[k] += made["줄수"]["호표" if k == "호표참조" else k]
return {"문서": docs, "수": sums, "실패": failed}
def _ref(tag: str, no: int) -> dict[str, Any]:
return {"층": LAYER, "주인": tag, "키": _key(no)}
def _copies(docs: dict[int, dict[str, Any]], tag: str, no: int) -> dict[str, dict[str, Any]]:
"""그 호표 + 닿는 하위 호표 `{ref_id: 문서}`(굳힌 알약까지)."""
out: dict[str, dict[str, Any]] = {}
todo = [no]
while todo:
at = todo.pop()
rid = refs.ref_id(_ref(tag, at))
if rid in out or at not in docs:
continue
out[rid] = refs.with_pills(docs[at])
todo += [
int(one["참조"]["키"][3:])
for one in docs[at].get("담은로직") or []
if (one.get("참조") or {}).get("층") == LAYER
]
return out
def folder_of(tag: str, no: int) -> str:
return library.folder_name("precombo", LAYER, None, f"{tag}-{no}")
def copy(root: str | Path, built: dict[str, Any], tag: str, no: int, who: Any) -> str | None:
"""호표 하나 → 서고 사본 폴더 이름(있으면 그대로) · 못 만든 호표 = None."""
docs = built["문서"]
if no not in docs:
return None
name = folder_of(tag, no)
if isinstance(read_json(library.library_dir(root) / name / MANIFEST_NAME), dict):
return name
ref, doc = _ref(tag, no), docs[no]
copies = _copies(docs, tag, no)
line = {"참조": ref, "수량": {"손": 1}}
top = {"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]}
pill = refs.pill(ref, lambda r: copies.get(refs.ref_id(r)))
labor = {**logic_rows(top), "알약": [pill], "참조사본": copies}
files = {LABOR_FILE: labor, PRICE_FILE: price_bundle(labor, Path(mstore.FOLDER))}
origin = {"참조": ref, "name": doc.get("이름") or ref["키"], "단위": doc.get("단위") or ""}
manifest = {"종류": "precombo", "원본": origin, "가져온날": now_text(), "가져온이": who}
library._write(root, name, files, manifest, {})
return name
@@ -58,9 +58,10 @@ from M02_MasterTemplete.M02_Item_Source import (
LIBRARY = "library"
KINDS = ("combo", "precombo")
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음)
#: 폴더 이름 — 종류 · 층(project = 불러온 공사 일위대가 · 707-7) · 주인 ·
#: 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음)
NAME = re.compile(
r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
r"^(combo|precombo)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$"
)
@@ -9,6 +9,9 @@
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
불러온 공사 일위대가(707-7 · 참조 층 `project`) = 하위 호표 굳힌 문서를 문서 `_하위` 에 실어 보내고
쓸 때 지금 층에 같이 씀(새 키 · `본뜬.복사원본` = 그 참조 · 같은 하위는 한 번만 ·
이미 있으면 그 키).
"""
from __future__ import annotations
@@ -19,6 +22,7 @@ from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -31,6 +35,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
KIND = "precombo"
SUBS = "_하위"
PRICE_KEYS = ("labor", "material", "expense")
@@ -145,21 +150,79 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None =
"담은로직": lines,
"자재": mats,
}
if subs := _subs(root, lines):
doc[SUBS] = subs
return {"문서": doc, "담음": kept, "안담음": skip}
def _own(lines: Any) -> list[dict[str, Any]]:
"""불러온 공사 하위 호표 참조(층 `project` — 층에 없음 · 굳힌 문서뿐)."""
return [
one["참조"]
for one in lines or []
if isinstance(one, dict) and (one.get("참조") or {}).get("층") == "project"
]
def _subs(root: Path, lines: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""닿는 불러온 하위 호표 `{ref_id: 굳힌 문서}` — 못 찾으면 LookupError."""
out: dict[str, dict[str, Any]] = {}
todo = _own(lines)
while todo:
ref = todo.pop()
rid = refs.ref_id(ref)
if rid in out:
continue
doc = _frozen(root, rid)
if doc is None:
raise LookupError(f"하위 호표를 못 찾음 「{ref.get('키')}」")
out[rid] = doc
todo += _own(doc.get("담은로직"))
return out
def _carry(lines: list[Any], subs: dict[str, dict[str, Any]], done: dict[str, Any]) -> list[Any]:
"""불러온 하위 참조 → 지금 층 새 키(하위 먼저 · 같은 것 한 번 · 앞서 옮긴 것 = 그 키)."""
here = refs.here()
have = {
refs.ref_id(got["복사원본"]): key
for key, got, _raw in hopyo_build._origins(store.root())
if isinstance(got.get("복사원본"), dict)
}
out = []
for one in lines:
ref = one.get("참조") if isinstance(one, dict) else None
if not isinstance(ref, dict) or ref.get("층") != "project":
out.append(one)
continue
rid = refs.ref_id(ref)
if rid not in done:
key = have.get(rid)
if key is None:
sub = {k: v for k, v in subs[rid].items() if k not in ("키", "알약")}
sub["담은로직"] = _carry(list(sub.get("담은로직") or []), subs, done)
key = refs.create(KIND, refs.origin_mark(sub, ref))["이름"]
done[rid] = {"층": here[0], "주인": here[1], "키": key}
out.append({**one, "참조": done[rid]})
return out
def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
subs = doc.get(SUBS) or {}
doc = {k: v for k, v in doc.items() if k != SUBS}
same = [
path.stem
for path in sorted((store.root() / KIND).glob("SC-*.json"))
if (read_json(path) or {}).get("이름") == doc["이름"]
]
if overwrite and overwrite not in same:
raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름")
if same and not overwrite:
raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]})
if subs:
doc = {**doc, "담은로직": _carry(doc["담은로직"], subs, {})}
if overwrite:
if overwrite not in same:
raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름")
old = store.read(KIND, overwrite)
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
if same:
raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]})
return refs.create(KIND, doc)
+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}
+143 -51
View File
@@ -8,6 +8,12 @@
* 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림
* DRM 파일 = 서버 400 「읽을 수 없음」 알림
*
* 경쟁사 공사 통째(PLAN 707-7) = 같은 창에 불러올 것 체크(기본 다 켬 · 못 읽는 것 흐리게 + 까닭) —
* 일위대가 · 단가산출(이 공사 서고 사본 · 층 쓰기 0) · 내역 줄(늘) · 원가계산서 요율 · 단가표 →
* [불러오기] 한 번 = 서버가 차례대로(apply 의 hopyo · rates · prices) → 지난번 이음(707-4) · 남은 것만 잇기 창 →
* 끝 알림 한 장(`report` — 호표 · 내역 줄 · 직접비 · 총공사비 엑셀 = 우리 · 어긋난 줄 · 다음 걸음 ·
* 줄 어긋남 없으면 알림 한 줄)
*
* 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함.
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] ·
* 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기]
@@ -15,8 +21,8 @@
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
*
* EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) ·
* 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음)
* EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + 단가표 ·
* 일위대가 · 요율 = 흐림 「같은 공사 엑셀에서」 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음)
* STmate 원본(.STC · PLAN 707-6) = .estx 와 같은 자리 · 안 읽은 몫(다공구 · 변경 차수) = 창 알림 줄
* ========================================================================== */
@@ -55,11 +61,15 @@ async function post(projectId: string, path: string, body: BodyInit, json = fals
return got;
}
/** 받은 캐시 = 지금 캐시 + 내역 덮개 · 조달수수료율(서버가 빈 때만 채움) */
/** 받은 캐시 = 지금 캐시 + 내역 덮개 · 원가계산서 값(조달수수료율 · 요율) · 단가표 덮개(707-7) */
function merged(user: Row, made: Row): Row {
const pct = made.estimate?.procurement_fee_pct;
const estimate = { ...(user.estimate || {}), ...(pct != null && { procurement_fee_pct: pct }) };
return { ...user, bill: made.bill, estimate };
const keep = ["labor_cover", "machine_cover", "materials"].filter((k) => k in made);
return {
...user,
bill: made.bill,
estimate: { ...(user.estimate || {}), ...(made.estimate || {}) },
...Object.fromEntries(keep.map((k) => [k, made[k]])),
};
}
function differences(rows: Row[]): HTMLElement {
@@ -75,6 +85,97 @@ function differences(rows: Row[]): HTMLElement {
}).root;
}
/** 끝 알림 한 장(707-7) — 호표 · 내역 줄 · 엑셀 합계 = 우리 합계 · 어긋난 줄 · 다음 걸음 ·
* 줄 어긋남 없으면 알림 한 줄(누름 0) */
function report(got: Row, user: () => Row, done: (payload: Row, user: Row) => void): void {
const n = got.알림 || {};
const step: Row = got.단계 || {};
const sum: Row = got.합계 || {};
const same = (a: unknown, b: unknown): boolean =>
typeof a === "number" && typeof b === "number" && Math.abs(a - b) < 1;
const pair = (name: string, excel: unknown, ours: unknown): string =>
excel == null
? `${name} 우리 ${won(ours)}`
: same(excel, ours)
? `${name} 엑셀 = 우리 ${won(ours)}`
: `${name} 엑셀 ${won(excel)} · 우리 ${won(ours)}`;
const part = (name: string, s: Row | undefined, text: (s: Row) => string): string | null =>
!s ? null : s.까닭 ? `${name} — ${s.까닭}` : `${name} ${text(s)}`;
const hp: Row | undefined = step.일위대가;
const lines = [
part(
"일위대가",
hp,
(s) =>
`호표 ${s.호표} · 줄 ${s.줄}(고정 ${s.고정} · 하위 ${s.호표참조} · 빠짐 ${s.빠짐}) · 이 공사 서고 사본 ${s.사본}`,
),
`내역 줄 새 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 일위대가 이음 ${n.이음}`,
part(
"요율",
step.요율,
(s) => `${s.줄} 줄${s.다름?.length ? ` · 식 다름 ${s.다름.join(", ")}` : ""}`,
),
part("단가표", step.단가표, (s) =>
Object.entries(PART)
.map(([k, name]) => `${name} 이음 ${s[k]?.linked ?? 0}`)
.join(" · "),
),
`${pair("직접비", sum.엑셀직접, sum.우리직접)} · ${pair("총공사비", sum.엑셀총, sum.우리총)}`,
].filter((t): t is string => !!t);
const off: Row[] = got.어긋남 || [];
const other: Row[] = hp?.단가다름 || [];
const next =
"다음 — 붙인 고정 줄은 펼친 일위대가에서 이 공사 단가로 다시 잇기 · 우리 공종 잇기는 줄 우클릭 · [저장]해야 남음";
const clean = !off.length && !other.length && same(sum.엑셀직접, sum.우리직접);
if (clean) {
const tone = sum.엑셀총 == null || same(sum.엑셀총, sum.우리총) ? "success" : "warning";
return void showToast(`${lines.join(" · ")} — [저장]해야 남음`, tone, 9000);
}
openModal({
title: "경쟁사 공사 불러오기 — 결과",
closeLabel: "닫기",
width: "40rem",
mount: (box, close, actions) => {
const link = createButton({
label: "우리 공종으로 잇기",
variant: "ghost",
size: "sm",
onClick: () => {
close();
void openBillLink(user(), done);
},
});
actions.append(link);
box.append(
...lines.map((t) => createText(t, "muted", 14)),
...(other.length
? [
createText(
`내역 단가 ≠ 일위대가표 단가 ${other.length} 줄 — 내역 단가 그대로(잇지 않음)`,
"warning",
14,
),
createDataTable({
columns: [
{ key: "행", label: "엑셀 행", kind: "number", width: 48 },
{ key: "이름", label: "명칭", width: 150 },
{ key: "번", label: "호표", kind: "number", width: 48 },
{ key: "내역", label: "내역 단가", kind: "number", width: 96 },
{ key: "일위대가", label: "일위대가표", kind: "number", width: 96 },
],
rows: other.map((x, i) => ({ key: String(i), ...x })),
}).root,
]
: []),
...(off.length
? [createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), differences(off)]
: []),
createText(next, "muted", 14),
);
},
});
}
/** [엑셀 내역 불러오기] 단추 — user = 지금 화면 캐시 · done = 받은 원가 자료 + 새 캐시 */
export function createBillImport(
projectId: string,
@@ -88,17 +189,32 @@ export function createBillImport(
`${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`,
`${raw ?? "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
(seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""),
seen.이음
? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음`
: "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)",
...(seen.호표
? [
`호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`,
`원가계산서 요율은 ${raw} 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)`,
]
: []),
...((seen.알림 as string[] | undefined) ?? []),
];
// 707-7 불러올 것 — 기본 다 켬 · 못 읽는 것 = 흐리게 + 까닭
const later = raw && `${raw} 에서 못 읽음 — 같은 공사 엑셀에서`;
const hopyo = createCheckbox(
later
? `일위대가 · 단가산출 — 구성 줄은 ${later}`
: "일위대가 · 단가산출 — 이 공사 서고 사본으로(내 자료집 · 회사에 안 씀)",
{ checked: !later, disabled: !!later },
);
const billLines = createCheckbox(`내역 줄 — 공종 ${seen.묶음} · 항목 ${seen.항목}(늘 불러옴)`, {
checked: true,
disabled: true,
});
const rates = createCheckbox(
later ? `원가계산서 요율 — ${later}` : "원가계산서 요율 — 율 덮개",
{ checked: !later, disabled: !!later },
);
const prices = createCheckbox("단가표 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", {
checked: true,
});
const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", {
radio: "b09-import-mode",
value: "take",
@@ -119,23 +235,21 @@ export function createBillImport(
const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, {
disabled: !seen.남는자동,
});
const withPrices = seen.호표
? createCheckbox("단가표도 같이 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", {
checked: true,
})
: null;
const run = createButton({ label: "불러오기", variant: "filled" });
run.dataset.b09ImportRun = "";
openModal({
title: "엑셀 내역 불러오기",
title: "경쟁사 공사 불러오기",
closeLabel: "닫기",
width: "40rem",
mount: (body, close, actions) => {
body.append(
...lines.map((t) => createText(t, "muted", 14)),
el("div", {
className: "b09-import__choices",
children: [hopyo.root, billLines.root, rates.root, prices.root],
}),
el("div", { className: "b09-import__choices", children: [take.root, price.root] }),
el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }),
...(withPrices ? [withPrices.root] : []),
);
actions.append(run);
run.addEventListener("click", async () => {
@@ -146,48 +260,26 @@ export function createBillImport(
mode: mode.value,
attach: attach.input.checked,
drop: drop.input.checked,
hopyo: hopyo.input.checked,
rates: rates.input.checked,
prices: prices.input.checked,
user: draft,
};
showLoadingOverlay();
let got: Row;
try {
const got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
let made = merged(draft, got.user);
let tail = "";
if (withPrices?.input.checked) {
const sheet = await postSheet("prices", made, null);
made = { ...made, ...sheet.patch };
const c: Row = sheet.counts || {};
tail = ` · 단가표 이음 ${Object.entries(PART)
.map(([k, name]) => `${name} ${c[k]?.linked ?? 0}`)
.join(" · ")}`;
}
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
close();
done(got.payload, made);
void openBillLink(user(), done, mode.value, true); // 707-4 다음: 지난번 이음 바로 · 남은 것만 창
const n = got.알림 || {};
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`;
const off: Row[] = got.어긋남 || [];
if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
const result = (): void =>
void openModal({
title: "엑셀 내역 불러오기 — 결과",
closeLabel: "닫기",
width: "40rem",
mount: (box) =>
box.append(
createText(head, "success", 14),
createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14),
differences(off),
),
});
requestAnimationFrame(() => requestAnimationFrame(result));
got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
} catch (error) {
showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error");
const why = error instanceof Error ? error.message : error;
return void showToast(`불러오지 못함 — ${why}`, "error");
} finally {
hideLoadingOverlay();
}
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
close();
done(got.payload, merged(draft, got.user));
await openBillLink(user(), done, mode.value, true); // 707-4 지난번 이음 바로 · 남은 것만 창
requestAnimationFrame(() => requestAnimationFrame(() => report(got, user, done)));
});
},
});
+187
View File
@@ -0,0 +1,187 @@
"""707-7 경쟁사 공사 통째 불러오기 — 한 번 [불러오기] = 일위대가 · 단가산출(이 공사 서고 사본) →
내역 줄 → 요율 → 단가표(`Router_Import` apply · `Bill_Hopyo`).
- 실무 xlsx(거창 · 대흥 · 소광 · 영월): 호표 빠짐 0 · 705-3 수(고정 · 하위 · 이음) 그대로 ·
직접비 우리 = 엑셀 · 어긋난 줄 0 · 층 쓰기 0(임시 storage 빈 채) ·
사본 = `precombo@project@-@{표지}-{번}` · 하위 = 참조사본
- 내역 단가 ≠ 일위대가표 단가(소광) = 잇지 않고 내역 단가 그대로 · 목록
- 다시 부르기 = 같은 사본 · 두 번 셈 0 · .estx · .STC = 일위대가 · 요율 까닭 「같은 공사 엑셀에서」
- 내 자료집에 저장(703-2) = 하위 호표도 지금 층에 같이 씀(한 번만)
"""
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from config import config_system # noqa: E402
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
FILES = {
"거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", # noqa: E501
"대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", # noqa: E501
"소광": "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", # noqa: E501
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"장수": "02.산불 장수/01.내역서/00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1).estx",
"기번3": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", # noqa: E501
} # fmt: skip
#: (호표, 줄, 고정, 하위, 이음, 사본, 내역 이음, 엑셀 직접비)
WANT = {
"거창": (14, 68, 56, 12, 11, 8, 8, 384916227),
"대흥": (34, 171, 143, 28, 0, 26, 45, 710359136),
"소광": (25, 117, 98, 19, 0, 0, 0, 173311386),
"영월": (23, 138, 135, 3, 0, 0, 0, 767148700),
}
URL = "/api/projects/8b98ca75-c572-4002-8468-2adae4323414/estimation/import/bill"
ALL = {"hopyo": True, "rates": True, "prices": True}
@pytest.fixture(scope="module")
def world(tmp_path_factory):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B09_Estimation import B09_Estimation_Router as b09
from B09_Estimation import B09_Estimation_Router_Import as ri
from common_util.common_util_auth import verify_session
from resources.tester.test_145_2_b09_labor_calc import _project
base = tmp_path_factory.mktemp("p7077")
(base / "storage").mkdir()
roots: dict[str, Path] = {}
async def root_of(pid):
return roots["now"]
with pytest.MonkeyPatch.context() as mp:
mp.setattr(config_system, "STORAGE_BASE_DIR", str(base / "storage"))
mp.setattr(ri, "_project_root", root_of)
mp.setattr(b09, "_project_root", root_of)
app = FastAPI()
app.include_router(b09.router)
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app.dependency_overrides[verify_session] = lambda: session
client = TestClient(app)
done: dict[str, dict] = {}
def run(name, body=ALL, fresh=True):
if fresh and name in done: # 같은 파일 처음 불러온 결과 다시 씀(읽기 느림)
roots["now"] = done[name]["root"]
return done[name]
if fresh:
roots["now"] = _project(base / name, [])
path = K / FILES[name]
seen = client.post(URL, files={"file": (path.name, path.read_bytes())})
assert seen.status_code == 200, seen.text
got = client.post(URL + "/apply", json={"file_key": seen.json()["file_key"], **body})
assert got.status_code == 200, got.text
out = {**got.json(), "file_key": seen.json()["file_key"], "root": roots["now"]}
if fresh:
done[name] = out
return out
yield {"run": run, "storage": base / "storage"}
def _libs(root):
folder = root / "B09_Estimation/v1/library"
return sorted(p.name for p in folder.iterdir()) if folder.is_dir() else []
@pytest.mark.parametrize("name", list(WANT))
def test_실무_엑셀_통째_빠짐_0_합계_같음_층_쓰기_0(world, name):
got = world["run"](name)
hopyo, lines, fixed, kids, links, copies, billed, direct = WANT[name]
step = got["단계"]["일위대가"]
assert (step["호표"], step["줄"], step["고정"], step["호표참조"], step["이음"]) == (
hopyo, lines, fixed, kids, links) # fmt: skip
assert step["빠짐"] == 0 and step["실패"] == [] and step["사본"] == copies
assert got["알림"]["이음"] == billed
assert got["합계"]["엑셀직접"] == direct and got["합계"]["우리직접"] == direct
assert got["어긋남"] == []
libs = _libs(got["root"])
assert len(libs) == copies
assert all(re.fullmatch(rf"precombo@project@-@{got['file_key']}-\d+", x) for x in libs)
assert "까닭" not in got["단계"]["요율"] and "까닭" not in got["단계"]["단가표"]
assert not [p for p in world["storage"].rglob("*") if p.is_file()] # 층 쓰기 0
def test_대흥_총공사비_엑셀과_같음_하위는_참조사본(world):
got = world["run"]("대흥")
assert got["합계"]["우리총"] == got["합계"]["엑셀총"] == 1201879000
nested = 0
for lib in _libs(got["root"]):
path = got["root"] / "B09_Estimation/v1/library" / lib / "일위대가.json"
labor = json.loads(path.read_text("utf-8"))
copies = labor["참조사본"]
for doc in copies.values():
for one in doc["담은로직"]:
if "참조" in one:
ref = one["참조"]
assert ref["층"] == "project" and ref["주인"] == got["file_key"]
assert f"참조|project|{got['file_key']}|{ref['키']}" in copies
nested += 1
assert nested > 0
def test_소광_내역_단가_다른_줄은_잇지_않음(world):
got = world["run"]("소광")
other = got["단계"]["일위대가"]["단가다름"]
assert len(other) == 12 and other[0]["내역"] == 46564 and other[0]["일위대가"] == 53070
def test_다시_부르기_같은_사본_두_번_셈_0(world):
first = world["run"]("거창")
again = world["run"]("거창", fresh=False)
assert _libs(again["root"]) == _libs(first["root"])
assert again["합계"]["우리직접"] == first["합계"]["우리직접"] == 384916227
rows = again["user"]["bill"]["rows"]
assert len({r["key"] for r in rows}) == len(rows)
@pytest.mark.parametrize("name", ["장수", "기번3"])
def test_원본_파일_일위대가_요율_까닭(world, name):
got = world["run"](name)
assert "같은 공사 엑셀에서" in got["단계"]["일위대가"]["까닭"]
assert "같은 공사 엑셀에서" in got["단계"]["요율"]["까닭"]
assert "labor" in got["단계"]["단가표"]
assert got["합계"]["우리직접"] == got["합계"]["엑셀직접"]
assert _libs(got["root"]) == []
def test_내_자료집에_저장_하위_호표_같이_한_번(world, monkeypatch):
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from M02_MasterTemplete import M02_MasterTemplete_Store as m02
from M02_MasterTemplete import M02_Template_Layers as layers
got = world["run"]("거창")
root = got["root"]
blocks = daega_inputs(root)["blocks"]
head = f"C:참조|project|{got['file_key']}"
tops = {b["cover"]: b for b in blocks if b["cover"].startswith(head)}
with_kids = [c for c, b in tops.items() if any(ln["kind"] == "daega" for ln in b["lines"])]
assert len(with_kids) >= 2
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(world["storage"]))
folder = layers.personal_dir(7, 42)
folder.mkdir(parents=True, exist_ok=True)
made = [personal.build(root, c, {}, f"거창 {i}")["문서"] for i, c in enumerate(with_kids[:2])]
assert all(personal.SUBS in doc for doc in made)
with m02.at(folder):
keys = [personal.write(doc)["이름"] for doc in made]
saved = {p.stem: json.loads(p.read_text("utf-8")) for p in (folder / "precombo").glob("*.json")}
for key in keys:
for one in saved[key]["담은로직"]:
if "참조" in one:
assert one["참조"]["층"] == "personal" and one["참조"]["키"] in saved
copied = [d["본뜬"]["복사원본"] for d in saved.values() if "복사원본" in (d.get("본뜬") or {})]
assert len(copied) == len({json.dumps(x, sort_keys=True) for x in copied}) # 같은 하위 한 번
for p in (folder / "precombo").glob("*.json"): # 다음 시험에 층 쓰기 안 남김
p.unlink()