"""B09 경쟁사 원가계산서 요율 불러오기(PLAN 707-2) — 서버 단독 · 저장 안 함 (제안만 → 화면 캐시 → [저장]). 읽는 시트 = STmate 「공사원가계산서」(율 = 비고 글 「직접노무비 x 14.5%」) · EST 「원가계산서」(율 = 「요율」 칸 · 비면 산출근거 글). 머리 = 「금액」 칸이 든 줄 · 행 이름 = 금액 칸 앞 마지막 한글 두 글자 넘는 글 (EST 세로 머리 · 구분 칸 뺌). 행 이름 → 원가계산서 줄 키(`ROWS` · RT 코드 = `B09_Estimation_Calc_Total` 줄 표) → 율 덮개 `estimate.rates{줄 키: %}` · 손입력 칸 = 지금 칸(`B09_Estimation_Store.ESTIMATE_FIELDS`): safety_base = 산업안전보건관리비 글 「% + 5,349,000」(글 있고 + 없으면 0 · 글 없으면 안 실음) · indirect_material · scrap = 그 줄 금액 · profit_adjust = ROUNDDOWN((순공사원가 − 재료비 + 일반관리비) × 율) − 엑셀 이윤 · vat_adjust = 엑셀 부가세 − ROUNDDOWN(총원가 × 율) · procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값). 대조(`check`) = 엑셀 비목 합 + 실은 값으로 우리 식(`compute_total`)을 돌려 줄마다 엑셀 금액과 견줌 — 1 원 넘게 다른 줄 = 「식 다름」(노인장기요양 · 산업안전보건관리비 A/B 등 식 꼴이 다른 비목) · 우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) · 셈 없는 현행 비목(퇴직공제부금비 …) = 「짝 없음」 · 「셈 없음」. """ from __future__ import annotations import io import re from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown from B09_Estimation.B09_Estimation_Calc_Total import ( CURRENT_ONLY, RATE_LINES, TAIL_LINES, compute_total, ) from M02_MasterTemplete.M02_Hopyo_Excel import DRM, ExcelError SHEETS = ("공사원가계산서", "원가계산서") #: 행 이름 앞머리(빈칸 뺀 글) → 키 — 율 줄 = 원가계산서 줄 키 · 그 밖 = 비목 합 · 대조용 ROWS: tuple[tuple[str, str], ...] = ( ("직접재료비", "material_direct"), ("간접재료비", "indirect_material"), ("작업설", "scrap"), ("직접노무비", "labor"), ("간접노무비", "indirect_labor"), ("산출경비", "expense"), ("산재보험", "accident"), ("고용보험", "employment"), ("건강보험", "health"), ("노인장기요양", "care"), ("연금보험", "pension"), ("산업안전보건관리비", "safety"), ("기타경비", "other"), ("환경보전비", "environment"), ("건설기계대여금", "machine"), ("퇴직공제", "retirement"), ("임금채권", "wage_claim"), ("석면", "asbestos"), ("하도급대금", "subcontract"), ("공사이행보증", "performance"), ("일반관리비", "overhead"), ("이윤", "profit"), ("총원가", "supply_price"), ("부가가치세", "vat"), ("도급", "contract"), ("관급자재대", "supply"), ("총공사비", "grand"), ("순공사원가", "net_cost"), ("소계", "subtotal"), ) RATE_KEYS = {key: (name, rt) for key, name, rt, _ in (*RATE_LINES, *TAIL_LINES)} NO_CALC = {key: (name, rt) for key, name, rt in CURRENT_ONLY} _PCT = re.compile(r"[x×*]\s*([\d.]+)\s*%") _BASE = re.compile(r"%\s*\+\s*([\d,]+)") _HANGUL = re.compile(r"[가-힣]") _FEE = re.compile(r"원자재대\s*:\s*([\d,]+).*?조달수수료\s*:\s*([\d,]+)") def flat(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")) def read_sheets(name: str, data: bytes, wanted: tuple[str, ...]) -> dict[str, list[list[Any]]]: """시트 이름 → 줄 목록(`wanted` 만 · 이름 앞뒤 빈칸 뺌) — xlsx · xls · DRM 막힘.""" if not data: raise ExcelError("빈 파일") if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"): raise ExcelError(DRM) try: if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): import openpyxl book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) out = { ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] for ws in book.worksheets if ws.title.strip() in wanted } book.close() return out import xlrd book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO()) return { sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)] for sh in book.sheets() if sh.name.strip() in wanted } except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님 raise ExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error def money(value: Any) -> float | None: """칸 → 수(「80,165,010」 글도) · 아니면 None.""" if isinstance(value, str): value = value.replace(",", "").strip() try: return float(value) if value else None except ValueError: return None return num(value) def _pct(value: Any) -> float | None: """요율 칸 — 「16.5%」 글 · 엑셀 % 수(0.165) → %.""" if isinstance(value, str): return money(value.replace("%", "")) if "%" in value else None got = num(value) return None if got is None else round(got * 100, 6) def _key(name: str) -> str | None: return next((key for head, key in ROWS if name.startswith(head)), None) def parse(rows: list[list[Any]]) -> dict[str, Any]: """원가계산서 줄 → [{name, key, amount, rate, text, more}] — 머리 = 「금액」 칸 든 줄.""" head = next((i for i, r in enumerate(rows[:12]) if "금액" in [flat(v) for v in r]), None) if head is None: raise ExcelError("원가계산서 머리(금액 칸)를 못 찾음") cols = [flat(v) for v in rows[head]] at = cols.index("금액") rate_col = cols.index("요율") if "요율" in cols else None text_col = next((cols.index(h) for h in ("산출근거", "비고") if h in cols), None) out = [] for row in rows[head + 1 :]: names = [flat(v) for v in row[:at] if len(_HANGUL.findall(str(v or ""))) > 1] amount = money(row[at]) if at < len(row) else None if not names: continue text = str(row[text_col] or "") if text_col is not None and text_col < len(row) else "" rate = _pct(row[rate_col]) if rate_col is not None and rate_col < len(row) else None if rate is None and (m := _PCT.search(text)): rate = float(m.group(1)) tail = row[max(at, text_col or 0) + 1 :] more = [v for v in (money(x) for x in tail) if v is not None] out.append({"name": names[-1], "key": _key(names[-1]), "amount": amount, "rate": rate, "text": text.strip(), "more": more}) # fmt: skip return {"rows": out} def _fee(row: dict[str, Any]) -> tuple[float, float] | None: """관급 줄 → (순자재대, 조달수수료) — STmate 글 「원자재대: X (조달수수료: Y 포함)」 · EST 뒤 칸 둘.""" if m := _FEE.search(row["text"].replace("\n", " ")): raw, fee = (float(g.replace(",", "")) for g in m.groups()) return raw - fee, fee if len(row["more"]) >= 2 and row["more"][0] > row["more"][1] > 0: return row["more"][0] - row["more"][1], row["more"][1] return None def _fee_pct(net: float, fee: float) -> float | None: for places in (2, 3, 4): pct = round(fee / net * 100, places) if rounddown(net * pct / 100) == fee: return pct return None def read_rates(name: str, data: bytes) -> dict[str, Any]: """파일 → {kind, rows, estimate(실을 값), excel(키: 금액), notes}.""" sheets = read_sheets(name, data, SHEETS) sheet = next((s for s in SHEETS if s in sheets), None) if sheet is None: raise ExcelError("「공사원가계산서」(STmate) · 「원가계산서」(EST) 시트가 없음") rows = parse(sheets[sheet])["rows"] excel: dict[str, float] = {} subtotals: list[float] = [] rates: dict[str, float] = {} estimate: dict[str, Any] = {} notes: list[str] = [] for row in rows: key, amount = row["key"], row["amount"] if key == "subtotal": subtotals.append(amount or 0.0) continue if key is None: if amount: notes.append( f"짝 없음 — 「{row['name']}」 {amount:,.0f} 원(우리 원가계산서에 줄 없음)" ) continue if key == "safety" and key in excel: # EST A · B 두 줄 = 작은 쪽(우리 식과 같음) excel[key] = min(excel[key], amount or excel[key]) continue excel[key] = amount if amount is not None else excel.get(key, 0.0) if key in RATE_KEYS or key in NO_CALC: if row["rate"] is not None: rates[key] = row["rate"] elif amount: notes.append(f"율 없음 — 「{row['name']}」 산출근거 빈칸 · 지금 율 그대로") if key in NO_CALC and amount: notes.append( f"셈 없음 — 「{row['name']}」 {amount:,.0f} 원 · 율 {row['rate']}% 만 실음" "(우리 원가계산서 줄만 · 총액에 안 듦)" ) if key == "safety" and row["text"]: got = _BASE.search(row["text"]) estimate["safety_base"] = float(got.group(1).replace(",", "")) if got else 0.0 if key in ("indirect_material", "scrap") and amount: estimate[key] = amount if key == "supply" and (fee := _fee(row)): excel["supply_net"], excel["supply_fee"] = fee if (pct := _fee_pct(*fee)) is not None: estimate["procurement_fee_pct"] = pct else: notes.append( f"조달수수료 {fee[1]:,.0f} 원 — 한 율로 안 맞음(품목마다 셈) · 수수료율 손입력" ) excel["material"] = subtotals[0] if subtotals else excel.get("material_direct") net = excel.get("net_cost") or (sum(subtotals[:3]) if len(subtotals) >= 3 else None) if net is not None and excel.get("overhead") is not None and "profit" in rates: base = net - (excel["material"] or 0.0) + excel["overhead"] adjust = rounddown(base * rates["profit"] / 100) - (excel.get("profit") or 0.0) if adjust: estimate["profit_adjust"] = adjust if excel.get("supply_price") is not None and "vat" in rates and "vat" in excel: adjust = excel["vat"] - rounddown(excel["supply_price"] * rates["vat"] / 100) if adjust: estimate["vat_adjust"] = adjust kind = "STmate" if sheet == "공사원가계산서" else "EST" return {"kind": kind, "sheet": sheet, "rows": rows, "rates": rates, "estimate": estimate, "excel": excel, "notes": notes} # fmt: skip NAMES = {"supply": "관급자재대", "supply_price": "총원가(공급가액)", "contract": "도급공사비", "grand": "총공사비"} # fmt: skip #: 대조 줄 — 엑셀 키 → 우리 셈 자리 _OURS = { **{key: ("lines", key) for key in RATE_KEYS}, "supply": ("lines", "supply"), "supply_price": ("subtotals", "supply_price"), "contract": ("subtotals", "contract"), "grand": ("subtotals", "grand"), } def check(read: dict[str, Any], estimate: dict[str, Any] | None, table: Any) -> dict[str, Any]: """엑셀 비목 합 + (지금 사용자 값 위에 실은 값)으로 우리 식 → 줄마다 엑셀과 견줌.""" excel = read["excel"] mine = dict(estimate or {}) est = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}} net, fee = excel.get("supply_net", 0.0), excel.get("supply_fee", 0.0) if "procurement_fee_pct" not in read["estimate"] and net: # 한 율로 못 맞춘 수수료 = 그 비율째 est["procurement_fee_pct"] = fee / net * 100 sums = {"material": excel.get("material"), "labor": excel.get("labor"), "expense": excel.get("expense"), "supply_net": net} # fmt: skip got = compute_total(sums, est, table) lines = [] for key, (part, at) in _OURS.items(): if key not in excel: continue ours = got[part].get(at) same = ours is not None and abs(ours - excel[key]) < 1 name = RATE_KEYS[key][0] if key in RATE_KEYS else NAMES[key] lines.append({"key": key, "name": name, "excel": excel[key], "ours": ours, "same": same}) return {"lines": lines, "grand": got["subtotals"]["grand"], "excel_grand": excel.get("grand"), "differ": [ln["name"] for ln in lines if not ln["same"]]} # fmt: skip