diff --git a/M02_MasterTemplete/M02_Hopyo_Fit.py b/M02_MasterTemplete/M02_Hopyo_Fit.py index 3d74d5389..78761091e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fit.py +++ b/M02_MasterTemplete/M02_Hopyo_Fit.py @@ -54,6 +54,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 = 1 # 맞춤셈 꼴 · 셈이 바뀌면 올림 — 옛 캐시 안 씀 COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"} HOUR = {"hr", "시간", "h"} NO_NUMBER = "숫자 판정 없음" @@ -296,7 +297,7 @@ def _hp_path(folder: Path, key: str) -> Path: def cache_path(folder: Path, key: str) -> Path: hp = hashlib.sha256(_hp_path(folder, key).read_bytes()).hexdigest()[:16] - return ours.folder() / f"맞춤_{hp}_{ours.version()}.json" + return ours.folder() / f"맞춤_{hp}_{ours.version()}_{CALC_VERSION}.json" def units(doc: dict[str, Any]) -> list[tuple[str, dict | None, dict]]: diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py new file mode 100644 index 000000000..2d77dd5e5 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -0,0 +1,323 @@ +"""M02 일위대가 실무자료 줄 맞춤 응답(PLAN 338-1 · 설계 3-3 · 3-4) — 줄 상태 · 체크 제안 · 대상 목록 +· +후보 · 품 비교 · 호표 통째 · 셈 · 만든 · 미리 셈(`guess`). + +상태 차례 = ① % ② 호표 참조(만들 층에 확인된 사전조합이면 체크) ③ 노임(그릇 없음) ④ 장부 「우리에 +없음 · +안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(규격 다름 · 근거 다름 · 숫자 판정 맞음이 다른 로직이면 체크 안 함) ⑥ +맞춤셈 +판정(맞음 = 체크 · 다른 점 · 후보) ⑦ 셈 전 · 숫자 없음 → 이름 · 근거 후보(「셈 중」 · 「숫자 판정 +없음」). +자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. +입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. +체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. +장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +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_Store as hopyo_store +from M02_MasterTemplete import M02_Hopyo_Verdict as verdict +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +SKIP = ("우리에 없음", "안 씀") + + +class Ctx: + """보기 한 번 — 파일 · 장부 · 맞춤셈 · 확인된 사전조합을 한 번 읽어 줄마다 넘김.""" + + def __init__(self, folder: Path, key: str, to: Path | None = None) -> None: + self.folder, self.key = Path(folder), key + self.to = Path(to or folder) + self.doc = hopyo_store.read(folder, key) + self.got = hm.targets() + self.units = {int(h["번"]): h for h in self.doc.get("호표") or []} + self.books = ledger.books(self.to) + self.calc = fit.ensure(folder, key) + self.cache = (fit.result(folder, key) or {}).get("블록") or {} + self.done = hopyo_build.confirmed(self.to, refs.where(self.folder)) + self.names = {x["로직"]: x for x in self.got["로직"]} + self.mats = {x["자재"]: x for x in self.got["자재"]} + + +def _target(code: str, line: dict, ctx: Ctx, found: dict | None, use: bool, **extra) -> dict: + """대상 하나(로직) — 입력값 · 못읽음 · 출처 · 환산 · 몫 · 배.""" + one = ctx.names.get(code) or {"이름": "", "단위": ""} + vals, unread = hm.spec_inputs(line, hm._logic_specs(code)) + src = {k: "규격" for k in vals} + for k, v in ((found or {}).get("입력") or {}).items() if use else (): + if str(vals.get(k)) != str(v): + vals[k], src[k] = v, "블록" + conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) + share = extra.pop("몫", None) + times = conv["배"] * share if conv["배"] is not None and share else conv["배"] + head = {"로직": code, "이름": one["이름"], "단위": one["단위"], "입력값": vals} + head.update(못읽음=[u for u in unread if u not in vals], 출처=src, 환산=conv) + return {**head, "몫": share, "배": times, **extra} + + +def _pair(pair: dict, line: dict, ctx: Ctx) -> dict: + """장부 짝 → 대상(입력값 출처 = 별칭).""" + vals = dict(pair.get("입력값") or {}) + if pair.get("로직"): + one = ctx.names.get(pair["로직"]) or {"이름": "", "단위": ""} + head = {"로직": pair["로직"]} + elif pair.get("자재"): + one, head = ctx.mats.get(pair["자재"]) or {"이름": "", "단위": ""}, {"자재": pair["자재"]} + else: + h = ctx.units.get(int(pair.get("호표") or 0)) or {} + one, head = ( + {"이름": h.get("이름", ""), "단위": h.get("단위", "")}, + {"호표": pair.get("호표")}, + ) + conv = {"배": pair.get("배"), "갈래": "별칭"} + return {**head, "이름": one["이름"], "단위": one["단위"], "입력값": vals, "못읽음": []} | { + "출처": {k: "별칭" for k in vals}, + "환산": conv, + "몫": pair.get("몫"), + "배": pair.get("배"), + } + + +def _qty(entry: dict, found: dict | None) -> dict: + """품 비교 — 엑셀 · 우리 · 같음(직종 · 장비 갈래마다 R 허용).""" + ex = entry.get("품") or {"인력": {}, "장비": [], "자재": []} + us = (found or {}).get("우리품") or option.ours_qty(None) + same = {} + for t, a in ex["인력"].items(): + b = us["인력"].get(t) + same[t] = bool(b) and option.near(a, b, fit.TOL[0], option.ru(a)) + for m in ex["장비"]: + mine = [x["시간"] for x in us["장비"] if set(x["갈래"]) & set(m["갈래"])] + hit = any(option.near(m["시간"], q, fit.TOL[0], option.ru(m["시간"])) for q in mine) + same["·".join(m["갈래"])] = hit or same.get("·".join(m["갈래"]), False) + return {"엑셀": ex, "우리": us, "같음": same} + + +def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict: + """⑥ 맞춤셈 판정 → 상태 · 대상 · 후보 · 품 · 글 · 꼬리표.""" + judged = entry["판정"] + state = judged["상태"] + top = (entry.get("차례") or [None])[0] + found = (entry.get("옵션") or {}).get(top) + use = state in ("맞음", "다른 점") + combo = [c for c in entry.get("조합") or [] if c["체크"]] + combo = combo if len(combo) > 1 else [] # 체크할 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표) + if combo: + targets = [ + _target( + c["로직"], line, ctx, entry["옵션"].get(c["로직"]), use, 몫=c["몫"], 어디=c["어디"] + ) + for c in combo + ] + else: + targets = [_target(top, line, ctx, found, use)] if top else [] + tags = list(judged.get("꼬리표") or []) + tags += [f"조합 — {' + '.join(c['로직'] for c in combo)}"] if combo else [] + for c in entry.get("조합") or []: + if not c["체크"]: + tags.append(f"기계 몫 {c['로직']} — 시간 = 엑셀 값(우리 셈 아님)") + if any("블록" in t["출처"].values() for t in targets): + tags.append("옵션 — 블록에서") + tags += [f"근거 {b} {n}" for b, n in (entry.get("지문") or {}).get("출처") or []] + ok = all(t["배"] is not None and t["환산"]["갈래"] != "추정" for t in targets) + out = {"상태": state, "체크": state == "맞음" and bool(targets) and ok, "대상": targets} + out.update(후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found)) + if found and found.get("갈리는칸"): + out["갈리는칸"] = found["갈리는칸"] + return out + + +def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict: + """⑦ 숫자 판정 없음 · 셈 전 — 이름 · 근거 후보(차례만 · 대상 = 1위).""" + if not full: + return {"상태": "후보", "체크": False, "대상": [], "후보": [], "글": why, "꼬리표": [why]} + order = hm.b_order(line, ctx.got) + top = order["top"] + cands = [{**c, "판정": "후보", "글": why} for c in order["후보"]] + targets = [_target(top[0], line, ctx, None, False)] if top else [] + state = "후보" if top else "없음" + return { + "상태": state, + "체크": False, + "대상": targets, + "후보": cands, + "글": why, + "꼬리표": [why], + } + + +def _material(line: dict, ctx: Ctx, full: bool) -> dict: + found = hm._exact(line, "자재", ctx.got) + if len(found) == 1: + one = found[0] + conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) + target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} + target.update(몫=None, 배=conv["배"]) + ok = conv["배"] is not None and conv["갈래"] != "추정" + return {"상태": "맞음", "체크": ok, "대상": [target], "후보": [], "글": "이름 · 규격 같음"} + cands = [{**c, "판정": "후보"} for c in hm.candidates(line, "자재", ctx.got, 5)] if full else [] + return { + "상태": "후보" if cands or not full else "없음", + "체크": False, + "대상": [], + "후보": cands, + } + + +def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: + """줄 → 맞춤(설계 3-3) — `full` 아니면 상태 · 체크만(호표 목록 수).""" + out: dict[str, Any] = {"열쇠": hm.line_key(line), "체크": False, "대상": [], "후보": []} + kind = finger.kind_of(line) + ref = line.get("참조") or {} + if kind == "%": + return {**out, "상태": "%", "글": "% 줄 — 우리 로직 덧줄이 셈"} + if kind == "호표" or ref.get("종류") == "일위": + no = int(ref.get("번") or 0) + h = ctx.units.get(no) or {} + conv = hm.factor(line.get("단위"), h.get("단위"), line.get("규격")) + made = ctx.done.get(hopyo_build.mark(ctx.key, no)) + target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}} + target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"]) + ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정" + text = f"확인된 사전조합 {made}" if made else f"제{no}호표 먼저 확인" + return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text} + if kind == "노임": + return {**out, "상태": "노임", "글": "노임 · 그릇 없음"} + alias = ledger.recall(line, ctx.to, ctx.books) + entry = ctx.cache.get(fit.unit_key(line)) if kind not in hm.MATERIAL_KINDS else None + if alias and alias.get("갈래") in SKIP: + return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} + if alias and alias.get("갈래") == "짝" and alias.get("짝"): + return {**out, **_aliased(line, ctx, alias, entry)} + if kind in hm.MATERIAL_KINDS: + return {**out, **_material(line, ctx, full)} + if entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): + return {**out, **(_numeric(line, ctx, entry) if full else _brief(entry))} + why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER + return {**out, **_names_only(line, ctx, why, full)} + + +def _brief(entry: dict) -> dict: + state = entry["판정"]["상태"] + return {"상태": state, "체크": state == "맞음", "글": entry["판정"]["글"]} + + +def _alias_info(alias: dict, basis_off: bool) -> dict: + keep = ("층", "주인", "때", "누가", "처음파일", "규격다름") + return {**{k: alias.get(k) for k in keep}, "근거다름": basis_off} + + +def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict: + """⑤ 장부 짝 — 체크 = 규격 같음 · 근거 같음 · 숫자 판정 맞음이 다른 로직 아님.""" + targets = [_pair(p, line, ctx) for p in alias["짝"]] + codes = {t["로직"] for t in targets if t.get("로직")} + theirs = ((line.get("근거") or {}).get("출처")) or [] + table = hopyo_basis.ours() + off = bool(theirs and codes) and all( + hm.clause(theirs, table[c])[0] < 0.9 for c in codes if c in table + ) + other = bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes) + check = not alias.get("규격다름") and not off and not other + tags = ["별칭 — " + " · ".join(str(x) for x in (alias.get("층"), alias.get("때")) if x)] + tags += ["규격 다름"] * bool(alias.get("규격다름")) + ["근거 다름"] * off + tags += [f"숫자 판정 맞음 = {entry['차례'][0]}"] if other else [] + out = {"상태": "별칭", "체크": check, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags} + out["별칭"] = _alias_info(alias, off) + if entry and entry.get("품"): + out["품"] = _qty(entry, (entry.get("옵션") or {}).get(next(iter(codes), None))) + return out + + +def whole(hopyo: dict, ctx: Ctx) -> dict | None: + """직접 줄 호표 통째 제안 — 장부 짝(호표 열쇠) 먼저 · 맞춤셈 「호표:번」.""" + head = {k: hopyo.get(k) for k in ("이름", "규격", "단위")} + alias = ledger.recall(head, ctx.to, ctx.books, whole=True) + if alias and alias.get("갈래") == "짝" and alias.get("짝"): + return { + "대상": [_pair(p, head, ctx) for p in alias["짝"]], + "글": "별칭 장부 짝", + "체크": True, + } + entry = ctx.cache.get(f"호표:{hopyo['번']}") + if not entry or not entry.get("차례"): + return None + top = entry["차례"][0] + state = entry["판정"]["상태"] + target = _target(top, head, ctx, entry["옵션"].get(top), state in ("맞음", "다른 점")) + return {"대상": [target], "글": f"통째 = {entry['판정']['글']}", "체크": state == "맞음"} + + +def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: + """호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 통째 · 셈 · 만든.""" + lines = [{**one, "맞춤": match_line(one, ctx, full)} for one in hopyo.get("줄") or []] + check = sum(1 for one in lines if one["맞춤"]["체크"]) + out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)]} + if full: + made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"]))) + out.update(통째=whole(hopyo, ctx), 셈=ctx.calc) + out["만든"] = {"키": made, "확인": True} if made else None + return out + + +def guess( + folder: Path, key: str, no: int, at: int, target: Any, to: Path | None = None +) -> dict[str, Any]: + """미리 셈(저장 안 함) — 고른 대상 · 옵션으로 엔진 한 번 → {대상, 품: {엑셀, 우리, 같음}, + 글}.""" + ctx = Ctx(folder, key, to) + hopyo = ctx.units.get(int(no)) + lines = (hopyo or {}).get("줄") or [] + if not 0 <= int(at) < len(lines): + raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」") + line = lines[int(at)] + picked = target if isinstance(target, list) else [target] + if not picked or not all(isinstance(t, dict) for t in picked): + raise store.StoreError(400, "대상 꼴이 틀림") + entry = ctx.cache.get(fit.unit_key(line)) + if entry is None: + block = next( + ( + b + for b in ctx.doc.get("단산") or [] + if b.get("번") == (line.get("참조") or {}).get("번") + ), + None, + ) + entry = {"품": verdict.excel_qty(finger.excel(block, line))} + targets, total = [], {"L": {}, "X": [], "M": {}, "S": {}} + for t in picked: + if t.get("로직"): + if t["로직"] not in ctx.names: + raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") + given = t.get("입력값") + one = _target(t["로직"], line, ctx, None, False, 몫=t.get("몫")) + if isinstance(given, dict): + one["입력값"] = {**one["입력값"], **given} + one["출처"].update({k: "고름" for k in given}) + v = ours.run(t["로직"], one["입력값"]) or {"L": {}, "X": [], "M": {}, "S": {}} + k = float(t.get("몫") or 1) + for name, q in v["L"].items(): + total["L"][name] = total["L"].get(name, 0.0) + q * k + total["X"] += [[a, b, q * k] for a, b, q in v["X"]] + for name, q in v["M"].items(): + total["M"][name] = total["M"].get(name, 0.0) + q * k + targets.append(one) + elif t.get("자재") in ctx.mats: + targets.append(_pair({"자재": t["자재"], "배": t.get("배")}, line, ctx)) + else: + raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") + qty = _qty(entry, {"우리품": option.ours_qty(total)}) + return {"대상": targets, "품": qty, "글": "우리 품 — 엔진 한 번(저장 안 함)"} diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index d1dd71ef1..3bc65074d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -1,18 +1,14 @@ -"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산. +"""M02 일위대가 실무자료 맞춤(PLAN 324-2 · 338) — 엑셀 구성 줄 → 우리 로직 · 자재 · 입력값 · 환산. -차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) · -씨앗(일위대가조합.json 메모 · 읽기만) -③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · -뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — -+ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 · -로직 키로 합쳐 점수 더함) · -③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. -줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / -% = 안 들어감(로직 덧줄이 셈). +이름 · 근거 차례(`b_order` · 338 설계 2-4 B) = 이름 같음(정규화) · 씨앗(일위대가조합.json 메모 · +읽기만)이 1위 · 단 엑셀 인용 조항이 다른 로직을 가리키면(근거 검사 · 옛 판 글이면 그대로) 후보 · +그 밖 = 이름 후보(글자 두 개 묶음 + 단위 + 산림 덤 · 뜻 낱말 안 겹치면 뺌 · 로직은 접지 않음) + +근거 후보(`M02_Hopyo_Basis` · 조항 같음 · 윗마디 · 직종) 합침 — 숫자 맞춤(`M02_Hopyo_Fit`)의 덤. +줄 · 호표 맞춤 응답(상태 · 체크 제안 · 대상 · 품 비교) = `M02_Hopyo_Line` · 별칭 장부 = 다 창. 규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) · 이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시. 환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B · -m→㎥ × B × T = 「추정」(확정 뒤에만) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력. +m→㎥ × B × T = 「추정」(체크 안 함) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력. M01 은 읽기만(로직 별칭 안 씀). """ @@ -21,7 +17,6 @@ from __future__ import annotations import json import re import unicodedata -from datetime import datetime from decimal import Decimal from functools import lru_cache from pathlib import Path @@ -41,7 +36,6 @@ mm = mp.mm SEED_NAME = "일위대가조합.json" LOGIC_KINDS = ("단가산출", "기계", "기타") MATERIAL_KINDS = ("자재", "별도계상") -READY = ("기억", "같음", "호표") MACH_WORDS = ( ("불도우저", "불도저"), ("굴삭기", "굴착기"), @@ -377,41 +371,108 @@ def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool: def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict: - """이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15]}` — 이름 후보 + - 근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) · - 옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리.""" + """이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15], 후보: [5], + 근거: {로직: 근거}}` — 이름 후보 + 근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 + 다른 로직을 가리키면 후보) · 옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리.""" got = got or targets() basis_ = dict(line.get("근거") or {}) if basis_.get("출처"): line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}} basis = hopyo_basis.candidates(line, got) named = candidates(line, "로직", got, None) - merged = [c.get("로직") for c in hopyo_basis.merge(basis, named) if c.get("로직")] + merged_c = [c for c in hopyo_basis.merge(basis, named) if c.get("로직")] + merged = [c["로직"] for c in merged_c] + why = {c["로직"]: c["근거"] for c in merged_c if c.get("근거")} + by = {c["로직"]: c for c in merged_c} + pub = {x["로직"]: x for x in got["로직"]} + + def done(state: str, top: list[str]) -> dict: + cands = [by.get(k) or {**_public(pub[k]), "점수": None} for k in top[:5] if k in pub] + return {"상태": state, "top": top[:5], "합침": merged[:15], "후보": cands, "근거": why} + found = _exact(line, "로직", got) if len(found) == 1: tgt = found[0]["로직"] theirs = (line.get("근거") or {}).get("출처") or [] our = hopyo_basis.ours().get(tgt) if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs: - return {"상태": "같음", "top": [tgt], "합침": merged[:15]} + return done("같음", [tgt]) if our and clause(theirs, our)[0] >= 0.9: - return {"상태": "같음", "top": [tgt], "합침": merged[:15]} - strong = [c for c in basis if c["근거"]["까닭"].startswith("조항 같음")] + return done("같음", [tgt]) + strong = [c for c in basis if c["근거"]["까닭"].startswith(("조항 같음", "윗마디 같음"))] strong = [c for c in strong if _strong_basis(line, c)] if not strong: - return {"상태": "같음", "top": [tgt], "합침": merged[:15]} + return done("같음", [tgt]) top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]])) - return {"상태": "후보", "top": top[:5], "합침": merged[:15]} + return done("후보", top) top = [x["로직"] for x in found] - rest = [c for c in hopyo_basis.merge(basis, named) if c.get("로직") not in top] + rest = [c for c in merged_c if c["로직"] not in top] first = next((c.get("로직") for c in named if c.get("로직") not in top), None) top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))] - return {"상태": "후보" if top else "없음", "top": top[:5], "합침": merged[:15]} + return done("후보" if top else "없음", top) + + +# ── 줄 · 호표 맞춤(338 · 설계 3-3 · 3-4 — 몸은 `M02_Hopyo_Line`) ───────────────── +def _logic_specs(code: str) -> list[dict[str, Any]]: + got = labor_inputs.logic_inputs([code])["로직"] + return got[0]["입력"] if got else [] + + +def _line_mod(): + from M02_MasterTemplete import M02_Hopyo_Line as line_mod # 줄 맞춤이 이 파일을 부름 + + return line_mod + + +def units_of(doc: dict[str, Any]) -> dict[int, str]: + return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []} + + +def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) -> dict[str, Any]: + """실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · 있으면 그 호표 줄마다 맞춤(3-3) + · + `to` = 만들 층(장부 · 확인된 사전조합 · 없으면 파일 층) · 맞춤셈이 없으면 뒤 셈을 넣음.""" + lines = _line_mod() + ctx = lines.Ctx(folder, key, to) + doc = ctx.doc + if no is None: + hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []] + keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행") + brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos] + ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] + head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc} + return {**head, "호표": brief, "맞춤": ready} + hopyo = ctx.units.get(int(no)) + if hopyo is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + return {**hopyo_store.brief(doc), "호표": lines.hopyo_view(hopyo, ctx)} + + +def guess( + folder: Path, key: str, no: int, at: int, target: Any, to: Path | None = None +) -> dict[str, Any]: + """미리 셈(3-4 · 저장 안 함) — 대상 목록(낱개도 받음) → {대상, 품, 글}.""" + return _line_mod().guess(folder, key, no, at, target, to) + + +def picks(folder: Path) -> list[dict[str, Any]]: + """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 [체크 제안 수, 줄 수] + (장부 · 확인된 사전조합 = 이 층).""" + lines, out = _line_mod(), [] + for one in chain(folder): + layer, owner = refs.where(one) + for row in hopyo_store.rows(one): + ctx = lines.Ctx(one, row["키"], folder) + for hopyo in ctx.doc.get("호표") or []: + ready = lines.hopyo_view(hopyo, ctx, False)["맞춤"] + head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]} + keep = ("번", "이름", "규격", "단위") + out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready}) + return out -# ── 층 기억 ─────────────────────────────────────────────────────────── def chain(folder: Path) -> list[Path]: - """기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" + """실무자료를 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" layer, owner = refs.where(folder) out = [Path(folder)] if layer == "personal": @@ -419,196 +480,3 @@ def chain(folder: Path) -> list[Path]: if layer in ("personal", "company"): out.append(Path(store.FOLDER)) return out - - -def memories(folder: Path) -> list[dict[str, dict[str, Any]]]: - return [hopyo_store.memory(one) for one in chain(folder)] - - -def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None: - key = line_key(line) - return next((one[key] for one in memo if key in one), None) - - -# ── 줄 하나 맞춤 ─────────────────────────────────────────────────────── -def _logic_specs(code: str) -> list[dict[str, Any]]: - got = labor_inputs.logic_inputs([code])["로직"] - return got[0]["입력"] if got else [] - - -def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None: - if remembered.get("로직"): - return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None) - if remembered.get("자재"): - return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None) - return None - - -def match_line( - line: dict[str, Any], - memo: list[dict[str, dict[str, Any]]], - units: dict[int, str], - full: bool = True, - got: dict[str, Any] | None = None, -) -> dict[str, Any]: - """줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` — - `units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만).""" - out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []} - kind = line.get("갈래") - pool = "자재" if kind in MATERIAL_KINDS else "로직" - remembered = recall(line, memo) - got = got or targets() - if kind == "%": - return {**out, "상태": "%", "환산": None, "들어감": False} - if remembered and remembered.get("빼기"): - return {**out, "상태": "빼기", "환산": None, "들어감": False} - if kind == "호표": - no = int((line.get("참조") or {}).get("번") or 0) - conv = factor(line.get("단위"), units.get(no), line.get("규격")) - target = {"호표": no, "단위": units.get(no)} - ready = no in units and conv["배"] is not None and conv["갈래"] != "추정" - return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready} - if remembered and (target := _target_of(remembered, got)): - conv = {"배": remembered.get("환산"), "갈래": "기억"} - state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}} - return {**out, **state, "환산": conv, "들어감": conv["배"] is not None} - found = _exact(line, pool, got) - if len(found) == 1: - target = found[0] - conv = factor(line.get("단위"), target["단위"], line.get("규격")) - out.update(상태="같음", 대상=_public(target), 환산=conv) - out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정" - if full and "로직" in target: - out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"])) - return out - out.update(상태="후보" if found else "없음", 환산=None, 들어감=False) - if not full: - return out - top = [{**_public(x), "점수": 9} for x in found] - same = [_public(x) for x in found] - basis = hopyo_basis.candidates(line, got) if pool == "로직" else [] - named = candidates(line, pool, got, None if basis else 5) - rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same] - first = next((c.get("로직") for c in named if _public(c) not in same), None) - out["후보"] = top[:5] + hopyo_basis.pick(rest, first if basis else None, 5 - len(top)) - return {**out, "상태": "후보" if out["후보"] else "없음"} - - -def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]: - """후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기).""" - line = _line(folder, key, no, at) - found = _target_of(target if isinstance(target, dict) else {}, targets()) - if found is None: - raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") - target = _public(found) - conv = factor(line.get("단위"), target.get("단위"), line.get("규격")) - if not target.get("로직"): - return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv} - values, unread = spec_inputs(line, _logic_specs(str(target["로직"]))) - return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv} - - -def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]: - doc = hopyo_store.read(folder, key) - hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) - lines = (hopyo or {}).get("줄") or [] - if not 0 <= int(at) < len(lines): - raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」") - return lines[int(at)] - - -def match_hopyo( - hopyo: dict[str, Any], - memo: list[dict[str, dict[str, Any]]], - units: dict[int, str], - full: bool = True, - got: dict[str, Any] | None = None, -) -> dict[str, Any]: - """호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수].""" - got = got or targets() - lines = [ - {**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or [] - ] - ready = sum(1 for one in lines if one["맞춤"]["들어감"]) - return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]} - - -def units_of(doc: dict[str, Any]) -> dict[int, str]: - return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []} - - -def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: - """실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보.""" - doc = hopyo_store.read(folder, key) - memo, units = memories(folder), units_of(doc) - if no is None: - got = targets() - hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] - keep = ("번", "이름", "규격", "단위", "금액", "맞춤") - brief = [{k: h.get(k) for k in keep} for h in hopyos] - ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] - return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} - hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) - if hopyo is None: - raise store.StoreError(404, f"없는 호표 「제{no}호표」") - return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)} - - -def picks(folder: Path) -> list[dict[str, Any]]: - """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m - (이 층 기억 · 만드는 층).""" - memo, got, out = memories(folder), targets(), [] - for one in chain(folder): - layer, owner = refs.where(one) - for row in hopyo_store.rows(one): - doc = hopyo_store.read(one, row["키"]) - units = units_of(doc) - for hopyo in doc.get("호표") or []: - ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"] - head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]} - keep = ("번", "이름", "규격", "단위") - out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready}) - return out - - -# ── 확정 저장 ───────────────────────────────────────────────────────── -def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]: - """확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`. - 로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄).""" - if not isinstance(entries, dict) or not entries: - raise store.StoreError(400, "확정할 줄이 없음") - got = targets() - logics = {x["로직"] for x in got["로직"]} - mats = {x["자재"] for x in got["자재"]} - stamp = datetime.now().isoformat(timespec="seconds") - clean: dict[str, Any] = {} - for key, raw in entries.items(): - if raw is None: - clean[str(key)] = None - continue - if not isinstance(raw, dict) or str(key).count("|") != 2: - raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」") - if raw.get("빼기") is True: - clean[str(key)] = {"빼기": True, "확정": stamp} - continue - code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "") - if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat): - raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」") - times = raw.get("환산") - number = None if isinstance(times, bool) else _number(times) - if number is None or number <= 0: - raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」") - values = raw.get("입력값") or {} - if not isinstance(values, dict): - raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」") - head = {"로직": code} if code else {"자재": mat} - clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp} - return {"기억": hopyo_store.remember(folder, clean)} - - -def _number(raw: Any) -> Decimal | None: - try: - got = Decimal(str(raw).replace(",", "").strip()) - except ArithmeticError: - return None - return got if got.is_finite() else None diff --git a/M02_MasterTemplete/M02_Hopyo_Verdict.py b/M02_MasterTemplete/M02_Hopyo_Verdict.py index 0539033e0..d7020eb8b 100644 --- a/M02_MasterTemplete/M02_Hopyo_Verdict.py +++ b/M02_MasterTemplete/M02_Hopyo_Verdict.py @@ -3,6 +3,7 @@ 블록 + 줄 → 엑셀 지문(`Finger`) → 이름 · 근거 차례(`Match.b_order`) → 지문 순위 섞기(`Fit`) · 같음 1위 고정(딱 맞음이 이김) → 섞인 줄 몫 먼저 · 조합(`Combine.solve`) → 위 다섯 옵션 거꾸로(`Option`) → +위 다섯 가운데 딱 맞음(옵션까지)이 있는데 1위가 아니면 그것이 1위(조합 없는 줄) → 판정 맞음 · 다른 점 · 후보 + 후보 다섯(로직마다 옵션 판정 꼬리표). 글 · 꼬리표는 숫자 사실만(엑셀 · 우리 품) — 「옛 판」 같은 까닭은 짓지 않음. """ @@ -117,6 +118,7 @@ def block(block_: dict | None, line: dict[str, Any], got: dict[str, Any]) -> dic top = [first] + [k for k in fused if k != first][:4] else: top = fused + entry["지문차례"] = top[: fit.TOP_OPT] # 조합 · 딱 맞음 먼저 전(시험 지표) parts = efp.get("몫") or [] if parts and btop and combine.is_mixed(parts, line): # 섞인 줄 — 이름 · 근거 차례로 몫 먼저 top = btop[: fit.TOP_OPT] @@ -128,6 +130,11 @@ def block(block_: dict | None, line: dict[str, Any], got: dict[str, Any]) -> dic pfp = _part_fp(part, c["몫"], line) if part else efp opts[c["로직"]] = option.find(c["로직"], pfp, text) if pfp["인력"] else None theirs = efp["출처"] + exact = [k for k in top[: fit.TOP_OPT] if _state(efp, k, opts.get(k), theirs) == "맞음"] + if ( + not combo and exact and exact[0] != top[0] + ): # 딱 맞음 먼저(K5) — 1위가 못 맞추면 위 다섯 딱 맞음 + top = [exact[0]] + [k for k in top if k != exact[0]] chans = {k: ch for _, k, ch, _ in pool} entry["순위"] = [ { @@ -142,7 +149,7 @@ def block(block_: dict | None, line: dict[str, Any], got: dict[str, Any]) -> dic entry["옵션"] = opts entry["조합"] = combo or None entry["판정"] = judge(efp, top, opts, theirs, got) - entry["후보"] = candidates(efp, top, opts, theirs, got, scores) + entry["후보"] = candidates(efp, top, opts, theirs, got, scores, border.get("근거") or {}) return entry @@ -170,7 +177,7 @@ def judge(efp: dict, top: list[str], opts: dict, theirs: list, got: dict) -> dic def candidates( - efp: dict, top: list[str], opts: dict, theirs: list, got: dict, scores: dict + efp: dict, top: list[str], opts: dict, theirs: list, got: dict, scores: dict, why: dict ) -> list[dict[str, Any]]: """후보 다섯 — 로직마다 옵션 판정 꼬리표 · 「맞음 둘」이면 둘째 맞음이 맨 앞.""" by = {x["로직"]: x for x in got["로직"]} @@ -180,8 +187,10 @@ def candidates( found = opts.get(k) or {} state = _state(efp, k, opts.get(k), theirs) our = table.get(k) - sc, clauses, why = hopyo_match.clause(theirs, our) if (our and theirs) else (0, [], "") + sc, clauses, tag = hopyo_match.clause(theirs, our) if (our and theirs) else (0, [], "") one = by.get(k) or {"로직": k, "이름": "", "단위": ""} + unit = hopyo_match.fits(efp["단위"], one["단위"]) + basis = why.get(k) or {"조항": clauses, "직종": [], "단위": unit, "점수": sc, "까닭": tag} text = state if state != "다른 점" else " · ".join(["다른 점", *_diffs(efp, found)[:1]]) out.append( { @@ -191,7 +200,7 @@ def candidates( "점수": round(scores[k], 3) if k in scores else None, "판정": state, "글": text, - "근거": {"조항": clauses, "까닭": why}, + "근거": basis, } ) first = [c for c in out[1:] if c["판정"] == "맞음"][:1] diff --git a/resources/tester/helper_m02_hopyo_golden.py b/resources/tester/helper_m02_hopyo_golden.py index 7ced12420..96a51a3da 100644 --- a/resources/tester/helper_m02_hopyo_golden.py +++ b/resources/tester/helper_m02_hopyo_golden.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import sys import time +import warnings from functools import lru_cache from pathlib import Path from typing import Any @@ -24,6 +25,7 @@ from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402 +warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") DOCS = ROOT / "resources/knowledge/original/실무문서" FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_golden.json" FILES = { @@ -84,15 +86,24 @@ def read(tag: str) -> dict[str, Any] | None: return book -def _key(line: dict[str, Any]) -> tuple: +def _keys(line: dict[str, Any]) -> list[tuple]: + """줄 → 짝 열쇠 후보 — (이름 · 규격 · 단위 · 이음) · 이음 = 단산 번 · 줄 코드 · 없음.""" ref = line.get("참조") or {} - no = ref.get("번") if ref.get("종류") == "단산" else None - code = str(ref.get("코드") or line.get("코드") or "") - return line.get("이름"), line.get("규격") or "", line.get("단위") or "", no, code[:6] + head = (line.get("이름"), line.get("규격") or "", line.get("단위") or "") + code = str(ref.get("코드") or line.get("코드") or "")[:6] + out = [(*head, ("단산", ref["번"]))] if ref.get("종류") == "단산" else [] + return out + ([(*head, ("코드", code))] if code else []) + [(*head, None)] def _lab_key(lab: dict[str, Any]) -> tuple: - return lab["이름"], lab["규격"], lab["단위"], lab.get("단산"), str(lab.get("코드") or "")[:6] + head = (lab["이름"], lab["규격"], lab["단위"]) + if lab.get("단산") is not None: + return (*head, ("단산", lab["단산"])) + return (*head, ("코드", str(lab["코드"])[:6]) if lab.get("코드") else None) + + +def _find(want: dict[tuple, dict], line: dict[str, Any]) -> dict | None: + return next((want[k] for k in _keys(line) if k in want), None) def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]: @@ -102,9 +113,7 @@ def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]: blocks = {int(b["번"]): b for b in book.get("단산") or []} for h in book["호표"]: for line in h["줄"]: - if _key(line) == _lab_key(lab) or ( - not line.get("참조") and _key(line)[:3] == _lab_key(lab)[:3] - ): + if _find({_lab_key(lab): lab}, line): ref = line.get("참조") or {} return (blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None), line raise KeyError(lid) @@ -123,7 +132,6 @@ def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]: continue out["읽음"].append(tag) want = {_lab_key(lab): lab for lab in labs if lab["파일"] == tag} - loose = {k[:3]: lab for k, lab in want.items()} blocks = {int(b["번"]): b for b in book.get("단산") or []} t0 = time.time() for h in book["호표"]: @@ -132,7 +140,7 @@ def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]: ref = line.get("참조") or {} if ref.get("종류") == "일위" and ref.get("번"): refs.append(int(ref["번"])) - lab = want.get(_key(line)) or (loose.get(_key(line)[:3]) if not ref else None) + lab = _find(want, line) if lab is None: continue ids.append(lab["id"]) @@ -141,6 +149,15 @@ def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]: block = blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)} out["호표"][f"{tag}|{h['번']}"] = (list(dict.fromkeys(ids)), refs) + by_code = {str(b.get("코드") or "")[:6]: b for b in blocks.values() if b.get("코드")} + for lab in want.values(): # 호표 줄이 안 부르는 블록(실험 C6 = 블록마다) — 블록 머리로 줄 + block = blocks.get(lab.get("단산") or -1) or by_code.get(str(lab.get("코드"))[:6]) + if lab["id"] in out["줄"] or block is None: + continue + basis = {"출처": block.get("출처") or [], "직종": block.get("직종") or []} + line = {**{k: lab[k] for k in ("이름", "규격", "단위")}, "갈래": "단가산출"} + line.update(참조={"종류": "단산", "번": block["번"]}, 근거=basis) + out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)} out["시간"][tag] = round(time.time() - t0, 1) return out diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 1adb7f3be..d6383a695 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -60,8 +60,8 @@ def test_출처_글(): def _top(book: dict, no: int) -> list[dict]: - got = hm.match_line(_line(book, no), [], hm.units_of(book)) - assert got["상태"] == "후보" and got["들어감"] is False # 자동 확정 없음 + got = hm.b_order(_line(book, no)) # 338 — 이름 · 근거 차례(숫자 판정의 덤) + assert got["상태"] == "후보" # 자동 확정 없음 return got["후보"] @@ -83,7 +83,7 @@ def test_근거_후보(north): assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]] assert _top(north, 49)[0]["로직"] == "GC000085" soil = next(x for x in north["호표"][2]["줄"] if x["이름"].startswith("잔토처리")) - names = [c["이름"] for c in hm.match_line(soil, [], {})["후보"]] + names = [c["이름"] for c in hm.b_order(soil)["후보"]] assert not any("철골재" in n for n in names) assert master_hash() == before @@ -106,7 +106,7 @@ def test_근거_없는_줄_그대로(north): assert bare for line in bare: plain = {k: v for k, v in line.items() if k != "근거"} - assert hm.match_line(line, [], {}) == hm.match_line(plain, [], {}) + assert hm.b_order(line) == hm.b_order(plain) def test_길_응답_근거_칸(client): diff --git a/resources/tester/test_m02_hopyo_fit.py b/resources/tester/test_m02_hopyo_fit.py index a3b18050b..83be778a0 100644 --- a/resources/tester/test_m02_hopyo_fit.py +++ b/resources/tester/test_m02_hopyo_fit.py @@ -46,7 +46,7 @@ def test_딱_맞음이_같음을_이김(got): def test_판정_셋(got): a = _block("N000", got) assert a["판정"]["상태"] == "맞음" and "소량감률 50" in a["판정"]["글"] - assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"] == "조항 같음" + assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"].startswith("조항 같음") b = _block("N008", got) assert b["판정"]["상태"] == "다른 점" and b["차례"][0] == "GC000083" assert "특별인부 엑셀 0.3542 · 우리 0.3704" in b["판정"]["꼬리표"] diff --git a/resources/tester/test_m02_hopyo_golden.py b/resources/tester/test_m02_hopyo_golden.py new file mode 100644 index 000000000..1006a4aa4 --- /dev/null +++ b/resources/tester/test_m02_hopyo_golden.py @@ -0,0 +1,82 @@ +"""338-1 골든(느림 · 2~4 분) — 정답 표(사람 136 · 새 파일 51)를 실물 엑셀 여덟 파일(읽기만)로 맞춰 +설계 7-3 기준 수치 · master_data 원본 그대로 · 셈 무게. 우리 지문 캐시가 없으면 처음 ≈ 40 초 더.""" + +import time + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Ours as ours +from resources.tester import helper_m02_hopyo_golden as golden +from resources.tester.test_m02_hopyo_excel import master_hash + +LABELED = ("북부청임도", "북부청사방", "동부청영월") + + +@pytest.fixture(scope="module") +def run(): + before = master_hash() + if not ours.ready(): + ours.build() + t0 = time.time() + res = golden.evaluate() + return {"결과": res, "시간": time.time() - t0, "원본": before} + + +@pytest.fixture(scope="module") +def no_seeds(): + keep = hm._seeds + hm._seeds = lambda: {} + hm._targets.cache_clear() + try: + yield golden.evaluate(LABELED) + finally: + hm._seeds = keep + hm._targets.cache_clear() + + +def test_모든_파일_모든_줄(run): + res = run["결과"] + assert not res["못읽음"] and len(res["읽음"]) == 8 + assert {lab["id"] for lab in golden.labels()} == set(res["줄"]) + + +def test_사람_126_순위(run): + got = golden.metrics(run["결과"], {"사람"}) + assert got["줄"] == 126 + assert got["1순위"] >= 116 # 92% + assert got["다섯 안"] >= 122 # 96% + + +def test_북부청_92_조합_전(run): + rows = [r for r in run["결과"]["줄"].values() if r["lab"]["파일"] in LABELED[:2]] + have = [r for r in rows if r["lab"]["정답"]] + ok = sum((r["셈"].get("지문차례") or [None])[0] in r["lab"]["정답"] for r in have) + assert len(have) == 92 and ok >= 81 # 88% + + +def test_판정_맞음_다른_점_옵션(run): + got = golden.metrics(run["결과"], {"사람"}) + assert got["맞음"] >= 55 and got["맞음 틀림"] <= 2 + assert got["다른 점 틀림"] <= 2 + assert got["옵션까지"] >= 50 + + +def test_새_파일_맞음_틀림(run): + got = golden.metrics(run["결과"], {"자동", "씨앗", "자동+씨앗"}) + assert got["줄"] == 51 and got["맞음 틀림"] <= 3 + + +def test_조합_맞음(run): + got = golden.metrics(run["결과"], {"사람"}) + assert got["조합 호표"] == 83 and got["조합 맞음"] >= 60 # 72% + + +def test_씨앗_끔_1순위(no_seeds): + got = golden.metrics(no_seeds, {"사람"}) + assert got["줄"] == 126 and got["1순위"] >= 111 # 88% + + +def test_원본_그대로_셈_무게(run): + assert master_hash() == run["원본"] + assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 1e8743fca..3a2e2d385 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -2,9 +2,13 @@ 맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더. """ +import shutil + import pytest +from M02_MasterTemplete import M02_Hopyo_Fit as fit from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Ours as ours from M02_MasterTemplete import M02_Template_Layers as layers # 올리기 길 시험의 `client` 마당을 같이 씀 @@ -86,88 +90,124 @@ def test_규격에서_입력값(): assert values == {} and unread == ["뒷길이"] # 고르기 밖 수 = 빈 값 + 표시 -# ── 맞춤 차례 ──────────────────────────────────────────────────────── +# ── 맞춤 응답(338 · 설계 3-3) ───────────────────────────────────────── +@pytest.fixture(autouse=True) +def calc(tmp_path, monkeypatch): + """맞춤셈 캐시 = 임시 폴더(우리 지문 사본) · 뒤 셈은 시험이 바로 돌림(`run_job`).""" + if not ours.ready(): + ours.build() + cache = tmp_path / "hopyo_cache" + cache.mkdir() + shutil.copy(ours.path_of(), cache / ours.path_of().name) + monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache)) + monkeypatch.setattr(fit, "ensure", fit.status) + + def _hopyo(client, no, layer="personal"): res = client.get(f"{URL.format(layer)}/HP-000001/{no}") assert res.status_code == 200, res.text return {one["이름"]: one["맞춤"] for one in res.json()["호표"]["줄"]} -def test_이름_같음_후보_호표_퍼센트(client): +def _ready(client, layer="personal"): + assert _post(client, layer).status_code == 200 + folder = layers.personal_dir(7, 42) if layer == "personal" else client.system + before = _hopyo(client, 1, layer)["레미콘타설(장비사용)"] + assert before["상태"] == "후보" and "셈 중" in before["꼬리표"] # 셈 전 = 이름 · 근거 후보 + fit.run_job(folder, "HP-000001") + return folder + + +def test_새_상태_체크_제안(client): before = master_hash() - assert _post(client).status_code == 200 + _ready(client) first = _hopyo(client, 1) + remicon = first["레미콘타설(장비사용)"] + assert remicon["상태"] == "맞음" and remicon["체크"] is True and "들어감" not in remicon + target = remicon["대상"][0] + assert target["로직"] == "GC000198" and target["입력값"]["소량감률"] == 50 + assert target["출처"]["소량감률"] == "블록" and target["배"] == 1 + assert remicon["품"]["같음"]["콘크리트공"] and remicon["품"]["엑셀"]["인력"]["콘크리트공"] plywood = first["합판거푸집"] - assert plywood["상태"] == "같음" and plywood["대상"]["로직"] == "GF000160" - assert plywood["입력값"] == {"횟수": "4회사용시"} and plywood["들어감"] is True - remicon = first["레미콘(25-21-120)"] - assert remicon["상태"] == "후보" and remicon["들어감"] is False - assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저 + assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"} + mat = first["레미콘(25-21-120)"] + assert mat["상태"] == "후보" and mat["체크"] is False + assert all(c["규격"] == "25-21-120" for c in mat["후보"][:3]) # 규격 같은 자재 먼저 dig = _hopyo(client, 3)["구조물터파기(인력100%)"] - assert dig["상태"] == "후보" and len(dig["후보"]) == 5 - assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위 - assert any(c["이름"].startswith("구조물터파기") for c in dig["후보"]) # 이름 1순위 자리 + assert dig["상태"] == "다른 점" and dig["체크"] is False + assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5 + assert "특별인부 엑셀 0.3542 · 우리 0.3704" in dig["꼬리표"] ref = _hopyo(client, 45)["고화매트재료비"] - assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] + assert ref["상태"] == "호표" and ref["체크"] is False and ref["글"] == "제46호표 먼저 확인" + assert ref["대상"][0]["호표"] == 46 and ref["대상"][0]["단위"] == "㎡" pct = _hopyo(client, 46)["잡재료비"] - assert pct["상태"] == "%" and pct["들어감"] is False + assert pct["상태"] == "%" and pct["체크"] is False whole = client.get(f"{URL.format('personal')}/HP-000001").json() - assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415 + assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415 and whole["셈"]["끝"] assert next(h for h in whole["호표"] if h["번"] == 61)["맞춤"] == [0, 0] + one = client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"] + assert one["셈"]["끝"] and one["만든"] is None and one["맞춤"][0] >= 2 assert master_hash() == before -def test_미리_채움_확정_기억_우선(client): - assert _post(client).status_code == 200 - url = f"{URL.format('personal')}/HP-000001/3/guess" - got = client.post(url, json={"차례": 1, "대상": {"로직": "GF000101"}}).json() - assert got["환산"] == {"배": 1, "갈래": "같음"} and got["대상"]["단위"] == "㎥" - assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400 - key = _hopyo(client, 3)["구조물터파기(인력100%)"]["열쇠"] - assert key == "구조물터파기(인력100%)|토사|m3" - body = {"줄": {key: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}}} - assert client.post("/api/m02/layers/personal/hopyo-memory", json=body).status_code == 200 - dig = _hopyo(client, 3)["구조물터파기(인력100%)"] - assert (dig["상태"], dig["대상"]["로직"], dig["입력값"], dig["들어감"]) == ( - "기억", - "GF000101", - {"지역": "서울"}, - True, +def _ledger(client, rows, layer="personal"): + res = client.put(f"/api/m02/layers/{layer}/hopyo-ledger", json={"줄": rows}) + assert res.status_code == 200, res.text + + +def test_별칭_체크_규칙(client): + _ready(client) + dig = "구조물터파기(인력100%)|토사|m3" + remicon = "레미콘타설(장비사용)|철근구조물-소량타설|m3" + _ledger( + client, + { + dig: {"갈래": "짝", "짝": [{"로직": "GC000083", "배": 1}]}, + remicon: {"갈래": "짝", "짝": [{"로직": "GC000199", "배": 1}]}, + "합판거푸집|6회|m2": {"갈래": "짝", "짝": [{"로직": "GC000211", "배": 1}]}, + "면목|구멍|개": {"갈래": "우리에 없음"}, + }, ) - # 이름 같음보다 기억이 앞섬 · 빼기 - plywood = _hopyo(client, 1)["합판거푸집"]["열쇠"] - out = {"줄": {plywood: {"빼기": True}}} - assert client.post("/api/m02/layers/personal/hopyo-memory", json=out).status_code == 200 - assert _hopyo(client, 1)["합판거푸집"]["상태"] == "빼기" - clear = {"줄": {plywood: None}} - assert client.post("/api/m02/layers/personal/hopyo-memory", json=clear).status_code == 200 - assert _hopyo(client, 1)["합판거푸집"]["상태"] == "같음" + got = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert got["상태"] == "별칭" and got["체크"] is True and got["대상"][0]["출처"] == {} + assert got["별칭"]["층"] == "personal" and not got["별칭"]["근거다름"] + first = _hopyo(client, 1) + other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 → 체크 안 함 + assert other["상태"] == "별칭" and other["체크"] is False + assert "숫자 판정 맞음 = GC000198" in other["꼬리표"] + loose = first["합판거푸집"] # 규격 4회 ↔ 장부 6회 — 느슨 열쇠 + assert loose["상태"] == "별칭" and loose["별칭"]["규격다름"] and loose["체크"] is False + _ledger(client, {dig: {"갈래": "짝", "짝": [{"로직": "GF000101", "배": 1}]}}) + off = _hopyo(client, 3)["구조물터파기(인력100%)"] # 산림 로직 ↔ 엑셀 건설 3-2-1 + assert off["별칭"]["근거다름"] and off["체크"] is False and "근거 다름" in off["꼬리표"] -def test_확정_검사와_위_층_기억(client): - bad = [ - {"로직": "GC000422", "환산": 1}, # 돈 아닌 로직 - {"로직": "GF000101", "환산": 0}, - {"로직": "GF000101", "자재": "MT004258", "환산": 1}, - {"환산": 1}, - ] - for one in bad: - res = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {"a|b|c": one}}) - assert res.status_code == 400, one - assert client.post("/api/m02/layers/system/hopyo-memory", json={"줄": {}}).status_code == 403 +def test_장부_뺌_위_층(client): client.who.update(role="SYSTEM_ADMIN") key = "구조물터파기(인력100%)|토사|m3" - body = {"줄": {key: {"로직": "GF000103", "환산": 1}}} - assert client.post("/api/m02/layers/system/hopyo-memory", json=body).status_code == 200 + _ledger(client, {key: {"갈래": "안 씀"}}, "system") client.who.update(role="USER") - assert _post(client).status_code == 200 - assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000103" # 위 층 - mine = {"줄": {key: {"로직": "GF000101", "환산": 1}}} - assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200 - assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저 + _ready(client) + got = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert got["상태"] == "뺌" and got["뺌"] == "안 씀" and got["별칭"]["층"] == "system" assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7) +def test_미리_셈_목록_낱개(client): + folder = _ready(client) + url = f"{URL.format('personal')}/HP-000001/1/guess" + one = client.post(url, json={"차례": 1, "대상": {"로직": "GC000198"}}) + assert one.status_code == 200, one.text + assert one.json()["대상"][0]["환산"] == {"배": 1, "갈래": "같음"} + assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400 + body = [{"로직": "GC000198", "입력값": {"타설방법": "장비사용타설", "소량감률": 50}}] + got = hm.guess(folder, "HP-000001", 1, 1, body) + assert got["대상"][0]["출처"]["소량감률"] == "고름" + assert got["품"]["같음"]["콘크리트공"] and got["품"]["엑셀"]["인력"]["콘크리트공"] + plain = hm.guess(folder, "HP-000001", 1, 1, [{"로직": "GC000198", "입력값": {"소량감률": 0}}]) + assert not plain["품"]["같음"]["콘크리트공"] # 옵션을 바꾸면 우리 품이 바뀜 + + def test_로직_추가_고르기_줄은_이_층과_위_층(client): client.who.update(role="SYSTEM_ADMIN") assert _post(client, "system").status_code == 200 @@ -197,7 +237,7 @@ def test_뜻_낱말이_안_겹치면_후보_아님(): assert hm.candidates(soil, "로직") == [] # 「처리」 두 글자만 겹침(소금처리 · 바탕처리) stone = {"이름": "임도표지석(받침,글새김포함)", "규격": "", "단위": "㎥", "갈래": "자재"} assert hm.candidates(stone, "자재") == [] # 속도표지판 · 도로표지용페인트 - got = hm.match_line(soil, [], {}) - assert got["상태"] == "없음" and got["후보"] == [] + got = hm.b_order(soil) + assert got["상태"] == "없음" and got["top"] == [] fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침