- 새 파일 — 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
615 lines
29 KiB
Python
615 lines
29 KiB
Python
"""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
|