Files
Aislo/resources/master_data/scripts/build_재료_검색어.py
T
eomsangdonandClaude Sonnet 5 d611aac675 feat(m01): 단위 후보 같은 성격인 빈 줄만 검색어로 (PLAN 8-3)
- 혼화제(GC000684) g ↔ kg — 검색어 「무수축혼화제」 · 단가 0 → 5 · 금액 변화 0(수량 0)
- 성격이 다른 11줄 · 값 빈 자리표 14줄은 그대로
- 도구: 빈 줄은 후보 단위가 같은 성격일 때만 바꿈 · 1,354 자동값 금액 변화 0 · check_master 로직 0 · 단위 16 → 15

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
2026-09-24 21:13:57 +09:00

371 lines
16 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.
# -*- 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
import master_pick as mp # noqa: E402
MASTER = HERE.parent
KEY = re.compile(r"MT\d{6}")
# ── 판정 — 서버 찾기(`master_pick.search`) 그대로 ─────────────────────────
def _has(word: str, row: dict) -> bool:
"""`master_pick.search` 와 같은 맞대기 — 낱말이 이름 또는 규격에 듦."""
w = mm.plain_spec(word)
return w in mm.plain(row.get("이름")).replace("×", "x") or w in mm.plain_spec(row.get("규격"))
def found(master: mf.Master, elem: dict, unit: str | None, env: dict) -> list[str]:
return mp.search(master, elem, env, unit)
def first(master, elem, unit, env) -> str | None:
got = found(master, elem, unit, env)
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 mp.REGIONS else None
def _ok(master, elem, unit, cases) -> bool:
"""판정 — 입력 벌마다 서버 찾기 첫째가 지금 품목."""
for env, want in cases:
filled = {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in elem.items()}
if first(master, filled, unit, env) != want:
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)))
env = cases[0][0]
got = found(master, {k: mf.fill(v, env) for k, v in elem.items()}, unit, env)
ahead = got[: got.index(want["키"])] if want["키"] in got else got
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 인 빈 줄 — 후보 단위가 줄 단위와 같은 성격(g ↔ kg 등)일 때만
# 검색어로(값이 생김 · 금액 바뀜 · 줄마다 보고) · 성격이 다르면 빈칸 그대로
probe = {**base, "검색어": _root(item.get("이름"))}
got = mp.search(master, probe, mau.values(master, row), unit)
if not got or not mp.same_kind(master.get(got[0])["단위"], unit):
return None, cases
name = _core(master.get(got[0]))[0]
return {**base, "검색어": name}, cases
if 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))