- 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>
279 lines
13 KiB
Python
279 lines
13 KiB
Python
"""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
|