From 6206b2cace56680d90300f6ecf06fc436182f367 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 15:02:01 +0900 Subject: [PATCH] =?UTF-8?q?refactor(815-2):=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0(=EA=B8=B0=EC=B4=88=20=EC=B0=BE=EA=B8=B0)=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EA=B1=B7=EA=B8=B0=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20762-1=20=C2=B7=20763-1=20=C2=B7=20=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20G1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 M02_Doc_Match · M02_Doc_Match_Note · M02_Doc_Match_Value · M02_Doc_MatchSk 지움 · 길 POST …/sangeun/{키}/match 지움 - 화면 판정 띠(M02_MasterTemplete_Sangeun_MatchBand · logic/M02_Sangeun_Match) · [연결 찾기] · [바꾸기] · [되돌리기] 단추 · 원문 카드 판정 글 지움 - 옛 SK 기록 `가져온.원본.연결후보` · 바꾼 SK `가져온보관` = 읽기만 그대로(카드 · 깨지지 않음 · 지우지 않음) - 시험 지움: test_810_4_match · test_812_2_match_logic · test_812_4_rate_value · test_812_11_match_values · helper_810_4_sweep(걷은 모듈 지킴) · test_814_1 의 꼬리 뗀 맞대기 · 공구손료 비목 · 띠 글 · 도우미 줄(연결 찾기 몫) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_Match.py | 696 ------------------ M02_MasterTemplete/M02_Doc_MatchSk.py | 150 ---- M02_MasterTemplete/M02_Doc_Match_Note.py | 256 ------- M02_MasterTemplete/M02_Doc_Match_Value.py | 233 ------ M02_MasterTemplete/M02_Doc_Router.py | 36 +- .../M02_MasterTemplete_Sangeun_Detail.ts | 3 +- .../M02_MasterTemplete_Sangeun_MatchBand.ts | 85 --- .../M02_MasterTemplete_Sangeun_Side.ts | 13 +- .../M02_MasterTemplete_Sangeun_View.ts | 34 +- M02_MasterTemplete/logic/M02_Sangeun_Match.ts | 129 ---- resources/tester/helper_810_4_sweep.py | 144 ---- resources/tester/test_810_4_match.py | 225 ------ resources/tester/test_812_11_match_values.py | 239 ------ resources/tester/test_812_2_match_logic.py | 183 ----- resources/tester/test_812_4_rate_value.py | 79 -- .../tester/test_814_1_import_decisions.py | 48 +- 16 files changed, 14 insertions(+), 2539 deletions(-) delete mode 100644 M02_MasterTemplete/M02_Doc_Match.py delete mode 100644 M02_MasterTemplete/M02_Doc_MatchSk.py delete mode 100644 M02_MasterTemplete/M02_Doc_Match_Note.py delete mode 100644 M02_MasterTemplete/M02_Doc_Match_Value.py delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts delete mode 100644 M02_MasterTemplete/logic/M02_Sangeun_Match.ts delete mode 100644 resources/tester/helper_810_4_sweep.py delete mode 100644 resources/tester/test_810_4_match.py delete mode 100644 resources/tester/test_812_11_match_values.py delete mode 100644 resources/tester/test_812_2_match_logic.py delete mode 100644 resources/tester/test_812_4_rate_value.py diff --git a/M02_MasterTemplete/M02_Doc_Match.py b/M02_MasterTemplete/M02_Doc_Match.py deleted file mode 100644 index e33fae82e..000000000 --- a/M02_MasterTemplete/M02_Doc_Match.py +++ /dev/null @@ -1,696 +0,0 @@ -"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독. - -가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어 -「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정). - ① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 · - 품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만. - ② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 · - 값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을 - 문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터. - ③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기종 · 단위 환산 · - 비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 % · 같은 기종 규격 · 수량 - (값 — 확정을 안 막음 · 812-11 `M02_Doc_Match_Value`) — 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 - 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」 · 값이 맞는 조합 없으면 기본 옵션 + 「옵션 확인」. - ⓪ 기계 한 묶음 + 작업량 식 수량 = 「기계 작업」(우리 기초에 안 붙임 · 요구 728 · 735-4). -결과 `{판정, 키, 이름, 옵션, 다른, 참고, 바꿈, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보` · -`바꿈` = [바꾸기] 때 갈아 끼울 기계 줄 [{기호(우리 단가 기호), 기계(가져온 원문번호), 이름, 규격}]. -""" - -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 -from M02_MasterTemplete import M02_Doc_Match_Note as note -from M02_MasterTemplete import M02_Doc_Match_Value as val - -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("합밖")), - "기호": _single(line.get("단가")), - "규격": str(line.get("규격") or ""), - "수량글": str(line.get("수량") or ""), - } - ) - val.mark_combo(doc, out) - note.rates(doc, got, out) - 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(" ", "").replace(",", "") - - -def _bare(o: Any) -> str: - return str(o).replace(" ", "").replace(",", "") # 「D1,800」 = 글 「D1800」 - - -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 _bare(o) 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["갈래"]} <= {"기계", "산출"} - - notes: list[dict] = [] - exact = take( - lambda a, b: ( - core(a, b) - and a["수량"] == b["수량"] - and a["단가"] == b["단가"] - and a["비목"] == b["비목"] - and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조 - and val.same_combo(a, b) - ) - ) - same += len(exact) - - def twin(a: dict, b: dict) -> bool: - return core(a, b) and cls(a, b) and a["수량"] == b["수량"] - - for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저 - same += 1 - notes += note.pair(a, b)[1] - for a, b in take(twin): - bad, ref = note.pair(a, b) - same, paired = (same + 1, paired) if not bad else (same, paired + 1) - diffs += bad - notes += ref - bad, ref, n = val.pairs(take, core, cls, note.pair, _ratio) # 같은 자원 수량 다름 = 값(812-11) - same, paired, diffs, notes = same + n, paired + len(bad), diffs + bad, notes + ref - 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 "수량" - say = {"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"} - diffs += note.pair(a, b)[0] or [say] # 같은 자원 아님 = 그 로직 까닭(비목 · 자원) - for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"): - paired += 1 - diffs.append( - {"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"} - ) - rate_diffs, rate_notes = note.rate_pairs(left, right, take) - diffs += rate_diffs - notes += rate_notes - 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["갈래"] != "율") - swaps = [x.pop("바꿈") for x in notes if "바꿈" in x] - return { - "같음": same, - "짝": same + paired, - "핵심": n_core, - "다른": diffs, - "참고": notes, - "바꿈": swaps, - } - - -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 not res["참고"] and ta != tb: - res["참고"].append( - {"항": "끝수", "글": f"줄은 같고 합이 다름 {_sum_txt(ta)} ↔ {_sum_txt(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 _sum_txt(t: dict) -> str: - return " · ".join(f"{k} {note.txt(v)}" for k, v in t.items() if v is not None) - - -# ── 한 문서 ─────────────────────────────────────────────────────────── -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 - ties: list[tuple] = [] - 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) - values = sum(1 for x in res["참고"] if x["항"] in val.VALUE) if res["판정"] == SURE else 0 - rank = ( - res["판정"] == SURE, - -len(off), - -values, # 값 참고(규격 · 수량)가 적은 조합 먼저(812-11) - res["같음"], - res["짝"], - -len(res["다른"]), - -len(res["참고"]), - ) - if best is None or rank > best["순위"]: - best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)} - ties.append((rank, pick)) - if res["판정"] == SURE and not res["참고"]: - break - if best and best["판정"] == SURE and any(x["항"] in val.VALUE[1:] for x in best["참고"]): - same = [p for r, p in ties if r == best["순위"]] # 값으로 못 가른 조합(요구 733 ㉢) - best["참고"] += val.option_check(base, best["옵션"], same, _qty_vars(base)) - return best or { - "판정": NONE, - "같음": 0, - "짝": 0, - "다른": [], - "참고": [], - "순위": (False, -9, 0, 0, 0, 0, 0), - } - - -def _qty_vars(doc: dict) -> set[str]: - """금액 줄 수량이 (거쳐서) 부르는 변수.""" - close = _closure(doc) - return set().union(*(_uses(x.get("수량"), close) or set() for x in doc.get("금액") or [])) - - -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) - imported = note.bare(imported) # 엑셀 이름 규격 · 단위 꼬리 뗌(814-1 · 맞대기만) - got_imp = _calc(imported, links_imp, {}) - if got_imp is None: - return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0} - mine = facts(imported, got_imp) - alone = val.work(imported, mine, note._names) - 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 and not res["참고"]: - break - exact = best and best["판정"] == SURE and not {x["항"] for x in best["참고"]} & set(val.CHECK) - if alone and not exact: # 기계 작업 — 우리 기초와 수량 · 규격까지 같을 때만 확정으로 붙임 - return alone - 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["다른"], - "참고": best["참고"], - "바꿈": best.get("바꿈") or [], - "금액": {"가져온": _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 deleted file mode 100644 index fa5cf795a..000000000 --- a/M02_MasterTemplete/M02_Doc_MatchSk.py +++ /dev/null @@ -1,150 +0,0 @@ -"""가져온 식 SK ↔ 우리 M01 기초 — 연결 찾기 기록 · 「확정」 때만 우리 기초 사본으로 바꾸기 · 되돌리기 -(PLAN 810-4 · 요구 707 · 708 · 709 · 703) — 서버 단독. - - 찾기(`find`) = 가져온 식 문서(우리 단가로 셈) → `M02_Doc_Match.match` → SK `가져온.원본.연결후보` 에 적음. - 바꾸기(`swap`) = 서버가 다시 찾아 「확정」 일 때만 — 같은 SK 키에 M01 사본 SK(바탕 = 그 기초 · - 입력값 = 찾은 옵션 · 802-4a 꼴 · 수식 사본은 저장 때 서버가 찍음)를 씀 · 원래 가져온 식 SK 몸은 - `가져온보관` 칸에 그대로 둠 · 일위대가 줄 참조 = 키 그대로(SK 내용만 바뀜 → 금액 다시 셈). - + 찾은 `바꿈`(같은 기종 규격만 다른 기계 줄 · 812-11 · 요구 733-2) = 사본 문서 그 줄 기계 입력(원문번호)을 - 가져온 규격의 우리 기계로 갈아 끼움(수식 길 저장 = 따로) · 품 · 율 · 단가 · 조건 값은 우리 값 그대로 · - 기계가 옵션 기호 · 식이면 안 바꿈. - 되돌리기(`back`) = `가져온보관` 몸을 그 키에 다시 씀(가져온 식 금액 그대로 · 수식 사본도 보관 몸 그대로). -우리 M01 기초는 어느 갈래에서도 안 바뀜(708) · 근접 · 없음 · 기계 작업 = 바꾸기 409(703). -""" - -from __future__ import annotations - -import copy as cp -import re -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_Doc_Sk as dsk -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: - aims = {k: d for k, d in docs.items() if not d.get("부품")} # 부품 문서 = 과녁 아님(813-2) - _INDEX["문서"], _INDEX["색인"] = docs, dm.Index(aims) - 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())) - from M02_MasterTemplete import M02_San_Link as san_link # 우리 정보로 셈 줄 수(814-1 띠) - - return {**got, "우리정보": san_link.tally(sk[imp.KEY].get("로직") or {}), "때": 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)) - done = refit(key, found.get("바꿈") or []) - return {dm.KEY: found, "판": done[0] or info.get("판"), "바꾼기계": done[1]} - - -def refit(key: str, swaps: list[dict]) -> tuple[str | None, list[dict]]: - """사본 문서 기계 줄 → 가져온 규격 우리 기계(그 줄 단가 기호 연결의 입력 「기계」 · 줄 이름 · 규격) → - 수식 길 저장 · (판, 바꾼 것).""" - from M02_MasterTemplete import M02_Doc_Sk_Path as path # 수식 길 — 늦게(돌아 부름 피함) - - if not swaps: - return None, [] - got = path.read(key) - doc = cp.deepcopy(got["문서"]) - done = [] - for one in swaps: - var = next((v for v in doc.get("변수") or [] if dm.md.var_id(v) == one["기호"]), None) - link = (var or {}).get("연결") or {} - given = link.get("입력") or {} - if link.get("갈래") != "단가산출" or not re.fullmatch(r"'[^']+'", str(given.get("기계"))): - continue - link["입력"] = {**given, "기계": f"'{one['기계']}'"} - for line in doc.get("금액") or []: - if dm._single(line.get("단가")) == one["기호"]: - if line.get("묶음") == line.get("이름"): # 그 줄 이름을 단 묶음 제목도 - line["묶음"] = f"{one['이름']} {one['규격']}".strip() - line["이름"], line["규격"] = one["이름"], one["규격"] - done.append(one) - if not done: - return None, [] - return path.save(key, {"문서": doc, "판": got["판"]})["판"], done - - -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("되돌릴 가져온 식 없음") - keep = ( - old.get(dc.KEY) if dc.has(old) else None - ) # 보관 몸 수식 사본 그대로(바꾼 기계 = 따로 버림) - token = dsk.SAVING.set({"키": key, "사본": keep}) if keep else None - try: - info = store.write(sd.KIND, key, version or got["판"], {**old, "키": key}) - finally: - if token is not None: - dsk.SAVING.reset(token) - return {"판": info.get("판")} diff --git a/M02_MasterTemplete/M02_Doc_Match_Note.py b/M02_MasterTemplete/M02_Doc_Match_Note.py deleted file mode 100644 index 158b6add2..000000000 --- a/M02_MasterTemplete/M02_Doc_Match_Note.py +++ /dev/null @@ -1,256 +0,0 @@ -"""연결 찾기 — 로직 차이(다른 항) 대 값 차이(참고) 가르기(PLAN 812-2 · 요구 712) — `M02_Doc_Match` 가 부름. - -확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기종 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 · - 율 구조(있고 없음 · 대상 비목 — 율 값 % 는 참고) · 단위 환산(`M02_Doc_Match_Value`). -「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 율 값(%) · 같은 기종 규격 · 수량(812-11) · - 그로 인한 비목 금액 · 계. -율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 · - 대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님. -""" - -from __future__ import annotations - -import re -from decimal import Decimal, InvalidOperation -from typing import Any - -from M02_MasterTemplete import M02_Doc_Copy as dc -from M02_MasterTemplete import M02_Doc_Match_Value as val -from M02_MasterTemplete import M02_Machine_Find as mfind - -md = dc.md -PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고) - - -# ── 율 구조 ─────────────────────────────────────────────────────────── -def _names(text: Any) -> set[str]: - if text in (None, ""): - return set() - try: - return set(md.names(md.parse(text))) - except md.DocError: - return set() - - -def _reach(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 []: - direct[str(line.get("기호"))] = _names(line.get("식")) - for line in doc.get("금액") or []: - if line.get("기호"): - direct[str(line["기호"])] = _names(line.get("수량")) | _names(line.get("단가")) - return direct - - -def _close(start: set[str], direct: dict[str, set[str]]) -> set[str]: - seen, todo = set(start), list(start) - while todo: - for n in direct.get(todo.pop(), ()): - if n not in seen: - seen.add(n) - todo.append(n) - return seen - - -def _short(r: Decimal) -> Decimal: - return Decimal(f"{r:.6g}").normalize() - - -def rates(doc: dict, got: dict, facts: list[dict]) -> None: - """율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리).""" - money, rows = doc.get("금액") or [], got.get("줄") or [] - direct = _reach(doc) - core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]] - for f in facts: - if f["갈래"] != "율": - continue - line, row = money[f["번"]], rows[f["번"]] - reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct) - costs = reach & set(md.COSTS) - hits = [c for c in core if _hit(money[c["번"]], reach)] - if hits: # 줄 기호 · 줄 수량 · 단가 이름 = 그 줄 금액 전부 - parts = [(c, tuple(c["비목"])) for c in hits] - elif costs: # 비목 이름(「노무비 × 4 %」) = 그 비목 몫만 - parts = [(c, tuple(k for k in c["비목"] if k in costs)) for c in core] - else: # 번지 합 · 못 찾음 = 블록 핵심 줄 전부 - parts = [(c, tuple(c["비목"])) for c in core] - parts = [(c, ks) for c, ks in parts if ks] - f["대상"] = frozenset((_cls(c["갈래"]), k) for c, ks in parts for k in ks) - f["율"] = None - amount = _mul(row.get("수량"), row.get("단가")) - if amount is None: - continue - bases = [sum((_num(c["비목"][k]) for c, ks in parts for k in ks), Decimal(0))] - if hits: - each = (_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) for c in hits) - bases.append(sum((x or 0 for x in each), Decimal(0))) - found = [_short(amount / b) for b in bases if b] - if found: - f["율"] = min(found, key=lambda r: len(r.as_tuple().digits)) - - -def _hit(line: dict, reach: set[str]) -> bool: - parts = _names(line.get("수량")) | _names(line.get("단가")) - return bool(line.get("기호") and str(line["기호"]) in reach) or bool(parts and parts <= reach) - - -def _cls(kind: str) -> str: - return "기계" if kind == "산출" else kind # 기계경비 묶음(코드 없음) = 기계 - - -def _num(v: Any) -> Decimal: - if isinstance(v, dict): - return sum((_num(v.get(k)) for k in md.COSTS), Decimal(0)) - try: - return Decimal(str(v)) if v is not None else Decimal(0) - except InvalidOperation: - return Decimal(0) - - -def _mul(q: Any, p: Any) -> Decimal | None: - if q is None or p is None: - return None - return _num(q) * _num(p) - - -def pct(r: Any) -> str: - """비율 → 「9 %」.""" - if not isinstance(r, Decimal): - return "?" - return f"{(r * 100).normalize():f} %" - - -# ── 짝 줄 ───────────────────────────────────────────────────────────── -def txt(v: Any) -> str: - """단가 글 — 수 = 원 단위 쉼표 · 기계 묶음 = 노 · 재 · 경.""" - if isinstance(v, tuple): - return " · ".join(f"{k[0]} {txt(x)}" for k, x in zip(md.COSTS, v) if x) - if isinstance(v, Decimal): - return f"{v.normalize():,f}" - return str(v) - - -def _plain(text: str) -> str: - """표기 정규화(소리 표기 · 빈칸) — 기계 찾기와 같은 규칙(812-9 `M02_Machine_Find.norm`).""" - return mfind.norm(text) - - -def _head(text: str) -> str: - """기계 이름 머리(괄호 · 규격 앞 한글) — 「머캐덤로울러(자주식)」 → 「머캐덤롤러」.""" - m = re.match(r"[가-힣]+", _plain(text)) - return m.group(0) if m else "" - - -def _same_machine(a: str, b: str) -> bool: - """같은 기종 — 기계표 · 별칭 장부로 둘 다 기종을 찾으면 그 판정(812-9 · 박힌 짝 없음) · - 못 찾으면 이름 머리 품음.""" - got = mfind.same_kind(a, b) - if got is not None: - return got - x, y = _head(a), _head(b) - return bool(x and y) and (x in y or y in x) - - -def _grams(text: str) -> set[str]: - t = _plain(text) - return {run[i : i + 2] for run in re.findall(r"[가-힣]+", t) for i in range(len(run) - 1)} - - -def pair(a: dict, b: dict) -> tuple[list[dict], list[dict]]: - """짝 줄 → (다른 항, 참고) — 단가 · 같은 기종 규격만 다르면 참고 · 자원 · 기종 · 비목이 다르면 다른 항.""" - diffs: list[dict] = [] - spec: list[dict] = [] # 같은 기종 규격만 다름 = 값(812-11 · 요구 733-2) - kinds = {a["갈래"], b["갈래"]} - # 기계경비 숫자 묶음(코드 없음 · 원파일 값) ↔ 우리 기계 = 이름 종류로 같은 기계인지 - loose = kinds == {"기계", "산출"} and not (a if a["갈래"] == "산출" else b)["코드"] - combo = not val.same_combo(a, b) # 조합 기계 ↔ 낱 기계 = 다른 기종 - if a["갈래"] == b["갈래"] == "기계" and (a["코드"] != b["코드"] or combo): - if not combo and val.same_machine(a, b): - spec.append(val.spec_note(a, b)) - else: - diffs.append( - {"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} — 다른 기종"} - ) - elif loose and not _same_machine(a["이름"], b["이름"]): - diffs.append({"항": "기계 규격", "글": f"{a['이름']} ↔ {b['이름']} {b['코드']}"}) - elif loose: - spec += [x for x in (val.loose_note(a, b),) if x] - elif a["갈래"] == b["갈래"] == "노임" and a["코드"] != b["코드"]: - diffs.append({"항": "자원", "글": f"{a['이름']} {a['코드']} ↔ {b['이름']} {b['코드']}"}) - elif a["갈래"] == b["갈래"] == "재료" and not _same_stuff(a, b): - diffs.append({"항": "자원", "글": f"{a['이름']} ↔ {b['이름']}"}) - machine = a["갈래"] == b["갈래"] == "기계" or loose # 시간당 단가 구성(운전 노무비 등) = 값 - priced = a["비목"] and b["비목"] # 한쪽 단가 없음(0) = 값 - if priced and not machine and set(a["비목"]) != set(b["비목"]): - diffs.append( - {"항": "비목", "글": f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])}"} - ) - if a["단가"] == b["단가"]: - return diffs, spec - say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}" - if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose: - return diffs, [*spec, {"항": "단가 차이", "글": say}] - return [*diffs, {"항": "산출 단가", "글": say}], spec - - -def _same_stuff(a: dict, b: dict) -> bool: - """자재 종류 같음 — 두 코드가 있으면 코드 · 없으면 이름 낱말(한글 두 글자) 겹침.""" - if a["코드"] and b["코드"] and a["코드"] == b["코드"]: - return True - return bool(_grams(a["이름"]) & _grams(b["이름"])) - - -def rate_pairs(left: list[dict], right: list[dict], take: Any) -> tuple[list[dict], list[dict]]: - """율 줄 짝 → (다른 항, 참고) — 구조 = 대상 · 비목 · 같으면 로직 같음(율 값 % 다름 = 참고 · 값은 업데이트됨).""" - diffs: list[dict] = [] - notes: list[dict] = [] - - def is_rate(a: dict, b: dict) -> bool: - return a["갈래"] == b["갈래"] == "율" - - def same(a: dict, b: dict, *keys: str) -> bool: - return is_rate(a, b) and all( - (set(a[k]) == set(b[k])) if k == "비목" else a.get(k) == b.get(k) for k in keys - ) - - def name(a: dict, b: dict) -> str: - return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}" - - def target(x: dict) -> str: - return "·".join(f"{k} {c}" for k, c in sorted(x.get("대상") or ())) or "없음" - - def value(a: dict, b: dict) -> None: - if a.get("율") != b.get("율"): - notes.append({"항": "율 값", "글": f"{name(a, b)} — 바꾸면 우리 값"}) - - take(lambda a, b: same(a, b, "율", "대상", "비목")) # 로직 · 값 같음 - for a, b in take(lambda a, b: same(a, b, "대상", "비목")): # 로직 같음 · 율 값만 다름 - value(a, b) - for a, b in take(lambda a, b: same(a, b, "대상")): - tool = val.tool_cost(a, b) # 공구손료 비목만 다름 = 값(814-1) - (notes if tool else diffs).append( - tool or {"항": "비목", "글": f"율 줄 {name(a, b)} 비목 다름"} - ) - value(a, b) - for a, b in take(lambda a, b: same(a, b, "율")): - diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"}) - for a, b in take(is_rate): - diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"}) - return diffs, notes - - -# ── 이름 꼬리(814-1 · 요구 743-3) ───────────────────────────────────────── -def bare(doc: dict) -> dict: - """엑셀 단가산출 이름의 규격 · 단위 꼬리(「구조물터파기(인력) 경질토사 / M3」) 뗀 맞댈 문서 — - 보이는 · 저장 이름은 그대로(연결 찾기만 이 사본을 씀).""" - name = str(doc.get("이름") or "") - unit = re.sub(r"^원\s*/\s*", "", str(doc.get("결과단위") or "")).strip() - spec = str((doc.get("가져온") or {}).get("규격") or "").strip() - got = re.sub(rf"\s*/\s*{re.escape(unit)}\s*$", "", name) if unit else name - if got != name and spec and got.rstrip().endswith(spec) and got.rstrip() != spec: - got = got.rstrip()[: -len(spec)] - got = got.strip() - return doc if not got or got == name else {**doc, "이름": got} diff --git a/M02_MasterTemplete/M02_Doc_Match_Value.py b/M02_MasterTemplete/M02_Doc_Match_Value.py deleted file mode 100644 index c902b2151..000000000 --- a/M02_MasterTemplete/M02_Doc_Match_Value.py +++ /dev/null @@ -1,233 +0,0 @@ -"""연결 찾기 값 기준 넓힘(PLAN 812-11 · 요구 714 · 733 · 734 · 735) — `M02_Doc_Match` 가 부름. - -값(참고 · 확정을 안 막음 · 바꾸면 우리 값): - 같은 기종 규격만 다름(굴착기 0.7㎥ ↔ 1.0㎥ — 기종 = 기계표 이름 머리 + 괄호 낱말 · 무한궤도 대 타이어 = 다른 기종 · - 조합 기계는 구성 줄마다) = 「규격」(바꾸면 가져온 규격 — [바꾸기] 때 그 줄 기계를 갈아 끼움 `바꿈`) · - 수량 차이(같은 자원 줄) = 「수량 끝자리」(1 % 안) · 「품 숫자」(가져온 수량 = 수 하나) · 「수량」(조건 값 · 비율 · 표 값). -로직(다른 항): 자원 줄 구성(갈래 · 기종 · 노임 직종 · 재료 종류 · 비목) · 줄 없음 · 율 구조 · 단위 환산 = - 같은 배(× 6 등)가 두 줄 넘게 같이 걸림(결과 단위 기준이 다름) — 한 줄만 배가 맞는 것은 수량 값. -기계 작업(`work`) = 가져온 문서가 기계 한 묶음(한 기계 줄 · 조합 기계면 그 구성 줄들)뿐 · 수량이 작업량 식(Q) = - 우리 기초에 안 붙임(요구 728 · 735-4 — 수량 · 규격까지 같은 우리 기초가 있을 때만 확정) · 공식 꼴이 그 기종 - 「작업량 틀」(813-2 부품)과 같으면 참고에 틀 이름. -""" - -from __future__ import annotations - -import re -from decimal import Decimal -from functools import lru_cache -from typing import Any - -from M02_MasterTemplete import M02_Machine_Find as mfind - -WORK = "기계 작업" -SPEC, QTY, TAIL, POOM = "규격", "수량", "수량 끝자리", "품 숫자" -VALUE = (SPEC, QTY, TAIL, POOM) # 확정 순위 — 값 참고가 적은 조합 · 기초 먼저 -CHECK = (*VALUE, "옵션 확인") # 기계 작업 꼴이 확정으로 붙지 않는 참고 -_NUMBER = re.compile(r"^\s*\d+(?:\.\d+)?\s*$") - - -# ── 공구손료 율 비목(814-1 · 요구 744 C1) ───────────────────────────────── -TOOL = "공구손료 비목" - - -def tool_cost(a: dict, b: dict) -> dict | None: - """공구손료 율 줄 짝 — 대상은 같고 비목만 다름(실무 재료비 ↔ 우리 경비) = 값(참고 · 확정 안 막음 · - [바꾸기] = 우리 기초 사본 = 우리 비목) · 공구손료 아님 = None.""" - if "공구" not in str(a.get("이름")) + str(b.get("이름")): - return None - say = f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])} — 바꾸면 우리 비목" - return {"항": TOOL, "글": say} - - -# ── 기종 ────────────────────────────────────────────────────────────── -def row_of(code: Any) -> Any: - """원문번호 → 기계표 줄(`M02_Machine_Find.Row`) · 없음 None.""" - key = mfind.by_code(code) - return mfind.table()[2].get(key) if key else None - - -def same_kind(a: Any, b: Any) -> bool: - """두 원문번호 = 같은 기종 — 이름 머리 + 괄호 낱말 같음(규격만 다름).""" - x, y = row_of(a), row_of(b) - return bool(x and y) and x.base == y.base and x.words == y.words - - -@lru_cache(maxsize=4096) -def _combo(name: str) -> tuple | None: - """조합 기계 이름 → 구성 기종 묶음(정렬) · 낱 기계 = None.""" - got = mfind.parts(name) - return tuple(sorted(mfind.split_name(n)[0] for n, _ in got)) if len(got) > 1 else None - - -def mark_combo(doc: dict, facts: list[dict]) -> None: - """기계 사실에 `조합`(구성 묶음 · 낱 = None) · `갈라짐`(가져온 조합을 둘로 가른 줄 = 낱 기계로도 맞음).""" - head = _machines(doc) - for f in facts: - if f["갈래"] in ("기계", "산출"): - f["조합"] = _combo(f["이름"]) - f["갈라짐"] = any(head.get(str(f.get("기호")), {}).get(k) for k in ("조합", "조합첫")) - - -def same_combo(a: dict, b: dict) -> bool: - """조합 여부 같음 — 구성 묶음 같음 · 가른 줄 ↔ 낱 기계도 같음(「굴착기+부착용 집게」 ↔ 굴착기 하나 = 다름).""" - x, y = a.get("조합"), b.get("조합") - return x == y or (a.get("갈라짐") and y is None) or (b.get("갈라짐") and x is None) - - -def same_machine(a: dict, b: dict) -> bool: - """같은 기종 기계 줄 — 원문번호 기종 같음 + 조합 구성 같음.""" - return same_kind(a["코드"], b["코드"]) and same_combo(a, b) - - -def _eq(code: Any) -> str: - r = row_of(code) - return f"{r.raw.get('이름')} {r.raw.get('규격') or ''}".strip() if r else str(code) - - -def spec_note(a: dict, b: dict) -> dict: - """같은 기종 규격 다름 → 참고 「규격」 + 바꿈(우리 줄 단가 기호 → 가져온 원문번호).""" - ra = row_of(a["코드"]) - say = f"규격 다름 {_eq(a['코드'])} ↔ {(row_of(b['코드']).raw.get('규격') or '')}" - swap = {"기호": b.get("기호"), "기계": a["코드"], "이름": ra.raw.get("이름"), - "규격": ra.raw.get("규격") or ""} # fmt: skip - return {"항": SPEC, "글": f"{say} — 바꾸면 가져온 규격", "바꿈": swap} - - -def loose_note(a: dict, b: dict) -> dict | None: - """못 이은 가져온 기계(원파일 묶음 값) ↔ 우리 기계 — 규격 글이 다르면 「안 바꿈」 참고.""" - loose, ours = (a, b) if a["갈래"] == "산출" else (b, a) - row = row_of(ours["코드"]) - if not row or not loose.get("규격"): - return None - if mfind.spec_fit(mfind.tokens(loose["규격"]), row.spec) == "같음": - return None - say = f"규격 다름 {loose['이름']} {loose['규격']} ↔ {_eq(ours['코드'])}" - return { - "항": SPEC, - "글": f"{say} — 가져온 기계를 우리 표에서 못 찾음 · 바꾸면 우리 기계 그대로", - } - - -# ── 수량 ────────────────────────────────────────────────────────────── -def _show(v: Any) -> str: - return f"{v:.4g}" if isinstance(v, Decimal) else str(v) - - -def qty_note(a: dict, b: dict) -> dict: - """같은 자원 줄 수량 다름 → 참고(끝자리 · 품 숫자 · 수량).""" - x, y = a["수량"], b["수량"] - if isinstance(x, Decimal) and isinstance(y, Decimal) and y and abs(x - y) <= abs(y) / 100: - what = TAIL - elif _NUMBER.match(str(a.get("수량글") or "")): - what = POOM - else: - what = QTY - return {"항": what, "글": f"{a['이름']} 수량 다름 {_show(x)} ↔ {_show(y)} — 바꾸면 우리 값"} - - -def pairs( - take: Any, core: Any, cls: Any, pair: Any, ratio: Any -) -> tuple[list[dict], list[dict], int]: - """수량 다른 같은 자원 짝(로직 같음 · `pair` 다른 항 0) → (다른 항 = 단위 환산 무리, 참고, 같은 줄 수) · - `ratio` = 단위 환산 배 글(「× 6」) · 없음 None.""" - got = take( - lambda a, b: core(a, b) and cls(a, b) and a["수량"] != b["수량"] and not pair(a, b)[0] - ) - hows = [ratio(a["수량"], b["수량"]) for a, b in got] - many = { - h for h in hows if h and hows.count(h) >= 2 - } # 같은 배가 두 줄 넘게 = 결과 단위 기준 다름 - diffs: list[dict] = [] - notes: list[dict] = [] - for (a, b), how in zip(got, hows): - if how in many: - diffs.append( - {"항": "단위 환산", "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how}"} - ) - else: - notes.append(qty_note(a, b)) - notes += pair(a, b)[1] - return diffs, notes, len(got) - sum(1 for h in hows if h in many) - - -# ── 기계 작업 ───────────────────────────────────────────────────────── -def _machines(doc: dict) -> dict[str, dict]: - head = (doc.get("가져온") or {}).get("기계") - return {str(x.get("기호")): x for x in head or [] if isinstance(x, dict)} - - -def _one_group(rows: list[dict]) -> bool: - if len(rows) == 1: - return True - return sum(1 for x in rows if x.get("조합첫")) == 1 and all( - x.get("조합") or x.get("조합첫") for x in rows - ) - - -def _form(text: Any) -> str: - """작업량 식 꼴 — 끝수 함수 벗김 · 빈칸 뺌 · 작은 글자(Cm = cm).""" - t = re.sub(r"\s", "", str(text or "")) - m = re.match(r"^(?:반올림|버림|올림)\((.*),\d+\)$", t) - return (m.group(1) if m else t).lower() - - -def _q_form(doc: dict, names: set[str]) -> str: - for line in doc.get("식") or []: - if str(line.get("기호")) in names and str(line.get("기호")).lower().startswith("q"): - return _form(line.get("식")) - return "" - - -def work(doc: dict, mine: list[dict], names: Any) -> dict | None: - """가져온 문서가 기계 한 묶음 + 작업량 식 수량이면 {판정 기계 작업, 참고} · 아니면 None - (`names` = 식 글 → 쓰는 이름 · 인력 · 재료 · 율 줄이 섞이면 보통 길).""" - heads = _machines(doc) - if not mine or not heads or any(f["갈래"] == "율" for f in mine): - return None - rows = [f for f in mine if not f["합밖"]] - if not rows or any( - str(f.get("기호")) not in heads or f["갈래"] not in ("기계", "산출") for f in rows - ): - return None - if not _one_group([heads[str(f["기호"])] for f in rows]): - return None - used = [names(f.get("수량글")) for f in rows] - if not all(used): # 수량이 수뿐 = 작업량 식 아님 - return None - notes = [] - form = _q_form(doc, set().union(*used)) - from M01_MasterData import M01_MasterData_Parts as parts # M02 가 M01 을 부름 — 늦게 - - for f in rows: - if not form or not row_of(f["코드"]): - continue - got = parts.of_machine(str(f["코드"])) - for t in got["틀"]: - if any( - _form(x.get("식")) == form - for x in t["문서"].get("식") or [] - if x.get("기호") == "Q" - ): - say = f"{got['기계']['이름']} — 작업량 공식 꼴 = {t['키']} {t['이름']}" - notes.append({"항": "작업량 틀", "글": say}) - break - return {"판정": WORK, "참고": notes, "후보수": 0} - - -# ── 옵션 확인 ───────────────────────────────────────────────────────── -def option_check(doc: dict, pick: dict, same: list[dict], qty_vars: set[str]) -> list[dict]: - """값이 정확히 맞는 조합이 없고 수량을 정하는 선택이 값으로 안 갈림(같은 순위 조합에 값 여럿) = - 고른 값(기본 먼저) + 참고 「옵션 확인」.""" - from M02_MasterTemplete.M02_Doc_Copy import md - - out = [] - for v in doc.get("변수") or []: - vid, link = md.var_id(v), v.get("연결") or {} - if link.get("갈래") != "선택" or vid not in qty_vars: - continue - if len({str(p.get(vid)) for p in same}) > 1: - how = "기본값" if pick.get(vid) == link.get("값") else "고른 값" - say = f"옵션 확인 — {v.get('이름') or vid} = {pick.get(vid)}({how} · 값으로 못 가름)" - out.append({"항": "옵션 확인", "글": say}) - return out diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py index cafb3ab04..f46a4d660 100644 --- a/M02_MasterTemplete/M02_Doc_Router.py +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -4,9 +4,7 @@ POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?} → {…, 전, 후, 바뀐자원?, 버림?} POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴 - · 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부(812-9) - POST /api/m02/layers/{층}/sangeun/{키}/match {할일 찾기|바꾸기|되돌리기, 판?}(층 쓰기 · 810-4) - → {연결후보?, 판}(바꾸기 = 확정만 · 아니면 409) +옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765). ⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). """ @@ -19,7 +17,6 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Doc_Sk_Path as path -from M02_MasterTemplete import M02_Machine_Find as mfind from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun Doc"]) @@ -52,31 +49,6 @@ 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, @@ -86,8 +58,4 @@ def save_doc( session: Session = Depends(verify_session), ) -> dict: folder = _root(session, layer, owner, write=True) - got = _call(folder, path.save, key, body.model_dump(exclude_none=True)) - if body.문서 is not None: # 812-9 — 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부 줄 - who = {"id": session.get("user_id"), "이름": session.get("name")} - _call(folder, mfind.learn, body.문서, who) - return got + return _call(folder, path.save, key, body.model_dump(exclude_none=True)) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index d539a3195..79f9a69ab 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -69,7 +69,6 @@ export interface SangeunDetailOptions { /** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */ onVersion?: (version: string) => void; slot?: HTMLElement; // 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에 - match?: Parameters[0]; // 원문 패널 판정 띠 단추(810-4) } const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; @@ -104,7 +103,7 @@ export function mountSangeunDetail( const inputsOf = new Map(); const infoOf = new Map(); // 더한줄 로직 → 원문 탭(752-1) let extraKeys: string[] = []; - const side = opt.source ? createSangeunSide(opt.match) : null; + const side = opt.source ? createSangeunSide() : null; let seq = 0; let alive = true; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts deleted file mode 100644 index adc45d641..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* ============================================================================= - * 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 66de8e82a..e3221b87d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -7,7 +7,7 @@ * 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) · * 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드 * (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음) - * 카드 맨 위 = 우리 기초 판정 띠(810-4 · `Sangeun_MatchBand`) · 우리 기초 사본으로 바꾼 SK = 보관한 가져온 식 카드 + * 옛 우리 기초 판정 띠(810-4)는 걷음(815-2) · 옛 바꾼 SK(`가져온보관`) = 보관한 가져온 식 카드(읽기만) * 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬). * ========================================================================== */ @@ -23,7 +23,6 @@ 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; @@ -63,7 +62,7 @@ const tx = (k: keyof typeof TEXT, n = 0): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n)); /** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */ -function originCard(o: Origin, band: HTMLElement | null): HTMLElement { +function originCard(o: Origin): HTMLElement { const marks = el("div"); const showMarks = (all: boolean): void => marks.replaceChildren( @@ -82,7 +81,6 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement { title: tx("Origin"), pad: 12, body: [ - ...(band ? [band] : []), ...(o.요약 ? [el("strong", { text: o.요약, attrs: { "data-origin-native": "" } })] : []), createKv([ { label: tx("Program"), value: o.프로그램 }, @@ -103,9 +101,9 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement { return card; } -export function createSangeunSide(match?: MatchCtx): { +export function createSangeunSide(): { wrap: (main: HTMLElement) => HTMLElement; - /** 원문 탭 · SK 문서(`가져온` · 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드 + 판정 띠) */ + /** 원문 탭 · SK 문서(`가져온` · 옛 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드) */ set: (tabs: SourceTab[], sk?: unknown) => void; } { const view = createSourceView({ @@ -140,8 +138,7 @@ export function createSangeunSide(match?: MatchCtx): { view.show(found); return void content.replaceChildren(view.root); } - if (origin) - return void content.replaceChildren(originCard(origin, createMatchBand(skDoc, match))); + if (origin) return void content.replaceChildren(originCard(origin)); if (!tabs.length) return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted")); at = Math.min(at, tabs.length - 1); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index 9c636c468..f25cd2a8f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -3,10 +3,8 @@ * 단가산출(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"; @@ -25,37 +23,9 @@ export function mountSangeunView( doc: unknown, opt: SangeunViewOptions, ): { getDoc: () => Promise; destroy: () => void } { - 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); + const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1 return { getDoc: () => one.flush().then(() => one.getDoc()), - destroy: () => ((alive = false), one.destroy()), + destroy: () => one.destroy(), }; } diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts deleted file mode 100644 index c1f61d34b..000000000 --- a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* ============================================================================= - * M02_Sangeun_Match.ts - * 가져온 식 ↔ 우리 M01 기초 연결 판정 띠 자료(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 카드 위. - * SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나: - * 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] · - * 다른 항 = 로직 차이(확정을 막음) · 참고 = 단가 · 끝수 · 율 값 %(값 차이 — 「우리 단가로 셈 ○○ 원」 · 812-2 · 812-4) · - * 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기] · - * 같은 기종 규격 · 수량 차이 = 참고(812-11) · 기계 작업 = 기계 한 줄 단가산출(우리 기초에 안 붙임 · 단추 없음) - * 화면 · DOM · fetch 없음 - * ========================================================================== */ - -export type MatchVerdict = "확정" | "근접" | "없음" | "기계 작업"; - -export interface MatchFound { - 판정?: MatchVerdict; - 키?: string; - 이름?: string; - 옵션?: Record; - 다른?: { 항: string; 글: string }[]; - /** 값 차이(단가 · 끝수 · 율 값 · 규격 · 수량) — 확정을 안 막음 */ - 참고?: { 항: string; 글: string }[]; - 금액?: { 가져온?: Record; 우리?: Record }; - 후보수?: number; - 가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] }; - /** 우리 정보로 셈(814-1) — 자원 줄 이음 수 · 형제 단가산출 부름뿐 = 조합 */ - 우리정보?: { 됨?: number; 줄?: number; 조합?: boolean }; -} - -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.글}`); - -const won = (n?: number | null): string => - n == null ? "" : `${Math.round(n).toLocaleString("ko-KR")} 원`; -const QTY = ["수량", "수량 끝자리", "품 숫자"]; -/** 참고 요약 — 「참고: 단가 다름 3 줄 · 율 값 다름 1 줄 · 우리 단가로 셈 53,218 원」 · 참고 없음 = "" */ -const noteText = (f: MatchFound): string => { - const n = f.참고 ?? []; - if (!n.length) return ""; - const price = n.filter((x) => x.항 === "단가 차이").length; - const rate = n.filter((x) => x.항 === "율 값").length; - const spec = n.filter((x) => x.항 === "규격").length; - const qty = n.filter((x) => QTY.includes(x.항)).length; - const bits = [ - spec ? `규격 다름 ${spec} 줄` : "", - qty ? `수량 다름 ${qty} 줄` : "", - price ? `단가 다름 ${price} 줄` : "", - rate ? `율 값 다름 ${rate} 줄` : "", - n.some((x) => x.항 === "옵션 확인") ? "옵션 확인" : "", - n.some((x) => x.항 === "끝수") ? "끝수 다름" : "", - ].filter(Boolean); - const ours = won(f.금액?.우리?.계); - return ` · 참고: ${bits.join(" · ")}${ours ? ` · 우리 단가로 셈 ${ours}` : ""}`; -}; - -/** 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: "찾기" }; - } - if (f.판정 === "기계 작업") { - return { - tone: "info", - text: "기계 한 줄 단가산출 — 그대로 씀 · 값은 [단가 갱신]으로 우리 값", - details: lines(f.참고), - action: null, - }; - } - const base = `${f.키 ?? ""} ${f.이름 ?? ""} ${opts(f.옵션)}`.trim(); - if (f.판정 === "확정") { - return { - tone: "success", - text: `확정 — 로직 같음 · 우리 기초 ${base}${noteText(f)}`, - details: lines(f.참고), - action: "바꾸기", - }; - } - if (f.판정 === "근접") { - const kinds = [...new Set((f.다른 ?? []).map((x) => x.항))].join(" · "); - return { - tone: "warning", - text: `근접 — ${base} · 다른 항: ${kinds}${noteText(f)}`, - details: [...lines(f.다른), ...lines(f.참고).map((x) => `참고 ${x}`)], - action: null, - }; - } - const near = f.가까움; - const mine = f.우리정보; // 「없음」 = 흠 아님(814-1 · 요구 745) — 우리 정보로 다시 만든 정도 - const made = mine?.줄 ? ` — 우리 정보로 셈 ${mine.됨 ?? 0}/${mine.줄} 줄` : ""; - return { - tone: "info", - text: mine?.조합 - ? `조합 — 다른 단가산출 ${mine.줄 ?? 0} 개 묶음(안의 단가산출로 셈)` - : `우리 기초 없음${made}`, - details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [], - action: null, - }; -} diff --git a/resources/tester/helper_810_4_sweep.py b/resources/tester/helper_810_4_sweep.py deleted file mode 100644 index e11dff597..000000000 --- a/resources/tester/helper_810_4_sweep.py +++ /dev/null @@ -1,144 +0,0 @@ -"""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() - aims = {k: d for k, d in docs.items() if not d.get("부품")} # 화면과 같은 과녁(813-2) - return t, dim, dm, whole, docs, dm.Index(aims), 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: got[k] for k in dm.md.COSTS}, - } # 수 그대로(814-1) - 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["옵션"])) - money = res["금액"] # 다시 셈 = 우리 · 참고 없으면 가져온 = 우리(812-2) - row["다시셈같음"] = ( - again is not None - and dm._plain(dm._totals(again)) == money["우리"] - and (bool(res.get("참고")) or money["우리"] == money["가져온"]) - ) - 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 - "확정참고": dict( - collections.Counter( - d["항"] for r in real if r["판정"] == "확정" for d in r.get("참고") or [] - ) - ), - "확정참고블록": sum(1 for r in real if r["판정"] == "확정" and r.get("참고")), - "오류": 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_810_4_match.py b/resources/tester/test_810_4_match.py deleted file mode 100644 index 90dc120fb..000000000 --- a/resources/tester/test_810_4_match.py +++ /dev/null @@ -1,225 +0,0 @@ -# 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) 줄 대조 — 같음 · 단위 환산 · 율 줄 = 다른 항 · 단가 차이 · 끝수 = 참고(812-2 · 확정을 안 막음) -(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_sure_formwork_tool_cost_is_value() -> None: - got = judge( - 41 - ) # 합판거푸집 4회 — 공구손료 재료비 ↔ 경비 = 값(814-1 · 요구 744 C1 · 옛 812-2 = 로직) - assert got["판정"] == dm.SURE and got["키"] == "GC000211" - assert got["옵션"]["사용횟수"] == "4회" and got["옵션"]["유형"] == "보통" - assert not got["다른"] - assert [d["항"] for d in got["참고"]] == [ - "단가 차이", - "공구손료 비목", - ] # 대전 합판 단가 · 비목 = 값 - - -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]["항"] == "끝수" # 끝수 = 참고(812-2) - b = [_line("인부", "노임", "0.24", "100"), _line("공", "노임", "3", "11")] - res = dm.verdict(a, b, tot, tot) # 같은 배(× 6)가 두 줄 = 단위 환산(812-11 · 한 줄만 = 수량 값) - assert [d["항"] for d in res["다른"]] == ["단위 환산"] * 2 and res["판정"] == dm.NEAR - assert [d["항"] for d in res["참고"]] == ["단가 차이"] - 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, 19)["이름"] # 파형강관(근접 — 41 은 814-1 로 확정) - 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 not {"단가 차이", "끝수"} & set(got["근접항"]) # 값 차이 = 참고(812-2) - 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 # 되돌리면 원파일 금액 diff --git a/resources/tester/test_812_11_match_values.py b/resources/tester/test_812_11_match_values.py deleted file mode 100644 index 119c3724e..000000000 --- a/resources/tester/test_812_11_match_values.py +++ /dev/null @@ -1,239 +0,0 @@ -# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""812-11 지킴 — 연결 찾기 값 기준 넓힘(요구 733 · 734 · 735 · `M02_Doc_Match_Value`). - -(a) 줄 대조 — 같은 기종 규격만 다름 = 확정 + 참고 「규격」(바꿈 = 가져온 원문번호) · 무한궤도 ↔ 타이어 = 다른 항 · - 수량 다름 = 참고(끝자리 · 품 숫자 · 수량) · 같은 배가 두 줄 = 단위 환산(다른 항) · 줄 구성 다름 = 다른 항 -(b) 견본 — main 230 되메우기(백호 0.2㎥ 70 % + 인력 30 %) ↔ GF000113 확정 + 규격 · 수량 · main 850 PC 슬래브(2ton 미만) - ↔ GC000257 확정 + 수량 끝자리 · main 828 조립식 PC 맨홀(D1800) ↔ GC000252 확정 + 품 숫자(옵션 ㉠ 글 「D1800」 = - D1,800) · 거창 46 토사절취(백호 0.7㎥) = 기계 작업 + 작업량 틀 · 거창 19 파형강관 Φ800 ↔ GF000179 근접 그대로 -(c) 옵션 확인(㉢) — 값으로 못 가른 선택 = 고른 값 + 참고 「옵션 확인」 -(d) 바꾸기(임시 층) — main 230: 사본 기계 줄 = 가져온 규격(0201-0020) · 금액 = M01 문서에 그 기계만 바꾼 셈 · - 되돌리기 = 원래 금액 · 기계 작업 SK 바꾸기 = 409 · M01 문서 파일 그대로 -(e) 전수(S81211_ALL=1) — 2,220 블록 · 오류 0 · 확정 다시 셈 다름 0 · 기계 작업 있음 · 근접 까닭에 값 항 없음 -원파일 없으면 (b) · (d) · (e) skip(다른 PC). -""" - -from __future__ import annotations - -import copy -import json -import os -import subprocess -import sys -from decimal import Decimal as D -from pathlib import Path - -import pytest - -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)) - -import test_810_2_native_doc as t2 # noqa: E402 -import test_810_4_match as t4 # noqa: E402 - -from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402 -from M02_MasterTemplete import M02_Doc_Match_Value as val # 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 — 같은 임시 층 - -TOT = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9} - - -def _line(name, kind, qty, price, cost="노무비", code="", sym="", text=""): - 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, "기호": sym, - "규격": "", "수량글": text or qty} # fmt: skip - - -MAN = _line("보통인부", "노임", "0.01", "100", code="LB000002") - - -# ── (a) 줄 대조 ────────────────────────────────────────────────────────── -def test_same_kind_spec_is_value_other_kind_is_logic() -> None: - a = [MAN, _line("굴착기", "기계", "0.03", "500", "경비", "0201-0020", "P_a", "1 ÷ Q")] - b = [MAN, _line("굴착기", "기계", "0.02", "600", "경비", "0201-0070", "P_b", "r ÷ Q")] - res = dm.verdict(a, b, TOT, TOT) - assert res["판정"] == dm.SURE and not res["다른"] - assert {x["항"] for x in res["참고"]} == {"규격", "수량", "단가 차이"} - spec = next(x for x in res["참고"] if x["항"] == "규격") - assert "0.2㎥ ↔ 0.7㎥" in spec["글"] and "바꾸면 가져온 규격" in spec["글"] - assert res["바꿈"] == [ - {"기호": "P_b", "기계": "0201-0020", "이름": "굴착기(무한궤도)", "규격": "0.2㎥"} - ] - same_qty = dm.verdict(a, [MAN, {**b[1], "수량": a[1]["수량"]}], TOT, TOT) - assert same_qty["판정"] == dm.SURE and len(same_qty["바꿈"]) == 1 # 수량 같아도 규격 = 값 - tire = [MAN, _line("굴착기", "기계", "0.02", "600", "경비", "0211-0060", "P_b")] - crawler = [MAN, _line("굴착기", "기계", "0.02", "600", "경비", "0201-0060", "P_a")] - res = dm.verdict(crawler, tire, TOT, TOT) # 무한궤도 ↔ 타이어 = 다른 기종 - assert res["판정"] != dm.SURE and [x["항"] for x in res["다른"]] == ["기계 규격"] - assert not res["바꿈"] - - -def test_combo_machine_is_its_own_kind() -> None: - one = _line("굴삭기(무한궤도)", "기계", "0.02", "5", "경비", "0201-0060", "P_a") - grab = _line("굴착기+부착용 집게 1.0㎥", "기계", "0.07", "5", "경비", "0201-0100", "P_b") - val.mark_combo({}, [one, grab]) # facts 가 붙이는 조합 표지 - res = dm.verdict([MAN, one], [MAN, grab], TOT, TOT) # 조합 기계 ↔ 낱 기계 = 다른 기종 - assert res["판정"] != dm.SURE and "기계 규격" in {x["항"] for x in res["다른"]} - pair = "로더(타이어)+소형노면파쇄기" - a = _line(pair, "기계", "0.1", "5", "경비", "0302-0095", "P_a") - val.mark_combo({}, [a]) - res = dm.verdict([MAN, a], [MAN, {**a, "기호": "P_b"}], TOT, TOT) # 같은 조합 = 같음 - assert res["판정"] == dm.SURE and not res["참고"] - - -def test_quantity_kinds_and_unit_conversion() -> None: - tail = dm.verdict([_line("크레인", "노임", "0.2959", "9", code="LB1")], - [_line("크레인", "노임", "0.2963", "9", code="LB1")], TOT, TOT) # fmt: skip - assert tail["판정"] == dm.SURE and [x["항"] for x in tail["참고"]] == ["수량 끝자리"] - poom = dm.verdict([_line("특별인부", "노임", "0.46", "9", code="LB3")], - [_line("특별인부", "노임", "0.48", "9", code="LB3", text="Q")], TOT, TOT) # fmt: skip - assert [x["항"] for x in poom["참고"]] == ["품 숫자"] and "0.46 ↔ 0.48" in poom["참고"][0]["글"] - assert "바꾸면 우리 값" in poom["참고"][0]["글"] - two = [ - _line("배관공", "노임", "0.04", "9", code="LB1"), - _line("인부", "노임", "0.02", "9", code="LB2"), - ] - six = [ - _line("배관공", "노임", "0.24", "9", code="LB1"), - _line("인부", "노임", "0.12", "9", code="LB2"), - ] - res = dm.verdict(two, six, TOT, TOT) # 같은 배(× 6)가 두 줄 = 결과 단위 기준 다름 = 로직 - assert [x["항"] for x in res["다른"]] == ["단위 환산", "단위 환산"] and res["판정"] == dm.NEAR - one = dm.verdict(two[:1] + six[1:], six, TOT, TOT) # 한 줄만 배 = 수량 값 - assert one["판정"] == dm.SURE and [x["항"] for x in one["참고"]] == ["품 숫자"] - kind = dm.verdict([MAN], [_line("특별인부", "노임", "0.02", "100", code="LB000003")], TOT, TOT) - assert kind["판정"] != dm.SURE and kind["다른"] # 직종 다름 = 로직 - more = dm.verdict([MAN], [MAN, _line("모래", "재료", "1", "9", "재료비")], TOT, TOT) - assert more["판정"] != dm.SURE and more["다른"][0]["항"] == "줄 없음" # 줄 구성(㉡) = 로직 - - -# ── (b) 견본 ───────────────────────────────────────────────────────────── -def _judge(tag: str, no: int) -> dict: - whole, _, index, links = t4.ctx() - try: - doc = t4.dim.derive(t2.sk_of(t2.fn.emit(t2.native("EST", tag), no)), None, t2.whole()) - except FileNotFoundError: - pytest.skip("원파일 없음") - return dm.match(doc, t4.dim.Links(whole, "우리"), index, links) - - -def _kinds(got: dict) -> list[str]: - return [x["항"] for x in got.get("참고") or []] - - -def test_backfill_spec_and_quantity() -> None: - got = _judge("main", 230) - assert got["판정"] == dm.SURE and got["키"] == "GF000113" and not got["다른"] - assert {"규격", "수량"} <= set(_kinds(got)) - assert got["바꿈"] == [ - {"기호": "P_굴착기무한궤", "기계": "0201-0020", "이름": "굴착기(무한궤도)", "규격": "0.2㎥"} - ] - - -def test_pc_slab_tail_and_manhole_poom() -> None: - slab = _judge("main", 850) - assert slab["판정"] == dm.SURE and slab["키"] == "GC000257" and _kinds(slab) == ["수량 끝자리"] - hole = _judge("main", 828) - assert hole["판정"] == dm.SURE and hole["키"] == "GC000252" and "품 숫자" in _kinds(hole) - assert hole["옵션"] == {"규격": "D1,800"} # ㉠ 이름 글 「D1800」 = 선택 「D1,800」 - - -def test_cut_soil_is_machine_work_and_pipe_stays_near() -> None: - work = _judge("거창", 46) - assert work["판정"] == val.WORK and not work.get("키") - assert _kinds(work) == ["작업량 틀"] and "GC001147" in work["참고"][0]["글"] - pipe = _judge("거창", 19) - kinds = [d["항"] for d in pipe["다른"]] - assert pipe["판정"] == dm.NEAR and pipe["키"] == "GF000179" - assert kinds.count("단위 환산") == 2 and "율 줄" in kinds - - -def test_machine_work_needs_one_machine_group() -> None: - names = dm.note._names - head = {"가져온": {"기계": [{"기호": "P_a"}, {"기호": "P_b"}]}, "식": []} - one = [_line("굴착기", "기계", "0.03", "5", "경비", "0201-0070", "P_a", "1 ÷ Q")] - assert val.work(head, one, names)["판정"] == val.WORK - assert val.work(head, [{**one[0], "수량글": "0.03"}], names) is None # 수량 = 수뿐 - assert val.work(head, [*one, {**MAN, "기호": "w"}], names) is None # 인력 섞임 = 보통 길 - both = [*one, {**one[0], "기호": "P_b", "코드": "0240-0007"}] - assert val.work(head, both, names) is None # 기계 둘(조합 아님) - combo = {"가져온": {"기계": [{"기호": "P_a", "조합첫": True}, {"기호": "P_b", "조합": True}]}} - assert val.work(combo, both, names)["판정"] == val.WORK # 조합 기계 = 한 묶음 - - -# ── (c) 옵션 확인 ──────────────────────────────────────────────────────── -def test_option_check_when_values_tie() -> None: - doc = {"변수": [{"기호": "S", "아래": "규격", "이름": "규격", "열쇠": "규격", - "연결": {"갈래": "선택", "목록": ["A", "B"], "값": "A"}}]} # fmt: skip - vid = dm.md.var_id(doc["변수"][0]) - got = val.option_check(doc, {vid: "A"}, [{vid: "A"}, {vid: "B"}], {vid}) - assert len(got) == 1 and "규격 = A(기본값" in got[0]["글"] - assert not val.option_check(doc, {vid: "A"}, [{vid: "A"}], {vid}) # 하나로 갈림 = 없음 - assert not val.option_check(doc, {vid: "A"}, [{vid: "A"}, {vid: "B"}], set()) # 수량 무관 - - -# ── (d) 바꾸기 · 되돌리기 ──────────────────────────────────────────────── -def test_swap_puts_imported_spec_machine_and_back(world) -> None: - folder = world["personal"] - try: - est = t2._file(t2.EST["main"]) - except FileNotFoundError: - pytest.skip("원파일 없음") - 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)["키"] - name = fh.make(folder, hp, 230)["이름"] - money0 = t4._money(name) - found = msk.find(name, store.read("sangeun", name)["판"]) - meta = found[dm.KEY] - assert meta["판정"] == dm.SURE and meta["바꿈"][0]["기계"] == "0201-0020" - out = msk.swap(name, found["판"]) - assert [x["기계"] for x in out["바꾼기계"]] == ["0201-0020"] - from M02_MasterTemplete import M02_Doc_Sk_Path as path - - doc = path.read(name)["문서"] - var = next(v for v in doc["변수"] if dm.md.var_id(v) == "P_굴착기무한궤") - assert var["연결"]["입력"]["기계"] == "'0201-0020'" - line = next(x for x in doc["금액"] if x["단가"] == "P_굴착기무한궤") - assert (line["이름"], line["규격"]) == ("굴착기(무한궤도)", "0.2㎥") - assert line["묶음"] == "굴착기(무한궤도) 0.2㎥" # 묶음 제목도 가져온 규격 - base = copy.deepcopy(dm.ml.load_all()["GF000113"]) # M01 값 그대로 · 기계만 가져온 규격 - for v in base["변수"]: - if dm.md.var_id(v) == "P_굴착기무한궤": - v["연결"]["입력"]["기계"] = "'0201-0020'" - want = dm._calc(base, dm.ml.Links(t2.whole()), dict(meta["옵션"])) - assert t4._money(name) == [want[k] for k in t4.COSTS] and t4._money(name) != money0 - msk.back(name, store.read("sangeun", name)["판"]) - again = store.read("sangeun", name)["문서"] - assert again["가져온"]["로직"] and not again.get("바탕") - assert t4._money(name) == money0 # 되돌리기 = 원래 가져온 식 금액 - with store.at(folder): - gc = t2._file(t2.EST["거창"]) - hp2 = fh.upload_native(folder, gc.name, gc.read_bytes(), t2.WHO)["키"] - cut = fh.make(folder, hp2, 46)["이름"] - found = msk.find(cut, store.read("sangeun", cut)["판"]) - assert found[dm.KEY]["판정"] == val.WORK - with pytest.raises(store.StoreError) as e: - msk.swap(cut, found["판"]) - assert e.value.status == 409 - assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")} - - -# ── (e) 전수 ───────────────────────────────────────────────────────────── -@pytest.mark.skipif(os.environ.get("S81211_ALL") != "1", reason="전수 = S81211_ALL=1") -def test_every_block_values() -> None: - out = subprocess.run( - [sys.executable, str(HERE / "helper_810_4_sweep.py")], capture_output=True, text=True - ) - assert out.returncode == 0, out.stderr[-2000:] - got = json.loads(out.stdout.strip().splitlines()[-1]) - assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0 - assert got["판정"].get(val.WORK, 0) >= 1 and got["판정"][dm.SURE] >= 184 - assert not set(val.VALUE) & set(got["근접항"]) # 값 차이 = 참고 diff --git a/resources/tester/test_812_2_match_logic.py b/resources/tester/test_812_2_match_logic.py deleted file mode 100644 index e10095f46..000000000 --- a/resources/tester/test_812_2_match_logic.py +++ /dev/null @@ -1,183 +0,0 @@ -"""812-2 지킴 — 연결 찾기 확정 = 로직(요구 712) · 값 차이 = 참고(`M02_Doc_Match_Note`). - -(a) 단가만 다름 = 확정 + 참고 「단가 차이」(원파일 기계경비 묶음 ↔ 우리 기계 = 이름 종류 같을 때) · - 끝수만 다름 = 확정 + 참고 「끝수」 -(b) 로직 차이 = 다른 항 — 율 대상 · 기계 규격 · 노임 종류(「자원」) · 산출 단가 · 비목 -(c) 율 = 비율 — 거창 합판거푸집(블록 41): 가져온 「주자재소계 × 9 %」 = M01 소모자재 9 % 짝 · 율_2 1 % 재료비 ↔ - 공구손료 1 % 경비 = 「비목」 · 대전 합판 단가 = 참고 -(d) 요구 704 배수관부설 D=800 ↔ GF000179 = 여전히 근접(단위 환산 × 6 · 기계 규격 · 율 줄) -(e) 전수(S8122_ALL=1) — 2,220 블록 · 확정 다시 셈 같음 · 근접 까닭에 단가 차이 · 끝수 없음 · 확정 참고 있음 -원파일 없으면 (c) · (d) · (e) skip(다른 PC). -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -from decimal import Decimal as D -from pathlib import Path - -import pytest - -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)) - -from M02_MasterTemplete import M02_Doc_Match as dm # noqa: E402 - -TOT = {"노무비": 9, "재료비": 0, "경비": 0, "계": 9} - - -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 _rate(name, rate, target=(("노임", "노무비"),), cost="경비"): - return {"번": 0, "이름": name, "갈래": "율", "코드": "", "수량": None, "단가": None, - "비목": {cost: D(1)}, "금액": D(1), "합밖": False, "율": D(rate), "대상": frozenset(target)} # fmt: skip - - -A = [_line("인부", "노임", "0.04", "100", code="LB1"), _line("공", "노임", "0.5", "10", code="LB2")] - - -# ── (a) 값 차이 = 참고 ─────────────────────────────────────────────────── -def test_price_only_is_sure_with_note() -> None: - b = [_line("인부", "노임", "0.04", "120", code="LB1"), A[1]] - res = dm.verdict(A, b, TOT, {**TOT, "노무비": 10, "계": 10}) - assert res["판정"] == dm.SURE and not res["다른"] and res["같음"] == 2 - assert [d["항"] for d in res["참고"]] == ["단가 차이"] - mach = [_line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")] - other = [_line("굴삭기", "기계", "0.1", "530", "경비", "0201-0070")] - assert dm.verdict(mach, other, TOT, TOT)["판정"] == dm.SURE # 기계 시간당 단가 = 참고 - file = [ - _line("머캐덤로울러(자주식)", "산출", "0.1", "530", "경비") - ] # 원파일 기계경비 묶음(코드 없음) - ours = [_line("탠덤롤러", "기계", "0.1", "500", "경비", "1206-0008")] - ours.append(_line("머캐덤롤러 10∼12ton", "기계", "0.1", "500", "경비", "1106-0012")) - res = dm.verdict(file, ours, TOT, TOT) # 이름 종류 같은 기계와 짝 · 단가 = 참고 - assert ( - not [d for d in res["다른"] if d["항"] != "줄 없음"] and res["참고"][0]["항"] == "단가 차이" - ) - run = [{**_line("드릴", "산출", "0.1", "530", "경비"), "비목": {"노무비": D(1), "경비": D(2)}}] - res = dm.verdict(run, [_line("드릴", "기계", "0.1", "500", "경비", "0901-0010")], TOT, TOT) - assert res["판정"] == dm.SURE # 기계경비 구성(운전 노무비 포함) 차이 = 시간당 단가 = 참고 - none = [{**_line("모래", "재료", "1", "0", "재료비"), "비목": {}}] - assert ( - dm.verdict([_line("모래", "재료", "1", "9", "재료비")], none, TOT, TOT)["판정"] == dm.SURE - ) - res = dm.verdict(file, ours[:1], TOT, TOT) - assert [d["항"] for d in res["다른"]] == ["기계 규격"] - - -def test_rounding_only_is_sure_with_note() -> None: - res = dm.verdict(A, list(A), TOT, {**TOT, "노무비": 10, "계": 10}) - assert res["판정"] == dm.SURE and [d["항"] for d in res["참고"]] == ["끝수"] - - -# ── (b) 로직 차이 = 다른 항 ────────────────────────────────────────────── -def test_rate_value_is_note_structure_is_logic() -> None: - res = dm.verdict([*A, _rate("공구손료", "0.02")], [*A, _rate("공구손료", "0.03")], TOT, TOT) - assert not res["다른"] and res["판정"] == dm.SURE - assert [d["항"] for d in res["참고"]] == ["율 값"] # 율 값(%) 다름 = 참고(812-4) · 로직 같음 - assert "2 % ↔" in res["참고"][0]["글"] and "3 %" in res["참고"][0]["글"] - same = dm.verdict([*A, _rate("율", "0.02")], [*A, _rate("공구손료", "0.02")], TOT, TOT) - assert same["판정"] == dm.SURE # 율 · 대상 · 비목 같음 = 이름 달라도 같은 율 줄 - tgt = dm.verdict( - [*A, _rate("율", "0.02", (("재료", "재료비"),))], [*A, _rate("율", "0.02")], TOT, TOT - ) - assert [d["항"] for d in tgt["다른"]] == ["율 대상"] - cost = dm.verdict([*A, _rate("율", "0.02", cost="재료비")], [*A, _rate("율", "0.02")], TOT, TOT) - assert [d["항"] for d in cost["다른"]] == ["비목"] - - -def test_machine_labor_kind_differs_blocks() -> None: - a = [*A, _line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")] - b = [ - *A, - _line("굴삭기", "기계", "0.1", "500", "경비", "0211-0080"), - ] # 타이어 = 다른 기종(812-11) - res = dm.verdict(a, b, TOT, TOT) - assert [d["항"] for d in res["다른"]] == ["기계 규격"] and res["판정"] == dm.NEAR - c = [_line("특별인부", "노임", "0.04", "120", code="LB9"), A[1]] - res = dm.verdict(A, c, TOT, TOT) - assert [d["항"] for d in res["다른"]] == ["자원"] and res["판정"] != dm.SURE - sub = [_line("모르타르", "산출", "0.1", "500", "재료비")] - res = dm.verdict(sub, [_line("모르타르", "산출", "0.1", "520", "재료비")], TOT, TOT) - assert [d["항"] for d in res["다른"]] == ["산출 단가"] # 안쪽 산출 = 로직 결과 - - -# ── (c) · (d) 원파일 블록 ──────────────────────────────────────────────── -def _judge(no: int) -> dict: - t4 = pytest.importorskip("test_810_4_match") - try: - return t4.judge(no) - except FileNotFoundError: - pytest.skip("원파일 없음") - - -def test_formwork_rate_ratio_pairs() -> None: - got = _judge(41) # 합판거푸집 4회 ↔ GC000211 — 공구손료 비목 차이 = 값(814-1 · 요구 744 C1) - assert got["판정"] == dm.SURE and got["키"] == "GC000211" and not got["다른"] - assert [d["항"] for d in got["참고"]] == ["단가 차이", "공구손료 비목"] - assert "1 %" not in got["참고"][1]["글"] and "재료비" in got["참고"][1]["글"] - - -def test_req704_still_near() -> None: - got = _judge(19) # 배수관부설 D=800 ↔ GF000179 - kinds = [d["항"] for d in got["다른"]] - assert got["판정"] == dm.NEAR and got["키"] == "GF000179" - assert kinds.count("단위 환산") == 2 and "기계 규격" in kinds and "율 줄" in kinds - - -# ── (e) 전수 ───────────────────────────────────────────────────────────── -@pytest.mark.skipif(os.environ.get("S8122_ALL") != "1", reason="전수 = S8122_ALL=1") -def test_every_block_logic() -> None: - out = subprocess.run( - [sys.executable, str(HERE / "helper_810_4_sweep.py")], capture_output=True, text=True - ) - assert out.returncode == 0, out.stderr[-2000:] - got = json.loads(out.stdout.strip().splitlines()[-1]) - assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0 - assert not {"단가 차이", "끝수"} & set(got["근접항"]) - assert got["확정참고블록"] >= 1 and got["판정"][dm.SURE] >= 98 - - -def test_rate_target_scope() -> None: - """율 대상 — 줄 기호 = 그 줄 · 비목 이름 = 그 비목 몫(기계 운전 노무비 포함) · 번지 합 = 핵심 줄 전부.""" - from M02_MasterTemplete import M02_Doc_Match_Note as note - - def run(rate_text: str) -> dict: - money = [ - {"이름": "인부", "수량": "1", "단가": "100", "기호": "C_인부"}, - {"이름": "굴삭기", "수량": "1", "단가": "300"}, - {"이름": "율", "단가": rate_text}, - ] - rows = [ - {"수량": D(1), "단가": D(100), "금액": D(100)}, - {"수량": D(1), "단가": D(300), "금액": D(300)}, - {"수량": D(1), "단가": D(0), "금액": D(0)}, - ] - facts = [ - {"번": 0, "갈래": "노임", "비목": {"노무비": D(100)}, "합밖": False}, - {"번": 1, "갈래": "산출", "비목": {"노무비": D(100), "경비": D(200)}, "합밖": False}, - {"번": 2, "갈래": "율", "비목": {"경비": D(4)}, "합밖": False}, - ] - doc = {"변수": [], "식": [], "금액": money} - rows[2]["단가"] = {"C_인부 × 4%": D(4), "노무비 × 2%": D(4), "번지.A01.계 × 1%": D(4)}[ - rate_text - ] - note.rates(doc, {"줄": rows}, facts) - return facts[2] - - one = run("C_인부 × 4%") - assert one["율"] == D("0.04") and one["대상"] == {("노임", "노무비")} - labor = run("노무비 × 2%") # 노무비 200(인부 100 + 굴삭기 운전 100) - assert labor["율"] == D("0.02") and labor["대상"] == {("노임", "노무비"), ("기계", "노무비")} - whole = run("번지.A01.계 × 1%") # 400 × 1 % - assert whole["율"] == D("0.01") and ("기계", "경비") in whole["대상"] diff --git a/resources/tester/test_812_4_rate_value.py b/resources/tester/test_812_4_rate_value.py deleted file mode 100644 index 92acefd57..000000000 --- a/resources/tester/test_812_4_rate_value.py +++ /dev/null @@ -1,79 +0,0 @@ -# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""812-4 지킴 — 율 값(%) 다름 = 참고(값은 M01 값으로 업데이트됨) · 율 대상 · 비목 다름 = 다른 항(로직). - -(a) 원파일 main 블록 105(터파기 · 리퍼 2.42 % ↔ 우리 유압식 리퍼 2 %) · 275(절토면고르기 리퍼) = - 확정 + 참고 「율 값」 (다른 항 0) · 글에 「바꾸면 우리 값」 -(b) 확정 + 참고 블록 [바꾸기] → 금액 = M01 사본 셈(율도 M01 값) · [되돌리기] → 원래 금액 · M01 문서 파일 그대로 -(c) 대상이 다른 율(교량받침 1.14 % ↔ 공구손료 3 % 노임 노무비) = 여전히 다른 항 「율 대상」 -원파일 없으면 skip(다른 PC) — 합성 줄 시험은 test_812_2. -""" - -from __future__ import annotations - -import sys -from decimal import Decimal as D -from pathlib import Path - -import pytest - -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)) - -import test_810_2_native_doc as t2 # noqa: E402 -import test_810_4_match as t4 # 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 — 같은 임시 층 - - -def _judge(no: int, tag: str = "main") -> dict: - whole, _, index, links = t4.ctx() - try: - out = t2.fn.emit(t2.native("EST", tag), no) - doc = t4.dim.derive(t2.sk_of(out), None, t2.whole()) - except FileNotFoundError: - pytest.skip("원파일 없음") - return dm.match(doc, t4.dim.Links(whole, "우리"), index, links) - - -@pytest.mark.parametrize(("no", "key"), [(105, "GC000089"), (275, "GC000111")]) -def test_rate_value_only_is_sure_with_note(no: int, key: str) -> None: - got = _judge(no) - assert got["판정"] == dm.SURE and got["키"] == key and not got["다른"] - rate = [d for d in got["참고"] if d["항"] == "율 값"] - assert len(rate) == 1 and "바꾸면 우리 값" in rate[0]["글"] and "2 %" in rate[0]["글"] - - -def test_rate_target_differs_stays_logic() -> None: - got = _judge(1533) # 교량받침설치 — 율 대상 기계 · 노임 ↔ 노임 - assert "율 대상" in {d["항"] for d in got["다른"]} and got["판정"] != dm.SURE - - -def test_swap_rate_note_block_uses_m01_rate(world) -> None: - folder = world["personal"] - est = t2._file(t2.EST["main"]) - 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)["키"] - name = fh.make(folder, hp, 105)["이름"] - old = store.read("sangeun", name) - money0 = t4._money(name) - found = msk.find(name, old["판"]) - meta = found[dm.KEY] - assert meta["판정"] == dm.SURE and any(d["항"] == "율 값" for d in meta["참고"]) - assert t4._money(name) == money0 # 찾기 = 기록만 - msk.swap(name, found["판"]) - doc = store.read("sangeun", name)["문서"] - want = dm._calc(dm.ml.load_all()[meta["키"]], dm.ml.Links(t2.whole()), dict(meta["옵션"])) - assert t4._money(name) == [want[k] for k in t4.COSTS] # 율도 M01 값 - assert doc["바탕"] == {"로직": meta["키"]} and sum(t4._money(name)) != sum(money0) - msk.back(name, store.read("sangeun", name)["판"]) - assert t4._money(name) == money0 # 되돌리기 = 원래 금액 - assert D(sum(money0)) > 0 - assert before == {p.name: p.read_bytes() for p in dm.ml.FOLDER.glob("수식_*.json")} diff --git a/resources/tester/test_814_1_import_decisions.py b/resources/tester/test_814_1_import_decisions.py index b4911e398..2c02abb26 100644 --- a/resources/tester/test_814_1_import_decisions.py +++ b/resources/tester/test_814_1_import_decisions.py @@ -2,10 +2,11 @@ """814-1 지킴 — 불러오기 결정 열 가지(방향 장부 `_방향_불러오기.md` E 표 · 요구 742 ~ 750). ① 파일 하나(같은 공사 붙이기 걷음) ② 확장자 파일 = 단가산출표 판(산출근거 머리) · 일위대가 없음 · -③ 엑셀 이름 규격 · 단위 꼬리 뗀 맞대기 ④ 기계 세 줄 = 한 줄(STC · EST · 엑셀) · 조합 다음 줄 기계마다 한 항목 · -⑤ 계산식 기계 목록(빠진 기계 더함 · 기본값 그대로) · 조합 = 우리 조합 식 ⑥ 공구손료 비목 = 참고 · -⑦ 못 이은 줄 까닭 · 「없음」 띠 ⑧ 유가 = 전국평균 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 · +④ 기계 세 줄 = 한 줄(STC · EST · 엑셀) · 조합 다음 줄 기계마다 한 항목 · +⑤ 계산식 기계 목록(빠진 기계 더함 · 기본값 그대로) · 조합 = 우리 조합 식 · +⑦ 못 이은 줄 까닭 ⑧ 유가 = 전국평균 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 · ⑨ 재료 지역 꼬리 뗀 정확 일치 ⑩ 가벼움(조합 첫 줄 규격 · 소계 검산 · 기계 찾기 판정 · 409 · 확정 글 · 전수 도우미). +③ ⑥ · 「없음」 띠 · 전수 도우미 = 연결 찾기와 함께 걷음(815-2). 원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC). """ @@ -29,8 +30,6 @@ import test_810_2_native_doc as t # 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_Doc_MatchSk as msk # noqa: E402 -from M02_MasterTemplete import M02_Doc_Match_Note as note # noqa: E402 from M02_MasterTemplete import M02_Doc_NativeLayout as lay # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402 from M02_MasterTemplete import M02_Machine_Hourly as mh # noqa: E402 @@ -84,18 +83,6 @@ def test_one_file_and_list_sheet(world) -> None: assert est["호표"] == [] and len(est["단산"]) == len(fn.numbers(t.native("EST", "거창"))) -# ── ③ 꼬리 뗀 맞대기 ───────────────────────────────────────────────────── -def test_bare_name_tail() -> None: - doc = { - "이름": "구조물터파기(인력) 경질토사 / M3", - "결과단위": "원/M3", - "가져온": {"규격": "경질토사"}, - } - assert note.bare(doc)["이름"] == "구조물터파기(인력)" and doc["이름"].endswith("/ M3") - plain = {"이름": "측구터파기", "결과단위": "원/㎥", "가져온": {"규격": "토사"}} - assert note.bare(plain) is plain - - # ── ④ 세 줄 묶기 · 조합 그룹 ───────────────────────────────────────────── def _machine_split(doc: dict, out: dict) -> int: """문서에 남은 기계 비목 낱 줄(같은 기계 · 같은 수량 · 비목 셋이 따로) 묶음 수.""" @@ -164,28 +151,6 @@ def test_combo_uses_our_combo_formula() -> None: assert main["규격"] != (main["자원"] or {}).get("규격") # 칩 글 = 그 기계 규격(조합 글 아님) -# ── ⑥ 공구손료 비목 = 참고 ─────────────────────────────────────────────── -def test_tool_rate_cost_is_value() -> None: - a = { - "갈래": "율", - "이름": "공구손료", - "율": "3", - "대상": {("인력", "노무비")}, - "비목": ["재료비"], - } - b = {**a, "비목": ["경비"]} - left, right = [a], [b] - - def take(test): - hits = [(x, y) for x in list(left) for y in list(right) if test(x, y)][:1] - for x, y in hits: - left.remove(x), right.remove(y) - return hits - - diffs, notes = note.rate_pairs(left, right, take) - assert not diffs and notes[0]["항"] == "공구손료 비목" - - # ── ⑧ ⑨ 유가 · 전력 · 재료 ─────────────────────────────────────────────── def test_oil_power_material_links() -> None: oil, _ = link.m01("재료", {"이름": "경 유", "규격": "", "단위": "ℓ"}) @@ -229,9 +194,6 @@ def test_unlinked_reasons_and_tally(monkeypatch) -> None: assert link.why(gone) == "별칭 대기" sib = {"호표": [{"이름": "a", "참조": {"키": "SK-1"}, "자원": {"갈래": "단가산출"}}]} assert link.tally(sib) == {"됨": 1, "줄": 1, "조합": True} - src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Match.ts").read_text("utf-8") - assert "우리 기초 없음" in src and "우리 정보로 셈" in src - assert "우리정보" in Path(msk.__file__).read_text("utf-8") # ── ⑩ 가벼움 ───────────────────────────────────────────────────────────── @@ -254,7 +216,5 @@ def test_light_fixes(world) -> None: assert seen == [False] origin = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Origin.ts").read_text("utf-8") assert "엑셀 읽기 확정" in origin - helper = (HERE / "helper_810_4_sweep.py").read_text("utf-8") - assert "str(got[k])" not in helper one = em.first_spec({"규격": "0.7+브레이카"}, [{"규격": "0.7㎥"}, {"규격": "0.7"}]) assert one["규격"] == "0.7㎥"