"""M02 엑셀 불러오기 1차 줄 판정(PLAN 785-3 · 요구 641 · 642 · 설계 642-A 2-3 · 분석 642-C 5장). 엑셀 사전 = 단가표 시트 → 갈래 + M01 키 후보(한 번 · `lexicon`) — JSON 꼴 `{꼴, 줄 [사전 줄]}` · 사전 줄 = `{갈래, 이름, 규격, 단위, 단가, 시트, 행, 코드?, 후보 [고른 것]}`. EST = 자재단가(재료 · 이름이 M01 유가전력이면 유가) · 노임단가(인력) · 기계경비총괄표(기계) · 기계경비적용기준(기본재료비 = 유가 · 환율 = 환율 · 줄 꼴 없음 → 꼬리표만) · 단가산출총괄표 · 일위대가총괄표 · 머리 줄 = 앞 열 줄 안 「품명」 칸(열은 머리 글로 찾음). STmate = 목록표(`Stmate._lists`) · 줄 코드가 열쇠(L 인력 · X 기계 · M 재료 · D 단가산출 · S · W 고정). 세부행 갈래(위가 먼저) = ① 비고 「단산N · 일위N참조」(이름으로 이은 줄 뺌) ② 품명 · 규격 「별도계상 · 별도계산」 → 재료 ③ 단위 % · 규격 「…비의」 → 율 · hr · 시간 → 기계 · 인 → 인력 ④ 사전(코드 › 품명+규격 › 품명) · 갈래 여럿 = 금액 칸 꼴(재료비만 → 재료 · 유가)로 가름 · 그 밖 = 고정. 후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층 SK · SC 같은 이름(위 층까지 · 참고). 판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) · 2차 필요(단가산출 줄 = `Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안). 고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가, 입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫). 수량 = `{엑셀, 배, 갈래}` — 우리 수량 = 엑셀 수량 × 몫 × 배(`factor` · 옮김 · 같은 단위 = 1). 서버 단독 · 읽기만(M01 · 층 문서 · 장부). """ from __future__ import annotations import copy import json import re import unicodedata from decimal import Decimal from functools import lru_cache from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_ReadEst as est from M02_MasterTemplete import M02_Hopyo_Stmate as stmate from M02_MasterTemplete import M02_Labor_Ref as refs mm, mp = hm.mm, hm.mp KINDS = ("기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정") VERDICTS = ("제안 있음", "2차 필요", "후보 여럿", "후보 없음") COSTS = ("노무비", "재료비", "경비") SHEET_KIND = { "자재단가": "재료", "노임단가": "인력", "기계경비총괄표": "기계", "기계경비적용기준": "유가", "단가산출총괄표": "단가산출", "일위대가총괄표": "일위대가", } SHEETS = (*SHEET_KIND, *stmate.LISTS) CODE_KIND = {"L": "인력", "X": "기계", "M": "재료", "D": "단가산출", "B": "일위대가"} APART = ("별도계상", "별도계산") MACHINE_UNITS = ("hr", "시간", "h") CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP")) _RATE_BASE = re.compile(r"(노무비|재료비|경비|인건비|자재비|노임|합계)\s*의") _BASE_COST = {"노": "노무비", "인": "노무비", "품": "노무비", "재": "재료비", "자": "재료비"} TOP = 5 def flat(value: Any) -> str: return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(value or ""))).lower() def _num(value: Any) -> float | None: if isinstance(value, bool) or value in (None, ""): return None try: return float(str(value).replace(",", "").strip()) except ValueError: return None def _plain(value: Any) -> int | float | None: if value is None: return None f = float(value) return int(f) if f == int(f) else round(f, 6) # ── 엑셀 사전 ─────────────────────────────────────────────────────────── def _header(rows: list[list[Any]]) -> tuple[int, dict[str, int]] | None: for at, row in enumerate(rows[:10]): heads = [flat(v) for v in row] if "품명" in heads: return at, {h: i for i, h in reversed(list(enumerate(heads))) if h} return None def _list_rows(rows: list[list[Any]], sheet: str, kind: str) -> list[dict[str, Any]]: """단가표 시트 줄 — No(수 · 「제N호표」) · 품명 있는 줄 · 단가 = 적용단가 › 합계 › 단가.""" got = _header(rows) if got is None: return [] at, cols = got name = cols["품명"] no = cols.get("no", cols.get("호표", name - 1)) price = next((cols[h] for h in ("적용단가", "합계", "단가") if h in cols), None) cell = lambda r, c: r[c] if c is not None and c < len(r) else None # noqa: E731 out = [] for i, row in enumerate(rows[at + 1 :], at + 2): label = flat(cell(row, no)) if not flat(cell(row, name)) or not (label.isdigit() or re.fullmatch(r"제\d+호표", label)): continue out.append( { "갈래": kind, "이름": str(cell(row, name)).strip(), "규격": str(cell(row, cols.get("규격")) or "").strip(), "단위": str(cell(row, cols.get("단위")) or "").strip(), "단가": _plain(_num(cell(row, price))), "시트": sheet, "행": i, } ) return out def _rates(rows: list[list[Any]], sheet: str) -> list[dict[str, Any]]: """기계경비적용기준 환율 칸 — 「화폐」 머리 아래 (화폐, 환율) 줄.""" for at, row in enumerate(rows[:10]): heads = [flat(v) for v in row] if "화폐" in heads: c = heads.index("화폐") return [ { "갈래": "환율", "이름": str(r[c]).strip(), "규격": "", "단위": "원", "단가": _plain(_num(r[c + 1])), "시트": sheet, "행": i, } # fmt: skip for i, r in enumerate(rows[at + 1 :], at + 2) if c + 1 < len(r) and flat(r[c]) and _num(r[c + 1]) ] return [] def lexicon(sheets: dict[str, list[list[Any]]], keys: bool = True) -> dict[str, Any]: """시트 줄 → 엑셀 사전 `{꼴, 줄}` — `keys` = 줄마다 M01 키 후보(한 번).""" if stmate.is_stmate(sheets): rows = [ { "갈래": CODE_KIND.get(code[0], "고정"), **one, "단가": None, "시트": "목록표", "행": None, "코드": code, } # fmt: skip for code, one in stmate._lists(sheets).items() ] shape = "STmate" else: rows = [] for sheet, kind in SHEET_KIND.items(): if sheets.get(sheet): rows += _list_rows(sheets[sheet], sheet, kind) rows += _rates(sheets.get("기계경비적용기준") or [], "기계경비적용기준") shape = "EST" for row in rows: if row["갈래"] == "재료" and elem.oil_rows(row["이름"]): row["갈래"] = "유가" if keys: row["후보"] = m01_picks(row["갈래"], row) return {"꼴": shape, "줄": rows} def _index(lex: dict[str, Any] | None) -> dict[str, list[dict[str, Any]]]: out: dict[str, list[dict[str, Any]]] = {} for row in (lex or {}).get("줄") or []: if row.get("코드"): out.setdefault("코드|" + row["코드"], []).append(row) out.setdefault(f"{flat(row['이름'])}|{flat(row['규격'])}", []).append(row) out.setdefault(flat(row["이름"]), []).append(row) return out # ── M01 키 후보(표마다) ─────────────────────────────────────────────────── def pick(kind: str, key: Any = None, name: Any = "", spec: Any = "", unit: Any = "", **more: Any): """고른 것 한 꼴(설계 2-1).""" head = {"갈래": kind, "키": key, "이름": str(name or ""), "규격": str(spec or "")} return {**head, "단위": str(unit or ""), "단가": more.pop("단가", None), "입력값": {}, "출처": more.pop("출처", {}), "몫": None, **more} # fmt: skip def _rows(table: str) -> dict[str, dict[str, Any]]: return m01.loaded()[1].numbers.get(table, {}) def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]: """갈래 하나 → M01 고른 것 후보(LB · MT · MO · EQ · FX) — 유일 짝이면 하나 · 같은 글 한 번.""" text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위")) return copy.deepcopy(_m01_picks(kind, *text, id(m01.loaded()[1]))) @lru_cache(maxsize=4096) def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]: if kind == "인력": trade = est.trade(name) hit = [r for r in _rows("LB").values() if trade and r.get("이름") == trade] if hit: return [ pick("인력", hit[0]["키"], hit[0]["이름"], "", "인", 단가=_plain(hit[0].get("값"))) ] got = elem.labor(str(name or "")) return [pick("인력", x["키"], x["이름"], "", "인", 단가=x["단가"]) for x in got] if kind == "기계": text = f"{name} {spec}" one = elem.machine(text) rows = [one] if one else [] got = [{"키": r["키"], "이름": r["이름"], "규격": r.get("규격")} for r in rows] got = got or elem.machines(text) return [pick("기계", x["키"], x["이름"], x.get("규격"), unit or "hr") for x in got] if kind == "유가": return [ pick("유가", x["키"], x["이름"], x["규격"], x["단위"], 단가=x["단가"]) for x in elem.oils(str(name or "")) ] if kind == "환율": code = next((c for w, c in CURRENCY if w in str(name)), flat(name).upper()) rows = [r for r in _rows("FX").values() if str(r.get("이름")).startswith(code)] return [ pick("환율", r["키"], r["이름"], "", r.get("단위"), 단가=_plain(r["값"])) for r in rows ] if kind == "재료": clean, apart = hmat.clean({"이름": name, "규격": spec, "단위": unit}) sure, _ = hmat.sure(clean) got = [r["one"] for r in sure[:1] if not r["one"].get("전국")] got = got or hmat.candidates(clean, TOP) more = {"별도계상": True} if apart else {} return [ pick("재료", x.get("자재"), x["이름"], x.get("규격"), x.get("단위"), **more) for x in got if x.get("자재") ] return [] # ── M02 층 SK · SC(같은 이름 · 참고) ──────────────────────────────────────── def layer_docs(folder: str | Path | None) -> dict[str, list[dict[str, Any]]]: """`{정규 이름: [고른 것]}` — 같은 층 → 위 층 · 단가산출 SK · 일위대가 SC.""" out: dict[str, list[dict[str, Any]]] = {} if folder is None: return out for one in ledger.chain(folder): layer, owner = refs.where(one) for kind, sub in (("단가산출", "sangeun"), ("일위대가", "precombo")): for path in sorted((Path(one) / sub).glob("S[CK]-*.json")): doc = json.loads(path.read_bytes()) if path.is_file() else {} name = doc.get("이름") or path.stem got = pick(kind, path.stem, name, "", doc.get("단위"), 층=layer, 주인=owner) out.setdefault(mm.plain(name), []).append(got) return out # ── 줄 판정 ───────────────────────────────────────────────────────────── def first(line: dict[str, Any]) -> tuple[str | None, str, int | None]: """1차 ①~③ — (갈래, 근거, 번) · 못 가름 = (None, "", None).""" ref = line.get("참조") or {} if ref and line.get("이음") != "이름": kind = "단가산출" if ref.get("종류") == "단산" else "일위대가" return kind, f"비고 {line.get('비고') or line.get('코드') or ''}".strip(), ref.get("번") text = flat(f"{line.get('이름')}{line.get('규격')}") if any(w in text for w in APART) or str(line.get("비고") or "").startswith("별산"): return "재료", "별도계상", None unit = flat(line.get("단위")) if unit == "%" or _RATE_BASE.search(str(line.get("규격") or "")): return "율", "단위 %", None if unit in MACHINE_UNITS: return "기계", "단위 hr", None if unit == "인": return "인력", "단위 인", None return None, "", None def _shape(line: dict[str, Any]) -> tuple[str, ...]: price = line.get("단가") or {} return tuple(k for k in COSTS if price.get(k)) def second(line: dict[str, Any], index: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: """④ 사전 — 코드 › 품명+규격 › 품명 · 갈래 여럿이면 금액 칸 꼴로 가름 → 사전 줄 목록.""" code = str(line.get("코드") or "") hits = index.get("코드|" + code) if code else None hits = hits or index.get(f"{flat(line.get('이름'))}|{flat(line.get('규격'))}") hits = hits or index.get(flat(line.get("이름"))) or [] if len({h["갈래"] for h in hits}) > 1 and _shape(line) == ("재료비",): hits = [h for h in hits if h["갈래"] in ("재료", "유가")] or hits if not hits and code[:1] in CODE_KIND: hits = [{"갈래": CODE_KIND[code[0]], "이름": line.get("이름"), "규격": line.get("규격")}] return hits def _rate_pick(line: dict[str, Any]) -> dict[str, Any]: """율 줄 — 기준 = 규격 「…비의」 글(EST 노 · 품 · 인 → 노무비 · 재 · 자 → 재료비 · 경 → 경비 · 합계 → 비목 셋 중 줄 금액 칸) · 비목 = 줄 단가가 든 칸(없으면 기준).""" hit = _RATE_BASE.search(f"{line.get('규격')}{line.get('이름')}") word = hit.group(1) if hit else "" base = _BASE_COST.get(word[:1], "경비" if word.startswith("경") else None) cost = next(iter(_shape(line)), None) or base or "재료비" one = pick("율", None, line.get("이름"), line.get("규격"), "%", 비목=cost, 기준=base or cost) return one # 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2) ITEM_LATER = "프로젝트에서" def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]: price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS} more = {"별도계상": True, "품목": ITEM_LATER} if apart else {} return pick( "고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more ) def _from_ledger(row: dict[str, Any]) -> list[dict[str, Any]]: """장부 짝 → 고른 것 — 새 꼴(`갈래`) 그대로 · 옛 꼴 로직 = 기초 · 자재 = 재료 · 유가.""" out = [] for p in row.get("짝") or []: if p.get("갈래"): got = pick(p["갈래"], p.get("키"), p.get("이름"), "", p.get("단위")) got.update({k: p[k] for k in ("입력값", "몫", "비목", "기준", "별도계상") if k in p}) elif p.get("로직"): got = {**pick("기초", p["로직"]), "입력값": p.get("입력값") or {}, "몫": p.get("몫")} else: code = str(p.get("자재") or "") got = pick("유가" if code.startswith("MO") else "재료", code) out.append({**got, "출처": {"장부": row.get("층")}, "배": p.get("배")}) return out def judge( line: dict[str, Any], index: dict[str, list[dict[str, Any]]], docs: dict[str, list[dict[str, Any]]] | None = None, book: list | None = None, doc: str | None = None, folder: str | Path | None = None, ) -> dict[str, Any]: """줄 하나 → 줄 판 `{차례, 행, 갈래 [..], 근거, 번?, 후보, 판정, 수량, 장부?}`.""" kind, why, no = first(line) hits = [] if kind else second(line, index) kinds = [kind] if kind else list(dict.fromkeys(h["갈래"] for h in hits)) or ["고정"] if not kind and hits: h = hits[0] why = f"사전 {h.get('시트')} {h.get('행') or h.get('코드') or ''}".strip() out = {"차례": line.get("차례"), "행": line.get("행"), "갈래": kinds, "근거": why} if no is not None: out["번"] = no qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"} picks: list[dict[str, Any]] = [] if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1) return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty} found = None if folder is not None: found = ledger.recall(line, folder, book, doc=doc) if found and found.get("갈래") == "짝": picks = _from_ledger(found) for k in kinds if not picks else (): if k == "일위대가" and no is not None: picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"), line.get("단위"), 출처={"엑셀": f"제{no}호표"})) # fmt: skip elif k in ("단가산출", "일위대가"): pass elif k == "율": picks.append(_rate_pick(line)) elif any(h["갈래"] == k and "후보" in h for h in hits): # 사전이 붙인 M01 후보 picks += [p for h in hits if h["갈래"] == k for p in h["후보"]] else: picks += m01_picks(k, line) if not found: picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds] picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values()) if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"): out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")} if "단가산출" in kinds: # 단산 줄 = 늘 2차 블록 풀이(장부 짝 = 1차 후보 힌트만 · 785-7) verdict = "2차 필요" elif ( found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름") ): verdict = "제안 있음" elif kinds[0] in ("일위대가", "율") or len(picks) == 1: verdict = "제안 있음" elif picks: verdict = "후보 여럿" else: verdict, picks = "후보 없음", [_fixed(line)] return {**out, "후보": picks, "판정": verdict, "수량": qty} def rows( book: dict[str, Any], folder: str | Path | None = None, doc: str | None = None ) -> dict[int, list[dict[str, Any]]]: """Book(+ `사전`) → `{호표 번: [줄 판]}` — 장부 · 층 문서는 한 번 읽어 넘김.""" index = _index(book.get("사전")) got = ledger.books(folder) if folder is not None else None docs = layer_docs(folder) return { h["번"]: [judge(x, index, docs, got, doc, folder) for x in h.get("줄") or []] for h in book.get("호표") or [] } # ── 환산(`M02_Hopyo_Match.factor` 옮김) ───────────────────────────────────── def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]: """`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력.""" units = [u.strip() for u in str(want or "").split("·") if u.strip()] plain = mm.plain_unit(have) if len(units) > 1: ok = plain in {mm.plain_unit(u) for u in units} return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"} if plain.replace("/", "") == mm.plain_unit(want).replace("/", ""): # 공/㎥ ≡ 공㎥ return {"배": 1, "갈래": "같음"} if str(want or "").strip() == "자재": # 결과단위 「원/자재 단위」 = 줄 단위 그대로 return {"배": 1, "갈래": "자재 단위"} a, b = mp.kind_of(have), mp.kind_of(want) if a is not None and a == b: scale = mp.UNITS[a] ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)]) return {"배": hm._json(ratio), "갈래": "단위표"} found = hm.numbers(spec) width, thick = hm._meters(found.get("B")), hm._meters(found.get("T")) by = {("넓이", "부피"): thick, ("길이", "넓이"): width} if (a, b) == ("길이", "부피") and width is not None and thick is not None: by[(a, b)] = width * thick got = by.get((a, b)) if got: return {"배": hm._json(got), "갈래": "추정"} return {"배": None, "갈래": "입력"} def quantity(line: dict[str, Any], want: Any, share: Any = None) -> dict[str, Any]: """단가산출 줄 수량 `{엑셀, 몫, 배, 갈래, 우리}` — 우리 = 엑셀 × 몫 × 배(배 None = 입력).""" got = factor(line.get("단위"), want, line.get("규격")) qty = line.get("수량") ours = None if qty is not None and got["배"] is not None: ours = _plain(Decimal(str(qty)) * Decimal(str(share or 1)) * Decimal(str(got["배"]))) return {"엑셀": qty, "몫": share, **got, "우리": ours}