Files
Aislo/M02_MasterTemplete/M02_San_MatFind.py
T
eomsangdonandClaude Opus 5.5 363cf3576b fix(828-2): 판 일위 구성 줄 재료에서 단위 % 자원(잡재료비 「재료비의 %」) 뺌
- 5 건 일위 자재 줄 140 → 136(봉화 1 · 대흥 3 = 율 자원) · 이어짐 69 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:49:18 +09:00

378 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""불러오기 재료 찾기 판정 · 단서 · 자동 규칙(PLAN 827-2 · 요구 810 ~ 812 · 조사 810-2 · 811-2).
`M02_San_Link.m01` 재료 줄이 부름 · 서버 단독 · 읽기만 · 판정은 이름 · 규격 · 단위 · 단서 글만(단가 값 안 봄) ·
별칭을 만들거나 제안하지 않음(별칭 = 사람이 표기 변형을 잇는 칸 · 요구 812-1).
줄 판정 `자원["재료찾기"]` = {판정, 방법, 키, 후보, 단서}:
- 「이어짐」 — 방법 = 코드 · 별칭 · 정확(정확 일치 · 바탕 이름 규격 맞음) · 표기(817-1 ㉮ · 후보 하나) ·
「규칙 A」 ~ 「규칙 F2」 · 「단서」
- 「규격 없음 — 고르기」 — 가져온 규격 빔 · 별도계상 · 바탕 이름 같은 후보 있음
- 「후보 여럿」 — 규격은 있는데 하나로 못 정함
- 「우리 표에 없음」 — 바탕 이름 같은 후보 없음 · 규격 수가 하나도 안 닮음(후보 = 닮은 줄 · 참고)
후보 = [{키, 이름, 규격, 단위, 단서?}] 차례 = 단서로 좁힌 것 › 이름 같은 묶음 › 옛 후보.
별도계상 줄(요구 811-2 — 품목은 정해짐 · 셈만 프로젝트에서) = 같은 찾기 · 키는 판정에만(줄 이음 · 단가 0 그대로).
단서(걸음 1 · 규격 빈 줄 · 별도계상 줄만 좁힘) = 산근 줄 설명 글(「철물(앵커용)」) · 산근 머리(Φ800mm) ·
같은 파일 STC 내역서 자재 직접 줄 · STmate 엑셀 일위대가표 자재 줄(같은 코드 · 대가 머리 · 이름 같은 줄) —
하나로 정해지면 이음 · 여럿이면 후보 차례만 · 관급형 · 일반형처럼 단서로 안 갈리면 후보로 둠.
자동 규칙(걸음 2 · 조사 810-2 5 절 · G 이름 + 단위만 = 안 씀):
A 가져온 규격 빔 + 바탕 이름 같은 우리 품목 하나(괄호 낱말 부딪힘 · 덧말 든 우리 줄 뺌) ·
B 이름 같은 우리 품목이 하나뿐 + 그 규격 빔 · C 규격 낱말이 우리 규격 낱말에 듦 + 단위 같음 ·
E 수 묶음(둘 이상) 같음 + 이름 끝 세 글자 이상 같음(이름이 같은 우리 줄 없을 때) + 단위 맞음 ·
F 이름 괄호 풀어 붙임 글자 같음 + 규격 빔 + 단위 같음 · F2 이름 + 한글 규격 글자 = 우리 이름 글자 + 단위 같음.
"""
from __future__ import annotations
import re
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_MatSame as ms
LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음"
TOP = 8
_CTX: ContextVar[dict] = ContextVar("재료단서", default={})
_LEAD = re.compile(r"^[\s\-·\d.)]+")
_RANGE = re.compile(r"[A-Za-z]*\d+(?:\.\d+)?\s*[~∼]\s*[A-Za-z]*\d+(?:\.\d+)?")
# ── 단서 읽기(걸음 1) ─────────────────────────────────────────────────────
def from_boq(rows: Any) -> list[dict]:
"""STC 내역서(`M02_San_Stc.read` 「내역」) → 자재 직접 줄 단서."""
out = [
{
"곳": "내역서",
"코드": str(r.get("코드") or ""),
"이름": r.get("이름"),
"규격": r.get("규격"),
}
for r in rows or []
if str(r.get("코드") or "").startswith("M") and r.get("이름")
]
return _uniq(out)
def from_book(rows: Any) -> list[dict]:
"""STmate 엑셀 「일위대가표」(시트 줄 · 또는 `M02_San_Xlsx_Ilwi.read` 답) → 자재 줄 단서(숨은 메타
COD M… · 대가 머리 = 이름 · 규격) — 읽기 = 공용(828-2)."""
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
sheet = rows if isinstance(rows, dict) else ilwi.read(rows)
out = []
for hopyo in sheet.get("호표") or []:
head = f"{hopyo.get('이름') or ''} {hopyo.get('규격') or ''}".strip()
for one in hopyo["줄"]:
if "메타" in one and one["코드"].startswith("M") and one["이름"]:
out.append({"곳": "일위대가표", "코드": one["코드"], "이름": one["이름"]})
out[-1] |= {"규격": one["규격"]} | ({"머리": head} if head else {})
return _uniq(out)
def ilwi_found(saved: dict) -> list[tuple[dict, dict]]:
"""둘 꼴 「일위」 구성 줄(828 · 엑셀 일위대가표 숨은 칸 · STC 구성표) 자재 줄 → [(일위 블록, 자원)] ·
단위 % 자원(잡재료비 「재료비의 %」) 뺌 · 자원 `재료찾기` = 산근 줄과 같은 판정(`M02_San_Link.m01` · 단서 = 파일 단서 + 대가 머리)."""
from M02_MasterTemplete.M02_San_Link import m01
out = []
for blk in saved.get("일위") or []:
head = f"{blk.get('이름') or ''} {blk.get('규격') or ''}"
with clues_on(saved.get("단서"), head):
for ln in blk.get("줄") or []:
if (
ln.get("갈래") != "M" or not ln.get("이름") or "%" in str(ln.get("단위"))
): # % = 율 자원
continue
res = {"꼴": saved.get("꼴"), "갈래": "재료", "코드": ln.get("코드") or ""}
res |= {k: ln.get(k) or "" for k in ("이름", "규격", "단위")}
m01("재료", res)
out.append((blk, res))
return out
def _uniq(rows: list[dict]) -> list[dict]:
seen, out = set(), []
for r in rows:
k = tuple(sorted((k, str(v)) for k, v in r.items()))
if k not in seen:
seen.add(k)
out.append(r)
return out
@contextmanager
def clues_on(rows: Any, head: Any = ""):
"""블록 하나 옮기는 동안 파일 단서 · 산근 머리 글(`M02_Doc_FromNative._emit`)."""
token = _CTX.set({"줄": tuple(map(_frozen, rows or [])), "머리": str(head or "").strip()})
try:
yield
finally:
_CTX.reset(token)
def _frozen(r: dict) -> tuple:
return tuple(sorted((k, str(v or "")) for k, v in r.items()))
def _texts(me: dict, code: str, label: str) -> tuple[tuple[str, str], ...]:
"""줄 단서 글 [(곳, 글)] — 설명 글(이름이 같은 바탕일 때) · 산근 머리 · 같은 코드 · 같은 이름 파일 줄."""
ctx, out = _CTX.get(), []
label = _LEAD.sub("", label or "") # 「5) 철물(앵커용)」 · 「-안전표지판(…)」
mine = ms._item(label, "")
if label and mine["core"] == me["core"] and mine["whole"] != me["whole"]:
out.append(("설명 글", label))
if ctx.get("머리"):
out.append(("산근 머리", ctx["머리"]))
for one in ctx.get("줄") or ():
r = dict(one)
same = code and r.get("코드") == code
if same or ms._item(r.get("이름"), "")["core"].startswith(
me["core"]
): # 「파형강관(2.7T)-영월」
out.append((r["곳"], f"{r.get('이름')} {r.get('규격')}".strip()))
out += [(f"{r['곳']} 머리", r["머리"])] if same and r.get("머리") else []
return tuple(dict.fromkeys(out))
# ── 판정 ─────────────────────────────────────────────────────────────────
def find(
line: dict, src: dict | None = None, keys: Any = (), label: str = "", apart: bool = False
) -> dict:
"""재료 줄(별도계상 글 뗀) → 판정(`src` 있으면 `자원["재료찾기"]` 에 둠) · keys = 지금 엔진 찾기 키들."""
text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위"))
code = str((src or {}).get("코드") or "")
me = ms._item(text[0], text[1])
keys = tuple(sorted(k for k in keys if k))
got = dict(_find(*text, keys, _texts(me, code, label), apart, id(hm.targets())))
got |= {"후보": [dict(x) for x in got["후보"]], "단서": list(got["단서"])}
if src is not None:
src["재료찾기"] = got
return got
def done(key: str, how: str, src: dict | None = None) -> dict:
"""이미 정한 이음(코드 길) 판정."""
got = {"판정": LINKED, "방법": how, "키": key, "후보": [], "단서": []}
if src is not None:
src["재료찾기"] = got
return got
@lru_cache(maxsize=4096)
def _find(
name: str, spec: str, unit: str, keys: tuple, texts: tuple, apart: bool, _got: int
) -> dict:
line = {"이름": name, "규격": spec, "단위": unit}
me = ms._item(name, spec)
if len(keys) == 1 and (how := _how(line, me, keys[0])):
return _out(LINKED, how, keys[0])
for rule, fn in RULES.items():
if key := fn(line, me):
return _out(LINKED, f"규칙 {rule}", key)
pool = _same_name(line, me)
narrowed, used = _narrow(pool, texts) if not me["spec"] or apart else (pool, [])
if used and len(_folds(narrowed)) == 1:
return _out(LINKED, "단서", ms._pick(narrowed, line).get("자재"), [], used)
cands = _cands(line, narrowed, used)
twin = len(
me["bag"]
) > 1 and any( # 이름은 달라도 규격 수 묶음이 같은 후보(그레이팅 395×995×52)
ms._item(c["이름"], c["규격"])["bag"] == me["bag"] for c in map(dict, cands)
)
if not twin and not any(_alike(me, x) for x in pool):
verdict = NONE
else:
verdict = NO_SPEC if apart or not spec.strip() else MANY
return _out(verdict, "", None, cands, used)
def _alike(me: dict, x: dict) -> bool:
"""이름 같은 우리 줄이 이 물건일 수 있음 — 우리 규격 빔 · 가져온 수 없음 · 수 하나라도 같음(BUTTON BIT Ø38 ↔ 105mm = 아님)."""
return (
not x["spec"] or not me["bag"] or any(ms._near(a, b) for a in me["bag"] for b in x["bag"])
)
def _out(verdict: str, how: str, key: Any, cands: Any = (), used: Any = ()) -> dict:
return {"판정": verdict, "방법": how, "키": key, "후보": tuple(cands), "단서": tuple(used)}
def _how(line: dict, me: dict, key: str) -> str | None:
"""지금 엔진이 정한 키의 길 — 별칭 · 정확 · 표기 · 후보 하나인데 규격 수가 부딪힘 = None(못 이음)."""
from M02_MasterTemplete import M02_Elem_Alias as ea
if ea.keys(ea.MARKET, line["이름"], line["규격"]):
return "별칭"
if ea.exact(line["이름"], line["규격"]) or hmat.sure(line)[0]:
return "정확"
x = _row(key)
if ms.same(line) or x is None or not me["bag"] or not x["bag"]:
return "표기"
return "표기" if ms.num_fit(me["bag"], x["bag"])[1] else None # 드릴롯드 T38×R32 ↔ AX 1.5
# ── 우리 표 색인 ────────────────────────────────────────────────────────
@lru_cache(maxsize=2)
def _index(_got: int) -> dict:
rows = ms.book()["줄"]
out: dict[str, dict] = {"키": {}, "bag": {}, "whole": {}}
for at, x in enumerate(rows):
out["키"].setdefault(x["row"]["one"].get("자재"), at)
if len(x["bag"]) >= 2:
out["bag"].setdefault(x["bag"], []).append(at)
out["whole"].setdefault(x["whole"], []).append(at)
return out
def _ix() -> dict:
return _index(id(hm.targets()))
def _row(key: Any) -> dict | None:
at = _ix()["키"].get(key)
return None if at is None else ms.book()["줄"][at]
def _rows(ats: Any) -> list[dict]:
rows = ms.book()["줄"]
return [rows[i] for i in dict.fromkeys(ats)]
def _unit_ok(line: dict, x: dict) -> bool:
return not line.get("단위") or hm.fits(line["단위"], x["row"]["one"]["단위"])
def _unit_same(line: dict, x: dict) -> bool:
return bool(line.get("단위")) and hm.mm.plain_unit(line["단위"]) == x["row"]["key"][1]
def _folds(xs: list[dict]) -> set:
"""품목 갈래 — 지역 · 관급/사급 변형은 한 품목 · 이름 괄호 수도 가름(「치즐(0.2㎥)」 ≠ 「치즐(0.7㎥)」)."""
return {(*ms._fold(x), x["bag"]) for x in xs}
def _one(xs: list[dict], line: dict) -> str | None:
return ms._pick(xs, line).get("자재") if xs and len(_folds(xs)) == 1 else None
def _clean(me: dict, xs: list[dict]) -> list[dict]:
"""괄호 · 규격 낱말 부딪힘 뺌(「각재(외송)」 ↔ 「각재(나왕)」)."""
return [x for x in xs if not ms._qual_fit(me, x)[0]]
def _same_name(line: dict, me: dict) -> list[dict]:
xs = _rows(
at for at, strong in ms._related(me).items() if strong
) # 817-1 굳센 같음(바탕 · 글자 · 괄호 이름)
return [x for x in _clean(me, xs) if _unit_ok(line, x)]
# ── 자동 규칙(걸음 2) ───────────────────────────────────────────────────
def _has(x: dict, words: frozenset) -> bool:
return all(ms._tidy(w) in x["whole"] + x["spec"] for w in words)
def _rule_a(line: dict, me: dict) -> str | None:
if me["spec"] or me["bag"]:
return None
xs = [x for x in _same_name(line, me) if x["core"] == me["core"] and _has(x, me["quals"])]
return _one([x for x in xs if not x["quals"] - me["quals"]], line) if xs else None
def _rule_b(line: dict, me: dict) -> str | None:
xs = _rows(
ms.book()["찾기"]["core"].get(me["core"], [])
) # 이름 같은 우리 품목이 하나뿐(단위 무관)
if not xs or len(_folds(xs)) != 1 or xs[0]["spec"]:
return None
return _one([x for x in _clean(me, xs) if _unit_ok(line, x)], line)
def _rule_c(line: dict, me: dict) -> str | None:
if me["bag"] or not me["quals"]:
return None
xs = [x for x in _same_name(line, me) if x["core"] == me["core"]]
return _one([x for x in xs if me["quals"] <= x["quals"] and _unit_same(line, x)], line)
def _tail(a: str, b: str) -> int:
n = 0
while n < min(len(a), len(b)) and a[-1 - n] == b[-1 - n]:
n += 1
return n
def _rule_e(line: dict, me: dict) -> str | None:
if len(me["bag"]) < 2:
return None
xs = _rows(_ix()["bag"].get(me["bag"], []))
xs = [x for x in xs if x["core"] != me["core"] and _tail(me["core"], x["core"]) >= 3]
if any(x["core"] == me["core"] for x in _rows(ms.book()["찾기"]["core"].get(me["core"], []))):
return None # 이름이 같은 우리 줄이 있으면 E 아님(PC강연선 Unbonded · 고정핀 — 이미 이은 줄 시험)
xs = [x for x in xs if _unit_ok(line, x)]
return _one(_clean(me, xs), line)
def _rule_f(line: dict, me: dict) -> str | None:
if me["spec"] or not me["pieces"]:
return None
by = ms.book()["찾기"]
xs = _rows([*_ix()["whole"].get(me["whole"], []), *by["chars"].get(me["chars"], [])])
return _one([x for x in xs if _unit_same(line, x)], line)
def _rule_f2(line: dict, me: dict) -> str | None:
if not me["spec"] or me["bag"] or re.search(r"\d", line["규격"]):
return None
chars = "".join(sorted(me["core"] + "".join(me["pieces"]) + me["spec"]))
xs = _rows(ms.book()["찾기"]["chars"].get(chars, []))
return _one([x for x in xs if _unit_same(line, x)], line)
RULES = {"E": _rule_e, "C": _rule_c, "A": _rule_a, "B": _rule_b, "F": _rule_f, "F2": _rule_f2}
# ── 단서로 좁힘 · 후보 ─────────────────────────────────────────────────
def _narrow(xs: list[dict], texts: tuple) -> tuple[list[dict], list[str]]:
"""단서 글의 수(같은 수) · 낱말 어근마다 그걸 품은 줄만 — 남는 줄이 있고 줄어들 때만 · 관급 · 사급을
가르는 걸음은 안 씀(사람 몫) → (남은 줄, 쓴 단서)."""
used = []
for where, text in texts:
item = ms._item("", _RANGE.sub(" ", text)) # 범위(「D13~D32」)는 규격 하나가 아님
tests = [lambda x, n=n: n in x["bag"] for n in item["bag"]]
stems = {ms._STEM.sub("", w) for w in item["quals"]}
tests += [lambda x, s=s: s in x["whole"] + x["spec"] and s not in x["core"] for s in stems]
for test in tests:
keep = [x for x in xs if test(x)]
supply = {x["row"]["supply"] for x in keep} == {x["row"]["supply"] for x in xs}
if keep and supply and len(_folds(keep)) < len(_folds(xs)):
xs = keep
used.append(f"{where} — {text}")
elif (
keep and len(keep) == len(xs) and len(_folds(xs)) == 1
): # 하나뿐인 줄의 덧말 · 수를 단서가 댐
used.append(f"{where} — {text}")
return xs, list(dict.fromkeys(used))
def _cands(line: dict, narrowed: list[dict], used: list) -> list[tuple]:
from M02_MasterTemplete import M02_Hopyo_Row as hrow
out: dict[str, tuple] = {}
for x in narrowed if used else []:
out.setdefault(x["row"]["one"].get("자재"), _pub(x["row"]["one"], "단서"))
for p in hrow.m01_picks("재료", line):
out.setdefault(p.get("키"), _pub({**p, "자재": p.get("키")}, ""))
for x in ms.narrow(line, TOP):
out.setdefault(x.get("자재"), _pub(x, ""))
return [v for k, v in out.items() if k][:TOP]
def _pub(one: dict, why: str) -> tuple:
got = {"키": one.get("자재"), **{k: str(one.get(k) or "") for k in ("이름", "규격", "단위")}}
return tuple((got | ({"단서": why} if why else {})).items())