diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 3a29fefa2..061f8d62b 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -30,6 +30,7 @@ import { newVar, setRow, shapeOf, + sourceOf, unfold, type StackItem, type StackLine, @@ -254,7 +255,9 @@ export function buildBlock( const was = h.before?.get(id); const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]); const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null; - const tag = expr ? null : sourceTag(h, id, link); + const src = v ? sourceOf(h.doc, v) : null; // 가져온 값(812-3) = 옵션 가로 칸과 같은 값 · 꼬리표만 + const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src; + const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link); const tools: HTMLElement[] = []; if (sub?.결과 && link?.키) tools.push( diff --git a/A00_Common/line_stack_sourced.ts b/A00_Common/line_stack_sourced.ts index 8bbbec8f9..aaadc0aa4 100644 --- a/A00_Common/line_stack_sourced.ts +++ b/A00_Common/line_stack_sourced.ts @@ -51,7 +51,7 @@ export function buildSourced( const n = Number(f.input.value.trim().replace(/,/g, "")); if (f.input.value.trim() === "" || !Number.isFinite(n)) return void (f.input.value = String(v.연결?.값 ?? "")); // 수만 — 빈 값 · 글 = 그대로 - set(id, v, String(n)); + set(id, v, n); // 수 그대로(글 「0.7」 = 셈 「—」 · 공식 묶음 값 칸 `setRow` 와 같은 꼴 · 812-3) }); f.root.dataset.option = id; const tagText = tag === "원파일" ? lt("Src_Native") : tag === "엑셀" ? lt("Src_Excel") : tag; diff --git a/M02_MasterTemplete/M02_Doc_Import_Shape.py b/M02_MasterTemplete/M02_Doc_Import_Shape.py index 2243ffd22..063a16d14 100644 --- a/M02_MasterTemplete/M02_Doc_Import_Shape.py +++ b/M02_MasterTemplete/M02_Doc_Import_Shape.py @@ -161,7 +161,10 @@ def machines(doc: dict, title: dict[str, str]) -> None: def shape(doc: dict, meta: dict) -> dict: """derive 문서 다듬기 — 원본 글줄 · 엔진 줄 자원 이름.""" + from M02_MasterTemplete import M02_Doc_NativeLayout as lay + src = meta.get("원본") or {} + lay.tag(doc, meta) # 812-3 원파일 차림 — 금액 줄 ↔ 로직 줄(끝에 `apply`) names(doc, list(src.get("글줄") or [])) title = {} for item in (meta.get("로직") or {}).get("호표") or []: @@ -169,4 +172,5 @@ def shape(doc: dict, meta: dict) -> dict: if res.get("갈래") == "기계" and res.get("이름"): title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x) machines(doc, title) + lay.apply(doc, meta) return doc diff --git a/M02_MasterTemplete/M02_Doc_NativeEst.py b/M02_MasterTemplete/M02_Doc_NativeEst.py index dc3e82908..fc47a82c2 100644 --- a/M02_MasterTemplete/M02_Doc_NativeEst.py +++ b/M02_MasterTemplete/M02_Doc_NativeEst.py @@ -19,6 +19,7 @@ from __future__ import annotations import re +from M02_MasterTemplete import M02_Doc_NativeLayout as lay from M02_MasterTemplete import M02_San_Emit as em from M02_MasterTemplete import M02_San_Est as est from M02_MasterTemplete import M02_San_Expr as ex @@ -76,6 +77,8 @@ class Block: self.groups: dict[str, list[tuple[str, float]]] = {} # 구간 글자 → (부름 글, 결과) self.extra: dict[int, dict] = {} # 행 → 소계가 정한 비목(아래 줄) self.got = {"확정": 0, "표지": 0, "추정": 0} + self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌) + self.frac: set[int] = set() # ── 작은 도구 ────────────────────────────────────────────────── def mark(self, ln: dict, why: str) -> None: @@ -182,6 +185,7 @@ class Block: return re.sub(r"\s*([×÷·+*/−-])\s*", r"\1", t).strip().split() tops, bottoms, at = cols(i - 1), cols(i + 1), [0] + self.frac |= {self.lines[k]["행"] for k in (i - 1, i + 1) if 0 <= k < len(self.lines)} def one(_: re.Match) -> str: k = at[0] @@ -485,6 +489,7 @@ class Block: def text_line(self, ln: dict) -> None: """글 줄 — 품셈 · 조건 이름만 있는 글(값 없음) = 「추정」 표지(연결 안 함 · 810-3 화면).""" + self.memo.append(ln["행"]) if _COND.search(ln["보임"]): self.o.glyph( ln["행"], ln["보임"], mark=f"{NOTE}{GUESS} — 품셈 조건 글(값 없음 · 연결 안 함)" @@ -493,6 +498,22 @@ class Block: return self.o.glyph(ln["행"], ln["보임"]) + def tags(self) -> None: + """812-3 차림 표지(`M02_Doc_NativeLayout`) — 글줄 `차림` = 머리(단 > 0 · + 소계 구간 첫 글 · 번호 글) · 합계 · 글(근거 · 메모).""" + self.o.head[lay.TAG] = True + for ln in self.lines: + one = self.o.at.get(ln["행"]) + if one is None or ln["행"] in self.frac or "자리" in one: + continue + head = (ln.get("단") or 0) > 0 or ln.get("색인") == -20 + if ln.get("합계"): + one[lay.TAG] = "합계" + elif head or (ln["행"] in self.memo and lay.NUMBERED.match(one["글"])): + one[lay.TAG] = "머리" + elif ln["행"] in self.memo: + one[lay.TAG] = "글" + # ── 한 벌 ───────────────────────────────────────────────────── def run(self) -> dict: for k, ln in enumerate(self.lines): # 「노무비 : ‹단가›」(식 · 결과 없음) = 단가 × 1 @@ -544,6 +565,7 @@ class Block: elif (ln.get("보임") or "").strip(): self.text_line(ln) i += 1 + self.tags() saved = self.blk.get("저장") or {} check = {COMP[c]: saved.get(c) or 0 for c in COMP} | {"계": saved.get("T") or 0} out = self.o.done("비목버림", None, check, "산근", self.file) diff --git a/M02_MasterTemplete/M02_Doc_NativeLayout.py b/M02_MasterTemplete/M02_Doc_NativeLayout.py new file mode 100644 index 000000000..685bd3fda --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_NativeLayout.py @@ -0,0 +1,442 @@ +"""가져온 단가산출 줄 쌓기 차림(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 계 줄 뒤 — 다음 첫 글 줄 = 제목 + + 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: + 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 _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 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 []} + 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 ("요소", "단가산출") 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 diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index f10a7c757..5abf9e17c 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -486,11 +486,15 @@ def stmate( me = prices.get(block["코드"]) or {} meta = me.get("meta") or {} o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"]) + o.head["차림"] = True # 812-3 — 계 단으로 항목 · 글 줄(`M02_Doc_NativeLayout`) st = _Stm(o, block, prices, keys) for node in nodes: kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""} if kind in ("빈", "글"): - if src["글"].strip(): + if src["글"].strip().strip("'").strip(): + o.glyph(src["행"], src["글"]) + o.at[src["행"]]["차림"] = "글" # 812-3 줄 쌓기 글 줄 · 제목(`M02_Doc_NativeLayout`) + elif src["글"].strip(): o.glyph(src["행"], src["글"]) elif kind == "대입": st.assign(node, src) diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index b92e49ba8..149be38ab 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -150,10 +150,11 @@ export function sourceOf(doc: FormulaDoc, v: DocVar): string | null { return String((v as { 출처?: string }).출처 ?? (head.원파일 ? "원파일" : "엑셀")); } -/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 · 가져온 식 값 옵션 칸 */ +/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 식 값 옵션 칸은 공식 묶음에도 보임 · 같은 값 · 812-3) + * 파이썬 거울 = `M02_Doc_NativeLayout.py`(stackOf · saveOf · hiddenOf · 가져온 단가산출 차림) — 고칠 때 같이 고침 */ export function hiddenOf(doc: FormulaDoc): Set { const out = new Set(ownedOf(doc).values()); - for (const v of doc.변수 ?? []) if (isOption(v) || sourceOf(doc, v)) out.add(varId(v)); + for (const v of doc.변수 ?? []) if (isOption(v)) out.add(varId(v)); return out; } diff --git a/resources/tester/helper_812_3_layout.cjs b/resources/tester/helper_812_3_layout.cjs new file mode 100644 index 000000000..18d06b81b --- /dev/null +++ b/resources/tester/helper_812_3_layout.cjs @@ -0,0 +1,52 @@ +/* 812-3 가져온 단가산출 차림 헬퍼 — 줄 쌓기 모델(common_util_line_stack.ts)을 그 자리에서 트랜스파일해 Node 로 돌림. + * stdin = [{키, 문서, 파이썬 [{제목, 가나다?, 줄 [{꼴, i | 기호 | 글}]}]}] + * stdout = [{키, 같음(문서 → 줄 → 문서 · 칸 차례 무시), 거울(TS stackOf = 파이썬 stack_of), 항목 · 제목 · 공식 · 글 · 쌓기}] + * 짝 = test_812_3_native_layout.py · 파이썬 거울 = M02_Doc_NativeLayout.py */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const ls = load(path.join(__dirname, "..", "..", "common_util", "common_util_line_stack.ts")); + +function canon(v) { + if (Array.isArray(v)) return v.map(canon); + if (v && typeof v === "object") { + const out = {}; + for (const k of Object.keys(v).sort()) out[k] = canon(v[k]); + return out; + } + return v; +} +const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b)); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const out = input.map(({ 키, 문서, 파이썬 }) => { + const items = ls.stackOf(문서); + const back = ls.docOf(문서, items); + const lines = items.flatMap((it) => it.줄); + return { + 키, + 같음: same(back, 문서), + 거울: same(items, 파이썬), + 항목: items.length, + 제목: items.filter((it) => it.제목.trim()).length, + 공식: lines.filter((l) => l.꼴 === "공식" && l.기호.length).length, + 글: lines.filter((l) => l.꼴 === "글").length, + 쌓기: "쌓기" in 문서, + ...(same(items, 파이썬) ? {} : { ts: items }), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_810_3_native_screen.py b/resources/tester/test_810_3_native_screen.py index ed3f26461..c33014b78 100644 --- a/resources/tester/test_810_3_native_screen.py +++ b/resources/tester/test_810_3_native_screen.py @@ -4,7 +4,7 @@ (a) 화면 셈 거울 — EST 여섯 · STC 일곱 산근 블록 모두: 화면 길(TS importView → calcDoc) = 서버 길 (`M02_Doc_Import.run` 계산기) 답 전부 같음(줄 · 비목 · 합 · 표지) (b) 옵션 칸 — 원파일 수 하나 변수(출처 「원파일」) = 옵션 가로 칸(`sourceOf`) · - 줄 쌓기 공식 묶음에 안 들어감 · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님 + 줄 쌓기 공식 묶음에도 보임(812-3) · 금액 줄 단가로 쓰는 수 · 엑셀 단가 기억 연결 = 옵션 칸 아님 (c) 추정 칸 — 문서 머리 `가져온.추정` 줄 수 = 원본 확정 추정 수(EST) · 원코드 · 규격 머리 (d) 소계 줄 — 합 밖 · 비목 없음(화면 비목 칸 빈칸 · 종류 「소계」 — `line_stack_edit` · `line_stack_out` 글 대조) @@ -121,7 +121,8 @@ def test_screen_calc_mirror_and_option_cells(kind: str, tag: str) -> None: and not any(str(m.get("단가")) == v["열쇠"] for m in doc.get("금액") or []) ] assert set(nums) <= set(shape["옵션"]), (tag, key, nums, shape["옵션"]) - assert not set(shape["옵션"]) & set(shape["공식"]), (tag, key) # 옵션 칸 = 공식 묶음 밖 + # 옵션 칸 = 공식 묶음에도 보임(812-3 · 같은 값) + assert set(shape["옵션"]) <= set(shape["공식"]), (tag, key) for v in doc["변수"]: if v["열쇠"] in shape["옵션"]: assert v["연결"]["갈래"] == "숫자" and "엑셀" not in v["연결"] diff --git a/resources/tester/test_812_3_native_layout.py b/resources/tester/test_812_3_native_layout.py new file mode 100644 index 000000000..41868858c --- /dev/null +++ b/resources/tester/test_812_3_native_layout.py @@ -0,0 +1,223 @@ +"""812-3 지킴 — 가져온 단가산출(원파일) 줄 쌓기 차림 = 경쟁사 산출근거 꼴(요구 713). + +차림 = `M02_Doc_NativeLayout`. +(a) 견본(거창 덤프 꼴) — 1 측구터파기(토사) 「기 계 … 굴삭기」 항목 + 공식 묶음 + q · K · f · E · Cm · Q + 보조인부 항목 · 41 합판거푸집(4회) 근거 글 · + 재료비(재료 · 소계 · 율 · 글) · 인건비(인력 · 소계 · 율 · 글) · + 15 전석 메쌓기 별산 · 단산 참조 Q 직접 · 기계 · 인부 · + 2 가 · 나 소항목 · 봉화 STC = 계 단마다 항목 +(b) 전수 — 원파일 EST · STC 산근 블록 모두: 차림 전후 셈(비목 · 계 · 줄 금액) 같음 · + TS 왕복(문서 → 줄 → 문서) 같음 · TS `stackOf` = 파이썬 거울 `stack_of` · + 수 표 = tmp/s8123/sweep.json(일회 기록) +(c) M01 문서 1,518 — 차림 안 바뀜(쌓기 없음 · 왕복 같음 · 거울 같음) +원파일 없으면 그 시험만 skip(다른 PC). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from functools import lru_cache +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import test_810_2_native_doc as t2 # noqa: E402 +from test_810_1_estx import FILES as EST # noqa: E402 +from test_810_1_stc import FILES as STC # noqa: E402 + +from M02_MasterTemplete import M02_Doc_Copy as dc # noqa: E402 +from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 +from M02_MasterTemplete import M02_Doc_NativeLayout as lay # noqa: E402 + +COSTS = ("노무비", "재료비", "경비", "계") + + +def _ts(cases: list[dict]) -> list[dict]: + proc = subprocess.run( + ["node", str(HERE / "helper_812_3_layout.cjs")], + input=json.dumps(dc.ml._norm(cases), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr[-3000:] + return json.loads(proc.stdout) + + +def _doc(kind: str, tag: str, no: int, laid: bool = True) -> tuple[dict, dict]: + sk = t2.sk_of(t2.fn.emit(t2.native(kind, tag), no)) + keep = lay.apply + if not laid: + lay.apply = lambda doc, meta: [x.pop(lay.KEEP, None) for x in doc.get("금액") or []] + try: + return sk, dim.derive(sk, None, t2.whole()) + finally: + lay.apply = keep + + +def _items(doc: dict) -> list[dict]: + return lay.stack_of(doc) + + +def _show(doc: dict) -> list[tuple]: + """(제목, 가나다, [줄 글]) — 금액 = 이름 · 공식 = 「=기호…」 · 글 = 「#글」.""" + money = doc["금액"] + out = [] + for it in _items(doc): + lines = [] + for x in it["줄"]: + if x["꼴"] == "금액": + lines.append(money[x["i"]]["이름"]) + elif x["꼴"] == "공식": + lines.append("=" + " ".join(x["기호"])) + else: + lines.append("#" + x["글"]) + out.append((it["제목"], bool(it.get("가나다")), lines)) + return out + + +# ── (a) 견본 ───────────────────────────────────────────────────────────── +def test_sample_excavation_machine_and_formula() -> None: + _, doc = _doc("EST", "거창", 1) + got = _show(doc) + assert got == [ + ("기 계(100%) : 굴삭기(무한궤도) (0.7㎥)", False, ["=q K f E Cm Q", "굴삭기(무한궤도)"]), + ("보조인부(1일당 1인)", False, ["보통인부"]), + ], got + assert [x["묶음"] for x in doc["금액"]] == [got[0][0], got[1][0]] + + +def test_sample_formwork_text_rate_subtotal() -> None: + _, doc = _doc("EST", "거창", 41) + got = _show(doc) + assert got[0] == ( + "", + False, + ["#< 토목품셈 6-3-1 합판거푸집(2022개정) >", "#※ 사용횟수 4회 : 보통"], + ) + assert got[1] == ( + "재료비", + False, + [ + "합판(내수),대전", + "외송각재,대전", + "주자재소계", + "#소모자재(박리재등) (주자재비의 9%)", + "율", + ], + ) + assert got[2][0] == "인건비 (24년 품셈 개정)" and got[2][2][:3] == [ + "형틀목공", + "보통인부", + "인부소계", + ] + assert got[2][2][3].startswith("#공구손료") and len(got) == 3 + sub = next(x for x in doc["금액"] if x["이름"] == "주자재소계") + assert sub["합밖"] and "비목" not in sub # 소계 = 합 밖 · 비목 칸 빈칸 그대로(810-2) + + +def test_sample_stone_wall_reference_and_direct_q() -> None: + _, doc = _doc("EST", "거창", 15) + got = _show(doc) + titles = [g[0] for g in got] + assert titles[0] == "전석 구입운반 : 별산" and got[0][2] == [] + assert ( + titles[1].startswith("뒷채움돌 운반 및 채집") + and got[1][2][-1] == "뒷채움돌기초잡석채집운반" + ) + assert got[1][2][0].split()[0] == "=Q" # 단산 참조 Q 직접 = 그 항목 공식 묶음 + assert titles[3].startswith("전석쌓기 기계") and got[3][2] == ["=Q_3", "굴삭기(무한궤도)"] + assert got[4] == ("전석쌓기 인부", False, ["석공", "보통인부"]) + assert "합" not in "".join(titles) # 합계 절 = 화면 자동 합계(두 번 안 보임) + + +def test_sample_stc_steps_are_items() -> None: + _, doc = _doc("STC", "봉화", 14) + got = [ + (t, g, [x for x in ls if not x.startswith("=P_")]) for t, g, ls in _show(doc) + ] # 손 단가 = 기본 자리 + assert got == [ + ("", False, ["#▣ 토공의 비탈규준틀[개소]2-6-1"]), + ("재 료 비", False, ["각재(거푸집용,외송)", "판재", "일반못(철못)"]), + ("인 건 비", False, ["건축목공", "보통인부"]), + ], got + + +def test_sample_ganada_sub_items() -> None: + _, doc = _doc("EST", "거창", 2) + got = _show(doc) + assert got[0] == ("기 계(100%)", False, []) + assert got[1][:2] == ("굴삭기(무한궤도) (0.7+브레이카)", True) + assert got[2] == ("치즐(0.7㎥용)", True, ["치즐(0.7㎥)"]) # 「다. 소 계」 절 = 뺌 + + +# ── (b) 전수 ───────────────────────────────────────────────────────────── +def _money(sk: dict, doc: dict) -> tuple: + got = dim.run(doc, sk, t2.whole()) + lines = sorted(str(x.get("금액")) for x in got.get("줄") or []) + return tuple(str(got.get(c)) for c in COSTS), lines + + +@lru_cache(maxsize=1) +def _sweep() -> dict: + stat = {"블록": 0, "금액같음": 0, "다름": [], "cases": []} + for kind, files in (("EST", EST), ("STC", STC)): + for tag, path in files.items(): + if not path.exists(): + continue + for no in t2.fn.numbers(t2.native(kind, tag)): + sk, doc = _doc(kind, tag, no) + _, plain = _doc(kind, tag, no, laid=False) + stat["블록"] += 1 + if _money(sk, doc) == _money(sk, plain): + stat["금액같음"] += 1 + else: + stat["다름"].append((kind, tag, no)) + stat["cases"].append( + {"키": f"{kind}|{tag}|{no}", "문서": doc, "파이썬": _items(doc)} + ) + return stat + + +def test_sweep_money_same_and_ts_round_trip() -> None: + stat = _sweep() + if not stat["블록"]: + pytest.skip("원파일 없음") + assert not stat["다름"] and stat["금액같음"] == stat["블록"], stat["다름"][:5] + got = _ts(stat["cases"]) + bad = {f: [g["키"] for g in got if not g[f]] for f in ("같음", "거울")} + assert bad == {"같음": [], "거울": []}, {k: v[:5] for k, v in bad.items()} + by = {} + for g in got: + kind = g["키"].split("|")[0] + row = by.setdefault( + kind, dict.fromkeys(("블록", "항목", "제목", "공식", "글", "쌓기", "무제목만"), 0) + ) + row["블록"] += 1 + for f in ("항목", "제목", "공식", "글"): + row[f] += g[f] + row["쌓기"] += g["쌓기"] + row["무제목만"] += g["항목"] > 0 and g["제목"] == 0 + out = ROOT / "tmp" / "s8123" + out.mkdir(parents=True, exist_ok=True) + (out / "sweep.json").write_text(json.dumps(by, ensure_ascii=False, indent=1), "utf-8") + assert by.get("EST", {}).get("제목", 0) > by.get("EST", {}).get("블록", 0) + + +# ── (c) M01 ────────────────────────────────────────────────────────────── +def test_m01_docs_layout_unchanged() -> None: + docs = dc.folder() + assert len(docs) >= 1518 + cases = [{"키": k, "문서": d, "파이썬": _items(d)} for k, d in docs.items()] + got = _ts(cases) + bad = {f: [g["키"] for g in got if not g[f]] for f in ("같음", "거울")} + assert bad == {"같음": [], "거울": []}, {k: v[:5] for k, v in bad.items()} + assert not any(g["쌓기"] for g in got)