- M02_San_Xlsx_Ilwi(새) = 일위대가표 읽기 한 곳 — N 열 메타(COD · IPR · PRI · PRO · EQC · KWN · DVD) · O 열 차수 꼬리 기준 · 줄 꼴 = 828-1 M02_San_Ilwi 꼴 + 엑셀 칸(행 · 차수 · 비고 · 전체 · 율.코드 · 메타) · 숨은 칸 없음 = 옛 코드 찾기 · 단산 길 일위 빈 목록 + 표지 - 5 건 호표 136 · 줄 706 · STC 구성 줄과 코드 · 갈래 · 수량 · 율 · 밑줄 같음(다른 5 줄 = STC 별도계상 규칙이 비고 별산 표시를 놓침) - 호표 길(M02_Hopyo_Stmate._hopyos) · 단산 길(M02_San_Xlsx_Stmate.read 일위) · 단서(M02_San_MatFind.from_book) 모두 공용 읽기 · 단서 · 산근 · 단가 전후 같음 - M02_Hopyo_Fixed._ratio = 숨은 칸 율 밑 · 대상 비목 먼저(글 짐작 대신) — 호표 길 전후 바뀜 = 영덕 제11호표 산악할증 0% 한 줄(금액만 → 비율 노무비 · 금액 0 그대로) · 단산 항목 1564 판정 그대로 - 재료 판(census) = 일위 구성 줄 자재도 셈(M02_San_MatFind.ilwi_found · 종 칸 일위 = 호표 코드 · 화면 「일위대가 N 호표」) - 시험: 새 test_828_2_xlsx_ilwi_meta · test_827_2 단서 시트 보기에 「제N호표」 줄 넣음(실제 시트는 늘 있음 · 공용 읽기가 호표 머리부터 셈) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
17 KiB
Python
376 lines
17 KiB
Python
"""불러오기 재료 찾기 판정 · 단서 · 자동 규칙(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("이름"):
|
||
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())
|