"""가져온 단가산출 줄 쌓기 차림(PLAN 812-3 · 요구 713 · 669~677) — 경쟁사 산출근거 꼴. 금액 · 셈 그대로. `M02_Doc_Import_Shape.shape` 가 부름(`tag` = 다듬기 앞 · `apply` = 끝) · 원본 `차림` 표지 있는 블록만(EST = `M02_Doc_NativeEst` · STmate = `M02_San_Emit.stmate`): 항목 = 머리 줄(글줄 `차림` 「머리」 · 「1. …」 「가. …」 글) · 번호 글은 떼고 화면이 매김 · 가 · 나 = `가나다` · STmate 는 계 줄(`단계:`)마다 끊고 그 뒤 첫 글 줄 = 제목 · 머리 앞 줄 = 제목 없는 항목 · 합계 줄(「합계」 · 「소계」 절)만 있는 머리 = 뺌 (화면 소계 · 합계가 냄) 금액 줄 = 그 자리(원본 행 차례) — EST 는 셈이 같을 때만 금액 차례를 원본 차례로 (단계 · 누계 이름 없고 기호 부름이 앞 줄일 때) 공식 묶음 = 변수 대입 줄(`mid:` · 쓰는 차례) · 글 줄 = 근거 · 메모(글줄 `차림` 「글」) 저장 = 금액 줄 `묶음` = 항목 제목 + 문서 `쌓기`(기본 차림과 다를 때만). 파이썬 · TS 짝 — `common_util/common_util_line_stack.ts` `stackOf` · `saveOf` · `hiddenOf`(안 놓인 기호 자리 · 기본 차림)의 거울 · 거울 시험 `resources/tester/test_812_3_native_layout.py`(TS 왕복). 고칠 때 둘을 같이 고침. """ from __future__ import annotations import re import sys from pathlib import Path from typing import Any _SCRIPTS = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "scripts" if str(_SCRIPTS) not in sys.path: sys.path.insert(0, str(_SCRIPTS)) import master_doc as md # noqa: E402 from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402 TAG = "차림" # 글줄 칸 — 머리 | 합계 | 글 · 원본 칸 = 차림 낼 블록 KEEP = "_로직줄" # 문서 금액 줄 ↔ 로직 줄 차례(다듬기 사이 임시) GANADA = "가나다라마바사아자차카타파하" NUMBERED = re.compile(rf"^\s*'?\s*(\d{{1,2}}|[{GANADA}])\s*[.)]\s*(?!\d)(\S.*)$") _TOTAL = {"합계", "소계", "계"} def tag(doc: dict, meta: dict) -> None: """다듬기(이름 · 기계 합침) 앞 — 금액 줄마다 로직 줄 차례(호표 → 덧줄)를 적어 둠.""" row = meta.get("로직") or {} n = len(row.get("호표") or []) + len(row.get("덧줄") or []) money = doc.get("금액") or [] if (meta.get("원본") or {}).get(TAG) and money and len(money) == n: for k, line in enumerate(money): line[KEEP] = k def title_of(text: str) -> tuple[str, bool]: """「 1. 기 계(100%)」 → (「기 계(100%)」, 거짓) · 「'가. 굴삭기'」 → (「굴삭기」, 가나다).""" flat = " ".join(str(text or "").replace("'", " ").split()) m = NUMBERED.match(flat) if not m: return flat, False return m.group(2).strip(), m.group(1) in GANADA # ── 차림 ────────────────────────────────────────────────────────────── def apply(doc: dict, meta: dict) -> None: money = doc.get("금액") or [] keys = [line.pop(KEEP, None) for line in money] src = meta.get("원본") or {} if not src.get(TAG) or not money or None in keys: return from M02_MasterTemplete import M02_Sangeun_Imported as imp row = meta.get("로직") or {} ids = sd.line_ids( imp.row_of({imp.KEY: {"로직": row}}) or {} ) # 글줄 자리 = 줄 정체(`imp._named`) lines = [*(row.get("호표") or []), *(row.get("덧줄") or [])] by: dict[str, int] = {} for k, (cid, one) in enumerate(zip(ids, lines)): by.setdefault(cid, k) by.setdefault(str(one.get("이름")), k) inv = {k: j for j, k in enumerate(keys)} texts = sorted( (t for t in src.get("글줄") or [] if isinstance(t.get("행"), int)), key=lambda t: t["행"] ) at: dict[int, int] = {} # 문서 금액 줄 → 원본 행 for t in texts: for spot in t.get("자리들") or ([t["자리"]] if t.get("자리") else []): j = inv.get(by.get(str(spot), -1)) if j is not None: at[j] = min(at.get(j, t["행"]), t["행"]) rows = _fill([at.get(j) for j in range(len(money))]) order = sorted(range(len(money)), key=lambda j: (rows[j], j)) if order != list(range(len(money))) and _safe(doc, order): doc["금액"] = money = [money[j] for j in order] rows = [rows[j] for j in order] else: for j in range(1, len(rows)): # 차례 그대로 — 앞 줄보다 위면 앞 줄 자리로 rows[j] = max(rows[j], rows[j - 1]) items = _items(doc, texts, rows, src.get("갈래") == "STmate") for it in items: for kind, j in it["줄"]: if kind == "금액": money[j].pop("묶음", None) money[j] |= {"묶음": it["제목"]} if it["제목"] else {} raw = [_save_item(it, money) for it in items] doc.pop("쌓기", None) plain = save_of(stack_of(doc), money) got = save_of(stack_of({**doc, "쌓기": raw}), money) if got != plain: doc["쌓기"] = got def _fill(rows: list[int | None]) -> list[int]: """원본 행 없는 줄(글줄에 안 붙은 소계 덧줄 등) = 다음 줄 행 · 없으면 앞 줄 행.""" out = list(rows) for j in range(len(out) - 1, -1, -1): if out[j] is None and j + 1 < len(out): out[j] = out[j + 1] for j in range(len(out)): if out[j] is None: out[j] = out[j - 1] if j else 0 return [int(x or 0) for x in out] def _safe(doc: dict, order: list[int]) -> bool: """금액 차례를 바꿔도 셈이 같음 — 단계 없음 · 누계 · 번지 · 단계 이름 안 씀 · 줄 기호 부름 = 앞 줄.""" if doc.get("단계"): return False money = doc.get("금액") or [] pos = {j: p for p, j in enumerate(order)} sym = {str(x["기호"]): pos[j] for j, x in enumerate(money) if x.get("기호")} for j, line in enumerate(money): for text in (line.get("수량"), line.get("단가")): if text is None or str(text).strip() == "": continue try: got = md.names(md.parse(text)) except md.DocError: return False if any(n in md.MONEY_NAMES or n == "계" for n in got): return False if any(n in sym and sym[n] >= pos[j] for n in got): return False return True def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dict]: """원본 행 차례로 항목 · 줄을 쌓음 — 줄 = (금액, 문서 줄) | (공식, [기호]) | (글, 글).""" have = set(symbols(doc)) hidden = hidden_of(doc) ev: list[tuple[int, int, int, str, Any]] = [] for t in texts: mark, text = t.get(TAG), str(t.get("글") or "") spots = t.get("자리들") or ([t["자리"]] if t.get("자리") else []) mids = [s[4:] for s in spots if str(s).startswith("mid:")] if any(str(s).startswith("단계:") for s in spots): ev.append((t["행"], 0, 0, "끝", None)) elif mark == "합계": ev.append((t["행"], 0, 0, "합계", None)) elif not spots and (mark == "머리" or (mark == "글" and NUMBERED.match(text))): ev.append((t["행"], 0, 0, "머리", title_of(text))) elif not spots and mark == "글" and text.strip(): ev.append((t["행"], 1, 0, "글", " ".join(text.replace("'", " ").split()))) ids = [m for m in mids if m in have and m not in hidden] if ids: ev.append((t["행"], 1, 0, "공식", ids)) ev += [(r, 2, j, "금액", j) for j, r in enumerate(rows)] ev.sort(key=lambda e: e[:3]) items: list[dict] = [] cur: dict | None = None cut = False # STmate 계 줄 뒤 — 다음 첫 글 줄 = 제목 money = doc.get("금액") or [] mach = { str(x.get("기호")): x for x in (doc.get("가져온") or {}).get("기계") or [] if isinstance(x, dict) and (x.get("조합") or x.get("조합첫")) } # 조합 기계(812-9 · 요구 738-3) = 기계마다 항목 하나 · 제목 = 그 기계 def new(title: str = "", sub: bool = False) -> dict: it = {"제목": title, "가나다": sub, "줄": [], "합계": False} items.append(it) return it for _, _, _, kind, val in ev: if kind == "끝": cur, cut = None, stm elif kind == "합계": if cur is not None: cur["합계"] = True elif kind == "머리" or (kind == "글" and cut and cur is None): title, sub = val if kind == "머리" else title_of(val) if cur is not None and not cur["줄"] and not cur["제목"]: cur["제목"], cur["가나다"] = title, sub else: cur = new(title, sub) cut = False else: one = mach.get(price_sym(money[val]) or "") if kind == "금액" else None if one and one.get("조합"): cur = new(_mach_title(one), bool(cur and cur["가나다"])) elif one and cur is not None and "+" in cur["제목"]: cur["제목"] = _mach_title(one) cur = cur if cur is not None else new() if kind == "공식" and cur["줄"] and cur["줄"][-1][0] == "공식": cur["줄"][-1][1].extend(x for x in val if x not in cur["줄"][-1][1]) else: cur["줄"].append((kind, list(val) if kind == "공식" else val)) keep = [] for it in items: flat = re.sub(r"\s+", "", it["제목"]) if not it["줄"] and (it["합계"] or not flat or flat in _TOTAL): continue keep.append(it) return keep def _mach_title(one: dict) -> str: """조합 기계 항목 제목 — 이은 우리 기계 이름 · 규격 › 가져온 이름 · 규격.""" first = (one.get("후보") or [{}])[0] if one.get("키") else {} said = first or { "이름": (one.get("원래") or {}).get("이름"), "규격": (one.get("원래") or {}).get("규격"), } return " ".join(str(said.get(k) or "") for k in ("이름", "규격")).strip() def _save_item(it: dict, money: list[dict]) -> dict: out: dict[str, Any] = {} if it["제목"]: out["제목"] = it["제목"] if it["가나다"]: out["가나다"] = True lines = [] for kind, val in it["줄"]: if kind == "금액": lines.append({"금액": val, "이름": money[val].get("이름") or ""}) elif kind == "공식": lines.append({"공식": val}) else: lines.append({"글": val}) return out | {"줄": lines} # ── 줄 쌓기 모델 거울(`common_util_line_stack.ts`) ────────────────────── def _names_in(text: Any) -> list[str]: if text is None or str(text).strip() == "": return [] try: return md.names(md.parse(text)) except md.DocError: return [] def symbols(doc: dict) -> list[str]: out = [md.var_id(v) for v in doc.get("변수") or []] for line in doc.get("식") or []: sid = str(line.get("기호") or "") if sid and sid not in out: out.append(sid) return out def _is_option(v: dict | None) -> bool: return bool(v and v.get("열쇠") and (v.get("연결") or {}).get("갈래") in ("선택", "입력")) def uses_of(doc: dict) -> dict[str, list[str]]: out: dict[str, list[str]] = {} def add(sid: str, more: list[str]) -> None: now = out.setdefault(sid, []) now += [n for n in more if n not in now] for v in doc.get("변수") or []: add(md.var_id(v), md.link_names(v["연결"]) if v.get("연결") else []) for line in doc.get("식") or []: add(str(line.get("기호") or ""), _names_in(line.get("식"))) return out def price_sym(line: dict) -> str | None: text = line.get("단가") if text is None or str(text).strip() == "": return None try: n = md.parse(text) except md.DocError: return None if n[0] == "var": return n[1] if n[0] == "field" and n[1][0] == "var": return n[1][1] return None def machine_syms(doc: dict) -> set[str]: """가져온 기계 줄 단가 기호(812-9 `가져온.기계`) — 못 이어 숫자 연결이어도 그 줄 기계(이름 단추 · 고르기).""" got = (doc.get("가져온") or {}).get("기계") if isinstance(doc.get("가져온"), dict) else None return {str(x.get("기호")) for x in got or [] if isinstance(x, dict)} def owned_of(doc: dict) -> dict[int, str]: count: dict[str, int] = {} for ns in ( *(_names_in(x.get("수량")) + _names_in(x.get("단가")) for x in doc.get("금액") or []), *uses_of(doc).values(), *([[doc["결과"]]] if doc.get("결과") else []), ): for n in ns: count[n] = count.get(n, 0) + 1 vars_ = {md.var_id(v): v for v in doc.get("변수") or []} lines = {str(x.get("기호") or "") for x in doc.get("식") or []} mach = machine_syms(doc) out = {} for i, line in enumerate(doc.get("금액") or []): s = price_sym(line) kind = ((vars_.get(s) or {}).get("연결") or {}).get("갈래") if s else None if ( s and (kind in ("요소", "단가산출") or s in mach) and count.get(s) == 1 and s not in lines ): out[i] = s return out def hidden_of(doc: dict) -> set[str]: """차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 값 옵션 칸은 공식 묶음에도 보임).""" out = set(owned_of(doc).values()) out |= {md.var_id(v) for v in doc.get("변수") or [] if _is_option(v)} return out def _post_order(doc: dict, roots: list[str], uses: dict[str, list[str]]) -> list[str]: out: list[str] = [] seen: set[str] = set() stack: list[tuple[str, int]] = [] for start in [*roots, *symbols(doc)]: # 되부름 없이(깊은 식 사슬) if start in seen: continue seen.add(start) stack.append((start, 0)) while stack: sid, k = stack.pop() kids = uses.get(sid) or [] if k < len(kids): stack.append((sid, k + 1)) if kids[k] not in seen: seen.add(kids[k]) stack.append((kids[k], 0)) else: out.append(sid) return out def _reach(line: dict, uses: dict[str, list[str]]) -> set[str]: out: set[str] = set() todo = _names_in(line.get("수량")) + _names_in(line.get("단가")) while todo: sid = todo.pop() if sid in out: continue out.add(sid) todo += uses.get(sid) or [] return out def stack_of(doc: dict) -> list[dict]: """`stackOf` — 저장 차림 + 안 놓인 것은 기본 자리 · 줄 = {꼴 금액 i | 꼴 공식 기호 틀? | 꼴 글 글}.""" money = doc.get("금액") or [] all_ = set(symbols(doc)) hidden = hidden_of(doc) items: list[dict] = [] where: dict[int, dict] = {} placed: set[str] = set() saved = doc.get("쌓기") if isinstance(doc.get("쌓기"), list) else [] taken: set[int] = set() for s in saved: s = s if isinstance(s, dict) else {} it: dict[str, Any] = { "제목": str(s.get("제목") if s.get("제목") is not None else ""), "줄": [], } if s.get("가나다"): it["가나다"] = True for line in s.get("줄") if isinstance(s.get("줄"), list) else []: if isinstance(line, dict) and "금액" in line: i = int(line["금액"]) name = line.get("이름") if not ( 0 <= i < len(money) and (money[i].get("이름") or "") == name and i not in taken ): free = (k for k, m in enumerate(money) if k not in taken) i = next((k for k in free if (money[k].get("이름") or "") == name), -1) if i < 0 or i in taken: continue taken.add(i) where[i] = it it["줄"].append({"꼴": "금액", "i": i}) elif isinstance(line, dict) and "공식" in line: got = [ x for x in line["공식"] or [] if x in all_ and x not in hidden and x not in placed ] placed |= set(got) one: dict[str, Any] = {"꼴": "공식", "기호": got} if line.get("틀"): one["틀"] = str(line["틀"]) if line.get("틀꼴"): one["틀꼴"] = str(line["틀꼴"]) it["줄"].append(one) elif isinstance(line, dict) and "글" in line: it["줄"].append( {"꼴": "글", "글": str(line["글"] if line["글"] is not None else "")} ) items.append(it) for i, m in enumerate(money): if i in taken: continue title = str(m.get("묶음") if m.get("묶음") is not None else "") prev = where.get(i - 1) if prev is not None and prev["제목"] == title: it = prev at = next(k for k, x in enumerate(prev["줄"]) if x["꼴"] == "금액" and x["i"] == i - 1) prev["줄"].insert(at + 1, {"꼴": "금액", "i": i}) else: it = next((x for x in items if x["제목"] == title), None) if saved else None if it is None: it = {"제목": title, "줄": []} items.append(it) it["줄"].append({"꼴": "금액", "i": i}) taken.add(i) where[i] = it uses = uses_of(doc) reach = [_reach(m, uses) for m in money] for sid in _post_order(doc, [doc["결과"]] if doc.get("결과") else [], uses): if sid not in all_ or sid in hidden or sid in placed: continue i = next((k for k, r in enumerate(reach) if sid in r), -1) it = where.get(i) if i >= 0 else (items[0] if items else None) if it is None: it = {"제목": "", "줄": []} items.append(it) block = next((x for x in it["줄"] if x["꼴"] == "공식"), None) if block is None: block = {"꼴": "공식", "기호": []} it["줄"].insert(0, block) block["기호"].append(sid) placed.add(sid) return items def save_of(items: list[dict], money: list[dict]) -> list[dict]: """`saveOf`(차례 그대로).""" out = [] for it in items: one: dict[str, Any] = {} if it["제목"]: one["제목"] = it["제목"] if it.get("가나다"): one["가나다"] = True lines = [] for x in it["줄"]: if x["꼴"] == "금액": lines.append({"금액": x["i"], "이름": money[x["i"]].get("이름") or ""}) elif x["꼴"] == "글": lines.append({"글": x["글"]}) else: lines.append( {"공식": list(x["기호"])} | ({"틀": x["틀"]} if x.get("틀") else {}) | ({"틀꼴": x["틀꼴"]} if x.get("틀꼴") else {}) ) out.append(one | {"줄": lines}) return out