Files
Aislo/B09_Estimation/B09_Estimation_Import_Rates.py
T
eomsangdonandClaude Opus 5.5 4cd4fdb3a3 feat(B09 불러오기): 원가계산서 요율 · 기초 단가표 읽기 · 이 공사 단가 책 이음 · 올린 파일 열쇠 길 (PLAN 707-2 · 707-3)
- POST estimation/import/rates · import/prices (file | file_key · 저장 안 함 · patch 만)
- 요율 = 행 이름 → 줄 키 · 손입력 칸(안전 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · 엑셀 대조
- 단가표 = 노임 · 자재 · 기계 → labor_cover · machine_cover · materials.applied · 짝 없음 · 짝 여럿

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:53:33 +09:00

279 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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