diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index 2dd647063..01146e50a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -1,13 +1,8 @@ """M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 · 우리 돈 로직과 견준 근거 후보. -읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 -다음 머리 앞까지 한 블록 · -출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · -그 밖 = 건설품셈 · -직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · -글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은 -그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열). +읽기(`read` · `sources` · `trade_names` · `SHEET` · `GLINES` · `GCHARS`) = EST 어댑터 +`M02_Hopyo_ReadEst`(776-2 · 이름은 그대로 다시 냄). 견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · @@ -27,129 +22,25 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete.M02_Hopyo_ReadEst import ( # noqa: F401 — 읽기는 EST 어댑터(776-2) · 옛 이름 그대로 + GCHARS, + GLINES, + SHEET, + read, + sources, + trade_names, +) -SHEET = "단가산출" COMMON = "보통인부" BONUS_PARTS = ("공통", "토목") -_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 -GLINES, GCHARS, WIDE = 200, 300, 140 _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 -_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} 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 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 _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["출처"] += [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) - return blocks - - # ── 우리 쪽 조항 · 직종 ──────────────────────────────────────────────── @lru_cache(maxsize=2) def _ours(whole_id: int) -> dict[str, dict[str, Any]]: diff --git a/M02_MasterTemplete/M02_Hopyo_ReadEst.py b/M02_MasterTemplete/M02_Hopyo_ReadEst.py new file mode 100644 index 000000000..fa09e86cc --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_ReadEst.py @@ -0,0 +1,270 @@ +"""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 diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py index e81ea07ad..8773333ae 100644 --- a/M02_MasterTemplete/M02_Hopyo_Stmate.py +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -13,8 +13,11 @@ PR · 단위 % = % · 그 밖 기타. 단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 · 소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 · 비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) · -머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` · -직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름). +머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `ReadEst.sources` · +직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름) · +금액 = 머리 줄 C~F(1 단위) · 항목(776-2 · Book 블록 항목 · EST 어댑터 `M02_Hopyo_ReadEst` 와 +같은 꼴) = 자원 + `{직종(L), 밑품 = 품, 율 [], 인자 [], 단가 I~L, 금액 C~F, 어떻게 「QTY」, 행}` +(기계 셋 줄 = 더함). 검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`. """ @@ -23,8 +26,8 @@ from __future__ import annotations import re from typing import Any -from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀 +from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est SHEET = "일위대가표" CALC = "단가산출근거" @@ -39,6 +42,8 @@ _RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」 _RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」 _SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)") _COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10} +_CALC_AMOUNT = {"합계": 2, "노무비": 3, "재료비": 4, "경비": 5} # 단가산출근거 C~F 금액 +_CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~L 단가(T · L · M · S) NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23 _KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"} @@ -67,6 +72,17 @@ def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None: return None +def _calc_money(row: list[Any], cols: dict[str, int]) -> dict[str, int | float | None]: + return {k: hopyo_excel._out(v) for k, v in hopyo_excel._money(row, cols, 0).items()} + + +def _add(into: dict[str, Any], more: dict[str, Any]) -> None: + """기계 셋 줄(노무비 · 재료비 · 경비) 단가 · 금액을 한 항목에 더함.""" + for k, v in more.items(): + if v is not None: + into[k] = hopyo_excel._out(hopyo_excel._num(into.get(k) or 0) + hopyo_excel._num(v)) + + def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]: """목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸 「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열).""" @@ -86,8 +102,8 @@ def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]: # ── 단가산출근거 블록 ────────────────────────────────────────────────── def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]: - """{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}.""" - names = hopyo_basis.trade_names() + """{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원, 금액, 항목}}.""" + names = hopyo_est.trade_names() out: dict[str, dict[str, Any]] = {} block: dict[str, Any] | None = None title = "" @@ -109,6 +125,8 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str "행": [at, at], "글": [], "자원": [], + "금액": _calc_money(row, _CALC_AMOUNT), + "항목": [], }, ) title = "" @@ -135,30 +153,46 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str block["이름"], block["단위"] = name.strip() or text, unit.strip() continue if text and _flat(text) not in ("소계", "계"): - if len(block["글"]) < hopyo_basis.GLINES: - block["글"].append(text[: hopyo_basis.GCHARS]) + if len(block["글"]) < hopyo_est.GLINES: + block["글"].append(text[: hopyo_est.GCHARS]) if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text): title = sec.group(1).strip() if not code: continue + price, amount = _calc_money(row, _CALC_PRICE), _calc_money(row, _CALC_AMOUNT) last = block["자원"][-1] if block["자원"] else None if code[0] == "X" and last and last["코드"] == code and last["절"] == title: - continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 + _add(block["항목"][-1]["단가"], price) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 + _add(block["항목"][-1]["금액"], amount) + continue name, _, spec = _s(_cell(row, NAME_COL)).partition("/") info = table.get(code) or {} qty = hopyo_excel._num(_cell(row, QTY_COL)) - block["자원"].append( + res = { + "갈래": code[0], + "코드": code, + "이름": name.strip() or info.get("이름", ""), + "규격": spec.strip() if name.strip() else info.get("규격", ""), + "품": float(qty) if qty is not None else None, + "절": title, + } + block["자원"].append(res) + who = hopyo_est.trade(res["이름"]) if code[0] == "L" else None + block["항목"].append( { - "갈래": code[0], - "코드": code, - "이름": name.strip() or info.get("이름", ""), - "규격": spec.strip() if name.strip() else info.get("규격", ""), - "품": float(qty) if qty is not None else None, - "절": title, + **res, + **({"직종": who} if code[0] == "L" else {}), + "밑품": res["품"], + "율": [], + "인자": [], + "단가": price, + "금액": amount, + "어떻게": "QTY", + "행": at, } ) for block in out.values(): - block["출처"] = hopyo_basis.sources(" ".join(block["글"])) + block["출처"] = hopyo_est.sources(" ".join(block["글"])) trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"] block["직종"] = list(dict.fromkeys(t for t in trades if t)) return out @@ -257,7 +291,20 @@ def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]: if not hopyo["근거글"]: del hopyo["근거글"] out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]}) - keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원") + keep = ( + "번", + "코드", + "이름", + "규격", + "단위", + "행", + "출처", + "직종", + "글", + "자원", + "금액", + "항목", + ) return { "꼴": "STmate", "시트": SHEET, diff --git a/resources/tester/fixtures/m02_hopyo_stages.json b/resources/tester/fixtures/m02_hopyo_stages.json new file mode 100644 index 000000000..daa604e4a --- /dev/null +++ b/resources/tester/fixtures/m02_hopyo_stages.json @@ -0,0 +1,518 @@ +{ + "북부청임도": { + "S1": { + "해시": "2a4df1c8e7a2", + "꼴": "엑셀꼴", + "호표": 68, + "줄": 415, + "블록": 95, + "갈래": { + "%": 1, + "기계": 3, + "단가산출": 268, + "별도계상": 64, + "자재": 78, + "호표": 1 + }, + "참조": { + "참조줄": 1, + "부르는": 1, + "불리는": 1, + "깊이": { + "1": 67, + "2": 1 + }, + "순환": 0 + } + }, + "S2": { + "단산:46|레미콘타설(장비사용)|철근구조물-소량타설|m3": "14b94bd44235", + "단산:24|합판거푸집|4회|m2": "dc9227913b7d", + "단산:33|기초잡석채집,운반,다짐|발파암활용,덤프15톤,l=500m|m3": "569f93218071", + "단산:47|철근가공조립(현장)|type-i(소량시공)|톤": "f333a56a3d0e", + "단산:6|야면석채집및운반(계곡내)|굴삭기0.6m3,35cm,4.5톤,l=500m|m2": "06a8c10d89e7", + "단산:3|고임돌채집및운반|굴삭기0.6m3(90%),4.5톤,l=500m|m3": "4c1b4b4cb599", + "단산:10|야면석소운반|l=35cm|m2": "e96d400b173b", + "단산:39|야면석찰붙임|l3=35cm적용,장비사용|m2": "e781e3163b81", + "단산:93|구조물터파기(인력100%)|토사|m3": "c523b43c617b", + "단산:12|구조물되메우기(인력100%)||m3": "559c125ef42e", + "단산:13|잔토처리(인력100%)||m3": "eb0395c76e02", + "단산:22|레미콘타설(장비사용)|무근구조물|m3": "0c3cebbe76de", + "단산:26|유로폼|보통,0~7m|m2": "a301adcd84cd", + "단산:18|문양거푸집||m2": "9f4efb5510bf", + "단산:37|p.v.c파이프설치|ø50mm|m": "a408f8e02b28", + "단산:38|다웰바설치|ø32x1000|개": "13410747be6e", + "단산:41|휠라설치|t=15mm,1000x1500mm|m2": "4df1b309aca4", + "단산:29|철근가공조립(현장)|연결철근,type-ix30%적용|톤": "2b96b0b322d2", + "단산:30|큰돌채집운반|공사중발생석활용50%적용|m2": "a0d7114b5599", + "단산:32|큰돌쌓기(메쌓기)|직경40cm이상~60cm미만|m2": "06881edcaad6", + "단산:2|뒷채움돌채집운반뒷채움(발파)|굴삭기0.6m3(90%),4.5톤,l=500m|m3": "8d60db527bae", + "단산:31|큰돌쌓기(찰쌓기)_배수공|직경40cm이상~60cm미만|m2": "c0610580cae7", + "단산:21|레미콘타설(인력운반)|무근구조물-소량타설|m3": "7ee6fe1e8d9d", + "단산:25|합판거푸집|6회|m2": "b5b6ec2a0beb", + "단산:42|도로반사경설치|d=1000(sts)|개소": "e521922cb4ba", + "단산:88|야면석메붙임|l3=35cm내외,장비사용|m2": "91ac9e8d76f7", + "단산:92|큰돌메붙임|직경40cm이상~60cm미만|m2": "26e6c1b118c5", + "단산:91|큰돌찰붙임|직경40cm이상~60cm미만|m2": "bd288a8d830f", + "단산:57|레미콘타설(장비사용)|소형구조물|m3": "b4bee2367968", + "단산:71|야면석찰쌓기|l3=35cm적용,장비사용|m2": "1649ce3151fb", + "단산:43|면고르기||m2": "f45d3e3af178", + "줄:대형브레이카|+굴착기(0.7m3)|시간": "fc11668f29eb", + "단산:60|와이어메시바닥깔기|#6100x100|m2": "567c16874a89", + "단산:62|원형돌망태설치및돌채움|#8,45x45cm|m2": "9ad93f86f385", + "단산:61|조약돌채집및운반|굴삭기0.6m3(90%),4.5톤,l=500m|m3": "ab7d8e14de22", + "단산:63|돌망태형옹벽설치|사각돌망태|m2": "6487dbf12ab6", + "단산:65|콘크리트포장(인력)|t=20cm,종단15%미만|m3": "2c48d8cc2468", + "단산:64|비탈면보호공|평떼|m2": "5d9d68fa8679", + "단산:66|콘크리트포장(인력)|t=20cm,종단15%이상|m3": "2374789b6ae0", + "단산:70|고화매트설치||m2": "80be3cc78047", + "단산:90|큰돌쌓기(찰쌓기)|직경40cm이상~60cm미만|m2": "52a0f47bb751", + "단산:23|레미콘타설(장비사용)|철근구조물|m3": "fe0d22d627da", + "단산:73|강관동바리|암거용-1개월사용|공/m3": "bc3f5092d701", + "단산:72|철근가공조립(현장)|type-ll|톤": "75dda2f569a2", + "단산:74|강관비계|10m이하,3개월사용|m2": "90ce7f0da95c", + "단산:49|흙깍기(토사)|굴삭기1.0m3,보통토사|m3": "f9aea56b06bf", + "단산:56|잔토정리|굴삭기0.6m3|m3": "9f76f0e32710", + "단산:82|잡철물제작설치|규격철물설치(일반철재)|톤": "531650fd115f", + "단산:95|도장(철재면2회)|방청1,유성2|m2": "92867210fce0", + "단산:81|임도안전표지판설치|삼각,900mm|개소": "ee09c0727967" + } + }, + "북부청사방": { + "S1": { + "해시": "54e46e934128", + "꼴": "엑셀꼴", + "호표": 27, + "줄": 201, + "블록": 67, + "갈래": { + "%": 1, + "기계": 1, + "노임": 2, + "단가산출": 139, + "별도계상": 18, + "자재": 38, + "호표": 2 + }, + "참조": { + "참조줄": 2, + "부르는": 2, + "불리는": 2, + "깊이": { + "1": 25, + "2": 2 + }, + "순환": 0 + } + }, + "S2": { + "단산:22|레미콘타설(장비사용)|철근구조물|m3": "e2940f403c6c", + "단산:20|레미콘타설(장비사용)|무근구조물|m3": "d0347d9e519e", + "단산:38|유로폼(euroform)|6회,보통|m2": "0cd2081fa29f", + "단산:53|철근가공조립(현장)|type-ii|톤": "d6d92c499d78", + "단산:4|강관비계|(10m이하)|m2": "7c1c9f2d283a", + "단산:3|강관동바리|(2.5m이하)|공/m3": "83d2a92252c6", + "단산:37|유로폼(euroform)|6회,간단|m2": "d6ac6fccfcfd", + "단산:52|철근가공조립(현장)|type-i(소량)|톤": "e3d8470f9851", + "단산:49|철강재운반(할증포함)|(구역화물)|톤": "a619ce7fe816", + "단산:51|철골설치||톤": "8e292d7859f8", + "단산:50|철골가공조립(공장)|(대형)|톤": "b386a69202ef", + "단산:42|잡철물제작||톤": "ef730a79e03f", + "단산:67|합판거푸집(6회)||m2": "b3ba20147436", + "단산:33|와이어메시바닥깔기||m2": "2f898ef1909d", + "단산:11|구조물터파기(토사)||m3": "5b2c549e3ec2", + "단산:8|구조물되메우기및다짐||m3": "184c85a346db", + "단산:39|잔토처리||m3": "147afeac7acd", + "단산:21|레미콘타설(장비사용)|무근구조물-소량타설|m3": "6b7edafbfd22", + "단산:66|합판거푸집(4회)||m2": "39bdcddca57c", + "단산:12|기초잡석다짐및채집운반|(9:1)(0.6m3+4.5ton)|m3": "dc7a46817506", + "단산:26|배수관부설|(d=1000mm)|m": "e6f492785d36", + "호표:7": "9238de708278", + "단산:19|레미콘타설(인력운반)|무근구조물-소량타설|m3": "728a28bc9224", + "단산:9|구조물터파기|인력|m3": "06855a4c159a", + "단산:7|구조물되메우기|인력|m3": "8d23c8175496", + "단산:40|잔토처리|인력|m3": "e62dedcec317", + "단산:35|원형돌망태조립설치|산림사업품셈|조": "05d490e21236", + "단산:36|원형돌망태채움|산림사업품셈|조": "a4b290a399fd", + "단산:43|조약돌채집운반|(9:1)(0.6m3+4.5ton)|m3": "3d10f7bb409b", + "단산:10|구조물터파기(암)||m3": "884a1241cb59", + "단산:27|배수관부설|(d=500mm)|m": "e00f055d8b21", + "단산:18|레미콘타설(무근펌프카)(130m3)|41m(80-95m3/hr)|m3": "07d3e7ce5cf4", + "줄:콘크리트펌프차|41m,80~95m3/hr|시간": "6dfaed1dd20a", + "단산:25|문양거푸집||m2": "fba1f776a83c", + "단산:46|지수판설치|산림사업품셈|m": "3ddb72dcd81d", + "단산:1|p.v.c파이프설치|산림사업품셈|m": "ec1cd531e2e6", + "단산:17|떼붙임(평떼)|산림사업품셈|m2": "fdd2e6bd02f0", + "단산:60|큰돌메쌓기|40cm-60cm,(산림사업품셈)|m2": "b44908334fae", + "단산:5|고임돌채집및운반|(9:1)(0.6m3+4.5ton)|m3": "6d67aa013e60", + "단산:16|뒷채움잡석채집운반다짐|(9:1)(0.6m3+4.5ton)|m3": "2f37d4203fdc", + "단산:62|큰돌찰쌓기|40cm-60cm,(산림사업품셈)|m2": "6b65c0b531df", + "단산:63|큰돌채집운반|산림사업품셈|m2": "09662a8f8d36", + "단산:13|뒷채움잡석구입운반||m3": "797df59d7756", + "단산:15|뒷채움잡석뒷채움||m3": "012a67631d07", + "단산:14|뒷채움잡석구입운반|(창봉기번3)(2차)|m3": "42b81776b3e4", + "단산:59|큰돌메붙임|40cm-60cm,(산림사업품셈)|m2": "cc09e80f4533", + "단산:61|큰돌찰붙임|40cm-60cm,(산림사업품셈)|m2": "469d3c56b621" + } + }, + "거창": { + "S1": { + "해시": "bf450b2215f9", + "꼴": "엑셀꼴", + "호표": 14, + "줄": 68, + "블록": 61, + "갈래": { + "%": 6, + "기계": 3, + "노임": 10, + "단가산출": 21, + "별도계상": 3, + "자재": 13, + "호표": 12 + }, + "참조": { + "참조줄": 12, + "부르는": 6, + "불리는": 6, + "깊이": { + "1": 8, + "2": 4, + "3": 2 + }, + "순환": 0 + } + }, + "S2": { + "줄:굴삭기(타이어형)|0.6m3|시간": "7e2ab592356e", + "호표:1": "1f6345e0085e", + "호표:2": "dacbaeeaa6c9", + "호표:3": "03d873100e35", + "단산:30|유로폼|보통,0~7m|m2": "e278a75b7213", + "단산:38|철근가공및조립(현장)|type-ii-1|톤": "4ea51eae487f", + "단산:39|강관비계|10m이하(3개월)|m2": "769cb5147b54", + "단산:29|유로폼|간단,0~7m|m2": "dcf229abaa91", + "호표:10": "fc2cc4cd2868", + "호표:11": "998a0263fed9", + "단산:36|레미콘타설(장비사용타설)|무근구조물|m3": "39982206412d", + "단산:40|레미콘타설(장비사용타설)|철근구조물|m3": "41c11b60b1e1", + "단산:41|합판거푸집|4회|m2": "dfd5ab302d05", + "단산:31|철근가공및조립(현장)|type-i-1(소형)|톤": "a98e998b35c0", + "단산:42|강관동바리|2.5m이하(1개월)|공m3": "99d65ebd3dcc", + "단산:43|잡철물제작및설치(현장)|일반철재|톤": "71c1f965449f", + "단산:44|안내표지판설치|단주식|개소": "38427ebd6d28", + "단산:37|레미콘타설(인력운반타설)|소형구조물|m3": "6c2df8e4fd6a", + "단산:45|합판거푸집|6회,간단|m2": "85dcc3e13e28" + } + }, + "봉화": { + "S1": { + "해시": "fcf03906734a", + "꼴": "STmate", + "호표": 45, + "줄": 209, + "블록": 65, + "갈래": { + "%": 7, + "기계": 7, + "노임": 30, + "단가산출": 103, + "별도계상": 7, + "자재": 33, + "호표": 22 + }, + "참조": { + "참조줄": 22, + "부르는": 18, + "불리는": 19, + "깊이": { + "1": 27, + "2": 14, + "3": 4 + }, + "순환": 0 + } + }, + "S2": { + "줄:굴삭기(타이어)|0.6m3,(부착용집게)|시간": "7ba959ec2e9d", + "호표:1": "ee8a116be0f0", + "단산:96|레미콘타설(장비)|무근,소형|m3": "fcbfc0642dc2", + "단산:102|합판거푸집(간단)|간단|m2": "70042bc48cb6", + "단산:4|글자새김|자당6x6|자": "ff7e1309d192", + "단산:2|구조물터파기(토사)|굴삭기0.2m3|m3": "2ce888963761", + "단산:7|구조물되메우기|굴삭기0.2m3|m3": "c72b4a1494d6", + "단산:49|잔토처리|굴삭기0.2m3|m3": "800b67112b8d", + "호표:5": "566b3ec95fef", + "단산:43|깬잡석채집|l=45cm내외|m2": "6c67d6e7d946", + "단산:5|고임돌채집|기계(0.7m3,100%)|m3": "304739062d51", + "단산:6|막자갈채집|기계(0.7m3,100%)|m3": "b149f96f7918", + "단산:8|기초다짐및뒤채움|소형장비사용시공|m3": "d1e2a67c4989", + "단산:93|유로폼설치및해체|간단|m2": "5174b34972ac", + "단산:95|레미콘타설(장비)|무근|m3": "1e45fdfca996", + "단산:89|콘크리트믹서사용|0.45m3|m3": "71825ddc35da", + "단산:94|유로폼설치및해체|보통|m2": "1985ee7ba47c", + "단산:98|철근가공및조립|type-i-1,현장가공조립|톤": "f91f3febebf1", + "호표:9": "992b00a50c65", + "호표:13": "2ecc59f87b37", + "단산:51|깬잡석채집|l=30cm내외|m2": "d23b63b9635a", + "단산:12|콘크리트(기계비빔)|무근구조물|m3": "c9ca9357a44a", + "호표:17": "b50496d3f922", + "호표:18": "d796d9bbd4bd", + "호표:22": "2c27c0d9b1e1", + "단산:23|구조물터파기(육상토사0~1m)|기계90%+인력10%(굴삭기0.7m3)|m3": "1e4b4e03372f", + "단산:24|잔토처리|굴삭기0.7m3|m3": "b34905b7e0c2", + "줄:굴삭기(타이어)|0.6m3|시간": "733bdb0730f2", + "호표:24": "24b7a61d2cac", + "단산:3|깬잡석채집|l=35cm내외|m2": "6fa38565606b", + "호표:26": "253290e88b9f", + "단산:52|깬잡석채집|l=60cm내외|m2": "daab119d679a", + "호표:28": "10cf341d2ca3", + "단산:81|철근가공및조립(현장)|일반(type-i-1)|톤": "39d3a7e2e7a9", + "호표:31": "82b74ab253f3", + "단산:77|합판거푸집|3회(복잡)|m2": "deaabc9a391f", + "호표:33": "35144fc92a5c", + "호표:34": "9b6cffd83699", + "호표:35": "4eb05246e0c3", + "단산:10|레미콘타설(장비사용)|소형구조물|m3": "8717ad5b8652", + "단산:90|평떼붙임||m2": "13cce5a5166b", + "단산:91|단끊기||m": "8701e94849ea", + "단산:97|레미콘타설(장비)|철근|m3": "6c8931bf2865", + "단산:103|레미콘타설(장비)|무근,버림|m3": "c99fd33aad21", + "단산:99|철근가공및조립|type-ii-1,현장가공조립|톤": "e6dc6a25683b", + "단산:113|강관동바리|암거용-3개월사용|공/m3": "55bd7c4f030a", + "단산:112|강관비계|3개월사용|m2": "2390793aeb4b", + "호표:45": "ca2600044054" + } + }, + "동부청영월": { + "S1": { + "해시": "5dd9ce597d5d", + "꼴": "STmate", + "호표": 23, + "줄": 138, + "블록": 69, + "갈래": { + "기계": 1, + "노임": 3, + "단가산출": 90, + "별도계상": 18, + "자재": 23, + "호표": 3 + }, + "참조": { + "참조줄": 3, + "부르는": 3, + "불리는": 2, + "깊이": { + "1": 20, + "2": 2, + "3": 1 + }, + "순환": 0 + } + }, + "S2": { + "호표:1": "7cf2b5da2c8d", + "단산:2058|무근콘크리트타설|인력운반타설|m3": "b661921b3a81", + "단산:56|합판거푸집(6회)|간단|m2": "6275e4b9a5fd", + "단산:2|구조물터파기(인력)|경질토사|m3": "706e398f653a", + "단산:7|구조물되메우기|(인력)|m3": "daf607a62e39", + "단산:9|잔토처리|인력100%|m3": "aef7e1b84e30", + "줄:크레인(타이어)|10톤|시간": "96a1f07e863b", + "호표:3": "6c59b8689986", + "단산:3|구조물터파기|토사(b/h90%+인력10%)|m3": "acefa16cc78a", + "단산:10|잔토처리|토사(b/h100%)|m3": "c425e33125c6", + "단산:37|콘크리트타설|임도품셈5-5|m3": "65c3e427122f", + "단산:59|유로폼설치,해체(복잡)|수직고7.0m이하|m2": "6b60ee0cf532", + "단산:60|철근가공조립(현장)|typei|톤": "d1c00f23be62", + "단산:600|기초잡석채집및운반부설|4.5톤(l=500)|m3": "91acb80ac602", + "단산:1926|임도안전표지판설치|삼각,d=900|개소": "de7833d5056a", + "단산:411|야면석채집운반|(25x25x35cm)장비|m2": "aeaa86e728d4", + "단산:644|야면석찰붙임|뒷길이35cm|m2": "6771a23d197b", + "단산:1361|고임돌채집운반|4.5톤덤프(l=500)|m3": "203a9315635b", + "단산:1928|잡석운반및다짐||m3": "3a7b79a11b47", + "단산:1927|면고르기||m2": "3d642227dc7b", + "단산:1942|구조물터파기|인력(경질토사)|m3": "b25a97237575", + "단산:1759|도로반사경설치|d=1000(sts)|개소": "8f206fca6ae6", + "단산:2071|철근콘크리트타설|장비사용타설|m3": "7a4603d42158", + "단산:58|유로폼설치,해체(보통)|수직고7.0m이하|m2": "de90b05e1449", + "단산:1854|지수판설치(pvc용접)|200x5t|m": "3aec0042517c", + "단산:1855|배수관부설및접합(p.v.c)|ø50mm-vg2일반관|m": "7200d398af99", + "단산:1466|야면석소운반(l=35cm)|(l=20m)|m2": "79a4f66ac624", + "단산:450|콘크리트소운반|(l=20m)|m3": "9d06f1876010", + "단산:1843|야면석메붙임|뒷길이35cm|m2": "2e66aa2c2197", + "단산:258|측구수로관부설|500∼700kg미만|m": "56683ae7c5bb", + "단산:1844|콘크리트타설|장비사용타설(소형)|m3": "cdd8f38898be", + "단산:1841|합판거푸집(4회)|보통|m2": "154931aee1b7", + "단산:2001|혼합골재부설|영월-산불|m3": "3c98157c0505", + "단산:1748|콘크리트포장(인력)|t=20cm|m3": "68e42fbf6871", + "단산:1752|콘크리트포장절단공|임도|m": "50eb38496dbf", + "단산:1519|뒷채움돌채집운반부설|4.5톤덤프트럭(l=500m)|m3": "17e1fe46f3c8", + "단산:81|큰돌메쌓기|뒷길이0.6~0.8m|m2": "99f8c85542df", + "단산:1810|큰돌채집운반(뒷길이0.6~0.8m)|장비(80%)+인력(20%)|m2": "8c9b791dab4b" + } + }, + "울진대흥": { + "S1": { + "해시": "a2052e0b170c", + "꼴": "STmate", + "호표": 34, + "줄": 171, + "블록": 47, + "갈래": { + "%": 3, + "기계": 11, + "노임": 29, + "단가산출": 69, + "별도계상": 16, + "자재": 15, + "호표": 28 + }, + "참조": { + "참조줄": 28, + "부르는": 17, + "불리는": 8, + "깊이": { + "1": 17, + "2": 17 + }, + "순환": 0 + } + }, + "S2": { + "호표:1": "7cf20aa31fa9", + "호표:2": "cac691e8d485", + "줄:굴삭기+부착용집게(0.6m3)||시간": "68272419c2ca", + "호표:3": "fb9671860caf", + "호표:4": "9b0429327796", + "호표:5": "5e88ce0e4c68", + "호표:6": "31bd5eea19c8", + "단산:75|콘크리트믹서사용|0.45m3|m3": "bb028865ae10", + "호표:7": "3476f12e55bf", + "호표:8": "b4f5087c15f6", + "호표:9": "51afe3b087b1", + "호표:10": "fb1dd2cf4c6a", + "줄:굴삭기(0.7m3)||시간": "9c5792c9e5eb", + "호표:11": "af2de3ed3f1c", + "단산:1124|고임돌채집|기계|m3": "d92fb83f5e54", + "단산:159|뒷채움|150m/m내외|m3": "6c3ab2cbc163", + "단산:959|막자갈채집|기계|m3": "f84d5f824e2d", + "단산:2200|콘크리트비빔|채움재|m3": "91472fd9fe57", + "줄:트럭탑재형크레인|5톤|시간": "71cbed958600", + "호표:17": "ad84568a1c33", + "호표:18": "6f1c4c076fd4", + "단산:1129|야면석채집|l3=45cm내외|m2": "629829a9d347", + "단산:2119|야면석채집|l3=35cm내외|m2": "245dfe68e79f", + "단산:1343|구조물터파기(토사)|굴삭기0.7m3|m3": "aa20475c3d30", + "단산:2139|다지기|굴삭기0.7m3|m3": "e1a21a5a7744", + "단산:2195|유로폼설치및해체|간단|m2": "2ada005f71d9", + "단산:2262|레미콘타설|무근(장비사용타설)|m3": "0011fde861d6", + "호표:28": "fd2ef8bc220b", + "줄:굴삭기(타이어)|0.6m3,(부착용집게,0.7~0.8m3)|시간": "94a580e075dd", + "호표:29": "02e551e5e700", + "호표:30": "92eda2cf44f5", + "단산:2248|콘크리트포장(b-type)|t=20cm|m2": "ad040befe954", + "단산:2251|철근가공및조립|type-ii-2,현장가공조립|톤": "b878bf35ed64", + "단산:2246|유로폼설치및해체|보통|m2": "1985ee7ba47c" + } + }, + "영덕계류": { + "S1": { + "해시": "3d03cb61741c", + "꼴": "STmate", + "호표": 9, + "줄": 31, + "블록": 15, + "갈래": { + "%": 3, + "기계": 5, + "노임": 14, + "단가산출": 2, + "자재": 4, + "호표": 3 + }, + "참조": { + "참조줄": 3, + "부르는": 2, + "불리는": 3, + "깊이": { + "1": 7, + "2": 2 + }, + "순환": 0 + } + }, + "S2": { + "줄:굴착기(타이어)|0.6m3,(부착용집게)|시간": "9c24ee2c8c3d", + "호표:5": "b534ae76dc78", + "호표:6": "e3b9e469607e", + "호표:7": "1cd3bb8c5e6d", + "호표:8": "f5f08c081e83", + "단산:150|고임돌채집|기계|m3": "6156ce99f6a7", + "호표:10": "c5b7c43c3ee1", + "단산:1453|인력운반|기타|톤": "355f5eac9936", + "호표:12": "c46a6d7e568f", + "호표:13": "d4bfe9643e53" + } + }, + "울진소광": { + "S1": { + "해시": "af7a97c94ed7", + "꼴": "STmate", + "호표": 25, + "줄": 117, + "블록": 40, + "갈래": { + "%": 9, + "기계": 5, + "노임": 17, + "단가산출": 43, + "별도계상": 9, + "자재": 15, + "호표": 19 + }, + "참조": { + "참조줄": 19, + "부르는": 11, + "불리는": 13, + "깊이": { + "1": 14, + "2": 10, + "3": 1 + }, + "순환": 0 + } + }, + "S2": { + "단산:1352|유로폼설치및해체|보통|m2": "2b8ac7770966", + "호표:2": "217ebea3fe00", + "호표:3": "98a36785cfcd", + "호표:4": "a3d428a8fc6a", + "줄:굴삭기+부착용집게(06)||시간": "c7e3b6e6cbea", + "호표:6": "c5ca59b8e3e9", + "호표:7": "e37dd3ced06c", + "줄:트럭탑재형크레인|5톤|시간": "657e83282c02", + "호표:8": "a902f332f29c", + "단산:1351|유로폼설치및해체|간단|m2": "86c99fa46000", + "호표:11": "b3ae0549b0fd", + "호표:12": "39f4044d3634", + "줄:굴삭기(타이어)|0.6m3|시간": "733bdb0730f2", + "호표:13": "af5f52dc7ecb", + "단산:1456|구조물터파기(토사)|굴착기0.6m3|m3": "5369cb770862", + "단산:1474|구조물되메우기|굴착기0.6m3|m3": "c42bf66f9e58", + "단산:1475|구조물잔토처리|굴착기0.6m3|m3": "ea935c401ec0", + "단산:492|채움콘크리트|기계비빔|m3": "76df4f4c944d", + "단산:91|고임돌채집|굴삭기0.7m3|m3": "445470e48368", + "단산:408|막자갈(파쇄석)채집||m3": "6f29521df826", + "단산:867|막자갈채우기|150mm내외|m3": "f77710424c09", + "단산:1336|레미콘타설|무근(장비)|m3": "5ff7079ff5a0", + "단산:918|채움con'c|기계비빔|m3": "0c75dd9a29d2", + "단산:1448|파쇄석채집|l3=35cm내외|m2": "5d1cf0f37342", + "단산:1030|고임돌채집|굴삭기0.2m3|m3": "f483cf51e53b", + "단산:1354|콘크리포장(t=20cm)포설|인력포설|m3": "ec05dd9a542f", + "단산:794|단끊기|절취없음|m": "6aa07c8cba17", + "단산:1329|평떼붙이기||m2": "0522e960d244", + "단산:1129|떼하차비||m2": "5329db32bc8a" + } + } +} diff --git a/resources/tester/helper_m02_hopyo_golden.py b/resources/tester/helper_m02_hopyo_golden.py index 96a51a3da..24ae166a8 100644 --- a/resources/tester/helper_m02_hopyo_golden.py +++ b/resources/tester/helper_m02_hopyo_golden.py @@ -1,8 +1,7 @@ """338 골든 도우미 — 실물 엑셀(`resources/knowledge/original/실무문서`)을 지금 읽기로 읽어 정답 표 (`fixtures/m02_hopyo_golden.json`)와 맞대고 지표(설계 7-3)를 셈. 읽기만. -블록 글 = HP 블록에 `글` 이 있으면 그것(나 창 2a 뒤) · 없으면 단가산출 시트 산출근거 열 글(엑셀꼴) · -거창 = 줄 칸을 이어 붙임. STmate 파일은 읽기(`M02_Hopyo_Excel.read_book`)가 되면 들어감 · 아니면 뺌. +블록 글 = 읽기가 HP 블록에 붙인 `글`(EST) · `자원`(STmate) 그대로. python resources/tester/helper_m02_hopyo_golden.py → 지표 찍음 · tmp/m02_338/golden.json """ @@ -20,7 +19,6 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402 @@ -55,35 +53,14 @@ def labels() -> list[dict[str, Any]]: return json.loads(FIXTURE.read_text(encoding="utf-8")) -def _row_text(row: list, start: int) -> str: - cells = [] - for v in row[start:140]: - if v is None or v == "": - continue - cells.append(str(int(v) if isinstance(v, float) and v == int(v) else v).strip()) - return " ".join(cells) - - @lru_cache(maxsize=8) def read(tag: str) -> dict[str, Any] | None: - """파일 → HP 꼴(블록 `글` 붙임) · 못 읽으면 None(STmate 읽기 전).""" + """파일 → HP 꼴 · 못 읽으면 None.""" path = FILES[tag] - data = path.read_bytes() try: - book = hx.read_book(path.name, data) + return hx.read_book(path.name, path.read_bytes()) except hx.ExcelError: return None - if any(b.get("글") or b.get("자원") for b in book.get("단산") or []): - return book - calc = hx._sheets(path.name, data).get(hb.SHEET) or [] - _, col, _ = hb._columns(calc) - join = tag == "거창" - for b in book.get("단산") or []: - a, z = b["행"] - rows = calc[a:z] - text = [_row_text(r, col) if join else (r[col] if col < len(r) else "") for r in rows] - b["글"] = [str(t).rstrip() for t in text if isinstance(t, str) and t.strip()] - return book def _keys(line: dict[str, Any]) -> list[tuple]: diff --git a/resources/tester/test_621_hopyo_stages.py b/resources/tester/test_621_hopyo_stages.py new file mode 100644 index 000000000..626584585 --- /dev/null +++ b/resources/tester/test_621_hopyo_stages.py @@ -0,0 +1,187 @@ +"""621 단계 고정(776-1) — 여덟 실물 엑셀의 단계 출력을 해시로 굳힘. 읽기만 · 서버 없음. + +S1 = 정규화 출력(HP · 뒤 Book) 공통 칸 투영 해시 + 호표 · 줄 · 블록 수 · 줄 갈래 · 호표 참조 그래프. + 투영 = 지금 HP 칸만(KEEP) — Book 에 칸을 더해도 그대로 · 빼거나 이름을 바꾸면 깨짐. +S2 = 셈 단위(`Fit.units`)마다 지문(`Finger.excel`) 해시. +옮기는 커밋마다 그대로여야 함 — 깨지면 그 커밋을 되돌림. 일부러 바꾼 걸음만 새 판으로 굳힘: +python resources/tester/test_621_hopyo_stages.py --write +""" + +from __future__ import annotations + +import hashlib +import json +import sys +import warnings +from collections import Counter +from functools import lru_cache +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Finger as finger # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Fit as fit # noqa: E402 +from resources.tester.helper_m02_hopyo_golden import FILES # noqa: E402 + +warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") +FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_stages.json" + +# 지금 HP 칸 — 층마다 이 칸만 해시에 넣음(새 칸은 투영 밖) +KEEP = { + "책": ("꼴", "시트", "호표", "단산", "검산"), + "호표": ("번", "코드", "이름", "규격", "단위", "금액", "행", "근거글", "줄"), + "줄": ( + "차례", + "이름", + "규격", + "수량", + "단위", + "단가", + "금액", + "비고", + "코드", + "행", + "갈래", + "참조", + "근거", + "이음", + ), + "참조": ("종류", "번", "코드"), + "근거": ("출처", "직종", "행"), + "단산": ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원"), + "자원": ("갈래", "코드", "이름", "규격", "품", "절"), +} + + +def _h(x: Any) -> str: + raw = json.dumps(x, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(raw.encode()).hexdigest()[:12] + + +def _pick(d: dict, level: str) -> dict: + return {k: d[k] for k in KEEP[level] if k in d} + + +def project(hp: dict[str, Any]) -> dict[str, Any]: + """HP · Book → 지금 칸만.""" + out = _pick(hp, "책") + hopyo = [] + for h in hp["호표"]: + row = _pick(h, "호표") + lines = [] + for line in h["줄"]: + got = _pick(line, "줄") + for k in ("참조", "근거"): + if isinstance(got.get(k), dict): + got[k] = _pick(got[k], k) + lines.append(got) + row["줄"] = lines + hopyo.append(row) + out["호표"] = hopyo + blocks = [] + for b in hp.get("단산") or []: + got = _pick(b, "단산") + if got.get("자원"): + got["자원"] = [_pick(r, "자원") for r in got["자원"]] + blocks.append(got) + out["단산"] = blocks + return out + + +def ref_graph(hp: dict[str, Any]) -> dict[str, Any]: + """호표 참조(일위) 줄 · 부르는 · 불리는 호표 · 깊이 분포(1 = 참조 없음) · 순환.""" + kids: dict[int, list[int]] = {int(h["번"]): [] for h in hp["호표"]} + for h in hp["호표"]: + for line in h["줄"]: + ref = line.get("참조") or {} + if ref.get("종류") == "일위": + kids[int(h["번"])].append(int(ref["번"])) + depth: dict[int, int] = {} + loops = 0 + + def walk(no: int, path: tuple[int, ...]) -> int: + nonlocal loops + if no in path: + loops += 1 + return 0 + if no not in depth: + depth[no] = 1 + max((walk(k, (*path, no)) for k in kids.get(no, [])), default=0) + return depth[no] + + for no in kids: + walk(no, ()) + return { + "참조줄": sum(len(v) for v in kids.values()), + "부르는": sum(1 for v in kids.values() if v), + "불리는": len({k for v in kids.values() for k in v}), + "깊이": dict(sorted(Counter(str(d) for d in depth.values()).items())), + "순환": loops, + } + + +def s1(hp: dict[str, Any]) -> dict[str, Any]: + lines = [line for h in hp["호표"] for line in h["줄"]] + return { + "해시": _h(project(hp)), + "꼴": hp["꼴"], + "호표": len(hp["호표"]), + "줄": len(lines), + "블록": len(hp.get("단산") or []), + "갈래": dict(sorted(Counter(str(x.get("갈래")) for x in lines).items())), + "참조": ref_graph(hp), + } + + +def s2(hp: dict[str, Any]) -> dict[str, str]: + out = {} + for key, block, line in fit.units(hp): + fp = block["_지문"] if block and "_지문" in block else finger.excel(block, line) + out[key] = _h(fp) + return out + + +@lru_cache(maxsize=1) +def measure() -> dict[str, Any]: + out = {} + for tag, path in FILES.items(): + hp = hx.read_book(path.name, path.read_bytes()) + one = s1(hp) # S2 가 블록에 덧칸을 달 수 있어 S1 먼저 + out[tag] = {"S1": one, "S2": s2(hp)} + return out + + +def _want() -> dict[str, Any]: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_S1_정규화_출력_그대로(tag): + assert measure()[tag]["S1"] == _want()[tag]["S1"] + + +@pytest.mark.parametrize("tag", list(FILES)) +def test_S2_셈단위_지문_그대로(tag): + got, want = measure()[tag]["S2"], _want()[tag]["S2"] + diff = sorted(k for k in set(got) | set(want) if got.get(k) != want.get(k)) + assert not diff, f"{tag} 지문 달라진 셈 단위 {len(diff)}: {diff[:10]}" + + +def test_여덟_파일_합(): + got = measure() + assert sum(v["S1"]["호표"] for v in got.values()) == 245 + assert sum(v["S1"]["줄"] for v in got.values()) == 1350 + assert sum(len(v["S2"]) for v in got.values()) == 275 + assert sum(v["S1"]["참조"]["참조줄"] for v in got.values()) == 90 + assert max(int(d) for v in got.values() for d in v["S1"]["참조"]["깊이"]) == 3 + assert all(v["S1"]["참조"]["순환"] == 0 for v in got.values()) + + +if __name__ == "__main__" and "--write" in sys.argv: + FIXTURE.write_text(json.dumps(measure(), ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + print("굳힘", FIXTURE)