fix(z01): 자재 0원 70 줄 — 값이 안 실린 공시를 건너뛰고 앞 제값 채택 · 사유 남김
- 까닭 — 고르기 규칙이 「물품코드마다 가장 나중 공시」인데 값이 안 실린 공시(`prce=0`)가 옛 제값을 덮음. 각형강관 25*25*1.4t `1,460 → 1,500 → 0`. 「가격이 0」이 아니라 **그 공시에 값이 안 실린 것**임 - 새 규칙 `_build_mat_price.py` — **원천 스냅숏은 읽기만** · 마스터 0원 줄만 손댐 · 6,999 중 **70 줄만 바뀌고 나머지 6,929 줄은 한 글자도 안 바뀜**(줄 수 그대로) · 0원 0 남음 - ⚠ 같은 날짜에 0 과 제값이 함께 실림(2025-04-24) — 날짜만 보면 0 을 집음. **0 을 건너뛴 뒤** 가장 나중 공시를 고름 - 조용히 안 바꿈 — 줄마다 `price_adopted_reason` 「○ 공시에 값이 없어 ○ 값을 씀」 · 공시 **날짜·번호도 채택한 공시의 것**으로(어느 판 값인지 화면에서 보임) · `selection_policy` 에 0 건너뛰기 명시 · 못 고칠 줄 자리 `zero_price_unrecovered`(오늘은 빔) - 이름표에 새 열 「값을 고른 사유」 · 마스터가 바뀌어 폴더 지문(`_manifest`) sha·크기 갱신 - 새 시험 7건 — 0원이 남는지 · 사유가 붙는지 · 날짜·번호가 채택 공시와 같은지 · 같은 날 0 과 제값이 섞여도 제값을 집는지 · 0원 아닌 줄을 안 건드렸는지 · 앞 제값이 없으면 빨강이 아니라 목록에 남는지 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# -*- 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/data_cost_input_value/_build_mat_price.py`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json"
|
||||
MASTER = ROOT / "resources/data_cost_input_value/mat_price_public_2026-08-14.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 = json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
records = master["variables"]["mat_price"]["records"]
|
||||
|
||||
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("price_krw")).strip() not in EMPTY_PRICES:
|
||||
continue
|
||||
was = notice_day(record | {"nticeDt": record.get("notice_datetime")})
|
||||
adopted = next((r for r in history.get(str(record["item_code"]), ()) if has_price(r)), None)
|
||||
if adopted is None:
|
||||
unrecovered.append(
|
||||
{
|
||||
"item_code": record["item_code"],
|
||||
"classification_name": record.get("classification_name", ""),
|
||||
"specification": record.get("specification", ""),
|
||||
"notice_datetime": record.get("notice_datetime", ""),
|
||||
"reason": "이 물품코드의 모든 공시에 값이 없음",
|
||||
}
|
||||
)
|
||||
continue
|
||||
record["price_krw"] = int(price_of(adopted))
|
||||
record["notice_datetime"] = str(adopted.get("nticeDt", ""))
|
||||
record["notice_number"] = str(adopted.get("prceNticeNo", ""))
|
||||
record["price_adopted_reason"] = f"{was} 공시에 값이 없어 {notice_day(adopted)} 값을 씀"
|
||||
repaired += 1
|
||||
|
||||
master["selection_policy"] = SELECTION_POLICY
|
||||
master["zero_price_unrecovered"] = {"note": UNRECOVERED_NOTE, "items": unrecovered}
|
||||
return {"master": master, "repaired": repaired, "unrecovered": len(unrecovered)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = build()
|
||||
MASTER.write_text(
|
||||
json.dumps(result["master"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(f"고친 줄 {result['repaired']} · 못 고친 줄 {result['unrecovered']}")
|
||||
@@ -48,8 +48,8 @@
|
||||
"file": "mat_price_public_2026-08-14.json",
|
||||
"dataset_id": "mat_price_public",
|
||||
"effective_date": "2026-08-14",
|
||||
"sha256": "4f5f76cf5b39ee4e3152fec11b9f5efc70ecea075a66a5cc1bda6ff7691de5b7",
|
||||
"size_bytes": 4741651
|
||||
"sha256": "75ddfe85b0140d782cd00187ec459e995cd0db9982e7bd48f62f83cd65248acc",
|
||||
"size_bytes": 4748976
|
||||
},
|
||||
{
|
||||
"file": "oil_2026-08-14.json",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_master_labels",
|
||||
"effective_date": "2026-01-01",
|
||||
"generated_at": "2026-09-16T10:49:25+09:00",
|
||||
"generated_at": "2026-09-16T11:39:55+09:00",
|
||||
"note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.",
|
||||
"policy": {
|
||||
"labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.",
|
||||
@@ -210,7 +210,15 @@
|
||||
"key": "notice_datetime",
|
||||
"name_ko": "공고 일시",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
"visible": true,
|
||||
"note": "채택한 공시의 날짜 — 0 원 공시를 건너뛴 줄은 앞 공시 날짜가 선다."
|
||||
},
|
||||
{
|
||||
"key": "price_adopted_reason",
|
||||
"name_ko": "값을 고른 사유",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "⭐ 0 원 공시를 건너뛰고 앞 제값을 쓴 줄에만 선다(70 줄). 「○ 공시에 값이 없어 ○ 값을 씀」."
|
||||
},
|
||||
{
|
||||
"key": "notice_number",
|
||||
@@ -1079,14 +1087,14 @@
|
||||
},
|
||||
"counts": {
|
||||
"merged_tables": 5,
|
||||
"merged_columns": 88,
|
||||
"merged_columns": 89,
|
||||
"files": 34,
|
||||
"tables": 91,
|
||||
"columns": 455,
|
||||
"value_groups": 172,
|
||||
"columns": 456,
|
||||
"value_groups": 173,
|
||||
"unknown": 0,
|
||||
"value_groups_by_kind": {
|
||||
"doc": 106,
|
||||
"doc": 107,
|
||||
"value": 66
|
||||
}
|
||||
},
|
||||
@@ -4382,6 +4390,12 @@
|
||||
"name_ko": "분야",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "price_adopted_reason",
|
||||
"name_ko": "값을 고른 사유",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4404,6 +4418,12 @@
|
||||
"name_ko": "줄을 가르는 칸",
|
||||
"summary": "이 표에서 줄 하나를 집는 칸 이름.",
|
||||
"kind": "doc"
|
||||
},
|
||||
{
|
||||
"key": "zero_price_unrecovered",
|
||||
"name_ko": "값을 못 찾은 0 원 줄",
|
||||
"summary": "앞 공시에도 값이 없어 0 이 남은 줄. 규칙으로 못 고친다 — 그 자재가 필요해지면 원천을 다시 받아야 한다.",
|
||||
"kind": "doc"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -6765,6 +6785,11 @@
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
"price_adopted_reason": {
|
||||
"name_ko": "값을 고른 사유",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
"price_krw": {
|
||||
"name_ko": "단가",
|
||||
"unit": "원",
|
||||
@@ -7566,6 +7591,10 @@
|
||||
"name_ko": "원천 줄 수",
|
||||
"summary": "걸러 내기 전 원본 줄 수."
|
||||
},
|
||||
"mat_price_public::zero_price_unrecovered": {
|
||||
"name_ko": "값을 못 찾은 0 원 줄",
|
||||
"summary": "앞 공시에도 값이 없어 0 이 남은 줄. 규칙으로 못 고친다 — 그 자재가 필요해지면 원천을 다시 받아야 한다."
|
||||
},
|
||||
"material_surcharge::observed_practice": {
|
||||
"name_ko": "실무 관측",
|
||||
"summary": "실무 집계에서 본 할증률. 참고이지 기본값이 아니다."
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값을 쓴다**(2026-09-16 브레인).
|
||||
|
||||
왜 — 우리 고르기 규칙이 「물품코드마다 가장 나중 공시」라, 값이 안 실린 공시(`prce = 0`)가
|
||||
옛 제값을 덮었다(각형강관 25*25*1.4t `1,460 → 1,500 → 0`). 「가격이 0」이 아니라
|
||||
**그 공시에 값이 안 실린 것**이라, 0 을 값으로 받으면 안 된다.
|
||||
|
||||
⚠ 조용히 바꾸지 않는다 — 고친 줄에는 사유가 남고, 공시 날짜·번호도 **채택한 공시의 것**이 된다.
|
||||
⚠ 앞 공시에도 값이 없어 0 이 남는 줄은 **빨강이 아니라 목록**(`zero_price_unrecovered`)으로 남는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MASTER = ROOT / "resources" / "data_cost_input_value" / "mat_price_public_2026-08-14.json"
|
||||
RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json"
|
||||
EMPTY_PRICES = {"", "0", "0.0", "0.00"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def records(master) -> list[dict]:
|
||||
return master["variables"]["mat_price"]["records"]
|
||||
|
||||
|
||||
def _empty(value) -> bool:
|
||||
return str(value).strip() in EMPTY_PRICES
|
||||
|
||||
|
||||
def test_값이_없는_줄은_목록에만_남는다(master, records):
|
||||
"""⭐ 규칙으로 고칠 수 있는 0 원 줄은 하나도 안 남는다.
|
||||
|
||||
앞 공시에도 값이 없는 줄만 `zero_price_unrecovered` 에 남고, 그 줄은 표에서도 0 이다.
|
||||
"""
|
||||
listed = {str(item["item_code"]) for item in master["zero_price_unrecovered"]["items"]}
|
||||
zeros = {str(r["item_code"]) for r in records if _empty(r.get("price_krw"))}
|
||||
assert zeros == listed, f"목록에 없는 0 원 줄: {sorted(zeros - listed)}"
|
||||
|
||||
|
||||
def test_못_고친_줄에는_사유가_붙는다(master):
|
||||
for item in master["zero_price_unrecovered"]["items"]:
|
||||
assert item["reason"].strip(), item["item_code"]
|
||||
assert master["zero_price_unrecovered"]["note"].strip()
|
||||
|
||||
|
||||
def test_고친_줄마다_사유가_남는다(records):
|
||||
"""조용히 바꾸지 않는다 — 「○ 공시에 값이 없어 ○ 값을 씀」이 줄에 남아야 한다."""
|
||||
repaired = [r for r in records if r.get("price_adopted_reason")]
|
||||
assert repaired, "고친 줄이 하나도 없다"
|
||||
for row in repaired:
|
||||
reason = row["price_adopted_reason"]
|
||||
assert "값이 없어" in reason and "씀" in reason, reason
|
||||
assert not _empty(row["price_krw"]), row["item_code"]
|
||||
|
||||
|
||||
def test_고친_줄의_공시_날짜와_번호가_채택한_공시의_것이다(records):
|
||||
"""어느 판 값인지 화면에서 보여야 한다 — 날짜가 0 원 공시에 머물면 안 된다."""
|
||||
raw = json.loads(RAW.read_text(encoding="utf-8"))
|
||||
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)
|
||||
|
||||
wrong = []
|
||||
for row in (r for r in records if r.get("price_adopted_reason")):
|
||||
adopted = next(
|
||||
(h for h in history.get(str(row["item_code"]), ()) if not _empty(h.get("prce"))),
|
||||
None,
|
||||
)
|
||||
assert adopted is not None, row["item_code"]
|
||||
if str(row["price_krw"]) != str(int(str(adopted["prce"]).strip())):
|
||||
wrong.append(f"{row['item_code']} 단가 {row['price_krw']} ≠ {adopted['prce']}")
|
||||
elif row["notice_datetime"] != str(adopted.get("nticeDt", "")):
|
||||
wrong.append(f"{row['item_code']} 날짜 {row['notice_datetime']}")
|
||||
elif row["notice_number"] != str(adopted.get("prceNticeNo", "")):
|
||||
wrong.append(f"{row['item_code']} 번호 {row['notice_number']}")
|
||||
elif (
|
||||
row["price_adopted_reason"].endswith(f"{str(adopted['nticeDt'])[:10]} 값을 씀") is False
|
||||
):
|
||||
wrong.append(f"{row['item_code']} 사유가 채택 공시와 다름")
|
||||
assert not wrong, wrong
|
||||
|
||||
|
||||
def test_0원_아닌_줄은_안_건드린다(records):
|
||||
"""규칙을 통째로 다시 돌리지 않는다 — 사유가 붙은 줄만 손댄 줄이다."""
|
||||
touched = [r for r in records if r.get("price_adopted_reason")]
|
||||
assert len(touched) < len(records) // 10, (
|
||||
"손댄 줄이 너무 많다 — 규칙이 전체를 다시 쓴 것 아닌가"
|
||||
)
|
||||
assert len(records) == 6999, "줄 수가 바뀌었다"
|
||||
|
||||
|
||||
def test_고르는_방침에_0원_건너뛰기가_적혀_있다(master):
|
||||
policy = master["selection_policy"]
|
||||
assert "zero" in policy and "skip" in policy.lower(), policy
|
||||
|
||||
|
||||
def test_같은_날짜에_0과_제값이_함께_있어도_제값을_고른다():
|
||||
"""2025-04-24 는 같은 날 0 과 제값이 함께 실린다 — 날짜만 보면 0 을 집을 수 있다."""
|
||||
raw = json.loads(RAW.read_text(encoding="utf-8"))
|
||||
same_day: dict[str, set[bool]] = {}
|
||||
for row in raw:
|
||||
if str(row.get("nticeDt", ""))[:10] != "2025-04-24":
|
||||
continue
|
||||
same_day.setdefault(str(row.get("prdctIdntNo")), set()).add(_empty(row.get("prce")))
|
||||
both = [code for code, kinds in same_day.items() if kinds == {True, False}]
|
||||
assert both, "같은 날 0 과 제값이 함께 있는 물품이 없다 — 시험 전제가 바뀌었다"
|
||||
|
||||
master = json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
rows = {str(r["item_code"]): r for r in master["variables"]["mat_price"]["records"]}
|
||||
for code in both:
|
||||
row = rows.get(code)
|
||||
if row is not None:
|
||||
assert not _empty(row["price_krw"]), f"{code} 가 0 으로 섰다"
|
||||
Reference in New Issue
Block a user