From 76dc4a6143dfca02046d1884d6cba905fd9c44c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:59:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?):=20=EA=B2=BD=EC=9F=81=EC=82=AC=20=EA=B3=B5=EC=82=AC=20?= =?UTF-8?q?=ED=86=B5=EC=A7=B8=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=B0=BD=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=9D=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=84=9C=EA=B3=A0=20=EC=82=AC=EB=B3=B8=20(707-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불러오기 창 = 불러올 것 체크(일위대가 · 단가산출 / 내역 줄 / 원가계산서 요율 / 단가표 · 기본 다 켬 · .estx · .STC 일위대가 · 요율 흐림 + 까닭) - [불러오기] 한 번 = 서버 차례 — 일위대가(705-3 고정 줄 · 하위 먼저 · 임시 폴더에서 짓고 서고 사본 precombo@project · 층 쓰기 0) → 내역 줄 → 요율 → 단가표 → 셈 - 내역 단가 ≠ 일위대가표 단가 줄 = 잇지 않고 내역 단가 그대로(경쟁사 금액 재현 · 목록) - 끝 알림 한 장 — 호표 · 내역 줄 · 직접비 · 총공사비 엑셀 = 우리 · 어긋난 줄 · 다음 걸음 · 줄 어긋남 없으면 알림 한 줄 - 내 자료집에 저장(703-2) = 불러온 하위 호표도 지금 층에 같이 씀(한 번만) - 시험 test_707_7 10 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Hopyo.py | 160 +++++++++++++++ B09_Estimation/B09_Estimation_Bill_Library.py | 5 +- .../B09_Estimation_Daega_Personal.py | 71 ++++++- .../B09_Estimation_Router_Import.py | 115 +++++++++-- B09_Estimation/B09_Estimation_UI_Import.ts | 194 +++++++++++++----- resources/tester/test_707_7_whole_import.py | 187 +++++++++++++++++ 6 files changed, 656 insertions(+), 76 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Bill_Hopyo.py create mode 100644 resources/tester/test_707_7_whole_import.py diff --git a/B09_Estimation/B09_Estimation_Bill_Hopyo.py b/B09_Estimation/B09_Estimation_Bill_Hopyo.py new file mode 100644 index 000000000..6555be977 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Hopyo.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index a53d71108..78b52bec9 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -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})?$" ) diff --git a/B09_Estimation/B09_Estimation_Daega_Personal.py b/B09_Estimation/B09_Estimation_Daega_Personal.py index b45119661..7aa7d7c9b 100644 --- a/B09_Estimation/B09_Estimation_Daega_Personal.py +++ b/B09_Estimation/B09_Estimation_Daega_Personal.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index fafbea2d6..e9e07762c 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -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} diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 4e74c77dd..a9abf99aa 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -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))); }); }, }); diff --git a/resources/tester/test_707_7_whole_import.py b/resources/tester/test_707_7_whole_import.py new file mode 100644 index 000000000..93eee2469 --- /dev/null +++ b/resources/tester/test_707_7_whole_import.py @@ -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()