"""M02 일위대가 실무자료 EST(엑셀꼴) 단가산출 어댑터(PLAN 776-2 · 요구 621-1 ② 정규화) — 엑셀 「단가산출」 시트 → 블록 `{번, 이름, 단위, 행, 출처, 직종, 글, 금액, 항목}`. 형식(EST 글)을 아는 곳은 여기까지. 블록 = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 · 출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 = 건설품셈 · 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · 글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리 (맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) · 금액 = 머리 줄 돈 칸(1 단위). 항목(Book 블록 항목) = 글 줄 풀이 — 줄 율(호표 줄 이름 · 규격)에 기대지 않는 것만 · 지문 단계가 율을 가름: L 직종 줄 「직종 : 단가 × 식 = 금액」(못 풀면 금액 ÷ 단가 「역산」 · `직종` = 우리 이름) · X 장비 = 장비 절 · 장비 글 줄 뒤 「노무비 · 경비 · 재료비 : 단가 ÷ Q」 — 절 · 장비마다 하나 (노무비(단가 > 0) > 경비 > 재료비 줄의 품) · 이름 = 장비 글 · 단가 = 비목마다 · M 자재 = 「이름 단가 × 수량 = 금액」 · 「이름 : 별도계상」(품 없음). 항목 = `{갈래, 이름, 규격, 품, 밑품, 율, 인자, 단가, 절, 절율, 어떻게, 행}`(L 은 + 직종) · STmate 어댑터(`M02_Hopyo_Stmate`)도 같은 꼴. 풀이 규칙은 지문(`M02_Hopyo_Finger._text`)과 같음 — 776-3 이 지문을 항목 위로 옮기면 그쪽 사본을 걷음. """ from __future__ import annotations import re from functools import lru_cache from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs SHEET = "단가산출" GLINES, GCHARS, WIDE = 200, 300, 140 _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} # 항목 풀이 — 지문(`Finger`)과 같은 규칙 COST = ("노무비", "재료비", "경비") SKIPW = frozenset( "소계 계 합계 인부소계 자재소계 주자재소계 운반 상차 기계 다짐 적사 살수 집토 암절취 채집 인력 " "장비 작업조건 1일시공량 작업면적 1시간작업량 기계채집 구조물터파기".split() ) _COLON = re.compile( r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P[^::=]{1,40}?)\s*[::]\s*(?P.*)$" ) _MAT = re.compile( r"^\s*(?P[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$" ) _SEC = re.compile(r"^\s*(\d+)\.\s*(.*)$") _SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$") _LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$") def _flat(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")) def _cell_text(value: Any) -> str: if isinstance(value, float) and value == int(value): value = int(value) return str(value).strip() if value is not None else "" # ── 이름 · 출처(두 어댑터 · 뒤 단계 공용) ──────────────────────────────── @lru_cache(maxsize=2) def _trade_names(whole_id: int) -> dict[str, str]: """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" out: dict[str, str] = {} for row in m01.loaded()[1].numbers.get("LB", {}).values(): name = str(row.get("이름") or "") out[_flat(name)] = name for old in row.get("옛이름") or []: out.setdefault(_flat(old), name) return out def trade_names() -> dict[str, str]: return _trade_names(id(m01.loaded()[1])) def trade(name: Any) -> str | None: """노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None.""" names, flat = trade_names(), _flat(name) return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat)) def sources(text: str) -> list[list[str]]: """글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」.""" out = [] for hit in _BOOK.finditer(text): num = labor_inputs._NUMBER.search(text, hit.end()) if not num or num.start() - hit.end() > 20: continue before = text[max(0, hit.start() - 12) : hit.start()] book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈" typo = _TYPO_CLAUSE.match(num.group()) number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group() if [book, number] not in out: out.append([book, number]) return out def rates_in(text: Any) -> set[float]: """글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1.""" t = str(text or "") out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t)} out = {v for v in out if 0 < v < 1} for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t): a, b = float(a), float(b) if a and b and a + b <= 20: out |= {round(a / (a + b), 4), round(b / (a + b), 4)} return out # ── 블록 읽기 ───────────────────────────────────────────────────────── def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) · 돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽).""" for at, row in enumerate(rows[:10]): flat = [_flat(v) for v in row] if "산출근거" in flat: last = len(flat) - 1 money = { key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat } return at + 1, flat.index("산출근거"), money return 0, 1, {} def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: """단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액, 항목}}` (행 = 엑셀 1부터).""" if not rows: return {} start, col, money = _columns(rows) end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE names = trade_names() blocks: dict[int, dict[str, Any]] = {} block: dict[str, Any] | None = None for at, row in enumerate(rows[start:], start + 1): text = row[col] if col < len(row) else None head = None if isinstance(text, str) and text.strip(): head = None if text[:1].isspace() else _HEAD.match(text) else: text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v)) if not text: continue if head: block = blocks.setdefault( int(head.group(1)), { "번": int(head.group(1)), "이름": head.group(2), "단위": head.group(3).strip(), "행": [at, at], "출처": [], "직종": [], "글": [], "금액": { key: v for key, c in money.items() for v in [row[c] if c < len(row) else None] if isinstance(v, (int, float)) and not isinstance(v, bool) }, "_글행": [], }, ) continue if block is None: continue block["행"][1] = at if len(block["글"]) < GLINES: block["글"].append(text.rstrip()[:GCHARS]) block["_글행"].append(at) block["출처"] += [one for one in sources(text) if one not in block["출처"]] trade_ = _TRADE.match(text) name = names.get(_flat(trade_.group(1))) if trade_ else None if name and name not in block["직종"]: block["직종"].append(name) for block in blocks.values(): block["항목"] = items(block["글"], block.pop("_글행")) return blocks # ── 글 → 항목 ───────────────────────────────────────────────────────── def _ratio(rhs: str) -> float | None: """식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용).""" m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs) n = _LASTNUM.search(rhs) if not m or not n: return None p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", "")) return a / p if p else None def _item(kind: str, name: str, got: dict[str, Any], how: str, **more: Any) -> dict[str, Any]: keep = {k: got.get(k) for k in ("품", "밑품", "율", "인자")} return {"갈래": kind, "이름": name, "규격": "", **keep, "어떻게": how, **more} def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]]: """블록 몸 글(머리 뺌) → 항목 목록(L · M 은 글 차례 · X 는 절 · 장비 처음 나온 차례로 뒤에).""" rows = rows or [0] * len(text) names = trade_names() out: list[dict[str, Any]] = [] machines: dict[tuple, dict[str, Any]] = {} sec, sub, mach, label = 0, "", False, "" sec_rates: set[float] = set() for raw, at in zip(text, rows, strict=True): row = str(raw).rstrip() h, s = _SEC.match(row), _SUB.match(row) if h or s: if h: sec, sub = int(h.group(1)), "" sec_rates = rates_in(h.group(2)) else: sub = s.group(1) sec_rates = sec_rates | rates_in(s.group(2)) title = (h or s).group(2) ts = mtypes(title) mach = bool(ts) label = title.strip() if ts else "" where = f"{sec}.{sub}" if sub else str(sec) place = {"절": where, "절율": sorted(sec_rates), "행": at} cm = _COLON.match(row) name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else "" who = names.get(name) if cm else None if who: rhs = cm.group("rhs") raw_name, how = cm.group("name").strip(), {"직종": who, **place} if got := parse_rhs(rhs): out.append(_item("L", raw_name, got, "식", 단가={"노무비": got["단가"]}, **how)) elif r := _ratio(rhs): got = {"품": r, "밑품": r, "율": [], "인자": []} out.append(_item("L", raw_name, got, "역산", **how)) continue if cm and name in COST: got = parse_rhs(cm.group("rhs")) if got and mach: key = (sec, sub, label) prio = 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2 one = machines.get(key) if one is None or prio < one["_순"]: price = one["단가"] if one else {} one = _item("X", label, got, "식", 단가=price, _순=prio, **place) machines[key] = one one["단가"][name] = got["단가"] continue if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")): got = {"품": None, "밑품": None, "율": [], "인자": []} out.append(_item("M", cm.group("name").strip(), got, "별도", **place)) continue mm = _MAT.match(row) if mm and not cm and (got := parse_rhs(mm.group("rhs"))): price = {"재료비": got["단가"]} out.append(_item("M", mm.group("name").strip(), got, "식", 단가=price, **place)) continue if not (h or s): ts = mtypes(row) if ts and "%" not in row and not cm: mach, label = True, row.strip() for one in machines.values(): del one["_순"] out.append(one) return out