From 109789a746b021d4fbe1f15ec023468e53e2d7ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 20:39:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(828-2):=20STmate=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=ED=91=9C=20=EC=88=A8?= =?UTF-8?q?=EC=9D=80=20=EC=B9=B8=20=E2=86=92=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EC=A4=84=20=E2=80=94=20=EC=9D=BD=EB=8A=94=20=EA=B3=B3=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=C2=B7=20=EC=9C=A8=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EB=8B=AB=EA=B8=B0=20=C2=B7=20=EC=9D=BC=EC=8B=9D=20=C2=B7=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=EA=B3=84=EC=83=81=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=EC=97=90=20=EC=9D=BC=EC=9C=84=20=EC=9E=AC=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_San_Xlsx_Ilwi(새) = 일위대가표 읽기 한 곳 — N 열 메타(COD · IPR · PRI · PRO · EQC · KWN · DVD) · O 열 차수 꼬리 기준 · 줄 꼴 = 828-1 M02_San_Ilwi 꼴 + 엑셀 칸(행 · 차수 · 비고 · 전체 · 율.코드 · 메타) · 숨은 칸 없음 = 옛 코드 찾기 · 단산 길 일위 빈 목록 + 표지 - 5 건 호표 136 · 줄 706 · STC 구성 줄과 코드 · 갈래 · 수량 · 율 · 밑줄 같음(다른 5 줄 = STC 별도계상 규칙이 비고 별산 표시를 놓침) - 호표 길(M02_Hopyo_Stmate._hopyos) · 단산 길(M02_San_Xlsx_Stmate.read 일위) · 단서(M02_San_MatFind.from_book) 모두 공용 읽기 · 단서 · 산근 · 단가 전후 같음 - M02_Hopyo_Fixed._ratio = 숨은 칸 율 밑 · 대상 비목 먼저(글 짐작 대신) — 호표 길 전후 바뀜 = 영덕 제11호표 산악할증 0% 한 줄(금액만 → 비율 노무비 · 금액 0 그대로) · 단산 항목 1564 판정 그대로 - 재료 판(census) = 일위 구성 줄 자재도 셈(M02_San_MatFind.ilwi_found · 종 칸 일위 = 호표 코드 · 화면 「일위대가 N 호표」) - 시험: 새 test_828_2_xlsx_ilwi_meta · test_827_2 단서 시트 보기에 「제N호표」 줄 넣음(실제 시트는 늘 있음 · 공용 읽기가 호표 머리부터 셈) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Fixed.py | 18 +- M02_MasterTemplete/M02_Hopyo_Stmate.py | 105 +++++----- .../M02_MasterTemplete_Hopyo_MatPick.ts | 8 +- M02_MasterTemplete/M02_San_MatFind.py | 45 +++-- M02_MasterTemplete/M02_San_MatPick.py | 31 ++- M02_MasterTemplete/M02_San_Xlsx_Ilwi.py | 186 ++++++++++++++++++ M02_MasterTemplete/M02_San_Xlsx_Stmate.py | 12 +- resources/tester/test_827_2_material_clues.py | 1 + resources/tester/test_828_2_xlsx_ilwi_meta.py | 148 ++++++++++++++ 9 files changed, 466 insertions(+), 88 deletions(-) create mode 100644 M02_MasterTemplete/M02_San_Xlsx_Ilwi.py create mode 100644 resources/tester/test_828_2_xlsx_ilwi_meta.py diff --git a/M02_MasterTemplete/M02_Hopyo_Fixed.py b/M02_MasterTemplete/M02_Hopyo_Fixed.py index 2755dbfa5..42e381ebe 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fixed.py +++ b/M02_MasterTemplete/M02_Hopyo_Fixed.py @@ -59,13 +59,19 @@ def _parts(money: Any) -> dict[str, Decimal]: def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] | None: - """% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나.""" - hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}") + """% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나 · + STmate 숨은 칸 율(`율` {밑, 대상} · 828-2)이 있으면 글 「노무비의」 · 금액 칸 짐작 대신 그것.""" under = _d((line.get("단가") or {}).get("계")) - paid = [k for k, v in _parts(line.get("금액")).items() if v] - if not hit or under is None or len(paid) != 1: - return None - base = "노무비" if hit.group(1) == "인건비" else hit.group(1) + if rate := line.get("율"): + base, paid = rate.get("밑"), [rate.get("대상")] + if base not in COSTS or paid[0] not in COSTS or under is None: + return None # 전체(닫기 뒤) 율 · 빈 칸 = 금액만 + else: + hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}") + paid = [k for k, v in _parts(line.get("금액")).items() if v] + if not hit or under is None or len(paid) != 1: + return None + base = "노무비" if hit.group(1) == "인건비" else hit.group(1) total = sum((_parts(x.get("금액"))[base] for x in others), Decimal(0)) return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py index dae50371f..adb1671d9 100644 --- a/M02_MasterTemplete/M02_Hopyo_Stmate.py +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -32,12 +32,13 @@ 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 +from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi SHEET = "일위대가표" CALC = "단가산출근거" LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표") SHEETS = (SHEET, CALC, *LISTS) -_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$") +_CODE = ilwi.CODE _META = re.compile(r"(? dict[str # ── 일위대가표 ──────────────────────────────────────────────────────── def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]: + """호표 · 구성 줄 = 공용 읽기(`M02_San_Xlsx_Ilwi.read` · 828-2) → HP 줄 꼴 · 설명 줄 = 근거글 · + 닫기 · 구분 줄 뺌 · 율 줄 코드 = PR + 율 코드(「PRA01」) · 숨은 칸 율 = `율` {밑, 대상}(비목 이름).""" hopyos: list[dict[str, Any]] = [] raw: list[list[dict[str, Any]]] = [] - want_head = done = False - for at, row in enumerate(rows, 1): - first = _s(_cell(row, 0)) - hit = hopyo_excel._HOPYO.fullmatch(_flat(first)) - if hit: - code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None) - hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []}) - raw.append([]) - want_head, done = True, False - continue - if not hopyos or done: - continue - hopyo = hopyos[-1] - if want_head: - hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3))) - hopyo["금액"] = {} - want_head = False - continue - if _flat(first).startswith("합계") or _flat(first) == "계": - hopyo["금액"] = hopyo_excel._money(row, _COLS, 1) - done = True # 끝 — 다음 호표까지 줄 안 받음 - continue - if not first: - continue - code = _code(row) - hopyo["행"][1] = at - if not code: - if re.search(r"[0-9A-Za-z가-힣]", first): - hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip()) - continue - if code[0] in "TC" and code[1].isdigit(): - continue # 계 · 소계 · 계약단가 - line = { - "차례": len(raw[-1]), - "이름": first, - "규격": _s(_cell(row, 1)), - "수량": hopyo_excel._num(_cell(row, 2)), - "단위": _s(_cell(row, 3)), - "단가": hopyo_excel._money(row, _COLS, 0), - "금액": hopyo_excel._money(row, _COLS, 1), - "비고": _s(_cell(row, NOTE)), - "코드": code, - "행": at, - } - line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2) - line["참조"] = ( - {"종류": "단산", "번": int(code[1:]), "코드": code} - if code[0] == "D" - else {"종류": "일위", "번": None, "코드": code} - if code[0] == "B" - else None - ) - raw[-1].append(line) + for got in ilwi.read(rows)["호표"]: + hopyo = {k: got[k] for k in ("번", "코드", "행")} | {"근거글": []} + if "머리행" in got: + hopyo |= {k: got[k] for k in ("이름", "규격", "단위")} | {"금액": {}} + if "끝행" in got: + hopyo["금액"] = hopyo_excel._money(rows[got["끝행"] - 1], _COLS, 1) + hopyos.append(hopyo) + raw.append([]) + for one in got["줄"]: + if not one["코드"]: # 설명 줄 · 코드 없는 글 + if re.search(r"[0-9A-Za-z가-힣]", one["이름"]): + hopyo["근거글"].append(f"{one['이름']} {one['규격']}".strip()) + continue + if one["갈래"] in ("닫기", "구분"): + continue # 계 · 소계 · 계약단가 + raw[-1].append(_hopyo_line(rows[one["행"] - 1], one, len(raw[-1]))) return hopyos, raw +def _hopyo_line(row: list[Any], one: dict[str, Any], at: int) -> dict[str, Any]: + rate = one.get("율") or {} + code = one["코드"] + rate.get("코드", "") if rate else one["코드"] + line = { + "차례": at, + "이름": one["이름"], + "규격": one["규격"], + "수량": hopyo_excel._num(_cell(row, 2)), + "단위": one["단위"], + "단가": hopyo_excel._money(row, _COLS, 0), + "금액": hopyo_excel._money(row, _COLS, 1), + "비고": one["비고"], + "코드": code, + "행": one["행"], + } + if rate.get( + "밑" + ): # 828-2 숨은 칸 율 밑 · 대상 비목(글 「노무비의 %」 짐작보다 먼저 · `M02_Hopyo_Fixed._ratio`) + line["율"] = {k: _PART.get(rate.get(k) or "", "") for k in ("밑", "대상")} + line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2) + line["참조"] = ( + {"종류": "단산", "번": int(code[1:]), "코드": code} + if code[0] == "D" + else {"종류": "일위", "번": None, "코드": code} + if code[0] == "B" + else None + ) + return line + + def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]: """STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수).""" hopyos, raw = _hopyos(sheets.get(SHEET) or []) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts index b81fed18a..780b58e78 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts @@ -42,6 +42,8 @@ interface MatKind { 별도계상: boolean; 블록: number[]; 줄: number; + /** 828-2 이 종이 든 일위대가 구성 줄의 호표 코드(STmate 엑셀 숨은 칸 · 원파일 구성표) */ + 일위?: string[]; 고름?: MatCand; } @@ -133,11 +135,13 @@ export function createMatPick(opt: { scope: HopyoScope; key: string; target: Hop function kindRow(k: MatKind): HTMLElement { const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—"; - const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서] + const hops = k.일위?.length ? ` · 일위대가 ${k.일위.length} 호표` : ""; + const sub = [k.방법, `${k.블록.length} 블록${hops} · ${k.줄} 줄`, ...k.단서] .filter(Boolean) .join(" · "); const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore - const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore + const title = `단산 ${k.블록.join(" · ")}${hops ? ` / 일위대가 ${k.일위?.join(" · ")}` : ""}`; + const row = createListRow({ label: what, sub, trailing: badge, size: 36, title }); // prettier-ignore const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] }); if (edits.has(k.종)) box.dataset.matEdited = ""; return box; diff --git a/M02_MasterTemplete/M02_San_MatFind.py b/M02_MasterTemplete/M02_San_MatFind.py index 8ed86dc20..66fc82628 100644 --- a/M02_MasterTemplete/M02_San_MatFind.py +++ b/M02_MasterTemplete/M02_San_MatFind.py @@ -37,7 +37,6 @@ from M02_MasterTemplete import M02_Hopyo_MatSame as ms LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음" TOP = 8 _CTX: ContextVar[dict] = ContextVar("재료단서", default={}) -_B = re.compile(r"B\d+") _LEAD = re.compile(r"^[\s\-·\d.)]+") _RANGE = re.compile(r"[A-Za-z]*\d+(?:\.\d+)?\s*[~∼]\s*[A-Za-z]*\d+(?:\.\d+)?") @@ -59,24 +58,40 @@ def from_boq(rows: Any) -> list[dict]: def from_book(rows: Any) -> list[dict]: - """STmate 엑셀 「일위대가표」 시트 줄 → 자재 줄 단서(숨은 메타 COD M… · 대가 머리 = 이름 · 규격).""" - from M02_MasterTemplete import M02_San_Book as book + """STmate 엑셀 「일위대가표」(시트 줄 · 또는 `M02_San_Xlsx_Ilwi.read` 답) → 자재 줄 단서(숨은 메타 + COD M… · 대가 머리 = 이름 · 규격) — 읽기 = 공용(828-2).""" + from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi - out, head = [], "" - for r in rows or []: - r = list(r) + [None] * 16 - if _B.fullmatch(str(r[14] or "").strip()): - head = " ".join(str(x or "").strip() for x in r[:2]).strip() - continue - code = book.meta(r[13]).get("COD", "") - if code.startswith("M") and r[0]: - out.append( - {"곳": "일위대가표", "코드": code, "이름": str(r[0]), "규격": str(r[1] or "")} - ) - out[-1] |= {"머리": head} if head else {} + sheet = rows if isinstance(rows, dict) else ilwi.read(rows) + out = [] + for hopyo in sheet.get("호표") or []: + head = f"{hopyo.get('이름') or ''} {hopyo.get('규격') or ''}".strip() + for one in hopyo["줄"]: + if "메타" in one and one["코드"].startswith("M") and one["이름"]: + out.append({"곳": "일위대가표", "코드": one["코드"], "이름": one["이름"]}) + out[-1] |= {"규격": one["규격"]} | ({"머리": head} if head else {}) return _uniq(out) +def ilwi_found(saved: dict) -> list[tuple[dict, dict]]: + """둘 꼴 「일위」 구성 줄(828 · 엑셀 일위대가표 숨은 칸 · STC 구성표) 자재 줄 → [(일위 블록, 자원)] · + 자원 `재료찾기` = 산근 줄과 같은 판정(`M02_San_Link.m01` · 단서 = 파일 단서 + 대가 머리).""" + from M02_MasterTemplete.M02_San_Link import m01 + + out = [] + for blk in saved.get("일위") or []: + head = f"{blk.get('이름') or ''} {blk.get('규격') or ''}" + with clues_on(saved.get("단서"), head): + for ln in blk.get("줄") or []: + if ln.get("갈래") != "M" or not ln.get("이름"): + continue + res = {"꼴": saved.get("꼴"), "갈래": "재료", "코드": ln.get("코드") or ""} + res |= {k: ln.get(k) or "" for k in ("이름", "규격", "단위")} + m01("재료", res) + out.append((blk, res)) + return out + + def _uniq(rows: list[dict]) -> list[dict]: seen, out = set(), [] for r in rows: diff --git a/M02_MasterTemplete/M02_San_MatPick.py b/M02_MasterTemplete/M02_San_MatPick.py index d53e141e1..eb5f7ee16 100644 --- a/M02_MasterTemplete/M02_San_MatPick.py +++ b/M02_MasterTemplete/M02_San_MatPick.py @@ -179,8 +179,10 @@ def entry(item: dict, sym: str, excel: Any) -> dict | None: # ── 판(그 파일 재료 종) ────────────────────────────────────────────────── def census(folder: str | Path, key: str) -> list[dict]: - """HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄}].""" + """HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄, + 일위?}] · 일위 = 그 종이 든 일위대가 구성 줄의 호표 코드(828-2 · 고름은 산근 블록 만들기에만 얹음).""" from M02_MasterTemplete import M02_Doc_FromNative as fn + from M02_MasterTemplete import M02_San_MatFind as mf from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh hopyo_store.read(folder, key) # 없는 HP = 404 @@ -206,21 +208,34 @@ def census(folder: str | Path, key: str) -> list[dict]: found = res.get("재료찾기") if not found: continue - k = kind_of(res) - g = groups.setdefault( - k, - {"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")}, - **{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")}, - "별도계상": _apart(res), "블록": [], "줄": 0}, - ) # fmt: skip + g = _group(groups, res) g["줄"] += 1 if no not in g["블록"]: g["블록"].append(no) + for blk, res in ( + mf.ilwi_found(src) if src else [] + ): # 828-2 일위대가 구성 줄 재료(일위 = 호표 코드) + if not res.get("재료찾기"): + continue + g = _group(groups, res) + g["줄"] += 1 + if blk["코드"] not in (hops := g.setdefault("일위", [])): + hops.append(blk["코드"]) out_ = sorted(groups.values(), key=lambda g: (KINDS.index(g["판정"]) if g["판정"] in KINDS else 9, -len(g["블록"]), g["이름"])) # fmt: skip atomic_write_json(path, {**saved, CENSUS: out_}) return out_ +def _group(groups: dict[str, dict], res: dict) -> dict: + found, k = res["재료찾기"], kind_of(res) + return groups.setdefault( + k, + {"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")}, + **{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")}, + "별도계상": _apart(res), "블록": [], "줄": 0}, + ) # fmt: skip + + def fingerprint(folder: str | Path, key: str) -> tuple[str, str]: hp = hopyo_store.read(folder, key) return str(hp.get("지문") or ""), str(hp.get("파일") or "") diff --git a/M02_MasterTemplete/M02_San_Xlsx_Ilwi.py b/M02_MasterTemplete/M02_San_Xlsx_Ilwi.py new file mode 100644 index 000000000..36c4c19d1 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Xlsx_Ilwi.py @@ -0,0 +1,186 @@ +"""STmate 출력 엑셀 「일위대가표」 → 호표 · 구성 줄(PLAN 828-2 · 요구 813 · 814 ㉮ · 읽기만 · 서버 단독). + +읽는 곳 하나 — 일위대가 불러오기(`M02_Hopyo_Stmate.read`) · 단가산출 불러오기 +(`M02_San_Xlsx_Stmate.read` 「일위」) · 재료 단서(`M02_San_MatFind.from_book`). +호표 = A 「제N호표」 줄(숨은 칸 `BDCOD|B…`) → 다음 줄 이름 · 규격 · 단위 → 구성 줄 → 「합계」 · 「계」. +구성 줄 = 숨은 칸 기준(표시대로 · 글 짐작 안 함 · 원파일 구성표 BDQTY 와 같은 칸) — + N 열 메타 `COD|M00131 ␅ EXI DVD BMK IPR KWN BLA EQC PRI PRO UNI TPR` · O 열 차수 꼬리 `M00131_1`. + 율 줄 = 밑줄(IPR 1) 밑 비목(PRI) × 율 → 대상 비목(PRO) · 비목 수 1 M · 2 L · 3 S · 4 T(전체) · + 율 코드 EQC · 닫기(C) 뒤 % 줄(켜쌓기적용 「전체금액의 %」) = `전체`. + 별도계상 = 비고 「별산…」(STmate 표기) · 규격 「별도계상」 · 관급 = KWN ≠ 0 · 나누기 = DVD T. +숨은 칸 없는 엑셀(값만 복사) = 코드는 M~Z 열 코드 꼴 첫 것(옛 길) · 시트 `숨은칸` 거짓 · + 단가산출 길 「일위」 = 빈 목록(물러남). + +줄 꼴 = 828-1 `M02_San_Ilwi` 일위 구성 줄 꼴 {차례, 코드, 갈래(M L S X B D W · 율 · 닫기 · 구분 · 글), + 이름, 규격, 단위, 수량, 단가 {T L M S}, 금액 {T L M S}, 율? {밑, 대상, 곱}, 밑줄?, 관급?, 별도계상?} + + 엑셀 칸 {행, 차수, 비고, 나누기?, 전체?, 율.코드, 메타}. +""" + +from __future__ import annotations + +import re +from typing import Any + +SHEET = "일위대가표" +CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$") +CODE_COLS = range(12, 26) # M ~ Z 열(옛 길 · 판마다 열 수 다름) +META_COL, CODE_COL, NOTE_COL = 13, 14, 12 # N 메타 · O 코드(차수 꼬리) · M 비고 +_HOPYO = re.compile(r"제\s*(\d+)\s*호표") +_HEAD = re.compile(r"BDCOD\|(B\d{5})") +_TAIL = re.compile(r"^[A-Z]{1,2}\d*_(\d+)$") +_X7, _X5 = "_x0007_", "_x0005_" +KIND = "MLSXBDW" # 자원 코드 머리(L 노임 · M 자재 · S 경비 · X 기계 · D 산근 · B 일위 · W 일식) +PART = {"1": "M", "2": "L", "3": "S", "4": "T"} # PRI · PRO 비목 수 +_MONEY = {"T": 4, "L": 6, "M": 8, "S": 10} # 단가 열 · 금액 = +1 + + +def _cell(row: list[Any], col: int) -> Any: + return row[col] if col < len(row) else None + + +def _s(v: Any) -> str: + if isinstance(v, float) and v == int(v): + v = int(v) + return str(v if v is not None else "").strip() + + +def _num(v: Any) -> float | None: + if isinstance(v, bool) or v in (None, ""): + return None + try: + return float(str(v).replace(",", "").strip()) + except ValueError: + return None + + +def meta(text: Any) -> dict[str, str]: + """숨은 메타 칸 「`COD|L00003 ␅ EXI|0 …」 → dict(`M02_San_Book.meta` 와 같은 풀이).""" + s = str(text or "").replace(_X7, "").replace(_X5, "\x05") + out = {} + for part in s.split("\x05"): + part = part.strip("`") + if "|" in part: + k, v = part.split("|", 1) + out[k] = v + return out + + +def scan_code(row: list[Any]) -> str | None: + """옛 길 — M~Z 열에서 코드 꼴 첫 것(숨은 칸 없는 엑셀 · `M02_Hopyo_Stmate` 와 같음).""" + for col in CODE_COLS: + hit = CODE.match(_s(_cell(row, col))) + if hit: + return hit.group(1) + return None + + +def kind_of(code: str) -> str: + """갈래(`M02_San_Ilwi` 꼴) — 자원 머리 글자 M L S X B D W · 율 · 닫기 · 구분 · 글(빈 코드).""" + if code.startswith(("PR", "RR")): + return "율" + if code[:1] in "CT" and code[1:2].isdigit(): + return "닫기" if code[0] == "C" else "구분" + return code[:1] if code[:1] and code[0] in KIND else "글" + + +def _money(row: list[Any], offset: int) -> dict[str, float | None]: + return {k: _num(_cell(row, c + offset)) for k, c in _MONEY.items()} + + +def _line(row: list[Any], at: int, closed: bool) -> dict[str, Any] | None: + """구성 줄 하나 — 숨은 메타 있으면 그것 · 없으면 옛 코드 찾기(코드 없으면 None).""" + raw = _cell(row, META_COL) + got = meta(raw) if isinstance(raw, str) and "COD|" in raw else {} + tail = _TAIL.match(_s(_cell(row, CODE_COL))) + if "COD" in got: + code = got["COD"].strip() + else: + code = scan_code(row) + if code is None: + return None + one: dict[str, Any] = { + "행": at, + "코드": code, + "갈래": kind_of(code), + "차수": int(tail.group(1)) if tail else None, + "이름": _s(_cell(row, 0)), + "규격": _s(_cell(row, 1)), + "단위": _s(_cell(row, 3)), + "수량": _num(_cell(row, 2)), + "단가": _money(row, 0), + "금액": _money(row, 1), + "비고": _s(_cell(row, NOTE_COL)), + } + flags = { + "나누기": got.get("DVD") == "T", + "밑줄": got.get("IPR") == "1", + "별도계상": one["비고"].startswith("별산") or "별도계상" in one["규격"], + "관급": got.get("KWN", "0").strip() not in ("0", ""), + } + if code.startswith(("PR", "RR")) and got: + one["율"] = {"밑": PART.get(got.get("PRI", "")), "대상": PART.get(got.get("PRO", ""))} + one["율"] |= {"곱": code.startswith("RR"), "코드": got.get("EQC") or ""} + pct = one["단위"] == "%" or got.get("UNI") == "%" + flags["전체"] = closed and pct and one["갈래"] in ("율", "W") + one |= {k: True for k, v in flags.items() if v} + if got: + one["메타"] = {k: v for k, v in got.items() if k != "COD"} + return one + + +def read(rows: Any) -> dict[str, Any]: + """시트 줄 → {숨은칸, 호표 [{번, 코드, 이름, 규격, 단위, 행 [처음,끝], 머리행, 저장 {T L M S}, 줄}]}. + 끝 줄(합계 · 계)에서 다음 「제N호표」 까지는 안 받음 · 이름 빈 줄은 건너뜀(옛 길과 같음).""" + hopyos: list[dict[str, Any]] = [] + want_head = done = closed = False + hidden = False + for at, row in enumerate(rows or [], 1): + row = list(row) + first = _s(_cell(row, 0)) + flat = re.sub(r"\s+", "", first) + if hit := _HOPYO.fullmatch(flat): + code = next((m.group(1) for v in row[1:26] if (m := _HEAD.search(_s(v)))), None) + hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "줄": []}) + want_head, done, closed = True, False, False + continue + if not hopyos or done: + continue + hopyo = hopyos[-1] + if want_head: + hopyo |= {"이름": first, "규격": _s(_cell(row, 1)), "단위": _s(_cell(row, 3))} + hopyo["머리행"] = at + want_head = False + continue + if flat.startswith("합계") or flat == "계": + hopyo["저장"] = _money(row, 1) + hopyo["끝행"] = at + done = True + continue + if not first: + continue + hopyo["행"][1] = at + one = _line(row, at, closed) + if one is None: # 숨은 칸 없는 엑셀의 코드 없는 글 줄(옛 길 근거글) + if re.search(r"[0-9A-Za-z가-힣]", first): + one = {"행": at, "코드": "", "갈래": "글", "이름": first, "규격": _s(_cell(row, 1))} + hopyo["줄"].append({"차례": len(hopyo["줄"]), **one}) + continue + hidden = hidden or "메타" in one + closed = closed or one["갈래"] == "닫기" + hopyo["줄"].append({"차례": len(hopyo["줄"]), **one}) + for hopyo in hopyos: + for key in ("이름", "규격", "단위"): + hopyo.setdefault(key, "") + return {"숨은칸": hidden, "호표": hopyos} + + +def blocks(sheet: dict[str, Any]) -> list[dict[str, Any]]: + """`read` 답 → 단가산출 불러오기 「일위」 블록(원파일 `M02_San_Stc.read` 「일위」 와 같은 머리 칸 + 줄) · + 숨은 칸 없는 시트 = 빈 목록(구성 줄을 글로 짐작하지 않음).""" + if not sheet.get("숨은칸"): + return [] + return [ + {k: h.get(k) for k in ("코드", "번", "이름", "규격", "단위", "저장")} + | {"엑셀행": h["행"][0], "줄": h["줄"], "줄합": None, "표지": []} + for h in sheet["호표"] + ] diff --git a/M02_MasterTemplete/M02_San_Xlsx_Stmate.py b/M02_MasterTemplete/M02_San_Xlsx_Stmate.py index ed506ff87..e27cf8d09 100644 --- a/M02_MasterTemplete/M02_San_Xlsx_Stmate.py +++ b/M02_MasterTemplete/M02_San_Xlsx_Stmate.py @@ -41,7 +41,8 @@ def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]: def read(sheets: dict[str, list]) -> dict[str, Any]: - """STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}.""" + """STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지} · 일위 = 일위대가표 숨은 칸 + 구성 줄(828-2 `M02_San_Xlsx_Ilwi` · 숨은 칸 없으면 빈 목록 + 표지).""" found = book.stmate_prices(sheets) prices = {c: {k: p.get(k) for k in ("T", "L", "M", "S", "meta")} for c, p in found.items()} res = { @@ -71,10 +72,15 @@ def read(sheets: dict[str, list]) -> dict[str, Any]: out["옵션"] = {"SAN_TYP": RULE[rule]} out["내역"] = None from M02_MasterTemplete import M02_San_MatFind as mf + from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi - out["단서"] = mf.from_book(sheets.get("일위대가표")) # 827-2 재료 단서 + sheet = ilwi.read(sheets.get(ilwi.SHEET)) # 828-2 일위대가표 숨은 칸 → 구성 줄(읽는 곳 하나) + out["일위"] = ilwi.blocks(sheet) + out["단서"] = mf.from_book(sheet) # 827-2 재료 단서 out["표지"] = [ - f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)", + f"{sx.NOTE}일위대가 구성 = 일위대가표 숨은 칸 · 내역은 안 읽음" + if sheet["숨은칸"] + else f"{sx.NOTE}일위대가표 숨은 칸 없음(값만 복사) — 구성 줄 안 읽음 · 일위대가는 호표 길", f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)", ] return out diff --git a/resources/tester/test_827_2_material_clues.py b/resources/tester/test_827_2_material_clues.py index 260b9b1e2..805b06e79 100644 --- a/resources/tester/test_827_2_material_clues.py +++ b/resources/tester/test_827_2_material_clues.py @@ -125,6 +125,7 @@ def test_clue_head_and_boq_apart(): def test_clue_book_sheet(): meta = "_x0007_`COD|{}_x0005_`EXI|0_x0005_`" rows = [ + [" 제 17 호표", *[None] * 12, "_x0005_`BDCOD|B01217_x0007_`POSS|3_x0007_`POSE|4_x0007_`"], ["파형강관부설", "D=800mm", None, "m", *[None] * 8, "대가 17", None, "B01217"], [ "파형강관(800mm)", diff --git a/resources/tester/test_828_2_xlsx_ilwi_meta.py b/resources/tester/test_828_2_xlsx_ilwi_meta.py new file mode 100644 index 000000000..79ac6c18a --- /dev/null +++ b/resources/tester/test_828_2_xlsx_ilwi_meta.py @@ -0,0 +1,148 @@ +"""828-2 STmate 출력 엑셀 「일위대가표」 숨은 칸 → 호표 · 구성 줄(`M02_San_Xlsx_Ilwi` · 요구 813 · 814 ㉮). + +5 건 호표 136 · 줄 706 · 율 줄 밑 · 대상 비목 · 닫기 뒤 전체 % · 일식 · 별도계상 · 숨은 칸 없는 파일 물러남 · +원파일 STC 구성 줄(828-1)과 같은 꼴 · 읽는 곳 하나(호표 길 · 단산 길 · 단서) · 판 일위 줄 재료. +실무 파일이 없으면 건너뜀. +""" + +import sys +from functools import lru_cache +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "tester"), str(ROOT / "resources" / "master_data" / "scripts")] # fmt: skip + +import helper_m02_hopyo_golden as golden # noqa: E402 + +from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix # noqa: E402 +from M02_MasterTemplete import M02_San_Book as book # noqa: E402 +from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402 +from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402 +from M02_MasterTemplete import M02_San_Xlsx_Ilwi as il # noqa: E402 + +TAGS = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광") +HOPYOS = {"봉화": 45, "동부청영월": 23, "울진대흥": 34, "영덕계류": 9, "울진소광": 25} +LINES = {"봉화": 216, "동부청영월": 138, "울진대흥": 184, "영덕계류": 39, "울진소광": 129} +APART = {"봉화": 7, "동부청영월": 18, "울진대흥": 16, "영덕계류": 0, "울진소광": 9} + + +@lru_cache(maxsize=8) +def _sheets(tag: str) -> dict: + path = golden.FILES[tag] + if not path.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + return book.sheets(path.name, path.read_bytes()) + + +def _lines(got: dict) -> list[dict]: + return [x for h in got["호표"] for x in h["줄"]] + + +def _bare(rows: list) -> list: + """값만 복사한 엑셀 — 숨은 메타(N 열) 지움.""" + return [[None if j == il.META_COL else v for j, v in enumerate(r)] for r in rows] + + +@pytest.mark.parametrize("tag", TAGS) +def test_hopyo_lines_by_meta(tag): + got = il.read(_sheets(tag)[il.SHEET]) + lines = _lines(got) + assert got["숨은칸"] and len(got["호표"]) == HOPYOS[tag] and len(lines) == LINES[tag] + assert all("메타" in x for x in lines) + assert all(h["코드"] and h["코드"].startswith("B") and h.get("저장") for h in got["호표"]) + assert sum(bool(x.get("별도계상")) for x in lines) == APART[tag] + for x in lines: + if x["갈래"] == "율": + assert ( + x["율"]["밑"] in "MLST" and x["율"]["대상"] in "MLST" and x["율"]["코드"] == "A01" + ) + if x["코드"] and x["갈래"] not in ("율", "닫기"): + assert x["차수"] == 1 + + +def test_total_count(): + got = [il.read(_sheets(t)[il.SHEET]) for t in TAGS] + assert sum(len(g["호표"]) for g in got) == 136 and sum(len(_lines(g)) for g in got) == 706 + + +def test_rate_close_lump(): + bong = _lines(il.read(_sheets("봉화")[il.SHEET])) + first = next(x for x in bong if x["갈래"] == "율") + assert (first["이름"], first["율"]["밑"], first["율"]["대상"]) == ("잡재료비", "L", "M") + marked = [x["행"] for x in bong if x.get("밑줄")] + assert 72 in marked and 73 in marked # 포장공 · 보통인부 = 잡재료비 밑수 줄 + yd = _lines(il.read(_sheets("영덕계류")[il.SHEET])) + closes = [x for x in yd if x["갈래"] == "닫기"] + whole = [x for x in yd if x.get("전체")] + assert len(closes) == 2 and [(x["코드"], x["수량"]) for x in whole] == [("W00005", 90.0)] * 2 + assert sum(x["갈래"] == "W" for x in _lines(il.read(_sheets("울진소광")[il.SHEET]))) == 3 + + +def test_bare_sheet_falls_back(): + rows = _bare(_sheets("봉화")[il.SHEET]) + got = il.read(rows) + assert not got["숨은칸"] and il.blocks(got) == [] and mf.from_book(rows) == [] + out = sx.read_sheets({**_sheets("봉화"), il.SHEET: rows}) + assert out["일위"] == [] and any("숨은 칸 없음" in t for t in out["표지"]) + # 호표 길 = 옛 코드 찾기(O 열) — 숨은 칸 율 · 호표 코드만 빠지고 줄은 같음 + from M02_MasterTemplete import M02_Hopyo_Stmate as st + + a = st._hopyos(_sheets("봉화")[il.SHEET]) + b = st._hopyos(rows) + strip = [[{k: v for k, v in x.items() if k != "율"} for x in h] for h in a[1]] + heads = [[{k: v for k, v in h.items() if k != "코드"} for h in x[0]] for x in (a, b)] + assert heads[0] == heads[1] and strip == b[1] # 호표 코드(BDCOD)도 숨은 칸이라 빠짐 + + +def test_same_form_as_stc(): + import test_810_1_stc as stc + + path = stc.FILES["봉화"] + if not path.exists(): + pytest.skip("원파일 없음") + xl = sx.read_sheets(_sheets("봉화")) + nat = {b["코드"]: b for b in stc.parsed("봉화")["일위"]} + + def key(x): + r = x.get("율") or {} + flags = tuple(bool(x.get(k)) for k in ("밑줄", "관급", "별도계상")) + return ( + x["코드"], + x["갈래"], + round(float(x["수량"] or 0), 6), + r.get("밑"), + r.get("대상"), + flags, + ) + + assert len(xl["일위"]) == 45 + for b in xl["일위"]: + assert [key(x) for x in b["줄"]] == [key(x) for x in nat[b["코드"]]["줄"]], b["코드"] + + +def test_one_reader_three_users(): + sh = _sheets("봉화") + clues = mf.from_book(sh[il.SHEET]) + assert len(clues) == 41 and clues == mf.from_book(il.read(sh[il.SHEET])) + assert all(c["곳"] == "일위대가표" and c["코드"].startswith("M") for c in clues) + path = golden.FILES["영덕계류"] + hp = hx.read_book(path.name, path.read_bytes()) + lines = hp["호표"][6]["줄"] # 제11호표 산악할증 — 숨은 칸 율(노무비 → 노무비 · 0 %) + rate = next(x for x in lines if x["코드"] == "PRA01") + assert rate["율"] == {"밑": "노무비", "대상": "노무비"} + assert hfix._ratio(rate, [x for x in lines if x is not rate]) == { + "기준": "노무비", + "비목": "노무비", + } + + +def test_census_ilwi_materials(): + path = golden.FILES["영덕계류"] + saved = fn.pack(sx.read_sheets(_sheets("영덕계류")), path.name) + got = mf.ilwi_found(saved) + assert len(got) == 4 and all(r["재료찾기"]["판정"] for _, r in got) + assert {b["코드"] for b, _ in got} == {"B01107"}