Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -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()
|
||||
@@ -4,6 +4,7 @@
|
||||
담당: 소요량_*.json · 계수_*.json (읽기+쓰기) · 로직_*.json · 원문 md (읽기만).
|
||||
다시 돌리면 같은 결과 — 순수 함수 + 정렬.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
|
||||
@@ -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", "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:
|
||||
|
||||
@@ -337,6 +337,25 @@ def check_links(whole: mf.Master) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def check_units(files: dict[str, dict], whole: mf.Master) -> list[str]:
|
||||
"""단위 경고 — 재료 고르기 조건의 후보 단위가 그 줄 단위와 다름(호표 줄 · 품셈재료 줄).
|
||||
데이터가 틀린 것이 아니라 조건을 더 좁혀야 하는 자리라 로직 검사와 따로 봄."""
|
||||
out = []
|
||||
for key, row in whole.index.get("MP", {}).items():
|
||||
cond = row.get("고르기")
|
||||
if isinstance(cond, dict):
|
||||
out += mf.check_unit(whole, whole.label(key) + " 고르기", cond, row.get("단위"))
|
||||
for name, data in files.items():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄", []):
|
||||
for item in row.get("호표", []):
|
||||
if isinstance(item.get("요소"), dict):
|
||||
where = f"{name} · {row.get('키')} 호표 {item.get('이름', '')} 요소"
|
||||
out += mf.check_unit(whole, where, item["요소"], item.get("단위"))
|
||||
return out
|
||||
|
||||
|
||||
def _check_table(where: str, table: dict) -> list[str]:
|
||||
out, conds, cols = [], table.get("조건", {}), table.get("값칸", {})
|
||||
allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols}
|
||||
@@ -613,6 +632,8 @@ def main(argv: list[str]) -> int:
|
||||
report["본문"] = check_body(files)
|
||||
if mode in ("로직", "전부"):
|
||||
report["로직"] = check_logics(files, whole)
|
||||
if mode in ("단위", "전부"):
|
||||
report["단위"] = check_units(files, whole)
|
||||
for title, found in report.items():
|
||||
print(f"({title}) {len(found)}건")
|
||||
for line in found:
|
||||
|
||||
@@ -481,7 +481,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
|
||||
ref = item["요소"] if isinstance(item["요소"], dict) else fill(item["요소"], env)
|
||||
try:
|
||||
price, source = element(master, ref, env)
|
||||
price, source = element(master, ref, env, item.get("단위"))
|
||||
except FormulaError:
|
||||
if qty != 0:
|
||||
raise
|
||||
@@ -703,6 +703,7 @@ from master_material import ( # noqa: E402, F401
|
||||
TARIFF,
|
||||
candidates,
|
||||
check_pick,
|
||||
check_unit,
|
||||
cond_text,
|
||||
element,
|
||||
pick,
|
||||
|
||||
@@ -111,35 +111,58 @@ 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]:
|
||||
def candidates(
|
||||
master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None
|
||||
) -> list[str]:
|
||||
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
|
||||
`unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건.
|
||||
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부)."""
|
||||
want = [plain_spec(w) for w in str(cond.get("규격") or "").split()]
|
||||
detail = cond.get("상세구분")
|
||||
need = plain_unit(unit) if unit else ""
|
||||
hits = [
|
||||
key
|
||||
for key, row in master.index.get(MARKET, {}).items()
|
||||
if row.get("구분") == cond.get("구분")
|
||||
and (not detail or row.get("상세구분") == detail)
|
||||
and all(w in plain_spec(row.get("규격")) for w in want)
|
||||
and (not need or plain_unit(row.get("단위")) == need)
|
||||
]
|
||||
region = plain((env or {}).get(REGION, ""))
|
||||
near = [k for k in hits if region in plain(master.get(k).get("이름"))] if region else []
|
||||
return near or hits
|
||||
|
||||
|
||||
def pick(master: mf.Master, cond: dict, env: dict) -> str:
|
||||
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄."""
|
||||
def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> str:
|
||||
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄(단위 같은 줄만)."""
|
||||
if cond.get("대표"):
|
||||
return str(cond["대표"])
|
||||
keys = candidates(master, cond, env)
|
||||
keys = candidates(master, cond, env, unit)
|
||||
keys = [k for k in keys if _offers(master, k)] or keys
|
||||
if not keys:
|
||||
raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}")
|
||||
return keys[0]
|
||||
|
||||
|
||||
def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]:
|
||||
def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]:
|
||||
"""단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름.
|
||||
틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`)."""
|
||||
rows = master.index.get(MARKET, {})
|
||||
rep = str(cond.get("대표") or "")
|
||||
if not unit or set(cond) - set(PICK_KEYS) or not cond.get("구분"):
|
||||
return []
|
||||
if rep in rows:
|
||||
if plain_unit(rows[rep].get("단위")) != plain_unit(unit):
|
||||
return [
|
||||
f"{where} · 대표 줄 단위 「{rows[rep].get('단위')}」 이 줄 단위 「{unit}」 과 다름"
|
||||
]
|
||||
return []
|
||||
if candidates(master, cond) and not candidates(master, cond, None, unit):
|
||||
return [f"{where} · 단위 「{unit}」 후보 0 — 조건 안 줄은 모두 다른 단위"]
|
||||
return []
|
||||
|
||||
|
||||
def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = None) -> list[str]:
|
||||
"""고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0."""
|
||||
if set(cond) - set(PICK_KEYS) or not cond.get("구분"):
|
||||
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
|
||||
@@ -157,11 +180,13 @@ def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def element(master: mf.Master, ref, env: dict) -> tuple[object, str | None]:
|
||||
def element(
|
||||
master: mf.Master, ref, env: dict, unit: str | None = None
|
||||
) -> tuple[object, str | None]:
|
||||
"""요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`.
|
||||
`ref` 가 고르기 조건 묶음이면 대표 줄(없으면 조건 안 첫 줄)로 풂."""
|
||||
`ref` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂."""
|
||||
if isinstance(ref, dict):
|
||||
ref = pick(master, ref, env)
|
||||
ref = pick(master, ref, env, unit)
|
||||
row = master.get(ref)
|
||||
if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄
|
||||
return _lowest(master, ref)
|
||||
|
||||
Reference in New Issue
Block a user