Merge remote-tracking branch 'origin/dev' into sub_desktop_1

This commit is contained in:
2026-09-20 23:09:54 +09:00
17 changed files with 7409 additions and 144 deletions
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""조달청 시설공통자재 xlsx(4분야) → 품명·규격·단위·금액 csv (지식DB 폴더 안). 마스터 JSON 은 안 만짐.
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_시설자재가격.py
세기: ... build_시설자재가격.py --count (12 재료 · ref/_없는_재료.md 이름이 자료에 몇 줄인지 출력)
"""
from __future__ import annotations
import csv
import re
import sys
from pathlib import Path
import openpyxl
ROOT = Path(__file__).resolve().parents[3]
DIR = ROOT / "resources/knowledge/original/원가계산/자재단가/조달청_시설자재_22하"
OUT = DIR / "시설공통자재_22하.csv"
MISSING = ROOT / "resources/master_data/ref/_없는_재료.md"
TWELVE = [
"합판",
"각재",
"건설용거푸집",
"동바리",
"파이프서포트",
"비계",
"클램프",
"철선",
"",
"폼타이",
"유공관",
"종자",
"방부",
"잡석",
]
def rows():
for f in sorted(DIR.glob("시설공통자재_*.xlsx")):
ws = openpyxl.load_workbook(f, read_only=True, data_only=True).worksheets[0]
head = None
for r in ws.iter_rows(values_only=True):
if head is None:
head = r if r and r[0] == "물품분류번호" else None
continue
if r[2]:
yield (
f.stem.split("_")[1],
r[2],
r[3] or "",
r[4] or "",
str(r[5] or "").replace(",", ""),
)
def count(data, words):
return {w: sum(1 for d in data if w in f"{d[1]}{d[2]}") for w in words}
def main():
data = list(rows())
if "--count" in sys.argv:
print(len(data), "")
for w, n in count(data, TWELVE).items():
print("12재료", w, n)
for m in re.finditer(
r"^\| MP\d+ \| ([^|]+?) \|", MISSING.read_text(encoding="utf-8"), re.M
):
name = re.sub(r"\(.*", "", m.group(1)).strip()
n = (
count(data, [name])[name]
if len(name) >= 3
else sum(1 for d in data if d[1] == name)
) # 짧은 이름은 품명 전체 일치만
print(f"없는재료 {name} {n}")
return
with OUT.open("w", encoding="utf-8-sig", newline="") as fp:
w = csv.writer(fp)
w.writerow(["분야", "품명", "규격", "단위", "금액"])
w.writerows(data)
print(OUT.name, len(data), "")
if __name__ == "__main__":
main()
@@ -14,6 +14,7 @@
import json
import re
import sys
import unicodedata
from pathlib import Path
HERE = Path(__file__).resolve().parent
@@ -56,20 +57,36 @@ OVERRIDE = {
# 이름이 같은 자재품목 줄이 하나뿐인 재료 — 그 줄을 대표로
"MP000105": ("건축마감", "천장·바닥·마감재", "MT007262"), # ROOFSHEET
"MP000168": ("건축마감", "천장·바닥·마감재", "MT008074"), # 초배지
"MP000289": ("잡재료", "고철·기타금속", "MT003499"), # 함석
"MP000292": ("잡재료", "고철·기타금속", "MT003499"),
"MP000307": ("잡재료", "고철·기타금속", "MT003499"),
"MP000312": ("잡재료", "고철·기타금속", "MT003499"),
"MP000318": ("잡재료", "고철·기타금속", "MT003499"),
"MP000339": ("잡재료", "고철·기타금속", "MT003499"),
# 검증(ref/_검증_품셈재료_고르기.md) — 이름만 비슷한 다른 물건 · 규격 다른 대표 · 묶음 안 맞음: 비움
**{
k: None
for k in (
"MP000013 MP000268 MP000284 MP000291 MP000322 MP000354 MP000080 MP000244 MP000071 "
"MP000122 MP000138 MP000059 MP000289 MP000292 MP000307 MP000312 MP000318 MP000339"
).split()
},
}
NO_GROUP = "자재품목 묶음에 짝이 없음 — 관리자가 화면에서 이음"
NO_ROW = "자재품목에 그 묶음 줄이 없음"
NO_UNIT = "자재품목 그 묶음에 같은 단위·이름 줄이 없음"
UNIT = {"": "kg", "": "ton", "Ton": "ton", "M": "m", "": "", "L": "", "EA": "", "set": "SET", "Set": "SET"}
PRICE = ("물가자료", "유통물가", "물가정보", "거래가격", "관급")
NOTE = re.compile(r"\s*·?\s*고르기 없음\([^)]*\)")
FIELD = re.compile(r'("연결": (?:null|"[^"]*"|\{[^{}]*\}))(, "고르기": (?:null|\{[^{}]*\}))?')
NOTE_FIELD = re.compile(r'(, "비고": )(null|"(?:[^"\\]|\\.)*")')
def _spec(s) -> str:
"""규격 표기 맞춤 — 지름 기호(∅ø⌀→Φ) 뒤 빈칸 없앰 · ㎜→mm."""
return re.sub(r"[∅ø⌀]\s*", "Φ", unicodedata.normalize("NFKC", str(s or "")).replace("", "Φ"))
def _has(word: str, spec) -> bool:
"""낱말이 규격 글자에 숫자 경계로 듦 — 「50mm」 가 「150mm」 에 걸리지 않게."""
w = re.escape(mm.plain_spec(_spec(word)))
return re.search(rf"(?<![\d.]){w}(?!\d)", mm.plain_spec(_spec(spec))) is not None
def 고르기(row: dict, market: list[dict]) -> tuple[dict | None, str]:
"""한 줄의 고르기 조건 — (조건 또는 None, 까닭)."""
key = str(row.get(""))
@@ -89,10 +106,38 @@ def 고르기(row: dict, market: list[dict]) -> tuple[dict | None, str]:
if not hits:
return None, NO_ROW
대표 = {"대표": rep[0]} if rep else {}
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, **대표}, ""
spec = _spec(row.get("규격")).replace(",", " ").strip()
want = spec.split()
cond = {"구분": sub, "상세구분": detail, **대표}
narrow = [r for r in hits if all(_has(w, r.get("규격")) for w in want)] if want else []
if narrow:
cond = {"구분": sub, "상세구분": detail, "규격": spec, **대표}
got = _단위(row, cond, narrow, market)
if got[0]:
return got
cond = {k: v for k, v in cond.items() if k != "규격"} # 규격까지 좁히면 단위 맞는 줄이 없음 — 규격 없이 다시
return _단위(row, cond, hits, market)
def _단위(row: dict, cond: dict, hits: list[dict], market: list[dict]) -> tuple[dict | None, str]:
"""후보 첫 줄의 단위가 원문 단위와 다르면 — 단위·이름이 같은 줄을 대표로, 없으면 비움."""
u = lambda x: UNIT.get(x, x) # noqa: E731
want = u(str(row.get("단위") or ""))
val = lambda r: [r[k] for k in PRICE if r.get(k) is not None] # noqa: E731
if not want:
return cond, ""
if cond.get("대표"):
rep = next(r for r in market if r[""] == cond["대표"])
return (cond, "") if u(rep["단위"]) == want else (None, NO_UNIT)
first = next((r for r in hits if val(r)), hits[0])
if u(first["단위"]) == want:
return cond, ""
root = re.sub(r"\s+", "", re.split(r"[(,\-]", str(row["이름"]))[0])
same = [r for r in hits if u(r["단위"]) == want and val(r) and root in re.sub(r"\s+", "", r["이름"])]
if not same:
return None, NO_UNIT
best = min(same, key=lambda r: (min(val(r)), r[""]))
return {**cond, "대표": best[""]}, ""
def main(show: bool) -> int: