Files
Aislo/B09_Estimation/B09_Estimation_Bill_Import.py
T
eomsangdonandClaude Opus 5.5 c0ea73501f feat(B09 설계내역서): 경쟁사 내역 엑셀 줄 불러오기 서버 — 읽기 · 짜기 · 고정 단가 칸 (PLAN 707-1)
- 읽기(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 <noreply@anthropic.com>
2026-10-31 15:43:51 +09:00

222 lines
9.2 KiB
Python

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