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:
@@ -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))
|
||||
Reference in New Issue
Block a user