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:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user