Files
Aislo/M02_MasterTemplete/M02_Doc_Match.py
T
eomsangdon 4e7ebf7f87 feat(812-4): 연결 찾기 율 값 = 참고 — 율 줄 판정 = 구조(대상 · 비목)만 · 율 값 % 다름 = 확정 + 참고(바꾸면 우리 값) · 율 대상 · 비목 다름 = 다른 항 그대로
- M02_Doc_Match_Note.rate_pairs → (다른 항, 참고) · 「율 값」 다른 항 폐지(EXPLAINED 에서도 뺌) · 띠 참고 요약에 「율 값 다름 N 줄」
- 시험: test_812_2 율 값 = 참고로 고침 · test_812_4 새(리퍼 105 · 275 확정 + 참고 · 율 대상 다름 = 다른 항 · 바꾸기 뒤 M01 율 · 되돌리기)
- 전수 2,220: 확정 152 → 163 · 근접 588 → 577 · 없음 1,480 · 확정 다시 셈 다름 0
2026-11-09 13:24:28 +09:00

654 lines
27 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.
"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독.
가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어
「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정).
① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 ·
품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만.
② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 ·
값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을
문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터.
③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기계 규격 · 수량 ·
비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 %(값 — 확정을 안 막음) —
다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」.
결과 `{판정, 키, 이름, 옵션, 다른, 참고, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`.
"""
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
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("합밖")),
}
)
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(" ", "")
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 str(o).replace(" ", "") 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["코드"])) # 코드 다름 = 짝 대조
)
)
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
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 "수량"
diffs.append({"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"})
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["갈래"] != "율")
return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs, "참고": notes}
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
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)
rank = (
res["판정"] == SURE,
-len(off),
res["같음"],
res["짝"],
-len(res["다른"]),
-len(res["참고"]),
)
if best is None or rank > best["순위"]:
best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)}
if res["판정"] == SURE and not res["참고"]:
break
return best or {
"판정": NONE,
"같음": 0,
"짝": 0,
"다른": [],
"참고": [],
"순위": (False, -9, 0, 0, 0, 0),
}
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)
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
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["참고"],
"금액": {"가져온": _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()}