diff --git a/M02_MasterTemplete/M02_Doc_Match.py b/M02_MasterTemplete/M02_Doc_Match.py index debdf6b48..0ef8c1f75 100644 --- a/M02_MasterTemplete/M02_Doc_Match.py +++ b/M02_MasterTemplete/M02_Doc_Match.py @@ -7,10 +7,13 @@ ② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 · 값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을 문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터. - ③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기계 규격 · 수량 · - 비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 %(값 — 확정을 안 막음) — - 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」. -결과 `{판정, 키, 이름, 옵션, 다른, 참고, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`. + ③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기종 · 단위 환산 · + 비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 % · 같은 기종 규격 · 수량 + (값 — 확정을 안 막음 · 812-11 `M02_Doc_Match_Value`) — 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 + 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」 · 값이 맞는 조합 없으면 기본 옵션 + 「옵션 확인」. + ⓪ 기계 한 묶음 + 작업량 식 수량 = 「기계 작업」(우리 기초에 안 붙임 · 요구 728 · 735-4). +결과 `{판정, 키, 이름, 옵션, 다른, 참고, 바꿈, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보` · +`바꿈` = [바꾸기] 때 갈아 끼울 기계 줄 [{기호(우리 단가 기호), 기계(가져온 원문번호), 이름, 규격}]. """ from __future__ import annotations @@ -24,6 +27,7 @@ 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( @@ -133,8 +137,12 @@ def facts(doc: dict, got: dict) -> list[dict]: "비목": {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 @@ -284,7 +292,11 @@ def _words(doc: dict) -> str: 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(" ", "") + 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( @@ -300,7 +312,7 @@ def domains( kind, base = link.get("갈래"), link.get("값") if kind == "선택": opts = list(link.get("목록") or []) - seen = [o for o in opts if str(o).replace(" ", "") in words and len(str(o)) > 1] + seen = [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) @@ -437,6 +449,7 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]: and a["단가"] == b["단가"] and a["비목"] == b["비목"] and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조 + and val.same_combo(a, b) ) ) same += len(exact) @@ -452,11 +465,14 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]: 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 "수량" - diffs.append({"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"}) + 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( @@ -473,7 +489,15 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]: what = "줄 없음" diffs.append({"항": what, "글": f"{side} — {x['이름']}"}) n_core = sum(1 for a in mine if a["갈래"] != "율") - return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs, "참고": notes} + swaps = [x.pop("바꿈") for x in notes if "바꿈" in x] + return { + "같음": same, + "짝": same + paired, + "핵심": n_core, + "다른": diffs, + "참고": notes, + "바꿈": swaps, + } def _totals(got: dict) -> dict: @@ -542,6 +566,7 @@ def try_doc( 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: @@ -549,9 +574,11 @@ def try_doc( 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["다른"]), @@ -559,18 +586,28 @@ def try_doc( ) 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), + "순위": (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), {}) @@ -585,6 +622,7 @@ def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, 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: @@ -593,6 +631,9 @@ def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, 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] @@ -606,6 +647,7 @@ def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, "옵션": _shown(base, best["옵션"]), "다른": best["다른"], "참고": best["참고"], + "바꿈": best.get("바꿈") or [], "금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])}, "후보수": len(keys), } diff --git a/M02_MasterTemplete/M02_Doc_MatchSk.py b/M02_MasterTemplete/M02_Doc_MatchSk.py index 72e7b9067..67bb3280e 100644 --- a/M02_MasterTemplete/M02_Doc_MatchSk.py +++ b/M02_MasterTemplete/M02_Doc_MatchSk.py @@ -5,18 +5,24 @@ 바꾸기(`swap`) = 서버가 다시 찾아 「확정」 일 때만 — 같은 SK 키에 M01 사본 SK(바탕 = 그 기초 · 입력값 = 찾은 옵션 · 802-4a 꼴 · 수식 사본은 저장 때 서버가 찍음)를 씀 · 원래 가져온 식 SK 몸은 `가져온보관` 칸에 그대로 둠 · 일위대가 줄 참조 = 키 그대로(SK 내용만 바뀜 → 금액 다시 셈). - 되돌리기(`back`) = `가져온보관` 몸을 그 키에 다시 씀(가져온 식 금액 그대로). -우리 M01 기초는 어느 갈래에서도 안 바뀜(708) · 근접 · 없음 = 바꾸기 409(703). + + 찾은 `바꿈`(같은 기종 규격만 다른 기계 줄 · 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 @@ -92,7 +98,36 @@ def swap(key: str, version: str) -> dict[str, Any]: if found.get("판정") != dm.SURE: raise _bad(f"우리 기초와 「확정」 일치가 아님({found.get('판정')}) — 바꾸지 않음", 409) info = store.write(sd.KIND, key, version or got["판"], copy_body(sk, found)) - return {dm.KEY: found, "판": info.get("판")} + 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]: @@ -101,5 +136,13 @@ def back(key: str, version: str) -> dict[str, Any]: old = got["문서"].get(KEEP) if not isinstance(old, dict) or not old.get(imp.KEY): raise _bad("되돌릴 가져온 식 없음") - info = store.write(sd.KIND, key, version or got["판"], {**old, "키": key}) + 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 index 7fe1fe20d..9b9ee23d7 100644 --- a/M02_MasterTemplete/M02_Doc_Match_Note.py +++ b/M02_MasterTemplete/M02_Doc_Match_Note.py @@ -1,8 +1,9 @@ """연결 찾기 — 로직 차이(다른 항) 대 값 차이(참고) 가르기(PLAN 812-2 · 요구 712) — `M02_Doc_Match` 가 부름. -확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기계 규격 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 · - 율 구조(있고 없음 · 대상 비목 — 율 값 % 는 참고) · 수량(옵션 풀이 뒤 · 원 단위 같아야 함). -「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 율 값(%) · 그로 인한 비목 금액 · 계. +확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기종 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 · + 율 구조(있고 없음 · 대상 비목 — 율 값 % 는 참고) · 단위 환산(`M02_Doc_Match_Value`). +「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 율 값(%) · 같은 기종 규격 · 수량(812-11) · + 그로 인한 비목 금액 · 계. 율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 · 대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님. """ @@ -14,6 +15,7 @@ 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 @@ -158,15 +160,24 @@ def _grams(text: str) -> set[str]: 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)["코드"] - if a["갈래"] == b["갈래"] == "기계" and a["코드"] != b["코드"]: - diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {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): @@ -178,11 +189,11 @@ def pair(a: dict, b: dict) -> tuple[list[dict], list[dict]]: {"항": "비목", "글": f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])}"} ) if a["단가"] == b["단가"]: - return diffs, [] + return diffs, spec say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}" if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose: - return diffs, [{"항": "단가 차이", "글": say}] - return [*diffs, {"항": "산출 단가", "글": say}], [] + return diffs, [*spec, {"항": "단가 차이", "글": say}] + return [*diffs, {"항": "산출 단가", "글": say}], spec def _same_stuff(a: dict, b: dict) -> bool: diff --git a/M02_MasterTemplete/M02_Doc_Match_Value.py b/M02_MasterTemplete/M02_Doc_Match_Value.py new file mode 100644 index 000000000..3b8487e9e --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Match_Value.py @@ -0,0 +1,220 @@ +"""연결 찾기 값 기준 넓힘(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*$") + + +# ── 기종 ────────────────────────────────────────────────────────────── +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_MasterTemplete_Sangeun_MatchBand.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts index d73d377a2..adc45d641 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts @@ -34,7 +34,7 @@ const LABEL: Record, string> = { }; const ASK: Partial, string>> = { 바꾸기: - "이 단가산출을 우리 기초 사본(찾은 옵션)으로 바꿈 — 키 그대로 · 일위대가 금액이 다시 셈됨 · 원래 가져온 식은 보관", + "이 단가산출을 우리 기초 사본(찾은 옵션 · 우리 값)으로 바꿈 — 규격만 다른 기계 줄은 가져온 규격의 우리 기계 · 키 그대로 · 일위대가 금액이 다시 셈됨 · 원래 가져온 식은 보관", 되돌리기: "우리 기초 사본을 원래 가져온 식으로 되돌림 — 가져온 식 금액 그대로", }; diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts index 66da16d97..f655d15a2 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts @@ -4,11 +4,12 @@ * SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나: * 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] · * 다른 항 = 로직 차이(확정을 막음) · 참고 = 단가 · 끝수 · 율 값 %(값 차이 — 「우리 단가로 셈 ○○ 원」 · 812-2 · 812-4) · - * 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기] + * 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기] · + * 같은 기종 규격 · 수량 차이 = 참고(812-11) · 기계 작업 = 기계 한 줄 단가산출(우리 기초에 안 붙임 · 단추 없음) * 화면 · DOM · fetch 없음 * ========================================================================== */ -export type MatchVerdict = "확정" | "근접" | "없음"; +export type MatchVerdict = "확정" | "근접" | "없음" | "기계 작업"; export interface MatchFound { 판정?: MatchVerdict; @@ -16,7 +17,7 @@ export interface MatchFound { 이름?: string; 옵션?: Record; 다른?: { 항: string; 글: string }[]; - /** 값 차이(단가 · 끝수 · 율 값) — 확정을 안 막음 */ + /** 값 차이(단가 · 끝수 · 율 값 · 규격 · 수량) — 확정을 안 막음 */ 참고?: { 항: string; 글: string }[]; 금액?: { 가져온?: Record; 우리?: Record }; 후보수?: number; @@ -49,15 +50,21 @@ const lines = (d?: { 항: string; 글: string }[]): string[] => 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.금액?.우리?.계); @@ -80,6 +87,14 @@ export function matchBand(sk: SkLike | null | undefined): MatchBand | null { 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 { diff --git a/docs/raw/요구_1104/_목록_812-11_연결판정.xlsx b/docs/raw/요구_1104/_목록_812-11_연결판정.xlsx new file mode 100644 index 000000000..6ad7a18b7 Binary files /dev/null and b/docs/raw/요구_1104/_목록_812-11_연결판정.xlsx differ diff --git a/resources/tester/helper_810_4_sweep.py b/resources/tester/helper_810_4_sweep.py index 08fa5d1fc..69b3dcdb5 100644 --- a/resources/tester/helper_810_4_sweep.py +++ b/resources/tester/helper_810_4_sweep.py @@ -31,7 +31,8 @@ def _ctx(): whole = t.whole() docs = dm.ml.load_all() - return t, dim, dm, whole, docs, dm.Index(docs), dm.Memo(dm.ml.Links(whole, docs)) + 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]: diff --git a/resources/tester/test_810_4_match.py b/resources/tester/test_810_4_match.py index 35adaea70..a50d6f3f6 100644 --- a/resources/tester/test_810_4_match.py +++ b/resources/tester/test_810_4_match.py @@ -120,9 +120,9 @@ def test_compare_kinds_and_no_tolerance() -> None: 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("공", "노임", "0.5", "11")] - res = dm.verdict(a, b, tot, tot) - assert [d["항"] for d in res["다른"]] == ["단위 환산"] and res["판정"] == dm.NEAR + 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) diff --git a/resources/tester/test_812_11_match_values.py b/resources/tester/test_812_11_match_values.py new file mode 100644 index 000000000..119c3724e --- /dev/null +++ b/resources/tester/test_812_11_match_values.py @@ -0,0 +1,239 @@ +# 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 index 2e39ccc89..36501fbec 100644 --- a/resources/tester/test_812_2_match_logic.py +++ b/resources/tester/test_812_2_match_logic.py @@ -98,7 +98,10 @@ def test_rate_value_is_note_structure_is_logic() -> None: def test_machine_labor_kind_differs_blocks() -> None: a = [*A, _line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")] - b = [*A, _line("굴삭기", "기계", "0.1", "500", "경비", "0201-0080")] + 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]]