diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index e4fcf4458..7d615ee34 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -8,7 +8,10 @@ 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로). 견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · -같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름. 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. +같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이 +하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 · +다섯 안에 이름 1순위 하나는 늘 남김(`pick`). +자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. M01 · 원본 로직은 읽기만. """ @@ -29,6 +32,7 @@ _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPOS = (("깍기", "깎기"),) +_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} @@ -179,8 +183,14 @@ def _text(line: dict[str, Any]) -> str: return text +def _loose_words(text: str) -> frozenset[str]: + """괄호 글까지 뜻 낱말 — 방법 낱말 뺌.""" + return hopyo_match.words(text.replace("(", " ").replace(")", " ")) - _PLAIN_WORDS + + def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]: - """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 점수, 근거}` 점수 차례.""" + """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 근거, _겹침, _낱말}` + 점수 차례(`_` 칸은 `merge` 가 쓰고 뺌).""" basis = line.get("근거") or {} theirs = [list(one) for one in basis.get("출처") or []] if not theirs: @@ -189,6 +199,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any] text = _text(line) mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」 grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름 + loose = _loose_words(text) table = ours() scored = [] for one in got["로직"]: @@ -214,18 +225,42 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any] tags.append("단위 맞음") dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} - scored.append( - (score, dice, {**hopyo_match._public(one), "근거": {**why, "까닭": " · ".join(tags)}}) - ) + theirs_loose = _loose_words(one["이름"]) + overlap = any(a in b or b in a for a in loose for b in theirs_loose) + full = tag == "조항 같음" and unit and (bool(shared) or not trades) # 근거 맞음 + why["까닭"] = " · ".join(tags) + extra = {"_겹침": dice, "_낱말": overlap or full} + scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra})) scored.sort(key=lambda x: (-x[0], -x[1])) return [one for _, _, one in scored] def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]: - """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례.""" + """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례 · + 같으면 이름 겹침 큰 쪽 · 이름 · 낱말 안 겹치는 근거 후보는 겹치는 근거 후보 뒤로.""" if not basis: return named by_name = {c.get("로직"): c["점수"] for c in named} - out = [{**c, "점수": round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)} for c in basis] + out = [] + for c in basis: + named_too = c["로직"] in by_name + score = round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3) + out.append({**c, "점수": score, "_낱말": c["_낱말"] or named_too}) out += [c for c in named if c.get("로직") in by_name] - return sorted(out, key=lambda c: -c["점수"]) + out.sort(key=lambda c: (-c["점수"], -c.get("_겹침", 0))) + loose = [c for c in out if c.get("_낱말") is False] + if loose and any(c.get("_낱말") for c in out): + out = [c for c in out if c.get("_낱말") is not False] + last = max(at for at, c in enumerate(out) if c.get("_낱말")) + out[last + 1 : last + 1] = loose + return [{k: v for k, v in c.items() if not k.startswith("_")} for c in out] + + +def pick(ordered: list[dict[str, Any]], first: Any, room: int) -> list[dict[str, Any]]: + """후보 `room` 개 — 이름 1순위(`first` 로직 키) 하나는 늘 남기고 나머지는 차례대로.""" + if room <= 0: + return [] + if first is None or all(c.get("로직") != first for c in ordered): + return ordered[:room] + others = [c for c in ordered if c.get("로직") != first][: room - 1] + return [c for c in ordered if c.get("로직") == first or any(c is o for o in others)] diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index c9ccbc557..c45f24f13 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -409,7 +409,8 @@ def match_line( 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] - out["후보"] = (top + rest)[:5] + 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 "없음"} diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 526bfd4cb..94d21bf83 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -74,6 +74,9 @@ def test_근거_후보(north): "점수": 1.75, "까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음", } + codes = [c["로직"] for c in dig] + assert "GF000101" in codes and "GC000346" not in codes # 이름 1순위 남김 · 낱말 안 겹침 뒤로 + assert all("_겹침" not in c and "_낱말" not in c for c in dig) assert _top(north, 21)[0]["로직"] == "GC000198" assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]] assert _top(north, 49)[0]["로직"] == "GC000085" @@ -83,6 +86,14 @@ def test_근거_후보(north): assert master_hash() == before +def test_이름_1순위_자리(): + ordered = [{"로직": k} for k in "ABCDEFG"] + assert [c["로직"] for c in hb.pick(ordered, "G", 5)] == ["A", "B", "C", "D", "G"] + assert [c["로직"] for c in hb.pick(ordered, "B", 5)] == ["A", "B", "C", "D", "E"] + assert [c["로직"] for c in hb.pick(ordered, None, 2)] == ["A", "B"] + assert hb.pick(ordered, "G", 0) == [] + + def test_근거_없는_줄_그대로(north): bare = [ x diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 16ba2b303..58c6cac90 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -110,6 +110,19 @@ def test_역할_읽기(client, role, layer, owner, want): assert client.get(GRID.format(layer, "HP-000001"), params=params).status_code == want +@pytest.mark.parametrize("layer", ["company", "system"]) +def test_위_층_격자_읽기(client, layer): + """아래 층 사용자가 고르기 줄(층 · 주인) 그대로 위 층 파일 격자를 읽음.""" + client.who.update({"role": "SYSTEM_ADMIN"}) + assert _post(client, layer).status_code == 200 + client.who.update({"role": "USER"}) + rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] + row = next(r for r in rows if r["층"] == layer) + params = {"owner": row["주인"]} if row["주인"] else {} + res = client.get(GRID.format(row["층"], row["키"]), params=params) + assert res.status_code == 200 and len(res.json()["호표"]) == 68 + + def test_xlsx_값만(): data = _xlsx() grid = hg.read("a.xlsx", data, hx.read_book("a.xlsx", data)) diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 69f8cd397..1e8743fca 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -106,6 +106,7 @@ def test_이름_같음_후보_호표_퍼센트(client): 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순위 자리 ref = _hopyo(client, 45)["고화매트재료비"] assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] pct = _hopyo(client, 46)["잡재료비"]