From 3f071c9726ec9c3f43eb639c96c2527514af43cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 16:47:27 +0900 Subject: [PATCH 1/4] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20652=20=E2=80=94=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=A4=84=20=EC=8B=9D=20=EC=8B=9C=ED=8A=B8=20=C2=B7?= =?UTF-8?q?=20=EA=B2=BD=EC=9F=81=EC=82=AC=20=EA=B8=80=20=EC=8B=9D=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=20=EA=B6=8C=EC=9E=A5=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1104/사용자요구_1104.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 63a57c9c0..2db6ebb45 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -186,3 +186,9 @@ M02 의 엑셀 불러오기에서 경쟁사 2개의 프로그램의 출력을 ## 요구 651 (11-05 · 사용자 · 650 덧말 — 다이어그램 방식 못 씀) 사실그래서 계산 공식을 수정하거나 변경할때 참고하려고 만든게 다이어그램 방식이었는데 사용자도 그렇지만 나도 전혀 사용못하겠거든 앞 요청의 부연 설명이야 + +## 요구 652 (11-05 · 사용자 · 권장대로 반영) + +권장대로 반영해보자. + +→ 대상 = _분석_650-H3 권함(시트 모양 · 다시 셈 자동 · M01 관리자도 같은 시트 · 다이어그램 걷음 · 차례 E1~E7) + _분석_649-G3 권함(갈래 C · 엑셀 단가 그대로 기본 · 식 칸 엶 · P1~P5) From bd34ba7bda7ae28ea18b549ea4138075d98c5518 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 16:48:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=84=9C=20652=20=E2=80=94=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=A4=84=20=EC=8B=9D=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=C2=B7=20=EA=B2=BD=EC=9F=81=EC=82=AC=20=EA=B8=80=20=EC=8B=9D?= =?UTF-8?q?=20=ED=98=B8=ED=99=98(=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EC=A0=95=ED=95=A8=20=C2=B7=20=EB=AC=BC?= =?UTF-8?q?=EA=B2=B0=201=20=EC=9D=BC=EA=B0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../요구서_652_단가산출시트_글식호환.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/raw/요구_1104/요구서_652_단가산출시트_글식호환.md diff --git a/docs/raw/요구_1104/요구서_652_단가산출시트_글식호환.md b/docs/raw/요구_1104/요구서_652_단가산출시트_글식호환.md new file mode 100644 index 000000000..f3588627c --- /dev/null +++ b/docs/raw/요구_1104/요구서_652_단가산출시트_글식호환.md @@ -0,0 +1,35 @@ +# 요구서 652 — 단가산출 줄 식 시트 · 경쟁사 글 식 호환 (요구 646~652 · 11-05) + +원문 = `사용자요구_1104.md` 요구 646 · 647 · 649 · 650 · 651 · 652 · 근거 = `_분석_648-F` · `_분석_649-G1` · `G2` · `G3` · `_분석_650-H1` · `H2` · `H3` · 계획 = `PLAN.md` 790~794. + +## 1. 사용자 확정 + +1. 갈래 C 섞음 — 경쟁사 산근을 「가져온 식 단가산출」로 그대로 담고 다시 셈(금액 그대로 · 단가 전파) · M01 기초 · 옵션 = 근거 제안 · [우리 기초로 바꾸기] = 사용자 선택(649-G3). +2. 가져올 때 기본 단가 = 엑셀 단가 그대로 → [우리 단가로] 한 번에(649-G3). +3. M02 단가산출에 사용자 식 칸 엶 — 「덮개 = 숫자만」 원칙 바뀜(649-G3). +4. 단가산출 한 장 = 줄 식 시트(이름 | 식 | 값 | 비목 · 근거 글 · 옵션 · 변수 · 계산 · 자원 · 율 · 소계 줄) + 산출서 보기 전환 + 상태줄 금액 셋(M01 원래 · 지금 · 고치기 전) + 까닭 칸 + [M01 원래로](650-H3). +5. 다시 셈 = 고칠 때마다 자동 · 서버 셈 먼저 · 느리면 브라우저 짝(650-H3). +6. M01 관리자 편집도 같은 시트 · 다이어그램(흐름도) 화면 걷음(요구 651 · 650-H3). +7. 불러오기 단산 줄 = 우리 단가산출 화면 모달 · 못 읽은 옵션 = 입력 필요 · 채워야 옮김(요구 646 · 647). +8. 차례 = E1 시트 화면 → E2 숫자 넓힘 → E3(P1) 엔진 셈 의미 → E4(P2) 식 칸 → E5 즉시 셈 → E6(P3 · P4) 번역기 · 불러오기 → E7(P5) M01 시트 · 다이어그램 걷음 · 판 표지 · 빈 기초 보강. + +## 2. 정함(브레인 · 사용자가 바꿀 수 있음) + +1. 시트 = M02 단가산출 상세 첫 탭 · 옛 보기(단가산출 상세 · 인력재료경비 · 시험 계산 · 텍스트 수식 · 기본정보)는 E7 까지 남김. +2. 식 칸 글 = 읽기 쉬운 이름 꼴(`[노임 형틀목공] × 5 / 시공량`) · 엔진 날식(찾기 · 만약 · 로직)은 숨김 · 표 찾기 값 = 값 + 표 꼬리표. +3. 엔진 셈 의미(E3) = 줄 묶음 이름(금액번지) · 소계 단계 · 제외판 · 줄 자리 속성 · 블록 끝전 정책(비목 버림 / 계 버림 + 큰 나머지) · 결과금액 조정(비목 옮기기) · 특수 %(처음부터 비목 누계 · 앞 소계 × 수량) · 변수 표시 반올림 — 계약단가 · 낙찰률 · 운전사 노임 5/24 는 B09 · 기계 쪽 다른 일감. +4. M01 로직 1,515 결과는 E3 전후 같음(지킴 시험) · 새 의미는 쓰는 로직에만 먹음. +5. 사용자 식 보안 = eval 없는 엔진 파서만 · 식 길이 · 괄호 깊이 · 줄 수 상한. +6. 번역기(E6)는 STmate(숨은 G 열 원문 식) 먼저 → EST(보이는 글 복원) · 먼저 파서 · 참조 평가기로 실무 여덟 파일 금액 100 % 를 지킴 시험으로 굳힘 → 우리 줄 꼴 내보내기는 E3 · E4 뒤. +7. 공용 부품(구조물 조합 일위대가 상세 · `Labor_*` · `Labor_SangeunModal`)은 E6 전까지 손대지 않음. + +## 3. 일감 · 창 (물결 1) + +| 일감 | 일 | 파일 | 창 | +| --- | --- | --- | --- | +| 791-1 E3 | 엔진 셈 의미 + 로직 JSON 꼴(_틀.md) + 검사 + M01 1,515 전후 같음 + 실무 손 옮김 견본 금액 = 엑셀 | `resources/master_data/scripts/master_formula*.py`(새 모듈) · `master_cut.py` · `master_formula_check.py` · `_틀.md` | sub1 | +| 790-2 E2 | 숫자 넓힘 서버 — 표값 · 식 속 상수 덮개 · 글 줄 · 까닭 · [M01 원래로] · M01 짝잃음 알림 | 새 `M02_Sangeun_Const.py` · `M02_Sangeun_Doc.py`(늘리지 않음) · `M02_Sangeun_Calc.py`(늘리지 않음) · `M02_Sangeun_Router.py` · 새 `logic/M02_Sangeun_Const_Model.ts` | sub2 | +| 790-1 E1 | 시트 화면 — 줄 갈래 · 이름 | 식 | 값 | 비목 · 지금 되는 고침 · 상태줄 금액 셋 · 산출서 보기 · 다시 셈 자동 | 새 `M02_MasterTemplete_Sangeun_Sheet*.ts` · `Sangeun_Detail.ts`(탭 등록만) · css | sub3 | +| 793-1 E6a | 번역기 1 — STmate 글 식 파서 · EST 보이는 글 → 식 복원 · 참조 평가기 · 실무 여덟 파일 금액 100 % 지킴 시험 | 새 `M02_MasterTemplete/M02_San_*.py` | sub4 | + +물결 2(물결 1 뒤) — 790-3 E2 화면 이음(sub3) · 792 E4 식 칸 · E5 즉시 셈 · 793-2 번역기 → 우리 줄 꼴 · 793-3 불러오기 화면 · 794 E7. From 428d35cbd6b2620e9cf9a847ae23b4a8efba0879 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 16:59:56 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(793-1):=20=EA=B2=BD=EC=9F=81=EC=82=AC?= =?UTF-8?q?=20=EC=82=B0=EA=B7=BC=20=EA=B8=80=20=EC=8B=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=EC=B0=B8=EC=A1=B0=20=ED=8F=89=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=8B=A4=EB=AC=B4=20=EC=97=AC=EB=8D=9F=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A7=80=ED=82=B4=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_San_Expr — eval 없는 식 나무 · 셈 · 줄 자리 규칙(버림 · 반올림 · 올림 × 0~3) - M02_San_Stmate — 숨은 G 열 원문 → 줄 나무(계 5 단 · (-=) · 대입 · [A] · ;; · &L/M/S/X/D& · &P/R& 율 · :=A01 · -> · // 덧글) - M02_San_Est — 보이는 글 → 식 복원(단가 · 앞줄 · 상수 역할 · 값 줄 숨은 합 · 변수 얼림 · 단가 정체 · 모호 표지) - M02_San_Eval — STmate 셈(특수 % · 끝전 두 갈래 · 결과금액 조정 · 계약단가) · EST 다시 셈 · 줄마다 검산 · 단가 전파 - M02_San_Book — 같은 책 블록 · 단가표 읽기(읽기만) - test_793_1 — STmate 233/233 · 금액 줄 1,315 · 계 줄 695 · EST 222/222 · 줄 2,901/2,903 · 모호 9 · 전파 견본 7 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_San_Book.py | 238 +++++++++++++++++ M02_MasterTemplete/M02_San_Est.py | 318 +++++++++++++++++++++++ M02_MasterTemplete/M02_San_Eval.py | 293 +++++++++++++++++++++ M02_MasterTemplete/M02_San_Expr.py | 260 ++++++++++++++++++ M02_MasterTemplete/M02_San_Stmate.py | 191 ++++++++++++++ resources/tester/test_793_1_san_parse.py | 171 ++++++++++++ 6 files changed, 1471 insertions(+) create mode 100644 M02_MasterTemplete/M02_San_Book.py create mode 100644 M02_MasterTemplete/M02_San_Est.py create mode 100644 M02_MasterTemplete/M02_San_Eval.py create mode 100644 M02_MasterTemplete/M02_San_Expr.py create mode 100644 M02_MasterTemplete/M02_San_Stmate.py create mode 100644 resources/tester/test_793_1_san_parse.py diff --git a/M02_MasterTemplete/M02_San_Book.py b/M02_MasterTemplete/M02_San_Book.py new file mode 100644 index 000000000..f02e2b789 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Book.py @@ -0,0 +1,238 @@ +"""경쟁사 산근 엑셀 읽기 — 블록 · 같은 책 단가표(PLAN 793-1 · 읽기만). + +STmate(.xlsx) = 단가산출근거 시트 G 열 「BDCOD|D…」 머리 · POSS~POSE 몸(숨은 G 열 원문) · +꼬리(계 · 계약단가 · 총계) + 목록표 단가(노무비 · 재료비 · 경비 = E 열 · 숨은 G 열 `COD` · +중기 · 단가산출근거 = E~H 열 계 · 노 · 재 · 경 · 숨은 J 열 메타). +EST(.xls · .xlsx) = 단가산출 시트 블록 글(`M02_Hopyo_ReadEst.read`) + 노임단가 · +자재단가(적용단가) · 기계경비총괄표 · 견적단가 · 단가산출총괄표(적용 칸 · 합이 안 맞으면 당초 칸). +""" + +from __future__ import annotations + +import io +import re +import warnings +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_ReadEst as read_est + +STM_SHEETS = ( + "단가산출근거", + "단가산출근거목록표", + "노무비목록표", + "재료비목록표", + "경비목록표", + "중기목록표", +) +EST_SHEETS = ("단가산출총괄표", "단가산출", "기계경비총괄표", "자재단가", "노임단가", "견적단가") +_X7, _X5 = "_x0007_", "_x0005_" +_HEAD = re.compile(r"BDCOD\|([A-Z]\d+).*?POSS\|(\d+).*?POSE\|(\d+)") + + +def sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: + """책 바이트 → {시트: 줄} (두 꼴 시트만).""" + keep = STM_SHEETS + EST_SHEETS + if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): + import openpyxl + + with warnings.catch_warnings(): # 인쇄 영역 이름 경고 + warnings.simplefilter("ignore") + wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + try: + return { + ws.title.strip(): [list(r) for r in ws.iter_rows(values_only=True)] + for ws in wb.worksheets + if ws.title.strip() in keep + } + finally: + wb.close() + import xlrd + + wb = xlrd.open_workbook(file_contents=data, logfile=io.StringIO()) + return { + sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)] + for sh in wb.sheets() + if sh.name.strip() in keep + } + + +def kind_of(book: dict[str, list]) -> str | None: + if "단가산출근거" in book: + return "STmate" + return "EST" if "단가산출" in book else None + + +# ── STmate ───────────────────────────────────────────────────────────── +def _col(row: list[Any], letter: str) -> Any: + i = ord(letter) - 65 + return row[i] if i < len(row) else None + + +def meta(text: Any) -> dict[str, str]: + """숨은 메타 칸 「COD|L00003 ␅ UNT|L% …」 → dict.""" + 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 + + +_BODY = "BCDEFGHIJKLMNO" + + +def stmate_blocks(book: dict[str, list]) -> list[dict[str, Any]]: + """→ [{코드, 이름, 행, 머리 {A..F}, 몸 [{행, B, G, C~F …}], 꼬리 [{행, A, C~F, H}]}].""" + rows = book["단가산출근거"] + out = [] + for i, r in enumerate(rows): + gv = _col(r, "G") + if not (isinstance(gv, str) and "BDCOD|" in gv): + continue + m = _HEAD.search(gv) + code, ps, pe = m.group(1), int(m.group(2)), int(m.group(3)) + body = [] + for rn in range(ps, pe + 1): + rr = rows[rn - 1] + one = {k: _col(rr, k) for k in _BODY} + one["G"] = str(one["G"] or "").replace(_X7, "") + body.append({"행": rn, **one}) + tail = [] + for rn in range(pe + 1, min(pe + 40, len(rows) + 1)): + rr = rows[rn - 1] + g2 = _col(rr, "G") + if isinstance(g2, str) and "BDCOD|" in g2: + break + a = str(_col(rr, "A") or "").replace(" ", "") + if a and isinstance(_col(rr, "C"), (int, float)): + tail.append({"행": rn, "A": a, **{k: _col(rr, k) for k in "CDEFH"}}) + head = {k: _col(r, k) for k in "ABCDEF"} + name = _col(rows[i + 1], "B") if i + 1 < len(rows) else None + out.append( + {"코드": code, "이름": name, "행": i + 1, "머리": head, "몸": body, "꼬리": tail} + ) + return out + + +def stmate_prices(book: dict[str, list]) -> dict[str, dict[str, Any]]: + """코드 → {T, L, M, S, meta, 이름, 규격, 단위}.""" + prices: dict[str, dict[str, Any]] = {} + for sheet, comp in (("노무비목록표", "L"), ("재료비목록표", "M"), ("경비목록표", "S")): + for r in book.get(sheet, [])[3:]: + m = meta(_col(r, "G")) if isinstance(_col(r, "G"), str) else {} + v = _col(r, "E") + if m.get("COD") and isinstance(v, (int, float)): + one = {c: (v if c == comp else 0) for c in "LMS"} + prices[m["COD"]] = {"T": v, **one, "meta": m} | _names(r) + for sheet in ("중기목록표", "단가산출근거목록표"): + for r in book.get(sheet, [])[3:]: + m = meta(_col(r, "J")) if isinstance(_col(r, "J"), str) else {} + if m.get("COD") and isinstance(_col(r, "E"), (int, float)): + one = {"T": _col(r, "E"), "L": _col(r, "F") or 0, "M": _col(r, "G") or 0} + prices[m["COD"]] = {**one, "S": _col(r, "H") or 0, "meta": m} | _names(r) + return prices + + +def _names(r: list[Any]) -> dict[str, Any]: + return {"이름": _col(r, "B"), "규격": _col(r, "C"), "단위": _col(r, "D")} + + +# ── EST ──────────────────────────────────────────────────────────────── +def flat(s: Any) -> str: + return re.sub(r"\s+", "", str(s or "")) + + +def _hcol(rows: list[list[Any]], word: str, start: int = 2) -> int | None: + for r in rows[start : start + 2]: + for j, v in enumerate(r): + if flat(v) == word: + return j + return None + + +def _n(v: Any) -> float | None: + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v).replace(",", "").strip()) + except ValueError: + return None + + +def applied(r: list[Any]) -> dict[str, float]: + """총괄표 줄 → {T, L, M, S} — 적용 칸(5~8) 합이 맞으면 적용 · 아니면 당초(25~28).""" + a = {k: _n(r[j]) for k, j in (("T", 5), ("L", 6), ("M", 7), ("S", 8))} + if ( + a["T"] + and all(a[k] is not None for k in "LMS") + and abs(a["L"] + a["M"] + a["S"] - a["T"]) < 0.5 + and (a["L"] or a["M"] or a["S"]) + ): + return a + return {k: (_n(r[j]) or 0.0) for k, j in (("T", 25), ("L", 26), ("M", 27), ("S", 28))} + + +def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]]]: + """→ (단가 목록 [{종류, 이름, 규격, 비목, 값}], 산근 총괄 {번: {T, L, M, S, 이름, 규격}}).""" + out: list[dict[str, Any]] = [] + lab = book.get("노임단가", []) + c = _hcol(lab, "적용단가") + for r in lab[3:]: + if c is not None and len(r) > c and r[2] and isinstance(r[c], (int, float)): + out.append( + {"종류": "노임", "이름": flat(r[2]), "규격": "", "비목": "L", "값": float(r[c])} + ) + mat = book.get("자재단가", []) + c = _hcol(mat, "적용단가") + for r in mat[4:]: + if c is not None and len(r) > c and r[2] and isinstance(r[c], (int, float)): + out.append( + { + "종류": "자재", + "이름": flat(r[2]), + "규격": flat(r[3]), + "비목": "M", + "값": float(r[c]), + } + ) + for sheet, kind in (("기계경비총괄표", "기계"), ("견적단가", "견적")): + for r in book.get(sheet, [])[3:]: + if len(r) > 28 and r[2]: + for k, v in applied(r).items(): + if isinstance(v, (int, float)) and (v or k != "T"): + one = {"종류": kind, "이름": flat(r[2]), "규격": flat(r[3])} + out.append({**one, "비목": k, "값": float(v or 0)}) + san: dict[int, dict[str, Any]] = {} + for r in book.get("단가산출총괄표", [])[3:]: + if len(r) > 28 and str(r[1]).strip().isdigit(): + n = int(str(r[1]).strip()) + san[n] = {k: float(v or 0) for k, v in applied(r).items()} + san[n] |= {"이름": flat(r[2]), "규격": flat(r[3])} + for k in "TLMS": + if san[n][k] or k != "T": + one = {"종류": "산근", "이름": f"단산{n}", "규격": ""} + out.append({**one, "비목": k, "값": san[n][k]}) + return out, san + + +def est_blocks(book: dict[str, list]) -> list[dict[str, Any]]: + """→ [{번, 이름, 단위, 글 [줄], 글행 [행]}].""" + blocks = read_est.read(book.get(read_est.SHEET)) + keep = ("번", "이름", "단위", "글", "글행") + return [{k: b[k] for k in keep} for b in blocks.values()] + + +def load(path: str | Path) -> dict[str, Any]: + """파일 → {꼴, 블록, 단가(STmate dict | EST 목록), 총괄(EST)}.""" + path = Path(path) + book = sheets(path.name, path.read_bytes()) + kind = kind_of(book) + if kind == "STmate": + return {"꼴": kind, "블록": stmate_blocks(book), "단가": stmate_prices(book)} + if kind == "EST": + prices, san = est_prices(book) + return {"꼴": kind, "블록": est_blocks(book), "단가": prices, "총괄": san} + raise ValueError("STmate · EST 산근 시트 없음") diff --git a/M02_MasterTemplete/M02_San_Est.py b/M02_MasterTemplete/M02_San_Est.py new file mode 100644 index 000000000..ba2f20eae --- /dev/null +++ b/M02_MasterTemplete/M02_San_Est.py @@ -0,0 +1,318 @@ +"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음). + +엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`. + +줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈. +- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) · + 값(보인 결과) · 식(`M02_San_Expr` 나무) · + 역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} · + 규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1) +- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는 + 바로 앞 돈 · 값 줄 이음 합) +- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘) +""" + +from __future__ import annotations + +import collections +import re +from typing import Any + +from M02_MasterTemplete import M02_San_Expr as ex +from M02_MasterTemplete.M02_San_Book import flat + +COMPW = {"노무비": "L", "재료비": "M", "경비": "S"} +NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+" +EPS = ex.EPS +OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"} +OPS |= {"-": "-", "−": "-"} +_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)") +VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=") +RES = re.compile( + r"^\s*(?:[A-Z]\s+)?(" + + NUMRE + + r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$" +) +_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*") +_LABEL = re.compile(r"^(?P[^::=]{0,40}?)\s*[::]\s*(?P.*)$") +_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]") + + +def num(s: str) -> float: + return float(s.replace(",", "").rstrip(".") or 0) + + +def tokens(seg: str) -> list[tuple] | None: + """식 조각 → Expr 토큰 · 숫자 식이 아니면(기호 · 수 둘 잇달아 · 연산 없음) None.""" + s = seg.replace("(", "(").replace(")", ")").replace("x", "x") + prev = None + while prev != s: # 수 없는 괄호 글 「(품할증)」 + prev = s + s = re.sub(r"\([^()\d]*\)", " ", s) + out: list[tuple] = [] + i, want, depth = 0, True, 0 + while i < len(s): + ch = s[i] + if ch.isspace(): + i += 1 + continue + m = re.match(NUMRE, s[i:]) + if m and (i == 0 or not (s[i - 1].isalpha() and s[i - 1].isascii())): + if not want: + return None + out.append(("수", num(m.group(0)))) + i += len(m.group(0)) + want = False + while i < len(s): # 단위 글 「인」 「㎡」 「본/일」 + m2 = _UNIT.match(s[i:]) + if m2 and m2.group(0).strip() and not re.fullmatch(r"\s*[xX]", m2.group(0)): + i += len(m2.group(0)) + continue + break + continue + if ch == "%": + if want: + return None + out.append(("%",)) + i += 1 + continue + if ch in OPS: + if ch in "xX" and want: + return None + if ch == "/" and not re.match(r"\s*[\d(.]", s[i + 1 :]): + return None + if ch in "--−" and want: + out.append(("연산", "-")) + i += 1 + continue + if want: + return None + out.append(("연산", OPS[ch])) + i += 1 + want = True + continue + if ch == "(": + if not want: + return None + depth += 1 + out.append(("(",)) + i += 1 + continue + if ch == ")": + depth -= 1 + if depth < 0 or want: + return None + out.append((")",)) + i += 1 + continue + if ch.isalpha() or ch == "_": + return None + i += 1 # 장식(₩ 등) + if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out): + return None + return out + + +def calc(node: tuple, sub: dict | None = None) -> float | None: + try: + return ex.value(node, {"V": {}, "sub": sub}) + except ex.ExprError: + return None + + +def label_comp(label: str) -> str | None: + w = flat(label) + return next((v for k, v in COMPW.items() if w.endswith(k)), None) + + +def parse_line(text: str) -> dict[str, Any]: + """보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전).""" + t = text.strip() + if not t: + return {"꼴": "빈"} + if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t: + return {"꼴": "그림" if "─" in t else "글"} + label, rhs = "", t + mc = _LABEL.match(t) + if mc and not re.search(r"\d\s*$", mc.group("l")): + label, rhs = mc.group("l"), mc.group("r") + if VARLINE.match(rhs) or VARLINE.match(t): + return {"꼴": "변수", "조각": rhs.split("=")} + if "=" not in rhs: + m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs) + if label and m: + return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))} + return {"꼴": "글"} + segs = rhs.split("=") + mres = RES.match(segs[-1]) + if not mres: + return {"꼴": "글끝"} + shown = num(mres.group(1)) + toks = None + for seg in reversed(segs[:-1]): + toks = tokens(seg) + if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며 + starts = {m.start() for m in re.finditer(NUMRE, seg)} + starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)} + for p in sorted(starts): + if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"): + continue + toks = tokens(seg[p:]) + if toks: + label = label or seg[:p] + break + if toks: + break + if not toks: + return {"꼴": "식못읽음", "라벨": label, "값": shown} + return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)} + + +# ── 블록 풀이 ────────────────────────────────────────────────────────── +def _ident(p: dict[str, Any]) -> tuple: + return (p["종류"], p["이름"], p["규격"]) + + +def _money(L: dict, prices_by_value: dict, results: list) -> None: + """돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙.""" + comp = L["비목"] + nums = [(n[2], n[1]) for n in ex.numbers(L["식"])] + roles: dict[int, dict[str, Any]] = {} + price_at = None + for i, v in nums: + if v >= 1 and any(abs(r[1] - v) < EPS for r in results): + continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저) + cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)] + if comp: + cands = [p for p in cands if p["비목"] == comp] or cands + if v == 0 and comp and not cands: + cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}] + first = i == nums[0][0] + if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands): + price_at = i + roles[i] = {"갈래": "단가", "후보": cands} + break + for i, v in nums: + if i in roles: + continue + hit = [r for r in reversed(results) if abs(r[1] - v) < EPS] + roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"} + L["역할"] = roles + L["셈"] = calc(L["식"]) + L["규칙"] = ex.fit(L["셈"], L["값"]) + if price_at is not None: + L["종류"] = "단가줄" + pc = roles[price_at]["후보"] + L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None) + L["후보수"] = len({_ident(p) for p in pc}) + elif any(r["갈래"] == "앞줄" for r in roles.values()): + L["종류"] = "참조줄" + else: + L["종류"] = "상수줄" + + +def _value(L: dict, lines: list, results: list) -> None: + """값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합.""" + v, how = L["값"], None + for r in reversed(results): + rule = ex.fit(r[1], v) + if rule: + how = {"갈래": "복사", "줄": [r[0]], "규칙": rule} + break + if not how and flat(L["라벨"]) in ("계", "합계"): + got: dict[str, dict] = {} + for x in reversed(lines): + c = x.get("비목") + if c and x.get("꼴") in ("돈", "값") and c not in got: + got[c] = x + if len(got) == 3: + break + if got: + rule = ex.fit(sum(x["값"] for x in got.values()), v) + if rule: + how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule} + if not how: + acc, used = 0.0, [] + for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]): + if len(used) >= 25: + break + acc += x["값"] + used.append(x["차례"]) + rule = ex.fit(acc, v) + if rule and len(used) > 1: + how = {"갈래": "합", "줄": used[::-1], "규칙": rule} + break + L["종류"] = "값줄" + L["숨은"] = how + L["규칙"] = how["규칙"] if how else None + + +def _var(L: dict) -> None: + segs = L.pop("조각") + m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None + if not m: + L["종류"] = "변수글" + return + shown, toks = num(m.group(1)), None + for seg in reversed(segs[1:-1]): + toks = tokens(seg) + if toks: + break + L["값"] = shown + L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None + L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호") + + +def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]: + """블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체).""" + by_value = collections.defaultdict(list) + for p in prices: + by_value[p["값"]].append(p) + lines: list[dict[str, Any]] = [] + results: list[tuple] = [] # (차례, 값, 비목) + for k, raw in enumerate(text): + L = {"차례": k, "원글": raw, **parse_line(raw)} + if L["꼴"] == "돈": + _money(L, by_value, results) + results.append((k, L["값"], L["비목"])) + elif L["꼴"] == "값": + _value(L, lines, results) + results.append((k, L["값"], L["비목"])) + elif L["꼴"] == "변수": + _var(L) + lines.append(L) + identify(lines) + return lines + + +def identify(lines: list[dict[str, Any]]) -> None: + """단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지.""" + head = "" + for L in lines: + if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]): + head = L["원글"] + if L.get("종류") != "단가줄": + continue + role = next(r for r in L["역할"].values() if r["갈래"] == "단가") + cands = role["후보"] + ids = {_ident(p) for p in cands} + how = "값" + if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"): + k0, inter = L["차례"], set(ids) + for M in lines[max(0, k0 - 3) : k0 + 4]: + if M is L or M.get("종류") != "단가줄": + continue + r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가") + ids2 = {_ident(p) for p in r2["후보"]} + if ids2 & inter and len(ids2 & inter) < len(inter): + inter &= ids2 + ids, how = inter, "기계 세 줄" + if len(ids) > 1: + ctx = flat(L["원글"]) + "|" + flat(head) + hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)} + ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥" + role["모호"] = len(ids) != 1 + role["갈림"] = how + pick = sorted(ids)[0] + p = next(p for p in cands if _ident(p) == pick) + role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]} diff --git a/M02_MasterTemplete/M02_San_Eval.py b/M02_MasterTemplete/M02_San_Eval.py new file mode 100644 index 000000000..9c697ad8a --- /dev/null +++ b/M02_MasterTemplete/M02_San_Eval.py @@ -0,0 +1,293 @@ +"""경쟁사 산근 참조 평가기(PLAN 793-1 · 요구서_652) — 줄 나무 + 같은 책 단가 → +줄 · 블록 금액 · 검산. + +STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1e-13) → 소수 1 버림 · +변수 `X = 식 =` = 소수 2 반올림 · 율 `&P..&` = 범위 비목 금액 × n % → 소수 1 버림 · +`&R..&` = × n · 특수 % L% · M% = 처음부터 그 비목 누계 × n % · +T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 · +`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) · +결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림. +EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 · +값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄. +단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`. +""" + +from __future__ import annotations + +import collections +import math +from typing import Any + +from M02_MasterTemplete import M02_San_Expr as ex + +COMPS = ("L", "M", "S") +SPECIAL = ("L%", "M%", "S%", "T%") +TOL_LINE = 0.05 + + +def _zero() -> dict[str, float]: + return dict.fromkeys(COMPS, 0.0) + + +# ── STmate ───────────────────────────────────────────────────────────── +def _rate_amount(node: dict, coef: float, A: dict, T: dict, since: dict, total: dict) -> dict: + rate = node["율"] + area = rate["범위"] + if area == "A": + base = _zero() + for spot in rate["번지"]: + for c in COMPS: + base[c] += A[spot][c] + elif area == "T": + base = T.get(rate["번지"][0]) or _zero() + else: + base = since if area == "C" else total + pct = coef if rate["곱"] else coef / 100 + src, dst = rate["비목"], rate["옮김"] + amt = _zero() + if src: + amt[dst or src] = ex.fl1(base[src] * pct) + elif dst: + amt[dst] = ex.fl1(sum(base.values()) * pct) + else: + for c in COMPS: + amt[c] = ex.fl1(base[c] * pct) + return amt + + +def _ref_amount( + node: dict, coef: float, prices: dict, total: dict, last_tot: Any, res: dict +) -> dict: + src_ = node["자원"] + code = src_["코드"] + if code not in prices: + raise ex.ExprError(f"단가 없음 {code}") + pr = prices[code] + src, dst = src_["비목"], src_["옮김"] + amt = _zero() + unt = (pr.get("meta") or {}).get("UNT") + res["단가"] = {c: pr[c] for c in COMPS} + if unt in SPECIAL: + dst2 = dst or {"L": "L", "M": "M", "S": "S"}.get(code[0]) + if unt == "T%": + b0 = last_tot if last_tot is not None else sum(total.values()) + amt[dst2] = ex.fl1(b0 * coef) + else: + b0 = total[unt[0]] + amt[dst2] = ex.fl1(b0 * coef / 100) + res["기준"] = b0 + res["종류"] = "특수" + return amt + c_eps = coef + 1e-13 + if src: + amt[dst or src] = ex.fl1(pr[src] * c_eps) + else: + for c in COMPS: + if pr[c]: + amt[c] = ex.fl1(pr[c] * c_eps) + return amt + + +def run_stmate(nodes: list[dict], prices: dict, dec: int = 2) -> tuple[list[dict], dict]: + """줄 나무 → (줄 결과 [{종류, 값 {L, M, S}, 계수, 오류 …}], 블록 소수 합 {L, M, S}).""" + V: dict[str, float] = {} + A: dict[str, dict] = collections.defaultdict(_zero) # 금액번지 + T: dict[str, dict] = {} # 누계번지 + acc = [_zero() for _ in range(6)] # 계 단 1~5 · 0 = 전체 + since = _zero() # 마지막 계표시 뒤 + last_tot = None + out = [] + for node in nodes: + kind = node["종류"] + res: dict[str, Any] = {"행": node.get("행"), "종류": kind, "값": None, "오류": None} + try: + if kind == "계": + lvl = node["단"] + val = dict(acc[lvl]) + res |= {"단": lvl, "제외": node["제외"], "값": val} + for k in range(1, lvl + 1): + acc[k] = _zero() + if node["제외"]: + for k in [*range(lvl + 1, 6), 0]: + for c in COMPS: + acc[k][c] -= val[c] + if node["누계번지"]: + T[node["누계번지"]] = val + last_tot = sum(val.values()) + since = _zero() + elif kind in ("참조", "율"): + if node.get("오류"): + raise ex.ExprError(node["오류"]) + coef = ex.value(node["식"], {"V": V, "R": 1.0}) + if kind == "율": + amt = _rate_amount(node, coef, A, T, since, acc[0]) + else: + amt = _ref_amount(node, coef, prices, acc[0], last_tot, res) + res |= {"값": amt, "계수": coef, "번지": node["번지"], "빼기": node["빼기"]} + addr = node["번지"] + if addr and addr.startswith("A"): + for c in COMPS: + A[addr][c] += amt[c] + if not node["빼기"]: + for k in range(6): + for c in COMPS: + acc[k][c] += amt[c] + for c in COMPS: + since[c] += amt[c] + elif kind == "대입": + for part in node["대입"]: + if part["이름"] is None: + try: + ex.value(part["식"], {"V": V, "R": 1.0}) + except ex.ExprError: + pass + continue + v = ex.value(part["식"], {"V": V, "R": 1.0}) + V[part["이름"]] = ex.rnd(v, dec) if part["찍기"] else v + except ex.ExprError as error: + res["오류"] = str(error) + out.append(res) + return out, acc[0] + + +def block_total(tot: dict, rule: str) -> dict[str, float]: + """블록 끝전 — 비목버림(비목 원 버림 · 계 = 합) / 합버림_큰나머지(계 원 버림 · 나머지를 소수 큰 + 차례 경 › 재 › 노 로 1 원씩).""" + c = {k: math.floor(round(tot[k], 6)) for k in COMPS} + if rule == "비목버림": + c["T"] = c["L"] + c["M"] + c["S"] + return c + total = math.floor(round(sum(tot.values()), 6)) + order = sorted(COMPS, key=lambda k: (-round(tot[k] - c[k], 6), "SML".index(k))) + for k in order[: total - sum(c.values())]: + c[k] += 1 + c["T"] = total + return c + + +def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dict[str, Any]: + """블록 → {줄, 계(끝전 뒤 · 계약 전), 금액(조정 · 계약 뒤), 낙찰}.""" + lines, tot = run_stmate(nodes, prices) + meta = (prices.get(block["코드"]) or {}).get("meta", {}) + kye = block_total(tot, rule) + sbm = (meta.get("SBM") or "").strip() + if meta.get("ADJ") == "T" and sbm in COMPS: + kye = {"L": 0, "M": 0, "S": 0, "T": kye["T"], sbm: kye["T"]} + nag = float(meta["NAG"]) if meta.get("UC") == "T" and meta.get("NAG") else None + fin = dict(kye) + if nag: + fin = {c: math.floor(round(kye[c] * nag / 100, 6)) for c in COMPS} + fin["T"] = fin["L"] + fin["M"] + fin["S"] + return {"줄": lines, "합": tot, "계": kye, "금액": fin, "낙찰": nag} + + +def stmate_check(block: dict, result: dict) -> dict[str, Any]: + """줄마다 · 블록 검산 — 금액 줄(참조 · 율 · 특수) C~F 대조 · 계표시 줄 · 블록 머리 · 꼬리 계.""" + bad, n_amt, n_tot, ok_tot = [], 0, 0, 0 + for r, ln in zip(result["줄"], block["몸"], strict=True): + want = {"L": ln["D"] or 0, "M": ln["E"] or 0, "S": ln["F"] or 0} + if r["종류"] in ("참조", "율", "특수"): + n_amt += 1 + if not r["값"] or any(abs(r["값"][c] - want[c]) >= TOL_LINE for c in COMPS): + bad.append({"행": ln["행"], "엑셀": want, "셈": r["값"], "오류": r["오류"]}) + elif r["종류"] == "계" and ln["C"] is not None: + n_tot += 1 + ok_tot += all(abs(r["값"][c] - want[c]) < 0.15 for c in COMPS) + head = block["머리"] + want = {"T": head["C"] or 0, "L": head["D"] or 0, "M": head["E"] or 0, "S": head["F"] or 0} + kye = next((t for t in block["꼬리"] if t["A"] == "계"), None) + want_k = {"T": kye["C"], "L": kye["D"], "M": kye["E"], "S": kye["F"]} if kye else None + same = lambda x, y: all(abs((x[k] or 0) - (y[k] or 0)) < 0.5 for k in "TLMS") # noqa: E731 + ok = same(result["금액"], want) and (want_k is None or same(result["계"], want_k)) + return { + "맞음": ok, + "엑셀": want, + "엑셀계": want_k, + "금액줄": n_amt, + "틀린줄": bad, + "계줄": n_tot, + "계줄맞음": ok_tot, + } + + +# ── EST ──────────────────────────────────────────────────────────────── +def key_of(ident: dict[str, Any]) -> tuple: + """EST 단가 정체 → 전파 열쇠 (종류, 이름, 규격, 비목).""" + return (ident["종류"], ident["이름"], ident["규격"], ident["비목"]) + + +def run_est(lines: list[dict], newp: dict[tuple, float] | None = None) -> dict[int, float | None]: + """복원 줄 → {차례: 다시 셈 값}(줄 자리 속성 그대로) · newp = {정체 열쇠: 새 단가}.""" + newp = newp or {} + val: dict[int, float | None] = {} + for L in lines: + k = L["차례"] + if L.get("꼴") == "돈" and L.get("규칙"): + sub = {} + for i, r in L["역할"].items(): + if r["갈래"] == "단가" and key_of(r["정체"]) in newp: + sub[i] = newp[key_of(r["정체"])] + elif r["갈래"] == "앞줄" and val.get(r["줄"]) is not None: + sub[i] = val[r["줄"]] + v = _est_calc(L["식"], sub) + val[k] = ex.RULE[L["규칙"]](v) if v is not None else None + elif L.get("종류") == "값줄" and L.get("숨은"): + how = L["숨은"] + s = sum( + (val.get(j) if val.get(j) is not None else lines[j]["값"]) or 0 for j in how["줄"] + ) + val[k] = ex.RULE[how["규칙"]](s) + return val + + +def _est_calc(node: tuple, sub: dict) -> float | None: + try: + return ex.value(node, {"V": {}, "sub": sub}) + except ex.ExprError: + return None + + +def designate(lines: list[dict], want: dict[str, float]) -> dict[str, Any]: + """블록 성분(총괄표) → 그 값을 낸 줄(마지막 · 라벨 비목 우선) · 0 = 「영」 · 없음 = None.""" + out: dict[str, Any] = {} + kinds = ("단가줄", "참조줄", "상수줄", "값줄") + for c in COMPS: + w = want[c] + if not w: + out[c] = "영" + continue + cand = [L for L in lines if L.get("종류") in kinds and abs(L["값"] - w) < ex.EPS] + pick = [L for L in cand if L.get("비목") == c] or cand + out[c] = pick[-1]["차례"] if pick else None + return out + + +def est_block(lines: list[dict], des: dict[str, Any], newp: dict | None = None) -> dict[str, Any]: + """성분 줄 → 블록 금액(성분 원 버림 · 계 = 합) · 줄 값.""" + val = run_est(lines, newp) + raw = { + c: (0.0 if k == "영" else (val.get(k) if k is not None else None)) for c, k in des.items() + } + if any(v is None for v in raw.values()): + return {"줄": val, "성분": raw, "금액": None} + fin = {c: math.floor(round(raw[c], 6)) for c in COMPS} + fin["T"] = fin["L"] + fin["M"] + fin["S"] + return {"줄": val, "성분": raw, "금액": fin} + + +def est_check(lines: list[dict], want: dict[str, float]) -> dict[str, Any]: + """블록 검산 — 다시 셈 성분 = 총괄표 · 줄마다 보인 값 대조(돈 · 값 줄).""" + des = designate(lines, want) + got = est_block(lines, des) + val = got["줄"] + n, bad = 0, [] + for L in lines: + if L.get("꼴") == "돈" or L.get("종류") == "값줄": + n += 1 + v = val.get(L["차례"]) + if v is None or abs(v - L["값"]) >= ex.EPS: + bad.append({"차례": L["차례"], "글": L["원글"].strip(), "보인": L["값"], "셈": v}) + fin = got["금액"] + ok = fin is not None and all(abs(fin[c] - want[c]) < 0.5 for c in COMPS) + ok = ok and abs(want["L"] + want["M"] + want["S"] - want["T"]) < 0.5 + return {"맞음": ok, "성분줄": des, "금액": fin, "줄수": n, "틀린줄": bad} diff --git a/M02_MasterTemplete/M02_San_Expr.py b/M02_MasterTemplete/M02_San_Expr.py new file mode 100644 index 000000000..848dd7bcf --- /dev/null +++ b/M02_MasterTemplete/M02_San_Expr.py @@ -0,0 +1,260 @@ +"""경쟁사 산근 글 식 — 셈 식 나무 · 평가 · 자리 규칙(PLAN 793-1 · 요구서_652 · eval 없음). + +토큰 = `("수", 값)` · `("이름", 글)` · `("참조",)` · `("연산", + - * / ^)` · `("(",)` · `(")",)` · +`(",",)` · `("%",)` · `("끼움", 이름 | None, 토큰 목록)`. +나무 마디 = `("수", 값, 토큰 차례)` · `("이름", 소문자)` · `("참조",)` · +`("끼움", 이름 | None, 마디)` · `("함수", round | int, [마디])` · `("음", 마디)` · +`("셈", 연산, 왼, 오른)` · `("오류", 까닭)`(읽기 실패 — 셀 때 오류). +우선순위 = 파이썬 식과 같음(`^` = 거듭제곱 · 오른쪽 결합 · 단항 `-` 보다 셈 · `%` = 「/ 100」 을 +곱셈 단에서 왼쪽부터) — 측정 도구(`tmp/brain_649/G2`)의 파이썬 식 평가와 같은 부동소수 결과. +""" + +from __future__ import annotations + +import math +from typing import Any + +FUNCS = ("round", "int") +MAX_DEPTH = 64 + + +class ExprError(Exception): + """식 읽기 · 셈 실패 — 줄 표지로 남김.""" + + +# ── 자리 규칙 ────────────────────────────────────────────────────────── +def fl1(x: float) -> float: + """소수 1 자리 버림(STmate 줄 금액).""" + return math.floor(round(x * 10, 6)) / 10 + + +def rnd(x: float, n: int) -> float: + """반올림(half up) — STmate 변수 표시 자리.""" + q = 10**n + return math.floor(x * q + 0.5 + 1e-9) / q + + +def _cut(q: float) -> Any: + return lambda v: math.floor(round(v * q, 6)) / q + + +def _half(q: float) -> Any: + return lambda v: math.floor(v * q + 0.5 + 1e-9) / q + + +def _up(q: float) -> Any: + return lambda v: math.ceil(round(v * q, 6)) / q + + +# 줄 자리 속성 — 차례 = 되찾기 우선(EST 기본 = 버림1) +RULES: list[tuple[str, Any]] = [ + ("버림1", _cut(10)), + ("버림0", _cut(1)), + ("반올림1", _half(10)), + ("반올림0", _half(1)), + ("버림2", _cut(100)), + ("반올림2", _half(100)), + ("버림3", _cut(1000)), + ("반올림3", _half(1000)), + ("올림0", _up(1)), + ("올림1", _up(10)), + ("올림2", _up(100)), + ("그대로", lambda v: v), +] +RULE = dict(RULES) +EPS = 1e-6 + + +def fit(value: float | None, shown: float) -> str | None: + """셈 값 → 보인 값이 되는 첫 자리 규칙 이름(없으면 None).""" + if value is None: + return None + return next((name for name, f in RULES if abs(f(value) - shown) < EPS), None) + + +# ── 읽기(재귀 하강) ──────────────────────────────────────────────────── +class _Parser: + def __init__(self, toks: list[tuple]) -> None: + self.toks, self.at, self.depth = toks, 0, 0 + + def peek(self) -> tuple | None: + return self.toks[self.at] if self.at < len(self.toks) else None + + def take(self) -> tuple: + tok = self.peek() + if tok is None: + raise ExprError("식이 덜 끝남") + self.at += 1 + return tok + + def is_op(self, *ops: str) -> bool: + tok = self.peek() + return tok is not None and tok[0] == "연산" and tok[1] in ops + + def expr(self) -> tuple: + self.depth += 1 + if self.depth > MAX_DEPTH: + raise ExprError("괄호가 너무 깊음") + node = self.term() + while self.is_op("+", "-"): + op = self.take()[1] + node = ("셈", op, node, self.term()) + self.depth -= 1 + return node + + def term(self) -> tuple: + node = self.unary() + while True: + if self.is_op("*", "/"): + op = self.take()[1] + node = ("셈", op, node, self.unary()) + elif (tok := self.peek()) is not None and tok[0] == "%": + self.take() + node = ("셈", "/", node, ("수", 100.0, None)) + else: + return node + + def unary(self) -> tuple: + if self.is_op("-"): + self.take() + return ("음", self.unary()) + if self.is_op("+"): + self.take() + return self.unary() + return self.power() + + def power(self) -> tuple: + node = self.atom() + if self.is_op("^"): + self.take() + return ("셈", "^", node, self.unary()) + return node + + def atom(self) -> tuple: + at = self.at + tok = self.take() + kind = tok[0] + if kind == "수": + return ("수", tok[1], at) + if kind == "참조": + return ("참조",) + if kind == "끼움": + return ("끼움", tok[1], parse(tok[2])) + if kind == "(": + node = self.expr() + if self.take()[0] != ")": + raise ExprError("괄호 짝 없음") + return node + if kind == "이름": + name = tok[1].lower() + if name in FUNCS and (nxt := self.peek()) is not None and nxt[0] == "(": + self.take() + args = [self.expr()] + while (nxt := self.peek()) is not None and nxt[0] == ",": + self.take() + args.append(self.expr()) + if self.take()[0] != ")": + raise ExprError("함수 괄호 짝 없음") + return ("함수", name, args) + return ("이름", name) + raise ExprError(f"식 자리 아닌 토큰 {kind}") + + +def parse(toks: list[tuple]) -> tuple: + """토큰 → 나무(남는 토큰 · 빈 식 = 오류).""" + if not toks: + raise ExprError("빈 식") + p = _Parser(toks) + node = p.expr() + if p.peek() is not None: + raise ExprError(f"식 뒤 남은 토큰 {p.peek()[0]}") + return node + + +# ── 셈 ───────────────────────────────────────────────────────────────── +def _brackets(node: tuple, env: dict, seen: dict) -> None: + """끼움 `[A]` · `[Q=0.208]` 먼저(왼쪽부터 · 원문 치환 차례) — 값은 seen[id].""" + kind = node[0] + if kind == "끼움": + _brackets(node[2], env, seen) + value = calc(node[2], env, seen) + if node[1]: + env["V"][node[1].lower()] = value + seen[id(node)] = value + elif kind == "셈": + _brackets(node[2], env, seen) + _brackets(node[3], env, seen) + elif kind == "음": + _brackets(node[1], env, seen) + elif kind == "함수": + for arg in node[2]: + _brackets(arg, env, seen) + + +def calc(node: tuple, env: dict, seen: dict | None = None) -> float: + """나무 → 값 · env = {"V": 변수(소문자), "R": 참조 값, "sub": {토큰 차례: 새 값}}.""" + kind = node[0] + if kind == "오류": + raise ExprError(node[1]) + if kind == "수": + sub = env.get("sub") + return sub[node[2]] if sub and node[2] in sub else node[1] + if kind == "이름": + if node[1] not in env["V"]: + raise ExprError(f"미정의 변수 {node[1]}") + return env["V"][node[1]] + if kind == "참조": + return env.get("R", 1.0) + if kind == "끼움": + if seen is not None and id(node) in seen: + return seen[id(node)] + return calc(node[2], env, seen) + if kind == "음": + return -calc(node[1], env, seen) + if kind == "함수": + args = [calc(a, env, seen) for a in node[2]] + if node[1] == "int": + return float(int(args[0])) + return float(round(args[0], int(args[1])) if len(args) > 1 else round(args[0])) + a, b = calc(node[2], env, seen), calc(node[3], env, seen) + op = node[1] + if op == "+": + return a + b + if op == "-": + return a - b + if op == "*": + return a * b + if op == "^": + try: + return float(a**b) + except (OverflowError, ZeroDivisionError) as error: + raise ExprError("거듭제곱 오류") from error + if b == 0: + raise ExprError("0 나누기") + return a / b + + +def value(node: tuple, env: dict) -> float: + """끼움 먼저 · 그 다음 셈.""" + seen: dict = {} + _brackets(node, env, seen) + out = calc(node, env, seen) + if isinstance(out, complex): + raise ExprError("복소수") + return out + + +def numbers(node: tuple) -> list[tuple]: + """나무 속 수 마디(왼쪽부터).""" + kind = node[0] + if kind == "수": + return [node] if node[2] is not None else [] + if kind == "셈": + return numbers(node[2]) + numbers(node[3]) + if kind in ("음",): + return numbers(node[1]) + if kind == "끼움": + return numbers(node[2]) + if kind == "함수": + return [n for a in node[2] for n in numbers(a)] + return [] diff --git a/M02_MasterTemplete/M02_San_Stmate.py b/M02_MasterTemplete/M02_San_Stmate.py new file mode 100644 index 000000000..89cde2e67 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Stmate.py @@ -0,0 +1,191 @@ +"""STmate 산근 글 식 파서(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-B` 3 장 · eval 없음). + +단가산출근거 시트 숨은 G 열 원문 한 줄 → 줄 나무(dict). 셈은 `M02_San_Eval.run_stmate`. + +줄 꼴(`종류`): +- 빈 · 글 — 셈 없음(`'글'` · 단위 조각 · `// 덧글` 뺀 뒤 빈 것) +- 계 — `(=)`~`(=====)` 단 1~5 · `(-=)` 제외(위 합에서 뺌) · `(=):T01` 누계번지 +- 대입 — `X = 식` 전 자리 · `X = 식 =` 소수 2 자리 반올림 · 한 줄 여러 대입(`,`) · + 맨 식(끼움 `[A]` 만 먹음) +- 참조 — `&L..&` · `&M..&` · `&S..&` · `&X..L/M/S(:L/M/S)&` · `&D..&` · `&B..&` 단가 × 계수 식 + (특수 % 코드 L% · M% · T% 는 목록표 UNT 로 셀 때 가림) +- 율 — `&P{A01|A0102..|T01|C_|S_}{L|M|S|}:{L|M|S|}& × n'%'` 범위 금액 × n % · `&R..&` × n +- 공통 칸 — 숨김(`;;`) · 번지(`:=A01` · `:=T01`) · 빼기(`->` = 번지엔 쌓되 합에서 뺌) · + 찍기(끝 `=` 또는 번지) · 식(계수 나무 · `("참조",)` = 단가 자리 1) · 오류(읽기 실패 까닭) +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_San_Expr as ex + +REF = re.compile(r"&([A-Z]+)([^&]*)&") +_TOK = re.compile(r"\s*(?:(\d+\.?\d*|\.\d+)|([A-Za-z][A-Za-z0-9_]*)|(@R)|(.))") +_TOTAL = re.compile(r"^\((-?)(=+)\)(?::(T\d+))?") +_NAME_EQ = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_]*\s*=(?!=)") +_ASSIGN = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*=(?!=)(.*)$") +_ADDR = re.compile(r":=\s*([AT]\d+)\s*$") +_RATE_TAIL = re.compile(r"^(\d*|_)([LMS]?)(?::([LMS]?))?$") +_CODE_TAIL = re.compile(r"^([LMS]?)(?::([LMS]))?$") +RATE_KINDS = ("PA", "PT", "PC", "PS", "RA", "RT", "RC", "RS") +OPS = "+-*/^" + + +def tokens(text: str) -> list[tuple]: + """STmate 식 글 → 토큰(끼움 `[..]` 안은 다시 토큰) · 모르는 기호 = ExprError.""" + out: list[tuple] = [] + at = 0 + while at < len(text): + if text[at].isspace(): + at += 1 + continue + if text[at] == "[": + end = text.find("]", at) + if end < 0: + raise ex.ExprError("끼움 괄호 짝 없음") + inner = text[at + 1 : end] + if "=" in inner: + name, body = inner.split("=", 1) + out.append(("끼움", name.strip(), tokens(body))) + else: + out.append(("끼움", None, tokens(inner))) + at = end + 1 + continue + m = _TOK.match(text, at) + if not m or m.end() == at: + break + at = m.end() + num, name, ref, other = m.groups() + if num: + out.append(("수", float(num))) + elif name: + out.append(("이름", name)) + elif ref: + out.append(("참조",)) + elif other and other.strip(): + if other in OPS: + out.append(("연산", other)) + elif other in "(),": + out.append((other,)) + else: + raise ex.ExprError(f"모르는 기호 {other!r}") + return out + + +def expr(text: str) -> tuple: + """식 글 → 나무 · 실패 = `("오류", 까닭)`(셀 때 터짐 — 측정 도구와 같은 차례).""" + try: + return ex.parse(tokens(text)) + except ex.ExprError as error: + return ("오류", str(error)) + + +def split_top(text: str, sep: str = ",") -> list[str]: + """괄호 밖 `sep` 로 나눔.""" + out, depth, cur = [], 0, "" + for ch in text: + if ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + if ch == sep and depth == 0: + out.append(cur) + cur = "" + else: + cur += ch + out.append(cur) + return out + + +def _clean(g: str) -> tuple[str, bool, str | None]: + """원문 → (셈 글, 숨김, 덧글) — `//` 뒤 덧글 · `;;` 숨은 줄 · `'글'` 지움.""" + raw, _, note = g.partition("//") + hidden = raw.lstrip().startswith(";;") + s = raw.lstrip()[2:] if hidden else raw + s = re.sub(r"'[^']*'", " ", s).replace("'", " ") + return s.strip(), hidden, (note.strip() or None) if _ else None + + +def _ref_line(st: str, m: re.Match) -> dict[str, Any]: + kind, tail = m.group(1), m.group(2) + pre = _NAME_EQ.sub(" ", st[: m.start()]) + body = pre + " @R " + st[m.end() :] + out: dict[str, Any] = {"빼기": "->" in body} + body = body.replace("->", " ") + addr = _ADDR.search(body) + out["번지"] = addr.group(1) if addr else None + if addr: + body = body[: addr.start()] + out["찍기"] = body.rstrip().endswith("=") or bool(addr) + out["식"] = expr(body.rstrip().rstrip("=")) + if kind in RATE_KINDS: + mt = _RATE_TAIL.match(tail) + if not mt: + return {**out, "종류": "율", "오류": f"율 꼬리 {tail}"} + num = mt.group(1) + area = kind[1] + if area == "A": + spots = ["A" + num[i : i + 2] for i in range(0, len(num), 2)] + elif area == "T": + spots = ["T" + num] + else: + spots = [] + rate = {"곱": kind[0] == "R", "범위": area, "번지": spots} + rate |= {"비목": mt.group(2) or None, "옮김": mt.group(3) or None} + return {**out, "종류": "율", "율": rate} + digits = re.match(r"^\d*", tail).group(0) + mc = _CODE_TAIL.match(tail[len(digits) :]) + if not mc: + return {**out, "종류": "참조", "오류": f"참조 꼬리 {tail[len(digits) :]}"} + src = {"코드": kind + digits, "비목": mc.group(1) or None, "옮김": mc.group(2)} + return {**out, "종류": "참조", "자원": src} + + +def _assign_line(st: str) -> dict[str, Any]: + parts: list[dict[str, Any]] = [] + for part in split_top(st): + p = part.strip() + if not p: + continue + ma = _ASSIGN.match(p) + if ma: + body = ma.group(2).strip() + show = body.endswith("=") + body = body.rstrip("=").strip() + if body: + parts.append({"이름": ma.group(1).lower(), "식": expr(body), "찍기": show}) + else: + node = expr(p.rstrip("=").strip()) + if node[0] not in ("오류", "이름"): # 맨 낱말 · 못 읽는 글 = 셈 없음 + parts.append({"이름": None, "식": node, "찍기": False}) + if not parts: + return {"종류": "글"} + return {"종류": "대입", "대입": parts} + + +def parse_line(g: str | None) -> dict[str, Any]: + """G 열 원문 한 줄 → 줄 나무.""" + st, hidden, note = _clean(g or "") + head = {"숨김": hidden, "덧글": note} + tot = _TOTAL.match(st.replace(" ", "")) + if tot: + return {**head, "종류": "계", "단": len(tot.group(2)), "제외": tot.group(1) == "-"} | { + "누계번지": tot.group(3) + } + if not st: + return {**head, "종류": "빈"} + refs = list(REF.finditer(st)) + if len(refs) > 1: + return {**head, "종류": "참조", "오류": "참조 둘 이상", "식": ("오류", "참조 둘 이상")} + if refs: + return {**head, **_ref_line(st, refs[0])} + return {**head, **_assign_line(st)} + + +def parse_block(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + """[{행, G}] → 줄 나무 목록(행 · 원문 붙임).""" + return [ + {"행": ln.get("행"), "글": ln.get("G") or "", **parse_line(ln.get("G"))} for ln in lines + ] diff --git a/resources/tester/test_793_1_san_parse.py b/resources/tester/test_793_1_san_parse.py new file mode 100644 index 000000000..05d59e602 --- /dev/null +++ b/resources/tester/test_793_1_san_parse.py @@ -0,0 +1,171 @@ +"""793-1 지킴 — 경쟁사 산근 글 식 파서 · 참조 평가기(`M02_San_*`) · 실무 여덟 파일 금액 그대로. + +STmate = 숨은 G 열 원문 식 → 233/233 블록 · 금액 줄 1,315 · 계표시 줄 695 다 맞음. +EST = 보이는 글 → 식 복원 → 222/222 블록 · 돈 · 값 줄 2,901/2,903 · 모호 단가 토큰 9. +단가 바뀜 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000). +엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만) · 없으면 skip(다른 PC). +""" + +from __future__ import annotations + +from functools import lru_cache + +import helper_m02_hopyo_golden as golden +import pytest + +from M02_MasterTemplete import M02_San_Book as book +from M02_MasterTemplete import M02_San_Est as est +from M02_MasterTemplete import M02_San_Eval as ev +from M02_MasterTemplete import M02_San_Stmate as stm + +# 파일: (블록, 금액 줄, 계표시 줄, 끝전) +STMATE = { + "봉화": (62, 349, 184, "비목버림"), + "동부청영월": (69, 411, 230, "합버림_큰나머지"), + "울진대흥": (47, 261, 128, "비목버림"), + "영덕계류": (15, 86, 41, "비목버림"), + "울진소광": (40, 208, 112, "비목버림"), +} +# 파일: (블록, 돈 · 값 줄, 틀린 줄 상한, 모호 토큰) +EST = {"북부청임도": (94, 1252, 2, 0), "북부청사방": (67, 849, 0, 7), "거창": (61, 802, 0, 2)} +LABOR = ("노임", "보통인부", "", "L") + + +@lru_cache(maxsize=8) +def _load(tag: str) -> dict: + path = golden.FILES[tag] + if not path.exists(): + pytest.skip(f"실무 엑셀 없음 {path.name}") + return book.load(path) + + +def _stmate_runs(tag: str) -> list[tuple]: + data = _load(tag) + out = [] + for blk in data["블록"]: + if not any(ln["G"].strip() for ln in blk["몸"]): + continue + res = ev.stmate_block(blk, stm.parse_block(blk["몸"]), data["단가"], STMATE[tag][3]) + out.append((blk, res, ev.stmate_check(blk, res))) + return out + + +@pytest.mark.parametrize("tag", list(STMATE)) +def test_stmate_blocks_equal_excel(tag: str) -> None: + blocks, amount_lines, total_lines, _ = STMATE[tag] + runs = _stmate_runs(tag) + assert len(runs) == blocks + wrong = [b["코드"] for b, _, c in runs if not c["맞음"]] + assert not wrong, wrong + assert sum(c["금액줄"] for _, _, c in runs) == amount_lines + assert [x for _, _, c in runs for x in c["틀린줄"]] == [] + assert sum(c["계줄"] for _, _, c in runs) == total_lines + assert sum(c["계줄맞음"] for _, _, c in runs) == total_lines + + +@pytest.mark.parametrize("tag", list(EST)) +def test_est_blocks_equal_excel(tag: str) -> None: + blocks, lines, bad_max, vague = EST[tag] + data = _load(tag) + n, wrong, got_lines, got_bad, got_vague = 0, [], 0, 0, 0 + for blk in data["블록"]: + want = data["총괄"].get(blk["번"]) + if not want or not (want["T"] or any(want[c] for c in "LMS")): + continue + parsed = est.parse_block(blk["글"], data["단가"]) + chk = ev.est_check(parsed, want) + n += 1 + if not chk["맞음"]: + wrong.append(blk["번"]) + got_lines += chk["줄수"] + got_bad += len(chk["틀린줄"]) + got_vague += sum(1 for L in parsed for r in (L.get("역할") or {}).values() if r.get("모호")) + assert (n, wrong) == (blocks, []) + assert got_lines == lines + assert got_bad <= bad_max + assert got_vague == vague + + +@pytest.mark.parametrize( + "tag, code, labor, before, after", + [ + ("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)), + ("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)), + ("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)), + ], +) +def test_stmate_price_propagates(tag, code, labor, before, after) -> None: + data = _load(tag) + blk = next(b for b in data["블록"] if b["코드"] == code) + nodes = stm.parse_block(blk["몸"]) + rule = STMATE[tag][3] + prices = dict(data["단가"]) + prices[labor] = {**prices[labor], "T": 180000, "L": 180000} + for p, want in ((data["단가"], before), (prices, after)): + got = ev.stmate_block(blk, nodes, p, rule)["계"] + assert (got["L"], got["M"], got["S"]) == want + + +@pytest.mark.parametrize( + "tag, no, after", + [ + ("북부청사방", 31, (14877, 5491, 0)), + ("북부청임도", 2, (22774, 3235, 3830)), + ("거창", 1, (3485, 832, 1049)), + ("북부청사방", 3, (20147, 391, 0)), + ], +) +def test_est_price_propagates(tag, no, after) -> None: + data = _load(tag) + blk = next(b for b in data["블록"] if b["번"] == no) + parsed = est.parse_block(blk["글"], data["단가"]) + des = ev.designate(parsed, data["총괄"][no]) + got = ev.est_block(parsed, des, {LABOR: 180000.0})["금액"] + assert (got["L"], got["M"], got["S"]) == after + + +# ── 엑셀 없이 도는 문법 견본(`_분석_648-B` 3-1 봉화 D00102) ── +def test_stmate_grammar_sample() -> None: + prices = { + "M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}}, + "L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}}, + "L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}}, + } + text = [ + "'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01", + ";;Q = 2.5 / 3 =", + "'보통인부' &L00007& * [Q] / 4 :=A02", + "&PA01M:M& * 11'%' =", + "&PA02L:S& * 1'%' = // 공구손료", + "(=)", + "&L00099& * 2 =", + "(-=)", + ] + nodes = stm.parse_block([{"행": i, "G": g} for i, g in enumerate(text)]) + assert [n["종류"] for n in nodes] == ["참조", "대입", "참조", "율", "율", "계", "참조", "계"] + assert nodes[1]["숨김"] and nodes[4]["덧글"] == "공구손료" + assert nodes[3]["율"] == {"곱": False, "범위": "A", "번지": ["A01"], "비목": "M", "옮김": "M"} + lines, tot = ev.run_stmate(nodes, prices) + assert lines[0]["값"]["M"] == 3862.5 + assert lines[2]["값"]["L"] == 34350.5 # 165,545 × 0.83(Q 반올림) / 4 → 0.1 버림 + assert lines[3]["값"]["M"] == 424.8 # A01 재료 3,862.5 × 11 % + assert lines[4]["값"]["S"] == 343.5 # A02 노무 34,350.5 × 1 % → 경비 + assert lines[5]["값"] == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5}) + assert lines[6]["종류"] == "특수" and lines[6]["값"]["L"] == 687.0 # 노무 누계 × 2 % + assert lines[7]["값"]["L"] == 687.0 # (-=) 찍되 합에서 뺌 + assert tot == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5}) + + +def test_est_grammar_sample() -> None: + prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}] + text = [ + "보통인부 : 172,068 × 0.09 인 = 15,486.1", + "공구손료 : 15,486.1 × 3 % = 464.5", + "소 계 : 15,950.6", + "노무비 : 15,950.6 × 1 = 15,950", + ] + lines = est.parse_block(text, prices) + assert [L.get("종류") for L in lines] == ["단가줄", "참조줄", "값줄", "참조줄"] + assert lines[3]["규칙"] == "버림0" and lines[2]["숨은"]["갈래"] == "합" + val = ev.run_est(lines, {LABOR: 180000.0}) + assert val[0] == 16200.0 and val[1] == 486.0 and val[3] == 16686.0 From 19ac55f9c6b613fe68ba45d66c4d529701986831 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:03:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(790-2):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=88=AB=EC=9E=90=20=EB=84=93=ED=9E=98=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=E2=80=94=20=EC=83=81=EC=88=98=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=EA=B0=92=20=EB=8D=AE=EA=B0=9C=20=C2=B7=20=EA=B8=80=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EA=B9=8C=EB=8B=AD=20=C2=B7=20M01=20=EC=9B=90=EB=9E=98?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20M01=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Sangeun_Const.py — 식 n 번째 숫자 · 표 찾기 덮개(엔진 파서 차례 · 식 구조 그대로라 옵션 따라감) · 글줄 · 까닭 꼴 검사 · 저장 때 M01판(자리마다 지문) · 셈 답 덧칸 원래 {노 · 재 · 경 · 계} · 시트 {자리 · 글줄 · 짝잃음} - Doc.patched 첫머리 · clean 끝 · Calc.clean_doc · calc_doc 한 줄씩 끼움(줄 수 그대로) — 화면 · 저장 원값 · B09 · 펼침 겹침 한 벌 - Library_Flat — 상수 · 표값 덮개 SK 는 안 펼침(참조 그대로 · 값 그대로) · [초기화]가 새 덮개 · 까닭도 걷음 - 새 logic/M02_Sangeun_Const_Model.ts — 캐시 고치기(setConst · setTable · 글줄 · setReason · restoreM01 · dropAddKeys · coverKinds) · 파이썬 짝 거울 시험 - 새 test_790_2_sangeun_const.py — 견본 금액 = _분석_650-H2(61,088 · 51,802 · 38,696 · 35,764) · 옛 SK 꼴 · 금액 그대로 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Library_Flat.py | 7 +- M02_MasterTemplete/M02_Sangeun_Calc.py | 6 +- M02_MasterTemplete/M02_Sangeun_Const.py | 404 ++++++++++++++++++ M02_MasterTemplete/M02_Sangeun_Doc.py | 14 +- .../logic/M02_Sangeun_Const_Model.ts | 192 +++++++++ resources/tester/test_790_2_sangeun_const.py | 318 ++++++++++++++ 6 files changed, 929 insertions(+), 12 deletions(-) create mode 100644 M02_MasterTemplete/M02_Sangeun_Const.py create mode 100644 M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts create mode 100644 resources/tester/test_790_2_sangeun_const.py diff --git a/M02_MasterTemplete/M02_Library_Flat.py b/M02_MasterTemplete/M02_Library_Flat.py index c1713b8fd..0451d58fe 100644 --- a/M02_MasterTemplete/M02_Library_Flat.py +++ b/M02_MasterTemplete/M02_Library_Flat.py @@ -35,6 +35,7 @@ from typing import Any from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Const as const from M02_MasterTemplete import M02_Sangeun_Doc as sd ADD = re.compile(r"^add:#(\d+)$") @@ -72,6 +73,7 @@ def _read(ref: refs.Ref, read: Read, trail: tuple) -> tuple[dict, tuple]: got = read(ref) if not isinstance(got, dict): raise sd.DocError(f"없는 참조 「{ref.get('키')}」") + const.no_flat(got) # 상수 · 표값 덮개 SK = 안 펼침(790-2) return got, (*trail, rid) @@ -256,6 +258,7 @@ def _cover_add(add: dict, cover: dict) -> dict: def compose(under: dict, over: dict, whole: Any) -> dict: """펼친 바탕 SK `under` 위에 `over`(덮개 · 뺀줄 · 고름 · 자재표시 · 더한줄 · 입력값) → 한 벌 — 위 줄 정체를 아래 바탕 정체로 옮김(`patched` 의 `정체` 짝) · 짝 잃은 위 고침은 버림(값 없음).""" + const.no_flat(over) # 위 상수 · 표값 덮개 자리 = 아래 정체로 못 옮김(790-2) base = under.get("바탕") row = whole.logic(base["로직"]) if (base or {}).get("로직") else sd.empty_row(under) got = sd.patched(row, under) @@ -522,8 +525,8 @@ def reset(kind: str, doc: dict) -> tuple[dict, int]: doc = flat_doc(kind, doc) if kind == "sangeun": adds, n = _reset_adds(doc.get("더한줄") or []) - n += len(doc.get("덮개") or {}) + len(doc.get("뺀줄") or []) - return {**doc, "덮개": {}, "뺀줄": [], "더한줄": adds}, n + n += const.covers_n(doc) # 790-2 상수 · 표값 덮개도 걷음(까닭 같이) + return {**const.restore(doc), "더한줄": adds}, n return _reset_sc(doc) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index bbe1a3757..bd9a087ea 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -36,7 +36,7 @@ from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Views as views COSTS = labor.COSTS @@ -536,7 +536,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: for cid, one in out["덮개"].items(): if cid in was: one["원값"] = _was(was[cid]) - out["바탕판"] = fingerprint(base["줄"]) + out |= const.stamp(base["줄"]) # 바탕판 · M01판(790-2) ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2) _stamp_adds(out, base, whole) except sd.DocError as e: @@ -579,7 +579,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) except sd.DocError as e: raise _fail(e) from e - return labor._json({**views.finish(per_unit(got, clean)), "머리": head_of(clean)}) + return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2) def from_line( diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py new file mode 100644 index 000000000..07f63a82d --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -0,0 +1,404 @@ +"""M02 단가산출(SK) 숫자 넓힘(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — 새 덮개 갈래 · 시트 답. + +짝 = `logic/M02_Sangeun_Const_Model.ts`(캐시 고치기 · M01 원래로 · 까닭 · 더한줄 열쇠 옮김 · +같은 꼴) · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`. + +문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 지문 그대로): + 상수덮개 {자리: {n: 값}} 자리 식의 n 번째 숫자 상수(0 부터 · 엔진 파서 차례 · 찾기 머리 · 키 · + 따옴표 글 안 셈) → 값 · 식 구조 그대로(옵션 바꾸면 나머지 따라감) + 표값덮개 {자리: {n: 값}} 자리 식의 n 번째 표 찾기 값(`찾기(…).칸`) → 값 · 그 줄만 표 연결 끊김 + 글줄 [{자리, 글}] 셈 없는 글 줄(근거 · ※ 조건) · 자리 뒤에 찍힘("" = 맨 위) + 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다(예정가격작성기준 제2조 ②) + M01판 {자리: 지문} 저장 때 서버가 바탕 줄마다 적음(`stamp` · `sd.clean` 은 버림 = + 바탕판처럼) · 셈 때 받은 값과 지금 바탕이 다르면 「M01바뀜」 +자리 = 바탕 줄 cid(호표 · 덧줄 `kind:키#n`) · 중간 줄 `mid:이름` · 열쇠 = 자리 · `add:#k` · +`add:#k/세부 cid` · 식 칸 = 중간 「식」 · 호표 「수량」 · 덧줄 「식」. +셈 = `M02_Sangeun_Doc.patched` 첫머리 `formulas`(바탕 줄 식을 바꾼 사본) → 모든 셈 길(화면 · 저장 +원값 · B09 · 펼침 겹침) 한 벌 · 펼침(`M02_Library_Flat._read`) = 새 덮개 SK 는 안 펼침 +(옛 참조 그대로) · [초기화](`M02_Library_Flat.reset`) = `restore` 전체. +화면 셈 답(`answer`) 덧칸 `원래` {노무비, 재료비, 경비, 계}(1 단위) + `시트` {자리, 글줄, 짝잃음} · +M01 원래 = 같은 옵션 · 덮개 · 뺀줄 · 더한줄 · 새 덮개 없는 셈. +""" + +from __future__ import annotations + +import functools +import hashlib +import json +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +NEW = ("상수덮개", "표값덮개", "글줄", "까닭") +_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$") +_KEY = re.compile(r"^(mid:.+|(add:#\d+/)?(labor|material|machine|logic|pct):.*#\d+|add:#\d+)$") +_ADD = re.compile(r"^add:#(\d+)(/.*)?$") +FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"} +LONG = 500 # 글 · 까닭 길이 상한 +MANY = 200 # 글줄 수 상한 + + +# ── 식 속 자리 ──────────────────────────────────────────────────────── +@functools.cache +def _parser() -> type: + """엔진 파서(`master_formula._Parser`) + 숫자 · 표 찾기 글자 자리 기록(엔진과 같은 차례).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + class Spots(labor.m01.cm.mf._Parser): + def __init__(self, text: str) -> None: + super().__init__(text) + self.spots: list[tuple[str, int, int]] = [] + + def primary(self): # noqa: ANN202 + self.ws() + at = self.i + node = super().primary() + if node[0] == "num": + self.spots.append(("num", at, self.i)) + return node + + def unary(self): # noqa: ANN202 + self.ws() + at = self.i + node = super().unary() + inner = node + while inner[0] == "field": + inner = inner[1] + if node[0] == "field" and inner[0] == "find": + self.spots.append(("find", at, self.i)) + return node + + return Spots + + +@functools.lru_cache(maxsize=4096) +def scan(text: str) -> dict[str, list[tuple[int, int]]]: + """식 글 → {num: [(처음,끝)], find: [(처음, 끝)]}(왼쪽부터) · 못 읽는 식 = 빈 목록.""" + p = _parser()(text) + try: + p.parse() + except Exception: # noqa: BLE001 — 엔진 FormulaError · 깊은 괄호 + return {"num": [], "find": []} + spots = [(t, a, a + len(text[a:b].rstrip())) for t, a, b in p.spots] # 뒤 빈칸(`.` 엿봄) 뗌 + return {k: sorted((a, b) for t, a, b in spots if t == k) for k in ("num", "find")} + + +def _lit(value: Any) -> str: + got = sd.text(value) + return f"({got})" if got.startswith("-") else got + + +def swap(text: str, nums: dict, finds: dict) -> str: + """식 글의 n 번째 숫자 · 표 찾기를 값으로(원래 식 차례 · 바꾼 표 찾기 안 숫자는 버림).""" + spots = scan(text) + cuts = [(*spots["find"][int(n)], v) for n, v in finds.items() if int(n) < len(spots["find"])] + for n, v in nums.items(): + if int(n) < len(spots["num"]): + a, b = spots["num"][int(n)] + if not any(x <= a and b <= y for x, y, _ in cuts): + cuts.append((a, b, v)) + for a, b, v in sorted(cuts, reverse=True): + text = text[:a] + _lit(v) + text[b:] + return text + + +def places(row: dict) -> dict[str, tuple[str, int]]: + """자리 → (갈래, 차례) — 중간 `mid:이름` · 호표 · 덧줄 cid.""" + out = {f"mid:{s.get('이름')}": ("중간", at) for at, s in enumerate(row.get("중간") or [])} + n = len(row.get("호표") or []) + for at, cid in enumerate(sd.line_ids(row)): + out[cid] = ("호표", at) if at < n else ("덧줄", at - n) + return out + + +def formulas(row: dict, doc: dict) -> dict: + """바탕 줄 + 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수).""" + nums, finds = doc.get("상수덮개") or {}, doc.get("표값덮개") or {} + if not nums and not finds: + return row + out = {**row, **{k: list(row.get(k) or []) for k in FIELD}} + for spot, (part, at) in places(row).items(): + if spot in nums or spot in finds: + line = out[part][at] = dict(out[part][at]) + field = FIELD[part] + line[field] = swap(str(line.get(field)), nums.get(spot) or {}, finds.get(spot) or {}) + return out + + +def lost(row: dict, doc: dict) -> list[dict]: + """짝 잃은 새 덮개 — 바탕에 없는 자리 · 없는 n 번째.""" + where, out = places(row), [] + for name, kind in (("상수덮개", "num"), ("표값덮개", "find")): + for spot, cover in (doc.get(name) or {}).items(): + if spot not in where: + out.append({"cid": spot, "까닭": f"{name} — 바탕에 없는 줄"}) + continue + part, at = where[spot] + have = len(scan(str((row[part][at] or {}).get(FIELD[part])))[kind]) + out += [ + {"cid": spot, "까닭": f"{name} — {int(n) + 1} 번째 없음"} + for n in cover + if int(n) >= have + ] + return out + + +def no_flat(doc: Any) -> None: + """펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로).""" + if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개")): + raise sd.DocError("상수 · 표값 덮개 단가산출은 펼치지 않음(참조 그대로)") + + +def covers_n(doc: dict) -> int: + """[초기화] 걷힐 고침 수 — 덮개 · 뺀줄 · 상수 · 표값 덮개 칸.""" + keys = ("덮개", "뺀줄", "상수덮개", "표값덮개") + return sum(len(doc.get(k) or ()) for k in keys) + + +# ── 문서 꼴 ────────────────────────────────────────────────────────── +def _key(raw: Any, where: str, pat: re.Pattern = _SPOT) -> str: + if not isinstance(raw, str) or not pat.match(raw): + raise sd.DocError(f"{where} · 자리 꼴 아님 「{raw}」") + return raw + + +def _words(raw: Any, where: str) -> str: + got = str(raw or "").strip() + if len(got) > LONG: + raise sd.DocError(f"{where} · 글이 {LONG} 자를 넘음") + return got + + +def _covers(raw: Any, name: str) -> dict: + if raw in (None, {}): + return {} + if not isinstance(raw, dict): + raise sd.DocError(f"{name} · 묶음 아님") + out = {} + for spot, one in raw.items(): + where = f"{name} 「{spot}」" + if not isinstance(one, dict): + raise sd.DocError(f"{where} · {{n: 값}} 아님") + got = {} + for n, v in one.items(): + if not re.fullmatch(r"\d{1,3}", str(n)): + raise sd.DocError(f"{where} · n 은 0 부터 수 「{n}」") + got[str(int(n))] = sd.plain(sd._num(v, f"{where} {n}")) + if got: + out[_key(spot, name)] = got + return out + + +def clean_more(raw: Any, out: dict) -> dict: + """`sd.clean` 끝 — 새 덮개 칸 검사 · 빈 칸은 안 둠(`M01판` · 원값은 서버 몫이라 버림).""" + raw = raw if isinstance(raw, dict) else {} + for name in ("상수덮개", "표값덮개"): + if got := _covers(raw.get(name), name): + out[name] = got + texts = raw.get("글줄") or [] + if not isinstance(texts, list) or len(texts) > MANY: + raise sd.DocError(f"글줄 · {MANY} 줄까지 목록") + lines = [] + for at, one in enumerate(texts, 1): + one = one if isinstance(one, dict) else {} + spot = str(one.get("자리") or "") + words = _words(one.get("글"), f"글줄 {at}째") + if words: + lines.append({"자리": spot and _key(spot, f"글줄 {at}째", _KEY), "글": words}) + if lines: + out["글줄"] = lines + why = raw.get("까닭") or {} + if not isinstance(why, dict): + raise sd.DocError("까닭 · 묶음 아님") + why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()} + if why := {k: v for k, v in why.items() if v}: + out["까닭"] = why + return out + + +def _print(one: Any) -> str: + raw = json.dumps(one, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12] + + +def stamp(row: dict) -> dict[str, Any]: + """저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문).""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])] + spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row) + return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))} + + +# ── 고치기(짝 = Const_Model.ts) ────────────────────────────────────── +def restore(doc: dict, key: str | None = None) -> dict: + """[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 지움(더한줄 · 글줄 · + 옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부 `add:#k/cid` = + 그 더한줄 세부).""" + out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "까닭")} + why = dict(doc.get("까닭") or {}) + if key is None: + out |= {"덮개": {}, "뺀줄": []} + why = {k: v for k, v in why.items() if _top_add(k)} + return out | ({"까닭": why} if why else {}) + hit = _ADD.match(key) + if hit and hit[2]: + k, sub = int(hit[1]), hit[2][1:] + adds = [dict(x) for x in doc.get("더한줄") or []] + if k < len(adds): + one = {n: v for n, v in adds[k].items() if n not in ("덮개", "뺀줄")} + covers = {c: v for c, v in (adds[k].get("덮개") or {}).items() if c != sub} + drop = [c for c in adds[k].get("뺀줄") or [] if c != sub] + adds[k] = one | ({"덮개": covers} if covers else {}) | ({"뺀줄": drop} if drop else {}) + out["더한줄"] = adds + else: + out["덮개"] = {c: v for c, v in (doc.get("덮개") or {}).items() if c != key} + out["뺀줄"] = [c for c in doc.get("뺀줄") or [] if c != key] + for name in ("상수덮개", "표값덮개"): + if got := {s: v for s, v in (doc.get(name) or {}).items() if s != key}: + out[name] = got + why.pop(key, None) + return out | ({"까닭": why} if why else {}) + + +def _top_add(key: str) -> bool: + hit = _ADD.match(key) + return bool(hit) and not hit[2] + + +def drop_add_keys(doc: dict, k: int) -> dict: + """더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 를 한 칸 당김 · k 는 지움.""" + + def move(key: str) -> str | None: + hit = _ADD.match(key) + if not hit: + return key + j = int(hit[1]) + return None if j == k else f"add:#{j - 1 if j > k else j}{hit[2] or ''}" + + out = dict(doc) + if "까닭" in doc: + why = {move(x): v for x, v in doc["까닭"].items() if move(x)} + out.pop("까닭") + out |= {"까닭": why} if why else {} + if "글줄" in doc: + out["글줄"] = [{**t, "자리": move(t["자리"]) or ""} for t in doc["글줄"]] + return out + + +def kinds(doc: dict, spot: str) -> list[str]: + """자리의 덮개 갈래 — 품 · 단가 · 상수 · 표값 · 뺌(차례 그대로).""" + cover, out = (doc.get("덮개") or {}).get(spot) or {}, [] + if "qty" in cover or "배율" in cover: + out.append("품") + if any(k in cover for k in sd.PARTS): + out.append("단가") + out += [ + n for n, k in (("상수", "상수덮개"), ("표값", "표값덮개")) if spot in (doc.get(k) or {}) + ] + return out + (["뺌"] if spot in (doc.get("뺀줄") or []) else []) + + +# ── 화면 셈 답 ──────────────────────────────────────────────────────── +def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> dict: + """`M02_Sangeun_Calc.calc_doc` 답 — 옛 답 + `원래` · `시트`.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + out = {**calc.views.finish(calc.per_unit(got, clean)), "머리": calc.head_of(clean)} + return calc.labor._json(out | sheet(out, clean, raw, given, trail)) + + +def _bare(doc: dict) -> dict: + out = {k: v for k, v in doc.items() if k not in (*NEW, "뺀더한줄")} + return out | {"덮개": {}, "뺀줄": [], "더한줄": []} + + +def _mids(whole: Any, key: str, row: dict, values: dict) -> tuple[dict, dict]: + """(중간 값, 엔진 입력) — 시험 계산과 같은 길(`M02_Sangeun_Views.text_of`) · 못 셈 = 빈.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + over = calc._Over(whole, key, {**row, "키": key}) + m01 = calc.labor.m01 + auto = {k: v for k, v in m01.mau.values(over, over.logic(key)).items() if v is not None} + given = {**auto, **m01._dec(values or {})} + try: + mid = calc.labor.mcut.engine(over, key, m01.cm.mf.run(over, key, given)).get("중간") or {} + except Exception: # noqa: BLE001 — 엔진 FormulaError · 셈 틀림 = 값 없음 + return {}, given + return mid, given + + +def _finds(whole: Any, text: str, env: dict) -> list[dict]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + mf, out = labor.m01.cm.mf, [] + for n, (a, b) in enumerate(scan(text)["find"]): + try: + value = mf.evaluate(mf.parse(text[a:b]), dict(env), whole) + except Exception: # noqa: BLE001 + value = None + out.append({"n": n, "글": text[a:b], "값": value}) + return out + + +def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> dict: + """`원래` · `시트` — 자리마다 원래 · 지금 · 덮개 갈래 · 까닭 · M01바뀜 · 상수 · 표값.""" + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + whole = calc.labor.m01.loaded()[1] + try: + base = calc._base_of(clean, whole, refs.read_ref, trail)["줄"] + bare = calc.per_unit(calc.run(_bare(clean), whole, given, trail=trail), clean) + except sd.DocError: + return {"원래": None, "시트": {"자리": {}, "글줄": clean.get("글줄") or [], "짝잃음": []}} + was = {x["cid"]: x for x in bare.get("줄") or [] if x.get("cid")} + now = {x["cid"]: x for x in ans.get("줄") or [] if x.get("cid")} + key = str(clean.get("키") or "SK-000000") + values = {**(clean.get("입력값") or {}), **(given or {})} + mids0, env0 = _mids(whole, key, base, values) + mids1, _ = _mids(whole, key, formulas(base, clean), values) + stamps = (raw.get("M01판") if isinstance(raw, dict) else None) or {} + fresh = stamp(base)["M01판"] + why = clean.get("까닭") or {} + nums, finds = clean.get("상수덮개") or {}, clean.get("표값덮개") or {} + spots: dict[str, dict] = {} + for spot, (part, at) in places(base).items(): + line = base[part][at] + text = str(line.get(FIELD[part])) + consts = [ + {"n": n, "값": nums.get(spot, {}).get(str(n), v), "원값": v} + for n, v in enumerate(Decimal(text[a:b]) for a, b in scan(text)["num"]) + ] + tables = [ + {**t, "원값": t["값"], "값": finds.get(spot, {}).get(str(t["n"]), t["값"])} + for t in _finds(whole, text, {**env0, **mids0}) + ] + if part == "중간": + name = str(line.get("이름")) + old, cur = mids0.get(name), mids1.get(name) + else: + old, cur = (was.get(spot) or {}).get("금액"), (now.get(spot) or {}).get("금액") + spots[spot] = { + "갈래": part, + "이름": line.get("이름") or "", + "식": text, + "상수": consts, + "표값": tables, + "원래": old, + "지금": cur, + "덮개": kinds(clean, spot), + "까닭": why.get(spot), + "M01바뀜": spot in stamps and stamps[spot] != fresh.get(spot), + } + for cid, line in now.items(): + if _ADD.match(cid) and cid not in spots: + spots[cid] = {"갈래": "더한줄", "이름": line.get("이름") or "", "원래": None} + spots[cid] |= {"지금": line.get("금액"), "덮개": [], "까닭": why.get(cid)} + gone = lost(base, clean) + [ + {"cid": s, "까닭": "M01 바뀜 — 바탕에 없는 줄"} for s in stamps if s not in fresh + ] + top = {"글줄": clean.get("글줄") or [], "짝잃음": gone} + return {"원래": bare.get("기준"), "시트": {"자리": spots, **top}} diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 71473929e..e01857fc5 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -36,6 +36,8 @@ import re from decimal import Decimal, InvalidOperation from typing import Any +from M02_MasterTemplete import M02_Sangeun_Const as const + KIND = "sangeun" TITLE = "단가산출" PREFIX = "SK" @@ -342,13 +344,11 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str _added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1) ] _rate_keys(out["더한줄"]) - drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []] + out["뺀줄"] = drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []] if len(set(drop)) != len(drop): raise DocError("뺀줄에 같은 줄 두 번") - out["뺀줄"] = drop - if gone := _gone_adds(doc.get("뺀더한줄"), whole): - out["뺀더한줄"] = gone - return out + out |= {"뺀더한줄": gone} if (gone := _gone_adds(doc.get("뺀더한줄"), whole)) else {} + return const.clean_more(doc, out) # 새 덮개 칸(790-2) def is_rate(one: Any) -> bool: @@ -460,11 +460,11 @@ def patched( `picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김. 돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`], 짝잃음 [{cid, 까닭}]}.""" + row = const.formulas(row, doc) # 상수 · 표값 덮개(790-2) = 식 바꾼 바탕 items, extras = list(row.get("호표") or []), list(row.get("덧줄") or []) ids = line_ids(row) covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or []) - mine = doc.get("고름") or {} - marks = doc.get("자재표시") or {} + mine, marks = doc.get("고름") or {}, doc.get("자재표시") or {} lost = [ {"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids ] diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts new file mode 100644 index 000000000..2e48fe763 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts @@ -0,0 +1,192 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts + * 단가산출(SK) 숫자 넓힘 캐시 고치기(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — + * 짝 = 서버 `M02_MasterTemplete/M02_Sangeun_Const.py`(`restore` · `drop_add_keys` · `kinds` 같은 꼴) + * · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`. + * 상수덮개 {자리: {n: 값}} 식 n 번째 숫자(0 부터 · 서버 답 `시트.자리[자리].상수` 의 n) + * 표값덮개 {자리: {n: 값}} 식 n 번째 `찾기(…).칸`(서버 답 `표값` 의 n) + * 글줄 [{자리, 글}] 셈 없는 글 줄 · 자리 뒤("" = 맨 위) + * 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다 + * M01판 서버 몫 — 받은 그대로 둠(셈 때 「M01바뀜」 판정) + * 자리 = 바탕 cid · 중간 `mid:이름` · 열쇠 = 자리 · `add:#k` · `add:#k/세부 cid`. + * 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈). + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +export type NumCovers = Record>; + +export interface TextLine { + 자리: string; + 글: string; +} + +export interface ConstDoc { + 덮개?: Record>; + 뺀줄?: string[]; + 더한줄?: Record[]; + 상수덮개?: NumCovers; + 표값덮개?: NumCovers; + 글줄?: TextLine[]; + 까닭?: Record; + [k: string]: unknown; +} + +export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌"; + +const ADD = /^add:#(\d+)(\/.*)?$/; +const PARTS = ["labor", "material", "expense"]; + +/** 빈 묶음 · 목록 칸은 지움(서버 `clean_more` 처럼 없으면 안 둠). */ +function put(doc: T, name: string, value: unknown): T { + const out: ConstDoc = { ...doc }; + const empty = + value == null || + (Array.isArray(value) + ? value.length === 0 + : typeof value === "object" && Object.keys(value).length === 0); + if (empty) delete out[name]; + else out[name] = value; + return out as T; +} + +function setNum( + doc: T, + name: "상수덮개" | "표값덮개", + spot: string, + n: number, + v: number | null, +): T { + const all: NumCovers = { ...(doc[name] ?? {}) }; + const one = { ...(all[spot] ?? {}) }; + if (v == null) delete one[String(n)]; + else one[String(n)] = v; + if (Object.keys(one).length) all[spot] = one; + else delete all[spot]; + return put(doc, name, all); +} + +/** 식 속 n 번째 숫자 상수 → 값(null = 원래로). */ +export const setConst = ( + doc: T, + spot: string, + n: number, + v: number | null, +): T => setNum(doc, "상수덮개", spot, n, v); + +/** 식 속 n 번째 표 찾기 값 → 값(null = 표 연결 되살림). */ +export const setTable = ( + doc: T, + spot: string, + n: number, + v: number | null, +): T => setNum(doc, "표값덮개", spot, n, v); + +/** 글 줄 넣기 — `at` 차례(없으면 끝). */ +export function addText(doc: T, spot: string, text: string, at?: number): T { + const lines = [...(doc.글줄 ?? [])]; + lines.splice(at ?? lines.length, 0, { 자리: spot, 글: text }); + return put(doc, "글줄", lines); +} + +export function editText(doc: T, at: number, text: string): T { + const lines = (doc.글줄 ?? []).map((t, i) => (i === at ? { ...t, 글: text } : t)); + return put(doc, "글줄", lines); +} + +export function dropText(doc: T, at: number): T { + return put( + doc, + "글줄", + (doc.글줄 ?? []).filter((_, i) => i !== at), + ); +} + +/** 까닭 — 빈 글 = 지움. */ +export function setReason(doc: T, key: string, text: string): T { + const why = { ...(doc.까닭 ?? {}) }; + if (text.trim()) why[key] = text.trim(); + else delete why[key]; + return put(doc, "까닭", why); +} + +const topAdd = (key: string): boolean => { + const hit = ADD.exec(key); + return !!hit && !hit[2]; +}; + +/** [M01 원래로] — 열쇠 없음 = 전체(덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 · 더한줄 · 글줄 · 옵션 남김) · + * 열쇠 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(`add:#k/cid` = 그 더한줄 세부) · 짝 `restore`. */ +export function restoreM01(doc: T, key?: string): T { + let out: ConstDoc = { ...doc }; + delete out.상수덮개; + delete out.표값덮개; + delete out.까닭; + const why = { ...(doc.까닭 ?? {}) }; + if (key == null) { + out = { ...out, 덮개: {}, 뺀줄: [] }; + const kept = Object.fromEntries(Object.entries(why).filter(([k]) => topAdd(k))); + return put(out, "까닭", kept) as T; + } + const hit = ADD.exec(key); + if (hit && hit[2]) { + const k = Number(hit[1]); + const sub = hit[2].slice(1); + const adds = (doc.더한줄 ?? []).map((x) => ({ ...x })); + if (k < adds.length) { + const was = adds[k]; + const one: Record = { ...was }; + delete one.덮개; + delete one.뺀줄; + const covers = Object.fromEntries( + Object.entries((was.덮개 as Record) ?? {}).filter(([c]) => c !== sub), + ); + const drop = ((was.뺀줄 as string[]) ?? []).filter((c) => c !== sub); + if (Object.keys(covers).length) one.덮개 = covers; + if (drop.length) one.뺀줄 = drop; + adds[k] = one; + } + out.더한줄 = adds; + } else { + out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key)); + out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key); + for (const name of ["상수덮개", "표값덮개"] as const) { + const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key)); + out = put(out, name, got); + } + } + delete why[key]; + return put(out, "까닭", why) as T; +} + +/** 더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 한 칸 당김 · k 는 지움 · 짝 `drop_add_keys`. */ +export function dropAddKeys(doc: T, k: number): T { + const move = (key: string): string | null => { + const hit = ADD.exec(key); + if (!hit) return key; + const j = Number(hit[1]); + return j === k ? null : `add:#${j > k ? j - 1 : j}${hit[2] ?? ""}`; + }; + let out: ConstDoc = { ...doc }; + if (doc.까닭) { + const why: Record = {}; + for (const [x, v] of Object.entries(doc.까닭)) { + const to = move(x); + if (to) why[to] = v; + } + out = put(out, "까닭", why); + } + if (doc.글줄) out.글줄 = doc.글줄.map((t) => ({ ...t, 자리: move(t.자리) ?? "" })); + return out as T; +} + +/** 자리의 덮개 갈래(차례 = 품 · 단가 · 상수 · 표값 · 뺌) · 짝 `kinds`. */ +export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] { + const cover = doc.덮개?.[spot] ?? {}; + const out: CoverKind[] = []; + if ("qty" in cover || "배율" in cover) out.push("품"); + if (PARTS.some((k) => k in cover)) out.push("단가"); + if (doc.상수덮개?.[spot]) out.push("상수"); + if (doc.표값덮개?.[spot]) out.push("표값"); + if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌"); + return out; +} diff --git a/resources/tester/test_790_2_sangeun_const.py b/resources/tester/test_790_2_sangeun_const.py new file mode 100644 index 000000000..b491f9f4f --- /dev/null +++ b/resources/tester/test_790_2_sangeun_const.py @@ -0,0 +1,318 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""790-2 단가산출(SK) 숫자 넓힘 — 상수 · 표값 덮개 · 글 줄 · 까닭 · M01 원래로 · 짝잃음(요구서_652). + +- 금액 = 서버 셈 = _분석_650-H2 4 장 견본(지붕 감률 17 → 20 = 61,088 · 공구손료 1 → 2 % = 51,802 · + 인력운반 120 → 100 = 38,696 · 표값 운반량 30 = 35,764) · 옵션 바꿔도 상수 덮개 식 따라감 +- 식 속 숫자 차례 = 엔진 파서(찾기 머리 · 키 · EQ: · 따옴표 글 안 셈) +- 옛 SK(새 칸 없음) = 꼴 · 금액 그대로 · 저장 = M01판 · 셈 답 `원래` · `시트` +- [M01 원래로] · 더한줄 열쇠 옮김 · 덮개 갈래 = 파이썬 `M02_Sangeun_Const` ↔ TS `Const_Model` 거울 +- 펼침 = 상수 덮개 SK 안 펼침(참조 그대로 · 값 그대로) · [초기화] = 새 덮개도 걷음 +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Flat as flat +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Const as const +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +FORM = "GC000211" # 합판거푸집 +CARRY = "GC001140" # 인력운반(지게) +ROOF = "지붕 슬래브(경사 20˚ 미만)" +TOOL = "pct:공구손료 및 경장비#0" +F211 = { + "자재지역": "포항", + "유형": "보통", + "사용횟수": "6회", + "높이감률": 0, + "지붕슬래브": "아님", + "폼타이": "아님", + "폼타이규격": "12.7㎜", + "측압": "3t/㎡", + "구멍땜": 0, +} + + +def _whole(): + return m01.loaded()[1] + + +def _carry(): + return { + **json.loads(json.dumps(_whole().logic(CARRY)["시험입력"], default=str)), + "재료종별": "암석", + "재료형상": "화강암", + "운반회수": 0, + } + + +def _doc(code, given, **more): + return { + "키": "SK-000001", + "이름": "견본", + "단위": "식", + "바탕": {"로직": code}, + "입력값": given, + **more, + } + + +def _sum(doc): + whole = _whole() + return float(calc.run(sd.clean(doc, None, whole), whole)["기준"]["계"]) + + +# ── 식 속 자리 ──────────────────────────────────────────────────────── +def test_scan_엔진_차례(): + t = "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)" + got = const.scan(t) + assert [t[a:b] for a, b in got["num"]] == ["1", "0", "17", "100"] + assert [t[a:b] for a, b in got["find"]] == ["찾기(QC000431, 유형=유형).시공량"] + tool = "(형틀목공 * LB000007 + 보통인부 * LB000002) * 1 / 100" + assert [tool[a:b] for a, b in const.scan(tool)["num"]] == ["1", "100"] + assert const.scan("EQ:0201-0012.잡재료율 * 2 + '3t/㎡'")["num"] == [(20, 21)] + assert const.scan("((((") == {"num": [], "find": []} # 못 읽는 식 = 빈 + assert const.swap(t, {"2": 20}, {"0": -45}).startswith("(-45) * (1 - ") + assert ", 0, 20)) / 100)" in const.swap(t, {"2": 20}, {}) + + +# ── 금액 = 견본(서버 셈) ────────────────────────────────────────────── +def test_상수_표값_덮개_견본_금액(): + roof = {**F211, "지붕슬래브": ROOF} + assert _sum(_doc(FORM, F211)) == 51419 + assert _sum(_doc(FORM, roof)) == 59341 + assert _sum(_doc(FORM, roof, 상수덮개={"mid:시공량": {"2": 20}})) == 61088 + assert _sum(_doc(FORM, F211, 상수덮개={TOOL: {"0": 2}})) == 51802 + assert _sum(_doc(CARRY, _carry())) == 42917 + assert _sum(_doc(CARRY, _carry(), 상수덮개={"mid:일운반회수": {"1": 100}})) == 38696 + assert _sum(_doc(CARRY, _carry(), 표값덮개={"mid:운반량": {"0": 30}})) == 35764 + + +def test_옵션_바꿔도_상수_덮개_따라감(): + """품 덮개(숫자로 굳힘)와 달리 식 구조 그대로 — 유형 복잡 = 손으로 식 고친 줄과 같은 값.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + whole = _whole() + given = {**F211, "지붕슬래브": ROOF, "유형": "복잡"} + got = _sum(_doc(FORM, given, 상수덮개={"mid:시공량": {"2": 20}})) + row = json.loads(json.dumps(whole.logic(FORM), ensure_ascii=False, default=str)) + for s in row["중간"]: + if s["이름"] == "시공량": + s["식"] = s["식"].replace(", 0, 17))", ", 0, 20))") + hand = labor.run_line(calc._Over(whole, FORM, row), FORM, given)["기준"]["계"] + assert got == float(hand) and got not in (61088, _sum(_doc(FORM, given))) + + +# ── 문서 꼴 ────────────────────────────────────────────────────────── +def test_새_칸_꼴_검사(): + whole = _whole() + doc = _doc( + FORM, + F211, + 상수덮개={"mid:시공량": {"2": "20"}, TOOL: {}}, + 표값덮개={}, + 글줄=[{"자리": "", "글": " 건설표준품셈 6-3-1 "}, {"자리": "mid:시공량", "글": ""}], + 까닭={"mid:시공량": "현장 경사 반영", "add:#0": " ", "add:#1/labor:LB000002#0": "x"}, + ) + got = sd.clean(doc, None, whole) + assert got["상수덮개"] == {"mid:시공량": {"2": 20}} and "표값덮개" not in got + assert got["글줄"] == [{"자리": "", "글": "건설표준품셈 6-3-1"}] + assert got["까닭"] == {"mid:시공량": "현장 경사 반영", "add:#1/labor:LB000002#0": "x"} + for bad in ( + {"상수덮개": {"엉뚱": {"0": 1}}}, + {"상수덮개": {"mid:시공량": {"a": 1}}}, + {"표값덮개": {"mid:운반량": {"0": "글"}}}, + {"까닭": {"x": "y"}}, + {"글줄": [{"자리": "", "글": "가" * 501}]}, + ): + with pytest.raises(sd.DocError): + sd.clean(_doc(FORM, F211, **bad), None, whole) + + +def test_옛_SK_꼴_금액_그대로(): + whole = _whole() + old = _doc(FORM, F211, 덮개={"labor:LB000007#0": {"qty": 0.2}}, 뺀줄=["labor:LB000002#0"]) + got = sd.clean(old, None, whole) + assert not set(got) & {*const.NEW, "M01판"} + assert const.formulas(whole.logic(FORM), got) is whole.logic( + FORM + ) # 새 덮개 없음 = 그 줄 그대로 + assert sd.clean({**old, "M01판": {"mid:시공량": "x"}}, None, whole) == got # M01판 = 서버 몫 + + +# ── 저장 · 셈 답(임시 storage) ───────────────────────────────────────── +def _sk(folder, **doc): + with store.at(folder): + return refs.create("sangeun", {"이름": "내 단가산출", "단위": "식", "더한줄": [], **doc})[ + "이름" + ] + + +def _read(folder, key): + with store.at(folder): + return store.read("sangeun", key)["문서"] + + +def _calc(folder, doc, given=None): + with store.at(folder): + return calc.calc_doc(doc, given) + + +def test_저장_M01판_셈답_원래_시트(world): + mine = world["personal"] + key = _sk( + mine, + 바탕={"로직": FORM}, + 입력값={**F211, "지붕슬래브": ROOF}, + 상수덮개={"mid:시공량": {"2": 20}}, + 덮개={"labor:LB000002#0": {"qty": 0.05}}, + 글줄=[{"자리": "", "글": "※ 지붕 경사 반영"}], + 까닭={"mid:시공량": "감률 20 % 적용"}, + ) + doc = _read(mine, key) + assert doc["상수덮개"] == {"mid:시공량": {"2": 20}} and len(doc["바탕판"]) == 16 + assert set(doc["M01판"]) >= {"mid:시공량", TOOL, "labor:LB000007#0"} + got = _calc(mine, doc) + assert got["원래"]["계"] == 59341 # 같은 옵션 · 덮개 없음 + assert got["기준"]["계"] != 59341 + no_qty = _calc(mine, {**doc, "덮개": {}}) + assert no_qty["기준"]["계"] == 61088 and no_qty["원래"]["계"] == 59341 + spot = got["시트"]["자리"]["mid:시공량"] + assert spot["갈래"] == "중간" and spot["덮개"] == ["상수"] and spot["까닭"] == "감률 20 % 적용" + assert spot["상수"][2] == {"n": 2, "값": 20, "원값": 17} + assert spot["표값"][0]["글"].startswith("찾기(QC000431") and spot["표값"][0]["값"] == 45 + assert spot["원래"] > spot["지금"] and spot["M01바뀜"] is False + line = got["시트"]["자리"]["labor:LB000002#0"] + assert line["덮개"] == ["품"] and line["원래"] != line["지금"] + assert got["시트"]["글줄"] == [{"자리": "", "글": "※ 지붕 경사 반영"}] + assert got["시트"]["짝잃음"] == [] + # M01 바뀜 — 저장 지문과 지금 바탕이 다른 자리만 · 사라진 자리 = 짝잃음 + stale = {**doc, "M01판": {**doc["M01판"], "mid:시공량": "000000000000", "mid:없는줄": "1"}} + got = _calc(mine, stale) + flags = {s for s, x in got["시트"]["자리"].items() if x.get("M01바뀜")} + assert flags == {"mid:시공량"} + assert {"cid": "mid:없는줄", "까닭": "M01 바뀜 — 바탕에 없는 줄"} in got["시트"]["짝잃음"] + + +def test_짝잃은_새_덮개_알림(world): + doc = _doc(FORM, F211, 상수덮개={"mid:없음": {"0": 1}, "mid:시공량": {"9": 1}}) + got = _calc(world["personal"], doc) + assert got["기준"]["계"] == 51419 + why = {(x["cid"], x["까닭"]) for x in got["시트"]["짝잃음"]} + assert why == { + ("mid:없음", "상수덮개 — 바탕에 없는 줄"), + ("mid:시공량", "상수덮개 — 10 번째 없음"), + } + + +def test_옛_저장본_셈답(world): + """새 칸 없는 옛 저장본(M01판 없음) = 금액 그대로 · M01바뀜 없음.""" + got = _calc(world["personal"], {**_doc(FORM, F211), "바탕판": "0" * 16}) + assert got["기준"]["계"] == 51419 == got["원래"]["계"] + assert not any(x.get("M01바뀜") for x in got["시트"]["자리"].values()) + + +def test_펼침_막음_초기화_걷음(world): + mine = world["personal"] + a = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 상수덮개={"mid:일운반회수": {"1": 100}}) + b = _sk(mine, 바탕=None, 더한줄=[{"참조": {**MINE, "키": a}, "수량": 1}]) + assert _read(mine, b)["더한줄"][0]["참조"]["키"] == a # 안 펼침 = 참조 그대로 + assert _calc(mine, _read(mine, b))["기준"]["계"] == 38696 + with store.at(mine), pytest.raises(store.StoreError): + flat.flat_lines("sangeun", {**MINE, "키": a}) + doc, n = flat.reset("sangeun", {**_read(mine, a), "까닭": {"mid:일운반회수": "가정"}}) + assert n == 1 and not set(doc) & {"상수덮개", "까닭"} + + +# ── 파이썬 ↔ TS 거울 ───────────────────────────────────────────────── +MIRROR_DOC = { + "덮개": {"labor:LB000002#0": {"qty": 1}, "labor:LB000007#0": {"labor": 5}}, + "뺀줄": ["pct:소모자재#0"], + "더한줄": [ + { + "종류": "로직", + "로직": "GC000001", + "덮개": {"labor:X#0": {"qty": 1}}, + "뺀줄": ["material:Y#0"], + }, + {"종류": "인력"}, + ], + "상수덮개": {"mid:시공량": {"2": 20}, "labor:LB000002#0": {"0": 3}}, + "표값덮개": {"mid:운반량": {"0": 30}}, + "글줄": [{"자리": "add:#1", "글": "a"}, {"자리": "add:#0", "글": "b"}], + "까닭": { + "mid:시공량": "c", + "add:#0": "d", + "add:#1": "e", + "add:#0/labor:X#0": "f", + "pct:소모자재#0": "g", + }, +} +KEYS = [ + None, + "mid:시공량", + "labor:LB000002#0", + "pct:소모자재#0", + "add:#0/labor:X#0", + "add:#0/material:Y#0", + "없음", +] + + +def _ts(body: str): + src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts").read_text("utf-8") + got = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_거울_M01원래로_열쇠옮김_갈래(): + d = json.dumps(MIRROR_DOC, ensure_ascii=False) + keys = json.dumps(KEYS) + spots = json.dumps( + ["labor:LB000002#0", "labor:LB000007#0", "pct:소모자재#0", "mid:운반량", "x"] + ) + ts = _ts( + f"\nconst d = {d};" + f"\nconst out = {{r: {keys}.map((k) => restoreM01(d, k ?? undefined))," + " a: [0, 1, 5].map((k) => dropAddKeys(d, k))," + f" k: {spots}.map((s) => coverKinds(d, s))}};" + "\nconsole.log(JSON.stringify(out));" + ) + assert ts["r"] == [const.restore(MIRROR_DOC, k) for k in KEYS] + assert ts["a"] == [const.drop_add_keys(MIRROR_DOC, k) for k in (0, 1, 5)] + assert ts["k"] == [const.kinds(MIRROR_DOC, s) for s in json.loads(spots)] + assert ts["k"][0] == ["품", "상수"] and ts["k"][2] == ["뺌"] + + +def test_TS_고치기_꼴_서버가_받음(): + """setConst · setTable · addText · editText · dropText · setReason 결과 = 서버 꼴 그대로 통과.""" + ts = _ts( + "\nlet d = {키: 'SK-000001', 이름: '견본', 단위: '식', 바탕: {로직: 'GC000211'}};" + "\nd = setConst(d, 'mid:시공량', 2, 20); d = setConst(d, 'mid:시공량', 0, 1);" + "\nd = setConst(d, 'mid:시공량', 0, null); d = setTable(d, 'mid:시공량', 0, 45);" + "\nd = setTable(d, 'mid:시공량', 0, null);" + "\nd = addText(d, '', '근거'); d = addText(d, 'mid:시공량', '※ 조건', 0);" + "\nd = editText(d, 1, '근거 고침'); d = dropText(d, 0);" + "\nd = setReason(d, 'mid:시공량', ' 까닭 '); d = setReason(d, 'add:#0', '');" + "\nconsole.log(JSON.stringify(d));" + ) + assert ts["상수덮개"] == {"mid:시공량": {"2": 20}} and "표값덮개" not in ts + assert ts["글줄"] == [{"자리": "", "글": "근거 고침"}] and ts["까닭"] == {"mid:시공량": "까닭"} + got = sd.clean({**ts, "입력값": F211}, None, _whole()) + assert {k: got[k] for k in const.NEW if k in got} == {k: ts[k] for k in const.NEW if k in ts}