knowledge(마스터): 나라장터 빼기 — 로직 호표를 자재품목으로 갈아 끼우고 MN 폐기
- 로직이 가리키던 나라장터 자리 121(정적 키 18 · 고르기 13벌)을 자재품목(MT) 줄로 갈아 끼움 — 이름·규격·단위가 맞는 줄 · 지역이 갈리는 품목은 서울 줄. - 자재품목에 맞는 줄이 없는 넷은 품셈재료 미연결로 돌림 — 시너(MP000266) · 동 및 동합금용 용접봉 BCUP3 · 폴리에틸렌필름 0.04㎜ · 아스팔트싱글(새 줄 셋). - 엔진이 자재품목 키를 바로 받으면 값 열 다섯 가운데 낮은 값(시험 계산용) · 출처는 「키.열」. - 재료_나라장터자재.json 과 그 빌더를 old 로 옮기고 키 대장에 MN 폐기 · check_master · 키 대장 · M01 서버·화면 · _틀.md · compare_estx 의 나라장터 자리 정리. - 끝 검사: check_master 틀 0 · 로직 0 · 일괄 시험 계산 통과 988 · 멈춤 363(앞 989 · 362 — 아스팔트싱글 한 줄) · pytest 67 통과 · typecheck 깨끗. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -1,103 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값 공시를 채택**하는 규칙(2026-09-16 브레인).
|
||||
|
||||
왜 — 우리 고르기 규칙이 「물품코드마다 가장 나중 공시」라, 값이 안 실린 공시(`prce = 0`)가
|
||||
옛 제값을 덮었다. 각형강관 25*25*1 이 `1,460 → 1,500 → 0` 으로 끝난 것이 그것이다.
|
||||
「가격이 0」이 아니라 **그 공시에 값이 안 실린 것**이라, 0 을 값으로 받으면 안 된다.
|
||||
|
||||
무엇을 고치나
|
||||
- 마스터의 0 원 줄만 고친다. **원천 스냅숏은 안 건드린다**(읽기만).
|
||||
- 0 이 아닌 줄은 한 글자도 안 건드린다 — 규칙을 통째로 다시 돌리지 않는다.
|
||||
- 고친 줄마다 **사유**(`price_adopted_reason`)를 남긴다. 조용히 바꾸지 않는다.
|
||||
- 공시 날짜·번호도 **채택한 공시의 것**으로 바꾼다 — 어느 판 값인지 화면에서 보여야 한다.
|
||||
- 앞 공시에도 값이 없어 0 이 남는 줄은 `zero_price_unrecovered` 로 낸다(규칙으로 못 고침).
|
||||
|
||||
⚠ 같은 날짜에 0 과 제값이 함께 실린다(2025-04-24) — 날짜만 보지 말고 **0 을 건너뛴 뒤**
|
||||
가장 나중 공시를 고른다.
|
||||
|
||||
돌리기: `./venv/Scripts/python.exe resources/master_data/scripts/_build_mat_price.py`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import master_keys as mk
|
||||
from _migrate_base import dump_master
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json"
|
||||
MASTER = ROOT / "resources/master_data/재료_나라장터자재.json"
|
||||
|
||||
#: 값이 안 실린 공시를 가리는 잣대 — 빈 칸·0 은 「값 없음」이다.
|
||||
EMPTY_PRICES = {"", "0", "0.0", "0.00"}
|
||||
|
||||
SELECTION_POLICY = (
|
||||
"latest notice per product identification number within the local snapshot, "
|
||||
"skipping notices whose price is empty or zero "
|
||||
"(a zero price means the notice carried no price, not a price of zero)"
|
||||
)
|
||||
UNRECOVERED_NOTE = (
|
||||
"앞 공시에도 값이 없어 0 이 남은 줄. **규칙으로 못 고친다** — 0 을 값으로 쓰지 말고 "
|
||||
"그 자재가 실제로 필요해지면 원천을 다시 받거나 견적을 받아야 한다."
|
||||
)
|
||||
|
||||
|
||||
def price_of(row: dict) -> str:
|
||||
return str(row.get("prce", "")).strip()
|
||||
|
||||
|
||||
def has_price(row: dict) -> bool:
|
||||
return price_of(row) not in EMPTY_PRICES
|
||||
|
||||
|
||||
def notice_day(row: dict) -> str:
|
||||
return str(row.get("nticeDt", ""))[:10]
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
raw = json.loads(RAW.read_text(encoding="utf-8"))
|
||||
master = mk.old_view(MASTER.name)
|
||||
records = master["줄"]
|
||||
|
||||
history: dict[str, list[dict]] = {}
|
||||
for row in raw:
|
||||
history.setdefault(str(row.get("prdctIdntNo")), []).append(row)
|
||||
for rows in history.values():
|
||||
rows.sort(key=lambda r: str(r.get("nticeDt")), reverse=True)
|
||||
|
||||
repaired, unrecovered = 0, []
|
||||
for record in records:
|
||||
if str(record.get("값")).strip() not in EMPTY_PRICES:
|
||||
continue
|
||||
was = notice_day(record | {"nticeDt": record.get("공시일시")})
|
||||
adopted = next((r for r in history.get(str(record["열쇠"]), ()) if has_price(r)), None)
|
||||
if adopted is None:
|
||||
unrecovered.append(
|
||||
{
|
||||
"열쇠": record["열쇠"],
|
||||
"이름": record.get("이름", ""),
|
||||
"규격": record.get("규격", ""),
|
||||
"공시일시": record.get("공시일시", ""),
|
||||
"사유": "이 물품코드의 모든 공시에 값이 없음",
|
||||
}
|
||||
)
|
||||
continue
|
||||
record["값"] = int(price_of(adopted))
|
||||
record["공시일시"] = str(adopted.get("nticeDt", ""))
|
||||
record["공시번호"] = str(adopted.get("prceNticeNo", ""))
|
||||
record["비고"] = f"{was} 공시에 값이 없어 {notice_day(adopted)} 값을 씀"
|
||||
repaired += 1
|
||||
|
||||
master["선정규칙"] = SELECTION_POLICY
|
||||
master["0원미회수"] = {"비고": UNRECOVERED_NOTE, "줄": unrecovered}
|
||||
return {"master": master, "repaired": repaired, "unrecovered": len(unrecovered)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = build()
|
||||
master = result["master"]
|
||||
dump_master(MASTER, {k: v for k, v in master.items() if k != "줄"}, master["줄"])
|
||||
print(f"고친 줄 {result['repaired']} · 못 고친 줄 {result['unrecovered']}")
|
||||
print("새 틀로 받음", mk.adopt())
|
||||
@@ -41,7 +41,6 @@ BOOKS = (
|
||||
"SW노임",
|
||||
"산림노임",
|
||||
"준용대상",
|
||||
"나라장터자재",
|
||||
"오피넷유가",
|
||||
"자재품목",
|
||||
"한국은행환율",
|
||||
|
||||
@@ -172,61 +172,23 @@ def bare_spec(name, spec) -> str:
|
||||
return "".join(ch for ch in s if ch not in "-()[]")
|
||||
|
||||
|
||||
def spec_match(nara_spec: str, estx_spec: str) -> bool:
|
||||
"""규격 같음 — 같거나, ESTX 규격(3자 이상)이 나라장터 규격 끝과 같음(앞 수식어 「아연도」 등)."""
|
||||
return nara_spec == estx_spec or (len(estx_spec) >= 3 and nara_spec.endswith(estx_spec))
|
||||
def spec_match(one: str, estx_spec: str) -> bool:
|
||||
"""규격 같음 — 같거나, ESTX 규격(3자 이상)이 상대 규격 끝과 같음(앞 수식어 「아연도」 등)."""
|
||||
return one == estx_spec or (len(estx_spec) >= 3 and one.endswith(estx_spec))
|
||||
|
||||
|
||||
def material_pairs(estx: Estx, nara: list[dict]):
|
||||
by_name = {}
|
||||
def estx_names(estx: Estx) -> dict[str, list[dict]]:
|
||||
by_name: dict[str, list[dict]] = {}
|
||||
for r in estx.table("StuffcDat"):
|
||||
if r.get("Name") and r.get("TC"):
|
||||
by_name.setdefault(base_name(r["Name"]), []).append(r)
|
||||
pairs = []
|
||||
for n in nara:
|
||||
cands = by_name.get(base_name(n["이름"])) if n.get("값") else None
|
||||
if not cands:
|
||||
continue
|
||||
s = bare_spec(n["이름"], n["규격"])
|
||||
hit = next((e for e in cands if spec_match(s, bare_spec(e["Name"], e.get("Size")))), None)
|
||||
if hit:
|
||||
pairs.append((n, hit))
|
||||
return by_name, pairs
|
||||
return by_name
|
||||
|
||||
|
||||
def material(estx: Estx, files: dict):
|
||||
nara = files["재료_나라장터자재.json"]["줄"]
|
||||
by_name, pairs = material_pairs(estx, nara)
|
||||
by_name = estx_names(estx)
|
||||
rows = sum(by_name.values(), [])
|
||||
names = {base_name(n["이름"]) for n in nara}
|
||||
print(
|
||||
f"## 자재 — 나라장터 {len(nara)}(품명 {len(names)}) · ESTX {len(rows)} · 품명 같음 {len(names & set(by_name))} · 품명·규격 짝 {len(pairs)}"
|
||||
)
|
||||
print(" TC 대 나라장터:", spread([rate(n["값"], e["TC"]) for n, e in pairs]))
|
||||
for i, slot in enumerate(SLOTS, 1):
|
||||
got = [rate(n["값"], e[f"Cost{i}"]) for n, e in pairs if e.get(f"Cost{i}")]
|
||||
print(f" 슬롯{i} {slot}:", spread(got))
|
||||
closest = {}
|
||||
for n, e in pairs:
|
||||
got = {
|
||||
s: abs(rate(n["값"], e[f"Cost{i}"]))
|
||||
for i, s in enumerate(SLOTS, 1)
|
||||
if e.get(f"Cost{i}")
|
||||
}
|
||||
best = min(got, key=got.get)
|
||||
closest[best] = closest.get(best, 0) + 1
|
||||
print(" 가장 가까운 슬롯:", closest)
|
||||
for n, e in pairs[:8]:
|
||||
print(
|
||||
" 예",
|
||||
n["이름"],
|
||||
"|",
|
||||
n["규격"],
|
||||
n["값"],
|
||||
"→",
|
||||
e.get("TC"),
|
||||
{s: e.get(f"Cost{i}") for i, s in enumerate(SLOTS, 1) if e.get(f"Cost{i}")},
|
||||
)
|
||||
print(f"## 자재 — ESTX 품목 {len(rows)}(품명 {len(by_name)}) · 품셈재료 미연결 후보")
|
||||
have, near, none = [], [], []
|
||||
for r in files["재료_품셈재료.json"]["줄"]:
|
||||
if r.get("연결"):
|
||||
|
||||
@@ -19,7 +19,6 @@ KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
TABLES = {
|
||||
("인력", None): "LB",
|
||||
("재료", "자재품목"): "MT",
|
||||
("재료", "나라장터자재"): "MN", # 자재품목과 잇지 않음 — 로직 호표가 키로 바로 가리킴
|
||||
("재료", "오피넷유가"): "MO",
|
||||
("재료", "품셈재료"): "MP",
|
||||
("기계", None): "EQ",
|
||||
|
||||
@@ -13,6 +13,7 @@ import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = "MT" # 자재품목 테이블ID
|
||||
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
@@ -36,7 +37,13 @@ def plain_unit(s) -> str:
|
||||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||||
"""자재품목 줄 하나의 (값, 출처) — 값이 있는 열마다 하나 · 출처는 그 열 이름."""
|
||||
row = master.get(key)
|
||||
return [(row[slot], f"{key}.{slot}") for slot in SLOTS if row.get(slot) is not None]
|
||||
return [(row[slot], f"{row['키']}.{slot}") for slot in SLOTS if row.get(slot) is not None]
|
||||
|
||||
|
||||
def _lowest(master: mf.Master, key: str) -> tuple[object, str | None]:
|
||||
"""자재품목 줄의 낮은 값 — ⚠ 시험 계산용."""
|
||||
offers = _offers(master, key)
|
||||
return min(offers, key=lambda x: x[0]) if offers else (None, None)
|
||||
|
||||
|
||||
def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]:
|
||||
@@ -73,8 +80,10 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
|
||||
|
||||
|
||||
def element(master: mf.Master, ref: str, env: dict) -> tuple[object, str | None]:
|
||||
"""요소 값과 출처 — 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값` 그대로(출처 None)."""
|
||||
"""요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`."""
|
||||
row = master.get(ref)
|
||||
if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄
|
||||
return _lowest(master, ref)
|
||||
if ref[:2] != LINKED:
|
||||
return master.value(ref)
|
||||
link = row.get("연결")
|
||||
|
||||
Reference in New Issue
Block a user