- 지움(git rm) 10 파일 3,374 줄: M02_Hopyo_Finger · Ours · Option · Fit · Combine · Verdict · Line · SkLink · SkPatch · Basis
- Match: 흐름 몫(candidates · name_cands · fix_typo · clause · b_order · _logic_specs · _line_mod · units_of · _state · view · guess · picks · chain) 지움 · 이름 도구 몫 · line_key 남김
- Words: conds · cond_score · close · cosine · jacc 지움 / Shares: sections 지움(trade · eq_key 남김)
- 옮김: Finger.kind_of → Fixed.kind_of · SkLink(sk_line · kept · left · LEFT · WHY) → Sangeun · SkPatch 기계 몫(MACH_IN · MACHINE · SOLO · _mach_logics) → Units.mach_logics
- Sangeun: 옛 /sangeun 몸(build · _sure · Maker.basis · judge · plan · make) 지움 · Kids: first · proposal · order 지움 · Build: build() · unlinked() 지움(모두 고정 Builder · build_all · _order · _origins 남김)
- Router: 옛 길 지움(GET hopyo/{키}/{번} · guess · hopyo-picks · hopyo-build · hopyo-unlinked · hopyo/{키}/sangeun) · GET hopyo/{키} = Judge.view(1차 줄 판정 줄상태 · 만든 · 블록 풀이 셈)
- Marks: 칠 = Judge.judged 만 · Forget: 맞춤 캐시 지움 줄 걷음 · Ledger: Ours.version 판 칸 걷음 · Item: Kids.first 걷음
- Row(덧): 단산 줄 = 장부 짝이 있어도 늘 「2차 필요」(장부 = 1차 후보 힌트만)
시험 정리(까닭 = 지운 길 · 모듈만 시험함):
- 지움 11 파일: test_m02_hopyo_finger · fit · golden · option · ours · basis · seam · flow · draft_calc · test_635 · test_628(지문 · 순위 · 옵션 거꾸로 · 맡은 절 · 옛 확인 흐름)
- 함수 지움: /sangeun · /hopyo-build · /hopyo-unlinked · hopyo-picks · 옛 줄 맞춤 보기를 부르던 것(572 · 718_1 · 729 · 739_1 · 777_1 · 778_5 · 780_1 · labor_national · 705_3 · m02_hopyo_match · 746_1)
- test_621 S2(지문 해시) 지움 · S1 남김 · test_m02_hopyo_build = 장부 길 시험만(PUT 으로 심음)
- 새 길로: 779_2(줄상태 = 1차 판정 · 칠 셈 전 None) · grid · stmate(hopyo-judge 줄 판) · 778_1 · 778_2(Units.mach_logics) · 642_6(Marks 원문) · 642_3 장부 짝 단산 줄 2차 지킴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""M02 일위대가 실무자료 이름 도구(PLAN 324-2 · 338 · 785-7) — 엑셀 구성 줄 · 우리 로직 · 자재 이름 정리 · 목록.
|
||
|
||
이름 정리(`tidy` · `words` · `near`) · 규격 수(`numbers` · `spec_inputs`) · 환산(`factor` = `M02_Hopyo_Row.factor`) ·
|
||
우리 목록(`targets` — 돈 로직 · 자재품목 · 씨앗) · 이름 같음(`_exact`) · B09 줄 열쇠(`line_key`).
|
||
규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) ·
|
||
이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시.
|
||
M01 은 읽기만(로직 별칭 안 씀). 옛 맞춤 흐름(후보 순위 · 근거 차례 · 보기 · 미리 셈)은 785-7 에서 지움.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import unicodedata
|
||
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
|
||
|
||
mf = m01.cm.mf
|
||
mp = mf.mp
|
||
mm = mp.mm
|
||
SEED_NAME = "일위대가조합.json"
|
||
LOGIC_KINDS = ("단가산출", "기계", "기타")
|
||
MATERIAL_KINDS = ("자재", "별도계상")
|
||
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 == "L" and "거리" in name and named.get("L", (0, None))[1] in ("cm", "mm"):
|
||
letter = None # 「L=35cm」 = 부재 길이 — 운반 · 거리(m) 칸 아님
|
||
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 (
|
||
_NUM.fullmatch(flat) and len(flat) > 1
|
||
): # 수 고르기 = 제 단위가 붙은(또는 맨) 수만
|
||
hits += (
|
||
[(len(flat), opt)]
|
||
if any(_num_in(flat, w, spec) for w in tokens)
|
||
else []
|
||
)
|
||
elif 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
|
||
|
||
|
||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||
|
||
|
||
def _num_in(flat: str, pool: str, spec: dict[str, Any]) -> bool:
|
||
"""규격 글에 수 `flat` 이 따로 서 있고 뒤에 다른 단위가 안 붙음(「35cm」 의 35 ≠ 경사 35°)."""
|
||
own = _spec_text(spec.get("단위"))
|
||
for m in re.finditer(rf"(?<![\d.]){re.escape(flat)}(?![\d.])", pool):
|
||
after = re.match(r"[a-z㎜㎝㎞㎡㎥°%]+", pool[m.end() :])
|
||
if not after or (own and after.group().startswith(own)):
|
||
return True
|
||
return False
|
||
|
||
|
||
# ── 환산 ─────────────────────────────────────────────────────────────
|
||
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
|
||
"""`{배, 갈래}` — `M02_Hopyo_Row.factor` 로 옮김(785-3 · 옛 길이 부름)."""
|
||
from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row # Row 가 Match 를 부름 — 늦게 읽음
|
||
|
||
return hopyo_row.factor(have, want, spec)
|
||
|
||
|
||
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 ())]
|