Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into CODEX

This commit is contained in:
2026-09-20 22:10:14 +09:00
47 changed files with 1165 additions and 835 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))
@@ -481,12 +481,20 @@ R = [
("식생·종자", "수목", r"촉촉소일|나무여과상자|만병초"),
("석재", "경계석", r"빗물머금석"),
("잡재료", "소모품", r"마킹펜|크레용|세척제|WHITEPAINT|보조통|암등|이단철개|열수축시트"),
# 일감 3 — 뒤에 붙여 옛 결과는 그대로
("운송·용역", "상하차", r"^(상|하)차비$"),
("식생·종자", "초화·수생식물", r"^칡$"),
("특수장비", "특수장비", r"AirHammer|콘쉴드|바탕면조정재|본우드"),
]
RX = [(a, b, re.compile(c, re.I)) for a, b, c in R]
def 분류(이름: str, 출처: str):
# 화초·수생식물 줄 — 규격이 분·포트·cm·분얼·개화구·엽·년생 꼴 (이름 글자로는 못 가림)
식물규격 = re.compile(r"^\d+(\.\d+)?cm$|포트|분얼|개화구|\d엽$|년생|\d+cm분$")
def 분류(이름: str, 출처: str, 규격: str = ""):
n = re.sub(r"\s+", "", 이름 or "")
if 출처.startswith("자재품목 조달청"):
return 조달청_분류.get(n, ("미분류", None))
@@ -495,6 +503,8 @@ def 분류(이름: str, 출처: str):
for a, b, rx in RX:
if rx.search(n):
return (a, b)
if 규격 and 식물규격.search(규격.replace(" ", "")):
return ("식생·종자", "초화·수생식물")
return ("미분류", None)
@@ -519,7 +529,8 @@ def main():
if m and '"": "MT' in ln:
nm = json.loads('"' + NAME.search(ln).group(1) + '"')
sc = json.loads('"' + SRC_.search(ln).group(1) + '"')
a, b = 분류(nm, sc)
sp = re.search(r'"규격": "((?:[^"\\]|\\.)*)"', ln)
a, b = 분류(nm, sc, json.loads('"' + sp.group(1) + '"') if sp else "")
ln = LINE.sub(lambda _: f'"구분": {q(a)}, "상세구분": {q(b)}', ln, count=1)
if not nm:
ln = ln.replace('"비고": null', f'"비고": "{NOTE_EMPTY}"', 1)
@@ -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]:
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부)."""
@@ -221,6 +221,10 @@ def region_pick(cands: dict, want: frozenset):
sup = [k for k in cands if towns and towns <= k]
if sup:
return [r for k in sup for r in cands[k]], f"지역 포함 {len(sup)}"
# 묶음 시군 가운데 일부만 조달청 줄이 있음 — 그 시군 줄만(같은 도 · 묶음 밖 시군이 낀 줄은 뺌)
part = [k for k in cands if towns & k and k <= want | PROVINCE and k - PROVINCE <= want]
if len(towns) > 1 and part:
return [r for k in part for r in cands[k]], f"지역 일부 {len(part)}"
return None, "지역 못 찾음"
@@ -308,7 +312,7 @@ class Matcher:
if cut != want:
cands, why = region_pick(table, cut)
if cands is None or (
why.startswith(("지역 묶음", "지역 포함")) and "제외" in name
why.startswith(("지역 묶음", "지역 포함", "지역 일부")) and "제외" in name
): # 「~ 제외」 묶음은 짝짓지 않음
return None, why if cands is None else "지역 못 찾음"
corp = re.findall(
@@ -538,6 +542,7 @@ def decide(x: dict, r: dict, day: str) -> tuple[int | None, str]:
return None, f"관급: 조달청 값 못 채움 — {r['까닭']} ({day})"
c = r["조달청"]
who = f" {c['제조사']} 최저" if "최저" in r["까닭"] else ""
who += " · 묶음 시군 일부만 조달청에 있음" if "지역 일부" in r["까닭"] else ""
return r["새값"], (
f"관급: 조달청 {METHOD.get(c['방식'], c['방식'])}{who} {day} · 계약 {c['계약시작']}~ · {c['지역']} · {c['인도']} · {c['부가세']}"
)