- 새 M02_Doc_Match_Value — 같은 기종(이름 머리 + 괄호 낱말 · 조합 구성) 규격만 다름 = 참고 「규격」 + 바꿈 · 같은 자원 수량 다름 = 참고 「수량 끝자리」 · 「품 숫자」 · 「수량」 · 같은 배 두 줄 넘음 = 단위 환산(로직) · 기계 한 묶음 + 작업량 식 = 「기계 작업」(수량 · 규격까지 같은 기초만 확정) · 작업량 틀 꼴 참고 · 옵션 값으로 못 가름 = 「옵션 확인」 - M02_Doc_Match — 사실에 단가 기호 · 규격 · 수량 글 · 조합 표지 · 값 참고 적은 조합 먼저 · 선택 글 근거 쉼표 무시(D1,800 = D1800) · 같은 자원 아닌 수량 짝 = 그 로직 까닭 - M02_Doc_MatchSk — 바꾸기 뒤 사본 문서 기계 입력(원문번호) · 줄 이름 · 규격 · 묶음 제목 = 가져온 규격(수식 길 저장) · 되돌리기 = 보관 몸 수식 사본 그대로 - 띠 — 기계 작업 「기계 한 줄 단가산출 — 그대로 씀 · 값은 [단가 갱신]으로 우리 값」 · 참고 요약에 규격 · 수량 · 옵션 확인 - helper_810_4_sweep — 과녁 = 부품 뺀 문서(화면과 같음) - 시험 고침: 810_4 단위 환산 = 같은 배 두 줄(한 줄만 배 = 수량 값) · 812_2 기계 규격 = 무한궤도 ↔ 타이어(같은 기종 규격 = 값) - 전수 2,220: 확정 184 → 337 · 근접 576 → 848 · 없음 1,460 → 837 · 기계 작업 198 · 확정 다시 셈 다름 0 · 목록 _목록_812-11_연결판정.xlsx Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
696 lines
30 KiB
Python
696 lines
30 KiB
Python
"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독.
|
||
|
||
가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어
|
||
「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정).
|
||
① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 ·
|
||
품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만.
|
||
② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 ·
|
||
값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을
|
||
문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터.
|
||
③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기종 · 단위 환산 ·
|
||
비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 % · 같은 기종 규격 · 수량
|
||
(값 — 확정을 안 막음 · 812-11 `M02_Doc_Match_Value`) — 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고
|
||
핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」 · 값이 맞는 조합 없으면 기본 옵션 + 「옵션 확인」.
|
||
⓪ 기계 한 묶음 + 작업량 식 수량 = 「기계 작업」(우리 기초에 안 붙임 · 요구 728 · 735-4).
|
||
결과 `{판정, 키, 이름, 옵션, 다른, 참고, 바꿈, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보` ·
|
||
`바꿈` = [바꾸기] 때 갈아 끼울 기계 줄 [{기호(우리 단가 기호), 기계(가져온 원문번호), 이름, 규격}].
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import functools
|
||
import itertools
|
||
import json
|
||
import re
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||
from M02_MasterTemplete import M02_Doc_Match_Note as note
|
||
from M02_MasterTemplete import M02_Doc_Match_Value as val
|
||
|
||
md, mc, ml = dc.md, dc.mc, dc.ml
|
||
if not hasattr(
|
||
md.parse, "cache_info"
|
||
): # 식 글 → 나무 = 순수 함수 · 조합마다 같은 글을 다시 풀지 않음
|
||
md.parse = functools.lru_cache(maxsize=50000)(md.parse)
|
||
|
||
KEY = "연결후보"
|
||
SURE, NEAR, NONE = "확정", "근접", "없음"
|
||
CAP_LINE = 3000 # 줄 하나 좁힐 때 돌리는 조합 상한(넘으면 그 줄은 좁힘에 안 씀)
|
||
CAP_ALL = 192 # 문서 하나 끝 셈 조합 상한
|
||
CAP_INPUT = 12 # 입력 변수 후보 수 상한
|
||
NICE = (2, 3, 4, 5, 6, 8, 10, 12, 100, 1000) # 단위 환산으로 보는 배
|
||
_SEC = re.compile(r"(?<![\d-])(\d{1,2}(?:-\d{1,2}){1,3})(?![\d-])")
|
||
_NUMS = re.compile(r"\d+(?:\.\d+)?")
|
||
_UNIT = {"m3": "㎥", "m2": "㎡", "m": "m", "ea": "개", "톤": "ton", "t": "ton", "hr": "시간"}
|
||
|
||
|
||
# ── 수 ────────────────────────────────────────────────────────────────
|
||
def _key(v: Any) -> Any:
|
||
"""비교 열쇠 — 수 = 유효 12 자리(Decimal 셈 자리 차이만 지움) · 묶음 = 비목 튜플."""
|
||
if isinstance(v, dict):
|
||
return tuple(_key(v.get(k)) for k in md.COSTS)
|
||
if isinstance(v, Decimal):
|
||
try:
|
||
return v.normalize() if v == 0 else Decimal(f"{v:.12g}").normalize()
|
||
except InvalidOperation:
|
||
return None
|
||
return v
|
||
|
||
|
||
def unit_of(text: Any) -> str:
|
||
"""결과단위 「원/㎡」 → 「㎡」(소문자 · m3 → ㎥ 등) · 돈 아닌 단위 = ""."""
|
||
text = str(text or "").strip()
|
||
if not text.startswith("원/"):
|
||
return ""
|
||
unit = text[2:].strip().lower().replace(" ", "")
|
||
return _UNIT.get(unit, unit)
|
||
|
||
|
||
def sections(texts: list[str]) -> set[str]:
|
||
"""품셈 절 번호(「6-3-1」) — 앞 둘 · 셋 마디 모두."""
|
||
out = set()
|
||
for t in texts:
|
||
for s in _SEC.findall(str(t or "")):
|
||
parts = s.split("-")
|
||
out |= {"-".join(parts[:n]) for n in range(2, len(parts) + 1)}
|
||
return out
|
||
|
||
|
||
# ── 줄 사실 ───────────────────────────────────────────────────────────
|
||
def _single(text: Any) -> str | None:
|
||
try:
|
||
node = md.parse(text)
|
||
except md.DocError:
|
||
return None
|
||
return node[1] if node[0] == "var" else None
|
||
|
||
|
||
def _what(doc: dict, line: dict) -> tuple[str, str]:
|
||
"""금액 줄 자원 → (갈래, 코드) · 갈래 = 노임 · 재료 · 기계 · 산출 · 율 · 수."""
|
||
sym = _single(line.get("단가"))
|
||
var = next((v for v in doc.get("변수") or [] if sym and md.var_id(v) == sym), None)
|
||
if var is None:
|
||
return "율", ""
|
||
link = var.get("연결") or {}
|
||
kind, key = link.get("갈래"), str(link.get("키") or "")
|
||
if kind == "요소":
|
||
if key.startswith("LB"):
|
||
return "노임", key
|
||
if key.startswith("EQ"):
|
||
return "기계", key
|
||
return "재료", key
|
||
if kind == "단가산출":
|
||
given = link.get("입력") or {}
|
||
code = str(given.get("기계") or "").strip("'")
|
||
return ("기계", code) if "기계" in given else ("산출", key)
|
||
if kind == "숫자" and isinstance(link.get("값"), dict):
|
||
return "산출", ""
|
||
if kind == "식":
|
||
return "율", ""
|
||
return "수", ""
|
||
|
||
|
||
def facts(doc: dict, got: dict) -> list[dict]:
|
||
"""셈 답 줄 → 대조 사실 — 소계 보임(비목 없는 합 밖) · 꺼진 줄(수량 0 · 금액 0) 뺌."""
|
||
out = []
|
||
money = doc.get("금액") or []
|
||
for i, (line, row) in enumerate(zip(money, got.get("줄") or [])):
|
||
if line.get("합밖") and not line.get("비목"):
|
||
continue
|
||
if row.get("금액") is not None and row["금액"] == 0 and row.get("수량") == 0:
|
||
continue
|
||
kind, code = _what(doc, line)
|
||
if kind == "기계" and code in (got.get("값") or {}): # 옵션 기호 = 그 값(기계 코드)
|
||
code = mc.plain_text(got["값"][code])
|
||
if kind == "율" or (kind == "수" and line.get("수량") in (None, "")):
|
||
kind = "율"
|
||
out.append(
|
||
{
|
||
"번": i,
|
||
"이름": str(line.get("이름") or ""),
|
||
"갈래": kind,
|
||
"코드": code,
|
||
"수량": _key(row.get("수량")) if kind != "율" else None,
|
||
"단가": _key(row.get("단가")) if kind != "율" else None,
|
||
"비목": {k: _key(v) for k, v in (row.get("비목") or {}).items() if v},
|
||
"금액": _key(row.get("금액")),
|
||
"합밖": bool(row.get("합밖")),
|
||
"기호": _single(line.get("단가")),
|
||
"규격": str(line.get("규격") or ""),
|
||
"수량글": str(line.get("수량") or ""),
|
||
}
|
||
)
|
||
val.mark_combo(doc, out)
|
||
note.rates(doc, got, out)
|
||
return out
|
||
|
||
|
||
def resources(doc: dict) -> dict[str, set]:
|
||
"""문서가 쓸 수 있는 자원(옵션 무관) — {노임 키, 재료, 기계}."""
|
||
out: dict[str, set] = {"노임": set(), "재료": set(), "기계": set()}
|
||
for line in doc.get("금액") or []:
|
||
kind, code = _what(doc, line)
|
||
if kind == "노임":
|
||
out["노임"].add(code)
|
||
elif kind == "재료":
|
||
out["재료"].add(code or "고름")
|
||
elif kind in ("기계", "산출"):
|
||
out["기계"].add(code or kind)
|
||
return out
|
||
|
||
|
||
# ── 연결 기억 ─────────────────────────────────────────────────────────
|
||
class Memo:
|
||
"""M01 연결 + 셈 기억(요소 · 표 · 부른 단가산출) — 같은 값을 조합마다 다시 찾지 않음."""
|
||
|
||
def __init__(self, links: Any):
|
||
self.links, self.hits, self.calls = links, {}, {}
|
||
|
||
def _hit(self, tag: str, p: dict, fn: Any) -> Any:
|
||
key = (tag, json.dumps(p, sort_keys=True, ensure_ascii=False, default=str))
|
||
if key not in self.hits:
|
||
try:
|
||
self.hits[key] = (True, fn(p))
|
||
except (mc.Gap, mc.Broken) as e:
|
||
self.hits[key] = (False, e)
|
||
ok, got = self.hits[key]
|
||
if not ok:
|
||
raise got
|
||
return got
|
||
|
||
def element(self, p: dict) -> Any:
|
||
return self._hit("요소", p, self.links.element)
|
||
|
||
def table(self, p: dict) -> Any:
|
||
return self._hit("표", p, self.links.table)
|
||
|
||
def doc(self, key: str) -> dict | None:
|
||
return self.links.doc(key)
|
||
|
||
|
||
# ── ① 후보 ────────────────────────────────────────────────────────────
|
||
class Index:
|
||
"""M01 수식 문서 색인(결과단위 · 자원 · 절) — 프로세스 안 한 번."""
|
||
|
||
def __init__(self, docs: dict[str, dict]):
|
||
self.docs = docs
|
||
self.by_unit: dict[str, list[str]] = {}
|
||
self.res: dict[str, dict[str, set]] = {}
|
||
self.secs: dict[str, set[str]] = {}
|
||
self.regions: set[str] = set()
|
||
for key, doc in docs.items():
|
||
for v in doc.get("변수") or []:
|
||
if str(v.get("열쇠") or "").endswith("지역"):
|
||
self.regions |= {str(x) for x in (v.get("연결") or {}).get("목록") or []}
|
||
unit = unit_of(doc.get("결과단위"))
|
||
if not unit or not doc.get("금액"):
|
||
continue
|
||
self.by_unit.setdefault(unit, []).append(key)
|
||
self.res[key] = resources(doc)
|
||
self.secs[key] = sections([doc.get("출처")])
|
||
|
||
def candidates(self, doc: dict) -> list[str]:
|
||
mine = resources(doc)
|
||
keys = []
|
||
for key in self.by_unit.get(unit_of(doc.get("결과단위")), []):
|
||
have = self.res[key]
|
||
if not mine["노임"] <= have["노임"]:
|
||
continue
|
||
if mine["재료"] and not have["재료"]:
|
||
continue
|
||
if mine["기계"] and not have["기계"]:
|
||
continue
|
||
if not (mine["노임"] or mine["재료"] or mine["기계"]):
|
||
continue
|
||
keys.append(key)
|
||
secs = sections([x.get("글") for x in (doc.get("가져온") or {}).get("추정") or []])
|
||
hit = [k for k in keys if secs & self.secs[k]]
|
||
return hit or keys
|
||
|
||
|
||
# ── ② 옵션 풀이 ───────────────────────────────────────────────────────
|
||
def _closure(doc: dict) -> dict[str, set[str]]:
|
||
"""기호 → 그 기호가 (거쳐서) 부르는 이름 전부."""
|
||
direct: dict[str, set[str]] = {}
|
||
for v in doc.get("변수") or []:
|
||
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
|
||
for line in doc.get("식") or []:
|
||
try:
|
||
direct[str(line.get("기호"))] = set(md.names(md.parse(line.get("식"))))
|
||
except md.DocError:
|
||
direct[str(line.get("기호"))] = set()
|
||
memo: dict[str, set[str]] = {}
|
||
|
||
def reach(name: str, seen: tuple = ()) -> set[str]:
|
||
if name in memo:
|
||
return memo[name]
|
||
out = set()
|
||
for n in direct.get(name, ()):
|
||
if n not in seen:
|
||
out |= {n} | reach(n, (*seen, name))
|
||
memo[name] = out
|
||
return out
|
||
|
||
return {n: reach(n) for n in direct}
|
||
|
||
|
||
def _uses(text: Any, close: dict) -> set[str] | None:
|
||
try:
|
||
names = md.names(md.parse(text)) if text not in (None, "") else []
|
||
except md.DocError:
|
||
return None
|
||
out = set(names)
|
||
for n in names:
|
||
out |= close.get(n, set())
|
||
return out
|
||
|
||
|
||
def numbers_of(doc: dict) -> list[Decimal]:
|
||
"""가져온 식에 나온 수(숫자 변수 · 식 · 수량 글 · 규격 · 이름)."""
|
||
got: list[Decimal] = []
|
||
texts = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
|
||
for v in doc.get("변수") or []:
|
||
val = (v.get("연결") or {}).get("값")
|
||
if not isinstance(val, dict):
|
||
texts.append(val)
|
||
texts += [x.get("식") for x in doc.get("식") or []]
|
||
texts += [x.get("수량") for x in doc.get("금액") or []]
|
||
texts += [x.get("규격") for x in doc.get("금액") or []]
|
||
for t in texts:
|
||
for s in _NUMS.findall(str(t or "")):
|
||
d = Decimal(s).normalize()
|
||
if d not in got:
|
||
got.append(d)
|
||
return got
|
||
|
||
|
||
def _words(doc: dict) -> str:
|
||
"""가져온 식 글(이름 · 규격 · 줄 규격 · 부름 입력 값 — 줄 이름 뺌) — 선택 값의 글 근거."""
|
||
bits = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
|
||
bits += [x.get("규격") for x in doc.get("금액") or []]
|
||
for v in doc.get("변수") or []:
|
||
bits += [str(x) for x in ((v.get("연결") or {}).get("입력") or {}).values()]
|
||
return " ".join(str(b or "") for b in bits).replace(" ", "").replace(",", "")
|
||
|
||
|
||
def _bare(o: Any) -> str:
|
||
return str(o).replace(" ", "").replace(",", "") # 「D1,800」 = 글 「D1800」
|
||
|
||
|
||
def domains(
|
||
doc: dict, imported: dict, regions: set[str] = frozenset()
|
||
) -> tuple[dict[str, list], dict[str, set]]:
|
||
"""선택 · 입력 변수 → (돌릴 값, 글 근거) — 기본 먼저 · 가져온 글에 든 선택 값(근거) 먼저 ·
|
||
글 입력 지역 = 가져온 글에 든 지역 이름."""
|
||
words, nums = _words(imported), numbers_of(imported)
|
||
out: dict[str, list] = {}
|
||
ev: dict[str, set] = {}
|
||
for v in doc.get("변수") or []:
|
||
link = v.get("연결") or {}
|
||
kind, base = link.get("갈래"), link.get("값")
|
||
if kind == "선택":
|
||
opts = list(link.get("목록") or [])
|
||
seen = [o for o in opts if _bare(o) in words and len(str(o)) > 1]
|
||
seen.sort(key=lambda o: -len(str(o)))
|
||
if seen:
|
||
ev[md.var_id(v)] = set(seen)
|
||
rest = [o for o in opts if o not in seen]
|
||
first = [base] if base in rest else []
|
||
out[md.var_id(v)] = seen + first + [o for o in rest if o != base]
|
||
elif kind == "입력":
|
||
try:
|
||
d = Decimal(str(base).replace(",", "")).normalize()
|
||
except (InvalidOperation, ValueError):
|
||
if str(v.get("열쇠") or "").endswith("지역"):
|
||
seen = sorted((r for r in regions if len(r) > 1 and r in words), key=len)
|
||
out[md.var_id(v)] = list(dict.fromkeys([base, *seen[::-1]]))
|
||
continue # 그 밖 글 입력 = 기본 그대로
|
||
low, high = (mc.number(x) for x in (link.get("범위") or [None, None]))
|
||
vals = [d, Decimal(0)] + nums
|
||
vals = [x for x in vals if (low is None or x >= low) and (high is None or x <= high)]
|
||
out[md.var_id(v)] = list(dict.fromkeys(vals))[:CAP_INPUT]
|
||
return out, ev
|
||
|
||
|
||
def _given(doc: dict, pick: dict) -> dict:
|
||
ids = {md.var_id(v): v.get("열쇠") or md.var_id(v) for v in doc.get("변수") or []}
|
||
return {ids[k]: v for k, v in pick.items()}
|
||
|
||
|
||
def _eval(doc: dict, links: Any, pick: dict, text: Any, memo: dict) -> Any:
|
||
run = mc._Run(doc, links, _given(doc, pick), (str(doc.get("키")),), links.calls)
|
||
got = run.expr(text, "줄")
|
||
if isinstance(got, Decimal) and got == 0:
|
||
return 0
|
||
return _key(got) if isinstance(got, (Decimal, dict)) else None
|
||
|
||
|
||
def narrow(
|
||
doc: dict, links: Any, dom: dict, want: dict[str, set], ev: dict[str, set] | None = None
|
||
) -> dict[str, list]:
|
||
"""줄마다 의존 변수만 돌려 가져온 값(수량 · 단가) · 0 에 닿는 값만 남김(두 바퀴) ·
|
||
글 근거 값(`ev`)은 안 지움(값이 달라도 그 조합을 셈 — 다른 항으로 드러냄)."""
|
||
ev = ev or {}
|
||
close = _closure(doc)
|
||
memo: dict = {}
|
||
base = {k: vs[0] for k, vs in dom.items()}
|
||
jobs = []
|
||
for line in doc.get("금액") or []:
|
||
for slot, field in (("수량", "수량"), ("단가", "단가")):
|
||
uses = _uses(line.get(field), close)
|
||
if uses is None or uses & (set(md.MONEY_NAMES) | _money_syms(doc)):
|
||
continue
|
||
free = sorted(u for u in uses if u in dom)
|
||
if free:
|
||
jobs.append((line.get(field), free, want[slot]))
|
||
for _ in range(2):
|
||
for text, free, targets in jobs:
|
||
space = [dom[f] for f in free]
|
||
if _size(space) > CAP_LINE:
|
||
continue
|
||
ok: dict[str, set] = {f: set() for f in free}
|
||
for combo in itertools.product(*space):
|
||
pick = {**base, **dict(zip(free, combo))}
|
||
got = _eval(doc, links, pick, text, memo)
|
||
if got == 0 or got in targets:
|
||
for f, c in zip(free, combo):
|
||
ok[f].add(c)
|
||
if all(ok.values()):
|
||
for f in free:
|
||
ok[f] |= ev.get(f, set())
|
||
dom[f] = [x for x in dom[f] if x in ok[f]]
|
||
base[f] = dom[f][0]
|
||
return dom
|
||
|
||
|
||
def _money_syms(doc: dict) -> set[str]:
|
||
return {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
|
||
|
||
|
||
def _size(space: list[list]) -> int:
|
||
n = 1
|
||
for s in space:
|
||
n *= max(len(s), 1)
|
||
return n
|
||
|
||
|
||
def combos(doc: dict, dom: dict) -> list[dict]:
|
||
"""좁힌 값 조합(기본 쪽 먼저 · 상한 `CAP_ALL`) — 한 값뿐인 변수는 그 값."""
|
||
keys = [k for k, v in dom.items() if v]
|
||
out = []
|
||
for combo in itertools.product(*(dom[k] for k in keys)):
|
||
out.append(dict(zip(keys, combo)))
|
||
if len(out) >= CAP_ALL:
|
||
break
|
||
return out
|
||
|
||
|
||
# ── ③ 줄 대조 ─────────────────────────────────────────────────────────
|
||
def _ratio(a: Any, b: Any) -> str | None:
|
||
if not isinstance(a, Decimal) or not isinstance(b, Decimal) or not a or not b:
|
||
return None
|
||
for n in NICE:
|
||
if _key(a * n) == b:
|
||
return f"× {n}"
|
||
if _key(b * n) == a:
|
||
return f"÷ {n}"
|
||
return None
|
||
|
||
|
||
def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||
"""가져온 줄(mine) ↔ M01 줄(ours) → {같음(로직 같은 줄), 짝, 핵심, 다른 [{항, 글}], 참고 [{항, 글}]}."""
|
||
left, right = list(mine), list(ours)
|
||
same = paired = 0
|
||
diffs: list[dict] = []
|
||
|
||
def take(test) -> list[tuple[dict, dict]]:
|
||
hits = []
|
||
for a in list(left):
|
||
b = next((x for x in right if test(a, x)), None)
|
||
if b is not None:
|
||
left.remove(a)
|
||
right.remove(b)
|
||
hits.append((a, b))
|
||
return hits
|
||
|
||
def core(a: dict, b: dict) -> bool:
|
||
return a["갈래"] != "율" and b["갈래"] != "율"
|
||
|
||
def cls(a: dict, b: dict) -> bool:
|
||
return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"}
|
||
|
||
notes: list[dict] = []
|
||
exact = take(
|
||
lambda a, b: (
|
||
core(a, b)
|
||
and a["수량"] == b["수량"]
|
||
and a["단가"] == b["단가"]
|
||
and a["비목"] == b["비목"]
|
||
and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조
|
||
and val.same_combo(a, b)
|
||
)
|
||
)
|
||
same += len(exact)
|
||
|
||
def twin(a: dict, b: dict) -> bool:
|
||
return core(a, b) and cls(a, b) and a["수량"] == b["수량"]
|
||
|
||
for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저
|
||
same += 1
|
||
notes += note.pair(a, b)[1]
|
||
for a, b in take(twin):
|
||
bad, ref = note.pair(a, b)
|
||
same, paired = (same + 1, paired) if not bad else (same, paired + 1)
|
||
diffs += bad
|
||
notes += ref
|
||
bad, ref, n = val.pairs(take, core, cls, note.pair, _ratio) # 같은 자원 수량 다름 = 값(812-11)
|
||
same, paired, diffs, notes = same + n, paired + len(bad), diffs + bad, notes + ref
|
||
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]):
|
||
paired += 1
|
||
how = _ratio(a["수량"], b["수량"])
|
||
what = "단위 환산" if how else "수량"
|
||
say = {"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"}
|
||
diffs += note.pair(a, b)[0] or [say] # 같은 자원 아님 = 그 로직 까닭(비목 · 자원)
|
||
for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"):
|
||
paired += 1
|
||
diffs.append(
|
||
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"}
|
||
)
|
||
rate_diffs, rate_notes = note.rate_pairs(left, right, take)
|
||
diffs += rate_diffs
|
||
notes += rate_notes
|
||
for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)):
|
||
for x in rows:
|
||
if x["갈래"] == "율":
|
||
what = "공구손료 유무" if "공구" in x["이름"] else "율 줄"
|
||
else:
|
||
what = "줄 없음"
|
||
diffs.append({"항": what, "글": f"{side} — {x['이름']}"})
|
||
n_core = sum(1 for a in mine if a["갈래"] != "율")
|
||
swaps = [x.pop("바꿈") for x in notes if "바꿈" in x]
|
||
return {
|
||
"같음": same,
|
||
"짝": same + paired,
|
||
"핵심": n_core,
|
||
"다른": diffs,
|
||
"참고": notes,
|
||
"바꿈": swaps,
|
||
}
|
||
|
||
|
||
def _totals(got: dict) -> dict:
|
||
return {k: _key(mc.number(got.get(k))) for k in md.BUNDLE}
|
||
|
||
|
||
EXPLAINED = (
|
||
"이름 다름",
|
||
"자원",
|
||
"산출 단가",
|
||
"율 대상",
|
||
"단위 환산",
|
||
"기계 규격",
|
||
"비목",
|
||
"율 줄",
|
||
"공구손료 유무",
|
||
)
|
||
|
||
|
||
def verdict(
|
||
mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = ()
|
||
) -> dict:
|
||
"""확정 = 다른 항 0(옵션 글 근거까지 맞음 · 참고는 있어도 됨) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄
|
||
있음 또는 다른 항이 모두 까닭 있는 갈래 — 자원 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음 ·
|
||
끝수 = 줄 로직 · 단가 모두 같은데 합만 다름(참고)."""
|
||
res = compare(mine, ours)
|
||
res["다른"] = [*off, *res["다른"]]
|
||
ta, tb = _totals(got_a), _totals(got_b)
|
||
if not res["다른"] and not res["참고"] and ta != tb:
|
||
res["참고"].append(
|
||
{"항": "끝수", "글": f"줄은 같고 합이 다름 {_sum_txt(ta)} ↔ {_sum_txt(tb)}"}
|
||
)
|
||
if not res["다른"] and res["같음"]:
|
||
res["판정"] = SURE
|
||
elif res["짝"] * 2 >= res["핵심"] + 1 and (
|
||
res["같음"] or all(d["항"] in EXPLAINED for d in res["다른"])
|
||
):
|
||
res["판정"] = NEAR
|
||
else:
|
||
res["판정"] = NONE
|
||
return res
|
||
|
||
|
||
def _sum_txt(t: dict) -> str:
|
||
return " · ".join(f"{k} {note.txt(v)}" for k, v in t.items() if v is not None)
|
||
|
||
|
||
# ── 한 문서 ───────────────────────────────────────────────────────────
|
||
def _calc(doc: dict, links: Any, given: dict) -> dict | None:
|
||
try:
|
||
got = mc.calc(doc, links, given, (), getattr(links, "calls", None))
|
||
except Exception: # noqa: BLE001 — 못 셈 = 그 조합 버림
|
||
return None
|
||
return got if got.get("셈됨") else None
|
||
|
||
|
||
def try_doc(
|
||
base: dict, links: Any, imported: dict, got_imp: dict, mine: list[dict], regions: set[str]
|
||
) -> dict:
|
||
"""M01 문서 하나 — 옵션 풀이 · 가장 나은 조합 판정(이름 · 절 근거 없으면 「이름 다름」 = 확정 아님)."""
|
||
want = {
|
||
"수량": {x["수량"] for x in mine if x["수량"] is not None},
|
||
"단가": {x["단가"] for x in mine if x["단가"] is not None},
|
||
}
|
||
dom, ev = domains(base, imported, regions)
|
||
dom = narrow(base, links, dom, want, ev)
|
||
named = [] if same_name(imported, base) else [_unnamed(imported, base)]
|
||
best: dict | None = None
|
||
ties: list[tuple] = []
|
||
for pick in combos(base, dom) or [{}]:
|
||
got = _calc(base, links, _given(base, pick))
|
||
if got is None:
|
||
continue
|
||
off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]]
|
||
off += named
|
||
res = verdict(mine, facts(base, got), got_imp, got, off)
|
||
values = sum(1 for x in res["참고"] if x["항"] in val.VALUE) if res["판정"] == SURE else 0
|
||
rank = (
|
||
res["판정"] == SURE,
|
||
-len(off),
|
||
-values, # 값 참고(규격 · 수량)가 적은 조합 먼저(812-11)
|
||
res["같음"],
|
||
res["짝"],
|
||
-len(res["다른"]),
|
||
-len(res["참고"]),
|
||
)
|
||
if best is None or rank > best["순위"]:
|
||
best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)}
|
||
ties.append((rank, pick))
|
||
if res["판정"] == SURE and not res["참고"]:
|
||
break
|
||
if best and best["판정"] == SURE and any(x["항"] in val.VALUE[1:] for x in best["참고"]):
|
||
same = [p for r, p in ties if r == best["순위"]] # 값으로 못 가른 조합(요구 733 ㉢)
|
||
best["참고"] += val.option_check(base, best["옵션"], same, _qty_vars(base))
|
||
return best or {
|
||
"판정": NONE,
|
||
"같음": 0,
|
||
"짝": 0,
|
||
"다른": [],
|
||
"참고": [],
|
||
"순위": (False, -9, 0, 0, 0, 0, 0),
|
||
}
|
||
|
||
|
||
def _qty_vars(doc: dict) -> set[str]:
|
||
"""금액 줄 수량이 (거쳐서) 부르는 변수."""
|
||
close = _closure(doc)
|
||
return set().union(*(_uses(x.get("수량"), close) or set() for x in doc.get("금액") or []))
|
||
|
||
|
||
def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
|
||
"""옵션 값이 가져온 글 근거와 다름 — 다른 항 「옵션 글」."""
|
||
var = next((v for v in doc.get("변수") or [] if md.var_id(v) == vid), {})
|
||
name = var.get("이름") or vid
|
||
return {"항": "옵션 글", "글": f"{name} 글 {'·'.join(map(str, seen))} ↔ {value}"}
|
||
|
||
|
||
def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]:
|
||
"""가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 참고, 금액, 후보수}`."""
|
||
links = links if isinstance(links, Memo) else Memo(links)
|
||
got_imp = _calc(imported, links_imp, {})
|
||
if got_imp is None:
|
||
return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0}
|
||
mine = facts(imported, got_imp)
|
||
alone = val.work(imported, mine, note._names)
|
||
keys = index.candidates(imported)
|
||
best, best_key = None, None
|
||
for key in keys:
|
||
res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions)
|
||
if best is None or res["순위"] > best["순위"]:
|
||
best, best_key = res, key
|
||
if res["판정"] == SURE and not res["참고"]:
|
||
break
|
||
exact = best and best["판정"] == SURE and not {x["항"] for x in best["참고"]} & set(val.CHECK)
|
||
if alone and not exact: # 기계 작업 — 우리 기초와 수량 · 규격까지 같을 때만 확정으로 붙임
|
||
return alone
|
||
if best is None:
|
||
return {"판정": NONE, "후보수": 0}
|
||
base = index.docs[best_key]
|
||
if best["판정"] == NONE: # 가까운 후보(보이기만 — 단추 없음)
|
||
near = {"키": best_key, "이름": base.get("이름"), "다른": best["다른"][:12]}
|
||
return {"판정": NONE, "후보수": len(keys), "가까움": near}
|
||
return {
|
||
"판정": best["판정"],
|
||
"키": best_key,
|
||
"이름": base.get("이름"),
|
||
"옵션": _shown(base, best["옵션"]),
|
||
"다른": best["다른"],
|
||
"참고": best["참고"],
|
||
"바꿈": best.get("바꿈") or [],
|
||
"금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])},
|
||
"후보수": len(keys),
|
||
}
|
||
|
||
|
||
def _shown(doc: dict, pick: dict) -> dict[str, Any]:
|
||
"""고른 옵션(기본과 다른 것) — {열쇠: 값}(수 = 수 · 선택 목록의 수 값과 같은 꼴 · SK 입력값)."""
|
||
out = {}
|
||
for v in doc.get("변수") or []:
|
||
vid = md.var_id(v)
|
||
if vid in pick and mc.plain_text(mc.number(pick[vid])) != mc.plain_text(
|
||
mc.number((v.get("연결") or {}).get("값"))
|
||
):
|
||
out[str(v.get("열쇠") or vid)] = ml._norm(mc.number(pick[vid]))
|
||
return out
|
||
|
||
|
||
_STOP = {"설치", "해체", "인력", "기계", "작업", "운반", "타설", "제작"}
|
||
|
||
|
||
def _grams(texts: list[Any]) -> set[str]:
|
||
"""이름 낱말 — 한글 두 글자 묶음 · 영문 · 수 낱말(일반 낱말 뺌)."""
|
||
out: set[str] = set()
|
||
for t in texts:
|
||
t = re.sub(r"\s|및", "", str(t or ""))
|
||
for run in re.findall(r"[가-힣]+", t):
|
||
out |= {run[i : i + 2] for i in range(len(run) - 1)}
|
||
out |= {w.lower() for w in re.findall(r"[A-Za-z]{2,}", t)}
|
||
return out - _STOP
|
||
|
||
|
||
def same_name(imported: dict, base: dict) -> bool:
|
||
"""뜻 근거 — 품셈 절 번호가 같거나 이름 낱말이 겹침(값만 같은 남의 기초를 확정으로 안 묶음)."""
|
||
secs = sections([x.get("글") for x in (imported.get("가져온") or {}).get("추정") or []])
|
||
if secs & sections([base.get("출처")]):
|
||
return True
|
||
return bool(_grams([imported.get("이름")]) & _grams([base.get("이름")]))
|
||
|
||
|
||
def _unnamed(imported: dict, base: dict) -> dict:
|
||
return {"항": "이름 다름", "글": f"{imported.get('이름')} ↔ {base.get('이름')}"}
|
||
|
||
|
||
def _plain(d: dict) -> dict:
|
||
return {k: (None if v is None else float(v)) for k, v in d.items()}
|