diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py new file mode 100644 index 000000000..fbe0ce94d --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -0,0 +1,223 @@ +"""경쟁사 원파일(.estx · .stc) → 가져온 식(PLAN 810-2 · 요구 705 · 706 · 서버 단독 · 원파일 우선). + +읽기 = 810-1 파서(`M02_San_Estx` · `M02_San_Stc`) → 둘 꼴(`pack`)을 실무자료 산근 자리 +(`M02_Sangeun_FromHopyo` · `실무자료/산근/.json` 칸 `원파일`)에 둠(원본 파일은 안 둠) → +블록 하나 = `{로직, 원본}`(`emit` · 793-2a 꼴 → 가져온 식 SK → `M02_Doc_Import.derive` 수식 문서): + EST = `M02_Doc_NativeEst`(구조 줄 표지로 정함) · + STmate = 원문 그대로 `M02_San_Emit.stmate`(원파일 원문 = 엑셀 숨은 G 열 · 단가 = COSTN · + 끝전 = 옵션 SAN_TYP 1 비목버림 / 0 합버림_큰나머지 · 810-1 저장값 대조로 확인). +블록 번 = 엑셀 길과 같음(EST = 산근 표 차례 1부터 = 「단산N」 · STmate = 코드 숫자). +같은 공사 = 엑셀 산근 블록이 모두 원파일 블록과 금액 · 이름이 같음(`same`) — 둘 다 있으면 원파일. +일위대가(EST HeadMainDat) → 실무자료 호표 꼴(`hopyo`) · STmate 일위대가 구성은 못 읽음(BDQTY 난독화) → +호표 없음(엑셀을 같이 올리면 엑셀 호표 + 원파일 산근). +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Doc_NativeEst as ne +from M02_MasterTemplete import M02_San_Emit as em + +EXT = (".estx", ".stc") +RULE = {"0": "합버림_큰나머지"} # STmate SAN_TYP — 그 밖 = 비목버림 +_EST_KEYS = ("행", "글", "보임", "품명", "단", "색인", "연결", "비목", "자원", "연결단가", "변수") +_EST_KEYS += ("줄참조", "결과", "합계", "저장") +_RES_KEYS = ("코드", "종류", "이름", "규격", "단위") + + +class NativeError(ValueError): + """못 읽는 원파일.""" + + +def is_native(name: str) -> bool: + return str(name or "").lower().endswith(EXT) + + +def read(name: str, data: bytes) -> dict[str, Any]: + """원파일 → 둘 꼴(`pack`) · 못 읽음 = NativeError.""" + from M02_MasterTemplete import M02_San_Estx as estx + from M02_MasterTemplete import M02_San_Stc as stc + + try: + got = estx.read(data) if name.lower().endswith(".estx") else stc.read(data) + except Exception as e: # noqa: BLE001 — 파서 오류 = 사유 글 + raise NativeError(f"원파일을 못 읽음 — {type(e).__name__}: {str(e)[:80]}") from e + return pack(got, name) + + +def pack(got: dict, name: str) -> dict[str, Any]: + """파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON).""" + out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []} + head = ("코드", "이름", "규격", "단위", "저장") + if got["꼴"] == "EST": + out["산근"] = [ + {**{k: b.get(k) for k in head}, "줄": [_est_line(ln) for ln in b["줄"]]} + for b in got["산근"] + ] + out["일위"] = [{**{k: b.get(k) for k in head}, "줄": b["줄"]} for b in got["일위"]] + return out + out["산근"] = [ + {**{k: b.get(k) for k in head}, "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]]} + for b in got["산근"] + ] + out["일위"] = [{k: b.get(k) for k in head} for b in got["일위"]] + res = got.get("자원") or {} + out["단가"] = { + code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}} + for code, p in (got.get("단가") or {}).items() + } + out["끝전"] = RULE.get(str((got.get("옵션") or {}).get("SAN_TYP")), "비목버림") + return out + + +def _est_line(ln: dict) -> dict: + one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")} + if one.get("자원"): + one["자원"] = {k: ln["자원"].get(k) for k in _RES_KEYS} + return one + + +# ── 블록 ────────────────────────────────────────────────────────────── +def numbers(saved: dict) -> dict[int, dict]: + """블록 번 → 산근 블록(엑셀 길 번과 같음).""" + if saved["꼴"] == "EST": + return {i: b for i, b in enumerate(saved["산근"], 1)} + return {int(str(b["코드"])[1:]): b for b in saved["산근"] if str(b["코드"])[1:].isdigit()} + + +def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]: + """원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError.""" + blk = numbers(saved)[int(no)] + if saved["꼴"] == "EST": + names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)} + return ne.block(blk, int(no), keys, saved["파일"], names) + from M02_MasterTemplete import M02_San_Stmate as stm + + nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]] + saved_amt = blk.get("저장") or {} + head = {"C": saved_amt.get("T"), "D": saved_amt.get("L"), "E": saved_amt.get("M")} + head["F"] = saved_amt.get("S") + block = {"코드": blk["코드"], "이름": blk.get("이름"), "몸": [], "꼬리": [], "머리": head} + out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"]) + marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지")) + sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"]) + out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}} + return out + + +def inner(saved: dict, logic: dict) -> list[tuple[dict, int | None, str | None]]: + """안쪽 산근 줄 `[(호표 줄, 같은 원파일 블록 번, 못 잇는 까닭)]`(`FromHopyo._inner` 짝).""" + have = numbers(saved) + by_code = {str(b.get("코드")): n for n, b in have.items()} + out = [] + for line in logic["호표"]: + res = line.get("자원") or {} + if res.get("갈래") != "단가산출": + continue + n = by_code.get(str(res.get("코드"))) + if n is None: + out.append((line, None, "같은 원파일에 안쪽 블록 없음")) + elif res.get("옮김"): + out.append((line, n, "비목이 바뀐 부름")) + else: + out.append((line, n, None)) + return out + + +# ── 같은 공사 · 일위대가 ─────────────────────────────────────────────── +def same(saved: dict, sheets: dict) -> bool: + """엑셀 산근 블록이 모두 원파일 같은 번 블록과 계 금액(1 원 안)이 같음 = 같은 공사.""" + from M02_MasterTemplete import M02_San_Book as book + + have = numbers(saved) + kind = book.kind_of(sheets) + if not kind or (kind == "STmate") != (saved["꼴"] == "STmate"): + return False + if kind == "STmate": + excel = {int(b["코드"][1:]): b["머리"].get("C") for b in book.stmate_blocks(sheets)} + else: + excel = {int(n): t.get("T") for n, t in book.est_prices(sheets)[1].items()} + if not excel: + return False + for n, total in excel.items(): + blk = have.get(int(n)) + if blk is None or abs(float(total or 0) - float((blk.get("저장") or {}).get("T") or 0)) > 1: + return False + return True + + +GALLAE = {6: "노임", 5: "자재", 105: "기계", 104: "단가산출", 103: "호표", 21: "기타", 101: "기타"} + + +def hopyo(saved: dict) -> dict[str, Any]: + """EST 일위대가(HeadMainDat) → 실무자료 읽기 꼴(`M02_Hopyo_Excel.read_book` 짝 · 호표 · 단산) · + STmate = 호표 없음(구성 못 읽음).""" + blocks = numbers(saved) + names = {str(b.get("코드")): n for n, b in blocks.items()} + heads = {str(h.get("코드")): n for n, h in enumerate(saved.get("일위") or [], 1)} + out: dict[str, Any] = {"꼴": "원파일", "시트": saved["꼴"], "호표": [], "검산": {"어긋남": []}} + out["단산"] = [ + {"번": n, "이름": b.get("이름"), "단위": b.get("단위"), "금액": _cost(b.get("저장"))} + for n, b in blocks.items() + ] + if saved["꼴"] != "EST": + return out + for n, h in enumerate(saved.get("일위") or [], 1): + lines = [] + for k, ln in enumerate(h.get("줄") or []): + link = ln.get("연결") + code = str((ln.get("자원") or {}).get("코드") or "") + ref = None + if link == 104 and code in names: + ref = {"종류": "단산", "번": names[code]} + elif link == 103 and code in heads: + ref = {"종류": "일위", "번": heads[code]} + kind = "%" if ln.get("연결") in (52, 58, 62) else GALLAE.get(link or 0, "기타") + if kind == "자재" and str(ln.get("단위") or "") == "인": + kind = "노임" + if not any((ln.get("단가") or {}).values()) and kind not in ("%",): + kind = "별도계상" if not ref else kind + lines.append( + { + "차례": k, + "이름": ln.get("품명") or "", + "규격": ln.get("규격") or "", + "수량": _dec(ln.get("수량")), + "단위": ln.get("단위") or "", + "단가": _cost(ln.get("단가")), + "금액": _cost(ln.get("저장")), + "비고": "", + "참조": ref, + "갈래": kind, + "행": ln.get("행"), + "코드": code or None, + } + ) + out["호표"].append( + { + "번": n, + "이름": h.get("이름"), + "규격": h.get("규격") or "", + "단위": h.get("단위") or "", + "금액": _cost(h.get("저장")), + "행": [n, n], + "줄": lines, + } + ) + return out + + +def _dec(v: Any) -> int | float | None: + """수 → 엑셀 읽기와 같은 꼴(정수 = int · 아니면 float · 없음 = None).""" + if v is None: + return None + d = Decimal(str(v)) + return int(d) if d == d.to_integral_value() else float(d) + + +def _cost(d: dict | None) -> dict[str, Any]: + d = d or {} + out = {"계": _dec(d.get("T"))} + return out | {name: _dec(d.get(c)) for c, name in em.COMP.items()} diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 019c19435..a962a362b 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -26,7 +26,8 @@ from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Imported as imp XL = "엑셀" # 연결 칸 — 엑셀 값 기억 -HEAD = ("파일", "시트", "행", "갈래", "코드") +HEAD = ("파일", "시트", "행", "갈래", "코드", "원파일") +SUB = "원파일 소계" # 원파일 소계 덧줄(810-2 `M02_Doc_NativeEst`) — 합 밖 · 비목 칸 비움 PICK = "소요량" # 고른 표 칸 기호 이름 머리 _SCALE = {"eq": (1,), "pct": (100, 0.01), "kgt": (1000, 0.001)} @@ -292,6 +293,8 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: doc, notes = dc.cv.convert(_Row(master, key, row), row, given) _attach(doc, row, ids, src) _numbers(doc, keep) + if (meta.get("원본") or {}).get("원파일"): # 원파일(810-2) — 소계 비목 칸 비움 · 값 변수 출처 + _native(doc) head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)} marks = list((meta.get("원본") or {}).get("표지") or []) if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")): @@ -302,6 +305,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))} +def _native(doc: dict) -> None: + """원파일 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 「원파일」.""" + for line in doc.get("금액") or []: + if line.get("출처") == SUB and line.get("합밖"): + line.pop("비목", None) + for var in doc.get("변수") or []: + if (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]: + var["출처"] = "원파일" + + EMPTY = "참고 · 빈 산근 — 엑셀 줄 없음 · 금액 0 그대로" diff --git a/M02_MasterTemplete/M02_Doc_NativeEst.py b/M02_MasterTemplete/M02_Doc_NativeEst.py new file mode 100644 index 000000000..dc3e82908 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_NativeEst.py @@ -0,0 +1,570 @@ +"""EST 원파일 산근 블록 → 가져온 로직 줄(PLAN 810-2 · 요구 705 · 706 · 서버 단독 · 읽기만). + +바탕 = `M02_San_Estx.read` 구조 줄(칸 뜻 `resources/master_data/_원파일파서_틀.md`) — 엑셀 글 추정 대신 +원파일 표지로 정함(`M02_San_Est` 값 역색인 안 씀): + 단가 = `\\x03` 수 + 자원 코드(LinkIndex) · 변수 = `\\x11` 수(앞 변수 줄 값) · 줄참조 = `\\x0c` 수 · + 결과 = `\\x12` 수 · 비목 = SCheck(음수 = 그 줄이 냄 · 율 · 소계 양수 = 대상). +꼴 = 793-2a 내보내기(`M02_San_Emit.Out`) 그대로 → `M02_Sangeun_Imported.create` · `M02_Doc_Import.derive` + 가 수식 문서로(엑셀 길과 같은 길 · 같은 연결 규칙 `M02_San_Emit._m01` · 797-1 기계 시간당 이음). + 변수 대입 줄 = 중간(변수 그대로 · 공식 글 · 분수 그림 ─ 은 위아래 줄 칸 · 결과와 맞을 때만 식 · + 아니면 결과 값 + 표지) · 자원 줄 = 호표(단가 = 원파일 값 · 수량 = 식 · 변수 = 이름) · + 기계 · 산근 · 견적 세 줄(같은 코드 · 노 · 재 · 경 · 같은 수량 식) = 로직 값묶음 한 줄(소계 줄 = 글) · + 소계(61) = 합 밖 덧줄(출처 `SUB` · 문서에서 비목 칸 비움) + 아래 줄이 그 비목으로 합에 듦 · + 율(58 · 62) = 덧줄(줄참조 = 앞 줄 부름 · 구간 `\\x07X\\x07` = `\\x06X\\x06` 줄 합) · + 합계(\\x1a) = 글(블록 합 = 문서 비목 합 · 끝수 버림 0). +못 읽은 줄 = 보인 결과 값 얼림 + 「참고 · …」 표지(`NOTE`) — 지어 넣는 값 없음. +""" + +from __future__ import annotations + +import re + +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 + +COMP = em.COMP +NOTE = em.NOTE +SUB = "원파일 소계" # 덧줄 출처 — 합 밖 소계(문서 비목 칸 비움 · `M02_Doc_Import`) +TRIPLE = (101, 104, 105) # 견적 · 산근 · 기계 — 노 · 재 · 경 세 줄 +GALLAE = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"} +GALLAE["표준"] = "고정" +NATURAL = {"노임": "L", "자재": "M"} +_SENT = 7_770_000.0 # 변수 이름 자리 표 수(토큰 읽기용 · 셈 전에 값으로 바꿈) +_ASSIGN = re.compile(r"(? str: + return _CTRL.sub("", _DROP.sub(" ", raw or "")) + + +def _close(a: float, b: float) -> bool: + return abs(a - b) < 1e-6 * max(1.0, abs(b)) + + +def _fit(got: float, want: float) -> str | None: + """보인 값이 되는 자리 규칙 — `M02_San_Expr.fit`(3 자리까지) + 버림 · 반올림 4~6 자리(EST Diget).""" + if rule := ex.fit(got, want): + return rule + for n in (4, 5, 6): + q = 10**n + if abs(ex.math.floor(round(got * q, 6)) / q - want) < 1e-9: + return f"버림{n}" + if abs(ex.math.floor(got * q + 0.5 + 1e-9) / q - want) < 1e-9: + return f"반올림{n}" + return None + + +class Block: + """EST 산근 블록 한 벌 → `{로직, 원본}`.""" + + def __init__(self, blk: dict, no: int, keys: bool, file: str, names: dict[str, int]) -> None: + name = str(blk.get("이름") or blk.get("코드") or "") + if blk.get("규격"): + name = f"{name}({blk['규격']})" + self.o = em.Out(name, blk.get("단위"), "EST", no) + self.o.head |= {"원파일": blk.get("코드")} + self.blk, self.keys, self.file, self.nos = blk, keys, file, names + self.lines: list[dict] = blk.get("줄") or [] + self.vars: dict[str, tuple[str, float]] = {} # 원 이름 → (엔진 이름, 값) + self.done: list[tuple[float, str, dict]] = [] # (결과, 부름 글, 줄) — 줄참조 찾기 + self.groups: dict[str, list[tuple[str, float]]] = {} # 구간 글자 → (부름 글, 결과) + self.extra: dict[int, dict] = {} # 행 → 소계가 정한 비목(아래 줄) + self.got = {"확정": 0, "표지": 0, "추정": 0} + + # ── 작은 도구 ────────────────────────────────────────────────── + def mark(self, ln: dict, why: str) -> None: + self.o.glyph(ln["행"], ln.get("보임") or "", mark=f"{NOTE}{why}") + self.got["표지"] += 1 + + def _sub(self, seg: str) -> tuple[str, dict[float, str]]: + """식 조각 속 아는 변수 이름 → 표 수(뒤에서 이름으로 바꿈).""" + out, k = {}, 0 + for name in sorted(self.vars, key=len, reverse=True): + pat = re.compile(rf"(? tuple[tuple, dict, str | None] | None: + """식 조각 → (나무, {토큰 차례: 변수 이름}, 자리 규칙) · 결과와 맞지 않으면 None · + extra = {표 수: 값}(구간 합 자리).""" + seg, names = self._sub(_power(seg.replace("·", "×"))) + toks = est.tokens(seg) + if not toks: + return None + try: + tree = ex.parse(toks) + except ex.ExprError: + return None + vars_ = {n[2]: names[n[1]] for n in ex.numbers(tree) if n[1] in names} + sub = {i: self.vars[v][1] for i, v in vars_.items()} + extra = extra or {} + sub |= {n[2]: extra[n[1]] for n in ex.numbers(tree) if n[1] in extra} + try: + got = ex.value(tree, {"V": {}, "sub": sub}) + except ex.ExprError: + return None + same = want is None or abs(got - want) <= 1e-9 * max(1.0, abs(want)) + rule = "그대로" if same else _fit(got, want) + return (tree, vars_, rule) if rule else None + + def ref_of(self, value: float, before: int) -> str | None: + """줄참조 수 → 앞 줄 부름(가장 가까운 같은 결과).""" + for v, call, ln in reversed(self.done): + if ln["행"] < before and _close(v, value): + return call + return None + + def var_of(self, value: float) -> str | None: + for name, (_, v) in reversed(list(self.vars.items())): + if _close(v, value): + return name + return None + + # ── 변수 줄 ──────────────────────────────────────────────────── + def assigns(self, i: int, ln: dict) -> bool: + """「q = 0.7 K = 0.55 f = 1 / 1.325 = 0.75」 · 분수 그림 Q = ── = ── = 23.39 → 중간.""" + text = _clean(ln.get("글") or "") + hits: list[re.Match] = [] + for m in _ASSIGN.finditer(text): # 앞 대입 값이 끝난 뒤(연산 · = 뒤 아님)만 새 대입 + gap = text[hits[-1].end() : m.start()] if hits else "" + if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)): + hits.append(m) + lead = text[: hits[0].start()].strip(" ○●·-") if hits else "" + if not hits or (lead and not re.search(r"[::]$", lead)): + return False + for n, m in enumerate(hits): + end = hits[n + 1].start() if n + 1 < len(hits) else len(text) + self.assign(i, ln, m.group(1), text[m.end() : end]) + return True + + def assign(self, i: int, ln: dict, name: str, rhs: str) -> None: + parts = rhs.split("=") + lead = re.match(r"\s*(" + est.NUMRE + r")", parts[-1]) + if not lead: + return + value = est.num(lead.group(1)) + body = None + for seg in parts[:-1]: + if "─" in seg: + seg = self.fraction(i, seg) + got = self.formula(seg, value) if seg else None + if got: + body = self.text(got) + break + eng = self.o.names.line(name) + if body is None: + body = em.num(value) + if len(parts) > 1: + self.mark(ln, f"변수 {name} 공식 글 못 읽음 — 결과 값 {em.num(value)}") + self.vars[name] = (eng, value) + self.o.middle(eng, body, None, ln["행"], ln.get("보임") or "") + self.got["확정"] += 1 + + def fraction(self, i: int, seg: str) -> str: + """분수 그림 ─ = 위 줄 · 아래 줄 같은 차례 칸(node `native-text` 와 같은 읽기).""" + + def cols(k: int) -> list[str]: + if not 0 <= k < len(self.lines): + return [] + t = _clean(self.lines[k].get("글") or "") + return re.sub(r"\s*([×÷·+*/−-])\s*", r"\1", t).strip().split() + + tops, bottoms, at = cols(i - 1), cols(i + 1), [0] + + def one(_: re.Match) -> str: + k = at[0] + at[0] += 1 + top = tops[k] if k < len(tops) else "" + low = bottoms[k] if k < len(bottoms) else "" + return f"({top})/({low})" if top and low else "?" + + return re.sub(r"─+", one, seg) + + def text(self, got: tuple, swap: dict[int, str] | None = None) -> str: + """식 글 — 자리 규칙(그대로 빼고)을 식 안 끝수로(금액 줄은 `money` 가 버림1 을 넘김 = 줄 끝수).""" + tree, vars_, rule = got + swap = {**{i: self.vars[v][0] for i, v in vars_.items()}, **(swap or {})} + body = em.text(tree, {}, swap) + m = re.fullmatch(r"(버림|반올림|올림)(\d)", rule or "") + if m: + body = f"{m.group(1)}({body}, {m.group(2)})" + return body + + # ── 금액 줄 ──────────────────────────────────────────────────── + def money(self, ln: dict) -> tuple[str, dict | None] | None: + """금액 줄 식 → (식 글(단가 자리 = 1 · 변수 = 이름 · 줄참조 = 부름), 자리) · 못 읽음 None.""" + raw = ln.get("글") or "" + group: dict[float, str] = {} + sums: dict[float, float] = {} + for k, m in enumerate(_GROUP.finditer(raw)): + calls = self.groups.get(m.group(1)) or [] + if not calls: + return None + mark = _SENT + 500 + k + group[mark] = "(" + " + ".join(c for c, _ in calls) + ")" + sums[mark] = sum(v for _, v in calls) + raw = raw.replace(m.group(0), f" {em.num(mark)} ", 1) + text = _clean(raw) + head = text.split("=", 1)[0] + if ":" in head: + text = text.split(":", 1)[1] + parts = text.split("=") + want = ln["결과"][-1] if ln.get("결과") else None + if want is None or len(parts) < 2: + return None + price = (ln.get("연결단가") or [None])[0] + for seg in parts[:-1]: + bare = re.fullmatch(r"\s*(" + est.NUMRE + r")\s*", seg) + if bare and price is not None and _close(est.num(bare.group(1)), want): + return "1", None # 단가 그대로(× 1) + got = self.formula(seg, want, sums) + if not got: + continue + swap: dict[int, str] = {} + used_price = False + for _, v, idx in ex.numbers(got[0]): + if idx in got[1]: + continue + if v in group: + swap[idx] = group[v] + elif price is not None and not used_price and _close(v, price): + swap[idx], used_price = "1", True + elif v in (ln.get("변수") or []) and self.var_of(v): + swap[idx] = self.vars[self.var_of(v)][0] + elif v in (ln.get("줄참조") or []) and (call := self.ref_of(v, ln["행"])): + swap[idx] = call + if price is not None and not used_price: + continue + place = em._place(got[2]) if got[2] != "그대로" else None + return self.text((got[0], got[1], None), swap), place + return None + + def frozen(self, ln: dict, why: str) -> tuple[str, dict | None]: + self.mark(ln, f"{why} — 보인 값 {em.num(ln['결과'][-1])} 얼림") + return em.num(ln["결과"][-1]), None + + def res(self, ln: dict, part: str | None, price: float | None) -> dict: + r = ln.get("자원") or {} + kind = r.get("종류") + out = {"꼴": "EST", "갈래": GALLAE.get(kind, "고정"), "종류": kind, "코드": r.get("코드")} + out |= {"이름": r.get("이름"), "규격": r.get("규격") or "", "단위": r.get("단위")} + if kind == "산근" and r.get("코드") in self.nos: + out["이름"] = f"단산{self.nos[r['코드']]}" # 같은 파일 안쪽 블록(797-1 형제 SK) + out["원이름"] = r.get("이름") + if part: + nat = NATURAL.get(kind, part) + out |= {"단가": {nat: price}, "비목": nat, "열쇠비목": nat} + out["옮김"] = part if part != nat else None + return out + + def register(self, ln: dict, call: str) -> None: + if ln.get("결과"): + self.done.append((ln["결과"][-1], call, ln)) + g = re.search(r"\x06([A-Z])\x06", ln.get("글") or "") + if g: + self.groups.setdefault(g.group(1), []).append((call, (ln.get("결과") or [0.0])[-1])) + + def item(self, ln: dict, part: str | None, inside: bool) -> None: + """자원 줄(노임 · 자재 · 표준 · 견적) → 호표 단가 줄.""" + price = (ln.get("연결단가") or [None])[0] + got = self.money(ln) if price is not None else None + qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1]) + comp = part or NATURAL.get((ln.get("자원") or {}).get("종류")) or "S" + r = self.res(ln, comp, price if got else ln["결과"][-1]) + name = self.o.names.item(ln.get("품명") or r.get("이름") or "줄") + link, many = em._m01(r["갈래"], r) if self.keys else (None, None) + one = em.resource(name, r.get("단위"), qty, r, link) + one |= {} if inside else {"합밖": True} + one |= {"자리": place} if place else {} + self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1]) + if many: + self.mark(ln, many.removeprefix(NOTE)) + self.got["확정"] += bool(got) + self.register(ln, f"줄.'{name}'") + + def triple(self, rows: list[dict], total: dict | None) -> None: + """기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다.""" + got = [self.money(ln) for ln in rows] + parts = [ln["비목"] for ln in rows] + same = all(got) and len({g for g in got}) == 1 and sorted(parts) == ["L", "M", "S"] + if not same: + for ln, g in zip(rows, got): + self.machine_line(ln, g) + if total: + self.subtotal(total, [call for _, call, _ in self.done[-len(rows) :]]) + return + qty, place = got[0] + prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows} + r = self.res(rows[0], None, None) + r |= {"단가": prices} + head = next( + (b for b in reversed(self.lines[: self.lines.index(rows[0])]) + if (b.get("자원") or {}).get("코드") == r.get("코드") and not b.get("비목")), + {}, + ) # fmt: skip + title = head.get("품명") or r.get("원이름") or r.get("이름") or "줄" + name = self.o.names.item(title) + one = {"이름": name, "규격": r.get("규격") or "", "단위": r.get("단위") or "", "수량": qty} + one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": self.logic_link(r), "자원": r} + one |= {"자리": place} if place else {} + value = total["결과"][-1] if total and total.get("결과") else None + for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄) + self.o.glyph(ln["행"], ln.get("보임") or "") + at = total or rows[0] + value = value if value is not None else sum(ln["결과"][-1] for ln in rows) + self.o.add_line(one, True, at["행"], at.get("보임") or "", value) + if total: + self.register(total, f"줄.'{name}'") + self.got["확정"] += 3 + + def machine_line(self, ln: dict, got: tuple | None) -> None: + qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1]) + price = (ln.get("연결단가") or [ln["결과"][-1]])[0] + r = self.res(ln, ln["비목"], price if got else ln["결과"][-1]) + r["갈래"] = "기계" if r["갈래"] == "기계" else r["갈래"] + name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}") + link, _ = em._m01(r["갈래"], r) if self.keys else (None, None) + one = em.resource(name, r.get("단위"), qty, r, link) + one |= {"자리": place} if place else {} + self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1]) + self.got["확정"] += bool(got) + self.register(ln, f"줄.'{name}'") + + def logic_link(self, r: dict) -> str | None: + """기계 = 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 그 밖 = 없음(산근 = 형제 SK 참조).""" + if not self.keys or r.get("갈래") != "기계": + return None + key, _ = em._m01("기계", r) + if not key: + return None + from M02_MasterTemplete import M02_Doc_Import as dim + + got = dim.hourly_link(key, "노무비") + if not got: + return None + args = "".join(f", {k}={v}" for k, v in got["입력"].items()) + return f"로직({got['키']}{args})" + + def subtotal( + self, ln: dict, kids: list[str], part: str | None = None, glyph: bool = True + ) -> str: + """합 밖 소계 덧줄 · glyph 거짓 = 글줄에 안 붙임(같은 줄 식이 붙음 · 61 「합 ÷ N = 결과」).""" + name = self.o.names.line(self.label(ln) or "소계") + one = {"이름": name, "식": " + ".join(kids) or "0", "비목": COMP.get(part or "", "경비")} + one |= {"합밖": True, "출처": SUB} + value = ln["결과"][-1] if ln.get("결과") else (ln.get("저장") or {}).get("T") + if glyph: + self.o.add_line(one, False, ln["행"], ln.get("보임") or "", value) + else: + self.o.adds.append(one) + self.done.append((value or 0.0, name, ln)) + return name + + def sub61(self, i: int, ln: dict) -> None: + """소계(61) — 앞 줄 결과 합(단 > 0 머리까지) · 아래 줄 비목 0 이면 그 줄이 소계 비목으로 합에 듦.""" + want = (ln.get("줄참조") or [None])[0] + part = ln.get("비목") + start, skip = 0, set() + for k in range(i - 1, -1, -1): # node `native-text` 61 — 머리 · -20 줄까지(그 줄 포함) + b = self.lines[k] + if b.get("색인") == -1: + skip.add(b["행"]) + if (b.get("단") or 0) > 0 or (b.get("색인") or 0) > 0 or b.get("색인") == -20: + start = b["행"] + break + kids = [d for d in self.done if start <= d[2]["행"] < ln["행"] and d[2]["행"] not in skip] + ok = want is not None and kids and _close(round(sum(k[0] for k in kids), 1), want) + if ok and ln.get("결과"): # 「인부소계 : ‹합› ÷ N = 결과」 — 합(합 밖) 뒤 식 줄이 합에 듦 + self.subtotal({**ln, "결과": [want]}, [k[1] for k in kids], part, glyph=False) + got = self.money(ln) + body, place = got if got else self.frozen(ln, "소계 식 못 읽음") + name = self.o.names.line("소계식") + one = {"이름": name, "식": body, "비목": COMP.get(part or "", "경비")} + one |= ({} if part else {"합밖": True}) | ({"자리": place} if place else {}) + self.o.add_line(one, False, ln["행"], ln.get("보임") or "", ln["결과"][-1]) + self.done.append((ln["결과"][-1], name, ln)) + self.got["확정"] += bool(got) + return + if ok and part and all(k[2].get("비목") is None and self._is_line(k[1]) for k in kids): + self.subtotal(ln, [k[1] for k in kids], part) + for _, call, kid in kids: # 아래 줄 = 소계 비목으로 합에 듦 + self.joins(call, part) + self.got["확정"] += 1 + return + if ok: # 그대로 — 소계 줄이 합에 듦 + name = self.o.names.line("소계") + one = { + "이름": name, + "식": " + ".join(k[1] for k in kids), + "비목": COMP.get(part, "경비"), + } + one |= {} if part else {"합밖": True} + self.o.add_line(one, False, ln["행"], ln.get("보임") or "", want) + self.done.append((want, name, ln)) + self.got["확정"] += 1 + return + value = want if want is not None else 0.0 + name = self.o.names.line("소계") + one = {"이름": name, "식": em.num(value), "비목": COMP.get(part, "경비")} + one |= {} if part else {"합밖": True} + self.mark(ln, "소계 아래 줄 못 가림 — 보인 값 얼림") + self.o.add_line(one, False, ln["행"], ln.get("보임") or "", value) + self.done.append((value, name, ln)) + + @staticmethod + def label(ln: dict) -> str: + """「인부소계 : …」 → 「인부소계」(= 앞 쌍점만 · 낱말 아니면 빈 글).""" + head = _clean(ln.get("글") or "").split("=", 1)[0] + if ":" not in head: + return "" + got = re.sub(r"[^\w가-힣]", "", head.split(":", 1)[0])[:20] + return "" if not got or got[0].isdigit() else got + + def _is_line(self, call: str) -> bool: + name = call[3:-1] if call.startswith("줄.'") else call + return any(one["이름"] == name for one in [*self.o.items, *self.o.adds]) + + def joins(self, call: str, part: str) -> None: + """합 밖 호표 · 덧줄(부름 글) → 그 비목으로 합에 듦.""" + name = call[3:-1] if call.startswith("줄.'") else call + for one in [*self.o.items, *self.o.adds]: + if one["이름"] != name: + continue + one.pop("합밖", None) + if one.get("종류") in ("인력", "재료", "기계") and "단가" in one: + one["비목"] = COMP[part] + r = one.get("자원") or {} + if r.get("비목"): + r["옮김"] = part if part != r["비목"] else None + elif "비목" in one and not one.get("종류"): + one["비목"] = COMP[part] + + def rate(self, ln: dict) -> None: + got = self.money(ln) + body, place = got if got else self.frozen(ln, "율 식 못 읽음") + part = ln.get("비목") + name = self.o.names.line(self.label(ln) or "율") + one = {"이름": name, "식": body, "비목": COMP.get(part or "", "경비")} + one |= {} if part in COMP else {"합밖": True} + one |= {"자리": place} if place else {} + self.o.add_line(one, False, ln["행"], ln.get("보임") or "", ln["결과"][-1]) + self.got["확정"] += bool(got) + self.register(ln, name) + + def plain(self, ln: dict) -> None: + """연결 없는 결과 줄 — 비목 있으면 덧줄(식) · 없으면 글 + 값(줄참조 대상이면 중간).""" + got = self.money(ln) + part = ln.get("비목") + if part in COMP: + body, place = got if got else self.frozen(ln, "식 못 읽음") + name = self.o.names.line("줄") + one = {"이름": name, "식": body, "비목": COMP[part]} + one |= {"자리": place} if place else {} + self.o.add_line(one, False, ln["행"], ln.get("보임") or "", ln["결과"][-1]) + self.register(ln, name) + self.got["확정"] += bool(got) + return + eng = self.o.names.line("값") + body = got[0] if got else em.num(ln["결과"][-1]) + if got and got[1]: + body = f"{got[1]['방법']}({body}, {got[1]['자리']})" + self.o.middle(eng, body, None, ln["행"], ln.get("보임") or "") + self.register(ln, eng) + + def text_line(self, ln: dict) -> None: + """글 줄 — 품셈 · 조건 이름만 있는 글(값 없음) = 「추정」 표지(연결 안 함 · 810-3 화면).""" + if _COND.search(ln["보임"]): + self.o.glyph( + ln["행"], ln["보임"], mark=f"{NOTE}{GUESS} — 품셈 조건 글(값 없음 · 연결 안 함)" + ) + self.got["추정"] += 1 + return + self.o.glyph(ln["행"], ln["보임"]) + + # ── 한 벌 ───────────────────────────────────────────────────── + def run(self) -> dict: + for k, ln in enumerate(self.lines): # 「노무비 : ‹단가›」(식 · 결과 없음) = 단가 × 1 + text = _clean(ln.get("글") or "") + if ln.get("연결단가") and not ln.get("결과") and "=" not in text and ln.get("비목"): + price = ln["연결단가"][0] + self.lines[k] = {**ln, "결과": [price], "글": f"{ln['글']} = {em.num(price)}"} + lines, i = self.lines, 0 + while i < len(lines): + ln = lines[i] + link, part = ln.get("연결"), ln.get("비목") + if ln.get("합계") and link != 62: + if (ln.get("보임") or "").strip(): + self.o.glyph(ln["행"], ln["보임"]) + i += 1 + continue + if link in TRIPLE and part in ("L", "M", "S"): + rows = [ln] + code = (ln.get("자원") or {}).get("코드") + while ( + i + len(rows) < len(lines) + and lines[i + len(rows)].get("연결") == link + and lines[i + len(rows)].get("비목") in ("L", "M", "S") + and (lines[i + len(rows)].get("자원") or {}).get("코드") == code + ): + rows.append(lines[i + len(rows)]) + nxt = lines[i + len(rows)] if i + len(rows) < len(lines) else None + total = nxt if nxt and nxt.get("연결") == link and nxt.get("비목") == "T" else None + if all(r.get("결과") for r in rows): + self.triple(rows, total) + else: + for r in rows: + self.mark(r, "결과 없는 세 줄") + i += len(rows) + bool(total) + continue + if link in (61, 51, 68): + self.sub61(i, ln) + elif link in (58, 62, 52) and ln.get("결과"): + self.rate(ln) + elif ln.get("결과") and (ln.get("자원") or {}).get("코드") and ln.get("연결단가"): + self.item(ln, part if part in COMP else None, part in COMP) + elif ln.get("결과") and link in TRIPLE and part == "T": + self.o.glyph(ln["행"], ln.get("보임") or "", value=ln["결과"][-1]) + self.register(ln, em.num(ln["결과"][-1])) + elif not ln.get("자원") and self.assigns(i, ln): + pass + elif ln.get("결과"): + self.plain(ln) + elif (ln.get("보임") or "").strip(): + self.text_line(ln) + i += 1 + 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) + out["원본"] |= {"원파일": True, "확정": dict(self.got)} + return out + + +def block(blk: dict, no: int, keys: bool = True, file: str = "", names: dict | None = None) -> dict: + """EST 산근 블록(`M02_San_Estx.read` 산근 한 개 · 번 = 파일 안 차례 1부터) → {로직, 원본}. + names = 같은 파일 산근 코드 → 번(안쪽 산근 「단산N」).""" + return Block(blk, no, keys, file, names or {}).run() + + +_POW = re.compile(r"(\([^()]*\)|" + est.NUMRE + r"|[A-Za-z][A-Za-z0-9]*)\s*\^\s*(\d)(?![\d.])") + + +def _power(seg: str) -> str: + """「x^2」 · 「(a+b)^2」 → 곱 되풀이(EST 토큰에 ^ 없음 · 정수 지수만).""" + for _ in range(8): + new = _POW.sub(lambda m: "(" + "×".join([m.group(1)] * int(m.group(2))) + ")", seg) + if new == seg: + break + seg = new + return seg diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index c0eff4bd6..d29041de6 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Doc_FromNative as from_native from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget @@ -63,6 +64,9 @@ def _who(session: Session) -> dict[str, Any]: def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: + # 경쟁사 원파일(810-2) — 같은 공사 엑셀 HP 에 붙음 · 원파일 우선 + if from_native.is_native(name): + return from_hopyo.upload_native(store.root(), name, data, who) mark = hopyo_store.fingerprint(data) hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): @@ -103,7 +107,7 @@ def hopyo_upload( session: Session = Depends(verify_session), ) -> dict: """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · - 엉터리 · 시트 없음 400.""" + 엉터리 · 시트 없음 400 · 원파일(.estx · .stc) = 산근 원파일 우선(810-2 `upload_native`).""" folder = _root(session, layer, owner, write=True) data = file.file.read(HOPYO_MAX_BYTES + 1) if len(data) > HOPYO_MAX_BYTES: diff --git a/M02_MasterTemplete/M02_San_Estx.py b/M02_MasterTemplete/M02_San_Estx.py index 55ebd5f0f..fcbd4288c 100644 --- a/M02_MasterTemplete/M02_San_Estx.py +++ b/M02_MasterTemplete/M02_San_Estx.py @@ -304,6 +304,7 @@ def cost_line(at: int, r: dict[str, Any], res: dict, tags: list[str]) -> dict[st "규격": ld.get("규격"), "단위": ld.get("단위"), "단": r.get("Level"), + "색인": r.get("Index"), "연결": link, "연결뜻": LINK.get(link or 0), "비목": _sc(r), diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 68abc23e0..6e2854e61 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -9,6 +9,9 @@ 블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해 엑셀 계와 맞는 것. +원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선 +(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 = +같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`). """ from __future__ import annotations @@ -20,6 +23,7 @@ from typing import Any from common_util.common_util_json import atomic_write_json from common_util.common_util_time import utc_now_text +from M02_MasterTemplete import M02_Doc_FromNative as fn from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_San_Emit as em @@ -58,10 +62,54 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None: return None path = _path(folder, key) path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json(path, {"파일": name, "시트": sheets}) + got = {"파일": name, "시트": sheets} + native = next((n for n in _natives(folder) if fn.same(n, sheets)), None) + atomic_write_json(path, got | ({"원파일": native} if native else {})) return None +# ── 원파일(810-2) ────────────────────────────────────────────────────── +def _saved(folder: str | Path) -> list[tuple[Path, dict]]: + out = [] + for path in sorted((hopyo_store._dir(folder) / DIRNAME).glob("HP-*.json")): + try: + out.append((path, json.loads(path.read_bytes()))) + except (OSError, ValueError): + continue + return out + + +def _natives(folder: str | Path) -> list[dict]: + return [d["원파일"] for _, d in _saved(folder) if isinstance(d.get("원파일"), dict)] + + +def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[str, Any]: + """원파일 올림 — 같은 공사 엑셀 HP 에 원파일을 붙임(`붙음` 키들 · 첫 HP 답) · 없으면 원파일로 + 새 HP(EST = 일위대가 호표 · STmate = 호표 없음 · 산근만). 같은 파일 다시 = 그 HP(`이어서`).""" + mark = hopyo_store.fingerprint(data) + if same := next((r for r in hopyo_store.rows(folder) if r.get("지문") == mark), None): + return {**same, "이어서": True, "원파일": True} + if why := unreadable(data): + raise store.StoreError(400, why) + try: + native = fn.read(name, data) + except fn.NativeError as e: + raise store.StoreError(400, str(e)) from e + joined = [] + for path, got in _saved(folder): + if got.get("시트") and fn.same(native, got["시트"]): + atomic_write_json(path, {**got, "원파일": native}) + joined.append(path.stem) + rows = {r["키"]: r for r in hopyo_store.rows(folder)} + if joined and joined[0] in rows: + return {**rows[joined[0]], "원파일": True, "붙음": joined} + row = hopyo_store.save(folder, name, fn.hopyo(native), who, None, mark) + path = _path(folder, row["키"]) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, {"파일": name, "원파일": native}) + return {**row, "원파일": True} + + def has(folder: str | Path, key: str) -> bool: return _path(folder, key).is_file() @@ -94,6 +142,11 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: saved = json.loads(_path(folder, key).read_bytes()) except (OSError, ValueError) as e: raise store.StoreError(409, RELOAD) from e + native = saved.get("원파일") + if isinstance(native, dict) and int(no) in fn.numbers(native): # 원파일 우선(810-2) + return fn.emit(native, int(no)) + if not saved.get("시트"): + raise store.StoreError(404, f"없는 단산{no}") sheets, name = saved["시트"], str(saved.get("파일") or "") if book.kind_of(sheets) == "STmate": blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None) @@ -109,12 +162,17 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name) -def _inner(source: Path, key: str, logic: dict) -> list[tuple[dict, int | None, str | None]]: +def _inner( + source: Path, key: str, logic: dict, native: bool = False +) -> list[tuple[dict, int | None, str | None]]: """안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 · EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만.""" from M02_MasterTemplete import M02_San_Book as book - sheets = json.loads(_path(source, key).read_bytes())["시트"] + saved = json.loads(_path(source, key).read_bytes()) + if native and isinstance(saved.get("원파일"), dict): + return fn.inner(saved["원파일"], logic) + sheets = saved["시트"] stmate = book.kind_of(sheets) == "STmate" have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {} total = set() if stmate else set(book.est_prices(sheets)[1]) @@ -143,7 +201,8 @@ def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> Non from M02_MasterTemplete import M02_Labor_Ref as refs made: dict[int, dict | None] = {} - for line, n, why in _inner(source, key, out["로직"]): + native = bool(out["원본"].get("원파일")) + for line, n, why in _inner(source, key, out["로직"], native): if n is not None and not why and (n == no or n in trail or len(trail) >= MAX_INNER): why = "안쪽 블록이 자기 자신을 부름" if n is not None and not why and n not in made: diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts index b92758ef8..408d01593 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_Sangeun_Origin.ts * 가져온 식 단가산출 「어디서 왔나」 카드 자료(PLAN 807-1 · 요구 699) — 원문 패널 첫 화면. - * SK `가져온.원본`(파일 · 시트 · 행 · 갈래 EST|STmate · 코드 · 표지 · 가져온때 · 글줄)에서 보이기 자료만 뽑음 · + * SK `가져온.원본`(파일 · 시트 · 행 · 갈래 EST|STmate · 코드 · 표지 · 가져온때 · 원파일 · 글줄)에서 보이기 자료만 뽑음 · * 품셈 절 글 = 엑셀 글줄에 적힌 「…품셈 N-N-N」 글(책 · 절 번호 · 그 줄 글) — 찾기 · 잇기 없음(보이기만) * 화면 · DOM · fetch 없음 * ========================================================================== */ @@ -16,6 +16,7 @@ export interface OriginMeta { 코드?: string | number; 표지?: string[]; 가져온때?: string; + 원파일?: boolean; // 경쟁사 원파일(.estx · .stc)에서 옮김(810-2) — 엑셀 아님 글줄?: { 행?: number; 글?: string }[]; }; } @@ -81,7 +82,7 @@ export function originOf(meta: OriginMeta | null | undefined): Origin | null { const block = src.갈래 === "EST" && code !== "" ? `단산${code}` : String(code); const rows = (src.행 ?? []).filter((x) => Number.isFinite(x)); return { - 프로그램: src.갈래 ?? "", + 프로그램: `${src.갈래 ?? ""}${src.원파일 ? " · 원파일" : ""}`, 파일: src.파일 ?? "", 블록: block, 행: rows.length ? `${src.시트 ? `${src.시트} ` : ""}${rows.join("~")}행` : (src.시트 ?? ""), diff --git a/common_util/common_util_formula_doc.ts b/common_util/common_util_formula_doc.ts index c30c73108..414575c3c 100644 --- a/common_util/common_util_formula_doc.ts +++ b/common_util/common_util_formula_doc.ts @@ -602,7 +602,7 @@ export function check(doc: FormulaDoc): Mark[] { } (doc.금액 ?? []).forEach((l, at) => { const where = `금액 ${l.이름 || at + 1}`; - if (![...COSTS, "묶음"].includes(l.비목 as string)) + if (![...COSTS, "묶음"].includes(l.비목 as string) && !(l.합밖 && !l.비목)) out.push(mark("금액 줄", `${where} 비목 「${l.비목}」`)); if (l.수량 !== null && l.수량 !== undefined && l.수량 !== "") need(l.수량, `${where} 수량`, true); diff --git a/common_util/common_util_formula_doc_calc.ts b/common_util/common_util_formula_doc_calc.ts index 5ddcad3f1..b796b73c4 100644 --- a/common_util/common_util_formula_doc_calc.ts +++ b/common_util/common_util_formula_doc_calc.ts @@ -464,6 +464,7 @@ function money(run: Run, doc: FormulaDoc, rules: Record) { const price = run.expr(line.단가, where, scope); const kind = line.비목 as string; let parts: Bundle = {}; + let bare: number | null = null; // 비목 없는 합 밖 줄(소계 보임 · 810-2) — 금액만 let why: string | null = null; const stop = (text: string): void => { stopped = true; @@ -474,6 +475,7 @@ function money(run: Run, doc: FormulaDoc, rules: Record) { why = (qty instanceof Stop ? qty : (price as Stop)).why; } else if (qty instanceof Blank) why = qty.why; else if (!isNum(qty)) stop("수량이 수 아님"); + else if (qty === 0 && !kind && line.합밖) bare = 0; else if (qty === 0) for (const k of kind === "묶음" ? COSTS : [kind]) parts[k] = 0; else if (price instanceof Blank) why = price.why; else if (kind === "묶음") { @@ -482,11 +484,17 @@ function money(run: Run, doc: FormulaDoc, rules: Record) { else stop("묶음 줄 단가가 단가산출 묶음 아님"); } else if ((COSTS as readonly string[]).includes(kind) && isNum(price)) parts[kind] = qty * price; + else if (!kind && line.합밖 && isNum(price)) bare = qty * price; else stop(isObj(price) ? "묶음 값 — 칸(.계 · .노무비 …) 필요" : "단가가 수 아님"); const rule = line.자리 ?? rules.줄; parts = Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, cut(v, rule) as number])); const keys = Object.keys(parts); - const amount = keys.length ? keys.reduce((s, k) => s + parts[k], 0) : null; + const amount = + bare !== null + ? (cut(bare, rule) as number) + : keys.length + ? keys.reduce((s, k) => s + parts[k], 0) + : null; if (!skip.has(i)) addTo(running, parts); if (line.기호) syms[String(line.기호)] = amount ?? new Blank(why ?? "빈 줄"); outs.push({ diff --git a/docs/raw/요구_1104/_목록_810-2_원파일변환.xlsx b/docs/raw/요구_1104/_목록_810-2_원파일변환.xlsx new file mode 100644 index 000000000..84b498435 Binary files /dev/null and b/docs/raw/요구_1104/_목록_810-2_원파일변환.xlsx differ diff --git a/resources/master_data/_수식문서_틀.md b/resources/master_data/_수식문서_틀.md index 7974922bf..5de01fab2 100644 --- a/resources/master_data/_수식문서_틀.md +++ b/resources/master_data/_수식문서_틀.md @@ -76,7 +76,7 @@ - 금액 줄 = `이름` · `규격?` · `단위` · `비목`(노무비 · 재료비 · 경비 · 묶음) · `수량?`(식 · 없으면 1) · `단가`(식) · `묶음?`(묶음 이름) · `기호?`(뒤 금액 줄이 부름) · `자리?` · `번지?` · `합밖?` · `출처?`. - 줄 금액 = 수량 × 단가 → 비목마다 끝수(`자리` › 문서 `끝수.줄`) → 합. 비목 「묶음」 = 단가가 단가산출 묶음 → 노 · 재 · 경 각각 수량 ×. - 수량 0 = 금액 0(단가가 비어도). 값 없음(빈 입력 · 표 줄 없음 · 요소 값 없음) = 그 줄만 비움 · 까닭 · 합에서 뺌. -- `합밖: true` = 번지엔 쌓되 비목 합 · 단계에서 뺌. `번지` = 금액번지 이름(그 줄 비목 금액이 쌓임). +- `합밖: true` = 번지엔 쌓되 비목 합 · 단계에서 뺌. `합밖: true` + 비목 비움 = 금액만(수량 × 단가 · 끝수 · 기호) · 비목 칸 없음(소계 보임 · 원파일 810-2). `번지` = 금액번지 이름(그 줄 비목 금액이 쌓임). - `단계`(문서 칸) = `[{이름, 단, 줄 [금액 줄 이름], 제외}]` — 단 1 = 제 줄 · 단 k = 제 줄 + 아직 안 담긴 아래 단 단계 · `제외` = 찍되 위 단계 · 비목 합에서 뺌. - 묶음 = 금액 줄 `묶음` 이름이 같은 것끼리 — 묶음 하나 = 「계」 · 둘 이상 = 묶음마다 「소계」(줄 금액 합) + 「합계」. 묶음 소계는 보이기만 — 합계 = 아래 끝수 규칙. diff --git a/resources/master_data/_원파일파서_틀.md b/resources/master_data/_원파일파서_틀.md index 14872cc5c..abd5d268c 100644 --- a/resources/master_data/_원파일파서_틀.md +++ b/resources/master_data/_원파일파서_틀.md @@ -34,6 +34,7 @@ | 품명 · 규격 · 단위 | `\x0e` · `\x13` · `\x15` 첫 조각 | Name · Size · Unite | 비움 | | 수량 | — | GN | — | | 단 | Level | — | 계 단 1~5 | +| 색인 | Index(-20 = 소계 구간 첫 줄 · -1 = 소계에서 뺌) | — | — | | 연결 · 연결뜻 | LinkIndex · `LINK` 뜻 | 같음 | 줄 종류(빈 · 글 · 계 · 대입 · 참조 · 율) | | 비목 | SCheck 음수 -1 L · -2 M · -3 S · -4 R · -5 T / 율 · 소계 양수 1 M · 2 L · 3 S · 4 R | 율 줄 SCheck | 참조 · 율 꼬리 L · M · S | | 자원 | {코드 · 종류 · 표 · 이름 · 규격 · 단위 · 저장} | 같음 | {코드 · 종류 · 비목 · 옮김 · 이름 · 규격 · 단위 · 저장} | @@ -77,3 +78,11 @@ - EST — 옵션 파일(datestoption.opt) · 원가 서식(wongadat) · 기계 구성(MachMainDat) 은 구조 줄로 안 옮김. - STmate — BDQTY.DBF(일위대가 구성) · SYSINFO.DBF 난독화 · 차수 2~4 금액 칸 안 옮김 · 코드 머리 W 뜻 모름. - 원파일 없는 것(북부청 2 · 2016 사방 · 수목) = 엑셀 길. + +## 6. 수식 문서로(810-2) + +- 길 = 원파일 → 둘 꼴(`M02_Doc_FromNative.pack` · 실무자료 산근 자리 칸 `원파일`) → 블록 하나 `{로직, 원본}`(EST `M02_Doc_NativeEst` · STmate `M02_San_Emit.stmate`) → 가져온 식 SK → `M02_Doc_Import.derive` 수식 문서. +- 원파일 우선 — 같은 공사(엑셀 산근 블록 계 = 원파일 같은 번 블록 계) 엑셀 HP 에 붙음 · 그 블록은 원파일로 · 원파일 없는 블록 · 엑셀만 = 엑셀 길. +- EST 줄 — 자원 줄 = 호표(단가 `` · 수량 식 · 변수 이름) · 세 줄(101 · 104 · 105 같은 코드 노 · 재 · 경 · 같은 수량 식) = 로직 값묶음 한 줄 · 소계 61 = 합 밖 덧줄(출처 `원파일 소계` · 문서 비목 칸 비움) + 아래 줄이 소계 비목으로 합에 듦(-20 · 머리 줄까지) · 율 58 · 62 = 덧줄(줄참조 · 구간 합) · 합계 = 글 · 변수 줄 = 중간(분수 그림 = 위아래 줄 칸 · 결과와 맞을 때만 식). +- 표지 — 못 읽은 줄 = 보인 값 얼림 + 「참고 · …」 · 품셈 조건 글(값 없음) = 「참고 · 추정 — …」 · 문서 수 하나 변수 출처 = 「원파일」. +- 일위대가 — EST HeadMainDat = 실무자료 호표 꼴(`hopyo`) · STmate = 호표 없음(산근만). diff --git a/resources/master_data/scripts/master_doc.py b/resources/master_data/scripts/master_doc.py index 99d8b73ad..cccd98d11 100644 --- a/resources/master_data/scripts/master_doc.py +++ b/resources/master_data/scripts/master_doc.py @@ -478,7 +478,9 @@ def check(doc: dict) -> list[dict]: uses[head] = uses.get(head, []) + need(line.get("식"), head, False, head) for at, line in enumerate(doc.get("금액") or []): where = f"금액 {line.get('이름') or at + 1}" - if line.get("비목") not in (*COSTS, "묶음"): + if line.get("비목") not in (*COSTS, "묶음") and not ( + line.get("합밖") and not line.get("비목") + ): out.append(_mark("금액 줄", f"{where} 비목 「{line.get('비목')}」")) for k in ("수량", "단가"): if k == "단가" or line.get(k) not in (None, ""): diff --git a/resources/master_data/scripts/master_doc_calc.py b/resources/master_data/scripts/master_doc_calc.py index e17bb8ac5..6f0b661b3 100644 --- a/resources/master_data/scripts/master_doc_calc.py +++ b/resources/master_data/scripts/master_doc_calc.py @@ -404,13 +404,15 @@ def _money(run: _Run, doc: dict, rules: dict): ) price = run.expr(line.get("단가"), where, scope) kind = line.get("비목") - parts, why = {}, None + parts, why, bare = {}, None, None if isinstance(qty, Stop) or isinstance(price, Stop): stopped, why = True, (qty if isinstance(qty, Stop) else price).why elif isinstance(qty, Blank): why = qty.why elif not isinstance(qty, Decimal): stopped, why = True, run.mark(Stop(f"{where} 수량이 수 아님", "셈 틀림", where)).why + elif qty == 0 and not kind and line.get("합밖"): + bare = Decimal(0) elif qty == 0: parts = {k: Decimal(0) for k in (COSTS if kind == "묶음" else (kind,))} elif isinstance(price, Blank): @@ -427,6 +429,8 @@ def _money(run: _Run, doc: dict, rules: dict): ) elif kind in COSTS and isinstance(price, Decimal): parts = {kind: qty * price} + elif not kind and line.get("합밖") and isinstance(price, Decimal): + bare = qty * price # 비목 없는 합 밖 줄(소계 보임 · 810-2) — 금액만 · 비목 칸 비움 else: what = ( "묶음 값 — 칸(.계 · .노무비 …) 필요" @@ -437,6 +441,8 @@ def _money(run: _Run, doc: dict, rules: dict): rule = line.get("자리") or rules["줄"] parts = {k: cut(v, rule) for k, v in parts.items()} amount = sum(parts.values(), Decimal(0)) if parts else None + if bare is not None: + amount = cut(bare, rule) if i not in skip: _add(running, parts) if line.get("기호"): diff --git a/resources/tester/test_810_2_native_doc.py b/resources/tester/test_810_2_native_doc.py new file mode 100644 index 000000000..dd4be6446 --- /dev/null +++ b/resources/tester/test_810_2_native_doc.py @@ -0,0 +1,259 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""810-2 지킴 — 원파일 구조 줄 → 수식 문서(`M02_Doc_NativeEst` · `M02_Doc_FromNative`) · 원파일 우선. + +(a) 전수 — EST 여섯 · STC 일곱 산근 블록 모두: 가져온 로직 → 수식 문서 셈(엑셀 단가 그대로) = 원파일 + 저장 금액(STmate 계약단가 = 낙찰률 버림 뒤) · 검산 줄 틀림은 모두 표지 줄(표지 없는 틀린 줄 0) +(b) 꼴 — 소계 = 합 밖 · 비목 칸 비움(문서 셈 금액만) · 기계 세 줄 = 묶음 한 줄 · 변수 = 이름(상수 아님) + · 분수 그림 공식 = 변수 식 · 수 하나 변수 = 숫자 연결 + 출처 「원파일」 · 품셈 조건 글 = 「추정」 표지 +(c) 문서 셈 거울 — 비목 없는 합 밖 줄 파이썬 = TS +(d) 올림 길 — 원파일 = 같은 공사 엑셀 HP 에 붙음 · 가져온 식 = 원파일 · 원파일만 = 새 HP(EST 호표 · + STmate 호표 없음) · 원파일 HP 호표 → 일위대가(SC) 금액 = 원파일 · 엑셀만 = 엑셀 길 그대로 +원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC). +""" + +from __future__ import annotations + +import math +import sys +from decimal import Decimal as D +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 helper_m02_hopyo_golden as golden # 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_FromNative as fn # noqa: E402 +from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Build as hb # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Imported as imp # noqa: E402 +from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층 + +WHO = {"이름": "sub3임시"} +COST = (("L", "노무비"), ("M", "재료비"), ("S", "경비"), ("T", "계")) + + +def _file(path: Path) -> Path: + if not path.exists(): + pytest.skip(f"원파일 없음 {path.name}") + return path + + +@lru_cache(maxsize=2) +def native(kind: str, tag: str) -> dict: + path = _file((EST if kind == "EST" else STC)[tag]) + return fn.read(path.name, path.read_bytes()) + + +@lru_cache(maxsize=1) +def whole(): + return imp._whole() + + +def sk_of(out: dict) -> dict: + """{로직, 원본} → 저장 없는 가져온 식 SK(만들기 몸과 같은 꼴 · 키 = 시험용).""" + row = out["로직"] + sk = {"키": "SK-999999", "이름": row["이름"], "단위": row["결과단위"].split("/", 1)[1]} + sk |= {"바탕": None, "입력값": {}, "덮개": {}, "뺀줄": [], "더한줄": []} + sk[imp.KEY] = {"로직": row, "원본": out["원본"], "단가기준": "엑셀"} + return sd.clean(sk, None, whole()) + + +def doc_and_money(out: dict) -> tuple[dict, dict, dict]: + sk = sk_of(out) + doc = dim.derive(sk, None, whole()) + return sk, doc, dim.run(doc, sk, whole()) + + +# ── (a) 전수 ───────────────────────────────────────────────────────────── +def _sweep(saved: dict) -> dict: + stat = {"블록": 0, "같음": 0, "표지없는틀린줄": 0, "다름": []} + for no, blk in fn.numbers(saved).items(): + out = fn.emit(saved, no) + sk, _, got = doc_and_money(out) + mine = {c: got.get(k) for c, k in COST} + nag = (out["원본"].get("계약") or {}).get("낙찰률") + if nag: # 계약단가(B09 몫) — 원파일 저장값은 낙찰률 뒤 + for c in "LMS": + mine[c] = math.floor(round(float(mine[c]) * nag / 100, 6)) + mine["T"] = mine["L"] + mine["M"] + mine["S"] + want = blk.get("저장") or {} + same = all( + mine[c] is not None and abs(float(mine[c]) - float(want.get(c) or 0)) < 0.5 + for c, _ in COST + ) + stat["블록"] += 1 + stat["같음"] += same + if not same: + stat["다름"].append((no, blk.get("이름"))) + marked = {int(m.split(":")[0]) for m in out["원본"]["표지"] if m.split(":")[0].isdigit()} + lines = imp.check(sk, whole())["줄"] + stat["표지없는틀린줄"] += sum(1 for x in lines if not x["같음"] and x["행"] not in marked) + return stat + + +@pytest.mark.parametrize("tag", list(EST)) +def test_est_every_block_equals_saved(tag: str) -> None: + stat = _sweep(native("EST", tag)) + assert stat["블록"] and not stat["다름"] and stat["같음"] == stat["블록"], stat["다름"][:5] + assert stat["표지없는틀린줄"] == 0 + + +@pytest.mark.parametrize("tag", list(STC)) +def test_stc_every_block_equals_saved(tag: str) -> None: + stat = _sweep(native("STC", tag)) + assert stat["블록"] and not stat["다름"], stat["다름"][:5] + assert stat["표지없는틀린줄"] == 0 + + +# ── (b) 꼴 ─────────────────────────────────────────────────────────────── +def _block(no: int) -> tuple[dict, dict, dict]: + out = fn.emit(native("EST", "거창"), no) + _, doc, got = doc_and_money(out) + return out, doc, got + + +def test_subtotal_out_of_sum_no_cost() -> None: + out, doc, got = _block(7) # 지장목 제거 — 인부소계 822.5 · 기구손료 10 % + sub = next(x for x in doc["금액"] if x["이름"] == "인부소계") + assert sub["합밖"] and "비목" not in sub and sub["출처"] == dim.SUB + line = next(x for x in got["줄"] if x["이름"] == "인부소계") + assert line["금액"] == D("822.5") and line["비목"] == {} and line["합밖"] + rate = next(x for x in doc["금액"] if "인부소계" in str(x.get("단가"))) + assert rate["비목"] == "경비" and not rate.get("합밖") + assert (got["노무비"], got["재료비"], got["경비"]) == (1437, 58, 210) + + +def test_machine_one_line_bundle() -> None: + out, doc, got = _block(1) # 측구터파기 토사 — 굴삭기 노 · 재 · 경 세 줄 + rows = [x for x in out["로직"]["호표"] if (x.get("자원") or {}).get("갈래") == "기계"] + assert len(rows) == 1 and rows[0]["종류"] == "로직" and rows[0]["수량"] == "(1 / Q)" + assert rows[0]["이음"] and rows[0]["이음"].startswith("로직(GC") # 시간당 기계경비 로직 + money = [x for x in doc["금액"] if x["이름"] == rows[0]["이름"]] + assert len(money) == 1 and money[0]["비목"] == "묶음" + line = next(x for x in got["줄"] if x["이름"] == rows[0]["이름"]) + assert line["비목"] == {"노무비": D("2523.3"), "재료비": D("832.5"), "경비": D("1049.7")} + + +def test_variables_by_name_and_fraction_formula() -> None: + out, doc, _ = _block(1) + mids = {m["이름"]: m["식"] for m in out["로직"]["중간"]} + assert mids["Q"] == "반올림(((((((3600 * q) * K) * f) * E)) / (Cm)), 2)" or "Cm" in mids["Q"] + assert {"q", "K", "f", "E", "Cm"} <= set(mids) + by = {v["열쇠"]: v for v in doc["변수"]} + nums = [v for v in by.values() if (v.get("연결") or {}).get("갈래") == "숫자"] + assert nums and all(v.get("출처") == "원파일" for v in nums) + assert not any(x for x in out["원본"]["표지"] if "공식 글 못 읽음" in x) + + +def test_guess_mark_for_condition_text() -> None: + out, _, _ = _block(7) + assert out["원본"]["확정"]["추정"] >= 1 + assert any("추정" in x and "품셈 조건" in x for x in out["원본"]["표지"]) + + +# ── (c) 문서 셈 거울 ───────────────────────────────────────────────────── +def test_bare_out_of_sum_line_mirror() -> None: + import test_802_1_formula_doc as t + + doc = t._doc( + [], + [], + [ + t._man("갑", "2", "100", 기호="C_갑"), + {"이름": "소계", "단위": "식", "수량": "1", "단가": "C_갑 + 0.07", "합밖": True}, + {"이름": "율", "단위": "식", "비목": "경비", "단가": "소계 × 10 ÷ 100"}, + ], + ) + doc["금액"][1]["기호"] = "소계" + want = t.mc.calc(doc, t.Fake()) + assert not want["표지"] and want["노무비"] == 200 and want["경비"] == 20 + assert want["줄"][1]["금액"] == D("200") and want["줄"][1]["비목"] == {} + got = t._ts({"문서들": {"맨": doc}, "대상": ["맨"], "기록": {}, "글": [], "표": []}) + t._same(t.ml._norm(want), got["답"]["맨"], "맨") + bad = dict(doc, 금액=[{**doc["금액"][1], "합밖": False}]) + assert any(m["갈래"] == "금액 줄" for m in t.mc.calc(bad, t.Fake())["표지"]) + + +# ── (d) 올림 길 ────────────────────────────────────────────────────────── +def _excel_hp(folder: Path, tag: str) -> str: + src = golden.FILES.get(tag) + if src is None or not src.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + data = src.read_bytes() + parsed = hx.read_book(src.name, data) + row = hs.save(folder, src.name, parsed, WHO, None, hs.fingerprint(data)) + fh.keep(folder, row["키"], src.name, data) + return row["키"] + + +def test_upload_native_joins_excel_and_wins(world) -> None: + folder = world["personal"] + est = _file(EST["거창"]) + with store.at(folder): + hp = _excel_hp(folder, "거창") + before = fh.emit(folder, hp, 15) + assert not before["원본"].get("원파일") # 엑셀만 = 엑셀 길 + got = fh.upload_native(folder, est.name, est.read_bytes(), WHO) + assert got["붙음"] == [hp] and got["키"] == hp and got["원파일"] + again = fh.upload_native(folder, est.name, est.read_bytes(), WHO) + assert again["붙음"] == [hp] # 다시 올려도 새 HP 없음 + assert len(hs.rows(folder)) == 1 + made = fh.make(folder, hp, 15) # 전석 메쌓기 — 안쪽 산근 둘 + 굴삭기 + sk = store.read("sangeun", made["이름"])["문서"] + assert sk["가져온"]["원본"]["원파일"] and made["검산"]["같음"] + refs = [x for x in sk["가져온"]["로직"]["호표"] if x.get("참조")] + assert len(refs) == 2 # 산근 속 산근 = 형제 SK 참조(797-1) + doc = dim.of(sk) + assert doc["가져온"]["원파일"] is True + ans = dim.run(doc, sk) + assert (ans["노무비"], ans["재료비"], ans["경비"]) == (74880, 7029, 12306) + # 엑셀 먼저 · 원파일 나중의 반대 — 원파일 HP 가 있으면 엑셀 올림에 붙음 + other = world["company"] + with store.at(other): + fh.upload_native(other, est.name, est.read_bytes(), WHO) + hp2 = _excel_hp(other, "거창") + assert fh.emit(other, hp2, 1)["원본"].get("원파일") + + +def test_upload_native_alone_hopyo_to_sc(world) -> None: + folder = world["personal"] + est, stc = _file(EST["장수"]), _file(STC["봉화"]) + with store.at(folder): + got = fh.upload_native(folder, est.name, est.read_bytes(), WHO) + assert got["원파일"] and not got.get("붙음") and got["호표수"] == 3 + out = hb.build_all(folder, folder, got["키"], None, True, 1) # 제1호표 = SC + 부르는 SK + assert [x["번"] for x in out["만듦"]] == [1] and out["같음"] == 1 and not out["실패"] + assert out["단가산출"] and all( + store.read("sangeun", k)["문서"]["가져온"]["원본"].get("원파일") + for k in out["단가산출"] + ) + st = fh.upload_native(folder, stc.name, stc.read_bytes(), WHO) + assert st["원파일"] and st["호표수"] == 0 # STmate 일위대가 구성 못 읽음 — 산근만 + made = fh.make(folder, st["키"], next(iter(fn.numbers(native("STC", "봉화"))))) + assert made["검산"]["같음"] + with pytest.raises(store.StoreError): + fh.upload_native(folder, "깨진.estx", b"PK\x03\x04garbage", WHO) + + +def test_router_branch_by_extension() -> None: + import inspect + + from M02_MasterTemplete import M02_Hopyo_Router as router + + src = inspect.getsource(router._upload) + assert "from_native.is_native(name)" in src and "upload_native" in src + assert fn.is_native("가.ESTX") and fn.is_native("나.stc") and not fn.is_native("다.xlsx")