"""M02 일위대가 맞춤 — 옵션 거꾸로 찾기(PLAN 338-1 · 설계 2-5): 고른 우리 로직의 입력 칸마다 후보(옵션 공간)를 엔진으로 돌려 엑셀 품(직종 품 · 장비 시간 · 자재 수량)과 같은 입력을 찾음. 공간 = 고르기 모두 · 기계(EQ) = 엑셀 규격 수와 맞는 기종(없으면 모두) · 범위 = 두 끝 + 범위 안 엑셀 수 · 표 찾기 칸 = 그 표 줄 값 · 그 밖 수 = 자동값 + 같은 이름 계수 + 단위 붙은 엑셀 수(% 칸 = 0 덧 · 「품」 칸 = 엑셀 품 × 결과단위) · 뺌 = 지역 · 보정작업 · 조종원직종. 조합 = 곱 ≤ CAP 모두(안 닿는 칸 · 같은 품 값 묶음 셈) · 넘으면 엑셀 조건 낱말로 고르기 좁힘 · 그래도 넘으면 기준점 둘에서 하나씩 + 고르기 둘씩. 수준 = 0(0.5%) · 1(R = 0.5% + 엑셀 값 자릿수 반올림 폭) · 2(3%) · 9(없음) — 「딱 맞음」(핵심 + 엑셀 몫을 다 덮음)만 셈 · 같은 수준이면 거리 작은 것. 규칙 · 문턱은 336 K5(`k5_lib` · `stage_b`) 그대로. 엔진 = `M02_Hopyo_Ours.run`(읽기만). """ from __future__ import annotations import itertools import json import math import re from decimal import Decimal from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Fit as fit # 손잡이(CAP · TOL) — 부를 때만 씀 from M02_MasterTemplete import M02_Hopyo_Ours as ours from M02_MasterTemplete.M02_Hopyo_Words import nfkc, specnums FIXED = ("지역", "보정작업", "조종원직종") _NUM = re.compile(r"(? dict[str, Any]: """엑셀 지문 → 옵션 공간 재료(글 · 글머리 · 계수 · 규격 수 · 조건 · 품 값).""" texts = [efp["이름"], efp.get("규격") or ""] + [str(t) for t in text or []] sp: set[float] = set() for m in efp["장비"]: sp |= set(m.get("규격") or []) sp |= specnums(f"{efp['이름']} {efp.get('규격') or ''}") return { "글": texts, "글머리": texts[:8], "계수": efp.get("계수") or {}, "규격수": sp, "조건": efp.get("조건") or {}, "품값": {Decimal(str(x["품"])) for x in efp["인력"].values() if x.get("품")}, } def excel_numbers(texts: list[str], spec: dict[str, Any] | None = None) -> set[Decimal]: """글의 수 — spec 이 있으면 그 칸 단위가 뒤에 붙거나 칸 이름이 앞에 붙은 수만(쉼표 단가 뺌).""" out: set[Decimal] = set() flat = [nfkc(t) for t in texts] if spec is None: pats = [_NUM] else: u = nfkc(spec.get("단위")).strip().lower() vs = UNITS.get(u, (u,) if u and len(u) <= 3 and "/" not in u else ()) vs = () if u == "%" else vs # 「노무비의 2%」 같은 율이 감률 칸 후보로 드는 것을 막음 num = r"(\d+(?:\.\d+)?)" pats = [] if vs: alt = "|".join(re.escape(x) for x in vs) pats.append( re.compile(r"(? set[float]: """기계 규격 수 — 단위 붙은 수 + 맨 수(「41 [80∼95]」 → 41 · 80 · 95).""" one = ours.whole().numbers.get("EQ", {}).get(str(code)) or {} bare = {float(n) for n in re.findall(r"\d+(?:\.\d+)?", nfkc(one.get("규격")))} return ours.eq_of(code)[1] | bare def _uses(row: dict) -> tuple[dict, set]: try: return m01.mau._uses(row) except Exception: # noqa: BLE001 — 식을 못 읽는 로직은 찾기 칸 없음 return {}, set() def space(code: str, ex: dict[str, Any] | None = None) -> list[tuple[str, list, str]]: """[(입력 이름, 후보, 갈래)] — 갈래 = 기계 · 고르기 · 범위 · 표 · 수.""" whole = ours.whole() row = whole.logic(code) auto = m01.mau.values(whole, row) finds, _ = _uses(row) ex = ex or {} texts = ex.get("글") or [] coef = {k.lower(): Decimal(str(v)) for k, v in (ex.get("계수") or {}).items()} spn = ex.get("규격수") or set() times = Decimal(str(ours.times_of(row))) out = [] for s in row.get("입력") or []: name, opts = s["이름"], s.get("고르기") or [] if name in FIXED: continue if opts and all(isinstance(v, str) and ours.EQ_CODE.match(v) for v in opts): hits = {v: len(eq_numbers(v) & spn) for v in opts} if spn else {} most = max(hits.values(), default=0) keep = [ v for v in opts if most and hits[v] == most ] # 엑셀 규격 수가 가장 많이 겹치는 기종 out.append((name, keep or list(opts), "기계")) elif opts: out.append((name, list(opts), "고르기")) elif isinstance(s.get("범위"), list) and len(s["범위"]) == 2: lo, hi = (Decimal(str(x)) for x in s["범위"]) vals = {lo, hi} | {v for v in excel_numbers(texts, s) if lo <= v <= hi} out.append((name, sorted(vals), "범위")) elif name in finds: rows = set() for ref, cond in finds[name]: rows |= set(m01.mau._fits(whole, ref, cond)) out.append((name, sorted(rows) or [auto.get(name)], "표")) else: base = auto.get(name) vals = {base} if base is not None else set() if name.lower() in coef: vals.add(coef[name.lower()]) vals |= excel_numbers(texts, s) if texts else set() if str(s.get("단위") or "").strip() == "%": vals.add(Decimal(0)) if name == "품": vals |= {v * times for v in ex.get("품값", set())} vals.discard(None) out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수")) return out def _product(sp: list) -> list[dict]: names = [n for n, _, _ in sp] return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o, _ in sp])] def narrow(sp: list, ex: dict[str, Any] | None) -> list: """고르기 후보를 엑셀 조건 낱말로 좁힘 — 낱말이 든 후보가 있으면 그것만.""" words = {w for ws in ((ex or {}).get("조건") or {}).values() for w in ws} flat = re.sub(r"\s+", "", nfkc(" ".join((ex or {}).get("글머리") or []))) out = [] for name, opts, kind in sp: if kind == "고르기" and len(opts) > 1: hit = [ o for o in opts if any(w in str(o) for w in words) or (len(str(o)) >= 2 and str(o) in flat) ] if hit: out.append((name, hit, kind)) continue out.append((name, opts, kind)) return out def modes() -> tuple[tuple[str, float, bool], ...]: """허용 셋 — 0.5% · R(0.5% + 엑셀 값 자릿수 반올림 폭) · 3%.""" return (("0.5%", fit.TOL[0], False), ("R", fit.TOL[0], True), ("3%", fit.TOL[2], False)) def combos( sp: list, ex: dict[str, Any] | None = None, cap: int | None = None ) -> tuple[list[dict], str]: """(조합, 어떻게 — 모두 · 좁힘 · 하나씩).""" cap = cap or fit.CAP if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= cap: return _product(sp), "모두" sp2 = narrow(sp, ex) if math.prod(len(o) for _, o, _ in sp2) <= cap: return _product(sp2), "좁힘" base = {n: o[0] for n, o, _ in sp2} out = [dict(base)] + [{**base, n: v} for n, o, _ in sp2 for v in o[1:]] small = [(n, o) for n, o, k in sp2 if k != "수"] for (n1, o1), (n2, o2) in itertools.combinations(small, 2): if len(o1) * len(o2) <= 400: out += [{**base, n1: a, n2: b} for a in o1 for b in o2] seen, uniq = set(), [] for c in out: k = json.dumps(c, sort_keys=True, default=str, ensure_ascii=False) if k not in seen: seen.add(k) uniq.append(c) return uniq[:cap], "하나씩" def sig(v: dict | None, nd: int = 4) -> str | None: """품 꼴 서명 — 직종 · 장비(갈래 · 시간) · 자재 · 딸린 로직(유효 nd 자리).""" if v is None: return None r = lambda x: float(f"{x:.{nd}g}") # noqa: E731 return json.dumps( [ sorted((k, r(q)) for k, q in v["L"].items() if q), sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"] if q), sorted((k, r(q)) for k, q in v["M"].items() if q), sorted((k, r(q)) for k, q in v["S"].items() if q), ], ensure_ascii=False, ) def plan(code: str, ex: dict[str, Any] | None = None) -> tuple[list[dict], str, list, list, dict]: """(조합, 어떻게, 움직이는 칸, 안 닿는 칸, 값 묶음).""" sp = space(code, ex) if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= fit.CAP: cb = _product(sp) sg = [sig(ours.run(code, c)) for c in cb] active, inert, cls = [], [], {} for n, o, k in sp: if len(o) < 2: continue ctx: dict[str, dict] = {} for c, s_ in zip(cb, sg, strict=True): rest = {x: str(y) for x, y in c.items() if x != n} ctx.setdefault(json.dumps(rest, sort_keys=True, ensure_ascii=False), {})[ str(c[n]) ] = s_ keys = sorted(ctx) prof = {str(v): tuple(ctx[g].get(str(v)) for g in keys) for v in o} if len(set(prof.values())) == 1: inert.append(n) continue active.append(n) if k != "고르기": ids: dict = {} cls[n] = {v: ids.setdefault(pf, len(ids)) for v, pf in prof.items()} return cb, "모두", active, inert, cls base = {n: o[0] for n, o, _ in sp} alt = {n: o[-1] for n, o, _ in sp} act, inert = [], [] for n, o, k in sp: if len(o) == 1: continue s0 = {sig(ours.run(code, {**base, n: v})) for v in o} s1 = {sig(ours.run(code, {**alt, n: v})) for v in o} if len(s0) == 1 else s0 if len(s0) == 1 and len(s1) == 1: inert.append(n) continue if k != "고르기": # 같은 표 줄에 드는 수는 한 값으로 — 두 기준점 품이 같으면 같은 값 keep, seen = [], set() for v in o: pair = (sig(ours.run(code, {**base, n: v})), sig(ours.run(code, {**alt, n: v}))) if pair not in seen: seen.add(pair) keep.append(v) o = keep act.append((n, o, k)) cb, how = combos(act, ex) return [{**base, **c} for c in cb], how, [n for n, _, _ in act], inert, {} def vary(hits: list[dict], act: list[str], cls: dict) -> list[str]: """맞은 조합들 사이에서 값이 갈리는 칸 — 수 칸은 같은 값 묶음이면 한 값.""" return [ n for n in act if len({cls.get(n, {}).get(str(g.get(n)), str(g.get(n))) for g in hits}) > 1 ] # ── 맞대기 ────────────────────────────────────────────────────────────── def near(a: Any, b: Any, t: float, r: float = 0.0) -> bool: """a(엑셀) ≈ b(우리) — 비 t 안 · r = 엑셀 값 자릿수 반올림 폭.""" return bool(a and b and a > 0 and b > 0 and abs(a - b) <= max(t * max(a, b), r)) def ru(x: float) -> float: """엑셀 값 반올림 폭 — 소수 둘째~넷째 자리로 적힌 값만 · 값의 4% 안.""" s = repr(float(x)) if "e" in s or "." not in s: return 0.0 d = len(s.split(".")[1].rstrip("0")) return min(0.5 * 10 ** (-d), 0.04 * float(x)) if 2 <= d <= 4 else 0.0 def mat_words(name: Any) -> set[str]: """자재 이름 낱말 — 한글 두 자 넘는 덩이 · 영문 세 자 넘는 덩이 · 단위 낱말 뺌.""" t = nfkc(name).lower().replace(".", "") return {w for w in re.findall(r"[가-힣]{2,}|[a-z]{3,}", t) if w not in UNITW} def _named(ws: set[str], k: str) -> bool: w = mat_words(k) return bool(ws & w) or any(a in b or b in a for a in ws for b in w) def compare( e: dict, v: dict | None, t: float, base: bool = False, rnd: bool = False ) -> dict | None: """엑셀 지문 ↔ 우리 셈 → {직종, 장비, 자재, 핵심, 직종만, 덧} · base = 엑셀 밑품으로.""" if v is None: return None tol = lambda x: ru(x) if rnd else 0 # noqa: E731 E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")} ov = {k: q for k, q in v["L"].items() if q and q > 0} out: dict[str, Any] = {"덧": []} if E: same = set(E) == set(ov) ok = same and all(near(E[k], ov[k], t, tol(E[k])) for k in E) out["직종"] = "같음" if ok else "직종 같음" if same else "다름" else: out["직종"] = "우리만" if ov else "없음" EX = [m for m in e["장비"] if m.get("시간")] OX = [(set(a), q) for a, _, q in v["X"] if q] if EX and OX: ok = [] for m in EX: mine = [q for a, q in OX if a & set(m["갈래"])] if not mine: out["덧"].append("엑셀만 장비 " + "·".join(sorted(m["갈래"]))) continue h = m["밑시간"] if base and m.get("밑시간") else m["시간"] hit = any(near(h, q, t, tol(h)) for q in mine) or near(h, sum(mine), t, tol(h)) ok.append(hit) et = set().union(*[set(m["갈래"]) for m in e["장비"]]) out["덧"] += ["우리만 장비 " + "·".join(sorted(a)) for a, _ in OX if not a & et] out["장비"] = ("같음" if all(ok) else "다름") if ok else "갈래 안 겹침" else: out["장비"] = "엑셀만" if EX else "우리만" if OX else "없음" EM = [m for m in e["자재"] if m.get("품")] OM = {k: q for k, q in v["M"].items() if q} if EM and OM: ok = [] for m in EM: mine = [q for k, q in OM.items() if _named(mat_words(m["이름"]), k)] if mine: x = m["밑품"] if base and m.get("밑품") else m["품"] ok.append(any(near(x, q, t, tol(x)) for q in mine)) out["자재"] = ("같음" if all(ok) else "다름") if ok else "이름 안 겹침" else: out["자재"] = "엑셀만" if EM else "없음" core = [out["직종"] == "같음"] if E else [False] if ov else [] core += [out[k] == "같음" for k in ("장비", "자재") if out[k] in ("같음", "다름")] out["핵심"] = bool(core) and all(core) out["직종만"] = bool(E) and out["직종"] == "같음" return out def strong(e: dict, c: dict | None) -> bool: """딱 맞음 — 핵심 + 엑셀 몫을 다 덮음(엑셀만 · 우리만 장비 없음 · 자재 다름 없음 · 「보통인부 하나뿐 · 장비 없음」 줄 아님).""" if not c or not c.get("핵심"): return False if c.get("장비") in ("엑셀만", "우리만", "갈래 안 겹침"): return False if any("엑셀만 장비" in d or "우리만 장비" in d for d in c.get("덧") or []): return False if c.get("자재") == "다름": return False labor = [k for k, x in e["인력"].items() if x.get("품")] return not (labor == ["보통인부"] and not any(m.get("시간") for m in e["장비"])) def gap(e: dict, v: dict | None, base: bool = False) -> float: """닮음 거리 — 직종 묶음 어긋남 + 품 |ln 비| 평균 + 장비 시간 반 무게.""" if v is None: return 99.0 E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")} ov = {k: q for k, q in v["L"].items() if q and q > 0} d = 0.0 if E or ov: d += len(set(E) ^ set(ov)) / len(set(E) | set(ov)) both = [min(1.0, abs(math.log(E[k] / ov[k]))) for k in set(E) & set(ov)] d += sum(both) / len(both) if both else 1.0 EX = [m for m in e["장비"] if m.get("시간")] OX = [(set(a), q) for a, _, q in v["X"] if q] if EX and OX: hs = [] for m in EX: mine = [q for a, q in OX if a & set(m["갈래"])] h = m["밑시간"] if base and m.get("밑시간") else m["시간"] hs.append(min(1.0, min(abs(math.log(h / q)) for q in mine)) if mine else 1.0) d += 0.5 * sum(hs) / len(hs) return d def csig(e: dict, v: dict | None, nd: int = 4) -> str | None: """엑셀이 보이는 몫만의 품 서명 — 직종 · 엑셀 갈래와 겹치는 장비 · 엑셀 이름과 겹치는 자재.""" if v is None: return None r = lambda x: float(f"{x:.{nd}g}") # noqa: E731 et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set() em = [mat_words(m["이름"]) for m in e["자재"] if m.get("품")] return json.dumps( [ sorted((k, r(q)) for k, q in v["L"].items() if q), sorted((tuple(a), r(q)) for a, _, q in v["X"] if q and set(a) & et), sorted((k, r(q)) for k, q in v["M"].items() if q and any(_named(ws, k) for ws in em)), ], ensure_ascii=False, ) def ours_qty(v: dict | None) -> dict[str, Any]: """우리 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}.""" if v is None: return {"인력": {}, "장비": [], "자재": []} return { "인력": {k: round(q, 6) for k, q in v["L"].items() if q}, "장비": [{"갈래": list(a), "시간": round(q, 6)} for a, _, q in v["X"] if q], "자재": [{"이름": k, "수량": round(q, 6)} for k, q in v["M"].items() if q], } def _plain(value: Any) -> Any: if isinstance(value, Decimal): value = value.normalize() return int(value) if value == value.to_integral_value() else float(value) return value def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]: """로직 하나의 옵션 거꾸로 찾기 → {수준, 판정, 입력, 갈리는칸, 견줌, 우리품, 거리, 섞기전}.""" none = {"수준": 9, "판정": "없음", "입력": {}, "갈리는칸": [], "견줌": None} none.update(우리품=ours_qty(None), 거리=99.0, 섞기전=False, 직종같음=False) try: ex = ex_of(efp, text) cb, how, act, _, cls = plan(code, ex) except Exception: # noqa: BLE001 — 로직 없음 · 식 못 읽음 return {**none, "판정": "못 셈"} best = (9, 9, 99.0, None, None) hits: dict[int, list] = {0: [], 1: [], 2: []} for g in cb: v = ours.run(code, g) if v is None or not (v["L"] or v["X"] or v["M"]): continue lvl = weak = 9 for i, (_, t, rnd) in enumerate(modes()): c = compare(efp, v, t, rnd=rnd) or {} if c.get("핵심"): weak = min(weak, i) if strong(efp, c): hits[i].append((g, csig(efp, v))) lvl = min(lvl, i) d = gap(efp, v) if (lvl, weak, d) < best[:3]: best = (lvl, weak, d, g, v) lvl, _, d, g, v = best if g is None: return none got = [h for h in hits.get(lvl, [])] if lvl < 9 else [] split = vary([h for h, _ in got], act, cls) if not got: verdict = "없음" elif len({s for _, s in got}) > 1: verdict = "여럿(다른 품)" else: verdict = "여럿(같은 품)" if split else "하나만" auto = m01.mau.values(ours.whole(), ours.whole().logic(code)) # 움직인 칸 + 자동값과 다른 칸 base = compare(efp, v, fit.TOL[2], base=True) or {} labor = {k for k, x in efp["인력"].items() if x.get("품")} return { "수준": lvl, "판정": verdict, "입력": {n: _plain(x) for n, x in g.items() if n in act or str(x) != str(auto.get(n))}, "모든입력": {n: _plain(x) for n, x in g.items()}, "갈리는칸": split, "견줌": compare(efp, v, fit.TOL[2]), "직종같음": bool(v["L"]) and set(k for k, q in v["L"].items() if q) == labor, "우리품": ours_qty(v), "거리": round(d, 4), "섞기전": lvl == 9 and bool(base.get("핵심")), "어떻게": how, }