Files
Aislo/M02_MasterTemplete/M02_Hopyo_Match.py
T
eomsangdonandClaude Opus 5.5 e9e949112c feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보)
- Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음
- 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미
- 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 23:36:23 +09:00

615 lines
29 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.
"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산.
차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) ·
씨앗(일위대가조합.json 메모 · 읽기만)
③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 ·
뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) —
+ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 ·
로직 키로 합쳐 점수 더함) ·
③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감.
줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 /
% = 안 들어감(로직 덧줄이 셈).
규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) ·
이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시.
환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B ·
m→㎥ × B × T = 「추정」(확정 뒤에만) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력.
M01 은 읽기만(로직 별칭 안 씀).
"""
from __future__ import annotations
import json
import re
import unicodedata
from datetime import datetime
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
mf = m01.cm.mf
mp = mf.mp
mm = mp.mm
SEED_NAME = "일위대가조합.json"
LOGIC_KINDS = ("단가산출", "기계", "기타")
MATERIAL_KINDS = ("자재", "별도계상")
READY = ("기억", "같음", "호표")
MACH_WORDS = (
("불도우저", "불도저"),
("굴삭기", "굴착기"),
("브레이카", "브레이커"),
("로울러", "롤러"),
("크로울러", "크롤러"),
)
_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$")
_PAREN = re.compile(r"\([^()]*\)")
_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)")
_NAMED = re.compile(r"(?<![A-Za-z])([A-Za-z]\d?)\s*[=:]\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)?", re.I)
# 이름 붙은 수 → 입력 이름 낱말(긴 낱말이 앞섬 · 「뒷길이」 = L3)
LETTERS = {
"D": ("관경", "직경", "구경", "지름"),
"H": ("높이",),
"B": ("폭", "너비"),
"T": ("두께",),
"L": ("거리", "길이"),
"L3": ("뒷길이",),
}
# ── 정규화 ───────────────────────────────────────────────────────────
def line_key(line: dict[str, Any]) -> str:
"""기억 열쇠 = 품명 | 규격 | 단위(정규화)."""
return "|".join(
(
mm.plain(line.get("이름")),
mm.plain_spec(line.get("규격")),
mm.plain_unit(line.get("단위")),
)
)
def tidy(name: Any) -> str:
"""정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸."""
text = str(name or "")
for old, new in MACH_WORDS:
text = text.replace(old, new)
return mm.plain(_PAREN.sub("", text))
def words(name: Any) -> frozenset[str]:
"""뜻 낱말 — 괄호 밖 한글 덩어리 · 영문 두 글자 넘는 것
(낱글자 덩어리는 붙임 「면 목」 = 면목)."""
text = unicodedata.normalize("NFKC", str(name or "")).lower()
for old, new in MACH_WORDS:
text = text.replace(old, new)
out, single = [], ""
for piece in re.findall(r"[가-힣]+|[a-z]{2,}", _PAREN.sub(" ", text)):
if len(piece) == 1:
single += piece
continue
out += [single, piece]
single = ""
return frozenset(w for w in [*out, single] if len(w) > 1)
def near(mine: frozenset[str], theirs: frozenset[str]) -> bool:
"""뜻 낱말이 하나라도 겹침(한쪽이 다른 쪽에 듦) — 내 낱말이 없으면 거르지 않음."""
return not mine or any(a in b or b in a for a in mine for b in theirs)
def _spec_text(text: Any) -> str:
out = str(text or "")
for old, new in MACH_WORDS:
out = out.replace(old, new)
return mm.plain_spec(out).replace(",", "").replace("~", "∼")
def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]:
"""규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임."""
text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or "")))
return {
letter.upper(): (Decimal(value), unit.lower() if unit else None)
for letter, value, unit in _NAMED.findall(text)
}
def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal:
"""길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로."""
scale = mp.UNITS["길이"]
want = mm.plain_unit(want) if want else None
if have in scale and want in scale:
return value * Decimal(scale[have]) / Decimal(scale[want])
return value
def _json(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None:
if found is None:
return None
return _in_unit(found[0], found[1] or "m", "m")
# ── 규격 → 입력값 ─────────────────────────────────────────────────────
def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]:
"""(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위."""
raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}"
pool = _spec_text(raw)
named = numbers(line.get("규격"))
tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)]
unit = mm.plain_unit(line.get("단위"))
values, unread = {}, []
for spec in specs:
name = str(spec.get("이름") or "")
picks = spec.get("고르기") or []
labels = spec.get("고르기이름") or {}
words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name]
letter = max(words, key=lambda x: len(x[0]))[1] if words else None
got: Any = None
if letter in named:
value = _json(_in_unit(*named[letter], spec.get("단위")))
got = value if not picks or value in picks else None
if got is None and picks:
hits = []
for opt in picks:
label = str(labels.get(opt) or opt)
for text in (label, _PAREN.sub("", label)):
flat = _spec_text(text)
if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")):
hits.append((len(flat), opt))
# 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」)
hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)]
got = max(hits, key=lambda x: x[0])[1] if hits else None
if got is not None:
values[name] = got
elif spec.get("자동값") in (None, ""):
unread.append(name)
return values, unread
# ── 환산 ─────────────────────────────────────────────────────────────
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
plain = mm.plain_unit(have)
if len(units) > 1:
ok = plain in {mm.plain_unit(u) for u in units}
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
if plain == mm.plain_unit(want):
return {"배": 1, "갈래": "같음"}
a, b = mp.kind_of(have), mp.kind_of(want)
if a is not None and a == b:
scale = mp.UNITS[a]
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
return {"배": _json(ratio), "갈래": "단위표"}
found = numbers(spec)
width, thick = _meters(found.get("B")), _meters(found.get("T"))
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
by[(a, b)] = width * thick
got = by.get((a, b))
if got:
return {"배": _json(got), "갈래": "추정"}
return {"배": None, "갈래": "입력"}
def fits(have: Any, want: Any) -> bool:
"""단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤)."""
units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()}
return mm.plain_unit(have) in units or mp.same_kind(have, want)
# ── 우리 쪽 목록(한 번 읽어 둠) ─────────────────────────────────────────
def _grams(text: str) -> set[str]:
return {text[i : i + 2] for i in range(len(text) - 1)} or {text}
@lru_cache(maxsize=2)
def _targets(whole_id: int) -> dict[str, Any]:
"""로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위."""
files, whole = m01.loaded()
rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []]
pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"]
unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만
logics, mats = [], []
for row in rows:
if str(row.get("키")) in unit_of:
name = str(row.get("이름") or "")
logics.append(
{
"로직": str(row["키"]),
"이름": name,
"단위": unit_of[str(row["키"])],
"산림": "산림" in str(row.get("구분") or ""),
"plain": mm.plain(name),
"tidy": tidy(name),
}
)
for key, row in whole.index.get(mm.MARKET, {}).items():
name = str(row.get("이름") or "")
mats.append(
{
"자재": str(key),
"이름": name,
"규격": str(row.get("규격") or ""),
"단위": str(row.get("단위") or ""),
"plain": mm.plain(name),
"spec": mm.plain_spec(row.get("규격")),
"base": (base := re.sub(r"-(사급|관급)$", "", name.strip())),
"tidy": tidy(base),
}
)
names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}}
for pool, group in (("로직", logics), ("자재", mats)):
for one in group:
one["grams"] = _grams(one["tidy"])
one["words"] = words(one["base"] if "base" in one else one["이름"])
names[pool].setdefault(one["plain"], []).append(one)
return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()}
def targets() -> dict[str, Any]:
return _targets(id(m01.loaded()[1]))
def _seeds() -> dict[str, set[str]]:
"""씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만)."""
try:
doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes())
except (OSError, ValueError):
return {}
out: dict[str, set[str]] = {}
for row in doc.get("줄") or []:
for one in row.get("담은로직") or []:
memo = _SEED_TAIL.sub("", str(one.get("메모") or ""))
if memo and one.get("로직"):
out.setdefault(mm.plain(memo), set()).add(str(one["로직"]))
return out
def _public(one: dict[str, Any]) -> dict[str, Any]:
keep = ("로직", "자재", "이름", "규격", "단위")
return {k: one[k] for k in keep if k in one}
def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]:
"""② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림)."""
name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격"))
same = got["이름"][pool].get(name) or []
if pool == "자재":
return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else [])
if same:
return same
codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get(
name
)
return [x for x in got["로직"] if x["로직"] in (codes or ())]
def candidates(
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
) -> list[dict[str, Any]]:
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
(None = 모두)."""
me = tidy(line.get("이름"))
grams, unit = _grams(me), line.get("단위")
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
scored, seen = [], set()
mine = words(line.get("이름"))
for one in (got or targets())[pool]:
if not grams & one["grams"] or not near(mine, one["words"]):
continue
score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
score += 1 if one["tidy"] == me else 0
score += 0.15 if fits(unit, one["단위"]) else 0
score += 0.05 if one.get("산림") else 0
score += 0.5 if one.get("spec") and one["spec"] in text else 0
scored.append((score, one))
scored.sort(key=lambda x: -x[0])
out = []
for score, one in scored:
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
if label in seen:
continue
seen.add(label)
out.append({**_public(one), "점수": round(score, 3)})
if len(out) == limit:
break
return out
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
_GENERIC = frozenset(
"인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 "
"설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 "
"건설표준품셈 이하 이상".split()
)
def fix_typo(theirs: list) -> list[list[str]]:
"""엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」."""
out = []
for book, n in theirs or []:
m = _TYPO_CLAUSE.match(str(n))
out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n])
return out
def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]:
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5
·
나 창 `Basis.clause` 가 있으면 그것."""
if hasattr(hopyo_basis, "clause"):
return hopyo_basis.clause(theirs, our)
nums = [n for book, n in theirs if book == our["책"]]
same = sorted(n for n in nums if n in our["조항"])
if same:
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-"))
if kids:
return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음"
heads = {n.rsplit("-", 1)[0] for n in nums}
near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads)
if near_:
return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음"
return 0.0, [], ""
def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool:
if cand["근거"]["직종"]:
return True
mine = {w for w in hopyo_basis._loose_words(f"{line.get('이름')}") if w not in _GENERIC}
theirs = {w for w in hopyo_basis._loose_words(cand["이름"]) if w not in _GENERIC}
return any(a in b or b in a for a in mine for b in theirs)
def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict:
"""이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15]}` — 이름 후보 +
근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) ·
옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리."""
got = got or targets()
basis_ = dict(line.get("근거") or {})
if basis_.get("출처"):
line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}}
basis = hopyo_basis.candidates(line, got)
named = candidates(line, "로직", got, None)
merged = [c.get("로직") for c in hopyo_basis.merge(basis, named) if c.get("로직")]
found = _exact(line, "로직", got)
if len(found) == 1:
tgt = found[0]["로직"]
theirs = (line.get("근거") or {}).get("출처") or []
our = hopyo_basis.ours().get(tgt)
if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs:
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
if our and clause(theirs, our)[0] >= 0.9:
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
strong = [c for c in basis if c["근거"]["까닭"].startswith("조항 같음")]
strong = [c for c in strong if _strong_basis(line, c)]
if not strong:
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]]))
return {"상태": "후보", "top": top[:5], "합침": merged[:15]}
top = [x["로직"] for x in found]
rest = [c for c in hopyo_basis.merge(basis, named) if c.get("로직") not in top]
first = next((c.get("로직") for c in named if c.get("로직") not in top), None)
top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))]
return {"상태": "후보" if top else "없음", "top": top[:5], "합침": merged[:15]}
# ── 층 기억 ───────────────────────────────────────────────────────────
def chain(folder: Path) -> list[Path]:
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
layer, owner = refs.where(folder)
out = [Path(folder)]
if layer == "personal":
out.append(refs.folder_of("company", str(owner).split("/")[0]))
if layer in ("personal", "company"):
out.append(Path(store.FOLDER))
return out
def memories(folder: Path) -> list[dict[str, dict[str, Any]]]:
return [hopyo_store.memory(one) for one in chain(folder)]
def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None:
key = line_key(line)
return next((one[key] for one in memo if key in one), None)
# ── 줄 하나 맞춤 ───────────────────────────────────────────────────────
def _logic_specs(code: str) -> list[dict[str, Any]]:
got = labor_inputs.logic_inputs([code])["로직"]
return got[0]["입력"] if got else []
def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None:
if remembered.get("로직"):
return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None)
if remembered.get("자재"):
return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None)
return None
def match_line(
line: dict[str, Any],
memo: list[dict[str, dict[str, Any]]],
units: dict[int, str],
full: bool = True,
got: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` —
`units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만)."""
out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []}
kind = line.get("갈래")
pool = "자재" if kind in MATERIAL_KINDS else "로직"
remembered = recall(line, memo)
got = got or targets()
if kind == "%":
return {**out, "상태": "%", "환산": None, "들어감": False}
if remembered and remembered.get("빼기"):
return {**out, "상태": "빼기", "환산": None, "들어감": False}
if kind == "호표":
no = int((line.get("참조") or {}).get("번") or 0)
conv = factor(line.get("단위"), units.get(no), line.get("규격"))
target = {"호표": no, "단위": units.get(no)}
ready = no in units and conv["배"] is not None and conv["갈래"] != "추정"
return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready}
if remembered and (target := _target_of(remembered, got)):
conv = {"배": remembered.get("환산"), "갈래": "기억"}
state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}}
return {**out, **state, "환산": conv, "들어감": conv["배"] is not None}
found = _exact(line, pool, got)
if len(found) == 1:
target = found[0]
conv = factor(line.get("단위"), target["단위"], line.get("규격"))
out.update(상태="같음", 대상=_public(target), 환산=conv)
out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정"
if full and "로직" in target:
out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"]))
return out
out.update(상태="후보" if found else "없음", 환산=None, 들어감=False)
if not full:
return out
top = [{**_public(x), "점수": 9} for x in found]
same = [_public(x) for x in found]
basis = hopyo_basis.candidates(line, got) if pool == "로직" else []
named = candidates(line, pool, got, None if basis else 5)
rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same]
first = next((c.get("로직") for c in named if _public(c) not in same), None)
out["후보"] = top[:5] + hopyo_basis.pick(rest, first if basis else None, 5 - len(top))
return {**out, "상태": "후보" if out["후보"] else "없음"}
def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]:
"""후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기)."""
line = _line(folder, key, no, at)
found = _target_of(target if isinstance(target, dict) else {}, targets())
if found is None:
raise store.StoreError(400, "돈 로직 · 우리 자재가 아님")
target = _public(found)
conv = factor(line.get("단위"), target.get("단위"), line.get("규격"))
if not target.get("로직"):
return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv}
values, unread = spec_inputs(line, _logic_specs(str(target["로직"])))
return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv}
def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]:
doc = hopyo_store.read(folder, key)
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
lines = (hopyo or {}).get("줄") or []
if not 0 <= int(at) < len(lines):
raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」")
return lines[int(at)]
def match_hopyo(
hopyo: dict[str, Any],
memo: list[dict[str, dict[str, Any]]],
units: dict[int, str],
full: bool = True,
got: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수]."""
got = got or targets()
lines = [
{**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or []
]
ready = sum(1 for one in lines if one["맞춤"]["들어감"])
return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]}
def units_of(doc: dict[str, Any]) -> dict[int, str]:
return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []}
def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]:
"""실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보."""
doc = hopyo_store.read(folder, key)
memo, units = memories(folder), units_of(doc)
if no is None:
got = targets()
hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []]
keep = ("번", "이름", "규격", "단위", "금액", "맞춤")
brief = [{k: h.get(k) for k in keep} for h in hopyos]
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready}
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
if hopyo is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)}
def picks(folder: Path) -> list[dict[str, Any]]:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m
(이 층 기억 · 만드는 층)."""
memo, got, out = memories(folder), targets(), []
for one in chain(folder):
layer, owner = refs.where(one)
for row in hopyo_store.rows(one):
doc = hopyo_store.read(one, row["키"])
units = units_of(doc)
for hopyo in doc.get("호표") or []:
ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"]
head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]}
keep = ("번", "이름", "규격", "단위")
out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready})
return out
# ── 확정 저장 ─────────────────────────────────────────────────────────
def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]:
"""확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`.
로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄)."""
if not isinstance(entries, dict) or not entries:
raise store.StoreError(400, "확정할 줄이 없음")
got = targets()
logics = {x["로직"] for x in got["로직"]}
mats = {x["자재"] for x in got["자재"]}
stamp = datetime.now().isoformat(timespec="seconds")
clean: dict[str, Any] = {}
for key, raw in entries.items():
if raw is None:
clean[str(key)] = None
continue
if not isinstance(raw, dict) or str(key).count("|") != 2:
raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」")
if raw.get("빼기") is True:
clean[str(key)] = {"빼기": True, "확정": stamp}
continue
code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "")
if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat):
raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」")
times = raw.get("환산")
number = None if isinstance(times, bool) else _number(times)
if number is None or number <= 0:
raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」")
values = raw.get("입력값") or {}
if not isinstance(values, dict):
raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」")
head = {"로직": code} if code else {"자재": mat}
clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp}
return {"기억": hopyo_store.remember(folder, clean)}
def _number(raw: Any) -> Decimal | None:
try:
got = Decimal(str(raw).replace(",", "").strip())
except ArithmeticError:
return None
return got if got.is_finite() else None