From 62b7cd972a0aa71e0cc1ad7145d172543268d2da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 24 Sep 2026 20:46:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01):=20=EC=9E=AC=EB=A3=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B2=80=EC=83=89=EC=96=B4=20=EB=B0=94=EA=BF=94=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=EB=8F=84=EA=B5=AC=20(PLAN=208-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대표 키 · 대표가 재료 입력인 줄 · 조건만 있는 줄 → {구분, 상세구분, 검색어} - 검색어 = 이름 핵심 + 규격 핵심 + 지역 낱말 · 지금 품목이 8-1 차례로 첫째가 될 때까지 낱말 더함 - 재료 입력 키 목록 걷음 · 넘기던 인자 뗌 · GC000211 소모자재 MT:{합판} 자리는 줄 금액으로 - 글 모양 그대로(바뀐 값 자리만) · 기본은 마른 돌리기 · --쓰기 로 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- .../master_data/scripts/build_재료_검색어.py | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 resources/master_data/scripts/build_재료_검색어.py diff --git a/resources/master_data/scripts/build_재료_검색어.py b/resources/master_data/scripts/build_재료_검색어.py new file mode 100644 index 00000000..907ae7bf --- /dev/null +++ b/resources/master_data/scripts/build_재료_검색어.py @@ -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))