Files
Aislo/B09_Estimation/B09_Estimation_Bill_Import.py
T
eomsangdonandClaude Opus 5.5 c33f6837f2 fix(B09 설계내역서): 엑셀 내역 불러오기 화면 검증 흠 — 단가만 빈칸 · 운반 줄 오붙음 · 차례 · 표 높이 (PLAN 707-1)
- 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음)
- 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만
- 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음)
- 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임
- 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:13:34 +09:00

229 lines
9.9 KiB
Python

"""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