- 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>
187 lines
9.1 KiB
Python
187 lines
9.1 KiB
Python
"""B09 경쟁사 기초 단가표 불러오기(PLAN 707-3) — 서버 단독 · 저장 안 함
|
|
(덮개 제안 → 화면 캐시 → [저장]).
|
|
|
|
읽는 시트(꼴마다 첫 것) — 머리 = 「명칭」 · 「품명」 칸이 든 줄:
|
|
노임 = STmate 「노무비목록표」 · EST 「노임단가」 → 단가(「적용단가」 › 「단가」)
|
|
자재 = STmate 「재료비목록표」 › 「자재단가대비표」 · EST 「자재단가」 → 적용 단가(단위 % 줄 뺌)
|
|
기계 = STmate 「중기목록표」(= 「중기사용료」 블록 합계) ·
|
|
EST 「기계경비총괄표」(= 「기계경비」 합) → 시간 단가 노무비 · 재료비 · 경비
|
|
이음 = 이 공사 단가 책 줄(바탕 셈 `compute_base` 노무비목록표 · 중기목록표 · 자재단가대비표)에
|
|
이름 · 규격 · 단위로 — 705-3 이음 낱말 그대로(노임 = `M02_Hopyo_Shares.trade` 직종 이름 ·
|
|
기계 = 이름 + 규격 같음 › `eq_key` 갈래 · 크기 · 자재 = 「별도계상」 글 뗀 이름 · 규격 · 단위 ›
|
|
이름 · 단위) · 한 줄에만 맞을 때만 · 기계 갈래 · 크기는 표지 글(타이어 · 브레이커 · 할증 …)
|
|
까지 · 한 책 줄에 엑셀 줄 여럿 = 「짝 여럿」(못 고름).
|
|
덮개 = 노임 `labor_cover` · 기계 `machine_cover`(새 · `B09_Estimation_Calc_Base`) ·
|
|
자재 = 지금 덮개 `materials[키].applied`(고른 출처 `pick` 은 뺌 — 적용 차례가 pick 먼저) ·
|
|
책 값과 같으면 덮지 않음(옛 덮개 걷음).
|
|
못 이은 줄 = 「짝 없음 n」(이름 목록).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unicodedata
|
|
from collections import Counter
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_Import_Rates import flat, money, read_sheets
|
|
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
|
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
|
from M02_MasterTemplete.M02_Hopyo_Excel import ExcelError
|
|
|
|
KINDS = {
|
|
"labor": ("노무비목록표", "노임단가"),
|
|
"material": ("재료비목록표", "자재단가대비표", "자재단가"),
|
|
"machine": ("중기목록표", "기계경비총괄표"),
|
|
}
|
|
PARTS = ("labor", "material", "expense")
|
|
_MACHINE = {"labor": "노무비", "material": "재료비", "expense": "경비"}
|
|
LIST_MAX = 40
|
|
#: 같은 갈래 · 크기라도 다른 기계 — 표지 글이 같아야 이음
|
|
_MARK = re.compile(r"타이어|브레|할증|암석|\(암\)|집게|진동|탑재|크레인|살수")
|
|
|
|
|
|
def _n(text: Any) -> str:
|
|
return flat(unicodedata.normalize("NFKC", str(text or ""))).lower()
|
|
|
|
|
|
def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]:
|
|
"""목록 시트 → [{name, spec, unit, price | labor · material · expense}]."""
|
|
for at, row in enumerate(rows[:12]):
|
|
cols = [flat(v) for v in row]
|
|
name = next((cols.index(h) for h in ("명칭", "품명") if h in cols), None)
|
|
if name is None:
|
|
continue
|
|
spec, unit = (cols.index(h) if h in cols else None for h in ("규격", "단위"))
|
|
if machine:
|
|
price = {p: cols.index(h) for p, h in _MACHINE.items() if h in cols}
|
|
if len(price) < 3:
|
|
continue
|
|
else:
|
|
price = next((cols.index(h) for h in ("적용단가", "단가") if h in cols), None)
|
|
if price is None:
|
|
continue
|
|
out = []
|
|
for line in rows[at + 1 :]:
|
|
|
|
def cell(col: int | None, line: list[Any] = line) -> Any:
|
|
return line[col] if col is not None and col < len(line) else None
|
|
|
|
item = {"name": str(cell(name) or "").strip(), "spec": str(cell(spec) or "").strip(),
|
|
"unit": str(cell(unit) or "").strip()} # fmt: skip
|
|
if machine:
|
|
item.update({p: money(cell(c)) for p, c in price.items()})
|
|
ok = None not in (item[p] for p in PARTS)
|
|
else:
|
|
item["price"] = money(cell(price))
|
|
ok = item["price"] is not None and item["unit"] != "%"
|
|
if item["name"] and ok:
|
|
out.append(item)
|
|
return out
|
|
return []
|
|
|
|
|
|
def read_prices(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]:
|
|
"""파일 → {labor, material, machine: 줄 목록} · 시트 없음 = 빈 목록 · 셋 다 없으면 막힘."""
|
|
sheets = read_sheets(name, data, tuple(s for names in KINDS.values() for s in names))
|
|
out = {}
|
|
for kind, names in KINDS.items():
|
|
sheet = next((s for s in names if s in sheets), None)
|
|
out[kind] = table(sheets[sheet], kind == "machine") if sheet else []
|
|
out[f"{kind}_sheet"] = sheet
|
|
if not any(out[k] for k in KINDS):
|
|
raise ExcelError("노무비목록표 · 재료비목록표 · 중기목록표(STmate) · 노임단가 · 자재단가 · "
|
|
"기계경비총괄표(EST) 시트가 없음") # fmt: skip
|
|
return out
|
|
|
|
|
|
def _labor_keys(row: dict[str, Any]) -> list[str]:
|
|
return [_n(shares.trade(row.get("name")) or row.get("name"))]
|
|
|
|
|
|
def _machine_keys(row: dict[str, Any]) -> list[str]:
|
|
"""[이름 + 규격 같음, 갈래 · 크기 + 표지 글(타이어 · 브레이커 · 할증 · 암 …)]."""
|
|
text = f"{row.get('name') or ''} {row.get('spec') or ''}"
|
|
kind = shares.eq_key(text)
|
|
marks = "+".join(sorted(set(_MARK.findall(_n(text)))))
|
|
return [_n(text), f"{kind}#{marks}" if kind else ""]
|
|
|
|
|
|
def _material_keys(row: dict[str, Any]) -> list[str]:
|
|
me, _ = hmat.clean({"이름": row.get("name"), "규격": row.get("spec")})
|
|
unit = _n(row.get("unit"))
|
|
return [f"{_n(me['이름'])}|{_n(me['규격'])}|{unit}", f"{_n(me['이름'])}||{unit}"]
|
|
|
|
|
|
def _match(book: list[dict[str, Any]], keys_of, item: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""책 줄 하나 — 단계마다 한 줄에만 맞으면 그 줄 · 여럿 · 없음이면 다음 단계."""
|
|
for step, want in enumerate(keys_of(item)):
|
|
hits = [r for r in book if keys_of(r)[step] == want] if want else []
|
|
if len(hits) == 1:
|
|
return hits[0]
|
|
return None
|
|
|
|
|
|
def _same(a: Any, b: Any) -> bool:
|
|
if isinstance(a, dict):
|
|
return all(_same(a[p], (b or {}).get(p)) for p in PARTS)
|
|
return a is not None and b is not None and abs(a - b) < 0.5
|
|
|
|
|
|
def propose(read: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
|
"""읽은 단가표 + 이 공사 단가 책(덮개 뺀 바탕 셈) → 덮개 patch · 이은 줄 · 짝 없음."""
|
|
base = {"labor": sheets.get("labor") or [], "machine": sheets.get("machine") or [],
|
|
"material": sheets.get("compare") or []} # fmt: skip
|
|
keys = {"labor": _labor_keys, "machine": _machine_keys, "material": _material_keys}
|
|
labor_cover = dict(user.get("labor_cover") or {})
|
|
machine_cover = dict(user.get("machine_cover") or {})
|
|
materials = {k: dict(v) for k, v in (user.get("materials") or {}).items()}
|
|
linked: list[dict[str, Any]] = []
|
|
missing: dict[str, list[str]] = {k: [] for k in KINDS}
|
|
for kind in KINDS:
|
|
items = read.get(kind) or []
|
|
rows = [_match(base[kind], keys[kind], item) for item in items]
|
|
hits = Counter(r["key"] for r in rows if r)
|
|
for item, row in zip(items, rows, strict=True):
|
|
label = " ".join(x for x in (item["name"], item["spec"]) if x)
|
|
if row is None or hits[row["key"]] > 1: # 한 책 줄에 엑셀 줄 여럿 = 못 고름
|
|
missing[kind].append(label if row is None else f"{label}(짝 여럿)")
|
|
continue
|
|
if kind == "machine":
|
|
got = {p: item[p] for p in PARTS}
|
|
book = {p: row.get(p) for p in PARTS}
|
|
else:
|
|
got, book = item["price"], row.get("price" if kind == "labor" else "applied")
|
|
linked.append({"kind": kind, "key": row["key"], "name": row.get("name") or "",
|
|
"from": label, "excel": got, "book": book,
|
|
"same": _same(got, book)}) # fmt: skip
|
|
same = linked[-1]["same"] # 책 값 = 엑셀 → 옛 덮개 걷음(값 = 책)
|
|
if kind in ("labor", "machine"):
|
|
cover = labor_cover if kind == "labor" else machine_cover
|
|
cover.pop(row["key"], None)
|
|
if not same:
|
|
cover[row["key"]] = got
|
|
else:
|
|
drop = ("pick", "applied") if same else ("pick",)
|
|
cell = {k: v for k, v in materials.get(row["key"], {}).items() if k not in drop}
|
|
materials[row["key"]] = cell if same else {**cell, "applied": got}
|
|
return {
|
|
"patch": {
|
|
"labor_cover": labor_cover,
|
|
"machine_cover": machine_cover,
|
|
"materials": materials,
|
|
}, # fmt: skip
|
|
"linked": linked,
|
|
"missing": {k: v[:LIST_MAX] for k, v in missing.items()},
|
|
"counts": {
|
|
k: {
|
|
"read": len(read.get(k) or []),
|
|
"linked": sum(x["kind"] == k for x in linked),
|
|
"covered": sum(x["kind"] == k and not x["same"] for x in linked),
|
|
"book": len(base[k]),
|
|
"missing": len(missing[k]),
|
|
}
|
|
for k in KINDS
|
|
}, # fmt: skip
|
|
}
|