diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py new file mode 100644 index 000000000..e4fcf4458 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -0,0 +1,231 @@ +"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 · +우리 돈 로직과 견준 근거 후보. + +읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 +다음 머리 앞까지 한 블록 · +출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · +그 밖 = 건설품셈 · +직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로). +견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) · +건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · +같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름. 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. +M01 · 원본 로직은 읽기만. +""" + +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_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs + +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,]") +_TYPOS = (("깍기", "깎기"),) +_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +# ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── +@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 "건설품셈" + if [book, num.group()] not in out: + out.append([book, num.group()]) + 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: + money = {key: flat.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) + 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 + if not isinstance(text, str) or not text.strip(): + continue + head = None if text[:1].isspace() else _HEAD.match(text) + 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 + 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]]: + """{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 · + 직종 = 호표 인력(LB) 요소.""" + files, whole = m01.loaded() + lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()} + out = {} + for data in files.values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + kind = str(row.get("구분") or "") + book, _, part = kind.partition(" ") + trades = { + lb[str(h.get("요소"))] + for h in row.get("호표") or [] + if isinstance(h, dict) and str(h.get("요소")) in lb + } + clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or ""))) + out[str(row.get("키"))] = { + "책": book, + "부문": part, + "구분": kind, + "조항": clauses, + "직종": trades, + } + return out + + +def ours() -> dict[str, dict[str, Any]]: + return _ours(id(m01.loaded()[1])) + + +def _head(number: str) -> str: + return number.rsplit("-", 1)[0] + + +def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: + """(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만.""" + 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], "조항 같음" + heads = {_head(n) for n in nums} + near = sorted(n for n in our["조항"] if _head(n) in heads) + if near: + return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음" + return 0.0, [], "" + + +def _text(line: dict[str, Any]) -> str: + text = f"{line.get('이름') or ''} {line.get('규격') or ''}" + for old, new in _TYPOS: + text = text.replace(old, new) + return text + + +def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]: + """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 점수, 근거}` 점수 차례.""" + basis = line.get("근거") or {} + theirs = [list(one) for one in basis.get("출처") or []] + if not theirs: + return [] + trades = set(basis.get("직종") or []) - {COMMON} + text = _text(line) + mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」 + grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름 + table = ours() + scored = [] + for one in got["로직"]: + our = table.get(one["로직"]) + if not our: + continue + score, clauses, tag = _clause(theirs, our) + if not score: + continue + shared = sorted(trades & our["직종"]) + if not shared and not any(a in b or b in a for a in mine for b in one["words"]): + continue + tags = [tag] + if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS: + score += 0.1 + tags.append(f"건설 {our['부문']}") + if shared: + score += 0.5 + tags.append("직종 " + " · ".join(shared)) + unit = hopyo_match.fits(line.get("단위"), one["단위"]) + if unit: + score += 0.15 + tags.append("단위 맞음") + dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) + why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} + scored.append( + (score, dice, {**hopyo_match._public(one), "근거": {**why, "까닭": " · ".join(tags)}}) + ) + scored.sort(key=lambda x: (-x[0], -x[1])) + return [one for _, _, one in scored] + + +def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]: + """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례.""" + if not basis: + return named + by_name = {c.get("로직"): c["점수"] for c in named} + out = [{**c, "점수": round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)} for c in basis] + out += [c for c in named if c.get("로직") in by_name] + return sorted(out, key=lambda c: -c["점수"]) diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index 38d11a81e..9995de2b4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -11,6 +11,9 @@ 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). +행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) · +근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) · +`단산` = 블록마다 `{번, 이름, 단위, 행}`. """ from __future__ import annotations @@ -20,6 +23,8 @@ import re from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis + SHEET = "일위대가" SUMMARY = "일위대가총괄표" COSTS = ("노무비", "재료비", "경비") @@ -67,8 +72,8 @@ def _out(value: Decimal | None) -> int | float | None: # ── 파일 → 시트 표(줄마다 값 목록) ──────────────────────────────────────── def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: - """시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음.""" - wanted = (SHEET, SUMMARY) + """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음.""" + wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) if not data: raise ExcelError("빈 파일") try: @@ -212,7 +217,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: start, cols = _columns(rows, ("합계", *COSTS)) hopyos: list[dict[str, Any]] = [] raw: list[list[dict[str, Any]]] = [] - for row in rows[start:]: + for at, row in enumerate(rows[start:], start + 1): head = _flat(_cell(row, cols.get("호표"))) name_ = _text(_cell(row, cols.get("품명"))) hit = _HOPYO.fullmatch(head) @@ -224,6 +229,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "규격": _text(_cell(row, cols.get("규격"))), "단위": _text(_cell(row, cols.get("단위"))), "금액": _money(row, cols, 1), + "행": [at, at], } ) raw.append([]) @@ -244,9 +250,15 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, } line["갈래"] = kind_of(line) + line["행"] = hopyos[-1]["행"][1] = at raw[-1].append(line) if not hopyos: raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET)) + for line in (one for lines in raw for one in lines): + ref = line["참조"] or {} + if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])): + line["근거"] = {k: block[k] for k in ("출처", "직종", "행")} wrong: list[str] = [] totals = _summary(sheets.get(SUMMARY)) for hopyo, lines in zip(hopyos, raw, strict=True): @@ -258,7 +270,13 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: {**_json(hopyo), "줄": [_json(line) for line in lines]} for hopyo, lines in zip(hopyos, raw, strict=True) ] - return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}} + basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()] + return { + "시트": SHEET, + "호표": out, + "단산": basis, + "검산": {"어긋남": wrong, "총괄표": len(totals)}, + } def _json(value: Any) -> Any: diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py new file mode 100644 index 000000000..39af897fa --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -0,0 +1,211 @@ +"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 → +A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 · +읽기 전용 보기). + +xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · +행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · +숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). +xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지. +결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. +""" + +from __future__ import annotations + +import io +from typing import Any + +from openpyxl.utils import get_column_letter + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel + +SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET) +HEAD_ROWS = 4 +_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} +_VALIGN = {0: "top", 1: "center"} +_LINES = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair"} +_SIDES = (("위", "top"), ("아래", "bottom"), ("왼", "left"), ("오른", "right")) + + +class _Styles: + """서식 표 — 같은 서식은 한 번만 · [0] = 기본 `{}`.""" + + def __init__(self) -> None: + self.table: list[dict[str, Any]] = [{}] + self._index: dict[str, int] = {"{}": 0} + + def add(self, style: dict[str, Any]) -> int: + key = repr(sorted(style.items())) + if not style: + return 0 + if key not in self._index: + self._index[key] = len(self.table) + self.table.append(style) + return self._index[key] + + +def _rgb(book: Any, index: int) -> str | None: + got = book.colour_map.get(index) + return "#{:02X}{:02X}{:02X}".format(*got) if got else None + + +def _xf_style(book: Any, xf: Any, base: Any) -> dict[str, Any]: + font = book.font_list[xf.font_index] + out: dict[str, Any] = {} + if font.name != base.name: + out["글꼴"] = font.name + if font.height != base.height: + out["크기"] = font.height / 20 + for key, on in ( + ("굵게", font.bold), + ("기울임", font.italic), + ("밑줄", font.underline_type), + ("취소선", font.struck_out), + ): + if on: + out[key] = True + color = _rgb(book, font.colour_index) + if color and color != "#000000": + out["글자색"] = color + if xf.background.fill_pattern == 1 and (fill := _rgb(book, xf.background.pattern_colour_index)): + out["채움"] = fill + align = xf.alignment + if align.hor_align in _HALIGN: + out["가로"] = _HALIGN[align.hor_align] + if align.vert_align in _VALIGN: + out["세로"] = _VALIGN[align.vert_align] + if align.text_wrapped: + out["줄바꿈"] = True + sides = {} + for name, side in _SIDES: + line = getattr(xf.border, f"{side}_line_style") + if line: + one = {"선": _LINES.get(line, "dashed")} + color = _rgb(book, getattr(xf.border, f"{side}_colour_index")) + if color and color != "#000000": + one["색"] = color + sides[name] = one + if sides: + out["테두리"] = sides + fmt = book.format_map.get(xf.format_key) + if fmt and fmt.format_str and fmt.format_str != "General": + out["형식"] = fmt.format_str + return out + + +def _value(cell: Any) -> Any: + import xlrd + + if cell.ctype == xlrd.XL_CELL_TEXT: + return cell.value or None + if cell.ctype in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE): + return int(cell.value) if cell.value == int(cell.value) else cell.value + if cell.ctype == xlrd.XL_CELL_BOOLEAN: + return bool(cell.value) + if cell.ctype == xlrd.XL_CELL_ERROR: + return xlrd.error_text_from_code.get(cell.value, "#N/A") + return None + + +def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]: + base = book.font_list[0] + by_xf: dict[int, int] = {} + cells: dict[str, dict[str, Any]] = {} + for r in range(sh.nrows): + for c in range(sh.ncols): + cell = sh.cell(r, c) + if cell.xf_index not in by_xf: + by_xf[cell.xf_index] = styles.add( + _xf_style(book, book.xf_list[cell.xf_index], base) + ) + one: dict[str, Any] = {} + if (value := _value(cell)) is not None: + one["값"] = value + if by_xf[cell.xf_index]: + one["서식"] = by_xf[cell.xf_index] + if one: + cells[f"{get_column_letter(c + 1)}{r + 1}"] = one + merged = [ + f"{get_column_letter(c0 + 1)}{r0 + 1}:{get_column_letter(c1)}{r1}" + for r0, r1, c0, c1 in sh.merged_cells + ] + cols = { + get_column_letter(c + 1): { + "폭": round(info.width / 256, 2), + **({"숨김": True} if info.hidden else {}), + } + for c, info in sh.colinfo_map.items() + if c < sh.ncols + } + rows = { + str(r + 1): {"높이": info.height / 20, **({"숨김": True} if info.hidden else {})} + for r, info in sh.rowinfo_map.items() + if r < sh.nrows and (info.height != sh.default_row_height or info.hidden) + } + frozen = sh.panes_are_frozen and (sh.horz_split_pos or sh.vert_split_pos) + freeze = {"행": sh.horz_split_pos, "열": sh.vert_split_pos} if frozen else {"행": HEAD_ROWS} + out = { + "id": sid, + "이름": sh.name.strip(), + "칸": cells, + "병합": merged, + "열": cols, + "행": rows, + "틀고정": {k: v for k, v in freeze.items() if v}, + } + if sh.default_row_height: + out["기본"] = {"행높이": sh.default_row_height / 20} + return out + + +def _xls(data: bytes) -> dict[str, Any]: + import xlrd + + book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO()) + styles = _Styles() + found = {sh.name.strip(): sh for sh in book.sheets()} + sheets = [ + _xls_sheet(book, found[name], styles, f"s{at}") + for at, name in enumerate((n for n in SHEETS if n in found), 1) + ] + font = book.font_list[0] + return { + "기본": {"글꼴": font.name, "크기": font.height / 20}, + "서식": styles.table, + "시트": sheets, + } + + +def _xlsx(name: str, data: bytes) -> dict[str, Any]: + # ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 + found = hopyo_excel._sheets(name, data) + sheets = [] + for at, title in enumerate((n for n in SHEETS if n in found), 1): + rows = found[title] + width = max( + (c + 1 for row in rows[:HEAD_ROWS] for c, v in enumerate(row) if v not in (None, "")), + default=0, + ) + cells = { + f"{get_column_letter(c + 1)}{r + 1}": { + "값": v if isinstance(v, (int, float, bool)) else str(v) + } + for r, row in enumerate(rows) + for c, v in enumerate(row[:width]) + if v not in (None, "") + } + sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) + return {"서식": [{}], "시트": sheets} + + +def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]: + """엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과 + (행 범위).""" + xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx") + book = _xlsx(name, data) if xlsx else _xls(data) + keep = ("번", "이름", "행") + return { + "책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"}, + "호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []], + "단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []], + } diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index dc894c8a4..c9ccbc557 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -4,6 +4,8 @@ 씨앗(일위대가조합.json 메모 · 읽기만) ③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · 뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — ++ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 · +로직 키로 합쳐 점수 더함) · ③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. 줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / % = 안 들어감(로직 덧줄이 셈). @@ -27,6 +29,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs @@ -295,9 +298,10 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st def candidates( - line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5 + line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 ) -> list[dict[str, Any]]: - """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`.""" + """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` + (None = 모두).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -402,11 +406,9 @@ def match_line( return out top = [{**_public(x), "점수": 9} for x in found] same = [_public(x) for x in found] - rest = [ - c - for c in candidates(line, pool, got) - if {k: v for k, v in c.items() if k != "점수"} not in same - ] + basis = hopyo_basis.candidates(line, got) if pool == "로직" else [] + named = candidates(line, pool, got, None if basis else 5) + rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same] out["후보"] = (top + rest)[:5] return {**out, "상태": "후보" if out["후보"] else "없음"} @@ -461,7 +463,8 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: if no is None: got = targets() hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] - brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos] + keep = ("번", "이름", "규격", "단위", "금액", "맞춤") + brief = [{k: h.get(k) for k in keep} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index c632a409d..b82ad7b51 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -1,5 +1,5 @@ """M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` · -맞춤 기억 `실무자료/맞춤.json`. +맞춤 기억 `실무자료/맞춤.json` · 엑셀꼴 격자 `실무자료/격자/<키>.json`(329-1 · 파일과 같이 지움). 파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######` (층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`. @@ -20,6 +20,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store DIRNAME = "실무자료" MEMORY = "맞춤" +GRID = "격자" +NO_GRID = "격자 없음 — 다시 올리면 보임" _KEY = re.compile(r"^HP-(\d{6})$") # ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -42,6 +44,10 @@ def _load(path: Path) -> Any: return None +def _grid_path(folder: str | Path, key: str) -> Path: + return _dir(folder) / GRID / _path(folder, key).name + + def brief(doc: dict[str, Any]) -> dict[str, Any]: """목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`.""" keep = ("키", "파일", "올린때", "올린이", "시트") @@ -69,8 +75,14 @@ def read(folder: str | Path, key: str) -> dict[str, Any]: return doc -def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]: - """읽은 결과 → 새 키로 씀 → 목록 한 줄.""" +def save( + folder: str | Path, + name: str, + parsed: dict[str, Any], + who: Any, + grid: dict[str, Any] | None = None, +) -> dict[str, Any]: + """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄.""" with _LOCK: have = [ int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem)) @@ -83,16 +95,28 @@ def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dic "올린이": who, **parsed, } + if grid is not None: + book = {**grid["책"], "열": key} + atomic_write_json(_grid_path(folder, key), {**grid, "책": book}) atomic_write_json(_path(folder, key), doc) return brief(doc) +def grid(folder: str | Path, key: str) -> dict[str, Any]: + """엑셀꼴 격자 `{책, 호표, 단산}` — 없으면(옛 파일) 404.""" + doc = _load(_grid_path(folder, key)) + if not isinstance(doc, dict): + raise store.StoreError(404, NO_GRID) + return doc + + def delete(folder: str | Path, key: str) -> None: path = _path(folder, key) with _LOCK: if not path.is_file(): raise store.StoreError(404, f"없는 실무자료 「{key}」") path.unlink() + _grid_path(folder, key).unlink(missing_ok=True) # ── 맞춤 기억 ───────────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 1f028a4b1..55739aa3a 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -176,7 +177,11 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: parsed = hopyo_excel.read_book(name, data) except hopyo_excel.ExcelError as error: raise store.StoreError(400, str(error)) from error - return hopyo_store.save(store.root(), name, parsed, who) + try: + grid = hopyo_grid.read(name, data, parsed) + except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) + grid = None + return hopyo_store.save(store.root(), name, parsed, who, grid) @router.get("/layers/{layer}/hopyo") @@ -205,7 +210,7 @@ def hopyo_upload( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB · + """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · 엉터리 · 시트 없음 400.""" folder = _root(session, layer, owner, write=True) data = file.file.read(HOPYO_MAX_BYTES + 1) @@ -225,6 +230,16 @@ def hopyo_read( return _call(folder, hopyo_match.view, folder, key) +@router.get("/layers/{layer}/hopyo-grid/{key}") +def hopyo_grid_read( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` · + 옛 파일 404.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_store.grid, folder, key) + + @router.get("/layers/{layer}/hopyo/{key}/{no}") def hopyo_one( layer: Layer, diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py new file mode 100644 index 000000000..526bfd4cb --- /dev/null +++ b/resources/tester/test_m02_hopyo_basis.py @@ -0,0 +1,108 @@ +"""일위대가 실무자료 단가산출 근거 맞춤(PLAN 330 · `M02_Hopyo_Basis` · 맞춤 후보 `근거`). + +블록 북부청 95 · 사방 67 · 단산93 근거 [건설 3-2-1] · [특별인부] · +후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · +잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · +master_data 그대로. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Basis as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Match as hm +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ( + NORTH, + SABANG, + URL, + _post, + master_hash, +) + +client = base.client # 공용 고정물(임시 층) + + +@pytest.fixture(scope="module") +def north() -> dict: + return hx.read_book(NORTH.name, NORTH.read_bytes()) + + +def _line(book: dict, no: int) -> dict: + """단산 no 를 가리키는 첫 줄.""" + return next(x for h in book["호표"] for x in h["줄"] if x["참조"] == {"종류": "단산", "번": no}) + + +def test_블록_수_와_근거(north): + assert len(north["단산"]) == 95 + assert [b["번"] for b in north["단산"]] == list(range(1, 96)) + sabang = hx.read_book(SABANG.name, SABANG.read_bytes()) + assert len(sabang["단산"]) == 67 + assert _line(north, 93)["근거"] == { + "출처": [["건설품셈", "3-2-1"]], + "직종": ["특별인부"], + "행": [4229, 4244], + } + assert _line(north, 21)["근거"]["직종"] == ["콘크리트공", "보통인부"] # 「보 통 인부」 빈칸 뺌 + assert _line(north, 64)["근거"]["출처"][0] == ["건설품셈", "4-1-1"] + lines = [x for h in north["호표"] for x in h["줄"] if x["갈래"] == "단가산출"] + assert all("근거" in x for x in lines) # 268 줄 모두 블록 있음 + + +def test_출처_글(): + assert hb.sources("[건설공사표준품셈 3-2-1 인력굴착]") == [["건설품셈", "3-2-1"]] + assert hb.sources("건설표줌품셈(3-1-3) 자갈") == [["건설품셈", "3-1-3"]] + assert hb.sources("[2025년 산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] + assert hb.sources("흙운반 [임도표준품셈 4-2]") == [["산림품셈", "4-2"]] + assert hb.sources("[건설표준품셈 준용]") == [] + + +def _top(book: dict, no: int) -> list[dict]: + got = hm.match_line(_line(book, no), [], hm.units_of(book)) + assert got["상태"] == "후보" and got["들어감"] is False # 자동 확정 없음 + return got["후보"] + + +def test_근거_후보(north): + before = master_hash() + dig = _top(north, 93) + assert dig[0]["로직"] == "GC000083" + assert dig[0]["근거"] == { + "조항": ["건설품셈 공통 3-2-1"], + "직종": ["특별인부"], + "단위": True, + "점수": 1.75, + "까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음", + } + assert _top(north, 21)[0]["로직"] == "GC000198" + assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]] + assert _top(north, 49)[0]["로직"] == "GC000085" + soil = next(x for x in north["호표"][2]["줄"] if x["이름"].startswith("잔토처리")) + names = [c["이름"] for c in hm.match_line(soil, [], {})["후보"]] + assert not any("철골재" in n for n in names) + assert master_hash() == before + + +def test_근거_없는_줄_그대로(north): + bare = [ + x + for h in north["호표"] + for x in h["줄"] + if x["갈래"] == "단가산출" and not x["근거"]["출처"] + ] + assert bare + for line in bare: + plain = {k: v for k, v in line.items() if k != "근거"} + assert hm.match_line(line, [], {}) == hm.match_line(plain, [], {}) + + +def test_길_응답_근거_칸(client): + before = master_hash() + assert _post(client).status_code == 200 + res = client.get(f"{URL.format('personal')}/HP-000001/3") + lines = {x["이름"]: x for x in res.json()["호표"]["줄"]} + dig = lines["구조물터파기(인력100%)"] + assert dig["근거"]["출처"] == [["건설품셈", "3-2-1"]] + assert dig["맞춤"]["후보"][0]["근거"]["까닭"].startswith("조항 같음") + assert "근거" not in lines["임도표지석(받침,글새김포함)"] # 자재 줄 + assert master_hash() == before diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py new file mode 100644 index 000000000..16ba2b303 --- /dev/null +++ b/resources/tester/test_m02_hopyo_grid.py @@ -0,0 +1,120 @@ +"""일위대가 실무자료 엑셀꼴 격자(PLAN 329-1 · `M02_Hopyo_Grid` · `GET hopyo-grid/{키}`). + +북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 · +제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 · +master_data 그대로. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Grid as hg +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ( + NORTH, + URL, + _post, + _xlsx, + master_hash, +) + +client = base.client # 공용 고정물(임시 층) +GRID = "/api/m02/layers/{}/hopyo-grid/{}" + + +@pytest.fixture(scope="module") +def north() -> dict: + data = NORTH.read_bytes() + return hg.read(NORTH.name, data, hx.read_book(NORTH.name, data)) + + +def _sheet(grid: dict, name: str) -> dict: + return next(s for s in grid["책"]["시트"] if s["이름"] == name) + + +def test_격자_모양(north): + book = north["책"] + assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1") + assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"] + sheet = _sheet(north, "일위대가") + assert len(sheet["병합"]) == 11 and "B1:O2" in sheet["병합"] + assert len(sheet["열"]) == 15 and sheet["열"]["C"]["폭"] == round(7208 / 256, 2) + assert sheet["틀고정"] == {"행": 4} + assert sheet["칸"]["B20"]["값"] == "제3호표" + assert sheet["칸"]["H5"]["값"] == 1040052 + styles = book["서식"] + assert styles[0] == {} and any(s.get("형식") == "#,##0" for s in styles) + assert any(s.get("테두리") for s in styles) + assert _sheet(north, "단가산출")["칸"]["B4"]["값"] == "1.유용토운반(무대)[㎥]" + + +def test_호표_단산_행_범위(north): + rows = [h["행"] for h in north["호표"]] + assert len(rows) == 68 + assert all(a[1] < b[0] for a, b in zip(sorted(rows), sorted(rows)[1:], strict=False)) + assert next(h for h in north["호표"] if h["번"] == 3) == { + "번": 3, + "이름": "임도표지석", + "행": [20, 24], + } + assert len(north["단산"]) == 95 + assert next(b for b in north["단산"] if b["번"] == 93)["행"] == [4229, 4244] + + +def test_줄_행(north): + book = hx.read_book(NORTH.name, NORTH.read_bytes()) + three = next(h for h in book["호표"] if h["번"] == 3) + assert three["행"] == [20, 24] and [x["행"] for x in three["줄"]] == [21, 22, 23, 24] + + +def test_올리기_격자_200_404_지우기(client): + before = master_hash() + assert _post(client).status_code == 200 + res = client.get(GRID.format("personal", "HP-000001")) + assert res.status_code == 200, res.text + got = res.json() + assert got["책"]["열"] == "HP-000001" and len(got["호표"]) == 68 + folder = layers.personal_dir(7, 42) + path = folder / hs.DIRNAME / hs.GRID / "HP-000001.json" + assert path.is_file() + assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000001"] + path.unlink() # 옛 파일(324) = 격자 없음 + old = client.get(GRID.format("personal", "HP-000001")) + assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임" + assert _post(client).status_code == 200 + assert client.get(GRID.format("personal", "HP-000002")).status_code == 200 + assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200 + assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists() + assert client.get(GRID.format("personal", "HP-000002")).status_code == 404 + assert client.get(GRID.format("personal", "../x")).status_code == 404 + assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘 + assert master_hash() == before + + +@pytest.mark.parametrize( + ("role", "layer", "owner", "want"), + [ + ({"role": "USER"}, "personal", None, 200), + ({"role": "USER"}, "personal", "7/43", 403), + ({"role": "USER"}, "company", None, 200), + ({"role": "USER"}, "system", None, 200), + ], +) +def test_역할_읽기(client, role, layer, owner, want): + client.who.update({"role": "SYSTEM_ADMIN"}) + assert _post(client, layer if want == 200 else "personal").status_code == 200 + client.who.update(role) + params = {"owner": owner} if owner else {} + assert client.get(GRID.format(layer, "HP-000001"), params=params).status_code == want + + +def test_xlsx_값만(): + data = _xlsx() + grid = hg.read("a.xlsx", data, hx.read_book("a.xlsx", data)) + sheet = grid["책"]["시트"][0] + assert sheet["이름"] == "일위대가" and sheet["틀고정"] == {"행": 4} + assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5} + assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지 + assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == [] diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 0f1224240..69f8cd397 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -105,7 +105,7 @@ def test_이름_같음_후보_호표_퍼센트(client): assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저 dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "후보" and len(dig["후보"]) == 5 - assert dig["후보"][0]["이름"].startswith("구조물터파기") + assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위 ref = _hopyo(client, 45)["고화매트재료비"] assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] pct = _hopyo(client, 46)["잡재료비"]