feat(827-2): 불러오기 재료 찾기 — 단서 넷 · 자동 규칙 A~F2 · 판정 네 갈래 · 별도계상 품목 이음

- 새 M02_San_MatFind: 줄 판정 자원「재료찾기」{판정 · 방법 · 키 · 후보 · 단서} — 이어짐 · 규격 없음 — 고르기 · 후보 여럿 · 우리 표에 없음
- 단서: 산근 줄 설명 글 · 산근 머리 · STC 내역서 자재 줄 · STmate 엑셀 일위대가표(규격 빈 줄 · 별도계상 줄만 좁힘 · 관급/사급은 안 가름)
- 자동 규칙 A · B · C · E · F · F2(G 안 씀) — 원천 넷 이미 이은 464 종에 얹어 다른 키 0 · E 는 이름 같은 우리 줄 있으면 안 씀
- 별도계상 줄: 품목은 판정 키에만 · 줄 이음 · 단가 0 그대로
- 의심 고침: 블레이드 d=320~400 t=3.2 → 4" (규격 수 하나를 두 번 맞춤 — spec_fit 한 번씩) · 유압식드릴롯드 → 드릴롯드 AX 1.5(후보 하나 + 규격 수 부딪힘 = 이음 안 함) · 참싸리(박피)는 사용자 과녁대로 둠
- 전수: 금액 같음 3241/3241 · 기계 그대로 · 재료 이음 EST 원파일 99.7 그대로 · EST 엑셀 82.9→84.6 · STmate 원파일 59.8→68.5 · STmate 엑셀 66.7→76.5 · 새 이음 32 줄 틀림 0 · 키 바뀜 0
- 시험: test_817_1 표 일곱 줄 후보→이음(사용자 과녁 1 순위 키 그대로 · 보조강재만 새 키 MT030619) · 낡은 기대 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 11:41:17 +09:00
co-authored by Claude Opus 5.5
parent b7d162da8a
commit 38ad78d380
9 changed files with 624 additions and 24 deletions
+10 -1
View File
@@ -82,6 +82,12 @@ def pack(got: dict, name: str) -> dict[str, Any]:
for code, p in (got.get("단가") or {}).items()
}
out["끝전"] = RULE.get(str((got.get("옵션") or {}).get("SAN_TYP")), "비목버림")
from M02_MasterTemplete import M02_San_MatFind as mf
if clues := got.get("단서") or mf.from_boq(
got.get("내역")
): # 827-2 재료 단서(내역서 · 일위대가표)
out["단서"] = clues
return out
@@ -130,7 +136,10 @@ def _emit(saved: dict, no: int, keys: bool) -> dict[str, Any]:
head["F"] = saved_amt.get("S")
block = {"코드": blk["코드"], "이름": blk.get("이름"), "머리": head}
block |= {"몸": blk.get("몸") or [], "꼬리": blk.get("꼬리") or []} # 엑셀 = 줄 금액 · 계 꼬리
out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"])
from M02_MasterTemplete import M02_San_MatFind as mf
with mf.clues_on(saved.get("단서"), f"{blk.get('이름') or ''} {blk.get('규격') or ''}"):
out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"])
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
+8 -1
View File
@@ -181,7 +181,14 @@ def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float:
return 1.0 if inside else 0.0
if not mine or not row["nums"]:
return 0.0
return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine)
mine, rest, hit = list(dict.fromkeys(mine)), list(row["nums"]), 0
for x in (
mine
): # 827-2 자재 수 하나는 한 번만(「d=320~400 t=3.2」 의 320 · 3.2 가 둘 다 3.2 로 맞던 것)
if (j := next((i for i, y in enumerate(rest) if _near(x, y)), None)) is not None:
hit += 1
rest.pop(j)
return hit / len(mine)
def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]:
+1
View File
@@ -22,6 +22,7 @@ STM_SHEETS = (
"경비목록표",
"중기목록표",
"중기사용료", # 821-4 기계 구성(못 이은 기계 → 내 기계 값)
"일위대가표", # 827-2 재료 단서(자재 줄 · 대가 머리 — `M02_San_MatFind.from_book`)
)
EST_SHEETS = (
"단가산출총괄표",
+1 -1
View File
@@ -420,7 +420,7 @@ class _Stm:
res["찾기"], link, many = parts[0], parts[0]["키"], None
res |= {"조합첫": True} if len(parts) > 1 else {}
else:
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
link, many = _m01(res["갈래"], res, _label(node)) if self.keys else (None, None)
one = first_spec(resource(o.names.item(name), pr.get("단위"), cf, res, link), parts)
if parts and one["종류"] == "로직":
one["이음"] = combo_link(one, parts) or hourly_text(link)
+39 -13
View File
@@ -10,12 +10,15 @@ NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머
OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값)
QUOTE = "견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님" # 815-1 K5 · 요구 762-3
APART = "별도계상 · 품목은 프로젝트에서" # 817-1 — 일위대가 길(`M02_CheckApart`)과 같은 글 · 요소 잇기 없음
APART_ITEM = "별도계상 · 품목 이음 · 셈은 프로젝트에서" # 827-2 — 품목은 판정(`재료찾기`)에만 · 줄 이음 · 단가 0 그대로
def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
def m01(kind: str, src: dict, label: str = "") -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).
재료 = 판정 `src["재료찾기"]`(827-2 `M02_San_MatFind` · label = 산근 줄 설명 글) · 별도계상 줄도 품목을 찾되
이음은 비움(셈 제외 · 단가 0 그대로)."""
if kind == "고정":
return quote(src)
if kind not in ("인력", "재료", "기계"):
@@ -23,12 +26,13 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
from M02_MasterTemplete import M02_Elem_Alias as ea
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
from M02_MasterTemplete import M02_San_MatFind as mf
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
if kind == "재료" and apart(
line
): # 817-1 — 원가 합 밖(원본 금액 그대로) · 품목은 프로젝트에서 이음
return None, f"{NOTE}{APART}"
if kind == "재료" and apart(line): # 817-1 원가 합 밖(원본 금액 그대로) · 827-2 품목은 판정에
clean = hmat_clean(line)
got = mf.find(clean, src, _keys(clean, hrow.m01_picks("재료", clean)), label, apart=True)
return None, f"{NOTE}{APART_ITEM if got['키'] else APART}"
if (
kind == "기계"
): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음)
@@ -39,7 +43,8 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
src.setdefault("찾기", {**got, "이름": got.get("이름") or line["이름"], "규격": got.get("규격") or line["규격"]}) # fmt: skip
return got["키"], mfind.mark(got, line["이름"], line["규격"])
if kind == "재료" and src.get("종류") == "자재" and (hit := ea.est_code(src.get("코드"))):
return ea.one(line, hit) or hit[0], None # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드
key = ea.one(line, hit) or hit[0] # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드
return mf.done(key, "코드", src)["키"], None
oil = ea.keys("MO", line["이름"], line["규격"]) if kind == "재료" else []
if kind == "재료":
if oil or elem.oil_rows(line["이름"]):
@@ -57,15 +62,13 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
)
else:
got = hrow.m01_picks(kind, line)
if kind == "재료": # 827-2 — 지금 찾기 키 → 판정(규칙 · 단서 · 네 갈래)
return mf.find(line, src, _keys(line, got), label)["키"], None
keys = {x.get("키") for x in got if x.get("키")}
if (
len(keys) > 1 and kind == "유가"
): # 814-1 — 전국평균 · 전력 = 일반용(갑)Ⅰ 저압(지역 · 계약종별은 옵션)
keys = {x["키"] for x in got if x["규격"] in OIL_BASE} or keys
elif len(keys) > 1 and kind == "재료": # 814-1 — 지역 꼬리 뗀 이름 · 규격 정확 일치
keys = exact(line, got) or keys
if not keys and kind == "재료": # 815-1 K3 — 자재표 전체에서 표기 정규화 정확 일치
keys = {k} if (k := ea.one(line, ea.exact(line["이름"], line["규격"]))) else keys
if len(keys) == 1:
return keys.pop(), None
if len(keys) > 1 and kind in ("유가", "환율"):
@@ -73,6 +76,24 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
return None, None
def _keys(line: dict, got: list[dict]) -> set[str]:
"""재료 1차 찾기 키 — 여럿이면 지역 꼬리 뗀 정확 일치(814-1) · 없으면 자재표 전체 정확 일치(815-1 K3)."""
from M02_MasterTemplete import M02_Elem_Alias as ea
keys = {x.get("키") for x in got if x.get("키")}
if len(keys) > 1:
keys = exact(line, got) or keys
if not keys:
keys = {k} if (k := ea.one(line, ea.exact(line["이름"], line["규격"]))) else keys
return keys
def hmat_clean(line: dict) -> dict:
from M02_MasterTemplete import M02_Hopyo_Material as hmat
return {**hmat.clean(line)[0], "단위": line.get("단위") or ""}
def apart(line: dict) -> bool:
"""줄 이름 · 규격에 「별도계상」(`M02_Hopyo_Material.clean`)."""
from M02_MasterTemplete import M02_Hopyo_Material as hmat
@@ -157,7 +178,7 @@ def why(item: dict) -> str:
from M02_MasterTemplete import M02_Hopyo_Row as hrow
if apart({"이름": name, "규격": res.get("규격") or ""}):
return APART
return APART_ITEM if (res.get("재료찾기") or {}).get("키") else APART
if elem.oil_rows(name):
return "유가 후보 여럿 — 고름"
line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""}
@@ -175,7 +196,12 @@ def tally(logic: dict) -> dict:
"""우리 정보로 셈 — 자원 줄 {됨, 줄, 조합(형제 단가산출 부름뿐)} (연결 판정 「없음」 띠 · 814-1)."""
items = logic.get("호표") or []
done = sum(
1 for x in items if x.get("이음") or x.get("참조") or (x.get("자원") or {}).get("조합식")
1
for x in items
if x.get("이음")
or x.get("참조")
or (x.get("자원") or {}).get("조합식")
or ((x.get("자원") or {}).get("재료찾기") or {}).get("키") # 827-2 별도계상 품목
)
inner = [x for x in items if (x.get("자원") or {}).get("갈래") == "단가산출" or x.get("참조")]
return {"됨": done, "줄": len(items), "조합": bool(items) and len(inner) == len(items)}
+360
View File
@@ -0,0 +1,360 @@
"""불러오기 재료 찾기 판정 · 단서 · 자동 규칙(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={})
_B = re.compile(r"B\d+")
_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 엑셀 「일위대가표」 시트 줄 → 자재 줄 단서(숨은 메타 COD M… · 대가 머리 = 이름 · 규격)."""
from M02_MasterTemplete import M02_San_Book as book
out, head = [], ""
for r in rows or []:
r = list(r) + [None] * 16
if _B.fullmatch(str(r[14] or "").strip()):
head = " ".join(str(x or "").strip() for x in r[:2]).strip()
continue
code = book.meta(r[13]).get("COD", "")
if code.startswith("M") and r[0]:
out.append(
{"곳": "일위대가표", "코드": code, "이름": str(r[0]), "규격": str(r[1] or "")}
)
out[-1] |= {"머리": head} if head else {}
return _uniq(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())
@@ -70,6 +70,9 @@ def read(sheets: dict[str, list]) -> dict[str, Any]:
rule, got = vote(out["산근"], prices)
out["옵션"] = {"SAN_TYP": RULE[rule]}
out["내역"] = None
from M02_MasterTemplete import M02_San_MatFind as mf
out["단서"] = mf.from_book(sheets.get("일위대가표")) # 827-2 재료 단서
out["표지"] = [
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
+8 -8
View File
@@ -20,10 +20,10 @@ TABLE = [
("접착제", "", "후보", None), # 1
("패널(유로폼)", "600*1200mm", "이음", "MT000467"), # 2
("각재", "외송", "후보", "MT000393"), # 3
("요소", "", "후보", "MT006763"), # 4
("요소", "", "이음", "MT006763"), # 4 — 827-2 규칙 A
("내부코너패널(유로폼)", "(100x200)x1200mm", "이음", "MT000470"), # 5
("결속선(철선)", "#20 , 0.9M/M", "후보", None), # 6
("각재 (외송)", "", "후보", "MT000393"), # 7
("각재 (외송)", "", "이음", "MT000393"), # 7 — 827-2 규칙 A
("파형강관", "별도계상", "후보", None), # 8
("금강사", "", "없음", None), # 9
("고무테이프", "0.5mm×19mm×10m", "이음", "MT009202"), # 10
@@ -47,7 +47,7 @@ TABLE = [
("전기뇌관(MS)", "3.5m×500개(하이데토)", "이음", "MT005677"), # 28
("BUTTON BIT", "Ø38×76㎜", "후보", None), # 29
("유압드릴샹크", "T38×CM470", "후보", None), # 30
("유압식드릴스리브", "T38×190mm", "후보", "MT005687"), # 31
("유압식드릴스리브", "T38×190mm", "이음", "MT005687"), # 31 — 827-2 규칙 E
("미송(판재)", "", "후보", None), # 32
("산림종자", "참싸리(박피)", "이음", "MT006664"), # 33
("유기질퇴비", "", "후보", None), # 34
@@ -92,7 +92,7 @@ TABLE = [
("산소(6000ℓ)", "99%,120kg/㎠", "이음", "MT003165"), # 73
("아세칠렌", "AC 98%,용접용", "이음", "MT003163"), # 74
("서비스볼트(고장력볼트)", "M20×60", "이음", "MT002536"), # 75
("보조강재(열연강판)", "T=9-12", "후보", None), # 76
("보조강재(열연강판)", "T=9-12", "이음", "MT030619"), # 76 — 827-2 규칙 B
("구역화물자동차(8Ton)", "10km이내", "이음", "MT023654"), # 77
("구역화물자동차(8Ton)", "20km이내", "이음", "MT023655"), # 78
("참싸리(박피)", "", "없음", None), # 79
@@ -106,15 +106,15 @@ TABLE = [
("P.V.C PIPE", "VG1(35mm)", "이음", "MT002859"), # 87
("휠라", "T=15mm,1000×1000mm", "없음", None), # 88
("측구수로관", "300×300×2000×120,앵글포함", "이음", "MT015883"), # 89
("중하중그레이팅", "395×995×52", "후보", "MT007025"), # 90
("중하중그레이팅", "395×995×52", "이음", "MT007025"), # 90 — 827-2 규칙 E
("측구수로관", "500×500×2000×150,앵글포함", "이음", "MT015887"), # 91
("중하중그레이팅", "595×995×67", "후보", "MT007032"), # 92
("중하중그레이팅", "595×995×67", "이음", "MT007032"), # 92 — 827-2 규칙 E
("잔디", "0.18×0.18×0.01", "이음", "MT006796"), # 93
("강관비계", "4000×48.6mm×2.3mm", "이음", "MT000481"), # 94
("삼각표지판", "삼각,900mm", "후보", None), # 95
("표지판지주", "D=76.3mm×3.0m", "이음", "MT003241"), # 96
("공사안내표지판", "300×1500", "후보", None), # 97
("공사안전PE드럼", "500×800", "후보", "MT003311"), # 98
("공사안전PE드럼", "500×800", "이음", "MT003311"), # 98 — 827-2 규칙 E
("윙카호스", "10cm", "후보", None), # 99
("조합페인트", "KSM6020 2급,백색", "이음", "MT003372"), # 100
]
@@ -139,7 +139,7 @@ def test_target_rows(name, spec, want, key):
def test_counts():
linked = sum(1 for *_x, w, _k in TABLE if w == "이음")
assert linked == 51
assert linked == 58
@pytest.mark.parametrize(
@@ -0,0 +1,194 @@
"""827-2 불러오기 재료 찾기 — 별도계상 품목 · 단서 넷 · 자동 규칙 A ~ F2 · 판정 네 갈래 · 별칭 안 만듦(요구 810 ~ 812).
판정 = `M02_San_MatFind`(`M02_San_Link.m01` 이 부름 · 줄 `자원["재료찾기"]`). 실무 원파일 시험은 파일 없으면 건너뜀.
"""
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "master_data" / "scripts"), str(ROOT / "resources" / "tester")] # fmt: skip
from M02_MasterTemplete import M02_Elem_Alias as ea # noqa: E402
from M02_MasterTemplete import M02_Hopyo_MatSame as ms # noqa: E402
from M02_MasterTemplete import M02_San_Link as sl # noqa: E402
from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402
def _go(name, spec, unit, label="", code=""):
src = {"이름": name, "규격": spec, "단위": unit, "갈래": "재료", "코드": code}
key, mark = sl.m01("재료", src, label)
return key, mark, src["재료찾기"]
# ── 자동 규칙(걸음 2) — 맞는 보기 ──────────────────────────────────────────
@pytest.mark.parametrize(
"name,spec,unit,rule,key",
[
("유압식드릴스리브", "T38×190mm", "EA", "E", "MT005687"),
("중하중그레이팅", "395×995×52", "EA", "E", "MT007025"),
("공사안전PE드럼", "500×800", "EA", "E", "MT003311"),
("아세틸렌", "용접용", "kg", "C", "MT003163"),
("요소", "", "KG", "A", "MT006763"),
("보조강재(열연강판)", "T=9-12", "kg", "B", "MT030619"),
("미송(판재)", "", "M3", "F", "MT000366"),
("각재 (외송)", "", "M3", "F", "MT000339"),
("각재", "외송", "M3", "F2", "MT000339"),
],
)
def test_rule_links(name, spec, unit, rule, key):
got, _mark, found = _go(name, spec, unit)
assert got == key and found["판정"] == mf.LINKED and found["방법"] == f"규칙 {rule}"
# ── 틀리면 안 되는 보기 ───────────────────────────────────────────────────
def _keys_named(word):
return {k for k, r in ea.book()["줄"]["MT"].items() if word in str(r.get("이름"))}
def test_no_wrong_rule_links():
nawang = _keys_named("나왕")
assert nawang
for name, spec in (
("각재(외송)", ""),
("각재 (외송)", ""),
("각재", "외송"),
("각재(외송)", "3.6×3.0×3.0"),
):
assert _go(name, spec, "M3")[0] not in nawang
me = ms._item("치즐", "0.7m3")
assert mf._rule_b({"이름": "치즐", "규격": "0.7m3", "단위": "EA"}, me) in (None, "MT000011")
assert _go("치즐", "0.7m3", "EA")[0] == "MT000011" # 이미 이은 줄 그대로
bit = _go("BUTTON BIT", "Ø38×76㎜", "EA")
assert bit[0] is None and bit[2]["판정"] == mf.NONE
rod = _go("PVC용접봉", "Ø3.2 (국산)", "KG")
assert rod[0] is None and rod[2]["판정"] == mf.NONE
for name in ("에폭시수지",):
assert _go(name, "YD-128", "kg")[0] is None
def test_doubted_three():
"""조사 810-2 의심 셋 — 블레이드 · 드릴롯드는 풀고 · 참싸리(박피)는 사용자 수동연결 과녁(817-1 #33)대로."""
assert _go("블레이드", "d=320~400mm t=3.2", "EA")[0] != "MT005744"
assert _go("유압식드릴롯드", "T38×R32x3660mm", "EA")[0] != "MT005695"
assert _go("산림종자", "참싸리(박피)", "KG")[0] == "MT006664"
assert _go("블레이드(커터)", 'd=320 400mm 14"xt3.2', "EA")[0] == "MT005749"
# ── 판정 네 갈래 ──────────────────────────────────────────────────────────
def test_four_verdicts():
assert _go("유압식드릴스리브", "T38×190mm", "EA")[2]["판정"] == mf.LINKED
no_spec = _go("철물", "", "EA")[2]
assert no_spec["판정"] == mf.NO_SPEC and no_spec["후보"][0]["키"] == "MT000511"
many = _go("결속선(철선)", "#20 , 0.9M/M", "KG")[2]
assert many["판정"] == mf.MANY and len(many["후보"]) > 1 and many["키"] is None
for name, spec, unit in (("떼", "", "m2"), ("BUTTON BIT", "Ø38×76㎜", "EA")):
assert _go(name, spec, unit)[2]["판정"] == mf.NONE
linked = _go("결속선(철선)", "#20 , 0.9M/M", "KG")[2]
assert set(linked) == {"판정", "방법", "키", "후보", "단서"}
# ── 단서 넷(걸음 1) ───────────────────────────────────────────────────────
BOQ = [ # STC 내역서 자재 직접 줄(동부청영월 꼴)
{"번": "1", "이름": "파형강관(2.7T)-영월", "규격": "Φ800-1RS", "단위": "M", "코드": "M01092"},
{"번": "2", "이름": "이형철근", "규격": "D13~D32", "단위": "Ton", "코드": "M00654"},
{"번": "3", "이름": "파형강관부설", "규격": "Φ800", "단위": "M", "코드": "D00071"},
]
def test_clue_label():
got, _mark, found = _go("철물", "", "EA", label="5) 철물(앵커용)")
assert (
got == "MT000511"
and found["방법"] == "단서"
and found["단서"] == ["설명 글 — 철물(앵커용)"]
)
def test_clue_head_and_boq_apart():
clues = mf.from_boq(BOQ)
assert [c["코드"] for c in clues] == ["M01092", "M00654"] # 자재(M) 줄만
with mf.clues_on(clues, "파형강관부설 Φ800mm"):
key, mark, found = _go("파형강관", "별도계상", "M")
assert key is None and mark == f"{sl.NOTE}{sl.APART}" # 관급 · 일반 둘 = 사람 몫 · 이음 비움
assert found["판정"] == mf.NO_SPEC
assert {c["키"] for c in found["후보"][:2]} == {"MT015181", "MT015282"}
assert all(c.get("단서") == "단서" for c in found["후보"][:2])
assert found["단서"][0] == "산근 머리 — 파형강관부설 Φ800mm"
with mf.clues_on(clues, "철근가공조립(현장) TYPE Ⅰ"):
found = _go("이형철근", "별도계상", "Ton")[2]
assert found["판정"] == mf.NO_SPEC and found["단서"] == [] # 범위 D13~D32 = 규격 하나 아님
def test_clue_book_sheet():
meta = "_x0007_`COD|{}_x0005_`EXI|0_x0005_`"
rows = [
["파형강관부설", "D=800mm", None, "m", *[None] * 8, "대가 17", None, "B01217"],
[
"파형강관(800mm)",
"별도계상",
1,
"M",
*[0] * 8,
"별산자재 19 ",
meta.format("M00222"),
"M00222_1",
], # fmt: skip
["보통인부", None, 0.66, "인", *[0] * 8, "노무 1 ", meta.format("L00001"), "L00001_1"],
]
got = mf.from_book(rows)
assert got == [{"곳": "일위대가표", "코드": "M00222", "이름": "파형강관(800mm)", "규격": "별도계상", "머리": "파형강관부설 D=800mm"}] # fmt: skip
with mf.clues_on(got, ""):
found = _go("파형강관", "별도계상", "M", code="M00222")[2]
assert found["판정"] == mf.NO_SPEC and all("800" in c["규격"] for c in found["후보"][:3])
assert any(x.startswith("일위대가표") for x in found["단서"])
def test_apart_linked_item_keeps_zero():
"""별도계상 줄 = 품목이 하나로 정해지면 판정 키 · 줄 이음은 비움(셈 제외 · 단가 0 그대로)."""
key, mark, found = _go("요소(별도계상)", "", "KG")
assert key is None and found["키"] == "MT006763" and mark == f"{sl.NOTE}{sl.APART_ITEM}"
assert sl.why({"자원": {"갈래": "재료", "이름": "요소(별도계상)", "규격": "", "재료찾기": found}}) == sl.APART_ITEM # fmt: skip
assert sl.tally({"호표": [{"이름": "요소", "자원": {"재료찾기": found}}]})["됨"] == 1
# ── 별칭 안 만듦 ──────────────────────────────────────────────────────────
def test_no_alias_written():
before = {k: r.get("별칭") for k, r in ea.book()["줄"]["MT"].items()}
for args in (
("각재", "외송", "M3"),
("종자", "초본류", "KG"),
("철물", "", "EA"),
("떼", "", "m2"),
):
_go(*args)
assert {k: r.get("별칭") for k, r in ea.book()["줄"]["MT"].items()} == before
# ── 실무 원파일(있을 때) — 단서가 원파일 · 엑셀에서 읽힘 ─────────────────────
def test_real_files_carry_clues():
import test_797_1a_import_flags as practice
import test_810_1_stc as c
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_San_Xlsx as sx
stc, xl = c.FILES["동부청영월"], practice.FILES.get("울진대흥")
if not stc.exists() or not xl or not xl.exists():
pytest.skip("실무 원파일 없음")
saved = fn.read(stc.name, stc.read_bytes())
assert any(x["이름"].startswith("파형강관") for x in saved["단서"])
book = fn.pack(sx.read(xl.name, xl.read_bytes()), xl.name)
assert any(x["곳"] == "일위대가표" and x.get("머리") for x in book["단서"])
out = fn.emit(saved, 71) # 파형강관부설 Φ800mm
line = next(
h for h in out["로직"]["호표"] if str((h.get("자원") or {}).get("규격")) == "별도계상"
)
assert (
line["이음"] is None
and not line.get("단가")
and not any((line.get("값묶음") or {}).values())
)
assert {c["키"] for c in line["자원"]["재료찾기"]["후보"][:2]} == {"MT015181", "MT015282"}