feat(m01): 재료 기본 검색어 바꿔 넣기 도구 (PLAN 8-3)

- 대표 키 · 대표가 재료 입력인 줄 · 조건만 있는 줄 → {구분, 상세구분, 검색어}
- 검색어 = 이름 핵심 + 규격 핵심 + 지역 낱말 · 지금 품목이 8-1 차례로 첫째가 될 때까지 낱말 더함
- 재료 입력 키 목록 걷음 · 넘기던 인자 뗌 · GC000211 소모자재 MT:{합판} 자리는 줄 금액으로
- 글 모양 그대로(바뀐 값 자리만) · 기본은 마른 돌리기 · --쓰기 로 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
This commit is contained in:
2026-09-24 20:46:43 +09:00
co-authored by Claude Opus 5.5
parent f29dc14c9a
commit 62b7cd972a
@@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
"""로직 재료 줄의 자재품목 키를 「기본 검색어」 로 바꿈 (PLAN 8-3 · 계약 8-1).
· 대표 키 박은 줄 · 대표가 재료 입력(`{철선}`)인 줄 · 조건만 있는 줄 — 요소를
`{구분, 상세구분, 검색어}` 로. 검색어는 지금 잡히는 품목(자동값)이 8-1 찾기 규칙 ·
자동값 차례로 첫째가 되게 지음(이름 핵심 + 규격 핵심 + 지역 낱말 · 모자라면 낱말을 더함)
· 재료 입력 키 목록(고르기가 자재품목 키인 입력)은 걷음 — 그 입력을 넘기던 `이름=이름` 인자도
· 지역·계약종별 조건 줄 · 유가전력 틀(`MO:경유:{지역}`)은 그대로 — 자재품목 키가 아님
· 글 모양 그대로 — 바뀐 값 자리만 갈음 · 다시 돌려도 같은 결과
사용: python scripts/build_재료_검색어.py (마른 돌리기 — 파일 안 씀 · 못 맞춘 줄 목록)
python scripts/build_재료_검색어.py --쓰기 (로직 파일 갱신)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import check_master as cm # noqa: E402
import master_auto as mau # noqa: E402
import master_formula as mf # noqa: E402
import master_material as mm # noqa: E402
MASTER = HERE.parent
KEY = re.compile(r"MT\d{6}")
# 같은 성격 단위(8-1 단위 환산) — `plain_unit` 뒤 모양
KINDS = ({"g", "kg", "톤"}, {"mm", "cm", "m", "km"}, {"cm2", "m2"}, {"cm3", "ℓ", "m3"})
# 이름 끝 지역 낱말(「합판(내수), 서울」) — 자동값 차례의 지역 자리
REGIONS = set(
"서울 부산 대구 인천 광주 대전 울산 세종 경기 강원 충북 충남 전북 전남 경북 경남 제주 "
"청주 전주 포항 창원 강릉 춘천 충주 고양 수원 원주 진주 목포 여수 순천 안동 구미 경주 "
"천안 군산 익산 김해 마산 성남 안양 부천 의정부".split()
)
# ── 8-1 찾기 규칙 (마른 돌리기 판정) ─────────────────────────────────────
def same_kind(a: str, b: str) -> bool:
a, b = mm.plain_unit(a), mm.plain_unit(b)
return a == b or any(a in k and b in k for k in KINDS)
def _has(word: str, row: dict) -> bool:
return mm.plain(word) in mm.plain(row.get("이름")) or mm.plain_spec(word) in mm.plain_spec(
row.get("규격")
)
def found(master: mf.Master, elem: dict, unit: str | None, soft: bool = True) -> list[str]:
"""검색어로 잡히는 자재품목 키 — 자동값 차례(값 있음 → 같은 단위 → 지역 낱말 → 키).
soft = 지역 낱말은 거르지 않고 차례만(아니면 여느 낱말처럼 거름)."""
words = str(elem.get("검색어") or "").split()
region = [w for w in words if soft and w in REGIONS]
must = [w for w in words if w not in region]
pool = mm.by_class(master).get(elem["구분"], {}) if elem.get("구분") else master.index["MT"]
detail = elem.get("상세구분")
hits = [
key
for key, row in pool.items()
if (not detail or row.get("상세구분") == detail)
and all(_has(w, row) for w in must)
and (not unit or same_kind(row.get("단위"), unit))
]
def rank(key):
row = master.get(key)
return (
not mm._offers(master, key),
bool(unit) and mm.plain_unit(row.get("단위")) != mm.plain_unit(unit),
-sum(_has(w, row) for w in region),
key,
)
return sorted(hits, key=rank)
def first(master, elem, unit, soft=True) -> str | None:
got = found(master, elem, unit, soft)
return got[0] if got else None
# ── 지금 잡히는 품목 ──────────────────────────────────────────────────
def material_inputs(row: dict) -> dict[str, list]:
"""고르기가 자재품목 키 목록인 입력 — 걷을 것."""
return {
s["이름"]: s["고르기"]
for s in row.get("입력", [])
if isinstance(s.get("고르기"), list)
and s["고르기"]
and all(isinstance(x, str) and KEY.fullmatch(x) for x in s["고르기"])
}
def _holes(elem: dict) -> list[str]:
return sorted({h for v in elem.values() if isinstance(v, str) for h in mf._HOLE.findall(v)})
def envs(master: mf.Master, row: dict, elem: dict) -> list[dict]:
"""줄을 판정할 입력 벌 — 자동값 하나 · 조건에 끼운 입력은 고르기 값마다."""
base = mau.values(master, row)
out = [base]
for hole in _holes(elem):
spec = next((s for s in row.get("입력", []) if s["이름"] == hole), {})
if spec.get("고르기") and hole not in material_inputs(row):
out = [{**e, hole: v} for e in out for v in spec["고르기"]]
return out
def old_pick(master: mf.Master, elem: dict, env: dict, unit: str | None) -> str | None:
try:
cond = {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in elem.items()}
return mm.pick(master, cond, env, unit)
except mf.FormulaError:
return None
# ── 검색어 짓기 ───────────────────────────────────────────────────────
def _name(v) -> str:
"""품명 — 띄어 쓴 한글 글자는 붙임(「골 함 석」) · 쉼표 둘레 빈칸 뗌 · 영문 낱말 사이는 둠."""
s = re.sub(r"\s*,\s*", ",", str(v or "").strip())
return re.sub(r"(?<=[가-힣])\s+(?=[가-힣])", "", s)
def _root(name: str) -> str:
"""이름 핵심 — 빈칸 뗌 · 첫 괄호·쉼표 앞(「박 리 제(중유)」 → 박리제)."""
return re.split(r"[(\[,]", _name(name))[0]
def _pieces(spec) -> list[str]:
"""규격 조각 — 빈칸·쉼표로 · 괄호 속 쉼표는 안 가름(「저유황(목재,합판용)」)."""
return [p for p in re.split(r"\s+|,(?![^()]*\))", str(spec or "")) if p]
def _core(row: dict) -> list[str]:
"""이름 핵심(첫 쉼표 앞 · 괄호 그대로 「합판(내수)」) + 규격 핵심(첫 조각 · 「12t×1220×…」 →
글자 든 첫 토막 12t)."""
name = _name(row.get("이름")).split(",")[0]
pieces = _pieces(row.get("규격"))
spec = "".join(pieces[:2]) if pieces and len(pieces[0]) < 2 else (pieces or [""])[0]
head = re.split(r"[×xX*]", spec)[0]
if head != spec and re.search(r"[^\d.]", head):
spec = head
return [w for w in (name, spec) if w]
def _tokens(row: dict) -> list[str]:
"""더할 낱말 후보 — 이름 조각(쉼표로) · 이름 괄호 · 규격 조각(쉼표·빈칸·×로)."""
name = _name(row.get("이름"))
parts = [p for p in name.split(",") if p]
out = parts + re.findall(r"\([^()]*\)", name)
pieces = _pieces(row.get("규격"))
out += pieces + [q for p in pieces for q in re.split(r"[×xX*]", p) if q and q != p]
return list(dict.fromkeys(w for w in out if w))
def _place(row: dict) -> str | None:
tail = _name(row.get("이름")).split(",")[-1]
return tail if tail in REGIONS else None
def _ok(master, elem, unit, cases) -> bool:
"""판정 — 입력 벌마다 지역 낱말을 거르든(hard) 차례로만 보든(soft) 첫째가 그 품목."""
for env, want in cases:
filled = {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in elem.items()}
if any(first(master, filled, unit, soft) != want for soft in (True, False)):
return False
return True
def words_for(master, base: dict, words: list[str], unit, cases) -> list[str] | None:
"""모자라면 첫 품목의 낱말을 하나씩 더함 — 앞선 줄을 가장 많이 떨구는 낱말부터."""
target_rows = {want for _, want in cases}
for _ in range(6):
elem = {**base, "검색어": " ".join(words)}
if _ok(master, elem, unit, cases):
return words
if len(target_rows) != 1 or None in target_rows:
return None # 입력 따라 품목이 갈리는 줄 — 끼운 낱말로만
want = master.get(next(iter(target_rows)))
ahead = []
for soft in (True, False):
got = found(master, {**elem}, unit, soft)
ahead += got[: got.index(want["키"])] if want["키"] in got else []
pick = None
for w in _tokens(want):
if w in words or not _has(w, want):
continue
cut = sum(not _has(w, master.get(k)) for k in set(ahead))
if cut and (pick is None or cut > pick[0]):
pick = (cut, w)
if not pick:
return None
words.append(pick[1])
return None
def convert(master: mf.Master, row: dict, item: dict) -> tuple[dict | None, list]:
"""재료 줄 하나 → 새 요소 · 판정 벌. 못 맞추면 (None, 벌)."""
elem, unit = item["요소"], item.get("단위")
cases = []
for env in envs(master, row, elem):
cases.append((env, old_pick(master, elem, env, unit)))
base = {k: elem[k] for k in ("구분", "상세구분") if elem.get(k)}
# 이미 좁혀 둔 이름·규격 낱말은 그대로 검색어로(`{관경}` 같은 끼운 칸도)
words = [w for k in ("이름", "규격") for w in str(elem.get(k) or "").split()]
targets = {want for _, want in cases}
if targets == {None}: # 지금 후보 0 인 줄 — 줄 이름 핵심으로 · 여전히 0 이어야 함
root = _root(item.get("이름"))
if root and not any(mm.plain(root) in mm.plain(w) for w in words):
words.insert(0, root)
elif len(targets) == 1:
want = master.get(next(iter(targets)))
name, *spec = _core(want)
if not any(mm.plain(w) in mm.plain(name) for w in words):
words.insert(0, name) # 조건 이름 낱말이 이미 품명을 가리키면 그대로
if spec and not elem.get("규격") and spec[0] not in words:
words.append(spec[0])
place = _place(want)
if place and place not in words:
words.append(place)
else:
roots = {_root(master.get(t)["이름"]) for t in targets if t}
if (
len(roots) == 1
and (root := roots.pop())
and not any(mm.plain(root) in mm.plain(w) for w in words)
):
words.insert(0, root)
got = words_for(master, base, list(words), unit, cases)
return ({**base, "검색어": " ".join(got)} if got else None), cases
# ── 돌리기 ────────────────────────────────────────────────────────────
def _targets(item: dict) -> bool:
elem = item.get("요소")
return (
item.get("종류") == "재료"
and isinstance(elem, dict)
and "검색어" not in elem
and elem.get("지역") != "입력"
and elem.get("계약종별") != "입력"
)
def plan(master: mf.Master, files: dict[str, dict]) -> tuple[dict, list]:
"""파일마다 {로직 키: {"요소": {호표 차례: 새 요소}, "걷음": [입력 이름]}} · 못 맞춘 줄."""
out, misses = {}, []
for name, data in files.items():
if not name.startswith("로직_"):
continue
for row in data["줄"]:
elems = {}
for i, item in enumerate(row.get("호표", [])):
if not _targets(item):
continue
elem, cases = convert(master, row, item)
if elem is None:
misses.append((row["키"], i, item.get("이름"), item["요소"], cases))
else:
elems[i] = elem
gone = list(material_inputs(row))
if elems or gone:
out.setdefault(name, {})[row["키"]] = {"요소": elems, "걷음": gone}
return out, misses
_ARG = r"\s*,\s*{n}\s*=\s*{n}(?=\s*[,)])"
# 걷는 입력을 식에서 품목 키로 쓰던 자리(`MT:{합판}`) — 같은 값의 호표 줄 금액으로
# (GC000211 소모자재: 줄 금액 = 찾기 수량 × 합판율 × 단가 이므로 합판율을 줄 금액이 품음)
SWAP = {
"(찾기(QC000427).합판 * MT:{합판} + 찾기(QC000427).각재 * MT:{각재}) * 합판율 * ": (
"(줄.'합판(내수 12㎜)' + 줄.'각재') * "
),
}
def _strip(text: str, names) -> str:
"""부르는 식의 `, 철선=철선` 인자를 뗌 · 품목 키 자리는 줄 금액으로."""
for n in names:
text = re.sub(_ARG.format(n=re.escape(n)), "", text)
for old, new in SWAP.items():
text = text.replace(old, new)
return text
def apply(row: dict, change: dict) -> dict:
"""바꿈을 줄 사본에 — 새 요소 · 재료 입력 걷음 · 그 입력을 넘기던 인자 뗌."""
new = _copy(row)
for i, elem in change["요소"].items():
new["호표"][i]["요소"] = elem
gone = change["걷음"]
if gone:
new["입력"] = [s for s in new["입력"] if s["이름"] not in gone]
for group in ("중간", "호표", "덧줄"):
for part in new.get(group, []):
for k in ("식", "요소", "수량"):
if isinstance(part.get(k), str):
part[k] = _strip(part[k], gone)
if "결과" in new:
new["결과"]["식"] = _strip(new["결과"]["식"], gone)
return new
def _copy(v):
if isinstance(v, dict):
return {k: _copy(x) for k, x in v.items()}
if isinstance(v, list):
return [_copy(x) for x in v]
return v
# ── 글 모양 그대로 쓰기 ──────────────────────────────────────────────────
_DEC = json.JSONDecoder()
def _skip(text: str, pos: int) -> int:
while text[pos] in " \t\r\n":
pos += 1
return pos
def _items(text: str, pos: int) -> list[tuple[int, int]]:
"""`[` 바로 뒤 `pos` 부터 목록 칸마다 (처음, 끝)."""
spans = []
while text[pos := _skip(text, pos)] != "]":
_, end = _DEC.raw_decode(text, pos)
spans.append((pos, end))
pos = _skip(text, end)
pos += text[pos] == ","
return spans
def _patch_row(text: str, row: dict, change: dict) -> str:
"""줄 글 하나 — 바뀐 요소 값 자리 · 걷은 입력 칸 · 뗀 인자만 갈음."""
spots = [m.end() for m in re.finditer(r'"요소"\s*:\s*', text)]
if len(spots) != len(row.get("호표", [])):
raise ValueError(f"{row['키']} 요소 자리 수가 호표와 다름")
for i in sorted(change["요소"], reverse=True):
at = spots[i]
_, end = _DEC.raw_decode(text, at)
text = text[:at] + json.dumps(change["요소"][i], ensure_ascii=False) + text[end:]
gone = change["걷음"]
if gone:
m = re.search(r'"입력"\s*:\s*\[', text)
spans = _items(text, m.end())
keep = [text[s:e] for s, e in spans if json.loads(text[s:e]).get("이름") not in gone]
lead, close = text[m.end() : spans[0][0]], _skip(text, spans[-1][1])
tail = text[spans[-1][1] : close]
body = lead + ("," + lead).join(keep) + tail if keep else ""
text = text[: m.end()] + body + text[close:]
text = re.sub(r'"(?:[^"\\]|\\.)*"', lambda s: _strip(s.group(0), gone), text)
return text
def patch(text: str, changes: dict) -> str:
"""파일 글 — 바뀐 로직 줄만 손봄."""
m = re.search(r'"줄"\s*:\s*\[', text)
spans = _items(text, m.end())
parts, at = [], 0
for s, e in spans:
row = json.loads(text[s:e])
if row["키"] in changes:
parts += [text[at:s], _patch_row(text[s:e], row, changes[row["키"]])]
at = e
return "".join(parts) + text[at:]
def main(write: bool) -> int:
files = cm.load()
master = mf.Master(files)
changes, misses = plan(master, files)
lines = sum(len(c["요소"]) for rows in changes.values() for c in rows.values())
inputs = sum(len(c["걷음"]) for rows in changes.values() for c in rows.values())
for name, rows in changes.items():
path = MASTER / name
text = path.read_text(encoding="utf-8")
new_text = patch(text, rows)
want = json.loads(text)
want["줄"] = [apply(r, rows[r["키"]]) if r["키"] in rows else r for r in want["줄"]]
if json.loads(new_text) != want:
raise SystemExit(f"{name} 글 갈음이 바꿈과 다름 — 안 씀")
if write:
path.write_text(new_text, encoding="utf-8", newline="\n")
print(
f"검색어로 바꾼 재료 줄 {lines} · 걷은 재료 입력 {inputs} · "
f"로직 {sum(map(len, changes.values()))} · 파일 {len(changes)}"
+ ("" if write else " (마른 돌리기)")
)
for key, i, name, elem, cases in misses:
wants = sorted({str(w) for _, w in cases})
print(f" 못 맞춤 {key} 호표[{i}] {name} {json.dumps(elem, ensure_ascii=False)} → {wants}")
return 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main("--쓰기" in sys.argv))