"""B09 경쟁사 내역 줄 불러오기 짜기(PLAN 707-1) — 읽은 내역(`Bill_Excel.read_bill`) → 설계내역서 덮개 v2(`user.bill` · 계약 = `B09_Estimation_Calc_Bill` 머리). 파일 · 저장 안 만짐(부르는 쪽 몫). 먼저 싣고 나중 잇기 — 빠짐 0 · 실은 줄 = 표 차례에서 관급자재대(`sec:G`) 앞: 묶음 줄 → 새 묶음(`hand:x{표지}-{행}` · 제외 맨 위 = 관급자재대 `sec:G` 아래) 항목 줄 → 새 줄 + 고정 단가 칸 `price`(엑셀 단가) + `src_qty`(엑셀 수량) + `note`(엑셀 비고) · 연결 = 같은 책 일위대가 사본(`links` {대가 번: ref}) — 없으면 고정 단가로 금액 같은 파일을 다시 부르면(같은 표지) 앞서 실은 줄을 갈아 끼움. 수량 갈래 — `take`(가 넘겨받기) 붙인 자동 줄 수량 = 엑셀(B08 = `auto_qty` 로 나란히) · `price`(나 단가만) 붙인 자동 줄 수량 = B08(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") #: 붙이지도 지우지도 않는 자동 줄 머리 — 자재(대비표 주인) · 조달수수료(운반 = 지움만) SKIP = ("mat", "fee") 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]], 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"].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, attach=True): 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 {} 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["줄"]: 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" 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 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"]] 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: 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