diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index f6aec7ef1..23e6f5ec9 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -395,11 +395,14 @@ class Links(dc.ml.Links): if rid in self.trail or len(self.trail) >= refs.DEPTH: continue sib = refs.read_ref(full) - if not is_imported(sib): + mine = ((sib or {}).get(dc.KEY) or {}).get( + "문서" + ) # 우리 기초 사본으로 바꾼 형제(810-4) + if not is_imported(sib) and not isinstance(mine, dict): continue self.mine[key] = None # 순환 막음 try: - sub = view(of(sib), self.basis) + sub = view(of(sib), self.basis) if is_imported(sib) else {**mine, "키": key} except Exception: # noqa: BLE001 — 못 지음 = 없는 단가산출(연결 끊김 표지) continue self.mine[key] = sub diff --git a/M02_MasterTemplete/M02_Doc_Match.py b/M02_MasterTemplete/M02_Doc_Match.py new file mode 100644 index 000000000..974901cbc --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Match.py @@ -0,0 +1,622 @@ +"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독. + +가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어 +「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정). + ① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 · + 품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만. + ② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 · + 값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을 + 문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터. + ③ 판정 = 줄 대조(수량 · 단가 · 비목 금액 · 율 줄) + 노 · 재 · 경 · 계 — + 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」. +결과 `{판정, 키, 이름, 옵션, 다른, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`. +""" + +from __future__ import annotations + +import functools +import itertools +import json +import re +from decimal import Decimal, InvalidOperation +from typing import Any + +from M02_MasterTemplete import M02_Doc_Copy as dc + +md, mc, ml = dc.md, dc.mc, dc.ml +if not hasattr( + md.parse, "cache_info" +): # 식 글 → 나무 = 순수 함수 · 조합마다 같은 글을 다시 풀지 않음 + md.parse = functools.lru_cache(maxsize=50000)(md.parse) + +KEY = "연결후보" +SURE, NEAR, NONE = "확정", "근접", "없음" +CAP_LINE = 3000 # 줄 하나 좁힐 때 돌리는 조합 상한(넘으면 그 줄은 좁힘에 안 씀) +CAP_ALL = 192 # 문서 하나 끝 셈 조합 상한 +CAP_INPUT = 12 # 입력 변수 후보 수 상한 +NICE = (2, 3, 4, 5, 6, 8, 10, 12, 100, 1000) # 단위 환산으로 보는 배 +_SEC = re.compile(r"(? Any: + """비교 열쇠 — 수 = 유효 12 자리(Decimal 셈 자리 차이만 지움) · 묶음 = 비목 튜플.""" + if isinstance(v, dict): + return tuple(_key(v.get(k)) for k in md.COSTS) + if isinstance(v, Decimal): + try: + return v.normalize() if v == 0 else Decimal(f"{v:.12g}").normalize() + except InvalidOperation: + return None + return v + + +def unit_of(text: Any) -> str: + """결과단위 「원/㎡」 → 「㎡」(소문자 · m3 → ㎥ 등) · 돈 아닌 단위 = "".""" + text = str(text or "").strip() + if not text.startswith("원/"): + return "" + unit = text[2:].strip().lower().replace(" ", "") + return _UNIT.get(unit, unit) + + +def sections(texts: list[str]) -> set[str]: + """품셈 절 번호(「6-3-1」) — 앞 둘 · 셋 마디 모두.""" + out = set() + for t in texts: + for s in _SEC.findall(str(t or "")): + parts = s.split("-") + out |= {"-".join(parts[:n]) for n in range(2, len(parts) + 1)} + return out + + +# ── 줄 사실 ─────────────────────────────────────────────────────────── +def _single(text: Any) -> str | None: + try: + node = md.parse(text) + except md.DocError: + return None + return node[1] if node[0] == "var" else None + + +def _what(doc: dict, line: dict) -> tuple[str, str]: + """금액 줄 자원 → (갈래, 코드) · 갈래 = 노임 · 재료 · 기계 · 산출 · 율 · 수.""" + sym = _single(line.get("단가")) + var = next((v for v in doc.get("변수") or [] if sym and md.var_id(v) == sym), None) + if var is None: + return "율", "" + link = var.get("연결") or {} + kind, key = link.get("갈래"), str(link.get("키") or "") + if kind == "요소": + if key.startswith("LB"): + return "노임", key + if key.startswith("EQ"): + return "기계", key + return "재료", key + if kind == "단가산출": + given = link.get("입력") or {} + code = str(given.get("기계") or "").strip("'") + return ("기계", code) if "기계" in given else ("산출", key) + if kind == "숫자" and isinstance(link.get("값"), dict): + return "산출", "" + if kind == "식": + return "율", "" + return "수", "" + + +def facts(doc: dict, got: dict) -> list[dict]: + """셈 답 줄 → 대조 사실 — 소계 보임(비목 없는 합 밖) · 꺼진 줄(수량 0 · 금액 0) 뺌.""" + out = [] + money = doc.get("금액") or [] + for i, (line, row) in enumerate(zip(money, got.get("줄") or [])): + if line.get("합밖") and not line.get("비목"): + continue + if row.get("금액") is not None and row["금액"] == 0 and row.get("수량") == 0: + continue + kind, code = _what(doc, line) + if kind == "기계" and code in (got.get("값") or {}): # 옵션 기호 = 그 값(기계 코드) + code = mc.plain_text(got["값"][code]) + if kind == "율" or (kind == "수" and line.get("수량") in (None, "")): + kind = "율" + out.append( + { + "번": i, + "이름": str(line.get("이름") or ""), + "갈래": kind, + "코드": code, + "수량": _key(row.get("수량")) if kind != "율" else None, + "단가": _key(row.get("단가")) if kind != "율" else None, + "비목": {k: _key(v) for k, v in (row.get("비목") or {}).items() if v}, + "금액": _key(row.get("금액")), + "합밖": bool(row.get("합밖")), + } + ) + return out + + +def resources(doc: dict) -> dict[str, set]: + """문서가 쓸 수 있는 자원(옵션 무관) — {노임 키, 재료, 기계}.""" + out: dict[str, set] = {"노임": set(), "재료": set(), "기계": set()} + for line in doc.get("금액") or []: + kind, code = _what(doc, line) + if kind == "노임": + out["노임"].add(code) + elif kind == "재료": + out["재료"].add(code or "고름") + elif kind in ("기계", "산출"): + out["기계"].add(code or kind) + return out + + +# ── 연결 기억 ───────────────────────────────────────────────────────── +class Memo: + """M01 연결 + 셈 기억(요소 · 표 · 부른 단가산출) — 같은 값을 조합마다 다시 찾지 않음.""" + + def __init__(self, links: Any): + self.links, self.hits, self.calls = links, {}, {} + + def _hit(self, tag: str, p: dict, fn: Any) -> Any: + key = (tag, json.dumps(p, sort_keys=True, ensure_ascii=False, default=str)) + if key not in self.hits: + try: + self.hits[key] = (True, fn(p)) + except (mc.Gap, mc.Broken) as e: + self.hits[key] = (False, e) + ok, got = self.hits[key] + if not ok: + raise got + return got + + def element(self, p: dict) -> Any: + return self._hit("요소", p, self.links.element) + + def table(self, p: dict) -> Any: + return self._hit("표", p, self.links.table) + + def doc(self, key: str) -> dict | None: + return self.links.doc(key) + + +# ── ① 후보 ──────────────────────────────────────────────────────────── +class Index: + """M01 수식 문서 색인(결과단위 · 자원 · 절) — 프로세스 안 한 번.""" + + def __init__(self, docs: dict[str, dict]): + self.docs = docs + self.by_unit: dict[str, list[str]] = {} + self.res: dict[str, dict[str, set]] = {} + self.secs: dict[str, set[str]] = {} + self.regions: set[str] = set() + for key, doc in docs.items(): + for v in doc.get("변수") or []: + if str(v.get("열쇠") or "").endswith("지역"): + self.regions |= {str(x) for x in (v.get("연결") or {}).get("목록") or []} + unit = unit_of(doc.get("결과단위")) + if not unit or not doc.get("금액"): + continue + self.by_unit.setdefault(unit, []).append(key) + self.res[key] = resources(doc) + self.secs[key] = sections([doc.get("출처")]) + + def candidates(self, doc: dict) -> list[str]: + mine = resources(doc) + keys = [] + for key in self.by_unit.get(unit_of(doc.get("결과단위")), []): + have = self.res[key] + if not mine["노임"] <= have["노임"]: + continue + if mine["재료"] and not have["재료"]: + continue + if mine["기계"] and not have["기계"]: + continue + if not (mine["노임"] or mine["재료"] or mine["기계"]): + continue + keys.append(key) + secs = sections([x.get("글") for x in (doc.get("가져온") or {}).get("추정") or []]) + hit = [k for k in keys if secs & self.secs[k]] + return hit or keys + + +# ── ② 옵션 풀이 ─────────────────────────────────────────────────────── +def _closure(doc: dict) -> dict[str, set[str]]: + """기호 → 그 기호가 (거쳐서) 부르는 이름 전부.""" + direct: dict[str, set[str]] = {} + for v in doc.get("변수") or []: + direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {})) + for line in doc.get("식") or []: + try: + direct[str(line.get("기호"))] = set(md.names(md.parse(line.get("식")))) + except md.DocError: + direct[str(line.get("기호"))] = set() + memo: dict[str, set[str]] = {} + + def reach(name: str, seen: tuple = ()) -> set[str]: + if name in memo: + return memo[name] + out = set() + for n in direct.get(name, ()): + if n not in seen: + out |= {n} | reach(n, (*seen, name)) + memo[name] = out + return out + + return {n: reach(n) for n in direct} + + +def _uses(text: Any, close: dict) -> set[str] | None: + try: + names = md.names(md.parse(text)) if text not in (None, "") else [] + except md.DocError: + return None + out = set(names) + for n in names: + out |= close.get(n, set()) + return out + + +def numbers_of(doc: dict) -> list[Decimal]: + """가져온 식에 나온 수(숫자 변수 · 식 · 수량 글 · 규격 · 이름).""" + got: list[Decimal] = [] + texts = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")] + for v in doc.get("변수") or []: + val = (v.get("연결") or {}).get("값") + if not isinstance(val, dict): + texts.append(val) + texts += [x.get("식") for x in doc.get("식") or []] + texts += [x.get("수량") for x in doc.get("금액") or []] + texts += [x.get("규격") for x in doc.get("금액") or []] + for t in texts: + for s in _NUMS.findall(str(t or "")): + d = Decimal(s).normalize() + if d not in got: + got.append(d) + return got + + +def _words(doc: dict) -> str: + """가져온 식 글(이름 · 규격 · 줄 규격 · 부름 입력 값 — 줄 이름 뺌) — 선택 값의 글 근거.""" + bits = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")] + bits += [x.get("규격") for x in doc.get("금액") or []] + for v in doc.get("변수") or []: + bits += [str(x) for x in ((v.get("연결") or {}).get("입력") or {}).values()] + return " ".join(str(b or "") for b in bits).replace(" ", "") + + +def domains( + doc: dict, imported: dict, regions: set[str] = frozenset() +) -> tuple[dict[str, list], dict[str, set]]: + """선택 · 입력 변수 → (돌릴 값, 글 근거) — 기본 먼저 · 가져온 글에 든 선택 값(근거) 먼저 · + 글 입력 지역 = 가져온 글에 든 지역 이름.""" + words, nums = _words(imported), numbers_of(imported) + out: dict[str, list] = {} + ev: dict[str, set] = {} + for v in doc.get("변수") or []: + link = v.get("연결") or {} + kind, base = link.get("갈래"), link.get("값") + if kind == "선택": + opts = list(link.get("목록") or []) + seen = [o for o in opts if str(o).replace(" ", "") in words and len(str(o)) > 1] + seen.sort(key=lambda o: -len(str(o))) + if seen: + ev[md.var_id(v)] = set(seen) + rest = [o for o in opts if o not in seen] + first = [base] if base in rest else [] + out[md.var_id(v)] = seen + first + [o for o in rest if o != base] + elif kind == "입력": + try: + d = Decimal(str(base).replace(",", "")).normalize() + except (InvalidOperation, ValueError): + if str(v.get("열쇠") or "").endswith("지역"): + seen = sorted((r for r in regions if len(r) > 1 and r in words), key=len) + out[md.var_id(v)] = list(dict.fromkeys([base, *seen[::-1]])) + continue # 그 밖 글 입력 = 기본 그대로 + low, high = (mc.number(x) for x in (link.get("범위") or [None, None])) + vals = [d, Decimal(0)] + nums + vals = [x for x in vals if (low is None or x >= low) and (high is None or x <= high)] + out[md.var_id(v)] = list(dict.fromkeys(vals))[:CAP_INPUT] + return out, ev + + +def _given(doc: dict, pick: dict) -> dict: + ids = {md.var_id(v): v.get("열쇠") or md.var_id(v) for v in doc.get("변수") or []} + return {ids[k]: v for k, v in pick.items()} + + +def _eval(doc: dict, links: Any, pick: dict, text: Any, memo: dict) -> Any: + run = mc._Run(doc, links, _given(doc, pick), (str(doc.get("키")),), links.calls) + got = run.expr(text, "줄") + if isinstance(got, Decimal) and got == 0: + return 0 + return _key(got) if isinstance(got, (Decimal, dict)) else None + + +def narrow( + doc: dict, links: Any, dom: dict, want: dict[str, set], ev: dict[str, set] | None = None +) -> dict[str, list]: + """줄마다 의존 변수만 돌려 가져온 값(수량 · 단가) · 0 에 닿는 값만 남김(두 바퀴) · + 글 근거 값(`ev`)은 안 지움(값이 달라도 그 조합을 셈 — 다른 항으로 드러냄).""" + ev = ev or {} + close = _closure(doc) + memo: dict = {} + base = {k: vs[0] for k, vs in dom.items()} + jobs = [] + for line in doc.get("금액") or []: + for slot, field in (("수량", "수량"), ("단가", "단가")): + uses = _uses(line.get(field), close) + if uses is None or uses & (set(md.MONEY_NAMES) | _money_syms(doc)): + continue + free = sorted(u for u in uses if u in dom) + if free: + jobs.append((line.get(field), free, want[slot])) + for _ in range(2): + for text, free, targets in jobs: + space = [dom[f] for f in free] + if _size(space) > CAP_LINE: + continue + ok: dict[str, set] = {f: set() for f in free} + for combo in itertools.product(*space): + pick = {**base, **dict(zip(free, combo))} + got = _eval(doc, links, pick, text, memo) + if got == 0 or got in targets: + for f, c in zip(free, combo): + ok[f].add(c) + if all(ok.values()): + for f in free: + ok[f] |= ev.get(f, set()) + dom[f] = [x for x in dom[f] if x in ok[f]] + base[f] = dom[f][0] + return dom + + +def _money_syms(doc: dict) -> set[str]: + return {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")} + + +def _size(space: list[list]) -> int: + n = 1 + for s in space: + n *= max(len(s), 1) + return n + + +def combos(doc: dict, dom: dict) -> list[dict]: + """좁힌 값 조합(기본 쪽 먼저 · 상한 `CAP_ALL`) — 한 값뿐인 변수는 그 값.""" + keys = [k for k, v in dom.items() if v] + out = [] + for combo in itertools.product(*(dom[k] for k in keys)): + out.append(dict(zip(keys, combo))) + if len(out) >= CAP_ALL: + break + return out + + +# ── ③ 줄 대조 ───────────────────────────────────────────────────────── +def _ratio(a: Any, b: Any) -> str | None: + if not isinstance(a, Decimal) or not isinstance(b, Decimal) or not a or not b: + return None + for n in NICE: + if _key(a * n) == b: + return f"× {n}" + if _key(b * n) == a: + return f"÷ {n}" + return None + + +def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]: + """가져온 줄(mine) ↔ M01 줄(ours) → {같음, 짝, 핵심, 다른 [{항, 글}]}.""" + left, right = list(mine), list(ours) + same = paired = 0 + diffs: list[dict] = [] + + def take(test) -> list[tuple[dict, dict]]: + hits = [] + for a in list(left): + b = next((x for x in right if test(a, x)), None) + if b is not None: + left.remove(a) + right.remove(b) + hits.append((a, b)) + return hits + + def core(a: dict, b: dict) -> bool: + return a["갈래"] != "율" and b["갈래"] != "율" + + def cls(a: dict, b: dict) -> bool: + return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"} + + exact = take( + lambda a, b: ( + a["수량"] == b["수량"] and a["단가"] == b["단가"] and a["비목"] == b["비목"] + if core(a, b) + else a["갈래"] == b["갈래"] == "율" and a["비목"] == b["비목"] + ) + ) + same += sum(1 for a, _ in exact if a["갈래"] != "율") + for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["수량"] == b["수량"]): + paired += 1 + if a["갈래"] == "기계" and b["갈래"] == "기계" and a["코드"] != b["코드"]: + diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']}"}) + elif a["단가"] != b["단가"]: + diffs.append({"항": "단가 차이", "글": f"{a['이름']} 단가 {a['단가']} ↔ {b['단가']}"}) + else: + diffs.append({"항": "비목", "글": f"{a['이름']} 비목 다름"}) + for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]): + paired += 1 + how = _ratio(a["수량"], b["수량"]) + what = "단위 환산" if how else "수량" + diffs.append({"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"}) + for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"): + paired += 1 + diffs.append( + {"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"} + ) + for a, b in take(lambda a, b: a["갈래"] == b["갈래"] == "율" and a["금액"] == b["금액"]): + diffs.append({"항": "비목", "글": f"율 줄 {a['이름']} 비목 다름"}) + for a, b in take(lambda a, b: a["갈래"] == b["갈래"] == "율"): + diffs.append({"항": "율 값", "글": f"{a['이름']} {a['금액']} ↔ {b['이름']} {b['금액']}"}) + for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)): + for x in rows: + if x["갈래"] == "율": + what = "공구손료 유무" if "공구" in x["이름"] else "율 줄" + else: + what = "줄 없음" + diffs.append({"항": what, "글": f"{side} — {x['이름']}"}) + n_core = sum(1 for a in mine if a["갈래"] != "율") + return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs} + + +def _totals(got: dict) -> dict: + return {k: _key(mc.number(got.get(k))) for k in md.BUNDLE} + + +EXPLAINED = ( + "이름 다름", + "단가 차이", + "단위 환산", + "기계 규격", + "비목", + "율 값", + "율 줄", + "공구손료 유무", + "끝수", +) + + +def verdict( + mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = () +) -> dict: + """확정 = 다른 항 0(옵션 글 근거까지 맞음) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄 있음 또는 + 다른 항이 모두 까닭 있는 갈래 — 단가 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음.""" + res = compare(mine, ours) + res["다른"] = [*off, *res["다른"]] + ta, tb = _totals(got_a), _totals(got_b) + if not res["다른"] and ta != tb: + res["다른"].append({"항": "끝수", "글": f"줄은 같고 합이 다름 {ta} ↔ {tb}"}) + if not res["다른"] and res["같음"]: + res["판정"] = SURE + elif res["짝"] * 2 >= res["핵심"] + 1 and ( + res["같음"] or all(d["항"] in EXPLAINED for d in res["다른"]) + ): + res["판정"] = NEAR + else: + res["판정"] = NONE + return res + + +# ── 한 문서 ─────────────────────────────────────────────────────────── +def _calc(doc: dict, links: Any, given: dict) -> dict | None: + try: + got = mc.calc(doc, links, given, (), getattr(links, "calls", None)) + except Exception: # noqa: BLE001 — 못 셈 = 그 조합 버림 + return None + return got if got.get("셈됨") else None + + +def try_doc( + base: dict, links: Any, imported: dict, got_imp: dict, mine: list[dict], regions: set[str] +) -> dict: + """M01 문서 하나 — 옵션 풀이 · 가장 나은 조합 판정(이름 · 절 근거 없으면 「이름 다름」 = 확정 아님).""" + want = { + "수량": {x["수량"] for x in mine if x["수량"] is not None}, + "단가": {x["단가"] for x in mine if x["단가"] is not None}, + } + dom, ev = domains(base, imported, regions) + dom = narrow(base, links, dom, want, ev) + named = [] if same_name(imported, base) else [_unnamed(imported, base)] + best: dict | None = None + for pick in combos(base, dom) or [{}]: + got = _calc(base, links, _given(base, pick)) + if got is None: + continue + off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]] + off += named + res = verdict(mine, facts(base, got), got_imp, got, off) + rank = (res["판정"] == SURE, -len(off), res["같음"], res["짝"], -len(res["다른"])) + if best is None or rank > best["순위"]: + best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)} + if res["판정"] == SURE: + break + return best or {"판정": NONE, "같음": 0, "짝": 0, "다른": [], "순위": (False, -9, 0, 0, 0)} + + +def _off(doc: dict, vid: str, value: Any, seen: set) -> dict: + """옵션 값이 가져온 글 근거와 다름 — 다른 항 「옵션 글」.""" + var = next((v for v in doc.get("변수") or [] if md.var_id(v) == vid), {}) + name = var.get("이름") or vid + return {"항": "옵션 글", "글": f"{name} 글 {'·'.join(map(str, seen))} ↔ {value}"} + + +def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]: + """가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 금액, 후보수}`.""" + links = links if isinstance(links, Memo) else Memo(links) + got_imp = _calc(imported, links_imp, {}) + if got_imp is None: + return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0} + mine = facts(imported, got_imp) + keys = index.candidates(imported) + best, best_key = None, None + for key in keys: + res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions) + if best is None or res["순위"] > best["순위"]: + best, best_key = res, key + if res["판정"] == SURE: + break + if best is None: + return {"판정": NONE, "후보수": 0} + base = index.docs[best_key] + if best["판정"] == NONE: # 가까운 후보(보이기만 — 단추 없음) + near = {"키": best_key, "이름": base.get("이름"), "다른": best["다른"][:12]} + return {"판정": NONE, "후보수": len(keys), "가까움": near} + return { + "판정": best["판정"], + "키": best_key, + "이름": base.get("이름"), + "옵션": _shown(base, best["옵션"]), + "다른": best["다른"], + "금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])}, + "후보수": len(keys), + } + + +def _shown(doc: dict, pick: dict) -> dict[str, Any]: + """고른 옵션(기본과 다른 것) — {열쇠: 값}(수 = 수 · 선택 목록의 수 값과 같은 꼴 · SK 입력값).""" + out = {} + for v in doc.get("변수") or []: + vid = md.var_id(v) + if vid in pick and mc.plain_text(mc.number(pick[vid])) != mc.plain_text( + mc.number((v.get("연결") or {}).get("값")) + ): + out[str(v.get("열쇠") or vid)] = ml._norm(mc.number(pick[vid])) + return out + + +_STOP = {"설치", "해체", "인력", "기계", "작업", "운반", "타설", "제작"} + + +def _grams(texts: list[Any]) -> set[str]: + """이름 낱말 — 한글 두 글자 묶음 · 영문 · 수 낱말(일반 낱말 뺌).""" + out: set[str] = set() + for t in texts: + t = re.sub(r"\s|및", "", str(t or "")) + for run in re.findall(r"[가-힣]+", t): + out |= {run[i : i + 2] for i in range(len(run) - 1)} + out |= {w.lower() for w in re.findall(r"[A-Za-z]{2,}", t)} + return out - _STOP + + +def same_name(imported: dict, base: dict) -> bool: + """뜻 근거 — 품셈 절 번호가 같거나 이름 낱말이 겹침(값만 같은 남의 기초를 확정으로 안 묶음).""" + secs = sections([x.get("글") for x in (imported.get("가져온") or {}).get("추정") or []]) + if secs & sections([base.get("출처")]): + return True + return bool(_grams([imported.get("이름")]) & _grams([base.get("이름")])) + + +def _unnamed(imported: dict, base: dict) -> dict: + return {"항": "이름 다름", "글": f"{imported.get('이름')} ↔ {base.get('이름')}"} + + +def _plain(d: dict) -> dict: + return {k: (None if v is None else float(v)) for k, v in d.items()} diff --git a/M02_MasterTemplete/M02_Doc_MatchSk.py b/M02_MasterTemplete/M02_Doc_MatchSk.py new file mode 100644 index 000000000..9cad03cce --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_MatchSk.py @@ -0,0 +1,104 @@ +"""가져온 식 SK ↔ 우리 M01 기초 — 연결 찾기 기록 · 「확정」 때만 우리 기초 사본으로 바꾸기 · 되돌리기 +(PLAN 810-4 · 요구 707 · 708 · 709 · 703) — 서버 단독. + + 찾기(`find`) = 가져온 식 문서(우리 단가로 셈) → `M02_Doc_Match.match` → SK `가져온.원본.연결후보` 에 적음. + 바꾸기(`swap`) = 서버가 다시 찾아 「확정」 일 때만 — 같은 SK 키에 M01 사본 SK(바탕 = 그 기초 · + 입력값 = 찾은 옵션 · 802-4a 꼴 · 수식 사본은 저장 때 서버가 찍음)를 씀 · 원래 가져온 식 SK 몸은 + `가져온보관` 칸에 그대로 둠 · 일위대가 줄 참조 = 키 그대로(SK 내용만 바뀜 → 금액 다시 셈). + 되돌리기(`back`) = `가져온보관` 몸을 그 키에 다시 씀(가져온 식 금액 그대로). +우리 M01 기초는 어느 갈래에서도 안 바뀜(708) · 근접 · 없음 = 바꾸기 409(703). +""" + +from __future__ import annotations + +from typing import Any + +from common_util.common_util_time import utc_now_text +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Import as dim +from M02_MasterTemplete import M02_Doc_Match as dm +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Imported as imp + +KEEP = "가져온보관" # 바꾼 SK 의 원래 가져온 식 몸(되돌리기) +_INDEX: dict[str, Any] = {"문서": None, "색인": None} + + +def _bad(why: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, why) + + +def index() -> dm.Index: + """M01 수식 문서 색인 — 폴더가 바뀌면(M01 관리자 고침) 다시.""" + docs = dc.folder() + if _INDEX["문서"] is not docs: + _INDEX["문서"], _INDEX["색인"] = docs, dm.Index(docs) + return _INDEX["색인"] + + +def _read(key: str) -> dict[str, Any]: + if not sd.KEY.match(str(key or "")): + raise _bad("키(SK-######)") + return store.read(sd.KIND, key) + + +def judge(sk: dict) -> dict[str, Any]: + """가져온 식 SK → 연결후보(저장 없음).""" + whole = imp._whole() + links_imp = dim.Links(whole, "우리") + doc = dim.of(sk) + links_imp.learn(doc) + got = dm.match(doc, links_imp, index(), dm.ml.Links(whole, dc.folder())) + return {**got, "때": utc_now_text()} + + +def find(key: str, version: str) -> dict[str, Any]: + """연결 찾기 → SK `가져온.원본.연결후보` 에 적음 → {연결후보, 판}.""" + got = _read(key) + sk = got["문서"] + if not dim.is_imported(sk): + raise _bad("가져온 식 단가산출만 연결을 찾음") + found = judge(sk) + meta = sk[imp.KEY] + new = {**sk, imp.KEY: {**meta, "원본": {**(meta.get("원본") or {}), dm.KEY: found}}} + info = store.write(sd.KIND, key, version or got["판"], new) + return {dm.KEY: found, "판": info.get("판")} + + +def copy_body(sk: dict, found: dict) -> dict[str, Any]: + """M01 사본 SK 몸(이름 · 단위 · 구분 그대로 · 바탕 = 찾은 기초 · 입력값 = 찾은 옵션).""" + keep = {k: sk.get(k) for k in ("키", "이름", "단위", "구분", "상세구분") if sk.get(k)} + old = {k: v for k, v in sk.items() if k not in ("키", "종류", KEEP)} + body = { + "덮개": {}, + "뺀줄": [], + "더한줄": [], + "고름": {}, + "자재표시": {}, + } # M01 그대로(고침 없음) + base = {"바탕": {"로직": found["키"]}, "입력값": dict(found.get("옵션") or {})} + return {**keep, **body, **base, KEEP: old} + + +def swap(key: str, version: str) -> dict[str, Any]: + """「확정」 일 때만 우리 기초 사본으로 — 서버가 다시 찾음(화면 판정을 믿지 않음).""" + got = _read(key) + sk = got["문서"] + if not dim.is_imported(sk): + raise _bad("가져온 식 단가산출만 바꿈") + found = judge(sk) + if found.get("판정") != dm.SURE: + raise _bad(f"우리 기초와 「확정」 일치가 아님({found.get('판정')}) — 바꾸지 않음", 409) + info = store.write(sd.KIND, key, version or got["판"], copy_body(sk, found)) + return {dm.KEY: found, "판": info.get("판")} + + +def back(key: str, version: str) -> dict[str, Any]: + """우리 기초 사본 → 원래 가져온 식(보관 몸 그대로).""" + got = _read(key) + old = got["문서"].get(KEEP) + if not isinstance(old, dict) or not old.get(imp.KEY): + raise _bad("되돌릴 가져온 식 없음") + info = store.write(sd.KIND, key, version or got["판"], {**old, "키": key}) + return {"판": info.get("판")} diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py index f248f7f1f..1f7b117f3 100644 --- a/M02_MasterTemplete/M02_Doc_Router.py +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -4,6 +4,8 @@ POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?} → {…, 전, 후, 바뀐자원?, 버림?} POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴 + POST /api/m02/layers/{층}/sangeun/{키}/match {할일 찾기|바꾸기|되돌리기, 판?}(층 쓰기 · 810-4) + → {연결후보?, 판}(바꾸기 = 확정만 · 아니면 409) ⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). """ @@ -48,6 +50,31 @@ def preview_doc( return _call(folder, path.preview, key, body.model_dump(exclude_none=True)) +class MatchBody(BaseModel): + 할일: str = "찾기" # 찾기 | 바꾸기 | 되돌리기(810-4) + 판: str | None = None + + +@router.post("/layers/{layer}/sangeun/{key}/match") +def match_doc( + layer: Layer, + key: str, + body: MatchBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """가져온 식 ↔ 우리 기초(810-4) — 찾기(연결후보 적음) · 바꾸기(확정만 · M01 사본 SK) · 되돌리기.""" + from fastapi import HTTPException + + from M02_MasterTemplete import M02_Doc_MatchSk as msk + + jobs = {"찾기": msk.find, "바꾸기": msk.swap, "되돌리기": msk.back} + if body.할일 not in jobs: + raise HTTPException(status_code=400, detail="찾기 · 바꾸기 · 되돌리기") + folder = _root(session, layer, owner, write=True) + return _call(folder, jobs[body.할일], key, body.판 or "") + + @router.post("/layers/{layer}/sangeun/{key}/doc/save") def save_doc( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 91b515372..d539a3195 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -68,8 +68,8 @@ export interface SangeunDetailOptions { source?: boolean; /** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */ onVersion?: (version: string) => void; - /** 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에 */ - slot?: HTMLElement; + slot?: HTMLElement; // 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에 + match?: Parameters[0]; // 원문 패널 판정 띠 단추(810-4) } const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; @@ -104,7 +104,7 @@ export function mountSangeunDetail( const inputsOf = new Map(); const infoOf = new Map(); // 더한줄 로직 → 원문 탭(752-1) let extraKeys: string[] = []; - const side = opt.source ? createSangeunSide() : null; + const side = opt.source ? createSangeunSide(opt.match) : null; let seq = 0; let alive = true; @@ -430,7 +430,7 @@ export function mountSangeunDetail( side?.set([ ...(base ? [tabOf(base.logic)] : []), ...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])), - ], doc.가져온); + ], doc); } /** 재료 고름(줄 모달) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts new file mode 100644 index 000000000..d73d377a2 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_MatchBand.ts + * 가져온 식 ↔ 우리 기초 판정 띠(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 「어디서 왔나」 카드 맨 위. + * 글 · 단추 = `logic/M02_Sangeun_Match.ts` `matchBand` · 길 POST `/m02/layers/{층}/sangeun/{키}/match` + * {할일 찾기 | 바꾸기 | 되돌리기} — 서버가 다시 찾아 「확정」 일 때만 바꿈(화면 판정을 믿지 않음) · + * 바꾸기 · 되돌리기 = 확인창 · 저장 안 한 고침이 있으면 막음(먼저 [저장]) · 끝나면 `done(판)` = 그 SK 를 다시 엶 + * 층 쓰기 권한 없음(읽기만) = 단추 없음 + * ========================================================================== */ + +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements"; +import { askConfirm } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { showToast } from "@ui/ui_template_toast"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { matchBand, type MatchAction } from "./logic/M02_Sangeun_Match"; + +export interface MatchCtx { + layer: Layer; + owner: string | null; + key: string; + readOnly: boolean; + /** 저장 안 한 고침 있음 */ + dirty: () => boolean; + /** 서버가 SK 를 고친 뒤(새 판) — 그 SK 다시 엶 */ + done: (version: string) => void; +} + +const LABEL: Record, string> = { + 찾기: "우리 기초와 연결 찾기", + 바꾸기: "우리 기초 사본으로 바꾸기", + 되돌리기: "가져온 식으로 되돌리기", +}; +const ASK: Partial, string>> = { + 바꾸기: + "이 단가산출을 우리 기초 사본(찾은 옵션)으로 바꿈 — 키 그대로 · 일위대가 금액이 다시 셈됨 · 원래 가져온 식은 보관", + 되돌리기: "우리 기초 사본을 원래 가져온 식으로 되돌림 — 가져온 식 금액 그대로", +}; + +/** 판정 띠 — 가져온 식 · 바꾼 SK 가 아니면 null */ +export function createMatchBand(sk: unknown, ctx: MatchCtx | undefined): HTMLElement | null { + const band = matchBand(sk as Parameters[0]); + if (!band) return null; + const act = band.action; + const actions = + act && ctx && !ctx.readOnly && ctx.key + ? [ + createButton({ + label: LABEL[act], + variant: act === "바꾸기" ? "filled" : "ghost", + size: 24, + onClick: (ev) => void run(act, ctx, ev.currentTarget as HTMLButtonElement), + }), + ] + : []; + const details = band.details.length + ? el("div", { children: band.details.map((x) => createText(x, "muted", 12)) }) + : undefined; + const root = createBanner({ tone: band.tone, text: band.text, details, actions }); + root.setAttribute("data-sk-match", act ?? ""); + return root; +} + +async function run(act: Exclude, ctx: MatchCtx, btn: HTMLButtonElement) { + if (ctx.dirty()) return void showToast("저장 안 한 고침이 있음 — 먼저 [저장]", "warning"); + const ask = ASK[act]; + if (ask && !(await askConfirm({ message: ask, okLabel: LABEL[act] }))) return; + const label = btn.textContent; + btn.disabled = true; + btn.textContent = act === "찾기" ? "찾는 중…" : "바꾸는 중…"; + try { + const owner = ctx.owner ? `?owner=${encodeURIComponent(ctx.owner)}` : ""; + const path = `/layers/${ctx.layer}/sangeun/${encodeURIComponent(ctx.key)}/match${owner}`; + const got = await call<{ 판?: string }>(path, { + method: "POST", + body: JSON.stringify({ 할일: act }), + }); + ctx.done(got.판 ?? ""); + } catch (e) { + showToast(e instanceof Error ? e.message : String(e), "error"); + btn.disabled = false; + btn.textContent = label; + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts index 2e1179b1f..b492db6cb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -7,6 +7,7 @@ * 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) · * 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드 * (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음) + * 카드 맨 위 = 우리 기초 판정 띠(810-4 · `Sangeun_MatchBand`) · 우리 기초 사본으로 바꾼 SK = 보관한 가져온 식 카드 * 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬). * ========================================================================== */ @@ -22,6 +23,7 @@ import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_S import { createSourceFind } from "../M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind"; import { originOf, type Origin, type OriginMeta } from "./logic/M02_Sangeun_Origin"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createMatchBand, type MatchCtx } from "./M02_MasterTemplete_Sangeun_MatchBand"; export interface SourceTab { label: string; @@ -61,7 +63,7 @@ const tx = (k: keyof typeof TEXT, n = 0): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n)); /** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */ -function originCard(o: Origin): HTMLElement { +function originCard(o: Origin, band: HTMLElement | null): HTMLElement { const marks = el("div"); const showMarks = (all: boolean): void => marks.replaceChildren( @@ -80,6 +82,7 @@ function originCard(o: Origin): HTMLElement { title: tx("Origin"), pad: 12, body: [ + ...(band ? [band] : []), ...(o.요약 ? [el("strong", { text: o.요약, attrs: { "data-origin-native": "" } })] : []), createKv([ { label: tx("Program"), value: o.프로그램 }, @@ -100,10 +103,10 @@ function originCard(o: Origin): HTMLElement { return card; } -export function createSangeunSide(): { +export function createSangeunSide(match?: MatchCtx): { wrap: (main: HTMLElement) => HTMLElement; - /** 원문 탭 · 가져온 식 머리(`가져온` — 있으면 첫 화면 = 카드) */ - set: (tabs: SourceTab[], imported?: unknown) => void; + /** 원문 탭 · SK 문서(`가져온` · 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드 + 판정 띠) */ + set: (tabs: SourceTab[], sk?: unknown) => void; } { const view = createSourceView({ path: "/m02/logic-lib/source", @@ -122,6 +125,7 @@ export function createSangeunSide(): { }); let tabs: SourceTab[] = []; let origin: Origin | null = null; + let skDoc: unknown = null; let at = 0; const side = createSidePanels({ prefKey: "m02-sangeun-side", @@ -136,7 +140,8 @@ export function createSangeunSide(): { view.show(found); return void content.replaceChildren(view.root); } - if (origin) return void content.replaceChildren(originCard(origin)); + if (origin) + return void content.replaceChildren(originCard(origin, createMatchBand(skDoc, match))); if (!tabs.length) return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted")); at = Math.min(at, tabs.length - 1); @@ -157,10 +162,12 @@ export function createSangeunSide(): { } return { wrap: (main) => el("div", { className: "ui-side-panels-row", children: [main, side.root] }), - set: (next, imported) => { - const card = originOf(imported as OriginMeta | null); - if (JSON.stringify([next, card]) === JSON.stringify([tabs, origin])) return; - [tabs, origin] = [next, card]; + set: (next, sk) => { + const one = sk as { 가져온?: unknown; 가져온보관?: { 가져온?: unknown } } | null; + const card = originOf((one?.가져온 ?? one?.가져온보관?.가져온) as OriginMeta | null); + const same = JSON.stringify([next, card, sk]) === JSON.stringify([tabs, origin, skDoc]); + if (same) return; + [tabs, origin, skDoc] = [next, card, sk ?? null]; paint(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index 66e131f31..9c636c468 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -3,8 +3,10 @@ * 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄). * 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`. * 804-8 = 페이지 [저장] 한 번에 수식도(`flush` 먼저 · 새 판) · 보기 고르기는 머리 줄 `slot`. + * 810-4 = 원문 패널 판정 띠 [바꾸기] · [되돌리기] · [연결 찾기] 뒤 그 SK 를 다시 읽어 상세를 다시 엶(새 판). * ========================================================================== */ +import { readTemplate } from "./M02_MasterTemplete_Api_Fetch"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; @@ -23,6 +25,37 @@ export function mountSangeunView( doc: unknown, opt: SangeunViewOptions, ): { getDoc: () => Promise; destroy: () => void } { - const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1 - return { getDoc: () => one.flush().then(() => one.getDoc()), destroy: one.destroy }; + let dirty = false; + const onChanged = (d: boolean): void => ((dirty = d), opt.onChanged(d)); + /** 판정 띠 단추(810-4) — 서버가 SK 를 고친 뒤 새 판 · 그 SK 를 다시 읽어 상세를 다시 엶 */ + const match = { + layer: opt.layer, + owner: opt.owner ?? null, + key: opt.name, + readOnly: opt.readOnly, + dirty: () => dirty, + done: (version: string): void => { + opt.onVersion?.(version); + void readTemplate(opt.layer, "sangeun", opt.name, null, opt.owner ?? null).then((got) => { + if (!alive) return; + one.destroy(); + one = mount(got.문서); + onChanged(false); + }); + }, + }; + const mount = (d: unknown) => + mountSangeunDetail(host, d, { + ...opt, + onChanged, + owner: opt.owner ?? null, + source: true, + match, + }); // 752-1 + let alive = true; + let one = mount(doc); + return { + getDoc: () => one.flush().then(() => one.getDoc()), + destroy: () => ((alive = false), one.destroy()), + }; } diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index 287f08d8e..0ecc5c8e3 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -251,7 +251,11 @@ def _meta(raw: Any, name: str) -> Any: def clean(raw: Any, out: dict) -> dict: - """`sd.clean` 끝(`const.clean_more`) — `가져온` 칸 · 바탕과 같이 못 둠.""" + """`sd.clean` 끝(`const.clean_more`) — `가져온` 칸 · 바탕과 같이 못 둠 · + `가져온보관` = 우리 기초 사본으로 바꾼 SK 의 원래 가져온 식 몸(810-4 되돌리기).""" + kept = raw.get("가져온보관") if isinstance(raw, dict) else None + if isinstance(kept, dict) and isinstance(kept.get(KEY), dict): + out = {**out, "가져온보관": _meta(kept, "가져온보관")} meta = raw.get(KEY) if isinstance(raw, dict) else None if meta in (None, {}): return out diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts new file mode 100644 index 000000000..05256cf65 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * M02_Sangeun_Match.ts + * 가져온 식 ↔ 우리 M01 기초 연결 판정 띠 자료(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 카드 위. + * SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나: + * 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] · + * 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기] + * 화면 · DOM · fetch 없음 + * ========================================================================== */ + +export type MatchVerdict = "확정" | "근접" | "없음"; + +export interface MatchFound { + 판정?: MatchVerdict; + 키?: string; + 이름?: string; + 옵션?: Record; + 다른?: { 항: string; 글: string }[]; + 금액?: { 가져온?: Record; 우리?: Record }; + 후보수?: number; + 가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] }; +} + +export type MatchAction = "찾기" | "바꾸기" | "되돌리기" | null; + +export interface MatchBand { + tone: "success" | "info" | "warning"; + text: string; + /** 다른 항 글(근접 · 없음의 가까운 후보) */ + details: string[]; + action: MatchAction; +} + +interface SkLike { + 바탕?: { 로직?: string } | null; + 입력값?: Record; + 가져온?: { 로직?: unknown; 원본?: { 연결후보?: MatchFound } } | null; + 가져온보관?: { 가져온?: { 원본?: { 연결후보?: MatchFound } } } | null; +} + +const opts = (o?: Record): string => { + const list = Object.entries(o ?? {}).map(([k, v]) => `${k} ${String(v)}`); + return list.length ? `(${list.join(" · ")})` : "(기본 옵션)"; +}; +const lines = (d?: { 항: string; 글: string }[]): string[] => + (d ?? []).map((x) => `${x.항} — ${x.글}`); + +/** SK → 띠 자료 · 가져온 식도 · 바꾼 SK 도 아니면 null */ +export function matchBand(sk: SkLike | null | undefined): MatchBand | null { + const kept = sk?.가져온보관; + if (kept && sk?.바탕?.로직) { + return { + tone: "info", + text: `우리 기초 사본 ${sk.바탕.로직} ${opts(sk.입력값)} — 원래 가져온 식은 보관`, + details: [], + action: "되돌리기", + }; + } + if (!sk?.가져온?.로직) return null; + const f = sk.가져온.원본?.연결후보; + if (!f?.판정) { + return { tone: "info", text: "우리 기초와 연결을 아직 안 찾음", details: [], action: "찾기" }; + } + const base = `${f.키 ?? ""} ${f.이름 ?? ""} ${opts(f.옵션)}`.trim(); + if (f.판정 === "확정") { + return { + tone: "success", + text: `우리 기초 ${base} 와 확정 일치`, + details: [], + action: "바꾸기", + }; + } + if (f.판정 === "근접") { + const kinds = [...new Set((f.다른 ?? []).map((x) => x.항))].join(" · "); + return { + tone: "warning", + text: `근접 — ${base} · 다른 항: ${kinds}`, + details: lines(f.다른), + action: null, + }; + } + const near = f.가까움; + return { + tone: "info", + text: `없음 — 우리 기초 후보 ${f.후보수 ?? 0} 개와 맞지 않음`, + details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [], + action: null, + }; +} diff --git a/docs/raw/요구_1104/_목록_810-4_연결판정.xlsx b/docs/raw/요구_1104/_목록_810-4_연결판정.xlsx new file mode 100644 index 000000000..52aa19e20 Binary files /dev/null and b/docs/raw/요구_1104/_목록_810-4_연결판정.xlsx differ diff --git a/resources/tester/helper_810_4_sweep.py b/resources/tester/helper_810_4_sweep.py new file mode 100644 index 000000000..36ee191aa --- /dev/null +++ b/resources/tester/helper_810_4_sweep.py @@ -0,0 +1,131 @@ +"""810-4 전수 도우미 — 원파일 산근 블록 전부(EST 6 · STC 7) × M01 수식 문서 1,518 연결 찾기. + +파일마다 프로세스 · 안쪽 산근(형제)은 같은 파일 블록을 우리 단가로 먼저 셈해 묶음 값으로 넣음(저장 층의 +형제 SK 읽기와 같은 값) · 확정은 M01 문서를 찾은 옵션(입력값 꼴)으로 다시 셈해 금액 대조. +마지막 줄 = JSON {블록, 판정, 근접항, 오류, 확정다시셈다름} · `--rows ` = 줄 목록 저장(목록 xlsx 용). +""" + +from __future__ import annotations + +import collections +import json +import os +import pickle +import sys +import time +from multiprocessing import Pool +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + + +def _ctx(): + import test_810_2_native_doc as t + + from M02_MasterTemplete import M02_Doc_Import as dim + from M02_MasterTemplete import M02_Doc_Match as dm + + whole = t.whole() + docs = dm.ml.load_all() + return t, dim, dm, whole, docs, dm.Index(docs), dm.Memo(dm.ml.Links(whole, docs)) + + +def file_docs(t, dim, dm, whole, kind: str, tag: str) -> dict[int, dict]: + """파일 블록 → 가져온 식 문서(형제 = 우리 단가 묶음).""" + saved = t.native(kind, tag) + nums = t.fn.numbers(saved) + by_code = {str(b.get("코드")): n for n, b in nums.items()} + links = dim.Links(whole, "우리") + made: dict[int, dict] = {} + ours: dict[int, dict | None] = {} + + def build(no: int, trail: tuple = ()) -> None: + if no in made: + return + out = t.fn.emit(saved, no) + doc = dim.derive(t.sk_of(out), None, whole) + for h in out["로직"]["호표"]: + res = h.get("자원") or {} + n = by_code.get(str(res.get("코드"))) + if res.get("갈래") != "단가산출" or n is None or n in trail or n == no: + continue + build(n, (*trail, no)) + got = ours.get(n) + line = next( + (x for x in doc["금액"] if x["이름"] == h["이름"] and x.get("비목") == "묶음"), None + ) # noqa: E501 + if not line or not got: + continue + for v in doc["변수"]: + if dm.md.var_id(v) == line["단가"] and (v.get("연결") or {}).get("갈래") == "숫자": + v["연결"] = {"갈래": "숫자", "값": {k: str(got[k]) for k in dm.md.COSTS}} + made[no] = doc + ours[no] = dm._calc(doc, links, {}) + + for no in nums: + build(no) + return made + + +PARTS = 10 # 파일마다 블록을 나눠 맡김(큰 파일 한 프로세스가 끝을 붙잡지 않게) + + +def sweep(arg: tuple[str, str, int]) -> list[dict]: + kind, tag, part = arg + t, dim, dm, whole, docs, index, links = _ctx() + rows = [] + try: + made = file_docs(t, dim, dm, whole, kind, tag) + except Exception as e: # noqa: BLE001 — 원파일 없음(다른 PC) = 빈 줄 + if part: + return [] + return [{"파일": tag, "오류": repr(e)[:200], "판정": "오류", "없는파일": "없음" in repr(e)}] + for no, doc in made.items(): + if no % PARTS != part: + continue + start = time.time() + try: + res = dm.match(doc, dim.Links(whole, "우리"), index, links) + except Exception as e: # noqa: BLE001 + res = {"판정": "오류", "까닭": repr(e)[:200]} + row = {"파일": tag, "꼴": kind, "번": no, "블록": doc["이름"], "단위": doc["결과단위"]} + row |= {"규격": (doc.get("가져온") or {}).get("규격"), **res} + if res.get("판정") == dm.SURE: + again = dm._calc(docs[res["키"]], dm.ml.Links(whole, docs), dict(res["옵션"])) + row["다시셈같음"] = ( + again is not None and dm._plain(dm._totals(again)) == res["금액"]["가져온"] + ) # noqa: E501 + row["초"] = round(time.time() - start, 2) + rows.append(row) + return rows + + +def main() -> None: + import test_810_1_estx as e + import test_810_1_stc as c + + tags = [("EST", k) for k in e.FILES] + [("STC", k) for k in c.FILES] + tags = [(kind, tag, part) for kind, tag in tags for part in range(PARTS)] + with Pool(min(len(tags), max(2, (os.cpu_count() or 4) - 2))) as pool: + rows = [r for part in pool.map(sweep, tags) for r in part] + if "--rows" in sys.argv: + pickle.dump(rows, open(sys.argv[sys.argv.index("--rows") + 1], "wb")) + real = [r for r in rows if not r.get("없는파일")] + out = { + "블록": sum(1 for r in real if r.get("판정") != "오류" or "번" in r), + "판정": dict(collections.Counter(r["판정"] for r in real)), + "근접항": dict( + collections.Counter(d["항"] for r in real if r["판정"] == "근접" for d in r["다른"]) + ), # noqa: E501 + "오류": sum(1 for r in real if r["판정"] == "오류"), + "확정다시셈다름": sum(1 for r in real if r.get("다시셈같음") is False), + } + print(json.dumps(out, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index fb2e2e0c2..7d29540b3 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -163,7 +163,7 @@ def test_상세_부품_길_부품상자(): view = _text(M02 / "M02_MasterTemplete_Sangeun_View.ts") detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts") - assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view + assert "mountSangeunDetail(host, d," in view and "createEmpty" not in view assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail assert "buildLabDetail(editor" in detail and "cover: { ho, open: lineOpener(hoCtx) }" in detail assert "buildCalc" not in detail # 799-2 시험 계산 보기 걷음 diff --git a/resources/tester/test_750_2_views_one_answer.py b/resources/tester/test_750_2_views_one_answer.py index 8ec1e48c9..04acc0b7d 100644 --- a/resources/tester/test_750_2_views_one_answer.py +++ b/resources/tester/test_750_2_views_one_answer.py @@ -41,7 +41,7 @@ def test_752_1_source_panel_only_on_sangeun_screen(): assert "source: true" in _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts") assert "source" not in _ts("M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts") detail = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") - assert "opt.source ? createSangeunSide()" in detail and "side?.set(" in detail + assert "opt.source ? createSangeunSide(opt.match)" in detail and "side?.set(" in detail def test_753_2_option_off_flag_reaches_sheet_and_views(): diff --git a/resources/tester/test_807_1_import_doc_one.py b/resources/tester/test_807_1_import_doc_one.py index 8675635ff..6c648a3b1 100644 --- a/resources/tester/test_807_1_import_doc_one.py +++ b/resources/tester/test_807_1_import_doc_one.py @@ -157,4 +157,4 @@ def test_source_find_on_m01_and_m02_panels() -> None: assert "/hopyo-build-all" in sheet and "번: h.번" in sheet assert '{ layer: "personal", owner: null }' in block and "openImported" in block detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text("utf-8") - assert "doc.가져온);" in detail # 원문 패널에 가져온 머리를 넘김 + assert "], doc);" in detail # 원문 패널에 SK 문서(가져온 머리 · 810-4 가져온보관)를 넘김 diff --git a/resources/tester/test_810_4_match.py b/resources/tester/test_810_4_match.py new file mode 100644 index 000000000..bb2bed4fc --- /dev/null +++ b/resources/tester/test_810_4_match.py @@ -0,0 +1,218 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""810-4 지킴 — 가져온 식 ↔ 우리 M01 기초 연결 찾기(`M02_Doc_Match`) · 확정일 때만 바꾸기(`M02_Doc_MatchSk`). + +(a) 판정 — 거창 원파일 블록: 확정(콘크리트 표층 인력포설 = GC000297 형식 B-Type · 금액 원 단위 같음) · + 근접(합판거푸집 4회 = GC000211 보통 · 4회 — 단가 차이 · 비목 · 율) · 요구 704 파형강관 매설 Φ800 = + GF000179 관경 800 근접 · 다른 항 = 단위 환산(× 6) · 기계 규격 · 율 줄 · 없음(후보 0) +(b) 옵션 풀이 — 줄 수량이 의존하는 변수만 돌려 좁힘 · 글 근거 값과 다르면 「옵션 글」 다른 항(확정 아님) +(c) 줄 대조 — 같음 · 단가 차이 · 단위 환산 · 율 줄 · 끝수(지어 맞추기 없음 — 1 원 달라도 확정 아님) +(d) 바꾸기 — 임시 층 개인: 확정 SK = 같은 키에 M01 사본(바탕 · 입력값) · 금액 = M01 문서 셈 · 원래 몸 보관 → + 되돌리기 = 원래 금액 · 근접 SK 바꾸기 = 409 · M01 문서 파일 그대로 +(e) 전수(1,518 문서 × 원파일 산근 2,220 블록 · 파일마다 프로세스) — 확정은 모두 다시 셈해도 금액 같음 · + 판정 셋 · 근접 다른 항 갈래 표(S8104_ALL=1 일 때만 · 수십 분 · S8104_ROWS= = 줄 목록 저장) +원파일 없으면 그 시험만 skip(다른 PC). +""" + +from __future__ import annotations + +import os +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 test_810_2_native_doc as t2 # noqa: E402 + +from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 +from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402 +from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402 +from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층 + + +@lru_cache(maxsize=1) +def ctx(): + whole = t2.whole() + docs = dm.ml.load_all() + return whole, docs, dm.Index(docs), dm.Memo(dm.ml.Links(whole, docs)) + + +def block(no: int, tag: str = "거창") -> dict: + """원파일 블록 → 가져온 식 문서(형제 산근 없는 블록만 씀 — 형제는 저장 층에서 읽음).""" + out = t2.fn.emit(t2.native("EST", tag), no) + return dim.derive(t2.sk_of(out), None, t2.whole()) + + +def judge(no: int) -> dict: + whole, _, index, links = ctx() + return dm.match(block(no), dim.Links(whole, "우리"), index, links) + + +# ── (a) 판정 ───────────────────────────────────────────────────────────── +def test_sure_one_block() -> None: + got = judge(33) # 콘크리트 포장포설 t=20cm이하 + assert got["판정"] == dm.SURE and got["키"] == "GC000297" + assert got["옵션"] == {"형식": "B-Type"} and not got["다른"] + assert got["금액"]["가져온"] == got["금액"]["우리"] + + +def test_near_formwork_lists_differences() -> None: + got = judge(41) # 합판거푸집 4회 + assert got["판정"] == dm.NEAR and got["키"] == "GC000211" + assert got["옵션"]["사용횟수"] == "4회" and got["옵션"]["유형"] == "보통" + kinds = {d["항"] for d in got["다른"]} + assert "단가 차이" in kinds and "비목" in kinds # 대전 합판 단가 · 공구손료 재료비 ↔ 경비 + + +def test_req704_pipe_is_near_with_unit_rate_machine() -> None: + got = judge(19) # 파형강관 매설 Φ800 ↔ GF000179(요구 704) + assert got["판정"] == dm.NEAR and got["키"] == "GF000179" and got["옵션"] == {"관경": 800} + kinds = [d["항"] for d in got["다른"]] + assert kinds.count("단위 환산") == 2 and "기계 규격" in kinds and "율 줄" in kinds + assert any("× 6" in d["글"] for d in got["다른"]) + + +def test_none_without_candidates() -> None: + got = judge(7) # 지장목 제거 — 같은 단위 · 자원 묶음 기초 없음 + assert got["판정"] == dm.NONE and not got.get("키") + + +# ── (b) 옵션 풀이 ──────────────────────────────────────────────────────── +def test_option_narrowing_and_text_evidence() -> None: + whole, docs, index, links = ctx() + imported = block(41) + base = docs["GC000211"] + dom, ev = dm.domains(base, imported, index.regions) + assert dom["n_사용"][0] == "4회" and "4회" in ev["n_사용"] # 규격 글 「4회」 = 근거 먼저 + got = dm._calc(imported, dim.Links(whole, "우리"), {}) + mine = dm.facts(imported, got) + want = {"수량": {x["수량"] for x in mine if x["수량"] is not None}, "단가": set()} + small = dm.narrow(base, links, {k: list(v) for k, v in dom.items()}, want, ev) + assert ( + small["S_유형"] == ["보통"] and D(0) in small["r_높이"] + ) # 5 ÷ 45 · 2 ÷ 45 = 보통 · 감률 0 + res = dm.try_doc(base, links, imported, got, mine, index.regions) + assert res["옵션"]["n_사용"] == "4회" + off = dm.verdict(mine, mine, got, got, [dm._off(base, "n_사용", "1회", {"4회"})]) + assert off["판정"] != dm.SURE and off["다른"][0]["항"] == "옵션 글" + + +# ── (c) 줄 대조 ────────────────────────────────────────────────────────── +def _line(name, kind, qty, price, cost="노무비", code=""): + q, p = D(qty), D(price) + return {"번": 0, "이름": name, "갈래": kind, "코드": code, "수량": dm._key(q), + "단가": dm._key(p), "비목": {cost: dm._key(q * p)}, "금액": dm._key(q * p), "합밖": False} # fmt: skip + + +def test_compare_kinds_and_no_tolerance() -> None: + a = [_line("인부", "노임", "0.04", "100"), _line("공", "노임", "0.5", "10")] + tot = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9} + assert dm.verdict(a, list(a), tot, tot)["판정"] == dm.SURE + off = dm.verdict(a, list(a), tot, {**tot, "노무비": 10, "계": 10}) + assert off["판정"] != dm.SURE and off["다른"][0]["항"] == "끝수" # 1 원도 확정 아님 + b = [_line("인부", "노임", "0.24", "100"), _line("공", "노임", "0.5", "11")] + res = dm.verdict(a, b, tot, tot) + assert [d["항"] for d in res["다른"]] == ["단가 차이", "단위 환산"] and res["판정"] == dm.NEAR + rate = {**_line("율", "율", "1", "3", "경비"), "수량": None, "단가": None} + res = dm.verdict([*a, rate], a, tot, tot) + assert res["다른"] == [{"항": "율 줄", "글": "가져온 쪽만 — 율"}] and res["판정"] == dm.NEAR + named = dm.verdict( + a, list(a), tot, tot, [dm._unnamed({"이름": "구조물되메우기"}, {"이름": "숲가꾸기"})] + ) + assert named["판정"] == dm.NEAR # 값만 같은 남의 기초 = 확정 아님 + assert not dm.same_name({"이름": "모르타르"}, {"이름": "기초다짐 및 뒷채움(인력)"}) + assert dm.same_name({"이름": "유로폼 설치및해체"}, {"이름": "유로폼 설치 및 해체"}) + c = [_line("딴", "노임", "3", "7", code="LB9")] + assert dm.verdict(a, c, tot, tot)["판정"] == dm.NONE + + +# ── (d) 바꾸기 · 되돌리기 ──────────────────────────────────────────────── +COSTS = ("노무비", "재료비", "경비") + + +def _money(key: str) -> list: + from M02_MasterTemplete import M02_Doc_Sk_Path as path + + got = path.read(key)["셈"] + return [got[k] for k in COSTS] + + +def test_swap_only_when_sure_and_back(world) -> None: + folder = world["personal"] + est = t2._file(t2.EST["거창"]) + before = {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")} + with store.at(folder): + hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"] + sure = fh.make(folder, hp, 33)["이름"] + near = fh.make(folder, hp, 41)["이름"] + old = store.read("sangeun", sure) + money0 = _money(sure) + found = msk.find(sure, old["판"]) + assert found[dm.KEY]["판정"] == dm.SURE + meta = store.read("sangeun", sure)["문서"]["가져온"]["원본"][dm.KEY] + assert meta["키"] == "GC000297" and meta["옵션"] == {"형식": "B-Type"} + assert _money(sure) == money0 # 찾기 = 기록만 · 금액 그대로 + msk.swap(sure, found["판"]) + now = store.read("sangeun", sure) + doc = now["문서"] + assert doc["바탕"] == {"로직": "GC000297"} and doc["입력값"] == {"형식": "B-Type"} + assert not doc.get("가져온") and doc[msk.KEEP]["가져온"]["로직"] + assert isinstance((doc.get("수식") or {}).get("문서"), dict) # 수식 사본(802-4a) 찍힘 + want = dm._calc(dm.ml.load_all()["GC000297"], dm.ml.Links(t2.whole()), {"형식": "B-Type"}) + assert _money(sure) == [want[k] for k in COSTS] # 바꾼 뒤 = M01 사본 셈 + msk.back(sure, now["판"]) + again = store.read("sangeun", sure)["문서"] + assert again["가져온"]["로직"] and not again.get("바탕") + assert _money(sure) == money0 # 되돌리기 = 원래 가져온 식 금액 + with pytest.raises(store.StoreError) as e: + msk.swap(near, "") + assert e.value.status == 409 + assert store.read("sangeun", near)["문서"]["가져온"]["로직"] # 근접 = 그대로 + assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")} + + +# ── (e) 전수 ───────────────────────────────────────────────────────────── +@pytest.mark.skipif(os.environ.get("S8104_ALL") != "1", reason="전수 = S8104_ALL=1") +def test_every_block_table() -> None: + import collections + import subprocess + + rows = ["--rows", os.environ["S8104_ROWS"]] if os.environ.get("S8104_ROWS") else [] + out = subprocess.run( + [sys.executable, str(HERE / "helper_810_4_sweep.py"), *rows], capture_output=True, text=True + ) + assert out.returncode == 0, out.stderr[-2000:] + import json + + got = json.loads(out.stdout.strip().splitlines()[-1]) + assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0 + assert set(got["판정"]) <= {dm.SURE, dm.NEAR, dm.NONE} + assert sum(got["판정"].values()) == 2220 and got["판정"].get(dm.SURE, 0) >= 1 + assert collections.Counter(got["근접항"]).get("단가 차이", 0) >= 1 + + +def test_parent_reads_swapped_sibling(world) -> None: + """형제 SK 를 우리 기초 사본으로 바꿔도 부르는 가져온 식은 그 사본 문서로 셈(연결 안 끊김).""" + folder = world["personal"] + est = t2._file(t2.EST["거창"]) + with store.at(folder): + hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"] + parent = fh.make(folder, hp, 15)["이름"] # 전석 메쌓기 — 형제 산근 둘 + sk = store.read("sangeun", parent)["문서"] + sib = next(x["참조"]["키"] for x in sk["가져온"]["로직"]["호표"] if x.get("참조")) + got = store.read("sangeun", sib) + body = msk.copy_body(got["문서"], {"키": "GC000211", "옵션": {}}) # 같은 ㎡ + store.write("sangeun", sib, got["판"], body) + mid = _money(parent) + assert None not in mid and sum(mid) != 94215 # 사본 문서로 셈(끊기면 None) + msk.back(sib, store.read("sangeun", sib)["판"]) + assert sum(_money(parent)) == 94215 # 되돌리면 원파일 금액