feat(master_data): 품셈재료 고르기 칸 · 로직 재료 줄 일괄 변환 · 후보 값 열 다섯

- `재료_품셈재료.json` 363 줄에 「고르기」 칸 — 자재품목 구분·상세구분[·규격] · 212 줄 채움(규격 조건 19) · 짝 없는 줄은 null + 비고에 까닭
- `scripts/build_품셈재료_고르기.py` — 자재품목을 가른 바로 그 품명 글자 규칙 재사용 · 손으로 바로잡은 25 줄은 OVERRIDE · 연결이 이미 있는 줄은 옛 방식 그대로
- `scripts/build_로직_재료_고르기.py` — 호표 요소 `MP…` 를 고르기 조건으로 바꿈(로직 32 파일 287 줄) · 고르기 빈 줄과 `{이름}` 낀 원문번호 줄은 그대로
- 일괄 시험 계산 전후 — 통과 1119 → 1203 · 새로 멈춤 0 · 앞뒤 다 통과한 1119 줄 금액 변동 0
- `check_master` — 품셈재료 「고르기」 칸 검사(조건 모양 · 없는 구분·상세구분 · 조건 밖 대표 · 후보 0)
- `GET /api/m01/materials` — 후보마다 값 열 다섯(`값들`) · `값` 은 시험 계산이 쓸 낮은 값 · 값 있는 줄을 맨 앞에
- `GET /api/m01/logic` 의 `prices` — 고르기 조건 줄도 실음 · 열쇠는 조건 글 `mf.cond_text` · 후보 수 포함
- `_틀.md` 5장에 고르기 칸 · 일괄 변환 스크립트

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-20 22:02:28 +09:00
co-authored by Claude Opus 5
parent 826495f199
commit 1bcc957e9a
42 changed files with 959 additions and 657 deletions
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""로직 호표의 재료 줄을 「고르기 조건」 으로 바꿈 — 요소 「MP……」 → {구분, 상세구분[, 규격]}.
· 바탕 = 재료_품셈재료.json 의 「고르기」 칸 (`scripts/build_품셈재료_고르기.py` 가 채움)
· 고르기 칸이 빈 품셈재료(연결이 이미 있는 줄 · 짝 없는 줄)는 옛 방식 그대로 둠
· 줄 텍스트의 「"요소": "MP……"」 한 줄만 바꿈 — 나머지 글자·차례 그대로
· 다시 돌려도 같은 결과(이미 바뀐 줄은 건드릴 것이 없음)
사용: python scripts/build_로직_재료_고르기.py (로직 파일 갱신)
python scripts/build_로직_재료_고르기.py --보기 (바꿀 줄 수만 셈)
"""
import json
import re
import sys
from pathlib import Path
MASTER = Path(__file__).resolve().parents[1]
SRC = MASTER / "재료_품셈재료.json"
LINE = re.compile(r'^(\s*"요소": )"(MP\d{6})"(,?)\s*$')
def main(show: bool) -> int:
picks = {
str(r[""]): r.get("고르기") for r in json.loads(SRC.read_text(encoding="utf-8"))[""]
}
done, kept, files = 0, 0, 0
for path in sorted(MASTER.glob("로직_*.json")):
text = path.read_text(encoding="utf-8")
out, hit = [], 0
for line in text.split("\n"):
m = LINE.match(line)
cond = picks.get(m.group(2)) if m else None
if cond:
out.append(m.group(1) + json.dumps(cond, ensure_ascii=False) + m.group(3))
hit += 1
else:
kept += bool(m)
out.append(line)
done += hit
if hit:
files += 1
if not show:
path.write_text("\n".join(out), encoding="utf-8", newline="\n")
print(f"고르기 조건으로 바꾼 재료 줄 {done} · 옛 방식 그대로 {kept} · 파일 {files}")
return 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main("--보기" in sys.argv))
@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""재료_품셈재료.json 의 「고르기」 칸 채움 — 품셈 원문 이름 → 자재품목 구분 · 상세구분[· 규격].
· 바탕 = `build_자재품목_분류.분류` (sub2 가 자재품목을 가른 바로 그 글자 규칙) · 묶음 목록 ref/_묶음_자재품목.md
· 손으로 바로잡은 줄은 OVERRIDE — 값이 None 이면 비움(까닭을 비고에 적음)
· 규격은 원문 규격 낱말이 모두 든 후보가 있을 때만 붙임(없으면 구분 · 상세구분까지만)
· 후보 0 이거나 못 가린 줄은 「고르기」 null + 비고에 까닭
· 값 · 이름 · 키 · 연결은 건드리지 않음 · 다시 돌리면 같은 결과
사용: python scripts/build_품셈재료_고르기.py (JSON 갱신)
python scripts/build_품셈재료_고르기.py --보기 (고칠 내용만 나열)
"""
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
MASTER = HERE.parent
sys.path.insert(0, str(HERE))
import master_material as mm # noqa: E402
from build_자재품목_분류 import 분류 # noqa: E402
SRC = MASTER / "재료_품셈재료.json"
MT = MASTER / "재료_자재품목.json"
# 글자 규칙이 엉뚱한 묶음을 집은 줄 — 손으로 바로잡음(None = 비움)
OVERRIDE = {
"MP000012": None, # 사다리(ton) — 어느 묶음인지 원문만으로 못 가림
"MP000015": ("단열·보온", "단열재"), # 문양 스티로폴 — 「문」 글자에 걸림
"MP000024": ("관·이음관", "이음쇠·부속"), # 고무링(VR관)
"MP000029": ("관·이음관", "이음쇠·부속"),
"MP000034": ("관·이음관", "이음쇠·부속"),
"MP000052": None, # 스페이셔(몰탈 블록)
"MP000094": None, # 쉬즈관 — 강관 값으로 재면 어긋남
"MP000101": None, # 내부PANEL(목재문) — 가설건축물 부재
"MP000110": ("거푸집·가설재", "가설건축물"),
"MP000111": ("거푸집·가설재", "가설건축물"),
"MP000112": ("거푸집·가설재", "가설건축물"),
"MP000134": None, # 모래주머니
"MP000139": None, # HDPE 시트 — PE「관」 아님
"MP000145": ("잡재료", "용접·가스"), # CO₂ 와이어
"MP000147": ("잡재료", "용접·가스"), # 잠호용접와이어
"MP000164": ("건축마감", "천장·바닥·마감재"), # 테라조 걸레받이
"MP000165": ("건축마감", "천장·바닥·마감재"), # 합성수지 걸레받이
"MP000195": ("잡재료", "용접·가스"), # 플럭스(브레이징용)
"MP000200": ("철근·강재", "철근"), # 철물(D22 철근)
"MP000209": None, # 버너(압접가열용)
"MP000214": None, # 퓨즈(테르밋 용접용)
"MP000250": None, # Lagging Sheet
"MP000317": ("잡재료", "소모품"), # 톱날
"MP000357": ("잡재료", "화약"), # 카트리지(화약)
"MP000362": ("잡재료", "소모품"), # 폴리에틸렌필름
}
NO_GROUP = "자재품목 묶음에 짝이 없음 — 관리자가 화면에서 이음"
NO_ROW = "자재품목에 그 묶음 줄이 없음"
NOTE = re.compile(r"\s*·?\s*고르기 없음\([^)]*\)")
FIELD = re.compile(r'("연결": (?:null|"[^"]*"|\{[^{}]*\}))(, "고르기": (?:null|\{[^{}]*\}))?')
NOTE_FIELD = re.compile(r'(, "비고": )(null|"(?:[^"\\]|\\.)*")')
def 고르기(row: dict, market: list[dict]) -> tuple[dict | None, str]:
"""한 줄의 고르기 조건 — (조건 또는 None, 까닭)."""
key = str(row.get(""))
if row.get("연결") is not None:
return None, "" # 관리자가 이미 이은 줄 — 옛 방식 그대로
if key in OVERRIDE:
got = OVERRIDE[key]
if got is None:
return None, NO_GROUP
sub, detail = got
else:
sub, detail = 분류(row.get("이름"), "")
if sub == "미분류":
return None, NO_GROUP
hits = [r for r in market if r.get("구분") == sub and r.get("상세구분") == detail]
if not hits:
return None, NO_ROW
want = [mm.plain_spec(w) for w in str(row.get("규격") or "").replace(",", " ").split()]
if want and any(all(w in mm.plain_spec(r.get("규격")) for w in want) for r in hits):
return {"구분": sub, "상세구분": detail, "규격": row["규격"]}, ""
return {"구분": sub, "상세구분": detail}, ""
def main(show: bool) -> int:
market = json.loads(MT.read_text(encoding="utf-8"))[""]
rows = {str(r[""]): r for r in json.loads(SRC.read_text(encoding="utf-8"))[""]}
out, n_pick, n_spec, n_blank = [], 0, 0, 0
for line in SRC.read_text(encoding="utf-8").split("\n"):
m = FIELD.search(line)
if not m or '"": "MP' not in line:
out.append(line)
continue
row = rows[json.loads(line.split('"": ', 1)[1].split(",", 1)[0])]
cond, why = 고르기(row, market)
n_pick += cond is not None
n_spec += bool(cond and "규격" in cond)
n_blank += cond is None
text = json.dumps(cond, ensure_ascii=False) if cond else "null"
line = FIELD.sub(lambda mm_: mm_.group(1) + f', "고르기": {text}', line, count=1)
line = _note(line, why)
if show and (cond or why):
print(f"{row['']} {row['이름']} | {row.get('규격', '')} | {text} {why}")
out.append(line)
print(f"고르기 {n_pick} 줄 (규격 조건 {n_spec}) · 빈 채 {n_blank}")
if not show:
SRC.write_text("\n".join(out), encoding="utf-8", newline="\n")
return 0
def _note(line: str, why: str) -> str:
"""비고 — 옛 「고르기 없음(…)」 을 지우고 까닭이 있으면 새로 붙임(비고 칸이 없으면 만듦)."""
m = NOTE_FIELD.search(line)
old = NOTE.sub("", (json.loads(m.group(2)) if m else None) or "").strip(" ·")
text = " · ".join(x for x in (old, f"고르기 없음({why})" if why else "") if x)
new = json.dumps(text, ensure_ascii=False) if text else "null"
if m:
return line[: m.start(2)] + new + line[m.end(2) :]
if not text:
return line
cut = line.rindex(" }")
return line[:cut] + f', "비고": {new}' + line[cut:]
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main("--보기" in sys.argv))
@@ -92,7 +92,7 @@ ROW_KEYS = {
"중간",
"끝수",
),
"품셈재료": ("", "원문번호", "이름", "규격", "단위", "요구절", "연결"),
"품셈재료": ("", "원문번호", "이름", "규격", "단위", "요구절", "연결", "고르기"),
}
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음
MERGED = {"인력": "구분", "기계": "세부분류"}
@@ -312,12 +312,17 @@ def _check_linked(where: str, row: dict) -> list[str]:
def check_links(whole: mf.Master) -> list[str]:
"""끊긴 키 — 품셈재료 연결 · 인력 준용이 가리키는 줄 · 추정 줄 값 = 준용 직종 값."""
"""끊긴 키 — 품셈재료 연결 · 고르기 조건 · 인력 준용이 가리키는 줄 · 추정 줄 값 = 준용 직종 값."""
out = []
for key, row in whole.index.get("MP", {}).items():
link = row.get("연결")
if isinstance(link, str) and link not in whole.index.get(link[:2], {}):
out.append(f"{whole.label(key)} · 연결 「{link}」 이 자재품목·유가전력에 없음")
pick = row.get("고르기")
if isinstance(pick, dict):
out += mf.check_pick(whole, whole.label(key) + " 고르기", pick)
elif pick is not None:
out.append(f"{whole.label(key)} · 고르기 모양 「{pick}」 — 조건 묶음이나 null")
for key, row in whole.index.get("LB", {}).items():
for col in ("준용",):
ref = row.get(col)
@@ -703,6 +703,7 @@ from master_material import ( # noqa: E402, F401
TARIFF,
candidates,
check_pick,
cond_text,
element,
pick,
)
@@ -106,6 +106,11 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
# ── 재료 고르기 ────────────────────────────────────────────────────────
def cond_text(cond: dict) -> str:
"""고르기 조건 한 글자 열쇠 — 「구분|상세구분|규격|대표」 · 화면 단가 미리보기가 줄을 찾는 이름."""
return "|".join(str(cond.get(k) or "") for k in PICK_KEYS)
def candidates(master: mf.Master, cond: dict, env: dict | None = None) -> list[str]:
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부)."""