diff --git a/resources/tester/fixtures/base_data_source_known.json b/resources/tester/fixtures/base_data_source_known.json new file mode 100644 index 00000000..0710669c --- /dev/null +++ b/resources/tester/fixtures/base_data_source_known.json @@ -0,0 +1,116 @@ +{ + "_설명": [ + "기초데이터 열(품셈 근거) ↔ 원문 — 지금 알고 있는 어긋남(2026-09-18 랩탑 메인이 시험으로 처음 뜸).", + "시험: resources/tester/test_base_data_source_match.py — 이 목록 밖에서 어긋나면 빨강.", + "고치면 지울 것: python resources/tester/test_base_data_source_match.py --prune (지우기만 함).", + "md=원문에서 읽은 값 · data=자료 값 · why=사람이 적은 까닭(시험은 맞대기에서 뺌).", + "못 재는 칸은 시험 파일 NOT_CHECKED 에 까닭과 함께." + ], + "coef": { + "coef_soil_C/11": { + "md": [ + "암괴나호박돌이섞인모래", + 0.9, + 0.95, + null + ], + "data": [ + "암괴ㆍ호박돌섞인모래", + 0.9, + 0.95, + null + ], + "why": "위와 같음" + }, + "coef_soil_L/11": { + "md": [ + "암괴나호박돌이섞인모래", + 1.4, + 1.45, + null + ], + "data": [ + "암괴ㆍ호박돌섞인모래", + 1.4, + 1.45, + null + ], + "why": "이름을 「암괴ㆍ호박돌 섞인 모래」로 줄여 적음 — 값은 원문과 같음" + } + }, + "material_surcharge": { + "이형철근": { + "md": [ + "3", + "6-7" + ], + "data": [ + 3, + 7 + ], + "why": "원문 복잡 구조물 주철근 「6-7」 범위 중 위 끝 7 을 씀 — 범위를 살릴지 사람 확인" + } + }, + "formwork_reuse": { + "reuse_ratio_pct/timber": { + "md": { + "각재": "비율 칸 비어 있음", + "합판": { + "재료별(%)": [ + "100.0", + "57.0", + "46.1", + "40.1", + "37.1", + "34.7" + ], + "노무비(%)": [ + "100.0", + "60.0", + "47.1", + "40.0", + "34.2", + "32.0" + ] + } + }, + "data": [ + 100.0, + 60.0, + 47.1, + 40.0, + 34.2, + 32.0 + ], + "why": "⚠ 원문 12-4 는 각재 줄 비율 칸이 비어 있음 — 이 여섯 값은 합판 줄 「노무비(%)」 열인데 각재(timber)라는 이름이 붙음 · 쓰는 곳 확인 필요" + } + }, + "rebar_complexity": { + "classes/복잡": { + "md": "12-3 [주]① 에 없는 예시", + "data": [ + "교량 슬래브" + ], + "why": "원문 「교량의 슬래브」를 줄여 적음" + } + }, + "timber_structure_class": {}, + "masonry_slope": {}, + "masonry_back_length": {}, + "stone_kind": {}, + "masonry_class": {}, + "machine_productivity": { + "dump_material/발파암/truck_efficiency": { + "md": [], + "data": "0.9", + "why": "10-12-3 운반 식에 E 가 없음 — 토사·암절취의 0.9 를 이어 씀" + }, + "dump_material/토사/loading_efficiency": { + "md": [ + 0.75 + ], + "data": "0.60", + "why": "본문 E0=0.75 대신 10-12-1 [주]⑤ 표 「임도 0.60(불량)」 을 씀 — 자료 loading_note 에 사유" + } + } +} diff --git a/resources/tester/fixtures/base_prices_source_known.json b/resources/tester/fixtures/base_prices_source_known.json new file mode 100644 index 00000000..def1e6ed --- /dev/null +++ b/resources/tester/fixtures/base_prices_source_known.json @@ -0,0 +1,1575 @@ +{ + "_설명": [ + "원문 ↔ 기초단가 다섯 갈래 — 지금 알고 있는 어긋남과 시험이 못 보는 자리(2026-09-18 랩탑 메인이 시험으로 처음 뜸).", + "시험: resources/tester/test_base_prices_source_match.py — 이 목록 밖에서 어긋나면 빨강 · 목록에 있는데 고쳐졌거나 모습이 바뀌어도 빨강.", + "고치면 지울 것: python resources/tester/test_base_prices_source_match.py --prune (지우기만 함).", + "labor·machine·material·oil·rate: source=원문에서 읽은 값 · data=자료 값 · 한쪽이 null 이면 한쪽에만 있음.", + "`셈 …`: (원문 줄 수, 자료 줄 수) — test_셈이_맞음 이 이 목록과 똑같을 때만 통과.", + "`… 자료에 없음`: 원문 줄은 읽었는데 자료에 없는 코드를 네 자리 기종별로 묶음.", + "*_blind: 시험이 못 보는 자리 — what=무엇 · why=까닭. `안 잰 칸 …` 은 자료에 있는데 원문과 안 맞대는 칸(새 칸이 생기면 빨강).", + "why: 사람이 적은 까닭 — 시험은 맞대기에서 뺌." + ], + "labor": { + "제조 115 industry": { + "source": "품질 관리 / 금속 재료", + "data": "금속 재료", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 116 industry": { + "source": "품질 관리 / 금속 재료", + "data": "금속 재료", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 117 industry": { + "source": "품질 관리 / 금속 재료", + "data": "금속 재료", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 118 industry": { + "source": "품질 관리 / 전기· 전자· 기계", + "data": "전기· 전자· 기계", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 119 industry": { + "source": "품질 관리 / 전기· 전자· 기계", + "data": "전기· 전자· 기계", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 120 industry": { + "source": "품질 관리 / 전기· 전자· 기계", + "data": "전기· 전자· 기계", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 121 industry": { + "source": "품질 관리 / 기타 공학", + "data": "기타 공학", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 122 industry": { + "source": "품질 관리 / 기타 공학", + "data": "기타 공학", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + }, + "제조 123 industry": { + "source": "품질 관리 / 기타 공학", + "data": "기타 공학", + "why": "품질 관리 하위 업종 — 112~114 는 「품질 관리 / 화학 공학」 꼴인데 115~123 은 상위 업종이 빠짐(자료 안 고름)" + } + }, + "machine": { + "가격 0230-0002 machine_name": { + "source": "대형 브레이커", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 0230-0004 machine_name": { + "source": "대형 브레이커", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 0230-0006 machine_name": { + "source": "대형 브레이커", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 0230-0007 machine_name": { + "source": "대형 브레이커", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 0230-0008 machine_name": { + "source": "대형 브레이커", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 0230-0010 machine_name": { + "source": "대형 브레이커", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 0240-0007 machine_name": { + "source": "유압식 진동콤팩터(굴착기 부착용)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 3611-0142 machine_name": { + "source": "콘크리트 피니셔(중앙분리대용)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 5220-0015 machine_name": { + "source": "소형브레이커(전기식)", + "data": "소형브레이커(전기식)시 간 당(10-7)분류번호규 격내용시간5220-00151.5㎾8,0002,500(5330) 드릴웨곤", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 5330-0074 machine_name": { + "source": "드릴웨곤", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6532-0220 machine_name": { + "source": "진동파일 해머(유압식)", + "data": "진동파일 해머(유압식)시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎾)시간가동시간비율비율계비율계수계수계수6532-02201627,0008900.90.50.11,2867146822,682(6540) 워터젯트", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 6540-0131 machine_name": { + "source": "워터젯트", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6802-0040 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6802-0060 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6802-0100 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6802-0120 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6802-0135 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 6802-0160 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7101-0450 machine_name": { + "source": "고성능 착정기", + "data": "고성능 착정기시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎾)시간가동시간비율비율계비율계수계수계수7101-0450335.706,3008000.90.650.11,4291,0327593,220[주] ① 트럭 적재식이고 공기압축기 및 동력이 포함되어 있다.", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 7120-0746 machine_name": { + "source": "버킷식준설기", + "data": "버킷식준설기시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(kW)시간가동시간비율비율계비율계수계수계수3,508 7120-07467.465,0008900.90.50.11,8001,000708[주] 호퍼식+자동굴절형을 포함한다. (7202) 자동세륜기(롤 타입)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 7202-0008 machine_name": { + "source": "자동세륜기(롤 타입)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7202-0010 machine_name": { + "source": "자동세륜기(롤 타입)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0001 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0002 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0003 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0005 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0007 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0010 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0015 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0020 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0025 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0030 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0040 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0050 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0075 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7930-0100 machine_name": { + "source": "모터", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 7995-0050 machine_name": { + "source": "배관파이프", + "data": "배관파이프시 간 당(10-7)분류번호규 격7995-0050ø50-2.6m5,0008-3-9 [80]스마트 건설장비(8201) 3D GNSS 머신 가이던스(굴착기용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "가격 8201-0100 machine_name": { + "source": "3D GNSS 머신 가이던스(굴착기용)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 9070-0015 machine_name": { + "source": "이우선(비자항)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "가격 9070-0020 machine_name": { + "source": "이우선(비자항)", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "셈 손료 코드 수": { + "source": 586, + "data": 387, + "why": "원문 586 줄 중 자료 387 — 뭉친 줄·해상장비·`-0003` 줄을 자료가 안 옮김(`… 자료에 없음` 참조)" + }, + "셈 연료 코드 수": { + "source": 197, + "data": 214, + "why": "시험은 토막 수가 맞는 197 줄만 읽음 — 자료 214 중 17 줄은 원문 표 토막이 안 맞아 못 봄(machine_blind `연료 못 읽은 줄`)" + }, + "손료 0103-0016 economic_life_hours": { + "source": 12000, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 0103-0019 economic_life_hours": { + "source": 12000, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 0103-0023 economic_life_hours": { + "source": 12000, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 0103-0027 economic_life_hours": { + "source": 12000, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 0103-0032 economic_life_hours": { + "source": 12000, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 0240-0007 machine_name": { + "source": "유압식 진동콤팩터(굴착기 부착용)", + "data": "대형 브레이커시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎥)시간가동시간비율비율계비율계수계수계수0230-00020.23,0008900.90.850.13,0002,8337686,60100040.43,0008900.90.850.13,0002,8337686,60100060.63,0008900.90.850.13,0002,8337686,60100070.73,0008900.90.850.13,0002,8337686,60100080.83,0008900.90.850.13,0002,8337686,60100101.03,0008900.90.850.13,0002,8337686,601(0240) 유압식 진동콤팩터(굴착기 부착용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "손료 2101 자료에 없음": { + "source": [ + "2101-0010", + "2101-0015", + "2101-0020", + "2101-0025", + "2101-0030", + "2101-0035", + "2101-0040", + "2101-0050", + "2101-0070", + "2101-0080", + "2101-0100", + "2101-0150", + "2101-0220", + "2101-0280", + "2101-0300" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 3108 자료에 없음": { + "source": [ + "3108-0040", + "3108-0060", + "3108-0080", + "3108-0100", + "3108-0120" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 3201 자료에 없음": { + "source": [ + "3201-0003" + ], + "data": null, + "why": "원문 줄 코드가 `-0003` 꼴 — 자료가 못 읽고 빠뜨림" + }, + "손료 3611-0142 machine_name": { + "source": "콘크리트 피니셔(중앙분리대용)", + "data": "콘크리트 피니셔(포장용)", + "why": "앞 기종 3601 이름(포장용)이 붙음 — 3611 은 중앙분리대용" + }, + "손료 4108 자료에 없음": { + "source": [ + "4108-0060", + "4108-0090", + "4108-0120", + "4108-0150", + "4108-0180", + "4108-0210" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 4115 자료에 없음": { + "source": [ + "4115-0100", + "4115-0150", + "4115-0200", + "4115-0300" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 4504 자료에 없음": { + "source": [ + "4504-0021", + "4504-0028", + "4504-0032", + "4504-0036", + "4504-0041", + "4504-0043", + "4504-0047", + "4504-0052" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 4505 자료에 없음": { + "source": [ + "4505-0015", + "4505-0026" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 4611 자료에 없음": { + "source": [ + "4611-0075", + "4611-0350" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5111 자료에 없음": { + "source": [ + "5111-0040", + "5111-0050", + "5111-0060", + "5111-0076", + "5111-0091" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5112 자료에 없음": { + "source": [ + "5112-0002", + "5112-0003", + "5112-0004", + "5112-0005" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5113 자료에 없음": { + "source": [ + "5113-0001", + "5113-0002", + "5113-0003", + "5113-0004", + "5113-0005", + "5113-0006", + "5113-0007", + "5113-0008", + "5113-0009", + "5113-0010", + "5113-0011" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5114 자료에 없음": { + "source": [ + "5114-0001", + "5114-0002", + "5114-0003", + "5114-0004", + "5114-0005", + "5114-0006", + "5114-0007", + "5114-0008" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5115 자료에 없음": { + "source": [ + "5115-0030", + "5115-0055", + "5115-0075", + "5115-0095" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5116 자료에 없음": { + "source": [ + "5116-0001", + "5116-0002", + "5116-0003", + "5116-0004", + "5116-0005", + "5116-0006", + "5116-0007", + "5116-0008" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5117 자료에 없음": { + "source": [ + "5117-0001", + "5117-0002", + "5117-0003", + "5117-0004", + "5117-0005", + "5117-0006", + "5117-0007", + "5117-0008" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5118 자료에 없음": { + "source": [ + "5118-0001", + "5118-0002", + "5118-0003", + "5118-0004", + "5118-0005", + "5118-0006", + "5118-0007" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 5202-0127 annual_management_ratio": { + "source": 0.1, + "data": 0.55, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 annual_standard_hours": { + "source": 800, + "data": 4500, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 depreciation_coefficient_1e_minus_7": { + "source": 2000, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 depreciation_ratio": { + "source": 0.9, + "data": 800, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 economic_life_hours": { + "source": 4500, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 maintenance_coefficient_1e_minus_7": { + "source": 1222, + "data": 2000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 maintenance_ratio": { + "source": 0.55, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0127 management_coefficient_1e_minus_7": { + "source": 788, + "data": 1222, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 annual_management_ratio": { + "source": 0.1, + "data": 0.55, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 annual_standard_hours": { + "source": 800, + "data": 4500, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 depreciation_coefficient_1e_minus_7": { + "source": 2000, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 depreciation_ratio": { + "source": 0.9, + "data": 800, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 economic_life_hours": { + "source": 4500, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 maintenance_coefficient_1e_minus_7": { + "source": 1222, + "data": 2000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 maintenance_ratio": { + "source": 0.55, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0240 management_coefficient_1e_minus_7": { + "source": 788, + "data": 1222, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 annual_management_ratio": { + "source": 0.1, + "data": 0.55, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 annual_standard_hours": { + "source": 800, + "data": 4500, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 depreciation_coefficient_1e_minus_7": { + "source": 2000, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 depreciation_ratio": { + "source": 0.9, + "data": 800, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 economic_life_hours": { + "source": 4500, + "data": 1050, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 maintenance_coefficient_1e_minus_7": { + "source": 1222, + "data": 2000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 maintenance_ratio": { + "source": 0.55, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5202-0300 management_coefficient_1e_minus_7": { + "source": 788, + "data": 1222, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 annual_management_ratio": { + "source": 0.1, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 annual_standard_hours": { + "source": 890, + "data": 5.5, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 depreciation_coefficient_1e_minus_7": { + "source": 2250, + "data": 0.6, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 depreciation_ratio": { + "source": 0.9, + "data": 4000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 economic_life_hours": { + "source": 4000, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 maintenance_coefficient_1e_minus_7": { + "source": 1500, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 maintenance_ratio": { + "source": 0.6, + "data": 890, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-1800 management_coefficient_1e_minus_7": { + "source": 730, + "data": 2250, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 annual_management_ratio": { + "source": 0.1, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 annual_standard_hours": { + "source": 890, + "data": 8.0, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 depreciation_coefficient_1e_minus_7": { + "source": 2250, + "data": 0.6, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 depreciation_ratio": { + "source": 0.9, + "data": 4000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 economic_life_hours": { + "source": 4000, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 maintenance_coefficient_1e_minus_7": { + "source": 1500, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 maintenance_ratio": { + "source": 0.6, + "data": 890, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2200 management_coefficient_1e_minus_7": { + "source": 730, + "data": 2250, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 annual_management_ratio": { + "source": 0.1, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 annual_standard_hours": { + "source": 890, + "data": 12.0, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 depreciation_coefficient_1e_minus_7": { + "source": 2250, + "data": 0.6, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 depreciation_ratio": { + "source": 0.9, + "data": 4000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 economic_life_hours": { + "source": 4000, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 maintenance_coefficient_1e_minus_7": { + "source": 1500, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 maintenance_ratio": { + "source": 0.6, + "data": 890, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-2700 management_coefficient_1e_minus_7": { + "source": 730, + "data": 2250, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 annual_management_ratio": { + "source": 0.1, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 annual_standard_hours": { + "source": 890, + "data": 20.0, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 depreciation_coefficient_1e_minus_7": { + "source": 2250, + "data": 0.6, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 depreciation_ratio": { + "source": 0.9, + "data": 4000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 economic_life_hours": { + "source": 4000, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 maintenance_coefficient_1e_minus_7": { + "source": 1500, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 maintenance_ratio": { + "source": 0.6, + "data": 890, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-3500 management_coefficient_1e_minus_7": { + "source": 730, + "data": 2250, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 annual_management_ratio": { + "source": 0.1, + "data": 0.9, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 annual_standard_hours": { + "source": 890, + "data": 35.0, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 depreciation_coefficient_1e_minus_7": { + "source": 2250, + "data": 0.6, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 depreciation_ratio": { + "source": 0.9, + "data": 4000, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 economic_life_hours": { + "source": 4000, + "data": null, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 maintenance_coefficient_1e_minus_7": { + "source": 1500, + "data": 0.1, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 maintenance_ratio": { + "source": 0.6, + "data": 890, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5203-4500 management_coefficient_1e_minus_7": { + "source": 730, + "data": 2250, + "why": "규격 칸이 둘(5202)·셋(5203)인 표 — 자료가 칸을 밀려 읽음(가동시간 자리에 내용시간 값 등) · 값 틀림" + }, + "손료 5210-0010 economic_life_hours": { + "source": 3600, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 5210-0013 economic_life_hours": { + "source": 3600, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 5210-0019 economic_life_hours": { + "source": 3600, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 5210-0027 economic_life_hours": { + "source": 3600, + "data": null, + "why": "축약 손료표(내용시간·계만) — 자료가 내용시간을 안 옮김" + }, + "손료 5330-0074 machine_name": { + "source": "드릴웨곤", + "data": "소형브레이커(전기식)시 간 당(10-7)분류번호규 격내용시간5220-00151.5㎾8,0002,500(5330) 드릴웨곤", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "손료 5401 자료에 없음": { + "source": [ + "5401-0015", + "5401-0017" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 6105 자료에 없음": { + "source": [ + "6105-0190", + "6105-0390" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 6202 자료에 없음": { + "source": [ + "6202-0060", + "6202-0125", + "6202-0200" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 6540-0131 machine_name": { + "source": "워터젯트", + "data": "진동파일 해머(유압식)시 간 당(10-7)연간분류규격내용연간표준상각정비관리상각비정비비관리비번호(㎾)시간가동시간비율비율계비율계수계수계수6532-02201627,0008900.90.50.11,2867146822,682(6540) 워터젯트", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "손료 6802-0040 machine_name": { + "source": "파일천공전용장비", + "data": "고압분사전용장비", + "why": "앞 기종 6801 이름이 붙음 — 원문 뭉개진 글(L2927) 경계에서 밀림" + }, + "손료 6802-0060 machine_name": { + "source": "파일천공전용장비", + "data": "고압분사전용장비", + "why": "앞 기종 6801 이름이 붙음 — 원문 뭉개진 글(L2927) 경계에서 밀림" + }, + "손료 6802-0100 machine_name": { + "source": "파일천공전용장비", + "data": "고압분사전용장비", + "why": "앞 기종 6801 이름이 붙음 — 원문 뭉개진 글(L2927) 경계에서 밀림" + }, + "손료 6802-0120 machine_name": { + "source": "파일천공전용장비", + "data": "고압분사전용장비", + "why": "앞 기종 6801 이름이 붙음 — 원문 뭉개진 글(L2927) 경계에서 밀림" + }, + "손료 6802-0135 machine_name": { + "source": "파일천공전용장비", + "data": "고압분사전용장비", + "why": "앞 기종 6801 이름이 붙음 — 원문 뭉개진 글(L2927) 경계에서 밀림" + }, + "손료 6802-0160 machine_name": { + "source": "파일천공전용장비", + "data": "고압분사전용장비", + "why": "앞 기종 6801 이름이 붙음 — 원문 뭉개진 글(L2927) 경계에서 밀림" + }, + "손료 7202 자료에 없음": { + "source": [ + "7202-0008", + "7202-0010" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 7430 자료에 없음": { + "source": [ + "7430-1100", + "7430-1300", + "7430-1500", + "7430-2300", + "7430-2500" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 7431 자료에 없음": { + "source": [ + "7431-1100", + "7431-1300", + "7431-2300", + "7431-2500" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 7811 자료에 없음": { + "source": [ + "7811-0025", + "7811-0030", + "7811-0040", + "7811-0045", + "7811-0070", + "7811-0120" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 7812 자료에 없음": { + "source": [ + "7812-0005", + "7812-0007", + "7812-0009", + "7812-0015", + "7812-0018", + "7812-0020", + "7812-0035", + "7812-0070", + "7812-0100", + "7812-0150", + "7812-0200" + ], + "data": null, + "why": "원문 한 칸에 여러 코드가 뭉친 줄 — 자료가 안 옮겨 손료가 없음" + }, + "손료 7930-0001 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0002 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0003 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0005 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0007 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0010 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0015 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0020 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0025 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0030 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0040 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0050 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0075 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 7930-0100 machine_name": { + "source": "모터", + "data": "우레탄폼 분사용기구", + "why": "앞 기종 7830 이름이 붙음 — 원문 뭉개진 글(L3209) 경계에서 밀림" + }, + "손료 8201-0100 machine_name": { + "source": "3D GNSS 머신 가이던스(굴착기용)", + "data": "배관파이프시 간 당(10-7)분류번호규 격7995-0050ø50-2.6m5,0008-3-9 [80]스마트 건설장비(8201) 3D GNSS 머신 가이던스(굴착기용)", + "why": "자료 이름 칸에 원문 뭉개진 손료 글이 통째로 들어감 — 자료 이름 틀림" + }, + "손료 9010 자료에 없음": { + "source": [ + "9010-0003", + "9010-0006", + "9010-0010", + "9010-0012", + "9010-0020", + "9010-0022", + "9010-0033", + "9010-0040", + "9010-0044", + "9010-0060", + "9010-0080", + "9010-0120", + "9010-0200" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9020 자료에 없음": { + "source": [ + "9020-0010", + "9020-0015", + "9020-0016" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9030 자료에 없음": { + "source": [ + "9030-0016", + "9030-0018", + "9030-0025", + "9030-0035", + "9030-0045", + "9030-0050", + "9030-0080", + "9030-0100", + "9030-0240" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9040 자료에 없음": { + "source": [ + "9040-0010", + "9040-0030", + "9040-0050", + "9040-0060", + "9040-0100", + "9040-0120", + "9040-0200", + "9040-0250", + "9040-0300", + "9040-0380", + "9040-0680" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9050 자료에 없음": { + "source": [ + "9050-0075", + "9050-0150", + "9050-0450", + "9050-0750", + "9050-0850" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9070 자료에 없음": { + "source": [ + "9070-0015", + "9070-0020" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9080 자료에 없음": { + "source": [ + "9080-0050", + "9080-0080", + "9080-0100", + "9080-0120", + "9080-0150", + "9080-0200", + "9080-0300", + "9080-0500", + "9080-0700", + "9080-1000", + "9080-1100", + "9080-1400", + "9080-1500", + "9080-1750", + "9080-2000", + "9080-3000" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "손료 9090 자료에 없음": { + "source": [ + "9090-0800", + "9090-1000", + "9090-1200", + "9090-1300", + "9090-1400", + "9090-1500", + "9090-1600" + ], + "data": null, + "why": "해상장비 손료표(형식·출력 여러 칸) — 자료가 안 옮김" + }, + "연료 6540-0131 machine_name": { + "source": "워터젯트", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "연료 6802-0040 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "연료 6802-0060 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "연료 6802-0100 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "연료 6802-0120 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "연료 6802-0135 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + }, + "연료 6802-0160 machine_name": { + "source": "파일천공전용장비", + "data": "", + "why": "자료 이름이 빔 — 원문 손료표 머리 `(코드) 이름` 엔 이름 있음" + } + }, + "material": {}, + "oil": {}, + "rate": { + "rate_overhead.specialty_electric_communication_fire_other[(1000억이상)].rate_percent": { + "source": 4.5, + "data": null, + "why": "원문 BL35 병합 칸이 300억 이상까지 4.5% — 자료는 100억~300억 구간까지만 둠" + }, + "rate_overhead.specialty_electric_communication_fire_other[(300억-1000억미만)].rate_percent": { + "source": 4.5, + "data": null, + "why": "원문 BL35 병합 칸이 300억 이상까지 4.5% — 자료는 100억~300억 구간까지만 둠" + }, + "셈 원문 값 수": { + "source": 233, + "data": 231, + "why": "위 전문·전기 일반관리비 300억 이상 두 구간이 자료에 없음" + } + }, + "labor_blind": { + "건설 pdf": { + "what": "3. 개별직종 노임단가.md", + "why": "원문 md 는 PDF 를 옮긴 것 — PDF 자체와의 옮김 틀림은 못 봄" + }, + "건설 별칭 이름": { + "what": [ + "labor_1001", + "labor_1002", + "labor_1003", + "labor_1007", + "labor_1008", + "labor_1012", + "labor_1013", + "labor_1015", + "labor_1016", + "labor_1017", + "labor_1018", + "labor_1019", + "labor_op_const", + "labor_op_general", + "labor_op_truck" + ], + "why": "별칭 이름은 프로그램이 지은 것 — 가리키는 코드가 원문에 있는지만 봄" + }, + "안 잰 칸 labor_mfg.unit": { + "what": "원문과 안 맞댐", + "why": "`KRW/day` 는 프로그램 표기 — 원문 글(원·1일)과 글자로 안 맞댐" + }, + "안 잰 칸 labor_rate.unit": { + "what": "원문과 안 맞댐", + "why": "`KRW/day` 는 프로그램 표기 — 원문 글(원·1일)과 글자로 안 맞댐" + }, + "제조 pdf": { + "what": "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.md", + "why": "원문 md 는 PDF 를 옮긴 것 — PDF 자체와의 옮김 틀림은 못 봄" + } + }, + "machine_blind": { + "손료 뭉개진 글 L1947": { + "what": [ + "0230" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L2384": { + "what": [ + "3601" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L2683": { + "what": [ + "5220" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L2894": { + "what": [ + "6532" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L2927": { + "what": [ + "6801" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L2956": { + "what": [ + "7101" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L2988": { + "what": [ + "7120" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L3209": { + "what": [ + "7830" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L3297": { + "what": [ + "7995" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L3346": { + "what": [], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료 뭉개진 글 L3367": { + "what": [ + "9060" + ], + "why": "원문 손료표가 표가 아니라 한 줄 글로 뭉개짐 — 코드·값을 못 가름, 이 기종 손료는 안 봄(자료에도 없음)" + }, + "손료보정 수 자리 가름": { + "what": "뭉개진 글의 이어 붙은 수(2510)를 통째로 맞댐", + "why": "8-1-7 가산비율 표가 글로 뭉개져 `2510` 처럼 붙음 — 25·10 인지 2·510 인지 못 가름" + }, + "안 잰 칸 mach_fuel_rate.parse_method": { + "what": "원문과 안 맞댐", + "why": "빌더가 남긴 읽기 방법 표시 — 원문 칸 아님" + }, + "안 잰 칸 mach_fuel_rate.source_tables.section": { + "what": "원문과 안 맞댐", + "why": "빌더가 붙인 절 제목 — 원문 칸 아님(표 머리·줄은 맞댐)" + }, + "안 잰 칸 mach_fuel_rate.specification": { + "what": "원문과 안 맞댐", + "why": "규격 칸이 한 칸에 여러 값·여러 열로 뭉쳐 가르기 어려움 — 안 맞댐" + }, + "안 잰 칸 mach_loss_coef.loss_coefficient_per_hour": { + "what": "원문과 안 맞댐", + "why": "계산 칸(계 × 10⁻⁷) — 원문 `계`(source_coefficient_1e_minus_7)만 맞댐" + }, + "안 잰 칸 mach_loss_coef.specification": { + "what": "원문과 안 맞댐", + "why": "규격 칸이 한 칸에 여러 값·여러 열로 뭉쳐 가르기 어려움 — 안 맞댐" + }, + "안 잰 칸 mach_operator_map.rules.alias": { + "what": "원문과 안 맞댐", + "why": "프로그램이 지은 이름·출처 표기" + }, + "안 잰 칸 mach_operator_map.rules.rule_id": { + "what": "원문과 안 맞댐", + "why": "프로그램이 지은 이름·출처 표기" + }, + "안 잰 칸 mach_operator_map.rules.source_section": { + "what": "원문과 안 맞댐", + "why": "프로그램이 지은 이름·출처 표기" + }, + "안 잰 칸 mach_price.specification": { + "what": "원문과 안 맞댐", + "why": "가격표엔 규격 칸이 없음(기종·코드·가격만)" + }, + "안 잰 칸 mach_rock_adj.rules.exception": { + "what": "원문과 안 맞댐", + "why": "[주]① 예외 글을 프로그램 말로 옮긴 칸 — 글자로 안 맞댐" + }, + "안 잰 칸 mach_rock_adj.rules.exclusion": { + "what": "원문과 안 맞댐", + "why": "[주]① 예외 글을 프로그램 말로 옮긴 칸 — 글자로 안 맞댐" + }, + "연료 못 읽은 줄 5405-0110": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 5405-0150": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 5701-0010": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 5701-0020": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0025": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0050": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0100": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0125": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0150": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0200": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0250": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0350": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0450": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0500": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7505-0700": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7993-0020": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 못 읽은 줄 7994-0050": { + "what": "원문 표 토막 수가 안 맞음", + "why": "원문 운전경비 표 토막 수가 코드 수와 안 맞음(`동력 15.1㎾`·`73.7+ 휘발유54.5` 등) — 이 줄 자료 값은 못 봄" + }, + "연료 표 L3420": { + "codes": [ + "3108-0040", + "3901-0300" + ], + "why": "원문 운전경비 표 토막 수가 안 맞음 — 이 표 줄은 못 읽음" + }, + "연료 표 L3444": { + "codes": [ + "5401-0015", + "5805-0003" + ], + "why": "원문 운전경비 표 토막 수가 안 맞음 — 이 표 줄은 못 읽음" + }, + "연료 표 L3456": { + "codes": [ + "7101-0450", + "7811-0120" + ], + "why": "원문 운전경비 표 토막 수가 안 맞음 — 이 표 줄은 못 읽음" + }, + "연료 표 L3462": { + "codes": [ + "7812-0005", + "7994-0050" + ], + "why": "원문 운전경비 표 토막 수가 안 맞음 — 이 표 줄은 못 읽음" + }, + "운전사 기종별 판단": { + "what": 121, + "why": "기종→운전사 구분(explicit_mappings)은 사람 판단 — 원문 8-1-3 5호는 기종 이름 글만 있음" + } + }, + "material_blind": { + "API 스냅숏": { + "what": "나라장터_시설공통자재_2026-08-14.json", + "why": "원문이 API 로 받은 스냅숏 — 게시처(나라장터·오피넷) 값 자체와는 못 맞댐" + }, + "같은 때 공시 20696249": { + "what": 2, + "why": "같은 공시 일시에 값 실린 공시가 둘 — 원천이 어느 것이 정본인지 안 알려 줌 · 자료는 그중 하나와 같음" + }, + "같은 때 공시 20696250": { + "what": 2, + "why": "같은 공시 일시에 값 실린 공시가 둘 — 원천이 어느 것이 정본인지 안 알려 줌 · 자료는 그중 하나와 같음" + }, + "안 잰 칸 mat_price.price_adopted_reason": { + "what": "원문과 안 맞댐", + "why": "빌더가 적은 까닭 글 — 원문 칸 아님" + }, + "원천 없는 묶음": { + "what": [ + "reinforcing_steel", + "ready_mixed_concrete", + "asphalt_concrete" + ], + "why": "철근·레미콘·아스콘은 종합쇼핑몰 원천이 저장소에 없음 — 값 못 봄" + } + }, + "oil_blind": { + "API 스냅숏": { + "what": [ + "유가_전국평균_2026-08-14.json", + "유가_시도별_2026-09-09.json" + ], + "why": "원문이 API 로 받은 스냅숏 — 게시처(나라장터·오피넷) 값 자체와는 못 맞댐" + }, + "시도 거래일": { + "what": "시도별 원천에 거래일 칸이 없음 — 수집일과만 맞댐", + "why": "시도별 원천 줄에 거래일이 없어 `수집일` 로만 맞댐" + }, + "안 잰 칸 oil.scope": { + "what": "원문과 안 맞댐", + "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음" + }, + "안 잰 칸 oil.unit": { + "what": "원문과 안 맞댐", + "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음" + }, + "안 잰 칸 oil_regional.scope": { + "what": "원문과 안 맞댐", + "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음" + }, + "안 잰 칸 oil_regional.source_product_name": { + "what": "원문과 안 맞댐", + "why": "시도별 원천에 상품 이름 칸 없음" + }, + "안 잰 칸 oil_regional.unit": { + "what": "원문과 안 맞댐", + "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음" + } + }, + "rate_blind": { + "processing_rules": { + "what": [ + "note", + "owner_supplied_material", + "performance_guarantee_fee", + "profit", + "rounding", + "safety_management_cost", + "scale_bracket" + ], + "why": "끝수·산식 처리 규칙 — 코드 규칙이라 원문 대조 대상 아님(거울 시험 몫)" + }, + "안 잰 칸 rate_environment.forest_road_selection_status": { + "what": "pending", + "why": "임도 공종 선택 미결 표시 — 사용자 협의 몫" + }, + "안 잰 칸 rate_environment.minimum_estimated_amount_krw": { + "what": 100000000, + "why": "xlsx 환경보전비 칸에 1억 기준이 없음(1억은 퇴직공제부금 칸) — 근거 원문이 sources 에 없어 못 봄" + }, + "안 잰 칸 rate_equipment_payment_guarantee.base_note": { + "what": "코드(`B09_Estimation_Statutory.STATUTORY_ITEMS`)가 직접공사비로 계산하고 있는 것과 맞춘 표기.", + "why": "설명 글" + }, + "안 잰 칸 rate_performance_guarantee_fee.typical_forest_road_applicability": { + "what": "not_applicable", + "why": "임도 적용 판단 — 원문 칸 아님" + }, + "안 잰 칸 rate_safety_base.amount_bracket": { + "what": "500_million_to_5_billion", + "why": "다른 칸을 가리키는 표시 — 값 아님" + }, + "안 잰 칸 rate_safety_base.embedded_in": { + "what": "rate_safety_pct.brackets", + "why": "다른 칸을 가리키는 표시 — 값 아님" + }, + "안 잰 칸 rate_safety_pct.base_rule": { + "what": "min_of_two_formulas", + "why": "도급자관급 포함 시 두 식 중 작은 값 — xlsx 글(EB11·EB13) 해석이라 안 맞댐" + }, + "안 잰 칸 rate_safety_pct.base_with_owner_supplied_material": { + "what": "minimum_of_statutory_two_formulas", + "why": "도급자관급 포함 시 두 식 중 작은 값 — xlsx 글(EB11·EB13) 해석이라 안 맞댐" + }, + "안 잰 칸 rate_vat.base": { + "what": "construction_cost_before_vat", + "why": "부가세 — 근거(부가가치세법)가 sources 에 없음" + }, + "안 잰 칸 rate_vat.rate_percent": { + "what": 10.0, + "why": "부가세 — 근거(부가가치세법)가 sources 에 없음" + }, + "조달청 요약표": { + "what": "2026.04.13_조달청_토목공사_제비율.xlsx", + "why": "xlsx 는 조달청 요약표 — 각 고시 원문과는 안 맞댐(연금만 법령과 맞댐)" + } + } +} diff --git a/resources/tester/fixtures/work_item_master_source_known.json b/resources/tester/fixtures/work_item_master_source_known.json index d2291f28..6c141064 100644 --- a/resources/tester/fixtures/work_item_master_source_known.json +++ b/resources/tester/fixtures/work_item_master_source_known.json @@ -8,10 +8,17 @@ "notes: md_only=원문 [주]에 있는데 마스터에 없는 줄 · master_only=마스터에만 있는 줄.", "form: 원문 표에 품 표 표지(직종 이름·소요인력·(인)·수량 단위 칸 + 숫자)가 있는데 마스터가 reference·coefficient 로 둔 표 · md_mark=그 표지 칸.", "basis 의 12-7-1·12-7-2·12-8·12-38-3·13-16-2(F0339·F0340·F0341·F0395·F0449)는 일부러 다름 — 작업조 표라 밑수가 시공량 열(c3da333e).", + "form_blind: 마스터는 품 표(requirement·productivity)인데 원문에서 품 표지를 못 찾은 표 — 시험이 성격 뒤집힘을 못 잡는 자리.", + "cost_rule: 첫 칸이 모두 비목(재료비·설치비 …)인 비목 구성표 — 모양은 잡히나 참조냐 품이냐는 원문으로 못 가림 · 사람이 봐야 함.", + "why: 사람이 적은 까닭 — 시험은 맞대기에서 뺌.", "basis 2026-09-18 — 작업조 표 — 원문이 「(단위: 일 당)」이나 밑수가 시공량 열이라 비워 둔다. 「1일」을 박으면 B09 CrewOutput 길이 막혀 호표가 사라진다(2026-09-17 유로폼에서 실증).", "notes 2026-09-18 — 마스터가 더 많이 가진 쪽(master_only) — 인용 산출예시 뒤·별표 줄·긴 표의 [주] 를 마스터가 2026-09-18 에 읽게 되었으나 원문 md 쪽 추출은 아직 그 자리에서 끊긴다. ⚠ 고칠 자리는 마스터가 아니라 **md 쪽 추출**이다.", - "basis 2026-09-18 — 작업조 표 — 원문이 「(단위: 일 당)」이나 밑수가 시공량 열이라 비워 둔다. 「1일」을 박으면 B09 CrewOutput 길이 막혀 호표가 사라진다(2026-09-17 유로폼에서 실증).", - "notes 2026-09-18 — 마스터가 더 많이 가진 쪽(master_only) — 인용 산출예시 뒤·별표 줄·긴 표의 [주] 를 마스터가 2026-09-18 에 읽게 되었으나 원문 md 쪽 추출은 아직 그 자리에서 끊긴다. ⚠ 고칠 자리는 마스터가 아니라 **md 쪽 추출**이다." + "place 2026-09-18 — 표 자리 어긋남 — 원문 차례 문제로 남겨 둠.", + "basis 2026-09-18 — 작업조 표 — 원문이 「(단위: 일 당)」이나 밑수가 시공량 열이라 비워 둔다(유로폼 호표가 사라져 실증).", + "notes 2026-09-18 — 마스터가 더 가진 쪽 — 인용 산출예시 뒤·별표 줄·긴 표의 [주] 를 마스터는 읽고 md 쪽 추출은 아직 끊긴다.", + "form 2026-09-18 — 표 성격 — 사람 판정이 자동 판정을 이긴 자리.", + "form_blind 2026-09-18 — 표 성격을 시험이 못 가리는 자리 — 사람 판정으로 둔다.", + "cost_rule 2026-09-18 — 비목 구성표 — 2026-09-18 에 `sub_requirement`(값은 실값 · 자리는 상위 공종 종속)로 가름. `requirement` 면 상위 공종이 이미 세는 몫을 두 번 세고 `reference` 면 금액이 0원 증발한다." ], "place": { "F0452": { @@ -481,6 +488,12 @@ "master": "reference", "md_mark": "적용시공량" }, + "F0227": { + "section": "8-6-1", + "master": "reference", + "md_mark": "유효 살포량/1회", + "why": "사람 판정 reference(Forms.py) — 기종별 희석배수별 1회 살포량 계산표" + }, "F0257": { "section": "9-11-1", "master": "coefficient", @@ -496,5 +509,167 @@ "master": "reference", "md_mark": "작업량" } + }, + "form_blind": { + "F0046": { + "section": "2-1-1", + "master": "requirement", + "why": "기계별 연료표(천공기 주연료 3ℓ·잡품 95%) — 품 낱말 없음. 같은 모양 F0044·F0045·F0047 은 사람 판정 reference 인데 이것만 requirement" + }, + "F0062": { + "section": "2-1-12", + "master": "requirement", + "why": "값 칸이 「층적부피에 따라 별도계산」·「〃」 — 숫자 없음" + }, + "F0140": { + "section": "5-26-1", + "master": "requirement", + "why": "머리 소실 — 토성×깊이 숫자만 남음(사람 판정: 100㎡당 보통인부)" + }, + "F0141": { + "section": "5-26-2", + "master": "requirement", + "why": "머리 소실 — 위와 같음" + }, + "F0142": { + "section": "5-26-3", + "master": "requirement", + "why": "머리 소실 — 위와 같음" + }, + "F0195": { + "section": "7-14", + "master": "productivity", + "why": "칸이 뭉침(「㎥ 0.06∼0.07 4.8∼6.0」) — 원문 md 가 병합 칸을 못 풂" + }, + "F0237": { + "section": "8-11", + "master": "productivity", + "why": "작업량이 식 글(「Q = 3.5 ㎥/hr」)" + }, + "F0333": { + "section": "12-1-3", + "master": "requirement", + "why": "⚠ 원문 md 의심 — 머리는 한중콘크리트 「온도 품종 | 0℃때 …」인데 몸 줄이 앞 표 F0332(시멘트·모래·자갈 kg)와 똑같음 · PDF 대조 필요" + }, + "F0342": { + "section": "12-9-1", + "master": "requirement", + "why": "수량 칸이 비어 있음 — 원문에 값 없음(「반중력식 옹벽 참조」)" + }, + "F0343": { + "section": "12-9-2", + "master": "requirement", + "why": "수량 칸이 비어 있음 — 원문에 값 없음" + }, + "F0344": { + "section": "12-9-3", + "master": "requirement", + "why": "수량 칸이 비어 있음 — 원문에 값 없음" + }, + "F0405": { + "section": "13-4-1", + "master": "requirement", + "why": "증가율(%) 표(높이별 30·40·60%) — 품이 아니라 할증률 모양 · requirement 가 맞는지 사람 확인" + } + }, + "cost_rule": { + "F0379": { + "section": "12-29", + "master": "requirement", + "md_rows": [ + [ + "재료비", + "", + "필요수량" + ], + [ + "설치비", + "재료비의 5%" + ] + ], + "why": "⚠ 같은 꼴 6표는 reference 인데 이것만 requirement — 설치비 = 재료비의 5%" + }, + "F0369": { + "section": "12-22", + "master": "sub_requirement", + "md_rows": [ + [ + "재료비" + ], + [ + "설치비", + "재료비의 5%" + ] + ] + }, + "F0382": { + "section": "12-31", + "master": "sub_requirement", + "md_rows": [ + [ + "재료비" + ], + [ + "설치비", + "주재료비의 5%" + ] + ] + }, + "F0383": { + "section": "12-32", + "master": "sub_requirement", + "md_rows": [ + [ + "재료비", + "P.V.C ∅54m/m" + ], + [ + "설치비", + "주재료비의 10%" + ] + ] + }, + "F0386": { + "section": "12-34-2", + "master": "sub_requirement", + "md_rows": [ + [ + "재료비", + "1회 기준 46.1%" + ], + [ + "노무비", + "1회 기준 47.1%" + ] + ] + }, + "F0388": { + "section": "12-34-4", + "master": "sub_requirement", + "md_rows": [ + [ + "재료비", + "JOINT FILLER" + ] + ] + }, + "F0390": { + "section": "12-36", + "master": "sub_requirement", + "md_rows": [ + [ + "제작비", + "견적처리" + ], + [ + "운송비", + "견적처리" + ], + [ + "설치비", + "견적처리" + ] + ] + } } } diff --git a/resources/tester/test_base_data_source_match.py b/resources/tester/test_base_data_source_match.py new file mode 100644 index 00000000..979504d9 --- /dev/null +++ b/resources/tester/test_base_data_source_match.py @@ -0,0 +1,556 @@ +"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 2026-09-18 브레인 일감(랩탑 메인). + +기초값 열다섯 중 **품셈 원문에서 옮겨 적은 열**을 원문 표와 칸 단위로 맞댐. 나머지 다섯 +(노임·기계·자재·유가·요율)은 `test_base_prices_source_match.py` 몫. + +- 원문 = 산림 품셈 md(`test_work_item_master_source_match.MD`) · 건설 품셈 제8장 md(불도저 제원) +- 어긋남은 `fixtures/base_data_source_known.json` 에 — 목록 밖 새 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강 + (고친 뒤 `python resources/tester/test_base_data_source_match.py --prune`, 지우기만 함) +- ⚠ **못 재는 칸은 못 잰다고 적음** — `NOT_CHECKED` 에 까닭과 함께. 자료에 새 칸이 생기면 빨강 + (재든지 못 잰다고 적든지 하게). +- 표 읽기는 옆 시험의 `md_tables` 한 벌 — 빌더 코드는 안 씀. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest + +from test_work_item_master_source_match import MD, ROOT, _without_why, md_tables + +KNOWN = Path(__file__).resolve().parent / "fixtures/base_data_source_known.json" +CONST_MACHINE_MD = ( + ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md" +) +FILES = { + "coef": "data_cost_input_value/coef_2026.json", + "material_surcharge": "data_material_surcharge/material_surcharge_2026-01-01.json", + "formwork_reuse": "data_formwork/formwork_reuse_2026-01-01.json", + "rebar_complexity": "data_rebar/rebar_complexity_2026-01-01.json", + "timber_structure_class": "data_timber/timber_structure_class_2026-01-01.json", + "masonry_slope": "data_masonry/masonry_slope_2026-01-01.json", + "masonry_back_length": "data_masonry/masonry_back_length_2026-01-01.json", + "stone_kind": "data_masonry/stone_kind_2026-01-01.json", + "masonry_class": "data_masonry/masonry_class_2026-01-01.json", + "machine_productivity": "data_machine_productivity/machine_productivity_2026-01-01.json", +} + +#: 값이 아니라 글·방침·출처 칸 — 맞댈 값이 없음. +TEXT_FIELDS = { + "schema_version", "dataset_id", "effective_date", "generated_at", "note", "why", "source", + "sources", "policy", "not_here", "no_folding", "option_key", "option_note", "original_tables", + "not_found", "candidates_pending_user", "observed_practice", "pending_user", +} # fmt: skip +#: 재는 칸. +CHECKED = { + "coef": {"variables"}, + "material_surcharge": {"rates_pct"}, + "formwork_reuse": {"reuse_by_class", "reuse_ratio_pct", "euroform_type"}, + "rebar_complexity": {"classes"}, + "timber_structure_class": {"classes"}, + "masonry_slope": {"steps_m", "table"}, + "masonry_back_length": {"steps_m", "table_cm"}, + "stone_kind": {"kinds", "wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2", "back_lengths_cm"}, + "masonry_class": {"back_length", "boulder_diameter", "bond"}, + "machine_productivity": {"variables"}, +} +#: ⚠ 못 재는 칸 — 까닭. +NOT_CHECKED = { + "formwork_reuse": { + "type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", + "shoring": "강관동바리 대상 메모 — 값 없음", + }, + "rebar_complexity": { + "form_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", + "price_hint_krw_per_ton": "B09 가 자재 단가로 셈한 참고값 — 원문에 없음", + }, + "timber_structure_class": { + "basis_unit": "밑수 풀이 글(「목재 채적 ㎥」) — 원문은 「㎥당」만 적음", + "type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", + }, + "stone_kind": { + "backfill_ratio_of_back_length": "교본 7-3 값(1/3·1/2) — 품셈 원문이 아님(교본 원본 대조 몫)", + "fallback": "건설품셈 [참고자료] 돌쌓기 규격별 소요량 — 원문 md 가 이 저장소에 없음", + }, + "masonry_class": { + "face_slope": "교본 7-3 기준(지식DB 돌쌓기.md 요약) — 품셈 원문이 아님", + }, +} + + +# ── 원문 읽기 ──────────────────────────────────────────────────────────── +def _sq(text: str) -> str: + return re.sub(r"\s+", "", str(text)) + + +def _num(text: str) -> float | None: + """`0.30` · `1:0.30` → 수. `-`·빈칸 → `None`.""" + t = _sq(text).split(":")[-1].replace(",", "") + return float(t) if re.fullmatch(r"\d+(\.\d+)?", t) else None + + +def _range(text: str) -> tuple[float | None, float | None]: + """`1.70∼2.00` · `25~35` · `6-7` · `75이상` → (아래, 위).""" + t = _sq(text) + if m := re.fullmatch(r"(\d+(?:\.\d+)?)이상", t): + return float(m.group(1)), None + parts = re.split(r"[∼~~-]", t) + if len(parts) == 2 and all(re.fullmatch(r"\d+(\.\d+)?", p) for p in parts): + return float(parts[0]), float(parts[1]) + return None, None + + +def _numbers(text: str) -> set[float]: + return {float(x) for x in re.findall(r"\d+(?:\.\d+)?", text)} + + +def _section(lines: list[str], heading: str) -> str: + """`### {heading}` 부터 다음 같은 급 제목 앞까지 글.""" + start = next(i for i, x in enumerate(lines) if x.startswith(f"### {heading}")) + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("### ")), len(lines)) + return "\n".join(lines[start:end]) + + +def _f(value) -> float | None: + return None if value is None else float(value) + + +# ── 열마다 맞대기 — {열쇠: {"md": 원문, "data": 자료}} ───────────────────── +def _coef(doc: dict, md: dict) -> dict: + out = {} + rows = md["tables"]["F0004"]["rows"] + for col, var in ((1, "coef_soil_L"), (2, "coef_soil_C")): + records = doc["variables"][var]["records"] + if len(records) != len(rows): + out[f"{var}/줄 수"] = {"md": len(rows), "data": len(records)} + for i, (row, rec) in enumerate(zip(rows, records)): + name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(row[0])) + lo, hi = _range(row[col]) + md_val = [name, lo, hi, None if lo is not None else _sq(row[col])] + data_name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(rec["soil_type"])) + data_val = [ + data_name, + _f(rec.get("min")), + _f(rec.get("max")), + rec.get("rule") and _sq(rec["rule"]), + ] + if md_val != data_val: + out[f"{var}/{i + 1}"] = {"md": md_val, "data": data_val} + # 원문 보관 표 — 원문 표와 글자까지 같아야 함 + by_line = {t["line"]: t for t in md["tables"].values()} + for var in ("surcharge_labor", "surcharge_mat"): + for t in doc["variables"][var]["tables"]: + m = by_line.get(t["line"]) + if m is None or [m["headers"], m["rows"]] != [t["headers"], t["rows"]]: + out[f"{var}/L{t['line']}"] = { + "md": m and [m["headers"], m["rows"]], + "data": [t["headers"], t["rows"]], + } + tool = doc["variables"]["rate_tool"] + if not {tool["min_percent"], tool["max_percent"]} <= _numbers(_section(md["lines"], "1-2-6.")): + out["rate_tool"] = { + "md": "1-2-6 글에 없음", + "data": [tool["min_percent"], tool["max_percent"]], + } + return out + + +#: 자재 → 원문 칸 자리 (표, 줄, 칸, 줄에 있어야 할 글). 줄이 밀리면 글 확인에서 걸림. +SURCHARGE_CELLS = { + "시멘트": [("F0008", 0, 1, "시멘트"), ("F0008", 0, 2, "시멘트")], + "잔골재": [("F0008", 1, 1, "잔골재"), ("F0008", 1, 2, "잔골재")], + "채움재": [("F0008", 1, 1, "채움재"), ("F0008", 1, 2, "채움재")], + "굵은골재": [("F0008", 2, 1, "굵은골재"), ("F0008", 2, 2, "굵은골재")], + "모래": [("F0009", 0, 1, "모래")], + "부순돌": [("F0009", 1, 1, "부순돌")], + "자갈": [("F0009", 1, 1, "자갈")], + "점질토": [("F0009", 3, 1, "점질토")], + "이형철근": [("F0011", 1, 1, "이형철근"), ("F0011", 2, 1, "주철근")], + "원형철근": [("F0011", 0, 1, "원형철근")], + "강판": [("F0011", 3, 1, "강판")], + "각재": [("F0012", 0, 2, "각재")], + "판재": [("F0012", 1, 1, "판재")], + "레미콘": [("F0012", 5, 2, "레디믹스트"), ("F0012", 6, 1, "철근구조물")], + "흄관": [("F0012", 16, 1, "원심력철근콘크리트관")], + "떼": [("F0012", 10, 1, "떼")], + "초화류": [("F0012", 10, 1, "초화류")], + "사방용 수목": [("F0012", 9, 1, "사방용수목")], + "원석": [("F0012", 8, 1, "마름돌용")], +} + + +def _material_surcharge(doc: dict, md: dict) -> dict: + out = {} + for rec in doc["rates_pct"]: + cells = SURCHARGE_CELLS.get(rec["material"]) + data_val = [rec.get("rate"), rec.get("alt_rate")] + if cells is None: + out[rec["material"]] = {"md": "원문 자리 모름", "data": data_val} + continue + md_val = [] + for table, r, c, label in cells: + row = md["tables"][table]["rows"][r] + md_val.append(row[c] if label in _sq("".join(row)) else f"줄 밀림({label})") + md_val += [None] * (2 - len(md_val)) + norm = [_num(v) if v and _num(v) is not None else v for v in md_val] + if norm != [_f(v) for v in data_val]: + out[rec["material"]] = {"md": md_val, "data": data_val} + return out + + +def _formwork_reuse(doc: dict, md: dict) -> dict: + out = {} + rows = md["tables"]["F0040"]["rows"] + for rec, row in zip(doc["reuse_by_class"], rows): + if [int(_num(row[0][:-1])), rec["class"] in row[1]] != [rec["reuse_count"], True]: + out[f"reuse_by_class/{rec['class']}"] = { + "md": row, + "data": [rec["reuse_count"], rec["class"]], + } + t = md["tables"]["F0336"] + heads = t["rows"][0] # 횟수별 | 재료별(%) | 노무비(%) + series = {} + for row in t["rows"]: + label = _sq(row[0]) + series[label] = {heads[1]: row[4].split(), heads[2]: row[5].split()} + ratio = doc["reuse_ratio_pct"] + for key, row_label in (("plywood", "합판"), ("timber", "각재")): + data_val = [ratio[key][str(n)] for n in range(1, 7)] + found = {h: [float(x) for x in v] for h, v in series.get(row_label, {}).items() if v} + if data_val not in found.values(): + out[f"reuse_ratio_pct/{key}"] = { + "md": {row_label: found or "비율 칸 비어 있음", "합판": series["합판"]}, + "data": data_val, + } + area = md["tables"]["F0395"]["rows"] + md_area = dict(zip([_sq(x) for x in area[0][:3]], [_num(x) for x in area[1][3:6]])) + for cls in doc["euroform_type"]["classes"]: + if md_area.get(cls["key"]) != cls["daily_area_m2"]: + out[f"euroform_type/{cls['key']}"] = { + "md": md_area.get(cls["key"]), + "data": cls["daily_area_m2"], + } + return out + + +def _rebar_complexity(doc: dict, md: dict) -> dict: + """예시마다 12-3 [주]① 글에 있나. + + ponytail: 글 포함만 봄 — 원문에 있는 낱말(「교량」)을 엉뚱한 갈래에 보태도 통과. 갈래별 문장 쪼개기는 틀릴 때. + """ + note = _sq(md["notes_12_3"]) + out = {} + for cls in doc["classes"]: + missing = [ex for ex in cls["examples"] if _sq(ex) not in note] + if missing: + out[f"classes/{cls['key']}"] = {"md": "12-3 [주]① 에 없는 예시", "data": missing} + return out + + +def _timber_structure_class(doc: dict, md: dict) -> dict: + rows, found, group = md["tables"]["F0440"]["rows"], {}, "" + for row in rows: + cells = [_sq(c) for c in row] + if cells[0].endswith("구조"): + group, cells = cells[0], cells[1:] + grade = "" if _num(cells[0]) is not None else cells.pop(0) + found[f"{group} {grade}".strip()] = [_num(cells[0]), _num(cells[1])] + out = {} + data = {c["key"]: [c["carpenter"], c["laborer"]] for c in doc["classes"]} + for key in sorted(set(found) | set(data)): + if found.get(key) != data.get(key): + out[f"classes/{key}"] = {"md": found.get(key), "data": data.get(key)} + return out + + +def _steps(cells: list[str]) -> list[float]: + return [ + float(_sq(c).lstrip("∼~~")) for c in cells if re.fullmatch(r"[∼~~]\d+(\.\d+)?", _sq(c)) + ] + + +def _masonry_slope(doc: dict, md: dict) -> dict: + t = md["tables"]["F0413"] + found, group = {}, "" + for row in t["rows"]: + cells = [c for c in row] + if _sq(cells[0]) in ("메쌓기", "찰쌓기"): + group, cells = _sq(cells[0]), cells[1:] + found[f"{group}/{_sq(cells[0])}"] = [_num(c) for c in cells[1:] if _sq(c)] + data = {f"{g}/{f}": v for g, faces in doc["table"].items() for f, v in faces.items()} + out = { + k: {"md": found.get(k), "data": data.get(k)} + for k in sorted(set(found) | set(data)) + if found.get(k) != data.get(k) + } + if _steps(t["headers"]) != doc["steps_m"]: + out["steps_m"] = {"md": _steps(t["headers"]), "data": doc["steps_m"]} + return out + + +def _masonry_back_length(doc: dict, md: dict) -> dict: + t = md["tables"]["F0412"] + labels = [re.sub(r"\(.*", "", x) for x in t["rows"][1][0].split()] # 메쌓기(㎝) 찰쌓기(㎝) + found = {label: [] for label in labels} + for cell in t["rows"][1][1:6]: + for label, part in zip(labels, cell.split()): + lo, hi = _range(part) + found[label].append([lo, hi]) + out = {} + for label in sorted(set(found) | set(doc["table_cm"])): + data = [[_f(a), _f(b)] for a, b in doc["table_cm"].get(label, [])] + if found.get(label) != data: + out[f"table_cm/{label}"] = {"md": found.get(label), "data": doc["table_cm"].get(label)} + if _steps(t["rows"][0]) != doc["steps_m"]: + out["steps_m"] = {"md": _steps(t["rows"][0]), "data": doc["steps_m"]} + return out + + +def _stone_grid(table: dict) -> dict[str, dict[str, float | None]]: + """뭉친 칸 표(`야면석(㎥) 깬잡석(㎥)` | `0.06 0.09`) → {돌: {뒷길이: 값}}.""" + lengths = [re.sub(r"[^\d]", "", h) for h in table["headers"][1:] if re.search(r"\d+㎝", h)] + grid = {} + for row in table["rows"]: + names = [re.sub(r"\(.*", "", n) for n in re.findall(r"[가-힣]+\s*[가-힣]*\(㎥\)", row[0])] + names = [_sq(n) for n in names] + for name in names: + grid[name] = {} + for length, cell in zip(lengths, row[1:]): + for name, part in zip(names, cell.split()): + grid[name][length] = _num(part) + return grid + + +def _stone_kind(doc: dict, md: dict) -> dict: + out = {} + first = {"야면석·호박돌": "야면석", "깬잡석": "깬잡석", "깬돌": "깬돌", "견치돌": "견치돌"} + for field, table in (("wedge_stone_m3_per_m2", "F0408"), ("fill_concrete_m3_per_m2", "F0410")): + grid = _stone_grid(md["tables"][table]) + fill_rows = { + "깬돌": "깬돌", + "견치돌": "견치돌", + "깬잡석": "깬잡석", + "야면석·호박돌": "야면석", + } + for kind in doc["kinds"]: + name = first[kind] if field.startswith("wedge") else fill_rows[kind] + data = {k: _f(v) for k, v in doc[field][kind].items()} + if grid.get(name) != data: + out[f"{field}/{kind}"] = {"md": grid.get(name), "data": doc[field][kind]} + if field.startswith("fill") and grid.get("야면석") != grid.get("호박돌"): + out[f"{field}/호박돌"] = {"md": grid.get("호박돌"), "data": "야면석과 한 줄로 묶음"} + lengths = [int(re.sub(r"[^\d]", "", h)) for h in md["tables"]["F0408"]["headers"][1:]] + if lengths != doc["back_lengths_cm"]: + out["back_lengths_cm"] = {"md": lengths, "data": doc["back_lengths_cm"]} + if [first[k] for k in doc["kinds"]] != list(_stone_grid(md["tables"]["F0408"])): + out["kinds"] = {"md": list(_stone_grid(md["tables"]["F0408"])), "data": doc["kinds"]} + return out + + +def _masonry_class(doc: dict, md: dict) -> dict: + out = {} + back = [ + int(_numbers(c).pop()) for c in md["tables"]["F0407"]["rows"][0] if _sq(c).endswith("이하") + ] + if back != [c["max_cm"] for c in doc["back_length"]["classes"]]: + out["back_length"] = { + "md": back, + "data": [c["max_cm"] for c in doc["back_length"]["classes"]], + } + boulder = [] + for cell in md["tables"]["F0419"]["rows"][0]: + if nums := re.findall(r"\d+", cell): + boulder.append(f"{nums[0]}~{nums[1]}") + if boulder != doc["boulder_diameter"]["classes"]: + out["boulder_diameter"] = {"md": boulder, "data": doc["boulder_diameter"]["classes"]} + for name, code in doc["bond"]["codes"].items(): + number = code.removeprefix("FP-").replace("-0", "-").lstrip("0") + if not any(x.strip() == f"### {number}. {name}" for x in md["lines"]): + out[f"bond/{name}"] = {"md": f"### {number}. {name} 제목 없음", "data": code} + return out + + +def _machine_productivity(doc: dict, md: dict) -> dict: + out, var = {}, doc["variables"] + speed = {} # (궤도, 톤, 단) → [전진, 후진] + for track, table in zip(("무한궤도", "타이어"), md["dozer_tables"]): + gears = [_sq(c) for c in table["rows"][0]] + fwd = [i for i, h in enumerate(table["headers"]) if "전진" in h][0] + rev = [i for i, h in enumerate(table["headers"]) if "후진" in h][0] + for row in table["rows"][1:]: + ton = re.match(r"\d+", row[0]).group() + for g in range(1, rev - fwd + 1): + f, r = ( + _num(row[fwd + g - 1]), + _num(row[rev + g - 1]) if rev + g - 1 < len(row) else None, + ) + if f is not None and r is not None: + speed[(track, ton, g)] = [f, r] + assert gears[fwd] == "1단" or gears[fwd].startswith("1") + data = { + (r["track"], r["tonnage_ton"], r["gear"]): [ + float(r["forward_m_per_min"]), + float(r["reverse_m_per_min"]), + ] + for r in var["dozer_speed"]["records"] + } + for key in sorted(set(speed) | set(data)): + if speed.get(key) != data.get(key): + out["dozer_speed/" + "/".join(map(str, key))] = { + "md": speed.get(key), + "data": data.get(key), + } + # 삽날 용량 — 원문 md 가 표를 한 줄로 뭉갬(`0.51.11.5…`) · 톤 차례는 속도 표 둘에서 옴 + tons = sorted({int(k[1]) for k in speed} | {int(k[1]) for k in data}) + line = next(x for x in md["const_lines"] if "q" in x and "무한궤도" in x and "타 이 어" in x) + blades = {} + for track, part in zip( + ("무한궤도", "타이어"), re.split(r"타\s*이\s*어", line.split("무한궤도", 1)[1]) + ): + for ton, token in zip(tons, re.findall(r"\d\.\d|-", part)): + if token != "-": + blades[(track, str(ton))] = float(token) + data = { + (r["track"], r["tonnage_ton"]): float(r["blade_m3"]) for r in var["dozer_blade"]["records"] + } + for key in sorted(set(blades) | set(data)): + if blades.get(key) != data.get(key): + out["dozer_blade/" + "/".join(key)] = {"md": blades.get(key), "data": data.get(key)} + # 덤프 — 원문이 표가 아니라 식 글(`t3=1.1`). **기호 자리**에 적힌 수와 맞댐 + haul = _section(md["lines"], "10-12-1.") + for r in var["dump_haul"]["records"]: + found = _symbol_values(haul, DUMP_HAUL_SYMBOLS[r["key"]]) + if float(r["value"]) not in found: + out[f"dump_haul/{r['key']}"] = {"md": sorted(found), "data": r["value"]} + for i, r in enumerate(var["dump_material"]["records"], 1): + text = _section(md["lines"], f"10-12-{i}.") + for field, pattern in DUMP_MATERIAL_SYMBOLS.items(): + found = _symbol_values(text, pattern) + if float(r[field]) not in found: + out[f"dump_material/{r['label']}/{field}"] = {"md": sorted(found), "data": r[field]} + return out + + +#: 덤프 밑값 → 10-12-1 글의 기호 자리. +DUMP_HAUL_SYMBOLS = { + "truck_ton": r"덤프트럭\((\d+)ton\)", + "bucket_m3": r"q0=([\d.]+)", + "loader_cycle_sec": r"㎝s=(\d+)", + "speed_loaded_kmh": r"V1\(적재\)\s*\|\s*([\d.]+)", + "speed_empty_kmh": r"V2\(공차\)\s*\|\s*([\d.]+)", + "unload_min": r"t3=([\d.]+)", + "wait_min": r"t4=([\d.]+)", + "cover_min": r"t5=([\d.]+)", + "loading_cycle_sec": r"㎝=(\d+)\(180°\)", +} +#: 재료별 값 → 그 재료 절(10-12-1·2·3) 글의 기호 자리. `E=` 는 `Es=`·`E0=` 와 가름. +DUMP_MATERIAL_SYMBOLS = { + "unit_weight_ton_per_m3": r"γt=([\d.]+)", + "loose_factor": r"f=1/([\d.]+?)=?[,\s]", + "bucket_factor": r"(? set[float]: + return {float(m.rstrip(".")) for m in re.findall(pattern, text)} + + +CHECKS = { + "coef": _coef, + "material_surcharge": _material_surcharge, + "formwork_reuse": _formwork_reuse, + "rebar_complexity": _rebar_complexity, + "timber_structure_class": _timber_structure_class, + "masonry_slope": _masonry_slope, + "masonry_back_length": _masonry_back_length, + "stone_kind": _stone_kind, + "masonry_class": _masonry_class, + "machine_productivity": _machine_productivity, +} + + +# ── 맞대기 ────────────────────────────────────────────────────────────── +def load_sources() -> dict: + lines = MD.read_text(encoding="utf-8").splitlines() + tables = {t["id"]: t for t in md_tables(lines)} + const_lines = CONST_MACHINE_MD.read_text(encoding="utf-8").splitlines() + dozer = [t for t in md_tables(const_lines) if "전진속도" in "".join(t["headers"])][:2] + notes_12_3 = [] + for x in lines[tables["F0335"]["end"] :]: + if x.strip().startswith("[주]"): + notes_12_3.append(x) + break + return { + "md": { + "lines": lines, + "tables": tables, + "const_lines": const_lines, + "dozer_tables": dozer, + "notes_12_3": " ".join(notes_12_3), + }, + "docs": { + k: json.loads((ROOT / "resources" / p).read_text(encoding="utf-8")) + for k, p in FILES.items() + }, + } + + +def current_mismatches(src: dict) -> dict[str, dict]: + return {kind: check(src["docs"][kind], src["md"]) for kind, check in CHECKS.items()} + + +def load_known() -> dict[str, dict]: + return json.loads(KNOWN.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def src() -> dict: + return load_sources() + + +@pytest.mark.parametrize("kind", list(FILES)) +def test_칸마다_재거나_못잰다고_적힘(src: dict, kind: str) -> None: + """자료의 칸이 셋 중 하나 — 재는 칸 · 못 재는 칸(까닭) · 글 칸. 새 칸이 생기면 빨강.""" + fields = set(src["docs"][kind]) - TEXT_FIELDS + declared = CHECKED[kind] | set(NOT_CHECKED.get(kind, {})) + assert fields == declared, ( + f"{kind} — 안 적힌 칸 {sorted(fields - declared)} · 없는 칸 {sorted(declared - fields)}" + ) + + +@pytest.mark.parametrize("kind", list(FILES)) +def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None: + """알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강(`why` 는 뺌).""" + now = current_mismatches(src)[kind] + known = {k: _without_why(v) for k, v in load_known()[kind].items()} + msgs = [ + f" 새로 어긋남 {k}: {json.dumps(now[k], ensure_ascii=False)}" + for k in sorted(set(now) - set(known)) + ] + msgs += [ + f" 모습 바뀜 {k}: 목록 {json.dumps(known[k], ensure_ascii=False)}\n 지금 {json.dumps(now[k], ensure_ascii=False)}" + for k in sorted(set(now) & set(known)) + if now[k] != known[k] + ] + msgs += [f" 고쳐짐 {k} — 목록에서 지울 것(--prune)" for k in sorted(set(known) - set(now))] + assert not msgs, f"{kind} {len(msgs)}건 — 원문 ↔ 자료:\n" + "\n".join(msgs) + + +if __name__ == "__main__" and "--prune" in sys.argv: + # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). + current = current_mismatches(load_sources()) + data = load_known() + for kind in FILES: + before = len(data[kind]) + data[kind] = { + k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v) + } + print(f"{kind}: {before} → {len(data[kind])}") + KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") diff --git a/resources/tester/test_base_prices_source_match.py b/resources/tester/test_base_prices_source_match.py new file mode 100644 index 00000000..1a74c62a --- /dev/null +++ b/resources/tester/test_base_prices_source_match.py @@ -0,0 +1,659 @@ +"""원문 ↔ 기초단가 다섯 갈래(노임·기계·자재·유가·제비율) 맞대기 — 2026-09-18 랩탑 메인. + +사람이 손으로 하던 값 대조를 시험으로 옮김. 원문 자리는 자료 `sources[].path` 를 그대로 씀. + +- 어긋남·셈 틀어짐은 `fixtures/base_prices_source_known.json` 에 적어 두고 **그 목록 밖**이면 빨강. + ⚠ 목록에 있는데 고쳐졌거나 모습이 바뀐 것도 빨강 — 목록이 낡지 않게. + 고친 뒤 목록 지우기: `python resources/tester/test_base_prices_source_match.py --prune` + (지우기만 함 · 새 어긋남은 안 보탬). +- `*_blind` 갈래 = **시험이 못 보는 자리**. 못 보는 것을 본 척하지 않고 목록에 까닭과 함께 둠. + 자료에 안 재는 칸이 새로 생겨도 목록 밖이라 빨강. + +⚠ 빌더 코드를 안 씀 — 원문 읽기를 여기서 따로 함. 표 읽기(`md_tables`)만 옆 시험 한 벌. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import openpyxl +import pytest +from openpyxl.utils import get_column_letter +from test_work_item_master_source_match import ROOT, _without_why, md_tables + +DATA = ROOT / "resources/data_cost_input_value" +KNOWN = Path(__file__).resolve().parent / "fixtures/base_prices_source_known.json" +FILES = { + "labor_const": "labor_const_2026-01-01.json", + "labor_mfg": "labor_mfg_2026-07-01.json", + "mach": "mach_base_2026.json", + "mat": "mat_price_public_2026-08-14.json", + "oil": "oil_2026-08-14.json", + "oil_regional": "oil_regional_2026-09-09.json", + "rates": "rates_2026.json", +} +KINDS = ["labor", "machine", "material", "oil", "rate"] +KIND_NAMES = { + "labor": "노임", "machine": "기계", "material": "자재", "oil": "유가", "rate": "제비율", + **{f"{k}_blind": f"{n} — 못 보는 자리" for k, n in + {"labor": "노임", "machine": "기계", "material": "자재", "oil": "유가", "rate": "제비율"}.items()}, +} # fmt: skip + + +# ── 공통 ──────────────────────────────────────────────────────────────── +def _num(text): + """`12,000` · `0.9` · `(1.8)` · `16%` → 수. `-` · `*` · 빈 칸 → None. 수가 아니면 ValueError.""" + t = str(text).strip().strip("()%").replace(",", "") + if t in ("", "-", "*", "None"): + return None + return float(t) if "." in t else int(t) + + +def _t(value) -> str: + return re.sub(r"\s+", "", str(value)) + + +def _compare(out: dict, key: str, source: dict | None, data: dict | None, fields) -> None: + """한 줄 맞대기 — 한쪽에만 있으면 줄 통째로, 둘 다 있으면 칸마다 `{source, data}`.""" + if source is None or data is None: + if source != data: + out[key] = {"source": source, "data": data} + return + for f in fields: + if source.get(f) != data.get(f): + out[f"{key} {f}"] = {"source": source.get(f), "data": data.get(f)} + + +def _unchecked(blind: dict, where: str, records, checked) -> None: + """자료에 있는데 원문과 안 맞대는 칸 — 못 본다고 적음.""" + for field in sorted({k for r in records for k in r} - set(checked)): + blind[f"안 잰 칸 {where}.{field}"] = {"what": "원문과 안 맞댐"} + + +def _lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() + + +# ── 노임 ──────────────────────────────────────────────────────────────── +def read_labor_const(path: Path) -> dict: + """건설업 md — `| 직종코드 | 직종명 | 신뢰도 | 2026.1.1 | …` 표 한 벌 · 맨 앞 열이 적용일 노임.""" + lines = _lines(path) + table = next(t for t in md_tables(lines) if t["headers"][:2] == ["직종코드", "직종명"]) + head = "\n".join(lines[: table["line"]]) + hours = int(re.search(r"1일 (\d+)시간 기준", head).group(1)) + rows = { + code: { + "occupation_name": name, + "reliability": mark or None, + "hours_per_day": hours, + "daily_wage_krw": _num(wage), + "status": "not_published" if wage == "-" else "published", + } + for code, name, mark, wage, *_ in table["rows"] + } + y, m, d = table["headers"][3].split(".") + published, effective = re.search(r"공표 ([\d-]+), 적용 ([\d-]+)", head).groups() + dates = {"publication_date": published, "effective_date": effective} + return {"rows": rows, "wage_column": f"{y}-{int(m):02d}-{int(d):02d}", "dates": dates} + + +_JOB = re.compile(r"^(\d+)\.(.+)$") + + +def read_labor_mfg(path: Path) -> dict: + """제조업 md — `업 종` 표들. 업종 칸은 빈 줄이 위 칸을 이어받음 · 하위 업종(`화학 공학`)은 `상위 / 하위`.""" + lines = _lines(path) + rows, main, sub = {}, "", "" + for table in md_tables(lines): + if table["headers"][0] != "업 종": + continue + for r in table["rows"]: + i = next((k for k, c in enumerate(r) if _JOB.match(c)), None) + if i is None: + continue + if r[0]: + main, sub = r[0], "" + if i == 2 and r[1]: + sub = r[1] + code, name = _JOB.match(r[i]).groups() + wage = r[i + 1] + rows[code] = { + "occupation_name": name, + "industry": f"{main} / {sub}" if sub else main, + "daily_wage_krw": _num(wage.lstrip("*")), + "variation_coefficient": _num(r[i + 2]), + "status": "not_published" if wage == "*" else "published", + "source_flag": re.match(r"\**", wage).group() or None, + } + text = "\n".join(lines) + y, m, d = re.search(r"적용시점 : (\d{4})\. (\d+)\. (\d+)\.", text).groups() + sy, sm = re.search(r"조사 기 준 일 : (\d{4})\. (\d+)\.", text).groups() + return { + "rows": rows, + "dates": {"effective_date": f"{y}-{int(m):02d}-{int(d):02d}", "survey_month": f"{sy}-{int(sm):02d}"}, + } # fmt: skip + + +LABOR = { + "labor_const": ("건설", "labor_rate", ("occupation_name", "reliability", "hours_per_day", "daily_wage_krw", "status")), + "labor_mfg": ("제조", "labor_mfg", ("occupation_name", "industry", "daily_wage_krw", "variation_coefficient", "status", "source_flag")), +} # fmt: skip + + +def check_labor(src: dict, bad: dict, blind: dict) -> dict: + counts = {} + for name, (label, var, fields) in LABOR.items(): + source, data = src[name]["source"], src[name]["data"] + rows = source["rows"] + records = {r["occupation_code"]: r for r in data["variables"][var]["records"]} + for code in rows | records: + _compare(bad, f"{label} {code}", rows.get(code), records.get(code), fields) + _compare(bad, f"{label} 날짜", source["dates"], data, source["dates"]) + if source.get("wage_column", data["effective_date"]) != data["effective_date"]: + bad[f"{label} 노임 열"] = { + "source": source["wage_column"], + "data": data["effective_date"], + } + for alias, code in data["variables"].get("aliases", {}).items(): + if code not in rows: + bad[f"{label} 별칭 {alias}"] = {"source": None, "data": code} + counts[f"{label} 직종 수"] = (len(rows), len(records)) + counts[f"{label} 원문에 없는 자료 줄"] = (0, len(set(records) - set(rows))) + blind[f"{label} pdf"] = {"what": src[name]["paths"][0].name} + _unchecked(blind, var, records.values(), {"occupation_code", *fields}) + blind["건설 별칭 이름"] = {"what": sorted(src["labor_const"]["data"]["variables"]["aliases"])} + return counts + + +# ── 기계 ──────────────────────────────────────────────────────────────── +def _codes(cell: str, prefix: str | None) -> tuple[list[str] | None, str | None]: + """`0101-0007 0010 0012` → 코드 목록 · 머리(`0101`)는 앞 코드를 이어받음(앞 표에서도). + + `9020- 0010` 처럼 머리만 떨어진 토막도 받음. 코드 아닌 토막이 끼면 `(None, None)`. + """ + codes = [] + for tok in cell.split(): + m = re.fullmatch(r"-?(?:(\d{4})-)?(\d{4})?", tok) + prefix = (m.group(1) or prefix) if m and tok.strip("-") else None + if prefix is None: + return None, None + if m.group(2): + codes.append(f"{prefix}-{m.group(2)}") + return codes, prefix + + +LOSS_COLS = {"economic_life_hours": "내용시간", "annual_standard_hours": "가동시간", "depreciation_ratio": "상각비율", "maintenance_ratio": "정비비율", "annual_management_ratio": "관리비율"} # fmt: skip +LOSS_COEF = ("depreciation_coefficient_1e_minus_7", "maintenance_coefficient_1e_minus_7", "management_coefficient_1e_minus_7", "source_coefficient_1e_minus_7") # fmt: skip +FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day") # fmt: skip + + +def _fuel(tok: str) -> tuple: + """주연료 토막 — `9.0` 경유([주]① ㉰: 표시 없는 것은 경유) · `휘발유0.7` · `중유487.2` · `-` 없음.""" + if tok == "-": + return None, None + m = re.fullmatch(r"(휘발유|중유)?(\d+(?:\.\d+)?)", tok) + if m is None: + raise ValueError(tok) + kind = {"휘발유": "gasoline", "중유": "heavy_oil", None: "diesel"}[m.group(1)] + return kind, float(m.group(2)) + + +def read_machine(path: Path) -> dict: + """제8장 md — 8-3 손료 · 8-4 운전경비 · 8-5 가격. 여러 코드가 한 칸에 뭉친 줄은 토막 수가 맞을 때만 읽음.""" + lines = _lines(path) + tables = md_tables(lines) + at = {w: next(i for i, x in enumerate(lines, 1) if w in x) for w in ("8-3 기계손료", "8-4 운전경비", "8-4-9 [90]", "8-5 기계가격")} # fmt: skip + out = {k: {} for k in ("price", "loss", "fuel", "fuel_tables", "skipped", "names")} + out["lines"] = lines + name_at = re.compile(r"\((\d{4})\) ?(.+?)(?=시 간 당\(10-7\)|\((?:'\d\d|\d{4}\))|$)") + for x in lines[ + at["8-3 기계손료"] - 1 : at["8-4 운전경비"] + ]: # 손료 표 머리 `(0101) 불도저(무한궤도)` + for m in name_at.finditer("" if x.lstrip().startswith("|") else x): + out["names"].setdefault(m.group(1), m.group(2).strip()) + prefix = None + for t in tables: + if t["line"] > at["8-5 기계가격"] and t["headers"] == ["기 종", "분류번호", "가격(₩)"]: + for n, r in enumerate(t["rows"]): + codes, prefix = _codes(r[1], prefix) + prices = r[2].split() + if codes is None or len(codes) != len(prices): + out["skipped"][f"가격 줄 L{t['line'] + 2 + n}"] = {"what": r[1][:40]} + continue + out["price"].update( + (c, {"price_thousand_krw": _num(p)}) for c, p in zip(codes, prices) + ) + prefix = None + for t in tables: + head = [_t(h) for h in t["headers"]] + if not (at["8-3 기계손료"] < t["line"] < at["8-4 운전경비"] and head[0] == "분류번호"): + continue + cols = {f: next(i for i, h in enumerate(head) if w in h) for f, w in LOSS_COLS.items() if any(w in h for h in head)} # fmt: skip + sub = next((r for r in t["rows"] if not r[0]), []) + if "상각비 계수" in sub: + cols.update(zip(LOSS_COEF, range(sub.index("상각비 계수"), 99))) + else: + cols[LOSS_COEF[3]] = head.index("시간당(10-7)") + for n, r in enumerate(t["rows"]): + if not r[0]: + continue + codes, prefix = _codes(r[0], prefix) + try: + values = {f: [_num(x) for x in r[i].split()] for f, i in cols.items()} + if not codes or any(len(v) != len(codes) for v in values.values()): + raise ValueError(r[0]) + except ValueError: + out["skipped"][f"손료 줄 L{t['line'] + 2 + n}"] = {"what": r[0][:40]} + continue + out["loss"].update( + (c, {f: v[k] for f, v in values.items()}) for k, c in enumerate(codes) + ) + for k in range(at["8-3 기계손료"], at["8-4 운전경비"]): + if not lines[k - 1].lstrip().startswith("|") and "시 간 당(10-7)" in lines[k - 1]: + heads = re.findall(r"\((\d{4})\) ?(?:[^|()]{0,30}\([^)]*\)){0,2}[^|()]{0,30}?시 간 당\(10-7\)", lines[k - 1]) # fmt: skip + out["skipped"][f"손료 뭉개진 글 L{k}"] = {"what": heads} + prefix = None + for t in tables: + if not at["8-4 운전경비"] < t["line"] < at["8-5 기계가격"]: + continue + out["fuel_tables"][t["line"]] = {"headers": t["headers"], "rows": t["rows"]} + if t["line"] > at["8-4-9 [90]"] or t["headers"][0] != "분류번호": + continue + for r in t["rows"]: + codes, prefix = _codes(r[0], prefix) + cols = [r[3].split(), r[4].split(), r[5].split()] + try: + if not codes or any(len(c) != len(codes) for c in cols): + raise ValueError(r[0]) + parsed = [(*_fuel(f), _num(m), _num(o)) for f, m, o in zip(*cols)] + except ValueError: + out["skipped"][f"연료 표 L{t['line']}"] = {"codes": codes and [codes[0], codes[-1]]} + out["fuel_unread"] = out.get("fuel_unread", set()) | set(codes or ()) + continue + out["fuel"].update((c, dict(zip(FUEL_FIELDS, p))) for c, p in zip(codes, parsed) if p[1] is not None) # fmt: skip + return out + + +#: 손료보정 표는 글로 뭉개짐 — 기종 글 바로 뒤에 `암석작업` `전석섞인토사` 두 수가 붙어 있음(`덤프트럭2510`). +ROCK = {"bulldozer_under_19_ton": "불도저(19톤이상제외)", "crawler_excavator_or_loader": "굴착기(무한궤도)및로더(무한궤도)", "dump_truck": "덤프트럭"} # fmt: skip + + +def check_machine(src: dict, bad: dict, blind: dict) -> dict: + source, v = src["mach"]["source"], src["mach"]["data"]["variables"] + counts = {} + for label, var, rows, fields in ( + ("가격", "mach_price", source["price"], ("price_thousand_krw",)), + ("손료", "mach_loss_coef", source["loss"], (*LOSS_COLS, *LOSS_COEF)), + ("연료", "mach_fuel_rate", source["fuel"], FUEL_FIELDS), + ): + records = v[var].get("records") or v[var]["parsed_records"] + by_code = {r["machine_code"]: r for r in records} + unread = source.get("fuel_unread", set()) if label == "연료" else set() + missing: dict[str, list] = {} + for code in rows | by_code: + if code in unread and code not in rows: + blind[f"연료 못 읽은 줄 {code}"] = {"what": "원문 표 토막 수가 안 맞음"} + continue + if code not in by_code: + missing.setdefault(code[:4], []).append(code) + continue + if code in rows: # 원문에 칸이 없는 표(축약 손료표)는 있는 칸만 맞댐 + rows_code = {f: rows[code].get(f, by_code[code].get(f)) for f in fields} + _compare(bad, f"{label} {code}", rows_code, by_code[code], fields) + else: + _compare(bad, f"{label} {code}", None, by_code[code], fields) + heading, name = source["names"].get(code[:4]), by_code[code].get("machine_name") + if _t(heading) != _t(name): + bad[f"{label} {code} machine_name"] = {"source": heading, "data": name} + for head, codes in missing.items(): + bad[f"{label} {head} 자료에 없음"] = {"source": codes, "data": None} + counts[f"{label} 코드 수"] = (len(rows), len(by_code)) + counts[f"{label} 원문에 없는 자료 줄"] = (0, len(set(by_code) - set(rows) - unread)) + _unchecked(blind, var, records, {"machine_code", "machine_name", *fields}) + blind.update(source["skipped"]) + tables = v["mach_fuel_rate"]["source_tables"] + for st in tables: + t = source["fuel_tables"].get(st["line"]) + if t != {"headers": st["headers"], "rows": st["rows"]}: + bad[f"운전경비 원표 L{st['line']}"] = {"source": t, "data": _without_why(st)} + counts["운전경비 원표 수"] = (len(source["fuel_tables"]), len(tables)) + _unchecked(blind, "mach_fuel_rate.source_tables", tables, {"line", "headers", "rows"}) + + op = next(t for t in md_tables(source["lines"]) if t["headers"] == ["구 분", "해 당 기 계"]) + labels = [_t(r[0]) for r in op["rows"]] + names = src["labor_const"]["source"]["rows"] + for rule in v["mach_operator_map"]["rules"]: + name = names.get(rule["occupation_code"], {}).get("occupation_name") + if name not in labels: + bad[f"운전사 {rule['rule_id']}"] = {"source": labels, "data": [rule["occupation_code"], name]} # fmt: skip + blind["운전사 기종별 판단"] = {"what": len(v["mach_operator_map"]["explicit_mappings"])} + _unchecked( + blind, "mach_operator_map.rules", v["mach_operator_map"]["rules"], {"occupation_code"} + ) + + text = _t("".join(source["lines"])) + for rule in v["mach_rock_adj"]["rules"]: + group, pair = rule["machine_group"], f"{rule['rock_work']}{rule['boulder_mixed_soil']}" + if group in ROCK: + ok = f"{ROCK[group]}{pair}" in text + else: + ok = pair == "00" and "불도저(19톤이상)의경우는보정하지않는다" in text + if not ok: + bad[f"손료보정 {group}"] = {"source": ROCK.get(group), "data": pair} + blind["손료보정 수 자리 가름"] = {"what": "뭉개진 글의 이어 붙은 수(2510)를 통째로 맞댐"} + _unchecked(blind, "mach_rock_adj.rules", v["mach_rock_adj"]["rules"], {"machine_group", "rock_work", "boulder_mixed_soil"}) # fmt: skip + return counts + + +# ── 자재 ──────────────────────────────────────────────────────────────── +MAT_FIELDS = { + "classification_code": "prdctClsfcNo", "classification_name": "prdctClsfcNoNm", + "specification": "krnPrdctNm", "unit": "unit", "price_krw": "prce", "notice_datetime": "nticeDt", + "notice_number": "prceNticeNo", "business_division_code": "bsnsDivCd", + "business_division_name": "bsnsDivNm", "vat_basis": "vatYnNm", "price_type": "prceDiv", + "delivery_condition": "dlvryCndtnNm", "field": "분야", +} # fmt: skip + + +def read_material(path: Path) -> dict: + """나라장터 API 스냅숏 — 물품식별번호마다 **값이 실린 공시 중 가장 나중** 것(같은 때가 둘이면 둘 다). + + 값 없는 공시(`0`·빈 칸)를 건너뜀은 자료 `selection_policy` 가 밝힌 규칙. + """ + raw = json.loads(path.read_text(encoding="utf-8")) + latest: dict[str, list[dict]] = {} + for row in raw: + if not _num(row["prce"]): + continue + mapped = {k: (_num(row[f]) if k == "price_krw" else row[f]) for k, f in MAT_FIELDS.items()} + best = latest.setdefault(row["prdctIdntNo"], []) + if best and best[0]["notice_datetime"] > mapped["notice_datetime"]: + continue + if best and best[0]["notice_datetime"] < mapped["notice_datetime"]: + best.clear() + if mapped not in best: + best.append(mapped) + return {"rows": len(raw), "ids": len({r["prdctIdntNo"] for r in raw}), "latest": latest} + + +def check_material(src: dict, bad: dict, blind: dict) -> dict: + source, data = src["mat"]["source"], src["mat"]["data"] + records = {r["item_code"]: r for r in data["variables"]["mat_price"]["records"]} + latest = source["latest"] + for code in latest | records: + cands, rec = latest.get(code, []), records.get(code) + if len(cands) > 1: + if rec and any(all(rec.get(k) == c[k] for k in MAT_FIELDS) for c in cands): + blind[f"같은 때 공시 {code}"] = {"what": len(cands)} + else: + bad[code] = {"source": cands, "data": rec} + continue + _compare(bad, code, cands[0] if cands else None, rec, MAT_FIELDS) + blind["API 스냅숏"] = {"what": src["mat"]["paths"][0].name} + blind["원천 없는 묶음"] = {"what": [g["group"] for g in data.get("excluded_named_groups", [])]} + _unchecked(blind, "mat_price", records.values(), {"item_code", *MAT_FIELDS}) + return { + "원천 줄 수": (source["rows"], data["source_row_count"]), + "물품코드 수": (source["ids"], len(records)), + "원문에 없는 자료 줄": (0, len(set(records) - set(latest))), + } + + +# ── 유가 ──────────────────────────────────────────────────────────────── +def check_oil(src: dict, bad: dict, blind: dict) -> dict: + counts = {} + raw, data = src["oil"]["source"], src["oil"]["data"] + prices = {r["PRODCD"]: r for r in raw["전국평균"]} + for var, v in data["variables"].items(): + row = prices.get(v["source_product_code"]) + d = row and row["TRADE_DT"] + source = row and {"value": _num(row["PRICE"]), "source_product_name": row["PRODNM"], "date": f"{d[:4]}-{d[4:6]}-{d[6:]}"} # fmt: skip + _compare(bad, f"전국 {var}", source, v, ("value", "source_product_name", "date")) + _compare(bad, "전국 적용일", {"effective_date": raw["수집일"]}, data, ("effective_date",)) + _unchecked(blind, "oil", data["variables"].values(), {"value", "source_product_name", "date", "source_product_code"}) # fmt: skip + + raw, data = src["oil_regional"]["source"], src["oil_regional"]["data"] + for var, v in data["variables"].items(): + rows = {r["SIDOCD"]: {"sido_name": r["SIDONM"], "value": r["PRICE"]} for r in raw["시도별"].get(v["source_product_code"], [])} # fmt: skip + records = {r["sido_code"]: r for r in v["records"]} + for code in rows | records: + _compare(bad, f"시도 {var} {code}", rows.get(code), records.get(code), ("sido_name", "value")) # fmt: skip + _compare(bad, f"시도 {var} 날짜", {"date": raw["수집일"]}, v, ("date",)) + counts[f"시도 {var} 줄 수"] = (len(rows), len(records)) + _unchecked(blind, f"oil_regional.{var}.records", v["records"], {"sido_code", "sido_name", "value"}) # fmt: skip + _unchecked(blind, "oil_regional", data["variables"].values(), {"records", "date", "source_product_code"}) # fmt: skip + blind["API 스냅숏"] = {"what": [src[n]["paths"][0].name for n in ("oil", "oil_regional")]} + blind["시도 거래일"] = {"what": "시도별 원천에 거래일 칸이 없음 — 수집일과만 맞댐"} + return counts + + +# ── 제비율 ────────────────────────────────────────────────────────────── +def read_xlsx(path: Path) -> dict: + """첫 시트 칸 — `raw` 는 적힌 칸만, `filled` 는 병합 칸 전체에 왼쪽 위 값을 퍼뜨림.""" + ws = openpyxl.load_workbook(path, data_only=True).worksheets[0] + raw = {c.coordinate: c.value for row in ws.iter_rows() for c in row if c.value not in (None, "")} # fmt: skip + filled = dict(raw) + for m in ws.merged_cells.ranges: + top = ws.cell(m.min_row, m.min_col).value + for row in range(m.min_row, m.max_row + 1): + for col in range(m.min_col, m.max_col + 1): + if top not in (None, ""): + filled[f"{get_column_letter(col)}{row}"] = top + return {"raw": raw, "filled": filled} + + +def read_pension(path: Path) -> dict: + """국민연금법 — 부칙 제4조① 해마다 사용자 부담률(1만분의) · 제88조③ 그 뒤 부담률(1천분의).""" + text = path.read_text(encoding="utf-8") + part = re.search(r"제4조\(연금보험료에 관한 특례\) ①(.*?)②", text, re.S).group(1) + years = {int(y): int(n) / 100 for y, n in re.findall(r"(\d{4})년은 1만분의 (\d+)", part)} + after = re.search(r"사용자가 각각 부담하되, 그 금액은 각각 기준소득월액의 1천분의 (\d+)", text) + return {"years": years, "after": int(after.group(1)) / 10} + + +BASE = {"노": "total_labor_cost", "직노": "direct_labor_cost", "건강보험료": "health_insurance_amount", "재+노": "material_cost_plus_total_labor_cost", "재+노+경": "material_plus_labor_plus_expense", "노+경+일": "labor_plus_expense_plus_overhead", "직접공사비": "direct_construction_cost", "재+직노": "material_cost_plus_direct_labor_cost"} # fmt: skip +BASE_CELLS = {"rate_goyong.base": "B63", "rate_indirect_labor.base": "AC7", "rate_other_expense.base": "AL7", "rate_overhead.base": "BD7", "rate_profit.base": "BS7", "rate_equipment_payment_guarantee.base": "BZ7", "rate_subcontract_payment_guarantee.base": "BZ63", "rate_environment.base": "AU63", "rate_performance_guarantee_fee.base": "B95", "rate_safety_pct.base_without_owner_supplied_material": "EB7"} # fmt: skip +SINGLE = {"rate_sanjae": "BZ51", "rate_health": "AU51", "rate_care": "BD51", "rate_retirement_mutual_aid": "AU110", "rate_wage_claim_contribution": "CS99", "rate_asbestos_contribution": "BZ99", "rate_pension": "BR51"} # fmt: skip +SCALE = {"10억미만": "lt_1_billion", "10억-50억미만": "1_to_5_billion", "50억-300억미만": "5_to_30_billion", "300억-1000억미만": "30_to_100_billion", "1000억이상": "gte_100_billion"} # fmt: skip +TERM = {"6개월이하(183일)": "lte_183_days", "7~12개월(365일)": "184_to_365_days", "13~36개월(1095일)": "366_to_1095_days", "36개월초과(1096일)": "gte_1096_days"} # fmt: skip +WORK_TYPE = {"토목": "civil", "조경": "landscape", "산업설비(토목)": "industrial_facilities_civil"} +SAFETY_SCALE = {"5억미만": "lt_500_million", "5억~50억미만": "500_million_to_5_billion", "50억이상": "gte_5_billion", "추정금액800억미만건설공사(주공종이토목인경우1,000억)": "gte_5_billion_below_manager_threshold", "추정금액800억이상건설공사(주공종이토목인경우1,000억)": "gte_5_billion_at_or_above_manager_threshold"} # fmt: skip +SAFETY_WORK = {"건축공사": "building", "토목공사": "civil", "중건설공사": "heavy_construction", "특수건설공사": "special_construction"} # fmt: skip +CIVIL_PRICE = {"5억미만": "lt_5_billion", "5억-30억미만": "lt_5_billion", "30억-50억미만": "lt_5_billion", "50억-100억미만": "5_to_30_billion", "100억-300억미만": "5_to_30_billion", "300억-1000억미만": "30_to_100_billion", "1000억이상": "gte_100_billion"} # fmt: skip +SPECIAL_PRICE = {"5억미만": "lt_500_million", "5억-30억미만": "500_million_to_3_billion", "30억-50억미만": "3_to_10_billion", "50억-100억미만": "3_to_10_billion", "100억-300억미만": "10_to_30_billion"} # fmt: skip +#: (라벨 열, 첫 줄, 끝 줄, 값 열, 라벨→자료 구간, 자료 열쇠 틀) +BANDS = [ + ("B", 67, 82, "AH", {"[1등급]1400억이상": "1|gte_140_billion", "[2등급]900억~1400억미만": "2|90_to_140_billion", "[3등급]570억~900억미만": "3|57_to_90_billion", "[4등급]370억~570억미만": "4|37_to_57_billion", "[5등급]220억~370억미만": "5|22_to_37_billion", "[6등급]140억~220억미만": "6|14_to_22_billion", "[7등급]고시금액~140억미만": "7|official_threshold_to_14_billion", "7등급미만": "below_7|below_official_threshold"}, "rate_goyong.brackets[{}].rate_percent"), + ("AZ", 68, 94, "BU", {"도로(예시:교량,터널,활주로등)": "civil_road", "플랜트(예시:발전소,쓰레기소각장등)": "civil_plant", "지하철": "civil_subway", "철도": "civil_railway", "상하수도(예시:폐수,하수처리장,정수장등)": "civil_water_and_sewer", "항만": "civil_port", "(오탁방지막또는준설토방지막을설치하는경우)": "civil_port_with_silt_screen", "댐": "civil_dam", "택지개발": "civil_land_development", "그밖의토목공사(하천등)": "civil_other", "조경": "landscape", "주택(재개발및재건축)": "building_housing_redevelopment", "주택(신축)": "building_new_housing", "그밖의건축공사": "building_other"}, "rate_environment.all_work_types[{}].rate_percent"), + ("BZ", 68, 83, "DC", {"50억미만": "lt_5_billion", "50억이상-100억미만": "5_to_10_billion", "100억이상-300억미만": "10_to_30_billion", "300억이상종심∙종평제(토목및산업설비)": "gte_30_billion_integrated_civil_or_industrial", "300억이상종심∙종평제(건축)": "gte_30_billion_integrated_building", "턴키∙대안공사": "turnkey_or_alternative"}, "rate_subcontract_payment_guarantee.brackets[{}].rate_percent"), + ("BZ", 19, 30, "CI", {"토목공사(토건)": "civil_general", "산업∙설비공사": "industrial_facilities", "조경공사": "landscape"}, "rate_equipment_payment_guarantee.general_construction[{}].rate_percent"), + ("CN", 19, 38, "DE", {"준설공사,포장공사": "dredging_or_paving_or_earthwork_or_scaffolding_dismantling", "토공사,비계구조물해제공사": "dredging_or_paving_or_earthwork_or_scaffolding_dismantling", "상하수도설비공사,수중공사": "water_sewer_or_underwater_or_boring_grouting", "보링그라우팅공사": "water_sewer_or_underwater_or_boring_grouting", "석공사,철근콘크리트공사": "stone_or_reinforced_concrete", "조경시설물설치,조경식재공사": "landscape_facility_or_planting_or_painting_or_rail_track_or_steel_installation", "도장공사,철도궤도공사": "landscape_facility_or_planting_or_painting_or_rail_track_or_steel_installation", "철강재설치공사": "landscape_facility_or_planting_or_painting_or_rail_track_or_steel_installation", "그외": "other"}, "rate_equipment_payment_guarantee.specialty_construction[{}].rate_percent"), + ("B", 98, 112, "P", {"70억미만": "lt_7_billion", "70억이상-120억미만": "7_to_12_billion", "120억이상-250억미만": "12_to_25_billion", "250억이상-500억미만": "25_to_50_billion", "500억이상": "gte_50_billion"}, "rate_performance_guarantee_fee.brackets[{}].formula"), + ("AU", 15, 46, "BD", CIVIL_PRICE, "rate_overhead.civil_landscape_industrial[{}].rate_percent"), + ("AU", 15, 46, "BL", SPECIAL_PRICE, "rate_overhead.specialty_electric_communication_fire_other[{}].rate_percent"), + ("AU", 15, 46, "BS", CIVIL_PRICE, "rate_profit.brackets[{}].rate_percent"), +] # fmt: skip + + +def _formula(text) -> tuple[float, ...]: + """수수료 식 속 수 — `79만원`·`75억원` 은 원으로. 원문 `[79만원+(직공비-75억원)x0.0070%]` ↔ 자료 식 글.""" + unit = {"만원": 10**4, "억원": 10**8, "": 1} + return tuple(float(n.replace(",", "")) * unit[u] for n, u in re.findall(r"(\d[\d,.]*)(만원|억원)?", str(text))) # fmt: skip + + +def rates_expected(xlsx: dict, pension: dict) -> dict: + """조달청 제비율 xlsx · 국민연금법 → 자료 `variables` 를 편 열쇠(`flatten`)와 같은 꼴의 값.""" + raw, filled = xlsx["raw"], xlsx["filled"] + exp = {key: BASE[re.search(r"\(([^)]*)\)", _t(raw[cell])).group(1)] for key, cell in BASE_CELLS.items()} # fmt: skip + for var, cell in SINGLE.items(): + base, rate = re.fullmatch(r"\(([^)]*)\)x([\d.]+)", _t(raw[cell])).groups() + exp[f"{var}.base"] = BASE[base] + exp["연금 2026 xlsx" if var == "rate_pension" else f"{var}.rate_percent"] = float(rate) + for year, rate in (pension["years"] | {2033: pension["after"]}).items(): + exp[f"rate_pension.annual_rates[{year}].rate_percent"] = rate + exp["rate_pension.rate_from_2033_percent"] = pension["after"] + for r in range(15, 55): + if (term := raw.get(f"M{r}")) is None: + continue + for var, cols in (("rate_indirect_labor", "AC AF AI"), ("rate_other_expense", "AL AO AR")): + for col in cols.split(): + key = f"{SCALE[_t(filled[f'B{r}'])]}|{TERM[_t(term)]}|{WORK_TYPE[_t(raw[f'{col}9'])]}" # fmt: skip + exp[f"{var}.brackets[{key}].rate_percent"] = _num(filled[f"{col}{r}"]) + unit = 1000 if "천원" in raw["FA15"] else 1 + for r in range(19, 51): + if (label := raw.get(f"EB{r}")) is None: + continue + scale = SAFETY_SCALE[_t(filled.get(f"DR{r}", filled[f"DK{r}"]))] + key = f"rate_safety_pct.brackets[{scale}|{SAFETY_WORK[_t(label)]}]" + exp[f"{key}.rate_percent"] = _num(filled[f"ES{r}"]) + if f"FA{r}" in filled: + exp[f"{key}.base_amount_krw"] = _num(filled[f"FA{r}"]) * unit + default, civil = re.findall(r"([\d,]+)억", _t(raw["DR35"])) + exp["rate_safety_pct.manager_thresholds.default_estimated_amount_krw"] = _num(default) * 10**8 + exp["rate_safety_pct.manager_thresholds.civil_main_work_estimated_amount_krw"] = _num(civil) * 10**8 # fmt: skip + exp["rate_safety_pct.minimum_total_construction_amount_krw"] = int(re.search(r"(\d+)천만원", raw["DK51"]).group(1)) * 10**7 # fmt: skip + exp["rate_retirement_mutual_aid.minimum_estimated_amount_krw"] = int(re.search(r"(\d+)억", raw["AU113"]).group(1)) * 10**8 # fmt: skip + for col, first, last, value_col, groups, template in BANDS: + got: dict[str, set] = {} + for r in range(first, last + 1): + if (label := raw.get(f"{col}{r}")) is None: + continue + value = filled.get(f"{value_col}{r}") + value = _formula(value) if template.endswith("formula") else _num(value) + got.setdefault(groups.get(_t(label), f"({_t(label)})"), set()).add(value) + for group, values in got.items(): + if not (group.startswith("(") and values == {None}): + value = values.pop() if len(values) == 1 else sorted(values, key=str) + exp[template.format(group)] = list(value) if isinstance(value, tuple) else value + for candidate, work_type in (("road", "civil_road"), ("other_civil_work", "civil_other")): + exp[f"rate_environment.forest_road_candidates[{candidate}].rate_percent"] = exp[f"rate_environment.all_work_types[{work_type}].rate_percent"] # fmt: skip + return exp + + +def flatten(obj, path: str = "", out: dict | None = None) -> dict: + """자료 `variables` 를 `이름.칸[구간|종류].rate_percent` 꼴로 폄 — 목록 줄은 값 아닌 칸을 이어 열쇠로.""" + out = {} if out is None else out + if isinstance(obj, dict): + for k, v in obj.items(): + flatten(v, f"{path}.{k}" if path else k, out) + elif isinstance(obj, list) and obj and isinstance(obj[0], dict): + values = {"rate_percent", "base_amount_krw", "formula"} + for item in obj: + label = "|".join(str(v) for k, v in item.items() if k not in values) + for k in values & item.keys(): + out[f"{path}[{label}].{k}"] = item[k] + else: + out[path] = obj + return out + + +def check_rate(src: dict, bad: dict, blind: dict) -> dict: + exp = dict(src["rates"]["source"]) + data = flatten(src["rates"]["data"]["variables"]) + xlsx_2026 = exp.pop("연금 2026 xlsx") + law_2026 = exp.get("rate_pension.annual_rates[2026].rate_percent") + if xlsx_2026 != law_2026: + bad["연금 2026 xlsx↔법"] = {"source": xlsx_2026, "data": law_2026} + for key in exp | data: + if key not in exp: + blind[f"안 잰 칸 {key}"] = {"what": data[key]} + continue + value = list(_formula(data[key])) if key.endswith("formula") and key in data else data.get(key) # fmt: skip + if exp[key] != value: + bad[key] = {"source": exp[key], "data": value} + blind["processing_rules"] = {"what": sorted(src["rates"]["data"].get("processing_rules", {}))} + blind["조달청 요약표"] = {"what": src["rates"]["paths"][0].name} + return {"원문 값 수": (len(exp), len(set(exp) & set(data)))} + + +# ── 맞대기 ────────────────────────────────────────────────────────────── +READERS = { + "labor_const": read_labor_const, "labor_mfg": read_labor_mfg, "mach": read_machine, + "mat": read_material, "oil": lambda p: json.loads(p.read_text(encoding="utf-8")), + "oil_regional": lambda p: json.loads(p.read_text(encoding="utf-8")), +} # fmt: skip +CHECKS = {"labor": check_labor, "machine": check_machine, "material": check_material, "oil": check_oil, "rate": check_rate} # fmt: skip + + +def load_sources() -> dict: + src = {} + for name, file in FILES.items(): + data = json.loads((DATA / file).read_text(encoding="utf-8")) + paths = [ROOT / s["path"] for s in data["sources"]] + src[name] = {"data": data, "paths": paths} + if name == "rates": + src[name]["source"] = rates_expected(read_xlsx(paths[0]), read_pension(paths[1])) + else: + src[name]["source"] = READERS[name](paths[0]) + return src + + +@pytest.fixture(scope="module") +def src() -> dict: + return load_sources() + + +def run_checks(src: dict) -> tuple[dict[str, dict], dict[str, dict]]: + """(갈래별 어긋남·못 보는 자리, 갈래별 셈 `(원문, 자료)`). 틀어진 셈은 어긋남에 `셈 …` 으로도 들어감.""" + out: dict[str, dict] = {kind: {} for kind in KIND_NAMES} + counts = {} + for kind, check in CHECKS.items(): + counts[kind] = check(src, out[kind], out[f"{kind}_blind"]) + for name, (s, d) in counts[kind].items(): + if s != d: + out[kind][f"셈 {name}"] = {"source": s, "data": d} + return out, counts + + +def current_mismatches(src: dict) -> dict[str, dict]: + return run_checks(src)[0] + + +def load_known() -> dict[str, dict]: + return json.loads(KNOWN.read_text(encoding="utf-8")) + + +# ── 시험 ──────────────────────────────────────────────────────────────── +@pytest.mark.parametrize("kind", list(KIND_NAMES)) +def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None: + """알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강. `why` 는 맞대기에서 뺌.""" + now = current_mismatches(src)[kind] + known = {k: _without_why(v) for k, v in load_known()[kind].items()} + lines: list[str] = [] + for key in sorted(set(now) - set(known)): + lines.append(f" 새로 어긋남 {key}: {json.dumps(now[key], ensure_ascii=False)}") + for key in sorted(set(now) & set(known)): + if now[key] != known[key]: + lines.append( + f" 모습 바뀜 {key}: 목록 {json.dumps(known[key], ensure_ascii=False)}" + f"\n 지금 {json.dumps(now[key], ensure_ascii=False)}" + ) + for key in sorted(set(known) - set(now)): + lines.append(f" 고쳐짐 {key} — 목록에서 지울 것(--prune)") + assert not lines, f"{KIND_NAMES[kind]} {len(lines)}건 — 원문 ↔ 자료:\n" + "\n".join(lines) + + +@pytest.mark.parametrize("kind", KINDS) +def test_셈이_맞음(src: dict, kind: str) -> None: + """원문 줄 수 = 자료 줄 수 · 자료 줄은 모두 원문에서 찾음 — 틀어진 셈은 목록 `셈 …` 과 똑같을 때만 통과.""" + known = load_known()[kind] + wrong = [ + f" {name}: 원문 {s} · 자료 {d}" + for name, (s, d) in run_checks(src)[1][kind].items() + if s != d and _without_why(known.get(f"셈 {name}", {})) != {"source": s, "data": d} + ] + assert not wrong, f"{KIND_NAMES[kind]} 셈 틀어짐(목록 밖):\n" + "\n".join(wrong) + + +if __name__ == "__main__" and "--prune" in sys.argv: + # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). + current = current_mismatches(load_sources()) + data = load_known() + for kind in KIND_NAMES: + before = len(data[kind]) + data[kind] = {k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v)} # fmt: skip + print(f"{KIND_NAMES[kind]}: {before} → {len(data[kind])}") + KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") diff --git a/resources/tester/test_work_item_master_source_match.py b/resources/tester/test_work_item_master_source_match.py index 1c59cd8f..5f97b79b 100644 --- a/resources/tester/test_work_item_master_source_match.py +++ b/resources/tester/test_work_item_master_source_match.py @@ -37,6 +37,15 @@ KNOWN = Path(__file__).resolve().parent / "fixtures/work_item_master_source_know TOC_TABLE = "F0001" #: 이 줄 뒤는 부록(할인·할증 공종표 · 적용 공종 · 단가산출서 예시) — 공종 품이 아니라 `orphan_tables` 몫. APPENDIX = "〔부록 1〕" +#: 알려진 목록 갈래. `form_blind`·`cost_rule` 은 어긋남이 아니라 **시험이 못 가리는 자리**를 적어 둔 것. +KIND_NAMES = { + "place": "표 자리", + "basis": "밑수", + "notes": "[주]", + "form": "표 성격", + "form_blind": "표 성격 — 품 표지를 못 알아봄", + "cost_rule": "비목 구성표 — 사람이 봐야 함", +} # ── 원문 읽기 ──────────────────────────────────────────────────────────── _SEP = re.compile(r"\s*\|(\s*:?-+:?\s*\|)+\s*") @@ -168,35 +177,68 @@ _WORKER = re.compile( r"인\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|" r"용접공|미장공|방수공|배관공|비계공" ) +#: 품 낱말 — 공백을 지운 칸에서 찾음(`주 재 료` → `주재료`). _WORK_MARK = re.compile( - r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|인/|/인|\(시간\)|\(hr\)" + r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|살포량|처리량|" + r"인력구분|인력구성|적용인부|인원\(명\)|^주재료$|인/|/인|\(시간\)|\(hr\)|[가-힣]\((kg|㎏|g|㎥|ℓ)\)" ) _QTY_UNIT = {"인", "매", "개", "본", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "대", "톤", "hr", "시간"} -#: 숫자 칸 — `0.16` · `1.5인` · `0.5ℓ` · 한 칸에 여러 값 `0.04 0.06 0.10`. -_NUMBER = re.compile(r"^\d[\d,]*(\.\d+)?(\s*(인|ℓ|시간|㎥|개|본|매|kg|m|%))?(\s+[-\d,.]+)*$") +#: 숫자 칸 — `0.16` · 범위 `3.3∼5.9` · 단위 붙음 `1.5인`·`10ℓ`·`5인/km`·`2.18Roll` · +#: 한 칸에 여러 값 `0.04 0.06 0.10`. +_NUMBER = re.compile( + r"^\d[\d,]*(\.\d+)?(\s*[~∼~-]\s*\d[\d,]*(\.\d+)?)?" + r"(\s*(인|ℓ|시간|㎥|㎡|개|본|매|kg|m|%|Roll)(/[a-z㎞㏊]+)?)?(\s+[-\d,.]+)*$" +) def md_work_mark(table: dict) -> str | None: - """품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. + """품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. 표에 숫자 칸이 있을 때만 봄. - ① 표 어딘가에 **짧은 칸(20자 이하)** 으로 직종 이름·품 낱말(`소요인력`·`(인)`·`작업능력` …)이 있고 - 숫자 칸도 있음 · ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음. + ① **짧은 칸(20자 이하)** 에 직종 이름·품 낱말(`소요인력`·`인력구분`·`(인)`·`시멘트(kg)` …) + ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음 + ③ 단위만 늘어선 줄(`| | ㎥ | ㎥ | ㎥ |`) — 값은 아래 줄에 있음(7-1-1 수확) 긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸). - ponytail: 품 표 317 중 276 만 알아봄 — 값이 범위(`3.3∼5.9`)거나 머리가 여러 줄로 갈린 표는 못 봄. + ⚠ 못 알아보는 품 표는 알려진 목록 `form_blind` 에 까닭과 함께 둠 — 그 표는 성격이 뒤집혀도 못 잡음. """ rows = [table["headers"], *table["rows"]] cells = [c for row in rows for c in row] - if any(_NUMBER.match(c) for c in cells): - for c in cells: - if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c)): - return c + if not any(_NUMBER.match(c) for c in cells): + return None + for c in cells: + if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c.replace(" ", ""))): + return c for row in rows: unit = next((c for c in row if c in _QTY_UNIT), None) if unit and any(_NUMBER.match(c) for c in row): return unit + filled = {c for c in row[1:] if c} + if len(filled) == 1 and filled <= _QTY_UNIT: + return next(iter(filled)) return None +#: 비목 이름 — 이것만으로 첫 칸이 채워진 표가 비목 구성표(`재료비 | 설치비 재료비의 5%`). +_COST_ITEMS = {"재료비", "설치비", "노무비", "경비", "제작비", "운송비", "자재비", "주재료비"} + + +def md_cost_rows(table: dict) -> list[list[str]] | None: + """비목 구성표면 그 줄들(뒤 빈 칸 뗌), 아니면 `None`. + + ⚠ 이 꼴은 **모양은 잡히나 성격은 원문으로 못 가림** — 「설치비 = 재료비의 5%」 가 품(자원 줄)인지 + 참조(비율 지시)인지는 설계 방식의 결정이라 사람이 봐야 함(알려진 목록 `cost_rule`). + """ + rows = [r for r in table["rows"] if r and r[0].strip()] + if not rows or any(r[0].replace(" ", "") not in _COST_ITEMS for r in rows): + return None + out = [] + for r in rows: + cut = list(r) + while cut and not cut[-1]: + cut.pop() + out.append(cut) + return out + + # ── 맞대기 ────────────────────────────────────────────────────────────── def _norm(text: str) -> str: return " ".join(str(text).split()) @@ -225,7 +267,7 @@ def load_sources() -> dict: def current_mismatches(src: dict) -> dict[str, dict]: """표 자리·밑수·[주]·표 성격 어긋남 — 알려진 목록과 같은 꼴. 밑수·[주]·성격은 공종에 붙은 표만 봄.""" lines = src["lines"] - out: dict[str, dict] = {"place": {}, "basis": {}, "notes": {}, "form": {}} + out: dict[str, dict] = {kind: {} for kind in KIND_NAMES} appendix = next(i for i, x in enumerate(lines, 1) if x.strip().startswith(APPENDIX)) orphans = {o["pum_table_id"] for o in src["master"]["orphan_tables"]} for table in src["tables"]: @@ -267,12 +309,19 @@ def current_mismatches(src: dict) -> dict[str, dict]: "master_only": [x for x in entry["notes"] if x not in md_note], } - if entry["pum_form"] in ("reference", "coefficient") and (mark := md_work_mark(table)): - out["form"][table["id"]] = { + form = entry["pum_form"] + if cost_rows := md_cost_rows(table): + out["cost_rule"][table["id"]] = { "section": section, - "master": entry["pum_form"], - "md_mark": mark, + "master": form, + "md_rows": cost_rows, } + continue # 성격은 사람 몫 — 품 표지로 재지 않음 + mark = md_work_mark(table) + if form in ("reference", "coefficient") and mark: + out["form"][table["id"]] = {"section": section, "master": form, "md_mark": mark} + elif form in ("requirement", "productivity") and not mark: + out["form_blind"][table["id"]] = {"section": section, "master": form} return out @@ -280,6 +329,10 @@ def load_known() -> dict[str, dict]: return json.loads(KNOWN.read_text(encoding="utf-8")) +def _without_why(entry: dict) -> dict: + return {k: v for k, v in entry.items() if k != "why"} + + # ── 시험 ──────────────────────────────────────────────────────────────── def test_표_수와_자리(src: dict) -> None: """원문 표가 원본(`pum_forest`)에 같은 차례·같은 줄로 있고, 마스터에 빠짐없이 한 번씩 있음.""" @@ -340,14 +393,14 @@ def test_셀_값이_글자까지_같음(src: dict) -> None: assert not diffs, f"셀 어긋남 {len(diffs)}건 — 앞 30:\n" + "\n".join(diffs[:30]) -KIND_NAMES = {"place": "표 자리", "basis": "밑수", "notes": "[주]", "form": "표 성격"} - - @pytest.mark.parametrize("kind", list(KIND_NAMES)) def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None: - """표 자리·밑수·[주]·표 성격 — 알려진 목록과 똑같아야 함. 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강.""" + """알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강. + + 목록 칸 `why` 는 사람이 적은 까닭 — 맞대기에서 뺌. + """ now = current_mismatches(src)[kind] - known = load_known()[kind] + known = {k: _without_why(v) for k, v in load_known()[kind].items()} lines: list[str] = [] for tid in sorted(set(now) - set(known)): lines.append(f" 새로 어긋남 {tid}: {json.dumps(now[tid], ensure_ascii=False)}") @@ -368,6 +421,8 @@ if __name__ == "__main__" and "--prune" in sys.argv: data = load_known() for kind in KIND_NAMES: before = len(data[kind]) - data[kind] = {k: v for k, v in data[kind].items() if current[kind].get(k) == v} + data[kind] = { + k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v) + } print(f"{KIND_NAMES[kind]}: {before} → {len(data[kind])}") KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")