From 57af9be22f5fc2927979d339ea9eced4386b3443 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 20:41:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(784-1):=20=E3=80=8C=EC=9A=B0=EB=A6=AC=20?= =?UTF-8?q?=ED=92=88=E3=80=8D=20=EC=9E=98=EB=AA=BB=20=EB=B3=B4=EC=9E=84=20?= =?UTF-8?q?=EB=BF=8C=EB=A6=AC=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20=EB=AA=BB?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20=EB=B9=88=EC=B9=B8=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20=EC=B9=B8=20=EC=9E=90=EB=8F=99=EA=B0=92=200=20?= =?UTF-8?q?=C2=B7=20=EC=88=98=20=EC=B9=B8=20=EC=A7=9D=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EB=8B=A8=EC=9C=84=20=EB=B0=B0=201=20=C2=B7=20?= =?UTF-8?q?=EC=A0=88=20=ED=95=98=EB=82=98=20=EB=8D=A7=20=EC=97=86=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EC=97=91=EC=85=80=20=EA=B0=92=20=ED=91=9C=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A 옵션 찾기 못 맞춤(수준 9 · 거리 ≥ 1) = 우리 품 · 입력 안 냄 · 「못맞춤」 표지 · 꼬리표 「→ –(우리 셈 조건 못 찾음)」 - B 자동값 「만약(X > 0, X, 기본)」 칸 = 나눗수 퍼뜨림에서 뺌(0 = 원문 기본) · 옵션 후보엔 옛 1 도 둠 - C 하나씩 조합 — 후보 ≤ 4 수 칸도 둘씩 짝 - D 엑셀이 값으로 넣은 「0 = 식」 칸 출처 「엑셀 값 · 식 아님」 · 꼬리표 「원문 식이면 품 · 금액(조건)」 - E 결과단위 「자재」 = 배 1 · 공/㎥ ≡ 공㎥ - F 품 비교 우리 품 × 대상 몫 - G 절 하나 블록 = 덧 로직은 후보만(이중 계상 막음) - H 1위 옵션 수준 ≤ 2 면 그 입력을 대상 입력값으로 - I 규격 「L=…cm」 를 거리 칸에 안 넣음 · 수 고르기는 제 단위 붙은 수만 · 엑셀 수 「1 ㎡」 를 m 로 안 읽음 - 우리지문 · 맞춤 캐시 판 올림(FP 2 · CALC 3) - 새 지킴 시험 test_635_hopyo_ours_qty - 낡은 시험 둘(test_628 표지 수 18 · test_621 S1 울진소광 해시)은 HEAD 에서도 실패 — 782-3 마스터 합침 뒤 값 · 손대지 않음 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Combine.py | 4 + M02_MasterTemplete/M02_Hopyo_Fit.py | 2 +- M02_MasterTemplete/M02_Hopyo_Line.py | 21 ++- M02_MasterTemplete/M02_Hopyo_Match.py | 29 ++- M02_MasterTemplete/M02_Hopyo_Option.py | 49 ++++- M02_MasterTemplete/M02_Hopyo_Ours.py | 2 +- M02_MasterTemplete/M02_Hopyo_Verdict.py | 22 ++- resources/master_data/scripts/master_auto.py | 15 +- resources/tester/test_635_hopyo_ours_qty.py | 185 +++++++++++++++++++ 9 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_635_hopyo_ours_qty.py diff --git a/M02_MasterTemplete/M02_Hopyo_Combine.py b/M02_MasterTemplete/M02_Hopyo_Combine.py index 6537cca0b..86badd5d9 100644 --- a/M02_MasterTemplete/M02_Hopyo_Combine.py +++ b/M02_MasterTemplete/M02_Hopyo_Combine.py @@ -258,6 +258,7 @@ def combine( groups: dict[str, list] = {} for p in parts: groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p) + single = bool(primary) and len(groups) == 1 # 절 하나 = 덧은 후보만(같은 몫 이중 계상 막음) have = {primary} if primary else set() def machine(E: dict, where: str) -> None: @@ -297,6 +298,9 @@ def combine( ok = mixed or reached(c, line, p.get("제목"), p, prior, got) chosen.append(one(c, p.get("제목"), f, x, "몫" if ok else "후보", g)) have.add(c) + if single: + return [c if c["단계"] in ("1순위", "후보") else {**c, "단계": "후보"} for c in chosen + if c["단계"] != "장비 몫"] # fmt: skip return chosen diff --git a/M02_MasterTemplete/M02_Hopyo_Fit.py b/M02_MasterTemplete/M02_Hopyo_Fit.py index 147df4691..572eecefb 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fit.py +++ b/M02_MasterTemplete/M02_Hopyo_Fit.py @@ -55,7 +55,7 @@ COMBO = (0.85, 0.75, 0.6) COVER = 0.6 SHARES = (0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9) EVERY = 20 # 블록 몇 개마다 캐시 덮음 -CALC_VERSION = 2 # 맞춤셈 꼴 · 셈이 바뀌면 올림 — 옛 캐시 안 씀 +CALC_VERSION = 3 # 맞춤셈 꼴 · 셈이 바뀌면 올림 — 옛 캐시 안 씀 COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"} HOUR = {"hr", "시간", "h"} NO_NUMBER = "숫자 판정 없음" diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 15549b76a..5297d1a75 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -73,9 +73,11 @@ def _target(code: str, line: dict, ctx: Ctx, found: dict | None, use: bool, **ex one = ctx.names.get(code) or {"이름": "", "단위": ""} vals, unread = hm.spec_inputs(line, hm._logic_specs(code)) src = {k: "규격" for k in vals} + use = use or (found or {}).get("수준", 9) <= 2 # 맞춘 옵션 입력 = 품 비교와 같은 입력 + typed = ((found or {}).get("식아님") or {}).get("칸") or {} # 엑셀이 값으로 넣은 「0 = 식」 칸 for k, v in ((found or {}).get("입력") or {}).items() if use else (): if str(vals.get(k)) != str(v): - vals[k], src[k] = v, "블록" + vals[k], src[k] = v, "엑셀 값 · 식 아님" if k in typed else "블록" conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) share = extra.pop("몫", None) times = conv["배"] * share if conv["배"] is not None and share else conv["배"] @@ -109,11 +111,17 @@ def _pair(pair: dict, line: dict, ctx: Ctx) -> dict: } -def _qty(entry: dict, found: dict | None) -> dict: +def _qty(entry: dict, found: dict | None, share: float | None = None) -> dict: """품 비교 — 엑셀 · 우리 · 같음(직종 · 장비 갈래마다 R 허용) · 같은 갈래 여러 줄은 합쳐 견줌 - (화면 품 비교 열쇠 = 갈래 가나다 차례 + 로 묶음).""" + (화면 품 비교 열쇠 = 갈래 가나다 차례 + 로 묶음) · 몫 = 대상 몫(섞인 줄 10% = 우리 × 0.1).""" ex = entry.get("품") or {"인력": {}, "장비": [], "자재": []} us = (found or {}).get("우리품") or option.ours_qty(None) + if share: + k = lambda q: round(q * share, 6) # noqa: E731 + us = {"인력": {t: k(q) for t, q in us["인력"].items()}} | { + "장비": [{**x, "시간": k(x["시간"])} for x in us["장비"]], + "자재": [{**x, "수량": k(x["수량"])} for x in us["자재"]], + } same = {} for t, a in ex["인력"].items(): b = us["인력"].get(t) @@ -159,6 +167,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: found = (entry.get("옵션") or {}).get(top) use = state in ("맞음", "다른 점") combo = _picked(entry) + share = None if combo: targets = [ _target( @@ -176,11 +185,13 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: for c in entry.get("조합") or []: if not c["체크"]: tags.append(f"기계 몫 {c['로직']} — 시간 = 엑셀 값(우리 셈 아님)") - if any("블록" in t["출처"].values() for t in targets): + if any({"블록", "엑셀 값 · 식 아님"} & set(t["출처"].values()) for t in targets): tags.append("옵션 — 블록에서") tags += [f"근거 {b} {n}" for b, n in (entry.get("지문") or {}).get("출처") or []] out = {"상태": state, "체크": _check(entry, line, ctx), "대상": targets} - out.update(후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found)) + out.update( + 후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found, share) + ) if found and found.get("갈리는칸"): out["갈리는칸"] = found["갈리는칸"] return out diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index da32d4ab6..85590ff45 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -151,6 +151,8 @@ def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name] letter = max(words, key=lambda x: len(x[0]))[1] if words else None got: Any = None + if letter == "L" and "거리" in name and named.get("L", (0, None))[1] in ("cm", "mm"): + letter = None # 「L=35cm」 = 부재 길이 — 운반 · 거리(m) 칸 아님 if letter in named: value = _json(_in_unit(*named[letter], spec.get("단위"))) got = value if not picks or value in picks else None @@ -160,7 +162,15 @@ def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict label = str(labels.get(opt) or opt) for text in (label, _PAREN.sub("", label)): flat = _spec_text(text) - if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")): + if ( + _NUM.fullmatch(flat) and len(flat) > 1 + ): # 수 고르기 = 제 단위가 붙은(또는 맨) 수만 + hits += ( + [(len(flat), opt)] + if any(_num_in(flat, w, spec) for w in tokens) + else [] + ) + elif len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")): hits.append((len(flat), opt)) # 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」) hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)] @@ -172,6 +182,19 @@ def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict return values, unread +_NUM = re.compile(r"\d+(?:\.\d+)?") + + +def _num_in(flat: str, pool: str, spec: dict[str, Any]) -> bool: + """규격 글에 수 `flat` 이 따로 서 있고 뒤에 다른 단위가 안 붙음(「35cm」 의 35 ≠ 경사 35°).""" + own = _spec_text(spec.get("단위")) + for m in re.finditer(rf"(? dict[str, Any]: """`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력.""" @@ -180,8 +203,10 @@ def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]: if len(units) > 1: ok = plain in {mm.plain_unit(u) for u in units} return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"} - if plain == mm.plain_unit(want): + if plain.replace("/", "") == mm.plain_unit(want).replace("/", ""): # 공/㎥ ≡ 공㎥ return {"배": 1, "갈래": "같음"} + if str(want or "").strip() == "자재": # 결과단위 「원/자재 단위」 = 줄 단위 그대로 + return {"배": 1, "갈래": "자재 단위"} a, b = mp.kind_of(have), mp.kind_of(want) if a is not None and a == b: scale = mp.UNITS[a] diff --git a/M02_MasterTemplete/M02_Hopyo_Option.py b/M02_MasterTemplete/M02_Hopyo_Option.py index f6b4a6c44..0363b83c4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Option.py +++ b/M02_MasterTemplete/M02_Hopyo_Option.py @@ -87,7 +87,7 @@ def excel_numbers(texts: list[str], spec: dict[str, Any] | None = None) -> set[D if vs: alt = "|".join(re.escape(x) for x in vs) pats.append( - re.compile(r"(? list[tuple[str, list, row = whole.logic(code) auto = m01.mau.values(whole, row) finds, _ = _uses(row) + guard = m01.mau.guards(row) ex = ex or {} texts = ex.get("글") or [] coef = {k.lower(): Decimal(str(v)) for k, v in (ex.get("계수") or {}).items()} @@ -159,6 +160,10 @@ def space(code: str, ex: dict[str, Any] | None = None) -> list[tuple[str, list, vals.add(Decimal(0)) if name == "품": vals |= {v * times for v in ex.get("품값", set())} + if ( + name in guard + ): # 「0 = 기본」 칸 — 옛 자리표 1 도 후보(엑셀이 그 칸으로 안 나눈 블록) + vals.add(Decimal(1)) vals.discard(None) out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수")) return out @@ -205,7 +210,7 @@ def combos( 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 != "수"] + small = [(n, o) for n, o, k in sp2 if k != "수" or len(o) <= 4] # 작은 수 칸도 둘씩 짝 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] @@ -442,6 +447,37 @@ def _plain(value: Any) -> Any: return value +_BY_FORMULA = re.compile(r"만약\(\s*(\w+)\s*>\s*0\s*,\s*\1\s*,(.*)\)\s*$") + + +def by_formula(code: str, g: dict, ex: dict[str, Any]) -> dict[str, Any] | None: + """엑셀이 값으로 넣은 「0 = 식」 칸(운반회수 n 등) → {칸, 입력, 우리품}: 그 칸 0(원문 식) · + 단위 칸 = 엑셀 글에 단위 붙어 하나만 있는 수(운반거리 6m · 경사 35°) · 없으면 그대로.""" + row = ours.whole().logic(code) + steps = [m.groups() for s in row.get("중간") or [] if (m := _BY_FORMULA.match(s["식"]))] + hit = {n: g[n] for n, rest in steps if re.search(r"[*/]", rest) and _num(g.get(n)) > 0} + if not hit: + return None + tried = {**g, **{n: Decimal(0) for n in hit}} + for s in row.get("입력") or []: + n, picks = s["이름"], s.get("고르기") or [] + if n in hit or not s.get("단위"): + continue + nums = sorted(excel_numbers(ex.get("글") or [], s)) + nums = [p for p in picks if _num(p) in nums] if picks else nums + if len(nums) == 1: + tried[n] = nums[0] + one = {"칸": {n: _plain(x) for n, x in hit.items()}, "우리품": ours_qty(ours.run(code, tried))} + return one | {"입력": {n: _plain(x) for n, x in tried.items()}} + + +def _num(v: Any) -> Decimal: + try: + return Decimal(str(v)) + except ArithmeticError: + return Decimal(0) + + def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]: """로직 하나의 옵션 거꾸로 찾기 → {수준, 판정, 입력, 갈리는칸, 견줌, 우리품, 거리, 섞기전}.""" none = {"수준": 9, "판정": "없음", "입력": {}, "갈리는칸": [], "견줌": None} @@ -482,15 +518,20 @@ def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[ 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("품")} + miss = lvl == 9 and round(d, 4) >= 1.0 # 못 맞춤(e배 넘게 어긋남) — 우리 품 · 입력을 내지 않음 return { "수준": lvl, "판정": verdict, - "입력": {n: _plain(x) for n, x in g.items() if n in act or str(x) != str(auto.get(n))}, + "입력": {} + if miss + else {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), + "우리품": ours_qty(None if miss else v), + "못맞춤": miss, + "식아님": None if miss else by_formula(code, g, ex), # 엑셀 값 칸 · 원문 식이면 "거리": round(d, 4), "섞기전": lvl == 9 and bool(base.get("핵심")), "어떻게": how, diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index f46178624..af9e4aac3 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -33,7 +33,7 @@ from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums mb, mu = m01.cm.mf.mb, m01.cm.mf.mp.mm.mu # 묶음(712-1) · 단위(712-2) 한 벌 -FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 +FP_VERSION = 2 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 ROOT = Path(__file__).resolve().parents[1] _SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$") EQ_CODE = re.compile(r"^\d{4}-\d{4}$") diff --git a/M02_MasterTemplete/M02_Hopyo_Verdict.py b/M02_MasterTemplete/M02_Hopyo_Verdict.py index eddf1c5a9..1ed1cea08 100644 --- a/M02_MasterTemplete/M02_Hopyo_Verdict.py +++ b/M02_MasterTemplete/M02_Hopyo_Verdict.py @@ -21,7 +21,7 @@ from M02_MasterTemplete import M02_Hopyo_Finger as finger from M02_MasterTemplete import M02_Hopyo_Fit as fit from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Option as option - +from M02_MasterTemplete import M02_Labor_Doc as labor_doc _num = money_fmt.fmt # 꼬리표 수 — 공용 수량 꼴(1만 넘어도 지수 꼴 아님) OUTSIDE = "참고 후보 — 근거 조항 아님" @@ -68,6 +68,8 @@ def _diffs(efp: dict, found: dict) -> list[str]: a, b = x.get("품"), ours_l.get(t) if a and b and not option.near(a, b, fit.TOL[2]): tags.append(f"{t} 엑셀 {_num(a)} · 우리 {_num(b)}") + elif a and not b and found.get("못맞춤"): + tags.append(f"{t} 엑셀 {_num(a)} → –(우리 셈 조건 못 찾음)") elif a and not b: tags.append(f"{t} 엑셀만 {_num(a)}") tags += [f"{t} 우리만 {_num(q)}" for t, q in ours_l.items() if t not in efp["인력"]] @@ -80,6 +82,20 @@ def _diffs(efp: dict, found: dict) -> list[str]: return tags +def formula_tag(code: str, found: dict | None) -> str | None: + """「운반회수 42 = 엑셀 값(식 아님) · 원문 식이면 보통인부 0.17 · 30,077원(운반거리 6 · …)」.""" + f = (found or {}).get("식아님") + if not f: + return None + cells = " · ".join(f"{k} {_num(v)}" for k, v in f["칸"].items()) + qty = " · ".join(f"{t} {_num(q)}" for t, q in f["우리품"]["인력"].items()) or "–" + won = (labor_doc.run_line(option.ours.whole(), code, f["입력"]).get("기준") or {}).get("계") + won_text = f" · {money_fmt.fmt(won)}원" if won is not None else "" + mine = {**f["칸"], **((found or {}).get("입력") or {})} # 맞춘 칸 뺀 원문 식 조건 + used = " · ".join(f"{k} {_num(v)}" for k, v in f["입력"].items() if k not in mine) + return f"{cells} = 엑셀 값(식 아님) · 원문 식이면 {qty}{won_text}({used})" + + def _state(efp: dict, code: str, found: dict | None, theirs: list) -> str: lvl = 9 if (found is None or fit.single(efp)) else found["수준"] if lvl <= 1: @@ -173,7 +189,7 @@ def judge(efp: dict, top: list[str], opts: dict, theirs: list, got: dict) -> dic t1, found = top[0], opts.get(top[0]) or {} state = _state(efp, t1, opts.get(t1), theirs) exact = [k for k in top[: fit.TOP_OPT] if _state(efp, k, opts.get(k), theirs) == "맞음"] - tags = [] + tags = [t for t in [formula_tag(t1, found)] if t and state in ("맞음", "다른 점")] if state == "맞음": label = _label(t1, found.get("입력") or {}) text = " · ".join(x for x in ("맞음 — " + t1, names.get(t1, ""), label) if x) @@ -183,7 +199,7 @@ def judge(efp: dict, top: list[str], opts: dict, theirs: list, got: dict) -> dic tags.append(f"{' · '.join(found['갈리는칸'])} 못 가름") return {"상태": state, "글": text, "꼬리표": tags} if state == "다른 점": - tags = _diffs(efp, found) + tags = _diffs(efp, found) + tags return {"상태": state, "글": " · ".join(["다른 점 — " + t1, *tags[:2]]), "꼬리표": tags} return {"상태": "후보", "글": f"후보 {len(top[: fit.TOP_OPT])}", "꼬리표": []} diff --git a/resources/master_data/scripts/master_auto.py b/resources/master_data/scripts/master_auto.py index 23e8e55bf..f9940947e 100644 --- a/resources/master_data/scripts/master_auto.py +++ b/resources/master_data/scripts/master_auto.py @@ -2,12 +2,14 @@ """견본 입력 — 마스터 화면 보기용 자동값(프로젝트 기본값 아님 · 저장 안 함). 고르기 = 목록 첫째 · 범위 = 최솟값 · 찾기 조건 값으로 쓰이는 수 = 그 표 그 칸 가장 낮은 줄 값 -(범위 줄이면 그 줄에 드는 아래 끝) · 나누는 자리에 오는 수 = 1 · 그 밖 0. +(범위 줄이면 그 줄에 드는 아래 끝) · 나누는 자리에 오는 수 = 1 · 그 밖 0 +(「만약(X > 0, X, 기본)」 의 X 는 나누는 자리여도 0 = 기본). 찾기·나누기 쓰임은 로직 식(중간 · 결과 · 호표 수량·요소 · 덧줄)을 풀어 알아냄. """ from __future__ import annotations +import re from decimal import Decimal import master_formula as mf @@ -46,12 +48,21 @@ def _uses(row: dict) -> tuple[dict, set]: for cond, arg in part[2]: if arg[0] == "name": finds.setdefault(arg[1], []).append((part[1], cond)) + guard = guards(row) # 「0 = 기본」 칸은 나누는 자리로 안 퍼뜨림(자동값 0 = 기본) for step in reversed(row.get("중간", [])): if step["이름"] in divs: - divs |= _names(mf.parse(step["식"])) + divs |= _names(mf.parse(step["식"])) - guard return finds, divs +_GUARD = re.compile(r"만약\(\s*(\w+)\s*>\s*0\s*,\s*\1\s*,") + + +def guards(row: dict) -> set: + """「만약(X > 0, X, 기본)」 꼴 중간 식의 X — 0 이면 원문 기본(25㎏ · 450분 · 식 · 표).""" + return {m for s in row.get("중간", []) for m in _GUARD.findall(s["식"])} + + def _fits(master: mf.Master, ref: str, cond: str) -> list: """표 `ref` 조건 칸 `cond` 에 드는 수 — 낮은 차례 · 범위 칸이면 줄마다 그 줄에 드는 끝 (아래 끝이 열린 줄 「60 미만」 은 0).""" diff --git a/resources/tester/test_635_hopyo_ours_qty.py b/resources/tester/test_635_hopyo_ours_qty.py new file mode 100644 index 000000000..1c1322185 --- /dev/null +++ b/resources/tester/test_635_hopyo_ours_qty.py @@ -0,0 +1,185 @@ +"""784-1(요구 635 · 636 · 637) — 「우리 품」 잘못 보임 뿌리 고침 · 같은 흠 전수(느림 · 3~6 분). + +자동값 「0 = 기본」 가드 칸 0 · 단산10 야면석소운반(GC001140) 우리 품 0.547619 · 대상 하나 · 배 1 · +미리 셈 282,681 = 엑셀 · 못 맞춤(수준 9 · 거리 ≥ 1) = 우리 품 · 입력 안 냄 · +여덟 파일 1위 품 비교 10배 넘는 줄 = 알려진 예외만 · +「자재 단위」 · 공/㎥ 배 1 · 봉화 단산91 단끊기 · 골든 지킴. +읽기만 · 서버 없음 · master_data 그대로. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Line as hl +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Option as option +from M02_MasterTemplete import M02_Hopyo_Ours as ours +from M02_MasterTemplete import M02_Hopyo_Verdict as verdict +from M02_MasterTemplete import M02_Labor_Doc as labor +from resources.tester import helper_m02_hopyo_golden as golden + +FILES = ( + "북부청임도", + "북부청사방", + "거창", + "봉화", + "동부청영월", + "울진대흥", + "영덕계류", + "울진소광", +) +KNOWN_10X = {"GC000110"} # 엑셀 롤러 10.4 시간 읽기(로직 아님) + + +@pytest.fixture(scope="module", autouse=True) +def _ours_ready(): + if not ours.ready(): + ours.build() + + +def _ctx(tag: str): + got = hm.targets() + + class C: + pass + + ctx = C() + ctx.got, ctx.names = got, {x["로직"]: x for x in got["로직"]} + ctx.mats = {x["자재"]: x for x in got["자재"]} + ctx.units = {int(h["번"]): h for h in golden.read(tag)["호표"]} + return ctx + + +def _unit(tag: str, no: int) -> tuple[dict, dict]: + for _, block, line in fit.units(golden.read(tag)): + if block and "_지문" not in block and block.get("번") == no: + return block, line + raise AssertionError(f"{tag} 단산{no} 셈 단위 없음") + + +def _auto(code: str) -> dict: + whole = ours.whole() + return m01.mau.values(whole, whole.logic(code)) + + +def test_1_가드_칸_자동값_0(): + a = _auto("GC001140") + assert a["회당운반량"] == a["실작업시간"] == a["운반회수"] == 0 + assert a["운반거리"] == 1 # 가드 아닌 나눗수 칸은 1 그대로 + b = _auto("GF000156") + assert b["모래단위중량"] == b["자갈단위중량"] == 0 + + +def test_2_단산10_옵션(): + block, line = _unit("북부청임도", 10) + found = option.find("GC001140", fit_efp(block, line), block.get("글")) + assert found["우리품"]["인력"] == {"보통인부": 0.547619} + assert found["입력"]["운반무게"] == 575 and found["입력"]["운반회수"] == 42 + assert found["식아님"]["칸"] == {"운반회수": 42} # D — 엑셀 값(식 아님) + + +def fit_efp(block: dict, line: dict) -> dict: + return verdict.finger.excel(block, line) + + +def test_3_못_맞춤_우리품_안_냄(): + block, line = _unit("북부청임도", 12) + found = option.find("GF000113", fit_efp(block, line), block.get("글")) + assert found["수준"] == 9 and found["거리"] >= 1.0 + assert found["못맞춤"] and found["입력"] == {} and not found["우리품"]["인력"] + tags = verdict._diffs(fit_efp(block, line), found) + assert any("우리 셈 조건 못 찾음" in t for t in tags) + + +def _money(line: dict, targets: list[dict]) -> float | None: + rows = [ + {"로직": t["로직"], "별칭": None, "메모": None, "입력값": t["입력값"]} + | {"수량": {"손": (line.get("수량") or 0) * t["배"]}} + for t in targets + if t.get("로직") and t.get("배") is not None + ] + if not rows: + return None + doc = labor.clean({"종류": "일위대가", "열": "", "단위": None, "담은로직": rows, "자재": []}) + return (labor.calc(doc, {}).get("반영합") or {}).get("계") + + +def test_4_확인_창_줄_단산10(): + book = golden.read("북부청임도") + blocks = {int(b["번"]): b for b in book["단산"]} + h2 = next(h for h in book["호표"] if int(h["번"]) == 2) + line = next(x for x in h2["줄"] if (x.get("참조") or {}).get("번") == 10) + m = hl._numeric(line, _ctx("북부청임도"), verdict.block(blocks[10], line, hm.targets())) + assert [t["로직"] for t in m["대상"]] == ["GC001140"] and m["대상"][0]["배"] == 1 + assert m["대상"][0]["출처"]["운반회수"] == "엑셀 값 · 식 아님" + assert m["품"]["우리"]["인력"] == {"보통인부": 0.547619} and m["품"]["같음"]["보통인부"] + assert _money(line, m["대상"]) == 282681 == line["금액"]["계"] + + +def _ratios(ex: dict, us: dict) -> list[float]: + out = [us["인력"][t] / a for t, a in ex["인력"].items() if a and us["인력"].get(t)] + sums: dict[str, list[float]] = {} + for x in ex["장비"]: + sums.setdefault("+".join(sorted(x["갈래"])), [0.0, 0.0])[0] += x["시간"] + for x in us["장비"]: + if (one := sums.get("+".join(sorted(x["갈래"])))) is not None: + one[1] += x["시간"] + return out + [b / a for a, b in sums.values() if a and b] + + +def test_5_여덟_파일_전수(): + far, leak, double = set(), [], [] + for tag in FILES: + ctx = _ctx(tag) + for key, block, line in fit.units(golden.read(tag)): + entry = verdict.block(block, line, ctx.got) + if not entry.get("차례") or key.startswith("호표:"): + continue + top = entry["차례"][0] + found = (entry.get("옵션") or {}).get(top) or {} + m = hl._numeric(line, ctx, entry) + if any(r > 10 or r < 0.1 for r in _ratios(m["품"]["엑셀"], m["품"]["우리"])): + far.add(top) + if m["상태"] == "다른 점" and found.get("못맞춤"): + leak += [key for t in m["대상"] if "블록" in t["출처"].values()] + parts = (entry["지문"].get("몫") if "몫" in entry["지문"] else None) or [] + groups = {str(p.get("절") or p.get("제목") or "") for p in parts} + if len(groups) == 1 and len([c for c in entry.get("조합") or [] if c["체크"]]) > 1: + double.append(key) + assert far <= KNOWN_10X, far + assert not leak and not double + + +def test_6_배_자재_단위_표기(): + assert hm.factor("㎡", "자재") == {"배": 1, "갈래": "자재 단위"} + assert hm.factor("공/㎥", "공㎥")["배"] == 1 + assert hm.factor("m", "본")["배"] is None # 단위 환산 필요한 줄은 그대로 사용자 몫 + + +def test_6_규격_읽기_거리_cm_안_넣음(): + vals, _ = hm.spec_inputs( + {"이름": "야면석소운반", "규격": "L=35cm", "단위": "㎡"}, hm._logic_specs("GC001140") + ) + assert "운반거리" not in vals and "경사각도" not in vals + vals, _ = hm.spec_inputs( + {"이름": "x", "규격": "25mm", "단위": "㎥"}, hm._logic_specs("GF000156") + ) + assert vals.get("골재최대치수") == Decimal(25) or vals.get("골재최대치수") == 25 + + +def test_7_봉화_단산91_단끊기(): + block, line = _unit("봉화", 91) + entry = verdict.block(block, line, hm.targets()) + assert entry["차례"][0] == "GF000298" and entry["판정"]["상태"] == "맞음" + + +def test_7_골든_지킴(): + res = golden.evaluate() + people, new = golden.metrics(res, {"사람"}), golden.metrics(res, {"자동", "씨앗", "자동+씨앗"}) + assert people["1순위"] + new["1순위"] >= 163 + assert people["다른 점 틀림"] + new["다른 점 틀림"] <= 3