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

This commit is contained in:
2026-09-20 16:33:09 +09:00
20 changed files with 48439 additions and 48479 deletions
@@ -1,183 +0,0 @@
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_시중물가.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
값 = 시중{가격정보·물가자료·유통물가·물가정보·거래가격 + 면수} · 조달{값 · 출처 · 기준}.
Cost1..5 = 시중 다섯 · Page1..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
품명에 「관급」 인 줄의 Cost1 = 조달청 관급 단가 — 조달 묶음으로 옮김(기준 = Page1 「조달 26.7」 · 「~26.5.26」).
나라장터 시설공통자재와 이름·규격·단위가 온전히 같고 서로 하나뿐인 줄은 조달 출처 「나라장터:<열쇠>」 — 값은 베끼지 않음.
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
ESTX 에 대·중분류 칸이 없어 분류는 두지 않음.
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_재료_시중물가.py
쓴 뒤 원본과 대조.
"""
import collections
import hashlib
import json
import math
import random
import re
import sys
import unicodedata
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
from _migrate_base import dump_master # noqa: E402
from compare_estx import ESTX, ROOT, SLOTS, Estx, bare_spec, base_name # noqa: E402
OUT = ROOT / "resources/master_data/재료_시중물가.json"
NARA = ROOT / "resources/master_data/재료_나라장터자재.json"
MARKET = SLOTS[:5] # 견적가(Cost6)는 마스터 아님
_NUM = re.compile(r"\d+(?:\.\d+)?")
_UNIT = {"ea": "", "ton": "", "t": "", "l": "", "리터": "", "set": "", "세트": ""}
def num(v):
if not v:
return None
return int(v) if float(v).is_integer() else v
def _base(s) -> str:
s = unicodedata.normalize("NFKC", str(s or "")).lower()
return re.sub(r"\s+", "", re.sub(r"[φΦ∅⌀]", "ø", s))
def _spec(s) -> str:
return _base(s).replace("×", "x").replace("*", "x")
def _unit(s) -> str:
s = _base(s).replace("", "l")
return _UNIT.get(s, s)
def _nara_spec(n: dict) -> str:
"""나라장터 규격 = 「품명, 규격」 — 앞 품명을 뗌."""
head, _, tail = n["규격"].partition(",")
return tail if tail and _base(head) == _base(n["이름"]) else n["규격"]
def nara_links(rows: list[dict], nara: list[dict]) -> dict[str, str]:
"""시중물가 열쇠 → 나라장터 열쇠 — 품명 뿌리 · 규격 수 모음이 맞는 짝 가운데
이름·규격·단위가 온전히 같고 서로 하나뿐인 것만(관급 줄 · 애매한 것은 사람이 화면에서)."""
by = collections.defaultdict(list)
for e in rows:
if e.get("Code") and "관급" not in e.get("Name", ""):
by[base_name(e.get("Name"))].append(e)
pairs = []
for n in nara:
if not n.get(""):
continue
nums = sorted(_NUM.findall(bare_spec(n["이름"], n["규격"])))
for e in by.get(base_name(n["이름"]), []):
if (
nums == sorted(_NUM.findall(bare_spec(e.get("Name"), e.get("Size"))))
and _base(n["이름"]) == _base(e.get("Name"))
and _spec(_nara_spec(n)) == _spec(e.get("Size"))
and _unit(n["단위"]) == _unit(e.get("Unite"))
):
pairs.append((n["열쇠"], e["Code"]))
cn = collections.Counter(n for n, _ in pairs)
ce = collections.Counter(e for _, e in pairs)
return {e: n for n, e in pairs if cn[n] == 1 and ce[e] == 1}
def build(rows: list[dict], links: dict[str, str]) -> list[dict]:
out = []
for r in sorted(rows, key=lambda r: r["Index"]):
code = r.get("Code")
if not code:
continue
market = {slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 1)}
market["면수"] = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 1)}
supply = {"": None, "출처": None, "기준": None}
if "관급" in r.get("Name", "") and market["가격정보"] is not None:
supply = {
"": market["가격정보"],
"출처": "가격정보",
"기준": market["면수"]["가격정보"],
}
market["가격정보"] = market["면수"]["가격정보"] = None
elif code in links:
supply["출처"] = f"나라장터:{links[code]}"
out.append(
{
"열쇠": code,
"이름": r.get("Name", ""),
"규격": r.get("Size", ""),
"단위": r.get("Unite", ""),
"": {"시중": market, "조달": supply},
"출처": f"시중물가 {code}",
}
)
return out
def check(rows: list[dict]) -> None:
"""원본 대조 — 줄 수 · 출처별 값 합(가격정보 = 시중 + 조달) · 표본 쉰 줄."""
data = json.loads(OUT.read_text(encoding="utf-8"))
got = {r["열쇠"]: r for r in data[""]}
raw = {r["Code"]: r for r in rows if r.get("Code")}
assert len(got) == len(data[""]) == len(raw), (len(got), len(raw))
print(
f"{len(got)} = 원본 코드 있는 줄 {len(raw)} (코드 없는 빈 줄 {len(rows) - len(raw)} 뺌)"
)
for i, slot in enumerate(MARKET, 1):
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
b = math.fsum(r[""]["시중"][slot] or 0 for r in got.values())
if slot == "가격정보":
b += math.fsum(r[""]["조달"][""] or 0 for r in got.values())
n = sum(1 for r in got.values() if r[""]["시중"][slot] is not None)
assert abs(a - b) < 1e-3, (slot, a, b)
print(f" {slot}: 시중 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
supply = collections.Counter(
(r[""]["조달"]["출처"] or "없음").split(":")[0] for r in got.values()
)
print(f" 조달: {dict(supply)}")
random.seed(20260719)
for code in random.sample(sorted(raw), 50):
r, g = raw[code], got[code]
assert (g["이름"], g["규격"], g["단위"]) == (
r.get("Name", ""),
r.get("Size", ""),
r.get("Unite", ""),
)
moved = g[""]["조달"]["출처"] == "가격정보"
for i, slot in enumerate(MARKET, 1):
have = g[""]["조달"][""] if moved and i == 1 else g[""]["시중"][slot]
page = g[""]["조달"]["기준"] if moved and i == 1 else g[""]["시중"]["면수"][slot]
assert (have or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
assert page == (r.get(f"Page{i}") or None), (code, slot)
print(" 표본 50줄 이름·규격·단위·값 5·면수 5 같음")
def main():
rows = Estx().table("StuffcDat")
head = {
"그룹": "재료",
"원문": "시중물가",
"": "2026-07",
"원천": [
{
"경로": ESTX.relative_to(ROOT).as_posix(),
"해시": hashlib.sha256(ESTX.read_bytes()).hexdigest(),
"역할": "primary",
}
],
"비고": [
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
"값 = 시중(물가지 다섯 · 면수) · 조달(값 · 출처 · 기준) — 기본가·채택값 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
"조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<키>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)",
],
}
nara = mk.old_view(NARA.name)[""]
dump_master(OUT, head, build(rows, nara_links(rows, nara)))
check(rows)
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 나라장터 연결 → 키
if __name__ == "__main__":
main()
@@ -0,0 +1,139 @@
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수.
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
품명에 「관급」 인 줄의 Cost1(가격정보) = 조달청 관급 단가 — 「관급」 열 · 기간은 비고 「조달 26.7」.
그 밖의 줄에 남은 Cost1 은 출처가 조달인지 물가지인지 원문이 안 가름 — 값 열에 넣지 않고 비고 「가격정보 ○○」 로만.
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 비움(지어내지 않음).
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_재료_자재품목.py
쓴 뒤 원본과 대조.
"""
import hashlib
import json
import math
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
from _migrate_base import dump_master # noqa: E402
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
OUT = ROOT / "resources/master_data/재료_자재품목.json"
BASE = SLOTS[0] # Cost1 「가격정보」 — 관급이면 관급 열 · 아니면 비고
MARKET = SLOTS[1:5] # 물가지 넷 · 견적가(Cost6)는 마스터 아님
def num(v):
if not v:
return None
return int(v) if float(v).is_integer() else v
def note(base, page, guard: str | None) -> str | None:
"""비고 — 관급이면 「조달 <기간>」 · 남은 가격정보는 「가격정보 <값> · <면수>」."""
if guard is not None:
return f"조달 {str(guard).removeprefix('조달 ')}".strip()
if base is None:
return None
return " · ".join(x for x in (f"가격정보 {base:,}", page) if x)
def build(rows: list[dict]) -> list[dict]:
out = []
for r in sorted(rows, key=lambda r: r["Index"]):
code = r.get("Code")
if not code:
continue
base, page = num(r.get("Cost1")), r.get("Page1") or None
public = "관급" in r.get("Name", "") and base is not None
면수 = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 2)}
out.append(
{
"열쇠": code,
"구분": None,
"상세구분": None,
"이름": r.get("Name", ""),
"규격": r.get("Size", ""),
"단위": r.get("Unite", ""),
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
"관급": base if public else None,
"출처": f"자재품목 {code}",
"비고": note(base, page, page if public else None),
"면수": 면수 if any(면수.values()) else None,
}
)
return out
def check(rows: list[dict]) -> None:
"""원본 대조 — 줄 수 · 값 열마다 합 · 관급·가격정보 가름 · 표본 쉰 줄."""
data = json.loads(OUT.read_text(encoding="utf-8"))
got = {r["열쇠"]: r for r in data[""]}
raw = {r["Code"]: r for r in rows if r.get("Code")}
assert len(got) == len(data[""]) == len(raw), (len(got), len(raw))
print(
f"{len(got)} = 원본 코드 있는 줄 {len(raw)} (코드 없는 빈 줄 {len(rows) - len(raw)} 뺌)"
)
for i, slot in enumerate((*MARKET, "관급"), 2):
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
if slot == "관급": # Cost1 가운데 관급 줄만
a = math.fsum(r.get("Cost1") or 0 for r in raw.values() if "관급" in r.get("Name", ""))
b = math.fsum(r[slot] or 0 for r in got.values())
n = sum(1 for r in got.values() if r[slot] is not None)
assert abs(a - b) < 1e-3, (slot, a, b)
print(f" {slot}: 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
left = [r for r in got.values() if str(r["비고"] or "").startswith("가격정보")]
print(
f" 비고에만 남긴 가격정보 {len(left)}줄 · 관급 기간 비고 {sum(1 for r in got.values() if str(r['비고'] or '').startswith('조달'))}"
)
random.seed(20260719)
for code in random.sample(sorted(raw), 50):
r, g = raw[code], got[code]
assert (g["이름"], g["규격"], g["단위"]) == (
r.get("Name", ""),
r.get("Size", ""),
r.get("Unite", ""),
)
public = "관급" in r.get("Name", "")
assert (g["관급"] or 0) == ((r.get("Cost1") or 0) if public else 0), code
for i, slot in enumerate(MARKET, 2):
assert (g[slot] or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
assert (g["면수"] or {}).get(slot) == (r.get(f"Page{i}") or None), (code, slot)
print(" 표본 50줄 이름·규격·단위·값 5·면수 4 같음")
def main():
rows = Estx().table("StuffcDat")
head = {
"그룹": "재료",
"원문": "자재품목",
"": "2026-07",
"원천": [
{
"경로": ESTX.relative_to(ROOT).as_posix(),
"해시": hashlib.sha256(ESTX.read_bytes()).hexdigest(),
"역할": "primary",
}
],
"비고": [
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급(조달) — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
"관급 = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 비고 「조달 26.7」",
"원문 「가격정보」 칸에 시중 값이 남은 72 줄은 값 열에 넣지 않고 비고 「가격정보 ○○」 로만 둠 — 출처가 조달인지 물가지인지 원문이 안 가름",
"구분 · 상세구분 = 원문에 대·중분류 칸이 없어 비움(지어내지 않음)",
"면수 = 물가지 값의 원문 면수 · 화면에는 접어 둠",
],
}
dump_master(OUT, head, build(rows))
check(rows)
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키
if __name__ == "__main__":
main()
+41 -10
View File
@@ -43,7 +43,7 @@ BOOKS = (
"준용대상",
"나라장터자재",
"오피넷유가",
"시중물가",
"자재품목",
"한국은행환율",
"조달청제비율",
"품셈재료",
@@ -89,6 +89,39 @@ LABOR_COLS = (
"준용",
)
LABOR_STATUS = ("공표", "산정", "미공표", "미확보")
# 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null)
COLS = {
"자재품목": (
"",
"원문번호",
"구분",
"상세구분",
"이름",
"규격",
"단위",
"물가자료",
"유통물가",
"물가정보",
"거래가격",
"관급",
"출처",
"비고",
"면수",
),
"오피넷유가": (
"",
"원문번호",
"구분",
"상세구분",
"이름",
"규격",
"단위",
"",
"출처",
"비고",
),
}
COLS["한국은행환율"] = COLS["오피넷유가"]
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
@@ -181,13 +214,16 @@ def check_form(name: str, data: dict) -> list[str]:
out.append(f"{where} · {merged}{row.get(merged)}」 이 머리에 없음")
if division and not number.startswith(division + " "):
out.append(f"{where} · 원문번호 앞에 부문 「{division}」 없음")
for need in ROW_KEYS[kind]:
cols = COLS.get(data.get("원문"))
for need in cols or ROW_KEYS[kind]:
if need not in row:
out.append(f"{where} · 칸 「{need}」 없음")
if "출처" in row and not _source_ok(row["출처"]):
out.append(f"{where} · 출처 모양 「{row['출처']}")
if data.get("그룹") == "인력":
out += _check_labor(where, row)
if cols and tuple(row) != cols:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}")
if kind == "품셈재료":
out += _check_linked(where, row)
if kind == "요소" and isinstance(row.get(""), dict):
@@ -214,7 +250,7 @@ def _check_labor(where: str, row: dict) -> list[str]:
def _check_linked(where: str, row: dict) -> list[str]:
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 키 · 후보 조건 · null."""
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 자재품목 키 · 후보 조건 · null."""
out = [f"{where} · 값 칸을 두지 않음"] if "" in row else []
need = row.get("요구절")
if not (isinstance(need, list) and need and all(_source_ok(x) for x in need)):
@@ -232,22 +268,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("MT", {}):
out.append(f"{whole.label(key)} · 연결 「{link}」 이 시중물가에 없음")
out.append(f"{whole.label(key)} · 연결 「{link}」 이 자재품목에 없음")
for key, row in whole.index.get("LB", {}).items():
for col in ("준용",):
ref = row.get(col)
if ref not in (None, "") and str(ref) not in whole.index.get("LB", {}):
out.append(f"{whole.label(key)} · {col}{ref}」 없는 인력 키")
nara = whole.index.get("MN", {})
for key, row in whole.index.get("MT", {}).items():
source = str(row[""]["조달"].get("출처") or "")
if source.startswith("나라장터:") and source.split(":", 1)[1] not in nara:
out.append(f"{whole.label(key)} · 조달 「{source}」 이 나라장터자재에 없음")
return out
@@ -669,4 +669,4 @@ def find_loops(graph: dict[str, set]) -> list[list[str]]:
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
from master_material import LINKED, REGION, element # noqa: E402, F401
from master_material import LINKED, REGION, SLOTS, element # noqa: E402, F401
+7 -11
View File
@@ -18,8 +18,8 @@ KEY = re.compile(r"[A-Z]{2}\d{6}")
# (그룹, 원문) → 테이블ID · 인력·기계는 원문 구분 없이 한 테이블
TABLES = {
("인력", None): "LB",
("재료", "시중물가"): "MT",
("재료", "나라장터자재"): "MN",
("재료", "자재품목"): "MT",
("재료", "나라장터자재"): "MN", # 자재품목과 잇지 않음 — 로직 호표가 키로 바로 가리킴
("재료", "오피넷유가"): "MO",
("재료", "품셈재료"): "MP",
("기계", None): "EQ",
@@ -54,7 +54,10 @@ def load_book(path: Path = BOOK) -> dict:
def save_book(book: dict, path: Path = BOOK) -> None:
"""키 한 줄씩 — 머리 「다음」 뒤에 키 차례대로."""
lines = ["{", ' "다음": ' + json.dumps(book["다음"], ensure_ascii=False) + ",", ' "": {']
lines = ["{", ' "다음": ' + json.dumps(book["다음"], ensure_ascii=False) + ","]
if book.get("폐기"):
lines.append(' "폐기": ' + json.dumps(book["폐기"], ensure_ascii=False) + ",")
lines.append(' "": {')
items = sorted(book[""].items())
for i, (key, v) in enumerate(items):
comma = "," if i < len(items) - 1 else ""
@@ -187,7 +190,7 @@ def _old_ref(book: dict, text: str) -> str:
def _convert(book: dict, row: dict, names: dict) -> dict:
"""줄 속 옛 참조 — 준용 · 후보 글(키 옆 이름) · 품셈재료 연결 · 시중물가 조달 나라장터 연결."""
"""줄 속 옛 참조 — 준용 · 후보 글(키 옆 이름) · 품셈재료 연결."""
for col in ("준용",):
value = row.get(col)
if isinstance(value, str) and ":" in value:
@@ -202,13 +205,6 @@ def _convert(book: dict, row: dict, names: dict) -> dict:
link = row.get("연결")
if isinstance(link, str) and not KEY.fullmatch(link):
row["연결"] = lookup(book).get(("MT", link), link)
value = row.get("")
supply = value.get("조달") if isinstance(value, dict) else None
if isinstance(supply, dict) and str(supply.get("출처") or "").startswith("나라장터:"):
code = supply["출처"].split(":", 1)[1]
if not KEY.fullmatch(code): # 묶음은 새로 만듦 — 옛 줄과 나눠 쓰지 않게
source = "나라장터:" + lookup(book).get(("MN", code), code)
row[""] = {**value, "조달": {**supply, "출처": source}}
return row
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
"""품셈재료 단가 — 연결을 따라 시중물가 줄의 시중·조달 값 가운데 낮은 값 (`_틀.md` 4장 재료 값).
"""품셈재료 단가 — 연결을 따라 자재품목 줄의 값 열 다섯 가운데 낮은 값 (`_틀.md` 4장 재료 값).
⚠ 낮은 값 고르기는 **시험 계산용**. 최저가·견적 고르기는 프로젝트 몫 — 마스터는 값을 모으기만 함.
`master_formula` 가 요소 값을 풀 때 부름. 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음.
"""
@@ -11,7 +13,7 @@ import unicodedata
from decimal import Decimal
LINKED = "MP" # 품셈재료 테이블ID
MARKET = ("가격정보", "물가자료", "유통물가", "물가정보", "거래가격")
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
_UNITS = {"ea": "", "ton": "", "t": "", "l": "", "리터": "", "set": "", "세트": ""}
@@ -32,21 +34,9 @@ def plain_unit(s) -> str:
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
"""시중물가 줄 하나의 (값, 출처) — 시중 다섯 칸 · 조달(나라장터 연결은 그 줄 값)."""
value = master.get(key)[""]
out = [
(value["시중"][slot], f"{key}.시중.{slot}")
for slot in MARKET
if value["시중"].get(slot) is not None
]
supply = value["조달"]
if supply.get("") is not None:
out.append((supply[""], f"{key}.조달"))
elif str(supply.get("출처") or "").startswith("나라장터:"):
ref = supply["출처"].split(":", 1)[1]
if master.get(ref).get("") is not None:
out.append((master.get(ref)[""], ref))
return out
"""자재품목 줄 하나의 (값, 출처) — 값이 있는 열마다 하나 · 출처는 그 열 이름."""
row = master.get(key)
return [(row[slot], f"{key}.{slot}") for slot in SLOTS if row.get(slot) is not None]
def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]: