From df5da11b37827061787e01975335d7f2bd808c80 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 15:28:05 +0900 Subject: [PATCH 1/5] =?UTF-8?q?test(243-1):=20B09=20=ED=91=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20=EA=B8=B8=20=EC=8B=9C=ED=97=98=20=E2=80=94?= =?UTF-8?q?=20702-1=20=ED=8E=BC=EC=B9=A8=20=ED=91=9C=20=EB=8D=94=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 702-1 B09_Estimation_UI_Tabs_Bill_Fold.ts 가 펼친 일위대가 구성 표를 제 표로 만듦(editOnDouble: true · 두 번 눌러 고침). 코드 흠 아님 · 시험이 팝업 · Shell 두 곳만 알던 낡은 목록 — 펼침 표를 목록에 더하고 두 번 눌러 고침 확인 한 줄. Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_243_1_double_click_tables.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index f03bdb57e..918a3df95 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -112,8 +112,14 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): calls = [ p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") ] - # 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) - assert calls == ["B09_Estimation_UI_Popup.ts", "B09_Estimation_UI_Shell.ts"] + # 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침) + assert calls == [ + "B09_Estimation_UI_Popup.ts", + "B09_Estimation_UI_Shell.ts", + "B09_Estimation_UI_Tabs_Bill_Fold.ts", + ] + fold = (ROOT / "B09_Estimation" / calls[2]).read_text("utf-8") + assert "editOnDouble: true," in fold shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8") assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳 builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") From c0ea73501f7ae62cd9f879e2bf1b1087d0bb76e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 15:43:51 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20=EA=B2=BD=EC=9F=81=EC=82=AC=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=97=91=EC=85=80=20=EC=A4=84=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=84=9C=EB=B2=84=20=E2=80=94=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A7=9C=EA=B8=B0=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EA=B0=80=20=EC=B9=B8=20(PLAN=20707-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 읽기(Bill_Excel) — STmate 「설계내역서」 숨은 코드 · 부모 코드 열 · EST 「내역서」 공종 번호 깊이 · 비고 대가 · 산근 · 단산 · 일위 · 제외 묶음(관급 · 제외금액) · 조달수수료 = 율 · DRM = 「읽을 수 없음」 · 열 머리 = M02 실무자료 칸 읽기 재사용 - 짜기(Bill_Import) — 공종 = 새 묶음 · 항목 = 새 줄 + 고정 단가 칸 · 수량 갈래 take · price · 이름 같은 자동 줄 붙임 · 남은 자동 줄 지움 · 같은 파일 다시 = 갈아 끼움 · 어긋난 줄 목록 - 길(Router_Import) — import/bill(file · file_key) 미리 보기 · import/bill/apply(같은 파일 이름 실무자료 사전조합 → 서고 사본 연결 · 층 쓰기 0) · 저장 안 함 - 셈 짝(Calc_Bill py · ts) — 줄 price = 연결이 단가를 못 낼 때 금액 · fixed · src_qty · Store 다듬기 - 확인 = 기번3 710,359,136 · 영월 767,148,700 · 봉화 781,283,415 · 거창 384,916,227 직접비 합 = 엑셀 · 빈 줄 0 · 어긋남 0 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Excel.py | 238 ++++++++++++++++++ B09_Estimation/B09_Estimation_Bill_Import.py | 221 ++++++++++++++++ B09_Estimation/B09_Estimation_Calc_Bill.py | 26 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 31 ++- B09_Estimation/B09_Estimation_Router.py | 5 +- .../B09_Estimation_Router_Import.py | 190 ++++++++++++++ B09_Estimation/B09_Estimation_Store.py | 20 +- resources/tester/test_707_1_bill_import.py | 187 ++++++++++++++ 8 files changed, 910 insertions(+), 8 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Bill_Excel.py create mode 100644 B09_Estimation/B09_Estimation_Bill_Import.py create mode 100644 B09_Estimation/B09_Estimation_Router_Import.py create mode 100644 resources/tester/test_707_1_bill_import.py diff --git a/B09_Estimation/B09_Estimation_Bill_Excel.py b/B09_Estimation/B09_Estimation_Bill_Excel.py new file mode 100644 index 000000000..0cde0a8e9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Excel.py @@ -0,0 +1,238 @@ +"""B09 경쟁사 내역 엑셀 읽기(PLAN 707-1) — 파일 → 내역 줄(묶음 · 항목) · 엑셀 합계. 저장 안 함. + +꼴 둘(`_분석_705-4_불러오기_범위.md` ②): + STmate 「설계내역서」(없으면 「착공내역서」 · 「설계변경내역서」) — + 숨은 열 = 줄 코드 · 부모 코드(`B00194_1` · `E2_1` — 비고 열 뒤 첫 둘) · + 묶음 = 코드 E · C(C = 제외금액 맨 위) · + 변경 차수(`_2` 뒤) 줄 = 안 읽음 · 부모 = 열린 묶음 가운데 부모 코드가 같은 가장 안쪽 · + 호표 = 코드 B 대가 · D 산근 · M 자재(번 = 비고 「대가 5호표」 · 「산근 13호표」 숫자) + EST 「내역서」 — 숨은 열 없음 · 묶음 = 공종 칸 있음 + 수량 없음 · 깊이 = 공종 번호 + (Ⅰ · Ⅱ = 1 · 「4.1」 = 점 둘레 수 + 1) · 공종 · 수량 없이 금액만 = 합계 줄(안 읽음) · + 호표 = 비고 「단산46참조」 · 「일위8참조」 · 「관급」 +열 = 머리 글(앞 10 줄 · 빈칸 뺌) 공종 · 명칭|품명 · 규격 · 수량 · 단위 · 합계 · + 노무비 · 재료비 · 경비 · 비고 — 돈 머리 = 단가 열 · 다음 열 = 금액 + (`M02_Hopyo_Excel` 의 칸 읽기 재사용) · 빈 단가 = 0. +제외 = 맨 위 묶음 가운데 STmate 코드 C · 비고 「제외」 · 이름 「관급」 — 직접비 합에서 뺌. +조달수수료 = 제외 묶음 안 단위 % · 이름 「수수료」 줄 → `조달수수료율` + (줄로 안 실음 · 우리 조달수수료 줄이 셈). +그 밖 % 줄 = 1 식 · 단가 = 엑셀 금액(금액 그대로). +감싼 묶음 = 직접 맨 위 묶음이 하나뿐(공사 이름)이면 벗김(깊이 5 한도 · 레벨 1 = 공종). +DRM 파일 = 「읽을 수 없음」(`M02_Hopyo_Excel.DRM`). +""" + +from __future__ import annotations + +import io +import re +import warnings +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Excel as hx + +SHEETS = ("설계내역서", "내역서", "착공내역서", "설계변경내역서") +HEADS = { + "공종": "공종", + "명칭": "품명", + "품명": "품명", + "규격": "규격", + "수량": "수량", + "단위": "단위", + "합계": "합계", + "노무비": "노무비", + "재료비": "재료비", + "경비": "경비", + "비고": "비고", +} +PARTS = (("노무비", "labor"), ("재료비", "material"), ("경비", "expense")) +_CODE = re.compile(r"^([A-Z]+\d*)_(\d+)$") +_ROMAN = re.compile(r"^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+$|^[IVX]+\.?$") +_ST_NOTE = re.compile(r"^(대가|산근|자재)(\d+)") +_EST_NOTE = re.compile(r"^(단산|일위)(\d+)") +NO_SHEET = "내역서 시트(설계내역서 · 내역서) 없음" + + +class BillExcelError(ValueError): + """못 읽는 파일 — 길이 400 으로 답함.""" + + +def _sheet(name: str, data: bytes) -> tuple[str, list[list[Any]]]: + if not data: + raise BillExcelError("빈 파일") + if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"): + raise BillExcelError(f"읽을 수 없음 — {hx.DRM}") + try: + if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): + import openpyxl + + with warnings.catch_warnings(): # 인쇄 영역 이름 경고(STmate) — 읽기와 무관 + warnings.simplefilter("ignore") + book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + titles = {ws.title.strip(): ws for ws in book.worksheets} + pick = next((t for t in SHEETS if t in titles), None) + rows = [list(r) for r in titles[pick].iter_rows(values_only=True)] if pick else [] + book.close() + else: + import xlrd + + book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO()) + titles = {sh.name.strip(): sh for sh in book.sheets()} + pick = next((t for t in SHEETS if t in titles), None) + sh = titles.get(pick or "") + rows = [sh.row_values(r) for r in range(sh.nrows)] if sh else [] + except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님 + raise BillExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error + if pick is None: + raise BillExcelError(NO_SHEET) + return pick, rows + + +def _columns(rows: list[list[Any]]) -> tuple[int, dict[str, int]]: + for at, row in enumerate(rows[:10]): + flat = [hx._flat(v) for v in row] + if "공종" not in flat or not ({"명칭", "품명"} & set(flat)): + continue + cols: dict[str, int] = {} + for col, head in enumerate(flat): + if head in HEADS and HEADS[head] not in cols: + cols[HEADS[head]] = col + below = [hx._flat(v) for v in rows[at + 1]] if at + 1 < len(rows) else [] + return (at + 2 if "단가" in below or "금액" in below else at + 1), cols + raise BillExcelError("내역서 머리 글(공종 · 명칭)을 못 찾음") + + +def _codes(row: list[Any], after: int) -> tuple[str, str] | None: + """STmate 숨은 열 (줄 코드, 부모 코드) — 비고 뒤 「X_n」 꼴 첫 둘 · + 변경 차수(_2 뒤)면 ("", "").""" + got = [m for v in row[after + 1 :] if (m := _CODE.match(hx._text(v)))] + if not got: + return None + if got[0].group(2) != "1": + return "", "" + return got[0].group(1), (got[1].group(1) if len(got) > 1 else "") + + +def _depth(head: str) -> int: + if _ROMAN.match(head): + return 1 + parts = [p for p in head.split(".") if p.strip()] + return len(parts) + 1 if parts and all(p.strip().isdigit() for p in parts) else 1 + + +def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decimal]: + got = hx._money(row, cols, offset) + return {key: got.get(head) or Decimal(0) for head, key in PARTS} + + +def _hopyo(note: str, code: str) -> dict[str, Any] | None: + flat = hx._flat(note) + if hit := _ST_NOTE.match(flat): + return {"종류": hit.group(1), "번": int(hit.group(2)), "코드": code} + if hit := _EST_NOTE.match(flat): + return {"종류": "산근" if hit.group(1) == "단산" else "대가", "번": int(hit.group(2))} + if code[:1] in ("B", "D", "M"): + return {"종류": {"B": "대가", "D": "산근", "M": "자재"}[code[0]], "코드": code} + return None + + +def read_bill(name: str, data: bytes) -> dict[str, Any]: + """엑셀 → `{꼴, 시트, 줄 [{행, 묶음, 위, 공종, 이름, 규격, 단위, 수량, 단가 {labor, material, + expense}, 금액 {…}, 비고, 호표, 제외}], 합계 {직접, 제외}, 조달수수료율, 합계줄, 율줄}` — + `위` = 부모 줄 행(None = 맨 위) · 수 = JSON 수.""" + title, rows = _sheet(name, data) + start, cols = _columns(rows) + lines: list[dict[str, Any]] = [] + stack: list[tuple[Any, dict[str, Any]]] = [] # (코드 | 깊이, 묶음 줄) + stmate, skipped, fee, pct_lines = False, 0, None, 0 + for at, row in enumerate(rows[start:], start + 1): + cell = lambda h: hx._cell(row, cols.get(h)) # noqa: E731 + head, item_name = hx._text(cell("공종")), hx._text(cell("품명")) + if not item_name: + continue + qty = hx._num(cell("수량")) + codes = _codes(row, cols.get("비고", 13)) + stmate = stmate or codes is not None + if codes == ("", ""): + continue + if codes: + own, parent = codes + group = own[:1] in ("E", "C") + at_up = next((n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == parent), None) + stack = stack[: at_up + 1] if at_up is not None else [] + else: + own = "" + group = bool(head) and qty is None + if not group and qty is None: + skipped += 1 # 「순 공 사 비」 같은 합계 줄 + continue + if group: + depth = _depth(head) + while stack and stack[-1][0] >= depth: + stack.pop() + up = stack[-1][1] if stack else None + note = hx._text(cell("비고")) + line = { + "행": at, + "묶음": group, + "위": up["행"] if up else None, + "공종": head, + "이름": item_name, + "규격": hx._text(cell("규격")), + "단위": hx._text(cell("단위")), + "수량": qty, + "단가": _money(row, cols, 0), + "금액": _money(row, cols, 1), + "비고": note, + "호표": None if group else _hopyo(note, own), + "제외": (up or {}).get("제외", False) + or (up is None and (own[:1] == "C" or "제외" in note or "관급" in item_name)), + } + if group: + stack.append((own if codes else _depth(head), line)) + elif hx._flat(line["단위"]) == "%": + if line["제외"] and "수수료" in item_name: + fee = fee if fee is not None else qty + continue + pct_lines += 1 + line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"}) + line["규격"] = " ".join(t for t in (line["규격"], f"({hx._out(qty)} %)") if t) + lines.append(line) + _unwrap(lines) + total = {"직접": Decimal(0), "제외": Decimal(0)} + for line in lines: + if not line["묶음"]: + total["제외" if line["제외"] else "직접"] += sum(line["금액"].values()) + return _plain( + { + "꼴": "STmate" if stmate else "EST", + "시트": title, + "줄": lines, + "합계": total, + "조달수수료율": fee, + "합계줄": skipped, + "율줄": pct_lines, + } + ) + + +def _unwrap(lines: list[dict[str, Any]]) -> None: + """직접 맨 위 묶음이 하나뿐이면(공사 이름) 벗김 — 자식이 맨 위로(되풀이).""" + while True: + tops = [x for x in lines if x["위"] is None and not x["제외"]] + if len(tops) != 1 or not tops[0]["묶음"]: + return + wrap = tops[0]["행"] + lines.remove(tops[0]) + for line in lines: + if line["위"] == wrap: + line["위"] = None + + +def _plain(value: Any) -> Any: + if isinstance(value, Decimal): + return hx._out(value) + if isinstance(value, dict): + return {k: _plain(v) for k, v in value.items()} + if isinstance(value, list): + return [_plain(v) for v in value] + return value diff --git a/B09_Estimation/B09_Estimation_Bill_Import.py b/B09_Estimation/B09_Estimation_Bill_Import.py new file mode 100644 index 000000000..b2b8b6f2b --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Import.py @@ -0,0 +1,221 @@ +"""B09 경쟁사 내역 줄 불러오기 짜기(PLAN 707-1) — 읽은 내역(`Bill_Excel.read_bill`) → 설계내역서 +덮개 v2(`user.bill` · 계약 = `B09_Estimation_Calc_Bill` 머리). 파일 · 저장 안 만짐(부르는 쪽 몫). + +먼저 싣고 나중 잇기 — 빠짐 0: + 묶음 줄 → 새 묶음(`hand:x{표지}-{행}` · 제외 맨 위 = 관급자재대 `sec:G` 아래) + 항목 줄 → 새 줄 + 고정 단가 칸 `price`(엑셀 단가) + `src_qty`(엑셀 수량) + `note`(엑셀 비고) · + 연결 = 같은 책 일위대가 사본(`links` {대가 번: ref}) — 없으면 고정 단가로 금액 +같은 파일을 다시 부르면(같은 표지) 앞서 실은 줄을 갈아 끼움. +수량 갈래 — `take`(가 넘겨받기) 붙인 자동 줄 수량 = 엑셀(B08 = `auto_qty` 로 나란히) · + `price`(나 단가만) 붙인 자동 줄 수량 = B08 · 엑셀 수량 = 비교 칸만. 새 줄 수량 = 늘 엑셀. +두 번 셈 막음 — `attach` = 이름(빈칸 뺌)이 같은 자동 항목 줄에 붙임(그 줄 = 불러온 자리로 옮기고 + 단가 = 연결 · 고정 단가 칸 · 자재 줄 · 조달수수료 뺌) · + `drop` = 안 붙은 자동 항목 줄 지움(`gone`). +조달수수료율 = 사용자 값이 비었을 때만 엑셀 율. +""" + +from __future__ import annotations + +import copy +import hashlib +import re +from typing import Any + +MODES = ("take", "price") +FIELDS = ("name", "spec", "unit", "qty", "ref") +PARTS = ("labor", "material", "expense") + + +def tag_of(data: bytes) -> str: + """파일 표지 — 같은 내용 = 같은 표지(새 줄 키 머리).""" + return hashlib.sha1(data).hexdigest()[:8] + + +def _flat(text: Any) -> str: + return re.sub(r"\s+", "", str(text or "")) + + +def freeze(user: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: + """화면 굳힘(`UI_Tabs_Bill_Edit.freeze`)과 같은 일 — v2 = 그대로 + 덮개에 없는 보이는 줄을 + 보이는 앞 줄 뒤에 · 옛 꼴 = 보이는 줄로(자동 줄 수량 덮개 · 새 줄 칸 옮김).""" + bill = user.get("bill") or {} + v2 = bill.get("v") == 2 + entries = copy.deepcopy(bill.get("rows") or []) if v2 else [] + gone = list(bill.get("gone") or []) if v2 else [] + prev = -1 + for row in rows: + key = row["key"] + if key == "fee": + continue + at = next((n for n, e in enumerate(entries) if e["key"] == key), -1) + if at < 0 and key not in gone: + entry = {"key": key, **({"parent": row["parent"]} if row.get("parent") else {})} + if not v2 and row.get("auto"): + if key in (bill.get("qty") or {}): + entry["qty"] = bill["qty"][key] + elif not v2: + hand = next((h for h in bill.get("rows") or [] if h.get("key") == key), {}) + entry.update({f: hand[f] for f in FIELDS if f in hand}) + if row["kind"] != "item": + entry["kind"] = "group" + entries.insert(prev + 1, entry) + at = prev + 1 + if at >= 0: + prev = at + return {"v": 2, "rows": entries, "gone": gone} + + +def _amount(line: dict[str, Any]) -> float: + return sum(float(line["금액"].get(p) or 0) for p in PARTS) + + +def _price(line: dict[str, Any]) -> dict[str, float]: + return {p: float(line["단가"].get(p) or 0) for p in PARTS} + + +def _autos(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """붙일 수 있는 자동 항목 줄 — 자재 줄 · 조달수수료 뺌.""" + return [ + r + for r in rows + if r["kind"] == "item" and r.get("auto") and r["key"] != "fee" and r["key"][:4] != "mat:" + ] + + +def matches(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, str]: + """{불러온 줄 행(글): 자동 줄 키} — 이름(빈칸 뺌)이 같은 첫 남은 자동 줄 · 제외 줄 뺌.""" + free: dict[str, list[str]] = {} + for row in _autos(rows): + free.setdefault(_flat(row["name"]), []).append(row["key"]) + out = {} + for line in parsed["줄"]: + if line["묶음"] or line["제외"]: + continue + keys = free.get(_flat(line["이름"])) or [] + if keys: + out[str(line["행"])] = keys.pop(0) + return out + + +def preview(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: + """불러오기 창 수 — 묶음 · 항목 · 이름 같은 자동 줄 · 그 밖 자동 줄 · 엑셀 합계.""" + lines = parsed["줄"] + pairs = matches(parsed, rows) + return { + "꼴": parsed["꼴"], + "시트": parsed["시트"], + "묶음": sum(1 for x in lines if x["묶음"]), + "항목": sum(1 for x in lines if not x["묶음"]), + "합계": parsed["합계"], + "조달수수료율": parsed["조달수수료율"], + "합계줄": parsed["합계줄"], + "율줄": parsed["율줄"], + "같은이름": len(pairs), + "남는자동": len(_autos(rows)) - len(pairs), + } + + +def apply( + parsed: dict[str, Any], + user: dict[str, Any], + rows: list[dict[str, Any]], + tag: str, + mode: str = "take", + attach: bool = True, + drop: bool = False, + links: dict[int, dict[str, Any]] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """(새 사용자 값, 알림 {실음, 붙임, 지움, 이음, 줄 {행: 줄 키}}) — `rows` = 지금 셈 줄.""" + if mode not in MODES: + raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나") + links = links or {} + head = f"hand:x{tag}-" + bill = freeze(user, rows) + kept = [] + for e in bill[ + "rows" + ]: # 앞서 실은 줄 갈아 끼움 — 그 묶음에 있던 자동 줄 = 제자리 · 새 줄 = 맨 위 + if e["key"].startswith(head): + continue + if str(e.get("parent") or "").startswith(head): + if not e["key"].startswith("hand:"): + continue + e.pop("parent") + kept.append(e) + bill["rows"] = kept + pairs = matches(parsed, rows) if attach else {} + keys = {line["행"]: f"{head}{line['행']:05d}" for line in parsed["줄"]} + put, report = [], {"실음": 0, "붙임": 0, "지움": 0, "이음": 0, "줄": {}} + for line in parsed["줄"]: + parent = keys.get(line["위"]) if line["위"] is not None else None + if parent is None and line["제외"]: + parent = "sec:G" + hopyo = line.get("호표") or {} + link = links.get(hopyo["번"]) if hopyo.get("종류") == "대가" and "번" in hopyo else None + report["이음"] += bool(link) + cells = {"price": _price(line), "src_qty": line["수량"]} + auto = pairs.get(str(line["행"])) + if auto: + entry = next((e for e in bill["rows"] if e["key"] == auto), None) + if entry is None: + entry = {"key": auto} + else: + bill["rows"].remove(entry) + entry.update({**cells, "ref": link}) + if mode == "take": + entry["qty"] = line["수량"] + entry.pop("parent", None) + put.append({**entry, **({"parent": parent} if parent else {})}) + report["붙임"] += 1 + report["줄"][str(line["행"])] = auto + continue + entry = {"key": keys[line["행"]], **({"parent": parent} if parent else {})} + entry.update({"name": line["이름"], "spec": line["규격"], "unit": line["단위"]}) + if line["묶음"]: + entry.update({"kind": "group", "qty": None, "ref": None}) + else: + entry.update({"qty": line["수량"], "ref": link, **cells, "note": line["비고"]}) + report["줄"][str(line["행"])] = entry["key"] + put.append(entry) + report["실음"] += 1 + if drop: + used = set(pairs.values()) + for row in _autos(rows): + if row["key"] not in used and row["key"] not in bill["gone"]: + bill["gone"].append(row["key"]) + report["지움"] += 1 + bill["rows"] = [e for e in bill["rows"] if e["key"] not in bill["gone"]] + bill["rows"] += put + out = {**user, "bill": bill} + estimate = dict(user.get("estimate") or {}) + if estimate.get("procurement_fee_pct") is None and parsed.get("조달수수료율") is not None: + estimate["procurement_fee_pct"] = parsed["조달수수료율"] + out["estimate"] = estimate + return out, report + + +def differences( + parsed: dict[str, Any], report: dict[str, Any], rows: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """불러온 직후 엑셀 금액 ≠ 우리 금액인 줄 [{행, 이름, 엑셀, 우리, 까닭}].""" + by_key = {r["key"]: r for r in rows} + out = [] + for line in parsed["줄"]: + row = by_key.get(report["줄"].get(str(line["행"]), "")) + if row is None: + continue + excel, ours = _amount(line), row.get("sum") + if ours is not None and abs(ours - excel) < 1: + continue + if ours is None: + why = "빈칸 — 단가 · 수량 없음" + elif row.get("qty") != line["수량"]: + why = "수량 = B08(단가만)" + elif not row.get("fixed"): + why = "이음 단가 = 우리 셈" + else: + why = "끝수" + out.append( + {"행": line["행"], "이름": line["이름"], "엑셀": excel, "우리": ours, "까닭": why} + ) + return out diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 5c7c2a8ce..cbdff9d85 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -27,7 +27,10 @@ (제목 `sec:*` = name 만 · `mat:` = ref 없음 · `fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정) 새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 · - "cost": "direct"(맨 위 새 묶음 · 없음도 direct)}], + "cost": "direct"(맨 위 새 묶음 · 없음도 direct) · + 고정 단가 칸(707-1 · 새 줄 · 자동 줄 둘 다) = "price" {labor, material, expense}(제 단가 · + 연결이 단가를 못 내면 이것으로 금액 · 줄 `fixed`) · "src_qty"(불러온 수량 = 비교 칸) · + "note"(새 줄 비고 · 연결 비고가 이김)}], "gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]} 덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 · `rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) = @@ -113,6 +116,8 @@ def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str, "ref": item.get("ref"), **{k: v for k, v in item.items() if k.startswith("auto_")}, **({"hand": True} if item.get("hand") else {"auto": True}), + **({"fixed": True} if item.get("fixed") else {}), + **({"src_qty": item["src_qty"]} if num(item.get("src_qty")) is not None else {}), **_amounts(qty, prices), } row["_gaps"] = [] @@ -170,16 +175,27 @@ def ref_price( return hopyo.get(key) if key else None +def fixed_price(price: Any) -> dict[str, Any] | None: + """고정 단가 칸(707-1) → 1 단위 단가 — 빈 비목 0 · 칸 없음 = None.""" + if not isinstance(price, dict): + return None + out = {p: num(price.get(p)) or 0.0 for p in PARTS} + return {"no": "", **out, "sum": out["labor"] + out["material"] + out["expense"]} + + def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]: - """새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠).""" + """새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) · + 연결이 단가를 못 내면 고정 단가 칸(707-1).""" out = [] for hand in (user.get("bill") or {}).get("rows") or []: if not str(hand.get("key") or "").startswith("hand:"): continue price = ref_price(hand.get("ref"), books, hopyo, hand["key"]) ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None + fixed = price is None and (price := fixed_price(hand.get("price"))) is not None why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND - item = {**hand, "hand": True, "note": (price or {}).get("no") or "", "ref": ref} + note = (price or {}).get("no") or hand.get("note") or "" + item = {**hand, "hand": True, "note": note, "ref": ref, "fixed": fixed} out.append((item, price, why)) return out @@ -209,6 +225,10 @@ def _covered( out["note"] = (prices or {}).get("no") or "" if (cover["ref"] or {}).get("tab") == "logic": out["why"] = WHY_LOGIC + if prices is None and (prices := fixed_price(cover.get("price"))) is not None: + out["fixed"] = True # 707-1 연결이 단가를 못 냄 = 고정 단가 칸 + if num(cover.get("src_qty")) is not None: + out["src_qty"] = num(cover["src_qty"]) return out, prices diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 631c91f25..9060fe51e 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -13,6 +13,8 @@ * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근(702-3 연결 즉시 뒤에서 셈) · 그 밖 hopyo) * 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} · * 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리 + * 고정 단가 칸(707-1) = 줄 price {labor, material, expense} — 연결이 단가를 못 내면 그것으로 금액(줄 fixed) · + * src_qty = 불러온 수량(비교 칸) * 출력 줄 칸 = kind · key · name · parent · level · no · cost · auto | hand · auto_{칸} · section * ========================================================================== */ @@ -79,6 +81,8 @@ function itemRow(item: Row, prices: Row | null): Row { ref: item.ref ?? null, ...Object.fromEntries(Object.entries(item).filter(([k]) => k.startsWith("auto_"))), ...(item.hand ? { hand: true } : { auto: true }), + ...(item.fixed ? { fixed: true } : {}), + ...(num(item.src_qty) !== null ? { src_qty: item.src_qty } : {}), ...amounts(qty, prices), }; row._gaps = []; @@ -134,15 +138,31 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): return key ? (hopyo[key] ?? null) : null; } -/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */ +/** 고정 단가 칸(707-1) → 1 단위 단가 — 빈 비목 0 · 칸 없음 = null */ +export function fixedPrice(price: unknown): Row | null { + if (!price || typeof price !== "object" || Array.isArray(price)) return null; + const out: Row = { no: "" }; + for (const p of PARTS) out[p] = num((price as Row)[p]) || 0; + out.sum = out.labor + out.material + out.expense; + return out; +} + +/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) · + * 연결이 단가를 못 내면 고정 단가 칸(707-1) */ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> { const out: Array<[Row, Row | null, string]> = []; for (const hand of (user.bill || {}).rows || []) { if (!String(hand.key || "").startsWith("hand:")) continue; - const price = refPrice(hand.ref ?? null, books, hopyo, hand.key); + let price = refPrice(hand.ref ?? null, books, hopyo, hand.key); const ref = price?.tab ? { tab: price.tab, key: price.key } : null; + let fixed = false; + if (price === null) { + price = fixedPrice(hand.price); + fixed = price !== null; + } const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND; - out.push([{ ...hand, hand: true, note: price?.no || "", ref }, price, why]); + const note = price?.no || hand.note || ""; + out.push([{ ...hand, hand: true, note, ref, fixed }, price, why]); } return out; } @@ -171,6 +191,11 @@ function covered( out.note = prices?.no || ""; if (cover.ref?.tab === "logic") out.why = WHY_LOGIC; } + if (prices === null) { + prices = fixedPrice(cover.price); // 707-1 연결이 단가를 못 냄 = 고정 단가 칸 + if (prices !== null) out.fixed = true; + } + if (num(cover.src_qty) !== null) out.src_qty = num(cover.src_qty); return [out, prices]; } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d15bd3353..07236d324 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -41,6 +41,7 @@ 지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} · 덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) · 같은 회사만(남의 회사 403) +경쟁사 내역 줄 불러오기(707-1) = `B09_Estimation_Router_Import`. 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · @@ -85,6 +86,7 @@ from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table +from B09_Estimation.B09_Estimation_Router_Import import router as import_router from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session @@ -554,8 +556,9 @@ async def past_import( return {**made, "key": key, "cover": cover, "payload": out} -# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 +# 166-4 견적서 파일 길 · 707-1 경쟁사 내역 줄 불러오기 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) +router.include_router(import_router) # 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py new file mode 100644 index 000000000..181dca087 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -0,0 +1,190 @@ +"""B09 경쟁사 내역 줄 불러오기 길(PLAN 707-1) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본). + + POST /api/projects/{id}/estimation/import/bill (multipart: file | file_key · user?) + 읽기 + 미리 보기 — 파일 = 프로젝트 `B09_Estimation/v1/import/{표지}/` + (file_key = 표지 · 같은 파일 다시 씀) → {file_key, 파일, 꼴, 시트, 묶음, 항목, 합계, + 조달수수료율, 합계줄, 율줄, 같은이름, 남는자동, 이음} · + user = 화면 캐시(JSON 글 · 없으면 저장 값) · DRM = 400 「읽을 수 없음」 + POST /api/projects/{id}/estimation/import/bill/apply {file_key, mode, attach, drop, user?} + 짜기(`B09_Estimation_Bill_Import.apply`) → + {user(새 캐시), 알림, 어긋남, 엑셀합계, payload(그 캐시로 셈)} · + 이음 = 읽을 수 있는 층(개인 → 회사 → 시스템)에서 같은 파일 이름으로 들여온 실무자료 사전조합 + (본뜬 「HP-… 제N호표」) → 서고 사본(층 쓰기 0 · 프로젝트 사본만) · 대가 N 줄 연결. +`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). +""" + +from __future__ import annotations + +import asyncio +import json +import re +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from pydantic import BaseModel, Field + +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B09_Estimation import B09_Estimation_Bill_Import as importer +from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Store as store +from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete.M02_Item_Snapshot import importer as who_of + +router = APIRouter() +PATH = "/{project_id}/estimation/import/bill" +IMPORT = "import" +MAX_BYTES = 50 * 1024 * 1024 +_TAG = re.compile(r"^[0-9a-f]{8}$") +_MARK = re.compile(r"^HP-\d{6} 제(\d+)호표$") + + +def _saved(root: Path, key: str) -> Path: + """file_key → 둔 파일 — 없으면 404.""" + folder = store.folder(root) / IMPORT / key + files = ( + [f for f in folder.iterdir() if f.is_file()] if _TAG.match(key) and folder.is_dir() else [] + ) + if not files: + raise HTTPException(status_code=404, detail="불러온 파일이 없음 — 다시 고름") + return files[0] + + +def _keep(root: Path, name: str, data: bytes) -> str: + key = importer.tag_of(data) + folder = store.folder(root) / IMPORT / key + folder.mkdir(parents=True, exist_ok=True) + for old in folder.iterdir(): + old.unlink() + (folder / (store.safe_name(name) or "내역.xlsx")).write_bytes(data) + return key + + +def _parse(path: Path) -> dict[str, Any]: + try: + return read_bill(path.name, path.read_bytes()) + except BillExcelError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +def _draft(raw: Any) -> dict[str, Any] | None: + if raw in (None, ""): + return None + try: + user = json.loads(raw) if isinstance(raw, str) else raw + except ValueError as error: + raise HTTPException(status_code=400, detail="user 가 JSON 이 아님") from error + return store.clean_user(user) if isinstance(user, dict) else None + + +def _found(session: dict[str, Any], name: str, numbers: set[int]) -> dict[int, tuple]: + """대가 번 → (층, 주인, 사전조합 키) — 같은 파일 이름 실무자료 본뜬 · 앞 층이 이김.""" + out: dict[int, tuple] = {} + for layer in ("personal", "company", "system"): + try: + got = access.grant(session, layer) + except HTTPException: # 회사 없음 등 — 그 층 건너뜀 + continue + for key, origin, _raw in hopyo_build._origins(refs.folder_of(got.layer, got.owner)): + hit = _MARK.match(str(origin.get("키") or "")) + no = int(hit.group(1)) if hit else None + if origin.get("파일") == name and no in numbers and no not in out: + out[no] = (got.layer, got.owner, key) + return out + + +def _links(root: Path, found: dict[int, tuple], who: Any) -> dict[int, dict[str, Any]]: + folders = {} + for no, (layer, owner, key) in found.items(): + try: + folders[no] = library.import_copy(root, "precombo", layer, owner, key, who)["폴더"] + except (LookupError, ValueError): # 원본 없음 · 모양 틀림 = 고정 단가로 + continue + libs = (daega_inputs(root).get("libs") or {}) if folders else {} + return { + no: {"tab": "dg", "key": libs[name], "lib": name} + for no, name in folders.items() + if libs.get(name) + } + + +def _numbers(parsed: dict[str, Any]) -> set[int]: + return { + int(h["번"]) + for line in parsed["줄"] + if (h := line.get("호표") or {}).get("종류") == "대가" and "번" in h + } + + +@router.post(PATH) +async def import_bill( + project_id: UUID, + file: UploadFile | None = File(None), + file_key: str | None = Form(None), + user: str | None = Form(None), + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 + + root = await _project_root(project_id) + if file is not None: + data = await file.read(MAX_BYTES + 1) + if len(data) > MAX_BYTES: + raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)") + file_key = await asyncio.to_thread(_keep, root, file.filename or "", data) + if not file_key: + raise HTTPException(status_code=400, detail="파일을 고름") + path = _saved(root, file_key) + parsed = await asyncio.to_thread(_parse, path) + rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"] + found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) + return { + "file_key": file_key, + "파일": path.name, + **importer.preview(parsed, rows), + "이음": len(found), + } + + +class ApplyBody(BaseModel): + file_key: str = Field(min_length=8, max_length=8) + mode: str = "take" + attach: bool = True + drop: bool = False + user: dict[str, Any] | None = None + + +@router.post(PATH + "/apply") +async def apply_bill( + project_id: UUID, body: ApplyBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 + + if body.mode not in importer.MODES: + raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나") + root = await _project_root(project_id) + path = _saved(root, body.file_key) + parsed = await asyncio.to_thread(_parse, path) + 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)) + made, report = importer.apply( + parsed, draft, rows, body.file_key, body.mode, body.attach, body.drop, links + ) + made = store.clean_user(made) + out = await payload(project_id, root, made, True) + lines = out["sheets"]["bill"]["rows"] + return { + "user": made, + "알림": {k: v for k, v in report.items() if k != "줄"}, + "어긋남": importer.differences(parsed, report, lines), + "엑셀합계": parsed["합계"], + "payload": out, + } diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index e228e2de4..b3305c860 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -152,9 +152,22 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: return None +def _fixed_cells(row: dict[str, Any]) -> dict[str, Any]: + """707-1 고정 단가 칸 `price` {노 · 재 · 경 수(빈 = 0)} · 불러온 수량 `src_qty` — 있는 것만.""" + out: dict[str, Any] = {} + if isinstance(row.get("price"), dict): + out["price"] = { + p: _num(row["price"].get(p)) or 0.0 for p in ("labor", "material", "expense") + } + if _num(row.get("src_qty")) is not None: + out["src_qty"] = _num(row["src_qty"]) + return out + + def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: """184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 · - 자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키.""" + 자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키 · + 고정 단가 칸 · 불러온 수량(707-1) = 항목 줄만 · 새 줄 비고 `note` 글.""" rows, seen = [], set() for row in raw.get("rows") if isinstance(raw.get("rows"), list) else []: key = row.get("key") if isinstance(row, dict) else None @@ -174,6 +187,10 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: out.update({k: _text(row.get(k)) for k in ("name", "spec", "unit")}) out["qty"] = None if group else _num(row.get("qty")) out["ref"] = None if group else _clean_ref(row.get("ref")) + if not group: + out.update(_fixed_cells(row)) + if _text(row.get("note")): + out["note"] = _text(row["note"]) else: for k in ("name", "spec", "unit"): if k in row: @@ -182,6 +199,7 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]: out["qty"] = _num(row["qty"]) if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None): out["ref"] = _clean_ref(row["ref"]) + out.update(_fixed_cells(row)) rows.append(out) given = raw.get("gone") if isinstance(raw.get("gone"), list) else [] gone = list( diff --git a/resources/tester/test_707_1_bill_import.py b/resources/tester/test_707_1_bill_import.py new file mode 100644 index 000000000..e726f8a3d --- /dev/null +++ b/resources/tester/test_707_1_bill_import.py @@ -0,0 +1,187 @@ +"""707-1 경쟁사 내역 줄 불러오기 — 읽기(`Bill_Excel`) · 짜기(`Bill_Import`) · 고정 단가 칸 셈 거울. + +- 실무 xlsx(STmate 기번3 · 영월 · 봉화 · EST 거창) = 빠짐 0 · 불러온 뒤 직접비 합 = 엑셀 합 · + 어긋난 줄 0 · 조달수수료 = 율(줄로 안 실음) · DRM = 「읽을 수 없음」 +- 수량 갈래 (가) take = 붙인 자동 줄 수량 엑셀 · auto_qty = B08 / (나) price = B08 · src_qty = 엑셀 +- 두 번 셈 막음 = 이름 같은 자동 줄에 붙임(한 번만 셈) · drop = 남은 자동 줄 지움 · + 같은 파일 다시 = 갈아 끼움 · 조달수수료율 = 빈 때만 +- 고정 단가 칸 = 연결이 단가를 못 낼 때만 · 다듬기 · TS 짝 같은 값 +""" + +import json +import re +import subprocess +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 B09_Estimation import B09_Estimation_Bill_Import as imp # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" / "실무문서" +FILES = { + "기번3": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", + "영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", + "봉화": K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", + "거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", +} # fmt: skip +#: (꼴, 항목 수, 엑셀 직접비 합) +WANT = { + "기번3": ("STmate", 136, 710359136), + "영월": ("STmate", 108, 767148700), + "봉화": ("STmate", 77, 781283415), + "거창": ("EST", 60, 384916227), +} +EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}} +ITEMS = [ + {"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 10, + "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "earth:9", "section": "1.6", "name": "노면 정리X", "unit": "㎡", "qty": 3, + "ref": {"tab": "sg_list", "key": "D01341"}}, +] # fmt: skip +INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"D01341": { + "labor": 1, "material": 0, "expense": 0}}} # fmt: skip +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +@pytest.fixture(scope="module") +def parsed(): + return {k: read_bill(p.name, p.read_bytes()) for k, p in FILES.items()} + + +def _rows(inputs, user): + return compute_estimation(inputs, user, None, {})["bill"] + + +def _load(got, inputs=EMPTY, user=None, **kw): + user = user or {} + made, report = imp.apply(got, user, _rows(inputs, user)["rows"], "abcd1234", **kw) + made = store.clean_user(made) + return made, report, _rows(inputs, made) + + +@pytest.mark.parametrize("name", list(FILES)) +def test_실무_내역_빠짐_0_합계_엑셀과_같음(parsed, name): + got = parsed[name] + kind, items, total = WANT[name] + assert (got["꼴"], sum(1 for x in got["줄"] if not x["묶음"])) == (kind, items) + assert got["합계"]["직접"] == total and got["조달수수료율"] == 0.54 + made, report, bill = _load(got) + assert report["실음"] == len(got["줄"]) and report["붙임"] == 0 + assert bill["totals"]["direct"]["sum"] == total and bill["totals"]["direct"]["blank"] == 0 + assert imp.differences(got, report, bill["rows"]) == [] + assert made["estimate"]["procurement_fee_pct"] == 0.54 + assert max(r["level"] for r in bill["rows"]) <= 5 + keys = {r["key"] for r in bill["rows"]} + assert all(k in keys for k in report["줄"].values()) # 실은 줄 모두 보임 + # 관급 = 제외(관급자재대 아래 · 직접비 밖) + assert bill["totals"]["owner"]["net_material"] > 0 + + +def test_EST_비고_참조_STmate_코드(parsed): + est = [x["호표"] for x in parsed["거창"]["줄"] if not x["묶음"]] + assert {"종류": "산근", "번": 46} in est and {"종류": "대가", "번": 8} in est + st = next(x for x in parsed["기번3"]["줄"] if x["이름"] == "규준틀설치") + assert st["호표"] == {"종류": "대가", "번": 5, "코드": "B00194"} + + +def test_DRM_읽을_수_없음(): + with pytest.raises(BillExcelError, match="읽을 수 없음"): + read_bill("a.xlsx", b"\x9b DRMONE" + b"\x00" * 300) + with pytest.raises(BillExcelError, match="엑셀"): + read_bill("a.xlsx", b"PK\x03\x04 broken") + + +def test_가_넘겨받기_붙인_자동_줄_수량_엑셀(parsed): + made, report, bill = _load(parsed["기번3"], INPUTS, mode="take") + rows = {r["key"]: r for r in bill["rows"]} + assert report["붙임"] == 1 # 절토(토사) 첫 줄만(1공구) + cut = rows["earth:4"] + assert (cut["qty"], cut["auto_qty"], cut["src_qty"]) == (5675, 10, 5675) + assert cut["fixed"] and cut["sum"] == 11003825 # 엑셀 단가 · 연결 덮개 null + assert cut["parent"].startswith("hand:xabcd1234-") # 불러온 자리로 옮김 + assert sum(1 for r in bill["rows"] if r["name"] == "절토(토사)") == 3 # 두 번 셈 없음 + # 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X) 그대로 = 직접비 = 엑셀 + 3 + assert bill["totals"]["direct"]["sum"] == 710359136 + 3 + + +def test_나_단가만_B08_이김_비교_칸(parsed): + made, report, bill = _load(parsed["기번3"], INPUTS, mode="price") + cut = {r["key"]: r for r in bill["rows"]}["earth:4"] + assert (cut["qty"], cut["src_qty"], "auto_qty" in cut) == (10, 5675, False) + assert cut["sum"] == 1939 * 10 # 엑셀 단가 × B08 수량 + diff = imp.differences(parsed["기번3"], report, bill["rows"]) + assert [d["까닭"] for d in diff] == ["수량 = B08(단가만)"] + + +def test_붙이지_않음_지우기_다시_부르기(parsed): + _, report, bill = _load(parsed["기번3"], INPUTS, attach=False, drop=True) + assert (report["붙임"], report["지움"]) == (0, 2) + assert bill["totals"]["direct"]["sum"] == 710359136 + made, _, _ = _load(parsed["기번3"], INPUTS) + made["bill"]["rows"].append({"key": "hand:mine", "parent": "hand:xabcd1234-00007", + "name": "내 줄", "qty": 1, "ref": None}) # fmt: skip + again, report, bill = _load(parsed["기번3"], INPUTS, user=made) + keys = [e["key"] for e in again["bill"]["rows"]] + assert len(keys) == len(set(keys)) and report["붙임"] == 1 + assert bill["totals"]["direct"]["sum"] == 710359136 + 3 # 갈아 끼움 · 두 번 안 셈 + mine = next(e for e in again["bill"]["rows"] if e["key"] == "hand:mine") + assert "parent" not in mine # 내 줄 = 남음(맨 위) + + +def test_조달수수료율_빈_때만(parsed): + made, _, _ = _load(parsed["거창"], user={"estimate": {"procurement_fee_pct": 1.0}}) + assert made["estimate"]["procurement_fee_pct"] == 1.0 + + +def test_고정_단가_칸_다듬기_연결이_이김(): + raw = {"bill": {"v": 2, "rows": [ + {"key": "hand:a", "name": "a", "qty": 2, "ref": {"tab": "sg", "key": "SG:1"}, + "price": {"labor": 5, "material": "x"}, "src_qty": 3, "note": "대가 5호표"}, + {"key": "earth:4", "price": {"expense": 7}, "src_qty": "y"}, + ]}} # fmt: skip + user = store.clean_user(raw) + hand, auto = user["bill"]["rows"] + assert hand["price"] == {"labor": 5, "material": 0, "expense": 0} and hand["src_qty"] == 3 + assert hand["note"] == "대가 5호표" and auto == {"key": "earth:4", "price": { + "labor": 0, "material": 0, "expense": 7}} # fmt: skip + books = {"sg_list": [{"no": 1, "key": "SG:1", "labor": 100, "material": 0, "expense": 0, + "sum": 100}]} # fmt: skip + rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, books)["bill"]["rows"]} + assert (rows["hand:a"]["sum"], rows["hand:a"].get("fixed")) == (200, None) # 연결 이김 + assert rows["earth:4"]["sum"] == 10 and "fixed" not in rows["earth:4"] # 자동 연결 이김 + rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, {})["bill"]["rows"]} + assert (rows["hand:a"]["sum"], rows["hand:a"]["fixed"]) == (10, True) + assert rows["hand:a"]["note"] == "대가 5호표" + + +def test_거울_TS_가_같은_값(parsed): + calc = "".join( + IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) + for n in ("Calc_Base", "Calc_Bill") + ) + users = [ + _load(parsed["거창"], INPUTS, mode="take")[0], + _load(parsed["영월"], INPUTS, mode="price")[0], + {"bill": {"v": 2, "rows": [{"key": "earth:4", "ref": None, "price": {"labor": 3}}, + {"key": "hand:a", "qty": 2, "price": {"material": 4}}]}}, + ] # fmt: skip + body = ( + f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" + f"\nconst us = {json.dumps(users, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, {}))));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [compute_estimation(INPUTS, u, None, {}) for u in users] From 02886d5d4d9a07f33565e784f3739536e0ee9127 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 15:50:19 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20[=EC=97=91=EC=85=80=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0]=20=EC=B0=BD=20?= =?UTF-8?q?=E2=80=94=20=EC=88=98=EB=9F=89=20=EA=B0=88=EB=9E=98=20=C2=B7=20?= =?UTF-8?q?=EB=91=90=20=EB=B2=88=20=EC=85=88=20=EB=A7=89=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=96=B4=EA=B8=8B=EB=82=9C=20=EC=A4=84=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20(PLAN=20707-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계내역서 도구 줄 끝 단추 → 파일 고름 → 창 한 번(넘겨받기 · 단가만 · 이름 같은 자동 줄 붙임 · 남는 자동 줄 지움) → [불러오기] = 화면 캐시(저장 안 함 · [저장]해야 남음) - 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록 · 없으면 닫고 알림 · DRM = 「읽을 수 없음」 - 단가만 갈래 = 불러온 수량과 10 % 넘게 다른 수량 칸 붉게 · 풍선 = 불러온 수량 - 시험 243-1(표 만드는 길 목록 = 불러오기 창 표 더함 · 보기 전용) · 200-2(도구 줄 배선 = 불러오기 단추 끝) 고침 — 낡은 글자 대조 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Import.ts | 169 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.css | 13 ++ B09_Estimation/B09_Estimation_UI_Shell.ts | 15 +- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 14 +- .../tester/test_200_2_b09_bill_level_bar.py | 3 +- .../tester/test_243_1_double_click_tables.py | 9 +- 6 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Import.ts diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts new file mode 100644 index 000000000..27e6f2d85 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -0,0 +1,169 @@ +/* ============================================================================= + * B09_Estimation_UI_Import.ts + * B09 설계내역서 [엑셀 내역 불러오기](PLAN 707-1) — 경쟁사 내역 엑셀(STmate · EST) 줄을 화면 캐시로. + * + * 누름 = 파일 고름 → 서버 읽기 · 미리 보기(`POST …/estimation/import/bill`) → 창 하나에서 한 번 고름 + * 수량 갈래(가 넘겨받기 · 나 단가만) · 이름 같은 자동 줄에 붙임(두 번 셈 막음) · 남는 자동 줄 지움 + * [불러오기] = `POST …/import/bill/apply` → 받은 원가 자료 · 새 캐시(내역 덮개 · 조달수수료율) → [저장]해야 남음 + * 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림 + * DRM 파일 = 서버 400 「읽을 수 없음」 알림 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { pickFiles } from "@ui/ui_template_drop"; +import { + createButton, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import type { Row } from "./B09_Estimation_Calc_Base"; + +const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR"); + +async function post(projectId: string, path: string, body: BodyInit, json = false): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/import/${path}`, { + method: "POST", + credentials: "include", + ...(json ? { headers: { "Content-Type": "application/json" } } : {}), + body, + }); + const got = (await response.json().catch(() => null)) as Row | null; + if (!response.ok || !got) { + const detail = got?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return got; +} + +/** 받은 캐시 = 지금 캐시 + 내역 덮개 · 조달수수료율(서버가 빈 때만 채움) */ +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 }; +} + +function differences(rows: Row[]): HTMLElement { + return createDataTable({ + columns: [ + { key: "행", label: "엑셀 행", kind: "number", width: 56 }, + { key: "이름", label: "명칭", width: 200 }, + { key: "엑셀", label: "엑셀 금액", kind: "number", width: 110 }, + { key: "우리", label: "우리 금액", kind: "number", width: 110 }, + { key: "까닭", label: "까닭", width: 160 }, + ], + rows, + }).root; +} + +/** [엑셀 내역 불러오기] 단추 — user = 지금 화면 캐시 · done = 받은 원가 자료 + 새 캐시 */ +export function createBillImport( + projectId: string, + user: () => Row, + done: (payload: Row, user: Row) => void, +): HTMLButtonElement { + const open = (seen: Row, mode: { value: string }): void => { + const total = seen.합계 || {}; + const lines = [ + `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, + `엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), + seen.이음 + ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` + : "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)", + ]; + const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { + radio: "b09-import-mode", + value: "take", + checked: mode.value === "take", + }); + const price = createCheckbox( + "단가만 — 수량 산출 수량을 씀(불러온 수량은 비교 · 차이 큰 줄 붉게)", + { + radio: "b09-import-mode", + value: "price", + checked: mode.value === "price", + }, + ); + const attach = createCheckbox(`이름이 같은 자동 줄 ${seen.같은이름} 개에 붙임(두 번 셈 막음)`, { + checked: seen.같은이름 > 0, + disabled: !seen.같은이름, + }); + const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, { + disabled: !seen.남는자동, + }); + const run = createButton({ label: "불러오기", variant: "filled" }); + run.dataset.b09ImportRun = ""; + openModal({ + title: "엑셀 내역 불러오기", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, actions) => { + body.append( + ...lines.map((t) => createText(t, "muted", 14)), + el("div", { className: "b09-import__choices", children: [take.root, price.root] }), + el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }), + ); + actions.append(run); + run.addEventListener("click", async () => { + mode.value = price.input.checked ? "price" : "take"; + const draft = user(); + const ask = { + file_key: seen.file_key, + mode: mode.value, + attach: attach.input.checked, + drop: drop.input.checked, + user: draft, + }; + showLoadingOverlay(); + try { + const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); + done(got.payload, merged(draft, got.user)); + const n = got.알림 || {}; + const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; + const off: Row[] = got.어긋남 || []; + if (!off.length) { + close(); + return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); + } + run.remove(); + body.replaceChildren( + createText(head, "success", 14), + createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), + differences(off), + ); + } catch (error) { + showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error"); + } finally { + hideLoadingOverlay(); + } + }); + }, + }); + }; + const mode = { value: "take" }; + const button = createButton({ + label: "엑셀 내역 불러오기", + variant: "ghost", + onClick: () => + pickFiles({ accept: ".xls,.xlsx" }, ([file]) => { + const form = new FormData(); + form.append("file", file); + form.append("user", JSON.stringify(user())); + showLoadingOverlay(); + post(projectId, "bill", form) + .then((seen) => open(seen, mode)) + .catch((error: unknown) => + showToast(`읽지 못함 — ${error instanceof Error ? error.message : error}`, "error"), + ) + .finally(hideLoadingOverlay); + }), + }); + button.dataset.b09Import = ""; + return button; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 88d873c46..c348665ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -301,3 +301,16 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { .ui-side-panels-row > .ui-side-panels.b09-store__side { flex: none; } + +/* 707-1 엑셀 내역 불러오기 창 — 고르기 줄 묶음 · 단가만 갈래 수량 차이 큰 칸(불러온 수량과 10 % 넘게 다름) */ +.b09-import__choices { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +.b09-estimation .ui-data-table__table tbody td.is-qty-gap { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 25811e184..5f236405b 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -42,6 +42,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; +import { createBillImport } from "./B09_Estimation_UI_Import"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { DRAFT, @@ -285,7 +286,7 @@ export function renderB09Estimation(root: HTMLElement): void { ); if (range) unpickOnRelease(table.root); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; - if (level) bars.setTools(level.tools); + if (level) bars.setTools(importer ? [...level.tools, importer] : level.tools); // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; @@ -301,6 +302,18 @@ export function renderB09Estimation(root: HTMLElement): void { if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; + // 707-1 [엑셀 내역 불러오기] = 설계내역서 도구 줄 끝 · 받은 캐시 → 원가 자료와 함께 다시 셈(저장 안 함) + const importer = projectId + ? createBillImport( + projectId, + () => user, + (next, made) => { + writeState("b09-estimation", made, projectId); + apply(next, false); + }, + ) + : null; + const apply = (next: Row, fromServer: boolean): void => { payload = next; const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 2f02ab6c2..e2362ed8d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -12,6 +12,7 @@ * 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) · * 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력 * 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결) + * 불러온 수량(707-1 src_qty) = 수량 칸 풍선 · 지금 수량과 10 % 넘게 다르면 붉은 칸(단가만 갈래) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -31,6 +32,14 @@ import { attachBillStore } from "./B09_Estimation_UI_Store"; const TEXT_KEYS = ["name", "spec", "unit"]; /** 폴더 줄 수식 칸 — 자손 합(셈) */ const SUM_KEYS = ["sum", "labor", "material", "expense"]; +/** 707-1 불러온 수량과 지금 수량 차이 — 이 비율 넘으면 붉은 칸 */ +const QTY_GAP = 0.1; +const qtyGap = (r: Row | undefined): boolean => + !!r && + r.kind === "item" && + typeof r.src_qty === "number" && + typeof r.qty === "number" && + Math.abs(r.qty - r.src_qty) > QTY_GAP * Math.abs(r.src_qty); export const buildBill: TabBuild = (ctx): BillTable => { const { sheets, user, setUser } = ctx; @@ -99,7 +108,8 @@ export const buildBill: TabBuild = (ctx): BillTable => { key === "connect" && r && linkable(raw[i]) ? `ui-data-table__lamp ui-data-table__lamp--only b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}` : ""; - return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null; + const gap = key === "qty" && qtyGap(r) ? "is-qty-gap" : ""; + return [lamp, edited(r, key) ? "is-edited" : "", gap].filter(Boolean).join(" ") || null; }, tip: (i, key) => { const r = rows[i]; @@ -109,6 +119,8 @@ export const buildBill: TabBuild = (ctx): BillTable => { return linkText(raw[i]) + back; } if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)"; + if (key === "qty" && typeof r?.src_qty === "number" && !edited(r, key)) + return `불러온 수량 ${r.src_qty}`; if (!r || !edited(r, key)) return null; const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`]; return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`; diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 23e35fbb5..1b01a8020 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -200,7 +200,8 @@ def test_셸_배선(): assert ( "bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) - assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 + # 229-1 단추 = 보조 탭 줄 · 707-1 [엑셀 내역 불러오기] = 그 끝 + assert "if (level) bars.setTools(importer ? [...level.tools, importer] : level.tools);" in src assert ( "const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 918a3df95..7ea0db851 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -112,15 +112,18 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): calls = [ p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") ] - # 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침) + # 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · + # 702-1 펼침 표 = 제 표(두 번 눌러 고침) assert calls == [ + "B09_Estimation_UI_Import.ts", "B09_Estimation_UI_Popup.ts", "B09_Estimation_UI_Shell.ts", "B09_Estimation_UI_Tabs_Bill_Fold.ts", ] - fold = (ROOT / "B09_Estimation" / calls[2]).read_text("utf-8") + assert "editable" not in (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") + fold = (ROOT / "B09_Estimation" / calls[3]).read_text("utf-8") assert "editOnDouble: true," in fold - shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8") + shell = (ROOT / "B09_Estimation" / calls[2]).read_text("utf-8") assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳 builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") assert "editOnDouble: spec.editOnDouble ?? true," in builders From c33f6837f2940ff507043af4ebe642fe6c7f09a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:13:34 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(B09=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C):=20=EC=97=91=EC=85=80=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=9D=A0=20=E2=80=94=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EB=A7=8C=20=EB=B9=88=EC=B9=B8=20=C2=B7=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EC=A4=84=20=EC=98=A4=EB=B6=99=EC=9D=8C=20=C2=B7=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=C2=B7=20=ED=91=9C=20=EB=86=92=EC=9D=B4=20(PLAN=207?= =?UTF-8?q?07-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음) - 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만 - 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음) - 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임 - 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Import.py | 27 +++++++++----- .../B09_Estimation_Router_Import.py | 10 +++-- B09_Estimation/B09_Estimation_UI_Import.ts | 37 +++++++++++-------- resources/tester/test_707_1_bill_import.py | 21 ++++++++--- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Import.py b/B09_Estimation/B09_Estimation_Bill_Import.py index b2b8b6f2b..077c3fb62 100644 --- a/B09_Estimation/B09_Estimation_Bill_Import.py +++ b/B09_Estimation/B09_Estimation_Bill_Import.py @@ -1,15 +1,16 @@ """B09 경쟁사 내역 줄 불러오기 짜기(PLAN 707-1) — 읽은 내역(`Bill_Excel.read_bill`) → 설계내역서 덮개 v2(`user.bill` · 계약 = `B09_Estimation_Calc_Bill` 머리). 파일 · 저장 안 만짐(부르는 쪽 몫). -먼저 싣고 나중 잇기 — 빠짐 0: +먼저 싣고 나중 잇기 — 빠짐 0 · 실은 줄 = 표 차례에서 관급자재대(`sec:G`) 앞: 묶음 줄 → 새 묶음(`hand:x{표지}-{행}` · 제외 맨 위 = 관급자재대 `sec:G` 아래) 항목 줄 → 새 줄 + 고정 단가 칸 `price`(엑셀 단가) + `src_qty`(엑셀 수량) + `note`(엑셀 비고) · 연결 = 같은 책 일위대가 사본(`links` {대가 번: ref}) — 없으면 고정 단가로 금액 같은 파일을 다시 부르면(같은 표지) 앞서 실은 줄을 갈아 끼움. 수량 갈래 — `take`(가 넘겨받기) 붙인 자동 줄 수량 = 엑셀(B08 = `auto_qty` 로 나란히) · - `price`(나 단가만) 붙인 자동 줄 수량 = B08 · 엑셀 수량 = 비교 칸만. 새 줄 수량 = 늘 엑셀. + `price`(나 단가만) 붙인 자동 줄 수량 = B08(B08 수량 없으면 엑셀) · 엑셀 수량 = 비교 칸. + 새 줄 수량 = 늘 엑셀. 두 번 셈 막음 — `attach` = 이름(빈칸 뺌)이 같은 자동 항목 줄에 붙임(그 줄 = 불러온 자리로 옮기고 - 단가 = 연결 · 고정 단가 칸 · 자재 줄 · 조달수수료 뺌) · + 단가 = 연결 · 고정 단가 칸 · 자재 줄 · 운반 줄(이름이 자재와 같음 「모래」) · 조달수수료 뺌) · `drop` = 안 붙은 자동 항목 줄 지움(`gone`). 조달수수료율 = 사용자 값이 비었을 때만 엑셀 율. """ @@ -24,6 +25,8 @@ from typing import Any MODES = ("take", "price") FIELDS = ("name", "spec", "unit", "qty", "ref") PARTS = ("labor", "material", "expense") +#: 붙이지도 지우지도 않는 자동 줄 머리 — 자재(대비표 주인) · 조달수수료(운반 = 지움만) +SKIP = ("mat", "fee") def tag_of(data: bytes) -> str: @@ -73,19 +76,21 @@ def _price(line: dict[str, Any]) -> dict[str, float]: return {p: float(line["단가"].get(p) or 0) for p in PARTS} -def _autos(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - """붙일 수 있는 자동 항목 줄 — 자재 줄 · 조달수수료 뺌.""" +def _autos(rows: list[dict[str, Any]], attach: bool = False) -> list[dict[str, Any]]: + """지울 수 있는 자동 항목 줄(자재 · 조달수수료 뺌) · + `attach` = 붙일 수 있는 줄(운반도 뺌 — 「모래」 = 자재대 줄 이름).""" + skip = (*SKIP, "haul") if attach else SKIP return [ r for r in rows - if r["kind"] == "item" and r.get("auto") and r["key"] != "fee" and r["key"][:4] != "mat:" + if r["kind"] == "item" and r.get("auto") and r["key"].split(":")[0] not in skip ] def matches(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, str]: """{불러온 줄 행(글): 자동 줄 키} — 이름(빈칸 뺌)이 같은 첫 남은 자동 줄 · 제외 줄 뺌.""" free: dict[str, list[str]] = {} - for row in _autos(rows): + for row in _autos(rows, attach=True): free.setdefault(_flat(row["name"]), []).append(row["key"]) out = {} for line in parsed["줄"]: @@ -144,6 +149,7 @@ def apply( kept.append(e) bill["rows"] = kept pairs = matches(parsed, rows) if attach else {} + by_key = {r["key"]: r for r in rows} keys = {line["행"]: f"{head}{line['행']:05d}" for line in parsed["줄"]} put, report = [], {"실음": 0, "붙임": 0, "지움": 0, "이음": 0, "줄": {}} for line in parsed["줄"]: @@ -162,8 +168,8 @@ def apply( else: bill["rows"].remove(entry) entry.update({**cells, "ref": link}) - if mode == "take": - entry["qty"] = line["수량"] + if mode == "take" or by_key[auto].get("auto_qty", by_key[auto]["qty"]) is None: + entry["qty"] = line["수량"] # 단가만 · B08 수량 없음 = 엑셀 entry.pop("parent", None) put.append({**entry, **({"parent": parent} if parent else {})}) report["붙임"] += 1 @@ -185,7 +191,8 @@ def apply( bill["gone"].append(row["key"]) report["지움"] += 1 bill["rows"] = [e for e in bill["rows"] if e["key"] not in bill["gone"]] - bill["rows"] += put + at = next((n for n, e in enumerate(bill["rows"]) if e["key"] == "sec:G"), len(bill["rows"])) + bill["rows"][at:at] = put # 불러온 공종 = 관급자재대 앞(엑셀 차례) out = {**user, "bill": bill} estimate = dict(user.get("estimate") or {}) if estimate.get("procurement_fee_pct") is None and parsed.get("조달수수료율") is not None: diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index 181dca087..259bb39cf 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -66,9 +66,9 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key -def _parse(path: Path) -> dict[str, Any]: +def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return read_bill(path.name, path.read_bytes()) + return read_bill(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error @@ -137,11 +137,13 @@ async def import_bill( data = await file.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)") + parsed = await asyncio.to_thread(_parse, file.filename or "", data) # 읽힌 파일만 둠 file_key = await asyncio.to_thread(_keep, root, file.filename or "", data) if not file_key: raise HTTPException(status_code=400, detail="파일을 고름") path = _saved(root, file_key) - parsed = await asyncio.to_thread(_parse, path) + if file is None: + parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"] found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) return { @@ -170,7 +172,7 @@ 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) + parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) 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)) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 27e6f2d85..93151d2ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -51,11 +51,11 @@ function merged(user: Row, made: Row): Row { function differences(rows: Row[]): HTMLElement { return createDataTable({ columns: [ - { key: "행", label: "엑셀 행", kind: "number", width: 56 }, - { key: "이름", label: "명칭", width: 200 }, - { key: "엑셀", label: "엑셀 금액", kind: "number", width: 110 }, - { key: "우리", label: "우리 금액", kind: "number", width: 110 }, - { key: "까닭", label: "까닭", width: 160 }, + { key: "행", label: "엑셀 행", kind: "number", width: 48 }, + { key: "이름", label: "명칭", width: 150 }, + { key: "엑셀", label: "엑셀 금액", kind: "number", width: 96 }, + { key: "우리", label: "우리 금액", kind: "number", width: 96 }, + { key: "까닭", label: "까닭", width: 130 }, ], rows, }).root; @@ -123,20 +123,27 @@ export function createBillImport( showLoadingOverlay(); try { const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); + // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 + close(); done(got.payload, merged(draft, got.user)); const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; - if (!off.length) { - close(); - return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); - } - run.remove(); - body.replaceChildren( - createText(head, "success", 14), - createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), - differences(off), - ); + 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)); } catch (error) { showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error"); } finally { diff --git a/resources/tester/test_707_1_bill_import.py b/resources/tester/test_707_1_bill_import.py index e726f8a3d..c558fb7c8 100644 --- a/resources/tester/test_707_1_bill_import.py +++ b/resources/tester/test_707_1_bill_import.py @@ -45,6 +45,10 @@ ITEMS = [ "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "earth:9", "section": "1.6", "name": "노면 정리X", "unit": "㎡", "qty": 3, "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": None, + "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "haul:3", "section": "3", "name": "모래", "unit": "㎥", "qty": 2, + "ref": {"tab": "sg_list", "key": "D01341"}}, ] # fmt: skip INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"D01341": { "labor": 1, "material": 0, "expense": 0}}} # fmt: skip @@ -102,14 +106,17 @@ def test_DRM_읽을_수_없음(): def test_가_넘겨받기_붙인_자동_줄_수량_엑셀(parsed): made, report, bill = _load(parsed["기번3"], INPUTS, mode="take") rows = {r["key"]: r for r in bill["rows"]} - assert report["붙임"] == 1 # 절토(토사) 첫 줄만(1공구) + assert report["붙임"] == 2 # 절토(토사) 첫 줄만(1공구) · 암발파 · 운반 「모래」 = 안 붙음 cut = rows["earth:4"] assert (cut["qty"], cut["auto_qty"], cut["src_qty"]) == (5675, 10, 5675) assert cut["fixed"] and cut["sum"] == 11003825 # 엑셀 단가 · 연결 덮개 null assert cut["parent"].startswith("hand:xabcd1234-") # 불러온 자리로 옮김 assert sum(1 for r in bill["rows"] if r["name"] == "절토(토사)") == 3 # 두 번 셈 없음 - # 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X) 그대로 = 직접비 = 엑셀 + 3 - assert bill["totals"]["direct"]["sum"] == 710359136 + 3 + # 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X 3 · 운반 모래 2) 그대로 = 직접비 = 엑셀 + 5 + assert bill["totals"]["direct"]["sum"] == 710359136 + 5 + assert "haul:3" in rows and rows["haul:3"]["parent"] == "sec:3" + tops = [r["key"] for r in bill["rows"] if r["parent"] is None] + assert tops.index("hand:xabcd1234-00006") < tops.index("sec:G") # 1공구 = 관급자재대 앞 def test_나_단가만_B08_이김_비교_칸(parsed): @@ -117,21 +124,23 @@ def test_나_단가만_B08_이김_비교_칸(parsed): cut = {r["key"]: r for r in bill["rows"]}["earth:4"] assert (cut["qty"], cut["src_qty"], "auto_qty" in cut) == (10, 5675, False) assert cut["sum"] == 1939 * 10 # 엑셀 단가 × B08 수량 + blast = {r["key"]: r for r in bill["rows"]}["earth:6"] + assert (blast["qty"], blast["sum"]) == (3889, 43700693) # B08 수량 없음 = 엑셀 수량 diff = imp.differences(parsed["기번3"], report, bill["rows"]) assert [d["까닭"] for d in diff] == ["수량 = B08(단가만)"] def test_붙이지_않음_지우기_다시_부르기(parsed): _, report, bill = _load(parsed["기번3"], INPUTS, attach=False, drop=True) - assert (report["붙임"], report["지움"]) == (0, 2) + assert (report["붙임"], report["지움"]) == (0, 4) # 운반 줄도 지움 assert bill["totals"]["direct"]["sum"] == 710359136 made, _, _ = _load(parsed["기번3"], INPUTS) made["bill"]["rows"].append({"key": "hand:mine", "parent": "hand:xabcd1234-00007", "name": "내 줄", "qty": 1, "ref": None}) # fmt: skip again, report, bill = _load(parsed["기번3"], INPUTS, user=made) keys = [e["key"] for e in again["bill"]["rows"]] - assert len(keys) == len(set(keys)) and report["붙임"] == 1 - assert bill["totals"]["direct"]["sum"] == 710359136 + 3 # 갈아 끼움 · 두 번 안 셈 + assert len(keys) == len(set(keys)) and report["붙임"] == 2 + assert bill["totals"]["direct"]["sum"] == 710359136 + 5 # 갈아 끼움 · 두 번 안 셈 mine = next(e for e in again["bill"]["rows"] if e["key"] == "hand:mine") assert "parent" not in mine # 내 줄 = 남음(맨 위) From 8956a6c9029a3e0698ec231ac9b5975743accc43 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:14:36 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test(707-1):=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=A4=84=20=EC=9D=B4=EC=9D=8C=20=EC=8B=9C=ED=97=98=20=E2=80=94?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EC=B1=85=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9D=B4=EC=9D=8C=20=EB=8B=A8=EA=B0=80=20=C2=B7=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=A8=20=EC=95=8C=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=20=EC=B0=BE=EA=B8=B0(=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=B8=EB=9C=AC?= =?UTF-8?q?=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_707_1_bill_import.py | 52 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_707_1_bill_import.py b/resources/tester/test_707_1_bill_import.py index c558fb7c8..c69512d2c 100644 --- a/resources/tester/test_707_1_bill_import.py +++ b/resources/tester/test_707_1_bill_import.py @@ -27,10 +27,10 @@ from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: K = ROOT / "resources" / "knowledge" / "original" / "실무문서" FILES = { - "기번3": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", + "기번3": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", # noqa: E501 "영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", "봉화": K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", - "거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", + "거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", # noqa: E501 } # fmt: skip #: (꼴, 항목 수, 엑셀 직접비 합) WANT = { @@ -194,3 +194,51 @@ def test_거울_TS_가_같은_값(parsed): ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == [compute_estimation(INPUTS, u, None, {}) for u in users] + + +def test_이음_같은_책_일위대가_이음_단가_어긋남_알림(parsed): + got = parsed["기번3"] + link = {"tab": "dg", "key": "DG:5", "lib": "precombo@personal@1-3@SC-000001"} + made, report, _ = _load(got, links={5: link}) + lines = [e for e in made["bill"]["rows"] if e.get("ref")] + assert report["이음"] == 3 and len(lines) == 3 # 대가 5호표(종단규준틀) 공구마다 + assert lines[0]["ref"] == link and lines[0]["price"]["labor"] == 21936 # 고정 단가 곁에 남김 + same = {"daega": [{"no": 5, "key": "DG:5", "labor": 21936, "material": 3885, "expense": 0, + "sum": 25821}]} # fmt: skip + rows = compute_estimation(EMPTY, made, None, same)["bill"]["rows"] + assert imp.differences(got, report, rows) == [] # 실무자료 사본 = 엑셀 단가 그대로 + other = {"daega": [{**same["daega"][0], "labor": 30000, "sum": 33885}]} + rows = compute_estimation(EMPTY, made, None, other)["bill"]["rows"] + diff = imp.differences(got, report, rows) + assert len(diff) == 3 and {d["까닭"] for d in diff} == {"이음 단가 = 우리 셈"} + # 사본이 없어짐(연결이 단가를 못 냄) = 고정 단가 + rows = compute_estimation(EMPTY, made, None, {})["bill"]["rows"] + assert imp.differences(got, report, rows) == [] + + +def test_이음_찾기_같은_파일_이름_본뜬만(tmp_path, monkeypatch): + from B09_Estimation import B09_Estimation_Router_Import as route + + folder = tmp_path / "precombo" + folder.mkdir() + docs = { + "SC-000001": {"층": "실무자료", "키": "HP-000001 제5호표", "파일": "a.xlsx"}, + "SC-000002": {"층": "실무자료", "키": "HP-000001 제5호표 2째 줄", "파일": "a.xlsx"}, + "SC-000003": {"층": "실무자료", "키": "HP-000002 제8호표", "파일": "b.xlsx"}, + "SC-000004": {"층": "실무자료", "키": "HP-000001 제9호표", "파일": "a.xlsx"}, + } + for key, origin in docs.items(): + (folder / f"{key}.json").write_text(json.dumps({"본뜬": origin}), encoding="utf-8") + + class Got: + layer, owner = "personal", "1/3" + + def grant(session, layer): + if layer != "personal": + raise route.HTTPException(status_code=409) + return Got + + monkeypatch.setattr(route.access, "grant", grant) + monkeypatch.setattr(route.refs, "folder_of", lambda layer, owner: tmp_path) + found = route._found({}, "a.xlsx", {5, 8}) + assert found == {5: ("personal", "1/3", "SC-000001")} # 감싼 줄 · 다른 파일 · 안 쓰는 번 뺌