From 35632f8cb97ea43d98f62237b11565f23ee5d42f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:00:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(z01):=20=EC=9E=90=EB=A3=8C=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=ED=91=9C=20=E2=80=94=20=EC=96=B4=EB=94=94=EC=84=9C=20?= =?UTF-8?q?=EC=98=A4=EA=B3=A0=20=EC=96=BC=EB=A7=88=20=EB=A7=8C=EC=97=90=20?= =?UTF-8?q?=EB=B0=94=EB=80=8C=EB=8A=94=EC=A7=80=20=EC=97=AC=EB=8D=9F=20?= =?UTF-8?q?=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resources/data_master_sources/sources_2026-09-15.json` — 갈래·자료 이름·내는 곳·받는 자리· 주기·최신 공표일·우리 판·확인한 날·비고. **값은 안 담음** - ⭐ **건설 노임이 뒤처진 것으로 섬** — 우리 2026-01-01 · 최신 2026-09-01 · 주기가 1.1 / 9.1 임(7.1 로 알던 것이 틀렸음) · 받는 자리 cak.or.kr [지원·사업]>[건설적산기준]>[건설임금] - **우리 판은 실제 파일에서 읽음** — 손으로 적으면 갱신하고 안 고쳐 또 뒤처짐. 파생본은 `derived_from.effective_date` 를 따라감(운전경비 = 2026-01-01 · 「판 모름」 아님) - 받는 자리·최신 공표일을 **모르는 칸은 비우고 사유를 닮** — 주소를 지어내지 않음(빈 칸 다섯) - 셈 — 뒤처짐 1 · 최신 3 · 모름 4(수시·일별이라 한 날로 안 정해지는 자재·유가 셋 포함) - 새 시험 `test_master_sources.py` 11건 — 표의 우리 판이 실제 파일과 어긋나거나 뒤처짐 판정이 뒤집히거나 모르는 칸에 사유가 없으면 빨강 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../sources_2026-09-15.json | 207 ++++++++++++++++++ resources/tester/test_master_sources.py | 128 +++++++++++ 2 files changed, 335 insertions(+) create mode 100644 resources/data_master_sources/sources_2026-09-15.json create mode 100644 resources/tester/test_master_sources.py diff --git a/resources/data_master_sources/sources_2026-09-15.json b/resources/data_master_sources/sources_2026-09-15.json new file mode 100644 index 00000000..ec955a7e --- /dev/null +++ b/resources/data_master_sources/sources_2026-09-15.json @@ -0,0 +1,207 @@ +{ + "schema_version": "1.0", + "dataset_id": "data_master_sources", + "effective_date": "2026-09-15", + "generated_at": "2026-09-15T21:59:23+09:00", + "note": "기초단가 자료가 **어디서 오고 얼마 만에 바뀌는지** 적은 표. 파일 안에는 제 판만 있고 「최신이 무엇인지·어디서 받는지」가 없어 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). 그 자리를 메운다.", + "policy": { + "values_untouched": "값을 담지 않는다. 자료가 어디서 오는지만 담는다.", + "our_edition_is_read": "`our_edition` 은 **실제 파일에서 읽는다**(`effective_date`, 파생본은 `derived_from.effective_date`). 손으로 적으면 갱신하고 안 고쳐 또 뒤처진다.", + "no_invented_address": "받는 자리는 **받은 것만** 적는다. 모르면 빈 칸으로 두고 사유를 단다 — 주소를 지어내지 않는다.", + "status_rule": "`latest_published` 가 `our_edition` 보다 뒤면 **뒤처짐**, 같으면 최신, 둘 가운데 하나라도 없으면 **모름**. 날마다 바뀌는 유가는 날짜 차이로 보여야 한다." + }, + "kinds": { + "labor": "노임", + "machine": "기계", + "material": "자재", + "oil": "유가", + "rate": "요율" + }, + "status": { + "behind": { + "name_ko": "뒤처짐", + "summary": "최신 공표일이 우리 판보다 뒤다 — 받아야 한다." + }, + "current": { + "name_ko": "최신", + "summary": "최신 공표일과 우리 판이 같다." + }, + "unknown": { + "name_ko": "모름", + "summary": "최신 공표일을 아직 못 잡았다 — 수시·일별이라 한 날로 안 정해지거나, 확인을 아직 안 했다." + } + }, + "counts": { + "sources": 8, + "by_status": { + "behind": 1, + "unknown": 4, + "current": 3 + } + }, + "sources": [ + { + "source_id": "labor_const", + "kind": "labor", + "kind_ko": "노임", + "name_ko": "건설업 시중노임단가", + "publisher": "대한건설협회", + "where_to_get": "cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]", + "cycle": "해마다 두 번 — **1월 1일 · 9월 1일**", + "latest_published": "2026-09-01", + "latest_basis": "2026-09-15 브레인 쪽지", + "our_edition": "2026-01-01", + "our_edition_from": "effective_date", + "status": "behind", + "checked_on": "2026-09-15", + "note": "⚠ **뒤처졌다.** 우리 판이 2026-01-01 이라 9월 공표를 반 년 가까이 못 받았다. ⚠ 주기를 **7월 1일로 알고 있던 것이 틀렸다** — 1.1 / 9.1 이다. 노임은 일위대가 품값의 밑값이라 여기가 뒤처지면 **모든 호표가 옛값으로 선다**.", + "files": [ + "resources/data_cost_input_value/labor_const_2026-01-01.json" + ] + }, + { + "source_id": "labor_mfg", + "kind": "labor", + "kind_ko": "노임", + "name_ko": "중소제조업 직종별 임금", + "publisher": "중소기업중앙회", + "where_to_get": "", + "cycle": "반기", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-07-01", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다. 우리 판은 2026 상반기 조사(조사월 2026-03 · 공표 2026-06-30)다.", + "files": [ + "resources/data_cost_input_value/labor_mfg_2026-07-01.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "mach_base", + "kind": "machine", + "kind_ko": "기계", + "name_ko": "건설공사 표준품셈 제8장 건설기계", + "publisher": "국토교통부", + "where_to_get": "", + "cycle": "해마다", + "latest_published": "2026-01-01", + "latest_basis": "2026-09-15 브레인 쪽지", + "our_edition": "2026-01-01", + "our_edition_from": "effective_date", + "status": "current", + "checked_on": "2026-09-15", + "note": "우리 판이 최신이다. 취득가·손료계수·연료소모량·조종원이 다 여기서 온다.", + "files": [ + "resources/data_cost_input_value/mach_base_2026.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + }, + { + "source_id": "machine_operating", + "kind": "machine", + "kind_ko": "기계", + "name_ko": "건설기계 운전경비(품셈 8-4 파생본)", + "publisher": "국토교통부", + "where_to_get": "", + "cycle": "해마다 — 위 품셈을 따라간다", + "latest_published": "2026-01-01", + "latest_basis": "파생 원본(mach_base)의 판", + "our_edition": "2026-01-01", + "our_edition_from": "derived_from.effective_date", + "status": "current", + "checked_on": "2026-09-15", + "note": "⚠ **판을 모르는 파일이 아니다** — 파일 안 `derived_from.effective_date` 에 2026-01-01 이 박혀 있다(건설품셈 8-4 운전경비 파생본 · 원본 지문까지 함께). `mach_fuel_rate`·`mach_operator_map` 이 다 차면 걷어낼 자리다.", + "files": [ + "resources/data_cost_machine_operating/machine_operating_2026.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + }, + { + "source_id": "mat_price_public", + "kind": "material", + "kind_ko": "자재", + "name_ko": "관급자재 단가(시설공통자재)", + "publisher": "조달청", + "where_to_get": "", + "cycle": "반기 + 수시", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-08-14", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "수시 공고라 「최신 공표일」이 한 날로 안 정해진다 — 받은 날(우리 판)이 곧 기준이다. 철근·레미콘·아스콘은 이 벌에 없다(쇼핑몰 스냅숏 미확보).", + "files": [ + "resources/data_cost_input_value/mat_price_public_2026-08-14.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "oil", + "kind": "oil", + "kind_ko": "유가", + "name_ko": "유가 — 전국평균", + "publisher": "한국석유공사(오피넷)", + "where_to_get": "", + "cycle": "주간 · 일별", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-08-14", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "날마다 바뀌는 값이라 「최신」이 곧 오늘이다. **뒤처짐을 날짜 차이로 보여야 하는 자료**다. ⚠ 시도별 판과 「전국」 줄이 겹치는데 값도 기준일도 다르다(어느 쪽을 쓸지 판정 대기).", + "files": [ + "resources/data_cost_input_value/oil_2026-08-14.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "oil_regional", + "kind": "oil", + "kind_ko": "유가", + "name_ko": "유가 — 시도별", + "publisher": "한국석유공사(오피넷)", + "where_to_get": "", + "cycle": "주간 · 일별", + "latest_published": "", + "latest_basis": "", + "our_edition": "2026-09-09", + "our_edition_from": "effective_date", + "status": "unknown", + "checked_on": "2026-09-15", + "note": "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」를 따르려고 둔 벌이다.", + "files": [ + "resources/data_cost_input_value/oil_regional_2026-09-09.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다.", + "latest_missing_reason": "수시·일별이라 최신 공표일이 한 날로 안 정해지거나, 아직 확인을 안 했다." + }, + { + "source_id": "rates", + "kind": "rate", + "kind_ko": "요율", + "name_ko": "토목공사 원가계산 제비율 적용기준", + "publisher": "조달청", + "where_to_get": "", + "cycle": "해마다 + 수시(법이 바뀌면)", + "latest_published": "2026-04-13", + "latest_basis": "2026-09-15 브레인 쪽지", + "our_edition": "2026-04-13", + "our_edition_from": "effective_date", + "status": "current", + "checked_on": "2026-09-15", + "note": "우리 판이 최신이다. 국민연금 요율은 국민연금법(현행 2026-06-17)을 따로 물었다. ⚠ 요율은 **법이 바뀌면 주기와 상관없이** 바뀐다 — 주기만 보고 안심하면 안 된다.", + "files": [ + "resources/data_cost_input_value/rates_2026.json" + ], + "where_to_get_missing_reason": "받는 자리를 아직 못 받았다 — 주소를 지어내지 않는다." + } + ] +} diff --git a/resources/tester/test_master_sources.py b/resources/tester/test_master_sources.py new file mode 100644 index 00000000..ec2a25c1 --- /dev/null +++ b/resources/tester/test_master_sources.py @@ -0,0 +1,128 @@ +"""자료 출처표(`resources/data_master_sources/sources_2026-09-15.json`) 시험. + +이 표가 있는 까닭 — 마스터 파일 안에는 **제 판만** 있고 「최신이 무엇인지·어디서 받는지」가 +없어서 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). + +- `our_edition` 이 **실제 파일과 어긋나면 빨강** — 손으로 적힌 판이 굳는 것을 막는다 +- 뒤처짐 판정이 뒤집히면 빨강 +- 받는 자리를 모르는 줄에 **사유가 없으면 빨강**(주소를 지어내지 않기로 한 자리) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCES_PATH = ROOT / "resources" / "data_master_sources" / "sources_2026-09-15.json" + + +@pytest.fixture(scope="module") +def sources() -> dict: + return json.loads(SOURCES_PATH.read_text(encoding="utf-8")) + + +def _edition_of(path: Path) -> tuple[str, str]: + doc = json.loads(path.read_text(encoding="utf-8")) + if doc.get("effective_date"): + return doc["effective_date"], "effective_date" + derived = doc.get("derived_from") + if isinstance(derived, dict) and derived.get("effective_date"): + return derived["effective_date"], "derived_from.effective_date" + return "", "" + + +def test_출처표가_읽힌다(sources): + assert sources["dataset_id"] == "data_master_sources" + assert sources["sources"], "출처가 하나도 없다" + + +def test_가리키는_파일이_다_있다(sources): + missing = [ + path for row in sources["sources"] for path in row["files"] if not (ROOT / path).is_file() + ] + assert not missing, f"없는 파일을 가리킨다: {missing}" + + +def test_우리_판이_실제_파일과_같다(sources): + """⭐ 손으로 적힌 판이 굳으면 또 뒤처진다 — 파일에서 읽은 값과 대 본다.""" + wrong = [] + for row in sources["sources"]: + edition, where = _edition_of(ROOT / row["files"][0]) + if row["our_edition"] != edition: + wrong.append(f"{row['source_id']}: 표 {row['our_edition']} ≠ 파일 {edition}") + elif row["our_edition_from"] != where: + wrong.append(f"{row['source_id']}: 읽은 자리 {row['our_edition_from']} ≠ {where}") + assert not wrong, wrong + + +def test_뒤처짐_판정이_맞다(sources): + for row in sources["sources"]: + latest, ours = row["latest_published"], row["our_edition"] + if not latest or not ours: + assert row["status"] == "unknown", row["source_id"] + elif latest > ours: + assert row["status"] == "behind", row["source_id"] + else: + assert row["status"] == "current", row["source_id"] + + +def test_건설_노임이_뒤처진_것으로_선다(sources): + """2026-09-15 에 드러난 자리 — 우리 2026-01-01, 최신 2026-09-01.""" + row = next(r for r in sources["sources"] if r["source_id"] == "labor_const") + assert row["status"] == "behind" + assert row["our_edition"] == "2026-01-01" + assert row["latest_published"] == "2026-09-01" + # 주기를 7월 1일로 알던 것이 틀렸다 — 1.1 / 9.1 이다. + assert "9월 1일" in row["cycle"] + assert row["where_to_get"], "받는 자리를 받았는데 비어 있다" + + +def test_운전경비는_판_모름이_아니다(sources): + """파일 안 `derived_from.effective_date` 에 판이 박혀 있다 — 「모름」으로 적으면 안 된다.""" + row = next(r for r in sources["sources"] if r["source_id"] == "machine_operating") + assert row["our_edition"] == "2026-01-01" + assert row["our_edition_from"] == "derived_from.effective_date" + assert row["status"] != "unknown" + + +def test_모르는_칸에는_사유가_있다(sources): + """주소·최신 공표일을 지어내지 않기로 한 자리 — 빈 칸이면 사유가 있어야 한다.""" + bad = [] + for row in sources["sources"]: + if not row["where_to_get"] and not row.get("where_to_get_missing_reason"): + bad.append(f"{row['source_id']} — 받는 자리 사유 없음") + if not row["latest_published"] and not row.get("latest_missing_reason"): + bad.append(f"{row['source_id']} — 최신 공표일 사유 없음") + assert not bad, bad + + +def test_줄마다_갈래와_주기가_있다(sources): + known = set(sources["kinds"]) + for row in sources["sources"]: + assert row["kind"] in known, row["source_id"] + assert row["kind_ko"] == sources["kinds"][row["kind"]] + assert row["cycle"].strip(), row["source_id"] + assert row["publisher"].strip(), row["source_id"] + assert row["checked_on"].strip(), row["source_id"] + + +def test_기초단가_다섯_갈래를_다_덮는다(sources): + covered = {row["kind"] for row in sources["sources"]} + assert covered == {"labor", "machine", "material", "oil", "rate"} + + +def test_세어_둔_수가_실제와_같다(sources): + counts = sources["counts"] + assert counts["sources"] == len(sources["sources"]) + tallied: dict[str, int] = {} + for row in sources["sources"]: + tallied[row["status"]] = tallied.get(row["status"], 0) + 1 + assert counts["by_status"] == tallied + + +def test_상태_낱말이_다_풀려_있다(sources): + known = set(sources["status"]) + assert {row["status"] for row in sources["sources"]} <= known