diff --git a/M02_MasterTemplete/M02_Hopyo_Elem.py b/M02_MasterTemplete/M02_Hopyo_Elem.py index bd64f52e6..4b6c7dc7e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Elem.py +++ b/M02_MasterTemplete/M02_Hopyo_Elem.py @@ -6,7 +6,7 @@ 노임 = LB 이름 글자 묶음 닮음 · 단가 = LB 값 기계 = EQ 갈래 겹침 · 규격 수(같음 1 · 겹침 0.5) · 이름 낱말 덤 · 괄호 낱말 어긋남 깎음 · 단가 없음(기계경비는 로직 셈) · `machine` = 하나로 가름(778-1) -차례 = 점수(0.1 단위로 접음) › 엑셀 단가에 가까운 것(엑셀 단가 = 블록 글 「이름 단가 × 식 = 금액」 · 모르면 점수만). +차례 = 점수(0.1 단위로 접음) › 엑셀 단가에 가까운 것(엑셀 단가 = 블록 단위 줄 단가 · 모르면 점수만). 서버 단독 · 읽기만. """ @@ -17,10 +17,10 @@ import re from typing import Any from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Finger as finger from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat -from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc, parse_rhs, specnums +from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line +from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc, specnums TOP = 3 POOL = 5 @@ -35,16 +35,12 @@ PARTS = ("자동덮개", "부착용", "콤팩터", "습지") def prices(block: dict[str, Any] | None) -> dict[str, float]: - """블록 글 → `{이름: 엑셀 단가}`(직종 줄 「직종 : 단가 × 식」 · 자재 줄 「이름 단가 × 식 = 금액」).""" + """블록 단위 줄(노무 · 자재 · 값) → `{이름: 엑셀 단가}`(첫 것 · 776-3 — 글 다시 안 풂).""" out: dict[str, float] = {} - for raw in (block or {}).get("글") or []: - row = str(raw).rstrip() - cm = finger._COLON.match(row) - mm = None if cm else finger._MAT.match(row) - name = (cm or mm).group("name").strip() if (cm or mm) else "" - got = parse_rhs((cm or mm).group("rhs")) if name else None - if got and got.get("단가"): - out.setdefault(re.sub(r"^[0-9가-힣]\.", "", name).strip(), float(got["단가"])) + for one in (block or {}).get("항목") or []: + price = unit_line.money(one.get("단가")) if one.get("묶음") in ("L", "M", "E") else None + if price: + out.setdefault(re.sub(r"^[0-9가-힣]\.", "", str(one.get("이름") or "")).strip(), price) return out diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index aeedea10b..ff1fc21e7 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -18,8 +18,9 @@ STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopy 어긋남은 글 목록(막지 않음). 행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) · 근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) · -`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) · -`꼴` = 「엑셀꼴」. +`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글, 금액, 항목}`(338 · 글 = 머리 뺀 몸 줄) · +`꼴` = 「엑셀꼴」 · 두 형식 끝에 `UnitLine.settle`(단위 줄 하위 · 재현 · 덮음 · 일위대가 줄 +투영 · 778-3). """ from __future__ import annotations @@ -31,6 +32,7 @@ from typing import Any from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_basis # EST 단가산출 어댑터(776-2) from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate +from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line SHEET = "일위대가" SUMMARY = "일위대가총괄표" @@ -300,7 +302,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: sheets, xls = _open(name, data, LISTS) _OPENED = (data, sheets, xls) # 격자(`Grid.read`)가 이어 씀 — 한 번 열기 if hopyo_stmate.is_stmate(sheets): - return hopyo_stmate.read(sheets) + return unit_line.settle(hopyo_stmate.read(sheets)) rows = sheets.get(SHEET) if rows is None: raise ExcelError(NO_SHEET) @@ -365,13 +367,14 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: ] keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "금액", "항목") basis = [{k: b[k] for k in keep} for b in blocks.values()] - return { + book = { "꼴": "엑셀꼴", "시트": SHEET, "호표": out, "단산": basis, "검산": {"어긋남": wrong, "총괄표": len(totals)}, } + return unit_line.settle(book) # 단위 줄 하위 · 재현 · 덮음 · 일위대가 줄 투영(778-3) def _json(value: Any) -> Any: diff --git a/M02_MasterTemplete/M02_Hopyo_Finger.py b/M02_MasterTemplete/M02_Hopyo_Finger.py index b5ee84a45..5e99ce578 100644 --- a/M02_MasterTemplete/M02_Hopyo_Finger.py +++ b/M02_MasterTemplete/M02_Hopyo_Finger.py @@ -1,102 +1,45 @@ -"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2) — 호표 줄 + 이어진 단가산출 블록 → -직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀(식 맨 앞 단가는 뗌). +"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2 · 776-3) — 호표 줄 + 이어진 단가산출 블록 → +직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀. -엑셀꼴 = 블록 `글`(머리 뺀 몸 줄)을 줄마다 풂 — 절 `^\\d+\\.` · 작은 절 `^[가-하]\\.` · -직종 줄 「직종 : 단가 × 식 = 금액」(우리 인력 이름 · 옛이름) · 장비 = 「노무비 · 경비 · 재료비 : -단가 ÷ Q」 -+ 지금 장비(노무비 > 경비 > 재료비) · 자재 = 「이름 단가 × 수량 = 금액」 · 「: 별도계상」 · -못 풀면 금액 ÷ 단가(「역산」 · 품 되찾기만). -STmate = 블록 `자원`(L · X · M · S) 품이 곧 품(노무비 · 재료비 · 경비 세 줄 같은 품은 한 번) · -섞음 = 이름 · 규격 몫 둘(작은 쪽 인력 · 큰 쪽 장비). -몫(조합용 · 설계 2-7) = 맨 윗 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares`). +형식 모름 — 블록 단위 줄(`항목` · `M02_Hopyo_UnitLine`)의 투영: + L 노무 = 직종 품 · 식이 있으면 맨 인자가 섞음 비율 후보(호표 이름 · 규격 · 절 제목의 (N%) · + 8:2)와 같을 때 율로 가름 · 식 없는 줄(QTY)은 호표 이름 비율 둘 이상이면 작은 쪽(인력) + X 기계 = 장비 {갈래, 규격, 시간, 밑시간, 율, 절, 글} · 식 없는 줄은 큰 비율(장비)로 가름 + M 자재 = 식(수량) · 별도계상(품 없음) · E 값 줄에 수량이 있으면 장비 글이면 장비 · 아니면 자재 + 셈 밖 · 율 · 하위 줄 = 안 씀(율 % 는 글에서). +글(블록 머리 「N.이름 [단위]」 + 몸 글) = 조건 · 옛 판 · 계수(q · K · f …) · 글 속 「…의 N %」. +몫(조합용 · 설계 2-7) = 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares.sections`). 블록 없는 기계(hr) 줄 = `machine` · 노임 · 장비를 바로 적은 호표 = `direct`. -값 · 규칙은 336 실험(C4 `s1_excel_fp` · K5 `stmate`) 그대로. """ from __future__ import annotations import re -from collections import Counter from typing import Any -from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est from M02_MasterTemplete import M02_Hopyo_Shares as shares from M02_MasterTemplete.M02_Hopyo_Shares import trade -from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs, specnums +from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums -COST = {"노무비", "재료비", "경비"} -SKIPW = { - "소계", - "계", - "합계", - "인부소계", - "자재소계", - "주자재소계", - "운반", - "상차", - "기계", - "다짐", - "적사", - "살수", - "집토", - "암절취", - "채집", - "인력", - "장비", - "작업조건", - "1일시공량", - "작업면적", - "1시간작업량", - "기계채집", - "구조물터파기", -} -_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*÷/].*=.*)$" -) _PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%") _COEF = re.compile( r"(? str: return re.sub(r"\s+", "", str(s or "")) -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 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 empty(line: dict[str, Any], block: dict[str, Any] | None = None) -> dict[str, Any]: block = block or {} basis = line.get("근거") or {} @@ -133,12 +76,10 @@ def _round(fp: dict[str, Any]) -> dict[str, Any]: def excel(block: dict[str, Any] | None, line: dict[str, Any]) -> dict[str, Any]: - """HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 글 · 자원이 없으면 + """HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 단위 줄 · 글이 없으면 기계(hr) 줄 지문(장비 1 시간) 또는 빈 지문.""" - if block and block.get("자원"): - return _round(_stmate(block, line)) - if block and block.get("글"): - return _round(_text(block, line)) + if block and (block.get("항목") or block.get("글")): + return _round(_units(block, line)) return machine(line) @@ -155,124 +96,59 @@ def machine(line: dict[str, Any]) -> dict[str, Any]: return fp -def _text(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]: - """엑셀꼴 — 블록 글 줄마다 풂(C4 `north`).""" - fp = empty(line, block) +def _text(fp: dict[str, Any], block: dict[str, Any]) -> None: + """블록 글 → 조건(머리 + 앞 넉 줄 + ※ · 작업조건 줄) · 옛 판 · 계수 · 「…의 N %」.""" head = f"{block.get('번', '')}.{block.get('이름', '')} [{block.get('단위', '')}]" text = [head, *[str(t) for t in block.get("글") or []]] - front = [t for t in text[:5]] + [t for t in text if "※" in t or "작업조건" in t] - head_text = " ".join([fp["이름"], fp["규격"], *front]) - fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()} + front = text[:5] + [t for t in text if "※" in t or "작업조건" in t] + got = conds(" ".join([fp["이름"], fp["규격"], *front])) + fp["조건"] = {k: sorted(v) for k, v in got.items()} fp["옛판"] = bool(OLD.search(" ".join(text[:6]))) - sec, sub, mach, label = 0, "", None, "" - got_cost: dict[tuple, tuple] = {} - item_rates = rates_in(f"{fp['이름']} {fp['규격']}") - sec_rates: set[float] = set() - - def unmix(got: dict | None) -> dict | None: - """율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름.""" - if got and not got["율"]: - for v in got.get("인자") or []: - if round(v, 4) in item_rates | sec_rates and 0 < v < 1: - return {**got, "밑품": got["밑품"] / v, "율": [[round(v, 4), "섞음"]]} - return got - - for raw in text[1:]: - row = raw.rstrip() - h, s = _HEAD.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 = (ts, specnums(title)) if ts else None - label = title.strip() if ts else "" - for k, v in _PCT.findall(row): - fp["율"].append([k, float(v)]) - cm = _COLON.match(row) - name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else "" - who = hopyo_basis.trade_names().get(name) if cm else None - if who: - rhs = cm.group("rhs") - got = unmix(parse_rhs(rhs)) - if got: - _labor(fp, who, got["품"], got["밑품"], got["율"], "식") - elif r := _ratio(rhs): - _labor(fp, who, r, r, [], "역산") - continue - if cm and name in COST: - got = unmix(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 - if key not in got_cost or prio < got_cost[key][0]: - got_cost[key] = (prio, mach, got) - continue - if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")): - fp["자재"].append({"이름": cm.group("name").strip(), "품": None, "밑품": None}) - continue - mm = _MAT.match(row) - if mm and not cm and (got := parse_rhs(mm.group("rhs"))): - fp["자재"].append( - {"이름": mm.group("name").strip(), "품": got["품"], "밑품": got["밑품"]} - ) - continue - if not (h or s): - ts = mtypes(row) - if ts and "%" not in row and not cm: - mach, label = (ts, specnums(row)), row.strip() + for row in text[1:]: + fp["율"] += [[k, float(v)] for k, v in _PCT.findall(row)] + if not _SECTION.match(row): for k, v in _COEF.findall(row): fp["계수"].setdefault(k, float(v)) - for (sec_, sub_, lab), (_, (ts, sp), got) in got_cost.items(): - where = f"{sec_}.{sub_}" if sub_ else str(sec_) - one = {"갈래": sorted(ts), "규격": sorted(sp), "시간": got["품"], "밑시간": got["밑품"]} - fp["장비"].append({**one, "율": got["율"], "절": where, "글": lab}) - fp["몫"] = shares.sections(text[1:]) - return fp -def _stmate(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]: - """STmate — 자원 품이 곧 품(K5 `stmate`).""" +def _unmix(one: dict[str, Any], rates: set[float]) -> tuple[float, list]: + """(밑품, 율) — 율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름.""" + if not one.get("율"): + for v in one.get("인자") or []: + if round(v, 4) in rates and 0 < v < 1: + return one["밑품"] / v, [[round(v, 4), "섞음"]] + return one["밑품"], one.get("율") or [] + + +def _units(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]: fp = empty(line, block) - text = [str(t) for t in block.get("글") or []][:60] - fp["조건"] = { - k: sorted(v) for k, v in conds(" ".join([fp["이름"], fp["규격"], *text[:4]])).items() - } - fp["옛판"] = bool(OLD.search(" ".join(text[:4]))) - for t in text: - for k, v in _COEF.findall(t): - fp["계수"].setdefault(k, float(v)) - for k, v in _PCT.findall(t): - fp["율"].append([k, float(v)]) - rates = rates_in(f"{fp['이름']} {fp['규격']}") - lab_rate = min(rates) if len(rates) >= 2 else None - mach_rate = max(rates) if len(rates) >= 2 else None - seen: Counter = Counter() - for x in block.get("자원") or []: - kind, q = x.get("갈래"), x.get("품") - nm, sp = str(x.get("이름") or ""), str(x.get("규격") or "") - if not q: + _text(fp, block) + item_rates = rates_in(f"{fp['이름']} {fp['규격']}") + pair = sorted(item_rates) if len(item_rates) >= 2 else [] + lab_rate, mach_rate = (pair[0], pair[-1]) if pair else (None, None) + for one in block.get("항목") or []: + kind, q, how = one.get("묶음"), one.get("품"), one.get("어떻게") + if one.get("밖") or (q is None and how != "별도"): continue - seen[(x.get("코드"), round(float(q), 9))] += 1 - if seen[(x.get("코드"), round(float(q), 9))] > 1: # 노무비 · 재료비 · 경비 세 줄 같은 품 - continue - q = float(q) - ts = mtypes(f"{nm} {sp}") - if kind == "L": - if who := trade(nm): - base = q / lab_rate if lab_rate else q - _labor(fp, who, q, base, [[lab_rate, "섞음"]] if lab_rate else [], "QTY") - elif kind == "X" or (kind == "S" and ts): - one = {"갈래": sorted(ts), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q} - base = q / mach_rate if mach_rate else q - fp["장비"].append({**one, "밑시간": base, "율": [], "절": "0", "글": f"{nm} {sp}"}) - elif kind in ("M", "S"): - fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": q, "밑품": q}) - fp["몫"] = shares.sections_stm(block.get("자원") or [], text) + text = f"{one.get('이름') or ''} {one.get('규격') or ''}".strip() + rates = item_rates | set(one.get("절율") or []) + if kind == "L" and (who := one.get("직종") or trade(one.get("이름"))): + if how == "QTY": + base, r = (q / lab_rate, [[lab_rate, "섞음"]]) if lab_rate else (q, []) + else: + base, r = _unmix(one, rates) if how == "식" else (one["밑품"], one["율"]) + _labor(fp, who, q, base, r, how) + elif kind == "X" or (kind == "E" and mtypes(text)): + if how == "QTY": + base, r = (q / mach_rate if mach_rate else q), [] + else: + base, r = _unmix(one, rates) + sizes = specnums(one.get("규격") or "") | specnums(one.get("이름") or "") + got = {"갈래": sorted(mtypes(text)), "규격": sorted(sizes), "시간": q} + fp["장비"].append({**got, "밑시간": base, "율": r, "절": one.get("절"), "글": text}) + elif kind in ("M", "E") and (how in ("식", "별도", "QTY")): + fp["자재"].append({"이름": text, "품": q, "밑품": one.get("밑품", q)}) + fp["몫"] = shares.sections(block.get("항목") or []) return fp @@ -282,7 +158,7 @@ def direct(hopyo: dict[str, Any]) -> dict[str, Any] | None: fp = empty({k: hopyo.get(k) for k in ("이름", "규격", "단위")}) texts = [str(t) for t in hopyo.get("근거글") or []] texts += [f"{x.get('이름')} {x.get('규격')}" for x in lines if x.get("갈래") == "근거글"] - fp["출처"] = hopyo_basis.sources(" ".join(texts)) + fp["출처"] = hopyo_est.sources(" ".join(texts)) hit = False for x in lines: q = x.get("수량") diff --git a/M02_MasterTemplete/M02_Hopyo_Fit.py b/M02_MasterTemplete/M02_Hopyo_Fit.py index a5b58c2cf..c740422ce 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fit.py +++ b/M02_MasterTemplete/M02_Hopyo_Fit.py @@ -20,6 +20,7 @@ import hashlib import json import queue import threading +from functools import lru_cache from pathlib import Path from typing import Any @@ -295,9 +296,21 @@ def _hp_path(folder: Path, key: str) -> Path: return hopyo_store._path(folder, key) +@lru_cache(maxsize=64) +def _hp_mark(path: str, stamp: tuple[int, int]) -> str: + """HP 지문 칸(올린 엑셀 sha256 + 읽기 판 · 776-3) — 없으면(붙여넣기) 파일 해시 · 파일이 그대로면 + (`stamp` = 바뀐 때 · 크기) 다시 안 읽음.""" + raw = Path(path).read_bytes() + doc = json.loads(raw) + mark = doc.get("지문") if isinstance(doc, dict) else None + return f"{mark[:16]}{doc.get('판')}" if mark else hashlib.sha256(raw).hexdigest()[:16] + + def cache_path(folder: Path, key: str) -> Path: - hp = hashlib.sha256(_hp_path(folder, key).read_bytes()).hexdigest()[:16] - return ours.folder() / f"맞춤_{hp}_{ours.version()}_{CALC_VERSION}.json" + hp = _hp_path(folder, key) + got = hp.stat() + mark = _hp_mark(str(hp), (got.st_mtime_ns, got.st_size)) + return ours.folder() / f"맞춤_{mark}_{ours.version()}_{CALC_VERSION}.json" def units(doc: dict[str, Any]) -> list[tuple[str, dict | None, dict]]: diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index f9fac47a5..da32d4ab6 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -343,7 +343,6 @@ def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[ # ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ───────────────────── _OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)") -_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4 _GENERIC = frozenset( "인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 " "설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 " @@ -352,32 +351,14 @@ _GENERIC = frozenset( def fix_typo(theirs: list) -> list[list[str]]: - """엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」.""" - out = [] - for book, n in theirs or []: - m = _TYPO_CLAUSE.match(str(n)) - out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n]) - return out + """엑셀 인용 조항 사본 — 오타(「13-114」 → 13-11-4)는 읽기(`ReadEst.sources`)가 이미 고침.""" + return [list(x) for x in theirs or []] def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]: - """(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5 - · - 나 창 `Basis.clause` 가 있으면 그것.""" - if hasattr(hopyo_basis, "clause"): - return hopyo_basis.clause(theirs, our) - nums = [n for book, n in theirs if book == our["책"]] - same = sorted(n for n in nums if n in our["조항"]) - if same: - return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음" - kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-")) - if kids: - return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음" - heads = {n.rsplit("-", 1)[0] for n in nums} - near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads) - if near_: - return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음" - return 0.0, [], "" + """(점수, 맞은 우리 조항 글, 꼬리표) — `M02_Hopyo_Basis.clause`(같음 1 · 윗마디 0.9 · + 앞 마디 0.5).""" + return hopyo_basis.clause(theirs, our) def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool: @@ -393,9 +374,6 @@ def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = 근거: {로직: 근거}}` — 이름 후보 + 근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) · 옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리.""" got = got or targets() - basis_ = dict(line.get("근거") or {}) - if basis_.get("출처"): - line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}} basis = hopyo_basis.candidates(line, got) named = candidates(line, "로직", got, None) merged_c = [c for c in hopyo_basis.merge(basis, named) if c.get("로직")] diff --git a/M02_MasterTemplete/M02_Hopyo_ReadEst.py b/M02_MasterTemplete/M02_Hopyo_ReadEst.py index fa09e86cc..2c75c78e8 100644 --- a/M02_MasterTemplete/M02_Hopyo_ReadEst.py +++ b/M02_MasterTemplete/M02_Hopyo_ReadEst.py @@ -9,16 +9,15 @@ 글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리 (맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) · 금액 = 머리 줄 돈 칸(1 단위). -항목(Book 블록 항목) = 글 줄 풀이 — 줄 율(호표 줄 이름 · 규격)에 기대지 않는 것만 · -지문 단계가 율을 가름: +항목(Book 블록 항목 · 778-3) = 글 줄 → 단위 줄(꼴 = `M02_Hopyo_UnitLine`) · 줄 금액 = 「= 금액」 · +끝 점(「28,500. ×」) 뗌 · 율(호표 줄 이름 · 규격)에 기대지 않는 것만(섞음 율은 지문 몫): L 직종 줄 「직종 : 단가 × 식 = 금액」(못 풀면 금액 ÷ 단가 「역산」 · `직종` = 우리 이름) · X 장비 = 장비 절 · 장비 글 줄 뒤 「노무비 · 경비 · 재료비 : 단가 ÷ Q」 — 절 · 장비마다 하나 - (노무비(단가 > 0) > 경비 > 재료비 줄의 품) · 이름 = 장비 글 · 단가 = 비목마다 · - M 자재 = 「이름 단가 × 수량 = 금액」 · 「이름 : 별도계상」(품 없음). - 항목 = `{갈래, 이름, 규격, 품, 밑품, 율, 인자, 단가, 절, 절율, 어떻게, 행}`(L 은 + 직종) · - STmate 어댑터(`M02_Hopyo_Stmate`)도 같은 꼴. -풀이 규칙은 지문(`M02_Hopyo_Finger._text`)과 같음 — 776-3 이 지문을 항목 위로 옮기면 -그쪽 사본을 걷음. + (품 = 노무비(단가 > 0) › 경비 › 재료비 줄 · 비목마다 매개값이 다르면 `계수`) · + M 자재 = 「이름 단가 × 수량 = 금액」(이름 속 = · / 도) · 「이름 : 별도계상」(품 없음) · + R 율 = 「기준 × N% = 금액」(라벨 = 이 줄 · 앞 줄 · 기준 = 「노무비의」 앞 낱말) · + E 값 = 셈 글 아닌 「이름 : … = 금액」(하위 가름은 `UnitLine.settle`) · + 절 = 제목(작은 절 = 「윗 제목 · 작은 제목」) · 합계 · 소계 절 안 줄 = `밖`. """ from __future__ import annotations @@ -28,6 +27,7 @@ from functools import lru_cache from typing import Any from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs @@ -53,6 +53,26 @@ _MAT = re.compile( _SEC = re.compile(r"^\s*(\d+)\.\s*(.*)$") _SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$") _LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$") +# 단위 줄(778-3) — 줄 금액 · 끝 점 · 율 · 느슨한 자재 · 셈 글 · 합계 절 +NUM = r"[\d,]+(?:\.\d+)?" +_AMOUNT = re.compile( # 끝 「= 금액」 — 원 · ₩ · 「\/㎡」 단위 · 「(지주)」 꼬리 허용 + r"=\s*[A-Z]?\s*(" + NUM + r")\s*\.?\s*(?:원|₩)?\s*(?:\\?/\s*\S+)?\s*(?:\([^()]*\))?\s*$" +) +_DOT = re.compile(r"(\d)\.(?=\s|$|%)") +_DECR = re.compile(r"[×xX*]\s*(0?\.\d+)\s*=") +_PCTN = re.compile(r"(\d+(?:\.\d+)?)\s*%") +_BASEW = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%") +_TOTAL = re.compile(r"(합\s*계|소\s*계|총\s*계)") +_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*") # 「3.」 · 「가.」 · 「1)」 머리 +_TOP_L = re.compile(r"^\s*\d+\s*\.\s*(.*)$") +_SUB_L = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$") +_DECIMAL = re.compile(r"^\s*\d+\.\d") +_LOOSE = re.compile( + r"^\s*(?P[^\d\s::].*?[A-Za-z가-힣)\]])\s+(?P" + NUM + r"\s*[×xX*÷/].*=.*)$" +) +_CALC = re.compile( + r"(^|[\s(])(Q\d?|q\w?|K|k|f\d?|E\w?|Cm|L|V\d|T|rt|n|t\d|A|N|W|V|S)\s*[=:]|─|㎥/hr|m3/hr|/hr\b" +) def _flat(value: Any) -> str: @@ -187,7 +207,7 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: return blocks -# ── 글 → 항목 ───────────────────────────────────────────────────────── +# ── 글 → 단위 줄 ─────────────────────────────────────────────────────── def _ratio(rhs: str) -> float | None: """식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용).""" m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs) @@ -198,22 +218,82 @@ def _ratio(rhs: str) -> float | None: return a / p if p else None +def _num(text: str) -> float | None: + try: + return float(text.replace(",", "")) + except ValueError: + return None + + +def amount(row: str) -> float | None: + """줄 끝 「= 금액」(원 · 끝 점 · 앞 글자 하나 허용).""" + m = _AMOUNT.search(row.replace("W", "")) + return _num(m.group(1)) if m 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} + keep = {"품": got.get("품"), "밑품": got.get("밑품")} + keep |= {"율": got.get("율") or [], "인자": got.get("인자") or []} + return {"묶음": kind, "이름": name, "규격": "", **keep, "어떻게": how, **more} + + +def _material(hit: Any, row: str, money: float | None, place: dict) -> dict[str, Any]: + """자재 줄 「이름 단가 × 수량 [× 손료율] = 금액」 — 손료율 = 식 속 % · 끝 「× 0.xx =」.""" + got = parse_rhs(hit.group("rhs")) + more = {"단가": {"재료비": got["단가"]} if got else {}, "금액": money, **place} + one = _item("M", hit.group("name").strip(), got or {}, "식", **more) + pct = [v for v, note in (got or {}).get("율") or [] if note == "%"] + dec = _DECR.search(row) + dec = float(dec.group(1)) if dec and 0.5 <= float(dec.group(1)) < 1 else None + if ( + pct or dec + ): # ponytail: 끝 「× 0.95 =」 = 손료율(0.5~1) 어림 — 글에 「손료」 표지가 생기면 그것으로 + one["손료율"] = pct[-1] if pct else dec + return one + + +def _rate(row: str, prev: str, cm: Any) -> dict[str, Any]: + """율 줄 「기준 × N% = 금액」(라벨 = 이 줄 이름 · 없으면 앞 줄) → + {이름, 품 = 율, 기준값, 기준}.""" + pm = _PCTN.findall(row) or _PCTN.findall(prev) + rate = float(pm[-1]) / 100 if pm else None + dec = _DECR.search(row) + if dec and not _PCTN.findall(row): + rate = float(dec.group(1)) + body = row.split(":", 1)[-1] if cm else row + par = re.search(r"\(([^()]*\d[^()]*)\)\s*[×xX*]", body) + if par and "+" in par.group(1): + base: float | None = sum(_num(x) or 0 for x in re.findall(NUM, par.group(1))) + else: + hit = re.search(r"(" + NUM + r")\s*[×xX*]\s*(?:" + NUM + r")\s*(?:%|=)", body) + hit = hit or re.search(r"(" + NUM + r")", body) + base = _num(hit.group(1)) if hit else None + word = _BASEW.search(row) or _BASEW.search(prev) + label = _NUMBERED.sub("", (cm.group("name") if cm else prev)).strip()[:40] + return {"이름": label, "품": rate, "기준값": base, "기준": word.group(1) if word else ""} def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]]: - """블록 몸 글(머리 뺌) → 항목 목록(L · M 은 글 차례 · X 는 절 · 장비 처음 나온 차례로 뒤에).""" + """블록 몸 글(머리 뺌) → 단위 줄(글 차례 · 기계 = 절 · 장비마다 하나 · 첫 비목 줄 자리 · + 품 = 노무비(단가 > 0) › 경비 › 재료비 줄) · 합계 · 소계 절 안 = `밖` · 값 → 하위 가름은 책 + 단위(`M02_Hopyo_UnitLine.settle`).""" 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, sub, mach, label, total, prev = 0, "", False, "", False, "" + top = path = "" # 절 제목 · 작은 절이면 「윗 제목 · 작은 제목」 + whole_out = 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 (lt := _TOP_L.match(row)) and not _DECIMAL.match(row): # 몫 절 · 셈 밖(띄어 쓴 「가 .」) + top = path = lt.group(1).strip() + whole_out = total = bool(_TOTAL.search(top)) + elif ls := _SUB_L.match(row): + total = whole_out or bool(_TOTAL.search(ls.group(1))) + path = path if total else f"{top} · {ls.group(1).strip()}" if h or s: if h: sec, sub = int(h.group(1)), "" @@ -225,46 +305,87 @@ def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any] 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} + else: + row = _DOT.sub(r"\1", row) # 「28,500. ×」 끝 점 + place = { + "절": path, + "윗절": top, + "절율": sorted(sec_rates), + "행": at, + **({"밖": True} if total else {}), + } + money = amount(row) 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} + raw_name, how = cm.group("name").strip(), {"직종": who, "금액": money, **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)) + got = {"품": r, "밑품": r} + out.append(_item("L", raw_name, got, "역산", 단가={}, **how)) + elif money is not None: # 품 없는 「직종 : P」 선언 줄은 뺌(값은 뒤 줄) + out.append(_item("L", raw_name, {}, "식", 단가={}, **how)) + prev = row continue if cm and name in COST: got = parse_rhs(cm.group("rhs")) - if got and mach: + if mach and (got or money is not None): 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) + if one is None: + one = _item("X", label, {}, "식", 단가={}, 금액=0.0, _순=9, _q={}, **place) machines[key] = one - one["단가"][name] = got["단가"] + out.append(one) + one["금액"] = round(one["금액"] + (money or 0), 6) + if got: + prio = ( + 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2 + ) + if prio < one["_순"]: + one.update({k: got[k] for k in ("품", "밑품", "율", "인자")}, _순=prio) + one["단가"][name] = got["단가"] + one["_q"][name] = got["품"] + prev = row 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)) + more = {"단가": {}, "금액": 0.0, **place} + out.append(_item("M", cm.group("name").strip(), {}, "별도", **more)) + prev = row 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)) + if mm and not cm: + out.append(_material(mm, row, money, place)) + prev = row continue if not (h or s): ts = mtypes(row) if ts and "%" not in row and not cm: mach, label = True, row.strip() + if ( + money is None + or total + or (cm and (name in ("소계", "계", "합계") or name.endswith("소계"))) + ): + prev = row + continue + dec = _DECR.search(row) + loose = None if cm else _LOOSE.match(row) + if loose and (names.get(_flat(loose.group("name"))) or _BASEW.search(loose.group("name"))): + loose = None # 직종 · 「…의 N%」 이름 = 자재 아님 + if loose: # 자재(이름 속 = · / · 손료율 % 도) + out.append(_material(loose, row, money, place)) + elif "%" in row or (dec and "%" in prev): + got = {"묶음": "R", "규격": "", **_rate(row, prev, cm), "단가": {}, "금액": money} + out.append({**got, "어떻게": "율", **place}) + elif cm and not _CALC.search(row): + first = re.search(r"(" + NUM + r")\s*[×xX*]", cm.group("rhs")) + price = {"계": _num(first.group(1))} if first else {} + one = {"묶음": "E", "이름": cm.group("name").strip()[:40], "규격": "", "품": None} + out.append({**one, "단가": price, "금액": money, "어떻게": "값", **place}) + prev = row for one in machines.values(): - del one["_순"] - out.append(one) + unit_line.machine_done(one) return out diff --git a/M02_MasterTemplete/M02_Hopyo_Shares.py b/M02_MasterTemplete/M02_Hopyo_Shares.py index bbf87d026..605453f7d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Shares.py +++ b/M02_MasterTemplete/M02_Hopyo_Shares.py @@ -1,45 +1,18 @@ -"""M02 일위대가 맞춤 몫(PLAN 338-1 · 설계 2-2 · 2-7) — 단가산출 블록을 맨 윗 절(1. 2. 3.) -· 작은 절(가. 나.)로 갈라 몫마다 노임(직종 → 인) · 장비(「굴착기|0.6」 → 시간) 벡터. - -엑셀꼴 = 「직종 : 노임 × 식 = 금액」(식을 못 풀면 금액 ÷ 노임) · 장비 이름 줄 뒤 「노무비 : 단가 ÷ Q -× 몫」 · -소계 · 합계 절 뺌. STmate = 자원 L · X(절 = 자원 `절` · 없으면 한 몫). 금액 안 씀. -규칙은 336 실험(C5 `c01_trees.sections` · `sections_stm`) 그대로 · 장비 갈래는 -`M02_Hopyo_Words.mtypes`. +"""M02 일위대가 맞춤 몫(PLAN 338-1 · 설계 2-2 · 2-7 · 776-3) — 단가산출 블록 단위 줄을 절(맨 윗 절 · +작은 절)마다 묶어 노임(직종 → 인) · 장비(「굴착기|0.6」 → 시간) 벡터. 형식 모름(단위 줄만 · +`M02_Hopyo_UnitLine`) · 셈 밖 줄 뺌 · 금액 안 씀 · 장비 갈래 = `M02_Hopyo_Words.mtypes`. """ from __future__ import annotations -import ast -import operator import re from typing import Any -from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete.M02_Hopyo_ReadEst import trade # 노임 이름 → 우리 인력(B09 도 씀) from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc +__all__ = ["eq_key", "sections", "trade"] _SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|kw|hp)", re.I) -_OPS = { - ast.Mult: operator.mul, - ast.Div: operator.truediv, - ast.Add: operator.add, - ast.Sub: operator.sub, -} -_TRADE = re.compile( - r"^[\s*·\-○◇]*([가-힣][가-힣\s]*?)\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*([\d,]+(?:\.\d+)?))?\s*$" -) -_SECTION = re.compile(r"^\s*(\d+)\s*\.\s*(.*)$") -_EQ_COST = re.compile(r"^\s*노\s*무\s*비\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*[\d,.]+)?\s*$") -_SKIP_SEC = re.compile(r"^(합\s*계|소\s*계|계)") -_SUBLABEL = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$") -_COSTLINE = re.compile(r"^\s*(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:") - - -def trade(name: Any) -> str | None: - """노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None.""" - names = hopyo_basis.trade_names() - flat = re.sub(r"\s+", "", str(name or "")) - return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat)) def eq_key(text: Any) -> str | None: @@ -54,124 +27,23 @@ def eq_key(text: Any) -> str | None: return f"{'+'.join(sorted(kinds))}|{size}" -def _eval(node: ast.AST) -> float: - if isinstance(node, ast.Expression): - return _eval(node.body) - if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): - return float(node.value) - if isinstance(node, ast.BinOp) and type(node.op) in _OPS: - return _OPS[type(node.op)](_eval(node.left), _eval(node.right)) - if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): - return -_eval(node.operand) - raise ValueError("식 아님") - - -def qty_of(expr: str) -> float | None: - """「273,540 × 3인 ÷ 55㎥ × 2」 → 3/55×2 · 「59,020 ÷ 23.1 × 90%」 → 1/23.1×0.9.""" - s = re.sub(r"\([^()0-9]*\)", "", str(expr)).replace(",", "") - s = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", s) - s = s.replace("×", "*").replace("÷", "/").replace("X", "*").replace("x", "*") - s = re.sub(r"/\s*(?=[^\d(\s.])", " ", s) # 「인/㎥」 의 / 는 단위 - s = re.sub(r"[^\d.+\-*/() ]", " ", s) - s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 「4.」 → 4 - s = re.sub(r"\(\s*\)", " ", s).strip() - m = re.match(r"\s*\d+(?:\.\d+)?", s) - if not m: - return None - s = "1" + s[m.end() :] - s = re.sub(r"(?<=[\d)])\s+(?=[\d(])", "*", s) # 빈칸으로 붙은 수는 곱 - try: - v = _eval(ast.parse(s, mode="eval")) - except (SyntaxError, ValueError, ZeroDivisionError): - return None - return v if 0 < v < 1e4 else None - - def _part(title: str, parent: str) -> dict[str, Any]: return {"제목": title, "절": parent, "노임": {}, "장비": {}} -def sections(texts: list[str]) -> list[dict[str, Any]]: - """블록 몸 글 → 몫마다 {제목, 절, 노임, 장비} — 노임 · 장비가 하나도 없는 몫은 뺌.""" - out: list[dict[str, Any]] = [] - cur: dict[str, Any] | None = None - eq, parent = None, "" - for raw in texts: - line = str(raw).rstrip() - sec = _SECTION.match(line) - if sec and not re.match(r"^\s*\d+\.\d", line): - title = sec.group(2).strip() - if _SKIP_SEC.match(title.replace(" ", "")): - cur = None - continue - parent = title - cur = _part(title, title) - out.append(cur) - eq = eq_key(title) +def sections(units: list[dict[str, Any]]) -> list[dict[str, Any]]: + """블록 단위 줄(`M02_Hopyo_UnitLine`) → 몫마다 {제목 = 줄 절, 절 = 윗절, 노임, 장비} — 셈 밖 · + 매개값 없는 줄 뺌 · 장비 = 시간 50 아래 · 노임 · 장비가 하나도 없는 몫은 뺌.""" + out: dict[str, dict[str, Any]] = {} + for one in units: + q, kind = one.get("품"), one.get("묶음") + if one.get("밖") or not q or kind not in ("L", "X"): continue - sub = _SUBLABEL.match(line) if cur is not None else None - if sub and not _SKIP_SEC.match(sub.group(1).replace(" ", "")): - cur = _part(f"{parent} · {sub.group(1).strip()}", parent) - out.append(cur) - if cur is None: - if out or not line.strip(): - continue - cur = _part("", "") - out.append(cur) - if re.search(r"(소계|합계|인부소계)", line.replace(" ", "")): - eq = None - continue - sub = _SUBLABEL.match(line) - if sub: - eq = eq_key(sub.group(1)) - elif not _COSTLINE.match(line) and (k := eq_key(line)): - eq = k - m = _EQ_COST.match(line) - if m and eq: - q = qty_of(f"{m.group(1)} {m.group(2)}") - if q and q < 50: - cur["장비"][eq] = cur["장비"].get(eq, 0) + q - continue - t = _TRADE.match(line) - if t and (who := trade(t.group(1))): - q = qty_of(f"{t.group(2)} {t.group(3)}") - if q is None and t.group(4): # 식을 못 풀면 금액 ÷ 노임(품 되찾기만) - q = float(t.group(4).replace(",", "")) / float(t.group(2).replace(",", "")) - if q: - cur["노임"][who] = cur["노임"].get(who, 0) + q - return [s for s in out if s["노임"] or s["장비"]] - - -def sections_stm(res: list[dict[str, Any]], texts: list[str]) -> list[dict[str, Any]]: - """STmate 자원(L · X) → 몫(자원 `절` = 글 줄 차례면 그 앞 맨 윗 절 · 글이면 그 제목 · 없으면 한 - 몫).""" - marks = [] - for at, t in enumerate(texts): - sec = _SECTION.match(t) - if sec and not re.match(r"^\s*\d+\.\d", t): - if not _SKIP_SEC.match(sec.group(2).replace(" ", "")): - marks.append((at, sec.group(2).strip())) - - def which(where: Any) -> str: - if isinstance(where, str): - return where - if isinstance(where, int) and not isinstance(where, bool): - got = [m for m in marks if m[0] <= where] - return got[-1][1] if got else "" - return "" - - secs: dict[str, dict[str, Any]] = {} - seen = set() - for x in res: - q, kind = x.get("품"), x.get("갈래") - if not q or kind not in ("L", "X") or (x.get("코드"), q, x.get("절")) in seen: - continue - seen.add((x.get("코드"), q, x.get("절"))) - title = which(x.get("절")) - s = secs.setdefault(title, _part(title, title)) - if kind == "L" and (who := trade(x.get("이름"))): - s["노임"][who] = s["노임"].get(who, 0) + float(q) - elif kind == "X": - k = eq_key(f"{x.get('이름') or ''} {x.get('규격') or ''}") or f"?{x.get('코드')}" - s["장비"][k] = s["장비"].get(k, 0) + float(q) - return [s for s in secs.values() if s["노임"] or s["장비"]] + title = str(one.get("절") or "") + part = out.setdefault(title, _part(title, str(one.get("윗절") or title))) + if kind == "L" and (who := one.get("직종") or trade(one.get("이름"))): + part["노임"][who] = part["노임"].get(who, 0) + float(q) + elif kind == "X" and q < 50: + k = eq_key(f"{one.get('이름') or ''} {one.get('규격') or ''}") or f"?{one.get('코드')}" + part["장비"][k] = part["장비"].get(k, 0) + float(q) + return [p for p in out.values() if p["노임"] or p["장비"]] diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py index f8f6efafe..dae50371f 100644 --- a/M02_MasterTemplete/M02_Hopyo_Stmate.py +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -15,9 +15,12 @@ PR · 단위 % = % · 그 밖 기타. 비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) · 머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `ReadEst.sources` · 직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름) · -금액 = 머리 줄 C~F(1 단위) · 항목(776-2 · Book 블록 항목 · EST 어댑터 `M02_Hopyo_ReadEst` 와 -같은 꼴) = 자원 + `{직종(L), 밑품 = 품, 율 [], 인자 [], 단가 I~L, 금액 C~F, 어떻게 「QTY」, 행}` -(기계 셋 줄 = 더함). +금액 = 머리 줄 C~F(1 단위) · 항목(778-3 · 단위 줄 꼴 = `M02_Hopyo_UnitLine`) = 행마다 + 자원 L 노무 · X 기계 · M 자재 · S 경비 = E 값(품 = H · 단가 I~L · 줄 금액 C · 어떻게 「QTY」) · + 기계 셋 줄 = 같은 코드 · 절 · 아직 빈 비목일 때만 하나(같은 코드 두 묶음 = 따로 · + 비목마다 H 가 다르면 `계수`) · G 열 「&자원& * N'%'」 = R 율(기준값 = I) · + PR · RR 행 = R 율(PR = N % · RR = 곱 · 기준 = G 열 표지 「&PA01M:M&」 앞) · + D 코드 행 = S 하위 · N 열 「C…」 = `밖`(소계(제외금액)) · 자원 = 자원 코드 줄 옛 칸. 검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`. """ @@ -28,6 +31,7 @@ from typing import Any from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀 from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est +from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line SHEET = "일위대가표" CALC = "단가산출근거" @@ -45,6 +49,17 @@ _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 +FORMULA_COL, OUT_COL = 6, 13 # G 식 · N 합 갈 곳(C = 셈 밖 「소계(제외금액)」) +COSTS = ("노무비", "재료비", "경비") +_KIND = {"L": "L", "X": "X", "M": "M", "S": "E"} # 자원 코드 머리 → 단위 줄 묶음(S 경비 = 값) +_RES_CODE = re.compile(r"^[LMXS]\d{5}$") +_RATE = re.compile(r"^(?:PR|RR)[A-Z]?_") # 율 행 「PR_1」 · 「RRS_(8)_1」 +_SUB_D = re.compile(r"^(D\d{5})_\d+$") # 다른 블록 부름 +_PCT_REF = re.compile(r"&([A-Z][A-Z0-9_]*?)(?::[A-Z])?&\s*\*\s*[\d.]+\s*'%'\s*=") # 율 꼴 자원 행 +_LOSS = re.compile(r"\*\s*([\d.]+)\s*'%'") # 자재 손료율 「* 0.443'%'」 +_BASE_WORD = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%") # 율 기준글 「(인건비의 3%)」 +_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*") +_TAG = re.compile(r"&([A-Z][A-Z0-9_]*?)(?::[A-Z])?&") # 율 기준 표지 「&PA01M:M&」 → PA01M def is_stmate(names: Any) -> bool: @@ -100,12 +115,78 @@ def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]: # ── 단가산출근거 블록 ────────────────────────────────────────────────── +def _part(row: list[Any]) -> str | None: + """기계 셋 줄 가운데 이 줄 비목(I~L 단가 칸 · 노무비 › 재료비 › 경비).""" + return next((k for k in COSTS if _cell(row, _CALC_PRICE[k]) not in (None, "")), None) + + +def _same_machine(last: dict[str, Any] | None, code: str, title: str, part: str | None) -> bool: + """바로 앞 기계 줄과 한 기계(셋 줄)인가 — 같은 코드 · 절 · 이 비목이 아직 빔 + (같은 코드 두 묶음 = 따로 · 울진대흥 1129).""" + return bool( + last + and last["묶음"] == "X" + and last["코드"] == code + and last["절"] == title + and part + and part not in last["_q"] + ) + + +def _unit(row: list[Any], code: str, title: str, info: dict[str, str]) -> dict[str, Any]: + """자원 행 → 단위 줄(L 노무 · X 기계 · M 자재 · S 경비 = E 값 · G 열 「'%'」 = R 율).""" + name, _, spec = _s(_cell(row, NAME_COL)).partition("/") + qty = hopyo_excel._num(_cell(row, QTY_COL)) + q = float(qty) if qty is not None else None + one = { + "묶음": _KIND.get(code[0], "E"), + "코드": code, + "이름": name.strip() or info.get("이름", ""), + "규격": spec.strip() if name.strip() else info.get("규격", ""), + "단위": info.get("단위", ""), + "품": q, + "절": title, + } + if rate := _PCT_REF.search(_s(_cell(row, FORMULA_COL))): # 「기구손료 : &L00020& * 2'%'」 + base = hopyo_excel._num(_cell(row, _CALC_PRICE["합계"])) + more = {"기준": rate.group(1), "기준값": float(base) if base is not None else None} + return {**one, "묶음": "R", "품": q / 100 if q is not None else None, **more} + if one["묶음"] == "M" and (loss := _LOSS.search(_s(_cell(row, FORMULA_COL)))): + one["손료율"] = round(float(loss.group(1)) / 100, 9) # 「&M00081& * 1.03'㎡'* 0.443'%'」 + return one + + +def _rate_row(row: list[Any], code: str, text: str, label: str) -> dict[str, Any]: + """PR · RR 율 행 → 단위 줄 R(기준 = G 열 표지 「&PA01M:M&」 앞 · PR = N % · RR = 곱) · 이름이 + 없는 식 줄(「210,212.3 * 0.09」)은 위 라벨 줄(「기구손료 : (가공노력품의 9%)」)에서 이름 · + 기준글 · 못 찾으면 이름 빈 줄 + `이름없음`.""" + qty = hopyo_excel._num(_cell(row, QTY_COL)) + base = hopyo_excel._num(_cell(row, _CALC_PRICE["합계"])) + tag = _TAG.search(_s(_cell(row, FORMULA_COL))) + rate = float(qty) / (100 if code.startswith("PR") else 1) if qty is not None else None + name = text.split(":")[0] if ":" in text else "" + if not re.search(r"[가-힣A-Za-z]", name): + name = _NUMBERED.sub("", label.split(":")[0] if ":" in label else label) + word = _BASE_WORD.search(f"{text} {label}") + return { + "묶음": "R", + "코드": code, + "이름": name.strip()[:40], + "규격": "", + "품": rate, + "기준": tag.group(1) if tag else "", + "기준값": float(base) if base is not None else None, + **({"기준글": word.group(1)} if word else {}), + **({} if name.strip() else {"이름없음": True}), + } + + def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]: """{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원, 금액, 항목}}.""" names = hopyo_est.trade_names() out: dict[str, dict[str, Any]] = {} block: dict[str, Any] | None = None - title = "" + title = label = "" # 절 제목 · 율 행 위 라벨 줄(코드 · 금액 없는 글) for at, row in enumerate(rows, 1): head = next( (hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None @@ -123,12 +204,11 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str "단위": info.get("단위", ""), "행": [at, at], "글": [], - "자원": [], "금액": _calc_money(row, _CALC_AMOUNT), "항목": [], }, ) - title = "" + title = label = "" continue if block is None: continue @@ -137,16 +217,18 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str block["행"][1] = at block = None continue - text = _s(_cell(row, 1)) - res = _RES.match(_s(_cell(row, RES_COL))) + text, cell = _s(_cell(row, 1)), _s(_cell(row, RES_COL)) + res = _RES.match(cell) code = res.group(1) + res.group(2) if res else None if not code: - alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6))) + alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, FORMULA_COL))) code = alt.group(1) if alt else None + rate = None if code else _RATE.match(cell) + sub = None if code or rate else _SUB_D.match(cell) if not text and not code: continue block["행"][1] = at - if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」 + if cell == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」 if not block["이름"]: name, _, unit = text.rpartition("/") block["이름"], block["단위"] = name.strip() or text, unit.strip() @@ -156,41 +238,56 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str 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() + money = _calc_money(row, _CALC_AMOUNT) + place = { + "단가": _calc_money(row, _CALC_PRICE), + "금액": money.get("계"), + "행": at, + "절": title, + "윗절": title, + **({"밖": True} if _s(_cell(row, OUT_COL)).startswith("C") else {}), + } + if rate: + block["항목"].append({**_rate_row(row, cell, text, label), **place, "어떻게": "율"}) + continue + if code or sub or money.get("계") is not None: + label = "" # 라벨은 바로 위 글 줄만 + elif text: + label = text.strip() + if sub and sub.group(1) != block["코드"] and money.get("계"): + qty = hopyo_excel._num(_cell(row, QTY_COL)) + one = {"묶음": "S", "코드": sub.group(1), "이름": text[:40], "규격": ""} + more = {"품": float(qty) if qty is not None else None, "어떻게": "QTY"} + block["항목"].append( + {**one, **more, **place, "하위": {"종류": "단산", "번": int(sub.group(1)[1:])}} + ) + continue 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: - _add(block["항목"][-1]["단가"], price) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 - _add(block["항목"][-1]["금액"], amount) + last, part = block["항목"][-1] if block["항목"] else None, _part(row) + if code[0] == "X" and _same_machine(last, code, title, part): + _add(last["단가"], place["단가"]) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 + last["금액"] = hopyo_excel._out( + hopyo_excel._num(last["금액"] or 0) + hopyo_excel._num(place["금액"] or 0) + ) + last["_q"][part] = hopyo_excel._num(_cell(row, QTY_COL)) continue - name, _, spec = _s(_cell(row, NAME_COL)).partition("/") - info = table.get(code) or {} - qty = hopyo_excel._num(_cell(row, QTY_COL)) - 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( - { - **res, - **({"직종": who} if code[0] == "L" else {}), - "밑품": res["품"], - "율": [], - "인자": [], - "단가": price, - "금액": amount, - "어떻게": "QTY", - "행": at, - } - ) + one = _unit(row, code, title, table.get(code) or {}) + who = hopyo_est.trade(one["이름"]) if one["묶음"] == "L" else None + one = {**one, **({"직종": who} if one["묶음"] == "L" else {}), "밑품": one["품"]} + one.update(율=[], 인자=[], 어떻게="율" if one["묶음"] == "R" else "QTY", **place) + if one["묶음"] == "X": + one["_q"] = {part: hopyo_excel._num(_cell(row, QTY_COL))} if part else {} + block["항목"].append(one) for block in out.values(): + for one in block["항목"]: + if "_q" in one: + unit_line.machine_done(one) + block["자원"] = [ # 옛 칸(S1 투영) — 자원 코드 줄만 + {"갈래": one["코드"][0], **{k: one[k] for k in ("코드", "이름", "규격", "품", "절")}} + for one in block["항목"] + if _RES_CODE.match(one["코드"]) + ] 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)) diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index 981e9525c..b3ce67a73 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -32,7 +32,7 @@ DONE = "불러옴" NO_GRID = "격자 없음 — 다시 올리면 보임" RELOAD = "읽기 꼴이 바뀐 옛 불러오기 — 엑셀을 다시 올림" EXPIRE = timedelta(days=1) # 작업 중 HP 보관 — 올린 뒤 하루 -BOOK = 2 # 읽기 꼴(Book) 판 — 읽기 · 정규화 출력 꼴을 바꾸면 올림 +BOOK = 3 # 읽기 꼴(Book) 판 — 읽기 · 정규화 출력 꼴을 바꾸면 올림 _KEY = re.compile(r"^HP-(\d{6})$") # ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() diff --git a/M02_MasterTemplete/M02_Hopyo_UnitLine.py b/M02_MasterTemplete/M02_Hopyo_UnitLine.py new file mode 100644 index 000000000..ead5c3da6 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_UnitLine.py @@ -0,0 +1,219 @@ +"""M02 일위대가 실무자료 단위 줄(PLAN 778-3 · D-1 · 요구 622) — 단가산출 블록 · 일위대가 줄을 같은 +「세부항목 계산 단위」 꼴로 · 자체 검산. 형식(EST · STmate)을 모름 — 읽기 어댑터 +(`M02_Hopyo_ReadEst.items` · `M02_Hopyo_Stmate._blocks`)가 블록 `항목` 을 단위 줄로 냄. + +단위 줄 = `{묶음, 이름, 규격, 단위, 품, 밑품, 율, 인자, 계수, 단가, 금액, 밖, 기준, 기준값, +하위, 할증, 재현, 직종, 절, 절율, 코드, 어떻게, 행}`: + 묶음 = L 노무 · X 기계 · M 자재 · R 율 · S 하위 · E 값 + 품 = 매개값(L 품 · X 시간 · M 수량 · R 율 소수 · S 수량 · E 없음) · 밑품 · 율 · 인자 = 지문 몫 + 계수 = X 비목마다 매개값이 다를 때 `{비목: 그 비목 매개값 ÷ 품}`(덤프 재료비 (Cm-t1)/Cm) + 단가 = {노무비 · 재료비 · 경비(· 계)} · 금액 = 줄 금액(None = 엑셀에 없음) + 밖 = 셈 밖(STmate N 열 C · EST 합계 · 소계 절) · 기준 · 기준값 = 율 줄 기준 표지 · 기준 금액 · + 기준글 = 라벨 「…의 N%」 앞 낱말(STmate) · 기준꼴 · 기준줄 = 기준값이 어느 줄 합인가 + (`base_rows`) · + 이름없음 = 율 줄 이름 못 찾음 · 손료율 = M 「수량 × 손료율 × 단가」 꼴 표지(율 줄 아님) + 하위 = S `{종류 단산|일위, 번}` · 할증 = X 이름 · 규격 「할증120%」 → 1.2 + 재현 = 단가 × 매개값(× 계수) ≈ 금액(0.5 % · 1 원) · 율 = 기준값 × 율 · 별도 · 값 = 참 +블록 `줄합` = 밖 아닌 줄 금액 합 · `덮음` = 줄합 ≈ 블록 금액(금액 없으면 None). +일위대가 줄 → `묶음` · `재현` · % 줄 `기준`(노무비 · 재료비 · 주재료비 · 경비 = 규격 글) · +호표 `줄합` · `덮음`. +값 줄(E · 첫 수 = 다른 블록 · 호표 1 단위 금액 ±0.51 · 100 넘음) → S 하위. +""" + +from __future__ import annotations + +import re +from typing import Any + +COSTS = ("노무비", "재료비", "경비") +TOL = 0.005 +_SURCHARGE = re.compile(r"할증\s*(\d+(?:\.\d+)?)\s*%") +_KIND = { + "노임": "L", + "기계": "X", + "자재": "M", + "별도계상": "M", + "%": "R", + "단가산출": "S", + "호표": "S", +} + + +def near(a: Any, b: Any, tol: float = TOL) -> bool: + if a is None or b is None: + return False + a, b = float(a), float(b) + return abs(a - b) <= max(tol * max(abs(a), abs(b)), 1.0) + + +def money(d: Any) -> float | None: + """금액 · 단가 칸 → 수(계 › 비목 합 · 수면 그대로).""" + if isinstance(d, (int, float)) and not isinstance(d, bool): + return float(d) + if not isinstance(d, dict): + return None + if d.get("계") not in (None, ""): + return float(d["계"]) + vals = [float(d[c]) for c in COSTS if d.get(c) not in (None, "")] + return sum(vals) if vals else None + + +def machine_done(one: dict[str, Any]) -> None: + """기계 줄 마무리(두 어댑터) — 비목마다 매개값이 다르면 `계수`.""" + one.pop("_순", None) + got_q, main = one.pop("_q", {}), one.get("품") + if main: + ratio = { + k: round(float(q) / main, 9) + for k, q in got_q.items() + if q is not None and abs(float(q) / main - 1) > 1e-6 + } + if ratio: + one["계수"] = ratio + + +def _parts(price: dict[str, Any]) -> float | None: + vals = [float(price[c]) for c in COSTS if price.get(c) not in (None, "")] + return sum(vals) if vals else money(price) + + +def redo(one: dict[str, Any]) -> bool: + """줄 재현 — 단가 × 매개값(× 계수) ≈ 금액.""" + kind, q, got = one["묶음"], one.get("품"), one.get("금액") + if one.get("어떻게") == "별도" or (kind == "E" and q is None): + return True + if kind == "S" and q is None: + return got is not None + if kind == "R": + return bool(q is not None and near((one.get("기준값") or 0) * q, got)) + price = one.get("단가") or {} + if q is None or got is None: + return False + if kind == "X": + ratio = one.get("계수") or {} + want = sum(float(price[c]) * q * ratio.get(c, 1) for c in COSTS if price.get(c)) + return near(want, got) + unit = _parts(price) + return bool(unit is not None and (near(unit * q, got) or (unit == 0 and got == 0))) + + +def _subs(units: list[dict[str, Any]], totals: dict[tuple[str, int], float]) -> None: + """값 줄 첫 수가 다른 블록 · 호표 1 단위 금액이면 하위.""" + for one in units: + p = money(one.get("단가")) if one["묶음"] == "E" else None + if not p or p < 100: + continue + hit = next((k for k, v in totals.items() if abs(p - v) <= 0.51), None) + if hit: + got = one.get("금액") + q = round(float(got) / p, 6) if got is not None else None + one.update(묶음="S", 품=q, 하위={"종류": hit[0], "번": hit[1]}, 어떻게="QTY") + + +def _labor_part(one: dict[str, Any]) -> float: + """기계 줄 노무비 몫 = 노무 단가 × 시간(× 계수).""" + price, q = (one.get("단가") or {}).get("노무비"), one.get("품") + return float(price) * q * (one.get("계수") or {}).get("노무비", 1) if price and q else 0.0 + + +def base_rows(units: list[dict[str, Any]]) -> None: + """율 줄 기준값 = 어느 줄 합인가 → `기준꼴` · `기준줄 [행]`(못 찾으면 안 붙임) — 차례: + 바로 앞 같은 묶음 줄들(절 소계) › 앞 인력 › 앞 인력 + 기계 노무 › 앞 자재 › 앞 자재 + 율 › + 앞 줄 모두 › 블록 인력 › 블록 자재.""" + live = [u for u in units if not u.get("밖") and u.get("금액")] + for i, one in enumerate(units): + if one["묶음"] != "R" or not one.get("기준값"): + continue + prev = [u for u in units[:i] if u in live] + run: list[dict[str, Any]] = [] + for u in reversed(prev): + if u["묶음"] == "R" or (run and u["묶음"] != run[0]["묶음"]): + break + run.insert(0, u) + of = lambda us, k: [u for u in us if u["묶음"] in k] # noqa: E731 + pools = [ + ("절 소계", run, None), + ("인력", of(prev, "L"), None), + ("노무비", of(prev, "LX"), "L+X노무"), + ("자재", of(prev, "M"), None), + ("자재 + 율", of(prev, "MR"), None), + ("앞 줄 모두", of(prev, "LXM"), None), + ("인력(블록)", of(live, "L"), None), + ("자재(블록)", of(live, "M"), None), + ] + for name, pool, how in pools: + total = sum( + _labor_part(u) if how and u["묶음"] == "X" else float(u["금액"]) for u in pool + ) + if pool and near(total, one["기준값"]): + one["기준꼴"], one["기준줄"] = name, [u.get("행") for u in pool] + break + + +def check_block(block: dict[str, Any], totals: dict[tuple[str, int], float]) -> None: + units = block.get("항목") or [] + own = ("단산", int(block["번"])) + _subs(units, {k: v for k, v in totals.items() if k != own}) + base_rows(units) + for one in units: + if one["묶음"] == "X" and (hit := _SURCHARGE.search(f"{one['이름']} {one['규격']}")): + one["할증"] = round(float(hit.group(1)) / 100, 4) + one["재현"] = redo(one) + total = money(block.get("금액")) + lines = round(sum(float(one.get("금액") or 0) for one in units if not one.get("밖")), 4) + block["줄합"] = lines + block["덮음"] = near(lines, total) if total else None + + +def check_hopyo(hopyo: dict[str, Any]) -> None: + """일위대가 줄 → 단위 줄 투영(묶음 · 재현 · % 기준) · 호표 줄합 · 덮음.""" + run = dict.fromkeys(COSTS, 0.0) + main = lines = 0.0 + for line in hopyo.get("줄") or []: + kind = _KIND.get(str(line.get("갈래")), "E") + q, got = line.get("수량"), money(line.get("금액")) + line["묶음"] = kind + if line.get("갈래") == "별도계상": + line["재현"] = True + elif kind == "R": + spec = re.sub(r"\s+", "", str(line.get("규격") or "")) + base = ( + "주재료비" + if "주재료" in spec + else "재료비" + if "재료비" in spec + else "경비" + if spec.startswith("경비") + else "노무비" + ) + line["기준"] = base + value = main if base == "주재료비" else run[base] + line["재현"] = bool(q and got is not None and near(value * float(q) / 100, got)) + else: + p = money(line.get("단가")) + line["재현"] = bool(q is not None and p is not None and near(float(q) * p, got)) + if kind != "R": + for c in COSTS: + run[c] += float((line.get("금액") or {}).get(c) or 0) + if line.get("갈래") == "자재": + main += float((line.get("금액") or {}).get("재료비") or 0) + lines += got or 0 + total = money(hopyo.get("금액")) + hopyo["줄합"] = round(lines, 4) + hopyo["덮음"] = near(lines, total) if total else None + + +def settle(book: dict[str, Any]) -> dict[str, Any]: + """읽은 책(HP 꼴) → 블록 단위 줄 하위 가름 · 재현 · 덮음 + 일위대가 줄 투영(제자리).""" + totals: dict[tuple[str, int], float] = {} + for block in book.get("단산") or []: + if (v := money(block.get("금액"))) is not None: + totals[("단산", int(block["번"]))] = v + for hopyo in book.get("호표") or []: + if (v := money(hopyo.get("금액"))) is not None: + totals[("일위", int(hopyo["번"]))] = v + for block in book.get("단산") or []: + check_block(block, totals) + for hopyo in book.get("호표") or []: + check_hopyo(hopyo) + return book diff --git a/resources/tester/fixtures/m02_hopyo_stages.json b/resources/tester/fixtures/m02_hopyo_stages.json index daa604e4a..6fb8ba5a2 100644 --- a/resources/tester/fixtures/m02_hopyo_stages.json +++ b/resources/tester/fixtures/m02_hopyo_stages.json @@ -26,56 +26,56 @@ } }, "S2": { - "단산:46|레미콘타설(장비사용)|철근구조물-소량타설|m3": "14b94bd44235", + "단산:46|레미콘타설(장비사용)|철근구조물-소량타설|m3": "fe6524b2bf88", "단산:24|합판거푸집|4회|m2": "dc9227913b7d", - "단산:33|기초잡석채집,운반,다짐|발파암활용,덤프15톤,l=500m|m3": "569f93218071", + "단산:33|기초잡석채집,운반,다짐|발파암활용,덤프15톤,l=500m|m3": "53d20022ff32", "단산: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", + "단산:6|야면석채집및운반(계곡내)|굴삭기0.6m3,35cm,4.5톤,l=500m|m2": "c0521e16ff75", + "단산:3|고임돌채집및운반|굴삭기0.6m3(90%),4.5톤,l=500m|m3": "2521254f68a8", + "단산:10|야면석소운반|l=35cm|m2": "50eb93121621", + "단산:39|야면석찰붙임|l3=35cm적용,장비사용|m2": "cf4739ae6e8c", "단산:93|구조물터파기(인력100%)|토사|m3": "c523b43c617b", "단산:12|구조물되메우기(인력100%)||m3": "559c125ef42e", "단산:13|잔토처리(인력100%)||m3": "eb0395c76e02", - "단산:22|레미콘타설(장비사용)|무근구조물|m3": "0c3cebbe76de", + "단산:22|레미콘타설(장비사용)|무근구조물|m3": "12555a58e991", "단산:26|유로폼|보통,0~7m|m2": "a301adcd84cd", "단산:18|문양거푸집||m2": "9f4efb5510bf", "단산:37|p.v.c파이프설치|ø50mm|m": "a408f8e02b28", "단산:38|다웰바설치|ø32x1000|개": "13410747be6e", - "단산:41|휠라설치|t=15mm,1000x1500mm|m2": "4df1b309aca4", + "단산:41|휠라설치|t=15mm,1000x1500mm|m2": "23bf202fc7a4", "단산: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", + "단산:30|큰돌채집운반|공사중발생석활용50%적용|m2": "66cb28bdab1c", + "단산:32|큰돌쌓기(메쌓기)|직경40cm이상~60cm미만|m2": "0d884c7f218d", + "단산:2|뒷채움돌채집운반뒷채움(발파)|굴삭기0.6m3(90%),4.5톤,l=500m|m3": "ec132e6186ae", + "단산:31|큰돌쌓기(찰쌓기)_배수공|직경40cm이상~60cm미만|m2": "b4e9d2ee8233", "단산: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", + "단산:42|도로반사경설치|d=1000(sts)|개소": "3c2ee04632d3", + "단산:88|야면석메붙임|l3=35cm내외,장비사용|m2": "fbc297bd41b0", + "단산:92|큰돌메붙임|직경40cm이상~60cm미만|m2": "d1fad346524d", + "단산:91|큰돌찰붙임|직경40cm이상~60cm미만|m2": "269c2c76c667", + "단산:57|레미콘타설(장비사용)|소형구조물|m3": "9038aaf1131e", + "단산:71|야면석찰쌓기|l3=35cm적용,장비사용|m2": "e74767626ed4", + "단산:43|면고르기||m2": "ec9f5d99380f", "줄:대형브레이카|+굴착기(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", + "단산:61|조약돌채집및운반|굴삭기0.6m3(90%),4.5톤,l=500m|m3": "b07780f6fab7", + "단산:63|돌망태형옹벽설치|사각돌망태|m2": "61535c440543", + "단산:65|콘크리트포장(인력)|t=20cm,종단15%미만|m3": "14fb1320b388", "단산:64|비탈면보호공|평떼|m2": "5d9d68fa8679", - "단산:66|콘크리트포장(인력)|t=20cm,종단15%이상|m3": "2374789b6ae0", - "단산:70|고화매트설치||m2": "80be3cc78047", - "단산:90|큰돌쌓기(찰쌓기)|직경40cm이상~60cm미만|m2": "52a0f47bb751", - "단산:23|레미콘타설(장비사용)|철근구조물|m3": "fe0d22d627da", + "단산:66|콘크리트포장(인력)|t=20cm,종단15%이상|m3": "4739d20909b5", + "단산:70|고화매트설치||m2": "61d7220158c9", + "단산:90|큰돌쌓기(찰쌓기)|직경40cm이상~60cm미만|m2": "8efce35c90b3", + "단산:23|레미콘타설(장비사용)|철근구조물|m3": "4814d3d0bb6f", "단산:73|강관동바리|암거용-1개월사용|공/m3": "bc3f5092d701", "단산:72|철근가공조립(현장)|type-ll|톤": "75dda2f569a2", "단산:74|강관비계|10m이하,3개월사용|m2": "90ce7f0da95c", - "단산:49|흙깍기(토사)|굴삭기1.0m3,보통토사|m3": "f9aea56b06bf", - "단산:56|잔토정리|굴삭기0.6m3|m3": "9f76f0e32710", + "단산:49|흙깍기(토사)|굴삭기1.0m3,보통토사|m3": "a893fe169785", + "단산:56|잔토정리|굴삭기0.6m3|m3": "63177d8c31eb", "단산:82|잡철물제작설치|규격철물설치(일반철재)|톤": "531650fd115f", "단산:95|도장(철재면2회)|방청1,유성2|m2": "92867210fce0", - "단산:81|임도안전표지판설치|삼각,900mm|개소": "ee09c0727967" + "단산:81|임도안전표지판설치|삼각,900mm|개소": "b28761e99520" } }, "북부청사방": { @@ -106,8 +106,8 @@ } }, "S2": { - "단산:22|레미콘타설(장비사용)|철근구조물|m3": "e2940f403c6c", - "단산:20|레미콘타설(장비사용)|무근구조물|m3": "d0347d9e519e", + "단산:22|레미콘타설(장비사용)|철근구조물|m3": "a35e3f7819d4", + "단산:20|레미콘타설(장비사용)|무근구조물|m3": "6caceddb40b1", "단산:38|유로폼(euroform)|6회,보통|m2": "0cd2081fa29f", "단산:53|철근가공조립(현장)|type-ii|톤": "d6d92c499d78", "단산:4|강관비계|(10m이하)|m2": "7c1c9f2d283a", @@ -115,44 +115,44 @@ "단산:37|유로폼(euroform)|6회,간단|m2": "d6ac6fccfcfd", "단산:52|철근가공조립(현장)|type-i(소량)|톤": "e3d8470f9851", "단산:49|철강재운반(할증포함)|(구역화물)|톤": "a619ce7fe816", - "단산:51|철골설치||톤": "8e292d7859f8", - "단산:50|철골가공조립(공장)|(대형)|톤": "b386a69202ef", + "단산:51|철골설치||톤": "f8b5b9b49bf5", + "단산:50|철골가공조립(공장)|(대형)|톤": "ebe8740e0a1e", "단산:42|잡철물제작||톤": "ef730a79e03f", "단산:67|합판거푸집(6회)||m2": "b3ba20147436", "단산:33|와이어메시바닥깔기||m2": "2f898ef1909d", - "단산:11|구조물터파기(토사)||m3": "5b2c549e3ec2", - "단산:8|구조물되메우기및다짐||m3": "184c85a346db", - "단산:39|잔토처리||m3": "147afeac7acd", - "단산:21|레미콘타설(장비사용)|무근구조물-소량타설|m3": "6b7edafbfd22", + "단산:11|구조물터파기(토사)||m3": "d104c4f35461", + "단산:8|구조물되메우기및다짐||m3": "4b834cdae99f", + "단산:39|잔토처리||m3": "62e430cfb337", + "단산:21|레미콘타설(장비사용)|무근구조물-소량타설|m3": "32bb4fda75e2", "단산:66|합판거푸집(4회)||m2": "39bdcddca57c", - "단산:12|기초잡석다짐및채집운반|(9:1)(0.6m3+4.5ton)|m3": "dc7a46817506", - "단산:26|배수관부설|(d=1000mm)|m": "e6f492785d36", + "단산:12|기초잡석다짐및채집운반|(9:1)(0.6m3+4.5ton)|m3": "24dcf4cf1ac0", + "단산:26|배수관부설|(d=1000mm)|m": "7c06bc3e3a91", "호표:7": "9238de708278", "단산:19|레미콘타설(인력운반)|무근구조물-소량타설|m3": "728a28bc9224", "단산:9|구조물터파기|인력|m3": "06855a4c159a", "단산:7|구조물되메우기|인력|m3": "8d23c8175496", - "단산:40|잔토처리|인력|m3": "e62dedcec317", + "단산:40|잔토처리|인력|m3": "e488fa518c11", "단산: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", + "단산:36|원형돌망태채움|산림사업품셈|조": "c8e1f0314e45", + "단산:43|조약돌채집운반|(9:1)(0.6m3+4.5ton)|m3": "1cc5362dc3b4", + "단산:10|구조물터파기(암)||m3": "0d821feb9c44", + "단산:27|배수관부설|(d=500mm)|m": "964f47b3dd45", + "단산:18|레미콘타설(무근펌프카)(130m3)|41m(80-95m3/hr)|m3": "6da4e3284f73", "줄:콘크리트펌프차|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" + "단산:17|떼붙임(평떼)|산림사업품셈|m2": "0319c3fc3b39", + "단산:60|큰돌메쌓기|40cm-60cm,(산림사업품셈)|m2": "83f1a9a2b201", + "단산:5|고임돌채집및운반|(9:1)(0.6m3+4.5ton)|m3": "6f841c019f4e", + "단산:16|뒷채움잡석채집운반다짐|(9:1)(0.6m3+4.5ton)|m3": "eb182b275f99", + "단산:62|큰돌찰쌓기|40cm-60cm,(산림사업품셈)|m2": "3045ba240634", + "단산:63|큰돌채집운반|산림사업품셈|m2": "35a75e3957bb", + "단산:13|뒷채움잡석구입운반||m3": "c425998ad058", + "단산:15|뒷채움잡석뒷채움||m3": "f9d5674557e8", + "단산:14|뒷채움잡석구입운반|(창봉기번3)(2차)|m3": "8a37a388d59a", + "단산:59|큰돌메붙임|40cm-60cm,(산림사업품셈)|m2": "1aefe1f21b3b", + "단산:61|큰돌찰붙임|40cm-60cm,(산림사업품셈)|m2": "b140bb188ade" } }, "거창": { @@ -188,26 +188,26 @@ "호표: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", + "단산:30|유로폼|보통,0~7m|m2": "8e45789b4266", + "단산:38|철근가공및조립(현장)|type-ii-1|톤": "f65df34c4c3e", + "단산:39|강관비계|10m이하(3개월)|m2": "a96380c10324", + "단산:29|유로폼|간단,0~7m|m2": "cb2624477569", "호표:10": "fc2cc4cd2868", "호표:11": "998a0263fed9", - "단산:36|레미콘타설(장비사용타설)|무근구조물|m3": "39982206412d", - "단산:40|레미콘타설(장비사용타설)|철근구조물|m3": "41c11b60b1e1", - "단산:41|합판거푸집|4회|m2": "dfd5ab302d05", + "단산:36|레미콘타설(장비사용타설)|무근구조물|m3": "c0b9cfa60d77", + "단산:40|레미콘타설(장비사용타설)|철근구조물|m3": "198ef508476c", + "단산:41|합판거푸집|4회|m2": "965f0578c65a", "단산:31|철근가공및조립(현장)|type-i-1(소형)|톤": "a98e998b35c0", - "단산:42|강관동바리|2.5m이하(1개월)|공m3": "99d65ebd3dcc", - "단산:43|잡철물제작및설치(현장)|일반철재|톤": "71c1f965449f", - "단산:44|안내표지판설치|단주식|개소": "38427ebd6d28", + "단산:42|강관동바리|2.5m이하(1개월)|공m3": "15e8a1fae939", + "단산:43|잡철물제작및설치(현장)|일반철재|톤": "9803bb826c42", + "단산:44|안내표지판설치|단주식|개소": "3c6f75841d3b", "단산:37|레미콘타설(인력운반타설)|소형구조물|m3": "6c2df8e4fd6a", - "단산:45|합판거푸집|6회,간단|m2": "85dcc3e13e28" + "단산:45|합판거푸집|6회,간단|m2": "ec2e16734886" } }, "봉화": { "S1": { - "해시": "fcf03906734a", + "해시": "4c3ba7796291", "꼴": "STmate", "호표": 45, "줄": 209, @@ -236,36 +236,36 @@ "S2": { "줄:굴삭기(타이어)|0.6m3,(부착용집게)|시간": "7ba959ec2e9d", "호표:1": "ee8a116be0f0", - "단산:96|레미콘타설(장비)|무근,소형|m3": "fcbfc0642dc2", + "단산:96|레미콘타설(장비)|무근,소형|m3": "fed15560be74", "단산:102|합판거푸집(간단)|간단|m2": "70042bc48cb6", "단산:4|글자새김|자당6x6|자": "ff7e1309d192", - "단산:2|구조물터파기(토사)|굴삭기0.2m3|m3": "2ce888963761", - "단산:7|구조물되메우기|굴삭기0.2m3|m3": "c72b4a1494d6", - "단산:49|잔토처리|굴삭기0.2m3|m3": "800b67112b8d", + "단산:2|구조물터파기(토사)|굴삭기0.2m3|m3": "cbead3149b3c", + "단산:7|구조물되메우기|굴삭기0.2m3|m3": "3ff7d6c09304", + "단산:49|잔토처리|굴삭기0.2m3|m3": "bc633bad0c98", "호표:5": "566b3ec95fef", - "단산:43|깬잡석채집|l=45cm내외|m2": "6c67d6e7d946", - "단산:5|고임돌채집|기계(0.7m3,100%)|m3": "304739062d51", - "단산:6|막자갈채집|기계(0.7m3,100%)|m3": "b149f96f7918", - "단산:8|기초다짐및뒤채움|소형장비사용시공|m3": "d1e2a67c4989", + "단산:43|깬잡석채집|l=45cm내외|m2": "9382675c6d66", + "단산:5|고임돌채집|기계(0.7m3,100%)|m3": "e6a8fdb7c927", + "단산:6|막자갈채집|기계(0.7m3,100%)|m3": "d862c41aa425", + "단산:8|기초다짐및뒤채움|소형장비사용시공|m3": "77b469a4fd17", "단산:93|유로폼설치및해체|간단|m2": "5174b34972ac", - "단산:95|레미콘타설(장비)|무근|m3": "1e45fdfca996", - "단산:89|콘크리트믹서사용|0.45m3|m3": "71825ddc35da", + "단산:95|레미콘타설(장비)|무근|m3": "bff32ab35cc6", + "단산:89|콘크리트믹서사용|0.45m3|m3": "1538c2627ebb", "단산:94|유로폼설치및해체|보통|m2": "1985ee7ba47c", "단산:98|철근가공및조립|type-i-1,현장가공조립|톤": "f91f3febebf1", "호표:9": "992b00a50c65", "호표:13": "2ecc59f87b37", - "단산:51|깬잡석채집|l=30cm내외|m2": "d23b63b9635a", - "단산:12|콘크리트(기계비빔)|무근구조물|m3": "c9ca9357a44a", + "단산:51|깬잡석채집|l=30cm내외|m2": "e2b6831be198", + "단산:12|콘크리트(기계비빔)|무근구조물|m3": "c74e9c324b2c", "호표:17": "b50496d3f922", "호표:18": "d796d9bbd4bd", "호표:22": "2c27c0d9b1e1", - "단산:23|구조물터파기(육상토사0~1m)|기계90%+인력10%(굴삭기0.7m3)|m3": "1e4b4e03372f", - "단산:24|잔토처리|굴삭기0.7m3|m3": "b34905b7e0c2", + "단산:23|구조물터파기(육상토사0~1m)|기계90%+인력10%(굴삭기0.7m3)|m3": "db3c5678df4f", + "단산:24|잔토처리|굴삭기0.7m3|m3": "d315c818fc0f", "줄:굴삭기(타이어)|0.6m3|시간": "733bdb0730f2", "호표:24": "24b7a61d2cac", - "단산:3|깬잡석채집|l=35cm내외|m2": "6fa38565606b", + "단산:3|깬잡석채집|l=35cm내외|m2": "77cbf6e4a2ff", "호표:26": "253290e88b9f", - "단산:52|깬잡석채집|l=60cm내외|m2": "daab119d679a", + "단산:52|깬잡석채집|l=60cm내외|m2": "44b9b3dcffc3", "호표:28": "10cf341d2ca3", "단산:81|철근가공및조립(현장)|일반(type-i-1)|톤": "39d3a7e2e7a9", "호표:31": "82b74ab253f3", @@ -273,11 +273,11 @@ "호표:33": "35144fc92a5c", "호표:34": "9b6cffd83699", "호표:35": "4eb05246e0c3", - "단산:10|레미콘타설(장비사용)|소형구조물|m3": "8717ad5b8652", + "단산:10|레미콘타설(장비사용)|소형구조물|m3": "640411e0f0a8", "단산:90|평떼붙임||m2": "13cce5a5166b", "단산:91|단끊기||m": "8701e94849ea", - "단산:97|레미콘타설(장비)|철근|m3": "6c8931bf2865", - "단산:103|레미콘타설(장비)|무근,버림|m3": "c99fd33aad21", + "단산:97|레미콘타설(장비)|철근|m3": "d419cafa4932", + "단산:103|레미콘타설(장비)|무근,버림|m3": "773debae71b8", "단산:99|철근가공및조립|type-ii-1,현장가공조립|톤": "e6dc6a25683b", "단산:113|강관동바리|암거용-3개월사용|공/m3": "55bd7c4f030a", "단산:112|강관비계|3개월사용|m2": "2390793aeb4b", @@ -320,41 +320,41 @@ "단산:9|잔토처리|인력100%|m3": "aef7e1b84e30", "줄:크레인(타이어)|10톤|시간": "96a1f07e863b", "호표:3": "6c59b8689986", - "단산:3|구조물터파기|토사(b/h90%+인력10%)|m3": "acefa16cc78a", - "단산:10|잔토처리|토사(b/h100%)|m3": "c425e33125c6", - "단산:37|콘크리트타설|임도품셈5-5|m3": "65c3e427122f", + "단산:3|구조물터파기|토사(b/h90%+인력10%)|m3": "7953545f58b8", + "단산:10|잔토처리|토사(b/h100%)|m3": "fa9015056847", + "단산:37|콘크리트타설|임도품셈5-5|m3": "2d0b3ca0824f", "단산: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", + "단산:600|기초잡석채집및운반부설|4.5톤(l=500)|m3": "4784bc1b14b0", + "단산:1926|임도안전표지판설치|삼각,d=900|개소": "e05b7a689c1d", + "단산:411|야면석채집운반|(25x25x35cm)장비|m2": "545af7826ba4", + "단산:644|야면석찰붙임|뒷길이35cm|m2": "20cfc54d1cb3", + "단산:1361|고임돌채집운반|4.5톤덤프(l=500)|m3": "09f75762e692", + "단산:1928|잡석운반및다짐||m3": "7c507cfd7d53", + "단산:1927|면고르기||m2": "5d24952044bf", "단산:1942|구조물터파기|인력(경질토사)|m3": "b25a97237575", "단산:1759|도로반사경설치|d=1000(sts)|개소": "8f206fca6ae6", - "단산:2071|철근콘크리트타설|장비사용타설|m3": "7a4603d42158", + "단산:2071|철근콘크리트타설|장비사용타설|m3": "998daf7e771a", "단산: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", + "단산:1843|야면석메붙임|뒷길이35cm|m2": "e366c880bdb0", + "단산:258|측구수로관부설|500∼700kg미만|m": "d7100836ca16", + "단산:1844|콘크리트타설|장비사용타설(소형)|m3": "2d506172eebd", "단산:1841|합판거푸집(4회)|보통|m2": "154931aee1b7", - "단산:2001|혼합골재부설|영월-산불|m3": "3c98157c0505", + "단산:2001|혼합골재부설|영월-산불|m3": "64a7980290a9", "단산: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" + "단산:1752|콘크리트포장절단공|임도|m": "35f09be77f1b", + "단산:1519|뒷채움돌채집운반부설|4.5톤덤프트럭(l=500m)|m3": "4bcbbc780730", + "단산:81|큰돌메쌓기|뒷길이0.6~0.8m|m2": "3c6626a3d944", + "단산:1810|큰돌채집운반(뒷길이0.6~0.8m)|장비(80%)+인력(20%)|m2": "374e7a1f93a3" } }, "울진대흥": { "S1": { - "해시": "a2052e0b170c", + "해시": "2a378597aa15", "꼴": "STmate", "호표": 34, "줄": 171, @@ -387,26 +387,26 @@ "호표:4": "9b0429327796", "호표:5": "5e88ce0e4c68", "호표:6": "31bd5eea19c8", - "단산:75|콘크리트믹서사용|0.45m3|m3": "bb028865ae10", + "단산:75|콘크리트믹서사용|0.45m3|m3": "b470c79eb976", "호표:7": "3476f12e55bf", "호표:8": "b4f5087c15f6", "호표:9": "51afe3b087b1", "호표:10": "fb1dd2cf4c6a", "줄:굴삭기(0.7m3)||시간": "9c5792c9e5eb", "호표:11": "af2de3ed3f1c", - "단산:1124|고임돌채집|기계|m3": "d92fb83f5e54", - "단산:159|뒷채움|150m/m내외|m3": "6c3ab2cbc163", - "단산:959|막자갈채집|기계|m3": "f84d5f824e2d", + "단산:1124|고임돌채집|기계|m3": "ae817a2ac5a0", + "단산:159|뒷채움|150m/m내외|m3": "08709d597e93", + "단산:959|막자갈채집|기계|m3": "eacff8bea289", "단산: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", + "단산:1129|야면석채집|l3=45cm내외|m2": "a59e92e86b67", + "단산:2119|야면석채집|l3=35cm내외|m2": "b0e17ce1bcaf", + "단산:1343|구조물터파기(토사)|굴삭기0.7m3|m3": "72abcf573fe3", + "단산:2139|다지기|굴삭기0.7m3|m3": "2718acaec93b", "단산:2195|유로폼설치및해체|간단|m2": "2ada005f71d9", - "단산:2262|레미콘타설|무근(장비사용타설)|m3": "0011fde861d6", + "단산:2262|레미콘타설|무근(장비사용타설)|m3": "fca8b8959999", "호표:28": "fd2ef8bc220b", "줄:굴삭기(타이어)|0.6m3,(부착용집게,0.7~0.8m3)|시간": "94a580e075dd", "호표:29": "02e551e5e700", @@ -448,7 +448,7 @@ "호표:6": "e3b9e469607e", "호표:7": "1cd3bb8c5e6d", "호표:8": "f5f08c081e83", - "단산:150|고임돌채집|기계|m3": "6156ce99f6a7", + "단산:150|고임돌채집|기계|m3": "7de261f760cd", "호표:10": "c5b7c43c3ee1", "단산:1453|인력운반|기타|톤": "355f5eac9936", "호표:12": "c46a6d7e568f", @@ -457,7 +457,7 @@ }, "울진소광": { "S1": { - "해시": "af7a97c94ed7", + "해시": "bd1310dccd12", "꼴": "STmate", "호표": 25, "줄": 117, @@ -498,17 +498,17 @@ "호표: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", + "단산:1456|구조물터파기(토사)|굴착기0.6m3|m3": "0973406f3e2a", + "단산:1474|구조물되메우기|굴착기0.6m3|m3": "d7cddaa3d1ba", + "단산:1475|구조물잔토처리|굴착기0.6m3|m3": "5827fbbcc4b8", + "단산:492|채움콘크리트|기계비빔|m3": "07f036449ef1", + "단산:91|고임돌채집|굴삭기0.7m3|m3": "d46bfcbe8564", + "단산:408|막자갈(파쇄석)채집||m3": "946bdf1420ed", + "단산:867|막자갈채우기|150mm내외|m3": "d2df9375e449", + "단산:1336|레미콘타설|무근(장비)|m3": "9991557bff22", "단산:918|채움con'c|기계비빔|m3": "0c75dd9a29d2", - "단산:1448|파쇄석채집|l3=35cm내외|m2": "5d1cf0f37342", - "단산:1030|고임돌채집|굴삭기0.2m3|m3": "f483cf51e53b", + "단산:1448|파쇄석채집|l3=35cm내외|m2": "8f7299995319", + "단산:1030|고임돌채집|굴삭기0.2m3|m3": "669ef7170a82", "단산:1354|콘크리포장(t=20cm)포설|인력포설|m3": "ec05dd9a542f", "단산:794|단끊기|절취없음|m": "6aa07c8cba17", "단산:1329|평떼붙이기||m2": "0522e960d244", diff --git a/resources/tester/test_778_3_hopyo_unit_lines.py b/resources/tester/test_778_3_hopyo_unit_lines.py new file mode 100644 index 000000000..d91bc22fc --- /dev/null +++ b/resources/tester/test_778_3_hopyo_unit_lines.py @@ -0,0 +1,174 @@ +"""778-3(D-1) 단위 줄 — 단가산출 블록 · 일위대가 줄을 같은 계산 단위 꼴로(`M02_Hopyo_UnitLine`). + +꼴(EST 글 · STmate 행) · 같은 기계 코드 두 묶음 따로 · 비목마다 매개값 `계수` · 율 행 · 셈 밖 · +여덟 파일(불리는 블록) 줄 수 · 재현 · 덮음 · 할증 풀이 · 요구 624(이름 없는 율 줄 · 두 행 노무 · +손료율 표지). 읽기만 · 서버 없음. +""" + +from __future__ import annotations + +import warnings +from collections import Counter +from functools import lru_cache + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_ReadEst as est +from M02_MasterTemplete import M02_Hopyo_Sangeun as sg +from M02_MasterTemplete import M02_Hopyo_Stmate as st +from M02_MasterTemplete import M02_Hopyo_UnitLine as ul +from resources.tester.helper_m02_hopyo_golden import FILES + +warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") +KEYS = {"묶음", "이름", "규격", "품", "단가", "금액", "어떻게", "행", "절", "윗절"} + + +def test_EST_글_단위_줄(): + text = [ + " 1. 굴착기(무한궤도)(0.7㎥)", + " 노무비 : 59,020 ÷ 26.95 × 0.9 = 1,970.9", + " 재료비 : 19,473 ÷ 26.95 = 722.5", + " 2. 인 부", + " 보통인부 : 172,068 × 0.011인 = 1,892.7 (지주)", + " 합판(내수),대전 (12×1220=2440) 28,500. × 0.1 = 2,850.", + " 각재(외송) 500,000 × 0.038 ㎥ × 38. % = 7,220 ₩ / ㎡", + " 특별인부 : P", + " 3. 공구손료(인력품의 3%)", + " 1,892.7 × 0.03 = 56.7", + " 레미콘 : 별도계상", + " 4. 합 계", + " 보통인부 : 172,068 × 0.011인 = 1,892.7", + ] + got = est.items(text, list(range(1, len(text) + 1))) + book = {"단산": [{"번": 1, "금액": {"계": 14712.8}, "항목": got}], "호표": []} + ul.settle(book) + kinds = [(u["묶음"], bool(u.get("밖"))) for u in got] + assert kinds == [ + ("X", False), + ("L", False), + ("M", False), + ("M", False), + ("R", False), + ("M", False), + ("L", True), + ] # fmt: skip — 「특별인부 : P」 선언 줄 뺌 + assert all(KEYS <= set(u) for u in got) + mach, labor, mat, loss, rate = got[:5] + assert mach["품"] == pytest.approx(0.9 / 26.95) and mach["계수"] == { + "재료비": pytest.approx(1 / 0.9) + } + assert mach["금액"] == pytest.approx(2693.4) and mach["재현"] + assert labor["직종"] == "보통인부" and labor["금액"] == 1892.7 and labor["재현"] + assert mat["이름"].startswith("합판") and mat["품"] == 0.1 and mat["재현"] # 끝 점 · 이름 속 = + assert loss["손료율"] == 0.38 and loss["금액"] == 7220 and loss["재현"] # 손료율 = 자재 줄 표지 + assert rate["품"] == 0.03 and rate["기준값"] == 1892.7 and rate["기준"] == "인력품" + assert ( + rate["재현"] and rate["절"] == "공구손료(인력품의 3%)" and rate["이름"].startswith("공구") + ) + assert rate["기준꼴"] == "인력" and rate["기준줄"] == [5] + assert book["단산"][0]["줄합"] == pytest.approx(14712.8) and book["단산"][0]["덮음"] + + +def _row(**cells) -> list: + row = [None] * 24 + for col, v in cells.items(): + row[int(col[1:])] = v + return row + + +def test_STmate_같은_코드_두_묶음_따로_계수_율_밖(): + machine = "굴삭기(0.7m3) / 0.7㎥,(암석)" + rows = [ + _row(c0="제 1호표", c2=100, c6="`BDCOD|D00001"), + _row(c1=" (1) 채집"), + _row(c1=" ① 굴삭기", c2=10, c3=10, c7=0.1, c8=100, c9=100, c12="X00009_1", c23=machine), + _row(c1=" 재료비", c2=5, c4=5, c7=0.05, c8=100, c10=100, c12="X00009_1", c23=machine), + _row(c1=" (2) 상차"), + _row(c1=" 노무비", c2=20, c3=20, c7=0.2, c8=100, c9=100, c12="X00009_1", c23=machine), + _row( + c1=" 기구손료 : 30 * 2 %", + c2=0.6, + c4=0.6, + c6="&PA01M:M& * 2'%' =", + c7=2, + c8=30, + c12="PR_1", + ), + _row(c1=" 기구손료 : (가공노력품의 9%)"), + _row( + c1=" 30 * 0.09 = 2.7", + c2=2.7, + c4=2.7, + c6="&RA01:M& * 0.09 =", + c7=0.09, + c8=30, + c12="RR_1", + ), + _row( + c1=" 보통인부 : 1 * 0.5", + c2=50, + c3=50, + c7=0.5, + c8=100, + c9=100, + c12="L00002_1", + c13="C1_1", + c23="보통인부 / ", + ), # fmt: skip + _row(c0="총계"), + ] + blocks = st._blocks(rows, {}) + units = blocks["D00001"]["항목"] + xs = [u for u in units if u["묶음"] == "X"] + assert [x["품"] for x in xs] == [0.1, 0.2] # 두 묶음 따로(단가 두 배 아님) + assert xs[0]["단가"]["계"] == 200 and xs[1]["단가"]["계"] == 100 + assert xs[0]["계수"] == {"재료비": 0.5} and xs[0]["금액"] == 15 + rate, bare = [u for u in units if u["묶음"] == "R"] + assert rate["품"] == 0.02 and rate["기준"] == "PA01M" and rate["기준값"] == 30 + assert bare["이름"] == "기구손료" and bare["기준글"] == "가공노력품" and bare["품"] == 0.09 + assert next(u for u in units if u["묶음"] == "L").get("밖") is True + assert [r["코드"] for r in blocks["D00001"]["자원"]] == ["X00009", "X00009", "L00002"] + + +@lru_cache(maxsize=1) +def _books() -> dict: + return {tag: hx.read_book(p.name, p.read_bytes()) for tag, p in FILES.items()} + + +def test_여덟_파일_불리는_블록_줄_재현_덮음(): + kinds, outside, redo, cover = Counter(), Counter(), Counter(), Counter() + lines = Counter() + for book in _books().values(): + _, first = sg.calls(book) + called = {int(k) for k in first} + for block in book["단산"]: + if int(block["번"]) not in called: + continue + cover[block["덮음"]] += 1 + for u in block["항목"]: + if u.get("밖"): + outside[u["묶음"]] += 1 + continue + kinds[u["묶음"]] += 1 + redo[u["재현"]] += 1 + for hopyo in book["호표"]: + lines.update(bool(x["재현"]) for x in hopyo["줄"]) + assert dict(kinds) == {"L": 366, "X": 190, "M": 130, "R": 152, "S": 3, "E": 1} + assert dict(outside) == {"L": 2} + assert redo[True] >= 836 and sum(redo.values()) == 842 # 전 811/836 + assert cover[True] == 204 and sum(cover.values()) == 206 # 남은 둘 = 엑셀 자체 흠(합이 줄 뺌) + assert lines[True] == 1344 and sum(lines.values()) == 1350 + + +def test_할증_풀이_같은_코드_따로(): + books = _books() + block = next(b for b in books["울진소광"]["단산"] if b["번"] == 1448) + xs = [u for u in block["항목"] if u["묶음"] == "X"] + assert [x["할증"] for x in xs] == [1.2, 1.2, 1.25] and all(x["재현"] for x in xs) + block = next(b for b in books["울진대흥"]["단산"] if b["번"] == 1129) + dig = [u for u in block["항목"] if u.get("코드") == "X00009"] + assert len(dig) == 2 and all(u["단가"]["계"] == 100378 and u["재현"] for u in dig) + block = next(b for b in books["봉화"]["단산"] if b["번"] == 3) + dump = next(u for u in block["항목"] if u["이름"].startswith("덤프")) + assert dump["계수"]["재료비"] == pytest.approx(0.1527, abs=1e-4) and dump["재현"] diff --git a/resources/tester/test_m02_hopyo_finger.py b/resources/tester/test_m02_hopyo_finger.py index b0e48acce..1e57d4eab 100644 --- a/resources/tester/test_m02_hopyo_finger.py +++ b/resources/tester/test_m02_hopyo_finger.py @@ -1,10 +1,11 @@ """338-1 엑셀 지문(`M02_Hopyo_Finger` · `_Words` · `_Shares`) — 식 풀기 일곱 꼴 · 합판 지문 · 고임돌 -섞음 · -몫 · STmate 자원 · 기계 줄 · 직접 줄 호표. 실물 = 북부청 임도(읽기만).""" +섞음 · 몫 · STmate 단위 줄 · 기계 줄 · 직접 줄 호표. 지문 = 블록 단위 줄 투영(776-3 · 형식 모름). +실물 = 북부청 임도(읽기만).""" import pytest from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_ReadEst as est from M02_MasterTemplete import M02_Hopyo_Shares as shares from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs from resources.tester.helper_m02_hopyo_golden import line_of @@ -12,6 +13,11 @@ from resources.tester.helper_m02_hopyo_golden import line_of approx = pytest.approx +def _est(text: list[str]) -> dict: + """EST 글 → 블록(단위 줄 = 어댑터 `ReadEst.items`).""" + return {"번": 1, "이름": "시험", "단위": "㎥", "글": text, "항목": est.items(text)} + + # ── 식 풀기 ──────────────────────────────────────────────────────────── def test_식_단가_떼고_품(): got = parse_rhs("273,540 × 3인 ÷ 55㎥ × 2 = 29,840.7") @@ -44,7 +50,7 @@ def test_식_횟수_품할증(): def test_식_못_풀면_역산(): assert parse_rhs("273,540 × P = 30,000") is None - block = {"번": 1, "이름": "시험", "단위": "㎥", "글": [" 특별인부 : 273,540 × P = 27,354"]} + block = _est([" 특별인부 : 273,540 × P = 27,354"]) fp = finger.excel(block, {"이름": "시험", "규격": "", "단위": "㎥"}) assert fp["인력"]["특별인부"]["품"] == approx(0.1) and fp["인력"]["특별인부"]["어떻게"] == [ "역산" @@ -94,28 +100,21 @@ def test_몫_절마다_노임_장비(): " 3. 합 계", " 보통인부 : 172,068 × 1 = 172,068", ] - parts = shares.sections(texts) + parts = shares.sections(est.items(texts)) assert [p["제목"] for p in parts] == ["굴착기 0.6㎥ 채집(90%)", "인력채집(10%)"] assert parts[0]["장비"] == {"굴착기|0.6": approx(0.9 / 23.1)} assert parts[1]["노임"] == {"보통인부": approx(0.03)} -def test_STmate_자원_지문(): +def test_STmate_단위_줄_지문(): + qty = {"율": [], "인자": [], "어떻게": "QTY"} block = { "글": ["잔토처리 (기계100%) 건설표준품셈9-3", "q = 0.7 , K = 0.9"], - "자원": [ - {"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.2}, - *[ - { - "갈래": "X", - "코드": "X00205", - "이름": "굴삭기(무한궤도)", - "규격": "0.7㎥", - "품": 0.0229, - } - ] - * 3, - {"갈래": "M", "코드": "M00001", "이름": "모래", "규격": "", "품": 1.2}, + "항목": [ + {"묶음": "L", "이름": "보통인부", "규격": "", "직종": "보통인부", "품": 0.2, **qty}, + {"묶음": "X", "이름": "굴삭기(무한궤도)", "규격": "0.7㎥", "품": 0.0229, **qty}, + {"묶음": "M", "이름": "모래", "규격": "", "품": 1.2, "밑품": 1.2, **qty}, + {"묶음": "L", "이름": "보통인부", "규격": "", "품": 0.5, "밖": True, **qty}, ], } fp = finger.excel(block, {"이름": "잔토처리", "규격": "토사", "단위": "M3"}) diff --git a/resources/tester/test_m02_hopyo_fit.py b/resources/tester/test_m02_hopyo_fit.py index 5ed65a34d..498ea896f 100644 --- a/resources/tester/test_m02_hopyo_fit.py +++ b/resources/tester/test_m02_hopyo_fit.py @@ -87,7 +87,7 @@ def test_조합_근거_안_닿은_덧은_후보(got): def test_숫자_없음_옛_HP(got): block, line = line_of("N000") - r = verdict.block({**block, "글": []}, line, got) + r = verdict.block({**block, "글": [], "항목": []}, line, got) # 단위 줄 · 글 없음 assert r["판정"]["상태"] == "후보" and r["판정"]["글"] == fit.NO_NUMBER assert r["차례"][0] == "GC000198" # 이름 · 근거 차례 그대로