From d8f4ac024005e8e494e4d4fbef141650b1db28cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 21:47:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=84=AF=EC=9D=84=20=ED=95=9C=20=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EB=8A=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EC=97=B4=EC=87=A0=C2=B7null?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `merged_tables` 다섯(노임·자재·유가·제비율·기계) — 열 62. **값은 안 옮김**, 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담음. 계산값 넷은 기계에만 있고 `editable: false` - `machine_cost_chain` 다섯 걸음 — 손료(취득가×1,000×손료계수) · 연료(연료량×유가×(1+잡재료)) · 조종원(일 노임÷8×제수당) · 암 할증 · 시간당 기계경비. **밑값 11 / 계산값 4** 를 가름 · 기계가 유가·노임 표를 물어 갱신 차례가 갈림을 적음 - `merge_findings` 네 갈래 — 이름 다른 같은 열 2 · 한쪽에만 있는 열 4 · 단위·축 어긋남 4 · 부딪힘 2 - `row_keys` 다섯 판정(브레인 ①) — 자재 `item_code`·기계 `machine_code` 는 전수 겹침 0 으로 **있음** · 노임은 두 코드 공간이라 `출처+직종코드` 로 묶어야 함 · 유가·제비율은 **줄 열쇠 없음**(지어내지 않음) - `null_is_real` 여섯(브레인 ②) — ⭐ 노임 일 노임이 **30 줄에 아예 없음**(미조사가 제값) · 기계 운전경비에 **명시 null 12** · 되돌리기 null 과 뜻이 겹치는 자리를 규칙과 함께 적음 - 시험 36건(8건 늘) — 계산값이 기계 밖으로 새거나 줄 열쇠·null 판정이 빠지면 빨강 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk --- .../data_master_labels/labels_2026-01-01.json | 822 +++++++++++++++++- resources/tester/test_master_labels_cover.py | 91 ++ 2 files changed, 912 insertions(+), 1 deletion(-) diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index e59ae89f..ef78ff05 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "data_master_labels", "effective_date": "2026-01-01", - "generated_at": "2026-09-15T19:33:14+09:00", + "generated_at": "2026-09-15T21:46:51+09:00", "note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.", "policy": { "labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.", @@ -12,6 +12,7 @@ "brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).", "out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).", "excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.", + "merged_tables_rule": "`merged_tables` 는 **기초단가 다섯**(노임·자재·유가·제비율·기계)을 화면이 한 표씩으로 보이려고 여러 원본 표를 모은 자리다. ⚠ **값을 옮기지 않는다** — 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담는다. `editable: false` 는 다른 값에서 나온 계산값이라 사람이 못 고친다. 기계는 한 표로 안 모이므로 `machine_cost_chain` 이 사슬을 적는다 (2026-09-15 사용자 지시 · 브레인).", "row_key_column": "사전 모양(shape=map) 표는 **줄 이름(키) 자체가 한 열**이다. API 가 `@key` 로 내보내므로 열 목록 맨 앞에 `@key`(`is_row_key: true`)를 두고 표마다 제 이름을 붙인다 — 뜻이 표마다 달라 한 이름으로 뭉치지 않는다(2026-09-15 브레인 ①).", "value_kind_rule": "값 묶음마다 `kind` 를 낱말로 적는다. `value`(값) = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. `doc`(설명) = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·집계·표 머리의 줄 키·담은 꼴) — 낱값 마디에 **안 보인다**. 참·거짓이 아니라 낱말이라 수식 같은 갈래가 늘면 낱말만 더하면 된다. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명. 곁값(값 단위·기준일·걸치는 범위)은 **값 쪽**이다 — 없으면 값의 뜻이 안 선다. `undecided: true` 는 값도 설명도 아닌 미결이라 판정을 기다린다(2026-09-15 브레인 ②)." }, @@ -49,6 +50,823 @@ "summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다." } }, + "merged_tables": [ + { + "key": "labor", + "name_ko": "노임", + "summary": "직종마다 하루 노임 한 표. 건설업·제조업 두 공표를 한 자리에 모은다.", + "sources": [ + "labor_const::variables/labor_rate/records", + "labor_mfg::variables/labor_mfg/records" + ], + "rows_note": "건설업 132 + 제조업 129 = 261 줄", + "columns": [ + { + "key": "source", + "name_ko": "출처", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "건설업(대한건설협회) · 제조업(중소기업중앙회). ⚠ 이 칸이 없으면 두 코드 공간이 섞인다." + }, + { + "key": "occupation_code", + "name_ko": "직종코드", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "occupation_name", + "name_ko": "직종명", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "industry", + "name_ko": "업종", + "unit": "", + "visible": true, + "editable": true, + "from": "제조업만" + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다", + "note": "둘 다 KRW/day — 어긋남 없음." + }, + { + "key": "hours_per_day", + "name_ko": "1일 근로시간", + "unit": "시간", + "visible": true, + "editable": true, + "from": "건설업만" + }, + { + "key": "daily_wage_krw", + "name_ko": "일 노임", + "unit": "원", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "status", + "name_ko": "공표 상태", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "reliability", + "name_ko": "신뢰도 기호", + "unit": "", + "visible": true, + "editable": true, + "from": "건설업만", + "note": "* 조사현장 5개 미만 · ** 미조사. 뜻풀이가 원본에 있다." + }, + { + "key": "source_flag", + "name_ko": "원문 표시", + "unit": "", + "visible": true, + "editable": true, + "from": "제조업만", + "note": "`*` 16 줄. ⚠ 뜻풀이가 원본에 없어 신뢰도 기호와 한 칸으로 못 묶는다." + } + ] + }, + { + "key": "material", + "name_ko": "자재 단가", + "summary": "물품마다 규격·단위·단가 한 표. 원본이 하나라 합칠 것이 없다.", + "sources": [ + "mat_price_public::variables/mat_price/records" + ], + "rows_note": "6,999 줄 — 다섯 가운데 가장 크다", + "columns": [ + { + "key": "item_code", + "name_ko": "물품코드", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "classification_code", + "name_ko": "물품분류번호", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "classification_name", + "name_ko": "물품분류명", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "unit", + "name_ko": "단위", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "price_krw", + "name_ko": "단가", + "unit": "원", + "visible": true, + "editable": true + }, + { + "key": "vat_basis", + "name_ko": "부가세 기준", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "price_type", + "name_ko": "가격 종류", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "delivery_condition", + "name_ko": "납품 조건", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "business_division_name", + "name_ko": "사업구분", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "field", + "name_ko": "분야", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "notice_datetime", + "name_ko": "공고 일시", + "unit": "", + "visible": true, + "editable": true + }, + { + "key": "notice_number", + "name_ko": "공고번호", + "unit": "", + "visible": false, + "editable": true + }, + { + "key": "business_division_code", + "name_ko": "사업구분 코드", + "unit": "", + "visible": false, + "editable": true + } + ] + }, + { + "key": "oil", + "name_ko": "유가", + "summary": "유종 × 지역 한 표. 전국평균 판과 시도별 판을 한 자리에 모은다.", + "sources": [ + "oil::variables", + "oil_regional::variables/oil_gasoline/records", + "oil_regional::variables/oil_diesel/records" + ], + "rows_note": "전국평균 2 + 시도별 34 = 36 줄", + "columns": [ + { + "key": "fuel", + "name_ko": "유종", + "unit": "", + "visible": true, + "editable": true, + "from": "원본 키", + "note": "휘발유 · 경유." + }, + { + "key": "source", + "name_ko": "출처", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "전국평균 판 · 시도별 판. 기준일이 판마다 다르다." + }, + { + "key": "region_code", + "name_ko": "시도코드", + "unit": "", + "visible": true, + "editable": true, + "from": "시도판", + "note": "전국평균 판은 `00` 으로 맞춘다." + }, + { + "key": "region_name", + "name_ko": "시도명", + "unit": "", + "visible": true, + "editable": true, + "from": "시도판" + }, + { + "key": "price_krw_per_l", + "name_ko": "단가", + "unit": "원/L", + "visible": true, + "editable": true, + "from": "둘 다" + }, + { + "key": "date", + "name_ko": "기준일", + "unit": "", + "visible": true, + "editable": true, + "from": "둘 다", + "note": "⚠ 판마다 다르다 — 같은 표에 두 날짜가 선다." + }, + { + "key": "source_product_name", + "name_ko": "원천 제품명", + "unit": "", + "visible": false, + "editable": true, + "from": "둘 다" + }, + { + "key": "source_product_code", + "name_ko": "원천 제품코드", + "unit": "", + "visible": false, + "editable": true, + "from": "둘 다" + } + ] + }, + { + "key": "rates", + "name_ko": "제비율", + "summary": "법정경비·일반관리비·이윤·부가세 요율 한 표. 원본 19 벌을 한 줄씩 펴서 모은다.", + "sources": [ + "rates::variables/* (19 벌)" + ], + "rows_note": "한 값짜리 8 + 구간표 11 벌을 펴서 약 190 줄", + "columns": [ + { + "key": "item_key", + "name_ko": "항목 키", + "unit": "", + "visible": false, + "editable": true, + "from": "원본 키" + }, + { + "key": "item_name", + "name_ko": "항목", + "unit": "", + "visible": true, + "editable": true, + "from": "이름표" + }, + { + "key": "variant", + "name_ko": "벌", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "한 항목이 표를 둘 가진 경우 — 일반관리비(토목/전문) · 기계 지급보증(종합/전문) · 환경보전비(임도 후보/전 공종)." + }, + { + "key": "work_type", + "name_ko": "공사 종류", + "unit": "", + "visible": true, + "editable": true, + "from": "6 벌" + }, + { + "key": "amount_bracket", + "name_ko": "금액 구간", + "unit": "", + "visible": true, + "editable": true, + "from": "9 벌", + "note": "⚠ **축이 넷인데 한 칸으로 접힌다** — 추정금액(고용) · 추정가격(하도급·일반관리비·이윤) · 직접공사비(간접노무·기타경비·이행보증) · 대상액(안전관리비). 어느 금액인지는 곁 칸이 말한다." + }, + { + "key": "bracket_basis", + "name_ko": "구간이 무슨 금액인가", + "unit": "", + "visible": true, + "editable": true, + "from": "새 칸", + "note": "위 칸을 접으면 사라지는 것 — 반드시 함께 둔다." + }, + { + "key": "duration_bracket", + "name_ko": "공사기간 구간", + "unit": "", + "visible": true, + "editable": true, + "from": "2 벌" + }, + { + "key": "grade", + "name_ko": "등급", + "unit": "", + "visible": true, + "editable": true, + "from": "고용보험만" + }, + { + "key": "year", + "name_ko": "연도", + "unit": "", + "visible": true, + "editable": true, + "from": "국민연금만" + }, + { + "key": "rate_percent", + "name_ko": "요율", + "unit": "%", + "visible": true, + "editable": true, + "from": "18 벌", + "note": "이행보증은 요율이 아니라 산식이라 이 칸이 빈다." + }, + { + "key": "base_amount_krw", + "name_ko": "기초액", + "unit": "원", + "visible": true, + "editable": true, + "from": "안전관리비만" + }, + { + "key": "formula", + "name_ko": "산식", + "unit": "", + "visible": true, + "editable": true, + "from": "이행보증만" + }, + { + "key": "base", + "name_ko": "밑수", + "unit": "", + "visible": true, + "editable": true, + "from": "19 벌 다", + "note": "이 요율을 어디에 곱하는가." + }, + { + "key": "minimum_krw", + "name_ko": "적용 하한", + "unit": "원", + "visible": true, + "editable": true, + "from": "3 벌", + "note": "이 금액에 못 미치면 줄이 안 선다 — 안전관리비 2천만 · 환경보전비 1억 · 퇴직공제 1억." + }, + { + "key": "note", + "name_ko": "비고", + "unit": "", + "visible": true, + "editable": true, + "from": "원본 곁글" + } + ] + }, + { + "key": "machine", + "name_ko": "기계경비", + "summary": "기종 한 줄 + 시간당 단가. ⚠ **한 표로 안 모인다** — 시간당 단가가 다섯 표를 물어 나오는 계산값이다.", + "sources": [ + "mach_base::variables/mach_price/records", + "mach_base::variables/mach_loss_coef/records", + "mach_base::variables/mach_fuel_rate/parsed_records", + "mach_base::variables/mach_operator_map/explicit_mappings", + "mach_base::variables/mach_rock_adj/rules", + "machine_operating::records" + ], + "rows_note": "취득가 613 줄이 뼈대 · 손료계수 387 · 연료 214 · 조종원 121 이 붙는다(안 붙는 줄은 빈 칸)", + "columns": [ + { + "key": "machine_code", + "name_ko": "기계코드", + "unit": "", + "visible": true, + "editable": true, + "from": "취득가" + }, + { + "key": "machine_name", + "name_ko": "기계명", + "unit": "", + "visible": true, + "editable": true, + "from": "취득가" + }, + { + "key": "specification", + "name_ko": "규격", + "unit": "", + "visible": true, + "editable": true, + "from": "취득가" + }, + { + "key": "price_thousand_krw", + "name_ko": "취득가", + "unit": "천원", + "visible": true, + "editable": true, + "from": "취득가", + "note": "⭐ 밑값." + }, + { + "key": "loss_coefficient_per_hour", + "name_ko": "시간당 손료계수", + "unit": "", + "visible": true, + "editable": true, + "from": "손료계수", + "note": "⭐ 밑값." + }, + { + "key": "fuel_type", + "name_ko": "연료 종류", + "unit": "", + "visible": true, + "editable": true, + "from": "연료" + }, + { + "key": "fuel_rate_l_per_hour", + "name_ko": "시간당 연료량", + "unit": "L", + "visible": true, + "editable": true, + "from": "연료", + "note": "⭐ 밑값." + }, + { + "key": "misc_material_percent", + "name_ko": "잡재료비", + "unit": "연료비의 %", + "visible": true, + "editable": true, + "from": "연료", + "note": "⭐ 밑값." + }, + { + "key": "operator_occupation_code", + "name_ko": "조종원 직종", + "unit": "", + "visible": true, + "editable": true, + "from": "조종원", + "note": "⭐ 밑값 — 노임 표를 가리킨다." + }, + { + "key": "operator_person_per_day", + "name_ko": "조종원", + "unit": "인/일", + "visible": true, + "editable": true, + "from": "조종원", + "note": "⭐ 밑값." + }, + { + "key": "rock_adjust_pct", + "name_ko": "암 작업 할증", + "unit": "%", + "visible": true, + "editable": true, + "from": "암석보정", + "note": "⭐ 밑값 — 기계 묶음별." + }, + { + "key": "hourly_loss_krw", + "name_ko": "시간당 손료", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "취득가 × 1,000 × 손료계수." + }, + { + "key": "hourly_fuel_krw", + "name_ko": "시간당 연료·잡재료", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "연료량 × 유가 × (1 + 잡재료비율). 유가는 **유가 표**에서 온다." + }, + { + "key": "hourly_operator_krw", + "name_ko": "시간당 조종원", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "일 노임 ÷ 8 × 제수당 계수. 일 노임은 **노임 표**에서 온다." + }, + { + "key": "hourly_total_krw", + "name_ko": "시간당 기계경비", + "unit": "원", + "visible": true, + "editable": false, + "from": "계산", + "note": "손료 + 운전경비. 수송비는 여기 안 든다." + } + ] + } + ], + "machine_cost_chain": { + "note": "기계경비 = 기계손료 + 운전경비 + 수송비(품셈 8-1-6). ⭐ **밑값만 고칠 수 있다** — 계산값은 밑값이 바뀌면 따라 움직인다.", + "steps": [ + { + "step": "① 손료", + "formula": "취득가(천원) × 1,000 × 시간당 손료계수", + "inputs": [ + "취득가", + "시간당 손료계수" + ], + "note": "손료계수는 상각·정비·관리 계수의 합이다. 그 셋과 내용시간·연간표준가동시간도 밑값이라 함께 보인다." + }, + { + "step": "② 운전경비 — 연료", + "formula": "시간당 연료량(L) × 유가(원/L) × (1 + 잡재료비율)", + "inputs": [ + "시간당 연료량", + "잡재료비", + "유가 표" + ], + "note": "유가는 이 표에 없다 — **유가 표**에서 온다. 그래서 유가를 고치면 모든 기종이 움직인다." + }, + { + "step": "③ 운전경비 — 조종원", + "formula": "일 노임 ÷ 8시간 × 제수당 계수 × 조종원(인/일)", + "inputs": [ + "조종원 직종", + "조종원 인/일", + "노임 표" + ], + "note": "일 노임은 이 표에 없다 — **노임 표**에서 온다. 8시간은 품셈 8-1-6 단서다." + }, + { + "step": "④ 암 작업 할증", + "formula": "위 값 × (1 + 암 할증률)", + "inputs": [ + "암 작업 할증" + ], + "note": "기계 묶음별로 붙는다(불도저·굴착기·덤프)." + }, + { + "step": "⑤ 시간당 기계경비", + "formula": "① + ② + ③ (④ 를 걸친 뒤)", + "inputs": [], + "note": "⚠ **수송비는 안 든다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이다." + } + ], + "editable_rule": "밑값 = 취득가 · 손료계수(와 그 속 계수들) · 연료량 · 잡재료비율 · 조종원 직종과 인수 · 암 할증률. 계산값 = 시간당 손료 · 시간당 연료 · 시간당 조종원 · 시간당 기계경비. ⚠ 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없게 된다 — 화면에서 막는다.", + "cross_table": "기계 표는 **유가 표와 노임 표를 문다**. 그 둘이 갱신되면 기계 시간당 단가가 다 바뀐다 — 갱신 차례는 노임·유가가 먼저, 기계가 나중이다." + }, + "merge_findings": { + "renamed_same_column": [ + { + "where": "노임", + "columns": [ + "labor_const.reliability", + "labor_mfg.source_flag" + ], + "verdict": "합치지 않음", + "why": "둘 다 원문 별표지만 뜻이 다르다 — 건설업은 「조사현장 5개 미만(*)·미조사(**)」로 뜻풀이가 있고, 제조업 `*`(16 줄)는 원본에 뜻풀이가 없다. 한 칸으로 묶으면 뜻이 섞인다." + }, + { + "where": "유가", + "columns": [ + "oil.value", + "oil_regional.value" + ], + "verdict": "합침", + "why": "둘 다 원/L 단가다. 이름만 `value` 로 같고 뜻도 같다 — 「단가」 한 칸으로 묶는다." + } + ], + "one_side_only": [ + { + "where": "노임", + "only_in": "건설업", + "columns": [ + "hours_per_day", + "reliability" + ] + }, + { + "where": "노임", + "only_in": "제조업", + "columns": [ + "industry", + "variation_coefficient", + "source_flag" + ] + }, + { + "where": "유가", + "only_in": "시도판", + "columns": [ + "sido_code", + "sido_name" + ], + "note": "전국평균 판은 시도코드 `00`·시도명 「전국」으로 맞춘다." + }, + { + "where": "제비율", + "only_in": "일부 벌", + "columns": [ + "grade", + "year", + "base_amount_krw", + "formula", + "duration_bracket" + ], + "note": "19 벌이 축을 제각각 쓴다 — 안 쓰는 벌에서는 빈 칸이다." + } + ], + "unit_or_axis_mismatch": [ + { + "where": "제비율", + "what": "금액 구간 축이 넷", + "detail": "추정금액(고용보험) · 추정가격(하도급보증·일반관리비·이윤) · 직접공사비(간접노무비·기타경비·이행보증) · 대상액(안전관리비). 한 칸으로 접으면 「어느 금액인가」가 사라지므로 `bracket_basis` 칸을 함께 둔다." + }, + { + "where": "제비율", + "what": "이행보증만 요율이 아니라 산식", + "detail": "`(직접공사비 × 0.0108%) × 공사연수` 꼴이라 `rate_percent` 가 빈다. `formula` 칸으로 받는다." + }, + { + "where": "기계", + "what": "취득가는 천원, 나머지 금액은 원", + "detail": "원본이 천원 단위다. 한 표에 섞이면 1,000배 틀린다 — 열 이름에 단위를 못박는다." + }, + { + "where": "노임", + "what": "단위는 어긋나지 않음", + "detail": "둘 다 `KRW/day` 다(건설 132 · 제조 129 전수 확인)." + } + ], + "collision": [ + { + "where": "노임", + "what": "직종코드 공간이 다르다", + "detail": "건설업은 네 자리(1001~), 제조업은 한두 자리(1·2·3…). **지금은 겹치는 코드가 0** 이지만 같은 칸에 넣으면 나중에 부딪힌다 — `source` 칸을 반드시 함께 둔다." + }, + { + "where": "유가", + "what": "「전국」이 두 줄 선다", + "detail": "전국평균 판(휘발유 1,863.25 · 경유 1,846.39 · 2026-08-14)과 시도판 코드 `00`(1,858.93 · 1,843.79 · 2026-09-09)이 **값도 기준일도 다르다**. ⚠ 어느 쪽을 쓸지는 판정이 필요하다 — 이름표는 두 줄을 그대로 보이고 `source` 로 가른다." + } + ] + }, + "row_keys": [ + { + "table": "labor", + "name_ko": "노임", + "verdict": "있음(묶어야 함)", + "key": [ + "source", + "occupation_code" + ], + "checked": "건설업 132 줄·제조업 129 줄 각각 직종코드 겹침 0(전수). 다만 코드 공간이 달라(건설 네 자리 1001~ · 제조 한두 자리 1·2·3) **출처 칸과 묶어야** 한 표에서 안 부딪힌다." + }, + { + "table": "material", + "name_ko": "자재 단가", + "verdict": "있음", + "key": [ + "item_code" + ], + "checked": "6,999 줄 물품코드 겹침 0(전수). 그대로 열쇠로 쓸 수 있다." + }, + { + "table": "oil", + "name_ko": "유가", + "verdict": "없음 — 묶어야 함", + "key": [ + "fuel", + "source", + "region_code" + ], + "checked": "원본이 유종마다 따로 선 표라 줄에는 시도코드뿐이다(유종별 17 줄 겹침 0). 유종은 표 이름에, 출처는 판에 있어 **줄 안에 없다** — 셋을 묶어야 줄이 하나로 가려진다." + }, + { + "table": "rates", + "name_ko": "제비율", + "verdict": "없음 — 조건으로만 가려짐", + "key": [ + "item_key", + "variant", + "(조건 열들)" + ], + "checked": "19 벌에 id 칸이 아예 없다. 벌마다 조건 묶음으로 줄이 가려지는 것은 확인했다(14 개 구간표 전수 겹침 0). 다만 **조건 축이 벌마다 다르다** — 등급·연도·공사 종류·공사기간·금액 구간 넷. 한 끈으로 쓰려면 항목키+벌+조건을 이어야 한다." + }, + { + "table": "machine", + "name_ko": "기계경비", + "verdict": "있음", + "key": [ + "machine_code" + ], + "checked": "취득가 613 · 손료계수 387 · 연료 214 · 운전경비 92 **네 표 모두 기계코드 겹침 0**(전수). 취득가가 가장 넓어 뼈대가 되고 나머지가 붙는다." + } + ], + "null_is_real": { + "columns": [ + { + "table": "labor", + "column": "daily_wage_krw", + "rows": 30, + "shape": "칸이 아예 없음", + "why": "⭐ **가장 위험한 자리** — 건설업 14 줄·제조업 16 줄에 일 노임 칸이 없다. 건설업 쪽은 `reliability` 가 `**`(미조사)인 줄이라 **값이 없는 것이 제값**이다. 되돌리기 null 과 구별이 안 된다." + }, + { + "table": "labor", + "column": "reliability · source_flag · variation_coefficient", + "rows": 221, + "shape": "칸이 아예 없음", + "why": "기호가 없는 줄 = 정상 공표라는 뜻이다. 빈 칸 자체가 값이다." + }, + { + "table": "machine", + "column": "fuel_liters_per_hour · misc_material_percent · operator_person_days", + "rows": 12, + "shape": "**명시 null**", + "why": "운전경비 파생본이 원문에서 못 읽은 칸을 `null` 로 적어 두었다 — 0 으로 안 때운 자리다. 다섯 가운데 **진짜 `null` 이 든 유일한 자료**다." + }, + { + "table": "machine", + "column": "economic_life_hours 외 손료계수 8 열", + "rows": 22, + "shape": "칸이 아예 없음", + "why": "손료계수는 있는데 그 속을 이루는 계수·내용시간이 원문에 없는 줄이다." + }, + { + "table": "machine", + "column": "specification", + "rows": 255, + "shape": "칸이 아예 없음", + "why": "규격이 없는 기종(취득가 230 · 연료 25). 규격 없음이 제값이라 빈 칸으로 둔다." + }, + { + "table": "material", + "column": "specification", + "rows": 2, + "shape": "빈 문자열", + "why": "null 이 아니라 `\"\"` 다. 되돌리기 null 과는 안 겹치나 **빈 칸 판정이 두 가지**가 된다." + } + ], + "rule": "⚠ 되돌리기를 `null` 로 보내면 위 자리에서 뜻이 겹친다. 겹침을 푸는 길은 둘 — ㉠ 되돌리기를 값이 아니라 **짓**으로 보낸다(덮개에서 그 열을 지움) · ㉡ 되돌리기 낱말을 따로 둔다(`\"@revert\"` 같은). 어느 쪽이든 **원본의 `null`·빈 칸은 그대로 살려야** 한다 — 「미조사」가 「값 지움」으로 바뀌면 안 된다. ⚠ 자재 `specification` 은 `null` 이 아니라 빈 문자열이라 빈 칸 판정이 두 가지다." + }, "value_kinds": { "value": { "name_ko": "값", @@ -93,6 +911,8 @@ } }, "counts": { + "merged_tables": 5, + "merged_columns": 62, "files": 34, "tables": 91, "columns": 455, diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py index 26ed63b0..671704e0 100644 --- a/resources/tester/test_master_labels_cover.py +++ b/resources/tester/test_master_labels_cover.py @@ -447,3 +447,94 @@ def test_미결은_미판정으로_표시된다(labels): column = labels["synthetic_tables"]["@values"]["columns"][3] assert column["key"] == "@undecided" assert column["name_ko"] == "미판정" + + +MERGED_KEYS = ("labor", "material", "oil", "rates", "machine") + + +def test_기초단가_다섯이_다_있다(labels): + merged = {m["key"]: m for m in labels["merged_tables"]} + assert tuple(merged) == MERGED_KEYS + for entry in merged.values(): + assert entry["name_ko"].strip() + assert entry["summary"].strip() + assert entry["columns"], entry["key"] + assert entry["sources"], entry["key"] + + +def test_합친_표의_열마다_이름과_고칠수있나가_있다(labels): + bad = [] + for entry in labels["merged_tables"]: + for column in entry["columns"]: + if not column["name_ko"].strip(): + bad.append(f"{entry['key']}/{column['key']} — 이름 없음") + if not isinstance(column.get("editable"), bool): + bad.append(f"{entry['key']}/{column['key']} — editable 없음") + assert not bad, bad + + +def test_계산값은_기계에만_있고_못_고친다(labels): + """⭐ 밑값만 고칠 수 있다 — 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없다.""" + derived = { + entry["key"]: [c["key"] for c in entry["columns"] if not c["editable"]] + for entry in labels["merged_tables"] + } + assert derived["machine"] == [ + "hourly_loss_krw", + "hourly_fuel_krw", + "hourly_operator_krw", + "hourly_total_krw", + ] + for key in ("labor", "material", "oil", "rates"): + assert derived[key] == [], f"{key} 에 계산값이 생겼다" + + +def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels): + chain = labels["machine_cost_chain"] + assert len(chain["steps"]) == 5 + for step in chain["steps"]: + assert step["step"].strip() and step["formula"].strip() + assert "밑값" in chain["editable_rule"] and "계산값" in chain["editable_rule"] + # 기계는 유가·노임 표를 문다 — 갱신 차례가 여기서 갈린다. + assert "유가" in chain["cross_table"] and "노임" in chain["cross_table"] + + +def test_열_맞대기_어긋남이_네_갈래로_적혀_있다(labels): + findings = labels["merge_findings"] + assert set(findings) == { + "renamed_same_column", + "one_side_only", + "unit_or_axis_mismatch", + "collision", + } + assert all(findings[k] for k in findings) + + +def test_줄_열쇠가_다섯_다_판정돼_있다(labels): + """덮개가 원본 줄을 붙드는 끈 — 없는 자료는 「없음」으로 내고 지어내지 않는다.""" + keys = {r["table"]: r for r in labels["row_keys"]} + assert set(keys) == set(MERGED_KEYS) + for entry in keys.values(): + assert entry["verdict"].strip() and entry["key"] and entry["checked"].strip() + assert keys["material"]["key"] == ["item_code"] + assert keys["machine"]["key"] == ["machine_code"] + # 한 칸으로는 안 되는 둘 — 묶어야 한다. + assert len(keys["labor"]["key"]) > 1 + assert keys["oil"]["verdict"].startswith("없음") + assert keys["rates"]["verdict"].startswith("없음") + + +def test_null_이_제값인_열이_적혀_있다(labels): + """되돌리기 null 과 원본의 「값 없음」이 겹치는 자리 — 겹치면 미조사가 값 지움으로 바뀐다.""" + block = labels["null_is_real"] + assert block["columns"] + for row in block["columns"]: + assert row["table"] in MERGED_KEYS + assert row["column"].strip() and row["why"].strip() + assert isinstance(row["rows"], int) + # 가장 위험한 자리 — 노임 일 노임이 없는 줄(미조사) + wage = [ + r for r in block["columns"] if r["table"] == "labor" and "daily_wage_krw" in r["column"] + ] + assert wage and wage[0]["rows"] == 30 + assert "되돌리기" in block["rule"] From 35632f8cb97ea43d98f62237b11565f23ee5d42f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:00:17 +0900 Subject: [PATCH 2/3] =?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 From 26a847babc806077cc421de5fab90b451f8f3a96 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:00:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=84=AF=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EB=85=B8=EC=9E=84=C2=B7=EA=B8=B0=EA=B3=84=C2=B7?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=C2=B7=EC=9C=A0=EA=B0=80=C2=B7=EC=9A=94?= =?UTF-8?q?=EC=9C=A8=EC=9D=84=20kind=20=EB=A7=88=EB=8B=A4=20=ED=95=9C=20?= =?UTF-8?q?=ED=91=9C(=EC=9E=90=EB=A3=8C=20=EC=A0=9C=20=EC=97=B4=EC=87=A0?= =?UTF-8?q?=20@id=20=C2=B7=20=ED=8C=90=20=EA=B8=B0=EC=A4=80=EC=9D=BC=20?= =?UTF-8?q?=EC=B9=B8)=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20=EA=B0=92=EC=9D=80?= =?UTF-8?q?=20=EB=8D=AE=EA=B0=9C=20resources/data=5Fmaster=5Foverride/{kin?= =?UTF-8?q?d}.json=20=EC=97=90=EB=A7=8C(=EC=9B=90=EB=B3=B8=EC=9D=80=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=EB=A7=8C)=20=C2=B7=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=B0=94=EB=80=90=20=EB=8D=AE=EA=B0=9C=EB=8A=94=20=EB=8D=AE?= =?UTF-8?q?=EA=B0=9C=20=EA=B0=92=20=EC=9C=A0=EC=A7=80=20+=20source=5Fchang?= =?UTF-8?q?ed=20=C2=B7=20=EC=A3=BC=EC=9D=B8=20=EC=97=86=EC=9D=8C=EC=9D=80?= =?UTF-8?q?=20=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=AA=A8?= =?UTF-8?q?=EC=95=84=20=EB=B3=B4=EA=B8=B0(=EC=84=9C=EB=B2=84=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=C2=B7=20=EA=B1=B0=EB=A6=84=20=C2=B7=20=EC=AA=BD)?= =?UTF-8?q?=EC=99=80=20=ED=95=9C=EA=BA=BC=EB=B2=88=EC=97=90=20=EB=B9=84?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EA=B8=B0=EA=B3=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8B=B9=20=EB=8B=A8=EA=B0=80=EB=8A=94=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=9E=A0=EA=B7=B8?= =?UTF-8?q?=EA=B3=A0=20=EC=82=B0=EC=B6=9C=EA=B7=BC=EA=B1=B0(@formula)=20?= =?UTF-8?q?=C2=B7=20=EB=85=B8=EC=9E=84=C2=B7=EC=9C=A0=EA=B0=80=20=EB=8D=AE?= =?UTF-8?q?=EA=B0=9C=EA=B0=80=20=EA=B8=B0=EA=B3=84=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=97=90=20=EB=8B=BF=EC=9D=8C=20=C2=B7=20B09=20hourly=5Fcost?= =?UTF-8?q?=5Fof=20=EC=99=80=20=EB=8C=80=EC=A1=B0(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=95=BD=20=C2=B7=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 347 +++++++++++++++ .../Z01_MasterData_BasePrices_Machine.py | 213 +++++++++ Z01_MasterData/Z01_MasterData_Overrides.py | 166 +++++++ Z01_MasterData/Z01_MasterData_Router.py | 58 ++- resources/tester/test_z01_base_prices.py | 411 ++++++++++++++++++ 5 files changed, 1193 insertions(+), 2 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_BasePrices.py create mode 100644 Z01_MasterData/Z01_MasterData_BasePrices_Machine.py create mode 100644 Z01_MasterData/Z01_MasterData_Overrides.py create mode 100644 resources/tester/test_z01_base_prices.py diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py new file mode 100644 index 00000000..bbbcadb1 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -0,0 +1,347 @@ +"""Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약). + +표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만) +줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄) +`@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code · + 자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]` · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌) +원본 파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from functools import lru_cache +from pathlib import Path +from typing import Any + +from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine +from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Tables as tables + +KINDS = ("labor", "machine", "material", "oil", "rate") +_KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" +_SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜" +_RATE_EDITABLE = ( + "rate_percent", + "base_amount_krw", + "minimum_estimated_amount_krw", + "minimum_total_construction_amount_krw", + "rate_from_2033_percent", + "manager_thresholds.default_estimated_amount_krw", + "manager_thresholds.civil_main_work_estimated_amount_krw", +) +_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)" + + +def _source(file_id: str) -> Path: + labels = tables.load_labels() + entry = next((f for f in labels["files"] if f["file_id"] == file_id), None) + path = tables._file_path(entry) if entry else None + if path is None: + raise FileNotFoundError(f"이름표에 원본 자리가 없음: {file_id}") + return path + + +@lru_cache(maxsize=16) +def _read(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def _doc(file_id: str) -> tuple[dict[str, Any], str]: + path = _source(file_id) + return _read(str(path), path.stat().st_mtime_ns), path.name + + +def _records(doc: dict[str, Any]) -> list[dict[str, Any]]: + return next( + v["records"] for v in doc["variables"].values() if isinstance(v.get("records"), list) + ) + + +def _labor() -> list[dict[str, Any]]: + rows = [] + for file_id in ("labor_const", "labor_mfg"): + doc, name = _doc(file_id) + dataset = doc.get("dataset_id") or file_id + for r in _records(doc): + # 기준일 — 두 판(건설 · 제조) 기준일이 달라 한 표에 섞임 → 칸으로 드러냄 + row = { + "@id": f"{dataset}/{r['occupation_code']}", + "@source": name, + "dataset_id": dataset, + "effective_date": doc.get("effective_date"), + **r, + } + row.setdefault("daily_wage_krw", None) # 미공표 직종 — 사람이 넣을 수 있음 + rows.append(row) + return rows + + +def _material() -> list[dict[str, Any]]: + doc, name = _doc("mat_price_public") + date = doc.get("effective_date") + return [ + {"@id": str(r["item_code"]), "@source": name, "effective_date": date, **r} + for r in _records(doc) + ] + + +def _oil() -> list[dict[str, Any]]: + rows = [] + for file_id in ("oil", "oil_regional"): + doc, name = _doc(file_id) + for variable, v in doc["variables"].items(): + head = {k: x for k, x in v.items() if k not in ("records", "value")} + common = { + "scope": v.get("scope"), + "fuel": variable, + "effective_date": doc.get("effective_date"), # 전국 · 지역 판 기준일이 다름 + **head, + } + if "records" not in v: + rows.append( + { + "@id": f"{v.get('scope')}/{variable}", + "@source": name, + **common, + "value": v.get("value"), + } + ) + for r in v.get("records") or []: + rows.append( + { + "@id": f"{v.get('scope')}/{variable}/{r['sido_code']}", + "@source": name, + **common, + **r, + } + ) + return rows + + +def _flat(d: dict[str, Any], prefix: str = "") -> dict[str, Any]: + out: dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, dict): + out.update(_flat(v, f"{prefix}{k}.")) + elif not isinstance(v, list): + out[f"{prefix}{k}"] = v + return out + + +def _rate() -> list[dict[str, Any]]: + doc, name = _doc("rates") + rows = [] + for variable, v in doc["variables"].items(): + rows.append( + { + "@id": variable, + "@source": name, + "variable": variable, + "part": "", + "effective_date": doc.get("effective_date"), + **_flat(v), + } + ) + for part, items in v.items(): + if not (isinstance(items, list) and items and all(isinstance(x, dict) for x in items)): + continue + for item in items: + bracket = ";".join( + f"{k}={item[k]}" + for k in sorted(item) + if k not in _RATE_EDITABLE + and k != "formula" + and not isinstance(item[k], (dict, list)) + ) + rows.append( + { + "@id": f"{variable}/{part}/{bracket}", + "@source": name, + "variable": variable, + "part": part, + "effective_date": doc.get("effective_date"), + "base": v.get("base"), + **item, + } + ) + return rows + + +_BUILDERS = {"labor": _labor, "material": _material, "oil": _oil, "rate": _rate} + + +def base_rows(kind: str) -> list[dict[str, Any]]: + """덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈).""" + if kind == "machine": + doc, name = _doc("mach_base") + return machine.base_rows(name, doc.get("effective_date")) + return _BUILDERS[kind]() + + +def _machine_inputs() -> machine.Inputs: + """기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일.""" + labor = [r for r in overrides.overlay("labor", _labor()) if r["dataset_id"] == "labor_const"] + oil = [ + r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}" + ] + return machine.Inputs( + wages={ + str(r["occupation_code"]): None + if r["daily_wage_krw"] is None + else Decimal(str(r["daily_wage_krw"])) + for r in labor + }, + wage_date=labor[0]["effective_date"] if labor else None, + oil={r["fuel"]: Decimal(str(r["value"])) for r in oil}, + oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil}, + operating_date=_doc("pum_const")[0].get("effective_date"), + ) + + +def rows(kind: str) -> list[dict[str, Any]]: + """덮개를 얹은 줄 — 기계는 그 위에서 시간당 단가를 다시 셈.""" + out = overrides.overlay(kind, base_rows(kind)) + if kind == "machine": + inputs = _machine_inputs() + out = [machine.compute(r, inputs) for r in out] + return out + + +def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: + keys: dict[str, None] = {} + for r in all_rows: + keys.update(dict.fromkeys(k for k in r if not k.startswith("@"))) + return list(keys) + + +def spec(kind: str, columns: list[str]) -> dict[str, Any]: + """표 수준 — editable · locked · formula (· notice).""" + if kind == "machine": + return machine.spec(columns) + editable = { + "labor": ["daily_wage_krw"], + "material": ["price_krw"], + "oil": ["value"], + "rate": [c for c in _RATE_EDITABLE if c in columns], + }[kind] + keys = {"labor": {"dataset_id", "occupation_code"}, "material": {"item_code"}}.get(kind, set()) + if kind == "oil": + keys = {"scope", "fuel", "sido_code"} + if kind == "rate": + locked = { + c: "규칙·구간 칸(로직) — 원본 갱신으로만 바뀜" for c in columns if c not in editable + } + else: + locked = {c: _KEY if c in keys else _SOURCE for c in columns if c not in editable} + out: dict[str, Any] = {"editable": editable, "locked": locked, "formula": {}} + if kind == "rate": + out["notice"] = _RATE_NOTICE + return out + + +def row_label(kind: str, row: dict[str, Any] | None) -> str: + if row is None: + return "" + if kind == "labor": + return str(row.get("occupation_name") or "") + if kind == "machine": + return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip() + if kind == "material": + return str(row.get("specification") or row.get("classification_name") or "") + if kind == "oil": + return f"{row.get('source_product_name') or row.get('fuel')} {row.get('sido_name') or '전국평균'}" + named = (tables.load_labels().get("value_overrides") or {}).get( + f"rates::variables/{row['variable']}" + ) or {} + bracket = row["@id"].split("/", 2)[2].replace(";", " · ") if row["part"] else "" + return f"{named.get('name_ko') or row['variable']} {bracket}".strip() + + +def column_meta(kind: str, key: str) -> dict[str, Any]: + """열 이름 — 이름표 `synthetic_tables['@base_prices/{kind}']` → `columns[열key]` → 영문 key.""" + labels = tables.load_labels() + own = (labels.get("synthetic_tables") or {}).get(f"@base_prices/{kind}") or {} + named = next((c for c in own.get("columns") or [] if c.get("key") == key), None) + named = named or labels["columns"].get(key) or {} + return { + "key": key, + "label": named.get("name_ko") or key, + "unit": named.get("unit") or "", + "hidden": named.get("visible") is False, + } + + +def public(row: dict[str, Any]) -> dict[str, Any]: + """내보낼 줄 — 안쪽 칸(`@source`)은 뺌.""" + return {k: v for k, v in row.items() if k != "@source"} + + +def table( + kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" +) -> dict[str, Any]: + all_rows = rows(kind) + columns = _columns(kind, all_rows) + needle = q.strip().lower() + hits = ( + [ + r + for r in all_rows + if needle in json.dumps(public(r), ensure_ascii=False, default=str).lower() + ] + if needle + else all_rows + ) + size = max(1, min(size, tables.MAX_PAGE_SIZE)) + start = (max(page, 1) - 1) * size + return { + "columns": [column_meta(kind, c) for c in columns], + "rows": [public(r) for r in hits[start : start + size]], + "total": len(hits), + **spec(kind, columns), + } + + +def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, dict[str, Any]]: + """고치기 — (상태 코드, 고친 뒤 그 줄 또는 막은 까닭).""" + base = next((r for r in base_rows(kind) if r["@id"] == row_id), None) + if base is None: + return 404, {"message": f"없는 줄: {row_id}"} + info = spec(kind, _columns(kind, rows(kind))) + for column, value in values.items(): + if column not in info["editable"] or column not in base: + reason = info["locked"].get(column) or "이 줄에 없는 칸" + return 400, {"message": f"못 고치는 칸 {column} — {reason}"} + problem = overrides.check_value(value) + if problem: + return 400, {"message": f"{column}: {problem}"} + overrides.write(kind, base, values, by) + return 200, public(next(r for r in rows(kind) if r["@id"] == row_id)) + + +def override_items( + kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE +) -> dict[str, Any]: + """모아 보기 — 서버가 정렬(원본 바뀜 → 주인 없음 → 고쳐짐) · 거름(kind·state) · 쪽 · kind 별 state 수.""" + out = [] + counts: dict[str, dict[str, int]] = {} + for each in KINDS: # 수는 늘 다섯 다 셈 — 거름은 줄에만 + base = base_rows(each) + by_id = {r["@id"]: r for r in base} + for item in overrides.items(each, base): + counts.setdefault(each, {}).setdefault(item["state"], 0) + counts[each][item["state"]] += 1 + if (kind and each != kind) or (state and item["state"] != state): + continue + item["row_label"] = row_label(each, by_id.get(item["row_id"])) + item["column_label"] = column_meta(each, item["column"])["label"] + out.append(item) + out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"])) + size = max(1, min(size, tables.MAX_PAGE_SIZE)) + start = (max(page, 1) - 1) * size + return {"items": out[start : start + size], "total": len(out), "counts": counts} + + +def clear_overrides(kind: str, state: str) -> int: + return overrides.clear(kind, state, base_rows(kind)) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py new file mode 100644 index 00000000..ed1e3bfb --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Machine.py @@ -0,0 +1,213 @@ +"""Z01 기초단가 — 기계 한 표: 기종 한 줄 + 시간당 단가(계산값) · 산출근거(2026-09-15 브레인 계약). + +기계단가는 취득가 613 · 손료계수 387 · 운전경비(연료·잡재료·조종원 — 건설품셈 8-4) · 노임 · 유가가 계산으로 물림. + 고치는 칸 = 취득가 · 손료계수(이 표) — 노임·유가는 그 표에서 고치고 여기는 **덮개 얹은 값**을 끌어 씀 + 계산 칸 = 손료 · 연료 · 조종원 · 시간당 단가 — editable 에서 빼고 locked 에 까닭 +⚠ 셈은 B09 함수를 그대로 부름(`load_machine_catalog` · `load_operating_records` · `hourly_machine_cost`) — 식을 두 벌로 안 둠. + `hourly_cost_of`(B09) 와 같은 값인지 `test_z01_base_prices.py` 가 대조. 반만 선 값(연료·조종원 빠짐)은 시간당 단가를 안 냄. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from decimal import Decimal +from functools import lru_cache +from typing import Any + +from B09_Estimation.B09_Estimation_MachineCost import ( + OPERATOR_WAGE_FORMULA, + hourly_machine_cost, + load_machine_catalog, +) +from B09_Estimation.B09_Estimation_MachineOperating import FUEL_VARIABLES, load_operating_records +from Z01_MasterData.Z01_MasterData_Tables import RESOURCES + +#: B09 셈이 여는 원본 셋 — 바뀌면 캐시를 새로 읽음 +_INPUT_FILES = ("mach_base_2026.json", "pum_const_2026.json", "labor_const_2026-01-01.json") +EDITABLE = ["price_thousand_krw", "loss_coefficient_per_hour"] +COMPUTED = ("loss_krw_per_hour", "fuel_krw_per_hour", "operator_krw_per_hour", "hourly_krw") +#: 조종원 식 글자 — B09 식 문자열(좌→우 차례)을 사람이 읽게만 바꿈 +_WAGE_TEXT = OPERATOR_WAGE_FORMULA.replace("*", " × ").replace("/", " ÷ ") +_FORMULA = { + "loss_krw_per_hour": "손료 = 취득가(천원) × 1,000 × 시간당 손료계수 (건설품셈 8-1-6)", + "fuel_krw_per_hour": "연료 = 주연료(ℓ/hr) × (1 + 잡재료%) × 전국평균 유가 (건설품셈 8-4 · 8-1-7)", + "operator_krw_per_hour": ( + f"조종원 = 일 노임 × 인원 × {_WAGE_TEXT}" + " · 원 미만 절사 (건설품셈 8-1-2 · 건협 임금적용요령 4-나)" + ), + "hourly_krw": "시간당 사용료 = 손료 + 연료 + 조종원", +} +_LOCKED_COMPUTED = "계산값 — 밑값(취득가·손료계수는 이 표 · 노임·유가는 그 표)을 고치면 따라 바뀜" +_LOCKED_OPERATING = "건설품셈 8-4 운전경비표(로직)에서 옴 — 기초단가가 아님" +_LOCKED = { + "machine_code": "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음", + "machine_name": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "specification": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "economic_life_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "annual_standard_hours": "공표 원문 칸 — 원본 갱신으로만 바뀜", + "fuel_kind": _LOCKED_OPERATING, + "fuel_liters_per_hour": _LOCKED_OPERATING, + "misc_material_percent": _LOCKED_OPERATING, + "operator_occupation_code": _LOCKED_OPERATING, + "operator_person_days": _LOCKED_OPERATING, + "fuel_price_krw_per_l": "유가 표에서 고침 — 여기는 덮개 얹은 전국평균을 끌어 씀", + "operator_daily_wage_krw": "노임 표에서 고침 — 여기는 덮개 얹은 건설 노임을 끌어 씀", + "hourly_note": "계산이 왜 안 섰는지 — 밑값이 비면 섬", + "effective_date": "원본 판 기준일 — 원본 갱신으로만 바뀜", + "operating_effective_date": "운전경비 원단위가 나온 건설품셈 판 기준일", + "fuel_price_date": "끌어 쓴 유가 판 기준일 — 유가 표 판", + "operator_wage_date": "끌어 쓴 노임 판 기준일 — 노임 표 판", + **dict.fromkeys(COMPUTED, _LOCKED_COMPUTED), +} + + +def _num(value: Decimal | int | None) -> int | float | None: + if value is None: + return None + value = Decimal(value) + return int(value) if value == value.to_integral_value() else float(value) + + +def _fmt(value: Decimal | int | float) -> str: + text = f"{Decimal(str(value)):,.4f}".rstrip("0").rstrip(".") + return text + + +@dataclass(frozen=True) +class Inputs: + """기계 셈이 다른 표에서 끌어 오는 값 — 덮개를 얹은 뒤 · 판 기준일과 함께.""" + + wages: dict[str, Decimal | None] + wage_date: str | None + oil: dict[str, Decimal] + oil_dates: dict[str, str | None] + operating_date: str | None + + +@lru_cache(maxsize=2) +def _cached(_mtimes: tuple[int, ...]): + return load_machine_catalog(), {r.machine_code: r for r in load_operating_records().records} + + +def _inputs(): + folder = RESOURCES / "data_cost_input_value" + return _cached(tuple((folder / name).stat().st_mtime_ns for name in _INPUT_FILES)) + + +def base_rows(source_name: str, effective_date: str | None) -> list[dict[str, Any]]: + """기종 한 줄 — 원본 칸(카탈로그가 되살린 이름·규격 · 손료계수 포함)과 운전경비 원단위.""" + catalog, records = _inputs() + rows = [] + for code, m in catalog.machines.items(): + record = records.get(code) + rows.append( + { + "@id": code, + "@source": source_name, + "machine_code": code, + "effective_date": effective_date, + "machine_name": m.name, + "specification": m.specification, + "price_thousand_krw": _num(m.price_thousand_krw), + "loss_coefficient_per_hour": _num(m.loss_coefficient_per_hour), + "economic_life_hours": m.economic_life_hours, + "annual_standard_hours": m.annual_standard_hours, + "fuel_kind": record.fuel_kind if record else None, + "fuel_liters_per_hour": _num(record.fuel_liters_per_hour) if record else None, + "misc_material_percent": _num(record.misc_material_percent) if record else None, + "operator_occupation_code": record.operator_occupation_code if record else None, + "operator_person_days": _num(record.operator_person_days) if record else None, + } + ) + return rows + + +def compute(row: dict[str, Any], inputs: Inputs) -> dict[str, Any]: + """덮개 얹은 줄 → 시간당 단가 칸을 붙인 새 줄. B09 `hourly_cost_of` 와 같은 길.""" + catalog, records = _inputs() + code = row["@id"] + coef = row["loss_coefficient_per_hour"] + record = records.get(code) + out = { + **row, + "operating_effective_date": inputs.operating_date if record is not None else None, + "fuel_price_krw_per_l": None, + "fuel_price_date": None, + "operator_daily_wage_krw": None, + "operator_wage_date": None, + **dict.fromkeys(COMPUTED), + } + if coef is None: + out["hourly_note"] = "손료계수 없음 — 시간당 단가 안 섬" + return out + spec = replace( + catalog.machines[code], + price_thousand_krw=Decimal(str(row["price_thousand_krw"])), + loss_coefficient_per_hour=Decimal(str(coef)), + ) + liters = fuel_price = wage = None + if record is not None: + liters = record.fuel_liters_per_hour + if liters is not None: + variable = FUEL_VARIABLES.get(record.fuel_kind, "") + fuel_price = inputs.oil.get(variable) + out["fuel_price_date"] = inputs.oil_dates.get(variable) + if record.misc_material_percent is not None: + liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) + wage = inputs.wages.get(record.operator_occupation_code) + out["operator_daily_wage_krw"] = _num(wage) + out["operator_wage_date"] = inputs.wage_date + if wage is not None and record.operator_person_days is not None: + wage = wage * record.operator_person_days # 인원 칸이 비면 B09 처럼 일 노임 그대로 + cost = hourly_machine_cost( + spec, + fuel_liters_per_hour=liters if fuel_price is not None else None, + fuel_price_per_liter=fuel_price, + operator_daily_wage=wage, + ) + money = cost.money + out["fuel_price_krw_per_l"] = _num(fuel_price) + out["loss_krw_per_hour"] = _num(money.expense) + formula = { + "loss_krw_per_hour": ( + f"{_fmt(money.expense)} = {_fmt(spec.price_thousand_krw)}천원 × 1,000 × {spec.loss_coefficient_per_hour}" + ) + } + if fuel_price is not None: + out["fuel_krw_per_hour"] = _num(money.material) + misc = record.misc_material_percent if record else None + formula["fuel_krw_per_hour"] = ( + f"{_fmt(money.material)} = {_fmt(record.fuel_liters_per_hour)}ℓ" + + (f" × (1 + {_fmt(misc)}%)" if misc is not None else "") + + f" × {_fmt(fuel_price)}원/ℓ" + ) + if wage is not None: + out["operator_krw_per_hour"] = _num(money.labor) + persons = record.operator_person_days + formula["operator_krw_per_hour"] = ( + f"{_fmt(money.labor)} = {_fmt(out['operator_daily_wage_krw'])}원" + + (f" × {_fmt(persons)}인" if persons is not None else "") + + f" × {_WAGE_TEXT} (원 미만 절사)" + ) + if cost.gaps: + out["hourly_note"] = " · ".join(cost.gaps) + else: + out["hourly_krw"] = _num(money.total) + out["hourly_note"] = None + formula["hourly_krw"] = ( + f"{_fmt(money.total)} = {_fmt(money.expense)} + {_fmt(money.material)} + {_fmt(money.labor)}" + ) + out["@formula"] = formula + return out + + +def spec(columns: list[str]) -> dict[str, Any]: + return { + "editable": list(EDITABLE), + "locked": { + c: _LOCKED.get(c, "공표 원문 칸 — 원본 갱신으로만 바뀜") + for c in columns + if c not in EDITABLE + }, + "formula": dict(_FORMULA), + } diff --git a/Z01_MasterData/Z01_MasterData_Overrides.py b/Z01_MasterData/Z01_MasterData_Overrides.py new file mode 100644 index 00000000..5beeb6db --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_Overrides.py @@ -0,0 +1,166 @@ +"""Z01 기초단가 덮개 — 사람이 고친 값만 담는 층(2026-09-15 브레인 틀 · 사용자 지시 「기초단가를 고칠 수 있게」). + + 원본 resources/data_*/… 읽기만 — 주기 갱신은 이 파일을 통째로 갈아끼움 + 덮개 resources/data_master_override/{kind}.json 고친 것만 — 주기가 달라 kind 마다 한 파일 + 읽기 원본 줄 위에 덮개를 얹음 · 줄 수준 `@overrides[열] = {original, value, state, current}` + +state 셋(판정은 **여기 한 곳** — 화면이 따로 재지 않음): + edited 고쳐짐 — 원본 그대로(덮개를 쓸 때 본 원본 = 지금 원본) + source_changed 고쳐짐 — 원본이 바뀜 · ⚠ 덮개 값을 그대로 씀(자동으로 원본을 쓰면 금액이 말없이 바뀜 — 사람이 정함) + orphan 주인 없음 — 갱신으로 그 줄·칸이 사라짐 · 버리지 않고 모아 보기에 남김 +되돌리기(「새 원본 값으로 받기」 도 같음) = 값 null → 그 칸을 덮개에서 뺌. +""" + +from __future__ import annotations + +import json +import math +import threading +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +OVERRIDE_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_master_override" +EDITED, SOURCE_CHANGED, ORPHAN = "edited", "source_changed", "orphan" +STATE_ORDER = (SOURCE_CHANGED, ORPHAN, EDITED) # 모아 보기 차례 — 확인할 것이 맨 위 +_LOCK = threading.Lock() # ponytail: 한 프로세스 잠금 — 서버를 여럿 띄우면 파일 잠금이 필요 + + +def _path(kind: str) -> Path: + return OVERRIDE_DIR / f"{kind}.json" + + +def load(kind: str) -> dict[str, dict[str, dict[str, Any]]]: + """`@id` → 열 → {value, original, source, at, by}. 파일이 없으면 빈 덮개.""" + path = _path(kind) + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")).get("overrides") or {} + + +def same(a: Any, b: Any) -> bool: + """값 비교 — 215907 과 215907.0 은 같음 · None 은 None 과만 같음.""" + if a is None or b is None: + return a is None and b is None + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return Decimal(str(a)) == Decimal(str(b)) + return a == b + + +def check_value(value: Any) -> str | None: + """넣을 수 있는 값인가 — 막는 까닭(없으면 None). null 은 되돌리기라 통과.""" + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "숫자만 넣을 수 있음" + if not math.isfinite(value) or value < 0: + return "0 이상 유한한 수만 넣을 수 있음" + return None + + +def mark(original: Any, value: Any, current: Any) -> dict[str, Any]: + state = EDITED if same(original, current) else SOURCE_CHANGED + return {"original": original, "value": value, "state": state, "current": current} + + +def overlay(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """원본 줄(바꾸지 않음) 위에 덮개를 얹은 **새 줄들**.""" + store = load(kind) + out = [] + for row in rows: + entries = store.get(row["@id"]) + if not entries: + out.append(row) + continue + row = dict(row) + marks = {} + for column, entry in entries.items(): + if column not in row: + continue # 주인 없는 칸 — 모아 보기에만 + marks[column] = mark(entry.get("original"), entry.get("value"), row[column]) + row[column] = entry.get("value") + if marks: + row["@overrides"] = marks + out.append(row) + return out + + +def write(kind: str, base_row: dict[str, Any], values: dict[str, Any], by: Any) -> None: + """고친 값을 덮개에 씀 — null 이거나 지금 원본과 같으면 그 칸을 뺌. 원본은 안 건드림.""" + row_id = base_row["@id"] + with _LOCK: + store = load(kind) + entries = dict(store.get(row_id) or {}) + for column, value in values.items(): + current = base_row.get(column) + if value is None or same(value, current): + entries.pop(column, None) + continue + entries[column] = { + "value": value, + "original": current, # 이 값을 보고 고침 — 갱신 뒤 원본과 달라지면 source_changed + "source": base_row.get("@source", ""), + "at": datetime.now().astimezone().isoformat(timespec="seconds"), + "by": by, + } + if entries: + store[row_id] = entries + else: + store.pop(row_id, None) + _save(kind, store) + + +def _save(kind: str, store: dict[str, dict[str, dict[str, Any]]]) -> None: + atomic_write_json( + _path(kind), + { + "schema_version": "1.0", + "kind": kind, + "note": "사람이 고친 기초단가만 — 원본 파일은 그대로 · Z01 마스터 데이터 화면이 씀", + "overrides": store, + }, + ) + + +def clear(kind: str, state: str, base_rows: list[dict[str, Any]]) -> int: + """한 kind 의 그 state 덮개를 한꺼번에 뺌 — 품목 퇴출로 여럿이 주인을 잃는 갱신 뒤(브레인 ②). 뺀 수.""" + with _LOCK: + doomed = {(i["row_id"], i["column"]) for i in items(kind, base_rows) if i["state"] == state} + if not doomed: + return 0 + store = load(kind) + for row_id, column in doomed: + store[row_id].pop(column, None) + if not store[row_id]: + del store[row_id] + _save(kind, store) + return len(doomed) + + +def items(kind: str, base_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """모아 보기 한 kind 몫 — 주인 없는 것까지 · 차례는 부르는 쪽이 STATE_ORDER 로.""" + by_id = {r["@id"]: r for r in base_rows} + out = [] + for row_id, entries in load(kind).items(): + row = by_id.get(row_id) + for column, entry in entries.items(): + if row is None or column not in row: + current, state = None, ORPHAN + else: + current = row[column] + state = mark(entry.get("original"), entry.get("value"), current)["state"] + out.append( + { + "kind": kind, + "row_id": row_id, + "column": column, + "original": entry.get("original"), + "current": current, + "value": entry.get("value"), + "state": state, + } + ) + return out diff --git a/Z01_MasterData/Z01_MasterData_Router.py b/Z01_MasterData/Z01_MasterData_Router.py index e3e1e315..31b9e53c 100644 --- a/Z01_MasterData/Z01_MasterData_Router.py +++ b/Z01_MasterData/Z01_MasterData_Router.py @@ -1,19 +1,32 @@ -"""Z01 마스터 데이터 라우터 — 읽기 전용(고치기는 다음 차례 · 2026-09-15 브레인). +"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인). GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) + GET /api/master-data/base-prices/{kind}?page=&size=&q= 기초단가 한 표(labor|machine|material|oil|rate) + PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기 + GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. """ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from typing import Any, Literal +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from Z01_MasterData import Z01_MasterData_BasePrices as base_prices from Z01_MasterData import Z01_MasterData_Tables as tables router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"]) +class BasePriceEdit(BaseModel): + values: dict[str, Any] + + @router.get("/tree") def get_tree() -> dict: return tables.tree() @@ -27,3 +40,44 @@ def get_rows( if result is None: raise HTTPException(status_code=404, detail="없는 파일이나 표입니다.") return result + + +def _kind(kind: str) -> str: + if kind not in base_prices.KINDS: + raise HTTPException(status_code=404, detail=f"없는 기초단가: {kind}") + return kind + + +@router.get("/base-prices/{kind}") +def get_base_prices( + kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" +) -> dict: + return base_prices.table(_kind(kind), page=page, size=size, q=q) + + +@router.put("/base-prices/{kind}/{row_id:path}") +def put_base_price( + kind: str, row_id: str, body: BasePriceEdit, session: dict = Depends(verify_session) +) -> JSONResponse: + status, payload = base_prices.edit(_kind(kind), row_id, body.values, session.get("user_id")) + if status != 200: + raise HTTPException(status_code=status, detail=payload["message"]) + return JSONResponse(content=payload) + + +@router.get("/overrides") +def get_overrides( + kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE +) -> dict: + return base_prices.override_items(kind and _kind(kind), state, page=page, size=size) + + +class OverrideClear(BaseModel): + kind: str + state: Literal["edited", "source_changed", "orphan"] + + +@router.post("/overrides/clear") +def clear_overrides(body: OverrideClear, _session: dict = Depends(verify_session)) -> dict: + """한 kind 의 그 state 덮개를 한꺼번에 뺌 — kind·state 둘 다 있어야 함(통째로 비우기 막음).""" + return {"removed": base_prices.clear_overrides(_kind(body.kind), body.state)} diff --git a/resources/tester/test_z01_base_prices.py b/resources/tester/test_z01_base_prices.py new file mode 100644 index 00000000..5c904de5 --- /dev/null +++ b/resources/tester/test_z01_base_prices.py @@ -0,0 +1,411 @@ +"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시). + +틀(브레인 못박음): 원본 `resources/data_*` 는 **읽기만** · 고친 값은 덮개 +`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 한 파일)에만 · 읽을 때 원본 위에 얹음 · +덮개 줄마다 original(원래 값)·source(원본 파일) — 원본이 바뀌면 「원본 바뀜」 · 줄이 사라지면 「주인 없음」(버리지 않음) · +기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜. +""" + +from __future__ import annotations + +import hashlib +import json +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from Z01_MasterData import Z01_MasterData_Overrides as overrides +from Z01_MasterData import Z01_MasterData_Router as router_module + +ROOT = Path(__file__).resolve().parents[2] +SOURCES = [ + ROOT / "resources/data_cost_input_value" / name + for name in ( + "labor_const_2026-01-01.json", + "labor_mfg_2026-07-01.json", + "mach_base_2026.json", + "mat_price_public_2026-08-14.json", + "oil_2026-08-14.json", + "oil_regional_2026-09-09.json", + "rates_2026.json", + ) +] + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + folder = tmp_path / "data_master_override" + monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder) + return folder + + +@pytest.fixture +def client(store: Path) -> TestClient: + from common_util.common_util_auth import verify_session + + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "SYSTEM_ADMIN"} + return TestClient(app) + + +def _get(client: TestClient, kind: str, **params) -> dict: + res = client.get(f"/api/master-data/base-prices/{kind}", params=params) + assert res.status_code == 200, res.text + return res.json() + + +def _row(client: TestClient, kind: str, row_id: str) -> dict: + rows = _get(client, kind, q=row_id.rsplit("/", 1)[-1], size=500)["rows"] + return next(r for r in rows if r["@id"] == row_id) + + +def _put(client: TestClient, kind: str, row_id: str, values: dict): + return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values}) + + +def _sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> None: + expected = { + "labor": (261, ["daily_wage_krw"]), + "material": (6999, ["price_krw"]), + "oil": (36, ["value"]), + "machine": (613, ["price_thousand_krw", "loss_coefficient_per_hour"]), + } + for kind, (total, editable) in expected.items(): + table = _get(client, kind, size=500) + assert table["total"] == total and table["editable"] == editable, kind + ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]] + assert len(ids) == len(set(ids)), kind + assert all("@overrides" not in r for r in table["rows"]), ( + kind + ) # 안 고친 줄엔 칸 자체가 없음 + columns = {c["key"] for c in table["columns"]} + assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖 + assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind + assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907 + assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)" + assert _row(client, "oil", "national_average/oil_diesel")["value"] == 1846.39 + assert _row(client, "oil", "regional/oil_diesel/01")["sido_name"] == "서울" + rate = _get(client, "rate", size=500) + assert rate["total"] == 218 and "rate_percent" in rate["editable"] + by_id = {r["@id"]: r for r in rate["rows"]} + assert by_id["rate_sanjae"]["rate_percent"] == 3.56 + goyong = ( + "rate_goyong/brackets/estimated_amount_bracket=gte_140_billion;grade=1" # 구간 칸 이름 차례 + ) + assert by_id[goyong]["rate_percent"] == 1.57 and by_id[goyong]["base"] == "total_labor_cost" + material = _get(client, "material", q="육각볼트", size=5) + assert 0 < material["total"] < 6999 and len(material["rows"]) == 5 + + +def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를_실음(client: TestClient) -> None: + table = _get(client, "machine", q="0101-0007", size=5) + assert "hourly_krw" in table["locked"] and "hourly_krw" in table["formula"] + row = next(r for r in table["rows"] if r["@id"] == "0101-0007") + parts = row["loss_krw_per_hour"] + row["fuel_krw_per_hour"] + row["operator_krw_per_hour"] + assert row["hourly_krw"] == pytest.approx(parts) + assert ( + "hourly_krw" in row["@formula"] + and f"{int(row['hourly_krw']):,}" in row["@formula"]["hourly_krw"] + ) + res = _put(client, "machine", "0101-0007", {"hourly_krw": 1}) + assert res.status_code == 400 and table["locked"]["hourly_krw"] in res.text + + +def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None: + from B09_Estimation.B09_Estimation_MachineOperating import ( + hourly_cost_of, + load_operating_records, + ) + + codes = sorted(r.machine_code for r in load_operating_records().records)[::9] + rows = {r["@id"]: r for r in _get(client, "machine", size=500, page=1)["rows"]} + rows |= {r["@id"]: r for r in _get(client, "machine", size=500, page=2)["rows"]} + checked = 0 + from B09_Estimation.B09_Estimation_MachineCost import MachineCostError + + for code in codes: + try: + cost = hourly_cost_of(code) + except MachineCostError: # 손료계수 없는 기종 — B09 도 못 셈 + assert rows[code]["hourly_krw"] is None and rows[code]["hourly_note"], code + continue + if cost.gaps: + assert rows[code]["hourly_krw"] is None, code # 반만 선 값은 안 냄 + continue + assert rows[code]["hourly_krw"] == pytest.approx(float(cost.money.total)), code + checked += 1 + assert checked >= 5 + + +def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None: + before = {p: _sha(p) for p in SOURCES} + res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + assert res.status_code == 200, res.text + row = res.json() + assert row["daily_wage_krw"] == 230000 + assert row["@overrides"] == { + "daily_wage_krw": { + "original": 215907, + "value": 230000, + "state": overrides.EDITED, + "current": 215907, + } + } + assert ( + _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000 + ) + assert _put(client, "oil", "national_average/oil_diesel", {"value": 1900}).status_code == 200 + assert sorted(p.name for p in store.iterdir()) == [ + "labor.json", + "oil.json", + ] # kind 마다 한 파일 + saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) + entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"] + assert ( + entry["original"] == 215907 + and entry["source"] == "labor_const_2026-01-01.json" + and entry["by"] == 42 + ) + assert {p: _sha(p) for p in SOURCES} == before + + +def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClient) -> None: + base = _row(client, "machine", "0101-0007") + operator = base["operator_occupation_code"] + wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"] + _put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000}) + _put(client, "oil", "national_average/oil_diesel", {"value": 2000}) + _put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2}) + after = _row(client, "machine", "0101-0007") + assert after["operator_krw_per_hour"] > base["operator_krw_per_hour"] + assert after["fuel_krw_per_hour"] > base["fuel_krw_per_hour"] + assert after["loss_krw_per_hour"] == pytest.approx(base["loss_krw_per_hour"] * 2) + price = base["price_thousand_krw"] + assert after["@overrides"] == { + "price_thousand_krw": { + "original": price, + "value": price * 2, + "state": overrides.EDITED, + "current": price, + } + } # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜 + + +def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None: + _put(client, "material", "10023392", {"price_krw": 30}) + reverted = _put(client, "material", "10023392", {"price_krw": None}).json() + assert reverted["price_krw"] == 22 and "@overrides" not in reverted + assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200 + saved = json.loads((store / "material.json").read_text(encoding="utf-8")) + assert saved["overrides"] == {} + + +def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김( + client: TestClient, store: Path +) -> None: + store.mkdir() + (store / "labor.json").write_text( + json.dumps( + { + "schema_version": "1.0", + "kind": "labor", + "overrides": { + "labor_const/1001": { + "daily_wage_krw": { + "value": 199000, + "original": 200000, + "source": "labor_const_2025-07-01.json", + } + }, + "labor_const/9999": { + "daily_wage_krw": { + "value": 1, + "original": 1, + "source": "labor_const_2025-07-01.json", + } + }, + }, + } + ), + encoding="utf-8", + ) + # 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이 + row = _row(client, "labor", "labor_const/1001") + assert row["daily_wage_krw"] == 199000 + mark = row["@overrides"]["daily_wage_krw"] + assert mark == { + "original": 200000, + "value": 199000, + "state": overrides.SOURCE_CHANGED, + "current": 215907, + } + _put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000}) + listed = client.get("/api/master-data/overrides").json() + assert [ + (i["row_id"], i["state"]) for i in listed["items"] + ] == [ # 서버가 정렬 — 원본 바뀜이 맨 위 + ("labor_const/1001", overrides.SOURCE_CHANGED), + ("labor_const/9999", overrides.ORPHAN), + ("labor_const/1002", overrides.EDITED), + ] + first = listed["items"][0] + assert set(first) == { + "kind", + "row_id", + "row_label", + "column", + "column_label", + "original", + "current", + "value", + "state", + } + assert first["row_label"] == "작업반장" and ( + first["original"], + first["current"], + first["value"], + ) == (200000, 215907, 199000) + assert listed["items"][1]["current"] is None + saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"] + assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움 + # 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기 + kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json() + assert kept["@overrides"]["daily_wage_krw"] == { + "original": 215907, + "value": 199000, + "state": overrides.EDITED, + "current": 215907, + } + taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json() + assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken + + +def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜( + client: TestClient, store: Path +) -> None: + blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None) + row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json() + assert row["@overrides"]["daily_wage_krw"] == { + "original": None, + "value": 150000, + "state": overrides.EDITED, + "current": None, + } + saved = json.loads((store / "labor.json").read_text(encoding="utf-8")) + saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = ( + 140000 # 옛 판엔 값이 있었다고 치면 + ) + (store / "labor.json").write_text(json.dumps(saved), encoding="utf-8") + assert ( + _row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"] + == overrides.SOURCE_CHANGED + ) + + +def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None: + rate = _get(client, "rate", size=1) + assert "갱신" in rate["notice"] and "주인" in rate["notice"] + assert "notice" not in _get(client, "material", size=1) + + +def test_막는_자리(client: TestClient) -> None: + assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404 + assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404 + assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400 + locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸 + assert locked.status_code == 400 and "공표 원문" in locked.text + for bad in (True, -1, "220000", float("nan")): + body = json.dumps({"values": {"daily_wage_krw": bad}}).encode() + res = client.put( + "/api/master-data/base-prices/labor/labor_const/1001", + content=body, + headers={"content-type": "application/json"}, + ) + assert res.status_code in (400, 422), bad + fee = next( + r + for r in _get(client, "rate", size=500)["rows"] + if r["@id"].startswith("rate_performance_guarantee_fee/brackets/") + ) + assert ( + _put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400 + ) # 그 줄엔 요율 칸이 없음 + assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56") + + +def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None: + """브레인 ① — 노임 두 판(건설 01-01 · 제조 07-01)·유가 두 판(전국 08-14 · 지역 09-09)이 한 표에 섞임.""" + assert _row(client, "labor", "labor_const/1001")["effective_date"] == "2026-01-01" + assert _row(client, "labor", "labor_mfg/1")["effective_date"] == "2026-07-01" + assert _row(client, "oil", "national_average/oil_diesel")["effective_date"] == "2026-08-14" + assert _row(client, "oil", "regional/oil_diesel/01")["effective_date"] == "2026-09-09" + assert _row(client, "material", "10023392")["effective_date"] == "2026-08-14" + assert _row(client, "rate", "rate_sanjae")["effective_date"] == "2026-04-13" + m = _row(client, "machine", "0101-0007") + assert (m["effective_date"], m["operating_effective_date"]) == ("2026-01-01", "2026-01-01") + assert (m["fuel_price_date"], m["operator_wage_date"]) == ("2026-08-14", "2026-01-01") + no_record = next(r for r in _get(client, "machine", size=500)["rows"] if r["fuel_kind"] is None) + assert no_record["operating_effective_date"] is None and no_record["hourly_krw"] is None + + +def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None: + """브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음.""" + store.mkdir() + gone = { + f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}} + for i in range(3) + } + (store / "material.json").write_text( + json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}), + encoding="utf-8", + ) + _put(client, "material", "10023392", {"price_krw": 30}) + _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}) + listed = client.get( + "/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"} + ).json() + assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN} + assert listed["counts"] == { + "material": {overrides.ORPHAN: 3, overrides.EDITED: 1}, + "labor": {overrides.EDITED: 1}, + } + paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json() + assert paged["total"] == 5 and len(paged["items"]) == 2 + everything = client.get("/api/master-data/overrides").json()[ + "items" + ] # labor 가 kind 차례로 앞이어도 + assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2 + res = client.post( + "/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN} + ) + assert res.status_code == 200 and res.json()["removed"] == 3 + left = client.get("/api/master-data/overrides").json() + assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례 + ("labor", overrides.EDITED), + ("material", overrides.EDITED), + ] + assert ( + client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code + == 422 + ) # 거름 없이 통째로 못 비움 + + +def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None: + rates = _get(client, "rate", size=500)["rows"] + fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")) + store.mkdir() + entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}} + (store / "rate.json").write_text( + json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8" + ) + items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"] + assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)] + assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음