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>
This commit is contained in:
2026-10-31 15:53:33 +09:00
co-authored by Claude Opus 5.5
parent 0b2330aee6
commit 4cd4fdb3a3
5 changed files with 786 additions and 1 deletions
@@ -0,0 +1,278 @@
"""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