From d8f4ac024005e8e494e4d4fbef141650b1db28cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 21:47:43 +0900 Subject: [PATCH 1/2] =?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 81a1c013d2f86fd6880f88bf624ecdc090129b9b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 21:55:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(z01):=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EC=9D=84=20B03~B09=20=EB=8F=84=ED=82=B9=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=96=91=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C(=EC=83=81=EC=9E=90=20=EB=91=98=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=EB=8B=A8=EA=B0=80=20=C2=B7=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=ED=8A=B8=EB=A6=AC)=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=8B=A8=EA=B0=80=20=EB=8B=A4=EC=84=AF(=EB=85=B8?= =?UTF-8?q?=EC=9E=84=C2=B7=EA=B8=B0=EA=B3=84=C2=B7=EC=9E=90=EC=9E=AC=C2=B7?= =?UTF-8?q?=EC=9C=A0=EA=B0=80=C2=B7=EC=9A=94=EC=9C=A8)=20=ED=91=9C=20+=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B3=A0=EC=B9=98=EA=B8=B0(editable=20=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EB=AA=BB=20=EA=B3=A0=EC=B9=98=EB=8A=94=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=80=20=EA=B9=8C=EB=8B=AD=C2=B7=EC=82=B0=EC=B6=9C=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0)=20=C2=B7=20=EB=8D=AE=EA=B0=9C=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=80=20=EC=84=9C=EB=B2=84=20state=20=EB=A7=8C(=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EB=B0=94=EB=80=9C=C2=B7=EC=A3=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C=EC=9D=80=20=EC=A4=84=EC=A7=B8=20=EB=98=90?= =?UTF-8?q?=EB=A0=B7=EC=9D=B4=20=C2=B7=20=EB=8B=A8=EC=B6=94=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EC=97=90=20=EA=B0=92)=20=C2=B7=20=EC=9A=94=EC=9C=A8?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20=EA=B2=83?= =?UTF-8?q?=20=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0(=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC)=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=EC=97=90=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8B=A8=EA=B3=84=C2=B7=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EB=81=84=EA=B8=B0=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= 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_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 83 +++-- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 58 ++++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 271 ++-------------- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 339 +++++++++++++++++++++ Z01_MasterData/Z01_MasterData_UI_Side.ts | 177 +++++++++++ Z01_MasterData/Z01_MasterData_UI_Style.css | 174 ++++++++--- resources/tester/test_z01_master_cells.py | 66 +++- ui_template/ui_template_locale_b3.ts | 50 ++- ui_template/ui_template_overlay.ts | 36 +-- 9 files changed, 929 insertions(+), 325 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_UI_Rows.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_Side.ts diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index d2113c51..78735084 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -6,7 +6,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn } from "./Z01_MasterData_UI_Cells"; +import type { MasterColumn, OverrideState, TableMeta } from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -32,36 +32,83 @@ export interface MasterGroup { files: MasterFile[]; } -export interface MasterRows { +/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */ +export interface MasterRows extends TableMeta { columns: MasterColumn[]; rows: Record[]; total: number; } -async function request(path: string): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); +export interface RowsQuery { + page: number; + size: number; + q: string; +} + +/** 기초단가 다섯 — 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). */ +export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const; +export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number]; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); const data = (await response.json()) as { detail?: string } & T; if (!response.ok) throw new Error(data.detail ?? "Request failed"); return data; } +const pageParams = (query: RowsQuery): Record => ({ + page: String(query.page), + size: String(query.size), + q: query.q, +}); + +export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise { + const params = new URLSearchParams(pageParams(query)); + return request(`/master-data/base-prices/${kind}?${params}`); +} + +export interface OverrideItem { + kind: BasePriceKind; + row_id: string; + row_label: string; + column: string; + column_label: string; + original: unknown; + current?: unknown; + value: unknown; + state: OverrideState; +} + +/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬해 줌. */ +export async function fetchOverrides(): Promise { + return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items; +} + +/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ +export function saveBasePrice( + kind: BasePriceKind, + rowId: string, + values: Record, +): Promise> { + return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, { + method: "PUT", + body: JSON.stringify({ values }), + }); +} + export async function fetchMasterTree(): Promise { return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups; } -export function fetchMasterRows(query: { - file: string; - table: string; - page: number; - size: number; - q: string; -}): Promise { - const params = new URLSearchParams({ - file: query.file, - table: query.table, - page: String(query.page), - size: String(query.size), - q: query.q, - }); +export function fetchMasterRows( + file: string, + table: string, + query: RowsQuery, +): Promise { + const params = new URLSearchParams({ file, table, ...pageParams(query) }); return request(`/master-data/rows?${params}`); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 7b170044..09a9b098 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -40,6 +40,64 @@ export function rowCells(row: Record, columns: MasterColumn[]): ); } +/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ +export interface TableMeta { + editable?: string[]; + /** 열key → 왜 못 고치나 */ + locked?: Record; + /** 열key → 식 자체 */ + formula?: Record; +} + +/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(2026-09-15 브레인). */ +export type OverrideState = "edited" | "source_changed" | "orphan"; + +export interface CellInfo { + editable: boolean; + reason: string | null; + overridden: boolean; + /** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */ + original?: unknown; + /** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */ + state: OverrideState | "missing" | null; + /** source_changed 칸만 — 지금 원본 값 */ + current?: unknown; + rule: string | null; + /** 이 줄 값을 넣은 식 — 줄마다 다를 때만 옴 */ + filled: string | null; +} + +/** 칸 하나의 고침 상태 — 줄의 `@overrides`·`@formula` 는 해당할 때만 실려 옴. */ +export function cellInfo(meta: TableMeta, row: Record, key: string): CellInfo { + const overrides = row["@overrides"] as + Record | undefined; + const filled = row["@formula"] as Record | undefined; + const override = overrides?.[key]; + // 판정은 서버 state 만 — 화면이 값을 견줘 따로 정하면 판정이 두 벌이 됨(2026-09-15 브레인). + const state = override ? (override.state ?? "missing") : null; + return { + editable: meta.editable?.includes(key) ?? false, + reason: meta.locked?.[key] ?? null, + overridden: override !== undefined, + ...(override ? { original: override.original } : {}), + state, + ...(state === "source_changed" ? { current: override?.current } : {}), + rule: meta.formula?.[key] ?? null, + filled: filled?.[key] ?? null, + }; +} + +const NUMBER_TEXT = /^-?[\d,]+(\.\d+)?$/; + +/** 고친 글자를 보낼 값으로 — 수 칸은 쉼표를 떼 수로, 못 읽으면 undefined(저장 안 함). */ +export function parseCellInput(text: string, original: unknown): unknown { + const trimmed = text.trim(); + const numeric = typeof original === "number" || (original == null && NUMBER_TEXT.test(trimmed)); + if (!numeric) return text; + if (!NUMBER_TEXT.test(trimmed)) return undefined; + return Number(trimmed.replaceAll(",", "")); +} + export function pageCount(total: number, size: number): number { return Math.max(1, Math.ceil(total / size)); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index df8c6f5b..d077e77e 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -1,46 +1,23 @@ /* ============================================================================= * Z01_MasterData_UI_Page.ts - * 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색) + * 마스터 데이터 — B03~B09 와 같은 틀: 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 * - * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터 - * (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립). + * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 워크플로 밖 화면이라 진행단계 + * 패널·프로젝트 이름은 안 세움 (2026-09-15 브레인 Z01 — 좌측 패널 양식 갈음 · 기초단가 칸). * ========================================================================== */ +import "@ui/ui_template_workflow_layout.css"; import { ROUTES } from "@config/config_frontend"; -import { - createButton, - createInputField, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; +import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { - fetchMasterRows, - fetchMasterTree, - type MasterFile, - type MasterGroup, - type MasterRows, - type MasterTable, - VALUES_TABLE_ID, -} from "./Z01_MasterData_Api_Fetch"; -import { - clampPage, - columnTitle, - latestOnly, - pageCount, - rowCells, - shownColumns, -} from "./Z01_MasterData_UI_Cells"; +import { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import { buildRowsView } from "./Z01_MasterData_UI_Rows"; +import { buildSidePanel } from "./Z01_MasterData_UI_Side"; import "./Z01_MasterData_UI_Style.css"; -const PAGE_SIZE = 50; -const SEARCH_DELAY_MS = 300; - export async function renderZ01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); if (user?.role !== "SYSTEM_ADMIN") { @@ -63,223 +40,17 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { function buildPage(groups: MasterGroup[]): HTMLElement { const view = buildRowsView(); - const tree = buildTree(groups, view.pick); - const content = el("div", { className: "z01-master__content", children: [tree, view.root] }); - return createGeneralLayout({ - pageClass: "z01-master", + const layout = el("div", { className: "ui-workflow-layout z01-master" }); + const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); + const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - subtitle: L("Z01_MasterData_Subtitle"), - content, - }).root; -} - -function buildTree( - groups: MasterGroup[], - pick: (file: MasterFile, table: MasterTable) => void, -): HTMLElement { - const nav = el("nav", { className: "z01-master__tree" }); - for (const group of groups) { - const groupBox = folder("z01-master__group", group.label, String(group.files.length)); - groupBox.open = true; - // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). - if (group.key === "byproduct") { - groupBox.append( - el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), - ); - } - for (const file of group.files) { - const fileBox = folder( - "z01-master__file", - file.label, - file.label === file.key ? "" : file.key, - ); - // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). - const ordered = [...file.tables].sort( - (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), - ); - for (const table of ordered) { - const button = el("button", { - className: "z01-master__table", - attrs: { type: "button", title: table.key }, - children: [ - el("span", { text: table.label }), - el("span", { - className: "z01-master__key", - text: table.row_count.toLocaleString("ko-KR"), - }), - ], - }); - button.addEventListener("click", () => { - nav.querySelector(".z01-master__table.is-active")?.classList.remove("is-active"); - button.classList.add("is-active"); - pick(file, table); - }); - fileBox.append(button); - } - groupBox.append(fileBox); - } - nav.append(groupBox); - } - return nav; -} - -/** 접는 칸 — 브라우저 기본
로 둠. */ -function folder(className: string, label: string, aside: string): HTMLDetailsElement { - const summary = el("summary", { - children: [ - el("span", { text: label }), - el("span", { className: "z01-master__key", text: aside }), - ], - }); - return el("details", { className, children: [summary] }); -} - -interface RowsView { - root: HTMLElement; - pick: (file: MasterFile, table: MasterTable) => void; -} - -function buildRowsView(): RowsView { - const state = { - file: null as MasterFile | null, - table: null as MasterTable | null, - page: 1, - q: "", - }; - let showHidden = false; - let last: MasterRows | null = null; - let searchTimer = 0; - const fetchLatest = latestOnly(fetchMasterRows); - - const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); - const titleKey = el("span", { className: "z01-master__key" }); - const search = createInputField({ - type: "search", - placeholder: L("Z01_MasterData_Search"), - onInput: (value) => { - window.clearTimeout(searchTimer); - searchTimer = window.setTimeout(() => { - state.q = value.trim(); - state.page = 1; - void load(); - }, SEARCH_DELAY_MS); - }, - }); - const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); - hiddenToggle.addEventListener("change", () => { - showHidden = hiddenToggle.checked; - draw(); - }); - const hiddenLabel = el("label", { - className: "z01-master__check", - children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], - }); - const grid = el("div", { className: "z01-master__grid-wrap" }); - const pageInput = el("input", { - className: "ui-input z01-master__page-input", - attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") }, - }); - pageInput.addEventListener("change", () => goTo(Number(pageInput.value))); - const pageInfo = el("span", { className: "z01-master__page-info" }); - // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). - const pagerButton = (key: Parameters[0], onClick: () => void) => - createButton({ label: L(key), variant: "ghost", onClick }); - const first = pagerButton("Z01_MasterData_First", () => goTo(1)); - const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); - const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); - const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); - const toolbar = el("div", { - className: "z01-master__toolbar", - children: [search.root, hiddenLabel], - }); - const pager = el("div", { - className: "z01-master__pager", - children: [first, prev, pageInput, pageInfo, next, end], - }); - const root = el("section", { - className: "z01-master__panel", - children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], - }); - toolbar.hidden = true; - pager.hidden = true; - - function goTo(page: number): void { - const pages = pageCount(last?.total ?? 0, PAGE_SIZE); - state.page = page === Infinity ? pages : clampPage(page, pages); - void load(); - } - - async function load(): Promise { - if (!state.file || !state.table) return; - grid.classList.add("is-loading"); - let data: MasterRows | undefined; - try { - data = await fetchLatest({ - file: state.file.id, - table: state.table.id, - page: state.page, - size: PAGE_SIZE, - q: state.q, - }); - } catch (error) { - grid.classList.remove("is-loading"); - showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); - return; - } - // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. - if (!data) return; - grid.classList.remove("is-loading"); - last = data; - draw(); - } - - function draw(): void { - if (!last) return; - const columns = shownColumns(last.columns, showHidden); - const headRow = el("tr"); - for (const column of columns) { - const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); - if (column.label !== column.key) { - th.append(el("span", { className: "z01-master__key", text: column.key })); - } - headRow.append(th); - } - const body = el("tbody"); - for (const row of last.rows) { - const tr = el("tr"); - for (const text of rowCells(row, columns)) { - tr.append(el("td", { text, attrs: { title: text } })); - } - body.append(tr); - } - grid.replaceChildren( - last.rows.length - ? el("table", { - className: "z01-master__grid", - children: [el("thead", { children: [headRow] }), body], - }) - : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), - ); - const pages = pageCount(last.total, PAGE_SIZE); - pageInput.value = String(state.page); - pageInput.max = String(pages); - pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; - first.disabled = prev.disabled = state.page <= 1; - next.disabled = end.disabled = state.page >= pages; - } - - function pick(file: MasterFile, table: MasterTable): void { - state.file = file; - state.table = table; - state.page = 1; - title.textContent = `${file.label} › ${table.label}`; - titleKey.textContent = `${file.key} / ${table.key}`; - toolbar.hidden = false; - pager.hidden = false; - last = null; - grid.replaceChildren(); - void load(); - } - - return { root, pick }; + optionsContent: buildSidePanel(groups, view), + showProjectName: false, + onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), + }); + layout.append( + el("div", { className: "ui-workflow-layout__body", children: [main] }), + overlays.root, + ); + return layout; } diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts new file mode 100644 index 00000000..75e6cca4 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * Z01_MasterData_UI_Rows.ts + * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침 + * + * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침. + * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬 + * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; +import { + cellInfo, + cellText, + clampPage, + columnTitle, + latestOnly, + pageCount, + parseCellInput, + rowCells, + shownColumns, + type CellInfo, + type MasterColumn, +} from "./Z01_MasterData_UI_Cells"; + +const PAGE_SIZE = 50; +const SEARCH_DELAY_MS = 300; + +type Row = Record; + +export interface RowsSource { + title: string; + key: string; + /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ + notice?: string; + load: (query: RowsQuery) => Promise; + /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ + save?: (rowId: string, values: Row) => Promise; +} + +export interface RowsView { + root: HTMLElement; + show: (source: RowsSource) => void; +} + +/** 사전 문구의 {value} 자리에 값을 넣음. */ +function fill(key: Parameters[0], value: string): string { + return L(key).replace("{value}", value); +} + +/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ +function stateText(info: CellInfo, column: MasterColumn): string { + const original = cellText(info.original, column.unit); + switch (info.state) { + case "source_changed": + return `${fill("Z01_MasterData_State_SourceChanged", cellText(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; + case "orphan": + return L("Z01_MasterData_State_Orphan"); + case "missing": + return L("Z01_MasterData_State_Missing"); + default: + return fill("Z01_MasterData_State_Edited", original); + } +} + +export function buildRowsView(): RowsView { + let source: RowsSource | null = null; + const state = { page: 1, q: "" }; + let showHidden = false; + let last: MasterRows | null = null; + let selected: { index: number; key: string } | null = null; + let searchTimer = 0; + const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query)); + + const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); + const titleKey = el("span", { className: "z01-master__key" }); + const search = createInputField({ + type: "search", + placeholder: L("Z01_MasterData_Search"), + onInput: (value) => { + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + state.q = value.trim(); + state.page = 1; + void load(); + }, SEARCH_DELAY_MS); + }, + }); + const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); + hiddenToggle.addEventListener("change", () => { + showHidden = hiddenToggle.checked; + draw(); + }); + const hiddenLabel = el("label", { + className: "z01-master__check", + children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], + }); + const notice = el("p", { className: "z01-master__notice" }); + const detail = el("div", { className: "z01-master__detail" }); + const grid = el("div", { className: "z01-master__grid-wrap" }); + const pageInput = el("input", { + className: "ui-input z01-master__page-input", + attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") }, + }); + pageInput.addEventListener("change", () => goTo(Number(pageInput.value))); + const pageInfo = el("span", { className: "z01-master__page-info" }); + // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). + const pagerButton = (key: Parameters[0], onClick: () => void) => + createButton({ label: L(key), variant: "ghost", onClick }); + const first = pagerButton("Z01_MasterData_First", () => goTo(1)); + const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); + const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); + const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); + const toolbar = el("div", { + className: "z01-master__toolbar", + children: [search.root, hiddenLabel], + }); + const pager = el("div", { + className: "z01-master__pager", + children: [first, prev, pageInput, pageInfo, next, end], + }); + const root = el("section", { + className: "z01-master__panel", + children: [el("div", { children: [title, titleKey] }), notice, toolbar, detail, grid, pager], + }); + toolbar.hidden = true; + pager.hidden = true; + detail.hidden = true; + notice.hidden = true; + + function goTo(page: number): void { + const pages = pageCount(last?.total ?? 0, PAGE_SIZE); + state.page = page === Infinity ? pages : clampPage(page, pages); + void load(); + } + + async function load(): Promise { + if (!source) return; + grid.classList.add("is-loading"); + let data: MasterRows | undefined; + try { + data = await fetchLatest(source, { page: state.page, size: PAGE_SIZE, q: state.q }); + } catch (error) { + grid.classList.remove("is-loading"); + showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); + return; + } + // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. + if (!data) return; + grid.classList.remove("is-loading"); + last = data; + selected = null; + detail.hidden = true; + draw(); + } + + function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { + const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); + const marks: [string, string][] = []; + if (source?.save && meta.editable?.includes(column.key)) { + marks.push(["✎", L("Z01_MasterData_Edit_Can")]); + } + const reason = meta.locked?.[column.key]; + if (reason) marks.push(["🔒", reason]); + const rule = meta.formula?.[column.key]; + if (rule) marks.push(["ƒ", rule]); + for (const [mark, tip] of marks) { + th.append(el("span", { className: "z01-master__mark", text: mark, attrs: { title: tip } })); + } + if (column.label !== column.key) { + th.append(el("span", { className: "z01-master__key", text: column.key })); + } + return th; + } + + function draw(): void { + if (!last) return; + const meta = last; + const columns = shownColumns(meta.columns, showHidden); + const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); + const body = el("tbody"); + meta.rows.forEach((row, index) => { + const texts = rowCells(row, columns); + const tr = el("tr"); + columns.forEach((column, i) => { + const info = cellInfo(meta, row, column.key); + const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); + if (info.state) { + // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). + td.classList.add("is-overridden", `is-${info.state.replace("_", "-")}`); + if (info.state !== "edited") { + td.textContent = `⚠ ${texts[i]}`; + tr.classList.add("has-warning"); + } + td.title = stateText(info, column); + } + const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null; + if (canEdit) td.classList.add("is-editable"); + if (selected?.index === index && selected.key === column.key) + td.classList.add("is-selected"); + td.addEventListener("click", () => { + if (td.querySelector("input")) return; + selectCell(index, column); + td.classList.add("is-selected"); + if (canEdit) startEdit(td, index, column); + }); + tr.append(td); + }); + body.append(tr); + }); + grid.replaceChildren( + meta.rows.length + ? el("table", { + className: "z01-master__grid", + children: [el("thead", { children: [headRow] }), body], + }) + : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), + ); + const pages = pageCount(meta.total, PAGE_SIZE); + pageInput.value = String(state.page); + pageInput.max = String(pages); + pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; + first.disabled = prev.disabled = state.page <= 1; + next.disabled = end.disabled = state.page >= pages; + } + + /** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */ + function selectCell(index: number, column: MasterColumn): void { + if (!last) return; + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); + selected = { index, key: column.key }; + const row = last.rows[index]; + const info = cellInfo(last, row, column.key); + const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; + const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); + if (source?.save) { + lines.push( + line( + info.editable + ? L("Z01_MasterData_Edit_Can") + : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, + ), + ); + } + if (info.state) { + const stateLine = line(stateText(info, column)); + if (info.state !== "edited") stateLine.classList.add("is-warning"); + lines.push(stateLine); + // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). + const target = + info.state === "source_changed" + ? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit)) + : info.state === "orphan" + ? null + : fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit)); + if (target && source?.save) { + lines.push( + createButton({ + label: target, + variant: "ghost", + onClick: () => void commit(index, column, null), + }), + ); + } + } + if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`)); + if (info.filled) lines.push(line(info.filled)); + detail.replaceChildren(...lines); + detail.hidden = false; + } + + function startEdit(td: HTMLElement, index: number, column: MasterColumn): void { + if (!last) return; + const current = last.rows[index][column.key]; + const input = el("input", { className: "ui-input z01-master__cell-input" }); + input.value = current == null ? "" : String(current); + td.replaceChildren(input); + input.focus(); + input.select(); + let finished = false; + const finish = (save: boolean): void => { + if (finished) return; + finished = true; + const value = save ? parseCellInput(input.value, current) : current; + if (value === undefined) showToast(L("Z01_MasterData_Edit_Invalid"), "error"); + if (value === undefined || value === current) { + draw(); + return; + } + void commit(index, column, value); + }; + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") finish(true); + if (event.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); + } + + /** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */ + async function commit(index: number, column: MasterColumn, value: unknown): Promise { + const from = source; + const table = last; + const rowId = table?.rows[index]["@id"]; + if (!from?.save || !table || rowId == null) return; + try { + const updated = await from.save(String(rowId), { [column.key]: value }); + if (from !== source || table !== last) return; + table.rows[index] = updated; + draw(); + selectCell(index, column); + showToast( + L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"), + "success", + ); + } catch (error) { + showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); + draw(); + } + } + + function show(picked: RowsSource): void { + source = picked; + state.page = 1; + title.textContent = picked.title; + titleKey.textContent = picked.key; + notice.textContent = picked.notice ?? ""; + notice.hidden = !picked.notice; + toolbar.hidden = false; + pager.hidden = false; + detail.hidden = true; + last = null; + selected = null; + grid.replaceChildren(); + void load(); + } + + return { root, show }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts new file mode 100644 index 00000000..b47e4fc4 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -0,0 +1,177 @@ +/* ============================================================================= + * Z01_MasterData_UI_Side.ts + * 좌측 패널 — B03~B09 와 같은 도킹 사이드바 안에 접히는 상자 둘(기초단가 · 마스터 트리) + * + * 상자 겉모습·접기는 공용(`ui-collapsible ui-sidebar-section`) · 누른 단추 하나만 강조 + * (두 상자를 가로질러 한 개) (2026-09-15 브레인 — 좌측 패널 양식 갈음 · 기초단가 칸). + * ========================================================================== */ + +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + BASE_PRICE_KINDS, + fetchBasePrices, + fetchMasterRows, + fetchOverrides, + type MasterGroup, + type MasterRows, + type RowsQuery, + saveBasePrice, + VALUES_TABLE_ID, +} from "./Z01_MasterData_Api_Fetch"; +import type { RowsView } from "./Z01_MasterData_UI_Rows"; + +export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { + const panel = el("div", { className: "z01-master__side" }); + const activate = (button: HTMLElement): void => { + panel.querySelector(".is-active")?.classList.remove("is-active"); + button.classList.add("is-active"); + }; + + const baseTitle = L("Z01_MasterData_BasePrices"); + const baseGrid = el("div", { className: "z01-master__base-grid" }); + for (const kind of BASE_PRICE_KINDS) { + const label = L(`Z01_MasterData_Base_${kind}` as const); + const button = el("button", { + className: "z01-master__base", + attrs: { type: "button" }, + text: label, + }); + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${baseTitle} › ${label}`, + key: `base-prices/${kind}`, + // 요율은 구간 줄에 코드가 없어 구간 이름으로 이음 — 법이 바뀌면 고친 값이 주인을 잃기 쉬움. + notice: kind === "rate" ? L("Z01_MasterData_Rate_Notice") : undefined, + load: (query) => fetchBasePrices(kind, query), + save: (rowId, values) => saveBasePrice(kind, rowId, values), + }); + }); + baseGrid.append(button); + } + const overridesButton = el("button", { + className: "z01-master__base z01-master__base--wide", + attrs: { type: "button" }, + text: L("Z01_MasterData_Overrides"), + }); + overridesButton.addEventListener("click", () => { + activate(overridesButton); + view.show({ + title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, + key: "overrides", + load: loadOverrides, + }); + }); + baseGrid.append(overridesButton); + + const tree = el("nav", { className: "z01-master__tree" }); + for (const group of groups) { + const groupBox = folder("z01-master__group", group.label, String(group.files.length)); + groupBox.open = true; + // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). + if (group.key === "byproduct") { + groupBox.append( + el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), + ); + } + for (const file of group.files) { + const fileBox = folder( + "z01-master__file", + file.label, + file.label === file.key ? "" : file.key, + ); + // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). + const ordered = [...file.tables].sort( + (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), + ); + for (const table of ordered) { + const button = el("button", { + className: "z01-master__table", + attrs: { type: "button", title: table.key }, + children: [ + el("span", { text: table.label }), + el("span", { + className: "z01-master__key", + text: table.row_count.toLocaleString("ko-KR"), + }), + ], + }); + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${file.label} › ${table.label}`, + key: `${file.key} / ${table.key}`, + load: (query) => fetchMasterRows(file.id, table.id, query), + }); + }); + fileBox.append(button); + } + groupBox.append(fileBox); + } + tree.append(groupBox); + } + + panel.append(section(baseTitle, baseGrid), section(L("Z01_MasterData_Tree"), tree)); + attachCollapsible(panel); + return panel; +} + +const STATE_LABEL = { + source_changed: "Z01_MasterData_List_SourceChanged", + orphan: "Z01_MasterData_List_Orphan", + edited: "Z01_MasterData_List_Edited", +} as const; + +/** + * 고친 것 목록을 표 한 벌로 — 서버 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)을 그대로 두고 쪽만 자름. + * 쪽을 서버에서 나눠 받으면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). + */ +async function loadOverrides(query: RowsQuery): Promise { + const needle = query.q.toLowerCase(); + const items = (await fetchOverrides()).filter( + (item) => !needle || JSON.stringify(item).toLowerCase().includes(needle), + ); + const start = (query.page - 1) * query.size; + const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); + return { + columns: [ + column("state", "Z01_MasterData_List_State"), + column("kind", "Z01_MasterData_BasePrices"), + column("row_label", "Z01_MasterData_List_Row"), + column("column_label", "Z01_MasterData_List_Column"), + column("value", "Z01_MasterData_List_Value"), + column("original", "Z01_MasterData_List_Original"), + column("current", "Z01_MasterData_List_Current"), + ], + rows: items.slice(start, start + query.size).map((item) => ({ + ...item, + // state 가 안 오면 때우지 않고 드러냄(서버 버그). + state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), + kind: L(`Z01_MasterData_Base_${item.kind}` as const), + })), + total: items.length, + }; +} + +function section(title: string, body: HTMLElement): HTMLElement { + return el("section", { + className: "z01-master__section ui-collapsible ui-sidebar-section", + children: [ + el("p", { className: "z01-master__section-title ui-collapsible__title", text: title }), + body, + ], + }); +} + +/** 트리 접는 칸 — 브라우저 기본
로 둠. */ +function folder(className: string, label: string, aside: string): HTMLDetailsElement { + const summary = el("summary", { + children: [ + el("span", { text: label }), + el("span", { className: "z01-master__key", text: aside }), + ], + }); + return el("details", { className, children: [summary] }); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index a0c0a9aa..824dbfff 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -1,24 +1,66 @@ -/* Z01 마스터 데이터 — 좌 트리 / 우 표 */ +/* Z01 마스터 데이터 — 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 */ .z01-master [hidden] { display: none !important; } -.z01-master__content { +/* ── 좌측 패널: B08 과 같은 상자 틀(겉선·접기는 공용 ui-sidebar-section · ui-collapsible) ── */ +.z01-master__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); +} + +.z01-master__section { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0; + padding: 10px 12px; + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); +} + +.z01-master__section-title { + margin: 0 0 2px; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 600; +} + +.z01-master__base-grid { display: grid; - grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); - gap: var(--spacing-24); - align-items: start; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-4); +} + +.z01-master__base { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + cursor: pointer; +} + +.z01-master__base--wide { + grid-column: 1 / -1; +} + +.z01-master__base:hover, +.z01-master__table:hover { + background: var(--color-paper); +} + +.z01-master__base.is-active, +.z01-master__table.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); } .z01-master__tree { - position: sticky; - top: var(--spacing-16); - max-height: calc(100vh - 160px); - overflow-y: auto; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); font-size: var(--text-body-sm); } @@ -26,7 +68,7 @@ display: flex; justify-content: space-between; gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); + padding: var(--spacing-4) var(--spacing-4); cursor: pointer; color: var(--color-text-body); } @@ -68,20 +110,13 @@ cursor: pointer; } -.z01-master__table:hover { - background: var(--color-surface-raised); -} - -.z01-master__table.is-active { - background: var(--color-mist-violet); - color: var(--color-accent); -} - +/* ── 우측 표 ── */ .z01-master__panel { display: flex; flex-direction: column; - gap: var(--spacing-16); + gap: var(--spacing-12); min-width: 0; + padding: var(--spacing-16) var(--spacing-24); } .z01-master__title { @@ -90,6 +125,16 @@ font-size: var(--text-body); } +/* 요율 알림 — 막지 않고 알리기만 */ +.z01-master__notice { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-paper); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + .z01-master__toolbar { display: flex; flex-wrap: wrap; @@ -105,8 +150,30 @@ font-size: var(--text-body-sm); } +/* 칸 설명 줄 — 고칠 수 있나 · 까닭 · 원래 값 · 산출근거 */ +.z01-master__detail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + font-size: var(--text-body-sm); +} + +.z01-master__detail-line { + color: var(--color-text-body); +} + +.z01-master__detail-line.is-warning { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + .z01-master__grid-wrap { - max-height: calc(100vh - 300px); + max-height: calc(100vh - 320px); overflow: auto; border: 1px solid var(--color-border); border-radius: var(--radius-cards); @@ -146,6 +213,52 @@ margin-left: 0; } +.z01-master__mark { + margin-left: var(--spacing-4); + cursor: help; +} + +.z01-master__grid td.is-editable { + cursor: text; + text-decoration: underline dotted var(--color-text-muted); +} + +.z01-master__grid td.is-selected { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +/* 고쳐짐 — 덮개 값을 쓰는 칸 */ +.z01-master__grid td.is-overridden { + background: var(--color-mist-violet); + color: var(--color-accent); + font-weight: var(--font-weight-medium); +} + +/* 원본 바뀜 · 주인 없음 · 판정 없음 — 줄째 또렷이(흐리면 옛 값이 조용히 쓰임) */ +.z01-master__grid tr.has-warning td { + background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas)); +} + +.z01-master__grid tr.has-warning td:first-child { + box-shadow: inset 4px 0 0 var(--color-danger); +} + +/* tr 을 넣어 줄 배경(tr.has-warning td)보다 무겁게 — 칸이 줄보다 더 진해야 함 */ +.z01-master__grid tr td.is-source-changed, +.z01-master__grid tr td.is-orphan, +.z01-master__grid tr td.is-missing { + background: color-mix(in srgb, var(--color-danger) 22%, var(--color-canvas)); + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +.z01-master__cell-input { + width: 100%; + min-width: 6em; + padding: 2px var(--spacing-4); +} + .z01-master__pager { display: flex; align-items: center; @@ -159,14 +272,3 @@ width: 6em; text-align: right; } - -@media (max-width: 860px) { - .z01-master__content { - display: block; - } - - .z01-master__tree { - position: static; - margin-bottom: var(--spacing-24); - } -} diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index d96a9299..d031a4de 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -24,9 +24,34 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; import { - cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns, + cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, + shownColumns, } from "./Z01_MasterData_UI_Cells.js"; +// 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인) +const meta = { + editable: ["daily_wage_krw"], + locked: { hourly_krw: "계산으로 나옴" }, + formula: { hourly_krw: "기계경비 = 손료 + 연료 + 노무" }, +}; +const machineRow = { + "@id": "M-001", + daily_wage_krw: 300000, + hourly_krw: 12340, + "@overrides": { daily_wage_krw: { original: 250000, value: 300000 } }, + "@formula": { hourly_krw: "12,340 = 8,200 + 2,140 + 2,000" }, +}; +// 판정은 서버 state 만 — edited · source_changed · orphan (화면이 original≠current 로 따로 안 셈) +const withState = (state, current) => ({ + "@id": "M-002", + daily_wage_krw: 300000, + "@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } }, +}); +// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited +const changedRow = withState("source_changed", 250000); +const sameRow = withState("edited", 270000); +const orphanRow = withState("orphan", undefined); + const wait = (ms, value, fail) => new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms)); const fetchLatest = latestOnly(wait); @@ -62,6 +87,21 @@ writeFileSync(process.argv[2], JSON.stringify({ race: race.map((v) => v ?? null), raceError: raceError.map((v) => v ?? null), latestError, + info: [ + cellInfo(meta, machineRow, "daily_wage_krw"), + cellInfo(meta, machineRow, "hourly_krw"), + cellInfo({}, { name: "x" }, "name"), + cellInfo(meta, changedRow, "daily_wage_krw"), + cellInfo(meta, sameRow, "daily_wage_krw"), + cellInfo(meta, orphanRow, "daily_wage_krw"), + ], + parsed: [ + parseCellInput("12,340", 100), + parseCellInput("abc", 100), + parseCellInput("", 100), + parseCellInput("보통인부2", "보통인부"), + parseCellInput("3.5", null), + ].map((v) => (v === undefined ? "invalid" : v)), })); """ @@ -109,3 +149,27 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["race"] == [None, "new"] assert got["raceError"] == [None, "new"] assert got["latestError"] == "thrown boom" + # 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식 + wage, hourly, plain, changed, same, orphan = got["info"] + # state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그) + assert wage == { + "editable": True, + "reason": None, + "overridden": True, + "original": 250000, + "state": "missing", + "rule": None, + "filled": None, + } + # 원본이 바뀐 고친 칸 — 고친 값은 그대로 쓰되 또렷이 알림 · 지금 원본 값을 곁들임(브레인) + assert changed["state"] == "source_changed" and changed["current"] == 250000 + assert same["state"] == "edited" and "current" not in same + assert orphan["state"] == "orphan" + assert hourly["state"] is None and plain["state"] is None + assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴" + assert hourly["overridden"] is False and "original" not in hourly + assert hourly["rule"] == "기계경비 = 손료 + 연료 + 노무" + assert hourly["filled"] == "12,340 = 8,200 + 2,140 + 2,000" + assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None + # 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수 + assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 8194f52d..a170ecde 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -140,10 +140,54 @@ export const ui_locales_b3 = { B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"], B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], Z01_MasterData_Title: ["마스터 데이터", "Master Data"], - Z01_MasterData_Subtitle: [ - "로직·기초값을 표로 봄 — 읽기 전용", - "Browse logic and base values — read only", + Z01_MasterData_Tree: ["마스터 트리", "Master tree"], + Z01_MasterData_BasePrices: ["기초단가", "Base prices"], + Z01_MasterData_Base_labor: ["노임단가", "Labor"], + Z01_MasterData_Base_machine: ["기계단가", "Machine"], + Z01_MasterData_Base_material: ["자재단가", "Material"], + Z01_MasterData_Base_oil: ["유가", "Fuel"], + Z01_MasterData_Base_rate: ["요율", "Rates"], + Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], + Z01_MasterData_Rate_Notice: [ + "법이 정한 값 — 고치면 갱신 때 주인을 잃기 쉬움", + "Values set by law — edits may lose their row when the law is updated", ], + Z01_MasterData_Edit_Can: [ + "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", + "Editable — click, type, Enter to save (Esc cancels)", + ], + Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"], + Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"], + Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], + Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], + Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], + Z01_MasterData_Edit_Invalid: ["수로 못 읽음 — 저장 안 함", "Not a number — not saved"], + Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], + Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], + Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], + Z01_MasterData_Formula: ["산출근거", "Basis"], + Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"], + Z01_MasterData_State_SourceChanged: [ + "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", + "⚠ Source changed after edit — edited value still used · new source {value}", + ], + Z01_MasterData_State_Orphan: [ + "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", + "⚠ Orphan — the row disappeared on update", + ], + Z01_MasterData_State_Missing: [ + "⚠ 서버가 판정(state)을 안 줌 — 버그", + "⚠ Server sent no state — bug", + ], + Z01_MasterData_List_State: ["상태", "State"], + Z01_MasterData_List_Row: ["줄", "Row"], + Z01_MasterData_List_Column: ["칸", "Column"], + Z01_MasterData_List_Value: ["고친 값", "Edited value"], + Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"], + Z01_MasterData_List_Current: ["지금 원본", "Current source"], + Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"], + Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"], + Z01_MasterData_List_Edited: ["고쳐짐", "Edited"], Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 9d1dfccc..53cf2505 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -13,9 +13,12 @@ const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; - progressContent: HTMLElement; + /** 없으면 진행단계 패널을 안 세움 — 워크플로 밖 화면(Z01 마스터 데이터)용. */ + progressContent?: HTMLElement; optionsContent?: HTMLElement; showTitlePanel?: boolean; + /** false 면 제목 줄 오른쪽 프로젝트 이름을 안 붙임 — 프로젝트와 무관한 화면용. */ + showProjectName?: boolean; onOptionsOpenChange?: (isOpen: boolean) => void; } @@ -239,15 +242,6 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo const root = document.createElement("div"); root.className = "ui-workflow-overlay"; - const progressBody = document.createElement("div"); - progressBody.append(options.progressContent); - - const progressPanel = createPanel( - "progress", - t("Workflow_Progress_Title"), - progressBody, - PROGRESS_OVERLAY_STATE_KEY, - ); let setTitleOpen = (_isOpen: boolean): void => {}; if (options.showTitlePanel !== false) { const titleBody = document.createElement("div"); @@ -258,16 +252,24 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo titleBody, TITLE_OVERLAY_STATE_KEY, options.onOptionsOpenChange, - createProjectNameTag(), + options.showProjectName === false ? undefined : createProjectNameTag(), ); root.append(titlePanel.root); setTitleOpen = titlePanel.setOpen; } - root.append(progressPanel.root); + let setProgressOpen = (_isOpen: boolean): void => {}; + if (options.progressContent) { + const progressBody = document.createElement("div"); + progressBody.append(options.progressContent); + const progressPanel = createPanel( + "progress", + t("Workflow_Progress_Title"), + progressBody, + PROGRESS_OVERLAY_STATE_KEY, + ); + root.append(progressPanel.root); + setProgressOpen = progressPanel.setOpen; + } - return { - root, - setTitleOpen, - setProgressOpen: progressPanel.setOpen, - }; + return { root, setTitleOpen, setProgressOpen }; }