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

This commit is contained in:
2026-09-20 16:52:43 +09:00
27 changed files with 439 additions and 7602 deletions
@@ -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노임",
"산림노임",
"준용대상",
"나라장터자재",
"오피넷유가",
"자재품목",
"한국은행환율",
+8 -46
View File
@@ -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("연결")