- 혼화제(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
371 lines
16 KiB
Python
371 lines
16 KiB
Python
# -*- 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))
|