diff --git a/resources/tester/conftest.py b/resources/tester/conftest.py index ee3ceafb9..09132875e 100644 --- a/resources/tester/conftest.py +++ b/resources/tester/conftest.py @@ -7,24 +7,4 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspa collect_ignore = ["scratch"] -# 2026-09-19 사용자: 마스터 데이터를 처음부터 다시 세우는 동안 옛 원가 사슬(B08·B09·Z01·공종 마스터·옛 품셈 md) -# 시험은 안 돌림 — 파일은 지우지 않고 모으기에서만 뺌. 되살릴 때 이 목록을 지움. -collect_ignore_glob = [ - "test_b08_*.py", - "test_b09_*.py", - "test_z01_*.py", - "test_work_item_*.py", - "test_master_*.py", - "test_build_pum.py", - "test_build_mach.py", - "test_base_data_source_match.py", - "test_const_pdf_coverage.py", - "test_preview_cost.py", - # 옛 마스터 파일을 resources/master_data · tmp 로 옮겨 끊긴 시험 - "test_base_prices_source_match.py", - "test_common_aliases.py", - "test_double_count_boundaries.py", - "test_form_const.py", - "test_mat_price_zero.py", - "test_pum_edition.py", -] +collect_ignore_glob = ["test_preview_cost.py", "test_const_pdf_coverage.py"] diff --git a/resources/tester/fixtures/base_data_source_known.json b/resources/tester/fixtures/base_data_source_known.json deleted file mode 100644 index 3c97ff3b9..000000000 --- a/resources/tester/fixtures/base_data_source_known.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "_설명": [ - "기초데이터 열(품셈 근거) ↔ 원문 — 지금 알고 있는 어긋남(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": {}, - "material_surcharge": { - "이형철근": { - "md": [ - "3", - "6-7" - ], - "data": [ - 3, - 7 - ], - "why": "원문 복잡 구조물 주철근 「6-7」 범위 중 위 끝 7 을 씀 — 범위를 살릴지 사람 확인", - "class": "㉰ 사용자 판단 필요", - "class_why": "원문이 범위 「6~7」 로 적어 두었는데 자료는 위 끝 7 하나만 씀 — **범위를 담는 잣대가 없음**(범위 자체는 자료에 실어 둠). 위 끝을 쓸지 범위를 살릴지 정해야 닫힘.", - "갈래": "잣대" - } - }, - "formwork_reuse": {}, - "rebar_complexity": {}, - "timber_structure_class": {}, - "masonry_slope": {}, - "masonry_back_length": {}, - "stone_kind": {}, - "masonry_class": {}, - "machine_productivity": {}, - "_갈래": { - "센 날": "2026-09-18", - "셈": { - "잣대 / ㉰": 1 - }, - "잣대": "세 갈래 — **원문 부재** 원문에 없음 · **잣대** 우리 잣대가 못 담음 · **남의 몫** 남이 원문·도구를 고쳐야 풀림. 누가 하나는 class(㉮·㉯·㉰)." - }, - "_할일_아님": { - "잣대": "㉮ — **영영 할 일이 아닌 것.** 원문에 없음 · 거절 자리 · 판정으로 닫힘. 바퀴마다 다시 세어지지 않게 갈래에서 **아주 빼** 여기 둔다. ⚠ 지우지는 않는다 — 되짚을 수 있어야 한다.", - "옮긴 날": "2026-09-18", - "셈": { - "machine_productivity": 2 - }, - "줄": { - "machine_productivity": { - "dump_material/발파암/truck_efficiency": { - "md": [], - "data": "0.9", - "why": "10-12-3 운반 식에 E 가 없음 — 토사·암절취의 0.9 를 이어 씀", - "class": "㉮ 영영 할 일 아님", - "class_why": "원문 식에 E 가 없어 토사·암절취 0.9 를 이어 씀 — 사유 적음", - "갈래": "거절 자리" - }, - "dump_material/토사/loading_efficiency": { - "md": [ - 0.75 - ], - "data": "0.60", - "why": "본문 E0=0.75 대신 10-12-1 [주]⑤ 표 「임도 0.60(불량)」 을 씀 — 자료 loading_note 에 사유", - "class": "㉮ 영영 할 일 아님", - "class_why": "원문 [주]⑤ 표 비고 「임 도」로 0.60 확정 — 사유 적음", - "갈래": "거절 자리" - } - } - }, - "갈래별": { - "거절 자리": 2 - } - } -} diff --git a/resources/tester/fixtures/base_prices_source_known.json b/resources/tester/fixtures/base_prices_source_known.json deleted file mode 100644 index be2c8d16d..000000000 --- a/resources/tester/fixtures/base_prices_source_known.json +++ /dev/null @@ -1,463 +0,0 @@ -{ - "_설명": [ - "원문 ↔ 기초단가 다섯 갈래 — 지금 알고 있는 어긋남과 시험이 못 보는 자리(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": {}, - "machine": {}, - "material": {}, - "oil": {}, - "rate": {}, - "labor_blind": {}, - "machine_blind": { - "규격 빈칸 7430-1300": { - "what": "싱글", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "규격 빈칸 7430-1500": { - "what": "드럼", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "규격 빈칸 7430-2500": { - "what": "드럼", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "규격 빈칸 7431-1300": { - "what": "드럼", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "규격 빈칸 7811-0030": { - "what": "엔진", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "규격 빈칸 7812-0007": { - "what": "엔진", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "규격 빈칸 9030-0240": { - "what": "150ton", - "why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "원문 손료표가 **한 칸에 든 기종 이름을 여러 줄로 쪼개 적은** 자리 — `(7430) 윈치` 는 「수동 싱글드럼」 이 수동·싱글·드럼 세 줄로 갈렸고 뒷줄은 칸이 비었음. 자료는 그 빈칸을 **앞 줄에서 이어받아** 채워 「싱글」·「드럼」·「엔진」 같은 **조각**이 규격으로 들어가 있음. 값(수·계수)은 원문과 같고 **이름 조각만** 어긋남.", - "갈래": "잣대 못 알아봄", - "owner": "랩탑 서브", - "owner_ask": "제8장 8-3 (7430)·(7431) 윈치 · (7811)·(7812) 엔진 · (9030) 예선 표 — PDF 에서 「기종」 칸이 한 칸에 어떻게 적혀 있는지 짚어 줄 것(「수동 싱글드럼」 처럼 한 칸인지, 줄마다 다른 이름인지). 그것이 갈리면 자료의 이어받기가 맞는지 곧 판가름남." - }, - "운전사 기종별 판단": { - "what": 121, - "why": "기종→운전사 구분(explicit_mappings)은 사람 판단 — 원문 8-1-3 5호는 기종 이름 글만 있음", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "원문 8-1-3 5호는 기종 이름 글만 두고 운전사 구분을 안 적음 — 기종→운전사 짝(`explicit_mappings`)은 사람이 정한 것. 원문에 견줄 것이 없음." - } - }, - "material_blind": { - "같은 때 공시 20696249": { - "what": 2, - "why": "같은 공시 일시에 값 실린 공시가 둘 — 원천이 어느 것이 정본인지 안 알려 줌 · 자료는 그중 하나와 같음", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "같은 공시 일시에 값 실린 공시가 둘인데 **원천이 어느 것을 정본으로 보는지 안 알려 줌** — 자료는 그중 하나와 같음. 어느 것을 취할지 정해야 닫힘." - }, - "같은 때 공시 20696250": { - "what": 2, - "why": "같은 공시 일시에 값 실린 공시가 둘 — 원천이 어느 것이 정본인지 안 알려 줌 · 자료는 그중 하나와 같음", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "위와 한 자리." - }, - "원천 없는 묶음": { - "what": [ - "reinforcing_steel", - "ready_mixed_concrete", - "asphalt_concrete" - ], - "why": "철근·레미콘·아스콘은 종합쇼핑몰 원천이 저장소에 없음 — 값 못 봄", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "철근·레미콘·아스콘은 종합쇼핑몰 원천이 저장소에 없어 값을 볼 수 없음 — 원천을 들여올지 정해야 닫힘." - } - }, - "oil_blind": {}, - "rate_blind": { - "안 잰 칸 rate_environment.forest_road_selection_status": { - "what": "pending", - "why": "임도 공종 선택 미결 표시 — 사용자 협의 몫", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "임도 공종 선택 미결 표시 — 사용자 협의로 닫을 자리." - }, - "안 잰 칸 rate_environment.minimum_estimated_amount_krw": { - "what": 100000000, - "why": "xlsx 환경보전비 칸에 1억 기준이 없음(1억은 퇴직공제부금 칸) — 근거 원문이 sources 에 없어 못 봄", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "xlsx 환경보전비 칸에 1억 기준이 없음(1억은 퇴직공제부금 칸) — 근거 원문이 `sources` 에 없어 볼 수 없음. 근거를 들여올지 정해야 닫힘." - }, - "안 잰 칸 rate_performance_guarantee_fee.typical_forest_road_applicability": { - "what": "not_applicable", - "why": "임도 적용 판단 — 원문 칸 아님", - "class": "㉰ 사용자·브레인 판단", - "갈래": "원문 부재", - "class_why": "임도에 적용되는지 판단 — 원문 칸이 아님." - } - }, - "_할일_아님": { - "갈래별": { - "낳은 값": 1, - "메모": 1, - "요약 이름": 2, - "원문 부재": 25, - "자리 표시": 2, - "프로그램 이름": 2 - }, - "셈": { - "labor_blind": 5, - "machine_blind": 8, - "material_blind": 2, - "oil_blind": 7, - "rate_blind": 11 - }, - "옮긴 날": "2026-09-18", - "잣대": "㉮ — **영영 할 일이 아닌 것.** 원문에 없는 말(프로그램 이름·사람 메모·살림살이) · 이미 다른 자리에서 맞대는 것. 바퀴마다 다시 세어지지 않게 갈래에서 아주 빼 여기 둔다. ⚠ 지우지는 않는다 — 되짚을 수 있어야 한다.", - "줄": { - "machine_blind": { - "안 잰 칸 mach_loss_coef.note": { - "what": "원문과 안 맞댐", - "why": "사람이 적은 메모 — 2026-09-18 5202·5203 칸 밀림을 바로잡은 사유다. 원문에 없는 글이라 맞댈 것이 없다.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "사람이 적은 메모 — 2026-09-18 5202·5203 칸 밀림을 바로잡은 사유다. 원문에 없는 글이라 맞댈 것이 없다.", - "갈래": "메모" - }, - "안 잰 칸 mach_loss_coef.loss_coefficient_per_hour": { - "what": "원문과 안 맞댐", - "why": "자료가 낳은 값 — 「계」 계수(10-7)를 10,000,000 으로 나눈 것이다. 낳은 밑값인 계수 넷은 이미 줄마다 맞대고 있어 여기서 또 맞대면 같은 것을 두 번 세는 셈이다.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "자료가 낳은 값 — 「계」 계수(10-7)를 10,000,000 으로 나눈 것이다. 낳은 밑값인 계수 넷은 이미 줄마다 맞대고 있어 여기서 또 맞대면 같은 것을 두 번 세는 셈이다.", - "갈래": "낳은 값" - }, - "안 잰 칸 mach_fuel_rate.source_tables.section": { - "what": "원문과 안 맞댐", - "why": "표를 가리키는 이름표일 뿐 — 8-4 표의 머리와 줄은 이미 **표째 통째로** 맞대고 있다(`운전경비 원표 N번째`).", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "표를 가리키는 이름표일 뿐 — 8-4 표의 머리와 줄은 이미 **표째 통째로** 맞대고 있다(`운전경비 원표 N번째`).", - "갈래": "자리 표시" - }, - "안 잰 칸 mach_operator_map.rules.alias": { - "what": "원문과 안 맞댐", - "why": "프로그램 안에서 쓰는 이름(`labor_op_const`) — 원문 낱말이 아니다.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "프로그램 안에서 쓰는 이름(`labor_op_const`) — 원문 낱말이 아니다.", - "갈래": "프로그램 이름" - }, - "안 잰 칸 mach_operator_map.rules.rule_id": { - "what": "원문과 안 맞댐", - "why": "프로그램 안에서 쓰는 규칙 이름(`construction_machine_operator`) — 원문 낱말이 아니다.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "프로그램 안에서 쓰는 규칙 이름(`construction_machine_operator`) — 원문 낱말이 아니다.", - "갈래": "프로그램 이름" - }, - "안 잰 칸 mach_operator_map.rules.source_section": { - "what": "원문과 안 맞댐", - "why": "어느 절에서 왔나 적어 둔 자리 표시(`8-1-3/5`) — 값이 아니다.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "어느 절에서 왔나 적어 둔 자리 표시(`8-1-3/5`) — 값이 아니다.", - "갈래": "자리 표시" - }, - "안 잰 칸 mach_rock_adj.rules.exception": { - "what": "원문과 안 맞댐", - "why": "원문 조건 글을 영어 이름으로 줄여 적은 것(`tire_or_wetland_bulldozer` = 타이어식·습지 불도저) — 글끼리 맞댈 수 없다. 붙는 수(25·20·10·0)는 「손료보정」 으로 이미 맞대고 있다.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "원문 조건 글을 영어 이름으로 줄여 적은 것(`tire_or_wetland_bulldozer` = 타이어식·습지 불도저) — 글끼리 맞댈 수 없다. 붙는 수(25·20·10·0)는 「손료보정」 으로 이미 맞대고 있다.", - "갈래": "요약 이름" - }, - "안 잰 칸 mach_rock_adj.rules.exclusion": { - "what": "원문과 안 맞댐", - "why": "위와 같음 — `dedicated_dump_truck_18_ton_or_more` = 18톤 이상 전용 덤프트럭.", - "class": "㉮ 영영 할 일 아님", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림", - "class_why": "위와 같음 — `dedicated_dump_truck_18_ton_or_more` = 18톤 이상 전용 덤프트럭.", - "갈래": "요약 이름" - } - }, - "labor_blind": { - "건설 pdf": { - "what": "3. 개별직종 노임단가.md", - "why": "원문 md 는 PDF 를 옮긴 것 — PDF 자체와의 옮김 틀림은 못 봄", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "맞댈 PDF 가 저장소에 없음 — md 가 우리 원문임. PDF↔md 옮김 틀림은 사람이 따로 볼 자리." - }, - "건설 별칭 이름": { - "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": "별칭 이름은 프로그램이 지은 것 — 가리키는 코드가 원문에 있는지만 봄", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "프로그램이 지은 별칭 — 원문에 있을 수 없는 말. 가리키는 코드가 원문에 있는지만 봄." - }, - "안 잰 칸 labor_mfg.unit": { - "what": "원문과 안 맞댐", - "why": "`KRW/day` 는 프로그램 표기 — 원문 글(원·1일)과 글자로 안 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "`KRW/day` 는 프로그램 표기 — 원문 글(원·1일)과 글자로 맞댈 꼴이 아님." - }, - "안 잰 칸 labor_rate.unit": { - "what": "원문과 안 맞댐", - "why": "`KRW/day` 는 프로그램 표기 — 원문 글(원·1일)과 글자로 안 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - }, - "제조 pdf": { - "what": "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.md", - "why": "원문 md 는 PDF 를 옮긴 것 — PDF 자체와의 옮김 틀림은 못 봄", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - } - }, - "material_blind": { - "API 스냅숏": { - "what": "나라장터_시설공통자재_2026-08-14.json", - "why": "원문이 API 로 받은 스냅숏 — 게시처(나라장터·오피넷) 값 자체와는 못 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "원문이 API 로 받은 한때의 스냅숏 — 게시처(나라장터·오피넷) 값 자체는 저장소에 없음." - }, - "안 잰 칸 mat_price.price_adopted_reason": { - "what": "원문과 안 맞댐", - "why": "빌더가 적은 까닭 글 — 원문 칸 아님", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "빌더가 적은 까닭 글 — 원문 칸이 아님." - } - }, - "oil_blind": { - "API 스냅숏": { - "what": [ - "유가_전국평균_2026-08-14.json", - "유가_시도별_2026-09-09.json" - ], - "why": "원문이 API 로 받은 스냅숏 — 게시처(나라장터·오피넷) 값 자체와는 못 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "원문이 API 스냅숏 — 게시처 값 자체는 저장소에 없음." - }, - "시도 거래일": { - "what": "시도별 원천에 거래일 칸이 없음 — 수집일과만 맞댐", - "why": "시도별 원천 줄에 거래일이 없어 `수집일` 로만 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "시도별 원천 줄에 거래일 칸이 아예 없음 — `수집일` 로만 맞댐." - }, - "안 잰 칸 oil.scope": { - "what": "원문과 안 맞댐", - "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "프로그램 표기(`national_average`) — 원천에 그 칸이 없음." - }, - "안 잰 칸 oil.unit": { - "what": "원문과 안 맞댐", - "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "프로그램 표기(`KRW/L`) — 원천에 그 칸이 없음." - }, - "안 잰 칸 oil_regional.scope": { - "what": "원문과 안 맞댐", - "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - }, - "안 잰 칸 oil_regional.source_product_name": { - "what": "원문과 안 맞댐", - "why": "시도별 원천에 상품 이름 칸 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "시도별 원천에 상품 이름 칸이 없음." - }, - "안 잰 칸 oil_regional.unit": { - "what": "원문과 안 맞댐", - "why": "프로그램 표기(KRW/L · national_average) — 원천에 칸 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - } - }, - "rate_blind": { - "processing_rules": { - "what": [ - "note", - "owner_supplied_material", - "performance_guarantee_fee", - "profit", - "rounding", - "safety_management_cost", - "scale_bracket" - ], - "why": "끝수·산식 처리 규칙 — 코드 규칙이라 원문 대조 대상 아님(거울 시험 몫)", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "끝수·산식 처리 규칙 — 코드 규칙이라 원문에 견줄 글이 없음(거울 시험 몫)." - }, - "안 잰 칸 rate_equipment_payment_guarantee.base_note": { - "what": "코드(`B09_Estimation_Statutory.STATUTORY_ITEMS`)가 직접공사비로 계산하고 있는 것과 맞춘 표기.", - "why": "설명 글", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "설명 글 — 값이 아님." - }, - "안 잰 칸 rate_overhead.basis": { - "what": "조달청 「2026년 토목·조경·산업환경설비공사 원가계산 간접공사비(제비율) 적용기준」(2026-04-13). ⚠ 구간 라벨의 `billion` 은 **십억 원** — `lt_5_billion` 은 50억 미만이다. 종합(토목·조경·산업설비)은 5억미만~30억~50억 8 · 50~100억·100~300억 6.5 · 300~1000억 5 · 1000억이상 4.5, 전문 등은 5억미만 8 · 5~30억 6.5 · 30~50억·50~100억 5 · 100억이상 4.5.", - "why": "데스크탑 서브가 붙인 설명 글 — 값 아님", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "데스크탑 서브가 붙인 설명 글 — 값이 아님." - }, - "안 잰 칸 rate_overhead.specialty_note": { - "what": "⚠ 2026-09-18 **300억 이상 두 구간이 통째로 빠져 있었음** — 마지막이 「100억-300억 미만」으로 닫혀 300억 넘는 전문공사가 어느 구간에도 안 걸렸음. 원본 XLSX(BL35:BR46) 대로 300~1000억 · 1000억 이상을 더함(둘 다 4.5%). 값은 원문 그대로.", - "why": "데스크탑 서브가 붙인 설명 글 — 값 아님", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - }, - "안 잰 칸 rate_safety_base.amount_bracket": { - "what": "500_million_to_5_billion", - "why": "다른 칸을 가리키는 표시 — 값 아님", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "다른 칸을 가리키는 표시 — 값이 아님." - }, - "안 잰 칸 rate_safety_base.embedded_in": { - "what": "rate_safety_pct.brackets", - "why": "다른 칸을 가리키는 표시 — 값 아님", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - }, - "안 잰 칸 rate_safety_pct.base_rule": { - "what": "min_of_two_formulas", - "why": "도급자관급 포함 시 두 식 중 작은 값 — xlsx 글(EB11·EB13) 해석이라 안 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "도급자관급이 든 때 두 식 중 작은 값을 쓰라는 xlsx 글(EB11·EB13)을 프로그램 식으로 옮긴 것 — 글과 식은 꼴이 달라 글자로 맞댈 수 없음." - }, - "안 잰 칸 rate_safety_pct.base_with_owner_supplied_material": { - "what": "minimum_of_statutory_two_formulas", - "why": "도급자관급 포함 시 두 식 중 작은 값 — xlsx 글(EB11·EB13) 해석이라 안 맞댐", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 한 자리." - }, - "안 잰 칸 rate_vat.base": { - "what": "construction_cost_before_vat", - "why": "부가세 — 근거(부가가치세법)가 sources 에 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "부가세 근거(부가가치세법)가 `sources` 에 없음 — 저장소에 원문이 없어 맞댈 것이 없음." - }, - "안 잰 칸 rate_vat.rate_percent": { - "what": 10.0, - "why": "부가세 — 근거(부가가치세법)가 sources 에 없음", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "위와 같음." - }, - "조달청 요약표": { - "what": "2026.04.13_조달청_토목공사_제비율.xlsx", - "why": "xlsx 는 조달청 요약표 — 각 고시 원문과는 안 맞댐(연금만 법령과 맞댐)", - "class": "㉮ 영영 할 일 아님", - "갈래": "원문 부재", - "class_why": "xlsx 는 조달청이 고시를 모아 요약한 표 — 각 고시 원문은 저장소에 없음(연금만 법령과 맞댐)." - } - } - } - }, - "_갈래": { - "센 날": "2026-09-18", - "셈": { - "원문 부재 / ㉰": 7, - "잣대 못 알아봄 / ㉯": 7 - }, - "잣대": "두 갈래로 가름 — **원문 부재**: 값이 원문·원천에 없거나 원문 밖 말이라 채울 것이 없음 · **잣대 못 알아봄**: 값은 원문에 있는데 전사·읽기가 못 가름 ⇒ 고치면 풀림. ㉮ 영영 할 일 아님 · ㉯ 남이 고쳐야 풀림 · ㉰ 사용자·브레인 판단.", - "할 일 아님으로 뺀 줄": 33, - "닫힌 자리": [ - { - "날": "2026-09-18", - "무엇": "양수기 7993-0020 휘발유 1.3ℓ (어긋남 세 줄)", - "어떻게": "원문 규명으로 닫힘(브레인 B30) — PDF 340쪽 주연료표의 「휘발유1.3」은 **바로 윗줄 7992(모르타르 믹서)** 것이고 양수기 줄은 전력 1.49㎾ 뿐이었음. 옛 추출이 **이웃 줄 값을 물려받은 자리 틀림**. ⇒ 「원문 부재」가 아니라 **이웃 줄 오귀속**이었음.", - "처리": "`mach_base_2026.json` 의 양수기 연료 줄을 지우고 다시 뽑음. 이어받기는 **옛 벌을 보고 되살리므로** 한 번 지워야 영영 안 돌아옴(`이어받음 {'fuel': 1}` → **0**). 7992 쪽 휘발유 1.3 은 이미 갈라 들어가 있음." - } - ] - } -} diff --git a/resources/tester/fixtures/rates_2024_uljin.json b/resources/tester/fixtures/rates_2024_uljin.json deleted file mode 100644 index 068e586ea..000000000 --- a/resources/tester/fixtures/rates_2024_uljin.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "schema_version": "1.0", - "dataset_id": "rates_2024_fixture", - "effective_date": "2024-01-01", - "_note": "검산 전용 픽스처 — 정본 아님. 울진 공통(2024) 공사원가계산서를 재현해 「연도별 요율 교체 구조가 실제로 도는가」를 시험한다. 값 출처는 그 서류의 관측 요율(PLAN 8-9)이며, 프로그램 기본값으로 쓰지 않는다.", - "variables": { - "rate_indirect_labor": { - "base": "direct_labor_cost", - "brackets": [ - { - "direct_cost_bracket": "lt_1_billion", - "duration_bracket": "lte_183_days", - "work_type": "civil", - "rate_percent": 14.5 - } - ] - }, - "rate_sanjae": { "rate_percent": 3.56, "base": "total_labor_cost" }, - "rate_goyong": { - "base": "total_labor_cost", - "brackets": [ - { "grade": "below_7", "estimated_amount_bracket": "below_official_threshold", "rate_percent": 1.01 } - ] - }, - "rate_health": { "rate_percent": 3.545, "base": "direct_labor_cost" }, - "rate_care": { "rate_percent": 12.95, "base": "health_insurance_amount" }, - "rate_pension": { - "base": "direct_labor_cost", - "annual_rates": [{ "year": 2024, "rate_percent": 4.5 }] - }, - "rate_safety_pct": { - "base_with_owner_supplied_material": "minimum_of_statutory_two_formulas", - "brackets": [ - { - "target_amount_bracket": "500_million_to_5_billion", - "work_type": "civil", - "rate_percent": 1.86, - "base_amount_krw": 5349000 - }, - { "target_amount_bracket": "lt_500_million", "work_type": "civil", "rate_percent": 2.35 } - ] - }, - "rate_other_expense": { - "base": "material_cost_plus_total_labor_cost", - "brackets": [ - { - "direct_cost_bracket": "lt_1_billion", - "duration_bracket": "lte_183_days", - "work_type": "civil", - "rate_percent": 6.0 - } - ] - }, - "rate_environment": { - "base": "direct_construction_cost", - "minimum_estimated_amount_krw": 100000000, - "all_work_types": [{ "work_type": "civil_road", "rate_percent": 0.8 }] - }, - "rate_equipment_payment_guarantee": { - "general_construction": [{ "work_type": "civil_general", "rate_percent": 0.4 }], - "specialty_construction": [] - }, - "rate_overhead": { - "base": "material_plus_labor_plus_expense", - "civil_landscape_industrial": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 6.0 }] - }, - "rate_profit": { - "base": "labor_plus_expense_plus_overhead", - "brackets": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 15.0 }] - }, - "rate_vat": { "rate_percent": 10.0, "base": "construction_cost_before_vat" } - } -} diff --git a/resources/tester/fixtures/work_item_master_source_known.json b/resources/tester/fixtures/work_item_master_source_known.json deleted file mode 100644 index 3cccba0d3..000000000 --- a/resources/tester/fixtures/work_item_master_source_known.json +++ /dev/null @@ -1,723 +0,0 @@ -{ - "_설명": [ - "원문 md ↔ 공종 마스터 — 지금 알고 있는 어긋남(2026-09-17 랩탑 메인이 시험으로 처음 뜸).", - "시험: resources/tester/test_work_item_master_source_match.py — 이 목록 밖에서 어긋나면 빨강.", - "고치면 지울 것: python resources/tester/test_work_item_master_source_match.py --prune (지우기만 함).", - "place: 원문 〔부록 1〕 뒤 표인데 공종에 붙었거나(부록 표), 앞 표인데 공종에 안 붙은 것.", - "basis: md=원문에서 읽은 밑수 [수, 단위] · master=마스터 밑수 · 둘 중 하나가 null 이면 한쪽만 있음.", - "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 쪽 추출**이다.", - "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원 증발한다.", - "⬜ **낱말 뜻이 갈린 자리** — 마스터가 더 가진 쪽(master_only) 17건. md 쪽 읽기는 이어짐을 `- ①②` · `- -` · `※*·` 로만 보아 「- 4. 고철공제 : A=1㎥×0.008×…」 같은 **번호 달린 규칙**에서 끊는다. 마스터는 그것을 [주]로 본다. ⚠ md 규칙을 그대로 쓰면 **2바퀴에서 잘렸다가 3바퀴에 되살린 고철 공제 50kg/㎥ 가 다시 사라진다.** 어느 쪽이 [주]인지는 브레인 판정 자리 (2026-09-18 데스크탑 서브)." - ], - "place": { - "F0452": { - "section": "14-2", - "md": "부록 표", - "master": "공종에 붙음", - "class": "㉰ 사용자 판단 필요", - "class_why": "원문 14-2 는 **부록 표**인데 마스터가 공종에 붙였음 — 부록을 공종에서 가르는 잣대가 아직 없음. 떼어 낼지 승인 대기(같은 식구 넷).", - "갈래": "잣대" - }, - "F0453": { - "section": "14-2", - "md": "부록 표", - "master": "공종에 붙음", - "class": "㉰ 사용자 판단 필요", - "class_why": "위와 한 자리.", - "갈래": "잣대" - }, - "F0454": { - "section": "14-2", - "md": "부록 표", - "master": "공종에 붙음", - "class": "㉰ 사용자 판단 필요", - "class_why": "위와 한 자리.", - "갈래": "잣대" - } - }, - "basis": { - "F0076": { - "section": "3-2", - "md": null, - "md_evidence": "", - "master": [ - 1.0, - "km" - ], - "class": "㉰ 사용자 판단 필요", - "class_why": "원문은 「인/1일 1km당」으로 **분모가 둘**(일·km)인데 마스터는 「1km」만 담음 — 분모 둘을 담는 잣대가 없어 반쪽임. 어느 쪽으로 세울지 판정 대기.", - "갈래": "잣대" - }, - "F0322": { - "section": "10-13-1", - "md": null, - "md_evidence": "", - "master": [ - 1000.0, - "본" - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "위와 같음(원문 「묘목 1,000본당」)", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **md 밑수 읽기**를 넓혀 줄 것 — 원문이 절 이름·본문·표머리에 적어 둔 자리를 md 쪽이 못 읽어 「마스터가 더 가짐」으로 뜸(마스터 값이 원문과 같음)", - "갈래": "남의 몫" - }, - "F0323": { - "section": "10-13-2", - "md": null, - "md_evidence": "", - "master": [ - 1000.0, - "본" - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "위와 같음", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **md 밑수 읽기**를 넓혀 줄 것 — 원문이 절 이름·본문·표머리에 적어 둔 자리를 md 쪽이 못 읽어 「마스터가 더 가짐」으로 뜸(마스터 값이 원문과 같음)", - "갈래": "남의 몫" - } - }, - "notes": { - "F0083": { - "section": "4-1-1", - "md_only": [], - "master_only": [ - "- 3. 박피품은 별도 적용하여야 한다. 다만 작업조건에 따라 1일 작업량은 변동 적용한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0104": { - "section": "5-3-4", - "md_only": [], - "master_only": [ - "[주] ① 본 품은 흉고 또는 근원직경을 추정하기 어려운 수종에 적용한다.", - "- ② 재료 소운반, 터파기, 나무 세우기, 묻기, 물주기, 지주목 세우기, 뒷정리 작업을 포함한다.", - "- ③ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.", - "- ④ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.", - "- ⑤ 암반식재, 부적기식재 등 특수식재시는 품을 별도 계상할 수 있다.", - "- ⑥ 현장의 시공조건, 수목의 성상에 따라 기계 시공이 불가피한 경우는 별도 계상한다.", - "- ⑦ 굴착기 규격은 0.4㎥를 기준으로 한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0106": { - "section": "5-3-5", - "md_only": [], - "master_only": [ - "[주] ① 본 품은 교목류 수종에 적용한다.", - "- ② 재료 소운반, 터파기, 나무 세우기, 묻기, 물주기, 지주목 세우기, 뒷정리 작업을 포함한다.", - "- ③ 식재 시 1회 기준의 물주기는 포함되어 있으며, 유지관리는 별도 계상한다.", - "- ④ 물주기를 위해 살수차 등의 장비가 필요한 경우 기계경비는 별도 계상한다.", - "- ⑤ 흉고직경은 지표면에서 높이 1.2m 부위의 나무줄기 지름이다.", - "- ⑥ 암반식재, 부적기식재 등 특수식재시는 품을 별도 계상할 수 있다.", - "- ⑦ 현장의 시공조건, 수목의 성상에 따라 기계시공이 불가피한 경우는 별도 계상한다.", - "- ⑧ 장비 규격은 다음을 기준으로 한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0189": { - "section": "7-11", - "md_only": [], - "master_only": [ - "[주]① 임업용 굴착기(우드그래플)이 임내를 주행하여 산물을 수집하는 산지 경사도는 15°∼25° 이하의 벌채지 집재 작업량이다.", - "- ② 본 품에는 벌채지의 재해방지 및 산불예방을 위한 산물정리비가 반영된 것으로 본다.", - "- ③ 잔존목 본 수가 100본 이상 130본 이하(잔존목간 거리 10~8.8m)인 경우 10%, 71본 이상 100본 미만(잔존목간 거리 12~10m)인 경우 5%를 할증한다.", - "- ④ 할인․할증은 경사도(1-4-5), 주행 장애물 상태(1-4-13)를 반영한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0223": { - "section": "8-4", - "md_only": [], - "master_only": [ - "- ③ 임업용테이프는 상하 고정이 필요한 경우에 적용하며 본당 소요량이다.", - "- ④ 할인․할증은 경사도(1-4-5), 방제 대상목 분포(1-4-17)를 반영한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0251": { - "section": "9-8-1", - "md_only": [], - "master_only": [ - "[주]① 장비는 무한궤도 굴착기(0.7㎥)를 적용한다.", - "- ② 굴착기작업능력은 무근콘크리트(T=30㎝미만) 값 적용한다.", - "- 4. 고철공제 : A=1㎥×0.008×7,850kg/㎥×80% = 50.24 kg/㎥ ≒50 kg/㎥", - "[주] 「건설공사 표준품셈 공통부문, 8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 유지관리부문, 3-1-3 철골재 철거(인력)(2025)」, 「건설공사 표준품셈 유지관리부문, 3-1-4 철골재 철거(기계)(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질, 3. 체적환산계수적용(2025)」, 「국토건설 설계실무요령, 수량산출요령(2021)」 참조한다" - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0281": { - "section": "9-15-1", - "md_only": [], - "master_only": [ - "- ③ 「건설공사 표준품셈 공통부문, 8-2-3 굴착기(2025)」 참조한다. (불도저작업이 어려운 경우)" - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0282": { - "section": "9-15-2", - "md_only": [], - "master_only": [ - "- ③ 「건설공사 표준품셈 공통부문, 8-2-1 불도저(2025)」 참조(표토제거 두께 T=0.2m)" - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0285": { - "section": "9-16-3", - "md_only": [], - "master_only": [ - "- ④ 토목은 동상방지층, 보조기층, 기층 등 층별 조건이나 본선, 길어깨 등에 따라 임도에는 대형장비(5,500ℓ)를 사용한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0286": { - "section": "9-17-2", - "md_only": [], - "master_only": [ - "- ③ 향후 나무심기가 예상되는 곳에 시행한다.", - "- ④ 「건설공사 표준품셈 공통부문 8-2-1 불도저(2025)」 참조한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0295": { - "section": "9-22", - "md_only": [], - "master_only": [ - "- ④ 0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는「건설공사 표준품셈 공통부문, 8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0319": { - "section": "10-11", - "md_only": [], - "master_only": [ - "- ③ 「건설공사 표준품셈 공통부문. 8-2-1 불도저(2025)」 참조한다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0323": { - "section": "10-13-2", - "md_only": [], - "master_only": [ - "[주] 위 단가산출은 일본 임야청에서 묘목 5,000본의 운반을 가정하여 현장 검증을 통해 산출한 내용이므로, 묘목 이외의 다른 화물을 운반할 시에는 달라질 수 있다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - }, - "F0393": { - "section": "12-38-2", - "md_only": [], - "master_only": [ - "[주] ① 재료량에는 재료의 할증 및 손율이 포함되어 있다.", - "- ② 플랫 타이(Flat Tie) 대신 폼 타이(Form Tie) 사용 시 소요 수량은 12-4 합판 거푸집 자재수량을 따른다." - ], - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸 — 읽기가 「- 숫자.」 줄에서 끊기는 탓. 잘라내면 진짜 계수가 나갈 수 있어 **붙여 둔 쪽**을 지킴(2026-09-18 브레인 「애매하면 안 깎는 쪽」).", - "owner": "랩탑 메인", - "owner_ask": "대조 시험의 **[주] 이어짐 읽기**(`_NOTE_CONT`)를 넓혀 줄 것 — 「- 숫자.」 로 시작하는 [주] 안쪽 줄을 이어짐으로 볼 것. 지금은 거기서 끊겨 마스터가 **뒤 표의 [주]**까지 끌어다 붙인 것으로 뜸. ⚠ 새 절 제목과 가르는 것이 일 — 알맹이(콜론·등호) 없는 짧은 줄만 제목.", - "why": "마스터가 **뒤 표의 [주]**까지 끌어다 붙임 — 잘라내면 진짜 계수가 나갈 수 있어 붙여 둔 쪽(2026-09-18 브레인 「애매하면 안 깎는 쪽」)", - "갈래": "남의 몫" - } - }, - "form": {}, - "form_blind": { - "F0333": { - "section": "12-1-3", - "master": "requirement", - "why": "⚠ 원문 md 의심 — 머리는 한중콘크리트 「온도 품종 | 0℃때 …」인데 몸 줄이 앞 표 F0332(시멘트·모래·자갈 kg)와 똑같음 · PDF 대조 필요", - "class": "㉯ 남이 고쳐야 풀림", - "class_why": "⚠ **원문 md 자체가 의심** — 머리는 한중콘크리트 「온도 품종 | 0℃때 …」인데 몸 줄이 앞 표 F0332(시멘트·모래·자갈 kg)와 똑같음. PDF 대조로 어느 쪽이 맞는지 짚어야 풀림.", - "owner": "랩탑 서브", - "owner_ask": "PDF ↔ md 대조 — 이 표의 **몸 줄이 앞 표와 똑같음**. PDF 로 어느 쪽이 맞는지 짚어 줄 것", - "갈래": "남의 몫" - }, - "F0405": { - "section": "13-4-1", - "master": "requirement", - "why": "증가율(%) 표(높이별 30·40·60%) — 품이 아니라 할증률 모양 · requirement 가 맞는지 사람 확인", - "class": "㉰ 사용자 판단 필요", - "class_why": "높이별 증가율(30·40·60%) 표 — **품이 아니라 할증률 모양**인데 우리 잣대엔 그 갈래가 없어 requirement 로 둠. 성격을 정해야 닫힘.", - "갈래": "잣대" - } - }, - "cost_rule": {}, - "_갈래": { - "센 날": "2026-09-18", - "잣대": "세 갈래 — **원문 부재**: 원문에 값·글이 없어 채울 것이 없음 · **잣대**: 원문에 있으나 우리 판정·읽기 잣대가 못 가름(우리가 고칠 자리) · **남의 몫**: 원문 md·남의 도구를 남이 고쳐야 풀림. 누가 하나는 class 로 — ㉮ 영영 할 일 아님 · ㉯ 남이 고쳐야 풀림 · ㉰ 사용자·브레인 판단.", - "셈": { - "남의 몫 / ㉯": 17, - "잣대 / ㉰": 5 - }, - "곁줄": "이 장부는 **어긋남**만 담음 — 「원문에 밑수가 없는 것」 71 건은 어긋남이 아니라 `basis_missing_2026-01-01.json` 에 사유와 함께 따로 있음. 그래서 여기 「원문 부재」 가 0 이어도 원문 부재 자체가 없다는 뜻은 아님.", - "할 일 아님으로 뺀 줄": 33, - "곁줄2": "⚠ 갈래 낱말이 두 벌임 — **여기(어긋남)**는 「무엇이 막고 있나」(원문 부재·잣대·남의 몫), **`_할일_아님`** 은 「왜 할 일이 아닌가」(거절 자리·그 밖·부록 예시·원문 부재). 섞어 읽지 말 것." - }, - "_할일_아님": { - "갈래별": { - "거절 자리": 11, - "그 밖": 17, - "부록 예시": 1, - "원문 부재": 4 - }, - "셈": { - "basis": 9, - "cost_rule": 7, - "form": 7, - "form_blind": 10 - }, - "옮긴 날": "2026-09-18", - "잣대": "㉮ — **영영 할 일이 아닌 것.** 원문에 없음 · 거절 자리 · 판정으로 닫힘. 바퀴마다 다시 세어지지 않게 갈래에서 **아주 빼** 여기 둔다. ⚠ 지우지는 않는다 — 되짚을 수 있어야 한다.", - "줄": { - "basis": { - "F0132": { - "section": "5-22-4", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(일당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "「일당」은 품의 단위(하루에 얼마)라 밑수로 안 받음", - "갈래": "거절 자리" - }, - "F0145": { - "section": "5-28-2", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(일 당)", - "master": null, - "why": "작업조 표(시공량 열이 밑수) — 마스터가 일부러 비움(c3da333e 와 같은 까닭) · 원문 「(일 당)」 은 코덱스 복원(61f35108)", - "class": "㉮ 영영 할 일 아님", - "class_why": "작업조 표 — 밑수가 시공량 열이라 일부러 비움", - "갈래": "거절 자리" - }, - "F0338": { - "section": "12-6", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(단위: 일당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "작업조 표(포장공 3인·시공량)", - "갈래": "거절 자리" - }, - "F0339": { - "section": "12-7-1", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(단위: 일당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "작업조 표 — 넣으면 호표가 사라짐(2026-09-17 유로폼에서 실증)", - "갈래": "거절 자리" - }, - "F0340": { - "section": "12-7-2", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(단위: 일당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "위와 같음", - "갈래": "거절 자리" - }, - "F0341": { - "section": "12-8", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(단위: 일당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "위와 같음", - "갈래": "거절 자리" - }, - "F0395": { - "section": "12-38-3", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(단위: 일 당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "위와 같음", - "갈래": "거절 자리" - }, - "F0447": { - "section": "13-15-2", - "md": [ - 1.0, - "조" - ], - "md_evidence": "(단위: 인/조)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "「인/조」 — 「조」는 세는 단위가 아님", - "갈래": "거절 자리" - }, - "F0449": { - "section": "13-16-2", - "md": [ - 1.0, - "일" - ], - "md_evidence": "(단위: 일 당)", - "master": null, - "class": "㉮ 영영 할 일 아님", - "class_why": "위와 같음", - "갈래": "거절 자리" - } - }, - "form": { - "F0002": { - "section": "1-2-2", - "master": "reference", - "md_mark": "직공인부", - "class": "㉮ 영영 할 일 아님", - "class_why": "사람 판정으로 닫힘 — 시험이 md 표지로 가리는 것과 다를 뿐", - "갈래": "그 밖" - }, - "F0007": { - "section": "1-2-7", - "master": "reference", - "md_mark": "㎥", - "class": "㉮ 영영 할 일 아님", - "class_why": "사람 판정으로 닫힘 — 시험이 md 표지로 가리는 것과 다를 뿐", - "갈래": "그 밖" - }, - "F0038": { - "section": "1-4-26", - "master": "reference", - "md_mark": "적용시공량", - "class": "㉮ 영영 할 일 아님", - "class_why": "사람 판정으로 닫힘 — 시험이 md 표지로 가리는 것과 다를 뿐", - "갈래": "그 밖" - }, - "F0227": { - "section": "8-6-1", - "master": "reference", - "md_mark": "유효 살포량/1회", - "why": "사람 판정 reference(Forms.py) — 기종별 희석배수별 1회 살포량 계산표", - "class": "㉮ 영영 할 일 아님", - "class_why": "사람 판정으로 닫힘 — 시험이 md 표지로 가리는 것과 다를 뿐", - "갈래": "그 밖" - }, - "F0257": { - "section": "9-11-1", - "master": "coefficient", - "md_mark": "ℓ", - "class": "㉮ 영영 할 일 아님", - "class_why": "사람 판정으로 닫힘 — 시험이 md 표지로 가리는 것과 다를 뿐", - "갈래": "거절 자리" - }, - "F0454": { - "section": "14-2", - "master": "reference", - "md_mark": "작업량", - "class": "㉮ 영영 할 일 아님", - "class_why": "사람 판정으로 닫힘 — 시험이 md 표지로 가리는 것과 다를 뿐", - "갈래": "부록 예시" - }, - "F0406": { - "section": "13-4-1", - "master": "reference", - "md_mark": "개", - "why": "〈돌쌓기의 개수 및 중량의 표준〉 — 개수·중량 기준표라 사람이 참고로 판정함([주] 인력 돌쌓기·돌붙임 기준). md 쪽은 단위 칸 「개」만 보고 품 표로 읽음", - "class": "㉮ 영영 할 일 아님", - "class_why": "〈돌쌓기의 개수 및 중량의 표준〉 — 품 표가 아니라 개수·중량 기준표. md 읽기가 단위 칸 「개」만 보고 품 표로 읽음. 사람 판정으로 닫힘([주] 인력 돌쌓기·돌붙임 기준).", - "갈래": "거절 자리" - } - }, - "form_blind": { - "F0046": { - "section": "2-1-1", - "master": "requirement", - "why": "기계별 연료표(천공기 주연료 3ℓ·잡품 95%) — 품 낱말 없음. 같은 모양 F0044·F0045·F0047 은 사람 판정 reference 인데 이것만 requirement", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "그 밖" - }, - "F0062": { - "section": "2-1-12", - "master": "requirement", - "why": "값 칸이 「층적부피에 따라 별도계산」·「〃」 — 숫자 없음", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "원문 부재" - }, - "F0140": { - "section": "5-26-1", - "master": "requirement", - "why": "머리 소실 — 토성×깊이 숫자만 남음(사람 판정: 100㎡당 보통인부)", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "그 밖" - }, - "F0141": { - "section": "5-26-2", - "master": "requirement", - "why": "머리 소실 — 위와 같음", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "그 밖" - }, - "F0142": { - "section": "5-26-3", - "master": "requirement", - "why": "머리 소실 — 위와 같음", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "그 밖" - }, - "F0195": { - "section": "7-14", - "master": "productivity", - "why": "칸이 뭉침(「㎥ 0.06∼0.07 4.8∼6.0」) — 원문 md 가 병합 칸을 못 풂", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "그 밖" - }, - "F0237": { - "section": "8-11", - "master": "productivity", - "why": "작업량이 식 글(「Q = 3.5 ㎥/hr」)", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "그 밖" - }, - "F0342": { - "section": "12-9-1", - "master": "requirement", - "why": "수량 칸이 비어 있음 — 원문에 값 없음(「반중력식 옹벽 참조」)", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "원문 부재" - }, - "F0343": { - "section": "12-9-2", - "master": "requirement", - "why": "수량 칸이 비어 있음 — 원문에 값 없음", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "원문 부재" - }, - "F0344": { - "section": "12-9-3", - "master": "requirement", - "why": "수량 칸이 비어 있음 — 원문에 값 없음", - "class": "㉮ 영영 할 일 아님", - "class_why": "시험이 못 가리는 자리 — 값 없음·머리 소실 따위로 사람 판정에 둠", - "갈래": "원문 부재" - } - }, - "cost_rule": { - "F0369": { - "section": "12-22", - "master": "sub_requirement", - "md_rows": [ - [ - "재료비" - ], - [ - "설치비", - "재료비의 5%" - ] - ], - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - }, - "F0379": { - "section": "12-29", - "master": "requirement", - "md_rows": [ - [ - "재료비", - "", - "필요수량" - ], - [ - "설치비", - "재료비의 5%" - ] - ], - "why": "⚠ 같은 꼴 6표는 reference 인데 이것만 requirement — 설치비 = 재료비의 5%", - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - }, - "F0382": { - "section": "12-31", - "master": "sub_requirement", - "md_rows": [ - [ - "재료비" - ], - [ - "설치비", - "주재료비의 5%" - ] - ], - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - }, - "F0383": { - "section": "12-32", - "master": "sub_requirement", - "md_rows": [ - [ - "재료비", - "P.V.C ∅54m/m" - ], - [ - "설치비", - "주재료비의 10%" - ] - ], - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - }, - "F0386": { - "section": "12-34-2", - "master": "sub_requirement", - "md_rows": [ - [ - "재료비", - "1회 기준 46.1%" - ], - [ - "노무비", - "1회 기준 47.1%" - ] - ], - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - }, - "F0388": { - "section": "12-34-4", - "master": "sub_requirement", - "md_rows": [ - [ - "재료비", - "JOINT FILLER" - ] - ], - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - }, - "F0390": { - "section": "12-36", - "master": "sub_requirement", - "md_rows": [ - [ - "제작비", - "견적처리" - ], - [ - "운송비", - "견적처리" - ], - [ - "설치비", - "견적처리" - ] - ], - "class": "㉮ 영영 할 일 아님", - "class_why": "비목 구성표 — `sub_requirement` 로 판정이 닫힘", - "갈래": "그 밖" - } - } - } - } -} diff --git a/resources/tester/test_b05_structures_migration.py b/resources/tester/test_b05_structures_migration.py index a99c28ecf..08fcf1eba 100644 --- a/resources/tester/test_b05_structures_migration.py +++ b/resources/tester/test_b05_structures_migration.py @@ -143,7 +143,7 @@ def test_group_a_labels_from_server_are_not_re_migrated(): {"chainage_m": 149.73, "structure": "세월교"}, {"chainage_m": 200.92, "structure": "BOX암거"}, {"chainage_m": 210.0, "structure": "물넘이포장"}, - {"chainage_m": 30.0, "structure": "배수관"}, + {"chainage_m": 30.0, "structure": "배관"}, {"chainage_m": 60.0, "structure": "노출형 횡단수로"}, {"chainage_m": 70.0, "structure": "개거(겉도랑)"}, ] diff --git a/resources/tester/test_b06_ground_unlock.py b/resources/tester/test_b06_ground_unlock.py index 7cd714e7f..a6b7de2ec 100644 --- a/resources/tester/test_b06_ground_unlock.py +++ b/resources/tester/test_b06_ground_unlock.py @@ -64,7 +64,7 @@ def test_원지반이_자리_다단을_막지_않고_노면_위만_막음(tmp_pa ) for emitted in out.rglob("*.js"): emitted.write_text( - re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + re.sub(r'(from\s+"\.{1,2}/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), encoding="utf-8", ) base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Solve.js")) diff --git a/resources/tester/test_b06_height_unlock.py b/resources/tester/test_b06_height_unlock.py index 476ccc7d4..2761ee9c8 100644 --- a/resources/tester/test_b06_height_unlock.py +++ b/resources/tester/test_b06_height_unlock.py @@ -62,7 +62,7 @@ def test_높이는_입력_그대로_0이하만_멈춤(tmp_path: Path) -> None: ) for emitted in out.rglob("*.js"): emitted.write_text( - re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + re.sub(r'(from\s+"\.{1,2}/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), encoding="utf-8", ) base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Const.js")) diff --git a/resources/tester/test_b08_ancillary.py b/resources/tester/test_b08_ancillary.py deleted file mode 100644 index 99afa9eaf..000000000 --- a/resources/tester/test_b08_ancillary.py +++ /dev/null @@ -1,85 +0,0 @@ -"""부대시설 다섯 줄 — 법이 요구하는데 안 내던 것 (2026-09-09 사용자 확정 ⑬). - -⚠ 겨누는 것 여섯 - ① 다섯 줄이 **개소를 안 넣어도 선다** — 빠진 것이 화면에 보이게 - ② 개소를 **지어내지 않는다** — `연장÷500` 같은 산식을 쓰지 않음 - ③ ⚠ 사유를 **갈라 적는다** — 「품셈에 공종이 없음」과 「개소 미입력」은 할 일이 다름 - ④ 법정 의무인 줄은 **그 사실이 사유에 적힌다** - ⑤ 공종이 있는 하나(가설창고)는 **값이 서고 코드가 붙는다** - ⑥ 인계에도 **실려 나간다** — 빼면 받는 쪽이 빠진 줄을 못 봄 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 - STATUS_READY, - ancillary_rows, - build_table, -) - - -def 줄(counts: dict | None = None) -> dict: - return {row["item"]: row for row in ancillary_rows(counts)} - - -def test_다섯_줄이_개소_없이도_선다() -> None: - rows = 줄() - assert set(rows) == { - "국가지점번호판", - "임도 안내판", - "차단기", - "가설창고(컨테이너)", - "수방대책 자재", - } - assert all(row["amount"] is None for row in rows.values()) - - -def test_개소를_지어내지_않는다() -> None: - """⚠ 임도규정이 「필요시 거리를 조정」이라 `연장÷500` 은 확정 산식이 아니다.""" - for row in 줄().values(): - assert row["amount"] is None - assert "개소가 아직 입력되지 않았습니다" in row["reason"] - - -def test_사유를_갈라_적는다() -> None: - """⚠ 「품셈에 공종이 없음」과 「개소 미입력」은 **다음에 할 일이 다르다**.""" - rows = 줄({"national_point_sign": 3}) - sign = rows["국가지점번호판"] - assert sign["amount"] == 3.0 - # 개소는 채워졌으니 그 사유는 사라지고, 공종이 없다는 사유만 남는다. - assert "개소가 아직 입력되지 않았습니다" not in sign["reason"] - assert "품셈에 그 이름의 공종이 없음" in sign["reason"] - - -def test_법정_의무가_사유에_적힌다() -> None: - rows = 줄() - assert "법정 의무" in rows["국가지점번호판"]["reason"] - assert "제26조제5항" in rows["국가지점번호판"]["reason"] - assert "법정 의무" in rows["임도 안내판"]["reason"] - # 차단기는 교본만 있고 법령에 없다 — 「법정」이라 적으면 거짓이 된다. - assert "법정 의무" not in rows["차단기"]["reason"] - - -def test_공종이_있는_하나는_값이_선다() -> None: - row = 줄({"site_container": 2})["가설창고(컨테이너)"] - assert (row["work_item_code"], row["amount"], row["status"]) == ("FP-11-01", 2.0, STATUS_READY) - - -def test_준비공_표와_인계에_함께_실린다() -> None: - """⚠ 빼면 받는 쪽이 「빠진 줄」을 못 본다 — 값이 없어도 사유와 함께 간다.""" - table = build_table({}, [], [], None, None, {"site_container": 1}) - names = [row["item"] for row in table["rows"]] - assert "국가지점번호판" in names - - rows = build_handoff(preparation_table=table)["work_items"] - handed = {row["name"]: row for row in rows} - assert "국가지점번호판" in handed - assert handed["국가지점번호판"]["in_bill"] is False - assert handed["가설창고(컨테이너)"]["work_item_code"] == "FP-11-01" diff --git a/resources/tester/test_b08_back_length_handoff.py b/resources/tester/test_b08_back_length_handoff.py deleted file mode 100644 index 6ef22f21c..000000000 --- a/resources/tester/test_b08_back_length_handoff.py +++ /dev/null @@ -1,66 +0,0 @@ -"""관 벽 · 돌쌓기 줄 뒷길이 — 인계가 **B08 이 실제로 쓴 뒷길이 + 출처**를 싣는다(2026-09-14 브레인). - -종전: 인계는 저장 원본 `back_len_cm` 만 실어, 안 고른 벽(관 유입·유출부 기슭막이는 늘 빔)은 -`variant_value=None` → B09 가 13-04-02/05 뒷길이 갈래(#35·55·75cm이하)를 못 고름. 그런데 수량표· -구조물도는 이미 품셈 13-4-4 [주]⑩ 표준표 하한으로 섰음(두께식·고임돌·채움콘크리트가 그 값). -⇒ 값이 선 그 뒷길이를 갈래 값으로 · 출처(저장 / [주]⑩ 하한)를 판정 근거 칸에. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 - - -def _row(type_id: str, options: dict) -> dict: - unit = build_unit( - [ - { - "structure_id": "x", - "type_id": type_id, - "start_m": 0.0, - "end_m": 10.0, - "options": options, - } - ], - {type_id: type_id}, - ) - handoff = build_handoff(unit_quantity_table=unit) - return next(r for r in handoff["work_items"] if r["variant_axis"] == "back_len_cm") - - -def test_안_고른_뒷길이는_표준표_하한을_출처와_함께_보냄() -> None: - row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0}) - assert row["variant_value"] == "25", row # 찰쌓기 ≤1.5m 하한 25㎝ - assert "뒷길이 25㎝" in row["spec_class_basis"] and "[주]⑩" in row["spec_class_basis"] - - -def test_고른_뒷길이는_원본값_그대로_출처는_저장_제원() -> None: - row = _row("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"}) - assert row["variant_value"] == "45" - assert "[주]⑩" not in row["spec_class_basis"] and "저장 제원" in row["spec_class_basis"] - - -def test_기슭막이_메쌓기도_하한으로_갈래가_서서_내역_금액이_섬() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - options = {"height_m": 2.5, "length_m": 10.0, "form": "돌쌓기(메)"} - row = _row("revetment", options) - assert row["variant_value"] == "45", row # 메쌓기 ~3m 하한 36 → 규격 45 - assert row["work_item_code"] == "FP-13-04-02" and not row["blocked_kind"], row - unit = build_unit( - [{"structure_id": "x", "type_id": "revetment", "start_m": 0.0, "end_m": 10.0, - "options": options}], - {"revetment": "revetment"}, - ) # fmt: skip - bill = build_bill(build_handoff(unit_quantity_table=unit)) - line = next(r for r in bill.rows if r.code == "FP-13-04-02") - assert line.amount_krw and int(line.amount_krw) > 0, line.note - assert line.price_code.endswith("#55cm이하"), line.price_code # 45㎝ 를 담는 가장 좁은 구간 diff --git a/resources/tester/test_b08_back_length_standard.py b/resources/tester/test_b08_back_length_standard.py deleted file mode 100644 index 7a26c8fe7..000000000 --- a/resources/tester/test_b08_back_length_standard.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -"""뒷길이 표준(품셈 13-4-4 [주]⑩) — 안 고른 돌쌓기의 뒷길이는 **표의 하한** (2026-09-14 브레인 판정 ㉮). - -앞서 붙박이 45㎝ 로 섰음. 표는 높이 × 메/찰 로 범위를 줌 — 하한이 규격(25·30·35·45·55·60·75)에 -없으면 **범위 안 그 위 첫 규격**(메쌓기 ~3m·~5m 하한 36 → 45). 경계값은 앞 칸(표준경사와 같음). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402 - _back_length, - back_length_default_note, - standard_back_length, -) - -# (높이, 메쌓기 제안, 찰쌓기 제안) — 원문 L7174~7177 -CELLS = [ - (1.0, 25, 25), - (1.5, 25, 25), # 경계값은 앞 칸 - (2.5, 45, 30), # 메 36~45 → 45 · 찰 30~35 → 30 - (3.0, 45, 30), - (4.0, 45, 35), # 메 36~60 → 45 · 찰 35~45 → 35 - (6.0, 45, 35), # 메 45~75 · 찰 35~55 - (7.5, 75, 45), # 메 75이상 · 찰 45~60 -] - - -@pytest.mark.parametrize("height, dry, wet", CELLS) -def test_표_하한(height: float, dry: int, wet: int) -> None: - assert standard_back_length(wet=False, height_m=height)[0] == dry - assert standard_back_length(wet=True, height_m=height)[0] == wet - - -def test_하한이_규격에_없으면_그_위_첫_규격_사유() -> None: - cm, basis = standard_back_length(wet=False, height_m=2.5) - assert cm == 45 - assert "36~45㎝" in basis and "하한 36㎝ 는 규격 일곱(원문 L7152)에 없어" in basis - assert "원문 L7172~7181" in basis # 사용자가 되짚을 줄 번호(브레인 2026-09-14) - - -def test_고른_값이_이긴다() -> None: - assert _back_length({"back_len_cm": "55"}, wet=True, height_m=2.5) == 55 - assert back_length_default_note({"back_len_cm": 55}, 55, wet=True, height_m=2.5) is None - - -def test_메찰_높이를_모르는_자리는_종전_45() -> None: - assert _back_length({}) == 45 - assert "기본 45㎝" in (back_length_default_note({}, 45) or "") diff --git a/resources/tester/test_b08_backfill_range.py b/resources/tester/test_b08_backfill_range.py deleted file mode 100644 index 2d097fa13..000000000 --- a/resources/tester/test_b08_backfill_range.py +++ /dev/null @@ -1,169 +0,0 @@ -"""막자갈(뒤채움 조약돌) — **품셈이 정한 두께 범위 안에 드는가** (2026-09-09). - -⚠⚠ **이 시험은 「같은지」가 아니라 「범위 안인지」를 잰다.** 우리 막자갈은 품셈과 **다른 식**이다. - 품셈 13-4-4 [주]⑨ 는 뒤채움 조약돌 두께를 **직고별 범위**로만 준다(상부 20~40㎝ · - 하부 직고별 30~140㎝). 두 길이 **같은 자리에 도착하는지**를 본다. -⚠⚠ **2026-09-09 오후 식이 갈아탔다** — 랩탑 보조가 확정 5차 작은 것 3 으로 막자갈을 - 「입적 − 몸통 − 고임돌」에서 **뒷채움 사다리꼴**로 바꿨다(정본 여섯 탭 상 0.30 · 하 0.45). - 그래서 아래 기록을 전부 다시 쟀다 — **옛 값(0.278~0.943)은 지금 값이 아니다.** - ⚠ **등호로 바꾸지 말 것** — 등호로 두면 우리 식이 품셈 식에 끌려간다. 우리 식은 우리 식이다. - ⚠ **범위 밖으로 나가면 「틀림」이 아니라 「봐야 함」이다** — 우리 식이 맞을 수도 있다. - 다만 **두께식이 바뀌면 조용히 새 나갈 자리**라(값은 계속 나오므로 아무도 못 본다) - 그 자리에 이 그물을 둔다. - -⚠ **이 시험이 잰 범위는 직고 1.0~7.5 m · 뒷길이 35~75㎝ 다.** - **검산은 「어디까지 쟀는지」를 함께 적지 않으면 다음 사람이 착시를 본다** — 앞서 4.0 까지만 - 재고 「다 범위 안」이라 한 적이 있다(그때 식으로는 4.5 부터 밖이었다). -⚠ **큰돌쌓기(13-6)는 이 시험의 대상이 아니다** — 두께를 **직경 위 끝**으로 보고(`BOULDER_MASONRY`) - 막자갈도 안 낸다. 그래서 높이 한계가 없어도 이 자리에 안 걸린다(2026-09-09 실측 확인). - -품셈 13-4-4 [주]⑨ <성토의 경우 뒤채움 조약돌의 두께> - 직고 ∼1.5m 상부 20~40 · 하부 30~60 ⇒ 평균 0.25~0.50 m - 직고 ∼3.0m 상부 20~40 · 하부 45~75 ⇒ 평균 0.33~0.58 m - 직고 ∼5.0m 상부 20~40 · 하부 60~100 ⇒ 평균 0.40~0.70 m - 직고 ∼7.0m 상부 20~40 · 하부 80~140 ⇒ 평균 0.50~0.90 m -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry # noqa: E402 - -#: (직고 상한, 평균두께 범위) — 원문 상·하부 범위의 평균이다. -PUMSEM_RANGE_M: tuple[tuple[float, float, float], ...] = ( - (1.5, 0.25, 0.50), - (3.0, 0.33, 0.58), - (5.0, 0.40, 0.70), - (7.0, 0.50, 0.90), -) - - -def 범위(height_m: float) -> tuple[float, float]: - for limit, low, high in PUMSEM_RANGE_M: - if height_m <= limit: - return low, high - return PUMSEM_RANGE_M[-1][1], PUMSEM_RANGE_M[-1][2] - - -def 두께(height_m: float, back_cm: int) -> float: - """우리 막자갈을 **면적으로 나눈 값** — 품셈이 말하는 「뒤채움 두께」와 같은 축.""" - components, _ = stone_masonry( - height_m, - 10.0, - {"height_m": height_m, "length_m": 10.0, "back_len_cm": back_cm}, - wet=True, - face="성토", - ) - got = {c.name: c.amount for c in components} - return got["막자갈"] / got["돌쌓기"] - - -def test_3m_까지는_품셈_범위_안에_든다() -> None: - """⚠ 실패하면 「틀렸다」가 아니라 **「봐야 한다」**는 뜻이다 — 두께식이 바뀐 자리를 알리는 그물. - - ⚠ **3.0m 까지인 것이 우연이 아니다** — 정본 여섯 탭을 전수 확인한 구간이 **H=1.0~3.0** - 이다(`STONE_BACKFILL_WIDTH_M` 머리 주석). 근거가 있는 구간에서는 품셈 범위와 맞고, - 근거가 없는 구간에서 갈린다. - """ - for height, back in ((1.0, 45), (1.2, 35), (2.5, 45), (3.0, 45), (3.0, 55), (3.0, 75)): - low, high = 범위(height) - got = 두께(height, back) - assert low <= got <= high, ( - f"H={height} ℓ3={back}㎝ 뒤채움 두께 {got:.3f}m 가 품셈 13-4-4 [주]⑨ 범위" - f" {low}~{high}m 밖 — 우리 식이 맞을 수도 있으니 **틀림이 아니라 봐야 함**" - ) - - -def test_3m_를_넘으면_범위_아래로_나간다() -> None: - """⚠⚠ **지금 상태를 기록해 두는 시험이다**(2026-09-09 오후 실측). - - ⚠ **방향이 뒤집혔다** — 옛 식은 높은 벽에서 품셈 범위 **위**로 나갔는데, 새 식은 - **아래**로 나간다. 새 식은 두께가 H 에 안 붙기 때문이다. - H 1.0 0.364 · 1.5 0.364 (안) · 2.0~3.0 0.359 (안) - H 3.5~5.0 0.354 · 6.0~7.0 0.348 · 7.5 0.342 (전부 **밖 — 아래**) - 까닭은 새 막자갈이 **(상 0.30 + 하 0.45) ÷ 2 × H × 연장** 이라 두께(= 막자갈 ÷ 비탈면적)가 - **0.375 ÷ √(1+경사²)** 로 거의 붙박이인 데 있다. 직고가 커지면 표준경사가 커져 - 비탈면적만 늘어 **오히려 조금씩 얇아진다.** 품셈 표는 반대로 직고와 함께 두꺼워진다. - ⚠ **틀렸다고 단정하지 않는다** — 정본 여섯 탭이 근거이고 품셈 표는 **범위**다. 다만 - 정본 전수 확인 구간이 **H=1.0~3.0** 이고 갈리는 자리도 **3.0m 부터**라 겹친다 — - **높은 벽의 정본 근거가 없다**는 뜻이므로 확정 5차 작은 것 3 을 다시 볼 때 이 자리를 볼 것. - ⚠ 이 시험이 깨지면(범위 안으로 들어오면) **두께식이 바뀐 것**이니 위 기록을 고칠 것. - """ - 안쪽 = 두께(3.0, 45) - 바깥 = 두께(3.5, 45) - assert 범위(3.0)[0] <= 안쪽 <= 범위(3.0)[1] - assert 바깥 < 범위(3.5)[0] - assert 0.35 < 바깥 < 0.36 - - -def test_직고가_커져도_두께가_거의_안_움직인다() -> None: - """⚠ **뒤집힌 시험이다** — 종전에는 「직고가 커지면 두꺼워진다」였다(품셈 표와 같은 방향). - - 새 식에서는 막자갈 **물량**이 H 에 비례해 늘지만 **두께**(물량 ÷ 비탈면적)는 거의 - 붙박이다. 이 사실 자체를 못 박아, 나중에 누가 「두께가 직고를 따라간다」고 잘못 - 읽는 것을 막는다. - """ - thicknesses = [두께(h, 45) for h in (1.2, 2.5, 4.0, 6.0)] - assert max(thicknesses) - min(thicknesses) < 0.02 - # ⚠ 물량 자체는 직고를 따라 는다 — 두께가 안 움직인다고 물량까지 굳은 것이 아니다. - 부피 = [두께(h, 45) * h for h in (1.2, 2.5, 4.0, 6.0)] - assert 부피 == sorted(부피) - - -def test_뒷길이는_두께를_안_움직인다() -> None: - """⚠ 옛 식에서는 뒷길이가 밑수였다 — 지금은 정본 폭 붙박이라 아예 안 본다.""" - assert len({round(두께(2.5, b), 6) for b in (35, 45, 55, 60, 75)}) == 1 - - -def test_등호가_아니라_범위다() -> None: - """⚠ 이 시험의 뜻을 못 박는다 — 품셈 값과 **같지 않아도 된다**. - - 같아지길 요구하면 우리 식(정본 뒷채움 사다리꼴)이 품셈 식에 끌려간다. - """ - low, high = 범위(2.5) - got = 두께(2.5, 45) - assert got != low and got != high # 경계값과 같을 이유가 없다 - assert low < got < high - - -def test_근거_구간_밖이면_사유가_뜬다() -> None: - """⚠ **값은 계속 나온다** — 사유가 없으면 아무도 못 본다. - - 교본 7-3 이 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**, - 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 높은 벽이 실제로 설 수 있다. - 막지도 눅이지도 않고 **정본 확인 구간 밖이라는 사실**만 사유로 낸다(임의 확정 금지). - """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry - - def 사유(height_m: float) -> list[str]: - _, notes = stone_masonry( - height_m, - 10.0, - {"height_m": height_m, "length_m": 10.0, "back_len_cm": 45}, - wet=True, - face="성토", - ) - return [n for n in notes if "정본 확인 구간" in n] - - assert 사유(3.0) == [] # 근거가 있는 구간에서는 잔소리하지 않는다 - 밖 = 사유(3.5) - assert len(밖) == 1 - assert "13-4-4 [주]⑨" in 밖[0] and "실무자 확인" in 밖[0] - - -def test_큰돌쌓기는_이_식을_안_쓴다() -> None: - """⚠ 높이 한계가 없는 것은 큰돌쌓기뿐이라 **거기까지 번지면 살아 있는 자리**가 된다. - - 큰돌쌓기(13-6)는 막자갈 줄 자체를 안 낸다 — 이 갈림이 거기로 안 번진다. - """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry - - components, _ = boulder_masonry( - 5.0, 10.0, {"height_m": 5.0, "length_m": 10.0, "stone_cm": "60~80"} - ) - assert not any(c.name == "막자갈" for c in components) diff --git a/resources/tester/test_b08_basin_composite.py b/resources/tester/test_b08_basin_composite.py deleted file mode 100644 index 8f2bcc420..000000000 --- a/resources/tester/test_b08_basin_composite.py +++ /dev/null @@ -1,85 +0,0 @@ -"""12-15 집수정 조립 — 2026-09-14 브레인 ㉯ 남은 몫 판정 ⑴~⑸. - -원문 L6460 12-15 표는 조립형(구체·버림 인력 비고 칸 · 다짐기 · 거푸집 합판4회 · -철근가공조립(보통) · 뚜껑 · 설치비 5%). B08 은 집수정을 12-15 에 곧장(개소) 이어 단가가 안 섰음. -⑴ 다짐기(카탈로그 없음) 빠진 구체 갈래는 안 세움 — 인력만이면 조립 줄이 조용히 싸짐 -⑵ □형 원단위에 버림 성분 없음 → 「성분 미확보」 로 막음(0 은 지어냄) -⑶ 철근 갈래는 12-15 표 「보통」(B08 추정 「간단」 걷음 · 4회와 같은 원칙) -⑷ 뚜껑·설치비는 조각 모양이 달라 조립 밖 · 사유만 -⑸ 돌집수정은 콘크리트 집수정 표가 아니라 종전대로 -목적: 금액을 못 세워도 **조용히 사라지던 것이 사유로 드러남**. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - -CONCRETE = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0, - "options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트", - "pipe_diameter_mm": "800"}} # fmt: skip -STONE = {"structure_id": "b2", "type_id": "pipe_inlet_basin", "start_m": 60.0, "end_m": 60.0, - "options": {"inlet_basin_form": "돌집수정 ㄷ형"}} # fmt: skip - - -def _basin(structure: dict) -> dict: - unit = build_table([structure], {"pipe_inlet_basin": "집수정"}) - handoff = build_handoff(unit_quantity_table=unit) - return next(r for r in handoff["work_items"] if r["origin"] == "structure") - - -def test_버림_갈래는_서고_구체_갈래는_다짐기가_풀려야_섬() -> None: - """⑴ 다짐기 빠진 구체 갈래는 안 세움 — 2026-09-14 661 뒤 ① 진동기(엔진식 4611-0350)가 풀려 이제 섬.""" - from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - - build = cached_build() - assert "B-FP-12-15#구체콘크리트" in build.book.titles - rows = detail_of(build, "B-FP-12-15#버림콘크리트")["rows"] - assert {r["ref_code"]: float(r["quantity"]) for r in rows} == {"1013": 0.15, "1002": 0.27} - body = {r["ref_code"] for r in detail_of(build, "B-FP-12-15#구체콘크리트")["rows"]} - assert "X-4611-0350" in body, body # 인력만으로 서지 않음(⑴ 까닭 그대로) - left = " ".join(build.unattached.get("FP-12-15", [])) - assert "구체콘크리트" not in left and "봉상후렉시블" not in left, left - - -def test_콘크리트_집수정은_조립_조각으로_가고_막힌_까닭이_드러남() -> None: - row = _basin(CONCRETE) - assert row["work_item_code"] is None, row - codes = {p["code"]: p for p in row["composite_parts"]} - assert codes["FP-12-15#구체콘크리트"]["quantity"] > 0 - assert codes["FP-12-04#4회"]["quantity"] > 0 - assert "FP-12-03#보통" in codes, list(codes) # ⑶ 표가 적은 갈래 - reasons = " ".join(str(m.get("reason")) for m in row["composite_not_ready"] or []) - assert "FP-12-15#버림콘크리트" in {str(m.get("code")) for m in row["composite_not_ready"]} - assert reasons, row - assert "뚜껑" in row["spec_class_basis"] and "설치비" in row["spec_class_basis"] # ⑷ - - -def test_돌집수정은_종전대로_12_15_곧장() -> None: - row = _basin(STONE) - assert row["work_item_code"] == "FP-12-15" and not row["composite_parts"], row - - -def test_콘크리트_집수정_콘크리트는_타설_줄이_또_세지_않음() -> None: - unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"}) - handoff = build_handoff(unit_quantity_table=unit) - placing = [ - r for r in handoff["work_items"] if str(r["work_item_code"] or "").startswith("FP-12-01") - ] - assert not placing, placing # 구체 조각이 셈 — 두 번 안 셈 - - -def test_내역_조립_줄은_금액_없이_사유() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"}) - bill = build_bill(build_handoff(unit_quantity_table=unit)) - line = next(r for r in bill.rows if r.code is None and "집수정" in r.name) - assert line.amount_krw is None and "묶음 조각" in line.note, line.note diff --git a/resources/tester/test_b08_basis_unit.py b/resources/tester/test_b08_basis_unit.py deleted file mode 100644 index a7b9da3fb..000000000 --- a/resources/tester/test_b08_basis_unit.py +++ /dev/null @@ -1,313 +0,0 @@ -"""보내는 단위가 품셈 밑수와 같은가 — 금액이 실제로 틀렸던 자리 (2026-09-08). - -⚠ 겨누는 것 다섯 - ① 돌쌓기가 **㎡ · 비탈면적**으로 나가는가 (m · 연장이면 금액이 2.6배) - ② 성분이 안 서면 **연장으로 대신 세지 않는가** — 세면 다시 틀린 축이다 - ③ 밑수가 m 인 공종(맹암거·측구)은 **종전대로** 연장인가 - ④ 표기 차이(㎥/m3 · 개소/개)는 흡수하되 **뜻이 다른 단위는 드러내는가** - ⑤ 인계본에 그 경고가 **실제로 실리는가** (만들어 두고 안 부르면 없는 것과 같다) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( # noqa: E402 - basis_units, - normalize_unit, - verify_unit_matches_basis, -) -from B08_Quantity.B08_Quantity_Engine_Handoff import ( # noqa: E402 - build_handoff, - load_mapping, -) - -MAPPING = load_mapping() - - -def 돌쌓기(**options: object) -> dict: - """전개까지 실제로 돌린 구조물 한 기 — 값을 손으로 적지 않는다.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand - - item = expand( - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, **options}, - }, - names={"masonry_wet": "돌쌓기(찰)"}, - ) - return { - "structure_id": item.structure_id, - "type_id": item.type_id, - "name": item.name, - "length_m": item.length_m, - "height_m": item.height_m, - "start_m": item.start_m, - "end_m": item.end_m, - "options": item.options, - "notes": item.notes, - "components": [ - {"name": c.name, "unit": c.unit, "amount": c.amount, "basis": c.basis} - for c in item.components - ], - } - - -def 인계(structure: dict) -> dict: - return build_handoff(unit_quantity_table={"structures": [structure]}, mapping=MAPPING) - - -def test_돌쌓기는_면적으로_나간다() -> None: - """⚠ 실측 — 10m 로 나가 529,389원이 섰다. 26.101㎡ 로 세야 1,381,753원이다.""" - row = 인계(돌쌓기(back_len_cm=45))["work_items"][0] - assert row["work_item_code"] == "FP-13-04-05" - assert row["unit"] == "㎡" - assert abs(row["quantity"] - 26.101) < 0.01, row["quantity"] - assert row["in_bill"] is True - - -def test_면적이_안_서면_연장으로_대신_세지_않는다() -> None: - """뒷길이 40 은 품셈 표에 없어 전개가 안 선다 — 그때 10.0 m 로 서면 안 된다.""" - row = 인계(돌쌓기(back_len_cm=40))["work_items"][0] - assert row["quantity"] != 10.0 - assert row["in_bill"] is False or row["blocked_kind"] - assert row["blocked_reason"] - - -def test_밑수가_m_인_공종은_종전대로_연장이다() -> None: - row = 인계( - { - "structure_id": "s2", - "type_id": "underdrain", - "name": "맹암거", - "length_m": 40.0, - "start_m": 0.0, - "end_m": 40.0, - "components": [], - "notes": [], - "options": {}, - } - )["work_items"][0] - assert (row["unit"], row["quantity"]) == ("m", 40.0) - - -def test_표기_차이는_흡수한다() -> None: - assert normalize_unit("m3") == normalize_unit("㎥") - assert normalize_unit("개") == "개소" - rows = [{"work_item_code": "FP-12-15", "unit": "개", "in_bill": True, "name": "집수정"}] - assert verify_unit_matches_basis(rows) == [] - - -def test_뜻이_다른_단위는_드러낸다() -> None: - """⚠ 환산하지 않는다 — m 을 ㎡ 로 바꾸려면 두께를 지어내야 한다.""" - rows = [{"work_item_code": "FP-13-04-05", "unit": "m", "in_bill": True, "name": "돌쌓기(찰)"}] - warnings = verify_unit_matches_basis(rows) - assert warnings and "㎡" in warnings[0] - - -def test_마스터에_없는_코드는_트집_잡지_않는다() -> None: - rows = [{"work_item_code": "FP-99-99", "unit": "포", "in_bill": True, "name": "가짜"}] - assert verify_unit_matches_basis(rows) == [] - - -def test_인계본이_그_경고를_싣는다() -> None: - """⚠ 검사를 만들어 두고 안 부르면 없는 것과 같다(2026-09-08 자기 감사에서 둘이 놀고 있었다).""" - handoff = 인계(돌쌓기(back_len_cm=45)) - assert handoff["basis_unit_warnings"] == [] - - -def test_품셈에_면적_밑수가_실제로_있다() -> None: - """⚠ 우리 판단의 근거가 **원문**인지 확인한다 — 없으면 이 고침 자체가 근거를 잃는다.""" - table = basis_units() - assert table["FP-13-04-05"] == {"㎡"} - assert table["FP-13-06-01"] == {"㎡"} - assert "m" in table["FP-12-10"] - - -# ── 마스터가 밑수를 모르는 공종 — 매핑이 원문에서 읽어 채운다 (2026-09-08 층따기) ── -# ⚠ 층따기 9-18 은 절 머리에 「(단위: …)」가 없고 [주] 공식 `Q1 = … = ㎥/시간` 만 있다. -# 마스터 `basis_unit` 이 비어 대조가 조용히 통과했고, ㎡ 2,645.21 × ㎥당 단가로 -# 410만원이 서 있었다. - - -def 토공(group: str, unit: str, amount: float, item: str | None = None) -> dict: - row = {"group": group, "unit": unit, "amount": amount, "in_bill": True} - if item: - row["item"] = item - return row - - -def test_층따기_길이를_넣으면_체적으로_나간다() -> None: - """⭐ 2026-09-09 확정 2차 ① — 면적이 정본이고 **부피는 사용자가 넣은 길이를 곱해** 쓴다. - - 그러면 품셈 ㎥ 단가를 그대로 쓸 수 있어 단위 불일치가 풀린다. - ⚠ 면적은 없애지 않는다 — 횡단도 하단 표가 면적을 쓴다. **㎥ 를 덧붙이는 것**이다. - """ - row = build_handoff( - summary_table={"rows": [토공("층따기", "㎡", 13696.75)]}, bench_cut_depth_m=0.5 - )["work_items"][0] - assert (row["unit"], row["quantity"]) == ("㎥", 13696.75 * 0.5) - assert row["in_bill"] is True - # 어디서 온 값인지 규격 칸에 남는다 — 면적을 못 되짚으면 검산이 안 된다. - assert "13,696.75㎡" in row["spec_detail"] and "0.5m" in row["spec_detail"] - - -def test_층따기_길이가_없으면_막히고_사유가_간다() -> None: - """⚠ 길이 기본값을 **임의로 박지 않는다** — 교본이 「설계도서에 명시」라 설계 입력이다.""" - row = build_handoff(summary_table={"rows": [토공("층따기", "㎡", 13696.75)]})["work_items"][0] - assert row["in_bill"] is False - assert row["blocked_kind"] == "input_missing" - assert "길이" in row["blocked_reason"] - # 면적은 규격 칸에 남는다 — 값이 사라지면 왜 막혔는지 못 짚는다. - assert "13,696.75" in row["spec_detail"] - - -def _옛_시험_층따기는_면적으로_나간다() -> None: - """⭐ 2026-09-09 사용자 확정 ⑦ — 단위는 **㎡**(실무 관행). - - ⚠ 앞서는 「품셈 공식이 ㎥ 라 못 곱한다」로 막아 두었다(410만원 자리). 확정으로 ㎡ 가 - 정본이 되었으므로 **막지 않는다.** 대신 매핑에 그 결정과 까닭을 적어 두었고, - ㎡ 단가를 세우는 것은 받는 쪽 몫이다. - """ - row = build_handoff(summary_table={"rows": [토공("층따기", "㎡", 2645.21)]})["work_items"][0] - assert row["work_item_code"] == "FP-09-18" - assert (row["unit"], row["quantity"]) == ("㎡", 2645.21) - assert row["in_bill"] is True - assert row["blocked_kind"] is None - - -def test_성토면다짐은_면적이_맞다() -> None: - """⚠ 좁게 — 같은 면적을 쓰는 형제 공종까지 막으면 멀쩡한 줄이 사라진다. - - 9-17-1 비탈면 다짐은 시공량이 **㎡/시간**이라 면적이 맞다(2026-09-08 B09 원문 확인). - """ - row = build_handoff(summary_table={"rows": [토공("성토면다짐", "㎡", 2645.21)]})["work_items"][ - 0 - ] - assert (row["unit"], row["quantity"]) == ("㎡", 2645.21) - assert row["in_bill"] is True - - -def test_매핑이_적은_밑수가_대조에_쓰인다() -> None: - """마스터가 비어 있어도 매핑이 적은 값으로 경고가 선다.""" - rows = [{"work_item_code": "FP-09-18", "unit": "㎡", "in_bill": True, "name": "층따기"}] - assert verify_unit_matches_basis(rows) == [] # 마스터만 보면 못 잡는다 - assert verify_unit_matches_basis(rows, extra={"FP-09-18": "㎥"}) - - -# ── 매핑이 적은 밑수는 **원문에 실제로 있는 글자**여야 한다 (2026-09-08) ──────────── -# ⚠ 근거 없는 단위가 대조의 기준이 되면 가드가 거짓말을 한다. 그래서 원문을 연다. - -PUM_TEXT = ( - ROOT - / "resources" - / "knowledge" - / "original" - / "원가계산" - / "산림_표준품셈" - / "첨부" - / "(산림청고시 제2025-82호) 산림사업 표준품셈.md" -) - -#: 매핑에 적은 밑수와 그 근거 줄(원문 줄번호는 1부터). -DECLARED_SOURCE = { - "FP-09-03-02": (4704, "㎥"), - "FP-09-04": (4738, "㎥"), - "FP-09-05": (4744, "㎥"), - # 2026-09-08 실무 양식이 정해져 부모(FP-09-16 노체)에서 **자식 포설**로 내렸다 — - # 실무 내역 셋에 「노체포설·노체다짐」으로 가른 줄이 없고 성토 본체가 한 줄이다. - "FP-09-16-01": (5322, "㎥"), - "FP-09-17-01": (5368, "㎡"), - "FP-10-11": (5919, "㎥"), - "FP-10-12": (5928, "㎥"), - # ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양 — 마스터가 못 읽던 자리(2026-09-08). - "FP-05-24": (2769, "㎡"), -} - - -#: 적어 둔 줄에서 얼마까지 벗어난 것을 봐줄 것인가. -#: ⚠ **줄 번호를 못박지 않는다** — 원문 md 가 손질되면 줄이 밀린다(2026-09-18 실제로 밀렸다). -#: 지키려는 것은 「그 단위가 **원문에 실제로 있나**」이지 「몇 번째 줄인가」가 아니다. -SOURCE_LINE_SLACK = 60 - - -def test_매핑이_적은_밑수가_원문_줄에_실제로_있다() -> None: - if not PUM_TEXT.is_file(): - return - lines = PUM_TEXT.read_text(encoding="utf-8").splitlines() - declared = MAPPING.declared_units() - for code, (line_no, unit) in DECLARED_SOURCE.items(): - assert declared.get(code) == unit, code - near = range( - max(0, line_no - 1 - SOURCE_LINE_SLACK), min(len(lines), line_no + SOURCE_LINE_SLACK) - ) - assert any(unit in lines[i] for i in near), f"{code} L{line_no} 주변에 「{unit}」이 없다" - - -def test_품_단위는_밑수로_안_받는다() -> None: - """⚠ 「일당」은 **하루에 얼마**라는 품의 단위다 — 받으면 틀린 단위로 검사를 통과시킨다.""" - assert "FP-12-06" not in MAPPING.declared_units() - - -def test_덤프_단위중량이_원문_줄에_있다() -> None: - """⚠ 「현장값이라 미확보」로 두었던 γt 가 원문에 있었다(2026-09-08 정정).""" - if not PUM_TEXT.is_file(): - return - # ⚠ 줄 번호로 집지 않는다 — 원문이 손질되면 밀린다(2026-09-18). 글자가 있는지만 본다. - text = PUM_TEXT.read_text(encoding="utf-8") - assert "1.9ton/㎥" in text and "2.4ton/㎥" in text - - -# ── 공종 단위가 아닌 글자는 **안 받는다** (2026-09-08 B09 전수에서 나온 모양들) ──── -# ⚠ `(㎥/1대, 1일)` 은 **시공량**이라 밑수의 **역수**다 — 받으면 그 공종이 조용히 -# 뒤집힌 단위를 갖는다. 안 받으면 대조가 없을 뿐이라 **덜 나쁘다.** - - -def test_시공량_모양은_밑수로_안_받는다() -> None: - from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import is_quantity_unit - - for bad in ("㎥/1대, 1일", "㎥/1인, 1일", "대/ton", "1ha당, 100본당", "일당", "조", "인"): - assert not is_quantity_unit(bad), bad - for good in ("㎥", "㎡", "m", "개소", "㏊당", "10m당"): - assert is_quantity_unit(good), good - - -def test_매핑에_적힌_밑수가_전부_공종_단위다() -> None: - """⚠ 사람이 손으로 적는 칸이라 **다음 사람이 시공량을 적을 수 있다.**""" - from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import is_quantity_unit - - for code, unit in MAPPING.declared_units().items(): - assert is_quantity_unit(unit), f"{code}: {unit}" - - -def test_부대시설_가설창고도_대조된다() -> None: - """⚠ 내역에 실리는 25 줄 중 **이 공종만** 밑수가 없어 가드가 못 보고 있었다(2026-09-09). - - 마스터는 절 머리에 「(단위: …)」가 있어야 밑수를 채우는데 11-1 은 그것이 없다. - 매핑에 원문 근거([주]① · [주]③)와 함께 「개소」를 적어 대조가 서게 했다. - """ - from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import load_mapping - - declared = load_mapping().declared_units() - assert declared.get("FP-11-01") == "개소" - - rows = [ - { - "work_item_code": "FP-11-01", - "name": "가설창고(컨테이너)", - "unit": "m", - "in_bill": True, - } - ] - warnings = verify_unit_matches_basis(rows, extra=declared) - assert len(warnings) == 1 and "개소" in warnings[0] - # 맞는 단위로 보내면 조용하다. - ok = [dict(rows[0], unit="개소")] - assert verify_unit_matches_basis(ok, extra=declared) == [] diff --git a/resources/tester/test_b08_blinding.py b/resources/tester/test_b08_blinding.py deleted file mode 100644 index 38a7f360d..000000000 --- a/resources/tester/test_b08_blinding.py +++ /dev/null @@ -1,125 +0,0 @@ -"""버림 콘크리트 — 빠뜨리고 있던 줄 (2026-09-09 사용자 확정 ⑭). - -⚠ 겨누는 것 다섯 - ① 기본은 **넣는다** — 정한 적 없는 프로젝트에서 줄이 사라지지 않아야 한다 - ② 「안 넣음」으로 두면 **빠진다** - ③ 두께는 **0.10m**(KDS 44 90 00) — 임의 값이 아니다 - ④ 근거 문구에 **잠정임이 드러난다**(폭이 잡석다짐 폭이라야 하나 그 값이 아직 없다) - ⑤ ⚠ 채움 콘크리트와 **섞이지 않는다** — 타설 줄은 버림 몫만 세운다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - BLINDING_THICKNESS_M, - boulder_masonry, - stone_masonry, - wants_blinding, -) - - -def 성분(**options: object) -> dict: - components, _ = stone_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True - ) - return {c.name: c for c in components} - - -def test_기본은_넣는다() -> None: - """⚠ 빈 칸을 「빼기」로 읽으면 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다.""" - assert wants_blinding({}) is True - assert "버림콘크리트" in 성분() - - -def test_안_넣음으로_두면_빠진다() -> None: - for value in ("안 넣음", "제외", "false", "no"): - assert wants_blinding({"blinding_concrete": value}) is False, value - assert "버림콘크리트" not in 성분(blinding_concrete="안 넣음") - - -def test_두께는_원문값이다() -> None: - """KDS 44 90 00 「구조물 시공이 원활하도록 **100 mm 두께**의 버림콘크리트를 타설」.""" - assert BLINDING_THICKNESS_M == 0.10 - got = 성분()["버림콘크리트"] - # 기초 폭(터파기 폭) × 연장 × 두께 — 폭이 근거 문구에 적혀 있으므로 그 값으로 검산한다. - assert got.unit == "㎥" - assert got.amount > 0 - - -def test_폭은_하단_길이에서_온다() -> None: - """⚠ 「폭은 잡석다짐 폭」(KCS 34 50 05)이고 그 폭이 확정 ⑪ 로 **하단 길이**가 됐다. - - 옛 잠정(터파기 폭 = 평균두께 + 0.2)을 지운 자리다 — 근거 문구에 어디서 온 폭인지 적힌다. - """ - got = 성분()["버림콘크리트"] - 하부두께 = 0.45 + 0.30 + 0.30 * (2.5 - 1.0) # 1.20 - assert abs(got.amount - 하부두께 * 10.0 * BLINDING_THICKNESS_M) < 1e-9 - assert "KDS" in got.basis and "하단 길이" in got.basis - assert "잠정" not in got.basis - - -def test_기초잡석은_계상하고_안_선다는_사유를_안_남긴다() -> None: - """ⓘ 2026-09-13 바뀜 — 옛 시험은 「두께가 원문에 없어 안 선다」를 굳혔음. - - 확정 3차 ② 로 두께 0.2 가 서고 `build_table` 이 기초잡석 줄을 세우는데, 전개는 여전히 - 「안 섬」 사유를 붙여 **구조물도에 줄과 사유가 함께 떠 서로 어긋났음**(화면 실측). - 브레인 판정(PLAN 10장 「기초잡석 계상함」)대로 사유를 걷고 줄이 서는지 봄. - """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - - _, notes = stone_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, wet=True - ) - assert not any("기초잡석(12-25)" in n and "안 섬" in n for n in notes) - table = build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, - } - ] - ) - names = [c["name"] for c in table["structures"][0]["components"]] - assert "기초잡석" in names - - -def test_큰돌쌓기에도_선다() -> None: - components, _ = boulder_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80"} - ) - assert any(c.name == "버림콘크리트" for c in components) - - -def test_타설_줄은_버림_몫만_센다() -> None: - """⚠ 채움 콘크리트가 섞이면 이중계상 — 그 공종 품에 이미 들어 있을 수 있다.""" - item = 성분() - structure = { - "structure_id": "s1", - "type_id": "masonry_wet", - "name": "돌쌓기(찰)", - "length_m": 10.0, - "height_m": 2.5, - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, - "notes": [], - "components": [ - {"name": c.name, "unit": c.unit, "amount": c.amount, "basis": c.basis} - for c in item.values() - ], - } - rows = build_handoff(unit_quantity_table={"structures": [structure]})["work_items"] - placing = [row for row in rows if row["name"] == "콘크리트 타설"] - assert len(placing) == 1 - assert abs(placing[0]["quantity"] - item["버림콘크리트"].amount) < 1e-6 - assert abs(placing[0]["quantity"] - item["채움콘크리트"].amount) > 1e-6 diff --git a/resources/tester/test_b08_blocked_invariant.py b/resources/tester/test_b08_blocked_invariant.py deleted file mode 100644 index c7894def2..000000000 --- a/resources/tester/test_b08_blocked_invariant.py +++ /dev/null @@ -1,152 +0,0 @@ -"""**코드가 없으면 반드시 막힘 표시가 붙는다** (2026-09-09 랩탑 메인 제보로 세운 불변식). - -⚠⚠ 오늘 세운 규칙 「막혔다고 말하기 전에 `blocked_kind` 를 볼 것」의 **뒤집힌 얼굴**이다. - 보는 쪽을 그렇게 고쳤으면 **다는 쪽도 빠짐없이 달아야** 한다. 안 그러면 받는 쪽이 - 「코드도 없고 막힘 표시도 없는 **멀쩡한 줄**」로 읽어 **금액이 조용히 빠진다.** - -실제로 그랬다 — `측구터파기 · 굴삭기+브레카` 가 `work_item_code: null` · `blocked_kind: null` -이었고 화면에 아무 말도 안 떴다. 인계본의 `bill_flag_warnings`·`unmatched_work_items` 에는 -**이미 실려 있었다** — 목록은 있는데 **줄에 표시가 없었던 것**이다. 줄 단위로 보는 쪽은 -목록을 안 본다. - -⚠ 예외 둘 — 이 둘만 코드 없이 서도 된다. - ① **묶음 줄**(`composite_parts`) — 무엇으로 묶이는지 조각에 적혀 있다 - ② **내역에 안 서는 줄**(`in_bill: False`) — 합계·무대처럼 **막힌 게 아니라 안 세는 것**. - 다만 그쪽도 **까닭은 있어야** 한다(`in_bill_reason` 또는 `blocked_kind`). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - - -def 집계(item: str | None, group: str = "흙깎기") -> dict: - return {"rows": [{"group": group, "item": item, "unit": "㎥", "amount": 100.0}]} - - -def 인계(**kwargs) -> list[dict]: - return build_handoff(**kwargs)["work_items"] - - -def test_시공법을_못_고른_줄에_막힘_표시가_붙는다() -> None: - """⚠ 실측 자리 — 「굴삭기+브레카」는 지반 갈래가 아니라 **시공법**이라 매핑이 안 맞았다.""" - rows = 인계(summary_table=집계("리핑암")) - row = next(r for r in rows if r["name"] == "흙깎기") - assert row["work_item_code"] is None - assert row["blocked_kind"] == "input_missing" # 시공법을 고르면 풀린다 - assert "시공법" in row["blocked_reason"] - - -def test_시공법을_고르면_코드가_붙고_표시가_사라진다() -> None: - # ⚠ 시공법 값은 **영문 키**다(`ripping`·`blasting`) — 한글을 넣으면 안 풀린다. - rows = 인계(summary_table=집계("리핑암"), ground_methods={"리핑암": "ripping"}) - row = next(r for r in rows if r["name"] == "흙깎기") - assert row["work_item_code"] is not None - assert row["blocked_kind"] is None - - -def test_품셈을_못_이은_줄은_다른_갈래로_적힌다() -> None: - """⚠ 「사용자가 고르면 풀림」과 「우리가 매핑을 못 이음」은 **다음에 할 일이 다르다**.""" - rows = 인계(summary_table=집계(None, group="지장목제거")) - row = next(r for r in rows if r["name"] == "지장목제거") - assert row["work_item_code"] is None - assert row["blocked_kind"] == "unit_data_missing" - assert "못 이었습니다" in row["blocked_reason"] - - -def test_불변식_모든_줄에_대해_선다() -> None: - """⚠ 한 자리를 고치는 것으로 끝내지 않는다 — **줄 빌더 여덟 곳 전부**를 건다.""" - unit = build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, - }, - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 20.0, - "end_m": 30.0, - "options": {"height_m": 2.0, "length_m": 10.0, "form": "반중력식"}, - }, - ] - ) - haul = { - "rows": [ - {"equipment": "dump_truck", "ground": "토사", "volume_m3": 50.0}, - {"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0}, - {"equipment": "모르는장비", "ground": "토사", "volume_m3": 5.0}, - ] - } - rows = 인계( - summary_table=집계("리핑암"), - haul_table=haul, - unit_quantity_table=unit, - preparation_table={"rows": []}, - ) - 나쁜 = [ - r["name"] - for r in rows - if not r.get("work_item_code") - and not r.get("composite_parts") - and r.get("in_bill") - and not r.get("blocked_kind") - ] - assert 나쁜 == [], f"코드도 막힘 표시도 없이 내역에 서는 줄: {나쁜}" - - # 내역에 안 서는 줄도 **까닭 없이** 있으면 안 된다. - 까닭없음 = [ - r["name"] - for r in rows - if not r.get("in_bill") - and not r.get("blocked_kind") - and not str(r.get("in_bill_reason") or "").strip() - ] - assert 까닭없음 == [], f"안 서는 까닭이 안 적힌 줄: {까닭없음}" - - -def test_무대는_막힌_것이_아니다() -> None: - """⚠ 무대(소운반 20m 이내)는 **품에 포함**이라 안 세우는 것이지 막힌 것이 아니다. - - 여기에 막힘 표시를 달면 「만들어야 할 것」 목록에 올라 없는 일이 생긴다. - """ - rows = 인계(haul_table={"rows": [{"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0}]}) - row = next(r for r in rows if r["haul_equipment"] == "free_haul") - assert row["in_bill"] is False - assert row["blocked_kind"] is None - assert "품에 포함" in row["in_bill_reason"] - - -def test_물량_0_인_구조물은_내역에_안_선다() -> None: - """⚠ 물넘이포장이 면적을 안 받아 **`0.0 ㎡` 로 내역에 서고 있었다**(2026-09-09 감사). - - 코드가 붙어 있어 **0 원 줄**이 만들어지고 화면에는 「값이 있는 줄」로 보인다 — - 0 은 「없음」과 구별이 안 된다(오늘 표토제거에서 겪은 자리와 같은 결). - 줄은 그대로 넘기되 **내역에서 빼고 까닭을 적는다.** - """ - unit = build_table( - [ - { - "structure_id": "f1", - "type_id": "ford_pavement", - "start_m": 0.0, - "end_m": 10.0, - "options": {"thickness_cm": 20, "length_m": 10.0}, - } - ] - ) - row = next(r for r in 인계(unit_quantity_table=unit) if r["name"] == "물넘이포장") - assert row["quantity"] == 0.0 - assert row["in_bill"] is False - assert "물량이 0" in row["in_bill_reason"] - assert row["blocked_kind"] == "input_missing" diff --git a/resources/tester/test_b08_borrow_row.py b/resources/tester/test_b08_borrow_row.py deleted file mode 100644 index be863e485..000000000 --- a/resources/tester/test_b08_borrow_row.py +++ /dev/null @@ -1,64 +0,0 @@ -"""토취(반입토) 줄 — 2026-09-14 브레인 ① 승인(수량만 · 금액은 사유). - -B06 유토곡선이 성토 부족분을 토취로 냄(936be972 `haul_plan.borrow_m3` 7,259.84㎥ · 두 구간)인데 B08 이 어느 탭·인계에도 -안 옮겨 수량이 통째로 빠졌음. ⇒ 운반표에 싣고 · 토공집계에 줄 · 인계에 막힌 줄(「토취장 거리·재료 미정」). -⚠ 수량은 유토곡선이 쌓은 **다짐상태** 그대로 — 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 - BORROW_NAME, - SummaryInput, - build_rows, -) -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_HaulSummary import borrow_of # noqa: E402 - -PLAN = { - "borrow_m3": 7259.84, - "residuals": [ - {"kind": "borrow", "volume_m3": 3494.69, "from_m": 246.54, "to_m": 529.33}, - {"kind": "borrow", "volume_m3": 3765.15, "from_m": 720, "to_m": 1078.01}, - {"kind": "spoil", "volume_m3": 4.57, "from_m": 205, "to_m": 205}, - ], -} - - -def test_운반계획의_토취를_구간과_함께_싣는다() -> None: - borrow = borrow_of(PLAN) - assert borrow["volume_m3"] == pytest.approx(7259.84) and borrow["volume_basis"] == "compacted" - assert [s["volume_m3"] for s in borrow["sites"]] == [3494.69, 3765.15] - assert borrow_of({"borrow_m3": 0}) is None and borrow_of(None) is None - - -def test_토공집계에_수량만_선다() -> None: - rows = [r for r in build_rows(SummaryInput(borrow_m3=7259.84)) if r.group == BORROW_NAME] - assert len(rows) == 1 and rows[0].amount == pytest.approx(7259.84) - assert rows[0].in_bill is False and "토취장 거리·재료 미정" in rows[0].note - - -def test_인계는_막힌_줄_하나_금액_없음() -> None: - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table - - borrow = borrow_of(PLAN) - summary = build_table(SummaryInput(borrow_m3=borrow["volume_m3"], borrow_sites=borrow["sites"])) - handoff = build_handoff(summary_table=summary, haul_table={"rows": [], "borrow": borrow}) - rows = [r for r in handoff["work_items"] if r["name"] == BORROW_NAME] - assert len(rows) == 1, rows # 한 줄만 - row = rows[0] - assert row["quantity"] == pytest.approx(7259.84) and row["in_bill"] is False - assert ( - row["blocked_kind"] == "input_missing" and "토취장 거리·재료 미정" in row["blocked_reason"] - ) - assert "246.54~529.33m 3,494.69㎥" in row["blocked_reason"] - assert not any(BORROW_NAME in str(item) for item in handoff["unmatched_work_items"]) diff --git a/resources/tester/test_b08_box_culvert_unit.py b/resources/tester/test_b08_box_culvert_unit.py deleted file mode 100644 index 90901f267..000000000 --- a/resources/tester/test_b08_box_culvert_unit.py +++ /dev/null @@ -1,136 +0,0 @@ -"""BOX암거 전개식 — m당 × 연장 (2026-09-15 브레인 12장 D 판정 ①~⑤). - -근거 둘(구조 같음) - 산림과임업기술(임도) 5장 「구조물도에 의한 수량산출(예)」 — 구체 = (외곽 − 유수구 + 헌치) · - 기초 폭×두께 · 거푸집 외벽·유수구·받침판·귀면 · 동바리 유수구 단면 - 실무 울진 기번3 구조도 「암거수량집계표(2×2)」 m당 — 레미콘 2.84 · 버림 0.28 · 유로폼 11.132 · - 합판 0.2 · 철근 0.336t(H13 0.179 · H16 0.157) · 동바리 3.92 · 비계 벽체 5.2 + 시종점 13.52 -⚠ 철근은 크기별 식이 없어 **2×2 관측값만** · 날개벽·토공은 사유. -""" - -from __future__ import annotations - -import pytest - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table, expand - -ULJIN_2X2 = { - "body_width_m": 2.0, - "body_height_m": 2.0, - "wall_thickness_m": 0.3, - "slab_thickness_m": 0.3, - "haunch_m": 0.2, - "blinding_thickness_m": 0.1, - "length_m": 10.0, -} - - -def _amounts(options: dict) -> tuple[dict, list[str], object]: - result = expand({"type_id": "box_culvert", "options": options}, {"box_culvert": "BOX암거"}) - return ( - {(c.name, c.spec): c.amount for c in result.components}, - result.notes, - result, - ) - - -def test_울진_2x2_m당_값을_연장에_곱한다() -> None: - got, notes, result = _amounts(ULJIN_2X2) - assert got[("콘크리트", "")] == pytest.approx(2.84 * 10) - assert got[("버림콘크리트", "")] == pytest.approx(0.28 * 10) - assert got[("유로폼", "")] == pytest.approx( - 11.1314 * 10, abs=0.01 - ) # 원문 11.132(귀면 0.566 반올림) - assert got[("합판거푸집", "")] == pytest.approx(0.2 * 10) - assert got[("이형철근", "D13")] == pytest.approx(179 * 10) - assert got[("이형철근", "D16")] == pytest.approx(157 * 10) - assert got[("동바리", "")] == pytest.approx(3.92 * 10) - # 비계 — 벽체 m당 5.2 × 연장 + 시종점 두 면 13.52 는 한 번(원문은 m당에 넣었음 · 사유). - assert got[("비계", "")] == pytest.approx(5.2 * 10 + 13.52) - # 비계는 공㎥(12-19 밑수) — 울진 단위 칸 「공/m3」 · 시종점 식 ×1.00 은 연장일 수 없어 폭 1.0m. - scaffold = next(c for c in result.components if c.name == "비계") - assert scaffold.unit == "㎥" and "비계 폭 1.0m 관측(울진)" in scaffold.basis - assert (result.billing_unit, result.billing_quantity) == ("m", 10.0) - text = " ".join(notes) - assert "관측" in text and "날개벽" in text - - -def test_예제_두께_기본값으로_구체가_줄어든다() -> None: - got, _notes, _r = _amounts({**ULJIN_2X2, "slab_thickness_m": 0.25}) - # (2.6 × 2.5 − 2 × 2 + 0.2² ÷ 2 × 4) = 2.58㎥/m - assert got[("콘크리트", "")] == pytest.approx(2.58 * 10) - - -def test_2x2_가_아니면_철근은_사유() -> None: - got, notes, _r = _amounts({**ULJIN_2X2, "body_width_m": 3.0}) - assert not [key for key in got if key[0] == "이형철근"] - assert any("크기별 식 없음" in note and "2×2" in note for note in notes) - assert got[("콘크리트", "")] > 0 # 나머지는 섬 - - -def test_연장이_없으면_안_선다() -> None: - got, notes, _r = _amounts({k: v for k, v in ULJIN_2X2.items() if k != "length_m"}) - assert got == {} - assert any("연장" in note for note in notes) - - -def test_치수를_안_적으면_기본값으로_서되_미확정() -> None: - rows = facility_structures( - [{"chainage_m": 50.0, "facility": "box_culvert", "options": {"length_m": 8.0}}] - ) - box = next(row for row in rows if row["type_id"] == "box_culvert") - assert box["unconfirmed"] and "측벽 두께" in box["unconfirmed"] - assert box["options"]["wall_thickness_m"] == 0.3 and box["options"]["slab_thickness_m"] == 0.25 - stored = facility_structures( - [{"chainage_m": 50.0, "facility": "box_culvert", "options": ULJIN_2X2}] - ) - assert not next(r for r in stored if r["type_id"] == "box_culvert").get("unconfirmed") - - -def test_구체_터파기는_평균_터파기고로_토공_축에만_선다() -> None: - """브레인 판정(2026-09-15 ③) — 칸 하나(평균 터파기고) · 구조물 터파기는 토공집계로만(이중계상). - - 밑폭 = 버림 폭 2.8 + 여유 0.5×2 · 옆면 1:0.5(울진 「암거구체 토공(2×2)」) · 깊이 0.8 · - 든 것 = 버림 폭 × (기초잡석 0.2 + 버림 0.1) + 구체 폭 × (0.8 − 0.3) · 되메우기 = 터파기 − 든 것. - """ - table = build_table( - [{"type_id": "box_culvert", "options": {**ULJIN_2X2, "trench_depth_m": 0.8}}] - ) - box = table["structures"][0] - earth = {c["name"]: c for c in box["components"] if c["destination"] == "earthwork"} - assert earth["터파기"]["amount"] == pytest.approx((3.8 + 4.6) / 2 * 0.8 * 10) - filled = 2.8 * (0.2 + 0.1) + 2.6 * (0.8 - 0.3) - assert earth["잔토처리"]["amount"] == pytest.approx(filled * 10) - assert earth["되메우기"]["amount"] == pytest.approx(((3.8 + 4.6) / 2 * 0.8 - filled) * 10) - assert box["trench_depth_m"] == 0.8 - # 잡석 사유 — 근거 둘에는 없고 사용자 확정으로 섬(브레인 판정 ①). - assert any("잡석" in note and "사용자 확정 3차" in note for note in box["notes"]) - # 묶음(내역 줄)에는 터파기가 안 들어감. - row = next( - r for r in build_handoff(unit_quantity_table=table)["work_items"] if "BOX" in r["name"] - ) - assert not [p for p in row["composite_parts"] or [] if "터파기" in str(p.get("name"))] - # 칸이 비면 터파기가 안 서고 사유. - empty = build_table([{"type_id": "box_culvert", "options": ULJIN_2X2}])["structures"][0] - assert not [c for c in empty["components"] if c["destination"] == "earthwork"] - assert any("평균 터파기고" in note for note in empty["notes"]) - - -def test_인계_묶음이_조각을_세운다() -> None: - table = build_table( - facility_structures( - [{"chainage_m": 50.0, "facility": "box_culvert", "options": ULJIN_2X2}] - ), - {"box_culvert": "BOX암거"}, - ) - row = next( - r for r in build_handoff(unit_quantity_table=table)["work_items"] if "BOX" in r["name"] - ) - parts = {part["code"].split("#")[0]: part for part in row["composite_parts"] or []} - assert {"FP-12-01-01", "FP-12-04", "FP-12-38", "FP-12-03", "FP-12-20", "FP-12-19"} <= set(parts) - assert parts["FP-12-03"]["quantity"] == pytest.approx((179 + 157) * 10 / 1000) - assert parts["FP-12-20"]["quantity"] == pytest.approx(3.92 * 10) - assert parts["FP-12-19"]["unit"] == "㎥" - assert parts["FP-12-19"]["quantity"] == pytest.approx(5.2 * 10 + 13.52) diff --git a/resources/tester/test_b08_build_table_templates.py b/resources/tester/test_b08_build_table_templates.py deleted file mode 100644 index de4976bb9..000000000 --- a/resources/tester/test_b08_build_table_templates.py +++ /dev/null @@ -1,107 +0,0 @@ -"""`build_table` 이 양식 있는 종류를 양식으로 갈음 — 값은 전개와 같고 흐름은 그대로. - -2026-09-13, PLAN 3장 ④-2. 겨누는 것 넷 - ① 찰쌓기 성분이 양식 풀이 값으로 섬 · 구조물에 `library_item` 이 붙음(양식 있음/없음) - ② 값·이름·규격·갈 곳이 전개와 같음 — 자재총괄·토공집계 갈래가 안 바뀜(브레인 챙길 것 ③) - ③ 양식 없는 종류(메쌓기)는 전개 그대로 · `library_item` 빔 - ④ 배합 성분이 자재총괄로 새지 않음(명세 6장·15장) -""" - -from __future__ import annotations - -import shutil -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 - build_table as build_material_table, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS, build_table # noqa: E402 - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - - -def _structures() -> list[dict]: - return [ - { - "structure_id": "wet", - "type_id": "masonry_wet", - "start_m": 80.0, - "end_m": 90.0, - "options": { - "height_m": 2.5, - "length_m": 10, - "foundation": "기초유", - "stone_kind": "야면석·호박돌", - # 2026-09-14 — 안 고르면 뒷길이 표준 하한(찰 ≤3m 30㎝)이라 야면석 관측 무게(35·45·55)가 - # 없어 돌 줄이 안 섬. 돌 줄을 보는 시험이라 45 로 고름 · 빈 칸 길은 아래 시험. - "back_len_cm": 45, - }, - }, - { - "structure_id": "dry", - "type_id": "masonry_dry", - "start_m": 140.0, - "end_m": 150.0, - "options": {"height_m": 2, "length_m": 10, "back_len_cm": 35}, - }, - ] - - -def test_양식_갈음은_값을_안_움직인다() -> None: - names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"} - before = build_table(_structures(), names, None, None, 0.2, use_templates=False) - after = build_table(_structures(), names, None, None, 0.2) - - wet_before, dry_before = before["structures"] - wet_after, dry_after = after["structures"] - assert wet_after["library_item"] == "돌쌓기(찰)" - assert dry_after["library_item"] == "" # 양식 없음 — 전개 그대로 - assert dry_after["components"] == dry_before["components"] - - assert len(wet_after["components"]) == len(wet_before["components"]) - for got, want in zip(wet_after["components"], wet_before["components"]): - assert (got["name"], got["unit"], got["destination"], got["spec"]) == ( - want["name"], - want["unit"], - want["destination"], - want["spec"], - ) - assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"] - # 돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음) · 관측 출처는 그대로. - stone = next(c for c in wet_after["components"] if c["name"] == "돌") - assert stone["spec"] == "야면석·호박돌" - assert stone["source"] == "uljin_library" - - -def test_뒷길이_안_고르면_양식도_전개와_같은_표준_하한() -> None: - """품셈 13-4-4 [주]⑩ 하한(찰 ≤3m 30㎝)을 전개와 양식이 한 벌로 씀(브레인 판정 ㉮).""" - structures = _structures()[:1] - structures[0]["options"].pop("back_len_cm") - structures[0]["options"].pop("stone_kind") - names = {"masonry_wet": "돌쌓기(찰)"} - before = build_table(structures, names, None, None, 0.2, use_templates=False)["structures"][0] - after = build_table(structures, names, None, None, 0.2)["structures"][0] - assert after["library_item"] == "돌쌓기(찰)" - assert [c["name"] for c in after["components"]] == [c["name"] for c in before["components"]] - for got, want in zip(after["components"], before["components"]): - assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"] - assert any("하한 30㎝" in note for note in before["notes"]) - - -def test_자재총괄_갈래와_배합_성분() -> None: - names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"} - before = build_material_table( - build_table(_structures(), names, None, None, 0.2, use_templates=False) - ) - after = build_material_table(build_table(_structures(), names, None, None, 0.2)) - rows = lambda table: {(r["name"], r["unit"]): r["total_amount"] for r in table["rows"]} # noqa: E731 - assert rows(after).keys() == rows(before).keys() - for key, value in rows(before).items(): - assert rows(after)[key] == pytest.approx(value, rel=1e-9), key - assert not any(name in MIX_COMPONENTS for name, _unit in rows(after)) diff --git a/resources/tester/test_b08_coeff_and_strength.py b/resources/tester/test_b08_coeff_and_strength.py deleted file mode 100644 index b7f46c415..000000000 --- a/resources/tester/test_b08_coeff_and_strength.py +++ /dev/null @@ -1,135 +0,0 @@ -"""계수 갈래·채움콘 강도·돌 중량 근거 (2026-09-08 사용자 확정 2차 ⑤·⑨·⑩). - -⚠ 겨누는 것 여섯 - ① **빈 칸은 「안 정함」**이다 — 기본값은 등록부가 아니라 계산 쪽이 갖는다 - ② ⑨ 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15) - ③ ⑨ 「실무 관행」으로 두면 건설품셈 참고자료 열(0.15 · 0.20)로 덮이고 사유가 뜬다 - ④ ⑩ 강도 기본은 **210**, 180 을 고르면 그 값이 이긴다 - ⑤ ⚠ 아는 값이 아니면 **조용히 넘어가지 않는다** - ⑥ ⑤ 돌 중량은 값을 그대로 두되 **어디서 온 값인지**가 줄에 적힌다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - FILL_CONCRETE_MPA_DEFAULT, - fill_concrete_mpa, - stone_masonry, - wants_practice_coefficients, -) - - -def 성분(**options: object) -> tuple[dict, list[str]]: - components, notes = stone_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True - ) - return {c.name: c for c in components}, notes - - -def test_빈_칸은_안_정함이다() -> None: - assert wants_practice_coefficients({}) is False - assert fill_concrete_mpa({})[0] == FILL_CONCRETE_MPA_DEFAULT == "210" - - -def test_기본은_품셈_열이다() -> None: - """야면석을 고르면 품셈 계수(고임돌 0.11 · 채움 0.15)가 선다 — 확정 ⑨.""" - got, _ = 성분(stone_kind="야면석·호박돌") - 돌쌓기 = got["돌쌓기"].amount - assert abs(got["고임돌"].amount - 돌쌓기 * 0.11) < 0.01 - assert abs(got["채움콘크리트"].amount - 돌쌓기 * 0.15) < 0.01 - - -def test_실무_관행으로_두면_덮이고_사유가_뜬다() -> None: - """⚠ 값이 조용히 바뀌면 안 된다 — 덮어썼다는 사실이 사유로 나온다.""" - got, notes = 성분(stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행") - 돌쌓기 = got["돌쌓기"].amount - assert abs(got["고임돌"].amount - 돌쌓기 * 0.15) < 0.01 - assert abs(got["채움콘크리트"].amount - 돌쌓기 * 0.20) < 0.01 - assert any("실무 관행" in n for n in notes) - - -def test_강도는_기본_210이고_고르면_그_값() -> None: - 기본, _ = 성분() - assert 기본["채움콘크리트"].spec == "210" - assert "확정 ⑩" in 기본["채움콘크리트"].basis - 고름, _ = 성분(fill_concrete_mpa="180") - assert 고름["채움콘크리트"].spec == "180" - assert "고른 값" in 고름["채움콘크리트"].basis - - -def test_모르는_강도는_조용히_넘어가지_않는다() -> None: - 강도, basis = fill_concrete_mpa({"fill_concrete_mpa": "240"}) - assert 강도 == "210" - assert "240" in basis - - -def test_돌_중량은_값을_두고_근거를_남긴다() -> None: - """⚠ 품셈·교본에 돌중량표가 없다 — 값은 그대로 두고 출처를 줄에 단다(확정 ⑤). - - ⚠ 2026-09-09 확정 5차 큰 것 7 로 **관측표를 보는 자리가 야면석 계열로 좁아졌다** — - 종류를 안 고르면 계산식(뒷길이 × 0.77 × 2.65)으로 선다. 출처를 다는 못은 그대로이되 - **어느 조건에서 다는지**가 달라졌으므로 조건을 명시한다. - """ - got, _ = 성분(stone_kind="야면석·호박돌") - 야면석 = got["돌"] # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ) - assert 야면석.spec == "야면석·호박돌" - assert abs(야면석.amount - got["돌쌓기"].amount * 0.88) < 1e-9 - assert "울진" in 야면석.basis and "품셈·교본에는 돌중량표가 없음" in 야면석.basis - assert 야면석.source == "uljin_library" - - -def test_안_고르면_계산식으로_서고_그_사실이_적힌다() -> None: - """⚠ 값이 서는 것으로 끝나면 **관측값인지 계산값인지**를 화면에서 못 가른다.""" - got, notes = 성분() - 돌 = got["돌"] - assert 돌.source == "" # 관측 출처가 아니다 - assert "0.77" in 돌.basis and "2.65" in 돌.basis - assert any("계산식" in n for n in notes) - - -def test_두께_칸이_비면_식으로_돈다() -> None: - """확정 2차 ② 후반 「사용자가 값을 바꿀 수 있게」 — 빈 칸은 「안 정함」이다.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness - - top, bottom, basis = wall_thickness({}, back_cm=45, height_m=2.5) - assert abs(top - 0.75) < 1e-9 # 뒷길이 0.45 + 0.30 - assert abs(bottom - 1.20) < 1e-9 # 상부 + 0.30 × (2.5 − 1.0) - assert "뒷길이" in basis and "사용자 입력" not in basis - - -def test_넣은_두께가_식을_이긴다() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness - - top, bottom, basis = wall_thickness( - {"thickness_top_m": 0.6, "thickness_bottom_m": "1.0"}, back_cm=45, height_m=2.5 - ) - assert (top, bottom) == (0.6, 1.0) - assert basis.count("사용자 입력") == 2 - - -def test_상부만_넣으면_하부는_그_위에서_식을_잇는다() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness - - top, bottom, _ = wall_thickness({"thickness_top_m": 0.6}, back_cm=45, height_m=2.5) - assert (top, abs(bottom - 1.05) < 1e-9) == (0.6, True) # 0.6 + 0.30 × 1.5 - - -def test_두께가_입적까지_닿는다() -> None: - """칸만 만들고 계산이 안 읽으면 「값이 안 닿는」 옛 사고와 같아진다.""" - 기본, _ = 성분() - 두꺼움, _ = 성분(thickness_top_m=1.2, thickness_bottom_m=1.6) - assert 두꺼움["입적"].amount > 기본["입적"].amount - - -def test_이상한_값은_식으로_돌린다() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness - - for bad in ("", None, "두꺼움", -1, 0): - top, _, basis = wall_thickness({"thickness_top_m": bad}, back_cm=45, height_m=2.5) - assert abs(top - 0.75) < 1e-9 and "사용자 입력" not in basis, bad diff --git a/resources/tester/test_b08_collected_stone.py b/resources/tester/test_b08_collected_stone.py deleted file mode 100644 index 4d7e008bc..000000000 --- a/resources/tester/test_b08_collected_stone.py +++ /dev/null @@ -1,104 +0,0 @@ -"""채집석 공제 — 캐서 쓴 돌만큼 사토가 준다 (2026-09-09 사용자 확정 ②). - -⚠⚠ **공제는 사토에서 한 번만 한다.** B08 은 소요량(㎥ **양수**)을 내기만 하고 빼지 않는다. - 빼는 자리는 유토곡선의 사토뿐이다 — 실어 내는 몫에서 먼저 빼고 모자라면 자연방토에서. - -⚠ 겨누는 것 다섯 - ① 기본은 「캔다」 — 정한 적 없으면 공제가 선다(법이 「가급적 현장 채취」) - ② 「구입」으로 둔 구조물은 **공제 없음** — 구조물마다 갈린다 - ③ ⚠ **부호를 안 넘긴다** — 양수로 준다(음수로 주면 받는 쪽에서 두 번 뒤집힌다) - ④ 자재총괄에 **안 섞인다** — 자재는 `material` 만 모은다 - ⑤ 인계 맨 위에 **한 값으로** 실린다(구조물마다 갈리므로 합산해서 하나로) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 - build_table as build_material_table, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - COLLECTED_STONE_KEY, - build_table, - is_collected_stone, - stone_masonry, -) - - -def 구조물(**options: object) -> dict: - return { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, - } - - -def 성분(**options: object) -> dict: - components, _ = stone_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True - ) - return {c.name: c for c in components} - - -def test_기본은_캔다() -> None: - """법이 그쪽을 권한다 — 별표2 「야면석 등은 **가급적 현장에서 채취·사용**」.""" - assert is_collected_stone({}) is True - assert "채집석" in 성분() - - -def test_구입으로_두면_공제가_없다() -> None: - for value in ("구입", "purchase", "buy"): - assert is_collected_stone({"stone_supply": value}) is False, value - assert "채집석" not in 성분(stone_supply="구입") - - -def test_양수로_낸다() -> None: - """⚠ 실무 시트가 `−274.66` 이라 부호를 넘기면 **두 번 뒤집힌다.**""" - got = 성분()["채집석"] - assert got.unit == "㎥" - assert got.amount > 0 - - -def test_여기서_빼지_않는다는_것이_근거에_적힌다() -> None: - basis = 성분()["채집석"].basis - assert "사토에서 한 번만" in basis - - -def test_자재총괄에_안_섞인다() -> None: - """⚠ 자재는 `material` 만 모은다 — 채집석이 자재로 서면 같은 돌을 두 번 센다.""" - unit = build_table([구조물()]) - material = build_material_table(unit) - assert all(row["name"] != "채집석" for row in material["rows"]) - - -def test_구조물마다_갈리고_합산해서_하나로_간다() -> None: - unit = build_table( - [ - 구조물(), - { - "structure_id": "s2", - "type_id": "masonry_dry", - "start_m": 20.0, - "end_m": 30.0, - "options": { - "height_m": 2.0, - "length_m": 10.0, - "back_len_cm": 35, - "stone_supply": "구입", - }, - }, - ] - ) - only_collected = 성분()["채집석"].amount - assert unit[COLLECTED_STONE_KEY] == round(only_collected, 3) - assert build_handoff(unit_quantity_table=unit)["collected_stone_deduction_m3"] == round( - only_collected, 3 - ) diff --git a/resources/tester/test_b08_conversion_factor_choice.py b/resources/tester/test_b08_conversion_factor_choice.py deleted file mode 100644 index 742ba415b..000000000 --- a/resources/tester/test_b08_conversion_factor_choice.py +++ /dev/null @@ -1,110 +0,0 @@ -"""토량환산계수를 「고를 수 있는 값」으로 연 자리 검사 (오솔길 대조 06절 3번). - -못 박는 것 셋 - ① **기본값이 안 바뀐다** — 안 고른 프로젝트는 정본 그대로 선다. - ② 고른 값은 **토적표·운반표가 같이** 읽는다(계수 정의처는 여전히 한 곳). - ③ 품셈 범위 **밖도 막지 않는다** — 사유와 함께 선다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402 - StationArea, - build_rows, -) -from B08_Quantity.B08_Quantity_Engine_HaulSummary import natural_m3 # noqa: E402 -from common_util.common_util_project_settings import ( # noqa: E402 - default_settings, - earthwork_conversion_choices, - earthwork_conversion_factors, -) -from config.config_system_design import ( # noqa: E402 - EARTHWORK_CONVERSION_C_RANGES, - EARTHWORK_CONVERSION_FACTORS, -) - - -def test_안_고르면_기본값이_그대로() -> None: - """빈 설정·기본 설정 둘 다 정본과 한 글자도 달라지지 않아야 한다.""" - assert earthwork_conversion_factors({}) == EARTHWORK_CONVERSION_FACTORS - assert earthwork_conversion_factors(default_settings()["quantity"]) == ( - EARTHWORK_CONVERSION_FACTORS - ) - - -def test_고른_갈래만_갈아_끼움() -> None: - settings = {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} - resolved = earthwork_conversion_factors(settings) - assert resolved["ripping_rock"]["compacted"] == pytest.approx(1.0) - # 나머지 갈래는 정본 그대로다. - assert resolved["soil"] == EARTHWORK_CONVERSION_FACTORS["soil"] - assert resolved["blasting_rock"] == EARTHWORK_CONVERSION_FACTORS["blasting_rock"] - # ⚠ 정본 dict 를 건드리지 않았는가 — 얕은 복사였다면 여기서 걸린다. - assert EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"] == pytest.approx(1.15) - - -def test_모르는_갈래와_말이_안_되는_값은_버림() -> None: - settings = { - "conversion_factors_override": { - "unknown_rock": {"compacted": 2.0}, - "soil": {"compacted": 0}, - "blasting_rock": {"compacted": "많이"}, - } - } - assert earthwork_conversion_factors(settings) == EARTHWORK_CONVERSION_FACTORS - - -def test_토적표가_고른_계수로_섬() -> None: - """오솔길처럼 암 계수 1.0 을 고르면 보정량이 그 값으로 선다.""" - stations = [ - StationArea(chainage_m=0.0), - StationArea(chainage_m=10.0, cut_rock_area_m2=2.0, cut_rock_kind="ripping_rock"), - ] - factors = earthwork_conversion_factors( - {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} - ) - row = build_rows(stations, factors)[1] - assert row.cut_rock_volume_m3 == pytest.approx(10.0) - assert row.cut_rock_adjusted_m3 == pytest.approx(10.0) # 기본값 1.15 였다면 11.5 - # 안 주면 기본값 — 같은 측점이 11.5 로 선다. - assert build_rows(stations)[1].cut_rock_adjusted_m3 == pytest.approx(11.5) - - -def test_운반표도_같은_계수를_씀() -> None: - """다짐 → 자연 되돌리기(÷C)도 고른 값으로 돌아야 표끼리 안 갈린다.""" - factors = earthwork_conversion_factors( - {"conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} - ) - assert natural_m3(11.5, "리핑암", factors) == pytest.approx(11.5) - assert natural_m3(11.5, "리핑암") == pytest.approx(11.5 / 1.15) - - -def test_범위_밖도_막지_않고_사유와_함께_섬() -> None: - low, _high = EARTHWORK_CONVERSION_C_RANGES["blasting_rock"] - settings = { - "conversion_factors_override": { - "blasting_rock": {"compacted": low - 0.5, "reason": "토질시험 값"} - } - } - resolved = earthwork_conversion_factors(settings) - assert resolved["blasting_rock"]["compacted"] == pytest.approx(low - 0.5) - choice = earthwork_conversion_choices(settings)["blasting_rock"] - assert choice["chosen"] is True - assert choice["in_range"] is False # 밖이라고 말은 하되 값은 그대로 선다. - assert choice["reason"] == "토질시험 값" - - -def test_선택_상태는_기본값과_범위를_함께_냄() -> None: - choice = earthwork_conversion_choices({})["ripping_rock"] - assert choice["chosen"] is False - assert choice["in_range"] is True - assert choice["default"] == pytest.approx(1.15) - assert choice["range"] == [1.00, 1.30] diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py deleted file mode 100644 index 67c4cd1c8..000000000 --- a/resources/tester/test_b08_destination_no_default.py +++ /dev/null @@ -1,88 +0,0 @@ -"""갈 곳(`destination`) 기본값 금지 — 명세 13장 「⛔ 기본값 금지(빠진 것이 조용해짐)」 (2026-09-14). - -종전 `DESTINATION.get(name, "quantity")` 는 표에 없는 이름을 **아무 축에도 안 가는 칸**으로 떨어뜨려 -L형수로 이형철근·면목 · 개거 유로폼·면목 · 떼흙막이 떼가 자재총괄에서 통째로 빠져 있었음. - -지키는 것 - ① 표에 없는 이름은 성분이 안 서고 **사유가 뜸**(조용히 새지 않음) - ② 정본 표(개거·떼흙막이·바닥막이)의 모든 줄 이름이 갈 곳 표에 있음 — 줄이 늘면 여기서 걸림 - ③ 드러난 넷이 제자리로 감(관측 원단위와 같은 갈 곳) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, routed # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 - BED_SILL_FORMS, - OPEN_DITCH_FORMS, - SOIL_GUARD_SOD, - open_ditch, - soil_guard, -) - - -def test_표에_없는_이름은_막히고_사유가_뜸() -> None: - notes: list[str] = [] - assert routed("낯선성분", notes) is None - assert routed("낯선성분", notes) is None - assert len(notes) == 1 and "갈 곳이 표" in notes[0] and "기본값 금지" in notes[0] - assert routed("터파기", notes) == "earthwork" and len(notes) == 1 - - -def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None: - names = {row[0] for table in OPEN_DITCH_FORMS.values() for row in table["rows"]} - names |= {row[0] for row in SOIL_GUARD_SOD["rows"]} - names |= {table["stone_name"] for table in BED_SILL_FORMS.values()} - assert sorted(name for name in names if name not in DESTINATION) == [] - - -def test_드러난_넷이_제자리로_감() -> None: - ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"}) - got = {component.name: component.destination for component in ditch} - assert got["이형철근"] == "material" and got["면목"] == "material" - assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로 - assert not [note for note in notes if "갈 곳이 표" in note] - plain, _ = open_ditch(10.0, {}) - assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price" - sod, sod_notes = soil_guard({"form": "떼"}) - assert {c.name: c.destination for c in sod}["떼"] == "material" - assert not [note for note in sod_notes if "갈 곳이 표" in note] - - -def test_관측_원단위_줄도_갈_곳이_없으면_막히고_사유() -> None: - """관측 원단위 로더의 `destination or "material"` 도 같은 병 — 줄이 늘 때 바로 드러나게.""" - from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ObservedUnitTable, expand_observed - - table = ObservedUnitTable( - entries=[ - { - "type_id": "시험구조물", - "unit": "개소", - "components": [ - {"name": "콘크리트", "unit": "㎥", "amount": 1.0, "destination": "unit_price"}, - {"name": "새자재", "unit": "㎥", "amount": 2.0}, - ], - } - ] - ) - components, notes = expand_observed("시험구조물", {}, {"options": {}}, table) - assert [c["name"] for c in components] == ["콘크리트"] - assert any("새자재" in note and "기본값 금지" in note for note in notes) - - -def test_관측_원단위_표의_전_줄에_갈_곳이_있음() -> None: - from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table - - missing = [ - (entry.get("type_id"), item.get("name")) - for entry in load_observed_table().entries - for item in entry.get("components") or [] - if not item.get("destination") - ] - assert missing == [] diff --git a/resources/tester/test_b08_dry_height_face_role.py b/resources/tester/test_b08_dry_height_face_role.py deleted file mode 100644 index 280bcab9a..000000000 --- a/resources/tester/test_b08_dry_height_face_role.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -"""메쌓기 높이 사유(B2) · 성토/절토 칸(B3) — 2026-09-14 브레인 판정 ㉰·㉲. - -㉰ 두 기준 공존 — 임도기술교본 7-3:27 「메쌓기 2.0m 이하」 ↔ 품셈 13-4-4 [주]⑪ 원문 L7192 - 「3m 이상이면 전부 또는 하부를 찰쌓기」. 막지 않고 사유(B08 줄) · 칸 경고(B05, `warn_above`). -㉲ `face_role` — 비우면 단면유형 + 설치 측 자동 판정(계산이지 기본값이 아님) · 고르면 그 값이 이김. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry # noqa: E402 -from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402 - -REGISTRY = json.loads( - (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") -) -FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") - - -def _options(type_id: str) -> dict[str, dict]: - item = next(t for t in REGISTRY["types"] if t["type_id"] == type_id) - return {o["key"]: o for o in item["options"]} - - -def _height_notes(height: float, wet: bool) -> list[str]: - _rows, notes = stone_masonry(height, 10.0, {"back_len_cm": 45}, wet) - return [n for n in notes if "메쌓기 높이" in n] - - -def test_메쌓기_높이_두_기준_사유() -> None: - assert _height_notes(2.0, False) == [] - over_guide = _height_notes(2.5, False) - assert len(over_guide) == 1 and "7-3:27" in over_guide[0] and "L7192" in over_guide[0] - over_limit = _height_notes(3.0, False) - assert len(over_limit) == 1 and "3m 이상이면" in over_limit[0] - assert _height_notes(3.5, True) == [] # 찰쌓기는 이 사유 대상 아님 - - -def test_등록부_메쌓기_높이_칸은_경고만() -> None: - height = _options("masonry_dry")["height_m"] - assert height["warn_above"] == 2.0 and "L7192" in height["warn_message"] - assert "is-outside-standard" in FIELDS and "warn_above" in FIELDS - - -def test_성토절토_칸이_이긴다() -> None: - assert structure_face_role_of("both_fill", {"face_role": "절토"}) == ( - "절토", - "사용자 지정 절토면(성토/절토 칸)", - ) - # 비우면 지금 판정 그대로 - assert structure_face_role_of("both_fill", {})[0] == "성토" - for type_id in ("masonry_wet", "masonry_dry", "revetment"): - role = _options(type_id)["face_role"] - assert role["choices"] == ["성토", "절토"] and role["default"] is None, type_id - - -def test_전개가_칸_값으로_기울기를_고른다() -> None: - structure = { - "structure_id": "w", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_role": "절토"}, - } - result = expand(structure, None, None, "both_fill") - wall = next(c for c in result.components if c.name == "돌쌓기") - # 찰쌓기 · 절토 · 직고 ≤3m → 1:0.25 (성토였으면 1:0.30) - assert "절토" in wall.basis and "1:0.25" in wall.basis diff --git a/resources/tester/test_b08_earthwork_summary.py b/resources/tester/test_b08_earthwork_summary.py deleted file mode 100644 index e0d4708de..000000000 --- a/resources/tester/test_b08_earthwork_summary.py +++ /dev/null @@ -1,287 +0,0 @@ -"""토공집계표·프로젝트 설정 검사 — PLAN 8-11·8-13·8-7. - -여기서 못 박는 것 셋 - · 암 갈래 **개수를 코드에 안 박음** — 공사마다 다르다(울진 2 · 거창 5 · BOM 1). - · 반영률 **기본 100 %** — 실무 관측 80/50/80 은 기본값이 아니다(★법대로). - · 무대는 **집계에는 오르되 내역 줄이 아니다** — 품셈 1-2-7. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 - SummaryInput, - build_rows, - build_table, - haul_check, - totals_by_unit, -) -from common_util.common_util_project_settings import ( # noqa: E402 - APPLICATION_RATIO_KEYS, - ROCK_CLASS_SETS, - application_ratio, - default_settings, - load_settings, - quantity_settings, - rock_classes, - save_section, -) - - -def 토적표합계() -> dict[str, float]: - return { - "cut_soil_volume_m3": 2526.99, - "cut_rock_volume_m3": 3512.09, - "ditch_soil_volume_m3": 90.51, - "ditch_rock_volume_m3": 101.22, - "adjusted_total_m3": 6511.06, - "fill_volume_m3": 16836.47, - "diverted_m3": 5801.21, - } - - -def 사면합계() -> dict[str, float]: - return { - "face_dressing_fill": 13518.6, - "face_dressing_cut": 5433.7, - "tree_removal_fill": 13518.6, - "tree_removal_cut": 5433.7, - "bench_cut_fill": 13518.6, - } - - -# ── 암 갈래 — 개수를 코드에 박지 않는다 ───────────────────────────── - - -def test_비율이_없으면_암을_쪼개지_않음() -> None: - """지어낸 비율로 나누지 않는다 — 「암」 한 줄로 낸다.""" - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - rock_classes=list(ROCK_CLASS_SETS["geochang5"]), - rock_ratios_pct={}, - ) - ) - cut = [r for r in rows if r.group == "흙깎기"] - assert [r.item for r in cut] == ["토사", "암"] - assert cut[1].amount == pytest.approx(3512.09) - - -def test_울진2갈래() -> None: - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - rock_classes=list(ROCK_CLASS_SETS["uljin2"]), - rock_ratios_pct={"연암": 20, "발파암": 80}, - ) - ) - cut = [r for r in rows if r.group == "흙깎기"] - assert [r.item for r in cut] == ["토사", "연암", "발파암"] - assert cut[1].amount == pytest.approx(3512.09 * 0.2) - assert cut[2].amount == pytest.approx(3512.09 * 0.8) - - -def test_거창5갈래() -> None: - """같은 코드가 갈래 수만 바꿔 선다 — 개수를 박지 않았다는 증거.""" - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - rock_classes=list(ROCK_CLASS_SETS["geochang5"]), - rock_ratios_pct={"풍화암": 10, "연암": 60, "보통암": 25, "경암": 5}, - ) - ) - cut = [r for r in rows if r.group == "흙깎기"] - assert [r.item for r in cut] == ["토사", "풍화암", "연암", "보통암", "경암"] - assert sum(r.amount for r in cut[1:]) == pytest.approx(3512.09) - - -def test_비율_합이_100이_아니어도_총량은_보존() -> None: - """설계자가 60/30 만 넣어도 암 총량이 새면 안 된다 — 준 비율끼리 안분한다.""" - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - rock_classes=list(ROCK_CLASS_SETS["uljin2"]), - rock_ratios_pct={"연암": 60, "발파암": 30}, - ) - ) - cut = [r for r in rows if r.group == "흙깎기"] - assert sum(r.amount for r in cut[1:]) == pytest.approx(3512.09) - # 60/30 이 실제로는 66.7/33.3 으로 돈다 — 값이 말없이 바뀌므로 비고에 드러낸다. - assert cut[1].amount == pytest.approx(3512.09 * 60 / 90) - assert "90" in cut[1].note and "안분" in cut[1].note - - -def test_비율_합이_100이면_비고가_비어_있음() -> None: - """제대로 넣었는데 안내가 뜨면 잡음이 된다.""" - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - rock_classes=list(ROCK_CLASS_SETS["uljin2"]), - rock_ratios_pct={"연암": 20, "발파암": 80}, - ) - ) - cut = [r for r in rows if r.group == "흙깎기"] - assert all(r.note == "" for r in cut[1:]) - - -# ── 반영률 — 기본 100 % ───────────────────────────────────────── - - -def test_반영률_기본은_100퍼센트() -> None: - rows = build_rows(SummaryInput(earthwork_totals=토적표합계(), slope_totals=사면합계())) - compaction = next(r for r in rows if r.group == "성토면다짐") - assert compaction.amount == pytest.approx(13518.6) - assert compaction.note == "" # 기본이면 비고에 아무것도 안 적는다 - - -def test_반영률을_주면_곱해지고_비고에_남음() -> None: - """실무 시트가 비고란에 적던 그 자리다 — 누가 정한 값인지 보이게 한다.""" - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - slope_totals=사면합계(), - application_ratios={"fill_slope_compaction": 0.8}, - ) - ) - compaction = next(r for r in rows if r.group == "성토면다짐") - assert compaction.amount == pytest.approx(13518.6 * 0.8) - assert "80" in compaction.note - - -def test_초류종자살포는_성토_절토_따로() -> None: - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - slope_totals=사면합계(), - application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, - ) - ) - seed = next(r for r in rows if r.group == "초류종자살포") - assert seed.amount == pytest.approx(13518.6 * 0.5 + 5433.7) - - -# ── 무대 — 집계에는 오르되 내역 줄이 아니다 ────────────────────── - - -def test_무대는_내역줄이_아님() -> None: - """품셈 1-2-7 — 소운반 20m 는 품에 포함이라 붙일 단가가 없다.""" - source = SummaryInput( - earthwork_totals=토적표합계(), - haul_rows=[ - {"equipment": "free_haul", "ground": "토사", "volume_m3": 871, "average_distance_m": 11.94}, - {"equipment": "dozer", "ground": "토사", "volume_m3": 1170, "average_distance_m": 43.66}, - {"equipment": "dump_truck", "ground": "암", "volume_m3": 1714, "average_distance_m": 318.6}, - ], - ) - rows = build_rows(source) - free = next(r for r in rows if r.group.startswith("무대")) - assert free.in_bill is False - assert "내역 제외" in free.note - assert free.amount == pytest.approx(871) - assert all(r.in_bill for r in rows if r.group in ("도자운반", "덤프운반")) - - -def test_무대를_내되_검산에_씀() -> None: - """무대를 아예 안 내면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다.""" - source = SummaryInput( - earthwork_totals=토적표합계(), - haul_rows=[ - {"equipment": "free_haul", "volume_m3": 1000}, - {"equipment": "dozer", "volume_m3": 2000}, - {"equipment": "dump_truck", "volume_m3": 2801.21}, - ], - ) - check = haul_check(source, 토적표합계()) - assert check["hauled_total_m3"] == pytest.approx(5801.21) - assert check["difference_m3"] == pytest.approx(0.0, abs=0.01) - - -def test_평균운반거리가_비고에_남음() -> None: - rows = build_rows( - SummaryInput( - earthwork_totals=토적표합계(), - haul_rows=[{"equipment": "dozer", "ground": "토사", "volume_m3": 1170, "average_distance_m": 43.66}], - ) - ) - dozer = next(r for r in rows if r.group == "도자운반") - assert "43.66" in dozer.note - - -# ── 표 모양 ───────────────────────────────────────────────────── - - -def test_열_구성은_실무_시트_그대로() -> None: - table = build_table(SummaryInput(earthwork_totals=토적표합계())) - assert table["columns"] == ["구분", "공종", "규격", "단위", "계", "비고"] - - -def test_단위를_섞어_더하지_않음() -> None: - rows = build_rows(SummaryInput(earthwork_totals=토적표합계(), slope_totals=사면합계())) - totals = totals_by_unit(rows) - assert set(totals) == {"㎥", "㎡"} - - -# ── 프로젝트 설정 ──────────────────────────────────────────────── - - -def test_기본설정_모양(tmp_path: Path) -> None: - settings = default_settings() - assert settings["schema_version"] == 1 - assert set(settings) == {"schema_version", "quantity", "estimation"} - quantity = settings["quantity"] - # override 는 기본이 None — 「안 정했으면 config 정본을 쓴다」는 뜻. - assert quantity["conversion_factors_override"] is None - # 도쟈 한계거리 — 안 읽히던 `haul_limits_m_override` 를 갈음(2026-09-13). None 이면 기본 60 m. - assert quantity["dozer_haul_limit_m"] is None - # 반영률 기본 100. 실무 관측 80/50/80 을 넣지 않는다. - assert quantity["application_ratios_pct"] == {key: 100 for key in APPLICATION_RATIO_KEYS} - # estimation 은 자리만 — 채우는 것은 B09 몫. - # ⚠ 「연도」가 아니라 **판**을 가리킨다 — 제비율은 연중에도 개정된다(현행판 2026-04-13). - assert settings["estimation"]["rate_dataset"] is None - assert "rate_year" not in settings["estimation"] - - -def test_파일이_없으면_기본값(tmp_path: Path) -> None: - assert load_settings(tmp_path) == default_settings() - - -def test_깨진_파일이어도_화면은_서야_함(tmp_path: Path) -> None: - (tmp_path / "project_settings.json").write_text("{ 망가짐", encoding="utf-8") - assert load_settings(tmp_path) == default_settings() - - -def test_한_구획만_갈아끼움(tmp_path: Path) -> None: - """두 페이지가 같은 파일을 쓴다 — 통째로 덮으면 상대 값이 사라진다.""" - save_section(tmp_path, "estimation", {"rate_dataset": {"dataset_id": "rates", "effective_date": "2026-04-13"}}) - save_section(tmp_path, "quantity", {"rock_class_set": "uljin2"}) - stored = json.loads((tmp_path / "project_settings.json").read_text(encoding="utf-8")) - # 남의 구획이 살아 있다 — B08 이 저장해도 B09 값이 안 지워진다. - assert stored["estimation"]["rate_dataset"]["effective_date"] == "2026-04-13" - assert stored["quantity"]["rock_class_set"] == "uljin2" - - -def test_모르는_구획은_거부(tmp_path: Path) -> None: - with pytest.raises(ValueError): - save_section(tmp_path, "hacked", {}) - - -def test_암갈래는_세트에서(tmp_path: Path) -> None: - save_section(tmp_path, "quantity", {"rock_class_set": "uljin2", "rock_classes": None}) - settings = quantity_settings(tmp_path) - settings["rock_classes"] = None - assert rock_classes(settings) == list(ROCK_CLASS_SETS["uljin2"]) - - -def test_반영률_읽기() -> None: - settings = default_settings()["quantity"] - assert application_ratio(settings, "obstacle_removal") == pytest.approx(1.0) - settings["application_ratios_pct"]["obstacle_removal"] = 80 - assert application_ratio(settings, "obstacle_removal") == pytest.approx(0.8) diff --git a/resources/tester/test_b08_extra_walls.py b/resources/tester/test_b08_extra_walls.py deleted file mode 100644 index 6cd5c457e..000000000 --- a/resources/tester/test_b08_extra_walls.py +++ /dev/null @@ -1,170 +0,0 @@ -# -*- coding: utf-8 -*- -"""다단 추가 기슭막이도 수량이 섬 — ④ (2026-09-14 브레인 판정 「수량이 통째로 빠지는 것」). - -앞서 B06 횡단도는 다단 벽(`design.extra_wall_counts` · `revet_adjust.extra*` · `extra_spans`)을 -그리고 면적에도 넣었는데, B08 전개는 관 유입·유출 기준벽만 셈 → 다단 벽 돌쌓기·터파기가 한 줄도 안 섬. - -길 — 실제로 **선** 단 수·높이는 지형이 정하므로(요청보다 적게 설 수 있음) 서버 Node 재계산이 -관 연장처럼 「선 다단 벽 목록」(`design.extra_walls`)을 소유 측점에 남기고, B08 이 그 목록으로 줄을 세움. -값이 없는 다단(높이를 안 적어 기본 1.5m 로 지반에 맞춰 그린 단)은 **미확정·금액 밖** 규칙 그대로. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Pipe import ( # noqa: E402 - extra_walls_from_designs, - facility_structures, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - -LAYOUTS = (ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts").read_text(encoding="utf-8") -NODE = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(encoding="utf-8") -PREBUILD = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text( - encoding="utf-8" -) - -WALLS = [ - { - "key": "extra0", - "side": "outlet", - "form": "돌쌓기(메)", - "height_m": 1.8, - "height_set": True, - "form_set": True, - "before_m": 4.0, - "after_m": 4.0, - }, - { - "key": "extra1", - "side": "outlet", - "form": "돌쌓기(메)", - "height_m": 1.3, - "height_set": False, - "form_set": False, - "before_m": 5.0, - "after_m": 5.0, - }, -] -POINT = { - "chainage_m": 85.05, - "options": { - "pipe_diameter_mm": 1000, - "outlet_revet_form": "돌쌓기(메)", - "outlet_revet_height_m": 2.0, - "outlet_revet_length_m": 10, - "inlet_revet_form": "돌쌓기(찰)", - "inlet_revet_height_m": 2.0, - "inlet_revet_length_m": 10, - "revet_foundation": "기초유", - }, -} - - -def _tiers(rows: list[dict]) -> list[dict]: - return [row for row in rows if "다단" in str(row.get("attachment_label") or "")] - - -def test_설계에서_다단_목록을_측점별로_읽는다() -> None: - designs = [ - {"chainage_m": 85.0, "design": {"extra_walls": WALLS}}, - {"chainage_m": 140.0, "design": {"cut_area_m2": 1.0}}, - ] - assert extra_walls_from_designs(designs) == {85.0: WALLS} - - -def test_선_다단_벽마다_기슭막이_줄이_선다() -> None: - rows = facility_structures([POINT], {85.0: WALLS}) - tiers = _tiers(rows) - assert [row["attachment_label"] for row in tiers] == [ - "유출측 다단 기슭막이 1단", - "유출측 다단 기슭막이 2단", - ] - first, second = tiers - assert first["type_id"] == "revetment" - assert first["options"]["height_m"] == 1.8 and first["options"]["form"] == "돌쌓기(메)" - assert (first["start_m"], first["end_m"]) == (85.05 - 4.0, 85.05 + 4.0) - assert first["options"]["foundation"] == "기초유" # 기준벽과 같은 기초 - assert first["unconfirmed"] == "" - # 높이를 안 적은 단 — 줄은 서되 미확정 - assert "다단 높이를 안 적음" in second["unconfirmed"] - - -def test_다단_줄이_원단위와_금액_합에_든다() -> None: - table = build_table(facility_structures([POINT], {85.0: WALLS}), {"revetment": "기슭막이"}, {}) - tiers = [s for s in table["structures"] if "다단" in s["name"]] - assert len(tiers) == 2 - assert tiers[0]["components"] and not tiers[0]["unconfirmed"] - assert tiers[1]["unconfirmed"] - - -def test_윗단이_미확정이면_아랫단도_미확정() -> None: - """면적은 첫 미확정 단부터 아래를 없는 것으로 봄(B06) — B08 도 같은 선에서 끊어야 성토·벽이 함께 움직임.""" - walls = [ - dict(WALLS[1], key="extra0"), - dict(WALLS[0], key="extra1"), - ] # 1단 미확정 · 2단 높이 적음 - tiers = _tiers(facility_structures([POINT], {85.0: walls})) - assert "다단 높이를 안 적음" in tiers[0]["unconfirmed"] - assert "윗단(1단)이 미확정" in tiers[1]["unconfirmed"] - - -def test_미확정_단은_성토를_안_깎는다() -> None: - """2026-09-14 브레인 판정 — 미확정 벽이 성토만 깎으면 「벽도 성토도 없는 빈 자리」가 생김.""" - geom = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text( - encoding="utf-8" - ) - assert "extendTrimSlope(drawnFillPoints(extrasForArea)" in geom - assert "extendTrimSlope(drawnFillPoints(basinExtrasForArea)" in geom - assert "applied.h == null" in geom - - -def test_벽_몸이_성토에도_든_겹침을_사유로_드러냄() -> None: - """2026-09-14 브레인 — 폐회로는 구조물 면적을 안 뺌(2026-09-06 확정)이라 벽 몸이 성토에도 셈. - 고치지 않고 사유로: 258.12 실측 1단 벽 몸 1.335㎡ 가 성토 폐회로 안.""" - walls = [dict(WALLS[0], fill_overlap_m2=1.336, before_m=5.0, after_m=5.0)] - note = " ".join(_tiers(facility_structures([POINT], {85.0: walls}))[0]["notes"]) - assert "성토 면적에도" in note and "1.34㎡" in note and "13.4㎥" in note - bare = " ".join(_tiers(facility_structures([POINT], {85.0: WALLS[:1]}))[0]["notes"]) - assert "성토 면적에도" not in bare # 값이 없으면(옛 저장본) 지어내지 않음 - - -def test_서버가_벽_몸_겹침을_잰다() -> None: - assert "wallBodyInFillM2(" in LAYOUTS and "fill_overlap_m2" in LAYOUTS - - -def test_관_기준벽도_겹침_사유가_뜬다() -> None: - """2026-09-14 브레인 ㉡ — 같은 겹침이 관 기준벽에도 있음. 다단에만 뜨면 「다단만 문제」로 읽힘. - 258.12 실측 유출 벽 몸 1.417㎡ 가 성토 폐회로 안.""" - designs = [ - {"chainage_m": 85.0, "design": {"extra_walls": [], "wall_fill_overlap": {"outlet": 1.417}}} - ] - rows = facility_structures([POINT], extra_walls_from_designs(designs)) - by_label = { - row["attachment_label"]: " ".join(row["notes"]) - for row in rows - if row.get("notes") is not None - } - outlet = next(text for label, text in by_label.items() if "유출" in label) - inlet = next(text for label, text in by_label.items() if "유입" in label) - assert "성토 면적에도" in outlet and "1.42㎡" in outlet and "14.2㎥" in outlet - assert "성토 면적에도" not in inlet # 잰 값이 없는 벽엔 지어내지 않음 - assert not _tiers(rows) # 기준벽 값이 다단 줄로 새지 않음 - assert "wall_fill_overlap" in LAYOUTS and '"wall_fill_overlap"' in PREBUILD - - -def test_목록이_없으면_종전과_같다() -> None: - assert not _tiers(facility_structures([POINT])) - assert not _tiers(facility_structures([POINT], {})) - - -def test_서버_재계산이_선_다단_목록을_남긴다() -> None: - assert "export function extraWallRows" in LAYOUTS - assert "extraWallRows(sections)" in NODE and "extra_walls" in NODE - assert '"extra_walls"' in PREBUILD diff --git a/resources/tester/test_b08_face_dressing.py b/resources/tester/test_b08_face_dressing.py deleted file mode 100644 index ca4d93944..000000000 --- a/resources/tester/test_b08_face_dressing.py +++ /dev/null @@ -1,214 +0,0 @@ -"""면고르기 줄 — 2026-09-14 브레인 판정 Ⓐ~Ⓓ. - -종전: 면적(face_dressing 성·절토)은 있는데 토공집계가 줄을 안 세워 인계가 안 보냄(내역 0줄). -실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% 일치 · 그러나 내역엔 사면적 통째가 아님 -(거창 줄 없음 · 영월 성토면 × 50%) — 원문 9-19-1 성토면 [주] 「식재를 위한」 · 9-19-2·3 [주]① -「식재기반 조성에만」 → Ⓐ 밑수 = 초류종자살포(파종) 면적 · 덮어쓰기 칸 · 파종 0 이면 0 + 사유 -Ⓑ 절토면 토사/암 갈라 암은 시공법(리핑 9-19-2 · 발파 9-19-3) Ⓒ 토질·시공 칸 둘 · 제안값 없음 -Ⓓ 성토면 기계(굴착기 0.6㎥ 형식 둘)는 사유로. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from test_b08_slope_area import 절토_설계선 # noqa: E402 - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 - FACE_DRESSING_CUT_CLASSES, - FACE_DRESSING_FILL_CLASSES, - SummaryInput, - build_rows, - build_table, -) -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as slope_table # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402 - StationSlope, - station_slope, -) - -CUT_SAND = "절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토" -FILL_CLAY = "성토면 · 인력 · 점토 또는 점질토" - - -# ── 사면길이 · 면적 — 절토면을 토사/암으로 ───────────────────────────── - - -def test_절토_사면길이가_토사_암으로_갈림() -> None: - slope = station_slope(20.0, 절토_설계선()) - assert slope.cut_soil_length_m + slope.cut_rock_length_m == pytest.approx(slope.cut_length_m) - soil = sum(s.length_m for s in slope.segments if s.role == "cut" and s.material == "soil") - assert slope.cut_soil_length_m == pytest.approx(soil) and soil > 0 - assert slope.cut_rock_length_m > 0 - - -def test_2단이_아닌_측점은_설계_지반_프리셋으로_가르고_모르면_안_가름() -> None: - """토사 프리셋은 비탈 전체가 토사 · 암 프리셋 1단은 전체가 암 · 프리셋이 없으면 못 가름.""" - one = {**절토_설계선(), "two_stage_slope": False} - soil = station_slope(20.0, {**one, "geometry_preset": "soil"}) - assert ( - soil.cut_soil_length_m == pytest.approx(soil.cut_length_m) and soil.cut_rock_length_m == 0 - ) - rock = station_slope(20.0, {**one, "geometry_preset": "rock"}) - assert ( - rock.cut_rock_length_m == pytest.approx(rock.cut_length_m) and rock.cut_soil_length_m == 0 - ) - unknown = station_slope(20.0, one) - assert unknown.cut_length_m > 0 and unknown.cut_soil_length_m == unknown.cut_rock_length_m == 0 - - -def test_면적_합계에_절토면_토사_암이_평균단면적법으로_실림() -> None: - one = StationSlope( - chainage_m=0.0, cut_length_m=4.0, cut_soil_length_m=3.0, cut_rock_length_m=1.0 - ) - two = StationSlope( - chainage_m=20, cut_length_m=6.0, cut_soil_length_m=2.0, cut_rock_length_m=4.0 - ) - slopes = [one, two] - totals = slope_table(slopes)["totals"] - assert totals["face_dressing_cut"] == pytest.approx(100.0) - assert totals["face_dressing_cut_soil"] == pytest.approx(50.0) - assert totals["face_dressing_cut_rock"] == pytest.approx(50.0) - - -# ── 토공집계 — 밑수 = 파종 면적 ────────────────────────────────────── - - -def _source(**extra) -> SummaryInput: - base = dict( - slope_totals={ - "face_dressing_fill": 1000.0, - "face_dressing_cut": 500.0, - "face_dressing_cut_soil": 300.0, - "face_dressing_cut_rock": 200.0, - }, - rock_classes=["토사", "연암", "경암"], - rock_ratios_pct={"연암": 50.0, "경암": 50.0}, - application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, - ) - base.update(extra) - return SummaryInput(**base) - - -def _faces(source: SummaryInput) -> list[tuple[str, str, float, bool]]: - return [ - (row.spec, row.item, round(row.amount, 6), row.in_bill) - for row in build_rows(source) - if row.group == "면고르기" - ] - - -def test_면고르기는_파종_면적을_따라가고_절토면은_토사_암_갈래로() -> None: - """성토 1000 × 파종 50% = 500 · 절토 500 × 100% → 토사 300 · 암 200 을 구성비로 연암·경암.""" - assert _faces(_source()) == [ - ("성토면", "", 500.0, True), - ("절토면", "토사", 300.0, True), - ("절토면 · 연암", "연암", 100.0, True), - ("절토면 · 경암", "경암", 100.0, True), - ] - fill = next(r for r in build_table(_source())["rows"] if r["spec"] == "성토면") - assert fill["amount_gross"] == 1000.0 and fill["application_ratio_pct"] == 50.0 - assert "초류종자살포" in fill["note"] - - -def test_덮어쓴_면적이_이기고_비고에_파종_면적과_나란히() -> None: - source = _source(face_dressing_area_m2={"fill": 123.0, "cut": 100.0}) - faces = _faces(source) - assert faces[0] == ("성토면", "", 123.0, True) - assert faces[1] == ("절토면", "토사", 60.0, True) # 100 × 300/500 - fill = next(r for r in build_rows(source) if r.group == "면고르기") - assert "123" in fill.note and "500" in fill.note - - -def test_파종_면적이_0이면_면고르기도_0이고_내역에_안_섬() -> None: - source = _source(application_ratios={"seed_spray_fill": 0.0, "seed_spray_cut": 0.0}) - faces = _faces(source) - assert faces[0] == ("성토면", "", 0.0, False) - rows = [r for r in build_rows(source) if r.group == "면고르기"] - assert all("파종 면적이 0" in r.note for r in rows) - - -# ── 인계 — 코드 · 갈래 · 칸 비면 사유 ───────────────────────────────── - - -def _handoff(**extra) -> dict: - summary = build_table(_source()) - return build_handoff( - summary_table=summary, - ground_classes=["토사", "연암", "경암"], - ground_methods={"연암": "ripping", "경암": "blasting"}, - **extra, - ) - - -def _items(handoff: dict) -> list[dict]: - return [row for row in handoff["work_items"] if row["name"] == "면고르기"] - - -def test_면고르기_인계는_성토면_절토면_토사_암을_각_절로() -> None: - items = _items(_handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY)) - got = [(r["spec"], r["ground_class"], r["work_item_code"], r["variant_value"]) for r in items] - assert got == [ - ("성토면", None, "FP-09-19-01", FILL_CLAY), - ("절토면", "토사", "FP-09-19-01", CUT_SAND), - ("절토면 · 연암", "연암", "FP-09-19-02", None), - ("절토면 · 경암", "경암", "FP-09-19-03", None), - ] - assert not any(r["blocked_kind"] for r in items) - - -def test_토질_시공_칸이_비면_금액_없이_입력_사유() -> None: - items = _items(_handoff()) - assert [r["spec"] for r in items[:2]] == ["성토면", "절토면"] # 줄이 없으면 헛통과 막음 - for row in items[:2]: - assert row["blocked_kind"] == "input_missing", row - assert "산출 조건" in row["blocked_reason"] - - -def test_고르는_갈래는_원문_표_두_벌과_마스터_갈래_키가_같음() -> None: - """B08 칸의 선택지와 B09 표 읽기가 세운 갈래 — 두 벌이 갈리면 빨강.""" - from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master - from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key - - node = next( - n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-19-01" - ) - keys = {normalize_variant_key(k) for k in node["variant_keys"]} - choices = { - normalize_variant_key(k) for k in FACE_DRESSING_CUT_CLASSES + FACE_DRESSING_FILL_CLASSES - } - assert keys == choices - - -# ── 내역 — 갈래가 서면 금액 · 기계는 사유 ───────────────────────────── - - -def test_내역은_갈래가_서면_금액이_서고_성토면_기계는_무한궤도_제안() -> None: - """9-19-2(연암 · 리핑) · 9-19-1(성토면 인력 점토 500㎡ × 3,269원) 금액이 섬. - ⓘ 9-19-1 은 첫 커밋(ee9368ec) 때 공기압축기 3.5 손료 미확보(공종 단위 「일부만」)에 막혀 있었고 - 운전경비 표 괄호 딸림값 파서 고침으로 풀림(같은 날 · `test_b09_operating_paren_spec`). - 성토면·기계는 굴착기 형식 두 갈래 · 제안 무한궤도(영월 실무 「06M3 B/H」 · 브레인 ②).""" - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - handoff = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY) - rows = [r for r in build_bill(handoff).rows if str(r.code).startswith("FP-09-19-0")] - ripping = next(r for r in rows if r.code == "FP-09-19-02") - assert ripping.amount_krw and int(ripping.amount_krw) > 0, ripping.note - fill = next(r for r in rows if r.code == "FP-09-19-01" and r.spec == FILL_CLAY) - assert fill.amount_krw == 500 * 3269, fill.note - track = "성토면 · 기계 · 무한궤도" - machine = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=track) - row = next(r for r in build_bill(machine).rows if r.code == "FP-09-19-01" and r.spec == track) - assert row.amount_krw and int(row.amount_krw) > 0, row.note - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import FACE_DRESSING_FILL_SUGGESTED - - value, basis = FACE_DRESSING_FILL_SUGGESTED - assert value == track and "06M3 B/H" in basis and "타이어" in basis diff --git a/resources/tester/test_b08_face_dressing_fields_ui.py b/resources/tester/test_b08_face_dressing_fields_ui.py deleted file mode 100644 index 691fd4804..000000000 --- a/resources/tester/test_b08_face_dressing_fields_ui.py +++ /dev/null @@ -1,42 +0,0 @@ -"""면고르기 산출 조건 칸 넷 — 화면이 서버 선택지를 그대로 쓰고 [저장]에 네 키를 싣는지(2026-09-14 메인 부탁). - -서버(ee9368ec)는 `face_dressing_cut_class`·`face_dressing_fill_class`·`face_dressing_fill_area_m2`· -`face_dressing_cut_area_m2` 를 받고 선택지는 `earthwork-table` 의 `face_dressing_choices` 로 내려줌. -⚠ 선택지를 화면에 다시 적으면 두 벌이 됨 — TS 에 갈래 이름이 없어야 함. -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -PAGE = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") -SIDE = ROOT / "B08_Quantity" / "B08_Quantity_UI_Side_FaceDressing.ts" -KEYS = ( - "face_dressing_cut_class", - "face_dressing_fill_class", - "face_dressing_fill_area_m2", - "face_dressing_cut_area_m2", -) - - -def test_저장에_네_키를_싣고_불러온다() -> None: - for key in KEYS: - assert f"{key}: draft.{key}" in PAGE, f"[저장]에 {key} 없음" - assert f"{key}: (stored.{key}" in PAGE, f"불러오기에 {key} 없음" - - -def test_성토면_제안은_회색_근거와_단추로만_넣는다() -> None: - """메인 976e5fd9 — `fill_suggested` {value, basis}. 비워 두면 「안 정함」 그대로(스스로 안 고름).""" - side = SIDE.read_text(encoding="utf-8") - assert "choices.fill_suggested" in side and "suggested.basis" in side - button = side.split("createButton(")[1] - assert "draft.face_dressing_fill_class = suggested.value" in button # 누른 때만 넣음 - assert side.count("= suggested.value") == 2 # 단추 안의 칸·초안 둘뿐 - assert "BenchCut" not in side # 층따기 문구(「KDS 토사 0.5 m」)를 빌려 쓰지 않음 - - -def test_선택지는_서버_목록_그대로() -> None: - side = SIDE.read_text(encoding="utf-8") - assert "appendFaceDressingFields(" in PAGE and "face_dressing_choices" in PAGE - assert "choices.cut" in side and "choices.fill" in side - for name in ("모래ㆍ사질토", "성토면 · 인력", "성토면 · 기계"): - assert name not in side and name not in PAGE, f"갈래 이름 {name} 을 화면에 다시 적음" diff --git a/resources/tester/test_b08_facility_structures.py b/resources/tester/test_b08_facility_structures.py deleted file mode 100644 index 5c3c31638..000000000 --- a/resources/tester/test_b08_facility_structures.py +++ /dev/null @@ -1,131 +0,0 @@ -"""계곡 통과 시설(`pipe_points.json`)이 원단위 전개·인계에 선다 (A1, 2026-09-14). - -⚠ 앞서 전개가 `structures.json` 만 읽어 관 유입·유출부 기슭막이·집수정·독립 기슭막이가 - 원단위·내역에 한 줄도 안 섰음. 관 줄(품셈 12-11 m당)에는 기슭막이 몫이 없어 **한 번만** 셈. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 -from B08_Quantity import B08_Quantity_Router_Material as material # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows, facility_structures # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - -NAMES = {"pipe": "배수관", "revetment": "기슭막이", "ford_bridge": "세월교"} - - -def _table(points: list[dict]) -> dict: - return build_table(facility_structures(points), NAMES) - - -def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관_줄에는_없다() -> None: - point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}} - table = _table([point]) - names = [s["name"] for s in table["structures"]] - assert names == ["배수관 · 유입부 기슭막이", "배수관 · 유출부 기슭막이"] - inlet, outlet = table["structures"] - assert inlet["options"]["form"] == "돌쌓기(찰)" and outlet["options"]["form"] == "돌쌓기(메)" - assert all(s["components"] and s["length_m"] == 10 for s in table["structures"]) - assert "기본값으로 섰음" in inlet["notes"][0] - # 높이는 횡단도와 같은 관경 기준 최소 높이(Ø1000 → 2.0) — 그림 둘·표가 한 값(브레인 판정) - assert inlet["height_m"] == outlet["height_m"] == 2.0 and "관경 기준" in inlet["notes"][0] - # 관 줄은 관 연장만 — 기슭막이가 거기 섞이지 않음(두 번 안 셈). - pipe = build_rows([point], [{"chainage_m": 100.0, "design": {"pipe_length_m": 8}}]) - assert [row["quantity"] for row in pipe["rows"]] == [8.0] - - work = {row["name"]: row for row in build_handoff(unit_quantity_table=table)["work_items"]} - assert work["배수관 · 유입부 기슭막이"]["work_item_code"] == "FP-13-04-05" - assert work["배수관 · 유출부 기슭막이"]["work_item_code"] == "FP-13-04-02" - assert work["배수관 · 유입부 기슭막이"]["unit"] == "㎡" - - -def test_기본값으로_선_벽은_줄만_서고_금액_합에는_안_든다() -> None: - """브레인 판정(2026-09-14) — 줄은 서야 채울 자리가 보이고, 금액은 실제 값이 있을 때만.""" - point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}} - table = _table([point]) - assert all(s["unconfirmed"] and s["components"] for s in table["structures"]) - assert table["totals"] == [] # 원단위 합(자재·채집석 밑수)에도 안 듦 - handoff = build_handoff(unit_quantity_table=table) - rows = handoff["work_items"] - walls = [r for r in rows if "기슭막이" in r["name"]] - assert len(walls) == 2 and all( - not r["in_bill"] and r["blocked_kind"] == "unconfirmed" for r in walls - ) - assert walls[0]["quantity"] > 0 # 수량은 보임 - # 터파기·되메우기·기초잡석·버림 타설이 금액 줄로 번지지 않음 - assert not [r for r in rows if r["in_bill"] and r["quantity"] > 0] - - from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill - - bill = build_bill(handoff) - placed = [r for r in bill.rows if not r.is_group and "기슭막이" in r.name] - assert len(placed) == 2 and all(r.unconfirmed == 1 and r.amount_krw is None for r in placed) - summary = bill_summary(bill) - assert summary["unpriced_count"] == 2 and summary["body_total_krw"] == "0" - - # 치수를 다 적으면 미확정이 풀림 - filled = { - f"{side}_revet_{key}": value - for side in ("inlet", "outlet") - for key, value in (("form", "돌쌓기(메)"), ("height_m", 1.5), ("length_m", 6)) - } - confirmed = _table([{**point, "options": {**point["options"], **filled}}]) - assert not any(s["unconfirmed"] for s in confirmed["structures"]) - - -def test_유입구가_집수정이면_집수정_줄이_사유로_서고_유입_기슭막이는_없다() -> None: - table = _table([{"chainage_m": 5.0, "options": {"inlet_type": "집수정"}}]) - basin, outlet = table["structures"] - assert basin["type_id"] == "pipe_inlet_basin" and not basin["components"] and basin["notes"] - assert outlet["name"] == "배수관 · 유출부 기슭막이" - - -def test_유입구가_기슭막이인데_집수정_형식이_남아_있으면_집수정은_안_세고_알린다() -> None: - options = {"inlet_type": "기슭막이", "inlet_basin_form": "□형(기본형)"} - inlet = _table([{"chainage_m": 5.0, "options": options}])["structures"][0] - assert inlet["type_id"] == "revetment" and "집수정은 안 셈" in inlet["notes"][0] - - -def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르면_사유() -> None: - same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌", "height_m": 2.0}} - rows = _table([same])["structures"] - assert [r["name"] for r in rows] == ["기슭막이 · 좌 벽"] and rows[0]["components"] - differ = {**same, "options": {"side": "우", "inlet_revet_height_m": 3.0}} - row = _table([differ])["structures"][0] - assert not row["components"] and "서버가 못 가림" in row["notes"][-1] - - -def test_독립_기슭막이_높이는_설계자_입력_없으면_미확정과_사방_근거() -> None: - """2026-09-14 브레인 판정 — 높이 = 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181).""" - point = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌"}} - (row,) = _table([point])["structures"] - assert "높이를 안 적음" in row["unconfirmed"] and "2-나:141" in row["unconfirmed"] - assert any("사방(계류) 교본 기준" in note and "3-가:151" in note for note in row["notes"]) - with_height = {**point, "options": {"side": "좌", "height_m": 2.0, "form": "돌쌓기(메)"}} - (row,) = _table([with_height])["structures"] - assert "높이를 안 적음" not in row["unconfirmed"] - - -def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None: - table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}]) - # 2026-09-15 브레인 판정 ⑥ — 「아직 없음」이 아니라 「근거 없음」으로 닫음. - assert "세월교 수량 근거 없음" in table["structures"][0]["notes"][0] - rows = build_handoff(unit_quantity_table=table)["work_items"] - assert not any("터파기가 안 선 구조물" in str(row.get("spec_detail")) for row in rows) - - -def test_구조물_목록의_관_지점_종류_옛_저장분은_안_셈(monkeypatch, tmp_path) -> None: - old = StructureInstance( - structure_id="old", type_id="revetment", placement="point", chainage_m=10.0 - ) - monkeypatch.setattr(material, "load_structures", lambda root: (1, [old])) - targets, _names, skipped = material._collect_structures(str(tmp_path)) - assert targets == [] and any("관 지점 정본이 주인" in note for note in skipped) diff --git a/resources/tester/test_b08_fixed_sheet_notes.py b/resources/tester/test_b08_fixed_sheet_notes.py deleted file mode 100644 index 07ffa0034..000000000 --- a/resources/tester/test_b08_fixed_sheet_notes.py +++ /dev/null @@ -1,96 +0,0 @@ -"""㉰ 고정형 장 사유 · ㉱ 규격 다름 (2026-09-14 브레인 판정). - -㉰ 고정형을 가져온 장에 **전개 사유(뒷길이 표준·돌 종류…)가 그대로 남아** 고정형 줄과 안 맞음 = 거짓 사유 → - 항목 사유(원문 시점 수량·가산 행)를 띄우고 전개 사유는 **남긴 전개 줄(토공·버림·기초잡석·채집석)에 걸린 것만** 둠. -㉱ H=2.0 호표를 H=2.5 벽에 쓰면 벽 수량은 박힌 값(H2.0) · 토공·사토 공제는 장 제원(H2.5) — 밑수가 갈림 → - 미확정과 같은 급으로 보임(빨간 테두리·「규격 다름」 배지) · 금액은 섬 · 막지 않음. -""" - -from __future__ import annotations - -from pathlib import Path - -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance -from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item -from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - -ROOT = Path(__file__).resolve().parents[2] -HOPYO = { - "no": 6, - "source_code": "B00010", - "name": "기슭막이(깬잡석,찰쌓기 L3=45m)", - "spec": "H=2.0m, 채집", - "unit": "M", - "basis": [], - "contract_rows": 0, - "rows": [ - { - "name": "깬잡석찰쌓기", - "spec": "L3=45", - "amount": 2.09, - "unit": "M2", - "remark": "", - "source_code": "", - }, - { - "name": "공구손료", - "spec": "노무비의 %", - "amount": 2, - "unit": "%", - "remark": "", - "source_code": "", - }, - ], -} -NAMES = {"masonry_wet": "돌쌓기(찰)"} - - -def _sheet(height: float) -> dict: - wall = StructureInstance.model_validate( - { - "structure_id": "a", - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": height, "foundation": "기초유"}, - } - ).model_dump() - templates = { - "masonry_wet": { - **recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"), - "code": "AX-ST-0000abcd", - } - } - table = build_table([wall], NAMES, {}, {}, None, structure_templates=templates) - payload = build_standard_sheets(table, {}) - apply_templates(payload, {}, templates) - return payload["sheets"][0] - - -def test_고정형_장은_항목_사유를_띄우고_남의_사유를_걷는다() -> None: - notes = " / ".join(_sheet(2.0)["notes"]) - assert "원문 시점 수량" in notes and "가산 행 1줄" in notes - assert "돌 종류를 안 골라" not in notes and "뒷채움 폭" not in notes - - -def test_규격이_다르면_미확정_급으로_밑수_갈림을_알린다() -> None: - sheet = _sheet(2.5) - mismatch = sheet["spec_mismatch"] - assert (mismatch["item_height_m"], mismatch["sheet_height_m"]) == (2.0, 2.5) - assert "규격 다름" in sheet["notes"][0] - assert "토공·사토 공제는 장 제원(H=2.5m)" in sheet["notes"][0] - assert any(row["amount"] for row in sheet["rows"]) # 금액·수량은 막지 않음 - - -def test_규격이_같으면_아무것도_안_뜬다() -> None: - sheet = _sheet(2.0) - assert not sheet.get("spec_mismatch") - assert not any("규격 다름" in note for note in sheet["notes"]) - - -def test_화면이_규격_다름을_빨간_배지로() -> None: - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(encoding="utf-8") - assert "spec_mismatch" in ui and "규격 다름" in ui and "b08-unit__badge" in ui diff --git a/resources/tester/test_b08_formula.py b/resources/tester/test_b08_formula.py deleted file mode 100644 index 734d89bd0..000000000 --- a/resources/tester/test_b08_formula.py +++ /dev/null @@ -1,322 +0,0 @@ -"""구조물도 식 풀이기 — 명세 13장 언어·반올림·참조를 **서버 Node 경로 그대로** 시험 (2026-09-13). - -풀이는 `B08_Quantity_Formula.ts` 한 벌이고(판정 Ⓐ), 서버는 그것을 Node 로 돌림. -그래서 이 시험도 파이썬 껍데기(`evaluate_sheets`) → Node 번들 길로 부름 — 화면과 서버가 -같은 코드를 쓰는지까지 한 번에 잼. - -⭐ 기준값 하나는 **실무 엑셀 캐시값** — 소광리 `07-구조도-소광리.xlsx` 「돌기슭막이(45)찰-고1.0」 - U20~U31 11줄(2026-09-13 openpyxl 로 읽음). 식을 셀 그대로 옮겨 같은 값이 나오는지 봄. -⚠ 부동소수로 풀면 틀리는 자리를 일부러 넣음 — `ROUND(0.95*0.3,2)`(부동소수 0.28499… → 0.28)· - `0.29*100` 버림(28.999… → 28). 분수로 풀어야 엑셀·실무 값과 맞음. -""" - -from __future__ import annotations - -import shutil -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402 - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - - -def _solve(rows: list[dict], vars: dict | None = None, tables: dict | None = None) -> list[dict]: - # 갈 곳은 계약상 필수(기본값 없음) — 값 시험에서는 「보여주기만」으로 채워 넣음. - rows = [{"destination": "reference", **row} for row in rows] - result = evaluate_sheets([{"rows": rows, "vars": vars or {}, "tables": tables or {}}]) - assert result is not None, "Node 풀이가 안 돌았다" - return result[0] - - -def _one(formula: str, rounding: dict | None = None, **vars) -> dict: - return _solve([{"seq": 1, "name": "x", "formula": formula, "rounding": rounding}], vars)[0] - - -def _amount(formula: str, rounding: dict | None = None, **vars) -> str: - row = _one(formula, rounding, **vars) - assert row["error"] is None, row - return row["amount"] - - -def test_적힌_차례대로_풂() -> None: - """명세 13장 지킬 것 ① — 접지 않음. 적힌 상수는 적힌 대로.""" - assert _amount("267360*5/24") == "55700" - assert _amount("267360*0.20833") == "55699.1088" - - -def test_우선순위는_엑셀과_같다() -> None: - assert _amount("2+3*4") == "14" - assert _amount("10-2-3") == "5" - assert _amount("8/2/2") == "2" - assert _amount("2^3^2") == "64" # 엑셀은 왼쪽부터 - assert _amount("-2^2") == "4" # 엑셀은 부호가 ^ 보다 먼저 - assert _amount("(1+2)*3") == "9" - - -def test_부동소수_함정을_안_밟는다() -> None: - assert _amount("0.29*100", {"mode": "floor", "digits": 0}) == "29" - assert _amount("1.15*100", {"mode": "floor", "digits": 0}) == "115" - assert _amount("0.95*0.3", {"mode": "round", "digits": 2}) == "0.29" - - -def test_반올림_갈래() -> None: - assert _amount("2.675", {"mode": "round", "digits": 2}) == "2.68" # 사사오입 - assert _amount("-2.5", {"mode": "round", "digits": 0}) == "-3" # 0 에서 먼 쪽(엑셀 ROUND) - assert _amount("2.665", {"mode": "round_half_even", "digits": 2}) == "2.66" - assert _amount("2.675", {"mode": "round_half_even", "digits": 2}) == "2.68" - assert _amount("0.121", {"mode": "ceil", "digits": 2}) == "0.13" - assert _amount("2.61*0.15", {"mode": "floor", "digits": 2}) == "0.39" - assert _amount("1234", {"mode": "round", "digits": -2}) == "1200" - row = _one("1/3", {"mode": "none", "digits": 0}) - assert row["amount"] == "0." + "3" * 30 - - -def test_엑셀_대응표_음수에서_갈리는_넷() -> None: - """명세 13장 대응표 — 양수에서는 같아 시험을 빠져나가던 자리(브레인 검산).""" - 갈래 = {"floor": "-3", "trunc": "-2", "ceil_away": "-3", "ceil": "-2", "round": "-3"} - for mode, expected in 갈래.items(): - assert _amount("-2.5", {"mode": mode, "digits": 0}) == expected, mode - assert _amount("-2.5", {"mode": "round_half_even", "digits": 0}) == "-2" - # 양수에서는 둘씩 같음 — 그래서 음수를 따로 봄. - assert _amount("2.5", {"mode": "trunc", "digits": 0}) == "2" - assert _amount("2.5", {"mode": "ceil_away", "digits": 0}) == "3" - assert "모르는 반올림" in _one("1", {"mode": "banker", "digits": 0})["error"] - - -def test_함수() -> None: - assert _amount("2*1*SQRT(1+0.3^2)", {"mode": "round", "digits": 3}) == "2.088" - assert _amount("SQRT(2.25)") == "1.5" - assert _amount("SUM(1,2,3.5)") == "6.5" - assert _amount("MIN(3,1,2)") == "1" - assert _amount("MAX(3,1,2)") == "3" - - -def test_IF_와_비교() -> None: - 식 = "IF(L3=35,0.12,IF(L3=45,0.15,0.18))" - assert _amount(식, L3=45) == "0.15" - assert _amount(식, L3=55) == "0.18" - assert _amount("IF(돌종류='야면석',0.15,0.2)", 돌종류="야면석") == "0.15" - assert _amount("IF(L3<>45,1,2)", L3=45) == "2" - assert _amount("IF(L3>=45,1,2)", L3=45) == "1" - # 안 고른 갈래는 풀지 않음 — 0 으로 나누는 갈래가 줄을 막지 않게. - assert _amount("IF(1=1,5,1/0)") == "5" - - -뒷길이표 = { - "뒷길이표": { - "keys": [35, 45, 55, 60], - "columns": {"고임돌": [0.12, 0.15, 0.18, 0.2], "빈칸": [0.1, None, 0.2, 0.3]}, - } -} - - -def _lookup(formula: str, **vars) -> dict: - return _solve([{"seq": 1, "name": "x", "formula": formula}], vars, 뒷길이표)[0] - - -def test_LOOKUP_은_근사_일치가_기본() -> None: - """엑셀 `VLOOKUP(…,1)` — 키 이하 중 가장 큰 줄(실무 13건 전부 근사).""" - assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=45)["amount"] == "0.15" - assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=50)["amount"] == "0.15" - assert _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=100)["amount"] == "0.2" - assert "못 찾음" in _lookup("LOOKUP(뒷길이표, L3, '고임돌')", L3=30)["error"] - - -def test_LOOKUP_EXACT_와_빈칸() -> None: - assert _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=55)["amount"] == "0.18" - assert "못 찾음" in _lookup("LOOKUP_EXACT(뒷길이표, L3, '고임돌')", L3=50)["error"] - # 원문 「-」 칸은 값이 아니라 오류 — 0 으로 때우지 않음. - assert "비어 있음" in _lookup("LOOKUP(뒷길이표, L3, '빈칸')", L3=45)["error"] - assert "없는 열" in _lookup("LOOKUP(뒷길이표, L3, '없는열')", L3=45)["error"] - - -def test_표_키가_오름차순이_아니면_오류() -> None: - tables = {"표": {"keys": [45, 35], "columns": {"a": [1, 2]}}} - row = _solve([{"seq": 1, "name": "x", "formula": "LOOKUP(표, 40, 'a')"}], {}, tables)[0] - assert "오름차순" in row["error"] - - -def test_앞_줄만_참조한다() -> None: - rows = [ - {"seq": 1, "name": "면적", "formula": "H*L"}, - {"seq": 2, "name": "고임돌", "formula": "AREA*0.15", "refs": {"AREA": 1}}, - {"seq": 3, "name": "뒷줄", "formula": "X*2", "refs": {"X": 4}}, - {"seq": 4, "name": "자기", "formula": "S*2", "refs": {"S": 4}}, - ] - result = _solve(rows, {"H": 2.5, "L": 10}) - assert [row["amount"] for row in result[:2]] == ["25", "3.75"] - assert "앞 줄만" in result[2]["error"] - assert "앞 줄만" in result[3]["error"] - - -def test_오류_난_줄을_가리키면_막힌다() -> None: - rows = [ - {"seq": 1, "name": "나눔", "formula": "1/0"}, - {"seq": 2, "name": "뒤", "formula": "A+1", "refs": {"A": 1}}, - ] - first, second = _solve(rows) - assert "0 으로 나눔" in first["error"] and first["amount"] is None - assert "앞 줄 1" in second["error"] and second["amount"] is None - - -def test_참조는_반올림_뒤_값을_받는다() -> None: - """엑셀 셀 참조와 같음 — `INT(…)` 가 든 셀을 가리키면 버린 값이 옴.""" - rows = [ - {"seq": 1, "name": "a", "formula": "0.456", "rounding": {"mode": "floor", "digits": 2}}, - {"seq": 2, "name": "b", "formula": "A*100", "refs": {"A": 1}}, - ] - result = _solve(rows) - assert result[0]["raw"] == "0.456" and result[0]["amount"] == "0.45" - assert result[1]["amount"] == "45" - - -def test_when_거짓이면_줄이_안_서고_가리키면_막힌다() -> None: - """명세 13장 Ⓐ — 「안 섬」은 0 이 아님. 가리키는 뒷줄은 오류, 안 쓰면 영향 없음.""" - rows = [ - {"seq": 1, "name": "버림", "formula": "2", "when": "BLIND<>'안 넣음'"}, - {"seq": 2, "name": "잡석", "formula": "B*2", "refs": {"B": 1}}, - { - "seq": 3, - "name": "잡석_조건", - "formula": "B*2", - "refs": {"B": 1}, - "when": "BLIND<>'안 넣음'", - }, - {"seq": 4, "name": "무관", "formula": "7", "refs": {"B": 1}}, - ] - 넣음 = _solve(rows, {"BLIND": "넣음"}) - assert [row["amount"] for row in 넣음] == ["2", "4", "4", "7"] - 안넣음 = _solve(rows, {"BLIND": "안 넣음"}) - assert 안넣음[0]["skipped"] and 안넣음[0]["amount"] is None and 안넣음[0]["error"] is None - assert "조건이 거짓" in 안넣음[0]["reason"] - assert "앞 줄 1(버림) 안 섬" in 안넣음[1]["error"] - assert 안넣음[2]["skipped"] and 안넣음[2]["error"] is None # 조건이 먼저라 막히지 않음 - assert 안넣음[3]["amount"] == "7" # 이름을 안 쓰면 영향 없음 - - -def test_갈_곳이_빠지면_오류_기본값_없음() -> None: - """명세 13장 Ⓑ ⛔⛔ — 기본값이 빠진 줄을 조용히 사라지게 한 결함이 실재함.""" - rows = [ - {"seq": 1, "name": "빠짐", "formula": "1"}, - {"seq": 2, "name": "모름", "formula": "1", "destination": "somewhere"}, - {"seq": 3, "name": "맞음", "formula": "1", "destination": "material"}, - ] - result = evaluate_sheets([{"rows": rows}])[0] - assert "갈 곳(destination)이 없음" in result[0]["error"] - assert "모르는 갈 곳" in result[1]["error"] - assert result[2]["amount"] == "1" - - -def test_이름은_고정_규격이_제원을_따른다() -> None: - """명세 13장 Ⓒ — 조인 키(이름+규격)가 흔들리지 않게 이름은 고정.""" - rows = [ - {"seq": 1, "name": "돌", "spec": "{돌종류}", "formula": "1"}, - {"seq": 2, "name": "물구멍관", "spec": "Ø{DIA}", "formula": "1"}, - {"seq": 3, "name": "모름", "spec": "{없는칸}", "formula": "1"}, - ] - result = _solve(rows, {"돌종류": "깬돌", "DIA": 50}) - assert (result[0]["name"], result[0]["spec"]) == ("돌", "깬돌") - assert result[1]["spec"] == "Ø50" - assert "규격의 모르는 제원" in result[2]["error"] - assert _solve(rows[:1], {"돌종류": ""})[0]["spec"] == "" # 안 고르면 규격 빔 — 매칭 성공 아님 - - -def test_고정형과_줄_제원() -> None: - rows = [ - {"seq": 1, "name": "박힌값", "amount": "2.088"}, - {"seq": 2, "name": "빈", "formula": ""}, - {"seq": 3, "name": "줄이이김", "formula": "K", "vars": {"K": 7}}, - ] - result = _solve(rows, {"K": 3}) - assert result[0]["amount"] == "2.088" - assert "식도 값도 없음" in result[1]["error"] - assert result[2]["amount"] == "7" - - -def test_모르는_이름_함수_글자는_오류() -> None: - assert "모르는 이름" in _one("constructor")["error"] - assert "모르는 함수" in _one("EVAL(1)")["error"] - assert "읽을 수 없는 글자" in _one("1;2")["error"] - assert "따옴표" in _one("'abc")["error"] - assert ( - "같은 차례" - in _solve( - [{"seq": 1, "name": "a", "formula": "1"}, {"seq": 1, "name": "b", "formula": "2"}] - )[1]["error"] - ) - - -def test_실무_찰쌓기_탭을_셀_그대로_재현() -> None: - """소광리 「돌기슭막이(45)찰-고1.0」 — H=1 · 뒷길이 45 · 1:0.3 · 윗폭 300㎜. - - 셀 식을 이름만 바꿔 옮김(C5→H, D3→L3, J11→N, L4→TOP_MM). 엑셀 캐시값과 같아야 함. - """ - vars = {"H": 1, "L3": 45, "N": 0.3, "TOP_MM": 300} - rows = [ - {"seq": 1, "name": "면적", "formula": "H*1*1.044"}, - {"seq": 2, "name": "중량", "formula": "A*0.91", "refs": {"A": 1}}, - { - "seq": 3, - "name": "체적", - "formula": "((L3*10+TOP_MM)/1000+(L3*10+H*1000*N)/1000)/2*A", - "refs": {"A": 1}, - }, - { - "seq": 4, - "name": "고임돌", - "formula": "A*IF(L3=35,0.12,IF(L3=45,0.15,IF(L3=55,0.18,0)))", - "refs": {"A": 1}, - "rounding": {"mode": "floor", "digits": 2}, - }, - {"seq": 5, "name": "석적", "formula": "H*L3/100*0.77"}, - {"seq": 6, "name": "막자갈", "formula": "(TOP_MM/1000+H*1000*N/1000)/2*H"}, - { - "seq": 7, - "name": "채움콘크리트", - "formula": "A*IF(L3=35,0.16,IF(L3=45,0.2,IF(L3=55,0.25,0)))", - "refs": {"A": 1}, - "rounding": {"mode": "floor", "digits": 2}, - }, - {"seq": 8, "name": "모르터", "formula": "A*0.009", "refs": {"A": 1}}, - {"seq": 9, "name": "물구멍", "formula": "A*1/2.5*0.5", "refs": {"A": 1}}, - { - "seq": 10, - "name": "터파기", - "formula": "((H*1000*N+L3*10)/1000+0.2)*0.3*1", - "rounding": {"mode": "round", "digits": 2}, - }, - { - "seq": 11, - "name": "되메우기", - "formula": "(0.2*0.3+(TOP_MM/1000+0.2)*(TOP_MM/1000+0.2)/2)*1", - }, - {"seq": 12, "name": "잔토정리", "formula": "T-B", "refs": {"T": 10, "B": 11}}, - ] - 엑셀 = { - "면적": 1.044, - "중량": 0.9500400000000001, - "체적": 0.783, - "고임돌": 0.15, - "석적": 0.34650000000000003, - "막자갈": 0.3, - "채움콘크리트": 0.2, - "모르터": 0.009396, - "물구멍": 0.2088, - "터파기": 0.29, - "되메우기": 0.185, - "잔토정리": 0.10499999999999998, - } - result = {row["name"]: row for row in _solve(rows, vars)} - for name, cached in 엑셀.items(): - assert result[name]["error"] is None, result[name] - # 엑셀 캐시는 부동소수 끝자리가 흔들림 — 유효 12자리까지 같으면 같은 값. - assert float(result[name]["amount"]) == pytest.approx(cached, rel=1e-12), name - # 그러나 우리 값은 **흔들림 없는 십진수**다. - assert result["잔토정리"]["amount"] == "0.105" - assert result["석적"]["amount"] == "0.3465" diff --git a/resources/tester/test_b08_formwork.py b/resources/tester/test_b08_formwork.py deleted file mode 100644 index 308923998..000000000 --- a/resources/tester/test_b08_formwork.py +++ /dev/null @@ -1,135 +0,0 @@ -"""거푸집 사용횟수 검사 — 품셈 1-7-1 · 2026-09-07 ⑩. - -⚠ 사용횟수는 **관측값이 아니라 법**이다 — 품셈 1-7-1 이 구조물 종류별로 정해 둔다. -⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다.** 품셈 12-4 의 비율(%)은 일위대가 재료비에 - 걸리는 값이라, B08 이 곱해 넘기면 B09 가 또 곱해 두 번 준다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Formwork import ( # noqa: E402 - FORMWORK_NAMES, - NOTE_REUSE_MISSING, - FormworkTable, - annotate, - load_formwork_table, - shoring_status, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - - -def 옹벽() -> dict: - return { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, - } - - -def test_원문에_이름이_있는_구조물은_횟수가_붙음() -> None: - """품셈 1-7-1 의 3회 줄에 「옹벽」이 그대로 있다.""" - table = build_table([옹벽()], {"retaining_wall": "옹벽"}) - forms = [c for s in table["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES] - assert forms - assert all(c["reuse_count"] == 3 for c in forms) - assert all("1-7-1" in c["reuse_note"] for c in forms) - assert table["formwork_reuse_missing"] == [] - - -def test_횟수별_비율을_여기서_곱하지_않을것() -> None: - """⚠ 이 시험이 이 파일의 핵심 — 합판 3회 46.1 % 를 여기서 곱하면 B09 와 겹쳐 두 번 준다. - B08 이 내는 것은 **접촉 면적 그대로**이고 횟수는 옆에 적기만 한다.""" - table = build_table([옹벽()], {"retaining_wall": "옹벽"}) - euroform = next( - c for s in table["structures"] for c in s["components"] if c["name"] == "유로폼" - ) - # 3.205 ㎡/m × 10m — 46.1 % 를 곱하지 않았다(2026-09-14 원문 값 3.205 · 전 라이브러리 3.20 → 32.0) - assert abs(euroform["amount"] - 32.05) < 1e-9 - # 비율표는 데이터에 있되 값에 안 걸린다. - assert load_formwork_table().reuse_ratio_pct["plywood_material_pct"]["3"] == 46.1 - - -def test_모르는_종류는_지어내지_않고_미확보() -> None: - structures = [ - { - "type_id": "듣도보도못한구조물", - "name": "무엇", - "components": [{"name": "합판거푸집", "unit": "㎡", "amount": 5.0}], - } - ] - notes, missing = annotate(structures) - assert missing == ["듣도보도못한구조물"] - assert structures[0]["components"][0]["reuse_count"] is None - assert structures[0]["components"][0]["reuse_note"] == NOTE_REUSE_MISSING - - -def test_거푸집이_없는_구조물은_건드리지_않음() -> None: - structures = [ - { - "type_id": "retaining_wall", - "components": [{"name": "콘크리트", "unit": "㎥", "amount": 1.0}], - } - ] - notes, missing = annotate(structures) - assert notes == [] and missing == [] - assert "reuse_count" not in structures[0]["components"][0] - - -def test_이름은_정확히_일치로만_본다() -> None: - """부분일치면 「거푸집씻기」(공사용수 항목)가 거푸집으로 잡힌다.""" - structures = [ - { - "type_id": "retaining_wall", - "components": [{"name": "거푸집씻기", "unit": "㎥", "amount": 1.0}], - } - ] - annotate(structures) - assert "reuse_count" not in structures[0]["components"][0] - - -def test_파일이_없으면_전부_미확보() -> None: - structures = [ - { - "type_id": "retaining_wall", - "components": [{"name": "유로폼", "unit": "㎡", "amount": 1.0}], - } - ] - notes, missing = annotate(structures, FormworkTable()) - assert missing == ["retaining_wall"] - - -# ── 동바리 ────────────────────────────────────────────────────────── - - -def test_동바리는_대상이_없으면_없다고_말할것() -> None: - """0 으로 적으면 「대상이 없음」과 「값이 0」이 구별되지 않는다.""" - status = shoring_status() - assert status["applicable"] is False - assert status["reason"] - # BOX암거는 2026-09-15 전개식이 서서 대상에서 빠짐 — 세월교만 남음(근거 없음 사유). - assert set(status["pending_types"]) == {"ford_bridge"} - - -def test_표에도_동바리_상태가_실림() -> None: - assert build_table([옹벽()])["shoring"]["applicable"] is False - box = { - "type_id": "box_culvert", - "options": { - "body_width_m": 2.0, - "body_height_m": 2.0, - "wall_thickness_m": 0.3, - "slab_thickness_m": 0.3, - "haunch_m": 0.2, - "blinding_thickness_m": 0.1, - "length_m": 5.0, - }, - } - assert build_table([box])["shoring"]["applicable"] is True diff --git a/resources/tester/test_b08_formwork_reuse_link.py b/resources/tester/test_b08_formwork_reuse_link.py deleted file mode 100644 index 0e6a4d3c3..000000000 --- a/resources/tester/test_b08_formwork_reuse_link.py +++ /dev/null @@ -1,45 +0,0 @@ -"""거푸집 사용횟수 → 12-4 갈래 잇기 · 집수정 4회 — 2026-09-14 브레인 ㉯ ①②. - -① 옹벽 조립 조각 「FP-12-04 합판거푸집」 에 꼬리가 없어 12-4 사용횟수 갈래(#1~6회)를 못 고름 → - B08 이 성분에 단 사용횟수(`reuse_count`, 품셈 1-7-1 옹벽 3회)를 꼬리로 붙임(값은 한 벌). -② 집수정은 **12-15 표가 직접 적은 「거푸집 합판4회」 가 정본** — 1-7-1 분류로 본 6회는 걷음. - 화면(자재 표 · 인계)과 표가 같은 값이어야 함(한 벌이 두 값으로 갈리지 않게). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Formwork import FORMWORK_NAMES # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - -WALL = {"structure_id": "w1", "type_id": "retaining_wall", "start_m": 100.0, "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}} # fmt: skip -BASIN = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0, - "options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트", - "pipe_diameter_mm": "800"}} # fmt: skip - - -def test_옹벽_조각_합판거푸집이_사용횟수_갈래로_이어져_단가가_섬() -> None: - from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - unit = build_table([WALL], {"retaining_wall": "옹벽"}) - row = next( - r for r in build_handoff(unit_quantity_table=unit)["work_items"] if r["composite_parts"] - ) - part = next(p for p in row["composite_parts"] if str(p["code"]).startswith("FP-12-04")) - assert part["code"] == "FP-12-04#3회" and not part.get("not_ready"), part - assert "B-FP-12-04#3회" in cached_build().book.titles - - -def test_집수정_거푸집은_12_15_표의_4회_화면과_인계가_같은_값() -> None: - unit = build_table([BASIN], {"pipe_inlet_basin": "집수정"}) - forms = [c for s in unit["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES] - assert forms and all(c["reuse_count"] == 4 for c in forms), forms - assert all("12-15" in c["reuse_note"] for c in forms), forms diff --git a/resources/tester/test_b08_frame_material.py b/resources/tester/test_b08_frame_material.py deleted file mode 100644 index b28fe2376..000000000 --- a/resources/tester/test_b08_frame_material.py +++ /dev/null @@ -1,114 +0,0 @@ -"""규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리** (2026-09-09). - -품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 -「안 센다」가 아니다. 근주이식·임목파쇄(**셀지 말지가 설계 판단**)와 성격이 다르다. -그래서 **제안값을 보이고 고칠 수 있게** 둔다 — 확정 ⑨·⑩ 과 같은 틀. - -⚠ 겨누는 것 여섯 - ① 개소가 서면 재료도 선다 - ② ⚠ 개소가 안 서면 재료도 안 선다 — 밑수가 그 줄이다 - ③ 값은 **제안값**이고 산출 조건이 이긴다 - ④ ⚠ 제안값이 **실무 관측값이지 법정 기준이 아님**이 사유에 적힌다 - ⑤ 손율은 원문값이 함께 적힌다(비탈 50% · 수평 80%) - ⑥ 자재 축으로 간다(할증은 자재총괄에서 한 번만) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 - FRAME_MATERIAL_SUGGESTED, - frame_material_rows, -) - -규준틀 = [ - {"item": "비탈 규준틀", "unit": "개소", "amount": 28.0}, - {"item": "수평 규준틀", "unit": "개소", "amount": 33.0}, -] - - -def 이름별(rows: list[dict]) -> dict: - return {(row["source"], f"{row['name']} {row.get('spec', '')}".strip()): row for row in rows} - - -def test_이름과_규격을_가른다() -> None: - """이름 칸에 규격을 섞으면 할증률표(각재 5 · 판재 10)와 안 맞음 — 관급구분 키는 그대로.""" - from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table - - rows = frame_material_rows(규준틀) - assert {(row["name"], row["spec"]) for row in rows} == { - ("각재", "50×50"), - ("판재", "T12"), - ("못", ""), - } - table = build_table({"structures": []}, extra_materials=rows) - rates = {row["supply_key"]: row["surcharge_pct"] for row in table["rows"]} - assert rates == {"각재 50×50": 5, "판재 T12": 10, "못": None} - - -def test_개소가_서면_재료도_선다() -> None: - rows = 이름별(frame_material_rows(규준틀)) - assert abs(rows[("비탈 규준틀", "각재 50×50")]["amount"] - 28.0 * 0.0044) < 1e-9 - assert abs(rows[("수평 규준틀", "못")]["amount"] - 33.0 * 0.03) < 1e-9 - - -def test_개소가_안_서면_재료도_안_선다() -> None: - """⚠ 밑수가 개소 줄이다 — 개소가 없으면 재료를 지어내지 않는다.""" - assert frame_material_rows([{"item": "비탈 규준틀", "amount": None}]) == [] - - -def test_산출_조건이_이긴다() -> None: - rows = 이름별(frame_material_rows(규준틀, {"못": 0.05})) - assert abs(rows[("비탈 규준틀", "못")]["amount"] - 28.0 * 0.05) < 1e-9 - assert "고른 값" in rows[("비탈 규준틀", "못")]["basis"] - - -def test_관측값이지_법정_기준이_아님이_적힌다() -> None: - """⚠ 값만 박고 근거를 안 보이면 「대신 페이지에 남길 것」 지시를 어기는 것이다.""" - basis = 이름별(frame_material_rows(규준틀))[("비탈 규준틀", "각재 50×50")]["basis"] - assert "실무 관측값" in basis and "법정 기준 아님" in basis - assert "설계수량에 따른다" in basis - - -def test_손율이_원문값으로_적힌다() -> None: - rows = 이름별(frame_material_rows(규준틀)) - assert "손율 50%" in rows[("비탈 규준틀", "판재 T12")]["basis"] - assert "손율 80%" in rows[("수평 규준틀", "판재 T12")]["basis"] - - -def test_자재_축으로_간다() -> None: - assert all(row["destination"] == "material" for row in frame_material_rows(규준틀)) - - -def test_화면과_인계가_같은_한_벌을_부른다() -> None: - """자재총괄 화면(6줄)·인계(9줄)가 갈리던 자리 — 규준틀 재료를 한 곳에서 얹음(2026-09-14). - - ⚠ 두 라우터가 이 함수를 부르는지까지 본다 — 한쪽이 옛 길로 돌아가면 다시 갈림. - """ - import inspect - - from B08_Quantity import B08_Quantity_Router_Material as router - - table = router.material_table_for( - {"structures": []}, - {"material_supply": {"각재 50×50": "owner_supplied"}}, - {"rows": 규준틀}, - ) - keys = {row["supply_key"]: row for row in table["rows"]} - assert set(keys) == {"각재 50×50", "판재 T12", "못"} - assert keys["각재 50×50"]["supply"] == "owner_supplied" # 화면에서 고른 값이 먹음 - for route in (router.get_material_summary, router.get_handoff): - source = inspect.getsource(route) - assert "material_table_for(" in source and "build_material_table(" not in source - - -def test_제안값이_실무_관측값과_같다() -> None: - """울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당).""" - assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044 - assert FRAME_MATERIAL_SUGGESTED["판재 T12"][0] == 0.0029 - assert FRAME_MATERIAL_SUGGESTED["못"][0] == 0.03 diff --git a/resources/tester/test_b08_gravel_surfacing.py b/resources/tester/test_b08_gravel_surfacing.py deleted file mode 100644 index 9acb7e73b..000000000 --- a/resources/tester/test_b08_gravel_surfacing.py +++ /dev/null @@ -1,133 +0,0 @@ -"""혼합석 부설 — 법령 조건 자동 · 연약·습윤 칸 · 「비포장 전 구간」 고르개 (2026-09-15 브레인 ⓑ). - -근거 - 법령 별표2 Ⅰ.2.바.(2) — 「종단기울기가 8퍼센트를 초과하는 사질토양 또는 점토질 토양인 구간과 - 종단기울기가 8퍼센트 이하인 구간으로서 지반이 약하고 습한 구간에는 쇄석·자갈을 부설」 - 임도기술교본 3-2 — 「콘크리트 포장 구간 이외에는 혼합석 부설 다짐 후 0.10m 내외」(고르개) - 임도기술교본 10-1 · 부록 7-2 — 포설 전 10㎝ 터파기 · 적용범위 「노면의 토사구간」 - 실무 소광 — 혼합석 C 0.85 · L 1.25 (원문 체적환산표에 혼합석 줄 없음 · - 역(礫) L 1.10~1.20 · C 1.05~1.10) -판정 — 토사 = 사질·점토 해당 · 암 = 비해당 · 폭 = 차도 표준 폭 + 확폭(노견 제외). -""" - -from __future__ import annotations - -import pytest - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, build_rows -from B08_Quantity.B08_Quantity_Engine_GravelSurfacing import gravel_material_rows, gravel_surfacing -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff - - -def _design(ground: str = "soil", widening: float = 0.0, paved: bool = False) -> dict: - return { - "ground_type": ground, - "carriageway_standard_width_m": 3.0, - "widening_left_m": widening, - "paved": paved, - } - - -DESIGNS = [ - {"chainage_m": 0.0, "design": _design()}, - {"chainage_m": 20.0, "design": _design()}, - {"chainage_m": 40.0, "design": _design(widening=1.0)}, - {"chainage_m": 60.0, "design": _design(paved=True)}, - {"chainage_m": 80.0, "design": _design(ground="ripping_rock")}, -] -GRADES = {0.0: 10.0, 20.0: 10.0, 40.0: 5.0, 60.0: 12.0, 80.0: 12.0} - - -def test_법령_조건만_자동_토사_8퍼센트_초과() -> None: - result = gravel_surfacing(DESIGNS, GRADES, {}) - # 0·20 만 해당 — [0,20] (3+3)/2×20 + [20,40] (3+0)/2×20 - assert result["area_m2"] == pytest.approx(90.0) - assert result["thickness_m"] == pytest.approx(0.10) - assert result["loose_m3"] == pytest.approx(90.0 * 0.10 * 1.25 / 0.85) - assert result["excavation_soil_m3"] == pytest.approx(9.0) - text = " ".join(result["notes"]) - assert "별표2" in text and "토사 전부를 해당으로 봄" in text - # 토사/암은 B06 토사 토글이 정본 — 리핑암은 「암」의 자리표시(브레인 2026-09-15 ① 정정). - assert "토사 = B06 측점 토사 토글(구성비는 암 몫만 가름)" in text - assert "원문 표에 혼합석 줄 없음" in text and "방향이 반대" in text - assert "다짐" in text - - -def test_연약_습윤_구간_칸이_8퍼센트_이하_측점을_더한다() -> None: - result = gravel_surfacing( - DESIGNS, GRADES, {"gravel_soft_wet_ranges": [{"from_m": 30, "to_m": 50}]} - ) - # 40 추가(경사 5 · 확폭 1) — [20,40] (3+4)/2×20 + [40,60] (4+0)/2×20 - assert result["area_m2"] == pytest.approx(60.0 + 70.0 + 40.0) - - -def test_비포장_전_구간_고르개는_포장만_뺀다_암은_사전터파기_안_셈() -> None: - result = gravel_surfacing(DESIGNS, GRADES, {"gravel_all_unpaved": True}) - assert result["area_m2"] == pytest.approx(60.0 + 70.0 + 40.0 + 30.0) - # 암 측점(80)은 교본 부록 7-2 「토사구간」 밖 — 사전 터파기 밑수에서 빠짐 - assert result["excavation_soil_m3"] == pytest.approx(170.0 * 0.10) - assert any("교본 3-2" in note for note in result["notes"]) - assert any("토사구간" in note for note in result["notes"]) - assert any( - "산림품셈에 노면 사전 터파기 공종 없음 — 9-3-2 토사깍기(기계)로 이음" in note - for note in result["notes"] - ) - - -def test_칸이_두께_C_L_을_덮어쓴다() -> None: - settings = {"gravel_thickness_m": 0.2, "gravel_conversion_c": 1.0, "gravel_conversion_l": 1.2} - result = gravel_surfacing(DESIGNS, GRADES, settings) - assert result["loose_m3"] == pytest.approx(90.0 * 0.2 * 1.2 / 1.0) - - -def test_토공집계_두_줄과_인계_자재() -> None: - result = gravel_surfacing(DESIGNS, GRADES, {}) - rows = {row.group: row for row in build_rows(SummaryInput(gravel=result))} - assert rows["혼합석부설"].amount == pytest.approx(result["loose_m3"]) - assert "토사" in rows["혼합석 사전터파기"].spec - assert rows["혼합석 사전터파기"].amount == pytest.approx(9.0) - materials = gravel_material_rows(result) - assert materials == [ - { - "name": "혼합석", - "spec": "", - "unit": "㎥", - "amount": pytest.approx(result["loose_m3"]), - "destination": "material", - "source": "혼합석 부설", - } - ] - - -def test_산출_조건_저장이_구간을_거르고_자재총괄에_혼합석이_선다() -> None: - from B08_Quantity.B08_Quantity_Router_Earthwork_Settings import ( - NULLABLE_SETTING_KEYS, - clean_setting_values, - ) - from B08_Quantity.B08_Quantity_Router_Material import material_table_for - - values = {"gravel_soft_wet_ranges": [{"from_m": 50, "to_m": 30}, {"from_m": 1}, "x"]} - assert clean_setting_values(values) is None - assert values["gravel_soft_wet_ranges"] == [{"from_m": 30, "to_m": 50}] - assert {"gravel_thickness_m", "gravel_conversion_c", "gravel_conversion_l"} <= set( - NULLABLE_SETTING_KEYS - ) - gravel = gravel_surfacing(DESIGNS, GRADES, {}) - rows = material_table_for({"rows": []}, {}, None, gravel)["rows"] - assert [row for row in rows if row["name"] == "혼합석"] - - -def test_인계_코드() -> None: - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table - - result = gravel_surfacing(DESIGNS, GRADES, {}) - items = build_handoff(summary_table=build_table(SummaryInput(gravel=result)))["work_items"] - codes = {row["work_item_code"]: row for row in items if "혼합석" in str(row["name"])} - assert codes["FP-11-04"]["quantity"] == pytest.approx(result["loose_m3"]) - assert codes["FP-09-03-02"]["quantity"] == pytest.approx(9.0) - assert codes["FP-11-04"]["in_bill"] and codes["FP-09-03-02"]["in_bill"] - # 해당 측점이 없으면(모두 암) 0 원 줄을 안 세움 — 936be972 실측에서 잡음. - rock = [{**d, "design": {**d["design"], "ground_type": "ripping_rock"}} for d in DESIGNS] - empty = gravel_surfacing(rock, GRADES, {}) - zero = build_handoff(summary_table=build_table(SummaryInput(gravel=empty)))["work_items"] - assert not [r for r in zero if "혼합석" in str(r["name"]) and r["in_bill"]] diff --git a/resources/tester/test_b08_ground_alias.py b/resources/tester/test_b08_ground_alias.py deleted file mode 100644 index 4a0615a9c..000000000 --- a/resources/tester/test_b08_ground_alias.py +++ /dev/null @@ -1,40 +0,0 @@ -"""갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」 (2026-09-08). - -⚠ 겨누는 것 넷 - ① 인계본에 별칭이 실린다 — 받는 쪽이 이름만 달라 못 붙이던 자리(도자 운반 ≒ 30만원) - ② ⚠ 갈래 이름 **자체는 안 바뀐다** — 흙깎기(FP-09-04)가 「리핑암」으로 서 있다 - ③ 근거가 데이터에 적혀 있다(품셈 10-11 f 「파쇄암 1/1.35」 = 10-12 [주]③ 「암절취 1.35」) - ④ 별칭 표가 비어도 인계본은 그 칸을 갖는다(빈 칸과 없는 칸을 가른다) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping # noqa: E402 - - -def test_인계본에_별칭이_실린다() -> None: - aliases = build_handoff()["ground_class_aliases"] - assert "파쇄암" in aliases["리핑암"]["names"] - - -def test_갈래_이름_자체는_안_바뀐다() -> None: - """⚠ 이름을 갈면 흙깎기 매핑(FP-09-04 리핑암)이 어긋난다 — 잇기만 한다.""" - mapping = load_mapping() - assert mapping.for_earthwork("흙깎기", "리핑암")["work_item_code"] == "FP-09-04" - assert mapping.for_earthwork("흙깎기", "파쇄암") is None - - -def test_근거가_데이터에_적혀_있다() -> None: - basis = load_mapping().ground_aliases["aliases"]["리핑암"]["basis"] - assert "10-11" in basis and "10-12" in basis and "1.35" in basis - - -def test_표가_비어도_칸은_있다() -> None: - assert build_handoff(mapping=WorkItemMapping())["ground_class_aliases"] == {} diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py deleted file mode 100644 index 08970e28d..000000000 --- a/resources/tester/test_b08_handoff.py +++ /dev/null @@ -1,1415 +0,0 @@ -"""B08 → B09 인계 검사 — PLAN 8-2 · 2026-09-07 3자 조율 계약. - -이 일감의 위험은 **축을 섞는 것**이다. - · 자재 줄에 공종코드가 붙으면 자재가 내역 줄로 오해된다 — 그게 곧 이중계상이다. - · `in_bill` 을 안 실으면 무대(㉡)가 ④예산내역서에 서서 운반비가 두 번 붙는다. - · 못 이은 줄을 빈 코드로 두면 「없어진 줄」과 구별이 안 된다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import ( # noqa: E402 - WorkItemMapping, - build_handoff, - iter_bill_rows, - load_mapping, - summarize, - verify_bill_flags, - verify_no_code_on_materials, -) -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 - SUPPLY_OWNER, - build_table as build_material_table, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - build_table as build_unit_table, -) - - -def 집계표(*rows: dict) -> dict: - return {"rows": list(rows)} - - -def 집계줄(group: str, item: str = "", amount: float = 1.0, **extra) -> dict: - return {"group": group, "item": item, "spec": "", "unit": "㎥", "amount": amount, **extra} - - -def 구조물() -> dict: - return { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 35.0, - "end_m": 45.0, - "options": {"height_m": 1.5, "length_m": 10.0}, - } - - -def 줄(handoff: dict, name: str) -> dict: - return next(row for row in handoff["work_items"] if row["name"] == name) - - -# ── 축 둘을 섞지 않는다 ───────────────────────────────────────────── - - -def test_자재_줄에는_공종코드가_없음() -> None: - """자재에 공종코드가 붙으면 내역 줄로 오해될 자리가 생긴다(8-2 이중계상 함정).""" - unit = build_unit_table([구조물()]) - handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) - assert handoff["materials"] - assert all("work_item_code" not in row for row in handoff["materials"]) - assert verify_no_code_on_materials(handoff) == [] - - -def test_섞이면_검사가_잡을것() -> None: - """규칙이 주석에만 있으면 지켜지지 않는다 — 오염된 모양을 만들어 검사를 시험한다.""" - tainted = {"materials": [{"material_name": "야면석", "work_item_code": "FP-13-04-05"}]} - assert verify_no_code_on_materials(tainted) == ["야면석"] - - -def test_구조물은_한_줄로_서고_전개_성분은_안_옴() -> None: - """내역 줄의 실체는 `돌쌓기(찰) H=1.5·10m` 이지 그 전개인 야면석이 아니다.""" - handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) - names = [row["name"] for row in handoff["work_items"]] - # ⚠ 2026-09-09 감사 — 종전에는 레지스트리 이름이 없으면 **코드값(`masonry_wet`)이 - # 그대로 내역에 떴다.** `_Wording.type_label` 이 이미 대비표를 들고 있어 그것을 쓰게 - # 고쳤다(옹벽이 `retaining_wall` 로 뜨던 자리에서 잡음). - assert "돌쌓기(찰)" in names - assert not any(name.isascii() and "_" in name for name in names), names - assert "야면석" not in names - assert "터파기" not in names - # ⚠ 돌쌓기 뒤채움(채움콘크리트)은 **타설 줄로 안 선다** — 그 공종의 품에 이미 - # 들어 있을 수 있어 세우면 이중계상이다(2026-09-08 ㉙). - # ⚠ **버림 콘크리트는 다름**(2026-09-09 확정 ⑭) — 기초 바닥에 따로 치는 것이라 - # 실무 내역도 「레미콘타설(장비) 무근,**버림**」을 별도 줄로 세운다. 그래서 여기서는 - # 타설 줄이 **하나 선다**. 그 줄이 무엇 때문에 섰는지까지 확인한다. - placing = [row for row in handoff["work_items"] if row["name"] == "콘크리트 타설"] - assert len(placing) == 1 - # 그 줄의 물량은 **버림 콘크리트 몫뿐**이어야 한다 — 채움이 섞이면 이중계상이다. - blinding = next( - c - for c in build_unit_table([구조물()])["structures"][0]["components"] - if c["name"] == "버림콘크리트" - ) - assert placing[0]["quantity"] == pytest.approx(blinding["amount"], abs=1e-6) - - -def test_구조물_줄에_측점과_규격이_실림() -> None: - handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) - row = handoff["work_items"][0] - assert row["station_from"] == 35.0 - assert row["station_to"] == 45.0 - assert row["spec_detail"] == "H=1.5·L=10m" - # ⚠ 여기서 「10.0 m」를 계약으로 못 박고 있었다 — 그것이 곧 **금액 2.6배 사고**였다. - # 품셈 13-4-5 밑수는 **㎡** 이고, 받는 쪽은 ㎡ 단가를 이 수량에 그대로 곱한다. - # (오늘 다섯 번째로 옛 시험이 틀린 계약을 지키고 있던 자리다 — 2026-09-08.) - assert row["unit"] == "㎡" - # ⚠ 2026-09-09 — 기울기는 **성토/절토**까지 알아야 표에서 고를 수 있다(확정 ⑨). - # 이 시험은 단면유형을 안 주므로 판정이 「가를 근거 없음」이 되고, 그때는 **종전값 - # 1:0.3** 으로 서고 사유가 근거에 적힌다. 10m × 1.5m × √(1+0.3²) = 15.660. - # ⚠ 성토로 눅여 표를 고르면 임의값이 금액으로 굳는다 — 그래서 이 값이 맞다. - assert row["quantity"] == pytest.approx(15.660, abs=0.001) - assert row["origin"] == "structure" - - -# ── 할증은 자재 쪽에만 ────────────────────────────────────────────── - - -def test_할증_전후가_자재에만_실림() -> None: - unit = build_unit_table([구조물()]) - # ⚠ 타설 방식을 **비빔**으로 둔다 — 2026-09-09 부터 콘크리트가 자재 축에 서고(확정 3차 ⑥) - # 레미콘일 때는 율이 붙으므로, 「율이 없는 자재만 있는 상태」를 만들려면 비빔이라야 한다. - material = build_material_table(unit, concrete_placing_method="machine_mixed") - handoff = build_handoff( - summary_table=집계표(집계줄("흙깎기", "토사", 100.0)), - unit_quantity_table=unit, - material_table=material, - ) - # ⚠ 이 자재들은 율이 표에 없어 **실제로는 안 붙었다** — 깃발이 실제와 어긋나면 안 된다 - # (2026-09-07 계약 정정). 상태는 세 갈래로 말한다. - assert handoff["surcharge_status"] == "rate_unavailable" - assert handoff["surcharge_applied_to_materials"] is False - for row in handoff["materials"]: - assert "net_amount" in row and "total_amount" in row - # 작업 공종에는 할증 칸 자체가 없다. - for row in handoff["work_items"]: - assert "surcharge_pct" not in row - - -def test_관급구분과_설치주체가_함께_감() -> None: - unit = build_unit_table([구조물()]) - # ⚠ 돌 종류를 안 고른 구조물의 자재 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09 - # 확정 5차 큰 것 7) — 관측표가 아니라 계산식으로 서고 이름도 정본 계산표대로다. - material = build_material_table( - unit, supply_map={"돌": {"supply": SUPPLY_OWNER, "install_by": "contractor"}} - ) - handoff = build_handoff(unit_quantity_table=unit, material_table=material) - row = next(r for r in handoff["materials"] if r["material_name"] == "돌") - assert row["supply_type"] == "owner_supplied" - assert row["install_by"] == "contractor" - - -# ── in_bill — 무대가 내역에 서면 안 된다 (㉡) ────────────────────── - - -def test_무대는_넘기되_내역에는_안_섬() -> None: - """빼고 넘기면 「빠진 줄」과 「제외된 줄」을 나중에 구별할 수 없다.""" - haul = { - "rows": [ - { - "equipment": "free_haul", - "ground": "토사", - "volume_m3": 800.0, - "average_distance_m": 11.9, - "in_bill": False, - }, - { - "equipment": "dump_truck", - "ground": "토사", - "volume_m3": 500.0, - "average_distance_m": 1200.0, - "in_bill": True, - }, - ] - } - handoff = build_handoff(haul_table=haul) - free = 줄(handoff, "무대(종방향유용토)") - dump = 줄(handoff, "덤프 운반") - assert free["in_bill"] is False - assert free["in_bill_reason"] # 왜 빠지는지 함께 간다 - assert free["quantity"] == pytest.approx(800.0) # 값은 그대로 넘어간다 - assert dump["in_bill"] is True - assert dump["work_item_code"] == "FP-10-12" - assert dump["haul_distance_m"] == pytest.approx(1200.0) - # 2026-09-14 — 덤프 운반마다 「덤프 적재」 짝 줄(10-12 「1. 적재」) · 거리 없음 · 수량 같음 - loading = 줄(handoff, "덤프 적재") - assert loading["haul_equipment"] == "dump_loading" and loading["haul_distance_m"] is None - assert loading["quantity"] == dump["quantity"] and loading["variant_value"] == "토사" - assert not any(r["name"] == "덤프 적재" and r["spec"] != "토사" for r in handoff["work_items"]) - - -def test_합계_줄은_내역에_안_섬() -> None: - """「보정량계」는 검산용이다 — 빼지 않고 깃발로 가른다.""" - handoff = build_handoff(summary_table=집계표(집계줄("보정량계", amount=6511.06))) - row = handoff["work_items"][0] - assert row["in_bill"] is False - assert handoff["unmatched_work_items"] == [] # 합계는 못 이은 것이 아니다 - - -def test_내역_줄만_뽑는_입구() -> None: - handoff = build_handoff( - summary_table=집계표(집계줄("흙깎기", "토사", 100.0), 집계줄("보정량계", amount=100.0)) - ) - assert [row["name"] for row in iter_bill_rows(handoff)] == ["흙깎기"] - assert handoff["bill_row_count"] == 1 - assert handoff["excluded_row_count"] == 1 - - -# ── 매핑 — 데이터에서 오고, 못 찾으면 드러낸다 ───────────────────── - - -def test_실제_매핑판이_읽힘() -> None: - mapping = load_mapping() - assert mapping.effective_date - assert mapping.for_earthwork("흙깎기", "토사")["work_item_code"] == "FP-09-03-02" - - -def test_지반유형별로_다른_코드() -> None: - """측구터파기는 토사·암절취·발파암이 품셈에서 갈린다.""" - mapping = load_mapping() - assert mapping.for_earthwork("측구터파기", "토사")["work_item_code"] == "FP-09-12-01" - assert mapping.for_earthwork("측구터파기", "발파암")["work_item_code"] == "FP-09-12-03" - - -def test_지반을_안_가르는_공종은_ground_없이_맞음() -> None: - mapping = load_mapping() - assert mapping.for_earthwork("층따기", None)["work_item_code"] == "FP-09-18" - assert mapping.for_earthwork("층따기", "토사")["work_item_code"] == "FP-09-18" - - -def test_못_이은_줄은_빈_코드로_두지_않고_목록으로() -> None: - """빈칸이면 「코드 없는 줄」과 「매핑을 못 찾은 줄」이 구별되지 않는다.""" - handoff = build_handoff(summary_table=집계표(집계줄("듣도보도못한공종", amount=5.0))) - assert handoff["work_items"][0]["work_item_code"] is None - assert handoff["unmatched_work_items"] == ["듣도보도못한공종"] - - -def test_코드가_없는데_내역에_서면_검사가_잡음() -> None: - """빈 코드로 내역에 세우면 B09 가 단가를 못 붙인 0원 줄을 만든다.""" - handoff = build_handoff(summary_table=집계표(집계줄("듣도보도못한공종", amount=5.0))) - assert verify_bill_flags(handoff) == ["듣도보도못한공종"] - - -def test_매핑_파일이_없으면_전부_드러남() -> None: - handoff = build_handoff( - summary_table=집계표(집계줄("흙깎기", "토사", 100.0)), mapping=WorkItemMapping() - ) - assert handoff["unmatched_work_items"] == ["흙깎기(토사)"] - - -def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None: - """암 갈래는 고를 근거가 없다 — 임의 확정 금지(3장). - - ⭐ 2026-09-13 판정으로 지장목제거는 작업 갈래(뿌리뽑기 9-21 · 잡관목제거 4-2-2)로 이어졌다 — - **작업 갈래가 없는** 지장목제거 줄만 여전히 못 잇는다. - """ - handoff = build_handoff(summary_table=집계표(집계줄("지장목제거", amount=100.0))) - assert handoff["work_items"][0]["work_item_code"] is None - assert "지장목제거" in handoff["unmatched_work_items"] - # ⚠ 미결 항목은 **공종(`group`)** 으로도 **구조물 종류(`type_id`)** 로도 들어온다 — - # 둘 다 「고를 근거가 없는 자리」라 한 목록에 담는다. 키가 하나뿐이라고 보면 깨진다. - keys = [ - item.get("group") or item.get("type_id") - for item in handoff["mapping_pending_user"]["items"] - ] - assert "지장목제거" not in keys # 판정으로 닫힘(2026-09-13) - # ⚠ 큰돌쌓기는 **닫혔다** — 랩탑이 `bond`(메쌓기/찰쌓기) 칸을 만들어 자동으로 갈린다. - # 미결 목록에서 빠졌는지도 함께 본다(고쳐졌는데 목록만 남는 것 방지). - assert "boulder_masonry" not in keys - - -# ── 사면 계열은 출처가 다르다 ────────────────────────────────────── - - -def test_사면_계열은_origin_이_slope() -> None: - handoff = build_handoff( - summary_table=집계표(집계줄("흙깎기", "토사", 100.0), 집계줄("성토면다짐", amount=50.0)) - ) - assert 줄(handoff, "흙깎기")["origin"] == "earthwork" - assert 줄(handoff, "성토면다짐")["origin"] == "slope" - - -def test_초류종자살포가_씨앗뿜어붙이기로_이어짐() -> None: - handoff = build_handoff(summary_table=집계표(집계줄("초류종자살포", amount=100.0))) - assert handoff["work_items"][0]["work_item_code"] == "FP-05-24" - - -# ── 요약 ──────────────────────────────────────────────────────────── - - -def test_요약이_못_이은_것을_숨기지_않음() -> None: - handoff = build_handoff( - summary_table=집계표( - 집계줄("흙깎기", "토사", 100.0), 집계줄("듣도보도못한공종", amount=1.0) - ) - ) - brief = summarize(handoff) - assert brief["bill_rows"] == 2 - assert brief["unmatched"] == ["듣도보도못한공종"] - - -def test_판_번호가_응답에_남음() -> None: - assert build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 1.0)))["mapping_edition"] - - -# ── 갈래 세트 (2026-09-07 서브 이견 채택) ────────────────────────── - - -def test_갈래_세트가_인계본에_실림() -> None: - """값이 「연암」이어도 몇 갈래 중 하나인지 알아야 ④에서 줄을 세운다(울진 2·거창 5).""" - handoff = build_handoff( - summary_table=집계표(집계줄("흙깎기", "연암", 100.0)), - ground_class_set="geochang5", - ground_classes=["토사", "풍화암", "연암", "보통암", "경암"], - ) - assert handoff["ground_class_set"] == "geochang5" - assert handoff["ground_classes"][2] == "연암" - assert handoff["work_items"][0]["ground_class"] == "연암" - - -def test_무대_줄에_수량이_그대로_실림() -> None: - """무대+도자+덤프 = 총 운반토량 검산에 쓰이는 값이다 — 빼면 검산이 죽는다.""" - haul = { - "rows": [ - { - "equipment": "free_haul", - "ground": "토사", - "volume_m3": 871.0, - "average_distance_m": 11.94, - "in_bill": False, - }, - ] - } - row = build_handoff(haul_table=haul)["work_items"][0] - assert row["in_bill"] is False - assert row["quantity"] == pytest.approx(871.0) - - -# ── 암 시공법 (2026-09-07 일감 9 실서버에서 드러난 자리) ─────────── - - -def test_시공법을_정하면_공종이_갈림() -> None: - """품셈은 긁어내기(암절취)와 터뜨리기(발파암)를 다른 공종으로 둔다.""" - ripping = build_handoff( - summary_table=집계표(집계줄("흙깎기", "연암", 100.0)), - ground_methods={"연암": "ripping"}, - ) - blasting = build_handoff( - summary_table=집계표(집계줄("흙깎기", "연암", 100.0)), - ground_methods={"연암": "blasting"}, - ) - assert ripping["work_items"][0]["work_item_code"] == "FP-09-04" - assert blasting["work_items"][0]["work_item_code"] == "FP-09-05" - # 갈래 이름은 그대로 남는다 — 바꿔치기하지 않는다. - assert ripping["work_items"][0]["ground_class"] == "연암" - assert ripping["work_items"][0]["excavation_method"] == "ripping" - - -def test_시공법을_안_정하면_찍지_않고_드러냄() -> None: - """잘못 찍으면 공종이 조용히 틀린다 — 기본값으로 때우지 않는다.""" - handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "보통암", 100.0))) - row = handoff["work_items"][0] - assert row["work_item_code"] is None - assert row["excavation_method"] is None - assert handoff["missing_method_classes"] == ["보통암"] - assert any("시공법" in item for item in handoff["unmatched_work_items"]) - - -def test_갈래로_안_나뉜_암은_구성비가_빠졌다고_말함() -> None: - """2026-09-14 — 한 줄 「암」은 시공법만 골라서는 안 풀림. B09 내역도 이 까닭을 그대로 실음.""" - handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "암", 100.0))) - row = handoff["work_items"][0] - assert row["work_item_code"] is None and row["blocked_kind"] == "input_missing" - assert "암 갈래 구성비" in row["blocked_reason"] - assert handoff["missing_method_classes"] == [] # 시공법 안내에 「암」을 안 띄움 - - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - bill = build_bill(handoff) - assert any("입력이 필요합니다 — 암 갈래 구성비" in m["reason"] for m in bill.missing) - - -def test_토사는_시공법이_필요없음() -> None: - handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0))) - assert handoff["work_items"][0]["work_item_code"] == "FP-09-03-02" - assert handoff["missing_method_classes"] == [] - - -def test_측구터파기도_시공법을_따라감() -> None: - handoff = build_handoff( - summary_table=집계표(집계줄("측구터파기", "경암", 50.0)), - ground_methods={"경암": "blasting"}, - ) - assert handoff["work_items"][0]["work_item_code"] == "FP-09-12-03" - - -# ── 고른 값을 되돌릴 수 있어야 한다 (2026-09-07 화면에서 걸린 자리) ── - - -def test_시공법을_안_정함으로_되돌릴_수_있을것(tmp_path) -> None: - """병합 저장이면 한 번 고른 값이 영영 남는다 — 지울 수 있는 칸은 통째로 갈아 끼운다.""" - from common_util.common_util_project_settings import quantity_settings, save_section - - save_section( - tmp_path, "quantity", {"rock_methods": {"연암": "ripping"}}, replace_keys=("rock_methods",) - ) - assert quantity_settings(tmp_path)["rock_methods"] == {"연암": "ripping"} - # 「안 정함」으로 되돌리면 빈 칸이 되어야 한다. - save_section(tmp_path, "quantity", {"rock_methods": {}}, replace_keys=("rock_methods",)) - assert quantity_settings(tmp_path)["rock_methods"] == {} - - -def test_되돌리기_대상이_아닌_칸은_그대로_병합될것(tmp_path) -> None: - """`replace_keys` 를 안 준 칸까지 통째로 덮으면 남의 값이 사라진다.""" - from common_util.common_util_project_settings import quantity_settings, save_section - - save_section(tmp_path, "quantity", {"rock_ratios_pct": {"연암": 40}}) - save_section(tmp_path, "quantity", {"rock_ratios_pct": {"경암": 60}}) - assert quantity_settings(tmp_path)["rock_ratios_pct"] == {"연암": 40, "경암": 60} - - -# ── 반영률 — 곱하기는 B08 한 곳에서만 (2026-09-07 3자 계약) ──────── - - -def test_반영률_세_칸이_함께_감() -> None: - """율만 보내면 B09 가 또 곱해 값이 두 배가 된다 — 곱한 값·곱하기 전 값·율을 함께 싣는다.""" - handoff = build_handoff( - summary_table=집계표( - { - "group": "성토면다짐", - "item": "", - "spec": "", - "unit": "㎡", - "amount": 10814.9, - "amount_gross": 13518.62, - "application_ratio_pct": 80.0, - } - ) - ) - row = handoff["work_items"][0] - assert row["quantity"] == pytest.approx(10814.9) - assert row["quantity_gross"] == pytest.approx(13518.62) - assert row["application_ratio_pct"] == 80.0 - - -def test_세_칸이_어긋나면_검사가_잡음() -> None: - """세 값을 실어 두고 서로 안 맞으면 받는 쪽이 어느 값을 믿을지 모른다.""" - handoff = build_handoff( - summary_table=집계표( - { - "group": "성토면다짐", - "unit": "㎡", - "amount": 13518.62, # 곱하기를 빠뜨린 모양 - "amount_gross": 13518.62, - "application_ratio_pct": 80.0, - } - ) - ) - assert handoff["ratio_math_warnings"] - assert "성토면다짐" in handoff["ratio_math_warnings"][0] - - -def test_정상이면_경고가_없음() -> None: - """검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다.""" - handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0))) - assert handoff["ratio_math_warnings"] == [] - - -def test_반영률_개념이_없는_줄은_None() -> None: - """운반·구조물에는 반영률이 없다 — 0 이 아니라 `None` 이다.""" - handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) - assert handoff["work_items"][0]["application_ratio_pct"] is None - assert handoff["work_items"][0]["quantity_gross"] is None - - -def test_집계_엔진이_세_칸을_실제로_냄() -> None: - """엔진 출력이 계약대로인지 — 손으로 만든 모양이 아니라 진짜 산출물로 본다.""" - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary - - table = build_summary( - SummaryInput( - earthwork_totals={}, - slope_totals={ - "face_dressing_fill": 1000.0, - "face_dressing_cut": 500.0, - "tree_removal_fill": 1000.0, - "tree_removal_cut": 500.0, - "bench_cut_fill": 1000.0, - }, - rock_classes=["토사", "암"], - application_ratios={ - "fill_slope_compaction": 0.8, - "seed_spray_fill": 0.5, - "seed_spray_cut": 0.5, - "obstacle_removal": 0.8, - }, - ) - ) - packed = build_handoff(summary_table=table) - compaction = 줄(packed, "성토면다짐") - assert compaction["quantity"] == pytest.approx(800.0) - assert compaction["quantity_gross"] == pytest.approx(1000.0) - assert compaction["application_ratio_pct"] == pytest.approx(80.0) - # 100 % 인 줄도 율을 적는다 — 비어 있으면 「적용됐는지」를 받는 쪽이 못 정한다. - assert packed["ratio_math_warnings"] == [] - - -# ── 할증 상태는 세 갈래 ───────────────────────────────────────────── - - -def test_율을_못_찾았으면_붙였다고_말하지_않음() -> None: - """두 갈래로 두면 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다.""" - unit = build_unit_table([구조물()]) - # 야면석·막자갈 — 율이 표에 없다. 콘크리트는 비빔이라 레미콘 율이 안 붙는다(확정 3차 ⑥). - material = build_material_table(unit, concrete_placing_method="machine_mixed") - handoff = build_handoff(unit_quantity_table=unit, material_table=material) - assert handoff["surcharge_status"] == "rate_unavailable" - assert handoff["surcharge_applied_to_materials"] is False - - -def test_실제로_붙었으면_applied() -> None: - from B08_Quantity.B08_Quantity_Engine_MaterialSummary import SurchargeTable - - unit = { - "structures": [ - { - "name": "A", - "components": [ - {"name": "모래", "unit": "㎥", "amount": 10.0, "destination": "material"} - ], - } - ], - "surcharge_applied": False, - } - material = build_material_table( - unit, surcharge_table=SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}}) - ) - handoff = build_handoff(unit_quantity_table=unit, material_table=material) - assert handoff["surcharge_status"] == "applied" - assert handoff["surcharge_applied_to_materials"] is True - - -def test_자재가_없으면_not_applied() -> None: - handoff = build_handoff(material_table=build_material_table({"structures": []})) - assert handoff["surcharge_status"] == "not_applied" - - -def test_관급구분_안_정하면_기본_사급() -> None: - """2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함).""" - unit = build_unit_table([구조물()]) - handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) - assert handoff["materials"] - assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"]) - - -# ── 운반 줄 (2026-09-07 조율 창 요청) ─────────────────────────────── -# -# 실물 프로젝트에서 운반 칸이 전부 `null` 로 나온 일이 있었다. 원인은 **종단 [확정] 전이라 -# 운반계획이 아직 없는 것**이지 결함이 아니었다(운반 표 자체가 빈 표였다). 다만 그 상태에서는 -# 서브의 ㉡ 가드가 **걸 것이 없어 놀고 있으므로**, 계획이 있을 때 실제로 실리는지를 못 박는다. - - -def 운반표() -> dict: - """운반계획이 있는 프로젝트가 내는 모양 — 수단 × 지반유형.""" - return { - "rows": [ - { - "equipment": "free_haul", - "ground": "토사", - "volume_m3": 871.0, - "average_distance_m": 11.94, - "in_bill": False, - }, - { - "equipment": "dozer", - "ground": "토사", - "volume_m3": 1240.0, - "average_distance_m": 42.0, - "in_bill": True, - }, - { - "equipment": "dump_truck", - "ground": "토사", - "volume_m3": 2100.0, - "average_distance_m": 1350.0, - "in_bill": True, - }, - { - "equipment": "dump_truck", - "ground": "발파암", - "volume_m3": 980.0, - "average_distance_m": 1350.0, - "in_bill": True, - }, - ] - } - - -def test_운반계획이_있으면_네_줄이_실림() -> None: - handoff = build_handoff(haul_table=운반표()) - haul_rows = [row for row in handoff["work_items"] if row["origin"] == "haul"] - # 운반 넷 + 덤프 운반 둘의 적재 짝 줄 둘(2026-09-14 · 10-12 「1. 적재」 — 거리 없음) - moves = [row for row in haul_rows if row["haul_equipment"] != "dump_loading"] - assert len(moves) == 4 and len(haul_rows) == 6 - assert all(row["haul_equipment"] for row in haul_rows) - assert all(row["haul_distance_m"] for row in moves) - - -def test_무대가_함께_와야_운반_검산이_걸림() -> None: - """무대를 빼고 넘기면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다.""" - handoff = build_handoff(haul_table=운반표()) - free = 줄(handoff, "무대(종방향유용토)") - assert free["in_bill"] is False - assert free["quantity"] == pytest.approx(871.0) - assert handoff["excluded_row_count"] == 1 - assert handoff["bill_row_count"] == 5 # 도자 · 덤프 운반 둘 · 덤프 적재 짝 줄 둘 - - -def test_운반계획이_없으면_줄이_없음() -> None: - """빈 표는 결함이 아니라 **종단 [확정] 전** 상태다 — 0 줄이 정상이다.""" - handoff = build_handoff(haul_table={"rows": []}) - assert [row for row in handoff["work_items"] if row["origin"] == "haul"] == [] - - -def test_율이_부분마다_다르면_칸으로_갈라_실림() -> None: - """비고 문장에 적으면 받는 쪽이 문자열을 뜯어야 한다 — 칸으로 준다(2026-09-07 계약).""" - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary - - table = build_summary( - SummaryInput( - slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, - rock_classes=["토사", "암"], - application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, - ) - ) - row = 줄(build_handoff(summary_table=table), "초류종자살포") - assert row["application_ratio_pct"] is None # 하나의 율로 못 적는다 - assert row["application_ratio_breakdown"] == {"fill": 50.0, "cut": 100.0} - assert row["quantity_breakdown"] == {"fill": pytest.approx(500.0), "cut": pytest.approx(500.0)} - assert row["quantity"] == pytest.approx(1000.0 * 0.5 + 500.0 * 1.0) - assert row["quantity_gross"] == pytest.approx(1500.0) - # 갈래 물량의 합이 총량과 맞아야 한다 — 안 맞으면 받는 쪽이 어느 값을 믿을지 모른다. - assert sum(row["quantity_breakdown"].values()) == pytest.approx(row["quantity"]) - - -def test_율이_같아도_breakdown_은_늘_실림() -> None: - """⚠ 계약 확정(2026-09-07) — **늘 breakdown**, `pct` 는 편의값이다. - 「같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고 - 그게 나중에 **한쪽만 고쳐지는** 자리가 된다.""" - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary - - table = build_summary( - SummaryInput( - slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, - rock_classes=["토사", "암"], - application_ratios={"seed_spray_fill": 0.8, "seed_spray_cut": 0.8}, - ) - ) - row = 줄(build_handoff(summary_table=table), "초류종자살포") - assert row["application_ratio_pct"] == pytest.approx(80.0) # 편의값은 채워진다 - assert row["application_ratio_breakdown"] == {"fill": 80.0, "cut": 80.0} - assert row["quantity_breakdown"] == {"fill": pytest.approx(800.0), "cut": pytest.approx(400.0)} - - -def test_갈래별_율일_때_거울_검사가_헛경고를_안_냄() -> None: - """`quantity == gross × 율` 이 성립 안 하는 줄이므로 검사가 건너뛰어야 한다.""" - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary - - table = build_summary( - SummaryInput( - slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, - rock_classes=["토사", "암"], - application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, - ) - ) - assert build_handoff(summary_table=table)["ratio_math_warnings"] == [] - - -# ── 묶음으로 서는 줄 (2026-09-07 ⑩) ──────────────────────────────── - - -def test_품셈에_없는_공종은_묶음으로_적힘() -> None: - """품셈 12장에 「옹벽」 공종이 없다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit - - unit = build_unit( - [ - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, - } - ], - {"retaining_wall": "옹벽"}, - ) - handoff = build_handoff(unit_quantity_table=unit) - row = handoff["work_items"][0] - assert row["work_item_code"] is None - assert row["composite_parts"] # 무엇으로 묶이는지 적혀 있다 - assert "옹벽" in row["in_bill_reason"] - # 묶음 줄은 「코드 없이 내역에 선 줄」 경고에 걸리지 않는다. - assert verify_bill_flags(handoff) == [] - assert handoff["unmatched_work_items"] == [] - - -def test_집수정은_품셈_공종이_있어_바로_이어짐() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit - - unit = build_unit( - [ - { - "structure_id": "p1", - "type_id": "pipe", - "start_m": 50.0, - "end_m": 50.0, - "options": {"inlet_basin_form": "돌집수정 ㄷ형"}, - } - ], - {"pipe": "배수관"}, - ) - handoff = build_handoff(unit_quantity_table=unit) - basin = next(r for r in handoff["work_items"] if "집수정" in r["name"]) - assert basin["work_item_code"] == "FP-12-15" - - -# ── 콘크리트 타설 갈래 (2026-09-07 3자 확정) ─────────────────────── - - -def test_철근_유무로_구조물_종류가_자동_판정될것() -> None: - """사람이 고르는 값이 아니다 — 옹벽 관측 원단위의 D13·D16 에서 그대로 나온다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind - - with_rebar = {"components": [{"name": "이형철근 D13"}, {"name": "콘크리트"}]} - without = {"components": [{"name": "콘크리트"}, {"name": "합판거푸집"}]} - assert structure_kind(with_rebar) == "철근구조물" - assert structure_kind(without) == "무근구조물" - - -def test_옹벽이_철근구조물로_섬() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit - - unit = build_unit( - [ - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, - } - ], - {"retaining_wall": "옹벽"}, - ) - row = build_handoff(unit_quantity_table=unit)["work_items"][0] - assert row["structure_kind"] == "철근구조물" - # ⚠ 계약이 바뀐 자리 — 처음엔 「일위대가가 아직 안 선 공종」 목록이었으나, B09 가 넷을 - # 다 세운 뒤로는 **「물량을 못 채운 조각」**을 사유와 함께 낸다. 시험도 함께 옮긴다 - # (「옛 시험이 틀린 계약을 못 박고 있었다」를 오늘 이미 한 번 겪었다). - # ⚠ 사유를 **구조로** 낸다 — 받는 쪽이 「단가 없음」과 「물량 없음」을 갈라야 한다. - # ⭐ 2026-09-09 채워진 자리 — 기초잡석이 **버림 폭에서** 나온다(확정 3차 ②). - # 관측 원단위에 그 줄이 없어도 버림 0.15㎥/m ÷ 0.1 = 폭 1.5m 이고 두께 0.2m 이라 - # 0.30㎥/m 로 선다. ⇒ **묶음 다섯 조각이 다 찼다.** - assert row["composite_not_ready"] is None - - -def test_타설_방식은_설정이_고르고_기본은_레디믹스트() -> None: - from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping, placing_code - - mapping = load_mapping() - assert placing_code(mapping, "ready_mixed") == ("FP-12-01-01", False) - assert placing_code(mapping, "hand_mixed") == ("FP-12-01-03", False) - # 모르는 방식이면 기본으로 떨어지되 **그 사실을 알린다**. - code, used_default = placing_code(mapping, None) - assert code == "FP-12-01-01" and used_default is True - - -# ── 묶음 조각의 부위별 수량 · 철근 갈래 (2026-09-07 3자) ──────────── - - -def 옹벽단위(form: str = "반중력식"): - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit - - return build_unit( - [ - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": form, "height_m": 2.0, "length_m": 10.0}, - } - ], - {"retaining_wall": "옹벽"}, - ) - - -def 조각(row: dict, code_fragment: str) -> dict: - return next(p for p in row["composite_parts"] if code_fragment in str(p.get("code"))) - - -def test_조각마다_수량과_단위가_실릴것() -> None: - """코드만 보내면 받는 쪽이 상세 줄을 못 세운다.""" - row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] - # 콘크리트 1.345 + 버림 0.145, ×10m — 2026-09-14 원문 값(전 라이브러리 1.35 + 0.15 → 15.0) - assert 조각(row, "12-01-01")["quantity"] == pytest.approx(14.9) - assert 조각(row, "12-04")["quantity"] == pytest.approx(6.0) - assert 조각(row, "12-38")["quantity"] == pytest.approx(32.05) - - -def test_철근은_ton_으로_환산될것() -> None: - """⚠ 단가가 원/ton 인데 원단위는 ㎏ 이다 — 안 맞추면 **1000배** 틀린다.""" - row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] - rebar = 조각(row, "12-03") - assert rebar["unit"] == "ton" - # D13 13.45 + D16 30.42 = 43.87 ㎏/m × 10m = 438.7 ㎏ = 0.4387 ton - assert rebar["quantity"] == pytest.approx(0.4387) - - -def test_조각마다_근거가_실릴것() -> None: - """치수 전개와 관측값이 한 묶음에 섞이므로 조각별로 보여야 한다.""" - row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] - assert 조각(row, "12-04")["basis_kind"] == "observed" - - -def test_철근_갈래가_원문으로_자동_판정될것() -> None: - """⚠ 거푸집 사용횟수와 같은 자리 — 품셈 12-3 [주]① 이 구조물 예시로 갈라 둔다. - 판정할 수 있는 것을 사람에게 물으면 그것이 곧 미결이 된다. - - ⚠ 판정은 **형식만 보는 순수 함수**라 직접 시험한다. 조각으로만 보면 관측 원단위가 있는 - 형식(반중력식)밖에 못 봐, **판정되는 것만 확인하고 나머지는 한 번도 안 도는** 자리가 된다. - """ - from B08_Quantity.B08_Quantity_Engine_Handoff import rebar_complexity - - for form, expected in (("중력식", "간단"), ("반중력식", "보통"), ("부벽식", "복잡")): - kind, why = rebar_complexity("retaining_wall", {"form": form}) - assert kind == expected - assert "12-3" in why - - -def test_원문에_없는_형식은_지어내지_않을것() -> None: - """캔틸레버식 옹벽은 [주]① 예시에 없다 — 「보통」으로 때우지 않는다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff import rebar_complexity - - kind, why = rebar_complexity("retaining_wall", {"form": "캔틸레버식"}) - assert kind is None - assert "캔틸레버식" in why - - -def test_판정된_갈래가_조각_코드에_붙을것() -> None: - """원단위가 있는 형식에서 코드가 실제로 갈리는지 — 함수 판정과 조각을 잇는 자리.""" - row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0] - rebar = 조각(row, "12-03") - assert rebar["code"] == "FP-12-03#보통" - assert rebar["kind"] == "보통" - - -def test_원단위가_통째로_없으면_한_줄로_말할것() -> None: - """⚠ 같은 사유를 조각마다 다섯 번 반복하면 **진짜 사유가 묻힌다** - (화면에서 실제로 그렇게 보였다). 캔틸레버식은 관측 원단위 자체가 없다.""" - row = build_handoff(unit_quantity_table=옹벽단위("캔틸레버식"))["work_items"][0] - assert row["composite_parts"] == [] - assert len(row["composite_not_ready"]) == 1 - reason = row["composite_not_ready"][0]["reason"] - assert "자료에 없습니다" in reason - # 「없다」만 말하지 않고 **표에 있는 규격**을 함께 알린다. - assert "반중력식" in reason - - -def test_기초잡석은_버림_폭에서_선다() -> None: - """⭐ 옛 이름은 「물량을 못 채운 조각은 0 이 아니라 사유와 함께」였다. - - 그 조각이 **채워졌다**(2026-09-09 확정 3차 ②). 버림 폭이 곧 잡석다짐 폭이라 - (KCS 34 50 05) 두께 비만 곱하면 관측 원단위 구조물에도 값이 선다 — - 버림 1.45㎥ × (0.2 ÷ 0.1) = 2.9㎥(= 0.29㎥/m × 10m · 2026-09-14 원문 버림 0.145, 전 3.0). - 「못 채운 조각은 0 이 아니라 사유와 함께」라는 규칙 자체는 다른 조각 시험이 지킨다. - """ - row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] - gravel = 조각(row, "12-25") - assert gravel["quantity"] == pytest.approx(2.9) - assert not gravel.get("not_ready") - - -def test_유로폼_갈래도_원문이_정할것() -> None: - """⚠ 「원문이 이미 정해 둠」 **네 번째** — 품셈 12-38-3 [주]④ 「보통: … 옹벽 …」. - (앞선 셋: 거푸집 사용횟수 1-7-1 · 철근 갈래 12-3 [주]① · 밑수는 표 위 본문.)""" - from B08_Quantity.B08_Quantity_Engine_Handoff import euroform_type - - kind, why = euroform_type("retaining_wall") - assert kind == "보통" - assert "12-38-3" in why and "옹벽" in why - assert euroform_type("듣도보도못한시설")[0] is None - - -def test_유로폼_조각_코드에_갈래가_붙을것() -> None: - row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0] - assert 조각(row, "12-38")["code"] == "FP-12-38#보통" - - -def test_유로폼_사용횟수는_1_7_1_과_다른_자리() -> None: - """⚠ 12-38-1 은 유로폼(강재)의 **잔존율**이고 1-7-1 은 소모성 거푸집 **전용 횟수**다. - 이름이 같아 하나로 이으면 조용히 틀린다.""" - from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table - - note = load_formwork_table().euroform_type["reuse_note"] - assert "다른 자리" in note and "잔존율" in note - - -def test_같은_코드가_지반만_달리해_여러_줄_올_것() -> None: - """⚠ 실물에서 잡힌 모양 — 도자운반 토사·리핑암이 **같은 코드로 두 줄** 온다. - 받는 쪽이 코드로만 묶으면 한 줄이 사라진다.""" - haul = { - "rows": [ - { - "equipment": "dozer", - "ground": "토사", - "volume_m3": 100.0, - "average_distance_m": 40.0, - "in_bill": True, - }, - { - "equipment": "dozer", - "ground": "리핑암", - "volume_m3": 200.0, - "average_distance_m": 40.0, - "in_bill": True, - }, - ] - } - rows = [r for r in build_handoff(haul_table=haul)["work_items"] if r["origin"] == "haul"] - assert len(rows) == 2 - assert {r["work_item_code"] for r in rows} == {"FP-10-11"} # 코드는 같고 - assert {r["ground_class"] for r in rows} == {"토사", "리핑암"} # 지반만 다르다 - - -def test_갈래_키는_내부_공백만_지울것() -> None: - """⚠ 두 창이 같은 규칙을 써야 맞는다 — 한쪽만 고치면 그날로 안 맞는다. - **다른 글자는 손대지 않는다**: 정규화를 넓히면 오늘 아홉 번 겪은 병을 새로 만든다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff import normalize_kind_key - - assert normalize_kind_key("보 통") == "보통" - assert normalize_kind_key("매우 복잡") == "매우복잡" - assert normalize_kind_key("보통") == "보통" - # 다른 글자는 그대로 — 괄호·기호를 지우지 않는다. - assert normalize_kind_key("간단(기초)") == "간단(기초)" - - -def test_원문_문구를_라벨로_함께_실을것() -> None: - """키는 공백을 지우되 **산출근거에는 원문이 보여야** 한다.""" - row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0] - rebar = 조각(row, "12-03") - assert rebar["code"] == "FP-12-03#보통" - assert rebar["kind_label"] == "보통" - assert "12-3" in rebar["kind_basis"] - - -def test_build_handoff_가_검사_셋을_실제로_부른다(): - """⚠ 「만들어 두고 안 부르면 없는 것과 같다」 — 2026-09-08 ㉘ 에서 둘이 놀고 있었다. - - 호출 여부를 **결과 칸으로** 잰다. 칸이 없으면 안 부른 것이다. - """ - handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) - for key in ("ratio_math_warnings", "material_code_warnings", "bill_flag_warnings"): - assert key in handoff, f"{key} 가 없다 — 검사를 안 불렀다" - - -def test_검사가_실제로_잡는다_일부러_깨뜨려_봄(): - """⚠ 부르기만 하고 못 잡으면 소용없다 — 일부러 깨뜨려 잡히는지 본다.""" - handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) - handoff["materials"].append({"material_name": "가짜자재", "work_item_code": "FP-99-99"}) - assert verify_no_code_on_materials(handoff) == ["가짜자재"] - handoff["work_items"].append( - {"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None} - ) - assert "코드없이내역에선줄" in verify_bill_flags(handoff) - - -# ── 콘크리트 타설 줄 (2026-09-08 ㉙) ───────────────────────────────────────── -# 품은 이 줄, 재료는 자재 쪽. 품셈 12-1-1 표가 직종·품만 주므로 겹치지 않는다. - - -def _콘크리트구조물(components: list[dict]) -> dict: - """⚠ 옹벽은 **묶음으로 서는 종류**라 타설 줄이 안 생긴다(묶음 조각이 셈). - - 그래서 타설 줄 시험은 **묶음이 없는** 콘크리트 구조물로 한다. - """ - return { - "structure_id": "b1", - "type_id": "pipe_inlet_basin", - "name": "집수정", - "length_m": 1.0, - "components": components, - } - - -def test_타설_줄이_실제로_선다() -> None: - """⚠ `placing_code()` 가 시험에서만 불리던 자리 — 이제 `build_handoff` 가 부른다.""" - unit = { - "structures": [ - _콘크리트구조물( - [ - {"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"}, - { - "name": "이형철근 D13", - "unit": "kg", - "amount": 134.5, - "destination": "material", - }, - ] - ) - ] - } - handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed") - 타설 = [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] - assert len(타설) == 1 - assert 타설[0]["work_item_code"] == "FP-12-01-01" - assert 타설[0]["quantity"] == 13.5 - # 철근이 있으므로 철근구조물 — 사람이 고르는 값이 아니다. - assert 타설[0]["structure_kind"] == "철근구조물" - assert handoff["placing_notes"] == [] - - -def test_방식을_안_정하면_기본값으로_서되_알린다() -> None: - unit = { - "structures": [ - _콘크리트구조물( - [{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}] - ) - ] - } - handoff = build_handoff(unit_quantity_table=unit) - 타설 = next(r for r in handoff["work_items"] if r["name"] == "콘크리트 타설") - assert 타설["work_item_code"] == "FP-12-01-01" # 기본값 = 레디믹스트 - assert 타설["structure_kind"] == "무근구조물" - assert handoff["placing_notes"], "기본값으로 선 사실을 안 알린다" - - -def test_방식을_바꾸면_공종이_갈린다() -> None: - unit = { - "structures": [ - _콘크리트구조물( - [{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}] - ) - ] - } - 코드 = {} - for method in ("ready_mixed", "machine_mixed", "hand_mixed"): - handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method=method) - 코드[method] = next( - r["work_item_code"] for r in handoff["work_items"] if r["name"] == "콘크리트 타설" - ) - assert 코드 == { - "ready_mixed": "FP-12-01-01", - "machine_mixed": "FP-12-01-02", - "hand_mixed": "FP-12-01-03", - } - - -def test_콘크리트가_없으면_타설_줄도_없다() -> None: - """0 ㎥ 짜리 빈 줄을 만들지 않는다.""" - unit = { - "structures": [ - _콘크리트구조물( - [{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}] - ) - ] - } - handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed") - assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] - - -# ── 내역 줄의 축 — 관측 원단위가 「개소당」인 종류 (2026-09-08 ㉕ 실증) ──────── - - -def _집수정(length_m: float) -> dict: - return { - "structure_id": "b1", - "type_id": "pipe_inlet_basin", - "name": "집수정", - "start_m": 50.0, - "end_m": 50.0 + length_m, - "options": { - "inlet_basin_form": "□형(기본형)", - "inlet_basin_material": "콘크리트", - "pipe_diameter_mm": "800", - "length_m": length_m, - }, - } - - -def test_개소_구조물은_연장으로_안_센다() -> None: - """⚠ 연장 2m 짜리 집수정이 「2.000 m」로 나가 **값이 두 배**로 실리던 자리. - - 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 어긋나 있어 아무 시험도 안 잡았다. - """ - handoff = build_handoff( - unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"}) - ) - 줄 = next(r for r in handoff["work_items"] if r["name"] == "집수정") - assert (줄["unit"], 줄["quantity"]) == ("개소", 1.0) - - -def test_개소_구조물은_연장이_길어도_한_개소() -> None: - 한개 = build_unit_table([_집수정(1.0)], {"pipe_inlet_basin": "집수정"}) - 긴것 = build_unit_table([_집수정(5.0)], {"pipe_inlet_basin": "집수정"}) - for table in (한개, 긴것): - 줄 = next( - r - for r in build_handoff(unit_quantity_table=table)["work_items"] - if r["name"] == "집수정" - ) - assert (줄["unit"], 줄["quantity"]) == ("개소", 1.0) - - -def test_m당_구조물은_그대로_연장으로_센다() -> None: - """⚠ 좁게 고친다 — 「개소」 때문에 정상 「m」 줄까지 바꾸면 그것이 더 나쁘다.""" - 옹벽 = { - "structure_id": "w1", - "type_id": "retaining_wall", - "name": "옹벽", - "start_m": 100.0, - "end_m": 120.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 20.0}, - } - 줄 = next( - r - for r in build_handoff( - unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"}) - )["work_items"] - if r["name"] == "옹벽" - ) - assert (줄["unit"], 줄["quantity"]) == ("m", 20.0) - - -# ── 준비공·사방공 인계 (2026-09-08, 보조 창 제보) ──────────────────────────── -# ⚠ 줄을 빼면 「빠졌다는 사실조차 안 보인다」 — 받는 쪽에서 「원래 없는 것」과 -# 「우리가 아직 못 내는 것」이 구별되지 않는다. - - -def _준비공표() -> dict: - from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_prep - - slope = { - "face_dressing_fill": 9000.0, - "face_dressing_cut": 6726.9, - "tree_removal_fill": 9000.0, - "tree_removal_cut": 6726.9, - "bench_cut_fill": 9000.0, - } - return build_prep(slope, [], [], 0.15, road_surface_area_m2=8000.0) - - -def test_준비공_값이_서는_줄은_내역에_선다() -> None: - handoff = build_handoff(preparation_table=_준비공표()) - 표토 = next(r for r in handoff["work_items"] if r["name"] == "표토제거") - assert 표토["work_item_code"] == "FP-09-15-02" - assert 표토["in_bill"] is True - assert 표토["quantity"] > 0 - assert 표토["blocked_kind"] is None - - -def test_못_내는_줄도_사유와_함께_간다() -> None: - """⚠ 여기가 요청의 핵심 — 줄이 아예 안 가면 「우리가 만들 것」에 안 얹힌다.""" - handoff = build_handoff(preparation_table=_준비공표()) - 제근 = next(r for r in handoff["work_items"] if r["name"] == "제근·뿌리다듬기") - # ⭐ 2026-09-09 확정 5차 6번으로 **밑수가 면적 축으로 정해져 값이 선다** — 그래서 이 줄은 - # 더 이상 「못 내는 줄」의 표본이 아니다. 「못 내는 줄도 사유와 함께 간다」는 계약 자체는 - # 아래 표토·부대시설 줄이 지킨다. - # ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 뿌리뽑기(FP-09-21)가 하고 이 줄은 **보이되 안 실린다**. - assert 제근["in_bill"] is False and 제근["quantity"] > 0 - assert 제근["work_item_code"] is None and 제근["blocked_kind"] is None - assert "토공집계" in 제근["in_bill_reason"] - assert 제근["unit"] == "㎡" # 밑수가 면적 축으로 확정됨(확정 5차 6번) - # ⚠ 사유가 **두 번 바뀐 자리**다 — ㉠ 「입목 본수가 없다」(틀린 말: 본수 축이 아니었다) - # ㉡ 「밑수 단위가 원문에 없다」(사실이었으나 확정으로 닫힘) ㉢ 지금은 **값이 서고** - # 남은 것은 **단가 갈래(임목축적 등급)**뿐이다. 사유는 그 자리에 남는다. - 사유 = 제근["spec_detail"] + " " + str(제근.get("blocked_reason") or "") - assert "임목축적" in 사유 - assert "교차 참조" in 사유 # 건설품셈 3-9-2 를 빌려 쓴다는 표시 - - -def test_다른_표에서_이미_선_줄은_막힌_것이_아니다() -> None: - """벌목은 토공집계에 이미 서 있다 — **막힌 것이 아니라 여기서 세면 안 되는 것**.""" - handoff = build_handoff(preparation_table=_준비공표()) - 벌목 = next(r for r in handoff["work_items"] if r["name"] == "벌목·지장목제거") - assert 벌목["in_bill"] is False - assert 벌목["blocked_kind"] is None, "이중계상 방지 줄을 「막힘」으로 보내면 오해된다" - assert "이미 섬" in 벌목["blocked_reason"] - - -def test_준비공_표를_안_주면_줄도_없다() -> None: - """⚠ 좁게 — 표가 없을 때 빈 줄을 지어내지 않는다.""" - handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) - assert not [r for r in handoff["work_items"] if r["origin"] == "preparation"] - - -def test_묶음으로_서는_구조물은_타설_줄을_따로_안_만든다() -> None: - """⚠⚠ 이중계상 — 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 이미 있다. - - 2026-09-08 B09 가 「철근이 겹치나」를 묻다 드러난 자리. 철근은 안 겹치고 - (자재는 재료 · 묶음 조각은 품이며 그 일위대가 재료가 0원) **타설이 겹쳤다.** - 묶음이 아직 미확보라 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. - """ - 옹벽 = { - "structure_id": "w1", - "type_id": "retaining_wall", - "name": "옹벽", - "length_m": 10.0, - "components": [ - {"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"}, - ], - } - handoff = build_handoff( - unit_quantity_table={"structures": [옹벽]}, concrete_placing_method="ready_mixed" - ) - assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] - - -def test_묶음이_없는_종류는_그대로_타설_줄이_선다() -> None: - """⚠ 좁게 — 묶음 하나 때문에 정상 타설 줄까지 지우면 그것이 더 나쁘다.""" - handoff = build_handoff( - unit_quantity_table={ - "structures": [ - _콘크리트구조물( - [ - { - "name": "콘크리트", - "unit": "㎥", - "amount": 2.84, - "destination": "unit_price", - } - ] - ) - ] - }, - concrete_placing_method="ready_mixed", - ) - 타설 = [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] - assert len(타설) == 1 and 타설[0]["quantity"] == 2.84 - - -# ── B군 종단배수 — 연장이 곧 수량인 공종 (2026-09-08 V-3) ──────────────────── - - -def _B군(type_id: str, name: str) -> dict: - return { - "structure_id": "d1", - "type_id": type_id, - "name": name, - "length_m": 40.0, - "components": [], - "notes": [f"{name}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다"], - } - - -def test_연장으로_서는_공종은_전개식이_없어도_안_막힌다() -> None: - """⚠⚠ 품셈 밑수가 **1 m** 라 연장이 곧 수량이다 — 원단위 전개가 필요 없다. - - 앞서 「성분이 없으면 전개식 없음」으로 단정해 B09 가 「우리가 만들 것」으로 빼 - **금액이 0** 이었다(실측: 맹암거 40m 이 0원 → 고친 뒤 1,019,685원). - """ - handoff = build_handoff(unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]}) - 줄 = next(r for r in handoff["work_items"] if r["name"] == "맹암거") - assert 줄["work_item_code"] == "FP-12-10" - assert 줄["blocked_kind"] is None, 줄["blocked_reason"] - assert 줄["in_bill"] is True - - -def test_공종코드가_없으면_여전히_막힌다() -> None: - """⚠ 좁게 — 코드도 없는데 안 막힌 것으로 두면 **0원 줄**이 조용히 선다.""" - handoff = build_handoff( - unit_quantity_table={"structures": [_B군("chute", "도수로·산비탈수로")]} - ) - 줄 = next(r for r in handoff["work_items"] if r["name"] == "도수로·산비탈수로") - assert 줄["work_item_code"] is None - assert 줄["blocked_kind"] is not None - assert "도수로·산비탈수로" in handoff["bill_flag_warnings"] - - -def test_연장이_0_이면_코드가_있어도_막힌다() -> None: - """⚠ 좁게 — 수량이 없는데 안 막힌 것으로 두면 0원 줄이 된다.""" - 빈것 = _B군("underdrain", "맹암거") - 빈것["length_m"] = 0.0 - handoff = build_handoff(unit_quantity_table={"structures": [빈것]}) - 줄 = next(r for r in handoff["work_items"] if r["name"] == "맹암거") - assert 줄["blocked_kind"] is not None - - -# ── B군은 연장표로 — 겹친 구간을 두 번 세지 않기 (2026-09-08 두 창 합의) ────── - - -def _연장표(length: float, raw: float, count: int = 2) -> list[dict]: - return [ - { - "type_id": "ditch_ridge", - "group": "B", - "name": "산마루측구", - "count": count, - "length_m": length, - "raw_length_m": raw, - } - ] - - -def test_B군은_종류별_한_줄로_선다() -> None: - handoff = build_handoff(length_table=_연장표(60.0, 80.0)) - 줄 = [r for r in handoff["work_items"] if r["name"] == "산마루측구"] - assert len(줄) == 1 - assert (줄[0]["work_item_code"], 줄[0]["quantity"], 줄[0]["unit"]) == ("FP-12-09-02", 60.0, "m") - assert 줄[0]["spec"] == "2개소" - - -def test_겹친_구간은_합쳐진_값이_수량이_된다() -> None: - """⚠ 구조물별로 세면 겹친 구간을 **두 번** 센다 — 80m 가 아니라 60m 다.""" - 줄 = build_handoff(length_table=_연장표(60.0, 80.0))["work_items"][0] - assert 줄["quantity"] == 60.0 - assert 줄["quantity_gross"] == 80.0 - assert "겹친 20m" in 줄["spec_class_basis"] - - -def test_겹침_설명은_막힌_사유가_아니다() -> None: - """⚠ `blocked_reason` 에 넣으면 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다. - - 2026-09-08 실측에서 실제로 그랬다 — B09 가 「막힘 — 입력 구간 합 80m…」으로 뺐다. - """ - 줄 = build_handoff(length_table=_연장표(60.0, 80.0))["work_items"][0] - assert 줄["blocked_kind"] is None - assert 줄["blocked_reason"] == "" - assert 줄["in_bill"] is True - - -def test_겹치지_않으면_원합을_안_싣는다() -> None: - """⚠ 좁게 — 안 겹쳤는데 `quantity_gross` 를 실으면 「반영률이 곱해진 값」으로 오해된다.""" - 줄 = build_handoff(length_table=_연장표(40.0, 40.0, count=1))["work_items"][0] - assert 줄["quantity_gross"] is None - assert 줄["spec_class_basis"] == "" - - -def test_공종을_못_이은_B군은_사유와_함께_막힌다() -> None: - 표 = [ - { - "type_id": "chute", - "group": "B", - "name": "도수로·산비탈수로", - "count": 1, - "length_m": 40.0, - "raw_length_m": 40.0, - } - ] - 줄 = build_handoff(length_table=표)["work_items"][0] - assert 줄["work_item_code"] is None - assert 줄["in_bill"] is False - assert "품셈 공종을 아직 못 이었습니다" in 줄["blocked_reason"] - - -# ── B군 산출근거 — 어디부터 어디까지 (2026-09-08 `spans` 받음) ──────────────── - - -def test_구간이_비고에_적히고_측점이_실린다() -> None: - 표 = _연장표(60.0, 80.0) - 표[0]["spans"] = [{"start_m": 80.0, "end_m": 140.0}] - 줄 = build_handoff(length_table=표)["work_items"][0] - assert (줄["station_from"], 줄["station_to"]) == (80.0, 140.0) - assert "구간 80~140m" in 줄["spec_class_basis"] - assert "겹친 20m" in 줄["spec_class_basis"] - - -def test_구간이_여럿이면_처음과_끝을_싣고_전부는_비고에() -> None: - 표 = [ - { - "type_id": "ditch_berm", - "group": "B", - "name": "소단측구", - "count": 3, - "length_m": 30.0, - "raw_length_m": 30.0, - "spans": [ - {"start_m": 10.0, "end_m": 20.0}, - {"start_m": 50.0, "end_m": 60.0}, - {"start_m": 90.0, "end_m": 100.0}, - ], - } - ] - 줄 = build_handoff(length_table=표)["work_items"][0] - assert (줄["station_from"], 줄["station_to"]) == (10.0, 100.0) - # 사이가 빈 것을 숨기지 않는다 — 셋이 다 적혀야 10~100 을 통으로 오해하지 않는다. - assert 줄["spec_class_basis"].count("m") >= 3 - assert "50~60m" in 줄["spec_class_basis"] - - -def test_구간_합이_수량과_같다() -> None: - """⚠ 겹침을 지운 뒤의 목록이라 **그 합이 곧 수량**이다 — 둘이 갈리면 어딘가 틀렸다.""" - 표 = _연장표(60.0, 80.0) - 표[0]["spans"] = [{"start_m": 80.0, "end_m": 140.0}] - 줄 = build_handoff(length_table=표)["work_items"][0] - 합 = sum(s["end_m"] - s["start_m"] for s in 표[0]["spans"]) - assert 합 == 줄["quantity"] - - -def test_구간이_없어도_줄은_선다() -> None: - """⚠ 좁게 — `spans` 가 없다고 줄을 빼면 옛 자료에서 수량이 통째로 사라진다.""" - 줄 = build_handoff(length_table=_연장표(40.0, 40.0, count=1))["work_items"][0] - assert 줄["quantity"] == 40.0 and 줄["in_bill"] is True - assert (줄["station_from"], 줄["station_to"]) == (None, None) diff --git a/resources/tester/test_b08_handoff_contract.py b/resources/tester/test_b08_handoff_contract.py deleted file mode 100644 index 56429d5cb..000000000 --- a/resources/tester/test_b08_handoff_contract.py +++ /dev/null @@ -1,224 +0,0 @@ -"""인계 계약 — **내보내는 키가 계약에 적힌 것과 같은지** (2026-09-07 ㉒ 앞). - -⚠ 오늘 이 병이 계약 양쪽에서 하나씩 났다. - · 우리 쪽 — 레지스트리 키(`back_len_cm`)와 엔진이 읽는 키가 달라 **저장값이 안 닿음**. - · 받는 쪽 — 우리가 보낸 `composite_parts`·`quantity_gross` 등을 **안 읽고 버림**. -**「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」 자리는 양쪽 다 조용하다.** - -⇒ 키를 늘릴 때 **계약에 안 적고 늘리면 받는 쪽이 영영 모른다.** 이 시험이 그것을 막는다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 - -#: **작업 공종 줄**이 내보내는 칸. 늘릴 때는 계약(8-2)에도 적고 받는 쪽에 알릴 것. -WORK_ITEM_KEYS = { - "work_item_code", - "name", - "spec", - "unit", - "quantity", - "quantity_gross", - "application_ratio_pct", - "application_ratio_breakdown", - "quantity_breakdown", - "ground_class", - "excavation_method", - "haul_distance_m", - "haul_equipment", - "station_from", - "station_to", - "spec_detail", - "composite_parts", - "structure_kind", - "variant_axis", - "variant_value", - # ⚠ 갈래 축이 둘 이상인 자리(돌쌓기 = 뒷길이 × 돌 종류) — 2026-09-08 추가. - "secondary_axes", - "spec_class", - "spec_class_basis", - "blocked_kind", - "blocked_reason", - "composite_not_ready", - "in_bill", - "in_bill_reason", - "origin", - # 공종 코드가 본 품셈 판 — B09 가 마스터 판과 다르면 값을 안 씀(명세 17장 · 2026-09-13 추가). - "pum_edition", - # 불변 열쇠(FW-·AX-) — 목차 번호가 개정으로 밀려도 안 바뀜(2026-09-17 8-6 이행 길목 · - # 받는 쪽 B09 `parse_handoff` 가 읽음). 코드 없는 줄은 None. - "work_item_key", -} - -#: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다). -MATERIAL_KEYS = { - "material_name", - "spec", - "unit", - "net_amount", - "total_amount", - "surcharge_pct", - "surcharge_note", - "supply_type", - "install_by", - "source_structure", -} - - -def _rows() -> dict: - unit = build_unit( - [ - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, - } - ], - {"retaining_wall": "옹벽"}, - ) - from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material - - haul = { - "rows": [ - { - "equipment": "dozer", - "ground": "토사", - "volume_m3": 10.0, - "average_distance_m": 40.0, - "in_bill": True, - } - ] - } - summary = {"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]} - return build_handoff( - summary_table=summary, - haul_table=haul, - unit_quantity_table=unit, - material_table=build_material(unit), - ) - - -def test_작업_공종_줄의_칸이_계약과_같을것() -> None: - """⚠ 계약에 없는 칸을 늘리면 받는 쪽이 영영 모른다.""" - for row in _rows()["work_items"]: - extra = set(row) - WORK_ITEM_KEYS - assert not extra, f"계약에 없는 칸: {extra}" - missing = WORK_ITEM_KEYS - set(row) - assert not missing, f"계약에 있는데 안 보내는 칸: {missing}" - - -def test_자재_줄의_칸이_계약과_같을것() -> None: - for row in _rows()["materials"]: - assert set(row) == MATERIAL_KEYS, set(row) ^ MATERIAL_KEYS - - -def test_자재_줄에_공종코드가_없을것() -> None: - """축이 둘이라는 것은 주석이 아니라 계약으로 지킨다.""" - assert "work_item_code" not in MATERIAL_KEYS - for row in _rows()["materials"]: - assert "work_item_code" not in row - - -def test_막힌_까닭이_세_갈래_중_하나일것() -> None: - """받는 쪽이 「입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 갈라야 한다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff import ( - BLOCKED_FORMULA_MISSING, - BLOCKED_INPUT_MISSING, - BLOCKED_UNCONFIRMED, - BLOCKED_UNIT_DATA_MISSING, - ) - - # ⭐ 넷째 갈래(2026-09-14 브레인 판정) — 치수 없이 기본값으로 선 줄: 수량은 보이되 금액 밖. - allowed = { - None, - BLOCKED_INPUT_MISSING, - BLOCKED_UNIT_DATA_MISSING, - BLOCKED_FORMULA_MISSING, - BLOCKED_UNCONFIRMED, - } - for row in _rows()["work_items"]: - assert row["blocked_kind"] in allowed - - -def test_막힌_줄에는_사유가_반드시_있을것() -> None: - """갈래만 있고 사유가 없으면 받는 쪽 화면이 빈다.""" - for row in _rows()["work_items"]: - if row["blocked_kind"]: - assert row["blocked_reason"], row["name"] - - -# ── 갈래 키를 우리가 조립하지 않는다 (2026-09-07 계약 변경) ───────── -# -# ⚠ 품셈 원문이 물결표를 섞어 쓴다 — 13-06-01·02 는 `∼`(U+223C), 13-06-03 은 `~`(U+FF5E). -# 두 창이 각자 키 문자열을 만들면 **글자 하나로 영영 안 맞는다.** -# ⇒ 우리는 **축과 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. - - -def _structure_rows() -> list[dict]: - out = [] - for type_id, options in ( - ("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"}), - ( - "boulder_masonry", - {"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80", "bond": "찰쌓기"}, - ), - ): - unit = build_unit( - [ - { - "structure_id": "x", - "type_id": type_id, - "start_m": 0.0, - "end_m": 10.0, - "options": options, - } - ], - {type_id: type_id}, - ) - out.extend(build_handoff(unit_quantity_table=unit)["work_items"]) - return out - - -def test_축과_원본값을_그대로_보낼것() -> None: - rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} - assert rows["back_len_cm"]["variant_value"] == "45" - assert rows["stone_cm"]["variant_value"] == "60~80" - - -def test_갈래_키_문자열을_코드에_붙이지_말것() -> None: - """⚠ 이 시험이 계약 변경을 지킨다 — `#55cm이하`·`#직경60㎝이상∼80㎝미만` 을 만들면 깨진다. - - ⚠ **메/찰은 예외다** — 그것은 키 표기가 아니라 **공종 자체가 갈리는 의미 판정**이라 - 코드가 `FP-13-06-01`/`-02` 로 통째로 달라진다. - """ - for row in _structure_rows(): - code = str(row["work_item_code"] or "") - assert "#" not in code, f"갈래 키를 코드에 붙였다: {code}" - assert "㎝" not in code and "cm" not in code - - -def test_원본값을_가공하지_말것() -> None: - """물결표·공백을 우리가 손대면 받는 쪽이 원문과 대조를 못 한다.""" - rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} - raw = rows["stone_cm"]["variant_value"] - assert raw == "60~80" # 물결표 종류를 바꾸거나 「㎝」를 붙이지 않는다 - - -def test_돌_종류를_둘째_축으로_보낼것() -> None: - """⚠ 품셈 13-4 품은 [주]② 로 **깬돌·깬잡석 전용**이고 돌 종류로 갈리는 표는 13-5 다. - - 어느 공종으로 볼지는 사용자 확정 대기라 **공종을 바꾸지 않고 원본값만** 실어 보낸다. - """ - rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} - axes = rows["back_len_cm"]["secondary_axes"] or [] - assert [item["axis"] for item in axes] == ["stone_kind"] diff --git a/resources/tester/test_b08_haul_double_count.py b/resources/tester/test_b08_haul_double_count.py deleted file mode 100644 index 1493d0d36..000000000 --- a/resources/tester/test_b08_haul_double_count.py +++ /dev/null @@ -1,76 +0,0 @@ -"""운반이 두 축에 실리던 자리 (2026-09-09 실측). - -⚠⚠ 토공집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣고, - 인계본에는 운반표(FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다.** - 실측 — 도자 17.389·61.130 · 덤프 37.511·122.417 이 두 축에 각각 있었고 **둘 다 in_bill**. - -⚠ 겨누는 것 넷 - ① 집계 쪽 운반 줄은 **내역 줄이 아니다** - ② 그래도 **값은 남는다** — 검산(무대+도자+덤프 = 총 운반토량)이 그 값을 씀 - ③ 사유가 적힌다 — 「내역 줄은 운반표 쪽」 - ④ 운반표 쪽은 그대로 내역 줄로 선다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 - -집계 = { - "rows": [ - {"group": "도자운반", "unit": "㎥", "amount": 17.39, "in_bill": True}, - {"group": "덤프운반", "unit": "㎥", "amount": 37.51, "in_bill": True}, - {"group": "무대(종방향유용토)", "unit": "㎥", "amount": 14.76, "in_bill": False}, - {"group": "성토", "unit": "㎥", "amount": 100.0, "in_bill": True}, - ] -} -운반 = { - "rows": [ - { - "equipment": "dozer", - "ground": "토사", - "volume_m3": 15.65, - "natural_m3": 17.39, - "conversion_c": 0.9, - "average_distance_m": 43.66, - "in_bill": True, - } - ] -} - - -def 줄들() -> dict: - rows = build_handoff(summary_table=집계, haul_table=운반)["work_items"] - return {row["name"]: row for row in rows} - - -def test_집계_쪽_운반은_내역_줄이_아니다() -> None: - rows = 줄들() - assert rows["도자운반"]["in_bill"] is False - assert rows["덤프운반"]["in_bill"] is False - - -def test_값은_남는다() -> None: - """⚠ 빼 버리면 검산이 안 된다 — 무대를 그렇게 둔 것과 같은 규칙.""" - assert 줄들()["도자운반"]["quantity"] == 17.39 - - -def test_사유가_적힌다() -> None: - reason = 줄들()["도자운반"]["in_bill_reason"] - assert "내역 줄은 운반표 쪽" in reason - - -def test_운반표_쪽은_그대로_선다() -> None: - row = 줄들()["불도저 운반"] - assert row["in_bill"] is True and row["work_item_code"] == "FP-10-11" - assert row["quantity"] == 17.39 - - -def test_토공_줄은_안_건드린다() -> None: - """⚠ 성토처럼 집계에만 있는 줄은 그대로 내역 줄이다 — 운반 계열만 가른다.""" - assert 줄들()["성토"]["in_bill"] is True diff --git a/resources/tester/test_b08_haul_inputs.py b/resources/tester/test_b08_haul_inputs.py deleted file mode 100644 index b05f61eca..000000000 --- a/resources/tester/test_b08_haul_inputs.py +++ /dev/null @@ -1,209 +0,0 @@ -"""유토곡선에 넘길 구조물 몫 — 채집석 공제 · 구조물 잔토 (2026-09-08). - -⚠ 겨누는 것 다섯 - ① 둘 다 **양수**로 낸다 — 빼고 더하는 것은 받는 쪽(B06) 몫 - ② ⚠ 「아직 안 옴(None)」과 「없음(0)」을 가른다 - ③ **측점별로도** 낸다 — 총량만 주면 잔량 비례로 흩어져 운반거리가 틀어진다 - ④ 구조물이 구간이라 **가운데 측점**을 자리로 본다 - ⑤ 칸 이름이 받는 쪽과 같은 낱말이다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( # noqa: E402 - STRUCTURE_SPOIL_KEY, - STRUCTURE_SPOIL_POINTS_KEY, - haul_inputs, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - COLLECTED_STONE_KEY, - build_table, -) - - -def 표(*ranges: tuple[float, float]) -> dict: - return build_table( - [ - { - "structure_id": f"s{index}", - "type_id": "masonry_wet", - "start_m": start, - "end_m": end, - "options": { - "height_m": 2.5, - "length_m": end - start, - "back_len_cm": 45, - "foundation": "기초유", - }, - } - for index, (start, end) in enumerate(ranges) - ] - ) - - -def test_둘_다_양수로_낸다() -> None: - got = haul_inputs(표((0.0, 10.0))) - assert got[COLLECTED_STONE_KEY] > 0 - assert got[STRUCTURE_SPOIL_KEY] > 0 - - -def test_아직_안_옴과_없음을_가른다() -> None: - """⚠ 0 으로 눅이면 받는 쪽이 「없다」와 「못 받았다」를 못 가른다.""" - 빈값 = haul_inputs(None) - assert 빈값[STRUCTURE_SPOIL_KEY] is None - assert 빈값[COLLECTED_STONE_KEY] is None - assert 빈값[STRUCTURE_SPOIL_POINTS_KEY] == [] - - -def test_측점별로도_낸다() -> None: - got = haul_inputs(표((0.0, 10.0), (100.0, 110.0))) - points = got[STRUCTURE_SPOIL_POINTS_KEY] - assert [row["chainage_m"] for row in points] == [5.0, 105.0] - assert abs(sum(row["spoil_m3"] for row in points) - got[STRUCTURE_SPOIL_KEY]) < 0.01 - - -def test_측점_차례로_선다() -> None: - """⚠ 받는 쪽이 잔량에 얹을 때 차례가 어긋나면 운반거리가 틀어진다.""" - points = haul_inputs(표((100.0, 110.0), (0.0, 10.0)))[STRUCTURE_SPOIL_POINTS_KEY] - assert [row["chainage_m"] for row in points] == [5.0, 105.0] - - -def test_칸_이름이_받는_쪽과_같다() -> None: - assert STRUCTURE_SPOIL_KEY == "structure_spoil_m3" - assert COLLECTED_STONE_KEY == "collected_stone_deduction_m3" - - -def test_지반_갈래를_함께_낸다() -> None: - """⚠ 새 판정이 아니라 **구조물터파기가 쓰는 그 값**이다 — 두 벌로 짜면 갈린다.""" - unit = build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": { - "height_m": 2.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - } - ], - None, - None, - {0.0: "ripping_rock", 10.0: "ripping_rock"}, - ) - point = haul_inputs(unit)[STRUCTURE_SPOIL_POINTS_KEY][0] - assert point["ground_type"] == "ripping_rock" - assert point["ground_label"] == "암절취" - assert "암절취" in point["ground_basis"] - - -def test_섞인_구조물은_모름으로_보낸다() -> None: - """⚠ 터파기에서 다수결로 안 고른 그 규칙 그대로 — 받는 쪽이 「모르는 몫」으로 드러낸다.""" - unit = build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": { - "height_m": 2.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - } - ], - None, - None, - {0.0: "soil", 10.0: "blasting_rock"}, - ) - point = haul_inputs(unit)[STRUCTURE_SPOIL_POINTS_KEY][0] - assert point["ground_type"] is None - assert "섞여" in point["ground_basis"] - - -def test_상태를_값으로_적어_보낸다() -> None: - """⚠⚠ 유토곡선 잔량은 **다짐상태**인데 이 값은 **자연상태**다 — 그냥 더하면 섞인다. - - 말로만 두면 다음 사람이 못 본다. 총량에도, 측점마다도 상태를 적는다. - """ - from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( - VOLUME_BASIS_KEY, - VOLUME_BASIS_NATURAL, - ) - - got = haul_inputs(표((0.0, 10.0))) - assert got[VOLUME_BASIS_KEY] == VOLUME_BASIS_NATURAL == "natural" - assert all(row[VOLUME_BASIS_KEY] == "natural" for row in got[STRUCTURE_SPOIL_POINTS_KEY]) - # 빈 값일 때도 칸은 있다 — 받는 쪽이 「상태를 모른다」와 「값이 없다」를 안 헷갈리게. - assert haul_inputs(None)[VOLUME_BASIS_KEY] == "natural" - - -def test_채집석을_갈래별로_낸다() -> None: - """⚠ 축을 맞추려면 갈래가 있어야 한다 — 받는 쪽이 ×C 로 다짐 축에 맞춰 뺀다.""" - from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( - COLLECTED_STONE_BY_GROUND_KEY, - COLLECTED_STONE_UNKNOWN_KEY, - ) - - unit = build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, - } - ], - None, - None, - {0.0: "ripping_rock", 10.0: "ripping_rock"}, - ) - got = haul_inputs(unit) - assert got[COLLECTED_STONE_BY_GROUND_KEY]["ripping_rock"] > 0 - assert got[COLLECTED_STONE_UNKNOWN_KEY] == 0.0 - # 갈래별 합이 총량과 같다 — 어느 쪽으로도 새지 않는다. - assert abs(sum(got[COLLECTED_STONE_BY_GROUND_KEY].values()) - got[COLLECTED_STONE_KEY]) < 0.01 - - -def test_갈래를_못_가른_몫은_따로_낸다() -> None: - """⚠ 계수가 없으므로 환산하지 않는다 — 받는 쪽이 그 사실을 알아야 한다.""" - from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( - COLLECTED_STONE_BY_GROUND_KEY, - COLLECTED_STONE_UNKNOWN_KEY, - ) - - unit = build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, - } - ], - None, - None, - {0.0: "soil", 10.0: "blasting_rock"}, - ) - got = haul_inputs(unit) - assert got[COLLECTED_STONE_BY_GROUND_KEY] == {} - assert got[COLLECTED_STONE_UNKNOWN_KEY] > 0 - - -def test_원문이_없다는_사실을_값과_함께_보낸다() -> None: - """⚠ 벽 입적 ↔ 원바닥 암 관계를 정한 원문이 없다 — 받는 쪽이 그 한계를 알아야 한다.""" - basis = haul_inputs(표((0.0, 10.0)))["collected_stone_basis"] - assert "원문이 없음" in basis and "실무 시트는 그냥 뺌" in basis diff --git a/resources/tester/test_b08_haul_state.py b/resources/tester/test_b08_haul_state.py deleted file mode 100644 index 21e9cca41..000000000 --- a/resources/tester/test_b08_haul_state.py +++ /dev/null @@ -1,84 +0,0 @@ -"""운반 줄은 자연상태로 선다 (2026-09-09). - -⚠⚠ **두 자가 섞여 있던 자리다.** 유토곡선은 **다짐상태**로 쌓고(운반거리를 그 기준으로 재야 - 맞는다) **내역서에 오르는 수량은 자연상태**다 — - `config_system_design` 5-4-3 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, - **내역서에 적용하는 수량은 자연상태로 한다**」. - -⚠ 겨누는 것 넷 - ① 되돌린 값(`natural_m3`)이 오면 **그 값으로 선다** - ② 쓴 계수를 근거에 적는다 — 되짚을 수 있어야 한다 - ③ ⚠ 되돌릴 계수가 없으면(갈래 못 붙임) **다짐 그대로 두고 사유를 낸다** — 토사 계수로 - 눅이면 근거 없이 금액이 움직인다 - ④ **거리는 안 바뀐다** — 거리는 다짐 기준으로 재는 것이 맞다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 - - -def 표(**extra: object) -> dict: - row = { - "equipment": "dump_truck", - "ground": "리핑암", - "volume_m3": 140.78, - "average_distance_m": 318.6, - "in_bill": True, - } - row.update(extra) - return {"rows": [row]} - - -def 줄(**extra: object) -> dict: - rows = build_handoff(haul_table=표(**extra))["work_items"] - return next(row for row in rows if row["name"] == "덤프 운반") - - -def test_되돌린_값이_오면_그것으로_선다() -> None: - row = 줄(natural_m3=122.42, conversion_c=1.15) - assert row["quantity"] == 122.42 - - -def test_쓴_계수가_근거에_적힌다() -> None: - row = 줄(natural_m3=122.42, conversion_c=1.15) - assert "÷ C 1.15" in row["spec_detail"] and "자연상태" in row["spec_detail"] - - -def test_되돌릴_계수가_없으면_다짐_그대로_두고_사유() -> None: - """⚠ 토사 계수로 눅이면 근거 없이 금액이 움직인다.""" - row = 줄() - assert row["quantity"] == 140.78 - assert "다짐상태 그대로" in row["spec_detail"] - - -def test_거리는_안_바뀐다() -> None: - """거리는 다짐 기준으로 재는 것이 맞다 — 상태를 되돌려도 그대로다.""" - assert 줄(natural_m3=122.42, conversion_c=1.15)["haul_distance_m"] == 318.6 - - -def test_갈래를_통로로_보낸다() -> None: - """⚠⚠ 운반 단가가 지반 갈래로 갈리는데 `spec` 문자열만 보내면 받는 쪽이 못 고른다. - - 「버림」에서 275,584원이 사라졌던 그 자리와 같다 — 갈래는 `variant_value` 한 통로로만. - """ - row = 줄(natural_m3=122.42, conversion_c=1.15) - assert row["variant_axis"] == "ground_class" - assert row["variant_value"] == "리핑암" - # ⚠ 이름은 우리 갈래 그대로 — 일위대가의 「파쇄암」과는 인계본 별칭이 이어 준다. - assert row["spec"] == "리핑암" - - -def test_갈래가_없는_줄은_칸이_비어_있다() -> None: - """무대처럼 갈래가 없는 자리에 빈 문자열을 넣지 않는다 — `None` 이라야 「없음」이다.""" - rows = build_handoff(haul_table={"rows": [{"equipment": "dozer", "volume_m3": 10.0}]})[ - "work_items" - ] - row = next(r for r in rows if r["name"] == "불도저 운반") - assert row["variant_axis"] is None and row["variant_value"] is None diff --git a/resources/tester/test_b08_haul_summary.py b/resources/tester/test_b08_haul_summary.py deleted file mode 100644 index 5f660d0bb..000000000 --- a/resources/tester/test_b08_haul_summary.py +++ /dev/null @@ -1,309 +0,0 @@ -"""운반 가중평균 검사 — PLAN 8-3·8-7 ㉡. - -실무는 (운반수단 × 지반유형)별 **가중평균 1개**를 내역에 올린다. 단순평균이 아니다. -무대는 값은 내되 **내역 줄이 되지 않는다**(품셈 1-2-7). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( # noqa: E402 - build_table, - check_against_plan, - summary_input_rows, -) - - -def plan() -> dict: - """띠 셋 + 장거리 이동 하나. 지반유형은 `HaulPlan` 이 이미 안분해 둔 값이다.""" - return { - "blocks": [ - { - "bands": [ - { - "equipment": "free_haul", - "haul_distance_m": 12.0, - "haul_from_m": 0.0, - "haul_to_m": 12.0, - "ea_m3": 800.0, - "rr_m3": 0.0, - "br_m3": 0.0, - }, - { - "equipment": "dozer", - "haul_distance_m": 40.0, - "haul_from_m": 20.0, - "haul_to_m": 60.0, - "ea_m3": 600.0, - "rr_m3": 400.0, - "br_m3": 0.0, - }, - { - "equipment": "dozer", - "haul_distance_m": 50.0, - "haul_from_m": 60.0, - "haul_to_m": 110.0, - "ea_m3": 400.0, - "rr_m3": 0.0, - "br_m3": 0.0, - }, - ] - } - ], - "transfers": [ - { - "equipment": "dump_truck", - "haul_distance_m": 300.0, - "from_m": 100.0, - "to_m": 400.0, - "ea_m3": 1000.0, - "rr_m3": 0.0, - "br_m3": 500.0, - } - ], - "hauled_m3": 2200.0, - "transferred_m3": 1500.0, - } - - -def test_가중평균이지_단순평균이_아님() -> None: - """도자 토사 = 600㎥@40m + 400㎥@50m → (600·40+400·50)/1000 = 44m.""" - rows = build_table(plan())["rows"] - dozer_soil = next(r for r in rows if r["equipment"] == "dozer" and r["ground"] == "토사") - assert dozer_soil["volume_m3"] == pytest.approx(1000.0) - assert dozer_soil["average_distance_m"] == pytest.approx(44.0) - assert dozer_soil["average_distance_m"] != pytest.approx(45.0) # 단순평균이면 45 - - -def test_지반유형별로_갈림() -> None: - """`HaulPlan` 이 안분해 둔 토사·리핑암·발파암을 그대로 쓴다 — 다시 판정하지 않는다.""" - rows = build_table(plan())["rows"] - dozer = {r["ground"] for r in rows if r["equipment"] == "dozer"} - assert dozer == {"토사", "리핑암"} - dump = {r["ground"] for r in rows if r["equipment"] == "dump_truck"} - assert dump == {"토사", "발파암"} - - -def test_무대는_내역줄이_아님() -> None: - """품셈 1-2-7 — 소운반 20m 는 품에 포함. 인력운반 10-6 도 「초과분」이다.""" - table = build_table(plan()) - free = next(r for r in table["rows"] if r["equipment"] == "free_haul") - assert free["in_bill"] is False - assert free["volume_m3"] == pytest.approx(800.0) # 값은 낸다 - assert all(r["in_bill"] for r in table["rows"] if r["equipment"] != "free_haul") - - -def test_내역줄_개수는_무대를_뺀_수() -> None: - table = build_table(plan()) - assert table["bill_row_count"] == len([r for r in table["rows"] if r["in_bill"]]) - assert table["bill_row_count"] == 4 # 도자 토사·리핑암 · 덤프 토사·발파암 - - -def test_근거줄이_함께_나옴() -> None: - """어느 구간이 그 평균을 만들었는지 되짚을 수 있어야 한다.""" - table = build_table(plan()) - legs = table["legs"] - assert len(legs) == 6 # 무대1 + 도자3 + 덤프2 - assert {leg["source"] for leg in legs} == {"band", "transfer"} - dozer_legs = [leg for leg in legs if leg["equipment"] == "dozer" and leg["ground"] == "토사"] - assert sorted(leg["distance_m"] for leg in dozer_legs) == [40.0, 50.0] - - -def test_줄_순서가_늘_같음() -> None: - """내역 줄 순서가 매번 달라지면 대조를 못 한다.""" - first = [(r["equipment"], r["ground"]) for r in build_table(plan())["rows"]] - second = [(r["equipment"], r["ground"]) for r in build_table(plan())["rows"]] - assert first == second - assert first[0][0] == "free_haul" # 무대가 맨 앞 - - -def test_검산_무대를_넣어야_합이_맞음() -> None: - """무대를 안 내면 이 대조가 죽는다 — 그래서 값은 내고 내역 줄만 뺀다.""" - table = build_table(plan()) - check = check_against_plan(table, plan()) - assert check.hauled_total_m3 == pytest.approx(3700.0) # 800+1000+400+1000+500 - assert check.plan_total_m3 == pytest.approx(3700.0) - assert check.difference_m3 == pytest.approx(0.0) - assert check.details["free_haul"] == pytest.approx(800.0) - - -def test_집계표_입력으로_줄임() -> None: - """토공집계표는 근거 줄을 안 쓴다 — 내역 줄만 넘긴다. - - ⚠ 2026-09-09 에 칸이 **둘 늘었다** — 다짐·자연 두 상태를 함께 넘긴다 - (`natural_m3`·`volume_basis`). 내역서 수량은 자연상태라(config 5-4-3) 받는 쪽이 - 어느 상태인지 스스로 봐야 한다. 근거 줄(`legs`)을 안 넘긴다는 계약은 그대로다. - """ - rows = summary_input_rows(build_table(plan())) - assert all( - set(row) - == { - "equipment", - "ground", - "volume_m3", - "average_distance_m", - "natural_m3", - "volume_basis", - # 쓴 계수도 함께 온다 — 받는 쪽이 되짚을 수 있어야 한다. - "conversion_c", - # 2026-09-14 ㉱ — 구성비로 가른 암 갈래(안 가른 줄은 None) · 집계표 공종 칸이 씀. - "rock_class", - } - for row in rows - ) - assert len(rows) == 5 # 무대 포함(집계표에는 오른다) - - -def test_계획이_비면_빈_표() -> None: - table = build_table(None) - assert table["rows"] == [] - assert table["legs"] == [] - - -# ── 저장 정본 모양 (2026-09-07 실증에서 잡은 자리) ────────────────── -# -# ⚠ 정본에 저장되는 것은 유토곡선 한 벌(`mass_haul`)이고 **배분은 그 안의 `haul_plan`** 이다. -# 바깥 껍데기를 그대로 넘기면 `blocks` 를 못 찾아 **운반 표가 영영 0줄**이 된다. -# [확정] 전에는 어차피 빈 표라 화면에서 티가 안 나던 자리다. - - -def 저장정본() -> dict: - """서버 계산이 실제로 내는 모양 — `mass_haul` 바깥에 `haul_plan` 이 들어 있다.""" - return { - "basis": "cross", - "cut_natural_m3": 5000.0, - "haul_plan": { - "hauled_m3": 3724.0, - "blocks": [ - { - "bands": [ - { - "equipment": "free_haul", - "haul_distance_m": 10.0, - "ea_m3": 3.9, - "rr_m3": 0, - "br_m3": 0, - }, - { - "equipment": "dozer", - "haul_distance_m": 40.0, - "ea_m3": 7.8, - "rr_m3": 0, - "br_m3": 0, - }, - { - "equipment": "dump_truck", - "haul_distance_m": 288.12, - "ea_m3": 3712.29, - "rr_m3": 0, - "br_m3": 0, - }, - ] - } - ], - "transfers": [], - }, - } - - -def test_바깥_껍데기를_그대로_주면_빈_표가_된다() -> None: - """⚠ 이 시험이 그 사고를 못 박는다 — 빈 표는 「계획이 없다」와 구별이 안 된다.""" - assert build_table(저장정본())["rows"] == [] - - -def test_haul_plan_을_벗겨_주면_세_줄이_선다() -> None: - table = build_table(저장정본()["haul_plan"]) - equipment = [ - row.equipment if hasattr(row, "equipment") else row["equipment"] for row in table["rows"] - ] - assert equipment == ["free_haul", "dozer", "dump_truck"] - assert table["bill_row_count"] == 2 # 무대는 내역 줄이 아니다 - - -# ── 암 운반 (2026-09-07 실증) ──────────────────────────────────────── -# -# 앞선 실증에서는 토사만 나와 **(수단 × 지반유형) 갈래가 실물로 갈리는 것을 못 봤다**. -# 암이 유용토에 실리는 노선으로 다시 돌려 6줄로 갈리는 것과 보정계수가 물리는 것을 확인했고, -# 그 모양을 여기 못 박는다. - - -def 암_운반계획() -> dict: - """서버 계산이 실제로 낸 모양(리핑암 노선) — 띠마다 지반별 물량이 함께 온다.""" - return { - "hauled_m3": 7179.0, - "blocks": [ - { - "bands": [ - { - "equipment": "free_haul", - "haul_distance_m": 10.0, - "ea_m3": 14.16, - "rr_m3": 18.09, - "br_m3": 0.0, - }, - { - "equipment": "dozer", - "haul_distance_m": 40.0, - "ea_m3": 28.32, - "rr_m3": 36.19, - "br_m3": 0.0, - }, - { - "equipment": "dump_truck", - "haul_distance_m": 439.92, - "ea_m3": 988.08, - "rr_m3": 6094.15, - "br_m3": 0.0, - }, - ] - } - ], - "transfers": [], - } - - -def test_수단과_지반유형으로_갈려_여섯_줄이_섬() -> None: - table = build_table(암_운반계획()) - pairs = [(row["equipment"], row["ground"]) for row in table["rows"]] - assert ("dump_truck", "리핑암") in pairs - assert ("free_haul", "리핑암") in pairs - assert len(table["rows"]) == 6 - - -def test_암도_무대는_내역에_안_섬() -> None: - """지반이 암이어도 무대는 품에 포함이다 — 지반유형이 그 규칙을 바꾸지 않는다.""" - table = build_table(암_운반계획()) - free = [row for row in table["rows"] if row["equipment"] == "free_haul"] - assert len(free) == 2 # 토사·리핑암 - assert all(not row["in_bill"] for row in free) - assert table["bill_row_count"] == 4 - - -def test_지반별_합이_총_운반토량과_맞을것() -> None: - table = build_table(암_운반계획()) - total = sum(row["volume_m3"] for row in table["rows"]) - assert total == pytest.approx(7179.0, abs=0.02) # 띠 값 자체의 반올림 몫 - - -def test_보정계수가_다짐환산에_물릴것() -> None: - """⚠ 실증 산수 — 리핑암 1.15 · 발파암 1.30 이 그대로 곱해진다. - - 자연토량 토사 1,240 · 암 5,560 인 같은 노선에서 - 리핑암: 1,240×0.9 + 5,560×1.15 = 1,116 + 6,394 = 7,510 - 발파암: 1,240×0.9 + 5,560×1.30 = 1,116 + 7,228 = 8,344 - 서버 계산이 낸 값과 자릿수까지 맞았다. 계수가 바뀌면 이 시험이 깨진다. - """ - from config.config_system_design import EARTHWORK_CONVERSION_FACTORS as F - - assert F["soil"]["compacted"] == 0.9 - assert F["ripping_rock"]["compacted"] == 1.15 - assert F["blasting_rock"]["compacted"] == 1.30 - assert 1240 * 0.9 + 5560 * 1.15 == pytest.approx(7510.0) - assert 1240 * 0.9 + 5560 * 1.30 == pytest.approx(8344.0) diff --git a/resources/tester/test_b08_library_name_tag.py b/resources/tester/test_b08_library_name_tag.py deleted file mode 100644 index eb2be8443..000000000 --- a/resources/tester/test_b08_library_name_tag.py +++ /dev/null @@ -1,56 +0,0 @@ -"""10-A ⑫ 라이브러리 항목 이름표 = 「종류 + 제원 요약」 자동 · 사용자가 고칠 수 있음 (2026-09-14 사용자 확정). - -코드는 난수라 이름이 흔들려도 안전 — 이름은 고르개에 보이는 글자일 뿐. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -from pydantic import ValidationError - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 - - -def _saved(folder: Path, code: str) -> dict: - return json.loads((folder / f"{code}.json").read_text(encoding="utf-8")) - - -def test_저장_때_적은_이름표가_실린다(tmp_path: Path) -> None: - template = load_template("masonry_wet") - code = library_module.save_personal( - tmp_path, template, None, None, "personal", name=" 돌쌓기(찰) H=2.5 1:0.3 봉화 " - ) - assert _saved(tmp_path, code)["name"] == "돌쌓기(찰) H=2.5 1:0.3 봉화" - assert template["name"] == load_template("masonry_wet")["name"] # 원본 안 건드림 - - -def test_이름표를_비우면_양식_이름_그대로(tmp_path: Path) -> None: - template = load_template("masonry_wet") - code = library_module.save_personal(tmp_path, template, None, None, "personal", name=" ") - assert _saved(tmp_path, code)["name"] == template["name"] - - -def test_요청은_이름표를_받고_길이를_막는다() -> None: - request = router_module.LibrarySaveRequest.model_validate({"sheet_key": "k", "name": "가"}) - assert request.name == "가" - with pytest.raises(ValidationError): - router_module.LibrarySaveRequest.model_validate({"sheet_key": "k", "name": "가" * 201}) - source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_StructureSheet.py").read_text("utf-8") - assert "payload.name" in source - - -def test_저장_발행_창이_종류_제원_요약을_이름표로_제안한다() -> None: - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text("utf-8") - assert ui.count("window.prompt(") == 2 # [내 라이브러리에 저장] · [발행] - assert "options.defaultName" in ui and "name: tag" in ui - sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text("utf-8") - assert "defaultName: sheet.title" in sheet_ui diff --git a/resources/tester/test_b08_library_share.py b/resources/tester/test_b08_library_share.py deleted file mode 100644 index 47feb08c1..000000000 --- a/resources/tester/test_b08_library_share.py +++ /dev/null @@ -1,108 +0,0 @@ -"""라이브러리 공유 — 같은 회사 동료에게 내 항목을 복사해 보냄 (PLAN 4장 · 2026-09-14 브레인 판정 ①). - -⚠ 받는 쪽이 모르게 들어가지 않음 — 받은 항목은 그 사람 개인 단을 덮지 않고 **「받음」 단**에 따로 섬 - (개인 단은 종류당 하나라 그대로 넣으면 동료가 고친 내 것이 조용히 덮임). 받은 뒤 가져오기·복제는 받는 사람 몫. -⚠ 같은 회사 안만 · 출처 공사명은 그대로(같은 회사라 그 공사를 앎 · 판정 ③). -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 -from common_util.common_util_auth import verify_session # noqa: E402 - -LIB = "/api/projects/33333333-3333-3333-3333-333333333333/quantity/structure-sheets/library" -MINE = "AX-ST-0000aaaa" - - -@pytest.fixture() -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage")) - item = { - **load_template("masonry_wet"), - "code": MINE, - "library_tier": "personal", - "name": "내 돌쌓기", - "origin": {"project": "봉화 임도"}, - } - folder = tmp_path / "storage" / "7" / "42" / "library" - folder.mkdir(parents=True) - (folder / f"{MINE}.json").write_text(json.dumps(item), encoding="utf-8") - # 받는 사람(43)도 같은 종류 내 것이 있음 — 덮이면 안 됨 - theirs = tmp_path / "storage" / "7" / "43" / "library" - theirs.mkdir(parents=True) - (theirs / "AX-ST-0000bbbb.json").write_text( - json.dumps({**item, "code": "AX-ST-0000bbbb", "name": "동료 것"}), encoding="utf-8" - ) - - async def members(company_id: int) -> list[dict]: - assert company_id == 7 - return [ - {"id": 42, "name": "나", "email": "me@x"}, - {"id": 43, "name": "동료", "email": "you@x"}, - ] - - monkeypatch.setattr(router_module, "_company_members", members) - app = FastAPI() - app.include_router(router_module.router) - app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} - return TestClient(app) - - -def test_동료_목록은_같은_회사에서_나를_뺀다(client: TestClient) -> None: - body = client.get(f"{LIB}/colleagues").json() - assert body["colleagues"] == [{"id": 43, "name": "동료"}] - - -def test_보내면_받는_쪽_받음_단에_서고_그_사람_개인_단은_안_덮인다( - client: TestClient, tmp_path: Path -) -> None: - sent = client.put( - f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 43} - ) - assert sent.status_code == 200, sent.text - received = tmp_path / "storage" / "7" / "43" / "library_received" / f"{MINE}.json" - saved = json.loads(received.read_text(encoding="utf-8")) - assert saved["library_tier"] == "received" - assert saved["received_from"]["name"] == "나" and saved["origin"]["project"] == "봉화 임도" - theirs = tmp_path / "storage" / "7" / "43" / "library" / "AX-ST-0000bbbb.json" - assert json.loads(theirs.read_text(encoding="utf-8"))["name"] == "동료 것" - dirs = library_module.tier_dirs(7, 43) - listed = library_module.list_items(dirs, "masonry_wet") - assert [i["tier"] for i in listed][:2] == ["personal", "received"] - - -def test_회사_밖이나_내_것_아닌_항목은_못_보낸다(client: TestClient) -> None: - stranger = client.put( - f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 99} - ) - assert stranger.status_code == 404 - missing = client.put( - f"{LIB}/share", json={"type_id": "masonry_wet", "code": "AX-ST-0000cccc", "to_user_id": 43} - ) - assert missing.status_code == 404 - myself = client.put( - f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 42} - ) - assert myself.status_code == 400 - - -def test_화면에_보내기_단추와_받음_표시() -> None: - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( - encoding="utf-8" - ) - assert "/share" in ui and "/colleagues" in ui and "동료에게 보내기" in ui - assert 'received: "받음"' in ui diff --git a/resources/tester/test_b08_master_basis_person.py b/resources/tester/test_b08_master_basis_person.py deleted file mode 100644 index 9e28d2a75..000000000 --- a/resources/tester/test_b08_master_basis_person.py +++ /dev/null @@ -1,84 +0,0 @@ -"""마스터 밑수 판독 — 「인」과 「칸 이어붙임」 두 자리 (2026-09-09). - -⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다.** 원문 대조로 넷이 오독으로 드러났다 — - 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」(그 「인」은 **소요인력**) · - 13-2-4 야면석 채집 「(단위: 인 당)」(표 안이 **㎡당·㎥당** 두 줄). - ⚠ 2026-09-18 **2-2-5 천공기도 뒤집혔다** — 「천공인부 1인당 1대」의 「1인당」도 밑수가 아니었다 - (PDF 대조 · 손료계수표라 애초에 밑수가 없음). ⇒ 「인」은 **숫자가 붙어도** 밑수가 아니다. - -⚠⚠ **칸을 이어 붙여 읽지 않는다.** 앞 칸의 **값**과 뒤 칸의 **단위**가 붙어 없는 밑수가 생겼다 — - 13-2-4 … 0.28 | **0.36** / **㎥당** | 0.60 … ⇒ 「0.36㎥당」(원문에 없는 값) - 5-19-2 15 | **30**(표토두께 ㎝) / **㎥ 당** | … ⇒ 「30㎥당」(두께를 밑수로 읽음) -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( # noqa: E402 - detect_basis, - person_basis_ok, -) - -MASTER = json.loads( - (ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json").read_text( - encoding="utf-8" - ) -) - - -def 밑수(code: str) -> list[tuple]: - for item in MASTER["work_items"]: - if item["work_item_code"] == code: - return [ - (t.get("basis_unit"), t.get("basis_quantity")) for t in (item.get("tables") or []) - ] - return [] - - -def test_숫자가_붙어야_인을_인정한다() -> None: - assert person_basis_ok("1", "인") is True - assert person_basis_ok("", "인") is False - assert person_basis_ok(None, "인") is False - # 「인」이 아닌 단위는 숫자가 없어도 그대로 — 종전 동작을 안 바꾼다. - assert person_basis_ok("", "㎥") is True - - -def test_방제_둘은_밑수가_안_선다() -> None: - """「(단위 : 인)」은 소요인력이지 밑수가 아니다 — 미확보가 정직하다.""" - assert all(unit != "인" for unit, _ in 밑수("FP-08-06-02")) - assert all(unit != "인" for unit, _ in 밑수("FP-08-06-03")) - - -def test_천공기도_인은_밑수가_아니다() -> None: - """⚠ 2026-09-18 **뒤집음** — 「천공인부 1인당 1대」의 「1인당」도 밑수가 아니었다. - - 앞서는 「숫자가 붙었으니 인정」으로 두었으나, 안티그래비티 PDF 대조로 그 글이 - **기계 한 대에 붙는 인원** 말임이 드러났다(2-2-5 는 손료계수표라 애초에 밑수가 없다). - ⇒ 「인은 밑수가 아니다」는 2026-09-09 판정이 **넓게 옳았다** — 사람 판정으로 비워 둔다. - """ - assert 밑수("FP-02-02-05") == [(None, None)] - - -def test_칸을_이어_붙여_읽지_않는다() -> None: - """앞 칸 값 + 뒤 칸 단위 = 없는 밑수. 두 실례를 그대로 잰다.""" - table = { - "headers": ["뒷 길 이(㎝)", "25", "35", "45", "55", "60", ""], - "rows": [["인 부", "㎡당", "0.11", "0.17", "0.22", "0.28", "0.36"], ["㎥당", "0.60"]], - } - assert detect_basis(table) == (None, None) - 두께표 = { - "headers": ["구 분", "직 종", "표 토 두 께(㎝)", "비고", ""], - "rows": [["15", "30", "", "", ""], ["㎥ 당", "보통인부", "0.14", "0.11", ""]], - } - assert 두께표 and detect_basis(두께표) == (None, None) - - -def test_한_칸_안의_밑수는_그대로_읽는다() -> None: - """「100㎥당」처럼 한 칸에 다 있는 것은 종전대로 선다.""" - assert detect_basis({"headers": ["(단위: 100㎥당)"], "rows": []}) == (100.0, "㎥") diff --git a/resources/tester/test_b08_material_concrete.py b/resources/tester/test_b08_material_concrete.py deleted file mode 100644 index 8866d941c..000000000 --- a/resources/tester/test_b08_material_concrete.py +++ /dev/null @@ -1,100 +0,0 @@ -"""콘크리트를 자재 축에 세움 · 할증 이름 잇기 (2026-09-09 확정 3차 ⑥). - -⚠⚠ **재료비가 통째로 빠져 있던 자리다.** 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다 - (서브 일위대가도 재료 0원). 콘크리트를 자재 축에 안 보내면 레미콘 값이 어디에도 없다. - -⚠ 겨누는 것 여섯 - ① 콘크리트 셋이 자재총괄에 선다 - ② 배합은 여전히 분해 안 함(㉢) — 「콘크리트 ㎥」에서 멈춘다 - ③ ⚠ 타설 줄과 **겹치지 않는다** — 그쪽은 품, 이쪽은 재료 - ④ 할증은 **레미콘일 때만** 붙는다 — 비빔은 시멘트·골재가 각각 할증됨 - ⑤ 이형철근 D13·D16 은 할증표 「이형철근」 줄로 이어진다(품셈 1-3-1, 규격 안 가림) - ⑥ ⚠ 이름을 **바꾸지 않는다** — 이름만 잇는다(리핑암↔파쇄암과 같은 처방) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 - build_table as build_material, -) -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import surcharge_lookup_name # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - - -def 단위표() -> dict: - return build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": { - "height_m": 2.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - } - ] - ) - - -def 자재(method: str | None = None) -> dict: - return { - row["name"]: row for row in build_material(단위표(), concrete_placing_method=method)["rows"] - } - - -def test_콘크리트가_자재총괄에_선다() -> None: - rows = 자재() - assert rows["채움콘크리트"]["net_amount"] > 0 - assert rows["버림콘크리트"]["net_amount"] > 0 - - -def test_배합은_분해하지_않는다() -> None: - """⚠ 시멘트·모래·자갈로 쪼개면 B09 일위대가와 두 배가 된다(㉢).""" - rows = 자재() - assert not ({"시멘트", "모래", "자갈"} & set(rows)) - - -def test_타설_줄과_겹치지_않는다() -> None: - """타설은 품, 자재는 재료 — 둘 다 서되 같은 것을 두 번 세지 않는다.""" - rows = build_handoff(unit_quantity_table=단위표())["work_items"] - placing = [row for row in rows if row["name"] == "콘크리트 타설"] - assert len(placing) == 1 - assert all(row["name"] != "채움콘크리트" for row in rows) - - -def test_할증은_레미콘일_때만_붙는다() -> None: - """⚠ 비빔은 시멘트·골재가 각각 할증되는 자리라 레미콘 할증을 붙이면 틀린다.""" - 레미콘 = 자재("ready_mixed")["채움콘크리트"] - 비빔 = 자재("machine_mixed")["채움콘크리트"] - # ⚠ 「안 정함」은 비빔이 아니다 — 타설 줄이 기본값(레디믹스트)으로 도는데 할증만 - # 미확보로 두면 같은 프로젝트에서 두 값이 어긋난다(실화면에서 걸린 자리). - 안정함 = 자재(None)["채움콘크리트"] - assert 안정함["surcharge_pct"] is not None - assert "기본값" in 안정함["note"] - assert 레미콘["surcharge_pct"] is not None - assert 비빔["surcharge_pct"] is None - assert "레미콘이 아니라" in 비빔["note"] or "레미콘이 아니라" in str(비빔) - - -def test_이형철근은_규격을_안_가린다() -> None: - """품셈 1-3-1 「이형철근」 줄 하나가 D13·D16 을 다 받는다.""" - assert surcharge_lookup_name("이형철근 D13", None)[0] == "이형철근" - assert surcharge_lookup_name("이형철근 D16", None)[0] == "이형철근" - - -def test_이름을_바꾸지_않는다() -> None: - """⚠ 줄 이름이 바뀌면 타설 줄·묶음 조각이 성분을 못 찾는다 — 찾을 때만 다른 이름을 쓴다.""" - rows = 자재("ready_mixed") - assert "레미콘" not in rows - assert rows["채움콘크리트"]["name"] == "채움콘크리트" diff --git a/resources/tester/test_b08_material_summary.py b/resources/tester/test_b08_material_summary.py deleted file mode 100644 index 53ccd9f85..000000000 --- a/resources/tester/test_b08_material_summary.py +++ /dev/null @@ -1,357 +0,0 @@ -"""자재 총괄표 검사 — PLAN 8-2·8-3·8-7. - -이 일감의 위험은 계산이 아니라 **할증을 두 번 붙이는 것**과 **모르는 값을 0 으로 넘기는 것**이다. - ㉠ 할증은 여기 한 번뿐 — 앞 단계가 붙였으면 경고가 떠야 한다. - · 표에 없는 자재를 0 % 로 조용히 넘기면 빠뜨린 것과 구별이 안 된다. - · 관급/사급은 발주 결정이라 지어내지 않는다 — 안 정하면 「미분류」로 드러난다. - · 할증 전·후를 둘 다 남긴다 — 하나만 넘기면 B09 가 역산한다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 - INSTALL_BY_CONTRACTOR, - INSTALL_BY_OWNER, - NOTE_INCLUDED, - NOTE_INSTALL_BY_MISSING, - NOTE_RATE_MISSING, - SUPPLY_CONTRACTOR, - SUPPLY_OWNER, - SUPPLY_UNKNOWN, - SurchargeTable, - build_table, - load_surcharge_table, - verify_single_surcharge, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table # noqa: E402 - - -def 성분(name: str, unit: str, amount: float, destination: str = "material", **extra) -> dict: - return {"name": name, "unit": unit, "amount": amount, "destination": destination, **extra} - - -def 원단위표(*components: dict, surcharge_applied: bool = False) -> dict: - """원단위 엔진이 내는 모양 그대로.""" - return { - "structures": [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "name": "돌쌓기(찰)", - "components": list(components), - } - ], - "surcharge_applied": surcharge_applied, - } - - -def 줄(table: dict, name: str) -> dict: - return next(row for row in table["rows"] if row["name"] == name) - - -# ── ㉠ 할증은 여기 한 번뿐 ────────────────────────────────────────── - - -def test_앞단계가_붙였으면_경고() -> None: - assert verify_single_surcharge({"surcharge_applied": True}) - assert not verify_single_surcharge({"surcharge_applied": False}) - - -def test_경고가_표에_실릴것() -> None: - """검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다.""" - table = build_table(원단위표(성분("시멘트", "㎏", 100.0), surcharge_applied=True)) - assert table["double_count_warnings"] - - -def test_원단위_엔진_출력을_그대로_받음() -> None: - """두 엔진이 실제로 맞물리는지 — 모양이 어긋나면 여기서 깨진다.""" - unit = build_unit_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 35.0, - "end_m": 45.0, - "options": {"height_m": 1.5, "length_m": 10.0}, - } - ] - ) - table = build_table(unit) - assert table["row_count"] > 0 - assert not table["double_count_warnings"] - # 돌은 자재, 터파기는 토공 — 표에는 자재만 온다. - # ⚠ 종류를 안 고른 구조물의 돌 줄 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09). - assert any(row["name"] == "돌" for row in table["rows"]) - assert not any(row["name"] == "터파기" for row in table["rows"]) - - -def test_이_표가_할증을_붙인_곳임을_못박음() -> None: - """B09 가 다시 붙이지 않도록 깃발을 남긴다.""" - assert build_table(원단위표(성분("모래", "㎥", 1.0)))["surcharge_applied"] is True - - -def test_품셈에_포함된_항목은_또_붙이지_않음() -> None: - """품셈 1-3-1 단서 — 「할증이 포함ㆍ표시된 경우 중복 적용 금지」.""" - table = build_table(원단위표(성분("모래", "㎥", 10.0, surcharge_included=True))) - row = 줄(table, "모래") - assert row["total_amount"] == pytest.approx(10.0) - assert row["note"].split(" · ")[0] == NOTE_INCLUDED # 뒤는 「기본 사급」 - - -# ── destination 가르기 ────────────────────────────────────────────── - - -def test_material_만_모음() -> None: - table = build_table( - 원단위표( - 성분("야면석", "㎥", 5.0), - 성분("터파기", "㎥", 3.0, destination="earthwork"), - 성분("모르터", "㎥", 0.1, destination="unit_price"), - ) - ) - assert [row["name"] for row in table["rows"]] == ["야면석"] - - -def test_거른_것을_버리지_않고_세어_보임() -> None: - table = build_table( - 원단위표( - 성분("터파기", "㎥", 3.0, destination="earthwork"), - 성분("되메우기", "㎥", 1.0, destination="earthwork"), - 성분("모르터", "㎥", 0.1, destination="unit_price"), - ) - ) - assert table["skipped_by_destination"] == {"earthwork": 2, "unit_price": 1} - - -def test_같은_자재는_구조물을_넘어_합쳐짐() -> None: - table = build_table( - { - "structures": [ - {"name": "A", "components": [성분("야면석", "㎥", 2.0)]}, - {"name": "B", "components": [성분("야면석", "㎥", 3.0)]}, - ], - "surcharge_applied": False, - } - ) - row = 줄(table, "야면석") - assert row["net_amount"] == pytest.approx(5.0) - assert row["sources"] == ["A", "B"] - - -def test_단위가_다르면_다른_줄() -> None: - table = build_table( - 원단위표(성분("떼", "㎡", 100.0), 성분("떼", "매", 50.0)), - ) - assert table["row_count"] == 2 - - -def test_규격이_다르면_다른_줄이고_옛_관급_열쇠도_받음() -> None: - """돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음). - - ⚠ 옛 설정 열쇠 — 종류 이름(「야면석·호박돌」)·이름만(「채움콘크리트」)도 받아야 - 사용자가 정한 값이 안 사라짐. - """ - table = build_table( - 원단위표( - 성분("돌", "ton", 3.0, spec="야면석·호박돌"), - 성분("돌", "ton", 2.0, spec="깬잡석"), - 성분("채움콘크리트", "㎥", 1.0, spec="180"), - 성분("채움콘크리트", "㎥", 4.0, spec="210"), - ), - supply_map={ - "야면석·호박돌": SUPPLY_CONTRACTOR, # 옛 열쇠(종류 이름) - "돌 깬잡석": SUPPLY_OWNER, # 새 열쇠(이름 + 규격) - "채움콘크리트": SUPPLY_CONTRACTOR, # 옛 열쇠(이름만) - }, - ) - by = {(r["name"], r["spec"]): r for r in table["rows"]} - assert table["row_count"] == 4 - assert by[("돌", "야면석·호박돌")]["net_amount"] == pytest.approx(3.0) - assert by[("돌", "야면석·호박돌")]["supply"] == SUPPLY_CONTRACTOR - assert by[("돌", "깬잡석")]["supply"] == SUPPLY_OWNER - assert by[("돌", "깬잡석")]["supply_key"] == "돌 깬잡석" - assert by[("채움콘크리트", "210")]["supply"] == SUPPLY_CONTRACTOR - assert table["missing_install_by_materials"] == ["돌 깬잡석"] - - -def test_바닥막이_메붙임_돌은_규격에_야면석() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill - - components, _notes = bed_sill(10.0, {"form": "돌붙임(메)"}) - stone = next(c for c in components if c.unit == "ton") - assert (stone.name, stone.spec, stone.destination) == ("돌", "야면석", "material") - - -# ── 할증률은 데이터에서, 없으면 드러낸다 ──────────────────────────── - - -def test_실제_데이터판이_읽힘() -> None: - table = load_surcharge_table() - assert table.effective_date - assert "시멘트" in table.material_names - - -def test_할증률이_코드에_없고_표에서_옴() -> None: - """표를 갈아 끼우면 결과가 따라간다 — 값이 코드에 박혀 있으면 안 바뀐다.""" - fake = SurchargeTable( - effective_date="9999-01-01", rates={"모래": {"material": "모래", "rate": 50}} - ) - row = 줄(build_table(원단위표(성분("모래", "㎥", 10.0)), surcharge_table=fake), "모래") - assert row["surcharge_pct"] == 50 - assert row["total_amount"] == pytest.approx(15.0) - - -def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None: - """0 % 로 조용히 넘기면 「할증 없음」과 「값을 못 찾음」이 구별되지 않는다.""" - table = build_table(원단위표(성분("낯선자재", "㎥", 10.0))) - row = 줄(table, "낯선자재") - assert row["surcharge_pct"] is None - assert row["note"].split(" · ")[0] == NOTE_RATE_MISSING # 뒤는 「기본 사급」 - assert "낯선자재" in table["missing_rate_materials"] - # 값은 잃지 않는다 — 순수량 그대로 둔다. - assert row["total_amount"] == pytest.approx(10.0) - - -def test_데이터_파일이_없으면_전부_미확보로_드러남() -> None: - empty = SurchargeTable() - table = build_table(원단위표(성분("모래", "㎥", 1.0)), surcharge_table=empty) - assert table["missing_rate_materials"] == ["모래"] - - -def test_출처판을_응답에_남김() -> None: - """어느 판으로 계산했는지 표가 스스로 말한다.""" - assert build_table(원단위표(성분("모래", "㎥", 1.0)))["surcharge_dataset"]["effective_date"] - - -# ── 할증 전·후 둘 다 ──────────────────────────────────────────────── - - -def test_전후_값이_둘_다_남음() -> None: - """하나만 넘기면 B09 가 어느 쪽인지 몰라 역산하다 사고가 난다(8-2).""" - fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}}) - row = 줄(build_table(원단위표(성분("자갈", "㎥", 100.0)), surcharge_table=fake), "자갈") - assert row["net_amount"] == pytest.approx(100.0) - assert row["total_amount"] == pytest.approx(104.0) - - -def test_합계는_반올림하지_않음() -> None: - """표기 자리와 계산 자리를 가른다(PLAN 8-16) — 반올림은 화면에서만.""" - fake = SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}}) - row = 줄(build_table(원단위표(성분("모래", "㎥", 1.0)), surcharge_table=fake), "모래") - assert row["total_amount"] == pytest.approx(1.06) - - -# ── 관급/사급 ─────────────────────────────────────────────────────── - - -def test_안_정한_자재는_기본_사급으로_서고_그_사실이_적힘() -> None: - """2026-09-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느 - 합계에도 안 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 줄은 그대로 미정.""" - table = build_table(원단위표(성분("야면석", "㎥", 5.0))) - assert 줄(table, "야면석")["supply"] == "contractor_supplied" - assert "기본 사급" in 줄(table, "야면석")["note"] - assert table["missing_supply_materials"] == [] - picked = build_table(원단위표(성분("야면석", "㎥", 5.0)), supply_map={"야면석": "unknown"}) - assert 줄(picked, "야면석")["supply"] == SUPPLY_UNKNOWN - assert 줄(picked, "야면석")["supply_label"] == "미정(발주기관 결정)" - assert picked["missing_supply_materials"] == ["야면석"] - - -def test_율이_없는_까닭이_줄에_뜸() -> None: - """보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임.""" - table = build_table(원단위표(성분("막자갈", "㎥", 10.0), 성분("못", "㎏", 1.0))) - assert 줄(table, "막자갈")["surcharge_pct"] is None - assert "조건이 다름 — 사용자 확정 후 옮길 것" in 줄(table, "막자갈")["note"] - assert "할증률표에 이름 없음" in 줄(table, "못")["note"] - - -def test_할증률은_표값_이내에서만_고름() -> None: - """1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀.""" - fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}}) - unit = 원단위표(성분("판재", "㎥", 1.0), 성분("낯선자재", "㎥", 1.0)) - lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3}) - assert 줄(lower, "판재")["surcharge_pct"] == 6 and 줄(lower, "판재")["surcharge_cap"] == 10 - assert "표 상한 10% 이내" in 줄(lower, "판재")["note"] - assert 줄(lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀 - over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12}) - assert ( - 줄(over, "판재")["surcharge_pct"] == 10 - and "이내가 아니라 안 씀" in 줄(over, "판재")["note"] - ) - - -def test_설정이_정한_구분을_따름() -> None: - table = build_table( - 원단위표(성분("시멘트", "㎏", 100.0), 성분("야면석", "㎥", 5.0)), - supply_map={ - "시멘트": {"supply": SUPPLY_OWNER, "install_by": INSTALL_BY_CONTRACTOR}, - "야면석": SUPPLY_CONTRACTOR, - }, - ) - assert 줄(table, "시멘트")["supply"] == "owner_supplied" - assert 줄(table, "시멘트")["supply_label"] == "관급" - assert 줄(table, "시멘트")["install_by_label"] == "도급자설치" - assert 줄(table, "야면석")["supply_label"] == "사급" - assert table["missing_supply_materials"] == [] - - -def test_사급_줄에는_설치주체가_안_붙음() -> None: - """안전관리비 대상액 밖이라 비워 두는 것이 맞다 — 잘못 적힌 값은 무시한다.""" - table = build_table( - 원단위표(성분("야면석", "㎥", 5.0)), - supply_map={"야면석": {"supply": SUPPLY_CONTRACTOR, "install_by": INSTALL_BY_OWNER}}, - ) - assert 줄(table, "야면석")["install_by"] is None - assert table["missing_install_by_materials"] == [] - - -def test_관급인데_설치주체를_안_정하면_드러남() -> None: - """기본값으로 때우면 안전관리비가 조용히 틀린다 — 「도급자설치 관급금액」이 대상액이다.""" - table = build_table( - 원단위표(성분("시멘트", "㎏", 100.0)), - supply_map={"시멘트": SUPPLY_OWNER}, - ) - row = 줄(table, "시멘트") - assert row["install_by"] is None - assert NOTE_INSTALL_BY_MISSING in row["note"] - assert table["missing_install_by_materials"] == ["시멘트"] - - -def test_이름은_정확히_일치로만_찾음() -> None: - """부분일치면 `막자갈`(뒤채움)이 `자갈` 할증을 문다 — 원단위에서 이미 겪은 자리다.""" - fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}}) - table = build_table(원단위표(성분("막자갈", "㎥", 100.0)), surcharge_table=fake) - row = 줄(table, "막자갈") - assert row["surcharge_pct"] is None - assert row["total_amount"] == pytest.approx(100.0) - - -# ── 구조물 밖에서 오는 자재 ──────────────────────────────────────── - - -def test_사면_계열_자재도_받음() -> None: - """떼·초류종자는 구조물 전개가 아니라 사면적에서 온다(8-4).""" - table = build_table( - 원단위표(성분("야면석", "㎥", 5.0)), - extra_materials=[ - {"name": "떼", "unit": "㎡", "amount": 200.0, "source": "성토면 떼붙임"}, - ], - ) - row = 줄(table, "떼") - assert row["net_amount"] == pytest.approx(200.0) - assert row["surcharge_pct"] == 10 # 품셈 1-3-1 「떼ㆍ초화류 10 %」 - assert row["total_amount"] == pytest.approx(220.0) - - -def test_열_이름에_금액이_없음() -> None: - """8-2 경계 — 금액은 B09 몫이다.""" - columns = build_table(원단위표(성분("모래", "㎥", 1.0)))["columns"] - assert not any("금액" in name or "단가" in name for name in columns) diff --git a/resources/tester/test_b08_observed_unit.py b/resources/tester/test_b08_observed_unit.py deleted file mode 100644 index c185da700..000000000 --- a/resources/tester/test_b08_observed_unit.py +++ /dev/null @@ -1,203 +0,0 @@ -"""관측 원단위표 검사 — PLAN 8-6·8-8 · 2026-09-07 3자 승인. - -이 일감의 위험은 계산이 아니라 **관측값을 늘려 쓰는 것**이다. - · 관측값은 그 규격에서만 맞다 — 벽·기초는 높이에 비례하지 않는다. - · 규격이 표에 없으면 가까운 값을 갖다 쓰지 않고 「원단위 미확보」로 드러낸다. - · 두 근거(식에서 나온 값 / 관측값)가 한 표에 섞이므로 줄마다 근거를 단다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( # noqa: E402 - BASIS_OBSERVED, - NOTE_UNIT_MISSING, - ObservedUnitTable, - expand_observed, - load_observed_table, - scale_for, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - BASIS_DERIVED, - build_table, - expand, -) - - -def 옹벽(height: float = 2.0, length: float = 10.0, form: str = "반중력식") -> dict: - return { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 100.0 + length, - "options": {"form": form, "height_m": height, "length_m": length}, - } - - -def 성분(result, name: str): - return next(c for c in result.components if c.name == name) - - -# ── 규격이 맞을 때만 쓴다 ─────────────────────────────────────────── - - -def test_규격이_맞으면_연장만큼_곱해짐() -> None: - """`H=2.0 옹벽 10m` 는 같은 단면이 10m 이어진 것 — 개수를 세는 것이지 규격을 늘리는 게 아니다.""" - result = expand(옹벽(length=10.0)) - # 원문 값 1.345 ㎥/m × 10 · 유로폼 3.205(2026-09-14 · 전 라이브러리 1.35 → 13.5 · 3.20 → 32.0) - assert 성분(result, "콘크리트").amount == pytest.approx(13.45) - assert 성분(result, "유로폼").amount == pytest.approx(32.05) - - -def test_높이가_다르면_비례로_늘리지_않고_미확보() -> None: - """⚠ 이 시험이 이 파일의 핵심 — H=1.6 은 표에 없다. 1.35 × 0.8 로 만들지 않는다.""" - result = expand(옹벽(height=1.6)) - assert result.components == [] - # ⚠ 사용자에게 뜨는 말로 잰다 — 키 이름이 새면 안 된다(㉑). - assert any("자료에 없습니다" in note for note in result.notes) - - -def test_형식이_다르면_미확보() -> None: - result = expand(옹벽(form="캔틸레버식")) - assert result.components == [] - # ⚠ 사용자에게 뜨는 말로 잰다 — 키 이름이 새면 안 된다(㉑). - assert any("자료에 없습니다" in note for note in result.notes) - - -def test_미확보_알림에_있는_규격을_같이_알려줄것() -> None: - """「없다」만 말하면 사용자가 무엇을 고쳐야 할지 모른다. - ⚠ 규격도 **사람 말**로 보인다 — `form`·`height_m` 이 아니라 「옹벽 형식」·「높이(m)」.""" - result = expand(옹벽(height=1.6)) - note = next(n for n in result.notes if "자료에 있는 규격" in n) - assert "옹벽 형식 반중력식" in note and "높이(m) 2.0" in note - assert "form" not in note and "height_m" not in note - - -def test_규격이_비어_있으면_고르지_못함을_알림() -> None: - result = expand({"type_id": "retaining_wall", "options": {"length_m": 5.0}}) - assert result.components == [] - assert result.notes - - -# ── 근거를 줄마다 단다 ────────────────────────────────────────────── - - -def test_관측값에는_observed_근거가_붙음() -> None: - result = expand(옹벽()) - for component in result.components: - assert component.basis_kind == BASIS_OBSERVED - assert "관측 원단위" in component.basis - assert component.source - - -def test_식에서_나온_값은_derived_로_남음() -> None: - """두 근거가 한 표에 섞이므로 갈라 보여야 한다.""" - stone = expand( - { - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 1.5, "length_m": 10.0}, - } - ) - assert all(c.basis_kind == BASIS_DERIVED for c in stone.components) - - -def test_표에도_근거가_실림() -> None: - """⚠ 관측 줄에 **파생 줄 하나가 섞인다** — 기초잡석은 버림 폭에서 나오는 값이라 - 관측이 아니라 `derived` 다(2026-09-09 확정 3차 ②). 근거가 갈려 실리는 것이 맞다.""" - table = build_table([옹벽()]) - components = [c for s in table["structures"] for c in s["components"]] - # 2026-09-14 — 단면으로 세운 터파기·되메우기·잔토도 파생 줄(브레인 판정 ①). - derived = {"기초잡석", "터파기", "되메우기", "잔토처리"} - kinds = {c["basis_kind"] for c in components if c["name"] not in derived} - assert kinds == {BASIS_OBSERVED} - assert {c["basis_kind"] for c in components if c["name"] in derived} == {BASIS_DERIVED} - 잡석 = next(c for c in components if c["name"] == "기초잡석") - assert 잡석["basis_kind"] == BASIS_DERIVED - - -# ── 이중계상 규칙은 그대로 ────────────────────────────────────────── - - -def test_관측값에도_배합이_섞이지_않음() -> None: - """㉢ — 콘크리트 ㎥ 까지만. 시멘트·모래로 쪼개는 것은 B09 몫이다.""" - assert build_table([옹벽()])["mix_components_found"] == [] - - -def test_터파기는_토공으로_되메우기는_토공으로() -> None: - """관측 원단위의 터파기도 내역 줄이 아니라 토공 합산이다.""" - table = load_observed_table() - entry = table.find("pipe_inlet_basin", {"inlet_basin_form": "돌집수정 ㄷ형"}) - destinations = {c["name"]: c["destination"] for c in entry["components"]} - assert destinations["터파기"] == "earthwork" - assert destinations["잔토처리"] == "earthwork" - assert destinations["콘크리트"] == "unit_price" - - -def test_할증은_여전히_자재총괄_몫() -> None: - assert build_table([옹벽()])["surcharge_applied"] is False - - -# ── 표 자체 ───────────────────────────────────────────────────────── - - -def test_실제_데이터판이_읽힘() -> None: - table = load_observed_table() - assert table.effective_date - assert table.find("retaining_wall", {"form": "반중력식", "height_m": 2.0}) - - -def test_숫자와_글자_규격을_같이_본다() -> None: - """입력 폼이 `"800"` 을 문자열로 준다 — 그렇다고 `2.0` 과 `1.6` 을 같다고 보면 안 된다.""" - table = load_observed_table() - assert table.find( - "pipe_inlet_basin", - { - "inlet_basin_form": "□형(기본형)", - "inlet_basin_material": "콘크리트", - "pipe_diameter_mm": 800, - }, - ) - assert table.find("retaining_wall", {"form": "반중력식", "height_m": 1.9999}) is None - - -def test_BOX암거는_관측표가_아니라_전개식으로_섬() -> None: - """2026-09-15 — 두께 칸이 등록부에 서고 전개식(`_UnitQuantity_Box`)이 생겨 관측표 몫이 아님. - - 관측값은 여전히 없음(울진 2×2 철근만 전개식 안에 관측값으로 듦) · 세월교는 사유로 닫힘. - """ - table = load_observed_table() - assert table.find("box_culvert", {"body_width_m": 3.0, "body_height_m": 1.2}) is None - reasons = {item["type_id"]: item for item in table.not_found["items"]} - assert "box_culvert" not in reasons - assert "세월교 수량 근거 없음" in reasons["ford_bridge"]["why"] - - -def test_파일이_없으면_전부_미확보() -> None: - empty = ObservedUnitTable() - components, notes = expand_observed("retaining_wall", {"form": "반중력식"}, 옹벽(), empty) - assert components == [] - # 자료가 통째로 없으면 「표준 물량 자료가 없다」로 말한다. - assert "표준 물량 자료가 아직 없습니다" in notes[0] - - -def test_곱할_연장이_0이면_내지_않음() -> None: - result = expand(옹벽(length=0.0)) - assert result.components == [] - - -def test_개소당_원단위는_안_곱해짐() -> None: - """집수정은 1개소가 1개소다 — 연장을 곱하면 값이 부푼다.""" - table = load_observed_table() - entry = table.find("pipe_inlet_basin", {"inlet_basin_form": "돌집수정 ㄴ형"}) - scale, note = scale_for(entry, {"options": {"length_m": 10.0}}) - assert scale == 1.0 - assert "개소" in note diff --git a/resources/tester/test_b08_option_keys.py b/resources/tester/test_b08_option_keys.py deleted file mode 100644 index d9847d684..000000000 --- a/resources/tester/test_b08_option_keys.py +++ /dev/null @@ -1,116 +0,0 @@ -"""저장 제원 키 ↔ 엔진이 읽는 키 대조 (2026-09-07 ⑳ 앞). - -⚠ **오늘 잡은 사고 중 사용자 입력이 직접 무시되던 첫 사례**가 여기였다 — - 레지스트리는 `back_len_cm` 인데 엔진은 `stone_back_length_cm` 을 읽고 있어 - **뒷길이를 75 로 골라도 늘 45 계수**로 돌았다. 값이 나오므로 아무 시험도 안 잡았다. - -⇒ 「이름으로 알아보는 코드는 정본과 대조하라」(사방공 `type_id` 건)의 **데이터 키 판**이다. -""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -#: 레지스트리에 칸이 **아직 없는** 키. 「칸이 생기면 받는다」는 뜻으로 코드에 남겨 둔 것이고, -#: 키 이름이 어긋난 것과는 다르다. 새로 늘리려면 **왜 없는지**를 여기 적을 것. -#: 레지스트리에 아직 칸이 없는 키 — **왜 없는지**를 함께 적는다. -#: ⚠ 2026-09-09 `face_slope_ratio` 는 칸이 생겨 뺐음(`ebdf2988`). 비워 두면 품셈 -#: 표준경사표로 자동 판정되고, 값이 있으면 그 값이 이긴다(확정 ⑨). -#: ⓘ 2026-09-09 오후 비었다 — `ditch_spec` 은 랩탑 보조가 칸을 만들어(`92fd9092`) 여기서 뺐다. -#: 목록이 비어 있는 것이 정상이다. **칸 없이 코드에만 있는 키가 생기면** 여기 까닭과 함께 -#: 적을 것 — 적지 않으면 이 시험이 「이름을 잘못 적었다」로 잡는다. -KNOWN_ABSENT: dict[str, str] = {} - - -def _registry_keys() -> set[str]: - payload = json.loads( - (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") - ) - types = payload if isinstance(payload, list) else (payload.get("types") or []) - return {option["key"] for item in types for option in item.get("options") or []} - - -def _engine_keys() -> dict[str, set[str]]: - found: dict[str, set[str]] = {} - for path in (ROOT / "B08_Quantity").glob("*.py"): - text = path.read_text(encoding="utf-8") - for match in re.finditer(r'options\.get\(\s*"([a-z0-9_]+)"', text): - found.setdefault(match.group(1), set()).add(path.name) - for path in (ROOT / "resources").rglob("*.json"): - try: - text = path.read_text(encoding="utf-8") - except OSError: - continue - for match in re.finditer(r'"option_key"\s*:\s*"([a-z0-9_]+)"', text): - found.setdefault(match.group(1), set()).add(path.name) - return found - - -def test_엔진이_읽는_키가_레지스트리에_있을것() -> None: - """⚠ 이 시험이 `back_len_cm` 사고를 다시 못 나게 한다.""" - registry = _registry_keys() - missing = { - key: sorted(where) - for key, where in _engine_keys().items() - if key not in registry and key not in KNOWN_ABSENT - } - assert not missing, f"레지스트리에 없는 옵션 키를 읽고 있다: {missing}" - - -def test_없는_키는_사유가_적혀_있을것() -> None: - """「없다」를 그냥 넘기지 않는다 — 왜 없는지가 적혀 있어야 한다.""" - for key, why in KNOWN_ABSENT.items(): - assert why, key - # 사유 목록에 적힌 키가 정말 레지스트리에 없는지도 본다(고쳐졌는데 목록만 남는 것 방지). - registry = _registry_keys() - stale = [key for key in KNOWN_ABSENT if key in registry] - assert not stale, f"레지스트리에 칸이 생겼으니 목록에서 뺄 것: {stale}" - - -def test_돌쌓기_뒷길이_키가_실제로_맞을것() -> None: - """사고가 났던 그 자리를 이름으로 직접 못 박는다.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS - - assert "back_len_cm" in BACK_LENGTH_KEYS - assert "back_len_cm" in _registry_keys() - - -def test_되돌릴_수_있어야_하는_숫자_칸은_계약에_등록될것() -> None: - """⚠ `None` 을 통째로 버리면 「안 정함」으로 되돌릴 길이 없다 (2026-09-08 ㉘). - - 시공법·타설 방식은 빈 문자열로 되돌리지만 **숫자 칸은 되돌릴 값이 `None` 뿐**이다. - 새 숫자 칸을 넣고 여기 등록하지 않으면 **한 번 넣은 값이 영영 남는다.** - 같은 병을 두 번 만났으므로 계약처럼 다룬다. - """ - from B08_Quantity.B08_Quantity_Router_Earthwork import ( - NULLABLE_SETTING_KEYS, - QuantitySettingsBody, - ) - - # ⚠ 좁게 본다 — `dict[str, float]`(비율표)까지 걸면 정상 칸을 잡는다(넓은 규칙 사고). - # 여기서 겨냥하는 것은 **홑 숫자 칸**뿐이다. - 홑숫자 = {float | None, int | None} - 숫자칸 = { - name - for name, field in QuantitySettingsBody.model_fields.items() - if field.annotation in 홑숫자 - } - assert 숫자칸, "홑 숫자 칸을 하나도 못 골랐다 — 판정이 틀렸다" - 빠진것 = 숫자칸 - set(NULLABLE_SETTING_KEYS) - assert not 빠진것, f"숫자 칸이 NULLABLE_SETTING_KEYS 에 없다 — 되돌릴 길이 없다: {sorted(빠진것)}" - - -def test_되돌리기_칸은_갈아끼우기_대상이기도_할것() -> None: - """등록만 하고 갈아끼우기(`replace_keys`)에서 빠지면 병합돼 옛 값이 남는다.""" - import inspect - - from B08_Quantity import B08_Quantity_Router_Earthwork as router - - 본문 = inspect.getsource(router.save_quantity_settings) - assert "NULLABLE_SETTING_KEYS" in 본문 diff --git a/resources/tester/test_b08_pavement_concrete_unit.py b/resources/tester/test_b08_pavement_concrete_unit.py deleted file mode 100644 index 837bc7f76..000000000 --- a/resources/tester/test_b08_pavement_concrete_unit.py +++ /dev/null @@ -1,158 +0,0 @@ -"""콘크리트 포장 전개식 — 면적 × 두께 · 거푸집 양면 · 수축줄눈 (2026-09-15 브레인 포장 둘 ①~⑤·ⓐ). - -근거 - 임도기술교본 3-2 기본설계 — 포장 폭 3.0m · 두께 0.2m · 곡선부 확폭 - 임도기술교본 부록 4-7 7-1 3.5.1 — 「수축줄눈의 간격은 4-6m를 기준」 - 산림품셈 12-6 콘크리트 포장(인력) · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집 - 실무 울진소광 「포장및난간개거」 — 면적 = B × L · 거푸집(1면기준) = 2L · - 수축줄눈 = 내림(L÷6) × B (103m·B 3.5 → 59.5 · 418m → 241.5) · 「포장확폭」 줄눈 = 면적 ÷ 6 -판정 — 폭·확폭·두께는 **B06 에서 잇고 칸이 이김**(한 값 원칙) · 비닐·철망은 고르기(기본값 없음). -""" - -from __future__ import annotations - -import pytest - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs - -FILLED = { - "width_m": 3.0, - "widening_area_m2": 6.0, - "thickness_cm": 20, - "joint_spacing_m": 6, - "separation_sheet": "있음", - "wire_mesh": "없음", -} - - -def _row(options: dict, start: float = 100.0, end: float = 130.0) -> dict: - structure = { - "structure_id": "p1", - "type_id": "pavement_concrete", - "start_m": start, - "end_m": end, - # 구간을 물넘이로 나누면 길이 칸(10)이 조각마다 그대로 복사됨 — 연장은 구간에서 읽음. - "options": {"length_m": 10, **options}, - } - return build_table([structure], {"pavement_concrete": "콘크리트 포장"})["structures"][0] - - -def test_칸이_찬_구간은_면적_두께_거푸집_줄눈이_선다() -> None: - row = _row(FILLED) - got = {c["name"]: c for c in row["components"]} - area = 3.0 * 30 + 6.0 - assert (row["billing_unit"], row["billing_quantity"]) == ("㎡", pytest.approx(area)) - assert got["콘크리트"]["amount"] == pytest.approx(area * 0.2) - assert got["콘크리트"]["destination"] == "material" - assert got["포장거푸집"]["amount"] == pytest.approx(2 * 30) - # 내림(30 ÷ 6) × 3.0 + 확폭 6 ÷ 6 - assert got["수축줄눈"]["amount"] == pytest.approx(5 * 3.0 + 1.0) - assert got["비닐"]["amount"] == pytest.approx(area) and "철망" not in got - text = " ".join(row["notes"]) - assert "12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅" in text - - -@pytest.mark.parametrize(("length", "joints"), [(103.0, 59.5), (418.0, 241.5)]) -def test_수축줄눈은_소광_관측과_같다(length: float, joints: float) -> None: - row = _row({**FILLED, "width_m": 3.5, "widening_area_m2": 0}, 0.0, length) - got = {c["name"]: c["amount"] for c in row["components"]} - assert got["수축줄눈"] == pytest.approx(joints) - - -def test_비닐_철망을_안_고르면_사유() -> None: - row = _row({k: v for k, v in FILLED.items() if k not in ("separation_sheet", "wire_mesh")}) - names = {c["name"] for c in row["components"]} - assert "비닐" not in names and "철망" not in names and "콘크리트" in names - text = " ".join(row["notes"]) - assert "비닐" in text and "철망" in text and "12-6" in text - - -DESIGNS = [ - {"chainage_m": 90.0, "design": {"carriageway_standard_width_m": 3.0, "paved": False}}, - { - "chainage_m": 100.0, - "design": { - "carriageway_standard_width_m": 3.0, - "widening_right_m": 1.0, - "paved": True, - "pavement_thickness_m": 0.2, - }, - }, - { - "chainage_m": 120.0, - "design": { - "carriageway_standard_width_m": 3.0, - "widening_left_m": 0.5, - "paved": True, - "pavement_thickness_m": 0.2, - }, - }, - { - "chainage_m": 140.0, - "design": {"carriageway_standard_width_m": 3.0, "paved": True, "pavement_thickness_m": 0.2}, - }, -] - - -def _structure(**options) -> dict: - return { - "type_id": "pavement_concrete", - "start_m": 100.0, - "end_m": 130.0, - "options": {"length_m": 10, **options}, - } - - -def test_빈_칸은_B06_폭_확폭_두께를_잇는다() -> None: - filled = pavement_from_designs(_structure(), DESIGNS) - options = filled["options"] - assert options["width_m"] == pytest.approx(3.0) - assert options["thickness_cm"] == pytest.approx(20.0) - # 100 → 1.0 · 120 → 0.5 · 130 → 0.25(120~140 사이 보간): (1.0+0.5)/2×20 + (0.5+0.25)/2×10 - assert options["widening_area_m2"] == pytest.approx(15.0 + 3.75) - assert any("B06" in note for note in filled["notes"]) - # 상세 칸은 배치 폼에 없어 저장에 없음 — 등록부 제안값 6m 을 사유와 함께 채움. - assert options["joint_spacing_m"] == 6 and any("4-6m" in note for note in filled["notes"]) - - -def test_칸을_적으면_칸이_이긴다() -> None: - filled = pavement_from_designs(_structure(width_m=3.5, widening_area_m2=0), DESIGNS) - assert filled["options"]["width_m"] == 3.5 and filled["options"]["widening_area_m2"] == 0 - assert filled["options"]["thickness_cm"] == pytest.approx(20.0) - - -def test_B06_설계도_칸도_없으면_안_선다() -> None: - filled = pavement_from_designs(_structure(), []) - row = build_table([filled])["structures"][0] - assert row["components"] == [] - assert any("폭" in note for note in row["notes"]) - - -def test_인계_묶음은_12_6_두께_갈래_12_8_12_7_1() -> None: - table = build_table( - [{**_structure(**FILLED), "structure_id": "p1"}], {"pavement_concrete": "콘크리트 포장"} - ) - row = next( - r for r in build_handoff(unit_quantity_table=table)["work_items"] if "포장" in r["name"] - ) - parts = {part["code"]: part for part in row["composite_parts"] or []} - area = 3.0 * 30 + 6.0 - assert parts["FP-12-06#20㎝"]["quantity"] == pytest.approx(area * 0.2) - assert parts["FP-12-08"]["quantity"] == pytest.approx(60.0) - assert parts["FP-12-07-01"]["quantity"] == pytest.approx(16.0) - - -def test_등록부_칸() -> None: - options = {o.key: o for o in structure_type_map()["pavement_concrete"].options} - assert not options["thickness_cm"].required and "B06" in options["thickness_cm"].empty_means - assert "B06" in options["width_m"].empty_means and "3.5" in options["width_m"].empty_means - assert "B06" in options["widening_area_m2"].empty_means - assert ( - options["joint_spacing_m"].default == 6 - and "4-6m" in options["joint_spacing_m"].default_basis - ) - for key in ("separation_sheet", "wire_mesh"): - assert options[key].default is None and options[key].choices == ["있음", "없음"] diff --git a/resources/tester/test_b08_pipe.py b/resources/tester/test_b08_pipe.py deleted file mode 100644 index 9d19d652d..000000000 --- a/resources/tester/test_b08_pipe.py +++ /dev/null @@ -1,172 +0,0 @@ -"""배수관 물량 — 정본 셋을 잇는 자리의 짝 시험 (2026-09-08). - -⚠ 여기서 겨누는 것 넷 - ① `facility` 가 있는 점(세월교 등)을 **관으로 세지 않는가** - ② 연장이 없으면 **0 으로 때우지 않고 사유와 함께 막히는가** - ③ 관종으로 공종이 갈리는가 · 기본값으로 선 것을 **알리는가** - ④ ⚠ 좁게 — 측점 맞추기가 **옆 측점 길이를 물어 오지 않는가** -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows # noqa: E402 - -MAPPING = json.loads( - ( - ROOT / "resources" / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json" - ).read_text(encoding="utf-8") -)["pipe"] - - -def 관(chainage: float, **options: object) -> dict: - return {"chainage_m": chainage, "facility": "pipe", "options": options} - - -def 길이(chainage: float, length: float) -> dict: - return {"chainage_m": chainage, "design": {"pipe_length_m": length}} - - -def test_관이_아닌_시설은_안_센다() -> None: - """⚠ `pipe_points.json` 은 계곡 통과 시설 **전부**의 정본이다 — 세월교가 섞여 있다.""" - points = [ - 관(85.05, pipe_diameter_mm=1000), - {"chainage_m": 173.09, "facility": "ford_bridge", "options": {}}, - {"chainage_m": 352.14, "facility": "ford_bridge", "options": {}}, - 관(258.12, pipe_diameter_mm=800), - ] - out = build_rows(points, [], MAPPING) - assert out["pipe_count"] == 2, "세월교를 관으로 셌다" - - -def test_관경이_없어도_관은_관이다() -> None: - """⚠ `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**.""" - out = build_rows([관(100.0)], [], MAPPING) - assert out["pipe_count"] == 1 - assert out["rows"][0]["variant_value"] is None - - -def test_연장이_없으면_0_으로_때우지_않는다() -> None: - out = build_rows([관(85.05, pipe_diameter_mm=1000)], [], MAPPING) - row = out["rows"][0] - assert row["in_bill"] is False - assert row["blocked_kind"] == "input_missing" - assert "횡단설계에서 [저장]" in row["blocked_reason"] - - -def test_연장이_오면_값이_선다() -> None: - out = build_rows([관(85.05, pipe_diameter_mm=1000)], [길이(85.05, 9.0)], MAPPING) - row = out["rows"][0] - assert (row["in_bill"], row["quantity"], row["unit"]) == (True, 9.0, "m") - assert out["length_total_m"] == 9.0 - - -def test_관종으로_공종이_갈린다() -> None: - 보기 = {"파형강관": "FP-12-11-03", "흄관": "FP-12-11-02", "VR관": "FP-12-11-01"} - for kind, code in 보기.items(): - out = build_rows([관(85.0, pipe_kind=kind, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING) - assert out["rows"][0]["work_item_code"] == code, kind - - -def test_관종을_안_정하면_기본값으로_서되_알린다() -> None: - """기본 「파형강관」은 2026-08-17 사용자 확정값이라 근거가 있다 — 다만 **조용히 쓰지 않는다**.""" - out = build_rows([관(85.0, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING) - row = out["rows"][0] - assert row["work_item_code"] == "FP-12-11-03" - assert row["kind_from_default"] is True - assert any("기본값" in note for note in out["notes"]) - - -def test_모르는_관종은_공종을_못_고른다() -> None: - out = build_rows([관(85.0, pipe_kind="철근콘크리트관")], [길이(85.0, 9.0)], MAPPING) - row = out["rows"][0] - assert row["work_item_code"] is None - assert row["in_bill"] is False - assert "아는 관종이 아니라" in row["blocked_reason"] - - -def test_측점이_소수점에서_어긋나도_찾는다() -> None: - out = build_rows([관(85.05, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING) - assert out["rows"][0]["quantity"] == 9.0 - - -def test_옆_측점_길이를_물어_오지_않는다() -> None: - """⚠ 좁게 — 넓히면 다른 측점 값이 조용히 실린다.""" - out = build_rows([관(85.0, pipe_diameter_mm=800)], [길이(120.0, 9.0)], MAPPING) - row = out["rows"][0] - assert row["quantity"] == 0.0 and row["in_bill"] is False - - -def test_실제_자료로_세월교가_걸러진다() -> None: - """정본 대조 — `5601e828` 은 11점 중 관 9 · 세월교 2 임(2026-09-08 랩탑 창 확인).""" - path = ( - ROOT - / "storage" - / "1" - / "3" - / "5601e828-feea-487a-9b25-415e5199f2f5" - / "B04_PreProcess" - / "drainage" - / "edits" - / "pipe_points.json" - ) - if not path.is_file(): - return - points = json.loads(path.read_text(encoding="utf-8"))["points"] - assert build_rows(points, [], MAPPING)["pipe_count"] == 9 - - -# ── 「[저장]하면 풀림」과 「눌러도 안 풀림」을 갈라 말하기 (2026-09-08 실측) ──── -# 실측: `5601e828` 관 9개 중 3개(439.55·620.43·720.37)가 **횡단 행 자체가 없는** 자리였다. -# 거기에 「[저장]을 누르면 섭니다」를 띄우면 눌러 보고 안 되어 헤맨다. - - -def test_횡단이_있는데_길이만_없으면_저장하라고_말한다() -> None: - out = build_rows( - [관(85.0, pipe_diameter_mm=800)], - [], - MAPPING, - section_chainages=[85.0], - ) - assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"] - - -def test_횡단_자체가_없으면_다르게_말한다() -> None: - out = build_rows( - [관(620.43, pipe_diameter_mm=1500)], - [], - MAPPING, - section_chainages=[85.0, 258.12], - ) - 사유 = out["rows"][0]["blocked_reason"] - assert "횡단 자체가 없습니다" in 사유 - assert "[저장]으로는 안 풀립니다" in 사유 - - -def test_측점_목록을_안_주면_종전대로_말한다() -> None: - """⚠ 좁게 — 목록이 없다고 「횡단 없음」으로 단정하면 거짓말이 된다.""" - out = build_rows([관(620.43, pipe_diameter_mm=1500)], [], MAPPING) - assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"] - - -def test_0_5m_안의_측점은_그_관의_횡단이다() -> None: - """⚠ **뒤집힌 시험이다** — 종전에는 「옆 측점이 있다고 횡단이 있는 것이 아니다」였다. - - 2026-09-09 랩탑 메인 실측이 그 전제를 깼다 — 측점은 **정수 미터 격자로 스냅**되므로 - 관 439.55 의 횡단은 **측점 440.0** 이고, 그 측점은 구조물 이름표까지 달고 있다. - 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 뜬다. 스냅 최대 - 어긋남이 0.5m 라 길이 찾기와 허용오차가 같아졌다(`e1fed5f6` — 배수관 넷 복구). - """ - out = build_rows( - [관(439.55, pipe_diameter_mm=800)], - [], - MAPPING, - section_chainages=[440.0], - ) - assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"] diff --git a/resources/tester/test_b08_prep_status_input.py b/resources/tester/test_b08_prep_status_input.py deleted file mode 100644 index 23c9a11b2..000000000 --- a/resources/tester/test_b08_prep_status_input.py +++ /dev/null @@ -1,135 +0,0 @@ -"""준비공 상태 칸 — 「입력이 필요함」과 「값을 낼 근거가 없음」을 가른다 (2026-09-14 브레인 ㉴). - -훑기에서 잡은 거짓 사유: 상태 칸이 **입력만 넣으면 서는 줄**(표토 두께·거리 · 임목폐기물 조사값 · -부대시설 개소 · 임목파쇄 부피)까지 「값을 낼 근거가 없음」으로 덮었다. 반대로 인계는 -**우리가 만들어야 하는 줄**(사방 원단위 · 뿌리 부피)까지 「입력이 필요합니다」로 보냈다. - - ① 입력이면 풀리는 줄 → 상태 「입력이 필요함」 · 인계 `input_missing` - ② 자료·산식이 없는 줄 → 상태 「값을 낼 근거가 없음」 · 인계 `unit_data_missing` - ③ 앞 단계(횡단·사면표)가 안 선 줄도 사람이 할 일이라 ① - ④ 규준틀이 필요 없는 노선(비탈길이 10m 이상 구간 없음)은 「해당 없음」 -""" - -from __future__ import annotations - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B08_Quantity.B08_Quantity_Engine_Preparation import build_table -from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( - STATUS_NEEDS_INPUT, - STATUS_NOT_APPLICABLE, - STATUS_PENDING, -) - -SLOPE = {"tree_removal_fill": 600.0, "tree_removal_cut": 400.0, "face_dressing_cut": 300.0} -SHORT_SLOPES = [{"lengths": {"fill": 4.0}, "distance_m": 20.0, "fill_height_m": 2.0}] - - -def _table(**overrides: object) -> dict: - args: dict = { - "slope_totals": SLOPE, - "structures": [{"type_id": "erosion_check"}], - "slope_rows": SHORT_SLOPES, - "road_surface_area_m2": 1000.0, - "chipping_enabled": True, - "tree_waste": {}, - "ancillary_counts": {}, - } - args.update(overrides) - return build_table(**args) - - -def _by_item(table: dict) -> dict[str, dict]: - return {row["item"]: row for row in table["rows"]} - - -def test_입력이면_서는_줄은_입력이_필요함() -> None: - rows = _by_item(_table()) - for item in ( - "표토 운반·적치", - "임목파쇄", - "임목폐기물 처리", - "국가지점번호판", - "가설창고(컨테이너)", - ): - assert rows[item]["status"] == STATUS_NEEDS_INPUT, item - # 두께를 넣으면 거리 칸이 남는다 — 여전히 입력이 필요함. - with_thickness = _by_item(_table(topsoil_thickness_m=0.2)) - assert with_thickness["표토 운반·적치"]["status"] == STATUS_NEEDS_INPUT - - -def test_자료나_산식이_없는_줄은_근거가_없음() -> None: - rows = _by_item(_table()) - assert rows["뿌리 운반"]["status"] == STATUS_PENDING - assert ( - rows[next(k for k, r in rows.items() if r["group"] == "사방공")]["status"] == STATUS_PENDING - ) - - -def test_앞_단계가_안_섰으면_입력이_필요함() -> None: - rows = _by_item(_table(slope_totals={}, slope_rows=[], road_surface_area_m2=None)) - assert rows["표토제거"]["status"] == STATUS_NEEDS_INPUT - assert rows["뿌리 적재"]["status"] == STATUS_NEEDS_INPUT - assert rows["비탈 규준틀"]["status"] == STATUS_NEEDS_INPUT - assert rows["수평 규준틀"]["status"] == STATUS_NEEDS_INPUT - - -def test_규준틀이_필요_없는_노선은_해당_없음() -> None: - assert _by_item(_table())["비탈 규준틀"]["status"] == STATUS_NOT_APPLICABLE - - -def test_인계_막힘_갈래가_상태를_따른다() -> None: - table = _table() - handed = {row["name"]: row for row in build_handoff(preparation_table=table)["work_items"]} - assert handed["표토 운반·적치"]["blocked_kind"] == "input_missing" - assert handed["임목파쇄"]["blocked_kind"] == "input_missing" - # 우리가 만들어야 하는 줄 — 「입력이 필요합니다」로 보내면 사용자가 헛걸음한다. - assert handed["뿌리 운반"]["blocked_kind"] == "unit_data_missing" - assert handed["골막이"]["blocked_kind"] == "unit_data_missing" - # 해당 없음은 막힘이 아니다. - assert handed["비탈 규준틀"]["blocked_kind"] is None - # 개소를 안 넣은 부대시설은 입력 갈래(개소를 넣으면 수량이 섬). - assert handed["국가지점번호판"]["blocked_kind"] == "input_missing" - - -def test_품셈_공종이_없는_부대시설은_셋째_상태로_자재_단가에_잇는다() -> None: - """개소를 넣어도 공종이 없어 단가가 영영 안 선다 — 「입력이 필요함」으로 두면 사용자가 기다린다. - - 브레인 판정(2026-09-14): 「품셈 공종 없음 — 단가를 직접 넣어야 함」 · 「자재 단가」 탭 통로 - (화약류·치즐과 같은 자리) — 수량은 자재총괄(사급) 줄로 가고 단가를 넣으면 금액이 선다. - """ - from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_NO_WORK_ITEM - from B08_Quantity.B08_Quantity_Router_Material import material_table_for - - entered = _table(ancillary_counts={"national_point_sign": 3, "site_container": 1}) - rows = _by_item(entered) - assert rows["국가지점번호판"]["status"] == STATUS_NO_WORK_ITEM - assert STATUS_NO_WORK_ITEM == "품셈 공종 없음 — 단가를 직접 넣어야 함" - assert "자재 단가" in rows["국가지점번호판"]["reason"] - # 공종이 있는 가설창고는 그대로 값 있음. - assert rows["가설창고(컨테이너)"]["status"] == "값 있음" - # 인계 — 막힘이 아니라 자재 줄로 가는 줄(두 번 세지 않음). - sign = next( - row - for row in build_handoff(preparation_table=entered)["work_items"] - if row["name"] == "국가지점번호판" - ) - assert sign["blocked_kind"] is None and sign["in_bill"] is False - # 자재총괄 사급 줄 — 「자재 단가」 탭이 이 이름으로 칸을 세운다. - materials = material_table_for({"rows": []}, {}, entered)["rows"] - board = [row for row in materials if row["name"] == "국가지점번호판"] - assert len(board) == 1 - assert (board[0]["unit"], board[0]["net_amount"], board[0]["supply"]) == ( - "개소", - 3.0, - "contractor_supplied", - ) - # 개소를 안 넣은 공종 없는 줄은 자재로 안 감(수량이 없음). - assert not [row for row in materials if row["name"] == "임도 안내판"] - - -def test_표_머리가_두_갈래를_따로_센다() -> None: - table = _table() - rows = table["rows"] - assert table["input_count"] == sum(1 for r in rows if r["status"] == STATUS_NEEDS_INPUT) - assert table["pending_count"] == sum(1 for r in rows if r["status"] == STATUS_PENDING) - assert table["input_count"] > 0 and table["pending_count"] > 0 diff --git a/resources/tester/test_b08_preparation.py b/resources/tester/test_b08_preparation.py deleted file mode 100644 index a77b15bb3..000000000 --- a/resources/tester/test_b08_preparation.py +++ /dev/null @@ -1,231 +0,0 @@ -"""준비공·사방공 검사 — PLAN 8-3 의 ❌ 둘 (2026-09-07 ⑪). - -여기서 하는 일은 **줄을 세우고 못 서는 줄의 사유를 적는 것**이다. 빈 표를 내면 -「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. - -⚠⚠ 벌목은 이미 토공집계의 「지장목제거」로 선다 — 여기서 또 세우면 같은 나무를 두 번 벤다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 - EROSION_CONTROL_TYPES, - STATUS_COUNTED_ELSEWHERE, - STATUS_NOT_APPLICABLE, - STATUS_PENDING, - build_table, - erosion_rows, -) - - -def 줄(table: dict, item: str) -> dict: - return next(row for row in table["rows"] if row["item"] == item) - - -# ── 이중계상 방어 ─────────────────────────────────────────────────── - - -def test_벌목은_값을_내지_않고_가리키기만_함() -> None: - """⚠ 이 파일의 핵심 — 토공집계의 「지장목제거」와 겹치면 같은 나무를 두 번 벤다.""" - table = build_table({"tree_removal_fill": 1000.0, "tree_removal_cut": 500.0}) - row = 줄(table, "벌목·지장목제거") - assert row["amount"] is None # 값은 안 낸다 - assert row["status"] == STATUS_COUNTED_ELSEWHERE - assert "이중계상" in row["reason"] - # 참고 면적은 보인다 — 어느 값과 겹치는지 사람이 알아야 한다. - assert row["reference_amount"] == pytest.approx(1500.0) - - -# ── 못 서는 줄은 사유를 적는다 ───────────────────────────────────── - - -def test_못_서는_줄이_목록에_남을것() -> None: - """빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다.""" - table = build_table() - items = [row["item"] for row in table["rows"]] - assert "표토제거" in items and "제근·뿌리다듬기" in items - # 규준틀은 둘로 갈렸다 — 비탈(11-2)과 수평(11-3)의 설치 기준이 다르다. - assert "비탈 규준틀" in items and "수평 규준틀" in items - - -def test_사유가_비어_있지_않을것() -> None: - for row in build_table()["rows"]: - assert row["reason"], f"{row['item']} 에 사유가 없다" - - -def test_값이_없어도_공종코드는_알려줄것() -> None: - """무엇으로 서게 될 줄인지 알아야 나중에 잇는다.""" - assert 줄(build_table(), "표토제거")["work_item_code"] == "FP-09-15-02" - - -# ── 사방공 ────────────────────────────────────────────────────────── - - -def test_사방_시설이_없으면_해당_없음() -> None: - """있는 것처럼 0 을 적지 않는다.""" - row = 줄(build_table(structures=[{"type_id": "masonry_wet"}]), "사방 시설") - assert row["status"] == STATUS_NOT_APPLICABLE - assert row["amount"] is None - - -def test_사방_시설이_있으면_줄이_섬() -> None: - table = build_table(structures=[{"type_id": "erosion_check"}, {"type_id": "revetment"}]) - 사방 = [row for row in table["rows"] if row["group"] == "사방공"] - # 키는 `type_id` 칸으로 오가고, 화면에 뜨는 `item` 은 사람 이름이다. - assert [row["type_id"] for row in 사방] == ["erosion_check", "revetment"] - assert [row["item"] for row in 사방] == ["골막이", "기슭막이"] - assert all(row["status"] == STATUS_PENDING for row in table["rows"] if row["group"] == "사방공") - - -def test_사방_종류_이름이_레지스트리와_맞을것() -> None: - """⚠ 이름을 지어내면 영영 안 걸린다 — 레지스트리의 실제 `type_id` 여야 한다.""" - import json - - registry = json.loads( - (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") - ) - types = registry if isinstance(registry, list) else (registry.get("types") or []) - known = {t["type_id"] for t in types} - assert EROSION_CONTROL_TYPES <= known, EROSION_CONTROL_TYPES - known - - -def test_셈이_맞을것() -> None: - table = build_table() - assert table["row_count"] == len(table["rows"]) - assert table["pending_count"] == sum(1 for r in table["rows"] if r["status"] == STATUS_PENDING) - - -# ── 화면에 안 보이던 확정 항목을 드러내기 (2026-09-07) ───────────── -# -# ⚠ 목록에만 있고 화면에 없으면 **사용자는 그것이 잠정인 줄도 모른다.** -# 「확인 필요」만 있어도 안 된다 — **지금 무슨 값으로 돌고 있는지**를 함께 적어야 -# 사용자가 무엇을 정할지 안다(원단위 미확보에서 「표에 있는 규격을 함께 알린」 그 방식). - - -def test_지장목제거_줄에_공종_자리가_적힐것() -> None: - """⭐ 2026-09-13 판정 — 잡관목제거는 단목베기(4-2-2), 뿌리뽑기는 제근(9-21).""" - row = 줄(build_table(), "벌목·지장목제거") - assert "FP-04-02-02" in row["reason"] and "FP-09-21" in row["reason"] - - -# ── 규준틀 개소 — 원문이 기준을 정해 둠 (2026-09-07 ㉒) ──────────── -# -# 11-2 [주]① 「비탈길이 10m 이상 20m마다 설치한다」 -# 11-3 [주]① 「중심점에서 성토 높이 5m 이상에 설치한다」 -# ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**. - - -def 사면줄(*pairs: tuple[float, float]) -> list[dict]: - """(그 측점의 사면길이, 앞 측점까지 거리) 목록.""" - return [ - {"lengths": {"face_dressing_fill": length}, "distance_m": dist} for length, dist in pairs - ] - - -def test_비탈길이_10m_이상_구간만_셀것() -> None: - """⚠ 「10m 이상」은 **비탈길이** 조건이고 「20m마다」는 **노선 거리** 간격이다. - 둘을 섞으면 짧은 사면 구간까지 세어 개소가 부푼다.""" - from B08_Quantity.B08_Quantity_Engine_Preparation import batter_frame_count - - # 사면길이 12m 인 구간 40m + 사면길이 3m 인 구간 100m → 40m 만 센다. - count, notes = batter_frame_count(사면줄((12.0, 20.0), (12.0, 20.0), (3.0, 100.0))) - assert count == 3 # 40 ÷ 20 + 1 - assert "11-2" in notes[0] - - -def test_긴_사면이_없으면_안_설것() -> None: - from B08_Quantity.B08_Quantity_Engine_Preparation import batter_frame_count - - count, notes = batter_frame_count(사면줄((4.0, 100.0))) - assert count == 0 - assert notes - - -def test_비탈_규준틀이_표에_값으로_설것() -> None: - # ⚠ 실제 사면표는 **첫 측점의 distance_m 이 0** 이다(앞 측점이 없다). 그 모양으로 넣는다. - table = build_table(slope_rows=사면줄((12.0, 0.0), (12.0, 20.0))) - row = 줄(table, "비탈 규준틀") - assert row["amount"] == 2.0 # 20 ÷ 20 + 1 - assert row["status"] == "값 있음" - # 재료는 여전히 미확보임을 같은 줄에 적는다. - assert "설계수량" in row["reason"] - - -def test_수평_규준틀은_성토고_칸이_없으면_못_셈() -> None: - """⚠ 0 으로 때우지 않는다 — 「없음」과 「못 셈」은 다르다.""" - row = 줄(build_table(slope_rows=[{"lengths": {}, "distance_m": 20.0}]), "수평 규준틀") - assert row["amount"] is None - assert "성토고" in row["reason"] and "11-3" in row["reason"] - - -def test_수평_규준틀은_성토고_5m_이상_측점마다() -> None: - """⚠ 비탈규준틀(「10m 이상 20m마다」)과 **기준이 다르다** — 이쪽은 **지점 조건**이라 - 간격이 없다. 두 기준을 같은 식으로 쓰면 조용히 틀린다.""" - rows = [ - {"fill_height_m": 6.0, "distance_m": 20.0}, - {"fill_height_m": 2.0, "distance_m": 20.0}, - {"fill_height_m": 5.0, "distance_m": 20.0}, - ] - row = 줄(build_table(slope_rows=rows), "수평 규준틀") - assert row["amount"] == 2.0 # 6.0 · 5.0 두 곳(5.0 은 「이상」이라 든다) - assert row["status"] == "값 있음" - - -def test_성토고가_다_낮으면_0개소() -> None: - """0 이 **값**인 자리다 — 「못 셈」과 달리 「없다」가 맞는 답이다.""" - row = 줄(build_table(slope_rows=[{"fill_height_m": 1.0, "distance_m": 20.0}]), "수평 규준틀") - assert row["amount"] == 0.0 - assert row["status"] == "값 있음" - - -# ── 표토제거 두께 — 품셈이 아니라 설계가 정함 (2026-09-07 ㉓) ─────── -# -# 9-15 [주]② 「Q=Q1/T … T : 표토두께(m)」 — **공식의 입력 변수**다. -# ⚠ 안 넣으면 0 으로 때우지 않고 물량을 안 낸다. - - -def test_표토제거는_노면과_절토대상지_면적이고_두께를_안_곱한다() -> None: - """⭐ 2026-09-13 판정 「실무대로」 — 밑수 ㎡ · 대상 노면+절토대상지 · 성토 사면은 안 셈.""" - table = build_table( - {"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, - topsoil_thickness_m=0.15, - road_surface_area_m2=4000.0, - ) - row = 줄(table, "표토제거") - assert (row["unit"], row["amount"], row["status"]) == ("㎡", pytest.approx(4500.0), "값 있음") - assert row["work_item_code"] == "FP-09-15-02" # 답외구간 - assert "노면·절토대상지" in row["reason"] and "20m 압토" in row["reason"] - - -def test_노면_면적을_못_세면_물량을_안_낼것() -> None: - """노체 끝이 없는 측점이 있으면 절토 사면만으로 세우지 않는다 — 합계가 그럴듯하게 틀림.""" - row = 줄(build_table({"face_dressing_cut": 500.0}), "표토제거") - assert row["amount"] is None and "노면 면적" in row["reason"] - assert row["reference_amount"] == pytest.approx(500.0) - - -def test_면적이_0이면_안_낼것() -> None: - """0 은 「없음」과 구별이 안 된다 — 0㎡ 를 내면 「표토가 없는 노선」으로 읽힌다.""" - row = 줄(build_table({}, road_surface_area_m2=0.0), "표토제거") - assert row["amount"] is None - - -def test_erosion_row_uses_registry_name_not_type_id(): - """사방공 줄 이름에 개발자 키가 새지 않는다 (2026-09-08 ㉕ 통과에서 `soil_guard` 로 뜬 자리).""" - rows = erosion_rows([{"type_id": "soil_guard"}], {"soil_guard": "흙막이"}) - assert [row["item"] for row in rows] == ["흙막이"] - assert rows[0]["type_id"] == "soil_guard" - - -def test_erosion_row_falls_back_to_wording_table_without_registry(): - """이름표가 없어도 문구표가 받아 준다 — 없으면 키를 보이되 지어내지 않는다.""" - assert erosion_rows([{"type_id": "soil_guard"}])[0]["item"] == "흙막이" - assert erosion_rows([{"type_id": "erosion_check"}])[0]["item"] == "골막이" diff --git a/resources/tester/test_b08_price_search_fragments.py b/resources/tester/test_b08_price_search_fragments.py deleted file mode 100644 index 88fc07340..000000000 --- a/resources/tester/test_b08_price_search_fragments.py +++ /dev/null @@ -1,61 +0,0 @@ -"""고르개 후보 — 두 글자 이상 조각이 겹치면 띄움 (2026-09-14 브레인 판정 ①). - -실측(936be972): 고정형 줄 이름 그대로(깬잡석채집·고임돌채집…)로 찾으면 **후보 0** — 단가표 제목 -(「막돌 채집」·「레디믹스트콘크리트 타설」)과 낱말이 달라 「낱말 전부 포함」 방식으로는 영영 안 맞음. -⇒ 겹친 조각(「채집」·「타설」)으로 넓게 띄우고 **겹친 글자 수 순**. 고르는 것은 사람(별칭표로 안 맞춤 · 명세 2장). -""" - -from __future__ import annotations - -from decimal import Decimal -from types import SimpleNamespace - -from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles -from B09_Estimation.B09_Estimation_PriceBook import Money3 - - -def _title(code: str, name: str, spec: str, unit: str) -> SimpleNamespace: - return SimpleNamespace( - code=code, name=name, spec=spec, unit=unit, kind=SimpleNamespace(value="unit_price") - ) - - -class Book: - titles = { - t.code: t - for t in ( - _title("B-1", "막돌 채집", "㎡당", "㎡"), - _title("B-2", "레디믹스트콘크리트 타설", "무근구조물", "㎥"), - _title("B-3", "고임돌 채집", "기계", "㎥"), - _title("B-4", "견치돌 찰쌓기", "뒷길이 35㎝", "㎡"), - _title("B-5", "표토 제거", "", "㎡"), - ) - } - - def resolve(self, code: str) -> Money3: - return Money3(Decimal(1), Decimal(0), Decimal(0)) - - -def _codes(query: str) -> list[str]: - return [item["code"] for item in search_titles(Book(), query, "work")] - - -def test_이름_그대로도_겹친_조각으로_후보가_뜬다() -> None: - assert _codes("깬잡석채집")[:1] and set(_codes("깬잡석채집")) >= {"B-1", "B-3"} - assert _codes("레미콘타설(장비)") == ["B-2"] - assert _codes("깬잡석찰쌓기") == ["B-4"] - - -def test_겹친_글자_수_순() -> None: - # 「고임돌채집」 — 고임돌 채집(5자 겹침) · 막돌 채집(「돌채집」 3자) 차례 - assert _codes("고임돌채집")[:2] == ["B-3", "B-1"] - - -def test_한_글자만_겹치면_안_띄운다() -> None: - assert "B-5" not in _codes("깬잡석채집") # 겹침 없음 - assert "B-4" not in _codes("고임돌채집") # 「돌」 한 글자만 겹침 - - -def test_종전_낱말_검색도_그대로() -> None: - assert _codes("찰쌓기 35") == ["B-4"] - assert set(_codes("돌")) == {"B-1", "B-3", "B-4"} # 낱말이 제목에 든 것은 종전대로 diff --git a/resources/tester/test_b08_provenance.py b/resources/tester/test_b08_provenance.py deleted file mode 100644 index 597f3fc35..000000000 --- a/resources/tester/test_b08_provenance.py +++ /dev/null @@ -1,132 +0,0 @@ -"""B08 근거 사전 — 사전이 **엔진과 어긋나지 않는지** 지키는 시험 (PLAN 8-36 ④). - -이 시험의 값어치는 마지막 것 하나에 있다: **사전에 적은 열 이름이 실제 토적표 줄에 있는가.** -엔진이 열을 바꾸거나 이름을 갈면 사전만 옛것으로 남아, 맞는 값 옆에 틀린 근거가 붙는다. -그 어긋남은 화면에서 눈에 안 띄므로(카드가 그냥 안 뜬다) 여기서 잡는다. -""" - -from __future__ import annotations - -import dataclasses - -import pytest - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryRow -from B08_Quantity.B08_Quantity_Engine_EarthworkTable import EarthworkRow -from B08_Quantity.B08_Quantity_Engine_HaulSummary import HaulSummaryRow -from B08_Quantity.B08_Quantity_Provenance import ( - earthwork_sheet, - haul_sheet, - quantity_provenance, - summary_sheet, -) -from common_util import common_util_provenance as provenance_module -from common_util.common_util_provenance import ( - TIERS, - ColumnProvenance, - provenance_payload, - sheet_provenance, -) - - -def test_토적표_사전이_엔진_열과_같은_이름을_쓴다(): - """사전 열 키가 전부 `EarthworkRow` 에 있어야 한다 — **이 시험이 사전의 존재 이유다.**""" - row_fields = {field.name for field in dataclasses.fields(EarthworkRow)} - dictionary = earthwork_sheet()["columns"] - 낯선_키 = sorted(set(dictionary) - row_fields) - assert not 낯선_키, f"사전에 있는데 토적표 줄에 없는 열: {낯선_키}" - - -def _모든_열(): - """개발환경에서 실리는 여섯 장을 한 줄로 펜다 — (장 이름, 열 키, 몸통).""" - payload = quantity_provenance() - assert payload is not None - for sheet_name, sheet in payload["sheets"].items(): - for key, body in sheet["columns"].items(): - yield sheet_name, key, body - - -def test_여섯_장이_다_실린다(): - payload = quantity_provenance() - assert payload is not None - assert set(payload["sheets"]) == { - "earthwork", - "summary", - "haul", - "preparation", - "material", - "unit_quantity", - } - - -def test_사전_등급이_전부_아는_값이다(): - for sheet_name, key, body in _모든_열(): - assert body["tier"] in TIERS, f"{sheet_name}.{key} 등급이 모르는 값: {body['tier']}" - - -def test_사전_열마다_이름과_식이_비어_있지_않다(): - """빈 카드는 「설명이 있다」는 거짓만 남긴다 — 적을 것이 없으면 열을 아예 안 넣는다.""" - for sheet_name, key, body in _모든_열(): - assert body.get("label"), f"{sheet_name}.{key} 에 이름이 없음" - assert body.get("formula"), f"{sheet_name}.{key} 에 식이 없음" - - -def test_같은_열을_두_번_적으면_막는다(): - with pytest.raises(ValueError): - sheet_provenance( - [ - ColumnProvenance(key="a", label="가", tier="calc", formula="x"), - ColumnProvenance(key="a", label="나", tier="calc", formula="y"), - ] - ) - - -def test_모르는_등급을_적으면_막는다(): - with pytest.raises(ValueError): - sheet_provenance([ColumnProvenance(key="a", label="가", tier="없는등급")]) - - -def test_배포환경에서는_사전을_아예_안_보낸다(monkeypatch): - """⚠ 로직 보안 — 화면에서 숨기는 것이 아니라 **응답에 안 싣는 것**이 문이다.""" - monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: False) - assert provenance_payload({"earthwork": earthwork_sheet()}) is None - assert quantity_provenance() is None - - -def test_개발환경에서는_시트가_실린다(monkeypatch): - monkeypatch.setattr(provenance_module, "is_dev_environment", lambda: True) - payload = quantity_provenance() - assert payload is not None - assert "earthwork" in payload["sheets"] - - -def test_고르는_자리의_채택_규칙은_적었을_때만_실린다(): - """`rule` 은 안전관리비처럼 **값 안에 선택이 숨은** 열에만 붙는다(B09 조사 ㉯).""" - 없는_것 = ColumnProvenance(key="a", label="가", tier="calc", formula="x").as_dict() - assert "rule" not in 없는_것 - 있는_것 = ColumnProvenance( - key="b", label="나", tier="calc", formula="x", rule="A·B 중 작은 쪽" - ).as_dict() - assert 있는_것["rule"] == "A·B 중 작은 쪽" - - -def test_집계표_사전이_엔진_열과_같은_이름을_쓴다(): - row_fields = {field.name for field in dataclasses.fields(SummaryRow)} - 낯선_키 = sorted(set(summary_sheet()["columns"]) - row_fields) - assert not 낯선_키, f"사전에 있는데 집계표 줄에 없는 열: {낯선_키}" - - -def test_운반표_사전이_엔진_열과_같은_이름을_쓴다(): - """⚠ `average_distance_m` 은 필드가 아니라 property 라 필드 목록만 보면 놓친다.""" - names = {field.name for field in dataclasses.fields(HaulSummaryRow)} - names |= {n for n in dir(HaulSummaryRow) if not n.startswith("_")} - 낯선_키 = sorted(set(haul_sheet()["columns"]) - names) - assert not 낯선_키, f"사전에 있는데 운반표 줄에 없는 열: {낯선_키}" - - -def test_집계표에_최종이_서고_토적표에는_없다(): - """등급 여섯은 한 장이 아니라 **두 장을 합쳐야** 다 쓰인다 — 그 갈림을 시험으로 박는다.""" - 토적표 = {body["tier"] for body in earthwork_sheet()["columns"].values()} - 집계표 = {body["tier"] for body in summary_sheet()["columns"].values()} - assert "final" not in 토적표, "토적표는 중간 장부라 최종 열이 없어야 함" - assert "final" in 집계표, "내역서로 나가는 값은 집계표 「계」에서 서야 함" diff --git a/resources/tester/test_b08_rebar_name_spec.py b/resources/tester/test_b08_rebar_name_spec.py deleted file mode 100644 index 0a7f0cd4b..000000000 --- a/resources/tester/test_b08_rebar_name_spec.py +++ /dev/null @@ -1,116 +0,0 @@ -"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼. - -「이형철근 D13」 한 칸을 이름 「이형철근」 + 규격 「D13」 으로 가르면 셋이 같은 글자에 묶여 있어 -하나만 고치면 조용히 어긋남(브레인 경고): ① 묶음 조각 `from_components` · `amounts[name]` -(D13·D16 이 서로 덮어씀) ② 이중계상 경계 검사 `_pickers`·`destinations`(못 집으면 검사가 놂) -③ 자재총괄 줄·할증. 이 시험은 **가르기 전 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 함. - - 옹벽 반중력식 10m D13 13.45 · D16 30.42 ㎏/m → 조각 0.4387 ton · 자재 134.5 · 304.2 ㎏ - 집수정 □형 Ø800 1개소 D13 4.776 ㎏ - L형수로 H=0.2 10m D13 0.398 ㎏/m → 3.98 ㎏ -""" - -from __future__ import annotations - -import copy -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402 - verify_double_count_boundaries, -) -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 - - -def _구조물들() -> dict: - return build_unit( - [ - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, - }, - { - "structure_id": "b1", - "type_id": "pipe_inlet_basin", - "start_m": 200.0, - "end_m": 200.0, - "options": { - "inlet_basin_form": "□형(기본형)", - "inlet_basin_material": "콘크리트", - "pipe_diameter_mm": "800", - }, - }, - { - "structure_id": "d1", - "type_id": "open_ditch", - "start_m": 300.0, - "end_m": 310.0, - "options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0}, - }, - ], - {"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"}, - ) - - -def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None: - row = next( - r - for r in build_handoff(unit_quantity_table=_구조물들())["work_items"] - if r.get("composite_parts") - ) - rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code"))) - assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready") - from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind - - wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall") - assert structure_kind(wall) == "철근구조물" - - -def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None: - table = build_table(_구조물들()) - rebar = { - row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"]) - for row in table["rows"] - if row["supply_key"].startswith("이형철근") - } - assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자 - assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98) - assert rebar["이형철근 D16"][0] == pytest.approx(304.2) - assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근 - assert "이형철근" not in " ".join(table["missing_rate_materials"]) - - -def test_경계_검사가_철근을_여전히_집는다() -> None: - """철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂.""" - unit = copy.deepcopy(_구조물들()) - wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall") - for component in wall["components"]: - if str(component["name"]).startswith("이형철근"): - component["destination"] = "earthwork" - found = verify_double_count_boundaries(unit, None, load_mapping()) - assert any("이형철근" in text and "묶음 조각" in text for text in found), found - assert not verify_double_count_boundaries(_구조물들(), None, load_mapping()) - - -def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None: - table = build_table(_구조물들()) - rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")] - assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")] - unit = _구조물들() - specs = { - (c["name"], c.get("spec")) - for s in unit["structures"] - for c in s["components"] - if str(c["name"]).startswith("이형철근") - } - assert specs == {("이형철근", "D13"), ("이형철근", "D16")} diff --git a/resources/tester/test_b08_revegetation_unit.py b/resources/tester/test_b08_revegetation_unit.py deleted file mode 100644 index 06a00db71..000000000 --- a/resources/tester/test_b08_revegetation_unit.py +++ /dev/null @@ -1,193 +0,0 @@ -"""비탈면 녹화 ㎡ 넷 — 새심기·평떼·줄떼·비탈덮기 (2026-09-15 브레인 12장 D 녹화 ⓐ~ⓓ). - -근거 - 산림품셈 5-14 새심기(㎡당) · 5-22 평떼(떼 0.3×0.3 11매/㎡ · 붙임 ㎡ · 떼꽂이 ㎡) · - 5-23 줄떼(떼 3.33매/㎡ · 붙임 ㎡ · 떼꽂이 ㎡) · 5-25 거적덮기(㎡당) · 5-28 비탈덮기(짚망 · 매트) -판정 — 면적 = 구간 안 사면 면적(칸: 절토면/성토면/양쪽 · 기본값 없음) · 초류종자 살포는 공법에서 뺌 - (토공집계가 5-24 로 사면 전체를 셈) · 단 직고·선떼 급수 칸은 기본값 없음 · 길이형은 다음 차례. -""" - -from __future__ import annotations - -import pytest - -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revegetation import ( - revegetation_from_designs, -) - -AREAS = {"b06_cut_area_m2": 40.0, "b06_fill_area_m2": 60.0} - - -def _row(**options) -> dict: - structure = { - "structure_id": "g1", - "type_id": "revegetation", - "start_m": 0.0, - "end_m": 20.0, - "options": {**AREAS, **options}, - } - table = build_table([structure], {"revegetation": "비탈면 녹화"}) - return table - - -def _parts(table: dict) -> dict: - row = next( - r for r in build_handoff(unit_quantity_table=table)["work_items"] if "녹화" in r["name"] - ) - return {part["code"]: part for part in row["composite_parts"] or []} - - -def test_평떼_양쪽은_면적과_떼_11매() -> None: - table = _row(method="평떼", face="양쪽") - row = table["structures"][0] - got = {(c["name"], c["unit"]): c["amount"] for c in row["components"]} - assert (row["billing_unit"], row["billing_quantity"]) == ("㎡", pytest.approx(100.0)) - assert got[("평떼", "㎡")] == pytest.approx(100.0) - assert got[("떼", "매")] == pytest.approx(1100.0) - text = " ".join(row["notes"]) - # 병행시공(임도기술교본 7장 · 울진1공구) — 흠이 아니라 정보(⚠ 안 붙임 · 안티그래비티 확인) - assert row["notes"][0].startswith("녹화와 초류종자살포는 병행시공(임도기술교본 7장)") - assert "떼 면적을 파종에서 빼지 않음" in text and "⚠" not in row["notes"][0] - assert "5-12 품에 걸린 것으로 읽음 — 5-22-3 떼꽂이는 따로 셈" in text - parts = _parts(table) - assert parts["FP-05-22-02"]["quantity"] == pytest.approx(100.0) - assert parts["FP-05-22-03"]["quantity"] == pytest.approx(100.0) - - -def test_줄떼_절토면은_3_33매() -> None: - table = _row(method="줄떼", face="절토면") - got = {(c["name"], c["unit"]): c["amount"] for c in table["structures"][0]["components"]} - assert got[("줄떼", "㎡")] == pytest.approx(40.0) - assert got[("떼", "매")] == pytest.approx(40.0 * 3.33) - assert set(_parts(table)) == {"FP-05-23-02", "FP-05-23-03"} - - -def test_새심기_성토면() -> None: - table = _row(method="새심기", face="성토면") - assert _parts(table)["FP-05-14"]["quantity"] == pytest.approx(60.0) - - -def test_비탈덮기는_덮개를_골라야_선다() -> None: - table = _row(method="비탈덮기", face="양쪽", cover_kind="거적") - assert _parts(table)["FP-05-25"]["quantity"] == pytest.approx(100.0) - unset = _row(method="비탈덮기", face="양쪽")["structures"][0] - assert not unset["components"] and any("덮개" in n for n in unset["notes"]) - - -def test_면을_안_고르면_사유() -> None: - row = _row(method="평떼")["structures"][0] - assert not row["components"] and any("절토면" in n and "성토면" in n for n in row["notes"]) - - -def test_조공은_아직_산출식이_없다() -> None: - row = _row(method="조공", face="양쪽")["structures"][0] - assert not row["components"] - assert any("산출식 아직 없음" in n for n in row["notes"]) - - -SOD = { - "method": "선떼붙이기", - "face": "양쪽", - "step_height_m": 1.5, - "sod_grade": "5급", - "sod_size": "대형 40×40", - "cutting_soil": "보통토사", - # 구간 안 사면 낙차 × 거리(m·m) — 절토 30 · 성토 60 ⇒ 연장 = 90 ÷ 1.5 = 60m - "b06_cut_rise_area_m2": 30.0, - "b06_fill_rise_area_m2": 60.0, -} - - -def test_선떼는_낙차_나누기_단_직고로_연장_표_2_2_6_매수() -> None: - """품셈 5-16-1 [주]② 연장 = 대상지 직고 ÷ 단 직고 × 연장 · 표 2-2-6 표 값(식 아님).""" - table = _row(**SOD) - row = table["structures"][0] - got = {(c["name"], c["unit"]): c for c in row["components"]} - assert (row["billing_unit"], row["billing_quantity"]) == ("m", pytest.approx(60.0)) - assert got[("선떼붙이기", "m")]["amount"] == pytest.approx(60.0) - # 5급 대형 표 값 7.80매/m(단면식 3.0 × 2.5 = 7.50 과 어긋남 — 표가 이김) - assert got[("떼", "매")]["amount"] == pytest.approx(60.0 * 7.80) - assert got[("떼", "매")]["spec"] == "40×40" - assert got[("떼붙임", "㎡")]["amount"] == pytest.approx(60.0 * 7.80 * 0.16) - text = " ".join(row["notes"]) - assert "5급 대형 표 7.80 ↔ 단면식 7.50" in text and "소형 2급 표 27.0 ↔ 단면 4.0" in text - parts = _parts(table) - assert parts["FP-05-16-01#보통토사"]["quantity"] == pytest.approx(60.0) - # 떼붙임은 수량만 — 호표가 없어 막힘 + 사유(금액 안 섬) - assert parts["FP-05-16-02"]["quantity"] == pytest.approx(60.0 * 7.80 * 0.16) - assert parts["FP-05-16-02"]["not_ready"] - assert "떼붙임 호표 없음" in parts["FP-05-16-02"]["why"] - - -def test_선떼_소형_2급은_표_값_27() -> None: - table = _row(**{**SOD, "sod_grade": "2급", "sod_size": "소형 18×18"}) - got = {(c["name"], c["unit"]): c["amount"] for c in table["structures"][0]["components"]} - assert got[("떼", "매")] == pytest.approx(60.0 * 27.0) - assert got[("떼붙임", "㎡")] == pytest.approx(60.0 * 27.0 * 0.18 * 0.18) - - -@pytest.mark.parametrize("missing", ["step_height_m", "sod_grade", "sod_size"]) -def test_선떼_칸이_비면_사유(missing: str) -> None: - row = _row(**{k: v for k, v in SOD.items() if k != missing})["structures"][0] - assert not row["components"] and row["notes"] - - -def test_단끊기_토질을_안_고르면_그_조각만_막힘() -> None: - table = _row(**{k: v for k, v in SOD.items() if k != "cutting_soil"}) - part = _parts(table)["FP-05-16-01"] - assert part["not_ready"] and "토질" in part["why"] - - -def test_구간_안_사면_면적은_B06_측점에서_보간() -> None: - designs = [ - {"chainage_m": 0.0, "design": {}}, - {"chainage_m": 20.0, "design": {}}, - ] - from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope - - slopes = [ - StationSlope(chainage_m=0.0, cut_length_m=2.0, fill_length_m=4.0), - StationSlope(chainage_m=20.0, cut_length_m=4.0, fill_length_m=0.0), - ] - structure = {"type_id": "revegetation", "start_m": 5.0, "end_m": 20.0, "options": {}} - filled = revegetation_from_designs(structure, designs, slopes) - # 절토 5→2.5, 20→4 : (2.5+4)/2×15 · 성토 5→3, 20→0 : (3+0)/2×15 - assert filled["options"]["b06_cut_area_m2"] == pytest.approx(48.75) - assert filled["options"]["b06_fill_area_m2"] == pytest.approx(22.5) - - -def test_구간_안_사면_낙차_면적도_B06_측점에서() -> None: - from B08_Quantity.B08_Quantity_Engine_SlopeLength import SlopeSegment, StationSlope - - def seg(role: str, rise: float) -> SlopeSegment: - return SlopeSegment("left", role, 0.0, 1.0, rise, 1.0, 1.0) - - slopes = [ - StationSlope(chainage_m=0.0, segments=(seg("cut", 2.0), seg("fill", -4.0))), - StationSlope(chainage_m=20.0, segments=(seg("cut", 4.0), seg("cut", 1.0))), - ] - structure = {"type_id": "revegetation", "start_m": 0.0, "end_m": 20.0, "options": {}} - options = revegetation_from_designs(structure, [], slopes)["options"] - assert options["b06_cut_rise_area_m2"] == pytest.approx((2.0 + 5.0) / 2 * 20) - assert options["b06_fill_rise_area_m2"] == pytest.approx((4.0 + 0.0) / 2 * 20) - - -def test_등록부_공법에서_초류종자_살포를_빼고_까닭을_보인다() -> None: - options = {o.key: o for o in structure_type_map()["revegetation"].options} - assert "초류종자 살포" not in options["method"].choices - assert "토공집계" in (options["method"].default_basis or "") - assert ( - options["face"].choices == ["절토면", "성토면", "양쪽"] and options["face"].default is None - ) - for key in ("step_height_m", "sod_grade", "sod_size", "cutting_soil"): - assert options[key].default is None and not options[key].not_in_table - assert options["sod_size"].choices == ["대형 40×40", "소형 18×18"] - # 단끊기 토질은 품셈 5-16-1 표 갈래 이름 그대로(B06 지반 프리셋과 다른 축) - assert options["cutting_soil"].choices == [ - "보통토사", - "경질ㆍ고사점토 및 자갈섞인 점토", - "호박돌 섞인 토사", - ] diff --git a/resources/tester/test_b08_rock_haul_split.py b/resources/tester/test_b08_rock_haul_split.py deleted file mode 100644 index ea8ca7c93..000000000 --- a/resources/tester/test_b08_rock_haul_split.py +++ /dev/null @@ -1,106 +0,0 @@ -"""㉱ (가) 암 운반량을 구성비로 가르기 — 2026-09-14 브레인 판정(구성비가 정본 · 8-1 사용자 확정). - -B06 토사 토글 끔 = 저장값 `ripping_rock` 은 **자리표시**(「갈라 넣는 것은 설계내역 몫」)인데 -운반·사토가 그 이름을 값처럼 써서 「깎기 암은 구성비가 비어 막히는데 운반 암은 리핑암으로 금액이 섬」. -⇒ 운반표 한 곳에서 암 줄을 구성비·시공법으로 가름 · 비면 「암」 한 줄로 막음(깎기와 같은 사유). -유토곡선 거리·다짐 부피는 그대로 — 검산이 안 흔들림((나)는 뒤 차례). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as summary # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: E402 - NOTE_METHOD_MISSING, - NOTE_ROCK_RATIO_MISSING, -) -from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table, check_against_plan # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split # noqa: E402 - -CLASSES = ["토사", "풍화암", "연암", "보통암", "경암"] -PLAN = { - "blocks": [ - { - "bands": [ - {"equipment": "dozer", "haul_distance_m": 40.0, "haul_from_m": 0.0, - "haul_to_m": 40.0, "ea_m3": 90.0, "rr_m3": 115.0, "br_m3": 0.0}, - ] - } - ], - "transfers": [], - "hauled_m3": 205.0, - "transferred_m3": 0.0, -} # fmt: skip -SPOIL = {"volume_m3": 23.0, "distance_m": 300.0, "by_ground_m3": {"rr_m3": 23.0}, - "natural_m3_by_ground": {"rr_m3": 20.0}} # fmt: skip - - -def _haul(ratios: dict, methods: dict) -> dict: - haul = build_table(PLAN) - haul["spoil"] = dict(SPOIL) - apply_rock_split(haul, CLASSES, ratios, methods) - return haul - - -def _rock(haul: dict) -> list[dict]: - return [r for r in haul["rows"] if r["ground"] != "토사"] - - -def test_구성비가_비면_운반_암도_한_줄_암으로_막힘() -> None: - haul = _haul({}, {}) - (rock,) = _rock(haul) - assert rock["ground"] == "암" and rock["natural_m3"] == pytest.approx(100.0) - assert rock["blocked_reason"] == NOTE_ROCK_RATIO_MISSING - items = build_handoff(haul_table=haul)["work_items"] - dozer = next(r for r in items if r["name"].endswith("운반") and r["ground_class"] == "암") - assert ( - dozer["blocked_kind"] == "input_missing" - and dozer["blocked_reason"] == NOTE_ROCK_RATIO_MISSING - ) - spoil = next(r for r in items if r["name"] == "사토 운반") - assert ( - spoil["blocked_kind"] == "input_missing" - and NOTE_ROCK_RATIO_MISSING in spoil["blocked_reason"] - ) - - -def test_구성비와_시공법대로_갈리고_검산은_그대로() -> None: - ratios = {"연암": 60.0, "보통암": 40.0} - methods = {"연암": "ripping", "보통암": "blasting"} - haul = _haul(ratios, methods) - got = [(r["rock_class"], r["ground"], round(r["natural_m3"], 6)) for r in _rock(haul)] - assert got == [("연암", "리핑암", 60.0), ("보통암", "발파암", 40.0)] - assert check_against_plan(haul, PLAN).difference_m3 == pytest.approx(0.0) - items = build_handoff(haul_table=haul)["work_items"] - dozer = [r for r in items if r["haul_equipment"] == "dozer" and r["ground_class"] != "토사"] - assert [(r["spec"], r["variant_value"], r["blocked_kind"]) for r in dozer] == [ - ("연암 · 리핑암", "리핑암", None), - ("보통암 · 발파암", "발파암", None), - ] - spoil = [r for r in items if r["name"] == "사토 운반"] - assert [(r["variant_value"], round(r["quantity"], 3)) for r in spoil] == [ - ("리핑암", 12.0), - ("발파암", 8.0), - ] - # 깎기와 같은 몫 — 흙깎기 암 1000 이 같은 구성비로 600 · 400 - rows = summary(SummaryInput(earthwork_totals={"cut_rock_volume_m3": 1000.0}, - rock_classes=CLASSES, rock_ratios_pct=ratios))["rows"] # fmt: skip - cut = {r["item"]: r["amount"] for r in rows if r["group"] == "흙깎기" and r["item"] != "토사"} - assert cut == {"연암": 600.0, "보통암": 400.0} - - -def test_시공법을_안_고른_갈래만_막힘() -> None: - haul = _haul({"연암": 50.0, "경암": 50.0}, {"연암": "ripping"}) - rows = {r["rock_class"]: r for r in _rock(haul)} - assert not rows["연암"]["blocked_reason"] - assert rows["경암"]["blocked_reason"] == NOTE_METHOD_MISSING diff --git a/resources/tester/test_b08_rubble_base.py b/resources/tester/test_b08_rubble_base.py deleted file mode 100644 index eb31e5b98..000000000 --- a/resources/tester/test_b08_rubble_base.py +++ /dev/null @@ -1,116 +0,0 @@ -"""기초잡석 — 버림 폭에서 나온다 (2026-09-09 사용자 확정 3차 ②). - -⚠ 겨누는 것 여섯 - ① 두께는 **0.2 m**(사용자 확정) — 품셈 12-25 는 ㎥당 품만 주고 두께를 안 정함 - ② 폭을 다시 세지 않는다 — **버림 폭이 곧 잡석다짐 폭**(KCS 34 50 05)이라 두께 비만 곱함 - ③ ⚠ 그래서 **관측 원단위로 오는 옹벽에도 값이 선다**(버림 0.145㎥/m ⇒ 잡석 0.29㎥/m - · 2026-09-14 원문 값 — 전 라이브러리 반올림 0.15 ⇒ 0.30) - ④ 두께는 화면에서 바꿀 수 있다 - ⑤ ⚠ 자재총괄에 안 섞인다 — 운반·부설·다짐 품이 붙는 **공종**이다 - ⑥ ⚠ 묶음으로 서는 구조물은 줄을 또 세우지 않는다(옹벽 조각이 이미 셈) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 - build_table as build_material, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - RUBBLE_BASE_THICKNESS_M, - build_table, -) - - -def 돌쌓기(thickness: float | None = None) -> dict: - return build_table( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": { - "height_m": 2.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - } - ], - None, - None, - None, - thickness, - ) - - -def 옹벽() -> dict: - return build_table( - [ - { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 100.0, - "end_m": 110.0, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, - } - ], - {"retaining_wall": "옹벽"}, - ) - - -def 성분(table: dict, name: str) -> dict | None: - for structure in table["structures"]: - for component in structure["components"]: - if component["name"] == name: - return component - return None - - -def test_두께는_사용자_확정값() -> None: - assert RUBBLE_BASE_THICKNESS_M == 0.2 - - -def test_버림_두께_비로_나온다() -> None: - """버림 1.2㎥ × (0.2 ÷ 0.1) = 2.4㎥ — 폭을 다시 세지 않는다.""" - table = 돌쌓기() - assert abs(성분(table, "버림콘크리트")["amount"] - 1.2) < 1e-9 - assert abs(성분(table, "기초잡석")["amount"] - 2.4) < 1e-9 - - -def test_관측_원단위_구조물에도_선다() -> None: - """⚠ 옹벽은 관측 원단위로 오는데 그 표에 기초잡석 줄이 없다 — 버림에서 나온다.""" - assert abs(성분(옹벽(), "기초잡석")["amount"] - 2.9) < 1e-9 # 0.29㎥/m × 10m - - -def test_두께를_바꾸면_따라간다() -> None: - assert abs(성분(돌쌓기(0.3), "기초잡석")["amount"] - 3.6) < 1e-9 - - -def test_자재총괄에_안_섞인다() -> None: - """⚠ 운반·부설·다짐 품이 붙는 공종이다 — 자재로 서면 같은 잡석을 두 번 센다.""" - assert 성분(돌쌓기(), "기초잡석")["destination"] == "unit_price" - assert all(row["name"] != "기초잡석" for row in build_material(돌쌓기())["rows"]) - - -def test_인계에_공종_줄로_선다() -> None: - rows = build_handoff(unit_quantity_table=돌쌓기())["work_items"] - row = next(r for r in rows if r["name"] == "기초잡석") - assert row["work_item_code"] == "FP-12-25" - assert abs(row["quantity"] - 2.4) < 1e-9 and row["in_bill"] is True - - -def test_묶음_구조물은_줄을_또_세우지_않는다() -> None: - """⚠ 옹벽 묶음에 이미 FP-12-25 조각이 있다 — 여기서 또 세우면 두 번 센다.""" - rows = build_handoff(unit_quantity_table=옹벽())["work_items"] - assert all(row["name"] != "기초잡석" for row in rows) - parts = rows[0]["composite_parts"] - gravel = next(part for part in parts if part["code"] == "FP-12-25") - assert abs(gravel["quantity"] - 2.9) < 1e-9 diff --git a/resources/tester/test_b08_seed_spray_leaf.py b/resources/tester/test_b08_seed_spray_leaf.py deleted file mode 100644 index c8921d0e6..000000000 --- a/resources/tester/test_b08_seed_spray_leaf.py +++ /dev/null @@ -1,70 +0,0 @@ -"""초류종자살포 잎 고르기 — 2026-09-14 브레인 차례 ㉮(B08 나머지 탭 훑기). - -매핑이 부모(FP-05-24 씨앗뿜어붙이기 · 갈래 고르기형)를 가리켜 B09 가 「잎 미선택」으로 막는데 고를 칸이 없었음 -→ 금액이 영영 안 섬(면고르기와 같은 병). 산출 조건 「초류종자살포 비탈면 토질」(5-24-1 일반 · 5-24-2 마사토) -칸 · 매핑 `leaf_from`·`leaf_codes` 가 잎 코드로 잇고 · 비면 금액 없이 입력 사유 · 제안값 없음(지어내지 않음). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SEED_SPRAY_GROUNDS # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 - -SUMMARY = {"rows": [{"group": "초류종자살포", "item": "", "spec": "씨드스프레이", "unit": "㎡", - "amount": 1500.0, "application_ratio_pct": 100.0}]} # fmt: skip - - -def _row(**inputs) -> dict: - rows = build_handoff(summary_table=SUMMARY, **inputs)["work_items"] - return next(r for r in rows if r["name"] == "초류종자살포") - - -def test_토질을_고르면_잎_코드로_감() -> None: - assert _row(seed_spray_ground="일반")["work_item_code"] == "FP-05-24-01" - row = _row(seed_spray_ground="마사토") - assert row["work_item_code"] == "FP-05-24-02" and not row["blocked_kind"], row - - -def test_토질이_비면_부모_코드에_금액_없이_입력_사유() -> None: - row = _row() - assert row["work_item_code"] == "FP-05-24" - assert row["blocked_kind"] == "input_missing", row - assert "일반·마사토" in row["blocked_reason"] and "산출 조건" in row["blocked_reason"] - - -def test_선택지는_마스터_잎_이름과_같음() -> None: - from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master - - leaves = { - n["work_item_code"]: n["name"].split("/")[-1] - for n in load_work_item_master()["work_items"] - if n.get("parent_code") == "FP-05-24" - } - assert leaves == {"FP-05-24-01": "일반", "FP-05-24-02": "마사토"} - assert set(SEED_SPRAY_GROUNDS) == set(leaves.values()) - - -def test_산출_조건이_받고_선택지_밖은_안_정함으로() -> None: - source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") - # 값 거르기는 2026-09-15 `_Router_Earthwork_Settings` 로 가름(700줄) — 규칙은 그대로. - settings = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork_Settings.py").read_text( - "utf-8" - ) - assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in settings - assert 'table["seed_spray_choices"]' in source - material = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Material.py").read_text("utf-8") - assert 'seed_spray_ground=settings.get("seed_spray_ground") or None' in material - - -def test_화면_칸이_있고_저장_읽기에_실림() -> None: - page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text("utf-8") - assert "seed_spray_ground: draft.seed_spray_ground" in page - assert "seed_spray_ground: (stored.seed_spray_ground" in page - assert "appendSeedSprayField(" in page diff --git a/resources/tester/test_b08_settings_recompute_haul.py b/resources/tester/test_b08_settings_recompute_haul.py deleted file mode 100644 index 0975d9e92..000000000 --- a/resources/tester/test_b08_settings_recompute_haul.py +++ /dev/null @@ -1,64 +0,0 @@ -"""산출 조건 [저장]이 운반계획을 다시 세움 — 2026-09-14 브레인 ②(지침 5장 「[저장]·[확정]은 서버가 정본으로 다시 계산」). - -구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획)의 밑수인데 B08 [저장]이 운반계획을 다시 안 셈 -→ B06 을 다시 저장하기 전까지 토적표(새 계수) ↔ 운반표(옛 계획)가 갈렸음(계수 칸은 전부터 같은 틈). -⇒ 그 칸이 **바뀐 저장**만 서버 재계산(`recompute_server_side`)을 부름 · 다른 칸 저장은 안 부름(무거움). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Router_Earthwork as earthwork_router # noqa: E402 - -PROJECT_ID = "56565656-5656-5656-5656-565656565656" -URL = f"/api/projects/{PROJECT_ID}/quantity/settings" - - -@pytest.fixture() -def calls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[TestClient, list]: - recorded: list = [] - - async def fake_run(func, *args): - return "project" - - async def fake_recompute(project_id): - recorded.append(str(project_id)) - return True - - monkeypatch.setattr(earthwork_router, "run_with_connection", fake_run) - monkeypatch.setattr(earthwork_router, "resolve_stored_project_path", lambda _p: str(tmp_path)) - monkeypatch.setattr(earthwork_router, "_recompute_haul_plan", fake_recompute) - app = FastAPI() - app.include_router(earthwork_router.router) - return TestClient(app), recorded - - -def test_운반계획_밑수가_바뀐_저장만_재계산(calls) -> None: - client, recorded = calls - ratios = {"rock_ratios_pct": {"연암": 60, "보통암": 40}} - first = client.put(URL, json=ratios) - assert first.status_code == 200 and first.json()["haul_plan_recomputed"] is True - assert recorded == [PROJECT_ID] - # 같은 값을 다시 저장 — 안 부름 - assert client.put(URL, json=ratios).json()["haul_plan_recomputed"] is False - # 운반계획과 무관한 칸 — 안 부름 - assert ( - client.put(URL, json={"topsoil_thickness_m": 0.2}).json()["haul_plan_recomputed"] is False - ) - assert len(recorded) == 1 - for body in ( - {"rock_methods": {"연암": "ripping"}}, - {"conversion_factors_override": {"soil": {"compacted": 0.85}}}, - {"dozer_haul_limit_m": 70}, - ): - assert client.put(URL, json=body).json()["haul_plan_recomputed"] is True, body - assert len(recorded) == 4 diff --git a/resources/tester/test_b08_slope_area.py b/resources/tester/test_b08_slope_area.py deleted file mode 100644 index 97b00e740..000000000 --- a/resources/tester/test_b08_slope_area.py +++ /dev/null @@ -1,260 +0,0 @@ -"""사면길이 유도·사면 4계열 면적 검사 — PLAN 8-4b·8-11. - -사면길이는 저장분에서 **유도**하는 값이라 판정 규칙이 곧 정확성이다. 그래서 -실측 설계선 모양을 그대로 넣어 사면과 원지반이 갈리는지를 못 박는다. -""" - -from __future__ import annotations - -import math -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_SlopeArea import ( # noqa: E402 - SlopeRatios, - build_rows, - build_table, - totals, - unclosed_stations, -) -from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402 - StationSlope, - station_slope, -) - - -def line(points: list[tuple[float, float]]) -> list[dict[str, float]]: - return [{"offset_m": o, "elevation_m": z} for o, z in points] - - -def 절토_설계선() -> dict: - """측점 20.0 **실측 모양 그대로** — 노면·측구·2단 절토 사면·원지반 (우측). - - 실측 구간 기울기: 노면 n=33.3 · 측구 벽 n=1.0 · 절토(암) n=0.4 · 절토(토사) n=1.0 · - 원지반 n=1.63. 측구 바닥은 노면보다 **낮다**(깊이 0.3). - ⚠ 측구 벽이 토사 절토비(1.0)와 같은 경사라, 이 모양이 곧 「측구를 사면으로 세지 않는가」 - 를 재는 시험이다. - """ - return { - # 실측 표고를 그대로 세운 것 — 노면(-2.00) 870.83 을 기준으로 바깥으로 쌓았다. - "design_line": line( - [ - (-5.00, 873.73), # 원지반 (n=1.63) - (-4.50, 873.32), # 절토 토사 n=1.0 - (-4.00, 872.82), # 절토 토사 n≈1.03 - (-3.50, 872.33), # 절토 암 n=0.4 - (-3.00, 871.08), # 절토 암 n=0.4 - (-2.90, 870.83), - (-2.60, 870.53), # 측구 바깥 벽 n=1.0 — 사면이 아니다 - (-2.30, 870.53), # 측구 바닥 (노면보다 0.3 낮다) - (-2.00, 870.83), # 노체 끝 - (0.00, 870.89), # 노면 - ] - ), - "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, - "ditch_enabled": True, - "ditch_side": "right", - "ditch": {"type": "standard", "top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, - "cut_slope_ratio": 0.4, - "soil_cut_slope_ratio": 1.0, - "fill_slope_ratio": 1.2, - "two_stage_slope": True, - } - - -def 성토_설계선() -> dict: - """성토 사면 n=1.2 가 이어지다 원지반(n=2.5)에서 끊긴다.""" - return { - "design_line": line( - [ - (2.0, 870.00), # 노체 끝 - (4.4, 868.00), # 성토 사면 n=1.2 - (6.8, 866.00), - (9.8, 864.80), # 원지반 n=2.5 - ] - ), - "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, - "cut_slope_ratio": 0.4, - "soil_cut_slope_ratio": 1.0, - "fill_slope_ratio": 1.2, - } - - -# ── 사면길이 유도 ──────────────────────────────────────────────────── - - -def test_절토_사면만_잡고_원지반은_끊음() -> None: - slope = station_slope(20.0, 절토_설계선()) - # 암 두 조각(0.10/0.25 · 0.50/1.25) + 토사 두 조각(0.50/0.49 · 0.50/0.50). - # 원지반(-4.50~-5.00, n=1.22)은 경사비가 안 맞아 안 든다. - expected = ( - math.hypot(0.10, 0.25) - + math.hypot(0.50, 1.25) - + math.hypot(0.50, 0.49) - + math.hypot(0.50, 0.50) - ) - assert slope.cut_length_m == pytest.approx(expected, rel=0.02) - - -def test_절토_2단이_암토사로_갈림() -> None: - """8-11 의 암 5분류는 설계자 % 입력 몫 — 여기서는 2단 경계까지만 본다.""" - slope = station_slope(20.0, 절토_설계선()) - materials = {segment.material for segment in slope.segments if segment.role == "cut"} - assert materials == {"rock", "soil"} - - -def test_측구는_사면이_아님() -> None: - """측구 벽도 n=1.0 이라 토사 절토비와 같다 — 노체 안쪽이므로 세면 안 된다.""" - slope = station_slope(20.0, 절토_설계선()) - for segment in slope.segments: - assert segment.from_offset_m <= -2.9 + 1e-6, f"측구 구간이 섞였다: {segment}" - - -def test_성토_사면도_유도됨() -> None: - """원지반선 없이도 경사비 불일치로 끊긴다 — 이 길이 3번의 갈림길이었다.""" - slope = station_slope(40.0, 성토_설계선()) - assert slope.fill_length_m == pytest.approx(math.hypot(4.8, 4.0), rel=0.02) - assert slope.cut_length_m == 0.0 - - -def 성토_실측_설계선() -> dict: - """route 150 측점 20.0 **좌측 실측 좌표** — 성토 사면이 원지반을 만나 끊기는 자리. - - 2.00~6.95 가 n=1.2 로 일정하고 6.95 에서 n=2.0 으로 꺾인다. 그 꺾임이 곧 원지반이다. - ⚠ 이 시험이 있는 까닭 — 「성토 사면적이 실무의 2.8배」라는 의심이 들어 손으로 따라간 - 자리다. 결과는 **엔진이 맞았고** 삼각형 근사(평지 가정)로 견준 쪽이 틀렸다. - 지반이 기울어 있으면 같은 면적이라도 사면이 훨씬 길어진다. - """ - return { - "design_line": line( - [ - (2.00, 870.323), - (2.50, 869.906), - (3.00, 869.490), - (3.50, 869.073), - (4.00, 868.656), - (4.50, 868.240), - (5.00, 867.823), - (5.50, 867.406), - (6.00, 866.990), - (6.50, 866.573), - (6.95, 866.202), # 여기까지가 사면 — 낙차 4.121m - (7.00, 866.177), # 원지반 n=2.0 - (7.50, 865.921), - (8.00, 865.716), - ] - ), - "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, - "ditch_enabled": True, - "ditch_side": "right", # 좌측에는 측구가 없다 - "ditch": {"top_width_m": 0.9}, - "fill_slope_ratio": 1.2, - "cut_slope_ratio": 0.4, - "soil_cut_slope_ratio": 1.0, - } - - -def test_성토_사면이_원지반에서_정확히_끊김() -> None: - """실측 좌표로 잰다 — 사면길이 6.44m(수평 4.95 · 낙차 4.121).""" - slope = station_slope(20.0, 성토_실측_설계선()) - assert slope.fill_length_m == pytest.approx(math.hypot(4.95, 4.121), rel=0.005) - assert slope.fill_length_m == pytest.approx(6.44, abs=0.01) - # 원지반(7.00 바깥)은 한 조각도 안 든다. - assert max(s.to_offset_m for s in slope.segments) == pytest.approx(6.95) - - -def test_잘린_측점은_표시가_따라옴() -> None: - design = dict(성토_설계선(), slope_unclosed=True) - assert station_slope(40.0, design).unclosed is True - - -def test_설계선이_없으면_0() -> None: - assert station_slope(0.0, {}).cut_length_m == 0.0 - - -# ── 사면 4계열 면적 ────────────────────────────────────────────────── - - -def 사면들() -> list[StationSlope]: - return [ - StationSlope(chainage_m=0.0, cut_length_m=0.0, fill_length_m=0.0), - StationSlope(chainage_m=20.0, cut_length_m=4.0, fill_length_m=6.0), - StationSlope(chainage_m=40.0, cut_length_m=6.0, fill_length_m=2.0), - ] - - -def test_면적은_평균단면적법() -> None: - """토적표와 같은 식이다 — 체적 대신 면적이 나올 뿐 계산을 두 벌로 짜지 않는다.""" - rows = build_rows(사면들()) - assert rows[1].areas["face_dressing_cut"] == pytest.approx((0.0 + 4.0) / 2 * 20) - assert rows[2].areas["face_dressing_cut"] == pytest.approx((4.0 + 6.0) / 2 * 20) - assert rows[2].areas["face_dressing_fill"] == pytest.approx((6.0 + 2.0) / 2 * 20) - - -def test_첫_측점은_면적이_없음() -> None: - assert all(value == 0.0 for value in build_rows(사면들())[0].areas.values()) - - -def test_층따기는_성토면만() -> None: - rows = build_rows(사면들()) - assert "bench_cut_fill" in rows[1].areas - assert "bench_cut_cut" not in rows[1].areas - - -def test_법면보호공은_면고르기와_같은_값() -> None: - """기본은 참조다 — 실무 시트가 그러했고 오솔길 산출에는 보호공이 비어 있었다.""" - row = build_rows(사면들())[2] - assert row.areas["slope_protection_cut"] == pytest.approx(row.areas["face_dressing_cut"]) - assert row.areas["slope_protection_fill"] == pytest.approx(row.areas["face_dressing_fill"]) - - -def test_반영률_기본은_100퍼센트() -> None: - """실무 관측 80/50/80 은 참고일 뿐 기본값이 아니다 — 법대로 방침(8-10 ★).""" - ratios = SlopeRatios() - assert (ratios.bench_cut, ratios.face_dressing, ratios.slope_protection, ratios.tree_removal) == ( - 1.0, - 1.0, - 1.0, - 1.0, - ) - - -def test_반영률을_주면_곱해짐() -> None: - plain = build_rows(사면들())[2].areas["face_dressing_cut"] - scaled = build_rows(사면들(), SlopeRatios(face_dressing=0.8))[2].areas["face_dressing_cut"] - assert scaled == pytest.approx(plain * 0.8) - - -def test_잘린_측점은_목록으로_드러남() -> None: - """조용히 적게 내면 안 된다 — 화면이 이 목록을 그대로 보인다.""" - slopes = 사면들() - slopes[1].unclosed = True - rows = build_rows(slopes) - assert unclosed_stations(rows) == [20.0] - - -def test_합계() -> None: - rows = build_rows(사면들()) - total = totals(rows) - assert total["face_dressing_cut"] == pytest.approx( - sum(row.areas["face_dressing_cut"] for row in rows) - ) - - -def test_표_모양() -> None: - table = build_table(사면들()) - assert table["method"] == "average_end_area" - assert table["protection_source"] == "face_dressing" - assert table["station_count"] == 3 - assert set(table["ratios"]) == { - "bench_cut", - "face_dressing", - "slope_protection", - "tree_removal", - } - assert len(table["rows"]) == 3 diff --git a/resources/tester/test_b08_slope_length_berm.py b/resources/tester/test_b08_slope_length_berm.py deleted file mode 100644 index 4ae1cd090..000000000 --- a/resources/tester/test_b08_slope_length_berm.py +++ /dev/null @@ -1,51 +0,0 @@ -"""소단이 선 사면의 사면길이 — 첫 소단에서 끊기지 않을 것 (2026-09-15 소단 착수 전 조사). - -잰 것(936be972 읽기만) — 전 측점에 소단(0.5m · 3m · 0°)을 놓자 성토 사면길이가 측점 20 에서 -8.91 → 3.0m 로 끊겨 면고르기·파종 성토면 15,919.95 → 3,136.23㎡(−80%)로 줄었음. -까닭 — 설계선이 0.5m 격자로 찍혀 소단 평탄부 0.5m 가 0.195 + 0.305 두 조각으로 쪼개짐 → -「평탄부 한 조각 = 소단 폭」 판정이 안 맞아 거기서 사면이 끝난 것으로 봄. 안쪽 기울기(°)를 주면 -평탄부가 아예 평탄으로 안 읽혀 같은 자리에서 끊김. -""" - -from __future__ import annotations - -import pytest - -from B06_Section.B06_Section_Engine_Design import compute_cross_design -from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slope -from common_util.common_util_cross_berm import BermSpec - -# 노면보다 8m 낮은 평지 — 양쪽 성토 사면이 길게 섬. -SAMPLES = [{"offset_m": x / 2, "elevation_m": -8.0} for x in range(-160, 161)] - - -def _design(berm: BermSpec | None) -> dict: - return compute_cross_design( - SAMPLES, 0.0, ground_type="soil", section_mode="both_fill", berm=berm - ) - - -@pytest.mark.parametrize("slope_deg", [0.0, 2.0]) -def test_소단이_있어도_사면길이가_끝까지_이어진다(slope_deg: float) -> None: - plain = station_slope(0.0, _design(None)) - stepped_design = _design(BermSpec(0.5, 3.0, slope_deg)) - stepped = station_slope(0.0, stepped_design) - assert plain.fill_length_m > 15 # 양쪽 합 — 시험이 뜻을 가지는 크기 - # 소단 평탄부는 사면길이에 안 들고, 비탈 조각은 끝까지 셈 — 같은 낙차라 거의 같음. - assert stepped.fill_length_m == pytest.approx(plain.fill_length_m, rel=0.05) - assert stepped.fill_height_m == pytest.approx(plain.fill_height_m, abs=0.2) - - -def test_소단_구조물은_원단위_줄을_안_세우고_까닭을_남긴다(monkeypatch, tmp_path) -> None: - """소단 계단은 횡단 설계가 절·성토 면적·사면길이에 이미 넣음 — 따로 줄을 세우면 「산출식 없음」 - 거짓 막힘(formula_missing)이 내역에 섬(2026-09-15 조사).""" - from B05_Profile.B05_Profile_Structures_Schema import StructureInstance - from B08_Quantity import B08_Quantity_Router_Material as material - - berm = StructureInstance( - structure_id="b", type_id="berm", placement="interval", start_m=100.0, end_m=120.0 - ) - monkeypatch.setattr(material, "load_structures", lambda root: (1, [berm])) - targets, _names, skipped = material._collect_structures(str(tmp_path)) - assert targets == [] - assert any("소단" in note and "토공" in note and "사면" in note for note in skipped) diff --git a/resources/tester/test_b08_spoil_fill_not_in_earthwork.py b/resources/tester/test_b08_spoil_fill_not_in_earthwork.py deleted file mode 100644 index 7771f458c..000000000 --- a/resources/tester/test_b08_spoil_fill_not_in_earthwork.py +++ /dev/null @@ -1,48 +0,0 @@ -"""사토장 성토가 노체 성토로 새지 않는가 (2026-09-09). - -⚠⚠ **둘은 다른 이야기다.** 사토장은 **없애는 곳이 아니라 목적지**다 — - 그 흙은 여전히 실어 내야 하고 여전히 사토다. 바뀌는 것은 **양이 아니라 처리처와 거리**. - 그리고 그 자리에 쌓인 흙은 **도로를 떠받치는 성토가 아니다** — 노체 성토로 세면 - 「흙이 모자라 사 와야 하는 양」이 실제보다 적어 보이는 착시가 생긴다. - -⇒ B06 이 `spoil_fill_area_m2` 로 **갈라서** 내고 본 면적(`fill_area_m2`)에서 뺐다. - 이 시험은 **우리 토적표가 그 갈래를 지키는지**를 본다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402 - StationArea, - build_table, -) - - -def 측점(chainage: float, fill: float, spoil: float) -> StationArea: - return StationArea.from_design( - chainage, - { - "fill_area_m2": fill, - "spoil_fill_area_m2": spoil, - "cut_soil_area_m2": 0.0, - "cut_rock_area_m2": 0.0, - }, - ) - - -def test_사토장_성토가_노체_성토에_안_섞인다() -> None: - """⚠ 섞이면 성토 물량이 갑자기 뛴다 — 사토장을 놓은 것만으로 흙 수요가 늘 리 없다.""" - 없을때 = build_table([측점(0.0, 10.0, 0.0), 측점(20.0, 10.0, 0.0)]) - 있을때 = build_table([측점(0.0, 10.0, 5.0), 측점(20.0, 10.0, 5.0)]) - assert 있을때["totals"]["fill_volume_m3"] == 없을때["totals"]["fill_volume_m3"] - - -def test_본_면적만_본다() -> None: - """토적표가 읽는 칸은 `fill_area_m2` 하나다 — 사토장 칸은 B06·인계가 따로 쓴다.""" - table = build_table([측점(0.0, 10.0, 5.0), 측점(20.0, 12.0, 5.0)]) - assert table["totals"]["fill_volume_m3"] == (10.0 + 12.0) / 2 * 20.0 diff --git a/resources/tester/test_b08_spoil_haul.py b/resources/tester/test_b08_spoil_haul.py deleted file mode 100644 index 50c83b921..000000000 --- a/resources/tester/test_b08_spoil_haul.py +++ /dev/null @@ -1,204 +0,0 @@ -"""사토를 실어 내는 줄 (2026-09-08). - -⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 띠(`bands`)와 - 이동(`transfers`)에서만 만들어지고 사토는 잔량(`residuals`)으로 남아 어느 쪽에도 없었다 — - 그래서 구조물 잔토 126.63㎥ 를 사토에 얹어도 **덤프가 하나도 안 늘었고**, 채집석 공제도 - 사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**. - -⚠ 겨누는 것 다섯 - ① 사토가 있으면 줄이 선다 - ② 거리를 안 정했으면 **막고 사유** — 임의 거리는 그대로 금액이 된다 - ③ 거리를 정하면 덤프 코드가 붙고 금액이 선다 - ④ ⚠ 자연방토는 실어 내지 않으므로 **뺀다** - ⑤ 사토가 없으면 줄도 없다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 -from B08_Quantity.B08_Quantity_Router_Earthwork import _spoil_of # noqa: E402 - - -def 줄(**spoil: object) -> dict | None: - table = {"rows": [], "spoil": spoil} - rows = build_handoff(haul_table=table)["work_items"] - found = [row for row in rows if row["name"] == "사토 운반"] - return found[0] if found else None - - -def test_사토가_있으면_줄이_선다() -> None: - row = 줄(volume_m3=126.63, distance_m=None) - assert row is not None and row["quantity"] == 126.63 - - -def test_거리를_안_정했으면_막고_사유() -> None: - """⚠ 품셈이 정하는 값이 아니다 — 임의 거리를 넣으면 그대로 금액이 된다.""" - row = 줄(volume_m3=126.63, distance_m=None) - assert row["blocked_kind"] == "input_missing" - assert row["in_bill"] is False - # ⚠ 문구가 **바뀐 자리** — 2026-09-09 사용자 확정으로 사토장이 **이미 있는 측점 위에만** - # 놓이게 되어 거리가 저절로 나온다. 그래서 「거리가 없다」가 아니라 「사토장을 안 놓았다」다. - assert "사토장을 아직 안 놓았고" in row["blocked_reason"] - - -def test_거리를_정하면_금액이_선다() -> None: - row = 줄(volume_m3=126.63, distance_m=1200.0) - assert row["work_item_code"] == load_mapping().for_haul("dump_truck")["work_item_code"] - assert row["haul_distance_m"] == 1200.0 - assert row["in_bill"] is True - assert row["blocked_kind"] is None - - -def test_자연방토는_뺀다() -> None: - """⚠ 자연방토는 실어 내지 않는 몫이다 — 함께 세면 운반이 부푼다.""" - got = _spoil_of({"haul_plan": {"spoil_m3": 200.0, "natural_spoil_m3": 50.0}}, {}) - assert got["volume_m3"] == 150.0 - assert got["distance_m"] is None - assert "자연방토" in got["note"] - - -def test_공제와_가산이_끝난_값을_그대로_쓴다() -> None: - """⚠ 여기서 또 빼거나 더하면 두 번 셈이다 — 근거 문구에만 적는다.""" - got = _spoil_of( - { - "haul_plan": { - "spoil_m3": 126.63, - "natural_spoil_m3": 0.0, - "structure_spoil_added_m3": 126.63, - "collected_stone_deducted_m3": 0.0, - } - }, - {"spoil_site_distance_m": 800.0}, - ) - assert got["volume_m3"] == 126.63 - assert got["distance_m"] == 800.0 - assert "구조물 잔토" in got["note"] - - -def test_사토가_없으면_줄도_없다() -> None: - assert 줄(volume_m3=0.0, distance_m=500.0) is None - - -def 줄들(**spoil: object) -> list[dict]: - rows = build_handoff(haul_table={"rows": [], "spoil": spoil})["work_items"] - return [row for row in rows if row["name"] == "사토 운반"] - - -def test_갈래마다_한_줄로_선다() -> None: - """⚠ 덤프 단가가 토사·암으로 갈린다 — 합쳐 세우면 한쪽 단가로 다 물린다.""" - rows = 줄들( - volume_m3=100.0, - distance_m=800.0, - by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0}, - ground_unknown_m3=0.0, - ) - assert {(row["ground_class"], row["quantity"]) for row in rows} == { - ("리핑암", 60.0), - ("토사", 40.0), - } - assert all(row["in_bill"] for row in rows) - - -def test_갈래를_못_붙인_몫은_따로_서고_막힌다() -> None: - """⚠ 토사로 눅이면 임의 단가가 된다 — 거리가 있어도 이 줄은 막는다.""" - rows = 줄들( - volume_m3=100.0, - distance_m=800.0, - by_ground_m3={"ea_m3": 70.0}, - ground_unknown_m3=30.0, - ) - unknown = next(row for row in rows if row["ground_class"] is None) - assert unknown["quantity"] == 30.0 - assert unknown["in_bill"] is False - assert "지반 갈래를 못 붙인" in unknown["blocked_reason"] - - -def test_갈래가_안_오면_종전처럼_한_줄() -> None: - """옛 저장분(갈래 없는 유토곡선 결과)에서도 줄이 사라지지 않는다.""" - rows = 줄들(volume_m3=61.88, distance_m=None) - assert len(rows) == 1 and rows[0]["quantity"] == 61.88 - - -def test_사토장_거리가_오면_그것이_이긴다() -> None: - """⚠ 정확한 값이 이긴다 — 설정의 대체 거리보다 **사토장 측점까지 누가거리**가 앞선다.""" - rows = 줄들( - volume_m3=100.0, - distance_m=800.0, - by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0}, - distance_by_ground_m={"ea_m3": 250.0, "rr_m3": 410.0}, - ground_unknown_m3=0.0, - ) - got = {row["ground_class"]: row for row in rows} - assert got["토사"]["haul_distance_m"] == 250.0 - assert got["리핑암"]["haul_distance_m"] == 410.0 - assert all(row["in_bill"] for row in rows) - assert "사토장 측점까지" in got["토사"]["spec_detail"] - - -def test_사토장_거리가_없는_갈래는_대체값으로_서고_그_사실이_적힌다() -> None: - rows = 줄들( - volume_m3=100.0, - distance_m=800.0, - by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0}, - distance_by_ground_m={"ea_m3": 250.0}, - ground_unknown_m3=0.0, - ) - got = {row["ground_class"]: row for row in rows} - assert got["리핑암"]["haul_distance_m"] == 800.0 - assert "대체 거리" in got["리핑암"]["spec_detail"] - - -def test_사토도_자연상태로_선다() -> None: - """⚠ 내역 수량은 자연상태 — 되돌린 값이 오면 그 값으로 선다(config 5-4-3).""" - rows = 줄들( - volume_m3=61.88, - distance_m=800.0, - by_ground_m3={"rr_m3": 61.88}, - natural_m3_by_ground={"rr_m3": 53.809}, - ground_unknown_m3=0.0, - ) - assert rows[0]["quantity"] == 53.809 - assert "자연상태" in rows[0]["spec_detail"] - - -def test_사토에도_적재_짝_줄이_서고_단가가_선다() -> None: - """사토도 싣는 일이 따로(10-12 「1. 적재」) — 내역에 서는 운반 줄에만 붙고, 검산엔 안 셈.""" - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - from B09_Estimation.B09_Estimation_MachineProductivity_Dump import dump_haul_distances - from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - payload = build_handoff( - haul_table={ - "rows": [], - "spoil": { - "volume_m3": 100.0, - "distance_m": 800.0, - "by_ground_m3": {"ea_m3": 70.0}, - "ground_unknown_m3": 30.0, - }, - } - ) - rows = payload["work_items"] - loads = [row for row in rows if row["name"] == "덤프 적재"] - assert [(row["variant_value"], row["quantity"]) for row in loads] == [("토사", 70.0)] - assert loads[0]["haul_distance_m"] is None and loads[0]["haul_equipment"] == "dump_loading" - # 갈래 못 붙인 몫은 막힌 줄이라 짝 줄이 없음 - assert len([row for row in rows if row["name"] == "사토 운반"]) == 2 - - bill = build_bill(payload, build=cached_build(dump_haul_m=dump_haul_distances(payload))) - got = {row.name: row for row in bill.rows if row.name in ("사토 운반", "덤프 적재")} - assert got["사토 운반"].price_code.startswith("B-FP-10-12-01#L800") - assert got["덤프 적재"].price_code == "B-FP-10-12-01#적재" - assert got["사토 운반"].amount_krw > 0 and got["덤프 적재"].amount_krw > 0 - - -def test_되돌린_값이_없으면_다짐_그대로_두고_사유() -> None: - rows = 줄들(volume_m3=61.88, distance_m=800.0, by_ground_m3={"rr_m3": 61.88}) - assert rows[0]["quantity"] == 61.88 - assert "다짐상태 그대로" in rows[0]["spec_detail"] diff --git a/resources/tester/test_b08_spoil_site_distance.py b/resources/tester/test_b08_spoil_site_distance.py deleted file mode 100644 index bca4239da..000000000 --- a/resources/tester/test_b08_spoil_site_distance.py +++ /dev/null @@ -1,88 +0,0 @@ -"""사토장이 서면 사토 운반거리가 **측점에서 나온다** (2026-09-09 사용자 확정 ③). - -「기존 구조물 측점이나 규칙 측점에만 들어가는 게 맞아」 ⇒ 사토장 자리가 측점이라 -거리는 **발생점 → 사토장 측점** 누가거리로 그냥 나온다. 가정할 것이 없다. - -⚠ 사토장이 없으면 설계 입력값으로 되돌아간다 — **임의 거리를 넣지 않는다**. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from B08_Quantity.B08_Quantity_Router_Earthwork import ( # noqa: E402 - _spoil_of, - _spoil_sites, -) - -_PLAN = { - "spoil_m3": 300.0, - "natural_spoil_m3": 0.0, - "residuals": [ - {"kind": "spoil", "from_m": 0.0, "to_m": 40.0, "volume_m3": 100.0, "natural_m3": 0.0}, - {"kind": "spoil", "from_m": 200.0, "to_m": 240.0, "volume_m3": 200.0, "natural_m3": 0.0}, - ], -} - - -def _designs(*, extra: float | None = None) -> list[dict]: - rows = [] - for chainage in (100.0, 120.0, 140.0): - rows.append( - { - "chainage_m": chainage, - "design": { - "spoil_fill_area_m2": 12.0, - "spoil_fill_structure_id": "sp-1", - "spoil_fill_capacity_m3": 500.0, - "spoil_fill_placed_m3": 480.0, - "spoil_fill_unplaced_m3": 20.0, - "spoil_fill_extra_distance_m": extra, - }, - } - ) - return rows - - -def test_사토장_구간을_한_덩어리로_접는다() -> None: - sites = _spoil_sites(_designs()) - assert len(sites) == 1 - assert sites[0]["from_m"] == 100.0 - assert sites[0]["to_m"] == 140.0 - assert sites[0]["center_m"] == 120.0 - assert sites[0]["placed_m3"] == 480.0 - - -def test_거리는_발생점에서_사토장_측점까지다() -> None: - spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs())) - # 발생점 20m(100㎥) → 100m · 발생점 220m(200㎥) → 100m ⇒ 가중평균 100m - assert spoil["distance_m"] == pytest.approx(100.0) - assert spoil["distance_basis"] == "사토장(측점) 기준" - assert "사토장 측점" in spoil["note"] - - -def test_추가_운반거리는_항상_더한다() -> None: - spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs(extra=50.0))) - assert spoil["distance_m"] == pytest.approx(150.0) - - -def test_못_담는_몫을_사유로_드러낸다() -> None: - spoil = _spoil_of(_PLAN, {}, _spoil_sites(_designs())) - assert "못 담는" in spoil["note"] - assert spoil["sites"][0]["unplaced_m3"] == 20.0 - - -def test_사토장이_없으면_설계_입력값으로_돌아간다() -> None: - """임의 거리를 넣지 않는다 — 값이 없으면 막히는 것이 맞다.""" - spoil = _spoil_of(_PLAN, {"spoil_site_distance_m": 900.0}, []) - assert spoil["distance_m"] == 900.0 - assert spoil["distance_basis"] == "설계 입력값" - blocked = _spoil_of(_PLAN, {}, []) - assert blocked["distance_m"] is None diff --git a/resources/tester/test_b08_standard_slope.py b/resources/tester/test_b08_standard_slope.py deleted file mode 100644 index 5cf872cf9..000000000 --- a/resources/tester/test_b08_standard_slope.py +++ /dev/null @@ -1,136 +0,0 @@ -"""돌쌓기 표준경사 — 직고·메찰·성절토로 갈린다 (2026-09-09 사용자 확정 ⑨). - -⚠ 겨누는 것 여섯 - ① 표대로 갈린다 — 우리 종전 0.3 은 「메쌓기·성토·1.5m 이하」 한 칸이었다 - ② ⚠ 경계값은 **앞 칸** — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다 - ③ 7m 초과라야 마지막 칸이다 - ④ 사용자가 정했으면 **그 값이 이긴다** - ⑤ 근거 문구에 **어느 칸에서 왔는지**와 **성토 열로 잠정임**이 적힌다 - ⑥ ⚠ 큰돌쌓기는 이 표의 대상이 아니다(교본 「1:0.3 이상」) -""" - -from __future__ import annotations - -import math -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - boulder_masonry, - face_slope_ratio, - stone_masonry, -) - - -def 기울기(wet: bool, height_m: float, face: str | None = "성토", **options: object) -> float: - """⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다 — - 시험에서는 그 자리를 명시해 표의 어느 열을 보는지 분명히 한다.""" - return face_slope_ratio(dict(options), wet=wet, height_m=height_m, face=face)[0] - - -def test_표대로_갈린다() -> None: - # 메쌓기 성토 - assert 기울기(False, 1.2) == 0.30 - assert 기울기(False, 2.0) == 0.35 - assert 기울기(False, 4.0) == 0.40 - assert 기울기(False, 6.0) == 0.45 - # 찰쌓기 성토 - assert 기울기(True, 1.2) == 0.25 - assert 기울기(True, 2.5) == 0.30 - assert 기울기(True, 4.0) == 0.35 - - -def test_경계값은_앞_칸이다() -> None: - """⚠ 원문 표기가 `∼1.5 · ∼3` 이라 **이하**로 읽는다 — 한 칸 밀리면 값이 갈린다.""" - assert 기울기(True, 1.5) == 0.25 # `~1.5` 칸 - assert 기울기(True, 1.51) == 0.30 # 다음 칸 - assert 기울기(False, 3.0) == 0.35 # `~3` 칸 - assert 기울기(False, 3.01) == 0.40 - - -def test_7m_초과라야_마지막_칸() -> None: - assert 기울기(False, 7.0) == 0.45 - assert 기울기(False, 7.01) == 0.50 - - -def test_사용자가_정하면_그_값이_이긴다() -> None: - ratio, basis = face_slope_ratio({"face_slope_ratio": 0.5}, wet=True, height_m=2.5, face="성토") - assert ratio == 0.5 - assert "사용자 지정" in basis - - -def test_근거에_칸과_잠정임이_적힌다() -> None: - _, basis = face_slope_ratio( - {}, wet=False, height_m=2.0, face="성토", face_reason="right_cut · 좌측 → 성토면" - ) - assert "13-4-4" in basis and "메쌓기" in basis - # ⚠ 어느 단면유형에서 왔는지가 근거에 실린다 — 값만 있고 까닭이 없으면 못 되짚는다. - assert "right_cut" in basis - - -def test_면적이_그_기울기로_선다() -> None: - """메쌓기 H=2.0 은 1:0.35 — 종전 0.3 보다 **면적이 커진다**.""" - components, _ = stone_masonry( - 2.0, 10.0, {"height_m": 2.0, "length_m": 10.0, "back_len_cm": 35}, False, "성토" - ) - area = next(c for c in components if c.name == "돌쌓기") - assert area.amount == 20.0 * math.hypot(1.0, 0.35) - assert "표준경사" in area.basis - - -def test_큰돌쌓기는_이_표의_대상이_아니다() -> None: - """교본이 「1:0.3 **이상**」으로만 두었다 — 직고로 갈리지 않는다.""" - components, _ = boulder_masonry( - 5.0, 10.0, {"height_m": 5.0, "length_m": 10.0, "stone_cm": "60~80"} - ) - area = next(c for c in components if c.name == "큰돌쌓기") - assert area.amount == 50.0 * math.hypot(1.0, 0.3) - - -def test_표_밖_값도_그대로_쓴다() -> None: - """⚠ 실무 도면에 **S0.7·0.8** 이 실재한다(품셈 표는 0.20~0.50). - - 표 밖이라고 막거나 가까운 칸으로 **접지 않는다** — 접으면 사용자가 넣은 값이 조용히 - 다른 값으로 돌아간다(뒷길이에서 이미 겪은 자리). 「사용자 지정」으로 그대로 쓴다. - """ - for value in (0.7, 0.8, 1.2): - ratio, basis = face_slope_ratio( - {"face_slope_ratio": value}, wet=True, height_m=2.5, face="성토" - ) - assert ratio == value - assert "사용자 지정" in basis - - components, _ = stone_masonry( - 2.5, - 10.0, - {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_slope_ratio": 0.7}, - wet=True, - ) - area = next(c for c in components if c.name == "돌쌓기") - assert area.amount == 25.0 * math.hypot(1.0, 0.7) - - -def test_성절토를_못_가르면_눅이지_않는다() -> None: - """⚠ `both_cut` + 「자동」처럼 **가를 근거가 없는 자리**가 있다(판정 한 벌이 `None` 을 줌). - - 성토로 눅여 표를 고르면 **임의값이 금액으로 굳는다.** 종전값으로 서되 **왜 못 갈랐는지**를 - 근거에 적어 사용자가 무엇이 없는지 보게 한다. - """ - ratio, basis = face_slope_ratio( - {}, wet=True, height_m=2.5, face=None, face_reason="both_cut · 자동 — 세울 성토면이 없음" - ) - assert ratio == 0.3 - assert "both_cut" in basis and "종전값" in basis - - -def test_판정_한_벌을_그대로_쓴다() -> None: - """⚠ 판정을 두 벌로 짜면 예외에서 갈린다 — 공용 함수 결과가 그대로 흘러야 한다.""" - from common_util.common_util_structure_face_role import structure_face_role - - role, reason = structure_face_role("right_cut", "좌") - assert role == "성토" - _, basis = face_slope_ratio({}, wet=True, height_m=2.5, face=role, face_reason=reason) - assert reason in basis diff --git a/resources/tester/test_b08_station_label.py b/resources/tester/test_b08_station_label.py deleted file mode 100644 index efbc1348f..000000000 --- a/resources/tester/test_b08_station_label.py +++ /dev/null @@ -1,30 +0,0 @@ -"""측점 표기는 **한 벌**이다 — 토적표와 구조물 원단위가 같은 함수를 쓴다 (2026-09-09). - -구조물 줄에 구간 표기(`NO.4 ~ NO.4+10`)를 붙였다(계획서 V-8). 표기를 각자 만들면 같은 -측점이 화면 두 곳에서 다르게 적히므로 `stationLabel` 하나를 내보내 쓴다. - -⚠ 길이와 갈리지 않는 것이 요점이다 — 구간은 `start_m`·`end_m` 에서 나오고 `length_m` 은 - 그 구간(겹침을 지운 목록)의 합이라 두 값이 어긋날 자리가 없다. -""" - -from __future__ import annotations - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -EARTHWORK = ROOT / "B08_Quantity" / "B08_Quantity_UI_EarthworkGrid.ts" -MATERIAL = ROOT / "B08_Quantity" / "B08_Quantity_UI_MaterialGrid.ts" - - -def test_표기_함수가_한_벌이다() -> None: - assert "export function stationLabel" in EARTHWORK.read_text(encoding="utf-8") - material = MATERIAL.read_text(encoding="utf-8") - assert 'import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"' in material - assert "function stationLabel" not in material, "표기 함수를 두 벌로 만들었다" - - -def test_구간은_구조물_값에서_나온다() -> None: - """지어낸 측점이 아니라 `start_m`·`end_m` 을 그대로 쓴다.""" - material = MATERIAL.read_text(encoding="utf-8") - assert "stationRange(structure.start_m, structure.end_m)" in material - assert "start_m?: number | null" in material diff --git a/resources/tester/test_b08_stmate_fixed_chain.py b/resources/tester/test_b08_stmate_fixed_chain.py deleted file mode 100644 index 6bce200f6..000000000 --- a/resources/tester/test_b08_stmate_fixed_chain.py +++ /dev/null @@ -1,123 +0,0 @@ -"""고정형 항목 사슬 ㉮ — 인계·내역으로 가는 문(2026-09-14 브레인 판정 「공종 코드가 없으면 어디에도 안 감」). - -실측(936be972): 뽑은 고정형엔 일위대가 구성(`unit_price.rows`)이 없어 `priced_sheets` 가 그 장을 안 셈 → -구조물 줄 수량 0 · 내역 줄 사라짐 · 본체 −4,062,480. 고친 길: 일위대가로 가는 줄마다 구성 줄을 두되 -**공종 코드는 비움** → 그 장은 호표 줄로 인계되고 금액은 「미완 — 공종 코드 미정」(조용한 0 아님) → -사용자가 고르개로 코드를 고르면 금액이 섬. 자원 축은 후보만(이름 자동 확정 금지 · 명세 2장). -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path -from types import SimpleNamespace - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import ( # noqa: E402 - price_sheets, - priced_sheets, -) -from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows # noqa: E402 -from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402 - -HOPYO = { - "no": 6, - "source_code": "B00010", - "name": "기슭막이", - "spec": "H=2.0m", - "unit": "M", - "basis": [], - "contract_rows": 0, - "rows": [ - { - "name": "깬잡석채집", - "spec": "L=45cm", - "amount": 2.09, - "unit": "M2", - "remark": "", - "source_code": "", - }, - { - "name": "고임돌채집", - "spec": "기계", - "amount": 0.31, - "unit": "M3", - "remark": "", - "source_code": "", - }, - ], -} - - -class Book: - titles = {"B-FP-X#채집": ("깬잡석 채집", "㎡", Money3(Decimal(0), Decimal(1000), Decimal(0)))} - - def title(self, code: str) -> SimpleNamespace: - if code not in self.titles: - raise PriceBookError(f"단가표에 없는 코드입니다: {code}") - name, unit, _money = self.titles[code] - return SimpleNamespace(name=name, spec="", unit=unit) - - def resolve(self, code: str) -> Money3: - return self.titles[code][2] if code in self.titles else self.title(code) - - -def _sheet(item: dict) -> dict: - """`apply_templates` 가 낸 장 모양 — 고정형 줄 = 박힌 단위량.""" - return { - "type_id": item["type_id"], - "title": "돌쌓기(찰) H=2.5", - "formula_sheet": {"vars": {}}, - "billing_unit": "m", - "members": [{"structure_id": "s1", "billing_quantity": 10}], - "rows": [ - { - "no": row["seq"], - "unit_amount": float(row["amount"]), - "unit": row["unit"], - "destination": row["destination"], - } - for row in item["rows"] - ], - } - - -def test_고정형_장이_호표_줄로_인계되고_코드_없으면_미완() -> None: - item = { - **recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"), - "code": "AX-ST-0000abcd", - } - entries = priced_sheets([_sheet(item)], {"masonry_wet": item}) - assert [e["structure_ids"] for e in entries] == [["s1"]] # 전엔 빈 목록 — 인계에서 끊겼음 - prices = price_sheets(entries, Book(), lambda code, value: None, [item]) - (entry,) = prices.values() - assert entry["blocked"] == 2 and entry["total"] == 0 - assert all("공종 코드 미정" in reason for reason in entry["reasons"]) - - -def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None: - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts").read_text( - encoding="utf-8" - ) - assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui - # 후보 0 이면 다음 길(갈래 바꾸기 · 수동 단가)을 적음 — 조용히 「없음」으로 막히지 않게(2026-09-14 브레인) - assert "수동 단가를 넣을 것" in ui - - -def test_사용자가_코드를_고르면_금액이_선다() -> None: - item = { - **recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"), - "code": "AX-ST-0000abcd", - } - picked = [ - dict(row, ref_code="B-FP-X#채집", unit="㎡") for row in item["unit_price"]["rows"][:1] - ] - template = with_rows(item, picked) - entries = priced_sheets([_sheet(item)], {"masonry_wet": template}) - (entry,) = price_sheets(entries, Book(), lambda code, value: None, [template]).values() - assert entry["blocked"] == 0 and entry["total"] == Decimal("2090") # 2.09㎡ × 1,000원 diff --git a/resources/tester/test_b08_stmate_library_router.py b/resources/tester/test_b08_stmate_library_router.py deleted file mode 100644 index 1df6376df..000000000 --- a/resources/tester/test_b08_stmate_library_router.py +++ /dev/null @@ -1,108 +0,0 @@ -"""라이브러리 — STmate 출력 엑셀에서 고정형 항목 뽑아 넣기 창구(PLAN 4장 · 2026-09-14 브레인 판정). - -두 걸음: [읽기]는 호표 수·구성 줄 수·못 읽은 사유만 돌려주고 아무것도 안 씀 → [넣기]는 같은 파일을 -서버가 **다시 읽어** 고른 호표를 개인 단에 씀(브라우저가 보낸 줄을 받아 적지 않음 · 개인 단만 · 판정 Ⓗ). -""" - -from __future__ import annotations - -import io -import json -import sys -from pathlib import Path - -import openpyxl -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402 -from common_util.common_util_auth import verify_session # noqa: E402 - -PROJECT_ID = "33333333-3333-3333-3333-333333333333" -BASE = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets/library/stmate" - - -def _xlsx() -> bytes: - book = openpyxl.Workbook() - ws = book.active - ws.title = "일위대가표" - for row in [ - ["일 위 대 가 표"], - ["공사명 : 시험 공사"], - ["명 칭", "규 격", "수 량", "단위"], - [None], - [" 제 1 호표"], - ["돌기슭막이(메쌓기)", "H=2.0", None, "m"], - ["메쌓기", "L3=55cm이하", 2.09, "m2"], - ["고임돌채집", "기계", 0.31, "m3"], - ["합 계"], - [" 제 2 호표"], - ["규준틀설치", "종단", None, "개소"], - ["각재", "외송", "약간", "M3"], - ["합 계"], - ]: - ws.append(row) - buffer = io.BytesIO() - book.save(buffer) - return buffer.getvalue() - - -@pytest.fixture() -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage")) - app = FastAPI() - app.include_router(router_module.router) - app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} - return TestClient(app) - - -def _files() -> dict: - return {"file": ("견본.xlsx", _xlsx(), "application/octet-stream")} - - -def test_읽기는_수와_사유만_주고_안_쓴다(client: TestClient, tmp_path: Path) -> None: - body = client.post(f"{BASE}/read", files=_files()).json() - assert body["counts"] == {"hopyo": 1, "rows": 2} - assert body["hopyo"][0]["name"] == "돌기슭막이(메쌓기)" and body["hopyo"][0]["rows"] == 2 - assert any("C12" in p for p in body["problems"]) # 수량이 수가 아닌 2호표는 사유로 - assert not (tmp_path / "storage").exists() - - -def test_넣기는_서버가_다시_읽어_개인_단에_고정형으로(client: TestClient, tmp_path: Path) -> None: - response = client.post( - f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"} - ) - assert response.status_code == 200, response.text - code = response.json()["code"] - saved = json.loads( - (tmp_path / "storage" / "7" / "42" / "library" / f"{code}.json").read_text(encoding="utf-8") - ) - assert saved["library_tier"] == "personal" and saved["origin"]["project"] == "시험 공사" - assert [r["amount"] for r in saved["rows"]] == ["2.09", "0.31"] - dirs = library_module.tier_dirs(7, 42) - assert [i["kind"] for i in library_module.list_items(dirs, "masonry_dry")] == ["fixed"] - - -def test_회사_없으면_403_없는_호표는_404_모르는_종류는_400(client: TestClient) -> None: - bad = client.post( - f"{BASE}/save", files=_files(), data={"hopyo_no": "2", "type_id": "masonry_dry"} - ) - assert bad.status_code == 404 # 못 읽은 호표는 넣을 수 없음 - unknown = client.post(f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "없음"}) - assert unknown.status_code == 400 - client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} - denied = client.post( - f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"} - ) - assert denied.status_code == 403 - - -def test_앱에_창구가_걸린다() -> None: - main = (ROOT / "main.py").read_text(encoding="utf-8") - assert "B08_Quantity_Router_StmateLibrary" in main and "b08_stmate_library_router" in main diff --git a/resources/tester/test_b08_stmate_library_ui.py b/resources/tester/test_b08_stmate_library_ui.py deleted file mode 100644 index 5bed025d7..000000000 --- a/resources/tester/test_b08_stmate_library_ui.py +++ /dev/null @@ -1,26 +0,0 @@ -"""라이브러리 칸의 [STmate 엑셀에서 뽑기] — 읽은 수를 보이고 묻고 넣는 두 걸음(2026-09-14 브레인 판정). - -⚠ 읽기 전에 넣기가 열리면 안 됨 · 넣기 전에 호표 수·구성 줄 수·덮어씀을 묻는 확인이 있어야 함 · - 넣기는 **파일을 다시 보냄**(서버가 다시 읽음 — 화면이 읽은 줄을 보내지 않음). -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -UI = ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Stmate.ts" -PANEL = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( - encoding="utf-8" -) - - -def test_라이브러리_칸에_뽑기_칸이_붙는다() -> None: - assert "buildStmatePanel(" in PANEL - - -def test_읽고_수를_보이고_묻고_파일을_다시_보내_넣는다() -> None: - ui = UI.read_text(encoding="utf-8") - assert "/stmate/read" in ui and "/stmate/save" in ui - assert 'form.append("file"' in ui and 'form.append("hopyo_no"' in ui - assert "window.confirm(" in ui and "counts.hopyo" in ui and "counts.rows" in ui - assert "put.hidden = true" in ui # 읽기 전에는 넣기 단추가 숨음 - assert "rows:" not in ui.split("/stmate/save")[1][:400] # 줄을 보내지 않음 diff --git a/resources/tester/test_b08_stmate_recipe_read.py b/resources/tester/test_b08_stmate_recipe_read.py deleted file mode 100644 index 75d4d4798..000000000 --- a/resources/tester/test_b08_stmate_recipe_read.py +++ /dev/null @@ -1,133 +0,0 @@ -"""STmate 출력 엑셀 「일위대가표」 읽개 — 고정형 라이브러리 항목의 둘째 길(PLAN 4장 · 2026-09-14 브레인 판정 ①). - -왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수 확인) · -경쟁사 보호 장치는 풀지 않음. 출력 엑셀은 평문이고 코덱스 `recipe_extract.py` 가 실무 6건 누락 0 으로 뽑은 모양. -⚠ **모양이 다르면 억지로 읽지 않음** — 「어느 칸이 안 맞는지」 사유로 돌려보냄(추측해서 맞추면 - 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임). -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import openpyxl -import pytest - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes # noqa: E402 - -PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" -BONGHWA = next( - (p for p in PRACTICE.rglob("*.xlsx") if "기번41" in p.name and not p.name.startswith("~$")), - None, -) - - -def _book(tmp_path: Path, rows: list[list[object]], sheet: str = "일위대가표") -> Path: - book = openpyxl.Workbook() - ws = book.active - ws.title = sheet - for row in rows: - ws.append(row) - path = tmp_path / "book.xlsx" - book.save(path) - return path - - -HEAD = [ - ["일 위 대 가 표"], - ["공사명 : 시험"], - ["명 칭", "규 격", "수 량", "단위", "합 계"], - [None], -] - - -@pytest.mark.skipif(BONGHWA is None, reason="실무 엑셀(지식DB 원문) 없음") -def test_봉화_호표_45_기슭막이_H2_구성_9줄() -> None: - read = read_recipes(BONGHWA) - assert read["problems"] == [] - assert len(read["hopyo"]) == 45 - target = next( - h - for h in read["hopyo"] - if h["name"] == "기슭막이(깬잡석,찰쌓기 L3=45m)" and h["spec"] == "H=2.0m, 채집" - ) - assert target["unit"] == "M" and target["source_code"] == "B00010" - rows = target["rows"] - assert len(rows) == 9 - assert (rows[0]["name"], rows[0]["spec"], rows[0]["amount"], rows[0]["unit"]) == ( - "깬잡석채집", - "L=45cm 내외", - 2.09, - "M2", - ) - assert rows[1]["source_code"] == "B00004" # 하위 호표 참조(한 단만 — 판정 Ⓕ) - assert rows[-1]["name"] == "콘크리트믹서사용" and rows[-1]["amount"] == 0.42 - - -def test_머리_칸이_다르면_안_읽고_칸을_짚는다(tmp_path: Path) -> None: - head = [row[:] for row in HEAD] - head[2][2] = "수량합" - read = read_recipes(_book(tmp_path, [*head, [" 제 1 호표"], ["벽", "H=1", None, "m"]])) - assert read["hopyo"] == [] - assert any("C3" in p and "수량" in p for p in read["problems"]) - - -def test_수량이_수가_아니면_그_호표를_안_읽는다(tmp_path: Path) -> None: - rows = [ - *HEAD, - [" 제 1 호표"], - ["벽", "H=1", None, "m"], - ["돌", "", "약 2", "㎡"], - ["합 계"], - [" 제 2 호표"], - ["담", "H=2", None, "m"], - ["돌", "", 3, "㎡"], - ["합 계"], - ] - read = read_recipes(_book(tmp_path, rows)) - assert [h["name"] for h in read["hopyo"]] == ["담"] # 틀린 호표는 통째로 뺌 - assert any("C7" in p for p in read["problems"]) - - -def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path: Path) -> None: - """판정 ㉮㉯㉰ — 별산은 자재총괄 · % 가산 행은 참고 + 항목 사유 · 계약단가(낙찰률)는 안 뽑음 · 근거 줄 보존.""" - rows = [ - *HEAD, - [" 제 1 호표"], - ["기슭막이", "H=2.0", None, "m"], - ["건설표준품셈", "7-1-1(메쌓기)", 0, None], - ["파쇄암", "별도계상", 2.09, "㎡", *[0] * 8, "별산자재 25 "], - ["메쌓기", "L3=55cm이하", 2.09, "m2", *[0] * 8, "대가 11호표"], - ["공구손료", "노무비의 %", 2, "%"], - ["계"], - ["계약단가", None, 88.5], - ] - from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item - - read = read_recipes(_book(tmp_path, rows)) - assert read["problems"] == [] and read["project"] == "시험" - item = recipe_item(read["hopyo"][0], type_id="masonry_dry", file_name="a.xlsx", project="시험") - assert [(r["name"], r["destination"]) for r in item["rows"]] == [ - ("파쇄암", "material"), - ("메쌓기", "unit_price"), - ("공구손료", "reference"), - ] - assert all(r["formula"] == "" and "7-1-1" in r["formula_text"] for r in item["rows"]) - assert item["rows"][1]["amount"] == "2.09" - assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"] - assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"] - assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험" - # ㉮ 일위대가 구성 — 일위대가로 가는 줄만(별산·가산 행 뺌) · 공종 코드는 비워 둠(사용자가 고름) - unit_rows = item["unit_price"]["rows"] - assert [(r["name"], r["from_row"]) for r in unit_rows] == [("메쌓기", 2)] - assert not any(r.get("work_item_code") or r.get("ref_code") for r in unit_rows) - - -def test_시트가_없으면_사유(tmp_path: Path) -> None: - read = read_recipes(_book(tmp_path, HEAD, sheet="다른표")) - assert read["hopyo"] == [] and any("일위대가표" in p for p in read["problems"]) diff --git a/resources/tester/test_b08_stone_kind.py b/resources/tester/test_b08_stone_kind.py deleted file mode 100644 index 65d29da6d..000000000 --- a/resources/tester/test_b08_stone_kind.py +++ /dev/null @@ -1,122 +0,0 @@ -"""돌 종류 축 — 계수 셋이 그 축으로 갈리는지 (2026-09-08). - -⚠ 겨누는 것 다섯 - ① 안 고르면 **종전 값 그대로**인가 (저장된 프로젝트가 안 흔들리게) - ② 고르면 산림품셈 계수로 바뀌는가 - ③ ⚠ 뒤채움 몫을 **뒤집어 쓰지 않는가** — 표는 「뒤채움」, 우리 식은 「빼는 몫」 - ④ 표에 없는 칸(「-」)을 **지어내지 않는가** - ⑤ 모르는 종류를 조용히 넘기지 않는가 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - load_stone_kind_table, - stone_masonry, -) - -#: 고치기 전 값 — 이 셋이 바뀌면 이미 저장된 프로젝트의 수량이 흔들린 것이다. -#: ⚠ 막자갈이 두 번 갈렸다 — 15.130 → 12.630(2026-09-08 확정 ② 벽 두께 실무 구조물도 식) -#: → **9.375**(2026-09-09 확정 5차 작은 것 3, 랩탑 보조). 지금은 「입적 − 몸통」이 아니라 -#: **뒷채움 사다리꼴**(정본 상 0.30 · 하 0.45)이라 **돌 종류·뒷길이를 아예 안 본다.** -#: 고임돌·채움콘크리트는 여전히 비탈면적 × 종류별 계수다. -BEFORE = {"고임돌": 3.915, "채움콘크리트": 5.220, "막자갈": 9.375} - - -def 성분(options: dict) -> dict: - components, notes = stone_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, **options}, wet=True - ) - return {c.name: round(c.amount, 3) for c in components}, notes - - -def test_안_고르면_종전_값_그대로() -> None: - """⚠ 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.""" - got, notes = 성분({"back_len_cm": 45}) - for name, before in BEFORE.items(): - assert abs(got[name] - before) < 0.002, f"{name} 이 {before} → {got[name]} 로 바뀌었다" - assert any("돌 종류를 안 정해" in n for n in notes), "기본값으로 선 사실을 안 알린다" - - -def test_야면석을_고르면_계수가_바뀐다() -> None: - """품셈 13-4-3 야면석 0.11 · 13-4-4 [주]① 야면석 0.15 (뒷길이 45).""" - got, notes = 성분({"back_len_cm": 45, "stone_kind": "야면석·호박돌"}) - assert abs(got["고임돌"] - 26.101 * 0.11) < 0.01 - assert abs(got["채움콘크리트"] - 26.101 * 0.15) < 0.01 - # ⚠ 사유는 여러 갈래가 함께 뜬다(기초 유/무 등) — **돌 종류 사유만** 본다. - assert all("돌 종류를 안 정해" not in n for n in notes) - - -def test_깬돌은_종전과_같은_계수() -> None: - """⚠ 종전 값이 「깬돌」 줄이었다 — 그것이 확인되면 지금까지 값의 성격이 밝혀진다.""" - got, _ = 성분({"back_len_cm": 45, "stone_kind": "깬돌"}) - assert abs(got["고임돌"] - BEFORE["고임돌"]) < 0.002 - assert abs(got["채움콘크리트"] - BEFORE["채움콘크리트"]) < 0.002 - - -def test_막자갈은_이제_돌_종류_축이_아니다() -> None: - """⚠ 뒤집힌 시험이다 — 종전에는 「깬돌 막자갈 > 야면석 막자갈」을 겨눴다. - - 그 시험은 막자갈이 「입적 − 몸통」이던 시절 **뒤채움 몫을 뒤집어 쓰는 것**을 막으려던 - 것이었다(표는 뒤채움 몫, 우리 식은 빼는 몫 — 반대라 19.045 가 나온 적 있다). - 2026-09-09 확정 5차로 막자갈이 **뒷채움 사다리꼴**로 갈리면서 그 축이 사라졌다 — - 이제 뒤집어 쓸 몫 자체가 없다. 대신 **종류로 안 갈린다는 것**을 못 박는다. - """ - 야면석, _ = 성분({"back_len_cm": 45, "stone_kind": "야면석·호박돌"}) - 깬돌, _ = 성분({"back_len_cm": 45, "stone_kind": "깬돌"}) - assert 깬돌["막자갈"] == 야면석["막자갈"] == BEFORE["막자갈"] - # ⚠ 갈리는 줄은 그대로 갈려야 한다 — 축이 통째로 죽은 것이 아니다. - assert 깬돌["고임돌"] > 야면석["고임돌"] - - -def test_표에_없는_칸은_지어내지_않는다() -> None: - """⚠ 견치돌은 뒷길이 25·30 이 원문에서 「-」다 — 그 규격에 그 돌을 안 쓴다는 뜻.""" - table = load_stone_kind_table() - assert table["wedge_stone_m3_per_m2"]["견치돌"]["25"] is None - assert table["wedge_stone_m3_per_m2"]["야면석·호박돌"]["75"] is None - - -def test_모르는_종류는_조용히_넘어가지_않는다() -> None: - got, notes = 성분({"back_len_cm": 45, "stone_kind": "화강석"}) - assert any("아는 돌 종류가 아니라" in n for n in notes) - # 값은 종전대로 서되 사유가 남는다 — 0 으로 때우지 않는다. - assert got["고임돌"] > 0 - - -def test_산출_근거에_돌_종류가_적힌다() -> None: - """⚠ 값만 바뀌고 근거가 그대로면 화면에서 왜 달라졌는지 못 짚는다.""" - components, _ = stone_masonry( - 2.5, - 10.0, - {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "stone_kind": "깬돌"}, - wet=True, - ) - # ⚠ 막자갈은 이제 종류를 안 본다 — 계수가 종류로 갈리는 **두 줄**을 본다. - # 채움콘크리트는 값이 갈리는데 근거에 종류가 안 적히던 자리다(2026-09-09 맞춤). - for name in ("고임돌", "채움콘크리트"): - got = next(c for c in components if c.name == name) - assert "깬돌" in got.basis, name - - -def test_고른_종류의_빈_칸은_줄을_안_만든다() -> None: - """⚠ 원문 「-」인 칸에 값이 서던 자리(만들다 잡음). - - 야면석 75㎝ 는 13-4-3 에 「-」다 — `None` 이라고 안 덮으면 종전 값(깬돌 0.25)이 남아 - **원문에 없는 값이 조용히 선다.** 0 줄로 만들지도 않는다 — 0 은 「없음」과 구별이 안 된다. - """ - got, notes = 성분({"back_len_cm": 75, "stone_kind": "야면석·호박돌"}) - assert "고임돌" not in got - assert any("「-」" in n or "칸이 비어" in n for n in notes) - - -def test_뒷길이_일곱_규격이_다_선다() -> None: - """⚠ 앞서 네 칸만 들고 25·30·75 를 접고 있었다.""" - for back in (25, 30, 35, 45, 55, 60, 75): - got, _ = 성분({"back_len_cm": back, "stone_kind": "깬잡석"}) - assert got.get("채움콘크리트", 0) > 0, back diff --git a/resources/tester/test_b08_structure_figure.py b/resources/tester/test_b08_structure_figure.py deleted file mode 100644 index ddf46501b..000000000 --- a/resources/tester/test_b08_structure_figure.py +++ /dev/null @@ -1,221 +0,0 @@ -"""구조물도 상단 그림 — 그림 치수가 **수량표와 같은 한 벌**인가 (PLAN 12장). - -⚠ 옛 B07 그림은 두께 식을 따로 적어, 상부·하부 두께를 넣어도 **그림만 옛 두께**였음. -""" - -from __future__ import annotations - -import math -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( # noqa: E402 - expand_observed, - load_observed_table, -) -from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 - attach_figures, - build_figure, - figure_reason, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - back_length_default_note, - stone_masonry, -) - - -def _sheet(**options) -> dict: - return { - "title": "돌쌓기(메)", - "type_id": "masonry_dry", - "height_m": 2.0, - "options": {"height_m": 2.0, **options}, - } - - -def _paths(shapes: list[dict]) -> list[dict]: - return [shape for shape in shapes if shape["kind"] == "path"] - - -def _area(points: list[list[float]]) -> float: - return abs(sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(points, points[1:]))) / 2 - - -def test_벽_두께는_사용자가_넣은_값을_따른다() -> None: - wall = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[0]["points"] - # 식: 상부 0.35+0.30=0.65 · 하부 0.65+0.30×(2−1)=0.95 · 기울기 0.3 → 앞면 위 끝 0.6 - assert wall[:4] == [[0.0, 0.0], [0.6, 2.0], [1.25, 2.0], [0.95, 0.0]] - given = _paths( - build_figure( - _sheet( - back_len_cm=35, face_slope_ratio=0.3, thickness_top_m=0.5, thickness_bottom_m=1.2 - ) - ) - )[0]["points"] - assert given[2] == [1.1, 2.0] and given[3] == [1.2, 0.0] - - -def test_터파기_넓이는_수량식과_같다() -> None: - dig = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[1] - assert dig["dash"] - assert abs(_area(dig["points"]) - 2.0 * ((0.65 + 0.95) / 2 + 0.2)) < 1e-9 - - -def test_못_그리는_장은_그림_대신_까닭이_실린다() -> None: - payload = { - "sheets": [ - _sheet(), - {**_sheet(stone_cm="60~80"), "type_id": "boulder_masonry"}, - _sheet(back_len_cm=45), - ] - } - attach_figures(payload) - no_back, boulder, drawn = payload["sheets"] - # 뒷길이 미정은 기본값이 있어 **그림이 섬**(2026-09-14 브레인 판정) — 까닭은 직경 축만. - assert no_back["figure_reason"] is None and no_back["figure"] - assert boulder["figure"] is None and "직경" in boulder["figure_reason"] - assert drawn["figure_reason"] is None and drawn["figure"] - - -def _texts(shapes: list[dict]) -> list[str]: - return [shape["text"] for shape in shapes if shape["kind"] == "text"] - - -def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -> None: - options = {"height_m": 2.0} - _rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False) - table_default = [note for note in table_notes if "뒷길이를 안 골라" in note] - # 2026-09-14 — 기본은 뒷길이 표준 하한(메 ≤3m 36~45㎝ → 45) · 표·그림이 같은 말. - assert table_default == [back_length_default_note(options, 45, wet=False, height_m=2.0)] - assert "하한 36㎝ 는 규격 일곱" in table_default[0] - - texts = _texts(build_figure(_sheet())) - assert table_default[0] in texts - assert "뒷길이 ℓ₃ = 45㎝ (기본)" in texts - # 두께도 기본 45 로 — 상부 0.45+0.30 - assert "상부 0.75 m" in texts - - chosen = _texts(build_figure(_sheet(back_len_cm=45))) - assert "뒷길이 ℓ₃ = 45㎝" in chosen and not any("안 골라" in text for text in chosen) - assert back_length_default_note({"back_len_cm": 45}, 45) is None - - -def _wall_sheet(**options) -> dict: - return { - "title": "옹벽 H=2", - "type_id": "retaining_wall", - "height_m": 2.0, - "options": {"height_m": 2.0, "form": "반중력식", **options}, - } - - -def test_옹벽_단면_치수가_표의_관측_수량을_그대로_낸다() -> None: - """표 = 그림 — 그림이 쓰는 단면으로 표 수량(라이브러리 반올림)이 다시 나와야 함.""" - entry = load_observed_table().find("retaining_wall", {"form": "반중력식", "height_m": 2.0}) - section = entry["section"] - amounts = {item["name"]: item["amount"] for item in entry["components"]} - footing, wall, key, blinding = (section[k] for k in ("footing", "wall", "key", "blinding")) - bottom = wall["top_m"] + wall["front_batter_m"] + wall["back_batter_m"] - assert abs(bottom - 0.45) < 1e-9 - concrete = ( - footing["width_m"] * footing["thickness_m"] - + key["width_m"] * key["depth_m"] - + (wall["top_m"] + bottom) / 2 * wall["height_m"] - ) - blind = blinding["thickness_m"] * ( - footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"] - ) - form = math.hypot(wall["height_m"], wall["back_batter_m"]) + math.hypot( - wall["height_m"], wall["front_batter_m"] - ) - pipe = wall["height_m"] / 2.0 * section["weep"]["pipe_length_m"] - # 원문 값 그대로(2026-09-14 브레인 판정 — 라이브러리 반올림 1.35·0.15·3.2 에서 바꿈) - assert abs(concrete - amounts["콘크리트"]) < 1e-9 # 1.345 - assert abs(blind - amounts["버림콘크리트"]) < 1e-9 # 0.145 - assert abs(form - amounts["유로폼"]) < 5e-4 # 3.2047 → 3.205 - assert abs(pipe - amounts["물구멍관"]) < 1e-9 # 0.32 (제원 칸 비었을 때) - assert footing["thickness_m"] + wall["height_m"] == 2.0 - - -def _wall_table(length: float = 10.0, rubble=None, **options) -> dict: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - - structure = { - "structure_id": "w1", - "type_id": "retaining_wall", - "start_m": 0.0, - "end_m": length, - "options": {"form": "반중력식", "height_m": 2.0, "length_m": length, **options}, - } - table = build_table([structure], {"retaining_wall": "옹벽"}, rubble_base_thickness_m=rubble) - return table["structures"][0] - - -def _amounts(structure: dict) -> dict: - return {row["name"]: row for row in structure["components"]} - - -def test_옹벽_터파기는_기초_폭과_양쪽_여유_옆면_1대0점5이고_토공으로만_간다() -> None: - wall = _wall_table() - rows = _amounts(wall) - # 밑폭 1.6 + 0.3×2 = 2.2 · 깊이 0.4 + 0.1 + 0.2 = 0.7 · 옆면 1:0.5 → 윗폭 2.9 - # (2.2 + 2.9) ÷ 2 × 0.7 = 1.785/m - assert abs(rows["터파기"]["amount"] - 17.85) < 1e-9 # 수직일 때 15.4(2026-09-14 판정으로 고침) - # 든 것 = 기초·전단키 0.745 + (버림 + 잡석) 1.45 × 0.3 = 1.18 → 되메우기 0.605/m - assert abs(rows["잔토처리"]["amount"] - 11.8) < 1e-9 - assert abs(rows["되메우기"]["amount"] - 6.05) < 1e-9 # 수직일 때 3.6 - assert {rows[k]["destination"] for k in ("터파기", "되메우기", "잔토처리")} == {"earthwork"} - assert abs(rows["기초잡석"]["amount"] - 2.9) < 1e-9 # 원문 버림 0.145 × 2 - assert wall["trench_depth_m"] == 0.7 - assert "1:0.5" in rows["터파기"]["basis"] and "지반선" in rows["터파기"]["basis"] - # 잡석을 얇게 두면 전단키가 그 밑으로 나와 그 몫을 더 팜: (2.2+2.8)/2×0.6 + 0.35×0.1 - thin = _amounts(_wall_table(rubble=0.1)) - assert abs(thin["터파기"]["amount"] - 15.35) < 1e-9 - - -def test_옹벽_물구멍관은_제원_칸이_닿고_돌쌓기와_같은_규격으로_합쳐진다() -> None: - plain = _amounts(_wall_table())["물구멍관"] - assert plain["spec"] == "Ø50" and abs(plain["amount"] - 3.2) < 1e-9 - assert plain["basis_kind"] == "observed" and "관측값" in plain["basis"] - given = _amounts(_wall_table(weep_hole_area_m2=2.5, weep_hole_diameter_mm=75))["물구멍관"] - assert given["spec"] == "Ø75" and abs(given["amount"] - 1.6 / 2.5 * 0.4 * 10) < 1e-9 - - -def test_옹벽_그림은_기초잡석_두께를_산출_조건에서_받고_사유는_표와_같다() -> None: - shapes = build_figure(_wall_sheet(), rubble_thickness_m=0.3) - texts = _texts(shapes) - assert "기초잡석 T=0.3 m — 산출 조건 · 폭은 버림과 같음" in texts - assert "버림 T=0.10 · 폭 1.80 − 키 0.35 = 1.45 m" in texts - wall = _paths(shapes)[0]["points"] - assert [0.95, 0.4] in wall and [1.068, 2.0] in wall and [1.368, 2.0] in wall - # 표 사유와 같은 말 — 터파기를 어떻게 셌나(비탈분 제외) - _rows, table_notes = expand_observed( - "retaining_wall", - {"form": "반중력식", "height_m": 2.0}, - _wall_sheet() | {"options": {"length_m": 10}}, - ) - trench = [note for note in table_notes if "터파기" in note] - assert trench and trench[0] in texts and "지반선" in trench[0] - # 잡석 0.3 → 깊이 0.8 · 한쪽 0.4 - assert ( - "터파기 밑폭 2.20 · 윗폭 3.00 × 깊이 0.80 m (기초 1.6 + 여유 0.3×2 · 옆면 1:0.5)" in texts - ) - assert not any(text.startswith("기초잡석") for text in _texts(build_figure(_wall_sheet(), 0))) - - -def test_옹벽_못_그리는_장은_표와_같은_까닭() -> None: - assert "형식" in figure_reason({**_wall_sheet(), "options": {"height_m": 2.0}}) - other = figure_reason(_wall_sheet(height_m=1.6) | {"height_m": 1.6}) - assert other.startswith("그림 없음 — ") and "자료에 없습니다" in other - label = [t for t in _texts(build_figure(_wall_sheet(weep_hole_area_m2=3))) if "물구멍" in t] - assert label == ["물구멍 Ø50 — 3㎡당 1개소 · 관 0.4 m(관측값)"] - - -def test_양식_장은_양식이_푼_뒷길이로_그린다() -> None: - sheet = {**_sheet(), "formula_sheet": {"vars": {"L3": 55}}} - texts = _texts(build_figure(sheet)) - assert "뒷길이 ℓ₃ = 55㎝ (기본)" in texts and "상부 0.85 m" in texts diff --git a/resources/tester/test_b08_structure_figure_observed.py b/resources/tester/test_b08_structure_figure_observed.py deleted file mode 100644 index eb81ad509..000000000 --- a/resources/tester/test_b08_structure_figure_observed.py +++ /dev/null @@ -1,113 +0,0 @@ -"""구조물도 그림 — 집수정 · 날개벽 · 물넘이포장 (관측 원단위, 2026-09-14 · 표 = 그림). - -⚠ 집수정 □형 Ø800 — 그림 치수(`section`)로 **표 수량이 원문 식 그대로** 다시 나와야 함. - 원문 값으로 바꿈: 콘크리트 2.84 → 2.83752 · 합판거푸집 21.28 → 21.2843 · - 철근 4.78 → 4.776 · 면목 12.67 → 12.6728. -""" - -from __future__ import annotations - -import math -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - -BASIN_SPEC = { - "inlet_basin_form": "□형(기본형)", - "inlet_basin_material": "콘크리트", - "pipe_diameter_mm": "800", -} - - -def _sheets(**options) -> list[dict]: - pipe = { - "structure_id": "p1", - "type_id": "pipe", - "start_m": 0.0, - "end_m": 0.0, - "options": {"pipe_kind": "흄관", **options}, - } - payload = build_standard_sheets(build_table([pipe], None)) - attach_figures(payload) - return payload["sheets"] - - -def _sheet(sheets: list[dict], type_id: str) -> dict: - return next(sheet for sheet in sheets if sheet["type_id"] == type_id) - - -def test_집수정_치수로_표_수량이_원문_식대로_다시_나온다() -> None: - entry = load_observed_table().find("pipe_inlet_basin", BASIN_SPEC) - s = entry["section"] - amounts = {item["name"]: item["amount"] for item in entry["components"]} - inner, wall, base = s["inner"], s["wall_m"], s["base_m"] - ol, ow, h = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall, inner["height_m"] - il, iw = inner["length_m"], inner["width_m"] - sp = s["spillway"] - notch = (sp["top_m"] + sp["bottom_m"]) / 2 * sp["depth_m"] - hole = 3.14 * (s["pipe_d_m"] / 2) ** 2 # 원문이 3.14 로 셈 - concrete = ol * ow * h - il * iw * h - sp["count"] * notch * wall - hole * wall + ol * ow * base - slant = math.hypot((sp["top_m"] - sp["bottom_m"]) / 2, sp["depth_m"]) - form = ( - (ol * h - hole) - + (ol * h - notch) - + (ow * h - notch) * 2 - + (il * h - hole) - + (il * h - notch) - + (iw * h - notch) * 2 - + (ol + ow) * base * 2 - + slant * wall * sp["count"] * 2 - ) - chamfer = ol + il * 2 + slant * sp["count"] + sp["bottom_m"] * sp["count"] + wall * 4 - trench = (ol + s["dig"]["extra_length_m"]) * s["dig"]["width_m"] * (h + base) - backfill = trench - il * iw * (h + base) - assert abs(concrete - amounts["콘크리트"]) < 1e-9 # 2.83752 - assert abs(form - amounts["합판거푸집"]) < 5e-5 # 21.28432 - assert abs(chamfer - amounts["면목"]) < 5e-5 # 12.67279 - assert abs(trench - amounts["터파기"]) < 1e-9 - assert abs(backfill - amounts["되메우기"]) < 1e-9 - assert abs(trench - backfill - amounts["잔토처리"]) < 1e-9 - - -def test_집수정_그림이_서고_날개벽은_까닭() -> None: - sheets = _sheets(wing_wall_type="A-TYPE", **BASIN_SPEC) - basin = _sheet(sheets, "pipe_inlet_basin") - texts = [s["text"] for s in basin["figure"] if s["kind"] == "text"] - assert "안 3×1×1.2 · 벽 0.2 · 바닥기초 3.4×1.4×0.2 m" in texts - assert "터파기 3.8 × 2 × 깊이 1.4 m" in texts - wing = _sheet(sheets, "pipe_wing_wall") - assert ( - wing["rows"] and wing["figure"] is None and "구조도 원본 확보 대기" in wing["figure_reason"] - ) - - -def test_돌집수정은_치수_원문이_없어_까닭() -> None: - basin = _sheet( - _sheets(pipe_diameter_mm="800", inlet_basin_form="돌집수정 ㄷ형"), "pipe_inlet_basin" - ) - assert basin["rows"] and basin["figure"] is None and "집계표 관측값" in basin["figure_reason"] - - -def test_물넘이포장_그림과_표에_원문_차이_사유가_같다() -> None: - structure = { - "structure_id": "f1", - "type_id": "ford_pavement", - "start_m": None, - "end_m": None, - "options": {"ford_width_m": 5, "length_m": 10, "thickness_cm": 20}, - } - payload = build_standard_sheets(build_table([structure], None)) - attach_figures(payload) - sheet = payload["sheets"][0] - texts = [s["text"] for s in sheet["figure"] if s["kind"] == "text"] - diff = [note for note in sheet["notes"] if "원문 둘이 다름" in note] - assert diff and diff[0] in texts - assert "와이어메쉬 — 밑에서 0.133 m (2/3T)" in texts diff --git a/resources/tester/test_b08_structure_figure_small.py b/resources/tester/test_b08_structure_figure_small.py deleted file mode 100644 index 18c6aafa2..000000000 --- a/resources/tester/test_b08_structure_figure_small.py +++ /dev/null @@ -1,111 +0,0 @@ -"""구조물도 그림 — 기슭막이·골막이·떼흙막이·바닥막이·개거 (2026-09-14 · 표 = 그림). - -표를 세운 그 전개(`build_table`)로 장을 만들고 그림 글자가 **표 근거와 같은 수**를 보이는지 대조. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 - BED_SILL_TRENCH_NOTE, - SOIL_GUARD_SOD, -) - - -def _sheet(type_id: str, rubble=None, **options) -> dict: - structure = { - "structure_id": type_id, - "type_id": type_id, - "start_m": 0.0, - "end_m": float(options.get("length_m") or 0.0), - "options": options, - } - table = build_table([structure], None, rubble_base_thickness_m=rubble) - payload = build_standard_sheets(table) - attach_figures(payload, rubble) - return payload["sheets"][0] - - -def _texts(sheet: dict) -> list[str]: - return [shape["text"] for shape in sheet["figure"] or [] if shape["kind"] == "text"] - - -def _basis(sheet: dict, name: str) -> str: - return next(row["basis"] for row in sheet["rows"] if row["name"] == name) - - -def test_기슭막이_돌쌓기형은_돌쌓기_그림이고_기울기_두께가_표와_같다() -> None: - sheet = _sheet("revetment", form="돌쌓기(메)", height_m=2.0, back_len_cm=35, length_m=10) - texts = _texts(sheet) - assert sheet["figure_reason"] is None - slope = re.search(r"1:([\d.]+)", _basis(sheet, "돌쌓기")).group(1) # 메쌓기 표준경사 - assert f"1 : {slope}" in texts - top = re.search(r"상부 ([\d.]+)", _basis(sheet, "입적")).group(1) - assert f"상부 {top} m" in texts - concrete = _sheet("revetment", form="콘크리트", height_m=2.0, length_m=10) - assert concrete["figure"] is None and "콘크리트 기슭막이" in concrete["figure_reason"] - - -def test_골막이_그림_치수는_표_두께식과_같다() -> None: - sheet = _sheet( - "erosion_check", form="돌", height_m=1.5, top_length_m=4, bottom_length_m=2, back_len_cm=45 - ) - texts = _texts(sheet) - thickness = re.search(r"평균두께 ([\d.]+)m", _basis(sheet, "입적")).group(1) - label = next(t for t in texts if t.startswith("상부")) - assert f"평균 {float(thickness):.2f}" in label - trench = next(t for t in texts if t.startswith("바닥파기")) - width = re.search(r"× ([\d.]+) \)", _basis(sheet, "터파기").replace(")", " )")).group(1) - assert f"폭 {float(width):.2f}" in trench - band = [s for s in sheet["figure"] if s["kind"] == "path" and s["dash"]][0] - assert min(y for _x, y in band["points"]) < 0 # 바닥파기 띠가 밑변 바깥(아래)에 섬 - - -def test_떼흙막이_붙박이_치수로_표_두_줄이_다시_나온다() -> None: - d = SOIL_GUARD_SOD["dims"] - sod = ( - d["top_m"] * d["sod_m"] - + d["bottom_m"] * d["sod_m"] - + (d["top_m"] + d["bottom_m"]) / 2 * d["height_m"] - + d["slant_m"] * d["sod_m"] * 2 - ) - trench = (d["slant_m"] * d["trench_width_m"] * d["trench_depth_m"] * 2) + ( - d["bottom_m"] * d["trench_width_m"] * d["trench_depth_m"] - ) - rows = {row[0]: row[2] for row in SOIL_GUARD_SOD["rows"]} - assert round(sod, 2) == rows["떼"] and round(trench, 2) == rows["터파기"] - assert ( - abs(((d["top_m"] - d["bottom_m"]) / 2) ** 2 + d["height_m"] ** 2 - d["slant_m"] ** 2) < 0.01 - ) - sheet = _sheet("soil_guard", form="떼", height_m=0.5, length_m=10) - assert sheet["figure"] and sheet["unit_label"] == "개소당" - other = _sheet("soil_guard", form="돌(찰)", height_m=0.5, length_m=10) - assert other["figure"] is None and "떼흙막이" in other["figure_reason"] - - -def test_바닥막이_그림_사유는_표와_같은_말이고_기초잡석은_산출_조건() -> None: - sheet = _sheet("bed_sill", rubble=0.3, form="돌붙임(찰)", area_m2=12, height_m=0.3) - texts = _texts(sheet) - assert BED_SILL_TRENCH_NOTE in sheet["notes"] and BED_SILL_TRENCH_NOTE in texts - assert "기초잡석 T=0.3 m — 산출 조건" in texts - dry = _sheet("bed_sill", form="돌붙임(메)", area_m2=12, height_m=0.3) - assert ( - not any("기초잡석" in t for t in _texts(dry)) and BED_SILL_TRENCH_NOTE not in dry["notes"] - ) - - -def test_개거는_표가_서도_치수를_못_정한_까닭() -> None: - sheet = _sheet("open_ditch", ditch_spec="콘크리트 개거 150×200", length_m=5) - assert sheet["rows"] and sheet["figure"] is None and "확인 대기" in sheet["figure_reason"] - l_type = _sheet("open_ditch", ditch_spec="L형수로 H=0.2", length_m=5) - assert l_type["rows"] and "단면 그림이 없어" in l_type["figure_reason"] diff --git a/resources/tester/test_b08_structure_library.py b/resources/tester/test_b08_structure_library.py deleted file mode 100644 index 8fab215fb..000000000 --- a/resources/tester/test_b08_structure_library.py +++ /dev/null @@ -1,383 +0,0 @@ -"""구조물도 양식 라이브러리 — 가져오기·박기 (2026-09-13, PLAN 4장 ①). - -겨누는 것 - ① 가져오기 전은 프로그램 기본 · 머리 「가져오기 전」 - ② 로그인한 사람의 개인 단에서 가져오면 **프로젝트에 박히고** 원단위·자재총괄도 그 양식을 봄 - ③ ⛔ 박힌 뒤에는 라이브러리를 안 읽음 — 개인 파일을 지우거나 다른 사람이 열어도 값이 같음 - ④ 가져오면 그 종류의 고친 식이 비워짐 - ⑤ [확정] 때 안 박힌 기본 양식을 박음 · 코드 모양이 아니면 거절 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 -from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 -from common_util.common_util_auth import verify_session # noqa: E402 - -PROJECT_ID = "33333333-3333-3333-3333-333333333333" -SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" -PERSONAL_CODE = "AX-ST-0badc0de" - - -@pytest.fixture() -def storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - base = tmp_path / "storage" - monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(base)) - # 개인 단(회사 7 · 사람 42)에 모르터 식을 두 배로 고친 찰쌓기 한 벌. - mine = load_template("masonry_wet") - for row in mine["rows"]: - if row["name"] == "모르터": - row["formula"] = "A*0.018" - mine.update(code=PERSONAL_CODE, library_tier="personal", name="돌쌓기(찰) 내 것") - folder = base / "7" / "42" / "library" - folder.mkdir(parents=True) - (folder / f"{PERSONAL_CODE}.json").write_text(json.dumps(mine), encoding="utf-8") - return base - - -@pytest.fixture() -def project(tmp_path: Path) -> Path: - root = tmp_path / "project" - root.mkdir() - wall = StructureInstance.model_validate( - { - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 100.0, - "end_m": 110.0, - "options": {"height_m": 2.5, "back_len_cm": 45}, - } - ) - save_structures(str(root), [wall], base_revision=0) - return root - - -@pytest.fixture() -def client(project: Path, storage: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - async def fake_root(project_id): - return str(project) - - async def no_route(project_id): - return {} - - monkeypatch.setattr(router_module, "_project_root", fake_root) - monkeypatch.setattr(material_module, "_section_modes", no_route) - monkeypatch.setattr(material_module, "_ground_types", no_route) - app = FastAPI() - app.include_router(router_module.router) - app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} - return TestClient(app) - - -def _sheet(client: TestClient) -> dict: - response = client.get(SHEETS) - assert response.status_code == 200, response.text - return response.json()["sheets"][0] - - -def _mortar(sheet: dict) -> dict: - return next(row for row in sheet["rows"] if row["name"] == "모르터") - - -def test_가져오기_전은_프로그램_기본(client: TestClient) -> None: - item = _sheet(client)["library_item"] - assert item["imported_from"] is None - assert item["code"] == load_template("masonry_wet")["code"] - - -def test_개인_단에서_가져오면_박히고_라이브러리를_다시_안_읽는다( - client: TestClient, project: Path, storage: Path -) -> None: - before = _mortar(_sheet(client))["unit_amount"] - listed = client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["items"] - assert [item["tier"] for item in listed] == ["personal", "program"] - - taken = client.put( - f"{SHEETS}/library/import", - json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE}, - ) - assert taken.status_code == 200, taken.text - sheet = _sheet(client) - assert sheet["library_item"]["imported_from"] == "personal" - assert _mortar(sheet)["unit_amount"] == pytest.approx(before * 2) - # 가져온 식은 「사용자 식」이 아니라 그 양식의 식. - assert _mortar(sheet)["source"] == "library" - - # 원단위·자재총괄 창구도 박힌 양식을 봄. - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - - structures, names, _ = material_module._collect_structures(str(project)) - table = build_table( - structures, - names, - structure_templates=library_module.project_templates(project), - ) - mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터") - assert mortar["amount"] == pytest.approx(_mortar(sheet)["unit_amount"] * 10.0) - - # ⛔ 개인 파일이 사라지고 다른 사람이 열어도 값이 그대로. - (storage / "7" / "42" / "library" / f"{PERSONAL_CODE}.json").unlink() - client.app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 99} - assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(before * 2) - - -def test_가져오면_그_종류의_고친_식이_비워진다(client: TestClient, project: Path) -> None: - sheet = _sheet(client) - seq = _mortar(sheet)["no"] - client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]}, - ) - program_code = load_template("masonry_wet")["code"] - taken = client.put( - f"{SHEETS}/library/import", - json={"type_id": "masonry_wet", "tier": "program", "code": program_code}, - ) - assert taken.json()["cleared_formulas"] == 1 - stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) - assert stored["quantity"]["structure_formula_overrides"] == {} - assert _sheet(client)["library_item"]["imported_from"] == "program" - - -def test_없는_항목_가져오기는_404(client: TestClient) -> None: - missing = client.put( - f"{SHEETS}/library/import", - json={"type_id": "masonry_wet", "tier": "company", "code": "AX-ST-12345678"}, - ) - assert missing.status_code == 404 - - -def test_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_안_바꾼다( - client: TestClient, project: Path, storage: Path -) -> None: - """PLAN 4장 ②③ — 개인 단에 한 벌(같은 종류는 덮어씀) · 지우기도 개인 단만.""" - personal = storage / "7" / "42" / "library" - sheet = _sheet(client) - seq = _mortar(sheet)["no"] - client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]}, - ) - settings_before = (project / "project_settings.json").read_text(encoding="utf-8") - - saved = client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]}) - assert saved.status_code == 200, saved.text - assert saved.json()["code"] == PERSONAL_CODE # 같은 종류가 있어 그 코드로 덮어씀 - files = list(personal.glob("*.json")) - assert len(files) == 1 - item = json.loads(files[0].read_text(encoding="utf-8")) - mortar_row = next(row for row in item["rows"] if row["name"] == "모르터") - assert mortar_row["formula"] == "A*0.03" and mortar_row["source"] == "library" - assert "default_formula" not in mortar_row - assert item["library_tier"] == "personal" and item["pum_edition"] == "2026-01-01" - # 프로젝트는 그대로 — 박힌 양식 없음 · 고친 식 그대로. - assert library_module.project_templates(project) == {} - assert (project / "project_settings.json").read_text(encoding="utf-8") == settings_before - - # 가져온 뒤 지워도 프로젝트는 그대로. - client.put( - f"{SHEETS}/library/import", - json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE}, - ) - amount = _mortar(_sheet(client))["unit_amount"] - deleted = client.delete(f"{SHEETS}/library/personal", params={"type_id": "masonry_wet"}) - assert deleted.json()["deleted"] == 1 and not list(personal.glob("*.json")) - assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount) - - -def test_프로그램_기본은_storage_작업본이_씨앗을_이긴다(storage: Path) -> None: - """관리자 UI(2026-09-14 브레인) — 프로그램 기본도 storage 작업본 · git 파일은 씨앗(없는 코드만).""" - seed = load_template("masonry_wet") - assert library_module.program_items()[0]["code"] == seed["code"] # 작업본 없으면 씨앗 - edited = {**seed, "name": "돌쌓기(찰) 관리자 고침"} - folder = library_module.program_library_dir() - folder.mkdir(parents=True) - (folder / f"{seed['code']}.json").write_text(json.dumps(edited), encoding="utf-8") - items = library_module.program_items() - assert [i["name"] for i in items if i["type_id"] == "masonry_wet"] == ["돌쌓기(찰) 관리자 고침"] - assert load_template("masonry_wet")["name"] == "돌쌓기(찰) 관리자 고침" - assert str(folder).startswith(str(storage)) # 씨앗(git) 파일은 안 건드림 - - -def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client: TestClient, storage: Path) -> None: - sheet = _sheet(client) - denied = client.put( - f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"} - ) - assert denied.status_code == 403 # 시스템 관리자만 - assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()[ - "can_publish" - ] == {"company": False, "program": False} - - client.app.dependency_overrides[verify_session] = lambda: { - "company_id": 7, - "user_id": 42, - "is_master": True, - "role": "ADMIN", - } - company = client.put( - f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "company"} - ) - assert company.status_code == 200, company.text - saved = json.loads( - (storage / "7" / "library" / f"{company.json()['code']}.json").read_text("utf-8") - ) - assert saved["library_tier"] == "company" - assert ( - client.put( - f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"} - ).status_code - == 403 - ) - - client.app.dependency_overrides[verify_session] = lambda: { - "company_id": None, - "user_id": 1, - "role": "SYSTEM_ADMIN", - } - program = client.put( - f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"} - ) - assert program.status_code == 200, program.text - assert program.json()["code"] == load_template("masonry_wet")["code"] # 씨앗 코드로 덮어씀 - assert (library_module.program_library_dir() / f"{program.json()['code']}.json").is_file() - assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["can_publish"][ - "program" - ] - - -def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None: - """2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③).""" - origin = { - "kind": "stmate_xlsx", - "file": "봉화.xlsx", - "project": "2024년 봉화 임도", - "hopyo_no": 6, - } - template = {**load_template("masonry_wet"), "origin": origin} - program = library_module.save_personal(tmp_path / "program", template, None, None, "program") - company = library_module.save_personal(tmp_path / "company", template, None, None, "company") - published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8")) - kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8")) - assert "project" not in published["origin"] and "file" not in published["origin"] - assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림" - assert published["origin"]["kind"] == "stmate_xlsx" - assert kept["origin"] == origin - assert template["origin"] == origin # 원본은 안 건드림 - - -def test_발행_확인창이_가릴_공사명을_보인다() -> None: - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( - encoding="utf-8" - ) - assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui - sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text( - encoding="utf-8" - ) - assert "originProject: sheet.library_item.origin_project" in sheet_ui - template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text( - encoding="utf-8" - ) - assert '"origin_project"' in template_py - - -def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None: - client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} - sheet = _sheet(client) - assert ( - client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]}).status_code - == 403 - ) - - -def test_확정_때_기본_양식을_박는다(project: Path) -> None: - assert library_module.pin_program_templates(project) >= 1 - pinned = library_module.project_templates(project)["masonry_wet"] - assert pinned["imported_from"] == "program" - assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로 - - -def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다( - client: TestClient, project: Path, storage: Path -) -> None: - """PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌.""" - import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router - - client.app.include_router(extra_router.router) - base = load_template("masonry_wet") - folder = storage / "7" / "42" / "library" - for path in folder.glob("*.json"): - path.unlink() # 개인 단을 비워 새로 생기는지 봄 - response = client.put( - f"{SHEETS}/library/clone", - json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]}, - ) - assert response.status_code == 200, response.text - code = response.json()["code"] - saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8")) - assert saved["library_tier"] == "personal" and saved["code"] != base["code"] - assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]} - assert saved["rows"] == base["rows"] - assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로 - missing = client.put( - f"{SHEETS}/library/clone", - json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"}, - ) - assert missing.status_code == 404 - client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} - denied = client.put( - f"{SHEETS}/library/clone", - json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]}, - ) - assert denied.status_code == 403 - - -def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None: - """PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림.""" - folder = tmp_path / "personal" - folder.mkdir() - fixed = { - "type_id": "masonry_wet", - "code": "AX-ST-0000beef", - "name": "뽑은 항목", - "rows": [{"name": "돌쌓기", "formula": "", "amount": 20.9}], - } - (folder / "AX-ST-0000beef.json").write_text(json.dumps(fixed), encoding="utf-8") - dirs = {"personal": folder, "program": library_module.TEMPLATE_DIR} - kinds = {item["tier"]: item["kind"] for item in library_module.list_items(dirs, "masonry_wet")} - assert kinds == {"personal": "fixed", "program": "form"} - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( - encoding="utf-8" - ) - assert "양식형" in ui and "고정형" in ui and "item.kind" in ui - - -def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None: - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( - encoding="utf-8" - ) - assert "/clone" in ui and "복제해서 내 것으로" in ui - assert 'clone.disabled = tier === "personal"' in ui - # 발행 단추 — 서버 권한(can_publish)대로만 보임 - assert "/publish" in ui and "toCompany.hidden = !canPublish?.company" in ui - assert "toProgram.hidden = !canPublish?.program" in ui - - -def test_코드_모양이_아니면_박지_않는다(project: Path) -> None: - with pytest.raises(ValueError): - library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal") diff --git a/resources/tester/test_b08_structure_sheet_router.py b/resources/tester/test_b08_structure_sheet_router.py deleted file mode 100644 index d902c4c1b..000000000 --- a/resources/tester/test_b08_structure_sheet_router.py +++ /dev/null @@ -1,298 +0,0 @@ -"""구조물도 창구(B08) — B07 에서 옮긴 뒤 조회·제원 저장이 한 벌로 도는지 (2026-09-13, PLAN 3장 ②). - -겨누는 것 셋 - ① 조회가 장을 냄 — 옛 B07 `/standard-sheets` 자리 - ② ⚠ 기초잡석 두께가 **산출 조건 값을 따름** — 옛 B07 창구는 두께를 안 넘겨 늘 0.2 로 섰음 - (원단위 탭과 구조물도가 같은 구조물에서 다른 값을 냈음) - ③ 제원 저장이 그 장의 개소에 걸리고, 다시 조회하면 새 제원으로 섬 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 -from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 - -PROJECT_ID = "22222222-2222-2222-2222-222222222222" -SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" - - -@pytest.fixture() -def project(tmp_path: Path) -> Path: - root = tmp_path / "project" - root.mkdir() - wall = StructureInstance.model_validate( - { - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 100.0, - "end_m": 110.0, - "options": {"height_m": 2.5, "back_len_cm": 45}, - } - ) - save_structures(str(root), [wall], base_revision=0) - return root - - -@pytest.fixture() -def client(project: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - async def fake_root(project_id): - return str(project) - - async def no_route(project_id): - return {} - - monkeypatch.setattr(router_module, "_project_root", fake_root) - monkeypatch.setattr(material_module, "_section_modes", no_route) - monkeypatch.setattr(material_module, "_ground_types", no_route) - app = FastAPI() - app.include_router(router_module.router) - return TestClient(app) - - -def _sheet(client: TestClient) -> dict: - response = client.get(SHEETS) - assert response.status_code == 200, response.text - sheets = response.json()["sheets"] - assert len(sheets) == 1, sheets - return sheets[0] - - -def _unit_amount(sheet: dict, name: str) -> float: - return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name) - - -def test_조회가_장을_낸다(client: TestClient) -> None: - sheet = _sheet(client) - assert sheet["type_id"] == "masonry_wet" - assert sheet["member_count"] == 1 - assert sheet["billing_unit"] == "m" and sheet["billing_total"] == pytest.approx(10.0) - assert all("spec" in row for row in sheet["rows"]) - - -def test_양식이_있는_종류는_줄마다_식을_싣는다(client: TestClient) -> None: - """PLAN 3장 ④ · 명세 13장 — 찰쌓기는 양식으로 줄이 서고 식·설명·갈 곳·출처가 실림.""" - sheet = _sheet(client) - assert sheet["library_item"]["type_id"] == "masonry_wet" - rows = {row["name"]: row for row in sheet["rows"]} - 돌쌓기 = rows["돌쌓기"] - assert 돌쌓기["formula"] == "H*L*SQRT(1+N^2)" - assert "비탈면적" in 돌쌓기["basis"] - assert 돌쌓기["source"] == "library" and 돌쌓기["destination"] == "unit_price" - assert all(row["destination"] for row in sheet["rows"]) # 갈 곳 빈 줄 없음 - # 돌 줄은 이름 고정 — 종류를 안 골라 규격이 빔(매칭 성공 아님, 명세 13장 Ⓒ). - assert rows["돌"]["spec"] == "" - # m당 값 — 제원(H=2.5·뒷길이 45·1:0.3)으로 다시 셈한 값. - assert 돌쌓기["unit_amount"] == pytest.approx(2.5 * (1 + 0.3**2) ** 0.5) - - -def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None: - """버림을 「안 넣음」으로 저장하면 버림·기초잡석이 0 이 아니라 「안 섬」으로 옴.""" - sheet = _sheet(client) - saved = client.put( - f"{SHEETS}/spec", - json={"sheet_key": sheet["key"], "base_revision": 1, "blinding_concrete": "안 넣음"}, - ) - assert saved.status_code == 200, saved.text - rows = {row["name"]: row for row in _sheet(client)["rows"]} - for name in ("버림콘크리트", "기초잡석"): - assert rows[name]["skipped"] is True and rows[name]["unit_amount"] is None, rows[name] - assert rows[name]["reason"], rows[name] - - -def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다( - client: TestClient, project: Path -) -> None: - """PLAN 3장 ⑤ — 프로젝트 장 단위 저장 · 값은 서버가 다시 냄 · 고친 줄은 출처 user.""" - sheet = _sheet(client) - rows = {row["name"]: row for row in sheet["rows"]} - 원래 = rows["모르터"] - saved = client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": "A*0.018"}]}, - ) - assert saved.status_code == 200, saved.text - assert saved.json()["changed"] == 1 and saved.json()["errors"] == [] - - after = {row["name"]: row for row in _sheet(client)["rows"]} - assert after["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"] * 2) - assert after["모르터"]["source"] == "user" - assert after["모르터"]["default_formula"] == 원래["formula"] - assert after["돌쌓기"]["source"] == "library" # 안 고친 줄은 그대로 양식 - stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) - # 장 이름이 아니라 양식(type_id)에 묶임 — 브레인 판정(PLAN 3장 ⑤ ⓒ). - assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == { - str(원래["no"]): {"formula": "A*0.018"} - } - - # 원단위·자재총괄(build_table)도 같은 고친 식을 봄 — 구조물도와 안 갈림. - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - from B08_Quantity.B08_Quantity_Router_Material import _collect_structures - - structures, names, _ = _collect_structures(str(project)) - table = build_table( - structures, - names, - None, - None, - None, - structure_formulas=stored["quantity"]["structure_formula_overrides"], - ) - mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터") - assert mortar["amount"] == pytest.approx(after["모르터"]["unit_amount"] * 10.0) - assert mortar["source"] == "user" and "사용자 식" in mortar["basis"] - - # 되돌리기 — 빈 식이면 양식 식으로, 저장 칸에서도 지워짐. - reverted = client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": None}]}, - ) - assert reverted.status_code == 200 and reverted.json()["changed"] == 1 - back = {row["name"]: row for row in _sheet(client)["rows"]} - assert back["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"]) - assert back["모르터"]["source"] == "library" - stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) - assert stored["quantity"]["structure_formula_overrides"] == {} - - -def test_줄마다_반올림을_고치면_m당에서_반올림하고_뒷줄이_그_값을_본다( - client: TestClient, project: Path -) -> None: - """PLAN 3장 반올림 칸 — 실무 구조물도처럼 m당 값을 반올림 · 원단위는 m당 × 연장.""" - sheet = _sheet(client) - rows = {row["name"]: row for row in sheet["rows"]} - area = rows["돌쌓기"]["unit_amount"] # 2.5 × √1.09 ≈ 2.61 - saved = client.put( - f"{SHEETS}/formulas", - json={ - "sheet_key": sheet["key"], - "rows": [ - { - "seq": rows["돌쌓기"]["no"], - "formula": None, - "rounding": {"mode": "floor", "digits": 0}, - } - ], - }, - ) - assert saved.status_code == 200 and saved.json()["changed"] == 1, saved.text - after = {row["name"]: row for row in _sheet(client)["rows"]} - assert after["돌쌓기"]["unit_amount"] == pytest.approx(2.0) and area > 2.5 - assert after["돌쌓기"]["source"] == "user" - assert after["돌쌓기"]["formula"] == rows["돌쌓기"]["formula"] # 식은 그대로 - assert after["돌쌓기"]["default_rounding"] == {"mode": "none", "digits": 0} - # 뒷줄(모르터 = A × 0.009)이 반올림한 A 를 봄. - assert after["모르터"]["unit_amount"] == pytest.approx(2.0 * 0.009) - stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) - assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == { - str(rows["돌쌓기"]["no"]): {"rounding": {"mode": "floor", "digits": 0}} - } - - # 원단위(build_table) = m당 반올림 값 × 연장 10m — 합계를 반올림하지 않음. - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - from B08_Quantity.B08_Quantity_Router_Material import _collect_structures - - structures, names, _ = _collect_structures(str(project)) - table = build_table( - structures, names, structure_formulas=stored["quantity"]["structure_formula_overrides"] - ) - parts = {c["name"]: c for c in table["structures"][0]["components"]} - assert parts["돌쌓기"]["amount"] == pytest.approx(20.0) - assert parts["모르터"]["amount"] == pytest.approx(0.18) - assert parts["돌쌓기"]["basis"] == "사용자 반올림 = INT 0자리 (양식 안 함)" - - # 되돌리기 — 반올림 null 이면 양식대로, 저장 칸에서도 지워짐. - client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": rows["돌쌓기"]["no"], "rounding": None}]}, - ) - stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) - assert stored["quantity"]["structure_formula_overrides"] == {} - # 모르는 갈래는 받지 않음. - bad = client.put( - f"{SHEETS}/formulas", - json={ - "sheet_key": sheet["key"], - "rows": [{"seq": 1, "rounding": {"mode": "bankers", "digits": 0}}], - }, - ) - assert bad.status_code == 422 - - -def test_대안_후보를_싣는다(client: TestClient) -> None: - candidates = {item["name"]: item for item in _sheet(client)["var_candidates"]} - assert candidates["HOLE_AREA"]["value"] == 2 - assert candidates["HOLE_AREA"]["candidates"][0]["value"] == 2.5 - - -def test_제원을_고쳐도_고친_식이_따라간다(client: TestClient) -> None: - """고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음.""" - sheet = _sheet(client) - seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터") - client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.018"}]}, - ) - spec = client.put( - f"{SHEETS}/spec", - json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"}, - ) - assert spec.status_code == 200, spec.text - after = _sheet(client) - assert after["key"] != sheet["key"] - mortar = next(row for row in after["rows"] if row["name"] == "모르터") - assert mortar["source"] == "user" and mortar["formula"] == "A*0.018" - - -def test_틀린_식도_막지_않고_저장하되_오류를_돌려준다(client: TestClient) -> None: - sheet = _sheet(client) - seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터") - saved = client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*없는칸"}]}, - ) - assert saved.status_code == 200, saved.text - assert any("모르터" in error and "모르는 이름" in error for error in saved.json()["errors"]) - bad = client.put( - f"{SHEETS}/formulas", - json={"sheet_key": sheet["key"], "rows": [{"seq": 999, "formula": "1"}]}, - ) - assert bad.status_code == 400 - - -def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None: - 기본 = _unit_amount(_sheet(client), "기초잡석") - (project / "project_settings.json").write_text( - json.dumps({"quantity": {"rubble_base_thickness_m": 0.3}}), encoding="utf-8" - ) - 바꿈 = _unit_amount(_sheet(client), "기초잡석") - # 기본 0.2 → 0.3 — 버림 폭에 두께 비를 곱하는 식이라 1.5 배. - assert 바꿈 == pytest.approx(기본 * 1.5) - - -def test_제원_저장이_그_장에_걸린다(client: TestClient) -> None: - sheet = _sheet(client) - saved = client.put( - f"{SHEETS}/spec", - json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"}, - ) - assert saved.status_code == 200, saved.text - assert saved.json()["changed"] == 1 - assert _sheet(client)["options"]["back_len_cm"] == 55 - - -def test_없는_장이면_404(client: TestClient) -> None: - response = client.put(f"{SHEETS}/spec", json={"sheet_key": "없는 장", "base_revision": 1}) - assert response.status_code == 404 diff --git a/resources/tester/test_b08_structure_summary.py b/resources/tester/test_b08_structure_summary.py deleted file mode 100644 index 8112d63c2..000000000 --- a/resources/tester/test_b08_structure_summary.py +++ /dev/null @@ -1,258 +0,0 @@ -"""구조물 집계표 (2026-09-13, PLAN 2장). - -겨누는 것 - ① 한 줄 = 측점 + 종류 + 실치수 + 개소·연장 · 종류별 표 · 개소·연장 합 · 평균치수 - ② 칸 출처 — 정본 값은 자동 · 빈 칸은 양식 기본값(라이브러리) · 둘 다 없으면 빈칸 - ③ 계곡 통과 시설은 관 지점 정본에서 — 배수관과 세월교는 **다른 표**(브레인 판정) · - 관 연장은 B06 횡단 값(0.5m 안) · 없으면 빈칸(0 아님) - ④ 손댄 칸은 「사용자」 · 그 값을 B05 가 바꾸면 자동으로 돌아가되 **알림이 남음** - ⑤ [저장] — 정본에 바로 씀(덮개층 없음) · 상세 칸만 · 놓기 칸·틀린 값·판번호 어긋남은 - 아무것도 안 씀 · 고치기 전 값으로 되돌리면 「자동」 · 관 지점 파일은 옵션만 바뀜 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 -from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 -from B05_Profile.B05_Profile_Structures_Schema import ( # noqa: E402 - StructureInstance, - structure_type_map, -) -from B08_Quantity.B08_Quantity_Engine_StructureSummary import build_summary # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 -from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402 - -PROJECT_ID = "66666666-6666-6666-6666-666666666666" - -WALLS = [ - { - "structure_id": "w1", - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 100.0, - "end_m": 110.0, - "options": {"height_m": 2.5, "back_len_cm": 35}, - }, - { - "structure_id": "w2", - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 40.0, - "end_m": 46.0, - "options": {"height_m": 1.5, "length_m": 5.0}, - }, -] -POINTS = [ - {"chainage_m": 60.2, "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 800}}, - {"chainage_m": 300.0}, - {"chainage_m": 200.0, "facility": "ford_bridge", "options": {"pipe_count": 2}}, -] - - -def _tables(user_cells=None) -> dict: - body = build_summary( - WALLS, - POINTS, - structure_type_map(), - {"masonry_wet": load_template("masonry_wet")}, - {60.0: 8.0}, - user_cells, - ) - return {table["type_id"]: table for table in body["tables"]} | {"_notes": body["notes"]} - - -def test_종류별_표에_측점_실치수_개소_연장이_선다() -> None: - walls = _tables()["masonry_wet"] - assert [row["id"] for row in walls["rows"]] == ["w2", "w1"] # 측점 차례 - w2, w1 = walls["rows"] - assert w1["length_m"] == pytest.approx(10.0) and w1["length_basis"] == "시·종점" - assert w2["length_m"] == pytest.approx(5.0) and w2["length_basis"] == "제원 연장" - assert walls["count"] == 2 and walls["length_total_m"] == pytest.approx(15.0) - assert walls["averages"]["height_m"] == pytest.approx(2.0) - # 칸 출처 — 정본 값 · 양식 기본(뒷길이 45) · 둘 다 없음. - assert w1["cells"]["back_len_cm"] == {"value": 35, "source": "auto"} - assert w2["cells"]["back_len_cm"] == {"value": 45, "source": "library"} - assert w2["cells"]["face_slope_ratio"] == {"value": None, "source": "empty"} - - -def test_배수관과_세월교는_다른_표이고_관_연장은_B06_값() -> None: - tables = _tables() - pipes = tables["pipe"] - assert [row["chainage_m"] for row in pipes["rows"]] == [60.2, 300.0] - near, far = pipes["rows"] - assert near["length_m"] == pytest.approx(8.0) and near["length_basis"] == "B06 횡단 관 연장" - assert far["length_m"] is None and pipes["length_missing"] == 1 # 0 으로 안 채움 - assert near["cells"]["pipe_kind"]["value"] == "흄관" - assert tables["ford_bridge"]["rows"][0]["cells"]["pipe_count"]["value"] == 2 - assert tables["ford_bridge"]["length_missing"] == 0 # 점 시설은 연장이 없는 것이 정상 - - -def test_손댄_칸은_사용자이고_B05_가_바꾸면_알림이_남는다() -> None: - marks = { - "w1": {"height_m": {"value": 2.5, "was": 2.0}, "back_len_cm": {"value": 55, "was": 35}} - } - tables = _tables(marks) - w1 = tables["masonry_wet"]["rows"][1] - assert w1["cells"]["height_m"] == {"value": 2.5, "source": "user", "was": 2.0} - # 뒷길이는 55 로 적었는데 정본이 35 — B05 가 바꿈. - assert w1["cells"]["back_len_cm"]["source"] == "auto" - assert w1["cells"]["back_len_cm"]["replaced_user_value"] == 55 - assert w1["replaced"] == ["뒷길이"] - assert any("자동값으로 돌아감" in note for note in tables["_notes"]) - - -@pytest.fixture() -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - root = tmp_path / "project" - root.mkdir() - save_structures( - str(root), [StructureInstance.model_validate(w) for w in WALLS], base_revision=0 - ) - path = pipe_points_path_in(root) - path.parent.mkdir(parents=True) - path.write_text(json.dumps({"route_signature": "", "points": POINTS}), encoding="utf-8") - - async def fake_connection(func, *args): - return "stored" - - async def designs(project_id): - return [{"chainage_m": 60.0, "design": {"pipe_length_m": 8.0}}] - - monkeypatch.setattr(material_module, "run_with_connection", fake_connection) - monkeypatch.setattr(material_module, "resolve_stored_project_path", lambda _p: str(root)) - monkeypatch.setattr(material_module, "_designs", designs) - app = FastAPI() - app.include_router(material_module.router) - return TestClient(app) - - -def test_창구가_정본_둘과_횡단_관_연장을_읽어_표를_낸다(client: TestClient) -> None: - response = client.get(f"/api/projects/{PROJECT_ID}/quantity/structure-summary") - assert response.status_code == 200, response.text - body = response.json() - names = [table["name"] for table in body["tables"]] - assert "배수관" in names and "세월교" in names and "돌쌓기(찰)" in names - pipes = next(table for table in body["tables"] if table["type_id"] == "pipe") - assert pipes["rows"][0]["length_m"] == pytest.approx(8.0) - assert body["revision"] == 1 - columns = {c["key"]: c for c in pipes["columns"]} - assert columns["pipe_diameter_mm"]["editable"] is False # 놓기 칸 - assert columns["wing_wall_type"]["editable"] is True # 상세 칸 - - -SUMMARY = f"/api/projects/{PROJECT_ID}/quantity/structure-summary" - - -def _cell(client: TestClient, type_id: str, row_id: str, key: str) -> dict: - tables = client.get(SUMMARY).json()["tables"] - table = next(t for t in tables if t["type_id"] == type_id) - return next(r for r in table["rows"] if r["id"] == row_id)["cells"][key] - - -def test_저장은_정본에_바로_쓰고_손댄_칸을_표시한다(client: TestClient, tmp_path: Path) -> None: - from B05_Profile.B05_Profile_Structures_Repository import load_structures - from common_util.common_util_project_settings import quantity_settings - - root = tmp_path / "project" - saved = client.put( - SUMMARY, - json={ - "base_revision": 1, - "edits": [ - {"id": "w1", "key": "back_len_cm", "value": "55"}, - {"id": "w2", "key": "stone_kind", "value": "야면석·호박돌"}, - {"id": "pipe@60.200", "key": "wing_wall_type", "value": "A-TYPE"}, - ], - }, - ) - assert saved.status_code == 200, saved.text - assert saved.json()["revision"] == 2 and saved.json()["changed_rows"] == 3 - - # 정본이 곧 값 — 구조물도·원단위가 읽는 자리에 그대로 적힘. - _rev, items = load_structures(str(root)) - assert {i.structure_id: i.options.get("back_len_cm") for i in items}["w1"] == 55 - document = json.loads(pipe_points_path_in(root).read_text(encoding="utf-8")) - assert document["points"][0]["options"] == { - "pipe_kind": "흄관", - "pipe_diameter_mm": 800, - "wing_wall_type": "A-TYPE", - } - assert document["route_signature"] == "" and len(document["points"]) == 3 - assert _cell(client, "masonry_wet", "w1", "back_len_cm") == { - "value": 55, - "source": "user", - "was": 35, - } - - # 고치기 전 값으로 되돌리면 손 표가 빠지고 「자동」. - back = client.put( - SUMMARY, - json={"base_revision": 2, "edits": [{"id": "w1", "key": "back_len_cm", "value": 35}]}, - ) - assert back.status_code == 200, back.text - assert _cell(client, "masonry_wet", "w1", "back_len_cm")["source"] == "auto" - marks = quantity_settings(str(root))["structure_summary_user_cells"] - assert "w1" not in marks and "w2" in marks - - -def test_놓기_칸이나_틀린_값이나_판번호_어긋남은_아무것도_안_쓴다( - client: TestClient, tmp_path: Path -) -> None: - from B05_Profile.B05_Profile_Structures_Repository import structures_file_path - - root = tmp_path / "project" - - def files() -> tuple[str, str]: - return ( - Path(structures_file_path(str(root))).read_text(encoding="utf-8"), - pipe_points_path_in(root).read_text(encoding="utf-8"), - ) - - before = files() - - def put(edits: list[dict], revision: int = 1): - return client.put(SUMMARY, json={"base_revision": revision, "edits": edits}) - - good = {"id": "pipe@60.200", "key": "wing_wall_type", "value": "A-TYPE"} - height = put([good, {"id": "w1", "key": "height_m", "value": 3}]) - assert height.status_code == 422 and "구조물 놓기(B05)" in height.json()["message"] - assert put([good, {"id": "w1", "key": "back_len_cm", "value": -1}]).status_code == 422 - assert put([good, {"id": "w1", "key": "stone_kind", "value": "없는 돌"}]).status_code == 422 - assert put([good, {"id": "nope", "key": "back_len_cm", "value": 1}]).status_code == 422 - assert put([good], revision=0).status_code == 409 - assert files() == before - - -def test_B05_가_바꾼_칸은_알림_뒤_저장하면_손_표에서_빠진다() -> None: - from B08_Quantity.B08_Quantity_Engine_StructureSummary import apply_edits, prune_marks - - walls = [dict(w, options=dict(w["options"])) for w in WALLS] - types = structure_type_map() - changed, marks = apply_edits( - walls, [], types, [{"id": "w1", "key": "back_len_cm", "value": 55}], {} - ) - assert changed == ["w1"] and marks == {"w1": {"back_len_cm": {"value": 55, "was": 35}}} - walls[0]["options"]["back_len_cm"] = 45 # B05 가 바꿈 - assert _tables_from(walls, marks)["masonry_wet"]["rows"][1]["replaced"] == ["뒷길이"] - assert prune_marks(marks, walls, []) == {} - # B05 가 바꾼 뒤 다시 고치면 「고치기 전」은 B05 값(45). - _, again = apply_edits( - walls, [], types, [{"id": "w1", "key": "back_len_cm", "value": 60}], marks - ) - assert again["w1"]["back_len_cm"] == {"value": 60, "was": 45} - - -def _tables_from(walls: list[dict], marks: dict) -> dict: - body = build_summary(walls, [], structure_type_map(), {}, {}, marks) - return {table["type_id"]: table for table in body["tables"]} diff --git a/resources/tester/test_b08_structure_table_holes.py b/resources/tester/test_b08_structure_table_holes.py deleted file mode 100644 index e38067bd0..000000000 --- a/resources/tester/test_b08_structure_table_holes.py +++ /dev/null @@ -1,98 +0,0 @@ -"""구조물도 그림을 그리려다 드러난 **표 구멍** (2026-09-14 · PLAN 12장 남은 종류 그림). - -⚠ 셋 — 값이 서기는 하거나(떼흙막이) 조용히 0 이라 아무 시험도 안 잡던 자리. - ① 골막이·바닥막이는 개소당·㎡당 값인데 내역 단위가 「m · 연장 0」 → **수량 0, 내역에 안 섬** - ② 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음 - ③ 개거 「L형수로 H=0.2」 — 등록부 선택지 글자와 엔진 키가 달라 골라도 영영 안 섬 - ④ 기슭막이 사유 「돌 종류·뒷길이 칸이 없어」 — 칸이 선 뒤로 거짓 · 같은 말 두 번 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - - -def _table(type_id: str, start: float, end: float, **options) -> dict: - structure = { - "structure_id": type_id, - "type_id": type_id, - "start_m": start, - "end_m": end, - "options": options, - } - return build_table([structure], None) - - -def _sheet(table: dict) -> dict: - return build_standard_sheets(table)["sheets"][0] - - -def _unit(sheet: dict, name: str) -> float: - return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name) - - -def test_골막이는_개소당으로_서고_내역_수량이_1_개소() -> None: - table = _table( - "erosion_check", 50, 50, form="돌", height_m=1.5, top_length_m=4, bottom_length_m=2 - ) - item = table["structures"][0] - assert (item["billing_unit"], item["billing_quantity"]) == ("개소", 1.0) - sheet = _sheet(table) - assert sheet["unit_label"] == "개소당" and sheet["unpriced_rows"] == [] - row = next(r for r in build_handoff(unit_quantity_table=table)["work_items"] if r["unit"]) - assert (row["unit"], row["quantity"]) == ("개소", 1.0) # 전: m · 0.0 → 내역에 안 섬 - - -def test_떼흙막이는_개소당_값이_그대로_보인다() -> None: - sheet = _sheet(_table("soil_guard", 60, 70, form="떼", height_m=0.5, length_m=10)) - assert sheet["unit_label"] == "개소당" - assert abs(_unit(sheet, "떼") - 1.39) < 1e-9 # 전: 0.139 (m당으로 나뉨) - - -def test_바닥막이는_제곱미터당() -> None: - table = _table("bed_sill", 80, 80, form="돌붙임(찰)", area_m2=12, height_m=0.3) - sheet = _sheet(table) - assert sheet["unit_label"] == "㎡당" and sheet["billing_total"] == 12 - assert abs(_unit(sheet, "돌붙임") - 1.0) < 1e-9 and abs(_unit(sheet, "입적") - 0.45) < 1e-9 - - -def test_개거_L형수로는_등록부_글자로_선다() -> None: - registry = json.loads( - (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") - ) - choices = next( - option["choices"] - for item in registry["types"] - if item["type_id"] == "open_ditch" - for option in item["options"] - if option["key"] == "ditch_spec" - ) - for choice in choices: - item = _table("open_ditch", 0, 0, ditch_spec=choice, length_m=5)["structures"][0] - assert item["components"], (choice, item["notes"]) - concrete = next( - c - for c in _table("open_ditch", 0, 0, ditch_spec="L형수로 H=0.2", length_m=5)["structures"][ - 0 - ]["components"] - if c["name"] == "콘크리트" - ) - assert abs(concrete["amount"] - 0.1508 * 5) < 1e-9 - - -def test_기슭막이_사유에_없는_칸_이야기가_없다() -> None: - notes = _table("revetment", 0, 10, form="돌쌓기(찰)", height_m=2, length_m=10)["structures"][0][ - "notes" - ] - assert not any("칸이 없어" in note for note in notes) - assert any("뒷길이를 안 골라" in note for note in notes) # 같은 사실은 돌쌓기 한 줄이 말함 diff --git a/resources/tester/test_b08_structure_template_parity.py b/resources/tester/test_b08_structure_template_parity.py deleted file mode 100644 index 98a61a311..000000000 --- a/resources/tester/test_b08_structure_template_parity.py +++ /dev/null @@ -1,139 +0,0 @@ -"""찰쌓기 양식 = 지금 전개 — 값이 한 줄도 안 갈리는지 (2026-09-13, PLAN 3장 ② ③). - -판정 Ⓑ 「첫 양식 값은 지금 전개와 같게」. 양식(`resources/library_structure/masonry_wet.json`)을 -식 풀이기(TS → Node)로 풀어 낸 값이 `build_table`(돌쌓기 전개 + 기초잡석)과 같아야 함. -어긋나면 **양식이 틀린 것**. - -⚠ 전개는 부동소수, 양식은 분수 — 상대 1e-9 안이면 같은 값으로 봄. -⚠ 줄이 「안 서는」 경우(버림 안 넣음 · 채집석 구입)는 `when` 으로 — 안 선 줄은 전개에도 없어야 함. -⚠ 원문 「-」 칸은 일부러 다름(양식 `differs_from_engine`) — 여기서는 그 규격을 안 넣음. -""" - -from __future__ import annotations - -import itertools -import shutil -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( # noqa: E402 - load_template, - template_sheet, - template_vars, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - build_table, - face_slope_ratio, -) - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - - -def _structure(height: float, length: float, **options) -> dict: - return { - "structure_id": "s1", - "type_id": "masonry_wet", - "name": "돌쌓기(찰)", - "start_m": 100.0, - "end_m": 100.0 + length, - "length_m": length, - "height_m": height, - "options": {"height_m": height, "length_m": length, **options}, - } - - -CASES = [ - dict(height=h, length=10.0, back_len_cm=l3, stone_kind=kind, foundation=found) - for h, l3, kind, found in itertools.product( - (1.0, 1.5, 2.5, 3.5), - (35, 45, 55), - ("", "야면석·호박돌", "깬돌"), - ("기초유", "기초버림", ""), - ) -] + [ - # 사용자가 정한 칸 — 기울기 · 두께 · 실무 관행 계수 · 물구멍 면적 · 잡석 두께 · 짧은 연장 - dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"), - dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"), - dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"), - # 뒷길이 45 — 안 고르면 표준 하한 30㎝ 라 야면석 관측 무게(35·45·55)가 없어 양식만 오류 줄(명세 13장 Ⓐ) - dict( - height=2.0, - length=10.0, - back_len_cm=45, - stone_kind="야면석·호박돌", - stone_coeff_basis="실무 관행", - ), - # 뒷길이 안 고름 — 전개·양식이 같은 표준 하한(찰 ≤3m 30㎝, 브레인 판정 ㉮) - dict(height=2.0, length=10.0, stone_kind="깬돌", foundation="기초유"), - dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"), - dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3), - # 줄이 안 서는 갈래 — `when` - dict(height=2.0, length=10.0, blinding_concrete="안 넣음", foundation="기초유"), - dict(height=2.0, length=10.0, stone_supply="구입", stone_kind="깬돌"), - dict(height=2.0, length=10.0, blinding_concrete="안 넣음", stone_supply="구입"), - dict(height=2.0, length=10.0, rubble=0.0), - # 규격 칸 — 강도 · 물구멍 지름 - dict(height=2.0, length=10.0, fill_concrete_mpa="180", weep_hole_diameter_mm=75), -] - - -@pytest.mark.parametrize("case", CASES, ids=lambda c: "-".join(f"{v}" for v in c.values())) -def test_양식_값이_전개와_같다(case: dict) -> None: - case = dict(case) - rubble = case.pop("rubble", None) - height = case.pop("height") - length = case.pop("length") - structure = _structure(height, length, **case) - - # ⚠ 전개만 봄 — `build_table` 은 이제 양식으로 갈음하므로(④-2) 끄지 않으면 자기와 대조가 됨. - engine = build_table( - [structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble, use_templates=False - ) - components = engine["structures"][0]["components"] - - template = load_template("masonry_wet") - assert template is not None - slope, _basis = face_slope_ratio(structure["options"], wet=True, height_m=height) - settings = {} if rubble is None else {"rubble_base_thickness_m": rubble} - values = template_vars(template, structure, slope, settings) - solved = evaluate_sheets([template_sheet(template, values)]) - assert solved is not None, "Node 풀이가 안 돌았다" - assert [row["error"] for row in solved[0]] == [None] * len(solved[0]), solved[0] - rows = [row for row in solved[0] if not row["skipped"]] - - assert len(rows) == len(components), ( - [r["name"] for r in rows], - [c["name"] for c in components], - ) - # ⓘ 「실무 관행」 계수가 돌종류를 지우던 전개 결함은 2026-09-13 고침(브레인 판정) — 차이 없음. - for row, component in zip(rows, components): - # 명세 13장 Ⓒ — 돌 줄도 양식·전개 모두 이름 고정 「돌」 + 규격에 종류(2026-09-13). - assert row["name"] == component["name"] - assert row["spec"] == component["spec"], (row, component) - assert template["rows"][row["seq"] - 1]["destination"] == component["destination"], row - assert float(row["amount"]) == pytest.approx(component["amount"], rel=1e-9, abs=1e-12), ( - row["name"], - row["amount"], - component["amount"], - ) - - -def test_실무_관측값은_대안_후보로_보인다() -> None: - """판정 Ⓑ — 지금 전개가 기본, 실무 관측(물구멍 2.5㎡)은 칸 옆 대안 후보.""" - hole = load_template("masonry_wet")["vars"]["HOLE_AREA"] - assert hole["default"] == 2 - assert any(item["value"] == 2.5 for item in hole["candidates"]) - - -def test_표는_키_오름차순() -> None: - """근사 LOOKUP 의 전제(명세 13장) — 정확 일치만 쓰더라도 표 모양은 한 규칙으로.""" - for name, table in load_template("masonry_wet")["tables"].items(): - assert table["keys"] == sorted(table["keys"]), name - for column, cells in table["columns"].items(): - assert len(cells) == len(table["keys"]), (name, column) diff --git a/resources/tester/test_b08_structure_trench_rows.py b/resources/tester/test_b08_structure_trench_rows.py deleted file mode 100644 index dc514046f..000000000 --- a/resources/tester/test_b08_structure_trench_rows.py +++ /dev/null @@ -1,245 +0,0 @@ -"""구조물 터파기·되메우기·잔토가 인계 축에 오르나 (2026-09-08). - -⚠⚠ **빠뜨렸던 자리다.** 성분으로만 있고 아무도 안 받아 **내역서에 한 줄도 안 나갔다** — - 두께 식·기초 몫을 아무리 맞춰도 금액이 0원이었다(B09 매김에서 드러남). - -⚠ 겨누는 것 여섯 - ① 터파기가 **공종 줄로 선다** - ② 심도로 갈린다 — 품셈 9-13 이 18구분(토질 3 × 육상/용수 × 심도 3)이라서 - ③ ⚠ 토질·용수를 **지어내지 않는다** — 상위 코드 + 사유 - ④ 되메우기는 코드가 붙고 **금액이 선다** - ⑤ ⚠ 잔토는 **사토로 갈 몫**이라 내역에 안 서되 값과 사유가 실린다 - ⑥ 구조물이 없으면 줄도 없다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 - - -def 구조물(**options: object) -> dict: - return { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, - } - - -def 줄(**options: object) -> dict: - unit = build_table([구조물(**options)]) - rows = build_handoff(unit_quantity_table=unit)["work_items"] - return {row["name"]: row for row in rows} - - -def test_터파기가_공종_줄로_선다() -> None: - row = 줄(foundation="기초유")["구조물터파기"] - assert row["unit"] == "㎥" - assert row["quantity"] > 0 - assert row["work_item_code"] == "FP-09-13" - - -def test_심도로_갈린다() -> None: - """직고 2.5 + 기초 0.5 = 3.0m → `2~3m` 칸. 기초를 안 고르면 2.5m 라 같은 칸이다.""" - assert 줄(foundation="기초유")["구조물터파기"]["spec"] == "심도 2~3m" - 깊은 = build_table( - [ - { - "structure_id": "s9", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": { - "height_m": 3.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - } - ] - ) - rows = build_handoff(unit_quantity_table=깊은)["work_items"] - assert next(r for r in rows if r["name"] == "구조물터파기")["spec"] == "심도 3m 초과" - - -def test_토질을_못_가르면_그렇게_적는다() -> None: - """⚠ 지반 갈래 표가 없으면 「토질을 못 가름」이다 — 토사로 눅이지 않는다.""" - row = 줄(foundation="기초유")["구조물터파기"] - assert row["blocked_kind"] == "input_missing" - assert "토질을 못 가름" in row["blocked_reason"] - assert row["ground_class"] is None - assert row["in_bill"] is False - - -def test_토질은_측점_설계값에서_온다() -> None: - """⚠ 새 칸을 만들지 않는다 — 이미 저장되는 `design.ground_type` 을 읽는다.""" - unit = build_table( - [구조물(foundation="기초유")], - None, - None, - {0.0: "ripping_rock", 5.0: "ripping_rock", 10.0: "ripping_rock"}, - ) - row = next( - r - for r in build_handoff(unit_quantity_table=unit)["work_items"] - if r["name"] == "구조물터파기" - ) - assert row["ground_class"] == "암절취" - assert row["spec"] == "암절취 · 심도 2~3m" - # 남은 축은 용수 하나다 — 그것만 사유에 남는다. - assert "용수" in row["blocked_reason"] and "토질" not in row["blocked_reason"] - - -def test_걸친_측점이_섞이면_안_고른다() -> None: - """⚠ 다수결로 고르면 임의값이 금액으로 굳는다 — 암 단가가 몇 배다.""" - unit = build_table( - [구조물(foundation="기초유")], - None, - None, - {0.0: "soil", 5.0: "soil", 10.0: "blasting_rock"}, - ) - row = next( - r - for r in build_handoff(unit_quantity_table=unit)["work_items"] - if r["name"] == "구조물터파기" - ) - assert row["ground_class"] is None - assert "섞여" in row["blocked_reason"] - assert "토사 2곳" in row["blocked_reason"] and "발파암 1곳" in row["blocked_reason"] - - -def test_되메우기는_금액이_선다() -> None: - row = 줄(foundation="기초유")["되메우기"] - assert row["work_item_code"] == "FP-09-14-01" - assert row["in_bill"] is True - assert abs(row["quantity"] - (0.5 + 2.5) * 0.2 * 10.0) < 1e-9 - - -def test_잔토는_사토로_갈_몫이라_내역에_안_선다() -> None: - """⚠ 유토곡선이 세야 겹치지 않는다 — 다만 그 통로가 아직 없다는 사실을 사유로 남긴다.""" - row = 줄(foundation="기초유")["잔토처리"] - assert row["in_bill"] is False - # ⚠ 「통로가 없어 안 실림」이던 문구가 **바뀐 자리** — 2026-09-09 유토곡선이 그 값을 - # 받아 사토에 얹고 「사토 운반」 줄로 세우게 됐다. 사유도 그 사실로 옮겼다. - assert "사토" in row["in_bill_reason"] and "사토 운반" in row["in_bill_reason"] - assert row["quantity"] > 0 - - -def test_구조물이_없으면_줄도_없다() -> None: - rows = build_handoff(unit_quantity_table={"structures": []})["work_items"] - assert all(row["name"] not in {"구조물터파기", "되메우기", "잔토처리"} for row in rows) - - -def test_같은_토질_같은_심도는_한_줄이다() -> None: - """⚠ 근거 문구가 측점마다 달라 **두 줄로 갈리던 자리**(2026-09-08 실화면). - - 사람이 보는 문구가 다르다는 이유로 내역 줄이 쪼개지면 같은 공종이 두 번 선다. - """ - unit = build_table( - [ - { - "structure_id": "a", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": { - "height_m": 2.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - }, - { - "structure_id": "b", - "type_id": "masonry_wet", - "start_m": 100.0, - "end_m": 110.0, - "options": { - "height_m": 2.5, - "length_m": 10.0, - "back_len_cm": 45, - "foundation": "기초유", - }, - }, - ], - None, - None, - {0.0: "soil", 10.0: "soil", 105.0: "soil"}, - ) - rows = [ - r - for r in build_handoff(unit_quantity_table=unit)["work_items"] - if r["name"] == "구조물터파기" - ] - assert len(rows) == 1 - assert rows[0]["ground_class"] == "토사" - - -# ── 용수 축 · 18구분 (2026-09-09 사용자 확정 3차 ④) ────────────────── - - -def test_18구분_차례가_원문_그대로다() -> None: - """⚠ 한 칸 밀리면 **다른 지반·다른 심도 단가**가 붙는다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import trench_child_code - - assert trench_child_code("FP-09-13", "soil", "육상", "0~1m") == "FP-09-13-01" - assert trench_child_code("FP-09-13", "soil", "용수", "0~1m") == "FP-09-13-04" - assert trench_child_code("FP-09-13", "ripping_rock", "육상", "0~1m") == "FP-09-13-07" - assert trench_child_code("FP-09-13", "ripping_rock", "용수", "2~3m") == "FP-09-13-12" - assert trench_child_code("FP-09-13", "blasting_rock", "용수", "2~3m") == "FP-09-13-18" - - -def test_축이_없으면_상위_코드로_둔다() -> None: - """⚠ 3m 를 넘는 칸은 **원문에 없다** — 지어내지 않는다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import trench_child_code - - assert trench_child_code("FP-09-13", "soil", "", "0~1m") is None - assert trench_child_code("FP-09-13", None, "육상", "0~1m") is None - assert trench_child_code("FP-09-13", "soil", "육상", "3m 초과") is None - - -def 물줄(water: str | None, ground: str = "ripping_rock") -> dict: - unit = build_table( - [구조물(foundation="기초유")], - None, - None, - {0.0: ground, 10.0: ground}, - ) - rows = build_handoff(unit_quantity_table=unit, structure_trench_water=water)["work_items"] - return next(row for row in rows if row["name"] == "구조물터파기") - - -def test_용수를_정하면_자식_코드로_내려가_금액이_선다() -> None: - row = 물줄("육상") - assert row["work_item_code"] == "FP-09-13-09" # 육상 암절취 2~3m - assert row["in_bill"] is True and row["blocked_kind"] is None - - -def test_통상값이라는_사실이_줄에_적힌다() -> None: - """⚠ 사용자 확정이 아니라 **통상값**이다 — 안 적으면 뒤집을 근거가 사라진다.""" - assert "통상값" in 물줄("육상")["spec_detail"] - - -def test_용수로_뒤집으면_코드가_옮겨_간다() -> None: - assert 물줄("용수")["work_item_code"] == "FP-09-13-12" - - -def test_안_정하면_상위_코드로_막힌다() -> None: - row = 물줄(None) - assert row["work_item_code"] == "FP-09-13" - assert row["in_bill"] is False and "용수" in row["blocked_reason"] - - -def test_상태_사실이_값_옆에_적힌다() -> None: - """⚠ 코드 주석에만 두면 화면에서 안 보인다 — 되메우기·잔토 줄에 함께 실린다.""" - rows = 줄(foundation="기초유") - for name in ("되메우기", "잔토처리"): - assert "다짐부피 ÷ C" in rows[name]["spec_detail"], name diff --git a/resources/tester/test_b08_structure_unit_price.py b/resources/tester/test_b08_structure_unit_price.py deleted file mode 100644 index 10246d275..000000000 --- a/resources/tester/test_b08_structure_unit_price.py +++ /dev/null @@ -1,330 +0,0 @@ -"""구조물도 하단 일위대가 표 틀 (2026-09-13, PLAN 3장 하단 ①). - -겨누는 것 - ① 줄 금액 = 원단위 줄 수량 × B09 단가(3분할) · 금액란 0.1원 버림 · 계금 1원 버림 - ② 못 푼 줄은 0 이 아니라 막힘 + 까닭 · 막힌 줄이 있으면 미완 - ③ 원단위 줄이 안 선 장(버림 「안 넣음」)은 일위대가 줄도 안 섬 — 막힘이 아님 - ④ 단위가 다르거나 하위 양식을 못 찾으면 막힘 - ⑤ 창구가 실제 단가표로 찰쌓기 갈래를 찾아 값을 냄 - ⑥ 재귀 — 하위 구조물 일위대가(B-AX-ST)를 제원으로 풀어 넣음 · 돌면 막힘 · 5단까지 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path -from types import SimpleNamespace - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 -from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table # noqa: E402 -from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402 - -PROJECT_ID = "44444444-4444-4444-4444-444444444444" -SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" - - -class FakeBook: - def __init__(self, titles: dict[str, tuple[str, str, Money3]]): - self.titles = titles - - def title(self, code: str) -> SimpleNamespace: - if code not in self.titles: - raise PriceBookError(f"단가표에 없는 코드입니다: {code}") - name, unit, _money = self.titles[code] - return SimpleNamespace(name=name, spec="", unit=unit) - - def resolve(self, code: str) -> Money3: - return self.titles[code][2] if code in self.titles else self.title(code) - - -BOOK = FakeBook( - { - "B-FP-13-04-05#55cm이하": ( - "찰쌓기(장비)", - "㎡", - Money3(Decimal("100.05"), Decimal("200.07"), Decimal("30")), - ), - "B-FP-12-25": ("기초잡석", "㎥", Money3(Decimal("1000"), Decimal("500"), Decimal("0"))), - "B-FP-99-01": ("다른 단위", "m", Money3(Decimal("1"))), - } -) -TEMPLATE = { - "code": "AX-ST-56e81a2c", - "name": "돌쌓기(찰)", - "unit_price": { - "rows": [ - { - "seq": 1, - "name": "돌쌓기", - "from_row": 1, - "work_item_code": "FP-13-04-05", - "variant_from": "L3", - }, - {"seq": 2, "name": "모르터", "from_row": 8}, - {"seq": 3, "name": "기초잡석", "from_row": 15, "work_item_code": "FP-12-25"}, - ] - }, -} - - -def _variant(code: str, value: str) -> str | None: - return "B-FP-13-04-05#55cm이하" if (code, value) == ("FP-13-04-05", "45") else None - - -def _sheet(**rows: dict) -> dict: - base = { - 1: {"no": 1, "unit": "㎡", "unit_amount": 2.61}, - 8: {"no": 8, "unit": "㎥", "unit_amount": 0.02349}, - 15: {"no": 15, "unit": "㎥", "unit_amount": 0.4}, - } - for key, value in rows.items(): - base[int(key[1:])] = {**base[int(key[1:])], **value} - return {"rows": list(base.values()), "formula_sheet": {"vars": {"L3": 45}}, "billing_unit": "m"} - - -def test_줄_금액은_수량_곱하기_단가이고_못_푼_줄은_막힘() -> None: - table = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant) - rows = {row["seq"]: row for row in table["rows"]} - stone = rows[1] - assert stone["ref_code"] == "B-FP-13-04-05#55cm이하" and stone["name"] == "찰쌓기(장비)" - # 2.61 × 100.05 = 261.1305 → 0.1원 미만 버림 261.1 - assert stone["material"] == pytest.approx(261.1) - assert stone["labor"] == pytest.approx(522.1) # 522.1827 - assert stone["expense"] == pytest.approx(78.3) # 78.3 - assert rows[2]["reason"] == "공종 코드 미정" and "total" not in rows[2] - assert rows[3]["total"] == pytest.approx(600.0) # 0.4 × 1500 - assert table["blocked"] == 1 and table["complete"] is False - # 계금 — 줄 금액 합(261.1+522.1+78.3+600) 1원 미만 버림 - assert table["total"] == pytest.approx(1461.0) - assert table["code"] == "B-AX-ST-56e81a2c" - - -def test_원단위_줄이_안_선_장은_일위대가_줄도_안_섬() -> None: - sheet = _sheet(r15={"skipped": True, "unit_amount": None, "reason": "조건이 거짓: BLIND"}) - rows = {row["seq"]: row for row in unit_price_table(TEMPLATE, sheet, BOOK, _variant)["rows"]} - assert rows[3]["skipped"] is True and rows[3]["reason"].startswith("조건이 거짓") - - -def test_갈래를_못_찾거나_단위가_다르거나_하위_구조물이면_막힘() -> None: - sheet = _sheet() - sheet["formula_sheet"]["vars"]["L3"] = 60 - template = { - "code": "AX-ST-00000000", - "unit_price": { - "rows": [ - {"seq": 1, "from_row": 1, "work_item_code": "FP-13-04-05", "variant_from": "L3"}, - {"seq": 2, "from_row": 1, "ref_code": "B-FP-99-01"}, - {"seq": 3, "quantity": 1, "ref_code": "B-AX-ST-12345678"}, - ] - }, - } - rows = {row["seq"]: row for row in unit_price_table(template, sheet, BOOK, _variant)["rows"]} - assert "갈래를 못 찾음" in rows[1]["reason"] - assert rows[2]["reason"].startswith("단위가 다름") - assert "못 찾음" in rows[3]["reason"] # 하위 양식이 프로젝트·기본에 없음 - - -def _leaf(code: str, next_code: str | None = None) -> dict: - """하위 양식 — 면적 = H×L×2(㎥) 한 줄. 다음 코드가 있으면 그것을 1m 부름.""" - unit_rows = [{"seq": 1, "name": "잡석", "from_row": 1, "work_item_code": "FP-12-25"}] - if next_code: - unit_rows = [ - { - "seq": 1, - "quantity": 1, - "unit": "m", - "ref_code": f"B-{next_code}", - "sub_vars": {"H": 1}, - } - ] - return { - "code": code, - "name": f"하위 {code[-2:]}", - "vars": {"H": {"source": "height_m"}, "L": {"source": "length_m"}}, - "tables": {}, - "rows": [ - { - "seq": 1, - "name": "면적", - "formula": "H*L*2", - "unit": "㎥", - "destination": "unit_price", - "rounding": {"mode": "none", "digits": 0}, - } - ], - "unit_price": {"unit": "m", "rows": unit_rows}, - } - - -def _parent(ref: str, sub_vars: dict | None = None) -> dict: - row = {"seq": 1, "name": "하위 부름", "quantity": 3, "unit": "m", "ref_code": ref} - if sub_vars is not None: - row["sub_vars"] = sub_vars - return {"code": "AX-ST-a0000000", "unit_price": {"rows": [row]}} - - -def test_하위_구조물_일위대가를_제원으로_풀어_윗_표에_넣는다() -> None: - """PLAN 3장 재귀 — 하위 양식 1m = 면적 3㎥ × 잡석(1000·500) · 윗 표 3m.""" - library = [_leaf("AX-ST-c0000001")] - table = unit_price_table( - _parent("B-AX-ST-c0000001", {"H": 1.5}), _sheet(), BOOK, _variant, library - ) - row = table["rows"][0] - assert row["name"] == "하위 01" and row["reason"] == "" - assert row["material"] == pytest.approx(9000.0) and row["labor"] == pytest.approx(4500.0) - assert table["complete"] is True and table["total"] == pytest.approx(13500.0) - - # 제원을 안 주면 막힘 — 0 으로 풀지 않음. - missing = unit_price_table(_parent("B-AX-ST-c0000001"), _sheet(), BOOK, _variant, library) - assert "제원이 비어 있음: H" in missing["rows"][0]["reason"] - - -def test_하위_일위대가가_돌면_막힘() -> None: - library = [_leaf("AX-ST-c0000002", next_code="AX-ST-c0000002")] - table = unit_price_table( - _parent("B-AX-ST-c0000002", {"H": 1}), _sheet(), BOOK, _variant, library - ) - assert "미완" in table["rows"][0]["reason"] and table["complete"] is False - - -def test_재귀는_5단까지() -> None: - """맨 윗 표 1단 + 하위 넷 = 5단은 섬 · 하위 다섯이면 6단이라 막힘.""" - codes = [f"AX-ST-d000000{i}" for i in range(1, 6)] - library = [ - _leaf(code, codes[i + 1] if i + 1 < len(codes) else None) for i, code in enumerate(codes) - ] - five = unit_price_table(_parent(f"B-{codes[1]}", {"H": 1}), _sheet(), BOOK, _variant, library) - assert five["complete"] is True # 윗 표 → d2 → d3 → d4 → d5(잎) = 5단 - six = unit_price_table(_parent(f"B-{codes[0]}", {"H": 1}), _sheet(), BOOK, _variant, library) - assert six["complete"] is False # 윗 표 → d1 → … → d5 = 6단 - - -def test_수동_단가는_막힌_줄을_세우고_미확정으로_센다() -> None: - """PLAN 3장 ③ — 모르터(코드 미정)에 수동 단가 · 0.02349㎥ × 재료 80,000 = 1,879.2.""" - manual = { - "2": { - "material": 80000, - "labor": 0, - "expense": 0, - "source": "견적", - "entered_at": "2026-09-13", - } - } - table = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant, (), manual) - mortar = next(row for row in table["rows"] if row["seq"] == 2) - assert mortar["manual"] is True and mortar["manual_source"] == "견적" - assert mortar["material"] == pytest.approx(1879.2) and mortar["reason"] == "" - assert table["blocked"] == 0 and table["complete"] is True and table["unconfirmed"] == 1 - # 단가표에 값이 있는 줄도 수동 단가가 이김 — 그래도 미확정. - manual["1"] = {"material": 1, "labor": 0, "expense": 0} - again = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant, (), manual) - assert again["rows"][0]["total"] == pytest.approx(2.6) and again["unconfirmed"] == 2 - - -def test_줄_조합_저장본은_양식과_같으면_지우고_수동_단가는_남은_줄만() -> None: - from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ( - save_manual, - save_rows, - with_rows, - ) - - template = {"type_id": "masonry_wet", **TEMPLATE} - rows = TEMPLATE["unit_price"]["rows"] - edited = [*rows[:2], {"seq": 4, "name": "추가", "quantity": 1.0, "ref_code": "B-FP-12-25"}] - merged, changed = save_rows({"other": []}, "masonry_wet", template, edited) - assert changed and merged["masonry_wet"] == edited and "other" in merged - back, changed = save_rows(merged, "masonry_wet", template, [dict(row) for row in rows]) - assert changed and back == {"other": []} - assert with_rows(template, edited)["unit_price"]["rows"] == edited - assert with_rows({"code": "x"}, None)["unit_price"]["rows"] == [] - - price = {"material": 10.0, "labor": 0.0, "expense": 0.0, "source": "견적"} - first = save_manual({}, "masonry_wet", edited, {"2": price, "3": price}, "2026-09-01") - assert list(first["masonry_wet"]) == ["2"] # 뺀 줄(3)의 값은 안 남음 - same = save_manual(first, "masonry_wet", edited, {"2": price}, "2026-09-13") - assert same["masonry_wet"]["2"]["entered_at"] == "2026-09-01" # 값이 같으면 옛 날짜 - moved = save_manual( - first, "masonry_wet", edited, {"2": {**price, "material": 11.0}}, "2026-09-13" - ) - assert moved["masonry_wet"]["2"]["entered_at"] == "2026-09-13" - assert save_manual(first, "masonry_wet", edited, {}, "2026-09-13") == {} - - -def test_고르개는_갈래별로_낱말이_모두_든_항목과_단가를_낸다() -> None: - from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles - from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind, PriceTitle - - book = PriceBook() - slots = [None] * 5 - book.add_title( - PriceTitle("M-001", PriceKind.MATERIAL, "시멘트", "40kg", "포", [*slots, Decimal(5000)]) - ) - book.add_title(PriceTitle("M-002", PriceKind.MATERIAL, "시멘트 모르터", "1:3", "m3")) - book.add_title(PriceTitle("B-FP-01", PriceKind.UNIT_PRICE, "모르터 비비기", "", "m3")) - found = search_titles(book, "시멘트", "resource") - assert [item["code"] for item in found] == ["M-001", "M-002"] - assert found[0]["price"] == pytest.approx(5000.0) and found[1]["price"] is None - assert found[1]["unit"] == "㎥" - assert [item["code"] for item in search_titles(book, "모르터 1:3", "resource")] == ["M-002"] - assert [item["code"] for item in search_titles(book, "모르터", "work")] == ["B-FP-01"] - assert search_titles(book, " ", "work") == [] - - -@pytest.fixture() -def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - root = tmp_path / "project" - root.mkdir() - wall = StructureInstance.model_validate( - { - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "back_len_cm": 45}, - } - ) - save_structures(str(root), [wall], base_revision=0) - - async def fake_root(project_id): - return str(root) - - async def no_route(project_id): - return {} - - async def real_build(project_id): - from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - return cached_build() - - monkeypatch.setattr(router_module, "_project_root", fake_root) - monkeypatch.setattr(router_module, "_price_build", real_build) - monkeypatch.setattr(material_module, "_section_modes", no_route) - monkeypatch.setattr(material_module, "_ground_types", no_route) - app = FastAPI() - app.include_router(router_module.router) - return TestClient(app) - - -def test_창구가_실제_단가표로_찰쌓기_갈래를_찾는다(client: TestClient) -> None: - sheet = client.get(SHEETS).json()["sheets"][0] - response = client.get(f"{SHEETS}/unit-price", params={"sheet_key": sheet["key"]}) - assert response.status_code == 200, response.text - table = response.json()["unit_price"] - rows = {row["seq"]: row for row in table["rows"]} - assert rows[1]["ref_code"] == "B-FP-13-04-05#55cm이하" - assert rows[1]["labor"] > 0 and rows[1]["quantity"] == pytest.approx(2.5 * 1.09**0.5) - assert rows[3]["ref_code"] == "B-FP-12-25" - # 모르터 — 산림 품셈에 배합 절이 없어 AX-WK 모르타르 배합 1:3(2026-09-13) - assert rows[2]["ref_code"] == "B-AX-WK-c0842a0d" and rows[2]["labor"] > 0 - assert table["complete"] is True diff --git a/resources/tester/test_b08_structure_unit_price_edit.py b/resources/tester/test_b08_structure_unit_price_edit.py deleted file mode 100644 index c80f9de0d..000000000 --- a/resources/tester/test_b08_structure_unit_price_edit.py +++ /dev/null @@ -1,181 +0,0 @@ -"""구조물도 일위대가 줄 고치기 창구 (2026-09-13, PLAN 3장 ③). - -겨누는 것 - ① 줄 더하기·빼기를 저장하면 표가 그 조합으로 서고, 양식대로 보내면 저장본이 지워짐 - ② 수동 단가는 막힌 줄을 세우고 「미확정」으로 셈 · 넣은 날짜는 서버가 붙임 - ③ ⛔ 저장 자리는 둘 — [내 라이브러리에 저장]은 줄 조합만 싣고 수동 단가는 안 실음 - ④ 다른 양식을 가져오면 그 종류의 줄 조합·수동 단가가 비워짐 - ⑤ 고르개가 실제 단가표에서 품셈·자원을 찾음 · 차례가 겹치면 거절 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 -import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 -from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 -from common_util.common_util_auth import verify_session # noqa: E402 -from common_util.common_util_project_settings import quantity_settings # noqa: E402 - -PROJECT_ID = "55555555-5555-5555-5555-555555555555" -SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" - - -@pytest.fixture() -def project(tmp_path: Path) -> Path: - root = tmp_path / "project" - root.mkdir() - wall = StructureInstance.model_validate( - { - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "back_len_cm": 45}, - } - ) - save_structures(str(root), [wall], base_revision=0) - return root - - -@pytest.fixture() -def client(project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: - async def fake_root(project_id): - return str(project) - - async def no_route(project_id): - return {} - - async def real_build(project_id): - from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - return cached_build() - - monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage")) - monkeypatch.setattr(router_module, "_project_root", fake_root) - monkeypatch.setattr(router_module, "_price_build", real_build) - monkeypatch.setattr(material_module, "_section_modes", no_route) - monkeypatch.setattr(material_module, "_ground_types", no_route) - app = FastAPI() - app.include_router(router_module.router) - app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} - return TestClient(app) - - -def _key(client: TestClient) -> str: - return client.get(SHEETS).json()["sheets"][0]["key"] - - -def _table(client: TestClient, key: str) -> dict: - response = client.get(f"{SHEETS}/unit-price", params={"sheet_key": key}) - assert response.status_code == 200, response.text - return response.json() - - -def test_줄을_고치고_수동_단가를_넣으면_저장_자리가_둘로_갈린다( - client: TestClient, project: Path, tmp_path: Path -) -> None: - key = _key(client) - before = _table(client, key) - assert before["editor"]["edited"] is False and before["unit_price"]["unconfirmed"] == 0 - rows = before["editor"]["rows"] - # 기초잡석(3) 빼고 · 고정 수량 1.5 줄 더하기 · 모르터(2)에 수동 단가. - edited = [ - rows[0], - rows[1], - {"seq": 4, "name": "추가 잡석", "quantity": 1.5, "ref_code": "B-FP-12-25"}, - ] - saved = client.put( - f"{SHEETS}/unit-price", - json={ - "sheet_key": key, - "rows": edited, - "manual_prices": { - "2": {"material": 80000, "source": "견적 3곳 평균"}, - "3": {"labor": 1}, - }, - }, - ) - assert saved.status_code == 200, saved.text - assert saved.json()["edited"] is True and saved.json()["manual_prices"] == 1 - - after = _table(client, key) - table = after["unit_price"] - assert [row["seq"] for row in table["rows"]] == [1, 2, 4] - mortar = table["rows"][1] - assert mortar["manual"] is True and mortar["manual_entered_at"] - assert table["unconfirmed"] == 1 and table["complete"] is True - assert table["rows"][2]["quantity"] == pytest.approx(1.5) and table["rows"][2]["total"] > 0 - - # 저장 자리 둘 — 줄 조합은 종류별, 수동 단가는 프로젝트 칸. - settings = quantity_settings(str(project)) - assert [row["seq"] for row in settings["structure_unit_price_rows"]["masonry_wet"]] == [1, 2, 4] - assert settings["structure_manual_prices"]["masonry_wet"]["2"]["source"] == "견적 3곳 평균" - - # ⛔ 내 라이브러리에는 줄 조합만 — 수동 단가 흔적이 없음. - mine = client.put(f"{SHEETS}/library/personal", json={"sheet_key": key}) - assert mine.status_code == 200, mine.text - folder = tmp_path / "storage" / "7" / "42" / "library" - item = json.loads((folder / f"{mine.json()['code']}.json").read_text(encoding="utf-8")) - assert [row["seq"] for row in item["unit_price"]["rows"]] == [1, 2, 4] - assert "80000" not in json.dumps(item) and "견적" not in json.dumps(item) - - # 양식대로 되돌리면 줄 조합 저장본이 지워짐. - back = client.put( - f"{SHEETS}/unit-price", - json={"sheet_key": key, "rows": after["editor"]["default_rows"], "manual_prices": {}}, - ) - assert back.json()["edited"] is False - assert "masonry_wet" not in quantity_settings(str(project))["structure_unit_price_rows"] - - -def test_다른_양식을_가져오면_줄_조합과_수동_단가가_비워진다( - client: TestClient, project: Path -) -> None: - key = _key(client) - rows = load_template("masonry_wet")["unit_price"]["rows"][:2] - client.put( - f"{SHEETS}/unit-price", - json={"sheet_key": key, "rows": rows, "manual_prices": {"2": {"material": 1}}}, - ) - taken = client.put( - f"{SHEETS}/library/import", - json={ - "type_id": "masonry_wet", - "tier": "program", - "code": load_template("masonry_wet")["code"], - }, - ) - assert taken.status_code == 200, taken.text - assert ( - taken.json()["cleared_unit_price_rows"] is True - and taken.json()["cleared_manual_prices"] == 1 - ) - settings = quantity_settings(str(project)) - assert "masonry_wet" not in settings["structure_unit_price_rows"] - assert "masonry_wet" not in settings["structure_manual_prices"] - - -def test_고르개와_차례_겹침_거절(client: TestClient) -> None: - found = client.get(f"{SHEETS}/price-search", params={"q": "FP-12-25", "kind": "work"}) - assert found.status_code == 200, found.text - assert any(item["code"] == "B-FP-12-25" for item in found.json()["items"]) - resource = client.get(f"{SHEETS}/price-search", params={"q": "보통인부", "kind": "resource"}) - assert resource.json()["items"], "노임 보통인부가 자원 갈래에 있어야 함" - - twice = [{"seq": 1, "name": "a", "quantity": 1}, {"seq": 1, "name": "b", "quantity": 1}] - bad = client.put(f"{SHEETS}/unit-price", json={"sheet_key": _key(client), "rows": twice}) - assert bad.status_code == 422 diff --git a/resources/tester/test_b08_subgrade_compaction.py b/resources/tester/test_b08_subgrade_compaction.py deleted file mode 100644 index 562c765e7..000000000 --- a/resources/tester/test_b08_subgrade_compaction.py +++ /dev/null @@ -1,46 +0,0 @@ -"""노체다짐 칸 — 기본 꺼짐 · 켜면 토공집계 별도 줄 · 수량 = 성토량 (2026-09-13 판정).""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, build_rows # noqa: E402 - -TOTALS = {"fill_volume_m3": 1234.5} - - -def _groups(enabled: bool) -> dict: - rows = build_rows(SummaryInput(earthwork_totals=TOTALS, subgrade_compaction_enabled=enabled)) - return {row.group: row for row in rows} - - -def test_끄면_줄_자체가_없다() -> None: - assert "노체다짐" not in _groups(False) - - -def test_켜면_성토량으로_선다_근거가_비고에() -> None: - row = _groups(True)["노체다짐"] - assert (row.unit, row.amount, row.in_bill) == ("㎥", 1234.5, True) - assert "[주]⑤" in row.note and "층다짐이 필요한 경우" in row.note - assert "굴착기" not in row.spec # 굴착기는 성토(포설) 줄이 셈 — 두 번 안 셈 - - -def test_인계가_9_16_2_로_잇고_갈래를_안_싣는다() -> None: - from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table - from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff - - table = build_table(SummaryInput(earthwork_totals=TOTALS, subgrade_compaction_enabled=True)) - handed = {r["name"]: r for r in build_handoff(summary_table=table)["work_items"]} - row = handed["노체다짐"] - assert (row["work_item_code"], row["quantity"], row["in_bill"]) == ("FP-09-16-02", 1234.5, True) - assert row["variant_value"] is None and row["blocked_kind"] is None - - -def test_설정_기본은_꺼짐() -> None: - from common_util.common_util_project_settings import default_settings - - assert default_settings()["quantity"]["subgrade_compaction_enabled"] is False diff --git a/resources/tester/test_b08_template_keep_earthwork.py b/resources/tester/test_b08_template_keep_earthwork.py deleted file mode 100644 index a93f84fae..000000000 --- a/resources/tester/test_b08_template_keep_earthwork.py +++ /dev/null @@ -1,93 +0,0 @@ -"""㉯ 양식이 안 품은 토공·버림 성분은 전개 값을 남김 (2026-09-14 브레인 판정 · 목록으로 못박음). - -실측(936be972): STmate 호표(고정형)를 가져오니 전개 성분이 통째로 갈음돼 구조물터파기 85.25→25㎥ -(−6,037,712원) · 되메우기·잔토·버림 타설도 줄었음. STmate 호표는 원래 토공을 안 품음(토공 따로 셈). -규칙 — `KEEP_ENGINE_COMPONENTS` 목록에 있는 이름은 **양식에 그 이름 줄이 없을 때** 전개 값을 보존, -목록에 없는 성분은 양식으로 갈음. 양식형(돌쌓기 찰)은 그 줄을 스스로 품어 겹치지 않음. -""" - -from __future__ import annotations - -from collections import Counter - -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance -from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item -from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( - KEEP_ENGINE_COMPONENTS, - apply_templates, -) -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table - -HOPYO = { - "no": 6, - "source_code": "B00010", - "name": "기슭막이", - "spec": "H=2.0m", - "unit": "M", - "basis": [], - "contract_rows": 0, - "rows": [ - { - "name": "깬잡석찰쌓기", - "spec": "L3=45", - "amount": 2.09, - "unit": "M2", - "remark": "", - "source_code": "", - }, - ], -} -WALL = StructureInstance.model_validate( - { - "structure_id": "a", - "type_id": "masonry_wet", - "placement": "interval", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "back_len_cm": 45, "foundation": "기초유"}, - } -).model_dump() -NAMES = {"masonry_wet": "돌쌓기(찰)"} - - -def _fixed() -> dict: - return { - **recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"), - "code": "AX-ST-0000abcd", - } - - -def test_목록이_코드에_못박혀_있다() -> None: - assert set(KEEP_ENGINE_COMPONENTS) >= {"터파기", "되메우기", "잔토처리", "버림콘크리트"} - # ② 원문 빈도(벽·돌쌓기 호표 57) — 기초잡석 0/57 · 채집석(사토 공제 부피)은 호표 채집 줄(품)과 다른 것 - assert {"기초잡석", "채집석"} <= set(KEEP_ENGINE_COMPONENTS) - - -def test_고정형을_가져와도_토공_버림_성분은_남는다() -> None: - engine = build_table([WALL], NAMES, {}, {}, None, use_templates=False)["structures"][0] - fixed = build_table([WALL], NAMES, {}, {}, None, structure_templates={"masonry_wet": _fixed()}) - components = {c["name"]: c for c in fixed["structures"][0]["components"]} - engine_names = {c["name"] for c in engine["components"]} - assert {"기초잡석", "채집석"} <= engine_names # 시험 벽에서 둘 다 서야 보존을 잼 - for name in ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석"): - before = next(c for c in engine["components"] if c["name"] == name) - assert components[name]["amount"] == before["amount"], name - assert "깬잡석찰쌓기" in components and "돌" not in components # 목록 밖은 양식으로 갈음 - - -def test_양식형은_그_줄을_스스로_품어_겹치지_않는다() -> None: - table = build_table([WALL], NAMES, {}, {}, None) - counts = Counter(c["name"] for c in table["structures"][0]["components"]) - assert all(count == 1 for count in counts.values()), counts - - -def test_구조물도_장에도_남긴_줄이_보인다() -> None: - templates = {"masonry_wet": _fixed()} - table = build_table([WALL], NAMES, {}, {}, None, structure_templates=templates) - payload = build_standard_sheets(table, {}) - apply_templates(payload, {}, templates) - names = [row["name"] for row in payload["sheets"][0]["rows"]] - assert names[0] == "깬잡석찰쌓기" and "터파기" in names and "버림콘크리트" in names - numbers = [row["no"] for row in payload["sheets"][0]["rows"]] - assert len(numbers) == len(set(numbers)) # 남긴 줄 차례가 양식 줄과 안 겹침 diff --git a/resources/tester/test_b08_topsoil_haul.py b/resources/tester/test_b08_topsoil_haul.py deleted file mode 100644 index 59f1b0605..000000000 --- a/resources/tester/test_b08_topsoil_haul.py +++ /dev/null @@ -1,164 +0,0 @@ -"""표토 운반·적치 — 법이 요구하는데 제거만 세고 있던 줄 (2026-09-09 · 2026-09-13 판정 「실무대로」). - -⚠⚠ 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): - 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 - 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」 - ⇒ 제거(9-15)만 세면 **운반이 빠진다.** - -⚠ 겨누는 것 - ① 줄이 **선다** — 값이 없어도 목록에 남는다 - ② 부피는 **제거 면적(㎡) × 두께** — 제거는 ㎡ 로 서고 두께는 운반 부피에만 쓴다 - ③ 거리는 **설계 입력** · 제거 품(9-15-2)이 **20m 압토**를 품어 **넘는 몫만** 운반으로 셈 - ④ 제거가 안 서거나 두께가 비면 운반도 안 선다 — 0 으로 때우지 않는다 - ⑤ 법 문구가 **사유에 적힌다** — 왜 세는지 화면에서 보여야 한다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 - STATUS_COUNTED_ELSEWHERE, - STATUS_NEEDS_INPUT, - STATUS_READY, - build_table, - preparation_rows, -) - -사면 = {"face_dressing_fill": 600.0, "face_dressing_cut": 400.0} -노면 = 600.0 # 노면 600 + 절토 사면 400 = 제거 1,000㎡ - - -def 줄(thickness: float | None, distance: float | None, road: float | None = 노면) -> dict: - rows = preparation_rows(사면, [], thickness, distance, None, road) - return {row["item"]: row for row in rows} - - -def test_줄이_선다() -> None: - assert "표토 운반·적치" in 줄(None, None) - - -def test_부피는_제거_면적_곱하기_두께_거리는_20m_넘는_몫() -> None: - rows = 줄(0.2, 300.0) - assert rows["표토제거"]["amount"] == 1000.0 - 운반 = rows["표토 운반·적치"] - assert 운반["amount"] == 1000.0 * 0.2 and 운반["status"] == STATUS_READY - assert 운반["work_item_code"] == "FP-10-12" - assert 운반["haul_distance_m"] == 280.0 # 300 − 압토 20 - assert "20m 를 넘는 몫" in 운반["reason"] - - -def test_20m_이내면_제거_품에_들어_따로_안_셈() -> None: - 운반 = 줄(0.2, 15.0)["표토 운반·적치"] - assert 운반["amount"] is None and 운반["status"] == STATUS_COUNTED_ELSEWHERE - assert 운반["reference_amount"] == 200.0 - - -def test_거리를_안_넣으면_막고_사유() -> None: - """⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다.""" - row = 줄(0.2, None)["표토 운반·적치"] - # 거리만 넣으면 서는 줄 — 「근거 없음」이 아니라 「입력이 필요함」(2026-09-14 ㉴). - assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT - assert "운반거리가 아직 입력되지 않았습니다" in row["reason"] - assert row["reference_amount"] == 200.0 # 값을 버리지 않는다 - - -def test_두께가_비거나_제거가_안_서면_운반도_안_선다() -> None: - no_thickness = 줄(None, 300.0)["표토 운반·적치"] - assert no_thickness["amount"] is None and "T=0.2m" in no_thickness["reason"] - no_removal = 줄(0.2, 300.0, road=None)["표토 운반·적치"] - assert no_removal["amount"] is None and "제거 면적이 아직 안 서서" in no_removal["reason"] - - -def test_법_문구가_사유에_적힌다() -> None: - for thickness, distance in ((None, None), (0.2, None), (0.2, 300.0), (0.2, 10.0)): - reason = 줄(thickness, distance)["표토 운반·적치"]["reason"] - assert "별표2" in reason and "운반하고 쌓아두어야" in reason - - -# ── 뿌리 적재·운반 — 품셈이 네 단계로 둔 자리 (2026-09-09) ────────────── - - -def test_뿌리_뒤_단계_두_줄이_선다() -> None: - rows = 줄(0.2, 300.0) - assert "뿌리 적재" in rows and "뿌리 운반" in rows - - -def test_적재는_9_20_2_운반은_덤프() -> None: - """⭐ 2026-09-09 확정 5차 4번 — 「뿌리 운반은 **덤프**」. 물량은 부피가 없어 안 선다.""" - rows = 줄(0.2, 300.0) - assert rows["뿌리 적재"]["work_item_code"] == "FP-09-20-02" - assert rows["뿌리 운반"]["work_item_code"] == "FP-10-12" - assert rows["뿌리 운반"]["amount"] is None - assert "부피" in rows["뿌리 운반"]["reason"] - - -def test_적재는_면적으로_서고_운반은_못_선다() -> None: - 사면_ = {"tree_removal_fill": 600.0, "tree_removal_cut": 400.0} - rows = {row["item"]: row for row in preparation_rows(사면_, [], 0.2, 300.0)} - assert rows["뿌리 적재"]["amount"] == 1000.0 - assert rows["제근·뿌리다듬기"]["amount"] == 1000.0 # 같은 밑수로 함께 섬 - assert rows["뿌리 운반"]["amount"] is None - assert rows["뿌리 운반"]["reference_amount"] == 1000.0 - - -def test_운반이_갈래를_실어_보낸다() -> None: - """⚠ `FP-10-12` 는 **부모**다 — 갈래(토사)를 안 보내면 B09 가 「후보 3건」에서 멈춘다.""" - from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff - - table = build_table( - slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, - topsoil_thickness_m=0.2, - topsoil_haul_distance_m=500.0, - road_surface_area_m2=12000.0, - ) - handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]} - 운반 = handed["표토 운반·적치"] - assert 운반["in_bill"] is True - assert (운반["variant_axis"], 운반["variant_value"]) == ("ground_class", "토사") - 제거 = handed["표토제거"] - assert (제거["unit"], 제거["quantity"], 제거["in_bill"]) == ("㎡", 21000.0, True) - - -def test_노면_면적은_노체_폭의_평균단면적() -> None: - from types import SimpleNamespace as S - - from B08_Quantity.B08_Quantity_Engine_SlopeLength import _roadbed_width, road_surface_area - - assert ( - _roadbed_width({"road_edges": {"left": {"offset_m": 2.5}, "right": {"offset_m": -2.5}}}) - == 5.0 - ) - assert ( - road_surface_area( - [S(chainage_m=20.0, roadbed_width_m=6.0), S(chainage_m=0.0, roadbed_width_m=4.0)] - ) - == 100.0 - ) - assert ( - road_surface_area( - [S(chainage_m=0.0, roadbed_width_m=4.0), S(chainage_m=20.0, roadbed_width_m=None)] - ) - is None - ) - assert road_surface_area([]) is None - - -def test_노면만을_고르면_절토_사면을_안_더한다() -> None: - """⭐ 2026-09-14 판정 — 기본은 법 문언(노면 + 절토) · 노면만은 영월 한 건이라 설계자가 고름.""" - rows = {r["item"]: r for r in preparation_rows(사면, [], 0.2, 300.0, None, 노면, True)} - assert rows["표토제거"]["amount"] == 600.0 and "노면만" in rows["표토제거"]["reason"] - assert "법 문언" in rows["표토제거"]["reason"] - assert rows["표토 운반·적치"]["amount"] == 600.0 * 0.2 # 운반도 고른 대상 따라감 - - -def test_대상_설정은_모르는_값이면_기본() -> None: - from common_util.common_util_project_settings import topsoil_target - - assert topsoil_target({}) == "road_and_cut" - assert topsoil_target({"topsoil_target": "road_only"}) == "road_only" - assert topsoil_target({"topsoil_target": "cut_only"}) == "road_and_cut" diff --git a/resources/tester/test_b08_tree_removal_split.py b/resources/tester/test_b08_tree_removal_split.py deleted file mode 100644 index 7ff941fb0..000000000 --- a/resources/tester/test_b08_tree_removal_split.py +++ /dev/null @@ -1,93 +0,0 @@ -"""지장목제거를 두 줄로 가름 (2026-09-09 사용자 확정 5차 2번). - -실무(영월 설계내역서 1.9)는 **한 면적에 두 작업**을 얹는다 — - 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475 - 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← **같은 11,035㎡** - -⚠ 겨누는 것 다섯 - ① 두 줄이 선다 - ② ⚠ **같은 면적**이 두 줄에 그대로 들어간다 — **이중계상이 아니다**(다른 작업이 얹히는 것) - ③ 그 사실이 사유에 적힌다 - ④ ⚠ 작업 갈래(뿌리뽑기·잡관목제거)를 **지반 갈래로 읽지 않는다** — 읽으면 - 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다 - ⑤ 잡관목제거는 **품셈에 이름이 없음**이 사유에 남는다(공종 보류 — 확정 5차 3번) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 - SummaryInput, - build_table, -) -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 - -사면 = {"tree_removal_fill": 9000.0, "tree_removal_cut": 8873.8} - - -def 집계() -> dict: - return build_table( - SummaryInput( - earthwork_totals={}, - slope_totals=사면, - haul_rows=[], - rock_classes=["토사"], - rock_ratios_pct={}, - application_ratios={}, - ) - ) - - -def 줄들() -> list[dict]: - return [ - row - for row in build_handoff(summary_table=집계())["work_items"] - if row["name"] == "지장목제거" - ] - - -def test_두_줄이_선다() -> None: - assert [row["spec"] for row in 줄들()] == ["뿌리뽑기", "잡관목제거"] - - -def test_같은_면적이_두_줄에_들어간다() -> None: - """⚠ 이중계상이 아니다 — 한 면적에 **다른 두 작업**이 얹히는 실무 서식이다.""" - amounts = {row["quantity"] for row in 줄들()} - assert amounts == {17873.8} - - -def test_이중계상이_아님이_사유에_적힌다() -> None: - for row in 줄들(): - assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str( - row["spec_detail"] - ) - - -def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None: - """⚠ 읽으면 「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙는다.""" - for row in 줄들(): - assert row["ground_class"] is None - unmatched = build_handoff(summary_table=집계())["unmatched_work_items"] - assert not any("지장목제거" in item for item in unmatched) # 두 작업 갈래 모두 이어짐 - - -def test_잡관목제거는_단목베기_5m_미만으로_이어진다() -> None: - """⭐ 2026-09-13 판정 — 지장목제거는 품셈에 있다(벌목 4장 + 제근 9-20~21).""" - 잡관목 = next(row for row in 줄들() if row["spec"] == "잡관목제거") - assert 잡관목["work_item_code"] == "FP-04-02-02" - assert 잡관목["variant_value"] == "5m 미만" - 뿌리뽑기 = next(row for row in 줄들() if row["spec"] == "뿌리뽑기") - assert 뿌리뽑기["work_item_code"] == "FP-09-21" - assert 뿌리뽑기["variant_axis"] == "stand_volume_class" - - -def test_뿌리뽑기는_임목축적_등급을_갈래로_싣는다() -> None: - """⭐ 판정 Ⓑ — 품이 등급(소림·중림·밀림)으로 갈려 갈래를 잃으면 안 된다.""" - rows = build_handoff(summary_table=집계(), stand_volume_class="중림")["work_items"] - 뿌리뽑기 = next(r for r in rows if r["name"] == "지장목제거" and r["spec"] == "뿌리뽑기") - assert 뿌리뽑기["variant_value"] == "중림" diff --git a/resources/tester/test_b08_tree_waste.py b/resources/tester/test_b08_tree_waste.py deleted file mode 100644 index 62ce3dd81..000000000 --- a/resources/tester/test_b08_tree_waste.py +++ /dev/null @@ -1,79 +0,0 @@ -"""임목폐기물 처리 — 톤으로 서는가 · 경비라 내역에 안 서는가 (2026-09-14 판정). - -⚠ 산식 — 한국건설기술연구원(2012) 3.2: WA = 0.5 × π × (B/2)² × h × 1.3 × W1 × N · WR = WA × 15/85. -⚠ 실정보고 예(p26) 「V1 = 0.5×π×(0.16/2)²×10.0×1.3×134/1000×7,450 = 130.40㎥」 로 - 지상부 부피를 대 봄. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import ( # noqa: E402 - tree_waste_rows, - tree_waste_tons, -) - -SLOPE = {"tree_removal_fill": 4000.0, "tree_removal_cut": 3450.0} # 7,450㎡ -FIELD = {"stems_per_1000m2": 134, "dbh_cm": 16, "height_m": 10.0, "unit_weight_kg_m3": 1000} - - -def test_지상부는_실정보고_부피와_같다() -> None: - """W1 = 1,000㎏/㎥ 로 두면 지상부 톤 = 지상부 부피(㎥) — 실정보고 130.40㎥.""" - above, root = tree_waste_tons(7450.0, FIELD) - # 지침 부록1 식·실정보고 모두 π 를 3.14 로 적음 — 그대로 셈해 130.40 과 같다. - assert above == pytest.approx(130.40, abs=0.01) - assert root == pytest.approx(above * 15 / 85) - - -def test_칸이_비면_톤이_안_서고_무엇이_빈지_적는다() -> None: - row = tree_waste_rows(SLOPE, {**FIELD, "height_m": None})[0] - assert row["amount"] is None and "평균 수고" in row["reason"] - assert row["reference_amount"] == 7450.0 - - -def test_단가가_없으면_금액이_안_서고_있으면_미확정() -> None: - no_price = tree_waste_rows(SLOPE, FIELD)[0] - assert no_price["unit"] == "톤" and no_price["amount"] > 0 - assert no_price["amount_krw"] is None and no_price["unconfirmed"] == 0 - priced = tree_waste_rows(SLOPE, FIELD, 60000)[0] - assert priced["unconfirmed"] == 1 - assert priced["amount_krw"] == int(priced["amount"] * 60000) - - -def test_톤_경계를_알리고_분리발주_대상_아님을_적는다() -> None: - big = tree_waste_rows(SLOPE, FIELD)[0] # 약 153톤 - assert "분리발주 대상 아님" in big["reason"] and "환경부" in big["reason"] - small = tree_waste_rows({"tree_removal_fill": 500.0}, FIELD)[0] # 약 10톤 - assert 5 <= small["amount"] < 100 and "5톤 이상" in small["reason"] - - -def test_경비라_내역에_안_서고_제외_사유가_간다() -> None: - from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff - from B08_Quantity.B08_Quantity_Engine_Preparation import build_table - - table = build_table(SLOPE, tree_waste=FIELD, tree_waste_unit_price_krw_per_ton=60000) - handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]} - row = handed["임목폐기물 처리"] - assert row["in_bill"] is False and row["blocked_kind"] is None - assert "경비" in row["in_bill_reason"] - - -def test_뿌리분_체적법은_D_세제곱_곱하기_1300() -> None: - """지침 부록1 — V = 0.3927 D³ · 뿌리분 중량 = V × 1,300㎏/㎥ (본수만큼).""" - field = {**FIELD, "root_ball_diameter_m": 0.5} - above, root = tree_waste_tons(7450.0, field, "root_ball") - stems = 134 / 1000 * 7450 - assert root == pytest.approx(0.3927 * 0.5**3 * 1300 * stems / 1000) - assert above == pytest.approx(tree_waste_tons(7450.0, FIELD)[0]) - - -def test_체적법인데_뿌리분_직경이_비면_안_선다() -> None: - row = tree_waste_rows(SLOPE, FIELD, root_method="root_ball")[0] - assert row["amount"] is None and "뿌리분 직경" in row["reason"] diff --git a/resources/tester/test_b08_unit_quantity.py b/resources/tester/test_b08_unit_quantity.py deleted file mode 100644 index 3ad095f4e..000000000 --- a/resources/tester/test_b08_unit_quantity.py +++ /dev/null @@ -1,446 +0,0 @@ -"""구조물 원단위 전개식 검사 — PLAN 8-6·8-8·8-15. - -이 일감의 진짜 위험은 계산이 아니라 **이중계상**이다. 그래서 시험도 거기에 무게를 둔다. - ㉢ 배합(시멘트·모래)을 여기서 쪼개면 B09 일위대가와 겹쳐 두 배가 된다. - ㉠ 할증을 여기서 붙이면 자재총괄과 겹친다. - · 터파기·되메우기는 토공으로 합산되는 값이라 내역 줄과 구분돼야 한다. - -실무 관측값(울진 13종)은 **검산 정답지**이지 맞춰야 할 목표가 아니다 — 크게 벌어지면 -전개식을 의심하되, 맞추려고 식을 비틀지는 않는다. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - MIX_COMPONENTS, - STONE_BACK_LENGTH_TABLE, - Component, - StructureQuantity, - build_table, - expand, - stone_masonry, - verify_no_mix_components, -) - - -def 돌쌓기찰(height: float = 1.5, length: float = 1.0, **options) -> dict: - """저장된 제원 모양 그대로 — `structures.json` 의 한 항목.""" - return { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 35.0, - "end_m": 35.0 + length, - "options": {"height_m": height, "length_m": length, **options}, - } - - -def 성분(result: StructureQuantity, name: str) -> Component: - return next(c for c in result.components if c.name == name) - - -# ── ㉢ 배합 분해 금지 — 이 일감 최대 위험 ──────────────────────────── - - -def test_배합_성분이_산출물에_없을것() -> None: - """모르터·콘크리트까지만 낸다 — 시멘트·모래는 B09 일위대가 몫이다. - - ⚠ **정확히 같은 이름**으로 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 - `자갈` 로 오탐된다 — 개발 중 실제로 걸렸던 자리라 시험도 같은 규칙으로 둔다. - """ - result = expand(돌쌓기찰()) - names = {c.name for c in result.components} - assert not (names & MIX_COMPONENTS), f"배합 성분이 섞였다: {names & MIX_COMPONENTS}" - assert "막자갈" in names # 뒤채움 재료는 배합이 아니라 남아 있어야 한다 - - -def test_모르터까지만_내고_멈춤() -> None: - result = expand(돌쌓기찰()) - assert 성분(result, "모르터").unit == "㎥" - assert 성분(result, "채움콘크리트").unit == "㎥" - - -def test_배합이_섞이면_검사가_잡을것() -> None: - """실무 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다.""" - tainted = StructureQuantity( - structure_id="x", - type_id="masonry_wet", - name="돌쌓기(찰)", - components=[Component("시멘트", "대", 2.6, "material")], - ) - assert verify_no_mix_components([tainted]) - assert not verify_no_mix_components([expand(돌쌓기찰())]) - - -def test_표에도_위반이_드러남() -> None: - table = build_table([돌쌓기찰()]) - assert table["mix_components_found"] == [] - - -# ── ㉠ 할증 금지 ──────────────────────────────────────────────────── - - -def test_할증_전_값임을_못박음() -> None: - """할증은 자재총괄 한 곳뿐이다(PLAN 8-7 ㉠).""" - assert build_table([돌쌓기찰()])["surcharge_applied"] is False - - -# ── 터파기·되메우기는 토공으로 합산 ───────────────────────────────── - - -def test_성분마다_갈_곳이_표시됨() -> None: - """내역 줄의 실체는 작업 공종이고, 터파기는 토공으로 합쳐진다(울진 D12~D14 실증).""" - result = expand(돌쌓기찰()) - assert 성분(result, "터파기").destination == "earthwork" - assert 성분(result, "되메우기").destination == "earthwork" - assert 성분(result, "잔토처리").destination == "earthwork" - assert 성분(result, "돌쌓기").destination == "unit_price" - # ⚠ 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09 확정 5차 큰 것 7) — 종류를 안 고르면 - # 관측표가 아니라 **계산식**으로 서고 줄 이름도 정본 계산표대로 「돌」이다. - assert 성분(result, "돌").destination == "material" - - -def test_잔토는_터파기_빼기_되메우기() -> None: - result = expand(돌쌓기찰()) - assert 성분(result, "잔토처리").amount == pytest.approx( - 성분(result, "터파기").amount - 성분(result, "되메우기").amount - ) - - -# ── 전개식 — 실무 시트 값과 대조 ──────────────────────────────────── - - -def test_실무_시트_m당_값_재현() -> None: - """`기슭막이(찰쌓기, H=1.5, 기초무)` 시트 — 터파기 1.55 · 되메우기 0.30 · 잔토 1.25. - - ⚠ 우리 값은 1.5375 로 실무 1.55 와 0.012 차이가 난다. **우리 쪽이 맞다** — - 평균두께가 0.825 인데 실무 시트는 **표기값 0.83 으로 다시 계산**해서 1.545 가 됐다. - 품셈 1-2-2 의 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16). - 맞추려고 식을 비틀지 않는다 — 허용오차를 그 차이만큼 둔다. - """ - # 실무 시트가 뒷길이 45 — 2026-09-14 부터 안 고르면 표준 하한(찰 ≤1.5m 25㎝)이라 고름. - result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) - assert result.height_m == 1.5 - assert result.length_m == 1.0 - assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02) - assert 성분(result, "되메우기").amount == pytest.approx(0.30, abs=0.01) - assert 성분(result, "잔토처리").amount == pytest.approx(1.25, abs=0.02) - - -def test_평균두께는_전정밀로() -> None: - """실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다.""" - result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) - # 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375 - assert 성분(result, "터파기").amount == pytest.approx(1.5375) - - -def test_비탈면적은_기울기만큼_길어짐() -> None: - """1:0.3 이면 정면적 1.5 → 돌쌓기 1.566 (실무 표기 1.57). - - ⚠ 2026-09-08 ㉘ 정정 — 이 시험이 **기울기 몫을 두 번 곱하는 것**(× 1.566 × 1.04)을 - 계약으로 못 박고 있었다. 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 - **곧 기울기 몫**이다(1:0.3 → 1.0440). 값이 나오고 자원도 맞아 아무도 안 봤다. - """ - import math - - # ⚠ 2026-09-09 — 기울기가 **품셈 표준경사표로 자동 판정**되면서 찰 H=1.5 는 1:0.25 가 - # 되었다(확정 ⑨). 이 시험이 보는 것은 「기울기 몫을 한 번만 먹는가」이므로 - # **기울기를 못 박고** 본다. - components, _ = stone_masonry(1.5, 1.0, {"face_slope_ratio": 0.3}, wet=True) - masonry = next(c for c in components if c.name == "돌쌓기") - assert masonry.amount == pytest.approx(1.5 * math.hypot(1.0, 0.3)) - - -def test_돌쌓기_면적은_기울기_몫을_한_번만_먹는다_거울시험() -> None: - """⚠ 거울 시험 — `정면적 × hypot` 과 어긋나면 깨진다. 기울기를 바꿔도 따라와야 한다.""" - import math - - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry - - for ratio in (0.3, 0.5, 1.0): - options = {"face_slope_ratio": ratio} - components, _ = stone_masonry(2.5, 10.0, options, wet=True) - 돌쌓기 = next(c for c in components if c.name == "돌쌓기") - assert 돌쌓기.amount == pytest.approx(25.0 * math.hypot(1.0, ratio)) - - options["stone_cm"] = "60~80" - components, _ = boulder_masonry(2.5, 10.0, options) - 큰돌 = next(c for c in components if c.name == "큰돌쌓기") - assert 큰돌.amount == pytest.approx(25.0 * math.hypot(1.0, ratio)) - - -def test_1대0점3_에서_실무_시트값과_맞는다() -> None: - """실무 돌골막이 시트 — 정면적 18.87 ㎡ → 돌쌓기 19.62 ㎡. 오차 0.5 % 안.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY - - # ⚠ 실무 시트는 1:0.3 으로 그린 것이라 **그 기울기를 못 박고** 대조한다(2026-09-09). - # 자동 판정에 맡기면 직고 18.87m 로 읽혀 7m 초과 칸(1:0.45)이 되어 대조가 깨진다. - components, _ = stone_masonry(18.87, 1.0, {"face_slope_ratio": 0.3}, wet=True) - masonry = next(c for c in components if c.name == "돌쌓기") - assert masonry.amount == pytest.approx(19.62, rel=0.005) - # 시트가 반올림해 적은 1.04 가 곧 기울기 몫임을 함께 박는다. - assert STONE_MASONRY["sheet_check_factor_at_0_3"] == pytest.approx( - masonry.amount / 18.87, rel=0.005 - ) - - -def test_길이에_비례() -> None: - one = expand(돌쌓기찰(length=1.0)) - ten = expand(돌쌓기찰(length=10.0)) - assert 성분(ten, "터파기").amount == pytest.approx(성분(one, "터파기").amount * 10) - - -def test_메쌓기는_콘크리트_모르터가_없음() -> None: - result = expand({**돌쌓기찰(), "type_id": "masonry_dry"}) - names = [c.name for c in result.components] - assert "채움콘크리트" not in names - assert "모르터" not in names - assert "돌쌓기" in names - - -# ── 계수표 — 식에 박지 않는다 ─────────────────────────────────────── - - -def test_뒷길이를_바꾸면_계수가_따라감() -> None: - """실무 방식의 약점을 고친 자리 — 뒷길이가 바뀌어도 식을 안 고친다(PLAN 8-8 ㉮).""" - a = expand(돌쌓기찰(stone_back_length_cm=45)) - b = expand(돌쌓기찰(stone_back_length_cm=55)) - ratio = STONE_BACK_LENGTH_TABLE[55]["fill_concrete_m3_per_m2"] / ( - STONE_BACK_LENGTH_TABLE[45]["fill_concrete_m3_per_m2"] or 1 - ) - assert 성분(b, "채움콘크리트").amount == pytest.approx(성분(a, "채움콘크리트").amount * ratio) - - -def test_원본에_없는_칸은_지어내지_않음() -> None: - """뒷길이 60㎝ **야면석**의 돌중량은 관측표가 비어 있다 — 값을 만들지 않고 알린다. - - ⚠ 2026-09-09 확정 5차 큰 것 7 로 **계산식이 기본**이 되면서, 종류를 안 고른 자리는 - 이제 표가 비어도 계산식으로 선다. 관측표를 보는 것은 **야면석 계열뿐**이라 그 조건으로 - 옮긴다 — 「빈 칸을 안 지어낸다」는 못이 사라진 것이 아니라 **자리가 좁아진 것**이다. - """ - result = expand(돌쌓기찰(stone_back_length_cm=60, stone_kind="야면석·호박돌")) - assert not any(c.unit == "ton" for c in result.components) - assert any("돌중량" in note for note in result.notes) - - -# ── 모르는 종류·빈 값 ──────────────────────────────────────────────── - - -def test_전개식이_없는_종류는_물량을_내지_않음() -> None: - """옹벽은 이제 **관측 원단위표**로 간다(치수가 저장돼 있지 않아 식을 못 세움). - 형식(반중력식…)을 안 고르면 규격이 안 맞아 **미확보**로 드러난다 — 값을 지어내지 않는다.""" - result = expand({"type_id": "retaining_wall", "options": {"height_m": 2.0, "length_m": 5.0}}) - assert result.components == [] - assert any("자료에 없습니다" in note for note in result.notes) - - -def test_모르는_종류는_전개식도_원단위도_없음() -> None: - result = expand({"type_id": "듣도보도못한구조물", "options": {"height_m": 2.0}}) - assert result.components == [] - assert any("수량 산출식이 아직 없습니다" in note for note in result.notes) - - -def test_높이가_없으면_전개하지_않음() -> None: - result = expand(돌쌓기찰(height=0.0)) - assert result.components == [] - assert result.notes - - -def test_표_모양() -> None: - table = build_table([돌쌓기찰(length=10.0), {**돌쌓기찰(), "type_id": "masonry_dry"}]) - assert table["structure_count"] == 2 - assert table["totals"] - assert all({"name", "unit", "amount", "destination"} <= set(entry) for entry in table["totals"]) - - -def test_물구멍_잠정값이_근거에_적힐것() -> None: - """⚠ 「미확정」만 적으면 무엇을 정해야 하는지 모른다 — 지금 값과 법 범위를 함께 적는다.""" - # ⚠ 이름이 「물구멍」 → 「물구멍관」 으로 바뀜(2026-09-08) — 자재 카탈로그가 이름으로 - # 줄을 찾고, 그 규약이 「공백 없는 한 낱말」이다(B09 확인). - basis = 성분(expand(돌쌓기찰()), "물구멍관").basis - # ⚠ 문구가 「잠정」 → 「안 정함」으로 바뀜(2026-09-09) — **무엇을 안 정했는지**가 - # 낱말에 붙어 화면에서 바로 읽힌다. - assert "안 정함" in basis - assert "Ø50" in basis # 실무 관측값 - assert "2~3㎡" in basis # 법이 정한 범위 - - -def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None: - """2026-09-13 브레인 판정 — 계수 열 고르기와 돌종류는 다른 축. - - 옛 전개는 「실무 관행」을 고르면 야면석이 「돌」·계산식 무게(0.918)로 조용히 섰음. - 이제 계수만 참고자료 열(고임돌 0.15)로 가고, 돌은 야면석 규격·관측 무게(0.88)를 지킴. - """ - result = expand( - 돌쌓기찰( - height=2.0, - length=10.0, - back_len_cm=45, - stone_kind="야면석·호박돌", - stone_coeff_basis="실무 관행", - face_slope_ratio=0.3, - ) - ) - 돌쌓기 = 성분(result, "돌쌓기").amount - 돌 = 성분(result, "돌") # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ) - assert 돌.spec == "야면석·호박돌" - assert 돌.amount == pytest.approx(돌쌓기 * 0.88) - assert 성분(result, "고임돌").amount == pytest.approx(돌쌓기 * 0.15) # 관행 열 - - -def test_물구멍관_길이는_평균두께() -> None: - """2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = 평균두께(옛 상수 0.5m 아님). - - H=2.0 · 뒷길이 45 → 상부 0.75 · 하부 1.05 · 평균 0.90. 1:0.3 · 2㎡/개소. - """ - result = expand(돌쌓기찰(height=2.0, length=10.0, back_len_cm=45, face_slope_ratio=0.3)) - 돌쌓기 = 성분(result, "돌쌓기").amount - 물구멍관 = 성분(result, "물구멍관") - assert 물구멍관.amount == pytest.approx(돌쌓기 / 2.0 * 0.90) - assert "평균두께 0.90" in 물구멍관.basis - - -def test_큰돌쌓기를_돌쌓기_식으로_돌리지_않을것() -> None: - """⚠ 2026-09-07 발견 — 큰돌쌓기는 품셈 **13-6**, 돌쌓기는 **13-4** 로 **규격 축이 다르다**. - 돌쌓기는 뒷길이(35·45·55·60㎝), 큰돌쌓기는 직경(40~60·60~80·80~100㎝). - 앞서 `stone_masonry(dry)` 로 전개해 직경 60~80㎝ 짜리가 「뒷길이 45㎝」 계수로 돌고 있었다. - **값이 나오기는 해서 어떤 시험도 안 잡던 자리** — 「값이 있기는 하니 안 보이는」 그것이다. - - ⚠ 지금은 13-6 축 전개식이 섰다. 그래서 **뒷길이 계수에서 나오던 성분이 없는지**로 잰다 — - 「전개가 안 된다」가 아니라 「**틀린 축으로 안 돈다**」가 지켜야 할 것이다. - """ - result = expand( - { - "type_id": "boulder_masonry", - "start_m": 10.0, - "end_m": 20.0, - "options": {"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80"}, - } - ) - bases = " ".join(component.basis for component in result.components) - assert "뒷길이" not in bases # 13-4 계수표가 안 걸렸다 - assert "13-6" in bases - names = {component.name for component in result.components} - # ⚠ 버림 콘크리트가 2026-09-09 확정 ⑭ 로 들어왔다 — 기초 바닥에 따로 치는 줄이다. - assert names == {"큰돌쌓기", "버림콘크리트", "터파기", "되메우기", "잔토처리"} - - -# ── 큰돌쌓기(품셈 13-6) — 규격 축이 직경이다 (2026-09-07 ⑲) ──────── - - -def 큰돌(diameter: str = "60~80", height: float = 2.0, length: float = 10.0) -> dict: - return { - "structure_id": "b1", - "type_id": "boulder_masonry", - "start_m": 10.0, - "end_m": 10.0 + length, - "options": {"height_m": height, "length_m": length, "stone_cm": diameter}, - } - - -def test_큰돌쌓기가_직경_축으로_설것() -> None: - """⚠ 앞서 돌쌓기(13-4) 뒷길이 계수로 돌던 자리 — 이제 13-6 축으로 선다.""" - result = expand(큰돌()) - area = 성분(result, "큰돌쌓기") - assert area.unit == "㎡" - assert "직경 60~80㎝" in area.basis and "13-6" in area.basis - # 돌쌓기 계열 성분(뒷길이 계수로 나오던 것)은 안 나온다. - names = {c.name for c in result.components} - assert "고임돌" not in names and "야면석" not in names and "막자갈" not in names - - -def test_표에_없는_직경은_지어내지_않을것() -> None: - result = expand(큰돌(diameter="120~150")) - assert result.components == [] - assert any("돌 직경이 아직 입력되지 않았습니다" in note for note in result.notes) - - -def test_직경이_비어_있으면_전개하지_않음() -> None: - result = expand({"type_id": "boulder_masonry", "options": {"height_m": 2.0, "length_m": 5.0}}) - assert result.components == [] - - -def test_품에_포함된_것을_따로_세우지_않을것() -> None: - """13-6 [주]① 「고임돌 및 채움콘크리트 품은 포함되어 있다」 — 세우면 이중계상.""" - result = expand(큰돌()) - assert any("품에 포함" in note for note in result.notes) - - -def test_재료_원단위가_없음을_알릴것() -> None: - """13-6 [주]⑦ 「재료량은 설계수량을 적용한다」 — 뒷길이별 돌중량 표에 해당하는 것이 없다.""" - result = expand(큰돌()) - assert any("재료(큰돌) 원단위 미확보" in note for note in result.notes) - - -def test_메찰_구분이_없음을_알릴것() -> None: - result = expand(큰돌()) - assert any("메/찰" in note for note in result.notes) - - -def test_터파기는_직경_위끝을_벽두께로_볼것() -> None: - """품셈에 큰돌쌓기 터파기 폭 규정이 없어 돌쌓기 방식을 준용한다 — 근거에 그 사실을 적는다.""" - result = expand(큰돌(diameter="60~80", height=2.0, length=10.0)) - dig = 성분(result, "터파기") - assert dig.amount == pytest.approx(2.0 * (0.8 + 0.2) * 10.0) - assert "준용" in dig.basis - - -# ── 돌쌓기 뒷길이 — 저장 칸 이름이 달랐다 (2026-09-07 발견) ───────── - - -def test_저장_칸_이름으로_뒷길이를_읽을것() -> None: - """⚠ 레지스트리는 `back_len_cm` 인데 엔진이 `stone_back_length_cm` 을 읽고 있었다. - 저장값이 영영 안 닿아 **뒷길이를 75 로 골라도 45 계수**가 붙던 자리다.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length - - assert _back_length({"back_len_cm": "55"}) == 55 - assert _back_length({"stone_back_length_cm": 35}) == 35 # 옛 이름도 본다 - assert _back_length({}) == 45 # 없으면 기본 - - -def test_뒷길이를_접지_않는다() -> None: - """⚠ 2026-09-08 정정 — 앞서 「덮는 위 칸으로 접는다」를 계약으로 못 박고 있었다. - - 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 다 주므로 접을 - 까닭이 없었다. 접으면 **40㎝ 가 45㎝ 계수로 조용히** 돌고 999㎝ 도 60 으로 접혔다. - """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length - - assert _back_length({"back_len_cm": "25"}) == 25 - assert _back_length({"back_len_cm": "75"}) == 75 - assert _back_length({"back_len_cm": "40"}) == 40, "표에 없는 값도 그대로 — 접지 않는다" - - -def test_표에_없는_뒷길이는_물량을_안_낸다() -> None: - """⚠ 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.""" - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry - - components, notes = stone_masonry( - 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 40}, wet=True - ) - assert components == [] - assert "품셈 표(25·30·35·45·55·60·75㎝)에 없어" in notes[0] - - -def test_큰돌쌓기_메찰을_고르면_그_사유가_사라진다() -> None: - """⚠ 사유가 거짓이면 **사유 칸 전체를 못 믿게 된다**(2026-09-09). - - `bond` 는 레지스트리에 있는 칸인데 조건 없이 「못 고름」을 붙이고 있었다. - ⚠ 얻는 것은 노무 품 갈래뿐 — 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다. - """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry - - base = {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80"} - _, without = boulder_masonry(2.5, 10.0, dict(base)) - _, chosen = boulder_masonry(2.5, 10.0, {**base, "bond": "메쌓기"}) - assert any("메/찰" in note for note in without) - assert not any("메/찰" in note for note in chosen) - # 재료 미확보 사유는 **그대로 남아야 한다** — 그건 아직 참이다. - assert any("재료(큰돌) 원단위 미확보" in note for note in chosen) diff --git a/resources/tester/test_b08_wall_height_unconfirmed.py b/resources/tester/test_b08_wall_height_unconfirmed.py deleted file mode 100644 index 0b375b304..000000000 --- a/resources/tester/test_b08_wall_height_unconfirmed.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- -"""높이 없이 놓인 벽 — **놓기는 되고 줄은 서되 미확정(금액 밖)** (2026-09-14 브레인 판정 ①). - -앞서 B05 폼이 C군 높이가 비면 놓기를 막았음. 우리 규칙 「줄은 서고 금액은 안 섬」과 어긋나 풀고, -높이가 물량 밑수인 벽(옹벽·돌쌓기 찰/메·큰돌쌓기)은 B08 표에서 미확정으로 뜨게 함. -흙막이(떼 개소당)는 높이를 안 써 그대로. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: E402 - UNCONFIRMED_NO_HEIGHT, - missing_height_reason, -) - -COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( - encoding="utf-8" -) - - -def _wall(type_id: str = "masonry_wet", **options) -> dict: - return { - "structure_id": "w", - "type_id": type_id, - "start_m": 0.0, - "end_m": 10.0, - "options": { - "length_m": 10.0, - "back_len_cm": 45, - "stone_kind": "깬돌", - "foundation": "기초유", - **options, - }, - } - - -def test_높이_없는_돌쌓기는_미확정으로_선다() -> None: - table = build_table([_wall()], {"masonry_wet": "돌쌓기(찰)"}, {}) - row = table["structures"][0] - assert row["unconfirmed"] == UNCONFIRMED_NO_HEIGHT - - -def test_높이를_적으면_미확정이_아니다() -> None: - table = build_table([_wall(height_m=2.0)], {"masonry_wet": "돌쌓기(찰)"}, {}) - assert table["structures"][0]["unconfirmed"] == "" - - -def test_높이를_안_쓰는_종류는_그대로() -> None: - assert missing_height_reason(_wall("soil_guard")) == "" - for type_id in ("retaining_wall", "masonry_dry", "boulder_masonry"): - assert missing_height_reason(_wall(type_id)) == UNCONFIRMED_NO_HEIGHT, type_id - assert missing_height_reason(_wall(type_id, height_m="1.5")) == "", type_id - - -def test_폼이_높이_빈칸으로_놓기를_안_막는다() -> None: - assert "height?.isEmpty()" not in COMMIT diff --git a/resources/tester/test_b08_wall_thickness.py b/resources/tester/test_b08_wall_thickness.py deleted file mode 100644 index 85639455e..000000000 --- a/resources/tester/test_b08_wall_thickness.py +++ /dev/null @@ -1,128 +0,0 @@ -"""돌쌓기 벽 두께 — 실무 구조물도 식 (2026-09-08 사용자 확정 2차 ②). - -⚠ 겨누는 것 여섯 - ① **뒷길이가 두께를 움직인다** — 옛 식(0.45+0.10H / 0.45+0.40H)에는 뒷길이가 없어 - 35 로 바꿔도 같은 값이 섰다 - ② 상부 = 뒷길이 + 0.30 - ③ 하부 = 상부 + 0.30 × (H − 1.0) - ④ ⚠ H ≤ 1.0 에서 하부가 상부보다 얇아지지 않는다(음수 몫을 0 으로 눅임) - ⑤ 두께가 **돌 입적·터파기의 밑수**다 — 둘이 같이 움직인다 - ⚠ 2026-09-09 랩탑 보조가 확정 5차로 **막자갈을 이 축에서 떼어 냈다** — 뒷채움 폭은 - 정본 여섯 탭 공통값(상 0.30 · 하 0.45)이지 벽 두께가 아니었다. 시험이 겨누던 - 자리를 **입적**으로 옮긴다(같은 식을 근거에 싣는 줄). - ⑥ 근거 문구에 그 식이 적힌다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry # noqa: E402 - - -def 성분(height_m: float, back_cm: int) -> dict: - components, _ = stone_masonry( - height_m, - 10.0, - {"height_m": height_m, "length_m": 10.0, "back_len_cm": back_cm}, - wet=True, - face="성토", - ) - return {c.name: c for c in components} - - -def 평균두께(height_m: float, back_cm: int) -> float: - top = back_cm / 100.0 + 0.30 - bottom = top + 0.30 * max(height_m - 1.0, 0.0) - return (top + bottom) / 2.0 - - -def test_뒷길이가_두께를_움직인다() -> None: - """⚠ 옛 식에는 뒷길이가 없었다 — 45 든 35 든 터파기가 같은 값으로 섰다.""" - 굵은 = 성분(2.0, 45)["터파기"].amount - 가는 = 성분(2.0, 35)["터파기"].amount - assert 굵은 > 가는 - - -def test_상부는_뒷길이에_0_30을_더한_값() -> None: - basis = 성분(2.5, 45)["입적"].basis - assert "상부 0.75" in basis # 0.45 + 0.30 - - -def test_하부는_높이_1m를_넘는_만큼_두꺼워진다() -> None: - basis = 성분(2.5, 45)["입적"].basis - assert "하부 1.20" in basis # 0.75 + 0.30 × (2.5 − 1.0) - - -def test_1m_이하에서는_상하부가_같다() -> None: - """⚠ (H−1) 을 그대로 곱하면 낮은 벽에서 하부가 상부보다 **얇아진다**.""" - basis = 성분(0.8, 35)["입적"].basis - assert "상부 0.65" in basis and "하부 0.65" in basis - - -def test_터파기가_평균두께에_매인다() -> None: - got = 성분(2.5, 45)["터파기"].amount - assert abs(got - 2.5 * (평균두께(2.5, 45) + 0.2) * 10.0) < 1e-6 - - -def test_뒷길이가_커져도_막자갈은_안_는다() -> None: - """⚠ 2026-09-09 확정 5차로 막자갈이 **뒷채움 정본 폭**(상 0.30 · 하 0.45)으로 갈렸다. - - 옛 식에서는 「입적 − 몸통」이라 뒷길이를 따라 아주 조금 움직였는데(12.653 → 12.630 → - 12.607), 지금은 **뒷길이를 아예 안 본다** — 값이 셋 다 같다. 두께가 실제로 움직이는 - 것은 **입적·터파기·잔토**에서 드러난다. - """ - 막자갈 = [성분(2.5, b)["막자갈"].amount for b in (35, 45, 55)] - assert len(set(막자갈)) == 1 - 터파기 = [성분(2.5, b)["터파기"].amount for b in (35, 45, 55)] - assert 터파기 == sorted(터파기) and 터파기[2] - 터파기[0] > 4.0 - - -def test_근거에_식이_적힌다() -> None: - """⚠ 값만 바뀌고 근거가 그대로면 왜 달라졌는지 화면에서 못 짚는다.""" - for name in ("입적", "터파기"): - basis = 성분(2.5, 45)[name].basis - assert "실무 구조물도 식" in basis - - -def test_입적이_줄로_선다() -> None: - """정본 계산표 좌측 열 이름 그대로다 — 「체적」으로 바꾸지 않는다 (확정 ⑦).""" - got = 성분(2.5, 45)["입적"] - assert got.unit == "㎥" - assert abs(got.amount - 2.5 * 10.0 * 평균두께(2.5, 45)) < 1e-9 - - -def test_석적은_정본에_없다는_사유를_달고_선다() -> None: - """⚠ 뒤집힌 시험이다 — 종전에는 「안 낸다」였다. - - 2026-09-09 확정 5차 작은 것 4 로 **세우기로 정했다.** 다만 정본 여섯 탭에는 없고 - 소광리 시트에만 있는 줄이라 **그 사실이 근거에 적혀야** 한다(값만 서고 출처가 없으면 - 나중에 어디서 왔는지 못 짚는다). - """ - got = 성분(2.5, 45)["석적"] - assert got.unit == "㎥" - assert "정본에 없는 줄" in got.basis and "소광리" in got.basis - - -def test_입적은_자재총괄에_안_섞인다() -> None: - """⚠ 보여 주기만 하는 줄이다 — 자재로 서면 돌을 두 번 센다.""" - from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as 자재 - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as 원단위 - - unit = 원단위( - [ - { - "structure_id": "s1", - "type_id": "masonry_wet", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, - } - ] - ) - assert 성분(2.5, 45)["입적"].destination == "reference" - assert all(row["name"] != "입적" for row in 자재(unit)["rows"]) diff --git a/resources/tester/test_b08_wall_trench.py b/resources/tester/test_b08_wall_trench.py deleted file mode 100644 index 1b019897f..000000000 --- a/resources/tester/test_b08_wall_trench.py +++ /dev/null @@ -1,83 +0,0 @@ -"""벽 터파기 — 공용 단면 함수 한 벌을 쓴다 (2026-09-08). - -⚠ 겨누는 것 여섯 - ① 식을 여기서 다시 짜지 않는다 — `common_util_excavation.wall_trench_area_m2` 값 그대로 - ② 「기초유」면 기초분 0.45㎥/m 가 붙는다 - ③ 「기초버림」이면 0.07㎥/m 로 준다 — 기초를 **안 두는 것이 아니라 얕게 두는 것** - ④ ⚠ 안 고른 프로젝트는 **비탈분만** 서고 사유가 뜬다 — 한쪽으로 찍으면 0.45 가 임의로 굳는다 - ⑤ 되메우기 = (기초깊이 + H) × 0.2 - ⑥ 큰돌쌓기도 같은 함수를 탄다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from common_util.common_util_excavation import wall_trench_area_m2 # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - boulder_masonry, - stone_masonry, -) - -H = 2.5 -L = 10.0 -BACK_CM = 45 -두께 = ((BACK_CM / 100 + 0.30) + (BACK_CM / 100 + 0.30 + 0.30 * (H - 1.0))) / 2 # 0.975 - - -def 성분(**options: object) -> tuple[dict, list[str]]: - components, notes = stone_masonry( - H, L, {"height_m": H, "length_m": L, "back_len_cm": BACK_CM, **options}, wet=True - ) - return {c.name: c for c in components}, notes - - -def test_공용_함수_값을_그대로_쓴다() -> None: - """⚠ 화면(B06 횡단도)이 그 함수로 그린다 — 여기서 다시 짜면 그림과 물량이 갈린다.""" - got, _ = 성분(foundation="기초유") - expected = wall_trench_area_m2(H, 두께, has_foundation=True) * L - assert abs(got["터파기"].amount - expected) < 1e-9 - - -def test_기초유는_기초분_0_45가_붙는다() -> None: - 유, _ = 성분(foundation="기초유") - 미지정, _ = 성분() - assert abs((유["터파기"].amount - 미지정["터파기"].amount) - 0.45 * L) < 1e-9 - - -def test_기초버림은_0_07로_준다() -> None: - """⚠ 「기초무」가 아니라 「기초버림」이다 — 얕게 두는 것이라 몫이 남는다.""" - 버림, _ = 성분(foundation="기초버림") - 미지정, _ = 성분() - assert abs((버림["터파기"].amount - 미지정["터파기"].amount) - 0.07 * L) < 1e-9 - - -def test_안_고르면_비탈분만_서고_사유가_뜬다() -> None: - got, notes = 성분() - assert abs(got["터파기"].amount - H * (두께 + 0.2) * L) < 1e-9 - assert any("기초 유/무" in n for n in notes) - - -def test_되메우기는_기초깊이를_함께_센다() -> None: - 유, _ = 성분(foundation="기초유") - assert abs(유["되메우기"].amount - (0.5 + H) * 0.2 * L) < 1e-9 - 미지정, _ = 성분() - assert abs(미지정["되메우기"].amount - H * 0.2 * L) < 1e-9 - - -def test_잔토는_터파기_빼기_되메우기() -> None: - got, _ = 성분(foundation="기초유") - assert abs(got["잔토처리"].amount - (got["터파기"].amount - got["되메우기"].amount)) < 1e-9 - - -def test_큰돌쌓기도_같은_함수를_탄다() -> None: - components, _ = boulder_masonry( - H, L, {"height_m": H, "length_m": L, "stone_cm": "60~80", "foundation": "기초유"} - ) - got = {c.name: c for c in components} - expected = wall_trench_area_m2(H, 0.80, has_foundation=True) * L - assert abs(got["터파기"].amount - expected) < 1e-9 diff --git a/resources/tester/test_b08_wing_wall.py b/resources/tester/test_b08_wing_wall.py deleted file mode 100644 index c3e4cfadf..000000000 --- a/resources/tester/test_b08_wing_wall.py +++ /dev/null @@ -1,119 +0,0 @@ -"""관보호공 날개벽 원단위 — 소광리 원본 다섯 탭에서 옮긴 값 (2026-09-09, 계획서 4-13). - -값은 **개소당·붙박이**다. 원본 탭(`07-구조도-소광리.xlsx`)의 소계를 그대로 옮겼고 -비례로 늘리지 않는다 — Ø800·Ø1000, A·C-TYPE, 그리고 「A-TYPE+집수정」 다섯뿐이다. - -⚠ 겹쳐 세는 자리 하나 — 「A-TYPE+집수정」은 집수정을 **품은** 형식이라 집수정 줄을 따로 - 세우면 한 개소를 두 번 센다. 값을 고치지 않고 **사유로 드러낸다.** -⚠ 철근·면목 줄은 원본 날개벽 탭에 **없다**(집수정구조도 탭에만 있음) — 지어내지 않는다. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 - ATTACHMENTS, - OBSERVED_SPEC_KEYS, - attachments_of, - wing_wall_double_count, -) - - -def _registry_choices() -> list[str]: - payload = json.loads( - (ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8") - ) - types = payload if isinstance(payload, list) else payload["types"] - pipe = next(item for item in types if item["type_id"] == "pipe") - option = next(o for o in pipe["options"] if o["key"] == "wing_wall_type") - return list(option["choices"]) - - -def test_다섯_형식이_표에_있다() -> None: - table = load_observed_table() - specs = { - (spec.get("wing_wall_type"), spec.get("pipe_diameter_mm")) - for spec in table.specs_for("pipe_wing_wall") - } - assert specs == { - ("A-TYPE", "800"), - ("C-TYPE", "800"), - ("A-TYPE", "1000"), - ("C-TYPE", "1000"), - ("A-TYPE+집수정", "800"), - } - - -def test_원본_소계와_같다() -> None: - """Ø800 A-TYPE — 원본 탭 소계(터파기 13.104 · 저판 0.952 + 벽체 1.1075 …).""" - found = load_observed_table().find( - "pipe_wing_wall", {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "800"} - ) - assert found is not None - amounts = {c["name"]: c["amount"] for c in found["components"]} - assert amounts["터파기"] == 13.104 - assert amounts["되메우기"] == 6.504 - assert amounts["잔토처리"] == 6.61 - assert amounts["기초잡석"] == 1.152 - assert abs(amounts["콘크리트"] - (0.952 + 1.1075)) < 1e-9 - assert abs(amounts["유로폼"] - (1.92 + 10.6176)) < 1e-9 - - -def test_거푸집_종류가_탭마다_다르다() -> None: - """Ø800 A 는 유로폼, Ø1000 은 합판(4회) — 단가가 갈리므로 이름을 뭉개지 않는다.""" - table = load_observed_table() - small = table.find("pipe_wing_wall", {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "800"}) - big = table.find("pipe_wing_wall", {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "1000"}) - assert "유로폼" in [c["name"] for c in small["components"]] - assert "합판거푸집" in [c["name"] for c in big["components"]] - - -def test_철근_면목은_안_지어낸다() -> None: - table = load_observed_table() - for spec in table.specs_for("pipe_wing_wall"): - found = table.find("pipe_wing_wall", spec) - names = [c["name"] for c in found["components"]] - assert not any("철근" in name or "면목" in name for name in names), spec - - -def test_형식을_고르면_줄이_선다() -> None: - rows = attachments_of( - { - "structure_id": "s1", - "type_id": "pipe", - "options": {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "800"}, - } - ) - assert [row["type_id"] for row in rows] == ["pipe_wing_wall"] - # 안 고르면 줄이 없다 — 안 놓은 것과 같다. - assert attachments_of({"structure_id": "s2", "type_id": "pipe", "options": {}}) == [] - - -def test_집수정_결합형은_두_번_세는_것을_알린다() -> None: - assert wing_wall_double_count({"wing_wall_type": "A-TYPE+집수정"}) is None - warn = wing_wall_double_count( - {"wing_wall_type": "A-TYPE+집수정", "inlet_basin_form": "□형(기본형)"} - ) - assert warn is not None and "두 번" in warn - assert ( - wing_wall_double_count({"wing_wall_type": "A-TYPE", "inlet_basin_form": "□형(기본형)"}) - is None - ) - - -def test_등록부_선택지와_표가_한_벌이다() -> None: - """칸에서 고를 수 있는데 표에 없으면 「원단위 미확보」로 끝난다 — 둘을 붙여 둔다.""" - table_types = { - spec.get("wing_wall_type") for spec in load_observed_table().specs_for("pipe_wing_wall") - } - assert set(_registry_choices()) == table_types - assert OBSERVED_SPEC_KEYS["pipe_wing_wall"] == ("wing_wall_type", "pipe_diameter_mm") - assert any(item[0] == "pipe_wing_wall" for item in ATTACHMENTS["pipe"]) diff --git a/resources/tester/test_b08_wood_chipping.py b/resources/tester/test_b08_wood_chipping.py deleted file mode 100644 index 7dfc2c39b..000000000 --- a/resources/tester/test_b08_wood_chipping.py +++ /dev/null @@ -1,61 +0,0 @@ -"""임목파쇄 — 켤 수 있는 칸 (2026-09-09 사용자 확정 5차 5번). - -「근주이식·임목파쇄는 **기본 안 셈**」이되 「⚠ 현장에 따라 **파쇄가 적용될 필요 있음**」이라 -**임목파쇄만** 켤 수 있게 둔다. **근주이식은 칸도 안 만든다.** - -⚠ 겨누는 것 다섯 - ① 기본은 **줄이 아예 없다** — 빈 칸이 「세야 함」으로 읽히면 안 된다 - (부대시설과 다른 자리 — 그쪽은 **법정 의무**라 줄이 늘 선다) - ② 켜면 줄이 선다 - ③ ⚠ 켜도 **부피는 지어내지 않는다** — 넣어야 값이 선다 - ④ 부피를 넣으면 값이 서고 코드가 붙는다(FP-08-11 이동식 임목 파쇄) - ⑤ 근주이식은 **어느 경우에도 줄이 없다** -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 - STATUS_NEEDS_INPUT, - STATUS_READY, - build_table, -) - - -def 표(enabled: object = False, volume: object = None) -> dict: - table = build_table({}, [], [], None, None, {}, None, None, enabled, volume) - return {row["item"]: row for row in table["rows"]} - - -def test_기본은_줄이_없다() -> None: - """⚠ 켤 자리가 없으면 물을 일도 없다 — 안 켠 프로젝트에 빈 줄을 세우지 않는다.""" - assert "임목파쇄" not in 표() - - -def test_켜면_줄이_선다() -> None: - assert "임목파쇄" in 표(True) - - -def test_켜도_부피는_지어내지_않는다() -> None: - row = 표(True)["임목파쇄"] - # 부피를 넣으면 서는 줄 — 「입력이 필요함」(2026-09-14 ㉴). - assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT - assert "부피" in row["reason"] - - -def test_부피를_넣으면_값이_선다() -> None: - row = 표(True, 78.0)["임목파쇄"] - assert row["amount"] == 78.0 and row["status"] == STATUS_READY - assert row["work_item_code"] == "FP-08-11" - assert row["unit"] == "㎥" - - -def test_근주이식은_어느_경우에도_없다() -> None: - """확정 5차 5번 — 근주이식(FP-14-02)은 **칸도 안 만든다**.""" - for enabled, volume in ((False, None), (True, None), (True, 78.0)): - assert "근주이식" not in 표(enabled, volume) diff --git a/resources/tester/test_b08_wording.py b/resources/tester/test_b08_wording.py deleted file mode 100644 index d2a5fa20a..000000000 --- a/resources/tester/test_b08_wording.py +++ /dev/null @@ -1,107 +0,0 @@ -"""사용자에게 뜨는 문구 검사 — **키 이름이 화면에 새지 않는다** (B08 ㉑). - -⚠ 「`back_len_cm` 가 저장돼 있지 않아 갈래를 못 고름」처럼 개발자 키가 그대로 뜨던 자리가 - 있었다. 사용자는 그 이름을 모르고 무엇을 해야 하는지도 알 수 없다. -⚠ 「없다」만 말하지 않는다 — **어디서 채우면 풀리는지**까지 있어야 그 말이 쓸모 있다. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, masonry_class # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table, expand # noqa: E402 -from B08_Quantity.B08_Quantity_Wording import ( # noqa: E402 - OPTION_LABELS, - option_missing, - spec_missing, - type_label, -) - -#: 화면 문구에 나오면 안 되는 것 — 저장 제원 키·구조물 `type_id` 같은 개발자 이름. -LEAK_PATTERN = re.compile( - r"back_len_cm|stone_cm|height_m|length_m|face_slope_ratio" - r"|masonry_wet|masonry_dry|boulder_masonry|retaining_wall|soil_guard|pipe_inlet_basin" -) - - -def _notes(structures: list[dict]) -> list[str]: - table = build_table(structures) - return [note for item in table["structures"] for note in item["notes"]] - - -def test_돌쌓기_뒷길이_안내가_사람_말일것() -> None: - kind, why = masonry_class({}) - assert kind is None - assert "뒷길이" in why - assert not LEAK_PATTERN.search(why), why - # 어디서 채우면 되는지가 있어야 한다. - assert "입력" in why - - -def test_큰돌쌓기_직경_안내가_사람_말일것() -> None: - notes = _notes([{"type_id": "boulder_masonry", "options": {"height_m": 2.0, "length_m": 10.0}}]) - assert notes and "돌 직경" in notes[0] - assert not LEAK_PATTERN.search(notes[0]), notes[0] - - -def test_산출식_없는_종류_안내가_사람_말일것() -> None: - notes = _notes([{"type_id": "soil_guard", "options": {"height_m": 2.0, "length_m": 10.0}}]) - assert any("흙막이" in note for note in notes) - assert not any(LEAK_PATTERN.search(note) for note in notes), notes - - -def test_규격_미확보_안내가_사람_말일것() -> None: - result = expand( - { - "type_id": "retaining_wall", - "options": {"height_m": 1.6, "length_m": 10.0, "form": "반중력식"}, - } - ) - note = next(n for n in result.notes if "규격" in n) - assert "옹벽" in note and "옹벽 형식" in note - assert not LEAK_PATTERN.search(note), note - - -def test_인계_못이은_줄도_사람_말일것() -> None: - unit = build_table( - [ - { - "type_id": "soil_guard", - "start_m": 0.0, - "end_m": 10.0, - "options": {"height_m": 2.0, "length_m": 10.0}, - } - ] - ) - unmatched = build_handoff(unit_quantity_table=unit)["unmatched_work_items"] - assert unmatched and "흙막이" in unmatched[0] - assert not any(LEAK_PATTERN.search(item) for item in unmatched), unmatched - - -def test_모르는_키는_지어내지_않을것() -> None: - """⚠ 잘못된 안내가 없는 안내보다 나쁘다 — 모르면 키를 그대로 보인다.""" - text = option_missing("듣도보도못한키") - assert "듣도보도못한키" in text - - -def test_라벨표에_어디서_채우는지가_있을것() -> None: - for key, (label, where) in OPTION_LABELS.items(): - assert label and label != key, key - assert isinstance(where, str) - - -def test_이름표에_없으면_원래_값을_보일것() -> None: - assert type_label("듣도보도못한종류") == "듣도보도못한종류" - assert type_label("masonry_wet") == "돌쌓기(찰)" - # 레지스트리 이름이 있으면 그것이 먼저다. - assert type_label("masonry_wet", {"masonry_wet": "돌쌓기(찰쌓기)"}) == "돌쌓기(찰쌓기)" - - -def test_자료가_통째로_없으면_그렇게_말할것() -> None: - assert "표준 물량 자료" in spec_missing("box_culvert", []) diff --git a/resources/tester/test_b08_work_item_master.py b/resources/tester/test_b08_work_item_master.py deleted file mode 100644 index 0065796fa..000000000 --- a/resources/tester/test_b08_work_item_master.py +++ /dev/null @@ -1,752 +0,0 @@ -"""B08 공종 마스터 정규화 검사 — PLAN 8-6·8-7. - -제일 중요한 것은 `pum_form` 이다. 생산량형(1÷값)과 소요량형(값÷밑수)은 환산 방향이 -반대라, 뒤집히면 값이 오류 없이 조용히 20배쯤 틀린다. 그래서 실물 표를 집어 -방향이 맞는지 못 박아 둔다. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( # noqa: E402 - OUT_DIR, - detect_form, - parse_toc, - section_number, -) - -MASTER = OUT_DIR / "work_item_master_2026-01-01.json" -UNDET = OUT_DIR / "form_undetermined_2026-01-01.json" - - -@pytest.fixture(scope="module") -def master() -> dict: - if not MASTER.exists(): - pytest.skip("공종 마스터 미생성 — 빌드 스크립트를 먼저 실행할 것") - return json.loads(MASTER.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def tables() -> list[dict]: - src = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json" - return json.loads(src.read_text(encoding="utf-8"))["variables"]["pum"]["tables"] - - -def find(tables: list[dict], table_id: str) -> dict: - return next(t for t in tables if t["table_id"] == table_id) - - -# ── pum_form — 뒤집히면 값이 반대가 되는 자리 ────────────────────────── - - -def test_생산량형_작업능력표(tables: list[dict]) -> None: - """`9-4-1 암파쇄` 는 「㎥/hr」 작업능력표다 — 품 = 1 ÷ 값.""" - form, why = detect_form(find(tables, "F0241"), "9") - assert form == "productivity", why - - -def test_생산량형이_비고의_직종에_뒤집히지_않을것(tables: list[dict]) -> None: - """`9-7-1` 은 「작업능력」 표인데 비고에 「보통인부 1인/일」이 붙어 있다. - - 직종을 먼저 보면 소요량형으로 뒤집힌다 — 그 사고를 막는 순서를 고정한다. - """ - table = find(tables, "F0247") - assert "보통인부" in json.dumps(table["rows"], ensure_ascii=False) - form, why = detect_form(table, "9") - assert form == "productivity", why - - -def test_소요량형_직종표(tables: list[dict]) -> None: - """`9-3-1 인력` 은 「보통인부(인) 0.16」 — ㎥당 품이다.""" - form, why = detect_form(find(tables, "F0239"), "9") - assert form == "requirement", why - - -def test_소요량형_직종이_둘째열에_있어도_잡을것(tables: list[dict]) -> None: - """`9-12-1 토사` 는 첫 열이 「인력(10%)」, 직종은 둘째 열에 있다.""" - form, why = detect_form(find(tables, "F0258"), "9") - assert form == "requirement", why - - -def test_계수표는_공종이_아님(tables: list[dict]) -> None: - """`9-3-2 기계` 는 시공능력 공식의 K·f·E 파라미터다.""" - form, why = detect_form(find(tables, "F0240"), "9") - assert form == "coefficient", why - - -def test_1장은_기준표(tables: list[dict]) -> None: - """품셈 제1장은 적용기준이라 공종으로 세우지 않는다.""" - form, why = detect_form(find(tables, "F0008"), "1") - assert form == "reference", why - - -def test_참조지시는_기준표(tables: list[dict]) -> None: - """`10-3-1 모래` 는 값이 아니라 「별도계상」 이라는 지시뿐이다.""" - form, why = detect_form(find(tables, "F0298"), "10") - assert form == "reference", why - - -# ── 계층·코드 ──────────────────────────────────────────────────────── - - -def test_절번호_파싱() -> None: - assert section_number("9-3-1. 인력") == "9-3-1" - assert section_number("12-2 표면 마무리를 따른다.") == "12-2" - assert section_number("부록1") is None - - -def test_목차_계층() -> None: - rows = [["제9장", "토공", "50"], ["9-3", "흙깎기", "51"], ["9-3-1", "인력", "51"]] - nodes = parse_toc(rows) - assert [n["work_item_code"] for n in nodes] == ["FP-09", "FP-09-03", "FP-09-03-01"] - assert [n["level"] for n in nodes] == [1, 2, 3] - assert nodes[2]["parent_code"] == "FP-09-03" - # STmate 관례대로 256 간격 — 중간 삽입 여유를 둔다. - assert [n["sort_order"] for n in nodes] == [256, 512, 768] - - -# ── 산출물 계약 (B09 가 읽는 부분) ───────────────────────────────────── - - -def test_원문셀을_버리지_않을것(master: dict) -> None: - """`raw_row` 가 없으면 B09 가 476표를 다시 열어야 한다 — PLAN 8-7.""" - got = [t for n in master["work_items"] for t in n["tables"]] - assert got, "표가 하나도 안 붙었다" - assert all("raw_row" in t for t in got) - assert any(t["raw_row"] for t in got) - - -def test_자원축은_비워둘것(master: dict) -> None: - """자원 축(`resource_kind` 등)은 B09 몫이다 — 여기서 채우면 경계 위반.""" - forbidden = {"resource_kind", "resource_code", "resource_spec", "amount", "amount_unit"} - for node in master["work_items"]: - for table in node["tables"]: - assert not (forbidden & set(table)), f"{table['pum_table_id']} 에 자원 축이 섞였다" - - -def test_dataset_version_세쪽_기록(master: dict) -> None: - """파일명만으로는 같은 날짜 재생성본과 구분이 안 된다 — PLAN 8-6.""" - dv = master["dataset_version"] - assert dv["dataset_id"] and dv["effective_date"] - assert len(dv["sha256"]) == 64 - - -def test_미판정은_빈칸이_아니라_목록으로(master: dict) -> None: - """판정 못 한 표를 조용히 비워 두면 뒤집힌 값이 들어간다 — 반드시 목록으로 낸다.""" - if not UNDET.exists(): - pytest.skip("미판정 목록 미생성") - listed = {i["pum_table_id"] for i in json.loads(UNDET.read_text(encoding="utf-8"))["items"]} - flagged = { - t["pum_table_id"] - for n in master["work_items"] - for t in n["tables"] - if t["pum_form"] == "undetermined" - } - assert flagged <= listed, "마스터에서 미판정인데 목록에 없는 표가 있다" - - -def test_귀속률(master: dict) -> None: - """표가 목차 계층에 붙어야 공종으로 선다. 미귀속이 늘면 목차 파싱이 깨진 것.""" - s = master["stats"] - assert s["tables_attached"] / s["tables_total"] > 0.9 - - -# ── 첫 칸이 분류 딱지인 표 (2026-09-07 교차 확인에서 나옴) ────────── -# -# 서브 창이 자기 파싱에서 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표를 놓쳐 자재·장비가 -# 통째로 빠지던 것을 잡았고, 같은 병이 여기 형태 판정에도 있었다 — 12장 임도 구조물 표 -# 8건이 `undetermined` 로 빠져 있었다. 다시 걸리지 않게 못 박는다. - - -def test_딱지형_표가_소요량형으로_판정될것() -> None: - """`['자재', 'PVC 지수판', 'm', '1.04']` — 첫 칸이 이름이 아니라 갈래다.""" - table = { - "headers": ["구 분", "단위", "적 용", "비 고", ""], - "rows": [ - ["자재", "PVC 지수판(200×5)", "m", "1.04", "4% 할증"], - ["용접봉", "kg", "0.042", "", ""], - ["철선(#8)", "kg", "0.21", "", ""], - ["인력(설치비)", "특별인부", "인", "0.151", ""], - ], - } - form, basis = detect_form(table, "12") - assert form == "requirement" - assert "딱지" in basis - - -def test_직종이_넷째_행에_있어도_잡힐것() -> None: - """첫 세 행만 보던 탓에 놓쳤던 모양 — 강관동바리의 형틀목공은 넷째 행이다.""" - table = { - "headers": ["구 분", "단위", "적 용", "비 고", "", ""], - "rows": [ - ["자재", "강관 동바리", "내관(48.6mm×2.4mm)", "본", "0.38", ""], - ["외관(60.6mm×2.3mm)", "본", "0.38", "", "", ""], - ["잡재료비(재료비의)", "%", "5", "", "", ""], - ["인력", "형틀목공", "인", "0.07", "", ""], - ], - } - assert detect_form(table, "12")[0] == "requirement" - - -def test_비율_지시_한_줄이_소요량표를_참조로_넘기지_않을것() -> None: - """⚠ 만들다 실제로 걸린 자리 — `잡재료비(재료비의) 5 %` 한 줄 때문에 강관동바리가 - 참조로 넘어갔다. **소요량을 먼저 보고** 비율 지시는 그 뒤에 본다.""" - ratio_only = { - "headers": ["구 분", "적 용", "비 고"], - "rows": [["재료비", "", ""], ["설치비", "재료비의 5%", ""]], - } - assert detect_form(ratio_only, "12")[0] == "reference" - - -def test_수치가_없으면_지어내지_않고_미판정으로_둘것() -> None: - """`['재료비', 'JOINT FILLER', '']` — 이름만 있고 소요량이 없다.""" - table = {"headers": ["구 분", "적 용", "비 고"], "rows": [["재료비", "JOINT FILLER", ""]]} - assert detect_form(table, "12")[0] == "undetermined" - - -def test_생산량형이_딱지_규칙에_안_밀릴것() -> None: - """딱지 규칙은 **맨 마지막**에 본다 — 앞의 판정을 흔들면 안 된다.""" - table = { - "headers": ["작업능력(㎥/hr)", "비고"], - "rows": [["자재", "보통인부 1인/일", "12.5"]], - } - assert detect_form(table, "9")[0] == "productivity" - - -# ── 딱지가 비율을 달고 오는 표 (2026-09-07 서브 창 제보로 확인) ───── -# -# `인력(10%)` · `장비(90%)` — 소요량형이면서 **장비 몫은 시공능력 공식**이라 값이 아니다. -# 형태 한 낱말로만 적으면 받는 쪽이 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. - - -def test_몫이_적힌_표는_몫을_실을것() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares - - table = { - "headers": ["구 분", "적 용", "비 고"], - "rows": [ - ["인력(10%)", "보통인부(인)", "0.23"], - ["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)", "k", "0.9"], - ], - } - assert resource_shares(table) == {"인력": 10.0, "장비": 90.0} - - -def test_몫이_없는_표는_빈칸() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares - - assert resource_shares({"rows": [["보통인부(인)", "0.16"]]}) == {} - - -def test_몫이_있으면_부분값으로_표시될것() -> None: - """⚠ 공식 기호가 **첫 표에만** 있고 이어지는 표는 물려받는다 — 기호가 있는 표만 세면 - 9-13-2 같은 절반을 놓친다. 넓게 잡되 **값을 지우지 않고 표시만** 한다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( - capacity_formula_pending, - resource_shares, - ) - - inherited = { - "headers": ["구 분", "적 용", "비 고"], - "rows": [ - ["인력 (10%)", "보통인부(인)", "0.26"], - ["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)"], - ], - } - assert resource_shares(inherited) # 몫은 있고 - assert not capacity_formula_pending(inherited) # 공식 기호는 이 표에 없다 - - -def test_형태_판정은_그대로_소요량형() -> None: - """몫 표시를 더해도 앞의 판정을 흔들지 않는다.""" - table = { - "headers": ["구 분", "적 용", "비 고"], - "rows": [["인력(10%)", "보통인부(인)", "0.23"], ["장비(90%)", "유압식백호우"]], - } - assert detect_form(table, "9")[0] == "requirement" - - -# ── 밑수는 표 안이 아니라 **표 바로 위 본문**에 있다 (2026-09-07) ──── -# -# 서브 창이 「기준 단위 없음으로 도는 단가가 125 중 122」를 보고했고 파 보니 뿌리가 여기였다. -# 「10㎡당」 표를 1㎡당으로 알면 **곱셈이 10배 틀린다.** -# ⚠ 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다. - - -def _basis(text_lines: list[str]) -> tuple[float | None, str | None]: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source - - # 마지막 줄이 표라고 보고, 그 줄 번호(1-기반)를 넘긴다. - return basis_from_source(text_lines, len(text_lines)) - - -def test_본문에서_밑수를_읽을것() -> None: - assert _basis(["### 12-2. 표면 마무리", "", "(단위: ㎡당)", "", "| 구 분 |"]) == (1.0, "㎡") - assert _basis(["### 5-1-4. 떼채취", "", "(100㎡당)", "", "| 구 분 |"]) == (100.0, "㎡") - - -def test_분모가_밑수인_꼴() -> None: - """`(단위: 인/㎡당)` — 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.""" - assert _basis(["### 13-4-1. 메쌓기(인력)", "", "(단위: 인/㎡당)", "", "| 뒷길이 |"]) == ( - 1.0, - "㎡", - ) - - -def test_당도_단위도_없으면_밑수가_아님() -> None: - """⚠ 만들다 실제로 걸린 자리 — `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.""" - assert _basis(["| 장비 | 유압식백호우 (무한궤도,0.7㎥) |", "", "| 구 분 |"]) == (None, None) - - -def test_앞_표의_밑수를_물어오지_않을것() -> None: - """위로 거슬러 보되 다른 표에 닿으면 멈춘다 — 남의 밑수를 물어 오면 조용히 틀린다.""" - lines = ["(100㎡당)", "| 앞 표 |", "| 값 |", "", "### 9-21. 제근", "", "| 종 류 |"] - assert _basis(lines) == (None, None) - - -def test_묶음_기준_판정() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_quantity_is_grouped - - assert basis_quantity_is_grouped(10.0) and basis_quantity_is_grouped(100.0) - assert not basis_quantity_is_grouped(1.0) - assert not basis_quantity_is_grouped(None) - - -def test_산출물에_밑수_미확보_목록이_나올것() -> None: - """빈칸으로 두면 「1단위당」으로 오해된다 — 곱하면 안 되는 줄을 받는 쪽이 가릴 수 있게 낸다.""" - import json - - path = ROOT / "resources" / "data_work_item_master" / "basis_missing_2026-01-01.json" - payload = json.loads(path.read_text(encoding="utf-8")) - assert payload["items"] - # 곱할 값이 아닌 형태(참조·계수)는 목록에 안 담는다 — 잡음이 되면 안 본다. - assert {item["pum_form"] for item in payload["items"]} <= {"requirement", "productivity"} - - -def test_실물_묶음_기준이_실제로_실릴것() -> None: - """13-6-2 찰쌓기는 원문이 `(단위: 10㎡당)` 이다 — 1 로 두면 10배 틀린다.""" - import json - - path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" - master = json.loads(path.read_text(encoding="utf-8")) - tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} - assert tables["F0420"]["basis_quantity"] == 10.0 - assert tables["F0420"]["basis_unit"] == "㎡" - assert tables["F0420"]["basis_source"] == "본문" - - -# ── 값 자리에 식이 적힌 칸 (2026-09-07 서브 창 제보) ──────────────── -# -# `0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적혀 있으면 **값이 숫자로 안 읽혀 -# 그 성분이 조용히 빠진다.** 형태 판정은 통과하므로 어떤 형태 검사에도 안 걸린다. -# ⚠ 식을 **계산하지 않는다** — 뜻을 잘못 읽으면 조용히 틀리므로 드러내기만 한다. - - -def test_식이_적힌_칸을_찾을것() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells - - table = { - "headers": ["구 분", "적 용", "비 고"], - "rows": [ - ["부설다짐", "할석공(인)", "0.6"], - ["소할(30%)", "할석공(인)", "0.2 × 30%"], - ], - } - assert expression_cells(table) == ["0.2 × 30%"] - - -def test_평범한_숫자는_식이_아님() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells - - assert expression_cells({"rows": [["보통인부(인)", "0.16"], ["계", "1,234.5"]]}) == [] - - -def test_긴_설명문은_식이_아님() -> None: - """⚠ 좁게 잡는다 — 비고의 설명문까지 걸면 목록이 잡음이 되어 결국 안 본다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells - - rows = [["비고", "- 지주목을 세우지 않을 때는 인력품의 10%를 감한다"]] - assert expression_cells({"rows": rows}) == [] - - -def test_기초잡석이_실제로_잡힐것() -> None: - """서브가 실물에서 부딪힌 그 표 — 부설다짐 0.6인만 서고 소할 몫이 빠져 있었다.""" - import json - - path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" - master = json.loads(path.read_text(encoding="utf-8")) - tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} - assert tables["F0373"]["expression_cells"] == ["0.2 × 30%"] - # 배분율 딱지가 없어 `partial_ratio` 로는 안 걸리는 자리다 — 그래서 깃발이 따로 필요했다. - assert tables["F0373"]["partial_ratio"] is False - - -# ── 작업조 표 · 「단 위」 표지 제거 (2026-09-07 서브 창 제보) ──────── -# -# ⚠ 「형틀목공 4인 + 보통인부 1인 / 시공량 35㎡」의 **「4」는 소요량이 아니라 인원**이다. -# 행-자원으로 그냥 읽으면 **35배 부푼다**. 값을 바꾸지 않고 표시만 한다. - - -def test_작업조_표를_알아볼것() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table - - table = { - "headers": ["구 분", "단 위", "수 량", "시 공 량 (㎡)"], - "rows": [ - ["복 잡", "보 통", "간 단"], - ["형틀목공 보통인부", "인 인", "4 1", "25", "35", "40"], - ], - } - assert crew_table(table) is True - - -def test_평범한_소요량표는_작업조가_아님() -> None: - """⚠ 짝 시험 — 넓게 잡으면 멀쩡한 소요량표가 다 걸린다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table - - assert ( - crew_table({"headers": ["구 분", "단 위", "수 량"], "rows": [["미 장 공", "인", "0.34"]]}) - is False - ) - - -def test_헤더_단위_만으로_참조가_되지_않을것() -> None: - """⚠ 이 시험이 이번 건의 핵심 — 「단위 열이 있다」는 소요량표의 흔한 모양이지 - 참조표의 표지가 아니다. 그 표지 하나로 **41건이 버려지고 있었고** 그 안에 - **돌쌓기(장비) 13-4-5**(우리 매핑이 실제로 쓰는 코드)가 있었다.""" - table = { - "headers": ["구 분", "단 위", "수 량"], - "rows": [["돌 쌓 기 공", "인", "0.32"], ["보통인부", "인", "0.28"]], - } - assert detect_form(table, "13")[0] == "requirement" - - -def test_돌쌓기_장비가_실제로_공종으로_설것() -> None: - """매핑이 `masonry_wet → FP-13-04-05` 로 쓰는 표다 — 참조로 버려지면 단가가 안 선다.""" - import json - - path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" - master = json.loads(path.read_text(encoding="utf-8")) - tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} - assert tables["F0414"]["pum_form"] == "requirement" # 13-4-5 찰쌓기(장비) - assert tables["F0407"]["pum_form"] == "requirement" # 13-4-2 메쌓기(장비) - - -def test_작업조_깃발이_산출물에_실릴것() -> None: - import json - - path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" - master = json.loads(path.read_text(encoding="utf-8")) - tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} - assert tables["F0395"]["crew_table"] is True # 유로폼 설치·해체 - assert tables["F0132"]["crew_table"] is True # 평떼 시비 — 서브가 실물에서 부딪힌 표 - assert tables["F0334"]["crew_table"] is False # 표면 마무리는 평범한 소요량표 - - -# ── 이름 표기 갈림 · 공식 기호 줄 (2026-09-07 서브 창 제보) ───────── -# -# ⚠ 같은 표 묶음 안에서도 표기가 갈린다 — `13-6-1` 은 「굴 삭 기 (무한궤도)」, -# 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」. 받는 쪽이 이름으로 자원을 찾으므로 -# 표기가 갈리면 **그 줄이 통째로 빠진다.** -# ⚠⚠ **여기서 이름을 고치지 않는다.** 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** — -# 서브 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 사용료가 이중이 됐다. - - -def test_자간_공백_자원_이름을_찾을것() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names - - table = { - "rows": [["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84"], ["작업반장", "", "인", "0.83"]] - } - assert spaced_names(table) == ["굴 삭 기 (무한궤도)"] - - -def test_머리글_재료명은_안_걸릴것() -> None: - """⚠ 짝 시험 — 「단 위」·「모 래」까지 걸면 101건이 되어 목록이 잡음이 된다. - **단위 칸과 수치가 함께 있는 자원 줄**만 본다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names - - assert spaced_names({"rows": [["단 위", "㎥"], ["모 래", "6"]]}) == [] - - -def test_공식_기호_줄을_찾을것() -> None: - """`K`·`f`·`E` 는 시공능력 공식 파라미터다 — 자원으로 세면 이중계상.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import formula_rows - - table = {"rows": [["k", "0.9"], ["f", "1/1.3"], ["보통인부", "인", "0.23"]]} - assert set(formula_rows(table)) == {"k", "f"} - - -def test_서브가_부딪힌_표가_실제로_잡힐것() -> None: - import json - - path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" - master = json.loads(path.read_text(encoding="utf-8")) - tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} - assert tables["F0419"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-6-1 메쌓기 - assert tables["F0423"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-7-2 찰붙이기 - # 바로 옆 13-6-2 찰쌓기는 공백이 없다 — 그래서 표기가 갈린다. - assert tables["F0420"]["spaced_names"] == [] - assert "k" in tables["F0258"]["formula_rows"] # 9-12-1 측구터파기 토사 - - -# ── 규격 표기 특수문자 (2026-09-07 갈래 계약) ────────────────────── -# -# ⚠ 원문이 한 종류로 안 쓴다 — 물결표만 셋(`∼` 662 · `~` 459 · `~` 2), 곱셈표 둘. -# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다.** -# 그래서 인계는 키를 조립하지 않고 **저장 원본값만** 보낸다. 여기서는 표시만 한다. - - -def test_특수문자_종류를_찾을것() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs - - table = {"rows": [["직경 40㎝이상∼60㎝미만", "600 x 1,200mm"]]} - found = special_glyphs(table) - assert "∼(U+223C)" in found and "x(U+0078)" in found - - -def test_평범한_글자는_안_걸릴것() -> None: - """⚠ 짝 시험 — 넓게 잡으면 목록이 잡음이 된다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs - - assert special_glyphs({"rows": [["보통인부", "인", "0.16"]]}) == [] - - -def test_물결표가_두_종류_섞인_표가_실제로_있을것() -> None: - """⚠ 이것이 계약을 바꾼 근거다 — 같은 절 안에서도 표기가 갈린다.""" - import json - - path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" - master = json.loads(path.read_text(encoding="utf-8")) - tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} - mixed = [ - tid - for tid, t in tables.items() - if sum(1 for g in t.get("special_glyphs") or [] if "U+223C" in g or "U+FF5E" in g) >= 2 - ] - assert mixed, "물결표가 섞인 표를 못 찾았다 — 계약 근거가 사라졌는지 확인할 것" - - -# ── 참조 표지 — 41건을 지웠던 그 규칙의 짝 시험 (2026-09-08 ㉘) ──────────────── -# 넓히면 정상 공종이 사라지고, 좁히면 기준표가 공종으로 선다. 양쪽을 다 박는다. - - -def _표(headers: list[str], rows: list[list[str]] | None = None) -> dict: - return {"headers": headers, "rows": rows or [["보통인부", "인", "1.0"]]} - - -def test_참조_표지가_기준표를_잡을것() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form - - for mark in REFERENCE_MARKS: - form, why = detect_form(_표([mark, "구분", "비율"]), "13") - assert form == "reference", f"'{mark}' 를 못 잡는다 — {why}" - - -def test_단위_열이_있다고_공종을_버리지_말것() -> None: - """⚠ 2026-09-07 사고 — 헤더 「단 위」 하나로 **41건**이 참조로 버려졌다. - - 그 안에 돌쌓기(장비) `13-4-5`·`13-4-2` 처럼 **우리 매핑이 실제로 쓰는 공종**이 있었다. - 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. - """ - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form - - assert "단위" not in [m.replace(" ", "") for m in REFERENCE_MARKS] - form, _why = detect_form(_표(["명칭", "규격", "단 위", "수량"]), "13") - assert form != "reference" - - -def test_참조_표지를_부분일치로_넓히지_말것() -> None: - """정상 공종 이름에 표지 글자가 스쳐도 걸리면 안 된다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form - - for headers in (["명칭", "규격", "단위", "수량"], ["직종", "단위", "수량"]): - form, _why = detect_form(_표(headers), "9") - assert form != "reference", headers - - -# ── 배수관 유입부 집수정 — 부속 줄 규칙의 짝 시험 (2026-09-08 ㉘) ────────────── - - -def test_부속은_그_칸이_있을_때만_줄이_선다() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of - - 관 = {"structure_id": "s1", "type_id": "pipe", "options": {}} - assert attachments_of(관) == [], "집수정 형식을 안 골랐는데 줄이 섰다" - 관["options"] = {"inlet_basin_form": "돌집수정 ㄷ형"} - 붙은것 = attachments_of(관) - assert [r["type_id"] for r in 붙은것] == ["pipe_inlet_basin"] - assert 붙은것[0]["attachment_of"] == "s1" - assert 붙은것[0]["structure_id"] != "s1", "부모와 같은 id 면 두 줄이 한 줄로 뭉친다" - - -def test_부속이_없는_종류에는_아무것도_안_붙는다() -> None: - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of - - 옹벽 = {"structure_id": "s2", "type_id": "retaining_wall", "options": {"inlet_basin_form": "ㄷ형"}} - assert attachments_of(옹벽) == [] - - -# ── 빈 단가산출서 서식 (2026-09-08 ㉙, 서브가 찾아 준 것) ───────────────────── - - -def test_빈_단가산출서_서식은_공종이_아님() -> None: - """품셈이 실어 둔 **채워 넣으라고 둔 양식** — 값이 아예 없다. - - 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다. - 실측: 이 규칙 하나로 견적 쪽 못 맞춘 자원이 **882 → 497** 로 줄었다. - """ - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form - - 표 = { - "headers": ["ha당 참나무시들음병방제 단가산출서(예시)", "", ""], - "rows": [["위치 및 임․소반", "1-0-1-0 또는 00임반 00소반", "비고"]], - } - form, why = detect_form(표, "4") - assert form == "reference", why - - -def test_머리글이_없어도_서식_모양으로_가름() -> None: - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form - - 표 = { - "headers": [], - "rows": [ - ["위치 및 면적", "00임반 00소반", "비 고"], - ["구분", "작업량", "단위품 (인원,수량,요율)", "소요품", "단가(원)"], - ], - } - assert detect_form(표, "3")[0] == "reference" - - -def test_서식_규칙이_정상_표를_지우지_말것() -> None: - """⚠ 「구분」·「단가」는 정상 표에도 흔하다 — 하나만 보면 정상 표가 사라진다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form - - 소요량표 = {"headers": ["명칭", "규격", "단위", "수량"], "rows": [["보통인부", "", "인", "1.0"]]} - assert detect_form(소요량표, "13")[0] == "requirement" - 구분표 = { - "headers": [], - "rows": [["구 분", "소요인력", "인력구성"], ["작업로 예정선 선정", "1.0", "초급기술자"]], - } - assert detect_form(구분표, "3")[0] != "reference" - - -def test_마스터에_빈_서식이_공종으로_남아있지_않을것() -> None: - """정본 대조 — 다시 만든 마스터에 그 표가 소요량표로 서 있으면 깨진다.""" - import json - from pathlib import Path - - master = json.loads( - ( - Path(__file__).resolve().parents[2] - / "resources" - / "data_work_item_master" - / "work_item_master_2026-01-01.json" - ).read_text(encoding="utf-8") - ) - 남은것: list[str] = [] - - def walk(node: object) -> None: - if isinstance(node, dict): - for table in node.get("tables") or []: - head = "".join( - str(c) for row in (table.get("raw_row") or [])[:1] for c in row - ).replace(" ", "") - if "위치및" in head and table.get("pum_form") != "reference": - 남은것.append(str(table.get("pum_table_id"))) - for value in node.values(): - walk(value) - elif isinstance(node, list): - for value in node: - walk(value) - - walk(master) - assert not 남은것, f"빈 서식이 아직 공종으로 서 있다: {sorted(set(남은것))}" - - -# ── 밑수가 표 아래 [주] 에만 있는 자리 (2026-09-08, B09 제보) ────────────────── - - -def test_규준틀_밑수를_표_아래_주에서_읽는다() -> None: - """⚠ 「원문에 있었음」 아홉 번째 — 이번엔 표 **아래** [주] 였다. - - 11-2·11-3 은 표에도 표 위 본문에도 밑수가 없고, [주]③ 「목재의 손율은 - **1개소 사용당** 50%로 한다」에만 「개소」가 나온다. 뜻으로도 개소가 맞다 — - [주]② 가 「본 품은 …한 비탈규준틀의 제작·도색·가설·철거를 포함한 것」이다. - """ - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source - - lines = [ - "### 11-2. 토공의 비탈 규준틀", - "| 종 류 | 단 위 | 수 량 |", - "|---|---|---|", - "| 건축목공 | 인 | 0.16 |", - "[주] ① 비탈길이 10m 이상 20m마다 설치한다.", - "- ③ 목재의 손율은 1개소 사용당 50%로 한다.", - ] - assert basis_from_source(lines, 2) == (1.0, "개소") - - -def test_개소당_평균면적_같은_흔한_말에는_안_걸린다() -> None: - """⚠ 넓히면 조림 2장이 통째로 걸린다 — 실측 19건. 「사용」이 있어야 잡는다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source - - lines = [ - "### 2-1. 풀베기", - "| o 집단화 정도(1-4-3) | 개소당 평균면적이 1~3ha 미만 | 5% |", - ] - assert basis_from_source(lines, 2) == (None, None) - - -def test_아래_주를_보되_다음_절까지_넘어가지_말것() -> None: - """남의 [주] 를 물어 오면 조용히 틀린다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source - - lines = [ - "### 11-9. 밑수가 없는 표", - "| 종류 | 단위 | 수량 |", - "|---|---|---|", - "### 11-2. 토공의 비탈 규준틀", - "- ③ 목재의 손율은 1개소 사용당 50%로 한다.", - ] - assert basis_from_source(lines, 2) == (None, None) - - -def test_마스터의_규준틀_둘에_밑수가_실려있을것() -> None: - """정본 대조 — 다시 만든 마스터에 값이 실제로 들어갔는가.""" - import json - from pathlib import Path - - master = json.loads( - ( - Path(__file__).resolve().parents[2] - / "resources" - / "data_work_item_master" - / "work_item_master_2026-01-01.json" - ).read_text(encoding="utf-8") - ) - 찾음: dict[str, tuple] = {} - - def walk(node: object) -> None: - if isinstance(node, dict): - code = node.get("work_item_code") - if code in ("FP-11-02", "FP-11-03"): - for table in node.get("tables") or []: - 찾음[str(code)] = (table.get("basis_quantity"), table.get("basis_unit")) - for value in node.values(): - walk(value) - elif isinstance(node, list): - for value in node: - walk(value) - - walk(master) - assert 찾음 == {"FP-11-02": (1.0, "개소"), "FP-11-03": (1.0, "개소")}, 찾음 diff --git a/resources/tester/test_b09_basis_sheet.py b/resources/tester/test_b09_basis_sheet.py deleted file mode 100644 index c6d0ebd28..000000000 --- a/resources/tester/test_b09_basis_sheet.py +++ /dev/null @@ -1,87 +0,0 @@ -"""산출기초 — 줄에 달린 근거를 한 장으로 (2026-09-09). - -별표2 (5)(가) 열셋째. **새로 세는 장이 아니다** — 이미 줄마다 달려 있는 근거를 모은다. - -⚠ 겨누는 것 다섯 - ① 어느 판으로 계산했나 — **장부(`_manifest.json`)를 그대로** 읽는다(목록을 따로 안 적음) - ② 고른 값만 싣는다 — 안 고른 칸은 기본값이라 줄이 안 선다 - ③ 근거는 **줄에 달린 문구 그대로** — 여기서 새로 짓지 않는다 - ④ 못 채운 자리는 **사유째** 모은다(0 으로 때운 자리가 아님) - ⑤ 여기서 **값을 다시 계산하지 않는다** — 일위대가가 낸 값과 두 벌이 되면 안 된다 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_BasisSheet import ( # noqa: E402 - basis_sheet, - chosen_conditions, - dataset_versions, - work_item_basis, -) -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 - - -def test_판_목록은_장부를_그대로_읽는다() -> None: - manifest = json.loads( - (ROOT / "resources" / "data_cost_input_value" / "_manifest.json").read_text( - encoding="utf-8" - ) - ) - ledger = {str(row["file"]) for row in manifest["files"]} - rows = dataset_versions() - assert ledger <= {row["file"] for row in rows} - # 공종 마스터도 함께 실린다 — 품셈 원문을 정규화한 판이다. - assert any(row["dataset_id"] == "work_item_master" for row in rows) - for row in rows: - assert row["effective_date"], row - - -def test_고른_값만_싣는다() -> None: - assert chosen_conditions({}) == [] - assert chosen_conditions({"misc_material_percent": "", "fuel_region": ""}) == [] - picked = chosen_conditions({"fuel_region": "01", "transport_distance_km": "12"}) - assert [row["value"] for row in picked] == ["01", "12"] - - -def test_품_할증은_고른_것을_풀어_적는다() -> None: - picked = chosen_conditions({"labor_surcharge": {"1-4-1": "1-4-1:0"}}) - assert len(picked) == 1 - assert "10%" in picked[0]["item"] - assert "작업시기" in picked[0]["value"] - - -def test_근거는_줄에_달린_문구_그대로다() -> None: - build = cached_build() - rows = {row["code"]: row for row in work_item_basis(build)} - assert rows, "근거가 달린 공종이 하나도 없습니다" - for code, row in rows.items(): - notes = { - detail.note.strip() - for detail in build.book.details.get(code, []) - if detail.note and detail.note.strip() - } - # 줄에 없는 문구를 지어내지 않는다(공종 단위 출처 한 줄만 더 붙을 수 있다). - assert notes <= set(row["notes"]) - - -def test_못_채운_자리는_사유째_모은다() -> None: - sheet = basis_sheet(cached_build(), {}) - assert sheet["gaps"], "못 채운 자리가 하나도 없다고 나옵니다 — 셈이 이상합니다" - for row in sheet["gaps"]: - assert row["kind"] and row["code"] and row["reason"] - - -def test_값을_다시_계산하지_않는다() -> None: - """⑤ 이 장에는 금액 칸이 없다 — 금액은 일위대가·내역서 몫이다.""" - sheet = basis_sheet(cached_build(), {}) - text = json.dumps(sheet, ensure_ascii=False) - assert "총액" not in text and "합계 금액" not in text - for row in sheet["work_items"]: - assert set(row) == {"code", "name", "unit", "notes"} diff --git a/resources/tester/test_b09_bill_code_link.py b/resources/tester/test_b09_bill_code_link.py deleted file mode 100644 index 37e8670cb..000000000 --- a/resources/tester/test_b09_bill_code_link.py +++ /dev/null @@ -1,91 +0,0 @@ -"""B08 → B09 내역 받는 문 — 이름이 아니라 코드로 잇기 (2026-09-13, PLAN 6장 첫 일감 ①). - -겨누는 것 - ① 운반 줄 단가는 **그 줄로 선 내역 줄**에서 — 이름이 같은 줄(도자운반 토사·리핑암)이 둘이어도 - 첫 줄 단가가 둘째에 안 붙음(종전 `row.name == item.name` 실결함) - ② 총 절취량은 **코드**(9-3·9-4·9-5)로 셈 — 줄 이름에 「깎기」가 없어도 검산이 돎 -""" - -from __future__ import annotations - -from decimal import Decimal -from functools import lru_cache - -import pytest - -import B09_Estimation.B09_Estimation_BillOfQuantities as bill_module -from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices - - -@lru_cache(maxsize=1) -def _build(): - return build_unit_prices() - - -def _row(code: str, name: str, quantity: float, **extra) -> dict: - return {"work_item_code": code, "name": name, "unit": "㎥", "quantity": quantity, **extra} - - -def test_운반_줄_단가는_이름이_아니라_그_줄에서_읽는다(monkeypatch: pytest.MonkeyPatch) -> None: - seen: list[list[dict]] = [] - monkeypatch.setattr( - bill_module, "check_free_haul_not_priced", lambda haul_rows: seen.append(haul_rows) - ) - payload = { - "work_items": [ - _row("FP-10-11", "dozer 운반", 10, haul_equipment="dozer", variant_value="토사"), - # 같은 이름 · 표에 없는 갈래라 단가가 안 섬. - _row("FP-10-11", "dozer 운반", 4, haul_equipment="dozer", variant_value="없는갈래"), - ], - "materials": [], - } - bill = build_bill(payload, build=_build()) - priced = [r for r in bill.rows if not r.is_group] - assert priced[0].unit_price_krw and priced[0].unit_price_krw > 0 - assert priced[1].unit_price_krw is None - prices = [row["unit_price_krw"] for row in seen[0]] - assert prices == [priced[0].unit_price_krw, Decimal(0)] # 종전엔 둘 다 첫 줄 단가 - - -def test_총_절취량은_코드로_센다(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[dict] = [] - monkeypatch.setattr( - bill_module, "check_haul_volume_within_cut", lambda **kwargs: calls.append(kwargs) - ) - payload = { - "work_items": [ - # 이름에 「깎기」가 없음(마스터 표기 「토사깍기」) — 코드로만 절취임을 앎. - _row("FP-09-03-02", "토사깍기", 10), - _row("FP-09-12-01", "측구터파기", 3), # 절취 셈에 안 듦(종전과 같음) - _row("FP-10-11", "dozer 운반", 5, haul_equipment="dozer", variant_value="토사"), - ], - "materials": [], - } - build_bill(payload, build=_build()) - assert calls and calls[0]["total_cut_volume_m3"] == Decimal(10) - assert calls[0]["haul_volume_total_m3"] == Decimal(5) - - -def test_느슨한_갈래_맞춤과_못_맞춘_수량_자리는_로그로_드러난다( - caplog: pytest.LogCaptureFixture, -) -> None: - """명세 14장 정정(브레인 판정 (나)) — 조용히 다른 갈래에 붙는 자리를 로그로.""" - import B09_Estimation.B09_Estimation_QuantityDigits as digits_module - import B09_Estimation.B09_Estimation_UnitPrice as unit_price_module - - unit_price_module._LOOSE_LOGGED.clear() - digits_module._UNMATCHED_LOGGED.clear() - caplog.set_level("INFO") - build = _build() - # 글자 그대로 맞으면 로그 없음. - assert unit_price_module.find_variant_code("FP-13-04-05", "55cm이하", build) - assert "느슨한 맞춤" not in caplog.text - # 한 값 45 → 구간 「55cm이하」 — 느슨한 맞춤이라 로그. - assert unit_price_module.find_variant_code("FP-13-04-05", "45", build).endswith("#55cm이하") - assert "느슨한 맞춤(구간): FP-13-04-05 「45」" in caplog.text - # 수량 자리 — 종목 이름을 맞추면 조용하고, 못 맞추면 단위 자리로 가며 로그. - assert digits_module.digits_for("돌쌓기", "㎡") == 1 - assert "종목 이름을 못 맞춤" not in caplog.text - assert digits_module.digits_for("사토 운반", "㎥") == 2 - assert "종목 이름을 못 맞춤: 「사토 운반」 ㎥ → 단위만으로 2자리" in caplog.text diff --git a/resources/tester/test_b09_bill_sheet_view.py b/resources/tester/test_b09_bill_sheet_view.py deleted file mode 100644 index 4dbfbbc65..000000000 --- a/resources/tester/test_b09_bill_sheet_view.py +++ /dev/null @@ -1,98 +0,0 @@ -"""B09 내역서 화면 자료 — 화면이 곱하거나 더하지 않게 서버가 싣는 칸 (PLAN 12장 1차). - -겨누는 것 - ① 내역 줄 성분 단가 × 수량 = 성분 금액(성분마다 절사) — 화면은 실린 값만 찍음 - ② 머리글 줄 금액 = 아래 줄 금액의 합(성분마다) · 본체 합계엔 안 들어감 - ③ 일위대가 호표 번호 — 내역에 처음 쓰인 차례 · 같은 호표는 한 번 · 안에서 부르는 호표는 뒤에 - ④ 단산 목록 — 부르는 일위대가 전부 · 성분 금액 -""" - -from __future__ import annotations - -from decimal import Decimal -from functools import lru_cache - -from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line, build_bill -from B09_Estimation.B09_Estimation_PriceBook import ( - Money3, - PriceBook, - PriceDetail, - PriceKind, - PriceTitle, -) -from B09_Estimation.B09_Estimation_UnitPrice import _slots, build_unit_prices -from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet - - -@lru_cache(maxsize=1) -def _bill(): - payload = { - "work_items": [ - {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 123.4}, - {"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7}, - {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 5}, - ], - "materials": [], - } - return build_bill(payload, build=build_unit_prices()) - - -def test_줄_성분_단가와_금액이_실린다() -> None: - priced = [r for r in _bill().rows if not r.is_group and r.amount_krw is not None] - assert priced - for row in priced: - unit = Money3(row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw) - line = bill_line(unit, row.quantity) - assert (line.material, line.labor, line.expense) == ( - row.material_krw, - row.labor_krw, - row.expense_krw, - ) - assert row.amount_krw == line.total - body = row.as_dict() - assert body["price_code"] == row.price_code and body["unit_labor_krw"] is not None - - -def test_머리글_줄은_아래_줄_합이고_본체엔_안_든다() -> None: - bill = _bill() - detail_total = sum(r.amount_krw or 0 for r in bill.rows if not r.is_group) - assert bill.body_total_krw == detail_total - for group in (r for r in bill.rows if r.is_group): - children = [ - r - for r in bill.rows - if not r.is_group and r.item_no.startswith(f"{group.item_no}-") and r.amount_krw - ] - assert group.amount_krw == sum(r.amount_krw for r in children) - assert group.labor_krw == sum(r.labor_krw for r in children) - - -def test_호표_번호는_처음_쓰인_차례이고_안에서_부르는_호표는_뒤에() -> None: - book = PriceBook() - book.add_title(PriceTitle("L", PriceKind.LABOR, "보통인부", slots=_slots(Decimal(100000)))) - for code in ("B-A", "B-B", "B-C"): - book.add_title(PriceTitle(code, PriceKind.UNIT_PRICE, code)) - book.add_detail(PriceDetail("B-C", "L", Decimal(1))) - book.add_detail(PriceDetail("B-A", "B-C", Decimal(2))) # A 안에서 C 를 부름 - book.add_detail(PriceDetail("B-B", "L", Decimal(1))) - - class Row: - def __init__(self, code: str) -> None: - self.price_code, self.name, self.spec, self.unit = code, code, "", "㎥" - - sheet = build_unit_price_sheet([Row("B-B"), Row("B-A"), Row("B-B"), Row("")], book) - assert [(e.number, e.code) for e in sheet.entries] == [(1, "B-B"), (2, "B-A"), (3, "B-C")] - assert sheet.entries[1].as_dict()["label"] == "제 2 호표" - assert sheet.entries[1].money.labor == 200000 - - -def test_내역에_호표_목록과_단산_목록이_함께_실린다() -> None: - bill = _bill() - codes = [e.code for e in bill.unit_price_sheet.entries] - used = list(dict.fromkeys(r.price_code for r in bill.rows if r.price_code)) - assert [c for c in codes if c in used] == used # 안에서 부르는 호표가 끼어도 차례는 그대로 - for entry in bill.price_basis.entries: - body = entry.as_dict() - assert body["unit_price_codes"] and Money3( - *(int(body[k]) for k in ("material_krw", "labor_krw", "expense_krw")) - ).total == int(body["unit_price_krw"].split(".")[0]) diff --git a/resources/tester/test_b09_brushcutter.py b/resources/tester/test_b09_brushcutter.py deleted file mode 100644 index 5e65e1101..000000000 --- a/resources/tester/test_b09_brushcutter.py +++ /dev/null @@ -1,102 +0,0 @@ -"""예취기·분무기 인력 표 한 모양 넷 — 2026-09-15 브레인 차례 ② (사유 없던 49 다음). - -산림 6-2-2 줄베기 · 6-2-3 모두베기 · 6-4-1 덩굴걷기 · 6-4-2 덩굴 약제 표는 「공법 | (갈래) | 사용도구 | 단위 | 소요인력 | -인력구분」 줄 — 이름이 끝 칸이라 줄 읽기가 한 줄도 못 읽었음(6-4-1 은 사유조차 없었음). - 6-2-2 묘목찾기 낫 인/100본 0.07 보통인부 · 줄베기 1,500본 미만 1.50 · 1,500~3,000 2.00 · 3,000 이상 2.50 특별인부(인/ha) - 6-2-3 묘목찾기 0.07 · 모두베기 조림 당해 연도 3.50 · 2년차 4.00 · 3년차 이상 5.00 - 6-4-1 기계작업 예취기 인/ha 3.30 특별인부 · 6-4-2 약제살포 배부식분무기 1.00 특별인부 + 작업보조 1.50 보통인부 -⭐ 6-2-2·6-2-3 [주]④⑤ 「묘목찾기와 줄베기(모두베기) 품을 **합하여** 적용」 — 조림목 본수(본/ha)는 설계 입력 칸 · - 비면 「일부만」 으로 막음(반만 선 틀린 값보다 안 선 값) · 제안값 없음(부록 예시 2,700본/ha 는 품도 표와 다른 자료) -2장: 예취기 휘발유 5.0ℓ/대/일 · 잡품 10%(2-1-1 2 [주]① 줄베기·모두베기·지상부 덩굴걷기에만) · 손료 0.0084(2-2-2) · - 배부식분무기 손료 0.0084(2-2-4) — 체인톱 한 벌 길 · 1대당 1인 -""" - -from __future__ import annotations - -from decimal import Decimal - -import pytest - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -CUTTER = "X-AR-X-da716e54" -SPRAYER = "X-AR-X-5d0a0416" - - -def _rows(book, title: str) -> dict[str, Decimal]: - return {r.ref_code: r.quantity for r in book.details[title]} - - -def test_덩굴걷기는_특별인부와_예취기_1대_1일() -> None: - build = cached_build() - rows = _rows(build.book, "B-FP-06-04-01") - assert rows["1003"] == Decimal("3.30") and rows[CUTTER] == Decimal("3.30"), rows - assert "FP-06-04-01" not in build.partial_ratio and "FP-06-04-01" not in build.basis_missing - assert build.book.titles["B-FP-06-04-01"].unit == "ha" - cutter = _rows(build.book, CUTTER) - assert cutter["M-FUEL-휘발유"] == Decimal("5.0"), cutter - misc = next(r for r in build.book.details[CUTTER] if r.percent_of_material is not None) - assert misc.percent_of_material == Decimal(10) - assert "예취기가격" in " ".join(build.unattached["FP-06-04-01"]) - - -def test_덩굴_약제는_두_인력을_더하고_분무기_손료_칸() -> None: - build = cached_build() - rows = _rows(build.book, "B-FP-06-04-02") - assert rows["1003"] == Decimal("1.00") and rows["1002"] == Decimal("1.50"), rows - assert SPRAYER not in rows # 연료 없는 장비 — 구입가가 없으면 빈 호표를 안 세움 - assert "배부식분무기가격" in " ".join(build.unattached["FP-06-04-02"]) - assert "FP-06-04-02" not in build.basis_missing # 단위 칸 인/ha 가 밑수 - priced = cached_build(material_prices=(("AR-M-d6394831", "150000", "견적"),)) - assert _rows(priced.book, "B-FP-06-04-02")[SPRAYER] == Decimal("1.00") - assert priced.book.resolve(SPRAYER).expense == Decimal("1260.0000") # 150,000 × 0.0084 - - -@pytest.mark.parametrize( - ("code", "variants"), - [ - ( - "FP-06-02-02", - {"1,500본미만": "1.50", "1,500본이상3,000본미만": "2.00", "3,000본이상": "2.50"}, - ), - ( - "FP-06-02-03", - { - "조림당해연도(전년도추기조림포함)": "3.50", - "조림2년차": "4.00", - "조림3년차이상": "5.00", - }, - ), - ], -) -def test_풀베기는_갈래마다_특별인부_예취기_묘목찾기는_본수_칸(code, variants) -> None: - empty = cached_build() - for key, people in variants.items(): - rows = _rows(empty.book, f"B-{code}#{key}") - assert rows["1003"] == Decimal(people) and rows[CUTTER] == Decimal(people), rows - assert "1002" not in rows # 본수가 없으면 묘목찾기를 안 셈 - assert code in empty.partial_ratio # 반만 선 값으로 안 붙게 막음 - assert "조림목 본수" in empty.component_gaps[code], empty.component_gaps.get(code) - filled = cached_build(seedlings_per_ha="2700") - for key in variants: - rows = _rows(filled.book, f"B-{code}#{key}") - assert rows["1002"] == Decimal("0.07") * Decimal(27), rows # 0.07인/100본 × 2,700본/ha - assert code not in filled.partial_ratio - assert "조림목 본수" not in (filled.component_gaps.get(code) or "") - - -def test_본수_칸은_양수만_받음() -> None: - from B09_Estimation.B09_Estimation_Brushcutting import parse_seedlings_per_ha - - assert parse_seedlings_per_ha("") is None - assert parse_seedlings_per_ha("2,700") == Decimal(2700) - for bad in ("0", "-5", "많이"): - with pytest.raises(ValueError): - parse_seedlings_per_ha(bad) - - -def test_부록_예시와_표의_어긋남은_기록만() -> None: - note = known_gap_note("FP-06-02-02") - assert "부록" in note and "0.10" in note and "0.07" in note, note - assert "2,700" not in (cached_build().component_gaps.get("FP-06-02-02") or "") # 제안값 없음 diff --git a/resources/tester/test_b09_chisel.py b/resources/tester/test_b09_chisel.py deleted file mode 100644 index 4e4d9d06e..000000000 --- a/resources/tester/test_b09_chisel.py +++ /dev/null @@ -1,85 +0,0 @@ -"""치즐 소모량(Ⓐ-3 · 2026-09-14 브레인 판정) — **칸을 채우면 치즐 공종 상태가 움직이는가**로 잼. - -「칸이 섰다」가 아니라 「칸을 채우면 못 붙은 줄이 풀린다」가 판정(81공종 다시 셈에서 칸을 다 채워도 -상태가 안 움직였던 까닭이 치즐 15공종). 단가는 없음 — 시험의 223,000 원은 실무 원본(영월 M00118) -대조용 값이고 프로그램에 넣지 않음. - -근거 — 실무 원본 단가산출근거 「(2) 치즐 손료 · M=0.006 × 223,000 / Q = 382.2 W/㎥」(재료비) · -품셈 제8장 [주]⑤ 「브레이커 … 손료 및 치즐 소모율을 **추가**」(기계 손료에 안 듦 → 이중계상 아님). -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_Chisel import CHISEL_CODE -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -#: 기본 조립에서 「치즐소모량」 이 못 붙은 줄로 남던 15공종. -CHISEL_ITEMS = ( - "FP-09-04-01", - "FP-09-05-02", - "FP-09-12-02", - "FP-09-12-03", - *(f"FP-09-13-{n:02d}" for n in range(7, 19)), -) -PRICE = (CHISEL_CODE, "223000", "시험") - - -def _chisel_labels(build, code: str) -> list[str]: - return [label for label in build.unattached.get(code, []) if "치즐" in label] - - -def test_칸을_안_채우면_종전대로_못_붙은_줄() -> None: - base = cached_build() - assert CHISEL_CODE not in base.book.titles - assert all(_chisel_labels(base, code) for code in CHISEL_ITEMS) - # 「자재 단가」 탭에 칸이 서는 자리 — 치즐을 쓰는 16공종이 다 실림(암질 표 둘 포함) - assert set(CHISEL_ITEMS) <= set(base.material_uses[CHISEL_CODE]) - - -def test_칸을_채우면_치즐_못_붙은_줄이_풀린다() -> None: - filled = cached_build(material_prices=(PRICE,)) - left = {code: _chisel_labels(filled, code) for code in CHISEL_ITEMS} - # 9-4-1 평균 갈래만 남음 — 원문 [주]① 은 Q 평균만 정하고 치즐 평균은 안 줌(지어내지 않음) - assert {code: labels for code, labels in left.items() if labels} == { - "FP-09-04-01": [left["FP-09-04-01"][0]] - } - assert "평균" in left["FP-09-04-01"][0] - assert "9-12-3" in left["FP-09-04-01"][0] and "0.018" in left["FP-09-04-01"][0] # 선례 - whole = [code for code in CHISEL_ITEMS if not filled.unattached.get(code)] - assert len(whole) >= 12, ( - whole - ) # 못 붙은 줄이 아예 없어진 공종(9-13-14·15 는 들어내기 줄이 남음) - - -def test_치즐은_시간당_소모량_나누기_Q_로_재료비에_붙음() -> None: - base = cached_build() - filled = cached_build(material_prices=(PRICE,)) - # 9-4-1 연암 — 0.006 본/hr ÷ Q 5.0 × 223,000 = 267.6 원/㎥ (배분율 없음) - gap = ( - filled.book.resolve("B-FP-09-04-01#연암").material - - base.book.resolve("B-FP-09-04-01#연암").material - ) - # 줄 0.1원·소계 원 미만 절사가 기존 재료비 끝전과 겹쳐 267 또는 268 - assert Decimal("267") <= gap <= Decimal("268") - rock = next(d for d in filled.book.details["D-FP-09-04-01#연암"] if d.ref_code == CHISEL_CODE) - assert rock.quantity == Decimal("0.006") / Decimal("5.0") and rock.output == Decimal("5.0") - # 9-12-2 암절취 — 장비(90%) 몫 · 0.006 ÷ 3.5 × 0.9 × 223,000 = 344.05 - detail = next(d for d in filled.book.details["D-FP-09-12-02"] if d.ref_code == CHISEL_CODE) - assert detail.quantity == Decimal("0.006") / Decimal("3.5") * Decimal("0.9") - assert detail.output == Decimal("3.5") - - -def test_치즐은_기계_손료와_겹치지_않음() -> None: - """㉠ 브레이커 시간당 사용료는 손료만(부착 장비) — 치즐이 X 층 안에 없음.""" - filled = cached_build(material_prices=(PRICE,)) - breaker = next(code for code in filled.book.titles if code.startswith("X-0230-")) - stack, seen = [breaker], set() - while stack: - code = stack.pop() - assert code != CHISEL_CODE - if code in seen: - continue - seen.add(code) - stack.extend(d.ref_code for d in filled.book.details.get(code, []) if d.ref_code != code) diff --git a/resources/tester/test_b09_compaction.py b/resources/tester/test_b09_compaction.py deleted file mode 100644 index 4c51fb51c..000000000 --- a/resources/tester/test_b09_compaction.py +++ /dev/null @@ -1,115 +0,0 @@ -"""노체다짐(9-16-2) — 다짐 공식으로 서는 공종 (2026-09-09 밤). - -굴착·운반과 **식이 다르다** — 사이클(㎝)이 아니라 롤러가 지나간 넓이로 센다. - - Q = 1000 × V × W × E × D × f / N (㎥/시간) - -⚠ 겨누는 것 넷 - ① 계수 여섯을 표에서 그대로 읽는다 — 하나라도 없으면 **채워 넣지 않고 안 선다** - ② Q 가 원문 값과 같다 (1000×4×1.9×0.6×0.3×1.0÷6 = 228 ㎥/hr) - ③ **롤러만** 든다 — [주]③ 의 「굴착기와 롤러 조합」은 포설(9-16-1)과 다짐을 함께 쓰라는 - 뜻이라, 이 줄에 굴착기를 또 넣으면 **포설에서 센 굴착기를 두 번 센다** - ④ 조건([주]⑤ 대규모 성토지 층다짐)과 기종 근거가 줄에 남는다 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MachineProductivity_Compaction import ( # noqa: E402 - COMPACTION_ITEMS, - FORMULA_PLATE, - capacity_per_hour, - compaction_factors, - compaction_rows, -) -from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402 - cached_build, - load_work_item_master, -) - - -def _node(code: str) -> dict: - master = load_work_item_master() - return next(w for w in master["work_items"] if w["work_item_code"] == code) - - -def test_계수_여섯을_표에서_그대로_읽는다() -> None: - factors = compaction_factors(_node("FP-09-16-02")["tables"][0]) - assert factors == { - "V": Decimal("4"), - "W": Decimal("1.9"), - "E": Decimal("0.6"), - "D": Decimal("0.3"), - "f": Decimal("1.0"), - "N": Decimal("6"), - } - - -def test_하나라도_없으면_안_선다() -> None: - """① 채워 넣으면 조용히 틀린 작업량이 선다.""" - assert compaction_factors({"raw_row": [["V(다짐속도,km/hr)", "4", ""]]}) is None - - -def test_작업량이_원문_식과_같다() -> None: - factors = compaction_factors(_node("FP-09-16-02")["tables"][0]) - assert factors is not None - assert capacity_per_hour(factors) == Decimal(228) - - -def test_롤러만_든다() -> None: - """③ 굴착기를 또 넣으면 포설에서 센 것을 두 번 센다.""" - rows = compaction_rows(_node("FP-09-16-02")) - assert [row["machine_code"] for row in rows] == ["1306-0100"] # 진동롤러(자주식) 10ton - - -def test_근거와_조건이_줄에_남는다() -> None: - rows = compaction_rows(_node("FP-09-16-02")) - text = rows[0]["source_text"] - assert "9-16-2 [주]①" in text and "층다짐" in text and "두 번" in text - - -def test_단가가_선다() -> None: - build = cached_build() - title = build.book.titles.get("B-FP-09-16-02") - assert title is not None and title.unit == "㎥" - money = build.book.resolve("B-FP-09-16-02") - assert money.total > 0 - # 포설(굴착기)보다 싸야 한다 — 롤러 한 대가 228㎥/hr 로 도는 줄이다. - assert money.total < build.book.resolve("B-FP-09-16-01").total - - -def test_콤펙터는_다른_식이다() -> None: - """⚠ 식이 둘이다 — 롤러 `V·W·E·D·f/N` · 콤펙터 `A·N·H·f·E/P`.""" - node = _node("FP-09-14-02") - factors = compaction_factors(node["tables"][0], FORMULA_PLATE) - assert factors == { - "A": Decimal("0.09"), - "N": Decimal(36000), - "H": Decimal("0.15"), - "f": Decimal("1.0"), - "E": Decimal("0.5"), - "P": Decimal(57), - } - - -def test_콤펙터_작업량이_원문_표기와_같다() -> None: - """⚠ 원문이 답까지 적어 두었다 — 「∙ = 4.26 ㎥/시간」. 어긋나면 우리가 잘못 읽은 것이다.""" - node = _node("FP-09-14-02") - factors = compaction_factors(node["tables"][0], FORMULA_PLATE) - assert factors is not None - got = capacity_per_hour(factors, FORMULA_PLATE) - stated = COMPACTION_ITEMS["FP-09-14-02"]["stated_capacity"] - assert abs(got - stated) < Decimal("0.01"), (got, stated) - - -def test_콤펙터_단가도_선다() -> None: - build = cached_build() - title = build.book.titles.get("B-FP-09-14-02") - assert title is not None and title.unit == "㎥" - assert build.book.resolve("B-FP-09-14-02").total > 0 diff --git a/resources/tester/test_b09_composite_partial_parts.py b/resources/tester/test_b09_composite_partial_parts.py deleted file mode 100644 index b2a8bf2e8..000000000 --- a/resources/tester/test_b09_composite_partial_parts.py +++ /dev/null @@ -1,85 +0,0 @@ -"""조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 2026-09-14 브레인 ㉱ 첫째(구조 결함). - -종전 `_composite_row` 는 조각 제목이 **있기만 하면** 금액에 더했음 → 조각 단가가 일부 몫만 섰거나(인력만) -밑수를 모르는 표여도 조립 줄이 **온전한 금액처럼** 섰음. 보통 줄은 그 둘을 막는데 조립 줄만 새던 자리. -⇒ 조각마다 보통 줄과 같은 두 검사 — 걸리면 금액을 안 세우고 어느 조각이 왜인지 사유. -""" - -from __future__ import annotations - -import pytest - -from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def _composite(parts: list[dict], quantity: str = "1") -> dict: - return { - "work_items": [ - { - "work_item_code": None, - "name": "시험 묶음", - "spec": "", - "unit": "개소", - "quantity": quantity, - "in_bill": True, - "composite_parts": parts, - "composite_not_ready": [], - } - ], - "materials": [], - } - - -def _line(parts: list[dict], quantity: str = "1"): - rows = build_bill(_composite(parts, quantity)).rows - return next(r for r in rows if r.code is None and r.name == "시험 묶음") - - -def test_일부만_선_조각이_있으면_묶음_금액을_안_세우고_까닭() -> None: - build = cached_build() - partial = next(c for c in build.partial_ratio if f"B-{c}" in build.book.titles) - line = _line([{"code": partial, "quantity": 1}]) - assert line.amount_krw is None, (partial, line.amount_krw) - assert partial in line.note and "일부만" in line.note, line.note - - -def test_밑수_없는_조각도_같이_막음() -> None: - build = cached_build() - missing = next(c for c in build.basis_missing if f"B-{c}" in build.book.titles) - line = _line([{"code": missing, "quantity": 1}]) - assert line.amount_krw is None and "밑수" in line.note, (missing, line.note) - - -def test_온전한_조각만이면_종전대로_금액() -> None: - line = _line([{"code": "FP-09-15-02", "quantity": 2}]) - assert line.amount_krw and line.amount_krw > 0, line.note - - -# ── 줄 수량이 1 이 아닐 때 — 2026-09-15 랩탑 서브가 찾은 부풀음 ── -#: ⭐ **인계 조각 수량은 「합계」다** — B08 계약이고 그쪽 시험이 지킴 -#: (BOX 동바리 3.92×10 · 포장 97.66㎥). -#: 그래서 `Σ(조각 단가 × 조각 수량)` 이 이미 **그 줄 전체 금액**이다. -#: 줄 수량을 또 곱하면 배로 부푼다. 종전 시험이 **줄 수량 1 로만 재서** 못 잡았다 — -#: 평떼 718.14㎡ 가 720배(42억)로 섰다. -_PART = {"code": "FP-09-15-02", "quantity": 2} - - -@pytest.mark.parametrize("quantity", ["2", "10", "718.14"]) -def test_묶음_금액은_줄_수량을_다시_안_곱한다(quantity) -> None: - """조각이 합계이므로 **줄 수량이 몇이든 금액은 같다**. 단가는 그 금액 ÷ 수량으로 보인다.""" - one = _line([_PART]) - many = _line([_PART], quantity) - assert one.amount_krw and many.amount_krw, (one.amount_krw, many.amount_krw) - assert many.amount_krw == one.amount_krw, (quantity, many.amount_krw, one.amount_krw) - assert many.unit_price_krw < one.unit_price_krw, (quantity, many.unit_price_krw) - - -def test_묶음_성분도_줄_수량만큼_안_부푼다() -> None: - """재·노·경 셋도 같이 봐야 함 — 합계만 보면 성분이 부푼 것을 놓친다.""" - one, many = _line([_PART]), _line([_PART], "10") - assert (many.material_krw, many.labor_krw, many.expense_krw) == ( - one.material_krw, - one.labor_krw, - one.expense_krw, - ) diff --git a/resources/tester/test_b09_consumables_chainsaw.py b/resources/tester/test_b09_consumables_chainsaw.py deleted file mode 100644 index 1a7acb525..000000000 --- a/resources/tester/test_b09_consumables_chainsaw.py +++ /dev/null @@ -1,80 +0,0 @@ -"""2장 소요재료·기계손료 표를 읽는 한 벌 — 체인톱부터(2026-09-15 브레인 ㉡ · 판정 ①②③). - -산림품셈 2-1-1 체인톱 표(F0042): 보통휘발유(주연료) 5.6ℓ/대/일 · 잡품 주연료비 40% · 체인오일(일반·친환경) 2.1ℓ/대/일 - 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 체인톱 손료(F0064): 45cc 0.0084(1일 1대) × 체인톱가격 -체인톱을 쓰는 공종(4-2-2 단목베기 · 6-5 어린나무가꾸기)이 인력만으로 서서 연료·잡품·손료가 통째로 빠져 있었음. - ③ 표가 이름으로 밝힌 인원 줄만 — 4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」 - ① 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 · 둘 다면 안 붙고 사유 - ② 체인톱가격·체인오일 시중가격은 칸 + 사유 · 휘발유는 유가 판으로 바로 섬 -""" - -from __future__ import annotations - -from decimal import ROUND_FLOOR, Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -SAW = "X-AR-X-61d1681d" -SAW_PRICE = "AR-M-5649cf3f" -OIL = "AR-M-fa7fbf6d" -ECO_OIL = "AR-M-de2fe662" - - -def _rows(book, code: str) -> list: - own = book.details.get(code) or [] - return [ - *own, - *(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]), - ] - - -def test_체인톱_1대_1일_호표는_표의_휘발유와_잡품_40퍼센트() -> None: - book = cached_build().book - rows = book.details[SAW] - fuel = next(r for r in rows if r.ref_code == "M-FUEL-휘발유") - assert fuel.quantity == Decimal("5.6"), fuel - misc = next(r for r in rows if r.percent_of_material is not None) - assert misc.percent_of_material == Decimal(40) - price = book.titles["M-FUEL-휘발유"].slots[-1] - expected = (Decimal("5.6") * price * Decimal("1.4")).quantize(Decimal(1), rounding=ROUND_FLOOR) - assert abs(book.resolve(SAW).material - expected) <= 1, (book.resolve(SAW), expected) - - -def test_단목베기는_벌목부_인원만큼_체인톱_어린나무가꾸기는_체인톱_사용_특별인부만큼() -> None: - book = cached_build().book - rows = _rows(book, "B-FP-04-02-02#5m미만") - faller = next(r for r in rows if book.titles[r.ref_code].name == "벌목부") - saw = next(r for r in rows if r.ref_code == SAW) - assert saw.quantity == faller.quantity, (saw, faller) - rows = _rows(book, "B-FP-06-05") - special = next( - r - for r in rows - if book.titles.get(r.ref_code) and book.titles[r.ref_code].name == "특별인부" - ) - saw = next(r for r in rows if r.ref_code == SAW) - assert saw.quantity == special.quantity, (saw, special) - # 보통인부(작업 보조)는 체인톱을 안 씀 — 표가 이름으로 밝힌 줄만(③) - assert sum(1 for r in rows if r.ref_code == SAW) == 1 - - -def test_체인오일은_넣은_쪽만_둘_다면_사유() -> None: - none = cached_build() - assert any("체인오일" in label for label in none.unattached.get("FP-04-02-02", [])) - one = cached_build(material_prices=((OIL, "2250", "시중"),)) - rows = _rows(one.book, "B-FP-04-02-02#5m미만") - faller = next(r for r in rows if one.book.titles[r.ref_code].name == "벌목부") - oil = next(r for r in rows if r.ref_code == OIL) - assert oil.quantity == faller.quantity * Decimal("2.1"), oil - both = cached_build(material_prices=((OIL, "2250", "시중"), (ECO_OIL, "6000", "시중"))) - assert not any(r.ref_code in (OIL, ECO_OIL) for r in _rows(both.book, "B-FP-04-02-02#5m미만")) - assert any("둘 다" in label for label in both.unattached.get("FP-04-02-02", [])) - - -def test_체인톱가격이_들면_손료_0_0084_가_경비로() -> None: - none = cached_build() - assert any("체인톱가격" in label for label in none.unattached.get("FP-06-05", [])) - priced = cached_build(material_prices=((SAW_PRICE, "900000", "견적"),)) - assert priced.book.resolve(SAW).expense == Decimal(7560) # 900,000 × 0.0084 - for code in (SAW_PRICE, OIL, ECO_OIL): - assert "FP-04-02-02" in none.material_uses.get(code, []), code diff --git a/resources/tester/test_b09_consumables_helicopter.py b/resources/tester/test_b09_consumables_helicopter.py deleted file mode 100644 index 5864d690e..000000000 --- a/resources/tester/test_b09_consumables_helicopter.py +++ /dev/null @@ -1,77 +0,0 @@ -"""유인헬기방제 8-6-1 소요재료·양수기 손료 — 2026-09-15 브레인 판정 ①③(2장 표 읽는 한 벌에 면적 꼴 더함). - -2-1-8 유인 헬기(160ha당): 휘발유(양수기) 10ℓ · 잡품 주재료비의 95% · 깃발 8개(20ha당 1개) - [주]① 대형헬기도 휘발유 10ℓ ③ 깃발은 20ha당 1개 · 2-2-6 양수기 5HP 손료 0.0084(1일 1대) -⇒ 표의 「160ha당」 이 1일 작업량 — 양수기 1대 1일 호표(휘발유 10ℓ + 잡품 95% + 손료)를 소형 ÷160ha · 대형 ÷400ha - 깃발은 ha당 1/20 개 · 양수기 구입가·깃발·헬기 임차료는 「자재 단가」 칸 + 사유(③) -(앞서 인력이 갈래 밑수로 안 나뉘어 160·400배 부풀던 것은 `test_b09_variant_basis` 가 바로잡음) -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -PUMP = "X-AR-X-ea09a419" -PUMP_PRICE = "AR-M-cfc70216" -FLAG = "AR-M-a79b4d20" -HELI = "AR-M-08db9ecc" -SMALL = "B-FP-08-06-01#소형헬기(160ha당)" -LARGE = "B-FP-08-06-01#대형헬기(400ha당)" - - -def _row(book, title: str, code: str): - return next((r for r in book.details[title] if r.ref_code == code), None) - - -def test_양수기_1대_1일_호표는_휘발유_10리터와_잡품_95퍼센트() -> None: - book = cached_build().book - fuel = _row(book, PUMP, "M-FUEL-휘발유") - assert fuel.quantity == Decimal(10), fuel - misc = next(r for r in book.details[PUMP] if r.percent_of_material is not None) - assert misc.percent_of_material == Decimal(95) - - -def test_소형은_160ha_대형은_400ha_로_나눈_하루_몫() -> None: - book = cached_build().book - assert _row(book, SMALL, PUMP).quantity == Decimal(1) / 160 - assert _row(book, LARGE, PUMP).quantity == Decimal(1) / 400 - - -def test_깃발_헬기임차료_양수기가격은_칸_넣으면_ha당으로() -> None: - none = cached_build() - left = " ".join(none.unattached.get("FP-08-06-01", [])) - assert "깃발" in left and "헬기 임차료" in left and "양수기가격" in left, left - priced = cached_build( - material_prices=( - (FLAG, "15000", "견적"), - (HELI, "3000000", "견적"), - (PUMP_PRICE, "800000", "견적"), - ) - ) - book = priced.book - assert _row(book, SMALL, FLAG).quantity == Decimal(1) / 20 - assert _row(book, LARGE, FLAG).quantity == Decimal(1) / 20 - assert _row(book, SMALL, HELI).quantity == Decimal(1) / 160 - assert _row(book, LARGE, HELI).quantity == Decimal(1) / 400 - assert book.resolve(PUMP).expense == Decimal(6720) # 800,000 × 0.0084 - - -def test_소방관서_급수_지원이면_휘발유_미반영_단서를_보임() -> None: - from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note - - assert "소방관서" in known_gap_note("FP-08-06-01") - - -def test_지상방제는_막힌_채_사유만_보탬() -> None: - build = cached_build() - assert "FP-08-06-03" in build.basis_missing - left = " ".join(build.unattached.get("FP-08-06-03", [])) - assert "15ha 는 연료 설명" in left and "1톤 트럭이 없음" in left, left - assert not any( - r.ref_code.startswith("M-FUEL") - for t in build.book.titles - if t.startswith("B-FP-08-06-03") - for r in build.book.details[t] - ) diff --git a/resources/tester/test_b09_contract.py b/resources/tester/test_b09_contract.py deleted file mode 100644 index 6199b6a65..000000000 --- a/resources/tester/test_b09_contract.py +++ /dev/null @@ -1,211 +0,0 @@ -"""계약 단계 — 설계 → 계약내역 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정). - -⚠ 값이 맞는지는 못 잰다 — 계약 표본 0건(STmate 27번 §9). 여기서 재는 것: - ① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜 - ② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다) - ③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음 - ④ 0% = 0 원(브레인 판정) · 「공내역 생성」 켜면 그 줄에 공내역 이름 - ⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌 - ⑥ 묶음 줄 합 · 틀린 적용률 거름 - ⑦ 2벌 — 기초단가 적용은 단가표 **복사본**만(설계 단가표 불변) · 못 푸는 줄은 까닭 · - 계약 호표(W-B·W-D)는 기초단가 적용과 함께만 섬 -""" - -from __future__ import annotations - -import copy -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Contract import clean_settings, contract_bill # noqa: E402 -from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 - PriceBook, - PriceDetail, - PriceKind, - PriceTitle, -) -from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402 - -ROWS = [ - {"item_no": "1", "is_group": True, "in_bill": True}, - { - "item_no": "1.1", - "is_group": False, - "in_bill": True, - "quantity": "12.5", - "price_code": "B-FP-09-03-02", - "unit_material_krw": "1001", - "unit_labor_krw": "2003", - "unit_expense_krw": "3005", - }, - { - "item_no": "1.2", - "is_group": False, - "in_bill": True, - "quantity": "3", - "price_code": "B-FP-09-03-02", - "unit_material_krw": "1001", - "unit_labor_krw": "2003", - "unit_expense_krw": "3005", - }, - { - "item_no": "1.3", - "is_group": False, - "in_bill": True, - "quantity": "2", - "price_code": "M-관급자재", - "unit_material_krw": "50000", - "unit_labor_krw": "0", - "unit_expense_krw": "0", - }, -] - - -def _rows(settings: dict) -> dict[str, dict]: - return {row["item_no"]: row for row in contract_bill(ROWS, settings)["rows"]} - - -def test_100퍼센트면_설계와_같고_입력은_안_바뀐다() -> None: - before = copy.deepcopy(ROWS) - result = contract_bill(ROWS, clean_settings({})[0]) - assert ROWS == before - assert result["totals"]["design"] == result["totals"]["contract"] - - -def test_노재경을_따로_곱하고_성분_단가를_원_미만_절사() -> None: - row = _rows({"labor_pct": "80", "material_pct": "90", "expense_pct": "70"})["1.1"] - assert ( - row["contract_unit_material_krw"], - row["contract_unit_labor_krw"], - row["contract_unit_expense_krw"], - ) == ("900", "1602", "2103") # 900.9→900 · 1602.4→1602 · 2103.5→2103 - assert row["contract_material_krw"] == "11250" # 12.5 × 900 - assert row["contract_code"] == "W-B-FP-09-03-02" - - -def test_적용_제외_줄은_설계_단가_W코드_없음() -> None: - row = _rows({"material_pct": "50", "excluded": ["1.3"]})["1.3"] - assert row["contract_unit_material_krw"] == "50000" and row["contract_excluded"] is True - assert not row["contract_code"].startswith("W-") - - -def test_0퍼센트_공내역_옵션() -> None: - zero = {"labor_pct": "0", "material_pct": "0", "expense_pct": "0"} - empty = _rows({**zero, "zero_makes_empty": True})["1.1"] - assert empty["contract_amount_krw"] == "0" and "공내역" in empty["contract_note"] - plain = _rows(zero)["1.1"] - assert plain["contract_amount_krw"] == "0" and plain["contract_note"] == "" # 그냥 0 원 줄 - - -def test_동일코드_개별생성() -> None: - shared = _rows({"material_pct": "90"}) - assert shared["1.1"]["contract_code"] == shared["1.2"]["contract_code"] - separate = _rows({"material_pct": "90", "separate_same_code": True}) - assert separate["1.1"]["contract_code"] != separate["1.2"]["contract_code"] - - -def test_묶음_줄_합과_합계_비율() -> None: - result = contract_bill(ROWS, {"material_pct": "90", "labor_pct": "90", "expense_pct": "90"}) - group = result["rows"][0] - children = [r for r in result["rows"] if not r.get("is_group")] - assert Decimal(group["contract_amount_krw"]) == sum( - Decimal(r["contract_amount_krw"]) for r in children - ) - assert result["totals"]["ratio_pct"]["labor"] is not None - - -def test_틀린_적용률은_거른다() -> None: - cleaned, errors = clean_settings({"labor_pct": "-3", "material_pct": "abc", "expense_pct": ""}) - assert len(errors) == 2 and cleaned["expense_pct"] == "100" - - -# ── 2벌 — 「기초단가에 적용」 · 「적용율 적용된 일위대가/산출근거 생성」 ────────────── - - -def _build(): - """자재 1,001 · 노임 2,003 · 산출근거 D-T(노임 0.5) · 일위대가 B-T(자재 2 + D-T 1).""" - book = PriceBook() - for code, kind, price in ( - ("M-A", PriceKind.MATERIAL, "1001"), - ("L-B", PriceKind.LABOR, "2003"), - ): - slots = [None] * 6 - slots[5] = Decimal(price) - book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots)) - book.add_title(PriceTitle(code="D-T", kind=PriceKind.PRICE_BASIS, name="산출근거")) - book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="일위대가")) - book.add_detail(PriceDetail("D-T", "L-B", Decimal("0.5"))) - book.add_detail(PriceDetail("B-T", "M-A", Decimal(2))) - book.add_detail(PriceDetail("B-T", "D-T", Decimal(1))) - return UnitPriceBuild(book=book) - - -def _line(item_no: str, code: str, material: str, labor: str) -> dict: - return { - "item_no": item_no, - "is_group": False, - "in_bill": True, - "quantity": "3", - "price_code": code, - "unit_material_krw": material, - "unit_labor_krw": labor, - "unit_expense_krw": "0", - } - - -BASE_ROWS = [ - _line("2.1", "B-T", "2002", "1001"), # 설계 단가 = 단가표 조립값 - _line("2.2", "B-T", "2500", "1001"), # 수동·할증으로 조립값과 다름 - _line("2.3", "Z-없음", "100", "0"), # 단가표 밖 -] -BASE_ON = { - "material_pct": "90", - "labor_pct": "80", - "apply_to_base_prices": True, - "generate_unit_prices": True, -} - - -def test_기초단가_적용은_복사본만_고치고_다시_조립() -> None: - build = _build() - before = (build.book.resolve("B-T"), copy.deepcopy(build.book.titles["M-A"].slots)) - result = contract_bill(BASE_ROWS, BASE_ON, build=build) - assert (build.book.resolve("B-T"), build.book.titles["M-A"].slots) == before # 설계 단가표 불변 - rows = {row["item_no"]: row for row in result["rows"]} - # 자재 900.9 × 2 = 1801.8 → 1801 · 노임 1602.4 × 0.5 = 801.2 → D 801 → B 801 - # (성분에 곱했으면 노무 1001 × 0.8 = 800.8 → 800 — 층을 다시 조립해야 801) - assert (rows["2.1"]["contract_unit_material_krw"], rows["2.1"]["contract_unit_labor_krw"]) == ( - "1801", - "801", - ) - assert rows["2.1"]["contract_note"].startswith("기초단가 적용") - - -def test_기초단가로_못_푸는_줄은_성분에_곱하고_까닭을_적는다() -> None: - rows = {r["item_no"]: r for r in contract_bill(BASE_ROWS, BASE_ON, build=_build())["rows"]} - assert ( - rows["2.2"]["contract_unit_material_krw"] == "2250" - and "조립값과 다름" in (rows["2.2"]["contract_note"]) - ) - assert "단가표 밖" in rows["2.3"]["contract_note"] - - -def test_계약_호표는_W코드로_따로_서고_산출근거도_따라_선다() -> None: - sheets = contract_bill(BASE_ROWS, BASE_ON, build=_build())["unit_price_sheets"] - by_code = {sheet["code"]: sheet for sheet in sheets} - assert set(by_code) == {"W-B-T", "W-D-T"} - assert (by_code["W-B-T"]["design_code"], by_code["W-B-T"]["total"]) == ("B-T", "2602") - assert by_code["W-D-T"]["labor"] == "801" - - -def test_호표_생성은_기초단가_적용이_함께_켜져야_선다() -> None: - only_sheets = {**BASE_ON, "apply_to_base_prices": False} - result = contract_bill(BASE_ROWS, only_sheets, build=_build()) - assert result["unit_price_sheets"] == [] - assert "generate_unit_prices" in result["options_not_used"] - off = contract_bill(BASE_ROWS, {**BASE_ON, "generate_unit_prices": False}, build=_build()) - assert off["unit_price_sheets"] == [] diff --git a/resources/tester/test_b09_cost_fixtures.py b/resources/tester/test_b09_cost_fixtures.py deleted file mode 100644 index b8954238b..000000000 --- a/resources/tester/test_b09_cost_fixtures.py +++ /dev/null @@ -1,338 +0,0 @@ -# -*- coding: utf-8 -*- -"""B09 ⑤ 엔진 — 실무 서류 재현 검산 고정값 두 벌 + 거울 테스트 3종. - -목적은 **구조 검증**이지 실무 금액 추종이 아니다 (PLAN 8-10 ★ 사용자 확정: 법대로). - ① **2024 요율** 로 돌리면 그 해 서류가 재현되는가 → 「연도 교체 구조가 실제로 도는가」 - ② **현행 요율** 로 돌리면 규정대로 다른 값이 나오는가 - ③ 안전관리비 A/B min 이 **양방향**으로 도는가 (울진 A 채택 · 거창 B 채택) - -고정값 출처 = PLAN 8-9 · 8-10 · 8-12. - -⚠ 2026-09-14 `tmp/tests/` 에서 **여기로 옮겨 옴**(브레인 지시) — - 시험은 git 안에 둬야 다른 창이 돌림. `tmp/tests/` 쪽 사본은 지우지 않음 - (tmp 는 손대지 않는 자리) — **같은 파일이 두 벌** 있음. -⚠ 짝 벌과 **겹치는 자리** — `test_b09_golden_cost_sheet.py` 가 울진 2024 를 - **XLSX 시트에서** 되풀므로 ① 의 금액 사슬과 겹침. 여기는 **고정값**, - 저기는 **원본 칸**이라 되짚는 길이 달라 둘 다 둠(정리는 사용자 몫). -⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 「안 켠 값」을 보는 자리는 - `cut_basis="none"` 으로 손수 끈다. -""" - -import os -from decimal import Decimal - -import pytest - -from B09_Estimation.B09_Estimation_Engine_Cost import ( - CostInput, - calculate_cost, - proposed_profit_adjustment, -) -from B09_Estimation.B09_Estimation_Guards import ( - DoubleCountError, - check_free_haul_not_priced, - check_haul_volume_within_cut, - check_mix_decomposed_once, - check_surcharge_once, -) - -FIXTURE_2024 = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "fixtures", "rates_2024_uljin.json" -) - - -def _won(v) -> Decimal: - return Decimal(str(v)) - - -# 울진 공통(2024) 원가계산서 입력 3종 — PLAN 8-9 -ULJIN_DIRECT_MATERIAL = _won(194_234_417) -ULJIN_DIRECT_LABOR = _won(346_800_554) -ULJIN_DIRECT_EXPENSE = _won(169_324_165) -ULJIN_OWNER_MATERIAL = _won(69_474_220) # 순자재대 (수수료 375,160 별도) -ULJIN_PROCUREMENT_FEE = _won(375_160) - - -def _uljin(**overrides) -> CostInput: - base = dict( - direct_material_krw=ULJIN_DIRECT_MATERIAL, - direct_labor_krw=ULJIN_DIRECT_LABOR, - direct_expense_krw=ULJIN_DIRECT_EXPENSE, - owner_supplied_material_krw=ULJIN_OWNER_MATERIAL, - procurement_fee_krw=ULJIN_PROCUREMENT_FEE, - duration_days=183, - ) - base.update(overrides) - return CostInput(**base) - - -# -------------------------------------------------------------------------- -# ① 2024 요율 재현 — 16/16 금액 사슬 -# -------------------------------------------------------------------------- - - -def _uljin_2024() -> CostInput: - """그 해 서류를 그대로 재현하려면 그 해 관행 처리 둘을 켜야 한다. - - 2024 요율 파일과 그 해 연금 요율만 갈아끼우면 된다 — **관행 옵션은 필요 없다**. - 관급은 **순자재대 69,474,220 + 수수료 375,160** 으로 나눠 넣으면 관급자재대 - `ROUNDUP(69,849,380, −3) = 69,850,000` 과 안전관리비 관급항 - `69,474,220 ÷ 1.1` 이 둘 다 저절로 맞는다. - """ - return _uljin( - rate_file_path=FIXTURE_2024, - pension_year=2024, - profit_adjustment_krw=_won(905_980), - ) - - -def test_uljin_2024_chain_reproduced(): - """2024 요율로 돌리면 그 해 원가계산서가 재현된다 — 금액 사슬 전건.""" - r = calculate_cost(_uljin_2024()) - - assert r.amount("indirect_labor_cost") == _won(50_286_080) - assert r.amount("industrial_accident_insurance") == _won(14_136_284) - assert r.amount("employment_insurance") == _won(4_010_575) - assert r.amount("health_insurance") == _won(12_294_079) - assert r.amount("long_term_care_insurance") == _won(1_592_083) - assert r.amount("national_pension") == _won(15_606_024) - assert r.amount("safety_management_cost") == _won(16_586_996) - assert r.amount("other_expense") == _won(35_479_263) - assert r.amount("environment_preservation") == _won(5_682_873) - assert r.amount("equipment_payment_guarantee") == _won(2_841_436) - - assert r.totals["expense"] == _won(277_553_778) - assert r.totals["net_construction_cost"] == _won(868_874_829) - assert r.amount("general_overhead") == _won(52_132_489) - assert r.amount("profit_before_adjustment") == _won(109_015_935) - assert r.amount("profit") == _won(108_109_955) - assert r.totals["total_cost"] == _won(1_029_117_273) - assert r.amount("vat") == _won(102_911_727) - assert r.totals["contract_amount"] == _won(1_132_029_000) - assert r.amount("owner_supplied_material_total") == _won(69_850_000) - assert r.totals["grand_total"] == _won(1_201_879_000) - - -def test_uljin_2024_safety_adopts_a(): - """2024 울진은 A 채택 — A 16,586,996 < B 18,494,700.""" - r = calculate_cost(_uljin_2024()) - assert r.amount("safety_management_cost_a") == _won(16_586_996) - assert r.amount("safety_management_cost_b") == _won(18_494_700) - assert r.amount("safety_management_cost") == r.amount("safety_management_cost_a") - - -def test_uljin_2024_item_list_comes_from_data(): - """비목 목록은 코드가 아니라 **요율 데이터**가 정한다 (PLAN 8-13). - - 2024 픽스처에는 퇴직공제부금비가 없으므로 그 줄이 서지 않는다. - """ - r = calculate_cost(_uljin_2024()) - assert not r.has("retirement_mutual_aid") - assert not r.has("wage_claim_contribution") - assert r.has("environment_preservation") - - -# -------------------------------------------------------------------------- -# ② 현행 요율 — 같은 입력, 규정대로 다른 값 -# -------------------------------------------------------------------------- - - -def test_owner_supplied_input_is_net_material_not_total(): - """⚠ 관급 입력은 **순자재대**다 — 실무 서류의 「관급자재대」를 그대로 넣으면 틀린다. - - 울진: 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → 천원 올림 **69,850,000**. - 안전관리비 관급항은 **순자재대만** 부가세 제외로 환산(69,474,220 ÷ 1.1). - 합계를 순자재대 자리에 넣으면 안전관리비가 8,629 원 커진다. - """ - right = calculate_cost(_uljin()) - wrong = calculate_cost(_uljin(owner_supplied_material_krw=_won(69_849_380))) - - assert right.amount("owner_supplied_material_total") == _won(69_850_000) - assert right.amount("safety_management_cost") == _won(18_586_091) - assert wrong.amount("safety_management_cost") - right.amount("safety_management_cost") == _won( - 8_629 - ) - - -def test_uljin_current_rates_safety_18_586_091(): - """현행 요율로 돌리면 안전관리비가 18,586,091 이 된다 (PLAN 8-10). - - A 대상액 604,193,353 × 2.53 % + 3,300,000 = 18,586,091 - B 버림(대상액 541,034,971 × 3.15 %) × 1.2 = 20,385,820 → A 채택 - - ⚠ 2026-09-14 B 가 1원 내려감 — 1.2배의 대상을 **호별 산정이 끝난 금액**으로 바꿨음 - (고시 제4조① 단서 문언 · STmate 영월 실증). 여기 값은 현행 요율이라 실무 대조본이 없음. - """ - r = calculate_cost(_uljin()) - assert r.amount("safety_management_cost_a") == _won(18_586_091) - assert r.amount("safety_management_cost_b") == _won(20_385_820) - assert r.amount("safety_management_cost") == _won(18_586_091) - - -def test_year_swap_changes_result_but_not_structure(): - """같은 입력·같은 코드, 요율만 갈아끼우면 값이 달라진다 — 교체 구조가 도는 증거.""" - old = calculate_cost(_uljin_2024()) - new = calculate_cost(_uljin()) - - assert old.amount("safety_management_cost") != new.amount("safety_management_cost") - assert old.amount("indirect_labor_cost") != new.amount("indirect_labor_cost") - assert old.rate_version["effective_date"] == "2024-01-01" - assert new.rate_version["effective_date"] == "2026-04-13" - # 픽스처는 지문이 없어 정본이 아님이 드러난다. - assert old.rate_version["sha256"] == "" - assert len(new.rate_version["sha256"]) == 64 - - -def test_current_rates_add_items_absent_in_2024(): - """현행 요율 판에는 2024 에 없던 비목이 붙는다 (PLAN 8-14 사용자 「전부 필요」).""" - r = calculate_cost(_uljin()) - for key in ("retirement_mutual_aid", "wage_claim_contribution", "asbestos_contribution"): - assert r.has(key), key - - -# -------------------------------------------------------------------------- -# ③ 안전관리비 A/B — 양방향 -# -------------------------------------------------------------------------- - - -def test_geochang_adopts_b(): - """거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」. - - ⚠⚠ **두 실증본이 갈림**(2026-09-14) — 1.2배를 언제 곱하나. - STmate(영월 2024) `버림(밑수 × 율) × 1.2` 뒤 버림 → 영월 B 20,330,638 = 원본 - 거창 원본(다른 프로그램) `버림(밑수 × 율 × 1.2)` → 거창 B 12,240,137 - 거창 원가계산서는 시트 이름이 「원가계산서」로 **STmate 출력이 아님**(줄 차례도 다름). - 우리 목표가 STmate 골든셋 재현이라 **STmate 쪽으로 맞춤** — 그래서 거창 B 가 1원 내려감. - ⇒ 발주처가 거창 쪽 셈을 요구하면 갈래를 둬야 함. **판정 대기(브레인)**. - """ - r = calculate_cost( - CostInput( - direct_material_krw=_won(80_165_010), - direct_labor_krw=_won(243_648_150), - direct_expense_krw=_won(0), - owner_supplied_material_krw=_won(74_634_214), - enabled_items=("safety_management_cost",), - ) - ) - assert r.amount("safety_management_cost_a") == _won(12_337_367) - assert r.amount("safety_management_cost_b") == _won(12_240_136) # 거창 원본은 12,240,137 - assert r.amount("safety_management_cost") == _won(12_240_136) - - -def test_safety_formula_text_is_per_row(): - """각 줄에 **제 산식**이 붙는다 — 실무 원문은 A 식을 B 줄에 복사한 오류가 있었다.""" - r = calculate_cost(_uljin_2024()) - a = r.line("safety_management_cost_a").formula_text - b = r.line("safety_management_cost_b").formula_text - assert a != b - assert "× 1.2" in b and "× 1.2" not in a - - -# -------------------------------------------------------------------------- -# 이윤 조정 — 보여만 주고 적용은 명시로 -# -------------------------------------------------------------------------- - - -def test_proposed_adjustment_is_advice_only(): - """목표 도급액을 주면 필요한 조정액을 계산해 보여 준다. 스스로 적용하지 않는다.""" - plain = calculate_cost(_uljin(rate_file_path=FIXTURE_2024, pension_year=2024, cut_basis="none")) - suggested = proposed_profit_adjustment(plain, _won(1_132_029_000)) - assert suggested == _won(905_980) - # 제안값을 받았다고 해서 결과가 바뀌지는 않는다. - assert plain.amount("profit") == plain.amount("profit_before_adjustment") - - -# -------------------------------------------------------------------------- -# 거울 테스트 3종 — 이중계상 감시 (PLAN 8-7 ㉠㉡㉢) -# -------------------------------------------------------------------------- - - -def test_guard_surcharge_double_detected(): - """㉠ 자재총괄이 할증 전 값에 두 번 붙으면 잡힌다.""" - check_surcharge_once( - material_summary_total=_won(110), - unit_price_material_total=_won(100), - surcharge_rate_percent=_won(10), - ) - with pytest.raises(DoubleCountError): - check_surcharge_once( - material_summary_total=_won(121), - unit_price_material_total=_won(100), - surcharge_rate_percent=_won(10), - ) - - -def test_guard_free_haul_must_not_be_priced(): - """㉡ 무대 줄은 남기되 단가가 붙으면 잡힌다.""" - rows = [ - {"equipment": "free_haul", "unit_price_krw": 0}, - {"equipment": "dozer", "unit_price_krw": 827}, - ] - check_free_haul_not_priced(haul_rows=rows) - rows[0]["unit_price_krw"] = 500 - with pytest.raises(DoubleCountError): - check_free_haul_not_priced(haul_rows=rows) - - -def test_guard_haul_volume_within_cut(): - """㉡ 보조 — 운반토량 합이 절취량을 넘으면 잡힌다.""" - check_haul_volume_within_cut(haul_volume_total_m3=_won(9_000), total_cut_volume_m3=_won(10_044)) - with pytest.raises(DoubleCountError): - check_haul_volume_within_cut( - haul_volume_total_m3=_won(10_915), total_cut_volume_m3=_won(10_044) - ) - - -def test_guard_mix_decomposed_once(): - """㉢ 시멘트가 콘크리트 체적 × 배합비를 넘으면 잡힌다.""" - check_mix_decomposed_once( - cement_total_kg=_won(3_200), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320) - ) - with pytest.raises(DoubleCountError): - check_mix_decomposed_once( - cement_total_kg=_won(6_400), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320) - ) - - -# -------------------------------------------------------------------------- -# 규모 구간 — 추정가격 반복 수렴 (국가계약법 시행령 제7조 1호) -# -------------------------------------------------------------------------- - - -def test_scale_converges_to_estimated_price(): - """구간은 씨앗(직접공사비)이 아니라 **수렴한 추정가격**으로 판정된다. - - 울진은 직접공사비 7.1억 · 총원가 10.3억 — 둘 다 50억 미만이라 구간은 같지만, - 엔진이 씨앗에서 멈추지 않고 총원가로 다시 판정했는지는 아래로 확인한다. - """ - r = calculate_cost(_uljin()) - # 수렴이 끝났으면 「안 굳음」·「진동」 메모가 없다(절사 메모는 늘 붙는다). - assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes - - -def test_explicit_estimated_price_skips_convergence(): - """설계자가 추정가격을 명시하면 반복 없이 그 값으로 판정한다.""" - small = calculate_cost(_uljin(estimated_price_krw=_won(1_000_000_000))) - big = calculate_cost(_uljin(estimated_price_krw=_won(6_000_000_000))) - # 50억을 넘기면 일반관리비 8 % → 6.5 %, 이윤 15 % → 12 % 로 갈린다. - assert small.line("general_overhead").rate_percent == Decimal("8.0") - assert big.line("general_overhead").rate_percent == Decimal("6.5") - assert small.line("profit_before_adjustment").rate_percent == Decimal("15.0") - assert big.line("profit_before_adjustment").rate_percent == Decimal("12.0") - - -def test_scale_crossing_bracket_is_resolved_by_convergence(): - """씨앗은 50억 미만인데 총원가가 50억을 넘는 공사 — 구간이 재판정되어야 한다.""" - r = calculate_cost( - CostInput( - direct_material_krw=_won(900_000_000), - direct_labor_krw=_won(2_400_000_000), - direct_expense_krw=_won(1_400_000_000), - duration_days=400, - ) - ) - # 씨앗(직접공사비) 47억 → 8 % 구간. 총원가가 50억을 넘으면 6.5 % 로 내려간다. - assert r.totals["direct_construction_cost"] < _won(5_000_000_000) - assert r.totals["total_cost"] > _won(5_000_000_000) - assert r.line("general_overhead").rate_percent == Decimal("6.5") - assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py deleted file mode 100644 index 17f26e404..000000000 --- a/resources/tester/test_b09_cost_options.py +++ /dev/null @@ -1,272 +0,0 @@ -"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절). - -⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀: - 봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996. -⚠ 2026-09-14 — **11 절사 기본이 켜짐**(총공사비 1,000원 미만, 산림청고시 제2025-82호). - 「안 자른 값」을 보는 자리는 `cut_basis="none"` 으로 **손수 꺼서** 본다. -""" - -from __future__ import annotations - -import sys -from dataclasses import replace -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 -from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( # noqa: E402 - cut_gap, - profit_cut, -) - -BASE = CostInput( - direct_material_krw=Decimal(123_456_789), - direct_labor_krw=Decimal(234_567_891), - direct_expense_krw=Decimal(45_678_912), - estimated_price_krw=Decimal(1_000_000_000), # 10억 — 주공사 8.0 / 전문 6.5 가 갈리는 자리 -) - - -def test_기본값은_주공사_공급가액_총공사비절사() -> None: - """10 주공사 · 12 공급가액 10% · **11 총공사비 1,000원 미만 절사**(2026-09-14 기본으로 켬).""" - explicit = replace( - BASE, - overhead_class="civil_landscape_industrial", - vat_mode="supply", - cut_basis="grand_total", - cut_unit_krw=1000, - ) - assert calculate_cost(explicit).totals == calculate_cost(BASE).totals - assert calculate_cost(BASE).totals["grand_total"] % 1000 == 0 - - -def test_전문공사는_요율표만_바뀐다() -> None: - main, special = ( - calculate_cost(BASE), - calculate_cost(replace(BASE, overhead_class="specialty_electric_communication_fire_other")), - ) - assert ( - main.line("general_overhead").base_amount_krw - == special.line("general_overhead").base_amount_krw - ) - assert ( - main.line("general_overhead").rate_percent != special.line("general_overhead").rate_percent - ) - - -def test_관리품목자재대는_일반관리비_밑수에_든다() -> None: - plain = calculate_cost(BASE) - managed = calculate_cost(replace(BASE, overhead_managed_material_krw=Decimal(10_000_000))) - gain = ( - managed.line("general_overhead").base_amount_krw - - plain.line("general_overhead").base_amount_krw - ) - assert gain == Decimal(10_000_000) - assert "관리품목자재대" in managed.line("general_overhead").base_label - - -@pytest.mark.parametrize( - ("mode", "expected"), - [ - ("material", lambda r, d: r.amount("material_cost")), - ("none", lambda r, d: Decimal(0)), - ("forest_coop", lambda r, d: r.amount("material_cost") + d.direct_expense_krw), - ( - "forest_coop_exempt", - lambda r, d: ( - r.amount("material_cost") - d.tax_exempt_material_krw + d.direct_expense_krw - ), - ), - ], -) -def test_부가세_방식은_밑수만_바꾼다(mode, expected) -> None: - data = replace(BASE, vat_mode=mode, tax_exempt_material_krw=Decimal(3_327_500)) - result = calculate_cost(data) - assert result.line("vat").base_amount_krw == expected(result, data) - if mode == "none": - assert result.amount("vat") == 0 - - -def test_실무_절사_식() -> None: - assert profit_cut(Decimal(373), "grand_total", "supply") == 339 # 봉화 - assert profit_cut(Decimal(695), "grand_total", "supply") == 632 # 영월 - assert profit_cut(Decimal(996), "grand_total", "forest_coop_exempt") == 996 # 영덕 - assert cut_gap(Decimal(1_270_728_373), 1000) == 373 - - -@pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"]) -def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None: - plain = calculate_cost(replace(BASE, vat_mode=vat_mode, cut_basis="none")) - cut = calculate_cost( - replace(BASE, vat_mode=vat_mode, cut_basis="grand_total", cut_unit_krw=1000) - ) - assert cut.amount("grand_total") % 1000 == 0 - assert 0 <= plain.amount("grand_total") - cut.amount("grand_total") < 1000 - assert cut.amount("profit") < plain.amount("profit") - assert cut.has("profit_adjustment") and any("자동보정" in note for note in cut.notes) - # 이윤 말고는 안 움직임 — 순공사원가·일반관리비 그대로. - for key in ("net_construction_cost", "general_overhead"): - assert cut.amount(key) == plain.amount(key) - - -def test_공급가액_절사는_차액_그대로() -> None: - plain = calculate_cost(replace(BASE, cut_basis="none")) - cut = calculate_cost(replace(BASE, cut_basis="supply", cut_unit_krw=10_000)) - assert cut.amount("total_cost") % 10_000 == 0 - assert plain.amount("profit") - cut.amount("profit") == plain.amount("total_cost") % 10_000 - - -def test_절사를_끄면_이윤을_안_건드린다() -> None: - """고르개 「절사 안 함」 = `none`. ⚠ 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).""" - for off in ("none", ""): - assert not calculate_cost(replace(BASE, cut_basis=off)).has("profit_adjustment"), off - # 기준만 있고 단위가 0 이면 안 자른다. - assert not calculate_cost(replace(BASE, cut_unit_krw=0)).has("profit_adjustment") - - -def test_자동보정_줄은_제_이름을_적는다() -> None: - cut = calculate_cost(replace(BASE, cut_basis="grand_total", cut_unit_krw=1000)) - assert "절사 자동보정" in cut.line("profit_adjustment").base_label - manual = calculate_cost(replace(BASE, cut_basis="none", profit_adjustment_krw=Decimal(100))) - assert manual.line("profit_adjustment").base_label == "설계자 명시 입력" - - -def test_고용보험_등급_고르기와_없음() -> None: - auto = calculate_cost(BASE) - grade1 = calculate_cost(replace(BASE, employment_insurance_grade="1")) - none = calculate_cost(replace(BASE, employment_insurance_grade="none")) - assert grade1.line("employment_insurance").rate_percent == Decimal("1.57") - assert grade1.amount("employment_insurance") > auto.amount("employment_insurance") - assert not none.has("employment_insurance") or none.amount("employment_insurance") == 0 - with pytest.raises(Exception): - calculate_cost(replace(BASE, employment_insurance_grade="9")) - - -def test_퇴직공제_적용_여부() -> None: - small = replace(BASE, estimated_price_krw=Decimal(50_000_000)) # 1억 미만 — 자동이면 안 섬 - assert ( - not calculate_cost(small).has("retirement_mutual_aid") - or calculate_cost(small).amount("retirement_mutual_aid") == 0 - ) - applied = calculate_cost(replace(small, retirement_mutual_aid_mode="apply")) - assert applied.amount("retirement_mutual_aid") > 0 - off = calculate_cost(replace(BASE, retirement_mutual_aid_mode="none")) - assert not off.has("retirement_mutual_aid") or off.amount("retirement_mutual_aid") == 0 - - -def test_폐기물_승률_밖_자리() -> None: - """실무 관행(울진소광) — 이윤 뒤 · 총원가 안 · 부가세 안 · 일반관리비·이윤 밑수 밖.""" - waste = Decimal(1_000_000) - law = calculate_cost(replace(BASE, waste_disposal_krw=waste)) - practice = calculate_cost( - replace(BASE, waste_disposal_krw=waste, waste_placement="after_profit") - ) - none = calculate_cost(BASE) - assert practice.amount("general_overhead") == none.amount("general_overhead") - assert practice.amount("profit") == none.amount("profit") - assert practice.amount("total_cost") - none.amount("total_cost") == waste - assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼 - keys = [line.key for line in practice.lines] - assert keys.index("profit") < keys.index("waste_disposal") < keys.index("total_cost") - - -def test_이행보증은_일반계약이면_300억_미만에서_안_선다() -> None: - assert not calculate_cost(BASE).has("performance_guarantee_fee") - tech = calculate_cost(replace(BASE, performance_guarantee_mode="lowest_price_tech")) - assert tech.amount("performance_guarantee_fee") > 0 - - -def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None: - """제비율 기준 §10 — [79만원 + (직공비−75억원) × 0.0070%] × 공기(년).""" - big = CostInput( - direct_material_krw=Decimal(500_000_000), - direct_labor_krw=Decimal(300_000_000), - direct_expense_krw=Decimal(9_200_000_000), # 직공비 100억 · 안전 대상액 8억 - estimated_price_krw=Decimal(12_000_000_000), - duration_days=365, - performance_guarantee_mode="lowest_price_tech", - ) - assert calculate_cost(big).amount("performance_guarantee_fee") == Decimal(790_000 + 175_000) - - -def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None: - on = replace(BASE, subcontract_guarantee="on") - assert calculate_cost(on).line("subcontract_payment_guarantee").rate_percent == Decimal("0.081") - turnkey = calculate_cost(replace(on, bid_method="turnkey")) - assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084") - comp = calculate_cost(replace(on, bid_method="comprehensive")) - assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071") - - -def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None: - same = calculate_cost( - replace(BASE, private_material_position="material", contract_law_basis="local") - ) - assert same.totals == calculate_cost(BASE).totals - - -def test_산업안전_50억_이상은_선임_대상으로_가른다() -> None: - """고시 별표1 — 「50억 이상」 열과 「보건관리자 선임 대상」 열은 공사가 선임 대상인가로 갈림.""" - big = replace( - BASE, - direct_material_krw=Decimal(4_000_000_000), - direct_labor_krw=Decimal(3_000_000_000), - estimated_price_krw=Decimal(20_000_000_000), # 토목 선임 기준 1,000억 미만 - ) - assert calculate_cost(big).line("safety_management_cost_b").rate_percent == Decimal("2.6") - target = calculate_cost(replace(big, estimated_price_krw=Decimal(120_000_000_000))) - assert target.line("safety_management_cost_b").rate_percent == Decimal("2.73") - - -def test_하도급대금_지급보증은_기본_꺼짐() -> None: - assert not calculate_cost(BASE).has("subcontract_payment_guarantee") - assert ( - calculate_cost(replace(BASE, subcontract_guarantee="on")).amount( - "subcontract_payment_guarantee" - ) - > 0 - ) - - -#: 실무 거창 2025 꼴 — 안전관리비 한 줄만 서고 관급이 큰 모양. 총공사비 끝자리가 -#: **건너뛰는** 자리가 생긴다(이윤 1원이 총공사비 1~2원이라 어떤 배수는 못 밟는다). -_SKIPPING = CostInput( - direct_material_krw=Decimal(80_165_010), - direct_labor_krw=Decimal(243_648_150), - direct_expense_krw=Decimal(0), - owner_supplied_material_krw=Decimal(74_634_214), - enabled_items=("safety_management_cost",), - cut_basis="grand_total", - cut_unit_krw=1000, -) - - -@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) -def test_절사는_끝자리를_건너뛰는_자리에서도_앉는다(delta) -> None: - """⭐ 이윤 1원을 깎으면 총공사비는 **1원이나 2원** 줄어 어떤 1,000 배수는 못 밟는다. - - 종전엔 ÷1.1 어림이 한 칸 아래로 지나친 뒤 3걸음 안에 못 돌아와 끝자리 999 가 남았다 - (거창 꼴 40 자리 중 16). 못 밟는 배수면 **한 칸 아래 배수로 내려가** 앉아야 한다. - """ - result = calculate_cost(replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta))) - assert result.totals["grand_total"] % 1000 == 0, delta - assert not [n for n in result.notes if "안 앉음" in n], result.notes - - -@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) -def test_절사는_깎을_수_있는_가장_적은_몫만_깎는다(delta) -> None: - """버림이므로 깎인 몫은 **한 칸(1,000원) 안쪽**이어야 한다. - - ⚠ 이 줄이 깨지면 「못 밟는 배수를 만나 한 칸 내려갔다」는 뜻이다 — 실측(거창 꼴 40 자리)에서는 - 한 칸도 안 내려갔다. 깨지는 자리가 생기면 그 자체가 봐야 할 소식이다. - """ - data = replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta)) - plain = calculate_cost(replace(data, cut_basis="none")) - cut = calculate_cost(data) - drop = plain.totals["grand_total"] - cut.totals["grand_total"] - assert 0 <= drop < 1000, (delta, drop) diff --git a/resources/tester/test_b09_cost_sheet_forms.py b/resources/tester/test_b09_cost_sheet_forms.py deleted file mode 100644 index cd89fc7fe..000000000 --- a/resources/tester/test_b09_cost_sheet_forms.py +++ /dev/null @@ -1,72 +0,0 @@ -"""원가계산서 나머지 세 형식 — 서식 차례만 · 0 원 안 채움 · 막힌 까닭 · 확인 대기 (2026-09-14 판정). - -근거: STmate 분석 12번 §4~6 · 35번 §1·§2·§5. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_CostSheet_Forms import ( # noqa: E402 - PENDING_PROFIT, - RATE_B, - RATE_RA, - SEPARATE, - form_rows, -) - - -@pytest.mark.parametrize("form", ["sugong", "actual_general", "actual_sugong"]) -def test_금액은_하나도_안_내고_0원도_없다(form) -> None: - rows = form_rows(form) - assert rows and all(row["amount_krw"] is None for row in rows) - assert all(row["note"] for row in rows) - - -def test_수공은_세부_경비_여덟이_율표_미확보() -> None: - rows = form_rows("sugong") - blocked = [row["label"] for row in rows if row["note"] == RATE_B] - for name in ("수도광열비", "복리후생비", "지급수수료", "폐기물처리비"): - assert name in blocked - assert "기타경비" not in [row["label"] for row in rows] - - -def test_실적형은_이윤만_확인_대기_안전관리비는_둘_다_별도내역() -> None: - """SA 안전관리비 「확인 대기」를 **별도내역**으로 내림 — - 코덱스가 STmate 원문에서 확인(2026-09-14). - - TA 는 처음부터 별도내역이었음. 요율이 아니라 따로 산출한 금액이라 **금액엔 안 닿음**. - """ - general = {row["label"]: row["note"] for row in form_rows("actual_general")} - sugong = {row["label"]: row["note"] for row in form_rows("actual_sugong")} - assert general["이 윤"] == PENDING_PROFIT and sugong["이 윤"] == PENDING_PROFIT - assert general["안전관리비"] == SEPARATE and sugong["안전관리비"] == SEPARATE - assert general["간접노무비"] == RATE_RA and general["기타경비"] == RATE_RA - - -def test_실적일반_보증_밑수는_직접공사비_환경은_재료직노산출경비() -> None: - """35번 §1 — 12번의 「TA 환경보전비 = 직접공사비」는 06번 정정에 따라 재+직노+산출경비.""" - rows = {row["label"]: row["formula"] for row in form_rows("actual_general")} - assert rows["하도대금 수수료"].startswith("직접공사비") - assert rows["환 경 보 전 비"].startswith("(재료비+직노+산출경비)") - - -def test_모르는_형식은_멈춘다() -> None: - with pytest.raises(ValueError): - form_rows("general") - - -@pytest.mark.parametrize("form", ["sugong", "actual_general", "actual_sugong"]) -def test_사유는_요율표_미확보가_아니라_실제_결론(form) -> None: - """2026-09-14 요율표 셋 결론(PLAN 7장) — 「자료를 못 구함」 이 아니라 실적형은 **임도 미적용** - (예정가격작성기준 §37② 100억 미만) · 수공 세부 경비는 **법정 요율 없음**(§34① 업체 실측).""" - notes = [row["note"] for row in form_rows(form)] - assert not any("요율표 미확보" in note for note in notes), notes - assert "임도 미적용" in RATE_RA and "§37②" in RATE_RA - assert "법정 요율 없음" in RATE_B and "값을 넣어 주십시오" in RATE_B and "§34①" in RATE_B diff --git a/resources/tester/test_b09_cost_sheet_layout.py b/resources/tester/test_b09_cost_sheet_layout.py deleted file mode 100644 index f287f98d6..000000000 --- a/resources/tester/test_b09_cost_sheet_layout.py +++ /dev/null @@ -1,140 +0,0 @@ -"""원가계산서 탭(일반형식) — STmate 서식 차례로 우리 엔진 줄을 늘어놓는가 (PLAN 12장). - -⚠ 금액을 박지 않는다 — 차례·표기·자리(경비 안/총원가 밖)와 「줄 = 엔진 값」만 잰다. -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_CostSheet import ( # noqa: E402 - clean_settings, - cost_input, - field_options, - sheet_rows, -) -from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost # noqa: E402 -from B09_Estimation.B09_Estimation_Rates import load_rate_dataset # noqa: E402 -from B09_Estimation.B09_Estimation_RateTable import amount_label, rate_sections # noqa: E402 - -DIRECT = { - "material": Decimal(30_000_000), - "labor": Decimal(40_000_000), - "expense": Decimal(10_000_000), -} - - -def _sheet(waste: Decimal = Decimal(0), separate: bool = False, stored: dict | None = None): - data = cost_input(DIRECT, stored or {}, waste, separate) - return sheet_rows(calculate_cost(data), data), calculate_cost(data) - - -def test_큰_묶음이_STmate_차례로_선다() -> None: - rows, _ = _sheet() - marks = [ - row["mark"] - for row in rows - if row["mark"] in ("나.", "ㄱ.", "ㄴ.", "ㄷ.", "다.", "라.", "마.", "바.") - ] - assert marks == ["나.", "ㄱ.", "ㄴ.", "ㄷ.", "다.", "라.", "마.", "바."] - - -def test_줄_금액은_엔진_값_그대로() -> None: - rows, result = _sheet() - by_key = {row["key"]: row for row in rows} - for key in ("net_construction_cost", "general_overhead", "profit", "total_cost", "grand_total"): - assert Decimal(by_key[key]["amount_krw"]) == result.amount(key) - assert Decimal(by_key["subtotal"]["amount_krw"]) == result.amount( - "net_construction_cost" - ) + result.amount("general_overhead") - - -def test_경비_세목은_2번부터_차례로_번호() -> None: - rows, _ = _sheet() - expense = [row for row in rows if row["level"] == 2 and row["mark"].endswith(")")] - numbers = [ - int(row["mark"][:-1]) - for row in expense - if row["key"] not in ("direct_labor", "indirect_labor_cost") - ] - assert numbers[0] == 1 and numbers[1:] == list(range(2, len(numbers) + 1)) - - -def test_폐기물은_기본_경비_안_분리발주면_도급공사비_뒤() -> None: - rows, _ = _sheet(Decimal(1_000_000)) - keys = [row["key"] for row in rows] - assert keys.index("waste_disposal") < keys.index("general_overhead") - rows, _ = _sheet(Decimal(1_000_000), separate=True) - keys = [row["key"] for row in rows] - assert keys.index("waste_disposal") > keys.index("contract_amount") - - -def test_저장값은_데이터에_있는_공종만_받는다() -> None: - dataset = load_rate_dataset() - cleaned = clean_settings( - { - "work_type_safety": "civil", - "environment_work_type": "moon_base", - "duration_days": "200", - "owner_supplied_material_krw": "-5", - "profit_adjustment_krw": "", - }, - dataset, - ) - assert cleaned == { - "work_type_safety": "civil", - "duration_days": 200, - "owner_supplied_material_krw": "0", - } - assert {o["value"] for o in field_options(dataset)["work_type_indirect_labor"]} == { - "civil", - "landscape", - "industrial_facilities_civil", - } - - -def test_저장한_기간이_간접노무비_구간을_바꾼다() -> None: - short = {r["key"]: r for r in _sheet(stored={"duration_days": 100})[0]} - long = {r["key"]: r for r in _sheet(stored={"duration_days": 800})[0]} - assert ( - short["indirect_labor_cost"]["rate_percent"] != long["indirect_labor_cost"]["rate_percent"] - ) - - -def test_요율표_구간_표기와_묶음() -> None: - assert amount_label("lt_5_billion") == "50억 미만" - assert amount_label("5_to_30_billion") == "50억~300억 미만" - titles = [section["title"] for section in rate_sections(load_rate_dataset())] - assert titles[0] == "일반관리비 · 이윤" and "산업안전보건관리비" in titles - for section in rate_sections(load_rate_dataset()): - assert all(len(row) == len(section["columns"]) for row in section["rows"]), section["title"] - - -def test_10_11_12_칸이_저장값을_엔진으로_옮긴다() -> None: - dataset = load_rate_dataset() - stored = clean_settings( - { - "overhead_class": "specialty_electric_communication_fire_other", - "cut_basis": "grand_total", - "cut_unit_krw": "1000", - "vat_mode": "forest_coop", - "tax_exempt_material_krw": "5000", - "cut_unit_krw_bad": "7", - }, - dataset, - ) - assert stored["cut_unit_krw"] == 1000 and stored["vat_mode"] == "forest_coop" - data = cost_input(DIRECT, stored) - assert (data.overhead_class, data.cut_basis, data.cut_unit_krw) == ( - "specialty_electric_communication_fire_other", - "grand_total", - 1000, - ) - rows = {r["key"]: r for r in sheet_rows(calculate_cost(data), data)} - assert int(rows["grand_total"]["amount_krw"]) % 1000 == 0 - assert clean_settings({"cut_unit_krw": "7", "vat_mode": "moon"}, dataset) == {} diff --git a/resources/tester/test_b09_design_doc_index.py b/resources/tester/test_b09_design_doc_index.py deleted file mode 100644 index d44491563..000000000 --- a/resources/tester/test_b09_design_doc_index.py +++ /dev/null @@ -1,102 +0,0 @@ -"""설계서 구성표 — 법이 정한 목차와 우리가 내는 것 (2026-09-09). - -산림자원법 시행규칙 [별표2] (5)(가) 가 **설계서 작성 순서를 열셋으로 못 박아** 두었다. -그 목록과 우리 화면을 맞대 두지 않으면 「무엇을 못 내는가」가 계획서에만 남는다. - -⚠ 겨누는 것 넷 - ① 법이 적은 **차례 그대로**다 — 순서를 우리가 바꾸지 않는다 - ② **「없음」과 「우리 몫 아님」을 가른다** — 시방서·예정공정표는 설계자가 쓰는 문서다 - ③ 「반쪽」은 반쪽이라 적는다 — 중기경비계산서·산출기초 - ④ 못 내는 것에는 **무엇을 받아야 내는지**가 적혀 있다 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_DesignDocIndex import ( # noqa: E402 - DESIGN_DOC_ITEMS, - OWNER_DESIGNER, - OWNER_PROGRAM, - STATUS_MISSING, - STATUS_PARTIAL, - STATUS_READY, - design_doc_index, -) - -#: 별표2 (5)(가) 원문 차례. -_LAW_ORDER = ( - "목차", - "공사설명서", - "일반시방서", - "특별시방서", - "예정공정표", - "예산내역서", - "일위대가표", - "단가산출서", - "각종 중기경비계산서", - "공종별 수량계산서", - "각종 소요자재총괄표", - "토적표", - "산출기초", -) - - -def test_법이_적은_차례_그대로다() -> None: - assert tuple(item["name"] for item in DESIGN_DOC_ITEMS) == _LAW_ORDER - assert [item["order"] for item in DESIGN_DOC_ITEMS] == list(range(1, 14)) - - -def test_설계자_몫과_우리_몫을_가른다() -> None: - """② 「없음」 넷 가운데 셋은 **애초에 우리가 만들 것이 아니다.**""" - theirs = {item["name"] for item in DESIGN_DOC_ITEMS if item["owner"] == OWNER_DESIGNER} - assert theirs == {"일반시방서", "특별시방서", "예정공정표"} - for item in DESIGN_DOC_ITEMS: - if item["owner"] == OWNER_DESIGNER: - assert item["status"] == STATUS_MISSING - - -def test_반쪽이_남지_않았다() -> None: - """⚠ 2026-09-09 밤 — 중기경비계산서·산출기초를 차례로 세우며 **반쪽이 다 없어졌다.** - - 이제 우리 몫 가운데 「없음」은 공사설명서 하나이고, 그것은 **서식·설계하중 표기를 - 사용자에게 받아야** 서는 자리다. - """ - partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL} - assert partial == set() - ours_missing = { - item["name"] - for item in DESIGN_DOC_ITEMS - if item["owner"] == OWNER_PROGRAM and item["status"] != STATUS_READY - } - assert ours_missing == {"공사설명서"} - - -def test_서는_것은_어디서_나오는지_적혀_있다() -> None: - for item in DESIGN_DOC_ITEMS: - if item["status"] == STATUS_READY: - assert item["where"], f"{item['name']}: 어느 화면인지 안 적혀 있음" - - -def test_못_내는_것에는_무엇이_필요한지_적혀_있다() -> None: - """④ 「없음」으로만 두면 다음 사람이 무엇부터 해야 할지 모른다.""" - for item in DESIGN_DOC_ITEMS: - if item["status"] != STATUS_READY: - assert item["note"].strip(), f"{item['name']}: 사유가 비어 있음" - - -def test_요약이_셈과_맞는다() -> None: - data = design_doc_index() - assert data["counts"][STATUS_READY] == 9 - assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS) - ours_missing = [ - item["name"] - for item in DESIGN_DOC_ITEMS - if item["owner"] == OWNER_PROGRAM and item["status"] != STATUS_READY - ] - for name in ours_missing: - assert name in data["summary"] diff --git a/resources/tester/test_b09_display_labels.py b/resources/tester/test_b09_display_labels.py deleted file mode 100644 index 732bb4244..000000000 --- a/resources/tester/test_b09_display_labels.py +++ /dev/null @@ -1,57 +0,0 @@ -"""내역 표시 흠 묶음 — 장비 id · 거리 14자리 · 규격 갈래 두 번 (2026-09-14 브레인 ㉰㉵). - -훑기(936be972 내역)에서 잡은 것: - ㉰ 운반 줄 이름이 장비 키 그대로 「dozer 운반」「dump_truck 운반」「free_haul 운반」 - ㉰ 덤프 규격 칸 「토사 L=137.13474461798225m」 — 가중평균 거리를 그대로 찍음 - ㉵ 배수관 규격 칸 「Ø800 800」 — 이미 적힌 관경을 갈래로 한 번 더 붙임 -⚠ 거리는 **보이기만** 줄인다 — 호표 열쇠·운반 식은 받은 거리 그대로. -""" - -from __future__ import annotations - -from decimal import Decimal - -import B09_Estimation.B09_Estimation_BillOfQuantities # noqa: F401 — 줄 모듈은 조판 모듈이 먼저 섬 -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import spec_with_variant -from B09_Estimation.B09_Estimation_MachineProductivity_Dump import distance_label, dump_title_code - - -def test_운반_줄_이름에_장비_키가_안_뜬다() -> None: - haul = { - "rows": [ - {"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0, "in_bill": False}, - {"equipment": "dozer", "ground": "토사", "volume_m3": 20.0, "average_distance_m": 40.0}, - { - "equipment": "dump_truck", - "ground": "토사", - "volume_m3": 30.0, - "average_distance_m": 137.13474461798225, - }, - ] - } - names = [row["name"] for row in build_handoff(haul_table=haul)["work_items"]] - assert "불도저 운반" in names and "덤프 운반" in names and "무대(종방향유용토)" in names - assert not [ - name for name in names if any(key in name for key in ("dozer", "dump_truck", "free_haul")) - ] - - -def test_거리는_둘째_자리까지_보이고_열쇠는_그대로() -> None: - raw = Decimal("137.13474461798225") - assert distance_label(raw) == "L=137.13m" - assert distance_label(Decimal("100")) == "L=100m" - assert distance_label(Decimal("60.5")) == "L=60.5m" - # 호표 열쇠는 받은 거리 그대로 — 거리마다 갈리는 호표가 뭉치면 안 된다. - assert dump_title_code("FP-10-12-01", raw).endswith("L137.13474461798225m") - - -def test_규격에_적힌_갈래는_한_번만() -> None: - assert spec_with_variant("Ø800", "800") == "Ø800" - assert spec_with_variant("연암 · 리핑암", "리핑암") == "연암 · 리핑암" - # 숫자 경계 — 「Ø1800」 에 800 은 적힌 것이 아님. - assert spec_with_variant("Ø1800", "800") == "Ø1800 800" - # 종전 동작 — 빈 규격 · 규격 글로 시작하는 갈래(면고르기) · 새 갈래. - assert spec_with_variant("", "토사") == "토사" - assert spec_with_variant("성토면", "성토면 인력") == "성토면 인력" - assert spec_with_variant("H=2", "돌쌓기") == "H=2 돌쌓기" diff --git a/resources/tester/test_b09_dump_haul.py b/resources/tester/test_b09_dump_haul.py deleted file mode 100644 index da497c9dc..000000000 --- a/resources/tester/test_b09_dump_haul.py +++ /dev/null @@ -1,126 +0,0 @@ -"""덤프 운반(산림품셈 10-12 「2. 운반」) — 거리별 호표 · 원문 어긋남은 고른 값·버린 값을 사유에. - -2026-09-14 축 C 1장 (브레인 판정 ①②④⑤): - ① n 의 Qt = 계산값 T/γt×L (원문 표기 10 버림) ② 발파암 E 0.9 ④ Es = 운반 줄 값 - ⑤ 운반거리마다 갈래 · 거리 없으면 0 원 아니라 「운반거리 미입력」 -손셈(토사 164.23 m): Qt 10.2632 · n 16.2907 · t1 6.3885 · t2 3.6131 · ㎝t 12.5016 · f 0.77 - → Q 34.135 → 34.14(사사오입) -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( # noqa: E402 - DUMP_MATERIALS, - DumpHaul, - dump_child_for, - dump_distance_from_code, - dump_haul_distances, - dump_title_code, -) -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 - -DISTANCE = Decimal("164.23") - - -def test_토사_운반_식_손셈() -> None: - haul = DumpHaul(DUMP_MATERIALS["FP-10-12-01"], DISTANCE) - assert round(haul.truck_load_m3, 4) == Decimal("10.2632") - assert round(haul.loader_cycles, 4) == Decimal("16.2907") - assert round(haul.cycle_minutes, 4) == Decimal("12.5016") - assert (haul.volume_factor, haul.hourly_output) == (Decimal("0.77"), Decimal("34.14")) - - -def test_암절취는_계산값_Qt_와_운반줄_Es() -> None: - haul = DumpHaul(DUMP_MATERIALS["FP-10-12-02"], DISTANCE) - assert round(haul.truck_load_m3, 4) == Decimal("8.4375") # 원문 표기 10 이 아님 - assert haul.hourly_output == Decimal("12.49") - assert any("원문 표기" in note for note in DUMP_MATERIALS["FP-10-12-02"].notes) - assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9") - assert any("E 원문 누락" in note for note in DUMP_MATERIALS["FP-10-12-03"].notes) - - -def test_거리마다_호표가_서고_코드에서_거리를_되읽는다() -> None: - build = cached_build(dump_haul_m=(str(DISTANCE),)) - code = dump_title_code("FP-10-12-01", DISTANCE) - assert code == "B-FP-10-12-01#L164.23m" - money = build.book.resolve(code) - assert money.total > 0 - assert dump_distance_from_code(code) == ("164.23",) - assert code not in cached_build().book.titles # 거리 없는 조립엔 안 섬 - - -def test_인계_거리와_갈래_잇기() -> None: - payload = { - "work_items": [ - { - "work_item_code": "FP-10-12", - "haul_equipment": "dump_truck", - "haul_distance_m": 164.23, - }, - {"work_item_code": "FP-10-12", "haul_equipment": "dump_truck", "haul_distance_m": None}, - {"work_item_code": "FP-10-11", "haul_equipment": "dozer", "haul_distance_m": 32.09}, - ] - } - assert dump_haul_distances(payload) == ("164.23",) - assert dump_child_for("리핑암", "2026-01-01") == "FP-10-12-02" # 범위 별칭 리핑암 → 암절취 - assert dump_child_for("토사", "2026-01-01") == "FP-10-12-01" - assert dump_child_for("모르는암", "2026-01-01") is None - - -def test_적재는_거리_무관_한_장이고_E0_는_0_60() -> None: - from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( - loading_output, - loading_title_code, - ) - - # 토사 3600×0.7×0.9×0.77×0.60/22 = 47.628 → 47.63 - # 암절취 3600×0.7×0.55×0.74×0.35/22 = 16.317 → 16.32 - assert loading_output("FP-10-12-01") == Decimal("47.63") - assert loading_output("FP-10-12-02") == Decimal("16.32") - build = cached_build() # 거리 없어도 섬 - code = loading_title_code("FP-10-12-01") - assert build.book.resolve(code).total > 0 - basis = build.book.details[f"D-{code[2:]}"][0].note - assert "0.75 버림" in basis - - -def test_내역_줄_거리_없으면_운반거리_미입력() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - def payload(distance): - return { - "work_items": [ - { - "work_item_code": "FP-10-12", - "name": "dump_truck 운반", - "spec": "토사", - "unit": "㎥", - "quantity": 40.0, - "haul_equipment": "dump_truck", - "haul_distance_m": distance, - "variant_axis": "ground_class", - "variant_value": "토사", - "in_bill": True, - "origin": "haul", - } - ], - "materials": [], - } - - blank = build_bill(payload(None), build=cached_build()) - assert any("운반거리 미입력" in m["reason"] for m in blank.missing) - priced = build_bill(payload(164.23), build=cached_build(dump_haul_m=("164.23",))) - row = next(r for r in priced.rows if r.name == "dump_truck 운반") - assert row.price_code == "B-FP-10-12-01#L164.23m" and row.amount_krw > 0 - loading = payload(None) - loading["work_items"][0].update(name="덤프 적재", haul_equipment="dump_loading") - loaded = build_bill(loading, build=cached_build()) - row = next(r for r in loaded.rows if r.name == "덤프 적재") - assert row.price_code == "B-FP-10-12-01#적재" and row.amount_krw > 0 diff --git a/resources/tester/test_b09_edits.py b/resources/tester/test_b09_edits.py deleted file mode 100644 index ab43f0ca6..000000000 --- a/resources/tester/test_b09_edits.py +++ /dev/null @@ -1,211 +0,0 @@ -"""B09 사용자가 고친 값 — 프로젝트 단위 저장·얹기·되돌리기 (PLAN 12장 2차 · 2026-09-14 브레인 판정). - -겨누는 것 - ① 고친 값은 **복사본에** 얹힘 — 기본 벌(캐시)은 그대로(골든셋·다른 프로젝트가 안 흔들림) - ② 자재단가대비표 채택 슬롯 — 값 있는 슬롯만 받음 · 채택하면 금액이 그 슬롯 값으로 섬 - ③ 지우면(↺) 계산값으로 돌아감 · 고친 값이 없으면 캐시 키가 빈 글 -""" - -from __future__ import annotations - -from decimal import Decimal -from typing import Any - -import pytest - -from B09_Estimation.B09_Estimation_Edits import ( - EditError, - apply_edits, - edits_key, - merge_changes, -) -from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle -from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild - - -def _build() -> UnitPriceBuild: - book = PriceBook() - slots = [Decimal(100), None, Decimal(90), None, None, Decimal(120)] - book.add_title(PriceTitle("M-1", PriceKind.MATERIAL, "시멘트", slots=slots)) - book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "타설")) - book.add_detail(PriceDetail("B-1", "M-1", Decimal(2))) - return UnitPriceBuild(book=book) - - -def test_채택_슬롯을_바꾸면_복사본에서만_금액이_바뀐다() -> None: - base = _build() - edits = merge_changes({}, base, [{"section": "adopted_slots", "key": "M-1", "value": "3"}]) - assert edits == {"adopted_slots": {"M-1": 3}} - edited = apply_edits(base, edits) - assert edited.book.resolve("B-1").material == Decimal(180) # 90 × 2 - assert base.book.resolve("B-1").material == Decimal(240) # 기본 벌은 6번 120 그대로 - assert edited.edit_skipped == [] - - -def test_값_없는_슬롯은_받지_않는다() -> None: - with pytest.raises(EditError): - merge_changes({}, _build(), [{"section": "adopted_slots", "key": "M-1", "value": 2}]) - with pytest.raises(EditError): - merge_changes({}, _build(), [{"section": "adopted_slots", "key": "B-1", "value": 1}]) - - -def test_지우면_계산값으로_돌아가고_캐시_키가_빈다() -> None: - base = _build() - edits = merge_changes( - {"adopted_slots": {"M-1": 1}}, - base, - [{"section": "adopted_slots", "key": "M-1", "value": None}], - ) - assert edits == {} and edits_key(edits) == "" - assert edits_key({"adopted_slots": {"M-1": 1}}) != "" - - -def _book_with_basis() -> UnitPriceBuild: - build = _build() - book = build.book - book.add_title( - PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[None] * 5 + [Decimal(100000)]) - ) - book.add_title( - PriceTitle("X-1", PriceKind.MACHINE_BASE, "굴착기", slots=[None] * 5 + [Decimal(55700)]) - ) - book.add_detail(PriceDetail("B-1", "L-1", Decimal("0.1"))) - # D — Q 15.71 로 선 장비 줄(몫 1): 수량 1/15.71 - book.add_output_detail("B-1", "X-1", Decimal(1) / Decimal("15.71"), "Q 식", Decimal("15.71")) - return build - - -def test_구성행_수량을_고치고_빼고_더하면_금액이_다시_서고_지우면_돌아온다() -> None: - base = _book_with_basis() - before = base.book.resolve("B-1") - changes = [ - { - "section": "sheet_rows", - "key": "B-1|1", - "value": {"quantity": "0.2"}, - }, # 보통인부 0.1 → 0.2 - {"section": "sheet_rows", "key": "B-1|0", "value": {"removed": True}}, # 시멘트 뺌 - {"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "L-1", "quantity": "0.05"}}, - ] - edits = merge_changes({}, base, changes) - edited = apply_edits(base, edits) - after = edited.book.resolve("B-1") - assert after.material == 0 and after.labor == Decimal(25000) # 0.2×10만 + 0.05×10만 - marks = edited.edit_rows["B-1"] - assert marks[0]["removed"] and marks[1]["was_quantity"] == "0.1" and marks[3]["added"] - assert base.book.resolve("B-1") == before # 기본 벌 그대로 - cleared = merge_changes(edits, base, [{**c, "value": None} for c in changes]) - assert cleared == {} and apply_edits(base, cleared).book.resolve("B-1") == before - - -def test_Q_식을_고치면_수량이_옛_Q_를_새_Q_로_바꾼_만큼_변한다() -> None: - base = _book_with_basis() - key = "D-1|0" - edits = merge_changes( - {}, - base, - [ - { - "section": "price_basis_q", - "key": key, - "value": {"q_formula": "3600*0.2*0.7*0.85*0.55/15*2"}, - } - ], - ) - edited = apply_edits(base, edits) - detail = edited.book.details["D-1"][0] - assert detail.output == Decimal("31.42") - assert detail.quantity == Decimal(1) / Decimal("15.71") * Decimal("15.71") / Decimal("31.42") - assert edited.edit_rows["D-1"][0]["q_formula"].startswith("3600") - # 줄 0.1원 · 머리 원 미만 절사: 55,700 ÷ 31.42 = 1,772.7 → 1,772 - assert edited.book.resolve("D-1").expense == Decimal(1772) - - -def test_Q_로_안_선_줄·비율_줄·돌림_참조는_받지_않는다() -> None: - base = _book_with_basis() - with pytest.raises(EditError): - merge_changes( - {}, base, [{"section": "price_basis_q", "key": "B-1|1", "value": {"q_formula": "10"}}] - ) - with pytest.raises(EditError): - merge_changes( - {}, - base, - [{"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "B-1", "quantity": "1"}}], - ) - with pytest.raises(EditError): - merge_changes( - {}, - base, - [{"section": "sheet_rows", "key": "D-1|+1", "value": {"ref": "B-1", "quantity": "1"}}], - ) - - -def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> None: - edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}}) - assert len(edited.edit_skipped) == 2 - assert edited.book.titles["M-1"].adopted_slot == 6 - - -def _bill_row(code: str, spec: str = "", quantity: str = "10") -> Any: - from B09_Estimation.B09_Estimation_BillOfQuantities import BillRow - - row = BillRow(item_no="1", level=1, code=code, name=code, spec=spec, quantity=Decimal(quantity)) - row.price_code = f"B-{code}" - row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = ( - Decimal(1000), - Decimal(2000), - Decimal(333), - ) - row.unit_price_krw = Decimal(3333) - row.amount_krw = Decimal(33330) - return row - - -def test_할증율은_줄_칸이_이기고_없으면_내역서_전체가_얹힌다() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line - from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates - - first, second = _bill_row("A"), _bill_row("B") - rates = { - "B-A|": {"all": "10"}, - "*": { - "material": "0", - "labor": "5", - "expense": "3", - "rounding": {"expense": {"mode": "round", "digits": 1}}, - }, - } - apply_bill_rates([first, second], rates, bill_line) - # 줄 칸 10% — 성분마다 원 미만 절사: 1,100 · 2,200 · 366.3 → 366 - assert (first.unit_material_krw, first.unit_labor_krw, first.unit_expense_krw) == ( - Decimal(1100), - Decimal(2200), - Decimal(366), - ) - assert first.amount_krw == Decimal(36660) and first.rate["source"] == "B-A|" - # 전체 칸 — 경비는 반올림 소수 1자리: 333 × 1.03 = 342.99 → 343.0 · 노무 2,100 - assert second.unit_expense_krw == Decimal("343.0") and second.unit_labor_krw == Decimal(2100) - assert second.amount_krw == Decimal(10000 + 21000 + 3430) - assert "내역서 전체" in second.note - - -def test_할증율_모양이_틀리면_받지_않고_지우면_돌아온다() -> None: - base = _build() - with pytest.raises(EditError): - merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "-100"}}]) - with pytest.raises(EditError): - merge_changes( - {}, - base, - [ - { - "section": "bill_rates", - "key": "*", - "value": {"all": "5", "rounding": {"mode": "ceil", "digits": 0}}, - } - ], - ) - edits = merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "5"}}]) - assert edits["bill_rates"]["*"]["all"] == "5" and edits_key(edits) == "" # 조립 캐시엔 안 듦 - assert merge_changes(edits, base, [{"section": "bill_rates", "key": "*", "value": None}]) == {} diff --git a/resources/tester/test_b09_euroform.py b/resources/tester/test_b09_euroform.py deleted file mode 100644 index 7846f3b33..000000000 --- a/resources/tester/test_b09_euroform.py +++ /dev/null @@ -1,124 +0,0 @@ -"""유로폼 사용수량 12-38-2 — 2026-09-14 브레인 301 판정 ①~⑥ · ③′. - -① 산림 12-38-2 정본 ② 「임대료 또는 손료」 설계자 선택 · 제안값 없음 -③′ 손료 = 자재가 × 표 수량 곧장(실무 넷 — 봉화 「31,500 × 0.89 / 10」) · 12-38-1 잔존율은 안 곱함 -④ 이중계상 ③ 가드에서 12-38-02 만 뺌 — 패널이 B08 자재총괄에 줄이 없어서. 되살아날 자리를 시험으로 걺 -⑤ 고르는 자리 = 「자재 단가」 탭(넣은 쪽으로 섬 · 둘 다면 안 섬) ⑥ 임대료 갈래엔 % 안 걺 -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - -PANEL = "AR-M-10aba455" -INNER = "AR-M-5b89b294" -RENT = "AR-M-d00a6b1a" -LOSS = ((PANEL, "31500", "봉화 2024"), (INNER, "21000", "봉화 2024")) - - -def _titles(build, code: str) -> list[str]: - return [t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")] - - -def test_아무것도_안_넣으면_안_서고_설계자_선택_사유() -> None: - build = cached_build() - assert not _titles(build, "FP-12-38-02") - reason = build.component_gaps.get("FP-12-38-02", "") - assert "임대료 또는 손료" in reason, reason - assert "임대료 또는 손료" in build.component_gaps.get("FP-12-38", "") # 부모까지 까닭이 올라감 - for material in (PANEL, INNER, RENT): - assert "FP-12-38-02" in build.material_uses.get(material, []), material - - -def test_손료는_표_수량_곧장_부자재_소모자재는_주자재비의_퍼센트() -> None: - """봉화 2024 호표 「유로폼 설치 및 해체」 재료비 보통 4,500 · 간단 3,697 과 같은 값 — 실무 실증.""" - build = cached_build(material_prices=LOSS) - assert sorted(_titles(build, "FP-12-38-02")) == [ - "B-FP-12-38-02#간단", - "B-FP-12-38-02#보통", - "B-FP-12-38-02#복잡", - ] - detail = detail_of(build, "B-FP-12-38-02#보통")["rows"] - rows = {r["ref_code"]: Decimal(r["quantity"]) for r in detail if r.get("ref_code")} - assert rows[PANEL] == Decimal("0.089") and rows[INNER] == Decimal("0.003"), rows - assert build.book.resolve("B-FP-12-38-02#보통").material == Decimal(4500) - assert build.book.resolve("B-FP-12-38-02#간단").material == Decimal(3697) # 봉화 간단 3,697 - # 부모 12-38 = 사용수량 + 설치 및 해체 — 두 단계 갈래가 같으면 갈래끼리 합산 - parent = {r["ref_code"] for r in detail_of(build, "B-FP-12-38#보통")["rows"]} - assert parent == {"B-FP-12-38-02#보통", "B-FP-12-38-03#보통"}, parent - - -def test_작업조_표는_밑수가_시공량이라_밑수_없음이_아님() -> None: - """12-38-3 「(단위: 일 당) 형틀목공 4인 · 시공량 35㎡」 — 1㎡당 = 인원 ÷ 시공량(CrewOutput). - - 밑수 「N㎡당」 문구가 없다고 `basis_missing` 에 들어 설치·해체가 통째로 막혀 있었음(작업조 표 넷). - """ - build = cached_build() - crew = ("FP-05-28-02", "FP-12-07-02", "FP-12-38-03", "FP-13-16-02") - assert not [code for code in crew if code in build.basis_missing] - rows = { - r["name"]: Decimal(r["quantity"]) for r in detail_of(build, "B-FP-12-38-03#보통")["rows"] - } - assert rows["형틀목공"] == Decimal(4) / Decimal(35), rows - - -def test_잔존율은_안_곱하고_역산은_추정이라_밝힘() -> None: - build = cached_build(material_prices=LOSS) - note = next( - r["note"] - for r in detail_of(build, "B-FP-12-38-02#보통")["rows"] - if r.get("ref_code") == PANEL - ) - assert "우리 역산" in note and "추정" in note and "25회" in note, note - - -def test_임대료는_한_줄만_퍼센트_안_걺() -> None: - build = cached_build(material_prices=((RENT, "5000", "견적"),)) - assert _titles(build, "FP-12-38-02") == ["B-FP-12-38-02"] - rows = detail_of(build, "B-FP-12-38-02")["rows"] - assert [r["ref_code"] for r in rows] == [RENT], rows - assert build.book.resolve("B-FP-12-38-02").material == Decimal(5000) - assert "B-FP-12-38#보통" in build.book.titles - - -def test_둘_다_넣거나_한쪽만_반쯤_넣으면_안_섬() -> None: - both = cached_build(material_prices=(*LOSS, (RENT, "5000", "견적"))) - assert not _titles(both, "FP-12-38-02") - assert "둘 다" in both.component_gaps["FP-12-38-02"] - half = cached_build(material_prices=(LOSS[0],)) - assert not _titles(half, "FP-12-38-02") - assert "내부 패널" in half.component_gaps["FP-12-38-02"] - - -def test_유로폼이_자재총괄에_줄로_서면_가드를_되살릴_것() -> None: - """④ 12-38-02 를 가드에서 뺀 전제 — 패널·유로폼이 B08 자재총괄(`material`)에 줄이 없음. - - 누가 유로폼을 자재총괄로 보내는 날 할증이 두 번 붙음 → 여기서 빨강 · 가드에 12-38-02 를 되돌릴 것. - """ - import json - from pathlib import Path - - from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION - from B09_Estimation.B09_Estimation_Guards import SURCHARGE_INCLUDED_ITEMS - - assert "FP-12-38-02" not in SURCHARGE_INCLUDED_ITEMS - words = ("유로폼", "패널") - assert all(DESTINATION[n] != "material" for n in DESTINATION if any(w in n for w in words)) - root = Path(__file__).resolve().parents[2] / "resources" / "data_structure_unit" - for path in root.glob("*.json"): - text = path.read_text(encoding="utf-8") - - def walk(node): - if isinstance(node, dict): - name = str(node.get("name") or "") - if any(w in name for w in words): - assert node.get("destination") != "material", (path.name, node) - for value in node.values(): - walk(value) - elif isinstance(node, list): - for value in node: - walk(value) - - walk(json.loads(text)) diff --git a/resources/tester/test_b09_execution.py b/resources/tester/test_b09_execution.py deleted file mode 100644 index 8f6ae88cc..000000000 --- a/resources/tester/test_b09_execution.py +++ /dev/null @@ -1,168 +0,0 @@ -"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정). - -⚠ 값이 맞는지는 못 잰다 — 실행예산 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것: - ① 시간당 실행단가 = 1단위당 사용료 ÷ 시간 → 설계 성분 비율 → 성분마다 절사 → 차액 보정 - ② 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김 - ③ 중기(X)를 갈아 끼운 **복사본**으로 일위대가 다시 조립 · 설계 단가표·입력 줄 불변 - ④ 실행수량 · 조립값과 다른 줄은 설계 단가 + 까닭 · 묶음 줄 합 · 틀린 칸 거름 -""" - -from __future__ import annotations - -import copy -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Execution import ( # noqa: E402 - clean_settings, - execution_bill, - execution_hourly, -) -from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 - Money3, - PriceBook, - PriceDetail, - PriceKind, - PriceTitle, -) -from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402 - -DESIGN_X = Money3(material=Decimal(2000), labor=Decimal(3000), expense=Decimal(1500)) - - -def _parts(money: Money3) -> tuple[str, str, str]: - return (str(money.material), str(money.labor), str(money.expense)) - - -def test_시간당_실행단가를_설계_성분_비율로_가르고_차액을_고른_비목에() -> None: - # 80,000원/일 ÷ 8시간 = 10,000 · 비율 2000:3000:1500 → 3076.9 / 4615.3 / 2307.6 → 절사 합 9,998 - money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "labor") - assert _parts(money) == ("3076", "4617", "2307") and diff == 2 and "노무비" in note - money, _, _ = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(100), "expense") - assert _parts(money) == ("3000", "4600", "2400") # 100원 미만 절사 · 차액 100 → 경비 - - -def test_기본보정은_뜻_미확인이라_차액을_남긴다() -> None: - money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "basic") - assert money.total == 9998 and diff == 2 and "기본보정" in note - - -def _build() -> UnitPriceBuild: - """중기 X-G(연료 2 · 운전원 1 · 취득가 0.0003) · B-T(X-G 0.5 + 노임 1) · B-N(연료만).""" - book = PriceBook() - for code, kind, price in ( - ("M-F", PriceKind.MATERIAL, "1000"), - ("L-O", PriceKind.LABOR, "3000"), - ("L-B", PriceKind.LABOR, "2000"), - ("S-G", PriceKind.MACHINE_BASE, "5000000"), - ): - slots = [None] * 6 - slots[5] = Decimal(price) - book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots)) - book.add_title(PriceTitle(code="X-G", kind=PriceKind.MACHINE_HOURLY, name="굴삭기")) - book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="흙깎기")) - book.add_title(PriceTitle(code="B-N", kind=PriceKind.UNIT_PRICE, name="자재만")) - for parent, ref, qty in ( - ("X-G", "M-F", "2"), - ("X-G", "L-O", "1"), - ("X-G", "S-G", "0.0003"), - ("B-T", "X-G", "0.5"), - ("B-T", "L-B", "1"), - ("B-N", "M-F", "1"), - ): - book.add_detail(PriceDetail(parent, ref, Decimal(qty))) - return UnitPriceBuild(book=book) - - -def _line(item_no: str, code: str, unit: tuple[str, str, str], qty: str = "3") -> dict: - return { - "item_no": item_no, - "is_group": False, - "in_bill": True, - "quantity": qty, - "price_code": code, - "unit_material_krw": unit[0], - "unit_labor_krw": unit[1], - "unit_expense_krw": unit[2], - } - - -ROWS = [ - {"item_no": "2", "is_group": True, "in_bill": True}, - _line("2.1", "B-T", ("1000", "3500", "750")), # 설계 = 조립값 - _line("2.2", "B-N", ("1000", "0", "0")), # 중기 없음 - _line("2.3", "B-T", ("1200", "3500", "750")), # 수동 단가 — 조립값과 다름 -] -SETTINGS = { - "machines": { - "X-G": { - "unit_price_krw": "80000", - "hours_per_unit": "8", - "cut_unit_krw": "1", - "correction": "labor", - } - }, - "quantities": {"2.2": "5"}, -} - - -def test_중기를_갈아_끼운_복사본으로_다시_조립하고_설계는_그대로() -> None: - build = _build() - before = (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles)) - rows_before = copy.deepcopy(ROWS) - result = execution_bill(ROWS, clean_settings(SETTINGS)[0], build=build) - assert (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles)) == before - assert ROWS == rows_before - rows = {row["item_no"]: row for row in result["rows"]} - # X-G 실행 3076/4617/2307 × 0.5 = 1538 / 2308.5 / 1153.5 + 노임 2000 → 원 미만 절사 - assert ( - rows["2.1"]["execution_unit_material_krw"], - rows["2.1"]["execution_unit_labor_krw"], - rows["2.1"]["execution_unit_expense_krw"], - ) == ("1538", "4308", "1153") - assert "X-G" in rows["2.1"]["execution_note"] - machine = result["machines"][0] - assert machine["code"] == "X-G" and machine["execution"]["total_krw"] == "10000" - assert machine["design"]["total_krw"] == "6500" - - -def test_실행수량과_중기_없는_줄() -> None: - rows = {r["item_no"]: r for r in execution_bill(ROWS, SETTINGS, build=_build())["rows"]} - assert ( - rows["2.2"]["execution_quantity"] == "5" and rows["2.2"]["execution_amount_krw"] == "5000" - ) - assert rows["2.2"]["execution_note"] == "" and rows["2.1"]["execution_quantity"] == "3" - - -def test_조립값과_다른_줄은_설계_단가와_까닭() -> None: - row = execution_bill(ROWS, SETTINGS, build=_build())["rows"][3] - assert row["execution_unit_material_krw"] == "1200" and "못 얹음" in row["execution_note"] - - -def test_설정이_없으면_설계와_같고_묶음_줄_합() -> None: - result = execution_bill(ROWS, {}, build=_build()) - assert result["totals"]["design"] == result["totals"]["execution"] - group = result["rows"][0] - children = [r for r in result["rows"] if not r.get("is_group")] - assert Decimal(group["execution_amount_krw"]) == sum( - Decimal(r["execution_amount_krw"]) for r in children - ) - - -def test_틀린_칸은_거른다() -> None: - cleaned, errors = clean_settings( - { - "machines": { - "X-A": {"unit_price_krw": ""}, - "X-B": {"unit_price_krw": "100", "hours_per_unit": "0"}, - "X-C": {"unit_price_krw": "100", "hours_per_unit": "8", "cut_unit_krw": "7"}, - }, - "quantities": {"1.1": "-1", "1.2": ""}, - } - ) - assert len(errors) == 2 and list(cleaned["machines"]) == ["X-C"] - assert cleaned["machines"]["X-C"]["cut_unit_krw"] == "1" and cleaned["quantities"] == {} diff --git a/resources/tester/test_b09_explosives.py b/resources/tester/test_b09_explosives.py deleted file mode 100644 index abcd3d004..000000000 --- a/resources/tester/test_b09_explosives.py +++ /dev/null @@ -1,28 +0,0 @@ -"""발파 화약류(9-5-1) — 2026-09-14 브레인 300: 규격 미정은 설계자 선택 + 사유 · 값은 수동 단가.""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - - -def test_단가가_없으면_규격_미정_사유와_자재_단가_칸() -> None: - build = cached_build() - labels = build.unattached.get("FP-09-05-01") or [] - assert any("뇌관 — 규격 미정" in label for label in labels), labels - assert not any(label.replace(" ", "") == "뇌관" for label in labels), labels - assert "FP-09-05-01" in build.material_uses.get("AR-M-0965627d", []) - - -def test_설계자_단가가_들면_표_수량으로_붙음() -> None: - build = cached_build(material_prices=(("AR-M-0965627d", "500", "견적"),)) - rows = {r["ref_code"]: float(r["quantity"]) for r in detail_of(build, "B-FP-09-05-01")["rows"]} - assert rows.get("AR-M-0965627d") == 1.0, rows - labels = build.unattached.get("FP-09-05-01") or [] - assert not any("뇌관" in label for label in labels), labels - - -def test_착암기는_압축기_부수물이라_손료_고시_없음_사유() -> None: - """건설품셈 8-3-6 (5205) [주]① 「부수물은 별도 계상」 — 압축기 손료에 든다고 안 정함.""" - labels = cached_build().unattached.get("FP-09-05-01") or [] - note = next((label for label in labels if label.startswith("착암기")), "") - assert "부수물" in note and "고시 없음" in note, labels diff --git a/resources/tester/test_b09_expression.py b/resources/tester/test_b09_expression.py deleted file mode 100644 index d24e632b8..000000000 --- a/resources/tester/test_b09_expression.py +++ /dev/null @@ -1,35 +0,0 @@ -"""B09 Q 식 편집이 쓰는 식 — **명세 13장 식 언어**(B08 구조물도 풀이기 한 벌)로 풀고 소수 2자리 사사오입. - -⚠ 2026-09-14 브레인 판정 — 식 풀이를 B09 에 두 벌 짜지 않음. 종전 `B09_Estimation_Expression`(파이썬 셈)은 걷어냄. -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_Edits import evaluate_q - - -def test_실무_Q_식을_명세_13장_언어로_풀고_2자리로_확정한다() -> None: - got = evaluate_q( - { - # 18번 §2.2 — f 는 0.85 로 먼저 확정해 넣음 → 15.708 → 15.71 - "a": "3600*0.2*0.7*0.85*0.55/15", - # 울진 대흥 제15호표 되메우기 Q 68.04 - "b": "3600*0.7*0.9*(1/1.25)*0.75/20", - # 콘크리트믹서 Q = 60 / t × q × E = 5.40 - "c": "60/4*0.45*0.8", - "d": "MAX(12.345, SQRT(100))", - } - ) - assert got == { - "a": Decimal("15.71"), - "b": Decimal("68.04"), - "c": Decimal("5.4"), - "d": Decimal("12.35"), - } - - -def test_모르는_이름·0_이하는_까닭으로_돌려준다() -> None: - got = evaluate_q({"x": "abc+1", "y": "0*5", "z": "1/0"}) - assert all(isinstance(value, str) and value for value in got.values()), got diff --git a/resources/tester/test_b09_fill_and_slope_price.py b/resources/tester/test_b09_fill_and_slope_price.py deleted file mode 100644 index be65e8a1d..000000000 --- a/resources/tester/test_b09_fill_and_slope_price.py +++ /dev/null @@ -1,97 +0,0 @@ -"""성토·성토면다짐에 단가가 서는가 (2026-09-09 밤). - -**왜 안 서고 있었나** — 둘 다 원문에는 값이 다 있는데 서로 다른 까닭으로 막혀 있었다. - - 9-16-1 노체포설 계수표(K·f·E·㎝)는 붙었는데 **기종이 [주]에만 있어** 못 골랐음 - → 시공능력 공식이 안 돌아 단가가 통째로 안 섬 - 9-17-1 비탈면 다짐 **표가 한 장도 없음** — 값이 절 제목에 있음(「A = 77.7 ㎡/시간」) - → 표만 보는 조립이 그 공종을 아예 안 봄 - -⚠ 그동안 인계에는 물량이 그대로 실려 **성토 13,150.2㎥ · 성토면다짐 12,350.3㎡ 가 금액 없이** -내역에 서 있었다. 그래서 아래 셋을 못으로 박는다. - - ① 두 공종에 단가가 선다 · ② 단위가 원문과 같다(㎥·㎡) · ③ 근거가 줄에 남는다 - ④ 부착 콤팩터 손료가 함께 붙는다 — 이름이 빈 카탈로그 줄이라 놓치던 자리 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_FactorChoices import MACHINE_CHOICES # noqa: E402 -from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( # noqa: E402 - SOURCE_TEXT_CAPACITY, - direct_capacity_rows, -) -from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402 - cached_build, - load_work_item_master, -) - - -def _build(): - return cached_build() - - -def test_노체포설에_단가가_선다() -> None: - build = _build() - title = build.book.titles.get("B-FP-09-16-01") - assert title is not None, "노체포설 일위대가가 안 섰습니다" - assert title.unit == "㎥" - assert build.book.resolve("B-FP-09-16-01").total > 0 - - -def test_노체포설_기종은_주에서_왔음을_적는다() -> None: - """⚠ 기본값을 어디서 가져왔는지 안 적으면 임의 값과 구별이 안 된다.""" - entry = MACHINE_CHOICES["FP-09-16-01"] - assert entry["default_code"] == "0201-0070" # 무한궤도 굴착기 0.7㎥ - assert entry["source"] == "note" - assert any("9-16-1" in line for line in entry["basis"]) - - -def test_비탈면다짐에_단가가_선다() -> None: - build = _build() - title = build.book.titles.get("B-FP-09-17-01") - assert title is not None, "비탈면 다짐 일위대가가 안 섰습니다" - assert title.unit == "㎡" - assert build.book.resolve("B-FP-09-17-01").total > 0 - - -def test_비탈면다짐은_원문_작업량을_그대로_쓴다() -> None: - """② 77.7 은 원문 값이다 — 지어낸 수가 아님을 줄에 남긴다.""" - build = _build() - # 층 차례 X → D → B(2026-09-13) — 작업량(Q) 줄은 D(단가산출)에, B 는 D 를 1 로 부름. - assert [(r.ref_code, r.quantity) for r in build.book.details["B-FP-09-17-01"]] == [ - ("D-FP-09-17-01", Decimal(1)) - ] - rows = build.book.details["D-FP-09-17-01"] - assert rows, "상세 줄이 없습니다" - for row in rows: - # 시간 = 1 ÷ 77.7 ㎡/시간 - assert row.quantity == Decimal(1) / Decimal("77.7") - assert "9-17-1" in row.note and "77.7" in row.note - - -def test_부착_콤팩터_손료가_함께_붙는다() -> None: - """④ 카탈로그에 **이름이 빈 줄**(0240-0007)이라 이름으로만 가리면 놓친다.""" - build = _build() - refs = {row.ref_code for row in build.book.details["D-FP-09-17-01"]} # 층 차례 — D 에 삶 - assert "X-0240-0007" in refs, refs - # 본체 굴착기는 조합 사용 층(잡재료 16%)으로 바뀌어야 한다 — 품셈 제8장 [주]⑤. - assert any(ref.startswith("X-0201-0070#") for ref in refs), refs - assert "0240-0007" not in build.incomplete_machines - - -def test_제목에만_있는_작업량은_표에서_온_것과_안_겹친다() -> None: - """⚠ 같은 기종이 표에도 있으면 두 번 세면 안 된다.""" - master = load_work_item_master() - node = next(w for w in master["work_items"] if w["work_item_code"] == "FP-09-17-01") - rows = direct_capacity_rows(node) - codes = [row["machine_code"] for row in rows] - assert len(codes) == len(set(codes)) - assert set(codes) == {entry["machine_code"] for entry in SOURCE_TEXT_CAPACITY["FP-09-17-01"]} diff --git a/resources/tester/test_b09_flexible_vibrator_tables.py b/resources/tester/test_b09_flexible_vibrator_tables.py deleted file mode 100644 index fd35e018a..000000000 --- a/resources/tester/test_b09_flexible_vibrator_tables.py +++ /dev/null @@ -1,83 +0,0 @@ -"""봉상후렉시블 셋 + 12-34-1 — 2026-09-14 브레인(672 다음). - -같은 「봉상후렉시블(45mm) Q=5.4㎥/hr」 줄 하나가 12-12 날개벽 · 12-13 면벽 · 12-16 맨홀을 막고 있었음. -세 표는 12-15 집수정과 같은 모양(㎥ 줄 비고 인력 + 다짐기 줄) — 판정표 한 벌에 더하고 엔진식 4611-0350 로 이음. -실무 영월 「콘크리트타설(임도품셈 5-5)」 호표가 같은 모양: 콘크리트공 0.15·보통인부 0.27 인/㎥ + 진동기 1/Q(5.4) -· 진동기 45φ(2.6㎾)엔진식플렉시블형 손료 252천원 × 0.5101 · 노무 0 — 엔진식·계수·조종원 없음이 실무 실증. - -12-34-1 콘크리트 타설(철근 진동기 포함) — 실무 호표 없음 → 브레인 판정 - ① 「콘크리트 진동기(3.5HP) 대 2」 만 4611-0350 × 2대 · 「봉상후렉시블(45mm) 대 2」 는 같은 기계의 봉이라 안 셈 - ② 머리 「(단위: 개소당)」 ↔ 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계도 Q ㎥/hr ⇒ ㎥당으로 읽고 사유에 나란히 -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - -VIBRATOR = "X-4611-0350" -PER_Q = Decimal(1) / Decimal("5.4") - - -def _rows(build, code: str) -> dict[str, Decimal]: - return { - r["ref_code"]: Decimal(r["quantity"]) - for r in detail_of(build, code)["rows"] - if r.get("ref_code") - } - - -def test_날개벽_면벽은_콘크리트_갈래가_진동기와_함께_섬() -> None: - build = cached_build() - for code in ("B-FP-12-12#콘크리트", "B-FP-12-13#콘크리트"): - rows = _rows(build, code) - assert rows[VIBRATOR] == PER_Q and rows["1013"] == Decimal("0.15"), (code, rows) - assert rows["1002"] == Decimal("0.27"), (code, rows) - assert build.book.titles[code].unit == "㎥" - assert ( - "FP-12-12" not in build.basis_missing - ) # 비고가 인/㎥ — 「개소당」 머리가 없어도 밑수는 ㎥ - - -def test_맨홀은_비고가_끝_칸이_아니어도_구체_버림_갈래() -> None: - build = cached_build() - body = _rows(build, "B-FP-12-16#구체콘크리트") - assert ( - body[VIBRATOR] == PER_Q - and body["1013"] == Decimal("0.17") - and body["1002"] == Decimal("0.29") - ) - lean = _rows(build, "B-FP-12-16#버림콘크리트") - assert lean == {"1013": Decimal("0.15"), "1002": Decimal("0.27")}, lean - for code in ("FP-12-12", "FP-12-13", "FP-12-16"): - assert "봉상후렉시블" not in " ".join(build.unattached.get(code, [])), code - - -def test_12_34_1_진동기_두_대만_세고_봉은_두_번_안_셈() -> None: - build = cached_build() - rows = _rows(build, "B-FP-12-34-01") - assert rows[VIBRATOR] == 2 * PER_Q, rows - assert rows["1013"] == Decimal("0.17") and rows["1002"] == Decimal("0.29"), rows - from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note - - assert "봉상후렉시블" in known_gap_note("FP-12-34-01") and "두 번 안 셈" in known_gap_note( - "FP-12-34-01" - ) - refs = [r.get("ref_code") for r in detail_of(build, "B-FP-12-34-01")["rows"]] - assert refs.count(VIBRATOR) == 1, refs # 봉 줄이 기계로 또 안 섬(한 줄 · 2대 몫) - - -def test_12_34_1_은_개소당_머리를_세제곱미터당으로_읽은_까닭을_보임() -> None: - from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note - - build = cached_build() - assert build.book.titles["B-FP-12-34-01"].unit == "㎥" - note = known_gap_note("FP-12-34-01") - assert "개소당" in note and "㎥당" in note and "12-16" in note, note - - -def test_표의_나머지_줄은_조용히_안_사라지고_사유에_원문_그대로() -> None: - """손 사유로 달던 줄 목록은 판정표 자동 목록(`_unread_rows`)으로 옮김 — `test_b09_judged_unread_rows`.""" - left = " ".join(cached_build().unattached.get("FP-12-16", [])) - assert "원형거푸집" in left and "설치비" in left, left diff --git a/resources/tester/test_b09_form_basis_visible.py b/resources/tester/test_b09_form_basis_visible.py deleted file mode 100644 index e03e8887f..000000000 --- a/resources/tester/test_b09_form_basis_visible.py +++ /dev/null @@ -1,33 +0,0 @@ -"""10-A ⑭ 표 형태 66표 까닭을 화면에 — 2026-09-14 사용자 확정 「SW 규칙 · 화면에 드러낼 것」. - -사람이 가른 표 형태(`_Forms.FORM_JUDGMENTS`)는 원문 근거가 없는 우리 규칙 — -마스터엔 있으나 화면에 한 번도 안 닿았음. 모양은 서브 10-A 채움과 같게 **칸 밑 회색 한 줄**: -일위대가 상세(`detail_of` → 화면 hint) · 일위대가가 없는 공종은 내역 「일위대가 없음」 비고. -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_KnownGaps import form_judgment_note -from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - - -def test_판정표가_딸린_일위대가_상세에_까닭_한_줄() -> None: - build = cached_build() - code = next(c for c in build.book.titles if c.startswith("B-FP-13-04-04")) - note = detail_of(build, code)["form_basis_note"] - assert "F0412" in note and "참조표" in note and "뒷길이 표준" in note, note - assert "SW 규칙" in note - - -def test_판정표_없는_공종은_빈_칸() -> None: - assert form_judgment_note("FP-09-03-02") == "" - - -def test_일위대가_없는_공종은_내역_비고에_까닭() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - item = {"work_item_code": "FP-12-38-01", "name": "사용횟수", "spec": "", "unit": "회", - "quantity": "1", "in_bill": True} # fmt: skip - rows = build_bill({"work_items": [item], "materials": []}).rows - line = next(r for r in rows if r.code == "FP-12-38-01") - assert "F0392" in line.note and "계수표" in line.note, line.note diff --git a/resources/tester/test_b09_formwork_use_count.py b/resources/tester/test_b09_formwork_use_count.py deleted file mode 100644 index 10ce1af62..000000000 --- a/resources/tester/test_b09_formwork_use_count.py +++ /dev/null @@ -1,55 +0,0 @@ -"""합판거푸집 12-4 사용횟수별 비율 — 2026-09-14 브레인 ㉮ 승인. - -원문 L6187~6211: 기준수량(1회사용) × 「사용횟수별 기준수량 비율(%)」 — 재료별 -100·57.0·46.1·40.1·37.1·34.7 · 노무비 100·60.0·47.1·40.0·34.2·32.0 (1~6회). -종전엔 비율 셈이 없어 풀면 1회 값으로 비싸게 서므로 일부러 막아 뒀음(BLOCKED_BY_DESIGN). -⇒ 사람 판정표로 1~6회 갈래를 세우고 재료 줄엔 재료 비율 · 인력 줄엔 노무비 비율. -「사용고재 평가기준 23%」 는 원문이 셈을 안 줘([주]① 2회 이상 비율에 기포함) 값으로 안 씀. -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - - -def _labor(code: str) -> dict[str, float]: - rows = detail_of(cached_build(), code)["rows"] - return {r["ref_code"]: float(r["quantity"]) for r in rows if r.get("kind") == "labor"} - - -def test_사용횟수_여섯_갈래가_서고_인력은_노무비_비율() -> None: - titles = cached_build().book.titles - variants = sorted(c for c in titles if c.startswith("B-FP-12-04#")) - assert variants == [f"B-FP-12-04#{n}회" for n in range(1, 7)], variants - assert _labor("B-FP-12-04#1회") == {"1007": 0.22, "1002": 0.12} - assert _labor("B-FP-12-04#4회") == {"1007": 0.088, "1002": 0.048} # 40.0 % - assert _labor("B-FP-12-04#6회") == {"1007": 0.0704, "1002": 0.0384} # 32.0 % - - -def test_재료는_재료_비율로_읽고_못_붙은_재료는_사유() -> None: - from B09_Estimation.B09_Estimation_ResourceAxis import ( - build_resource_axis, - load_combined_catalog, - load_work_item_master, - ) - - axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) - left = {u.cell for u in axis.unmatched if u.work_item_code == "FP-12-04"} - # 합판·못 은 카탈로그 없음 · 각재·철선·박리제는 규격 미정 — 값을 짓지 않고 못 붙은 줄로 - assert {"합판", "못", "각재", "철선", "박리제"} <= {c.replace(" ", "") for c in left}, left - build = cached_build() - assert "FP-12-04" not in build.component_gaps and "FP-12-04" not in build.partial_ratio - note = known_gap_note("FP-12-04") - assert "사용고재" in note and "23" in note, note - - -def test_거푸집_갈래가_내역에서_금액이_섬() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - item = {"work_item_code": "FP-12-04", "name": "합판거푸집", "spec": "", "unit": "㎡", - "quantity": "10", "in_bill": True, "variant_axis": "use_count", - "variant_value": "4회"} # fmt: skip - line = next(r for r in build_bill({"work_items": [item], "materials": []}).rows - if r.code == "FP-12-04") # fmt: skip - assert line.price_code == "B-FP-12-04#4회" and line.amount_krw, line.note diff --git a/resources/tester/test_b09_fuel_region.py b/resources/tester/test_b09_fuel_region.py deleted file mode 100644 index 619d921ee..000000000 --- a/resources/tester/test_b09_fuel_region.py +++ /dev/null @@ -1,86 +0,0 @@ -"""유가 지역 단가 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」 (2026-09-09). - -칸은 전부터 있었는데 **값이 없어 잠겨 있던** 자리다. 오피넷 시도별 판을 받아 물렸다. - -⚠ 겨누는 것 다섯 - ① 안 고르면 **전국평균 그대로** — 현장 소재지를 임의로 찍지 않음 - ② 고르면 **그 시도 값**으로 서고, 기계 시간당 사용료가 함께 움직임 - ③ 판에 없는 지역은 **조용히 전국평균으로 눕지 않고** 그 사실을 신원에 적음 - ④ 코드 `00`(전국)은 지역 선택지에서 뺌 — 두 판이 같은 이름으로 서면 못 가림 - ⑤ 「지역 공시가」를 고를 수 있는지는 **코드가 아니라 판이 정함** -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Lists_Sources import fuel_scopes # noqa: E402 -from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402 - load_fuel_price, - load_regional_fuel_table, -) -from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402 -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 - - -def test_안_고르면_전국평균() -> None: - price, meta = load_fuel_price() - assert meta["scope"] == "national_average" - assert meta["region"] == "" and meta["region_name"] == "" - assert price > 0 - - -def test_고르면_그_시도_값으로_선다() -> None: - table, _ = load_regional_fuel_table() - code = next(iter(sorted(table))) - price, meta = load_fuel_price(region=code) - assert price == table[code]["value"] - assert meta["scope"] == "regional" and meta["region_name"] == table[code]["name"] - - -def test_판에_없는_지역은_사실을_적는다() -> None: - """⚠ 조용히 전국평균으로 누우면 사용자는 지역값인 줄 안다.""" - national, _ = load_fuel_price() - price, meta = load_fuel_price(region="ZZ") - assert price == national - assert "판에 없어" in meta["region_missing"] - - -def test_전국_줄은_지역_선택지에서_뺀다() -> None: - table, _ = load_regional_fuel_table() - assert "00" not in table - assert len(table) >= 10 - - -def test_고를_수_있는지는_판이_정한다() -> None: - scopes = {s["key"]: s for s in fuel_scopes()} - assert scopes["national_average"]["available"] is True - # 판이 들어와 있으므로 지역도 열려 있어야 한다. - assert scopes["regional"]["available"] is bool(load_regional_fuel_table()[0]) - - -def test_기계_시간당_사용료가_지역을_따라_움직인다() -> None: - """② 값이 실제로 갈리는지 — 안 갈리면 칸만 있고 뜻이 없다.""" - table, _ = load_regional_fuel_table() - # 전국평균과 값이 다른 시도를 골라 견준다. - national, _ = load_fuel_price() - code = next(c for c, v in sorted(table.items()) if v["value"] != national) - - base = cached_build() - picked = cached_build((), (), "", code) - machine = next( - c for c, t in sorted(base.book.titles.items()) if t.kind is PriceKind.MACHINE_HOURLY - ) - diff = picked.book.resolve(machine).total - base.book.resolve(machine).total - assert diff != Decimal(0) - - fuel_title = next( - t for t in picked.book.titles.values() if t.kind is PriceKind.MATERIAL and t.name == "경유" - ) - # 어느 판으로 섰는지 줄에 남아야 한다. - assert table[code]["name"] in fuel_title.spec diff --git a/resources/tester/test_b09_golden_cost_sheet.py b/resources/tester/test_b09_golden_cost_sheet.py deleted file mode 100644 index 95150aceb..000000000 --- a/resources/tester/test_b09_golden_cost_sheet.py +++ /dev/null @@ -1,396 +0,0 @@ -"""STmate 골든셋 ⑤ — 공사원가계산서를 **우리 엔진**으로 되풀어 대조(PLAN 6장·12장 · 명세 8장). - -짝 파일 `test_b09_golden_stmate.py` 의 다섯째 벌임 — 그 파일이 700줄에 닿아 갈라 둠. -실무 원본 읽기는 그 파일의 `_workbooks` 를 그대로 씀(한 번만 읽어 나눠 쓰는 자리). - -재는 것 — 제비율(법정경비) · 간접노무비 · 일반관리비 · 이윤 · 부가세 · 도급공사비 · 총공사비. - 요율은 **원본 비고에서 뽑아** 그 원본 전용 요율 판을 지어 넣음 — - 「요율이 달라서 틀림」이 안 생김. - 남는 차이는 **밑수 고르기 · 원 단위 버림 · 사슬**, 곧 우리 엔진 몫임. - -⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). -⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 입력**으로만 씀. -⚠ 이 벌은 **재기만 함** — 값을 맞추려고 엔진을 고치지 않음(브레인 지시 2026-09-14). -""" - -from __future__ import annotations - -import json -import pathlib -import re -from dataclasses import replace -from decimal import Decimal - -import pytest -from test_b09_golden_stmate import _num, _workbooks - -from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost - -#: 원본 줄 이름(공백 지운 것) → 엔진 줄 키. 「소계」 셋은 나온 차례로 재료비·노무비·경비. -_COST_KEYS: dict[str, str] = { - "직접재료비": "direct_material", - "간접재료비": "indirect_material", - "작업설,부산물등(△)": "byproduct", - "작업설.부산물등(△)": "byproduct", - "직접노무비": "direct_labor", - "간접노무비": "indirect_labor_cost", - "산출경비": "direct_expense", - "산재보험료": "industrial_accident_insurance", - "고용보험료": "employment_insurance", - "건강보험료": "health_insurance", - "노인장기요양보험료": "long_term_care_insurance", - "연금보험료": "national_pension", - "산업안전보건관리비": "safety_management_cost", - "기타경비": "other_expense", - "환경보전비": "environment_preservation", - "건설기계대여금지급보증금액": "equipment_payment_guarantee", - "순공사원가": "net_construction_cost", - "일반관리비": "general_overhead", - "이윤": "profit", - "페기물처리": "waste_disposal", # 원본 표기(소광) 그대로 - "폐기물처리비": "waste_disposal", - "총원가": "total_cost", - "부가가치세": "vat", - "도급공사비": "contract_amount", - "관급자재대": "owner_supplied_material_total", - "관급자재대(도급자설치)": "owner_supplied_material_total", - "총공사비": "grand_total", -} -#: 원본 비고에 요율이 적히는 법정경비 줄 — 이 줄만 엔진과 견줌. -_STATUTORY_KEYS = ( - "industrial_accident_insurance", - "employment_insurance", - "health_insurance", - "long_term_care_insurance", - "national_pension", - "safety_management_cost", - "other_expense", - "environment_preservation", - "equipment_payment_guarantee", -) -_RE_PERCENT = re.compile(r"[x×]\s*([\d.]+)\s*%") -_RE_FLAT_ADD = re.compile(r"%\s*\+\s*([\d,]+)") -_RE_OWNER_NOTE = re.compile(r"원자재대[::]\s*([\d,]+).*?조달수수료[::]\s*([\d,]+)", re.S) -_RE_EXEMPT = re.compile(r"면세품목[::]\D*([\d,]+)") -_RE_SAFETY_AB = re.compile(r"A[))][^=]*=\s*([\d,]+).*?B[))][^=]*=\s*([\d,]+)", re.S) -#: 합성 요율 판의 「어디에나 걸리는」 구간·기간 라벨 — 구간 갈래는 이 벌이 재는 것이 아님. -_WIDE_BRACKET = "lt_1000_billion" -_WIDE_DURATION = "lte_100000_days" -#: 연금 요율을 원본이 보인 값 그대로 쓰려고 두는 표식 해(연도 갈래는 이 벌 밖). -_SHEET_YEAR = 2000 - - -def _won(text: object) -> Decimal: - return Decimal(str(text).replace(",", "")) - - -def _cost_rows(rows: list[tuple]) -> tuple[dict[str, tuple], list[tuple[str, Decimal]]]: - """`공사원가계산서` 시트 → `({키: (금액, 요율, 기초액, 비고)}, 이름 못 고른 줄들)`. - - 금액 칸은 「금 액」 머리로 찾고, 당초/변경/증감으로 갈린 원본(영덕 변경설계)은 **변경** 칸을 씀. - """ - head = next(r for r in rows if any("금" in str(c or "") and "액" in str(c or "") for c in r)) - amount_col = next( - i for i, c in enumerate(head) if "금" in str(c or "") and "액" in str(c or "") - ) - note_col = next( - (i for i, c in enumerate(head) if "비" in str(c or "") and "고" in str(c or "")), None - ) - body = rows[rows.index(head) + 1 :] - for index, cell in enumerate(body[0] if body else ()): - if "변" in str(cell or "") and "경" in str(cell or ""): - amount_col = index - got: dict[str, tuple] = {} - unknown: list[tuple[str, Decimal]] = [] - subtotals = 0 - for row in body: - label = "" - for cell in row[:amount_col]: - if isinstance(cell, str) and cell.strip(): - label = cell.replace(" ", "").replace(" ", "") - amount = _num(row[amount_col]) if amount_col < len(row) else None - if amount is None or not label: - continue - if label == "소계": - subtotals += 1 - key = ("material_cost", "labor_cost", "expense")[subtotals - 1] - else: - key = _COST_KEYS.get(label) - if key is None: - unknown.append((label, amount)) - continue - note = ( - " ".join(str(c) for c in row[note_col:] if isinstance(c, str)) - if note_col is not None - else "" - ) - percent = _RE_PERCENT.search(note) - flat = _RE_FLAT_ADD.search(note) - got[key] = ( - amount, - Decimal(percent.group(1)) if percent else None, - _won(flat.group(1)) if flat else Decimal(0), - note, - ) - return got, unknown - - -def _cost_sheets() -> list[tuple[str, dict[str, tuple], list[tuple[str, Decimal]]]]: - return [ - (name, *_cost_rows(sheets["공사원가계산서"])) - for name, sheets in _workbooks() - if "공사원가계산서" in sheets - ] - - -def _sheet_rate_dataset(got: dict[str, tuple]) -> dict: - """원본 비고에 적힌 요율만으로 **그 원본 전용 요율 판**을 지음. - - 비고가 빈 줄은 판에 안 들어가고, 그 줄은 엔진이 아예 안 세움(`available_items`). - """ - - def percent(key: str) -> Decimal | None: - row = got.get(key) - return row[1] if row else None - - variables: dict[str, object] = {} - if (rate := percent("indirect_labor_cost")) is not None: - variables["rate_indirect_labor"] = { - "brackets": [ - { - "direct_cost_bracket": _WIDE_BRACKET, - "duration_bracket": _WIDE_DURATION, - "work_type": "civil", - "rate_percent": float(rate), - } - ] - } - if (rate := percent("industrial_accident_insurance")) is not None: - variables["rate_sanjae"] = {"rate_percent": float(rate)} - if (rate := percent("employment_insurance")) is not None: - variables["rate_goyong"] = { - "brackets": [ - { - "grade": "below_7", - "estimated_amount_bracket": "below_official_threshold", - "rate_percent": float(rate), - } - ] - } - if (rate := percent("health_insurance")) is not None: - variables["rate_health"] = {"rate_percent": float(rate)} - if (rate := percent("long_term_care_insurance")) is not None: - variables["rate_care"] = {"rate_percent": float(rate)} - if (rate := percent("national_pension")) is not None: - variables["rate_pension"] = { - "annual_rates": [{"year": _SHEET_YEAR, "rate_percent": float(rate)}] - } - if (rate := percent("safety_management_cost")) is not None: - variables["rate_safety_pct"] = { - "brackets": [ - { - "target_amount_bracket": _WIDE_BRACKET, - "work_type": "civil", - "rate_percent": float(rate), - "base_amount_krw": float(got["safety_management_cost"][2]), - } - ] - } - if (rate := percent("other_expense")) is not None: - variables["rate_other_expense"] = { - "brackets": [ - { - "direct_cost_bracket": _WIDE_BRACKET, - "duration_bracket": _WIDE_DURATION, - "work_type": "civil", - "rate_percent": float(rate), - } - ] - } - if (rate := percent("environment_preservation")) is not None: - variables["rate_environment"] = { - "all_work_types": [{"work_type": "civil_road", "rate_percent": float(rate)}] - } - if (rate := percent("equipment_payment_guarantee")) is not None: - variables["rate_equipment_payment_guarantee"] = { - "general_construction": [{"work_type": "civil_general", "rate_percent": float(rate)}], - "specialty_construction": [], - } - variables["rate_overhead"] = { - "civil_landscape_industrial": [ - { - "estimated_price_bracket": _WIDE_BRACKET, - "rate_percent": float(percent("general_overhead")), - } - ] - } - variables["rate_profit"] = { - "brackets": [ - {"estimated_price_bracket": _WIDE_BRACKET, "rate_percent": float(percent("profit"))} - ] - } - variables["rate_vat"] = {"rate_percent": float(percent("vat") or 0)} - return {"dataset_id": "sheet", "effective_date": "sheet", "variables": variables} - - -def _cost_input(got: dict[str, tuple], rate_path: pathlib.Path) -> CostInput: - """원본 칸을 엔진 입력으로 옮김 — 직접비 셋 · 관급 순자재대·수수료 · 부가세 방식 · 폐기물.""" - rate_path.write_text(json.dumps(_sheet_rate_dataset(got)), encoding="utf-8") - owner = _RE_OWNER_NOTE.search(got.get("owner_supplied_material_total", (0, 0, 0, ""))[3]) - gross, fee = (_won(owner.group(1)), _won(owner.group(2))) if owner else (Decimal(0), Decimal(0)) - exempt = _RE_EXEMPT.search(got.get("vat", (0, 0, 0, ""))[3]) - return CostInput( - direct_material_krw=got["direct_material"][0], - direct_labor_krw=got["direct_labor"][0], - direct_expense_krw=got["direct_expense"][0], - indirect_material_krw=got.get("indirect_material", (Decimal(0),))[0], - # ⚠ 관급 입력은 **순자재대** — 원본 비고의 원자재대는 조달수수료를 이미 머금음. - owner_supplied_material_krw=gross - fee, - procurement_fee_krw=fee, - pension_year=_SHEET_YEAR, - vat_mode="forest_coop_exempt" if exempt else "supply", - tax_exempt_material_krw=_won(exempt.group(1)) if exempt else Decimal(0), - waste_disposal_krw=got.get("waste_disposal", (Decimal(0),))[0], - waste_placement="after_profit", # 소광 원본 자리 — 이윤 뒤·총원가 안 - rate_file_path=str(rate_path), - ) - - -def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -> None: - """⑤ 제비율 줄 — 밑수 고르기 + 원 단위 버림이 원본과 **줄마다** 같음(실무 6건). - - 안 맞을 자리를 셋으로 갈라 둠 — - · 우리 엔진이 틀림 → `misses` 에 남아 실패로 드러남 - · 요율 데이터가 다름 → 요율을 원본에서 뽑아 쓰므로 **안 생김** - · 원본이 식을 안 보임 → 비고 빈 줄(건강·노인장기·연금·산업안전). 앞 차수 값을 그대로 - 물려 적은 자리라 되풀 수 없음 — 울진 신설·영덕·소광 각 4줄, 세어만 두고 대조 밖. - · 절사 자리가 다름 → 아래 `rounding` 에 따로 담음. **지금은 비어 있어야 함**. - - ⭐ **안전관리비 B 의 절사 자리 — 2026-09-14 실증본 쪽으로 맞춤**(브레인 배정). - 고침 전 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639 - 고침 뒤 `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 = 원본 - 고시 제4조① 단서 문언도 이쪽임 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임. - 여섯 원본 중 영월에서만 갈렸음(봉화·울진은 두 셈법이 같은 값). - """ - sheets = _cost_sheets() - if not sheets: - pytest.skip("실무 원본 XLSX 가 없음") - checked, blank, misses, rounding = 0, 0, [], [] - for index, (name, got, _unknown) in enumerate(sheets): - blank += sum(1 for k in _STATUTORY_KEYS if k in got and got[k][1] is None) - result = calculate_cost(_cost_input(got, tmp_path / f"rate{index}.json")) - for key in ("indirect_labor_cost", *_STATUTORY_KEYS): - if key not in got or got[key][1] is None: - continue - checked += 1 - have = result.amount(key) if result.has(key) else None - if have != got[key][0]: - misses.append((name, key, got[key][0], have)) - # 안전관리비 A·B 두 값도 원본 비고가 나란히 적음 — 작은 쪽 채택까지 확인. - pair = _RE_SAFETY_AB.search(got.get("safety_management_cost", (0, 0, 0, ""))[3]) - if pair: - checked += 2 - want = (_won(pair.group(1)), _won(pair.group(2))) - have_pair = ( - result.amount("safety_management_cost_a"), - result.amount("safety_management_cost_b"), - ) - if have_pair != want: - # 1원 이내는 절사 자리 차이(B 줄) — 그보다 크면 엔진이 틀린 것. - bucket = ( - rounding if max(abs(h - w) for w, h in zip(want, have_pair)) <= 1 else misses - ) - bucket.append((name, "safety_a_b", want, have_pair)) - assert result.amount("safety_management_cost") == min(have_pair) - assert len(sheets) >= 6, len(sheets) # 실무 6건 - # 54 = 요율이 보이는 줄 48(온전한 셋 ×10 + 빈칸 있는 셋 ×6) + 안전관리비 A·B 짝 6. - assert checked >= 54, checked - assert blank == 12, blank # 원본이 식을 안 보인 줄 — 3건 × 4줄 - assert not misses, misses - # 절사 자리 차 — 실증본 쪽으로 맞춘 뒤로는 **하나도 없어야** 함(영월 B 1원이 마지막이었음). - assert not rounding, rounding - - -def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: - """⑤ 사슬 — 순공사원가·일반관리비·이윤·총원가·부가세·도급공사비·관급자재대·총공사비. - - 비고가 다 보이는 원본 셋(봉화·영월·울진 공통)만 사슬에 듦. 이윤은 두 걸음으로 가름 — - ① **자동절사**(총공사비 1,000원 미만 → 이윤에서 ÷1.1 반올림)로 맞는가 — 봉화 339 · 영월 632 - ② 안 맞으면 그 차는 **설계자 명시 이윤조정**(★법대로 8-10, 프로그램이 역산하지 않음). - 울진 공통 905,980 — 예산 맞춤이라 절사 규칙으로는 안 나옴(자동절사는 525). - 조정액은 설계자 입력 칸이라 원본에서 읽어 넣음 — 이 벌이 재는 것은 **그 뒤 사슬**임. - 도급공사비 차는 **원본에만 있는 줄**로만 설명됨 — 영월 국가지점번호판검증수수료 762,000. - - ⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 그래서 「안 켠 값」을 볼 때는 - `cut_basis="none"` 으로 **손수 꺼서** 봄. 자동절사 쪽은 기본값 그대로 돌림. - """ - sheets = _cost_sheets() - if not sheets: - pytest.skip("실무 원본 XLSX 가 없음") - full = [s for s in sheets if all(s[1][k][1] is not None for k in _STATUTORY_KEYS if k in s[1])] - assert len(full) >= 3, [s[0] for s in full] - chained, automatic, manual, misses = 0, [], [], [] - for index, (name, got, unknown) in enumerate(full): - data = replace(_cost_input(got, tmp_path / f"chain{index}.json"), cut_basis="none") - plain = calculate_cost(data) - gap = plain.amount("profit_before_adjustment") - got["profit"][0] - cut = calculate_cost(replace(data, cut_basis="grand_total", cut_unit_krw=1000)) - (automatic if cut.amount("profit") == got["profit"][0] else manual).append((name, gap)) - result = calculate_cost(replace(data, profit_adjustment_krw=gap)) - extra = sum((amount for _label, amount in unknown), Decimal(0)) - for key in ( - "material_cost", - "labor_cost", - "expense", - "net_construction_cost", - "general_overhead", - "profit", - "total_cost", - "vat", - "contract_amount", - "owner_supplied_material_total", - "grand_total", - ): - if key not in got: - continue - chained += 1 - have = result.totals.get(key) - if have is None: - have = result.amount(key) if result.has(key) else None - # 도급공사비 아래는 원본에만 있는 줄만큼 낮게 나옴 — 그 몫을 덜어 견줌. - want = got[key][0] - ( - extra if key in ("contract_amount", "grand_total") else Decimal(0) - ) - if have != want: - misses.append((name, key, want, have)) - assert chained >= 30, chained - assert not misses, misses - # 자동절사가 이윤을 그대로 맞추는 원본이 둘, 설계자 조정이 필요한 원본이 하나. - assert len(automatic) == 2, automatic - assert [g for _n, g in automatic] == [Decimal(339), Decimal(632)], automatic - assert [g for _n, g in manual] == [Decimal(905_980)], manual - - -def test_총공사비는_천원_미만_버림이_기본(tmp_path) -> None: - """⑤ 총액 절사 — 실무 6건이 **여섯 다** `000` 으로 앉음(2026-09-14 기본으로 켬). - - 근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」. - 끄면(`cut_basis="none"`) 여섯 다 끝자리가 남는 것으로 **절사가 실제로 도는지**를 가름. - - ⚠ 잔차 걸음도 ÷1.1 을 해야 앉음(2026-09-14 고침) — 그대로 빼면 울진 신설이 - `654 → 999 → 900 → 910 → 909 …` 로 진동해 끝자리가 남았음. - """ - sheets = _cost_sheets() - if not sheets: - pytest.skip("실무 원본 XLSX 가 없음") - landed, left_over = [], [] - for index, (name, got, _unknown) in enumerate(sheets): - data = _cost_input(got, tmp_path / f"cut{index}.json") # 기본값 그대로 - result = calculate_cost(data) - landed.append((name, result.totals["grand_total"] % 1000)) - off = calculate_cost(replace(data, cut_basis="none")) - left_over.append((name, off.totals["grand_total"] % 1000)) - assert any("2025-82호" in note for note in result.notes), result.notes - assert not [n for n in result.notes if "안 앉음" in n], result.notes - assert [tail for _n, tail in landed] == [Decimal(0)] * len(sheets), landed - assert all(tail for _n, tail in left_over), left_over diff --git a/resources/tester/test_b09_golden_stmate.py b/resources/tester/test_b09_golden_stmate.py deleted file mode 100644 index e8bb36c5e..000000000 --- a/resources/tester/test_b09_golden_stmate.py +++ /dev/null @@ -1,443 +0,0 @@ -"""STmate 골든셋 재현 — 실무 내역 원본(XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장). - -기준점 — `resources/knowledge/original/실무문서/` 의 실무 6건(STmate 출력 XLSX). -브레인 완료 판정 자리 — 6장 사슬 단계를 하나 세울 때마다 여기 한 벌씩 더하고 **단계마다 돌림**. - - ② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ - ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ - ③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅ - ① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅ - ⑤ 공사원가계산서 제비율·이윤·부가세·총공사비 — 옆 `test_b09_golden_cost_sheet.py` - -⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). -⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀. -""" - -from __future__ import annotations - -import pathlib -import warnings -from decimal import Decimal -from functools import lru_cache - -import pytest - -from B09_Estimation.B09_Estimation_MachineCost import hourly_operator_wage -from B09_Estimation.B09_Estimation_PriceBook import ( - Money3, - PriceBook, - PriceDetail, - PriceKind, - PriceTitle, -) -from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots - -ROOT = pathlib.Path(__file__).resolve().parents[2] -PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" - - -@lru_cache(maxsize=1) -def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: - """실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`.""" - openpyxl = pytest.importorskip("openpyxl") - wanted = ( - "환율및기초자료", - "중기사용료", - "단가산출근거", - "일위대가표", - "설계내역서", - "재료비수량금액집계표", - "노무비수량금액집계표", - "경비수량금액집계표", - "중기시간금액집계표", - "중기목록표", - "공사원가계산서", - ) - found = [] - for path in sorted(PRACTICE.rglob("*.xlsx")): - if path.name.startswith("~$"): - continue - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - try: - book = openpyxl.load_workbook(path, data_only=True, read_only=True) - except Exception: # 깨진 사본 — 기준점이 아님 - continue - sheets = { - name: list(book[name].iter_rows(max_col=13, values_only=True)) - for name in wanted - if name in book.sheetnames - } - book.close() - if sheets: - found.append((str(path.relative_to(PRACTICE)), sheets)) - return tuple(found) - - -def _decimal_places(value: Decimal) -> int: - return max(0, -value.normalize().as_tuple().exponent) - - -def _wage_rows() -> list[tuple[str, str, Decimal, str, Decimal]]: - rows = [] - for name, sheets in _workbooks(): - for row in sheets.get("환율및기초자료", []): - if len(row) < 7 or not isinstance(row[3], (int, float)) or not isinstance(row[4], str): - continue - if row[2] and isinstance(row[6], (int, float)): - formula = row[4].replace("*", "", 1).replace("=", "").strip() - rows.append( - (name, str(row[2]), Decimal(str(row[3])), formula, Decimal(str(row[6]))) - ) - return rows - - -def test_노임_시간당_환산_실무_원본_전수_재현() -> None: - """② 일당 × `1/8*16/12*25/20` 좌→우 순차 + 절사 — 실무 원본의 시간당 칸과 **전부** 같음. - - 절사 자리는 원본이 보인 대로(다섯 건 원 · 2025 울진 소광 0.1원) — 설정값이라 칸에서 읽음. - """ - rows = _wage_rows() - if not rows: - pytest.skip("실무 원본 XLSX 가 없음") - assert len(rows) >= 18 # 6건 × 운전사 3직종 - misses = [ - (name, job, daily, expected, got) - for name, job, daily, formula, expected in rows - if (got := hourly_operator_wage(daily, formula, digits=_decimal_places(expected))) - != expected - ] - assert not misses, misses - - -def _num(value: object) -> Decimal | None: - return Decimal(str(value)) if isinstance(value, (int, float)) else None - - -def _machine_sheets() -> list[tuple[str, str, list[tuple], tuple]]: - """`중기사용료` 시트의 호표 — `(원본, 호표 이름, 구성 줄들, 합계 줄)`.""" - found = [] - for name, sheets in _workbooks(): - block: list[tuple] | None = None - title = "" - for row in sheets.get("중기사용료", []): - head = str(row[0] or "").replace(" ", "") - if head.startswith("제") and head.endswith("호표"): - block, title = [], "" - continue - if block is None: - continue - if head == "합계": - found.append((name, title, block, row)) - block = None - elif row[2] is None and row[3] and not title: - title = f"{row[0]} {row[1] or ''}".strip() - elif _num(row[2]) is not None: - block.append(row) - return found - - -def _assemble(rows: list[tuple]) -> Money3 | None: - """원본 호표 줄을 **우리 단가표 층**(S·L·M·잡품 비율 줄 → X)에 올려 풂. 모르는 줄이면 `None`.""" - book = PriceBook() - book.add_title(PriceTitle("X", PriceKind.MACHINE_HOURLY, "호표")) - for index, row in enumerate(rows): - quantity, price, unit = _num(row[2]), _num(row[4]), str(row[3] or "").strip() - code = f"R{index}" - if unit == "%": - # 이름은 원본마다 다름(잡품·잡재료·잡유·잡재료비) — 「주연료의 %」 가산 행 모양. - book.add_detail(_misc_row("X", quantity)) - continue - kind = {"천원": PriceKind.MACHINE_BASE, "인": PriceKind.LABOR}.get(unit, PriceKind.MATERIAL) - if price is None: - return None - book.add_title(PriceTitle(code, kind, str(row[0]), unit=unit, slots=_slots(price))) - book.add_detail(PriceDetail("X", code, quantity)) - return book.resolve("X") - - -def test_중기_시간당_사용료_실무_원본_호표_전수_재현() -> None: - """④ 손료·운전원·주연료·**잡품(주연료비 × 율 가산 행)** — 줄 0.1원 · 성분 소계 원 미만 절사. - - 회귀 기준 — 봉화 2024 제2호표 굴삭기 0.7㎥: 23,128 + 55,700 + 18,015 = **96,843**(명세 7장). - """ - sheets = _machine_sheets() - if not sheets: - pytest.skip("실무 원본 XLSX 가 없음") - checked = 0 - misses = [] - for name, title, rows, total in sheets: - got = _assemble(rows) - if got is None: - continue - checked += 1 - want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11])) - if (got.total, got.labor, got.material, got.expense) != want: - misses.append((name, title, want, (got.total, got.labor, got.material, got.expense))) - assert checked >= 50, checked - assert not misses, misses[:5] - bonghwa = next(t for t in sheets if "현동" in t[0] and "0.7" in t[1]) - assert _assemble(bonghwa[2]).total == Decimal(96843) - - -def _basis_blocks() -> list[tuple[str, str, tuple, list[tuple]]]: - """`단가산출근거` 시트의 호표 — `(원본, 호표, 머리 줄, Q 줄들)`. 머리 = 합계·노무·재료·경비. - - `소계(제외금액)`(틀 `(-=)`) 위 줄은 머리에 안 들어감 — Q 를 끌어내려고 보인 인력 품 따위. - """ - found = [] - for name, sheets in _workbooks(): - head: tuple | None = None - rows: list[tuple] = [] - kept = 0 # 마지막 소계까지 든 줄 수 - for row in [*sheets.get("단가산출근거", []), ("총계", None)]: - label = str(row[1] or "").replace(" ", "") - first = str(row[0] or "").replace(" ", "") - if (label.startswith("제") and label.endswith("호표")) or first.startswith("총"): - if head is not None and rows: - found.append((name, str(head[1]).replace(" ", ""), head, rows)) - head, rows, kept = (row if label.endswith("호표") else None), [], 0 - continue - if label.startswith("소계") or label.startswith("계("): - template = str(row[6]) - if "(-==)" in template: # 계(제외금액) — 그때까지 든 줄 전부 - rows, kept = [], 0 - elif "(-=)" in template: # 소계(제외금액) — 마지막 소계 뒤 줄 - rows = rows[:kept] - else: - kept = len(rows) - continue - if ( - head is not None - and first.startswith("계") # 「계」 · 「계(경비로적용)」 - and _num(row[2]) is not None - and _num(row[7]) is None # 「계약단가」·「계 x 낙찰율」 은 율 칸이 참 - ): - # 머리가 낙찰률을 곱한 계약단가인 원본 — 절사 규칙 대조는 그 앞 「계」 줄로. - head = (head[0], head[1], *row[2:]) - continue - if ( - head is not None - and len(row) > 8 - and _num(row[7]) is not None - and _num(row[8]) is not None - ): - rows.append(row) - return found - - -def _assemble_basis(rows: list[tuple]) -> Money3: - """원본 Q 줄(QTY · 성분 단가 J·K·L)을 **우리 D 층**에 올려 풂 — 줄 0.1원 · 머리 원 미만(엔진 규칙). - - 성분 단가 칸이 둘 이상 찬 줄은 다른 호표 참조(「산근 N호표」) — 성분마다 잎 하나씩. - """ - kinds = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE) - book = PriceBook() - book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출")) - for index, row in enumerate(rows): - quantity, price, shown = _num(row[7]), _num(row[8]), _num(row[2]) or Decimal(0) - if abs(quantity * price / 100 - shown) < abs(quantity * price - shown): - quantity /= 100 # 공구손료 「노무비 × 2 %」 — 칸 QTY 가 백분율 수인 줄 - parts = [(k, _num(p)) for k, p in zip(kinds, (*row[9:12], None, None, None))] - parts = [(k, p) for k, p in parts if p] - if not parts: # 성분 단가 칸이 빈 원본 — 줄 금액이 든 성분 칸으로 가름 - kind = kinds[next((i for i in range(3) if _num(row[3 + i])), 2)] - parts = [(kind, price)] - for kind, part in parts: - code = f"R{index}{kind.name}" - book.add_title(PriceTitle(code, kind, str(row[1]), slots=_slots(part))) - book.add_detail(PriceDetail("D", code, quantity)) - return book.resolve("D") - - -def test_단가산출_Q_식_실무_원본_호표_전수_재현() -> None: - """③ 줄 = 중기 성분 × 1/Q(Q 소수 2자리 확정) → 0.1원 미만 절사 · 머리 성분 = 원 미만 절사. - - 회귀 기준 — 봉화 2024 제2호표 굴삭기 0.2㎥ Q 15.71: 노무 55,700 ÷ Q = **3,545.5** → 머리 3,545(명세 7장). - """ - blocks = _basis_blocks() - if not blocks: - pytest.skip("실무 원본 XLSX 가 없음") - misses = [] - for name, title, head, rows in blocks: - got = _assemble_basis(rows) - want = (_num(head[2]), _num(head[3]), _num(head[4]), _num(head[5])) - if not want[1] and not want[2] and want[0] == want[3]: - # 경비로 넘기는 호표(운반·소운반 따위) — 성분 절사 합을 경비 한 칸에 모음. - got = Money3(expense=got.total) - if (got.total, got.labor, got.material, got.expense) != want: - misses.append((name, title, want, (got.total, got.labor, got.material, got.expense))) - assert len(blocks) >= 200, len(blocks) # 6건 208 호표 - assert not misses, (len(misses), misses[:5]) - second = next(b for b in blocks if "현동" in b[0] and b[1] == "제2호표") - assert Decimal("3545.5") in [_num(r[3]) for r in second[3]] - assert _assemble_basis(second[3]).labor == Decimal(3545) - - -def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None: - """③ 엔진 — 18번 §2.2 예: q 0.2 · f 1/1.175 → 0.85 · K 0.7 · Cm 15 · E 0.55 → Q **15.71**.""" - from B09_Estimation.B09_Estimation_MachineProductivity import ( - CycleFactors, - hourly_output, - machine_hours_per_unit, - ) - - factors = CycleFactors( - "T", "T", "0201-0020", "굴삭기", Decimal("0.2"), Decimal("0.7"), - Decimal(1) / Decimal("1.175"), Decimal("0.55"), Decimal(15), - ) # fmt: skip - assert hourly_output(factors) == Decimal("15.71") # 원값 15.708… - book = PriceBook() - book.add_title(PriceTitle("X", PriceKind.MACHINE_BASE, "중기", slots=_slots(Decimal(55700)))) - book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출")) - book.add_detail(PriceDetail("D", "X", machine_hours_per_unit(factors))) - # 줄 55,700 ÷ 15.71 = 3,545.5(0.1원 절사) → 머리 원 미만 3,545 · 원값 Q 로 나누면 3,545.9 로 갈림. - assert book.resolve("D").expense == Decimal(3545) - - -_COMPONENT_KINDS = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE) - - -def _unit_price_blocks() -> list[tuple[str, str, tuple, list[tuple]]]: - """`일위대가표` 시트의 호표 — `(원본, 호표, 합계 줄, 구성 줄들)`. - - 칸: 명칭 · 규격 · 수량 · 단위 · 합계(단가·금액) · 노무 · 재료 · 경비. 끝 줄은 「합계」·「계」· - 「합계(경비로적용)」 — 뒤따르는 「계약단가」(낙찰률 곱)는 절사 규칙 대조 밖. - """ - found = [] - for name, sheets in _workbooks(): - rows: list[tuple] | None = None - title = "" - for row in sheets.get("일위대가표", []): - head = str(row[0] or "").replace(" ", "") - if head.startswith("제") and head.endswith("호표"): - rows, title = [], head - elif rows is None: - continue - elif head in ("합계", "계") or head.startswith("합계("): - found.append((name, title, row, rows)) - rows = None - elif _num(row[2]) is not None and row[3]: - rows.append(row) - return found - - -def _assemble_unit_price(rows: list[tuple]) -> Money3: - """원본 구성 줄(수량 · 성분 단가)을 **우리 B 층**에 올려 풂 — 줄 0.1원 · 성분 소계 원 미만(엔진).""" - book = PriceBook() - book.add_title(PriceTitle("B", PriceKind.UNIT_PRICE, "일위대가")) - for index, row in enumerate(rows): - quantity = _num(row[2]) - if str(row[3]).strip() == "%": # 「노무비의 3 %」 — 단가 칸이 밑수, 수량 칸이 백분율 수 - quantity /= 100 - for kind, part in zip(_COMPONENT_KINDS, (_num(row[6]), _num(row[8]), _num(row[10]))): - if part: - code = f"R{index}{kind.name}" - book.add_title(PriceTitle(code, kind, str(row[0]), slots=_slots(part))) - book.add_detail(PriceDetail("B", code, quantity)) - return book.resolve("B") if book.details else Money3() - - -def test_일위대가_실무_원본_호표_성분_소계_절사() -> None: - """① 줄 = 수량 × 성분 단가 → 0.1원 미만 절사 · 성분 소계 원 미만 절사(명세 7장). - - ⚠ **100%를 좇지 않음**(브레인 판정) — 17번 원문이 「345건 중 94.2%, 나머지는 호표 머리·부분계 - 서식이 달라 자동 구획이 어긋난 것」이라 적음. 안 맞는 호표는 **서식 탓·반올림 탓**을 갈라 둠. - """ - blocks = _unit_price_blocks() - if not blocks: - pytest.skip("실무 원본 XLSX 가 없음") - misses, components, matched = [], 0, 0 - for name, title, total, rows in blocks: - got = _assemble_unit_price(rows) - want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11])) - if not want[1] and not want[2] and want[0] == want[3]: - got = Money3(expense=got.total) # 합계(경비로적용) - pairs = list(zip(want[1:], (got.labor, got.material, got.expense))) - components += len(pairs) - matched += sum(w == g for w, g in pairs) - if (got.total, got.labor, got.material, got.expense) != want: - misses.append( - (name, title, want, (got.total, got.labor, got.material, got.expense), rows) - ) - assert len(blocks) >= 150, len(blocks) # 6건 170 호표 · 성분 소계 510 - # 성분 소계 477/510 = 93.5%(17번 원문 94.2% 언저리). 안 맞는 19 호표는 둘로 갈림 — - # 반올림 탓 17: 영월 2024 한 건이 일위대가표를 **절사가 아닌 반올림 자리**로 뽑음(같은 원본의 - # 중기사용료·단가산출근거는 절사로 전수 일치 — 그 원본의 단수 설정, 엔진 규칙 아님) - # 서식 탓 2: 영덕 2025 「켜쌓기적용 90 %」 줄이 앞 줄 합을 밑수로 받아 **앞 줄을 대신함** - assert matched >= components * 0.93, (matched, components) - formatted = [m for m in misses if "영월" not in m[0]] - assert len(formatted) <= 2, [m[:4] for m in formatted] - assert all(any(str(r[3]).strip() == "%" for r in m[4]) for m in formatted) - # 봉화 2024 제1호표 깬잡석찰쌓기 — 노무 20,714.8 + 6,621.8 + 16,710 = 44,046 · 합계 58,889. - first = next(b for b in blocks if "현동" in b[0] and b[1] == "제1호표") - assert _assemble_unit_price(first[3]).total == Decimal(58889) - - -def _bill_rows() -> list[tuple[str, tuple]]: - """`설계내역서` 말단 줄 — 칸: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비(단가·금액).""" - return [ - (name, row) - for name, sheets in _workbooks() - for row in sheets.get("설계내역서", []) - if _num(row[3]) is not None and row[4] and any(_num(row[i]) for i in (7, 9, 11)) - ] - - -def test_내역_줄_금액은_성분마다_절사한_합() -> None: - """① 내역 줄 — 성분마다 `절사(수량 × 성분 단가)` · 합계 = 셋의 합(16번 1,515건 · 착공 178건 100%).""" - from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line - - rows = _bill_rows() - if not rows: - pytest.skip("실무 원본 XLSX 가 없음") - misses = [] - for name, row in rows: - unit = Money3(material=_num(row[9]), labor=_num(row[7]), expense=_num(row[11])) - quantity = _num(row[3]) - if str(row[4]).strip() == "%": # 조달수수료 「재료비 × 0.54 %」 — 단가 칸이 밑수 - quantity /= 100 - line = bill_line(unit, quantity) - got = (line.total, line.labor, line.material, line.expense) - want = (_num(row[6]), _num(row[8]), _num(row[10]), _num(row[12])) - if got != want: - misses.append((name, row[1], want, got)) - assert len(rows) >= 300, len(rows) - assert not misses, (len(misses), misses[:5]) - - -def test_집계표_금액은_반올림_중기는_성분마다_반올림한_합() -> None: - """집계표 넷 — 재료·노무·경비는 `반올림(수량 × 단가)`, 중기는 **성분마다 반올림한 뒤 합**(단가 열 없음). - - 실측 — 자원 셋 279/279 · 중기 131/144(합계 한 번에 반올림 107/144). 중기 짝은 목록표 명칭·규격으로 찾음 - (같은 명칭·규격이 갈래로 둘인 기종은 짝이 흔들림 — 규칙 탓 아님). - """ - from B09_Estimation.B09_Estimation_Lists import machine_summary_amounts - from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at - - resource, machine, machine_hits = 0, 0, 0 - for _name, sheets in _workbooks(): - for sheet in ("재료비수량금액집계표", "노무비수량금액집계표", "경비수량금액집계표"): - for row in sheets.get(sheet, []): - quantity, price, amount = _num(row[3]), _num(row[5]), _num(row[6]) - if quantity is None or price is None or amount is None: - continue - resource += 1 - assert round_at(quantity * price, OutputPlace.RESOURCE_SUMMARY) == amount, row - units = { - (row[1], row[2]): Money3(_num(row[6]), _num(row[5]), _num(row[7])) - for row in sheets.get("중기목록표", []) - if _num(row[4]) is not None - } - for row in sheets.get("중기시간금액집계표", []): - quantity = _num(row[3]) - if quantity is None or (row[1], row[2]) not in units: - continue - machine += 1 - parts = machine_summary_amounts(units[(row[1], row[2])], quantity) - got = (sum(parts.values()), parts["labor"], parts["material"], parts["expense"]) - machine_hits += got == tuple(_num(v) or Decimal(0) for v in row[5:9]) - if not resource: - pytest.skip("실무 원본 XLSX 가 없음") - assert resource >= 250 and machine >= 100, (resource, machine) - assert machine_hits >= machine * 0.9, (machine_hits, machine) diff --git a/resources/tester/test_b09_hour_rows.py b/resources/tester/test_b09_hour_rows.py deleted file mode 100644 index d3e34fa80..000000000 --- a/resources/tester/test_b09_hour_rows.py +++ /dev/null @@ -1,60 +0,0 @@ -"""시간 줄 판정표 — 14-1 입목뿌리 밑막이 · 14-2 근주이식(2026-09-15 브레인 ㉡-3 판정 ①②③). - -원문(10본당): 14-2 「0.8㎥ 굴착기 | h | 0.25 · 보통인부 | h | 0.66」 · 14-1 「0.8㎥ 굴착기(우드그랩 부착) | h | 0.17 · 보통인부 | h | 0.5」 -⚠ 인력 단위가 **시간** — 옛 14-2 11,356원은 0.66 을 인으로 셈(8배) + 굴착기 줄이 말없이 빠짐. - 장비 h 그대로 · 인력 h ÷ 8(총칙 「일일 작업시간 8시간」) · ÷ 10본 -① 「0.8㎥ 굴착기」 형식을 원문이 안 적음 — 무한궤도 0201-0080 · 타이어 0211-0080 두 갈래 · 제안 없음 -② 우드그랩 = 부착용 집게 7206-0070(규격 「0.6∼0.8」 이 0.8 을 품음) — 이름이 다름을 사유에 -③ 막힌 셋(12-26 · 10-7-4 · 10-8-3)은 옛 값이 왜 틀렸는지 사유에 -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def _rows(book, title: str) -> dict[str, Decimal]: - return {r.ref_code: r.quantity for r in book.details[title]} - - -def test_근주이식은_두_갈래_장비_시간과_인력_시간_나누기_8() -> None: - build = cached_build() - book = build.book - crawler = _rows(book, "B-FP-14-02#무한궤도") - tire = _rows(book, "B-FP-14-02#타이어") - assert crawler["X-0201-0080"] == Decimal("0.25") / 10, crawler - assert tire["X-0211-0080"] == Decimal("0.25") / 10, tire - assert crawler["1002"] == Decimal("0.66") / 8 / 10, crawler - assert "FP-14-02" not in build.partial_ratio - assert "FP-14-02" not in build.default_variants # 제안 없음(①) - - -def test_입목뿌리_밑막이는_부착용_집게를_같은_시간만큼() -> None: - book = cached_build().book - rows = _rows(book, "B-FP-14-01#무한궤도") - # 부착용 집게와 조합 — 본체는 잡재료 16% 층(`#조합` · 품셈 제8장 [주]⑤) - assert ( - rows["X-0201-0080#조합"] == Decimal("0.17") / 10 - and rows["X-7206-0070"] == Decimal("0.17") / 10 - ) - assert rows["1002"] == Decimal("0.5") / 8 / 10 - note = known_gap_note("FP-14-01") - assert "우드그랩" in note and "부착용 집게" in note and "7206-0070" in note, note - - -def test_사유는_시간_단위와_옛_값의_잘못을_밝히고_무한궤도를_권하지_않음() -> None: - note = known_gap_note("FP-14-02") - assert "시간" in note and "8시간" in note and "8배" in note, note - assert "영월" not in note and "제안" not in note and "10-12" not in note, note - - -def test_막힌_셋은_옛_값이_왜_틀렸는지를_사유에() -> None: - build = cached_build() - assert "보통인부 1인 값뿐" in known_gap_note("FP-12-26") - assert "같은 사람" in known_gap_note("FP-10-07-04") and "두 번" in known_gap_note("FP-10-07-04") - assert "특별인부 1인 값뿐" in known_gap_note("FP-10-08-03") - for code in ("FP-12-26", "FP-10-07-04", "FP-10-08-03"): - assert code in build.partial_ratio, code diff --git a/resources/tester/test_b09_hourly_wage.py b/resources/tester/test_b09_hourly_wage.py deleted file mode 100644 index aa1445d8b..000000000 --- a/resources/tester/test_b09_hourly_wage.py +++ /dev/null @@ -1,101 +0,0 @@ -"""조종원 시간당 노임 — 식 좌→우 순차 + 원 미만 절사 (2026-09-13, PLAN 6장 · 명세 7장). - -기준점 — STmate 분석 18번 §2.1: 6건 × 3직종 18/18 이 좌→우일 때만 일치(계수 선계산 14/18). - 건설기계운전사 267,360 → 55,700 · 화물차운전사 226,709 → 47,231 · - 일반기계운전사 161,142 → 33,571 -""" - -from __future__ import annotations - -from decimal import Decimal -from functools import lru_cache - -import pytest - -from B09_Estimation.B09_Estimation_MachineCost import ( - OPERATOR_ALLOWANCE_FACTOR, - OPERATOR_WAGE_FORMULA, - hourly_operator_wage, -) -from B09_Estimation.B09_Estimation_UnitPrice import HOURLY_WAGE_SUFFIX, build_unit_prices - - -@pytest.mark.parametrize( - ("daily", "hourly"), - [(267360, 55700), (226709, 47231), (161142, 33571)], -) -def test_좌에서_우로_차례로_풀고_원_미만_절사(daily: int, hourly: int) -> None: - assert OPERATOR_WAGE_FORMULA == "1/8*16/12*25/20" - assert hourly_operator_wage(Decimal(daily)) == Decimal(hourly) - - -def test_계수를_미리_접으면_1원_틀린다() -> None: - """회귀 막이 — 접은 계수 곱은 55,699.99… 라 절사하면 55,699(STmate 55,700).""" - folded = (Decimal(267360) / 8 * OPERATOR_ALLOWANCE_FACTOR).to_integral_value( - rounding="ROUND_FLOOR" - ) - assert folded == Decimal(55699) # 종전 코드 — 계수 1.666…6 이 끝자리에서 잘려 55,699.99… - assert hourly_operator_wage(Decimal(267360), digits=None) == Decimal(55700) - - -@lru_cache(maxsize=1) -def _build(): - return build_unit_prices() - - -def test_자르는_자리는_프로젝트_설정이고_기본은_원_미만() -> None: - """실무마다 다름(다섯 건 0 · 2025 울진 소광 1) — 규정이 없어 설정 칸(브레인 판정).""" - from B09_Estimation.B09_Estimation_UnitPrice import parse_operator_wage_digits - - assert [parse_operator_wage_digits(v) for v in ("", None, "0", "1", "2", "3", "x")] == [ - 0, 0, 0, 1, 2, 0, 0, - ] # fmt: skip - assert hourly_operator_wage(Decimal(273971), digits=1) == Decimal("57077.2") - one = build_unit_prices(operator_wage_digits=1) - assert one.operator_wage_digits == 1 - code = next(c for c in one.book.titles if c.endswith(HOURLY_WAGE_SUFFIX)) - daily = _build().book.titles[code].adopted_price() # 기본 벌 — 원 미만 - assert one.book.titles[code].adopted_price() >= daily - assert daily == daily.to_integral_value() - - -def test_중기_호표의_운전원은_시간당_노임_제목을_1시간분_부른다() -> None: - book = _build().book - hourly = [code for code in book.titles if code.endswith(HOURLY_WAGE_SUFFIX)] - assert hourly, "조종원 시간당 제목이 한 벌도 안 섬" - for code in hourly: - daily = ( - book.titles[code.removesuffix(HOURLY_WAGE_SUFFIX)].adopted_price() - if code.removesuffix(HOURLY_WAGE_SUFFIX) in book.titles - else None - ) - price = book.titles[code].adopted_price() - assert price == price.to_integral_value() # 원 미만 절사 - if daily is not None: - assert price == hourly_operator_wage(daily) - # X 층 운전원 줄은 계수 접은 수량이 아니라 시간당 제목 × 인수. - operator_rows = [ - detail - for details in book.details.values() - for detail in details - if detail.ref_code.endswith(HOURLY_WAGE_SUFFIX) - ] - assert operator_rows and all( - d.quantity == d.quantity.to_integral_value() for d in operator_rows - ) - - -def test_중기_호표는_연료를_따로_세우고_잡품은_비율_가산_행이며_소계를_자른다() -> None: - """PLAN 6장 ④ — 연료 수량에 잡품을 접지 않음 · 줄 0.1원 · 성분 소계 원 미만(명세 7장).""" - from B09_Estimation.B09_Estimation_MachineOperating import load_operating_records - - book = _build().book - record = next(r for r in load_operating_records().records if r.machine_code == "0201-0070") - details = book.details["X-0201-0070"] - fuel = next(d for d in details if d.ref_code.startswith("M-FUEL-")) - misc = next(d for d in details if d.percent_of_material is not None) - assert fuel.quantity == record.fuel_liters_per_hour # 종전엔 × (1 + 잡품%) - assert misc.percent_of_material == record.misc_material_percent and misc.note.startswith("잡품") - money = book.resolve("X-0201-0070") - for part in (money.material, money.labor, money.expense): - assert part == part.to_integral_value() # 성분 소계 원 미만 절사 diff --git a/resources/tester/test_b09_judged_unread_rows.py b/resources/tester/test_b09_judged_unread_rows.py deleted file mode 100644 index 6db210aa5..000000000 --- a/resources/tester/test_b09_judged_unread_rows.py +++ /dev/null @@ -1,50 +0,0 @@ -"""판정표가 안 읽은 줄 — 자동으로 「못 붙은 줄」 목록에 (2026-09-14 브레인 · ㉰ 앞 뿌리 고침). - -판정표는 적어 둔 줄만 읽음 → 나머지 줄(거푸집·철근·뚜껑·설치비·비고 …)이 사유 없이 사라지고 있었음 -(12-15 · 12-04 비고 · 12-34-1 레미콘). 표를 넣을 때마다 손으로 사유를 달면 다음 표가 또 샘. -⇒ 판정표 공통 한 곳에서 「읽힌 줄 밖의 줄」 을 올림. - ㉠ 넘치지 않게 — 자원 머리(첫 줄 머리 모양) · 빈 줄 · 다짐기처럼 다른 줄이 쓴 줄은 뺌 - ㉡ 손 사유와 안 겹치게 — 공종 사유 한 줄(`known_gap_note`)이 이미 적은 이름은 안 올림 -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def _left(build, code: str) -> str: - return " / ".join(build.unattached.get(code, [])) - - -def test_집수정_표의_거푸집_철근_뚜껑_설치비가_목록에_뜸() -> None: - left = _left(cached_build(), "FP-12-15") - for name in ("거 푸 집", "철근", "집수정 뚜껑", "설치비"): - assert name in left, (name, left) - assert "봉상후렉시블" not in left, left # 구체 갈래가 쓴 다짐기 줄은 안 올림(㉠) - - -def test_합판거푸집_비고는_글과_함께_뜨고_손_사유에_있는_사용고재는_안_겹침() -> None: - left = _left(cached_build(), "FP-12-04") - assert "비고" in left and "7m" in left, left - assert "사용고재" in known_gap_note("FP-12-04") and "사용고재" not in left, left # ㉡ - assert "횟수별" not in left, left # 머리 줄(㉠) - - -def test_토사면_고르기_자원_머리_줄은_안_뜸() -> None: - left = _left(cached_build(), "FP-09-19-01") - assert "보통인부 (인)" not in left and "공기압축기 (시간)" not in left, left - - -def test_12_34_1_레미콘_별도계상_줄은_이름으로_뜸() -> None: - left = _left(cached_build(), "FP-12-34-01") - assert "콘크리트(레미콘)" in left and "자재" not in left.split(" / "), left - - -def test_날개벽_면벽_맨홀은_자동_목록이_맡고_손_사유는_줄_목록을_안_되풀이() -> None: - build = cached_build() - assert "원형거푸집" in _left(build, "FP-12-16") and "기초잡석" in _left(build, "FP-12-12") - assert "기초잡석" in _left(build, "FP-12-13") - for code in ("FP-12-12", "FP-12-13", "FP-12-16"): - assert "거푸집" not in known_gap_note(code), code - assert "30%" in known_gap_note("FP-12-12") # 줄이 아닌 [주] 는 손 사유로 남음 diff --git a/resources/tester/test_b09_labor_reliability.py b/resources/tester/test_b09_labor_reliability.py deleted file mode 100644 index b68d9999b..000000000 --- a/resources/tester/test_b09_labor_reliability.py +++ /dev/null @@ -1,96 +0,0 @@ -"""표본이 얇은 노임에 신뢰도 표시 (2026-09-09 · PLAN 9-6). - -⚠⚠ **금액을 막지 않는다.** 단가는 그대로 서고 「조사현장이 적다」는 사실만 나른다 — - 지식DB `노임단가_적용 §2-3` 이 「프로그램에서 단가 채택 시 **플래그 유지 필요**」로, - `원가_입력변수_사전` 이 「해당 단가 사용 시 **경고 표시**」로 두었는데 노임 층에 - 그 표시가 없었다. - -⚠ 겨누는 것 다섯 - ① 원문 기호 그대로 — `*`(조사현장 5개 미만) · `**`(미조사). 우리가 낱말을 지어내지 않음 - ② 정상 공표 직종은 **아예 안 담김** (빈 값과 「정상」을 구별할 일이 없게) - ③ 노임 제목에 그 기호가 실림 — 조종원도 노임이라 같이 붙음 - ④ ⚠ **내역에 실제로 실린 것만** 경고 — 카탈로그 40직종을 다 띄우면 화면이 묻힘 - ⑤ 화면 요약에 사람 말로 실려 나감 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402 - LABOR_RELIABILITY_LABEL, - load_labor_reliability, -) -from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402 -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 -from B09_Estimation.B09_Estimation_UnitPrice_View import build_summary # noqa: E402 - - -def test_원문_기호를_그대로_쓴다() -> None: - """⚠ 「주의」·「낮음」 같은 우리 낱말로 바꾸면 원문과 못 맞춘다.""" - flags = set(load_labor_reliability().values()) - assert flags <= {"*", "**"}, flags - assert set(LABOR_RELIABILITY_LABEL) == {"*", "**"} - - -def test_정상_직종은_담기지_않는다() -> None: - """⚠ 빈 값을 담으면 「정상」과 「모름」이 구별되지 않는다 — 있는 것만 담는다.""" - table = load_labor_reliability() - assert all(v.strip() for v in table.values()) - # 지식DB 가 「원문 40직종 해당」이라 적어 둔 자리다. - assert len(table) == 40 - - -def test_노임_제목에_기호가_실린다() -> None: - build = cached_build() - flagged = load_labor_reliability() - checked = 0 - for code, flag in flagged.items(): - title = build.book.titles.get(code) - if title is None: - continue - assert title.kind is PriceKind.LABOR - assert title.reliability == flag, code - checked += 1 - assert checked > 0, "노임 제목이 하나도 안 섰다" - - -def test_정상_직종_제목에는_빈_값이다() -> None: - build = cached_build() - flagged = load_labor_reliability() - normals = [ - title - for code, title in build.book.titles.items() - # 조종원 시간당 제목(`1050#시간`)은 그 직종 기호를 이어받음 — 직종 코드로 가름(2026-09-15 · 콤팩터가 - # 일반기계운전사(1050 *)로 서며 처음 드러남) - if title.kind is PriceKind.LABOR and code.split("#")[0] not in flagged - ] - assert normals, "정상 공표 직종 제목이 하나도 없다" - assert all(title.reliability == "" for title in normals) - - -def test_내역에_실린_것만_경고한다() -> None: - """⚠ 카탈로그 40직종을 다 띄우면 **쓰지도 않는 직종**이 화면을 채운다.""" - build = cached_build() - used = build.labor_reliability - assert 0 < len(used) < len(load_labor_reliability()) - # 실제로 일위대가에 붙은 줄만 들어 있다. - attached = {row.ref_code for rows in build.book.details.values() for row in rows} - assert set(used) <= attached - - -def test_화면_요약에_사람_말로_나간다() -> None: - """⚠ 기호만 보내면 사용자가 `*` 가 무슨 뜻인지 모른다 — 뜻을 함께 보낸다.""" - summary = build_summary(cached_build()) - rows = summary["labor_reliability"] - assert rows, "표본이 얇은 노임이 하나도 안 실렸다" - for row in rows: - assert set(row) == {"code", "name", "flag", "why"} - assert row["flag"] in {"*", "**"} - assert row["why"] == LABOR_RELIABILITY_LABEL[row["flag"]] - # 지식DB 가 예로 든 임도 빈발 직종 — 실제로 우리 내역에 실린다. - assert any(row["code"] == "1050" for row in rows), "일반기계운전사(1050)가 안 잡힌다" diff --git a/resources/tester/test_b09_labor_surcharge.py b/resources/tester/test_b09_labor_surcharge.py deleted file mode 100644 index 633e0ab73..000000000 --- a/resources/tester/test_b09_labor_surcharge.py +++ /dev/null @@ -1,111 +0,0 @@ -"""품의 할인·할증 26계열 (산림품셈 1-4) — 2026-09-09 밤. - -한 계열도 안 붙고 있었다. **붙일 자리를 잘못 고르면 그것이 곧 이중계상**이라 자리부터 가렸다. - - 붙는 자리 = **일위대가의 품(인력) 줄** - ⚠ B08 물량에 곱하면 자재·기계까지 함께 부푼다. - 근거 — 원문 제목이 「1-4. **품**의 할인·할증」 · 1-6 라 표가 직접노무비를 품셈으로 냄. - -⚠ 겨누는 것 여섯 - ① **안 고르면 한 원도 안 움직인다** - ② 고르면 **노무비만** 커진다 — 재료비·경비는 그대로 - ③ 밑수는 **사람 품만** — 기계 줄 안의 조종원 노임은 안 센다(제잡비와 같은 규칙) - ④ 제잡비가 **할증 뒤 노무비**를 밑수로 삼는다(줄 차례가 곧 셈 차례) - ⑤ 원문에 없는 선택지는 안 받는다 - ⑥ 합산·곱 셈법이 **한 곳에서** 갈린다(원문이 안 정한 자리) -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation import B09_Estimation_LaborSurcharge as LS # noqa: E402 -from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 - PriceBook, - PriceDetail, - PriceKind, - PriceTitle, -) -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402 - -_CODE = "B-FP-09-13-09" # 인력 품이 있는 공종(구조물터파기 암절취 2~3m) - - -def test_원문_행을_그대로_선택지로_낸다() -> None: - series = LS.load_series() - assert len(series) == 24, "율 표는 24개다(1-4-24·1-4-26 은 성격이 다름)" - first = next(item for item in series if item["key"] == "1-4-1") - assert [str(option["percent"]) for option in first["options"]] == ["10", "5", "0"] - # [주] 원문이 함께 실려야 「어느 작업에 쓰는 표인지」가 화면에 보인다. - assert "어린나무가꾸기" in first["source_note"] - - -def test_원문에_없는_선택지는_안_받는다() -> None: - assert LS.parse_choices({"1-4-1": "1-4-1:0"}) == {"1-4-1": "1-4-1:0"} - assert LS.parse_choices({"1-4-1": "없는값"}) == {} - # 계열과 선택지가 어긋나도 안 받는다. - assert LS.parse_choices({"1-4-2": "1-4-1:0"}) == {} - - -def test_셈법은_한_곳에서_갈린다(monkeypatch) -> None: - picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5% - assert LS.total_percent(picks)[0] == Decimal(15) - monkeypatch.setattr(LS, "COMBINE_RULE", "product") - assert LS.total_percent(picks)[0] == Decimal("15.5") - - -def test_안_고르면_한_원도_안_움직인다() -> None: - base = build_unit_prices().book.resolve(_CODE) - same = build_unit_prices(labor_surcharge_choices={}).book.resolve(_CODE) - assert base.total == same.total - - -def test_고르면_노무비만_커진다() -> None: - before = build_unit_prices().book.resolve(_CODE) - after = build_unit_prices( - labor_surcharge_choices={"1-4-1": "1-4-1:0"} # 10% - ).book.resolve(_CODE) - assert after.material == before.material - assert after.expense == before.expense - assert after.labor > before.labor - - -def test_밑수는_사람_품만이다() -> None: - """③ 기계 줄 안의 조종원 노임까지 세면 할증이 부풀어 붙는다.""" - book = PriceBook() - book.add_title(PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[Decimal(100_000)] * 6)) - book.add_title(PriceTitle("S-1", PriceKind.MACHINE_BASE, "기계", slots=[Decimal(1)] * 6)) - book.add_title(PriceTitle("X-1", PriceKind.MACHINE_HOURLY, "기계 사용료")) - book.add_detail(PriceDetail("X-1", "L-1", Decimal(1))) # 기계 층 안의 조종원 - book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "시험공종")) - book.add_detail(PriceDetail("B-1", "L-1", Decimal(1))) - book.add_detail(PriceDetail("B-1", "X-1", Decimal(1))) - book.add_detail( - PriceDetail( - "B-1", "B-1", Decimal(0), percent_of_labor=Decimal(10), percent_of_labor_target="labor" - ) - ) - # 사람 품 100,000 의 10% 만 붙어야 한다(기계 층 안의 100,000 은 안 셈). - assert book.resolve("B-1").labor == Decimal(210_000) - - -def test_제잡비는_할증_뒤_노무비를_본다() -> None: - """④ 품이 늘면 그 품에 비례하는 제잡비도 함께 는다.""" - book = PriceBook() - book.add_title(PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[Decimal(100_000)] * 6)) - book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "시험공종")) - book.add_detail(PriceDetail("B-1", "L-1", Decimal(1))) - book.add_detail( - PriceDetail( - "B-1", "B-1", Decimal(0), percent_of_labor=Decimal(10), percent_of_labor_target="labor" - ) - ) - book.add_detail(PriceDetail("B-1", "B-1", Decimal(0), percent_of_labor=Decimal(5))) - money = book.resolve("B-1") - assert money.labor == Decimal(110_000) - assert money.expense == Decimal(110_000) * Decimal(5) / Decimal(100) diff --git a/resources/tester/test_b09_labor_surcharge_combine.py b/resources/tester/test_b09_labor_surcharge_combine.py deleted file mode 100644 index 877d77934..000000000 --- a/resources/tester/test_b09_labor_surcharge_combine.py +++ /dev/null @@ -1,26 +0,0 @@ -"""품 할증 겹침 — 2026-09-14 브레인 672: 건설 공통 1-4-2 「할증의 중복가산요령」. - -원문: 「W = 기본품 × (1 + a1 + a2 + a3 + … + an) · 단, 동일성격의 품할증요소의 이중적용은 불가함」 -산림품셈 1-4 에는 겹침 규정이 없어 **교차 참조**로 건설 규정을 따름 — 합산은 이제 「우리가 둔 것」이 아니라 원문. -「동일성격」이 어느 계열끼리인지는 원문이 안 정함 → 막지 않고 설계자에게 그 단서를 그대로 보임. -""" - -from __future__ import annotations - -from decimal import Decimal - -import B09_Estimation.B09_Estimation_LaborSurcharge as LS - - -def test_여럿을_고르면_합산이고_근거는_건설_1_4_2() -> None: - assert LS.COMBINE_RULE == "sum" - picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5% - assert LS.total_percent(picks)[0] == Decimal(15) - note = LS.COMBINE_NOTE - assert "1-4-2" in note and "1 + a1 + a2" in note and "교차 참조" in note, note - assert "확정 대기" not in note and "우리가 그렇게" not in note, note - - -def test_동일성격_이중적용_불가_단서를_보임() -> None: - assert "동일성격" in LS.COMBINE_NOTE and "이중적용" in LS.COMBINE_NOTE - assert "설계자" in LS.COMBINE_NOTE # 어느 계열이 동일성격인지는 원문이 안 정함 diff --git a/resources/tester/test_b09_machine_catalog_names.py b/resources/tester/test_b09_machine_catalog_names.py deleted file mode 100644 index 1c31354dd..000000000 --- a/resources/tester/test_b09_machine_catalog_names.py +++ /dev/null @@ -1,61 +0,0 @@ -"""기계 카탈로그 이름 — 원천 파싱 병(쪽 끝 이름에 다음 쪽 머리글이 붙고 다음 분류 이름이 빔) 되살림. - -2026-09-14 축 C 1장 ① (브레인 차례) · 원문 = 건설공사 표준품셈 제8장 기종 목록 줄. - ① 표 머리글(「시 간 당」·「(10-7)」)이 붙은 이름이 하나도 없음 - ② 빈 이름이 하나도 없음 - ③ 되살린 이름·규격이 원문 차례와 같음 · 취득가는 원천 그대로 -""" - -from __future__ import annotations - -import json -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog # noqa: E402 - -CATALOG = load_machine_catalog() - - -def test_표_머리글이_붙은_이름이_없다() -> None: - glued = [ - m.machine_code - for m in CATALOG.machines.values() - if "시 간 당" in m.name or "(10-7)" in m.name - ] - assert glued == [] - - -def test_빈_이름이_없다() -> None: - assert [m.machine_code for m in CATALOG.machines.values() if not m.name.strip()] == [] - - -def test_되살린_이름과_규격() -> None: - expected = { - "5220-0015": ("소형브레이커(전기식)", "1.5㎾"), - "7101-0450": ("고성능 착정기", "335.70㎾"), - "0240-0007": ("유압식 진동콤팩터(굴착기 부착용)", "0.7"), - "3611-0142": ("콘크리트 피니셔(중앙분리대용)", "105.9"), - "6802-0100": ("파일천공전용장비", "100"), - # 2026-09-18 규격이 되살아남 — 앞서 빈칸이던 것이 원문 규격으로 섰다(기계 원천 벌 재추출) - "9070-0020": ("이우선(비자항)", "80ton대선 8ton달기"), - } - for code, (name, spec) in expected.items(): - machine = CATALOG.machines[code] - assert (machine.name, machine.specification) == (name, spec), code - - -def test_취득가는_원천_그대로() -> None: - raw = json.loads( - (ROOT / "resources/data_cost_input_value/mach_base_2026.json").read_text(encoding="utf-8") - ) - prices = { - r["machine_code"]: r["price_thousand_krw"] - for r in raw["variables"]["mach_price"]["records"] - } - for code in ("5220-0015", "0240-0007", "6802-0040", "7930-0100"): - assert CATALOG.machines[code].price_thousand_krw == Decimal(str(prices[code])) diff --git a/resources/tester/test_b09_machine_expense.py b/resources/tester/test_b09_machine_expense.py deleted file mode 100644 index 5602b4277..000000000 --- a/resources/tester/test_b09_machine_expense.py +++ /dev/null @@ -1,90 +0,0 @@ -"""각종 중기경비계산서 — 기종마다 한 장 (2026-09-09). - -별표2 (5)(가) 가 설계서에 「각종 중기경비계산서」를 넣으라 한다. 중기목록표는 「얼마」 한 줄 -이고, 이 장은 **그 값이 나온 과정**이다. - -⚠ 겨누는 것 다섯 - ① **내역에 선 기종만** 낸다 — 카탈로그 613 을 다 뿌리지 않는다 - ② 손료 = 취득가(천원) × 1,000 × 계수 — 천원 단위를 놓치면 1,000배 틀린다 - ③ 계산서의 시간당 합계가 **일위대가가 실제로 쓰는 값과 같다**(두 벌이면 안 된다) - ④ 부착 장비(브레이커·콤팩터·집게)는 **손료만 드는 것이 정상** — 결함으로 세지 않는다 - ⑤ 수송비는 이 장에 안 붙는다 — 「회당」으로 서는 별개 공종이라 자리만 가리킨다 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MachineExpenseSheet import ( # noqa: E402 - machine_expense_report, -) -from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402 -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 - - -def _report(): - return machine_expense_report(cached_build()) - - -def test_내역에_선_기종만_낸다() -> None: - build = cached_build() - hourly = {c for c, t in build.book.titles.items() if t.kind is PriceKind.MACHINE_HOURLY} - sheets = _report()["sheets"] - assert sheets, "계산서가 한 장도 없습니다" - assert len(sheets) <= len(hourly) - assert {sheet["code"] for sheet in sheets} <= hourly - - -def test_손료가_취득가와_계수로_맞는다() -> None: - """② 천원 단위를 놓치면 1,000배 틀린다.""" - for sheet in _report()["sheets"]: - if sheet["loss_krw_per_hour"] is None or sheet["loss_coefficient"] is None: - continue - price = Decimal(sheet["price_thousand_krw"]) * Decimal(1000) - coefficient = Decimal(sheet["loss_coefficient"]) / Decimal(10**7) - assert abs(Decimal(sheet["loss_krw_per_hour"]) - price * coefficient) < Decimal("0.01") - - -def test_계산서_합계가_일위대가와_같은_값이다() -> None: - """③ 두 벌로 세면 화면과 금액이 어긋난다.""" - build = cached_build() - for sheet in machine_expense_report(build)["sheets"]: - used = build.book.resolve(sheet["code"]).total - assert Decimal(sheet["total_krw"]) == used, sheet["code"] - - -def test_부착_장비는_결함이_아니다() -> None: - """④ 제 엔진이 없어 연료·조종원이 본체에 든다(품셈 제8장 [주]⑤).""" - sheets = _report()["sheets"] - attachments = [sheet for sheet in sheets if sheet["attachment"]] - assert attachments, "부착 장비가 한 대도 안 잡혔습니다" - for sheet in attachments: - assert sheet["gaps"] == [] - assert sheet["attachment_note"] - assert sheet["loss_krw_per_hour"] is not None # 손료는 있어야 한다 - - -def test_수송비는_이_장에_안_붙는다() -> None: - """⑤ 「회당」으로 서는 별개 공종이라 자리만 가리킨다.""" - report = _report() - assert any("수송비" in line and "회당" in line for line in report["notes"]) - for sheet in report["sheets"]: - assert "transport" not in sheet - - -def test_같은_기종의_두_장은_갈래로_갈린다() -> None: - """⚠ 조합 사용이면 잡재료가 16% 로 줄어 재료비가 달라진다 — 갈래를 안 적으면 - 똑같은 장이 두 번 나온 것처럼 읽힌다(건설품셈 제8장 [주]⑤).""" - sheets = {sheet["code"]: sheet for sheet in _report()["sheets"]} - plain = sheets.get("X-0201-0070") - combined = sheets.get("X-0201-0070#조합") - assert plain is not None and combined is not None - assert plain["variant"] == "" and combined["variant"] == "조합" - assert combined["misc_material_percent"] == "16" - # 조합 쪽이 잡재료가 줄어 재료비가 더 싸야 한다. - assert Decimal(combined["material_krw"]) < Decimal(plain["material_krw"]) diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py deleted file mode 100644 index 0bb875d08..000000000 --- a/resources/tester/test_b09_material_prices.py +++ /dev/null @@ -1,189 +0,0 @@ -"""자재 수동 단가(PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). - -지키는 것 - ① 키 = 코드(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 — 조립엔 코드 키만 얹음 - ② 0 이하·수 아님은 안 받음 · 비우면 지움 · 값·출처가 같으면 넣은 날 그대로 - ③ 넣으면 자재 제목(6번 슬롯)이 서고 자원 축 자재 줄이 붙어 재료비가 섬 — 안 넣으면 종전 벌 그대로 - ④ 수동 단가가 닿은 내역 줄 = 금액은 서되 「미확정 N건」 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MaterialPrices import ( # noqa: E402 - MaterialPriceError, - build_key, - listing, - merge, -) -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 - -STAKE = "AR-M-dce99e46" # 말뚝 직경4~6㎝ — FP-05-15 에 2개/단위 - - -def test_키와_넣은_날() -> None: - first = merge({}, [{"key": STAKE, "price_krw": "1,500", "source": "견적 A"}], "2026-09-14") - assert first[STAKE]["price_krw"] == "1500" and first[STAKE]["entered_at"] == "2026-09-14" - same = merge(first, [{"key": STAKE, "price_krw": "1500", "source": "견적 A"}], "2026-09-20") - assert same[STAKE]["entered_at"] == "2026-09-14" # 다시 저장해도 안 바뀜 - moved = merge(first, [{"key": STAKE, "price_krw": "1600", "source": "견적 A"}], "2026-09-20") - assert moved[STAKE]["entered_at"] == "2026-09-20" - assert merge(first, [{"key": STAKE, "price_krw": ""}], "2026-09-20") == {} - for bad in ("0", "-3", "abc"): - with pytest.raises(MaterialPriceError): - merge({}, [{"key": STAKE, "price_krw": bad}], "2026-09-14") - named = merge(first, [{"key": "각재 50×50", "price_krw": "900000"}], "2026-09-14") - assert build_key(named) == ((STAKE, "1500", "견적 A"),) # 이름 키는 조립에 안 얹음 - - -def test_넣으면_재료비가_서고_안_넣으면_종전_그대로() -> None: - base = cached_build() - assert base.book.resolve("B-FP-05-15").material == 0 - assert STAKE not in base.book.titles and not base.manual_materials - built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) - title = built.book.titles[STAKE] - assert (title.slots[5], title.slot_pages[5], title.unit) == (Decimal("1500"), "견적 A", "개") - assert built.book.resolve("B-FP-05-15").material == Decimal("3000") # 2개 × 1,500 - assert built.book.resolve("B-FP-05-15").labor == base.book.resolve("B-FP-05-15").labor - assert "FP-05-15" in built.material_uses[STAKE] - - -def test_내역_줄은_미확정으로_선다() -> None: - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) - payload = { - "work_items": [ - { - "work_item_code": "FP-05-15", - "name": "말뚝박기", - "unit": built.book.titles["B-FP-05-15"].unit, - "quantity": 10, - "in_bill": True, - } - ], - "materials": [], - } - row = next(r for r in build_bill(payload, build=built).rows if r.name == "말뚝박기") - assert row.material_krw == Decimal("30000") and row.unconfirmed == 1 - result = build_bill(payload, build=built) - assert result.unconfirmed == [{"name": "말뚝박기", "code": "B-FP-05-15", "count": 1}] - plain = build_bill(payload, build=cached_build()) - assert not plain.unconfirmed - - -def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict: - return { - "material_name": name, - "spec": spec, - "unit": "㎥", - "net_amount": total, - "total_amount": total, - "supply_type": supply, - "source_structure": source, - } - - -def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None: - """Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈.""" - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - - payload = { - "work_items": [], - "materials": [ - _자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]), - _자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]), - _자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]), - ], - } - prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}} - result = build_bill(payload, build=cached_build(), material_prices=prices) - group = next(r for r in result.rows if r.name == "자재(사급)") - rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")] - assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림 - # 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000 - assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500") - assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500") - assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing) - sheet = result.material_sheet - assert ( - sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual - ) # 관급엔 수동 단가 안 씀 - plain = build_bill(payload, build=cached_build()) - assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0 - - -def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None: - """이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소).""" - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap - - built = cached_build(material_prices=((STAKE, "1500", ""),)) - unit = built.book.titles["B-FP-05-15"].unit - payload = { - "work_items": [ - {"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10} - ], - "materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])], - } - result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}}) - assert not any(r.name == "자재(사급)" for r in result.rows) - hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect") - assert "말뚝" in hit["reason"] - inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")] - assert overlap("시멘트", "", inside).startswith("시멘트") - assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만 - - -def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: - built = cached_build() - stored = { - STAKE: {"price_krw": "1500"}, - "AR-M-00000000": {"price_krw": "7", "name": "옛 자재"}, - } - materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])] - rows = { - row["key"]: row for row in listing(built, {**stored, "못": {"price_krw": "3"}}, materials) - } - assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500" - assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림 - assert rows["AR-M-b0853497"]["price_krw"] is None - sheet_row = rows["각재 50×50"] - assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == ( - "각재", - "50×50", - "material_sheet", - ) - assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True - - -def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None: - """1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정). - - 인계 전정밀 0.28181999…96 로 곱하면 183,182 원(1원 샘) — 확정 수량 0.28 × 650,000 = 182,000. - """ - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - from B09_Estimation.B09_Estimation_QuantityDigits import digits_for - - payload = { - "work_items": [], - "materials": [ - _자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"]) - ], - } - result = build_bill( - payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}} - ) - row = next(r for r in result.rows if r.name == "각재") - assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000")) - assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값 - assert digits_for("낯선종목", "개") == 2 # 표에 없는 종목은 우리 기본 2자리 - assert digits_for("돌쌓기(찰)", "㎡") == 1 and digits_for("철근", "kg") == 0 diff --git a/resources/tester/test_b09_misc_material.py b/resources/tester/test_b09_misc_material.py deleted file mode 100644 index 903e32c30..000000000 --- a/resources/tester/test_b09_misc_material.py +++ /dev/null @@ -1,162 +0,0 @@ -"""공구손료·잡재료 칸 — 「지금은 안 넣되 숫자 넣으면 되게」 (2026-09-09 확정 5차 작은 것 1). - -근거는 산림품셈 1-2-6 — 「명시되어 있지 않는 잡재료 및 소모재료 등을 계상하고자 할 때에는 -**주재료비(재료비의 할증수량 제외)의 2~5%까지** 별도 계상하되 산정 근거를 명시하여야 한다」. - -⚠ 겨누는 것 다섯 - ① **비면 줄이 아예 안 선다** — 지금 금액이 한 원도 안 움직여야 함 - ② 넣으면 **재료비**로 붙는다(경비 아님) — 1-2-6 은 잡재료·소모재료 자리임 - ③ 밑수는 **주재료비만** — 노무·경비는 안 들고, 하위 일위대가 재료비도 안 듦 - (그쪽에서 이미 한 번 셌음 — 두 번 세면 층이 깊을수록 부풀어 오름) - ④ **상한 5% 를 넘는 값은 안 받는다** — 조용히 깎아 넣지도 않음 - ⑤ 저장했다 다시 읽어도 그 줄이 살아 있다 (비율 줄이 직렬화에서 빠지면 조용히 싸짐) -""" - -from __future__ import annotations - -import asyncio -import json -import sys -from decimal import Decimal -from pathlib import Path -from uuid import uuid4 - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 - PriceBook, - PriceDetail, - PriceKind, - PriceTitle, -) -from B09_Estimation.B09_Estimation_Storage import ( # noqa: E402 - _detail_from_dict, - _detail_to_dict, -) -from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402 - MISC_MATERIAL_MAX_PERCENT, - parse_misc_material_percent, -) - - -def _book(percent: Decimal | None) -> PriceBook: - """자재 100,000 + 노임 50,000 짜리 일위대가 한 줄.""" - book = PriceBook() - book.add_title( - PriceTitle(code="M-1", kind=PriceKind.MATERIAL, name="자재", slots=[Decimal(100_000)] * 6) - ) - book.add_title( - PriceTitle(code="L-1", kind=PriceKind.LABOR, name="보통인부", slots=[Decimal(50_000)] * 6) - ) - book.add_title(PriceTitle(code="B-1", kind=PriceKind.UNIT_PRICE, name="시험공종")) - book.add_detail(PriceDetail("B-1", "M-1", Decimal(1))) - book.add_detail(PriceDetail("B-1", "L-1", Decimal(1))) - if percent is not None: - book.add_detail(PriceDetail("B-1", "B-1", Decimal(0), percent_of_material=percent)) - return book - - -def test_비면_한_원도_안_움직인다() -> None: - """① 「지금은 안 넣음」 — 칸이 비면 줄 자체가 안 서야 함.""" - money = _book(None).resolve("B-1") - assert money.material == Decimal(100_000) - assert money.total == Decimal(150_000) - - -def test_넣으면_재료비로_붙는다() -> None: - """② 경비가 아니라 재료비 — 1-2-6 은 잡재료·소모재료 자리.""" - money = _book(Decimal(3)).resolve("B-1") - assert money.material == Decimal(103_000) - assert money.labor == Decimal(50_000) - assert money.expense == Decimal(0) - - -def test_밑수는_주재료비만이다() -> None: - """③ 노무비가 밑수에 들면 3% 가 4.5% 처럼 서게 됨.""" - money = _book(Decimal(3)).resolve("B-1") - 붙은값 = money.material - Decimal(100_000) - assert 붙은값 == Decimal(100_000) * Decimal(3) / Decimal(100) - - -def test_하위_일위대가_재료비는_밑수에_안_든다() -> None: - """③ 층이 깊어질수록 같은 재료비를 거듭 세면 안 됨.""" - book = _book(Decimal(3)) - book.add_title(PriceTitle(code="B-2", kind=PriceKind.UNIT_PRICE, name="윗공종")) - book.add_detail(PriceDetail("B-2", "B-1", Decimal(1))) - book.add_detail(PriceDetail("B-2", "B-2", Decimal(0), percent_of_material=Decimal(3))) - # B-1 이 품고 온 재료비(103,000)는 B-2 의 밑수가 아니다 — B-2 엔 제 자재 줄이 없다. - assert book.resolve("B-2").material == book.resolve("B-1").material - - -def test_상한을_넘으면_안_받는다() -> None: - """④ 「2~5%까지」 — 넘는 값을 조용히 깎아 넣지 않고 거절함.""" - with pytest.raises(ValueError) as caught: - parse_misc_material_percent("6") - assert str(MISC_MATERIAL_MAX_PERCENT) in str(caught.value) - assert parse_misc_material_percent("") is None - assert parse_misc_material_percent(" ") is None - assert parse_misc_material_percent("3.5%") == Decimal("3.5") - - -def test_밑수가_있는지_미리_물어볼_수_있다() -> None: - """화면이 「넣을 데가 있는가」를 물어보는 자리 — 없으면 0 이라고 말해야 함.""" - book = _book(None) - assert book.material_base("B-1") == Decimal(100_000) - book.add_title(PriceTitle(code="B-9", kind=PriceKind.UNIT_PRICE, name="자재 없는 공종")) - book.add_detail(PriceDetail("B-9", "L-1", Decimal(1))) - assert book.material_base("B-9") == Decimal(0) - - -def test_저장했다_읽어도_비율_줄이_산다() -> None: - """⑤ 직렬화에서 빠지면 다시 읽은 단가가 조용히 싸짐.""" - original = PriceDetail( - "B-1", "B-1", Decimal(0), note="공구손료", percent_of_material=Decimal(3) - ) - again = _detail_from_dict(_detail_to_dict(original)) - assert again.percent_of_material == Decimal(3) - # 제잡비(노무비의 %)도 같은 자리에서 빠져 있었다 — 함께 살린다. - labor_row = PriceDetail("B-1", "B-1", Decimal(0), percent_of_labor=Decimal(5)) - assert _detail_from_dict(_detail_to_dict(labor_row)).percent_of_labor == Decimal(5) - - -def test_칸을_저장했다_지웠다_할_수_있다(tmp_path, monkeypatch) -> None: - """화면 [적용] 이 지나는 길 — 넣기·되비우기·상한 거절 셋을 한자리에서 본다. - - ⚠ **되비우는 길이 있어야 한다** — 한번 넣으면 못 지우는 칸이면 「지금은 안 넣음」으로 - 돌아갈 수 없다. - """ - from B09_Estimation import B09_Estimation_Router as router - from B09_Estimation import B09_Estimation_Router_Factors as factors - - project_id = uuid4() - monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path))) - - def _put(value: str): - body = factors.FactorChoiceBody(misc_material_percent=value) - got = asyncio.run(factors.put_factor_choices(project_id, body)) - return got.status_code, json.loads(got.body.decode()) - - from common_util.common_util_project_settings import estimation_settings - - status, _ = _put("3") - assert status == 200 - assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3" - - status, payload = _put("6") - assert status == 400 and "1-2-6" in payload["message"] - # ⚠ 거절된 값이 저장을 건드리면 안 된다 — 3% 가 그대로 살아 있어야 한다. - assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3" - - status, _ = _put("") - assert status == 200 - assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "" - - -def _fake_root(path: str): - async def _root(_project_id): - return path - - return _root diff --git a/resources/tester/test_b09_operating_paren_spec.py b/resources/tester/test_b09_operating_paren_spec.py deleted file mode 100644 index eb458698b..000000000 --- a/resources/tester/test_b09_operating_paren_spec.py +++ /dev/null @@ -1,34 +0,0 @@ -"""운전경비 표 규격 칸의 괄호 딸림값 — 2026-09-14 공기압축기 손료(브레인 차례). - -품셈 8-4 운전경비 표는 규격 칸에 딸림값을 괄호로 붙여 적음 — -「50ton/hr(93㎾) 100 (155) 150 (260)」 · 크레인 「10ton (0.29) 15 (0.38)」. -파서가 괄호 토큰도 규격 하나로 세어 코드 수와 안 맞는다며 **줄째 버렸음** → 공기압축기(5205) -여섯 · 크레인 26 · 콘크리트 기계 등이 운전경비를 잃고 기계 층이 「미완성」으로 섬. -9-19-1 연암·보통암 · 9-19-3 의 공기압축기 3.5 가 그래서 「일부만」 으로 막혔음. -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_MachineOperating import load_operating_records -from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of - - -def test_괄호_딸림값은_규격_개수에_안_셈_공기압축기_운전경비가_섬() -> None: - result = load_operating_records() - records = {record.machine_code: record for record in result.records} - compressor = records.get("5205-0035") - assert compressor is not None, result.dropped_tables - assert (str(compressor.fuel_liters_per_hour), compressor.fuel_kind) == ("6.2", "경유") - assert str(compressor.misc_material_percent) == "16" - assert str(compressor.operator_person_days) == "1" - assert not [t for t in result.dropped_tables if t.startswith("8-4-6")], result.dropped_tables - - -def test_공기압축기_층이_서서_토사면_고르기_암_갈래에_붙음() -> None: - build = cached_build() - assert "5205-0035" not in build.incomplete_machines - rows = { - r["ref_code"]: float(r["quantity"]) - for r in detail_of(build, "B-FP-09-19-01#절토면·연암")["rows"] - } - assert rows.get("X-5205-0035") == 0.125, rows diff --git a/resources/tester/test_b09_operator_class.py b/resources/tester/test_b09_operator_class.py deleted file mode 100644 index e68a7e7cd..000000000 --- a/resources/tester/test_b09_operator_class.py +++ /dev/null @@ -1,70 +0,0 @@ -"""조종원 직종 — 건설품셈 8-1-2 5호 「운전사의 구분」 (2026-09-15 브레인 · 잰 틀림 12 · 조립에 선 것 2). - -원문: 건설기계운전사 「…덤프트럭(12ton이상) … 콘크리트 믹서(0.55㎥ 이상) … 공기압축기(이동식, 2.83㎥/min 이상)」 - 화물차운전사 「12ton미만의 덤프트럭, 화물트럭, 살수차 …」 - 일반기계운전사 「소형의 공기압축기, 양수기, 소형믹서, 윈치 … 발전기, 래머, 콤팩터 …」 -⚠ 옛 잠정 규칙(이름에 트럭 들면 화물차 · 그 밖 전부 건설기계) — 덤프 15t 이 화물차 · 콤팩터·래머·소형믹서가 건설기계로 섰음. -거창 실무 기계경비 「덤프트럭 15Ton 건설기계운전사 · 8Ton·4.5Ton 화물차운전사 · 플레이트 콤팩터 일반기계운전사」 = 원문. -원문이 이름으로 안 가르는 기종(콘크리트 펌프차 · 트랙터(타이어) · 콘크리트 믹서트럭 …)은 **잠정 그대로 + 사유**. -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets -from B09_Estimation.B09_Estimation_MachineOperating import ( - _CATALOG_SUBPATH, - _read_json, - load_operating_records, -) -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -ALIASES = _read_json(*_CATALOG_SUBPATH, "labor_const_2026-01-01.json")["variables"]["aliases"] -CONST, TRUCK, GENERAL = ( - ALIASES[k] for k in ("labor_op_const", "labor_op_truck", "labor_op_general") -) - - -def _records(): - return {r.machine_code: r for r in load_operating_records().records} - - -def test_원문이_가르는_직종() -> None: - records = _records() - expected = { - "0602-0150": CONST, # 덤프트럭 15t — 12ton 이상 - "0602-0320": CONST, - "0602-0045": TRUCK, # 덤프트럭 4.5t — 12ton 미만 - "7204-0055": TRUCK, # 살수차 - "1730-0015": GENERAL, # 플레이트 콤팩터 - "1630-0080": GENERAL, # 래머 - "4205-0045": GENERAL, # 콘크리트 믹서 0.45㎥ — 소형믹서(0.55 미만) - "0201-0070": CONST, # 굴착기 - "5205-0035": CONST, # 공기압축기(이동식) 3.5 — 2.83 이상 - } - for code, occupation in expected.items(): - record = records[code] - assert record.operator_occupation_code == occupation, record - assert record.operator_mapping_is_provisional is False, record - - -def test_원문이_안_가르는_기종은_잠정_그대로() -> None: - records = _records() - for code in ("2602-0015", "4304-0060"): # 트랙터(타이어) · 콘크리트 믹서트럭 - assert records[code].operator_mapping_is_provisional is True, records[code] - assert records["2602-0015"].operator_occupation_code == CONST # 옛 규칙 그대로 - assert records["4304-0060"].operator_occupation_code == TRUCK - - -def test_조립에_선_둘이_원문_직종으로_섬() -> None: - build = cached_build(dump_haul_m=("164.23",)) - dump = {r.ref_code for r in build.book.details["X-0602-0150"]} - compactor = {r.ref_code for r in build.book.details["X-1730-0015"]} - assert f"{CONST}#시간" in dump, dump - assert f"{GENERAL}#시간" in compactor, compactor - - -def test_잠정_직종은_중기경비_장에_사유() -> None: - sheets = {s["machine_code"]: s for s in machine_expense_sheets(cached_build())} - assert sheets["0201-0070"]["operator_note"] == "" # 굴착기 — 원문이 가름 - note = sheets["7205-0125"]["operator_note"] # 이동식 임목파쇄기 — 원문 목록에 없음 - assert "잠정" in note and "8-1-2" in note, note diff --git a/resources/tester/test_b09_parent_steps.py b/resources/tester/test_b09_parent_steps.py deleted file mode 100644 index d659b2501..000000000 --- a/resources/tester/test_b09_parent_steps.py +++ /dev/null @@ -1,105 +0,0 @@ -"""부모 공종 모양 `parent_mode` (명세 2장 · 2026-09-13 축 C Ⓐ). - -지키는 것 - ① 마스터 — 부모는 기본 choose_one, 합산형(9-4·9-5·12-38)만 사람이 적은 단계·가중치를 든다 - ② 흙깎기 리핑암 = 9-4 암절취 = 암파쇄(암질별 1/Q) + 집토 — 부모 제목이 잎들의 합으로 선다 - ③ 내역 줄의 암 갈래가 그대로 갈래 값으로 가고, 표에 없는 암질은 9-4-1 [주]① 평균으로만 선다 - ④ 단계 하나라도 못 서면 부모를 안 세우고 사유 · 갈래 고르기형 부모를 가리키면 오류로 막는다 -""" - -from __future__ import annotations - -from decimal import Decimal -from functools import lru_cache - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill -from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_work_item_master -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices - - -@lru_cache(maxsize=1) -def _build(): - return build_unit_prices() - - -def _node(code: str) -> dict: - return next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == code) - - -def test_마스터_부모_모양은_기본_고르기_합산형만_단계를_든다(): - assert _node("FP-10-12")["parent_mode"] == "choose_one" - rock = _node("FP-09-05") - assert rock["parent_mode"] == "sum_steps" - assert [(s["code"], s["weight"]) for s in rock["steps"]] == [ - ("FP-09-05-01", "0.1"), - ("FP-09-05-02", "0.9"), - ("FP-09-05-03", "1"), - ] - assert "parent_mode" not in _node("FP-09-04-01") # 잎 - - -def test_암절취는_암파쇄와_집토의_합으로_선다(): - book = _build().book - # 층 차례 X → D → B — 잎 일위대가는 D(단가산출)를 1 로 부르고, 기계 1/Q 줄은 D 에 삶. - assert [d.ref_code for d in book.details["B-FP-09-04-01#연암"]] == ["D-FP-09-04-01#연암"] - leaf = book.details["D-FP-09-04-01#연암"] - assert {d.ref_code for d in leaf} == {"X-0201-0070#조합", "X-0230-0007"} # 조합 16 % - assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf) - whole = book.resolve("B-FP-09-04#연암").total - parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total - # 28자리 끝의 반올림 차례만 다를 수 있음 — 금액 자리(원)에서 같으면 같은 합. - assert parts > 0 and abs(whole - parts) < Decimal("1e-15") - assert "B-FP-09-04#평균" in book.titles # [주]① 평균 - - -def test_단계가_못_서면_부모를_안_세우고_사유를_남긴다(): - build = _build() - assert not any(code.startswith("B-FP-09-05#") for code in build.book.titles) - assert "FP-09-05-01" in build.component_gaps["FP-09-05"] # 발파 착암기 층 없음 - assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 — 임대료·손료 단가 안 넣음 - # 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음) - assert "[주]" in build.book.details["D-FP-09-05-02#경암"][0].note # Q 줄은 D 에 - - -def _bill(rock: str, method: str = "ripping"): - handoff = build_handoff( - summary_table={ - "rows": [{"group": "흙깎기", "item": rock, "spec": "", "unit": "㎥", "amount": 10.0}] - }, - ground_methods={rock: method}, - ) - return handoff["work_items"][0], build_bill(handoff, build=_build()) - - -def test_암_갈래가_그대로_단가를_고른다(): - row, bill = _bill("보통암") - assert (row["work_item_code"], row["variant_value"]) == ("FP-09-04", "보통암") - priced = next(r for r in bill.rows if not r.is_group and r.name == "흙깎기") - assert priced.unit_price_krw and "보통암" in priced.spec - - -def test_표에_없는_암질은_주1_평균으로만_선다(): - _row, bill = _bill("풍화암") - priced = next(r for r in bill.rows if not r.is_group and r.name == "흙깎기") - assert priced.unit_price_krw and "[주]①" in priced.note - - -def test_발파암은_단계_사유로_막히고_고르기형_부모는_오류(): - _row, bill = _bill("경암", "blasting") - assert any("단계 합산 미완" in m["reason"] for m in bill.missing) - handoff = { - "work_items": [{**_row, "work_item_code": "FP-10-12", "variant_value": None}], - "materials": [], - } - blocked = build_bill(handoff, build=_build()) - assert any("갈래 고르기형 부모" in m["reason"] for m in blocked.missing) - - -def test_깨기_브레이커는_굴착기_몸체_시간과_함께_붙는다(): - """9-12·9-13 「깨기 대형브레이커」 — 부착 장비라 몸체도 같은 1/Q 로(2026-09-13 판정).""" - details = _build().book.details["D-FP-09-13-07"] # 작업량 줄은 D(단가산출)에 - breaker = next(d for d in details if d.ref_code == "X-0230-0007") - assert breaker.quantity == Decimal(1) / Decimal("3.5") * Decimal("0.9") # 장비 90 % 몫 - breaking = [d for d in details if d.quantity == breaker.quantity] - assert {d.ref_code for d in breaking} == {"X-0230-0007", "X-0201-0070#조합"} diff --git a/resources/tester/test_b09_prep_handoff_bill.py b/resources/tester/test_b09_prep_handoff_bill.py deleted file mode 100644 index c9c40f0f9..000000000 --- a/resources/tester/test_b09_prep_handoff_bill.py +++ /dev/null @@ -1,61 +0,0 @@ -"""PLAN 7장 V-12 — 준비공 줄이 B08 인계에서 **B09 내역서까지 하나도 안 새나** (2026-09-13). - -「안 하면 표토제거 2,359.03㎥ 가 다시 통째로 사라짐」 자리. 보내는 쪽(B08)과 받는 쪽(B09)을 -**마주 걸어** 본다 — 한쪽만 보면 양쪽 다 조용히 틀린다(PLAN 7장 머리 규칙). - -⚠ 겨누는 것 둘 - ① 준비공·부대시설 줄은 **내역 줄 아니면 제외 줄**로 반드시 선다 — 어디에도 없는 줄이 0 - ② 금액이 서는 줄(`in_bill`)은 **수량째** 내역 본체에 선다 — 표토제거 2,359.035㎥ -⚠ 단가가 붙는지는 여기서 안 본다 — 표토 축(시트 9번)·임목축적 입력이 정해져야 서는 자리다. -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 -from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_prep # noqa: E402 -from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill # noqa: E402 - -SLOPE = { - "face_dressing_fill": 9000.0, - "face_dressing_cut": 6726.9, - "tree_removal_fill": 9000.0, - "tree_removal_cut": 6726.9, - "bench_cut_fill": 9000.0, -} - - -def _handoff_and_bill(): - table = build_prep(SLOPE, [], [], 0.15, road_surface_area_m2=8000.0) - handoff = build_handoff(preparation_table=table) - return handoff, build_bill(handoff) - - -def test_준비공_줄은_내역_아니면_제외로_반드시_선다() -> None: - handoff, bill = _handoff_and_bill() - sent = [r for r in handoff["work_items"] if r["origin"] == "preparation"] - assert len(sent) >= 6 - body = [r.name for r in bill.rows if not r.is_group] - excluded = [r.name for r in bill.excluded] - for row in sent: - landed = body if row["in_bill"] else excluded - assert row["name"] in landed, (row["name"], row["in_bill"]) - - -def test_금액이_서는_줄은_수량째_본체에_선다() -> None: - handoff, bill = _handoff_and_bill() - topsoil = next(r for r in bill.rows if not r.is_group and r.name == "표토제거") - # ⭐ 2026-09-13 「실무대로」 — 노면 8,000 + 절토 사면 6,726.9 ㎡ (두께를 안 곱함) - assert Decimal(str(topsoil.quantity)).quantize(Decimal("0.01")) == Decimal("14726.90") - in_bill = [ - r["name"] for r in handoff["work_items"] if r["origin"] == "preparation" and r["in_bill"] - ] - assert {"표토제거", "뿌리 적재"} <= set(in_bill) - # ⭐ 2026-09-13 판정 Ⓑ — 제근은 토공집계 뿌리뽑기가 셈. 준비공 줄은 참조(제외 목록)로 간다. - assert "제근·뿌리다듬기" not in in_bill diff --git a/resources/tester/test_b09_price_layers.py b/resources/tester/test_b09_price_layers.py deleted file mode 100644 index 9974a4a40..000000000 --- a/resources/tester/test_b09_price_layers.py +++ /dev/null @@ -1,77 +0,0 @@ -"""단가 층 차례 `X → D → B` (2026-09-13, PLAN 6장 · 명세 3장 정정 · 브레인 판정). - -겨누는 것 - ① 시공능력 Q 로 선 기계 줄은 D(단가산출)에 살고 B 는 D 를 수량 1 로 부름 — D 는 X 만 부름 - ② 층 차례만 바꿈 — 금액 불변(420 제목 전후 대조는 스크립트, 여기선 B = D 합) - ③ 자원 집계가 가운데 층(B→B 잎 · B→D)을 **끝까지 풀어** 기계 사용료에 닿음(종전 조용히 버림) - ④ 「단산 N」은 D 를 품은 줄에만 — 사람 품만 있는 일위대가엔 산출서가 없음 -""" - -from __future__ import annotations - -from decimal import Decimal -from functools import lru_cache - -from B09_Estimation.B09_Estimation_Lists import resource_summary -from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis, price_basis_detail -from B09_Estimation.B09_Estimation_PriceBook import PriceKind -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices - - -@lru_cache(maxsize=1) -def _build(): - return build_unit_prices() - - -def test_D_는_X_만_부르고_B_가_D_를_수량_1로_부른다() -> None: - book = _build().book - bases = [code for code, t in book.titles.items() if t.kind is PriceKind.PRICE_BASIS] - assert len(bases) >= 40, len(bases) # 2026-09-13 기본 벌 45장 - for basis in bases: - refs = {book.titles[d.ref_code].kind for d in book.details[basis]} - assert refs == {PriceKind.MACHINE_HOURLY}, (basis, refs) - parent = f"B-{basis[2:]}" - calls = [d for d in book.details[parent] if d.ref_code == basis] - assert len(calls) == 1 and calls[0].quantity == Decimal(1), basis - # 금액은 B 에 D 한 줄이 그대로 — 사람 품 등 나머지 줄과 더해짐(층만 바뀜). - others = [d for d in book.details[parent] if d.ref_code != basis] - if not others: - assert book.resolve(parent) == book.resolve(basis) - # 굴착기 공식 공종 — Q 줄이 B 에 바로 안 붙음. - assert all( - book.titles[d.ref_code].kind is not PriceKind.MACHINE_HOURLY - for d in book.details["B-FP-09-03-02"] - ) - - -def test_자원_집계가_가운데_층을_끝까지_풀어_기계에_닿는다() -> None: - build = _build() - summary = resource_summary({"B-FP-09-04#연암": Decimal(1)}, build) - machines = {row["code"] for row in summary["groups"]["machine"]} - # 암절취 부모 → 잎 B(암파쇄·집토) → D → X — 종전엔 부모의 B→B 참조를 조용히 버렸음. - assert {"X-0230-0007", "X-0201-0070#조합"} <= machines, machines - assert not summary["missing"] - - -def test_단산_번호는_D_를_품은_줄에만_붙는다() -> None: - build = _build() - book = build.book - labor_only = next( - code - for code, t in sorted(book.titles.items()) - if t.kind is PriceKind.UNIT_PRICE - and book.details.get(code) - and all(book.titles[d.ref_code].kind is PriceKind.LABOR for d in book.details[code]) - ) - sheet = build_price_basis(["B-FP-09-03-02", labor_only, "B-FP-09-04#연암"], build) - assert [e.code for e in sheet.entries] == [ - "D-FP-09-03-02", - "D-FP-09-04-01#연암", - "D-FP-09-04-02", - ] - assert sheet.label_for("B-FP-09-03-02") == "단산 1 참조" - assert sheet.label_for(labor_only) == "" # 없는 근거를 가리키지 않음 - assert sheet.label_for("B-FP-09-04#연암") == "단산 2·3 참조" # 단계 합산 부모 - detail = price_basis_detail("D-FP-09-03-02", build) - assert detail["kind"] == PriceKind.PRICE_BASIS.value - assert detail["rows"] and all(r["ref_code"].startswith("X-") for r in detail["rows"]) diff --git a/resources/tester/test_b09_progress.py b/resources/tester/test_b09_progress.py deleted file mode 100644 index 3d60eea09..000000000 --- a/resources/tester/test_b09_progress.py +++ /dev/null @@ -1,237 +0,0 @@ -"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (PLAN 12장 · 2026-09-14 배정). - -⚠ 값이 맞는지는 못 잰다 — 기성 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것: - ① ⭐ 제잡비 줄 = 금회직접공사비 × 계약잡비율(도급액 ÷ 계약 직접공사비) - — 줄마다 밑수×요율 다시 안 셈 - ② 계약 · 전회(앞 회차 금회의 합) · 금회 · 누계 네 칸과 각 비율 · 기성(%) · 잔량 - ③ 부가세 넷 — 직접입력 · 공급가액 · 재료비 · 재료비+산출경비 - ④ 계약 줄(입력) 불변 · 분리발주 폐기물은 제잡비 밖 · 틀린 칸 거름 -""" - -from __future__ import annotations - -import copy -import sys -from decimal import Decimal -from pathlib import Path -from types import SimpleNamespace - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402 -from B09_Estimation.B09_Estimation_Progress import ( # noqa: E402 - clean_settings, - completion_sheet, - progress_sheet, -) - - -def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict: - total = sum(unit) * int(qty) - return { - "item_no": item_no, - "is_group": False, - "in_bill": True, - "quantity": qty, - "contract_unit_material_krw": str(unit[0]), - "contract_unit_labor_krw": str(unit[1]), - "contract_unit_expense_krw": str(unit[2]), - "contract_amount_krw": str(total), - } - - -CONTRACT_ROWS = [ - {"item_no": "1", "is_group": True, "in_bill": True}, - _contract_row("1.1", "100", (1000, 2000, 500)), # 350,000 - _contract_row("1.2", "10", (5000, 0, 0)), # 50,000 → 계약 직접공사비 400,000 -] - - -def _cost(separate_waste: bool = False) -> tuple[SimpleNamespace, CostResult]: - result = CostResult() - for key, amount in ( - ("indirect_labor_cost", 20000), - ("industrial_accident_insurance", 7000), - ("waste_disposal", 5000), - ("general_overhead", 24000), - ("profit", 30000), - ("vat", 48100), - ): - result.lines.append( - CostLine(key, key, "", Decimal(amount), None, Decimal(0), Decimal(amount)) - ) - data = SimpleNamespace(waste_separate_order=separate_waste, indirect_material_krw=Decimal(0)) - return data, result - - -SETTINGS = { - "rounds": [ - {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"}, - {"quantities": {"1.1": "30"}, "vat_mode": "material"}, - ] -} - - -def _sheet(settings=SETTINGS, round_no=None, separate_waste=True) -> dict: - data, result = _cost(separate_waste) - return progress_sheet(CONTRACT_ROWS, data, result, clean_settings(settings)[0], round_no) - - -def test_제잡비는_금회직접공사비_곱하기_계약잡비율() -> None: - before = copy.deepcopy(CONTRACT_ROWS) - sheet = _sheet() - assert CONTRACT_ROWS == before # 계약 줄 불변 - items = {item["key"]: item for item in sheet["items"]} - labor = items["indirect_labor_cost"] - # 도급액 20,000 ÷ 계약 직접 400,000 = 5% · 1회 150,000 → 7,500 · 2회 105,000 → 5,250 - assert (labor["contract_ratio_pct"], labor["previous_krw"], labor["current_krw"]) == ( - "5.000", - "7500", - "5250", - ) - assert (labor["cumulative_krw"], labor["cumulative_pct"]) == ("12750", "63.750") - # 7,000 × 105,000 ÷ 400,000 = 1,837.5 → 회차마다 원 미만 절사 - assert items["industrial_accident_insurance"]["current_krw"] == "1837" - assert "waste_disposal" not in items # 분리발주면 도급 밖 - assert sheet["contract_overhead_ratio_pct"] == "20.250" # 81,000 ÷ 400,000 - - -def test_네_칸과_기성율_잔량() -> None: - sheet = _sheet() - row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] - assert ( - row["progress_previous_quantity"], - row["progress_current_quantity"], - row["progress_cumulative_quantity"], - ) == ("40", "30", "70") - assert (row["progress_cumulative_amount_krw"], row["progress_pct"]) == ("245000", "70.000") - assert row["progress_remaining_quantity"] == "30" - group = sheet["rows"][0] - assert group["progress_cumulative_amount_krw"] == "255000" # 245,000 + 1.2 의 10,000 - summary = {s["key"]: s for s in sheet["summary"]} - assert (summary["direct"]["previous_krw"], summary["direct"]["current_krw"]) == ( - "150000", - "105000", - ) - assert summary["direct"]["cumulative_pct"] == "63.750" - - -def test_부가세_넷() -> None: - summary = {s["key"]: s for s in _sheet()["summary"]} - # 1회 공급가액 180,375 × 10% = 18,037 · 2회 재료비 30,000 × 10% = 3,000 - assert (summary["supply"]["previous_krw"], summary["vat"]["previous_krw"]) == ( - "180375", - "18037", - ) - assert summary["vat"]["current_krw"] == "3000" - coop = {"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "forest_coop"}]} - vat = {s["key"]: s for s in _sheet(coop)["summary"]}["vat"]["current_krw"] - assert vat == "6000" # (재료비 40,000 + 산출경비 20,000) × 10% - manual = { - "rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "manual", "vat_manual_krw": "12345"}] - } - assert {s["key"]: s for s in _sheet(manual)["summary"]}["vat"]["current_krw"] == "12345" - - -def test_회차를_고르면_그_앞이_전회() -> None: - first = _sheet(round_no=1) - row = {r["item_no"]: r for r in first["rows"]}["1.1"] - assert (row["progress_previous_quantity"], row["progress_current_quantity"]) == ("0", "40") - assert first["round"] == 1 and first["round_count"] == 2 - - -def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_제잡비() -> None: - over = {"rounds": [{"quantities": {"1.2": "12"}, "vat_mode": "supply"}]} - row = {r["item_no"]: r for r in _sheet(over)["rows"]}["1.2"] - assert row["progress_remaining_quantity"] == "-2" and "넘음" in row["progress_note"] - items = {item["key"] for item in _sheet(separate_waste=False)["items"]} - assert "waste_disposal" in items - - -def _items(sheet: dict) -> dict: - return {item["key"]: item for item in sheet["items"]} - - -def _summary(sheet: dict) -> dict: - return {s["key"]: s for s in sheet["summary"]} - - -def test_계약잡비율_직접입력이_이기고_사유가_없으면_거른다() -> None: - settings = { - **SETTINGS, - "rate_overrides": {"indirect_labor_cost": {"rate_pct": "10", "reason": "계약서 제잡비율"}}, - } - labor = _items(_sheet(settings))["indirect_labor_cost"] - assert (labor["contract_ratio_pct"], labor["default_ratio_pct"]) == ("10.000", "5.000") - assert labor["current_krw"] == "10500" and labor["override"]["reason"] == "계약서 제잡비율" - _, errors = clean_settings({"rate_overrides": {"profit": {"rate_pct": "7", "reason": " "}}}) - assert len(errors) == 1 - - -def test_단일율_곱과_벌어진_차이를_알린다() -> None: - # 1.2 × 0.1998 = 999원 → 줄별 49 + 17 + 59 + 74 = 199 · 단일율 20.25% 곱 202 → 3원 - sheet = _sheet({"rounds": [{"quantities": {"1.2": "0.1998"}}]}) - assert sheet["single_rate_gap_krw"]["current"] == "3" - assert any("단일율 곱과 금회 3원" in note for note in sheet["notes"]) - - -def test_공급가액_절사와_총공사비_절사는_이윤에서() -> None: - first = {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"} - cut = _sheet({"rounds": [{**first, "supply_cut_krw": "100"}]}) - # 공급가액 180,375 → 180,300 · 이윤 11,250 − 75 · 부가세 18,030 - assert _items(cut)["profit"]["current_krw"] == "11175" - assert (_summary(cut)["supply"]["current_krw"], _summary(cut)["vat"]["current_krw"]) == ( - "180300", - "18030", - ) - total = _sheet({"rounds": [{**first, "total_cut_krw": "1000"}]}) - # 총공사비 198,412 → 412 ÷ 1.1 = 375 을 이윤에서 → 공급가액 180,000 · 부가세 18,000 - assert _summary(total)["total"]["current_krw"] == "198000" - assert _items(total)["profit"]["current_krw"] == "10875" - - -def test_이윤금액_직접입력과_사정_칸() -> None: - entry = { - "quantities": {"1.1": "40", "1.2": "2"}, - "profit_manual_krw": "10000", - "assessed": {"1.1": "130000"}, - } - sheet = _sheet({"rounds": [entry]}) - assert _items(sheet)["profit"]["current_krw"] == "10000" - assert _summary(sheet)["supply"]["current_krw"] == "179125" - row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] - assert row["progress_assessed_krw"] == "130000" # 칸만 — 금액에 안 섞임 - assert row["progress_current_amount_krw"] == "140000" - - -def test_준공은_마지막_회차_누계를_옮기기만() -> None: - progress = _sheet() - before = copy.deepcopy(progress) - done = completion_sheet(progress) - assert progress == before # 기성 한 장 불변 - row = {r["item_no"]: r for r in done["rows"]}["1.1"] - assert (row["contract_amount_krw"], row["completion_amount_krw"]) == ("350000", "245000") - lines = {line["key"]: line for line in done["lines"]} - summary = {s["key"]: s for s in progress["summary"]} - assert lines["total"]["completion_krw"] == summary["total"]["cumulative_krw"] - assert lines["indirect_labor_cost"]["completion_krw"] == "12750" and done["from_round"] == 2 - - -def test_기성_회차가_없으면_준공금액을_비운다() -> None: - done = completion_sheet(_sheet({"rounds": []})) - assert all(line["completion_krw"] is None for line in done["lines"]) - assert done["notes"] and done["from_round"] == 0 - - -def test_틀린_칸은_거른다() -> None: - _, errors = clean_settings( - { - "rounds": [ - {"quantities": {"1.1": "-1"}, "vat_mode": "supply"}, - {"quantities": {}, "vat_mode": "manual", "vat_manual_krw": ""}, - {"quantities": {}, "vat_mode": "없는방식"}, - ] - } - ) - assert len(errors) == 3 diff --git a/resources/tester/test_b09_quantity_digits_golden.py b/resources/tester/test_b09_quantity_digits_golden.py deleted file mode 100644 index e241fe2ea..000000000 --- a/resources/tester/test_b09_quantity_digits_golden.py +++ /dev/null @@ -1,29 +0,0 @@ -"""수량 자리 맞춤 골든 대조 — STmate 실무 원본 내역 수량이 우리 자리 규칙으로 **안 바뀌어야** 함. - -2026-09-14 브레인 판정 — 내역 수량은 1-2-2 자리로 반올림해 확정한 뒤 금액(버림). 골든 금액 시험 -(`test_b09_golden_stmate`)은 원본 수량을 `bill_line` 에 바로 넣어 이 길을 안 거치므로, 원본 수량 -자체로 자리 규칙을 따로 잰다(321줄 · 철근 ton 소수 3자리가 이 대조에서 드러남). -""" - -from __future__ import annotations - -import pytest -from test_b09_golden_stmate import _bill_rows, _num - -from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity - - -def test_실무_원본_내역_수량이_자리_규칙으로_안_바뀐다() -> None: - rows = _bill_rows() - if not rows: - pytest.skip("실무 원본 XLSX 가 없음") - changed = [] - for name, row in rows: - quantity, unit = _num(row[3]), str(row[4] or "").strip() - if quantity is None or unit == "%": - continue - settled, digits = round_quantity(quantity, str(row[1] or ""), unit, str(row[2] or "")) - if settled != quantity: - changed.append((name, row[1], unit, str(quantity), digits)) - assert len(rows) >= 300, len(rows) - assert not changed, (len(changed), changed[:5]) diff --git a/resources/tester/test_b09_rate_override.py b/resources/tester/test_b09_rate_override.py deleted file mode 100644 index 2a791f313..000000000 --- a/resources/tester/test_b09_rate_override.py +++ /dev/null @@ -1,119 +0,0 @@ -"""프로젝트 요율 덮어쓰기 — 프로젝트 단위 · 사유 저장 · 기본값↔고친 값 (PLAN 6장 새 절). - -⚠ 겨누는 것 - ① 마스터 요율판은 **안 바뀜**(덮어쓰기는 복사본에만) - ② 사유 없는 줄·틀린 주소·같은 칸 두 번은 거름 - ③ 덮어쓴 요율이 계산에 실제로 걸림 · 안 쓴 프로젝트는 값이 그대로 - ④ 요율표 칸이 기본값·지금 값·주소를 함께 싣고, 그 주소를 그대로 돌려주면 저장됨 -""" - -from __future__ import annotations - -import sys -from dataclasses import replace -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 -from B09_Estimation.B09_Estimation_RateOverride import ( # noqa: E402 - apply_overrides, - clean_overrides, -) -from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset # noqa: E402 -from B09_Estimation.B09_Estimation_RateTable import rate_sections # noqa: E402 - -BASE = CostInput( - direct_material_krw=Decimal(123_456_789), - direct_labor_krw=Decimal(234_567_891), - direct_expense_krw=Decimal(45_678_912), - estimated_price_krw=Decimal(300_000_000), # 50억 미만 구간 -) -OVERHEAD = { - "variable": "rate_overhead", - "table": "civil_landscape_industrial", - "match": {"estimated_price_bracket": "lt_5_billion"}, - "rate_percent": "7.5", - "reason": "발주처 지침(시험)", -} - - -def test_마스터는_안_바뀐다() -> None: - master = load_rate_dataset() - before = master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] - changed = apply_overrides(master, [OVERHEAD]) - assert ( - changed.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == 7.5 - ) - assert ( - master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == before - ) - assert ( - load_rate_dataset().variables["rate_overhead"]["civil_landscape_industrial"][0][ - "rate_percent" - ] - == before - ) - - -def test_덮어쓴_요율이_계산에_걸리고_안_쓰면_그대로() -> None: - plain = calculate_cost(BASE) - again = calculate_cost(replace(BASE, rate_overrides=())) - assert plain.totals == again.totals - changed = calculate_cost(replace(BASE, rate_overrides=(OVERHEAD,))) - assert changed.line("general_overhead").rate_percent == Decimal("7.5") - assert changed.amount("general_overhead") < plain.amount("general_overhead") - assert any("요율 덮어쓰기 1건" in note and "발주처 지침" in note for note in changed.notes) - - -def test_한_값짜리_요율도_덮어쓴다() -> None: - sanjae = { - "variable": "rate_sanjae", - "table": None, - "match": {}, - "rate_percent": "3.7", - "reason": "고시 개정", - } - result = calculate_cost(replace(BASE, rate_overrides=(sanjae,))) - assert result.line("industrial_accident_insurance").rate_percent == Decimal("3.7") - - -def test_사유_주소_중복을_거른다() -> None: - master = load_rate_dataset() - kept, errors = clean_overrides( - [ - {**OVERHEAD, "reason": " "}, - {**OVERHEAD, "match": {"estimated_price_bracket": "no_such"}}, - {**OVERHEAD, "rate_percent": "-1"}, - OVERHEAD, - OVERHEAD, - ], - master, - ) - assert len(kept) == 1 and kept[0]["rate_percent"] == "7.5" - assert len(errors) == 4 - assert any("사유" in e for e in errors) and any("두 번" in e for e in errors) - - -def test_주소가_안_맞으면_계산이_멈춘다() -> None: - stale = {**OVERHEAD, "match": {"estimated_price_bracket": "gone"}} - with pytest.raises(RateLookupError): - calculate_cost(replace(BASE, rate_overrides=(stale,))) - - -def test_요율표_칸이_기본값과_주소를_싣고_그대로_돌려주면_저장된다() -> None: - master = load_rate_dataset() - current = apply_overrides(master, [OVERHEAD]) - sections = rate_sections(current, master) - cells = [cell for s in sections for row in s["rows"] for cell in row if isinstance(cell, dict)] - assert cells and all({"value", "default", "overridden", "address"} <= set(c) for c in cells) - changed = [c for c in cells if c["overridden"]] - assert len(changed) == 1 and (changed[0]["value"], changed[0]["default"]) == (7.5, 8.0) - # 화면이 받은 주소 그대로 새 값·사유를 붙여 돌려주면 모두 저장 가능해야 함. - raw = [{**c["address"], "rate_percent": "1", "reason": "왕복"} for c in cells] - kept, errors = clean_overrides(raw, master) - assert not errors and len(kept) == len(cells) diff --git a/resources/tester/test_b09_rate_rules_mirror.py b/resources/tester/test_b09_rate_rules_mirror.py deleted file mode 100644 index b7d7961aa..000000000 --- a/resources/tester/test_b09_rate_rules_mirror.py +++ /dev/null @@ -1,149 +0,0 @@ -"""제비율 데이터의 **밑수·처리 규칙**이 코드와 같은가 — 거울 시험 (2026-09-09). - -요율 숫자만 데이터에 두면 **그 데이터만 보고는 금액을 되짚을 수 없다.** 밑수를 무엇으로 -잡고 어떻게 자르는지가 코드에만 있으면, 데이터를 갈아 끼운 사람이 규칙이 바뀐 줄 모른다. - -그래서 `rates_2026.json` 에 밑수(`base`)와 `processing_rules` 를 함께 싣고, **여기서 코드와 -맞대 본다.** 한쪽만 바뀌면 이 시험이 멈춘다. - -⚠ 겨누는 것 넷 - ① 비목마다 데이터가 적은 밑수와 **코드가 실제로 쓰는 밑수**가 같은가 - ② 안전관리비 두 식(A·B)의 배수 1.2 · 관급 부가세 제수 1.1 이 데이터와 같은가 - ③ 자르는 법 — 줄은 원 단위 버림, 관급자재대만 천원 올림 - ④ 밑수 칸이 **빠진 요율이 없는가** (빠지면 그 비목만 데이터로 못 되짚음) -""" - -from __future__ import annotations - -import json -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Engine_Cost import ( # noqa: E402 - CostInput, - ceil_thousand, - floor_won, -) -from B09_Estimation.B09_Estimation_Rates import load_rate_dataset # noqa: E402 -from B09_Estimation.B09_Estimation_Statutory import ( # noqa: E402 - _SAFETY_B_MULTIPLIER, - _VAT_DIVISOR, - STATUTORY_ITEMS, - ExpenseContext, - _base_and_rate, -) - -RATES = json.loads( - (ROOT / "resources" / "data_cost_input_value" / "rates_2026.json").read_text(encoding="utf-8") -) - -#: 밑수 이름 → 그 밑수를 만드는 식. 값은 서로 겹치지 않는 소수를 써서 **합이 유일**하게 한다. -MATERIAL = Decimal(1_000_003) -DIRECT_LABOR = Decimal(10_000_019) -TOTAL_LABOR = Decimal(100_000_007) -DIRECT_CONSTRUCTION = Decimal(1_000_000_007) - -_CONTEXT = ExpenseContext( - material_cost=MATERIAL, - direct_labor_cost=DIRECT_LABOR, - total_labor_cost=TOTAL_LABOR, - direct_construction_cost=DIRECT_CONSTRUCTION, - scale_reference=DIRECT_CONSTRUCTION, -) - -_BASE_VALUE = { - "total_labor_cost": TOTAL_LABOR, - "direct_labor_cost": DIRECT_LABOR, - "direct_construction_cost": DIRECT_CONSTRUCTION, - "material_cost_plus_total_labor_cost": MATERIAL + TOTAL_LABOR, -} - - -def _dataset(): - return load_rate_dataset() - - -def test_비목마다_데이터와_코드의_밑수가_같다() -> None: - """① 데이터가 적은 밑수로 실제 금액이 서는가 — 이름만 맞고 계산이 다르면 헛것이다.""" - dataset = _dataset() - data = CostInput( - direct_material_krw=MATERIAL, - direct_labor_krw=DIRECT_LABOR, - direct_expense_krw=Decimal(0), - ) - checked = 0 - for item in STATUTORY_ITEMS: - if item.key == "performance_guarantee_fee": - continue # 밑수에 공사기간(년)을 곱하는 자리 — 아래 시험에서 따로 본다 - declared = RATES["variables"][item.rate_variable].get("base") - if declared not in _BASE_VALUE: - continue # 밑수가 식으로 갈리는 비목(안전관리비·보증수수료)은 아래 시험에서 본다 - base, _percent, _note = _base_and_rate(dataset, data, _CONTEXT, item) - assert base == _BASE_VALUE[declared], f"{item.key}: 데이터 {declared} 와 코드가 다름" - checked += 1 - assert checked >= 8 - - -def test_공사이행보증은_기간을_곱한다() -> None: - """데이터가 `multiplier: 공사기간(년)` 이라 적은 자리 — 곱이 실제로 붙는가.""" - dataset = _dataset() - rules = RATES["processing_rules"]["performance_guarantee_fee"] - assert rules["base"] == "direct_construction_cost" and "공사기간" in rules["multiplier"] - item = next(i for i in STATUTORY_ITEMS if i.key == "performance_guarantee_fee") - one_year = CostInput( - direct_material_krw=MATERIAL, - direct_labor_krw=DIRECT_LABOR, - direct_expense_krw=Decimal(0), - duration_days=365, - ) - two_years = CostInput( - direct_material_krw=MATERIAL, - direct_labor_krw=DIRECT_LABOR, - direct_expense_krw=Decimal(0), - duration_days=730, - ) - base_one, _, _ = _base_and_rate(dataset, one_year, _CONTEXT, item) - base_two, _, _ = _base_and_rate(dataset, two_years, _CONTEXT, item) - assert base_one is not None and base_two is not None - assert base_two == base_one * 2 - - -def test_안전관리비_두_수가_데이터와_같다() -> None: - """② 1.2(B 배수)·1.1(관급 부가세 제수)이 코드에만 있으면 데이터로 못 되짚는다.""" - safety = RATES["processing_rules"]["safety_management_cost"] - assert Decimal(str(safety["b_multiplier"])) == _SAFETY_B_MULTIPLIER - assert Decimal(str(safety["owner_supplied_vat_divisor"])) == _VAT_DIVISOR - assert safety["adopt"] == "min_of_A_and_B" - assert RATES["variables"]["rate_safety_pct"]["base_rule"] == "min_of_two_formulas" - - -def test_자르는_법이_데이터와_같다() -> None: - """③ 줄은 버림, 관급자재대만 천원 올림.""" - rounding = RATES["processing_rules"]["rounding"] - assert rounding["line_amount"] == "floor_won" - assert rounding["owner_supplied_material_total"] == "ceil_thousand" - assert floor_won(Decimal("1999.9")) == Decimal(1999) - assert ceil_thousand(Decimal(1)) == Decimal(1000) - - -def test_밑수_칸이_빠진_요율이_없다() -> None: - """④ 하나라도 비면 그 비목만 데이터로 못 되짚는다.""" - without_base = [ - name - for name, value in RATES["variables"].items() - if isinstance(value, dict) - and "base" not in value - and not {"base_without_owner_supplied_material", "embedded_in"} & set(value) - ] - assert without_base == [], without_base - - -def test_조달수수료를_어디서도_빼지_않는다() -> None: - """★법대로 — 차감 처리는 없다. 데이터에도 그 사실을 못 박는다.""" - owner = RATES["processing_rules"]["owner_supplied_material"] - assert owner["procurement_fee_deduction"] is False - assert "순자재대" in owner["safety_base_uses"] diff --git a/resources/tester/test_b09_resource_axis_join.py b/resources/tester/test_b09_resource_axis_join.py deleted file mode 100644 index 74253c29e..000000000 --- a/resources/tester/test_b09_resource_axis_join.py +++ /dev/null @@ -1,188 +0,0 @@ -"""자원 축 조인 키 규칙 (2026-09-13 축 C 1장 · 명세 §2③·§5·§11·§17). - -지키는 것 - ① `AR-` 자원 코드 모양 · 종류 글자 · 겹침 — 읽을 때 막는다 - ② **규격이 조인 키** — 후보가 하나여도 규격이 안 맞으면 고르지 않는다(판정 Ⓑ) - ③ **형식이 하나뿐인 계열**만 규격으로 고른다 — 형식이 둘이면 「규격 미정」(판정 Ⓒ) - ④ **범위 별칭** — 범위 밖·판 다름이면 안 쓴다 · scope 없는 줄과 겹친 두 코드는 오류 - ⑤ 맞췄으나 단가 층이 없는 기계 줄은 일위대가에서 **조용히 안 빠지고 막힌다** -""" - -from __future__ import annotations - -import re - -import pytest - -from B09_Estimation.B09_Estimation_ResourceAxis import ( - AxisResult, - CatalogEntry, - ResourceAxisError, - ResourceCatalog, - build_resource_axis, - load_combined_catalog, - load_work_item_master, - match_table, -) -from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( - _read_optional, - parse_ext_entries, - parse_scoped_aliases, - resolve_family, - unmatched_reason, -) - -ALIAS = { - "axis": "resource", - "from": "화약공", - "to": "1016", - "scope": "FP-09-05", - "pum_edition": "2026-01-01", -} - - -def _table(*rows: list[str]) -> dict: - return { - "pum_table_id": "T-시험", - "pum_form": "requirement", - "basis_quantity": None, - "basis_unit": "㎥", - "raw_row": [list(row) for row in rows], - } - - -def test_자원_목록_코드_모양과_겹침(): - entries = parse_ext_entries(_read_optional("resource_catalog_ext_2026-01-01.json")["entries"]) - assert entries, "자원 목록이 비었음" - for entry in entries: - assert re.fullmatch(r"AR-[MLX]-[0-9a-f]{8}", entry.code) - assert entry.strict_spec - assert len({e.code for e in entries}) == len(entries) - with pytest.raises(ResourceAxisError): - parse_ext_entries( - [{"code": "AR-X-0000000a", "kind": "material", "name": "비료", "unit": "kg"}] - ) - with pytest.raises(ResourceAxisError): - parse_ext_entries([{"code": "M00042", "kind": "material", "name": "비료", "unit": "kg"}]) - - -def test_규격이_조인_키_후보_하나여도_자동_채택_안_함(): - catalog = ResourceCatalog( - entries=[CatalogEntry("AR-M-0000000a", "비료", "material", "복합비료", strict_spec=True)] - ) - assert catalog.resolve("비료", "") is None - assert catalog.resolve("비료", "요소") is None - assert catalog.resolve("비료", "복합 비료").code == "AR-M-0000000a" - - -def test_옆_칸_글자_규격으로_자재가_선다_규격_없으면_규격_미정(): - catalog = ResourceCatalog( - entries=[ - CatalogEntry("AR-M-0000000a", "비료", "material", "복합비료", strict_spec=True), - CatalogEntry("AR-M-0000000b", "종자", "material", "초본류", strict_spec=True), - ] - ) - result = AxisResult() - node = {"work_item_code": "FP-05-24-01"} - match_table( - node, - _table(["자재", "종 자", "", "kg", "0.025"], ["비 료", "복합비료", "kg", "0.1"]), - catalog, - result, - ) - assert [(r.resource_code, str(r.amount)) for r in result.rows] == [("AR-M-0000000a", "0.1")] - assert len(result.unmatched) == 1 - assert result.unmatched[0].reason.startswith("규격 미정 — 후보 1건") - - -def test_형식이_하나뿐인_계열만_규격으로_고른다(): - catalog = ResourceCatalog( - entries=[ - CatalogEntry("5205-0035", "공기압축기(이동식)", "machine", "3.5"), - CatalogEntry("5205-0103", "공기압축기(이동식)", "machine", "10.3"), - CatalogEntry("5205-5500", "물탱크(살수차)", "machine", "5,500"), - CatalogEntry("5210-0010", "소형브레이커(공압식)", "machine", "1.0㎥/min"), - CatalogEntry("5220-0015", "소형브레이커(전기식)", "machine", "1.0㎥/min"), - ] - ) - assert resolve_family(catalog, "공기압축기(3.5㎥/min)", ["x"]).code == "5205-0035" - assert ( - resolve_family(catalog, "공기압축기", ["공기압축기", "10.3㎥/min", "hr", "0.074"]).code - == "5205-0103" - ) - assert ( - resolve_family(catalog, "물탱크", ["물탱크", "5,500ℓ", "시간", "0.0036"]).code - == "5205-5500" - ) - assert resolve_family(catalog, "공기압축기(99㎥/min)", ["x"]) is None - # 형식이 둘 — 공압식·전기식은 다른 장비라 규격이 같아도 고르지 않는다 - assert ( - resolve_family(catalog, "소형브레이커", ["소형브레이커", "1.0㎥/min", "hr", "0.2"]) is None - ) - assert unmatched_reason(catalog, "소형브레이커").startswith("규격 미정 — 후보 2건") - - -def test_범위_별칭은_범위_안에서만_판이_다르면_안_씀(): - catalog = ResourceCatalog( - entries=[CatalogEntry("1016", "화약취급공", "labor")], - scoped_aliases=parse_scoped_aliases([ALIAS]), - ) - row = ["인력", "화 약 공", "", "인", "0.041"] - - inside = AxisResult() - match_table({"work_item_code": "FP-09-05-01"}, _table(row), catalog, inside) - assert [r.resource_code for r in inside.rows] == ["1016"] - - outside = AxisResult() - match_table({"work_item_code": "FP-12-10"}, _table(row), catalog, outside) - assert not outside.rows and outside.unmatched - - node = {"work_item_code": "FP-09-05-01", "tables": [_table(row)]} - other_edition = build_resource_axis( - {"effective_date": "2027-01-01", "work_items": [node]}, catalog - ) - assert not other_edition.rows, "판이 다른 별칭 범위를 썼음(명세 17장)" - same_edition = build_resource_axis( - {"effective_date": "2026-01-01", "work_items": [node]}, catalog - ) - assert [r.resource_code for r in same_edition.rows] == ["1016"] - - -def test_별칭_scope_없음과_겹친_두_코드는_오류(): - with pytest.raises(ResourceAxisError): - parse_scoped_aliases([{**ALIAS, "scope": ""}]) - with pytest.raises(ResourceAxisError): - parse_scoped_aliases([ALIAS, {**ALIAS, "to": "1002", "scope": "FP-09-05-01"}]) - # 범위가 안 겹치면 같은 이름이 다른 코드로 가도 된다 - assert len(parse_scoped_aliases([ALIAS, {**ALIAS, "to": "1002", "scope": "FP-12-10"}])) == 2 - - -@pytest.fixture(scope="module") -def real_axis(): - return build_resource_axis(load_work_item_master(), load_combined_catalog()) - - -def test_실데이터_자재가_서고_규격_없는_종자는_규격_미정(real_axis): - materials = { - (r.work_item_code, r.resource_name) for r in real_axis.rows if r.resource_kind == "material" - } - assert ("FP-05-24-01", "비료") in materials - assert ("FP-12-10", "유공관") in materials - seed = [u for u in real_axis.unmatched if u.work_item_code == "FP-05-24-01" and "종" in u.cell] - assert seed and seed[0].reason.startswith("규격 미정") - blasting = { - (r.resource_code, r.resource_kind) - for r in real_axis.rows - if r.work_item_code == "FP-09-05-01" - } - assert ("1016", "labor") in blasting - - -def test_단가_층_없는_기계_줄은_조용히_안_빠지고_막힌다(): - from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices - - build = build_unit_prices() - # 착암기는 카탈로그 밖(AR-X) — 맞췄으나 기계 층이 없어 단가를 막아야 한다 - assert "착암기" in (build.component_gaps.get("FP-09-05-01") or "") - assert "FP-09-05-01" in build.partial_ratio - assert any("유공관" in label for label in build.unattached.get("FP-12-10", [])) diff --git a/resources/tester/test_b09_rock_loss.py b/resources/tester/test_b09_rock_loss.py deleted file mode 100644 index 38e11143e..000000000 --- a/resources/tester/test_b09_rock_loss.py +++ /dev/null @@ -1,79 +0,0 @@ -"""암석 작업 기계손료 보정 — 건설품셈 8-1-7 1 (2026-09-14 브레인 661 ①②). - -원문: 「암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는 손료(관리비 제외)를 보정 가산」 - 불도저(19톤 이상 제외) 25 · 굴착기(무한궤도) 및 로더(무한궤도) 20 · 덤프트럭 25 (%) - [주]① 전용덤프트럭(18톤 이상)과 불도저(19톤 이상)는 보정하지 않음(타이어·습지 불도저는 보정) -실무 봉화 2024 중기목록 「(암석)」 줄 셋이 (상각 + 정비) × (1 + 가산) + 관리 로 역산됨 — -굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679. B09 는 이 보정을 한 번도 안 걸고 있었음. -⚠ 브레이커 조합 본체(`#조합`)는 안 걺 — 봉화 「굴삭기 0.7 브레이커조합」 손료 = 비암석 23,128 + 브레이커. -⚠ 전석섞인토사 10% 는 혼입율 입력이 없어 안 걺(② · 칸도 안 만듦). -""" - -from __future__ import annotations - -from decimal import ROUND_FLOOR, Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -HAUL = ("164.23",) - - -def _rows(book, code: str) -> list: - """그 제목과 제 단가산출(D) 줄.""" - own = book.details.get(code) or [] - return [ - *own, - *(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]), - ] - - -def _machines(book, code: str) -> set[str]: - """그 제목과 제 단가산출(D) 줄이 부르는 기계 호표.""" - return {r.ref_code for r in _rows(book, code) if r.ref_code.startswith("X-")} - - -def test_실무_암석_손료계수_셋이_원천표로_역산됨() -> None: - from B09_Estimation.B09_Estimation_RockLoss import rock_coefficient - - # 실무 표기는 취득가 천원당 — 우리 계수는 원당(× 1000 이 실무 값) - assert rock_coefficient("0201-0100") * 1000 == Decimal("0.2405") # 굴착기 1.0 (900+700)×1.2+485 - assert rock_coefficient("0602-0025") * 1000 == Decimal("0.3533") # 덤프 2.5 3533.75 버림 - assert rock_coefficient("0602-0150") * 1000 == Decimal("0.2679") # 덤프 15 - assert rock_coefficient("0101-0019") is None # 불도저 19톤 — [주]① 보정 안 함 - assert rock_coefficient("0602-0240") is None # 덤프 24톤 — [주]① 18톤 이상 - assert rock_coefficient("0211-0060") is None # 굴착기(타이어) — 표에 없음 - - -def test_암_공종의_대상_기계는_암석_호표를_부름() -> None: - book = cached_build(dump_haul_m=HAUL).book - assert "X-0201-0070#암석" in _machines(book, "B-FP-10-12-02#적재") - assert "X-0602-0150#암석" in _machines(book, "B-FP-10-12-02#L164.23m") - assert "X-0602-0150#암석" in _machines(book, "B-FP-10-12-03#L164.23m") - assert "X-0201-0070#암석" in _machines(book, "B-FP-09-04-02") # 암절취 집토 - assert "X-0201-0070#암석" in _machines(book, "B-FP-09-19-02") # 비탈면 면고르기(암절취) - - -def test_토사_공종과_브레이커_조합_본체는_그대로() -> None: - book = cached_build(dump_haul_m=HAUL).book - assert _machines(book, "B-FP-10-12-01#적재") == {"X-0201-0070"} - assert "X-0602-0150" in _machines(book, "B-FP-10-12-01#L164.23m") - assert "X-0201-0070#조합" in _machines(book, "B-FP-09-04-01#연암") - assert not any(m.endswith("#암석") for m in _machines(book, "B-FP-09-19-01#절토면·풍화암")) - - -def test_불도저_19톤_발파암_운반은_보정_안_하고_까닭을_남김() -> None: - book = cached_build(dump_haul_m=HAUL).book - rows = [r for r in _rows(book, "B-FP-10-11#발파암") if r.ref_code == "X-0101-0019"] - assert rows and "8-1-7" in rows[0].note and "19톤" in rows[0].note, rows - - -def test_암석_호표_손료는_취득가_곱하기_보정_계수() -> None: - from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog - - book = cached_build(dump_haul_m=HAUL).book - price = load_machine_catalog().machines["0201-0070"].price_thousand_krw * 1000 - rock = book.resolve("X-0201-0070#암석").expense - plain = book.resolve("X-0201-0070").expense - assert rock == (price * Decimal("0.0002405")).quantize(Decimal(1), rounding=ROUND_FLOOR) - assert plain == (price * Decimal("0.0002085")).quantize(Decimal(1), rounding=ROUND_FLOOR) - assert book.resolve("X-0201-0070#암석").labor == book.resolve("X-0201-0070").labor diff --git a/resources/tester/test_b09_safety_notice.py b/resources/tester/test_b09_safety_notice.py deleted file mode 100644 index b749fe02e..000000000 --- a/resources/tester/test_b09_safety_notice.py +++ /dev/null @@ -1,206 +0,0 @@ -"""산업안전보건관리비 — **고시 원문과 직접 대조**(2026-09-14 편입). - -원문 `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 및 사용기준/` - `현행_20250212.md`(제3조·제4조) · `별표/별표1_…계상기준표.md` - -⚠ 값을 여기서 짓지 않음 — **별표1 표를 원문에서 읽어** 우리 요율 데이터와 견줌. - 요율표가 개정되면 원문과 데이터가 함께 바뀌어야 이 벌이 초록으로 남음. - -아직 안 한 것(2026-09-14 실측 · 배정 대기) — 여기서 **안 재는 자리**임을 밝혀 둠 - · 제3조 「총공사금액 2천만원 이상」 적용 하한을 엔진이 안 읽음 - (요율 데이터엔 `minimum_total_construction_amount_krw` 가 있음 · 임도에선 안 걸림) - · 제4조①3호 「대상액이 명확하지 않은 경우 = 총공사금액의 10분의 7」 미구현 - · 관급 ÷1.1 은 고시에 없는 **실무 해석**(실무 원본 6건이 「관급재/1.1」로 적음) - · 보건관리자 선임 문턱(800억·토목 1,000억)은 고시 밖(산업안전보건법 시행령 별표5, 원문 미보유) -""" - -from __future__ import annotations - -import io -import json -import pathlib -from decimal import ROUND_FLOOR, Decimal - -import pytest - -from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost - -ROOT = pathlib.Path(__file__).resolve().parents[2] -NOTICE = ( - ROOT - / "resources" - / "knowledge" - / "original" - / "행정규칙" - / "건설업 산업안전보건관리비 계상 및 사용기준" -) -RATES = ROOT / "resources" / "data_cost_input_value" / "rates_2026.json" - -#: 별표1 행 이름 → 요율 데이터 공종 키. -_WORK_TYPES = { - "건 축 공 사": "building", - "토 목 공 사": "civil", - "중 건 설 공 사": "heavy_construction", - "특 수 건 설 공 사": "special_construction", -} -#: 별표1 칸 차례 → 요율 데이터 구간 이름. 기초액은 **가운데 구간에만** 붙음(제4조①1·2호). -_BRACKETS = ( - "lt_500_million", - "500_million_to_5_billion", - "gte_5_billion_below_manager_threshold", - "gte_5_billion_at_or_above_manager_threshold", -) - - -def _table() -> dict[str, tuple[list[Decimal], Decimal]]: - """별표1 표 → `{공종: ([네 구간 율], 기초액)}`. 원문이 없으면 빈 표.""" - path = NOTICE / "별표" / "별표1_공사종류 및 규모별 산업안전보건관리비 계상기준표.md" - if not path.exists(): - return {} - rows: dict[str, tuple[list[Decimal], Decimal]] = {} - for line in io.open(path, encoding="utf-8").read().splitlines(): - cells = [c.strip() for c in line.strip().strip("|").split("|")] - if cells and cells[0] in _WORK_TYPES: - percents = [Decimal(cells[i].replace("%", "")) for i in (1, 2, 4, 5)] - rows[_WORK_TYPES[cells[0]]] = ( - percents, - Decimal(cells[3].replace("원", "").replace(",", "")), - ) - return rows - - -def _floor(value: Decimal) -> Decimal: - return value.quantize(Decimal(1), rounding=ROUND_FLOOR) - - -def _run(material: Decimal, labor: Decimal, **over) -> object: - """안전관리비 한 줄만 세워 돌림 — 규모 구간은 못 박아 이 벌 밖으로 뺌.""" - fields = dict( - direct_material_krw=material, - direct_labor_krw=labor, - direct_expense_krw=Decimal(0), - owner_supplied_includes_vat=False, - enabled_items=("safety_management_cost",), - estimated_price_krw=Decimal(1_000_000_000), - cut_basis="none", - ) - fields.update(over) - return calculate_cost(CostInput(**fields)) - - -def test_별표1_요율표가_고시_원문과_전수_같다() -> None: - """별표1 **16칸**(4 공종 × 4 열) — 율과 기초액이 원문과 한 자도 안 갈림.""" - table = _table() - if not table: - pytest.skip("고시 원문이 없음") - ours = { - (row["work_type"], row["target_amount_bracket"]): ( - Decimal(str(row["rate_percent"])), - Decimal(str(row.get("base_amount_krw", 0))), - ) - for row in json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"][ - "brackets" - ] - } - misses = [] - for work_type, (percents, flat) in table.items(): - for index, bracket in enumerate(_BRACKETS): - # 제4조①1호 — 5억 미만·50억 이상은 **기초액 없이** 율만 곱함. - want = (percents[index], flat if bracket == "500_million_to_5_billion" else Decimal(0)) - if ours.get((work_type, bracket)) != want: - misses.append((work_type, bracket, want, ours.get((work_type, bracket)))) - assert len(table) == 4 and not misses, misses - - -@pytest.mark.parametrize( - ("label", "target", "percent", "flat"), - [ - ("5억 바로 아래", Decimal(499_999_999), Decimal("3.15"), Decimal(0)), - ("정확히 5억", Decimal(500_000_000), Decimal("2.53"), Decimal(3_300_000)), - ("50억 바로 아래", Decimal(4_999_999_999), Decimal("2.53"), Decimal(3_300_000)), - ("정확히 50억", Decimal(5_000_000_000), Decimal("2.60"), Decimal(0)), - ], -) -def test_제4조_구간_경계가_고시대로다(label, target, percent, flat) -> None: - """제4조①1·2호 — 「5억 미만 **또는** 50억 이상」은 기초액이 없고, 그 사이만 기초액을 더함.""" - result = _run(target / 2, target / 2) - line = result.line("safety_management_cost_a") - assert (line.rate_percent, line.flat_amount_krw) == (percent, flat), label - assert result.amount("safety_management_cost") == _floor(target * percent / 100 + flat), label - - -def test_제4조_단서_A와_B의_1점2배_중_작은_쪽() -> None: - """단서 — 관급 포함(A)과 관급 제외 × 1.2(B) 를 견줘 **작은 쪽**. - - A·B 가 **서로 다른 구간**에 떨어지는 자리로 잼 — A 대상액 5억(2호) · B 대상액 4억(1호). - """ - result = _run( - Decimal(200_000_000), Decimal(200_000_000), owner_supplied_material_krw=Decimal(100_000_000) - ) - want_a = _floor(Decimal(500_000_000) * Decimal("2.53") / 100 + 3_300_000) - want_b = _floor(_floor(Decimal(400_000_000) * Decimal("3.15") / 100) * Decimal("1.2")) - got = (result.amount("safety_management_cost_a"), result.amount("safety_management_cost_b")) - assert got == (want_a, want_b) - assert result.amount("safety_management_cost") == min(got) - - -def test_1점2배는_호별_산정액을_먼저_원_단위로_맺은_뒤_곱한다() -> None: - """⭐ 단서 문언 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임(2026-09-14 고침). - - 골든셋 실증 — 영월 2024 B 줄이 종전 20,330,639(먼저 곱하고 한 번 버림) · - 원본·고침 뒤 20,330,638. 끝자리가 살아 있는 밑수로 두 셈법이 갈리는 것을 여기서 잼. - """ - base = Decimal(123_456_789) # × 3.15 % = 3,888,888.85… — 버림 자리가 살아 있음 - result = _run(base / 2, base / 2) - raw = base * Decimal("3.15") / 100 - assert _floor(raw) * Decimal("1.2") != raw * Decimal("1.2") # 두 셈법이 실제로 갈리는 밑수 - assert result.amount("safety_management_cost_b") == _floor(_floor(raw) * Decimal("1.2")) - - -def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다() -> None: - """제2조2호 — 대상액 = 직접재료비 + 간접재료비 + 직접노무비(발주자 제공 재료 포함).""" - plain = _run(Decimal(100_000_000), Decimal(100_000_000)) - indirect = _run( - Decimal(100_000_000), Decimal(100_000_000), indirect_material_krw=Decimal(50_000_000) - ) - owner = _run( - Decimal(100_000_000), Decimal(100_000_000), owner_supplied_material_krw=Decimal(30_000_000) - ) - base = plain.line("safety_management_cost_a").base_amount_krw - assert base == Decimal(200_000_000) - assert indirect.line("safety_management_cost_a").base_amount_krw == base + 50_000_000 - assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000 - # 관급 제외 밑수(B)는 관급이 들어도 안 움직임. - assert owner.line("safety_management_cost_b").base_amount_krw == base - - -def _scaled(estimated_price: Decimal) -> object: - """총공사금액(규모 기준액)만 갈아 끼워 돌림 — 대상액은 구간이 안 갈리게 작게 둠.""" - return calculate_cost( - CostInput( - direct_material_krw=Decimal(5_000_000), - direct_labor_krw=Decimal(5_000_000), - direct_expense_krw=Decimal(0), - enabled_items=("safety_management_cost",), - estimated_price_krw=estimated_price, - cut_basis="none", - ) - ) - - -def test_제3조_총공사금액_2천만원_미만이면_줄이_안_선다() -> None: - """제3조 — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 들고 있음. - - ⚠ 우리가 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)임. - 고시의 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 - 안전관리비 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 씀 — 보건관리자 문턱도 같은 축. - """ - minimum = json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"].get( - "minimum_total_construction_amount_krw" - ) - assert minimum == 20_000_000, minimum - assert not _scaled(Decimal(minimum) - 1).has("safety_management_cost") - assert _scaled(Decimal(minimum)).has("safety_management_cost") - # 안 서면 A·B 곁줄도 안 선다 — 0 원으로 채우지 않음. - below = _scaled(Decimal(minimum) - 1) - assert not below.has("safety_management_cost_a") and not below.has("safety_management_cost_b") diff --git a/resources/tester/test_b09_seed_spray.py b/resources/tester/test_b09_seed_spray.py deleted file mode 100644 index 52a9f8abb..000000000 --- a/resources/tester/test_b09_seed_spray.py +++ /dev/null @@ -1,76 +0,0 @@ -"""초류종자살포 5-24-1·2 뿜어붙이기 — 2026-09-15 브레인 판정 ①②′④ (③ 물탱크는 `test_b09_water_tank_operating`). - -산림 5-24 표(㎡당): 종자 0.025 · 복합비료 0.1 · 화이버 0.18 · 합성접착제 0.1 · 색소 0.002 kg · - 종자살포기 2,500-3,000ℓ 0.0024h · 트럭 4.5ton 0.0024h · 물탱크 5,500ℓ 0.0036h · 조경공 0.0007 · 보통인부 0.0004 - [주]⑤ 「건설공사 표준품셈 공통부문 4-1-2 초류종자 살포」 참조 -① 트럭 4.5ton → 덤프트럭 4.5(0602-0045) — 카탈로그에 「트럭」 없음(원문 애매) · 거창 실무 「덤프트럭 (4.5Ton)」 -②′ 종자살포기 → 취부기 11.94㎾(7750-0016) · **손료만** — 원문 규격은 ℓ(탱크 용량) · 건설 4-1-3 「취부기 11.94kW」 · - 거창 실무 「종자살포기 : 취부기(녹생토)」 · 8-4 운전경비표에 7750 줄 없음(원문 없음) · 거창도 손료 20,295 만 - 디젤엔진 11.19㎾ 는 거창만 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣고 어긋남만 기록 -④ 종자 규격 후보 넷(생태복원형 초본·목본 · 초본류 · 목본류) — 화약류 길: 「자재 단가」 칸 · 넣은 쪽 하나가 붙음 -""" - -from __future__ import annotations - -from decimal import ROUND_FLOOR, Decimal - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -CODES = ("FP-05-24-01", "FP-05-24-02") -SEEDS = ("AR-M-f89c57d4", "AR-M-d52b8fc1", "AR-M-8f852514", "AR-M-e39ee36e") - - -def _rows(book, code: str) -> dict[str, Decimal]: - return {r.ref_code: r.quantity for r in book.details[f"B-{code}"]} - - -def test_트럭은_덤프트럭_종자살포기는_취부기_물탱크까지_표_시간대로() -> None: - build = cached_build() - for code in CODES: - rows = _rows(build.book, code) - assert rows["X-0602-0045"] == Decimal("0.0024"), rows - assert rows["X-7750-0016"] == Decimal("0.0024"), rows - assert rows["X-7204-0055"] == Decimal("0.0036"), rows - left = " ".join(build.unattached.get(code, [])) - assert "트 럭" not in left and "종자살포기" not in left, left - assert code not in build.component_gaps, build.component_gaps.get(code) - - -def test_취부기는_손료만으로_서고_사유가_붙음() -> None: - build = cached_build() - assert not any(m.startswith("7750-0016") for m in build.incomplete_machines) - money = build.book.resolve("X-7750-0016") - assert money.material == 0 and money.labor == 0 - assert ( - money.expense.quantize(Decimal(1), rounding=ROUND_FLOOR) == 20295 - ) # 거창 46,603,000 × 4,355 - notes = " ".join(r.note for r in build.book.details["X-7750-0016"]) - assert "8-4" in notes and "거창" in notes, notes - sheet = next(s for s in machine_expense_sheets(build) if s["code"] == "X-7750-0016") - assert sheet["gaps"] == [] and "8-4" in sheet["attachment_note"], sheet - - -def test_종자는_후보_넷_칸_넣은_하나만_붙고_둘이면_사유() -> None: - none = cached_build() - for code in CODES: - assert all(code in none.material_uses.get(seed, []) for seed in SEEDS) - left = " ".join(none.unattached.get(code, [])) - assert "종자 — 규격 미정" in left and "종 자" not in left, left - one = cached_build(material_prices=(("AR-M-8f852514", "20000", "견적"),)) - assert _rows(one.book, "FP-05-24-01")["AR-M-8f852514"] == Decimal("0.025") - assert "종자" not in " ".join(one.unattached.get("FP-05-24-01", [])) - two = cached_build( - material_prices=(("AR-M-8f852514", "20000", "견적"), ("AR-M-e39ee36e", "30000", "견적")) - ) - rows = _rows(two.book, "FP-05-24-01") - assert "AR-M-8f852514" not in rows and "AR-M-e39ee36e" not in rows - assert "하나만" in " ".join(two.unattached.get("FP-05-24-01", [])) - - -def test_사유는_어긋남을_나란히() -> None: - note = known_gap_note("FP-05-24-01") - for word in ("트럭", "덤프트럭", "ℓ", "취부기", "손료만", "디젤엔진"): - assert word in note, (word, note) - assert known_gap_note("FP-05-24-02") == note diff --git a/resources/tester/test_b09_silent_machine_rows.py b/resources/tester/test_b09_silent_machine_rows.py deleted file mode 100644 index c690ae9e4..000000000 --- a/resources/tester/test_b09_silent_machine_rows.py +++ /dev/null @@ -1,43 +0,0 @@ -"""말없이 버려지던 기계·연료 줄 — 2026-09-15 브레인 ㉠(인력만으로 싸게 서던 공종 막기). - -자원 축 줄 읽기가 세 자리에서 기계·연료 줄을 「못 붙은 줄」 목록에도 안 올리고 버렸음 → -그 공종 단가가 인력만으로 **막히지도 사유도 없이** 싸게 섬(사용자는 값이 맞는 줄 앎). - ① 규격이 이름 앞 「0.8㎥ 굴착기 | h | 0.25」(14-2 · 14-1) — 숫자로 시작해 머리글로 걸러짐 - ② 값·이름 둘 다 못 읽음 「디젤엔진(15HP) | 11.19kW」(12-26) · 「연료비 | 소요량 적용 | ps × 0.253ℓ × 6h」(10-7-4) - ③ 수량 빈 연료 줄 「연료비 | 경유 | L | 」(10-8-3) — 숫자 없는 줄은 머리 줄로 봄 -⇒ 기계·연료로 보이는 줄은 「못 맞춤 + 일부만」 — 틀린 금액보다 안 선 금액(사유 또렷이). -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def test_기계_연료_줄이_안_붙은_다섯_공종은_일부만으로_막히고_사유() -> None: - build = cached_build() - # 14-1·14-2 는 뒤이어 시간 줄 판정표가 읽어 풀림(`test_b09_hour_rows` · 2026-09-15 ㉡-3) - for code in ("FP-12-26", "FP-10-08-03", "FP-10-07-04"): - assert code in build.partial_ratio, code - assert "기계·연료 줄" in (build.component_gaps.get(code) or ""), ( - code, - build.component_gaps.get(code), - ) - left = " ".join(build.unattached.get("FP-12-26", [])) - assert "디젤엔진" in left and "양수기" in left, left - assert "FP-14-02" not in build.partial_ratio and "FP-14-01" not in build.partial_ratio - - -def test_머리_줄과_인력만인_정상_공종은_안_막힘() -> None: - build = cached_build() - for code in ("FP-09-15-02", "FP-12-12", "FP-04-02-02", "FP-09-03-02"): - assert code not in build.partial_ratio, code - - -def test_파쇄_모듈이_쓰는_파쇄기_줄은_막힘으로_안_남음() -> None: - build = cached_build() - assert "FP-08-11" not in build.partial_ratio - assert "기계·연료 줄" not in (build.component_gaps.get("FP-08-11") or "") - assert not any( - "파쇄기 " in label or label == "이동식 임목 파쇄기" - for label in build.unattached.get("FP-08-11", []) - ) diff --git a/resources/tester/test_b09_similar_occupation.py b/resources/tester/test_b09_similar_occupation.py deleted file mode 100644 index 42a645ee1..000000000 --- a/resources/tester/test_b09_similar_occupation.py +++ /dev/null @@ -1,48 +0,0 @@ -"""유사 직종 바꿔쓰기 — 야면석 채집 「인 부」 → 보통인부 (2026-09-13 · 사용자 확정 8-1). - -⚠ 겨누는 것 셋 - ① 야면석 채집(13-2-4)의 「인 부」가 **못 붙은 줄에서 빠지고** 보통인부 노임으로 붙는다 - ② 바꿔쓰기는 **그 공종에만** 걸린다 — 전역 별칭이 아니다 - ③ 산출기초에 **준용 근거**가 함께 뜬다(조용히 바꾸지 않음) -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_BasisSheet import work_item_basis # noqa: E402 -from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import ( # noqa: E402 - SIMILAR_OCCUPATION, - similar_occupation_note, -) -from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 - -CODE = "FP-13-02-04" - - -def test_인부가_보통인부로_붙는다() -> None: - build = cached_build() - assert "인 부" not in build.unattached.get(CODE, []) - titles = [code for code in build.book.titles if code.startswith(f"B-{CODE}#")] - # 뒷길이 다섯 × 밑수 둘(㎡당·㎥당) - assert len(titles) == 10, titles - for code in titles: - names = {build.book.title(d.ref_code).name for d in build.book.details.get(code, [])} - assert "보통인부" in names, (code, names) - assert build.book.resolve(code).labor > 0 - - -def test_바꿔쓰기는_그_공종에만_걸린다() -> None: - assert {code for code, _ in SIMILAR_OCCUPATION} == {CODE} - assert similar_occupation_note("FP-12-10") == "" - - -def test_산출기초에_준용_근거가_뜬다() -> None: - rows = [row for row in work_item_basis(cached_build()) if row["code"].startswith(f"B-{CODE}#")] - assert rows - for row in rows: - assert any("보통인부 준용" in note and "45101-45" in note for note in row["notes"]), row diff --git a/resources/tester/test_b09_spec_column_table.py b/resources/tester/test_b09_spec_column_table.py deleted file mode 100644 index 270a8ba65..000000000 --- a/resources/tester/test_b09_spec_column_table.py +++ /dev/null @@ -1,62 +0,0 @@ -"""규격이 열로 선 표(관부설 12-11) — 규격 칸·비고를 읽어 줄은 서고 못 넣는 줄은 까닭으로 드러남. - -2026-09-14 축 C 1장 ② (브레인 차례 · 판정 불필요). 원문 = 산림사업 표준품셈 12-11-1·2·3. - ① 원문 비고 「별산」·「별도계산」·「설계수량」(〃 · 병합 칸 포함)은 **안 넣는 줄**로 까닭을 남김 - ② 규격 칸(「1:2」)과 관경 열(「∅800mm」)이 조인 키 — 접합몰탈·고무링이 자재 줄로 섬 - ③ 기종이 여럿인 크레인은 **고르지 않고** 「규격 미정 · 원문 규격 10ton」 - ④ 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_ResourceAxis import ( # noqa: E402 - build_resource_axis, - load_combined_catalog, - load_work_item_master, -) - -AXIS = build_resource_axis(load_work_item_master(), load_combined_catalog()) - - -def _unmatched(code: str) -> dict[str, str]: - return {u.cell: u.reason for u in AXIS.unmatched if u.work_item_code == code} - - -def _rows(code: str) -> list: - return [r for r in AXIS.rows if r.work_item_code == code] - - -def test_원문_비고대로_안_넣는_줄은_까닭을_남긴다() -> None: - vr = _unmatched("FP-12-11-01") - assert "별도계산" in vr["VR관"] and "설계수량" in vr["기초콘크리트"] - assert "설계수량" in vr["거 푸 집"] # 「〃」 - hume = _unmatched("FP-12-11-02") - assert "별산" in hume["흄 관"] and "설계수량" in hume["거 푸 집"] # 병합 칸 - corrugated = _unmatched("FP-12-11-03") - assert "필요시적용" in corrugated["커플링밴드"] and "설계수량" in corrugated["모래부설"] - - -def test_규격_칸과_관경_열이_조인_키() -> None: - rings = { - r.variant: r.resource_spec for r in _rows("FP-12-11-01") if r.resource_name == "고무링" - } - assert rings == {"∅800mm": "∅800mm", "∅1000mm": "∅1000mm", "∅1200mm": "∅1200mm"} - mortar = [r for r in _rows("FP-12-11-02") if r.resource_name == "접합몰탈"] - assert {r.resource_spec for r in mortar} == {"1:2"} and len(mortar) == 3 - - -def test_크레인은_고르지_않고_원문_규격을_보인다() -> None: - reason = _unmatched("FP-12-11-03")["크레인"] - assert "규격 미정" in reason and "원문 규격 5ton" in reason - assert not any(r.resource_name.startswith("크레인") for r in _rows("FP-12-11-03")) - - -def test_절단기_40_64cm_는_카탈로그_40_64() -> None: - cutters = [r for r in _rows("FP-12-11-02") if r.resource_name == "절단기"] - assert [(r.resource_code, str(r.amount)) for r in cutters] == [("7620-0003", "0.93")] diff --git a/resources/tester/test_b09_structure_price_link.py b/resources/tester/test_b09_structure_price_link.py deleted file mode 100644 index 75b839067..000000000 --- a/resources/tester/test_b09_structure_price_link.py +++ /dev/null @@ -1,139 +0,0 @@ -"""구조물도 일위대가 → B09 내역 받는 문 (2026-09-13, PLAN 6장 첫 일감 ② · 브레인 판정). - -겨누는 것 - ① 양식 일위대가로 셀 구조물은 인계 줄 하나 = 코드 AX-ST · 갈래 = 제원 키 · 수량 = 연장 m - ② 양식이 품은 기초잡석은 따로 인계 안 함(이중계상) — 양식 장이 없으면 종전대로 섬 - ③ 제원 키는 제목 글이 아니라 제원 값 — 연장이 달라도 같은 키 · 뒷길이가 다르면 다른 키 - ④ B09 내역 — 호표 금액 × 연장 · 금액 못 받으면 사유 · 수동 단가는 미확정 -""" - -from __future__ import annotations - -from decimal import Decimal -from functools import lru_cache - -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff -from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import ( - price_sheets, - priced_sheets, - structure_price_code, -) -from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets -from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates, load_template -from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table -from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill -from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices, find_variant_code - - -@lru_cache(maxsize=1) -def _build(): - return build_unit_prices() - - -def _wall(sid: str, start: float, end: float, back: int = 45) -> dict: - return StructureInstance.model_validate( - { - "structure_id": sid, - "type_id": "masonry_wet", - "placement": "interval", - "start_m": start, - "end_m": end, - "options": {"height_m": 2.5, "back_len_cm": back}, - } - ).model_dump() - - -def _chain(walls: list[dict]) -> tuple[dict, list[dict]]: - table = build_table(walls, {"masonry_wet": "돌쌓기(찰)"}, {}, {}, None) - payload = build_standard_sheets(table, {}) - apply_templates(payload, {}, {}) - entries = priced_sheets(payload["sheets"], {"masonry_wet": load_template("masonry_wet")}) - return table, entries - - -def _rows(handoff: dict, prefix: str) -> list[dict]: - return [ - r for r in handoff["work_items"] if str(r.get("work_item_code") or "").startswith(prefix) - ] - - -def test_양식_장_구조물은_호표_줄_하나로_가고_기초잡석은_따로_안_간다() -> None: - table, entries = _chain([_wall("a", 0, 10), _wall("b", 50, 56)]) - assert len(entries) == 1 and entries[0]["structure_ids"] == ["a", "b"] - assert "기초잡석" in entries[0]["covered"] and "모르터" in entries[0]["covered"] - - before = build_handoff(unit_quantity_table=table) - assert _rows(before, "FP-13-04-05") and _rows(before, "FP-12-25") # 종전 — 돌쌓기 ㎡ + 잡석 - - after = build_handoff(unit_quantity_table=table, priced_sheets=entries) - ax = _rows(after, "AX-ST-") - assert [(r["unit"], r["quantity"]) for r in ax] == [("m", 10.0), ("m", 6.0)] - assert {r["variant_axis"] for r in ax} == {"structure_sheet"} - assert not _rows(after, "FP-13-04-05") and not _rows(after, "FP-12-25") - assert set(ax[0]) == set(before["work_items"][0]) # 인계 칸 계약 그대로 - - -def test_제원_키는_제원_값이고_연장과_제목은_안_든다() -> None: - _table, entries = _chain([_wall("a", 0, 10), _wall("c", 20, 30, back=55)]) - keys = {e["structure_ids"][0]: e["key"] for e in entries} - assert "L3=45" in keys["a"] and "L3=55" in keys["c"] and "|L=" not in keys["a"] - assert keys["a"] != keys["c"] - assert structure_price_code("AX-ST-56e81a2c", "A = 1 | B~2").endswith("#A=1|B~2") - - -def _bill(manual: dict | None = None): - build = _build() - table, entries = _chain([_wall("a", 0, 10)]) - handoff = build_handoff(unit_quantity_table=table, priced_sheets=entries) - prices = price_sheets( - entries, - build.book, - lambda code, value: find_variant_code(code, value, build), - [load_template("masonry_wet")], - manual, - ) - return entries, prices, build_bill(handoff, build=build, structure_prices=prices) - - -def test_내역은_호표_금액에_연장을_곱한다() -> None: - entries, prices, bill = _bill() - ref = structure_price_code(entries[0]["code"], entries[0]["key"]) - entry = prices[ref] - row = next(r for r in bill.rows if r.code == entries[0]["code"]) - # 모르터 공종 코드가 선 뒤(랩탑 메인 AX-WK) 찰쌓기 호표 세 줄이 다 섬. - assert not entry["blocked"], entry["reasons"] - # 단가 = 구조물도 일위대가 표 계금 그대로(화면과 한 값) · 금액 = 성분 소계 × 연장. - from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table - - table = unit_price_table( - entries[0]["template"], - entries[0]["sheet"], - _build().book, - lambda code, value: find_variant_code(code, value, _build()), - [load_template("masonry_wet")], - ) - assert row.unit_price_krw == Decimal(str(table["total"])) - cells = sum(Decimal(str(table[k])) for k in ("material", "labor", "expense")) - assert row.amount_krw == round_at(cells * Decimal(10), OutputPlace.BOQ_ROW) - assert row.quantity == Decimal(10) and f"호표 {ref}" in row.note - - -def test_금액을_못_받으면_사유로_서고_수동_단가는_미확정으로_센다() -> None: - build = _build() - table, entries = _chain([_wall("a", 0, 10)]) - handoff = build_handoff(unit_quantity_table=table, priced_sheets=entries) - missing = build_bill(handoff, build=build, structure_prices={}) - row = next(r for r in missing.rows if r.code == entries[0]["code"]) - assert row.amount_krw is None and "못 받음" in row.note - - # 모든 일위대가 줄에 수동 단가 — 막힘 없이 서고 미확정 3건. - manual = { - "masonry_wet": {str(s): {"material": 1000, "labor": 0, "expense": 0} for s in (1, 2, 3)} - } - _entries, _prices, bill = _bill(manual) - row = next(r for r in bill.rows if r.code == entries[0]["code"]) - assert row.amount_krw is not None and "미확정" in row.note - summary = bill_summary(bill) - assert summary["unconfirmed_count"] == 3 and summary["unconfirmed"][0]["count"] == 3 diff --git a/resources/tester/test_b09_table_reading.py b/resources/tester/test_b09_table_reading.py deleted file mode 100644 index 521436676..000000000 --- a/resources/tester/test_b09_table_reading.py +++ /dev/null @@ -1,239 +0,0 @@ -"""표 읽기 (2026-09-13 축 C 1장 · 브레인 차례 ②). - -지키는 것 - ① 밑수가 **절 이름에만** 있는 표는 이름 전체가 「수+단위+당」일 때만 뽑는다(4-2-2 「1,000㎡당」) - ② 뭉친 줄: 「굴 삭 기 (무한궤도)」 표기 맞추기 · 비고 줄 거름 · 「-」 칸은 그 갈래에 품 없음 - ③ 조합 기종 「굴착기+부착용집게」는 공백이 달라도 카탈로그 「부착용 집게」에 붙는다 - ④ 형식을 안 적은 「굴 삭 기」는 잠정 우선순위로 고르지 않는다(판정 Ⓒ) - ⑤ 실무값 대조 — 단목베기 5m 미만이 영월 설계내역 「잡관목제거 벌목(5m미만)」 882원/㎡ 근처로 선다 -""" - -from __future__ import annotations - -from decimal import Decimal - -from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_name -from B09_Estimation.B09_Estimation_ResourceAxis import ( - AxisResult, - CatalogEntry, - ResourceCatalog, - match_table, -) - - -def test_절_이름_밑수는_이름_전체가_밑수일_때만(): - assert basis_from_name("1,000㎡당") == (1000.0, "㎡") - assert basis_from_name("100본당") == (100.0, "본") - assert basis_from_name("나무주사(100본당)") == (None, None) - assert basis_from_name("자재의 1회당 표준 운반량") == (None, None) - - -def _packed(*rows: list[str]) -> dict: - return { - "pum_table_id": "T-시험", - "pum_form": "requirement", - "basis_quantity": 10.0, - "basis_unit": "㎡", - "raw_row": [list(row) for row in rows], - } - - -MACHINES = [ - CatalogEntry("0201-0080", "굴착기(무한궤도)", "machine", "0.8"), - CatalogEntry("0211-0080", "굴착기(타이어)", "machine", "0.8"), - CatalogEntry("0201-0020", "굴착기(무한궤도)", "machine", "0.2"), - CatalogEntry("7206-0020", "부착용 집게", "machine", "0.2"), - CatalogEntry("7206-0100", "부착용 집게", "machine", "1.0"), - CatalogEntry("1002", "보통인부", "labor"), - CatalogEntry("1037", "벌목부", "labor"), - CatalogEntry("1003", "특별인부", "labor"), -] - - -def _codes(result: AxisResult, variant: str) -> dict[str, Decimal]: - return {r.resource_code: r.amount for r in result.rows if r.variant == variant} - - -def test_뭉친_줄_굴삭기_표기를_맞추고_비고는_거른다(): - table = _packed( - ["직경 40㎝이상 ∼60㎝미만", "직경 60㎝이상 ∼80㎝미만"], - ["보통인부", "", "인", "1.04(1.17)", "1.08(1.22)"], - ["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84", "3.52"], - ["비고", "- 본 품의 집재거리는 100m까지를 기준하며 매 100m 증가마다 30%씩 가산한다."], - ) - result = AxisResult() - match_table({"work_item_code": "FP-13-06-01"}, table, ResourceCatalog(entries=MACHINES), result) - first = _codes(result, "직경 40㎝이상 ∼60㎝미만") - assert first["0201-0080"] == Decimal("0.384") # 무한궤도 — 이름에 형식이 적혀 있음 - assert "0211-0080" not in first - assert "FP-13-06-01" not in result.partial_items # 비고 줄로 막히지 않음 - - -def test_조합_기종은_공백이_달라도_붙는다(): - table = _packed( - ["5m 미만", "5m이상~8m미만"], - ["벌목부 보통인부", "", "인 인", "2.14 0.51", "2.80 0.66"], - ["굴착기+부착용집게", "0.2㎥", "hr", "2.71", "3.54"], - ) - result = AxisResult() - match_table({"work_item_code": "FP-04-02-02"}, table, ResourceCatalog(entries=MACHINES), result) - assert set(_codes(result, "5m 미만")) >= {"0201-0020", "7206-0020"} - assert "FP-04-02-02" not in result.partial_items - - -def test_형식_없는_굴삭기와_빈칸_표시(): - table = _packed( - ["식생매트설치", "복 토"], - ["특 별 인 부", "", "인", "0.014", "-"], - ["굴 삭 기", "0.6 ㎥", "시간", "-", "0.031"], - ) - result = AxisResult() - match_table({"work_item_code": "FP-13-13-02"}, table, ResourceCatalog(entries=MACHINES), result) - # 「-」 — 복토 갈래에는 특별인부가 안 든다(자리만 지킴) - assert _codes(result, "식생매트설치") == {"1003": Decimal("0.0014")} - # 형식이 없는 굴삭기는 무한궤도·타이어 중 고르지 않는다 → 막고 사유를 남긴다 - assert "굴 삭 기" in result.partial_items.get("FP-13-13-02", "") - - -def test_단목베기_5m_미만이_실무값_근처로_선다(): - from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices - - build = build_unit_prices() - assert "FP-04-02-02" not in build.partial_ratio - # 합판거푸집 — 2026-09-14 사용횟수 갈래로 풀림(`test_b09_formwork_use_count`) · 막힘 사유 없음 - assert "FP-12-04" not in build.component_gaps - total = build.book.resolve("B-FP-04-02-02#5m미만").total - # 영월 설계내역 1.9.2 「잡관목제거 벌목(5m미만)」 @882 — 노임 연도 차이로 ±5 % 안이면 같은 읽기 - assert Decimal("838") <= total <= Decimal("926"), total - - -def test_단위_글자_없는_분모형_밑수도_본문에서_읽는다(): - """「(인/100m당)」 — 「단위:」 없이 분모만 적은 22 곳(2026-09-13). 「인」은 밑수가 아니다.""" - from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source - - assert basis_from_source(["(인/100m당)", "", "| 공정별 |"], 3) == (100.0, "m") - assert basis_from_source(["(인/본당)", "| 규격 |"], 2) == (1.0, "본") - assert basis_from_source(["(무한궤도/0.7㎥)", "| 규격 |"], 2) == (None, None) # 「당」 없음 - - -_단끊기 = { - "pum_table_id": "F-단끊기", - "pum_form": "requirement", - "basis_quantity": 100.0, - "basis_unit": "m", - "condition_note": ["공정별", "보통인부(인)", "비고"], - "raw_row": [ - ["보통토사", "경질ㆍ고사점토 및 자갈섞인 점토", "호박돌 섞인 토사", "", ""], - ["절 취", "2.4", "3.3", "5.4", ""], - ["수평잡기 및 단정리", "0.34", "0.34", "0.34", ""], - ["잡석 및 뿌리 정리", "0.36", "0.36", "0.36", ""], - ["절·성토면 고르기", "1.17", "1.17", "1.17", ""], - ["합계", "2.03", "2.09", "2.23", ""], - ], -} - - -def test_단끊기는_공정_줄을_더하고_작업반장을_20인당_1인_더한다(): - catalog = ResourceCatalog(entries=[*MACHINES, CatalogEntry("1001", "작업반장", "labor")]) - result = AxisResult() - match_table({"work_item_code": "FP-05-16-01"}, _단끊기, catalog, result) - assert _codes(result, "보통토사") == {"1002": Decimal("0.0427"), "1001": Decimal("0.002135")} - assert _codes(result, "호박돌 섞인 토사")["1002"] == Decimal("0.0727") - assert "FP-05-16-01" not in result.partial_items - # 적어 두지 않은 공종은 같은 모양이어도 더하지 않는다(뭉기기 13-12-1 은 줄마다 단위가 다름) - other = AxisResult() - match_table({"work_item_code": "FP-13-12-01"}, _단끊기, catalog, other) - assert not other.rows - - -_STONE = [*MACHINES, CatalogEntry("1033", "석공", "labor"), CatalogEntry("1019", "포장공", "labor")] - - -def test_인력_찰쌓기_두_단_머리는_hwpx_병합대로_돌과_쌓기를_가른다(): - """앞 세 돌은 골·켜 두 잎씩, 호박돌 및 야면석은 한 잎(행 병합) — 7 잎 × 석공·보통인부.""" - table = { - "pum_table_id": "F-찰쌓기", - "pum_form": "requirement", - "basis_quantity": 1.0, - "basis_unit": "㎡", - "condition_note": ["뒷길이 (㎝)", "견 치 돌", "깬 돌", "깬 잡 석", "호박돌 및 야면석"], - "raw_row": [ - ["골쌓기", "켜쌓기", "골쌓기", "켜쌓기", "골쌓기", "켜쌓기", "", ""], - ["석 공 (인)", "보통인부 (인)"] * 7 + [""], - ["25", *["-"] * 8, "0.12", "0.12", "0.10", "0.10", "0.08", "0.10"], - ["35", "0.40", "0.40", "0.44", "0.44", "0.24", "0.24", "0.22", "0.22"] - + ["0.20", "0.20", "0.18", "0.18", "0.11", "0.14"], - ], - } - result = AxisResult() - match_table({"work_item_code": "FP-13-04-04"}, table, ResourceCatalog(entries=_STONE), result) - assert _codes(result, "견치돌 · 켜쌓기 · 뒷길이 35㎝") == { - "1033": Decimal("0.44"), - "1002": Decimal("0.44"), - } - assert _codes(result, "호박돌 및 야면석 · 뒷길이 35㎝") == { - "1033": Decimal("0.11"), - "1002": Decimal("0.14"), - } - assert not _codes(result, "견치돌 · 골쌓기 · 뒷길이 25㎝") # 「-」 — 그 뒷길이엔 품이 없음 - assert "FP-13-04-04" not in result.partial_items - - -def test_콘크리트_포장_작업조는_두께마다_시공량으로_나눈다(): - """12-6 — 두께 칸이 작업조 칸과 엇갈려 병합(hwpx) · 후진 진입 칸은 「50%까지 감」 범위.""" - table = { - "pum_table_id": "F-포장", - "pum_form": "reference", - "condition_note": ["배치인원(인)", "포장두께", "시공량(㎥)"], - "raw_row": [ - [ - "콘크리트믹서트럭 직접타설인경우", - "콘크리트믹서트럭 후진 진입 또는 경운기 등으로 운반인 경우", - ], - ["포장공", "3", "20㎝", "100", "좌측 시공량의 50%까지 감하여 적용한다."], - ["30㎝", "150", "", "", ""], - ["보통인부", "3", "", "", ""], - ["40㎝", "200", "", "", ""], - ], - } - result = AxisResult() - match_table({"work_item_code": "FP-12-06"}, table, ResourceCatalog(entries=_STONE), result) - assert _codes(result, "20㎝") == {"1019": Decimal("0.03"), "1002": Decimal("0.03")} - assert _codes(result, "40㎝")["1019"] == Decimal("0.015") - assert {r.amount_unit for r in result.rows} == {"㎥"} # 시공량 열 머리의 단위 - - -def test_사람이_가른_표_형태는_까닭을_달고_절_번호가_어긋나면_멈춘다(): - """미판정 표 판정(2026-09-13) — 못 가른 표는 싣지 않고, 표 차례가 밀리면 조용히 안 씀.""" - import json - from pathlib import Path - - import pytest - - from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import FORM_JUDGMENTS, judged_form - - assert judged_form("F0413", "13-4-4")[0] == "reference" - assert judged_form("F9999", "1-1") is None - with pytest.raises(ValueError): - judged_form("F0413", "13-4-5") - assert all(why for _, _, why in FORM_JUDGMENTS.values()) - # 마스터에 실렸고, 형태가 제대로 갈려 있다 - # (2026-09-17 파형강관 12-11-3 F0349 도 사람 판정으로 소요량형이 됨 — 산림 미판정 0) - path = Path("resources/data_work_item_master/work_item_master_2026-01-01.json") - tables = { - t["pum_table_id"]: t - for n in json.loads(path.read_text(encoding="utf-8"))["work_items"] - for t in n["tables"] - } - assert tables["F0413"]["pum_form"] == "reference" - assert tables["F0189"]["pum_form"] == "productivity" - assert tables["F0349"]["pum_form"] == "requirement" - assert tables["F0349"]["form_basis"].startswith("사람 판정") - - -def test_인력_찰쌓기는_표준경사_표에_막히지_않는다(): - from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - build = cached_build() - assert "FP-13-04-04" not in build.partial_ratio - assert build.book.resolve("B-FP-13-04-04#깬돌·골쌓기·뒷길이45㎝").labor > 0 diff --git a/resources/tester/test_b09_table_reading_2.py b/resources/tester/test_b09_table_reading_2.py deleted file mode 100644 index 039584a72..000000000 --- a/resources/tester/test_b09_table_reading_2.py +++ /dev/null @@ -1,172 +0,0 @@ -"""표 읽기 ②(2026-09-14 · PLAN 1장) — 「못 붙은 줄」 로 잘못 서던 자리를 **상태가 움직이는지**로 잼. - -9-21 제근 고르기형 표 안내 글이 `unmatched` 에 들어가 못 붙은 줄로 섬 — 밑수는 사용자 확정 - (건설품셈 1,000㎡당 빌림)이라 안내는 비고(`known_gap_note`)로 옮김(사유는 안 사라짐) -12-2 표면 마무리 원본이 12-17-1 [주] 문장을 절 제목으로 읽어 표 7장·가짜 갈래 #설치·#철거 가 붙음 -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def test_제근은_못_붙은_줄이_아니고_교차참조_사유는_비고에_남음() -> None: - build = cached_build() - assert not build.unattached.get("FP-09-21"), build.unattached.get("FP-09-21") - assert sum(1 for code in build.book.titles if code.startswith("B-FP-09-21#")) == 6 - note = known_gap_note("FP-09-21") - assert "교차 참조" in note and "1,000㎡당" in note - - -def test_표면_마무리엔_미장공_표만_있고_가짜_갈래가_없음() -> None: - nodes = {n["work_item_code"]: n for n in load_work_item_master()["work_items"]} - assert [t["pum_table_id"] for t in nodes["FP-12-02"]["tables"]] == ["F0334"] - assert "F0356" in [t["pum_table_id"] for t in nodes["FP-12-17-01"]["tables"]] - build = cached_build() - assert [c for c in build.book.titles if c.startswith("B-FP-12-02")] == ["B-FP-12-02"] - assert not build.unattached.get("FP-12-02") - - -def test_육상_발파암_1_3m_들어내기는_0_1m_로_읽고_고른_값_버린_값을_사유에() -> None: - """③ 원문 「육상 발파암(1~2m)와 동일」(고시 5189·5201줄)은 9-13-14 자기 참조 — 같은 절 암절취· - 용수 발파암이 모두 「(0~1m)와 동일」 이라 0~1m(9-13-13)로 읽음(2026-09-14 브레인 승인).""" - build = cached_build() - for code in ("FP-09-13-14", "FP-09-13-15"): - assert code not in build.partial_ratio and code not in build.component_gaps, code - # 들어내기 참조 줄은 풀림 — 남는 것은 치즐뿐(기본 조립엔 치즐 단가가 없어 종전대로) - left = [label for label in build.unattached.get(code, []) if "치즐" not in label] - assert not left, left - source = build.factor_sources.get(code, "") - assert "육상 발파암(0~1m)" in source and "자기 참조" in source, source - - -def test_비탈면_고르기_발파암_소형브레이커는_1_0_규격으로_코드에_이어짐() -> None: - """④ 9-19-3 값 「(2.45+3.05)/2/10㎡」 ← 9-19-1 표 ← [주]① 「소형브레이커는 1㎥/분」 — - 값의 출처가 규격을 정함(2026-09-14 브레인 승인). 범위 별칭이 **코드로** 곧장 이음(이름만 - 돌려주면 규격 후보 넷이 다시 흐려짐). 「어어호스」 는 원문 오기 + 카탈로그에 호스 없음.""" - from B09_Estimation.B09_Estimation_ResourceAxis import ( - build_resource_axis, - load_combined_catalog, - ) - - axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) - rows = [ - (r.resource_code, str(r.amount)) for r in axis.rows if r.work_item_code == "FP-09-19-03" - ] - assert ("5210-0010", "0.275") in rows, rows - left = [u.cell for u in axis.unmatched if u.work_item_code == "FP-09-19-03"] - assert left == ["어어호스(3/4인치)"], left - - -def test_토사면_고르기는_두_표가_섞여_값이_불완전하다고_먼저_말함() -> None: - """⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 섞여 보통인부 한 줄만 - 섰었음(조용히 틀린 값). ⑤ 본작업(같은 날)으로 두 표를 갈래로 읽어 「불완전」 사유와 섞인 - 본표(B-FP-09-19-01)가 함께 걷힘 — 갈래 없는 섞인 값이 다시 서면 빨강.""" - assert "불완전" not in known_gap_note("FP-09-19-01") - assert "B-FP-09-19-01" not in cached_build().book.titles - - -#: ⑤ 9-19-1 원문 L5414~5436 — (단위: 10㎡당)이라 ㎡당 = 표값 ÷ 10. -CUT = "B-FP-09-19-01#절토면·" # 갈래 키는 공백을 지움 -FILL = "B-FP-09-19-01#성토면·" - - -def _per_m2(code: str) -> dict[str, float]: - """자원 줄(비율 줄 「%」 뺌) → ㎡당 수량.""" - from B09_Estimation.B09_Estimation_UnitPrice import detail_of - - rows = detail_of(cached_build(), code)["rows"] - return {str(r["ref_code"]): float(r["quantity"]) for r in rows if r.get("unit") != "%"} - - -def test_토사면_고르기_절토면은_토질_여섯_갈래로_규격까지_이어_섬() -> None: - """[주]① 공기압축기 3.5㎥/분 · 소형브레이커 1㎥/분 · 굴착기 0.7㎥ — 범위 별칭이 코드로 이음 - (카탈로그 0.7 은 무한궤도뿐). 「·」 는 그 갈래에 없음(칸 자리만 지킴).""" - titles = cached_build().book.titles - cut = sorted(code for code in titles if code.startswith(CUT)) - assert len(cut) == 6, cut - sand = _per_m2(CUT + "모래ㆍ사질토ㆍ점토ㆍ점질토") - assert sand == {"1002": 0.005, "X-0201-0070": 0.015}, sand - soft_rock = _per_m2(CUT + "연암") - # 공기압축기 3.5 — 운전경비 표 괄호 딸림값 파서 고침(같은 날)으로 기계 층이 서서 붙음 - assert soft_rock == {"1002": 0.046, "X-5205-0035": 0.125, "X-5210-0010": 0.245}, soft_rock - from B09_Estimation.B09_Estimation_ResourceAxis import ( - build_resource_axis, - load_combined_catalog, - ) - - axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) - rock = { - (r.resource_code, float(r.amount)) - for r in axis.rows - if r.work_item_code == "FP-09-19-01" and r.variant == "절토면 · 연암" - } - assert rock == {("1002", 0.046), ("5205-0035", 0.125), ("5210-0010", 0.245)}, rock - - -def test_토사면_고르기_성토면_인력_두_갈래_기계는_형식_두_갈래() -> None: - """Ⓒ 둘째 줄이 병합 칸 탓에 한 칸 밀려 옴 — 사람 판정표로만 읽음(일반 보정 금지 · 목재틀흙막이 - 503만원 전례). 굴착기 0.6㎥ 는 원문이 형식을 안 적음 — 처음(Ⓑ)엔 근거가 없어 안 세웠고, - 2026-09-14 브레인 ② 로 무한궤도·타이어 두 갈래를 세움(고르기는 B08 칸 · 제안 무한궤도).""" - build = cached_build() - assert _per_m2(FILL + "인력·점토또는점질토") == {"1002": 0.019} - assert _per_m2(FILL + "인력·모래또는사질토") == {"1002": 0.017} - assert _per_m2(FILL + "기계·무한궤도") == {"X-0201-0060": 0.009} - assert _per_m2(FILL + "기계·타이어") == {"X-0211-0060": 0.009} - left = build.unattached.get("FP-09-19-01", []) - assert not any("굴착기" in label for label in left), left - - -def test_범위_별칭은_칸이_규격을_적었으면_덮지_않음() -> None: - """Ⓐ 별칭은 빈 곳을 채우는 것 — 같은 공종 성토면 「굴착기 0.6㎥」 가 0.7 로 덮이면 안 됨.""" - from B09_Estimation.B09_Estimation_ResourceAxis import load_combined_catalog - from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( - apply_scoped_alias, - scoped_alias_entry, - ) - - catalog = load_combined_catalog() - assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01").code == "0201-0070" - side = ["0.6㎥", "시간", "0.09"] - assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01", side) is None - # 이름만 갈아도 옆 칸 0.6 으로 무한궤도가 조용히 골라짐 — 이름도 그대로 - assert apply_scoped_alias(catalog, "굴착기", "FP-09-19-01", side) == "굴착기" - # 값·식 칸은 규격이 아님 — 9-19-3 소형브레이커 줄(값 0.275 · 식)은 그대로 이음 - value = ["hr", "0.275", "(2.45+3.05)/2/10㎡"] - assert scoped_alias_entry(catalog, "소형브레이커", "FP-09-19-03", value).code == "5210-0010" - - -def test_토사면_고르기_원문_주는_값을_안_짓고_비고로() -> None: - """Ⓓ [주]③ 착암공은 수량이 없어 줄을 안 지음 · Ⓔ [주]② 풍화암 연암 준용은 선택이라 갈래 아님.""" - note = known_gap_note("FP-09-19-01") - assert "착암공" in note and "준용" in note, note - - -def test_제근은_빌린_밑수가_확정이라_갈래가_서면_내역_금액이_섬() -> None: - """9-21 원문엔 밑수가 없어 마스터 `basis_missing` 에 F0294 가 남음(원문 사실 그대로) — 그러나 - 사용자 확정(건설품셈 3-9-2 「1,000㎡당」 · ÷1,000 은 ChooseOne 이 검)이 있어 내역에서 - 「밑수 미확보」로 막히면 안 됨(2026-09-14 브레인). 교차 참조 사유는 비고에 그대로 남음.""" - from B09_Estimation.B09_Estimation_BasisSheet import open_gaps - from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill - from B09_Estimation.B09_Estimation_UnitPrice import detail_of, load_basis_missing - - # 2026-09-18 원문이 되살아나 F0294 는 **제 밑수(100㎡당)를 갖는다** — 더는 미확보가 아니다. - # ⬜ 다만 사용자 확정은 교차 참조로 「1,000㎡당」이었다 — 열 배 갈리는 자리라 브레인 판정 대기. - assert "F0294" not in load_basis_missing() - build = cached_build() - assert "FP-09-21" not in build.basis_missing - assert not [g for g in open_gaps(build) if g["code"] == "FP-09-21"] - title = "B-FP-09-21#굴착기(무한궤도)0.7·소" - # ⚠ 2026-09-18 **원문으로 물림** — 9-21 표 우측 상단 「(단위: 100㎡당)」(코덱스 PDF 142면). - # 사용자 확정 1,000㎡당(건설 3-9-2 교차 참조)을 뒤집은 자리다 — **열 배 오른다**(55 → 555원/㎡). - # 진작 적혀 있던 「100㎡당으로 보면 실무와 자릿수가 맞다」가 맞았다(실무 영월 475원/㎡). - assert build.book.title(title) and 540 <= int(detail_of(build, title)["total"]) <= 570 - item = {"work_item_code": "FP-09-21", "name": "지장목제거", "spec": "뿌리뽑기", "unit": "㎡", - "quantity": "1000", "in_bill": True, "variant_axis": "stand_volume_class", - "variant_value": "굴착기(무한궤도)0.7·소"} # fmt: skip - result = build_bill({"work_items": [item], "materials": []}, build=build) - assert not result.missing, result.missing - row = next(r for r in result.rows if r.code == "FP-09-21") - assert row.amount_krw and int(row.amount_krw) > 0, row.note diff --git a/resources/tester/test_b09_topsoil_removal.py b/resources/tester/test_b09_topsoil_removal.py deleted file mode 100644 index caab61ccb..000000000 --- a/resources/tester/test_b09_topsoil_removal.py +++ /dev/null @@ -1,44 +0,0 @@ -"""표토제거 답외구간 9-15-2 — 2026-09-14 브레인 ㉰ 첫째(B08 준비공 표토제거 줄이 부르는 코드). - -원문 L5288: T 0.2m · L 20m · E 0.4 · q0 3.2㎥ · e 0.96 · f 1/1.3 · V1 40m/분(1단) · V2 46m/분(1단) · t 0.25분 - [주]① 무한궤도 불도저(19ton) ② q = 3.2 × e · ㎝ = L/V1 + L/V2 + 0.25 · Q1 = 60 × q × f × E / ㎝ ㎥/시간 · Q = Q1/T ㎡/시간 - ③ 건설품셈 8-2-1 불도저 참조 → 불도저 식(`dozer_hourly_output`)을 그대로 쓰고 T 로 나눔 -⚠ 실무 영월 「표토제거 답외구간」 호표 Q=576.95 는 **E 자리에 e(0.96)를 넣은 값**으로 정확히 역산됨 - (60 × 3.07 × 0.77 × 0.96 ÷ (1.18 × 0.2) = 576.95) · 다른 실무(봉화·대흥·소광·거창)엔 이 호표 없음 → - 그 현장 실수 · 원문이 또렷하므로 원문 E=0.4 로 셈(2026-09-15 브레인 규칙 · 어긋남 기록) -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def test_표토제거_답외구간_일위대가가_불도저_19톤_1_Q_로_섬() -> None: - from B09_Estimation.B09_Estimation_TopsoilRemoval import topsoil_output - - q1, q = topsoil_output() - assert q1 == Decimal("47.92") and q == Decimal("239.60"), (q1, q) - book = cached_build().book - title = book.titles["B-FP-09-15-02"] - assert title.unit == "㎡" - rows = [ - r for d in book.details["B-FP-09-15-02"] for r in [d, *book.details.get(d.ref_code, [])] - ] - dozer = next(r for r in rows if r.ref_code == "X-0101-0019") - assert dozer.quantity == Decimal(1) / q and dozer.output == q, dozer - - -def test_식_줄에_T_로_나눈_것과_실무_어긋남이_적힘() -> None: - book = cached_build().book - notes = " ".join( - r.note - for d in book.details["B-FP-09-15-02"] - for r in [d, *book.details.get(d.ref_code, [])] - ) - assert "T" in notes and "9-15-2" in notes, notes - from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note - - note = known_gap_note("FP-09-15-02") - assert "576.95" in note and "0.96" in note and "E=0.4" in note, note diff --git a/resources/tester/test_b09_transport.py b/resources/tester/test_b09_transport.py deleted file mode 100644 index 7f188e2bc..000000000 --- a/resources/tester/test_b09_transport.py +++ /dev/null @@ -1,146 +0,0 @@ -"""기계 수송비 — 기계경비의 셋째 몫 (2026-09-09 밤). - -기계경비 = 기계손료 + 운전경비 + **수송비**(건설품셈 8-1-6의 1)인데 수송비가 통째로 빠져 -있었다. 수송비를 내는 공종 `FP-10-04` 중기운반은 표가 「미판정」이라 **단가가 안 서고** 있었다. - -⚠ 겨누는 것 여섯 - ① **거리가 없으면 한 줄도 안 선다** — 임의 거리로 금액이 조용히 서면 안 됨 - ② 도로 구분도 마찬가지 — 속도가 도로로 갈림 - ③ 사이클이 원문 그대로 — 트레일러 t1=20·t3=20·t4=0.42 / 트럭 t1=10·t3=10·t4=5 - ④ 운반시간은 **왕복** · 회전율 N = 60×0.9/㎝ - ⑤ 원문이 「-」로 둔 칸(트레일러 · 고속4차선)은 **지어내지 않고 사유로 냄** - ⑥ 그 차량들의 운전경비가 실제로 읽혀 있음 — 앞서 표가 페이지에서 잘려 통째로 버려졌음 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402 - expand_codes, - load_operating_records, -) -from B09_Estimation.B09_Estimation_Transport import ( # noqa: E402 - TRANSPORT_VARIANTS, - TRIPS_NOTE, - cycle_minutes, - hours_per_trip, - parse_distance_km, - parse_trips, - road_class, - transport_amount, -) -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402 - -_TRAILER = next(v for v in TRANSPORT_VARIANTS if v["key"] == "트레일러20ton") -_TRUCK = next(v for v in TRANSPORT_VARIANTS if v["key"] == "트럭10.5ton") - - -def test_거리가_없으면_한_줄도_안_선다() -> None: - build = build_unit_prices() - assert not [code for code in build.book.titles if code.startswith("B-FP-10-04")] - assert any("거리" in note for note in build.transport_notes) - - -def test_도로를_안_고르면_안_선다() -> None: - build = build_unit_prices(transport_distance_km=Decimal(12)) - assert not [code for code in build.book.titles if code.startswith("B-FP-10-04")] - assert any("도로" in note for note in build.transport_notes) - - -def test_사이클이_원문_그대로다() -> None: - """③④ 트레일러 12㎞ 사리도로(양호) — 20 + (24÷20)×60 + 20 + 0.42 = 112.42분.""" - road = road_class("gravel_good") - assert road is not None - minutes = cycle_minutes(_TRAILER, Decimal(12), road) - assert minutes == Decimal(20) + Decimal("72") + Decimal(20) + Decimal("0.42") - # N = 60×0.9/㎝ 의 역수가 회당 시간이다. - hours = hours_per_trip(_TRAILER, Decimal(12), road) - assert hours == minutes / (Decimal(60) * Decimal("0.9")) - - -def test_트럭은_트럭_사이클을_쓴다() -> None: - road = road_class("gravel_good") - assert road is not None - minutes = cycle_minutes(_TRUCK, Decimal(12), road) - # 트럭은 사리도로(양호) 25㎞/hr — 10 + (24÷25)×60 + 10 + 5 - assert minutes == Decimal(10) + Decimal("57.6") + Decimal(10) + Decimal(5) - - -def test_원문이_비운_칸은_지어내지_않는다() -> None: - """⑤ 트레일러는 고속4차선 값이 원문에 「-」다.""" - road = road_class("expressway_4") - assert road is not None and road["trailer"] is None - with pytest.raises(ValueError) as caught: - cycle_minutes(_TRAILER, Decimal(12), road) - assert "원문에 없습니다" in str(caught.value) - - -def test_거리_칸은_비우면_없음이다() -> None: - assert parse_distance_km("") is None - assert parse_distance_km(" ") is None - assert parse_distance_km("0") is None - assert parse_distance_km("12.5") == Decimal("12.5") - with pytest.raises(ValueError): - parse_distance_km("멀다") - - -def test_거리를_넣으면_회당_단가가_선다() -> None: - build = build_unit_prices(transport_distance_km=Decimal(12), transport_road="gravel_good") - assert build.transport_notes == [] - for variant in TRANSPORT_VARIANTS: - code = f"B-FP-10-04#{variant['key']}" - title = build.book.titles[code] - assert title.unit == "회" - money = build.book.resolve(code) - assert money.total > 0 - # 손료(경비)·연료(재료)·운전사(노무) 셋이 다 들어야 수송비다. - assert money.material > 0 and money.labor > 0 and money.expense > 0 - - -def test_수송_차량_운전경비가_읽힌다() -> None: - """⑥ 표가 페이지에서 잘려 앞자리를 못 이어받아 39 기종이 통째로 버려지고 있었다.""" - codes = {record.machine_code for record in load_operating_records().records} - assert "2702-0020" in codes # 트럭 트랙터 및 평판트레일러 20ton - assert "0602-0105" in codes # 덤프트럭 10.5ton - - -def test_앞자리_이어받기는_이어받을_것이_있을_때만() -> None: - """⚠ 넓히면 없는 코드를 지어낸다 — 이어받을 앞자리가 없으면 그대로 버린다.""" - assert expand_codes(["0080", "0100"]) == [] - assert expand_codes(["0080", "0100"], "2101") == ["2101-0080", "2101-0100"] - assert expand_codes(["2101-0010", "0015"]) == ["2101-0010", "2101-0015"] - - -# ── 회수(대수 × 왕복) — 품셈이 안 정하는 자리 (2026-09-14 브레인 판정) ── - - -def test_회수는_설계_입력_칸이고_비우면_없음이다() -> None: - """품셈 원문에 **회수 공식이 없음** — 산림품셈 10-4·건설품셈 8-1-3 은 회당 단가 산출식뿐. - - ⇒ 대수·왕복은 **설계자가 넣는 값**이다. 비면 `None` 이고 금액이 안 선다(지어내지 않음). - """ - assert parse_trips("") is None and parse_trips(None) is None - assert parse_trips("0") is None # 0 회는 「안 나른다」 — 줄이 안 섬 - assert parse_trips("3") == Decimal(3) - assert parse_trips(" 2.5 ") == Decimal("2.5") # 반 회(편도 한 번)도 설계자가 넣을 수 있음 - with pytest.raises(ValueError): - parse_trips("두 번") - - -def test_회수를_넣으면_회당_단가에_곱해진다() -> None: - """회수 × 회당 단가 = 수송비. **곱하기 하나뿐** — 품셈이 안 준 규칙을 끼워 넣지 않는다.""" - assert transport_amount(Decimal(12_345), None) is None - assert transport_amount(Decimal(12_345), Decimal(3)) == Decimal(37_035) - - -def test_회수_사유가_늘_붙는다() -> None: - """화면이 「왜 설계자가 넣나」를 읽을 수 있어야 함 — 근거 문구에 원문 없음이 적혀 있음.""" - assert "회수" in TRIPS_NOTE and "설계" in TRIPS_NOTE diff --git a/resources/tester/test_b09_transport_reason.py b/resources/tester/test_b09_transport_reason.py deleted file mode 100644 index 24c479294..000000000 --- a/resources/tester/test_b09_transport_reason.py +++ /dev/null @@ -1,39 +0,0 @@ -"""B09 기계 수송비 — 거리가 비면 **사유가 붙은 빈 줄**이 선다(2026-09-16 브레인 · 사용자 지시). - -기계경비는 손료 + 운전경비 + **수송비**(건설품셈 8-1-6의 1)인데, 수송비는 거리(인근 시·군·구청 -소재지 → 현장)가 설계 입력이라 안 넣으면 줄이 안 섰다. ⚠ 그때 **사유도 안 떠서** 모든 프로젝트에서 -수송비가 조용히 0 이었다(2026-09-16 조사) — 「조용히 빠짐」은 우리가 내내 막아 온 자리다. -⇒ 금액은 지어내지 않는다. **사유만** 세운다 — 문구는 아래 층(`B09_Estimation_Transport`) 것 그대로. -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def test_거리가_비면_사유가_서고_금액은_안_섬() -> None: - build = cached_build() - assert f"B-{WORK_ITEM_CODE}" not in build.book.titles # 금액을 지어내지 않음 - gap = build.component_gaps.get(WORK_ITEM_CODE) or "" - assert "거리" in gap and "산출 조건" in gap - assert gap in build.transport_notes # 아래 층 문구 그대로 — 새로 짓지 않음 - - -def test_거리를_넣으면_줄이_서고_사유는_사라짐() -> None: - build = cached_build(transport_distance_km="12", transport_road="paved") - codes = [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")] - assert codes, "거리를 넣었는데 수송비 줄이 안 섬" - assert WORK_ITEM_CODE not in build.component_gaps - detail = build.book.details[codes[0]][0] - assert detail.quantity > Decimal(0) - - -def test_한_갈래만_서면_사유는_그_갈래에만_남음() -> None: - """고속4차선은 원문에 트레일러 속도가 「-」 — 덤프 줄은 서고 트레일러만 사유.""" - build = cached_build(transport_distance_km="12", transport_road="expressway_4") - assert [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")] - assert build.transport_notes and any("원문에 없습니다" in n for n in build.transport_notes) - assert WORK_ITEM_CODE not in build.component_gaps # 줄이 섰으면 공종 사유는 안 붙음 diff --git a/resources/tester/test_b09_unread_tables.py b/resources/tester/test_b09_unread_tables.py deleted file mode 100644 index 99a32cf43..000000000 --- a/resources/tester/test_b09_unread_tables.py +++ /dev/null @@ -1,59 +0,0 @@ -"""사유 없이 버려지는 표 — 2026-09-15 브레인 차례 ① (㉯ 가르기에서 6 · 전체로 재면 49). - -일할 표(작업량·소요량·형식 미정)를 가진 잎 공종인데 **제목도 사유도 없는** 것 — 표를 읽는 길이 없어 -한 줄도 안 서고 `unattached`·`component_gaps`·밑수·[주] 사유 어디에도 안 남아, 내역서엔 「일위대가가 아직 -없습니다」 만 뜸(무엇을 손볼지 모름). 예: 3-1 경계표시 F0075 「0.2 | 보통인부」(값이 이름 앞) · -6-4-1 덩굴걷기 F0158 · 7-3 아키야윈치 F0174. -(10-13-1 드론운반 F0322 는 2026-09-17 사람 판정으로 풀림.) -⇒ 읽는 길을 만드는 것은 뒤(예취기 한 모양 4 …) — 여기서는 **버린 표를 사유로 남김**(버릴 때는 사유). -""" - -from __future__ import annotations - -from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note -from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -WORK_FORMS = {"productivity", "requirement", "undetermined"} - - -def test_제목_없는_잎_공종은_모두_사유가_있음() -> None: - build = cached_build() - master = load_work_item_master() - parents = {n.get("parent_code") for n in master["work_items"]} - titled = {t[2:].split("#")[0] for t in build.book.titles if t.startswith("B-")} - silent = [ - n["work_item_code"] - for n in master["work_items"] - if n["work_item_code"] not in titled - and n["work_item_code"] not in parents - and any(t.get("pum_form") in WORK_FORMS for t in n.get("tables") or []) - and not ( - build.unattached.get(n["work_item_code"]) - or build.component_gaps.get(n["work_item_code"]) - or n["work_item_code"] in build.basis_missing - or known_gap_note(n["work_item_code"]) - ) - ] - assert silent == [], silent - - -def test_사유는_표_번호와_모양을_가리킴() -> None: - gaps = cached_build().component_gaps - # 6-4-1 덩굴걷기 F0158 은 예취기 도구 줄 표로 풀려 사유가 저절로 빠짐 → 6-2-1 둘레베기로 갈음 - for code, table in ( - ("FP-03-01", "F0075"), - ("FP-06-02-01", "F0154"), - ("FP-07-03", "F0174"), - ("FP-03-08", "F0082"), - ): - assert table in gaps[code] and "한 줄도" in gaps[code], (code, gaps.get(code)) - # 2026-09-17 F0322 드론운반은 사람 판정으로 소요량형(묘목 1,000본당)이 됐다 — - # 「형식 미정」 사유가 사라지고, 표를 읽는 길이 열렸다. - assert "FP-10-13-01" not in gaps or "형식 미정" not in gaps["FP-10-13-01"] - - -def test_부모와_제목_선_공종에는_안_붙음() -> None: - build = cached_build() - assert "FP-04-01" not in build.component_gaps # choose_one 부모 — 잎이 섬 - assert "FP-05-24-01" not in build.component_gaps # 제목 선 공종 diff --git a/resources/tester/test_b09_variant_basis.py b/resources/tester/test_b09_variant_basis.py deleted file mode 100644 index 836db1b46..000000000 --- a/resources/tester/test_b09_variant_basis.py +++ /dev/null @@ -1,40 +0,0 @@ -"""갈래마다 밑수가 다른 표 — 2026-09-15 브레인(헬기 착수 전 바로잡기). - -8-6-1 유인헬기방제 표(F0225)는 「소형헬기 (160ha당)」·「대형헬기 (400ha당)」 갈래마다 밑수가 다른데, -2단 머리 표 읽기(`_match_two_row_table`)가 **밑수로 아예 안 나눠** 제목 단위 「ha」 에 160ha·400ha 몫이 -그대로 앉아 있었음(160배·400배 부풂). 표 밑수가 한 벌(160ha)로만 잡혀 대형 400ha 도 못 가름. -⇒ 갈래 이름의 「(N단위당)」 이 있으면 그 밑수로, 없으면 표 밑수로 나눔. -그 길을 지나는 표 다섯 중 밑수가 1 이 아닌 표는 8-6-1 하나 · 갈래마다 밑수가 다른 표도 전체에서 8-6-1 하나. -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - - -def _labor(book, title: str) -> dict[str, Decimal]: - return { - book.titles[r.ref_code].name: r.quantity - for r in book.details[title] - if r.ref_code in ("1002", "1003") - } - - -def test_유인헬기_인력은_갈래_밑수로_나눈_ha당() -> None: - book = cached_build().book - small = _labor(book, "B-FP-08-06-01#소형헬기(160ha당)") - assert ( - small["특별인부"] == Decimal("0.8") / 160 and small["보통인부"] == Decimal("8.2") / 160 - ), small - large = _labor(book, "B-FP-08-06-01#대형헬기(400ha당)") - assert ( - large["특별인부"] == Decimal("2.1") / 400 and large["보통인부"] == Decimal("13.4") / 400 - ), large - - -def test_밑수_1_인_2단_표는_그대로() -> None: - book = cached_build().book - rows = [r for t in book.titles if t.startswith("B-FP-12-03#") for r in book.details[t]] - assert rows and all(r.quantity < 20 for r in rows) # 철근 가공·조립 ton당 — 나누기 전과 같음 diff --git a/resources/tester/test_b09_verify_guards.py b/resources/tester/test_b09_verify_guards.py deleted file mode 100644 index b82f081a9..000000000 --- a/resources/tester/test_b09_verify_guards.py +++ /dev/null @@ -1,97 +0,0 @@ -"""검증 리스트 셋을 시험으로 못 박음 — V-11 · V-16 · V-17 (2026-09-09 밤). - -「사람이 한 번 봤다」로 두면 다음 사람이 또 봐야 한다. **본 것을 시험으로 옮긴다.** - - V-11 인계 계약 검사가 **새 칸에도** 걸리나 — 계약 시험이 실제로 무는지(가드의 가드) - V-16 불도저 운반이 **금액으로** 서나 — 갈래(토사·리핑암)마다 단가가 서는지 - V-17 공구손료가 **이중으로 안 붙나** — 제잡비와 밑수가 겹치지 않는지 -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402 -from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices, cached_build # noqa: E402 - - -# ── V-11 ──────────────────────────────────────────────────────────── -def test_v11_계약_검사는_양쪽으로_문다() -> None: - """⚠ 가드의 가드 — 칸을 하나 늘리거나 줄이면 계약 시험이 **반드시** 걸려야 한다. - - 「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」 자리는 양쪽 다 조용해서, 계약 시험이 - 무는지 자체를 시험으로 남긴다. - """ - from resources.tester.test_b08_handoff_contract import WORK_ITEM_KEYS - - row = {key: None for key in WORK_ITEM_KEYS} - - # ① 계약에 없는 칸을 늘리면 잡는다. - grown = dict(row, 새_칸="값") - assert set(grown) - WORK_ITEM_KEYS == {"새_칸"} - - # ② 계약에 있는 칸을 빠뜨려도 잡는다. - shrunk = dict(row) - shrunk.pop("quantity") - assert WORK_ITEM_KEYS - set(shrunk) == {"quantity"} - - -# ── V-16 ──────────────────────────────────────────────────────────── -def test_v16_불도저_운반이_갈래마다_금액으로_선다() -> None: - """⚠ 세워 놓고 한 번도 안 돌려 본 채로 두지 않는다 — 갈래마다 단가가 서야 한다.""" - build = cached_build() - codes = [code for code in build.book.titles if code.startswith("B-FP-10-11#")] - assert len(codes) >= 2, codes - grounds = {code.split("#", 1)[1] for code in codes} - assert {"토사", "파쇄암"} <= grounds, grounds - for code in codes: - assert build.book.resolve(code).total > 0, code - - -def test_v16_덤프_운반은_거리가_와야_선다() -> None: - """⚠ 안 서는 것도 사실대로 못 박는다 — 운반은 운반거리(설계 입력)가 와야 선다. - - 2026-09-14 — 적재(10-12 「1. 적재」)는 거리 무관이라 늘 서고, 운반(`#L…m`)은 거리가 온 조립에만. - """ - build = cached_build() - dump = [code for code in build.book.titles if code.startswith("B-FP-10-12")] - assert dump and all(code.endswith("#적재") for code in dump) - - -# ── V-17 ──────────────────────────────────────────────────────────── -def test_v17_공구손료와_제잡비는_밑수가_겹치지_않는다() -> None: - """⚠ 둘 다 「%로 붙는 줄」이라 겹치면 조용히 부푼다. - - 공구손료 = **주재료비**의 % → 재료비 / 제잡비 = **노무비**의 % → 경비. - 밑수도 가는 자리도 달라 한 공종에 둘 다 있어도 이중계상이 아니다. - """ - build = build_unit_prices(misc_material_percent=Decimal(3)) - both = 0 - for code, rows in build.book.details.items(): - title = build.book.titles.get(code) - if title is None or title.kind is not PriceKind.UNIT_PRICE: - continue - has_overhead = any(row.percent_of_labor is not None for row in rows) - has_tool = any(row.percent_of_material is not None for row in rows) - if not (has_overhead and has_tool): - continue - both += 1 - before = build.book.resolve(code) - # 제잡비는 경비로, 공구손료는 재료비로 간다 — 성분이 갈리므로 겹칠 수 없다. - assert before.expense >= 0 and before.material >= 0 - assert both > 0, "제잡비와 공구손료가 함께 있는 공종이 하나도 없습니다" - - -def test_v17_지금은_공구손료가_한_원도_안_붙는다() -> None: - """⚠ 오늘 상태를 못 박는다 — 칸이 비어 있고, 붙을 주재료비도 아직 0 이다.""" - base = cached_build() - with_percent = build_unit_prices(misc_material_percent=Decimal(3)) - for code, title in base.book.titles.items(): - if title.kind is not PriceKind.UNIT_PRICE: - continue - assert base.book.resolve(code).total == with_percent.book.resolve(code).total, code diff --git a/resources/tester/test_b09_waste_disposal_cost.py b/resources/tester/test_b09_waste_disposal_cost.py deleted file mode 100644 index c9ce66abe..000000000 --- a/resources/tester/test_b09_waste_disposal_cost.py +++ /dev/null @@ -1,99 +0,0 @@ -"""폐기물처리비 자리 — 경비 비목 · 일반관리비·이윤 밑수에 듦 (2026-09-14 판정). - -⚠ 겨누는 것은 **구조**다 — 내역 금액을 박지 않는다(다른 창 절사로 몇 원 움직여도 안 깨지게). - ① 분리발주 아님(기본): 경비 한 줄 → 순공사원가·일반관리비 밑수·이윤 밑수가 **그만큼** 는다 - ② 법정경비 밑수에는 안 섞인다 — 법정경비 줄은 그대로 - ③ 분리발주: 총원가 밖 · 총공사비에만 더한다 -근거 — 예정가격작성기준 제19조③18호(경비 세비목 「폐기물처리비」). -""" - -from __future__ import annotations - -import sys -from decimal import Decimal -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT)) - -from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 - -WASTE = Decimal(1_000_000) -#: 규모 구간을 못 박는다 — 폐기물 몫으로 구간이 바뀌면 율이 달라져 구조를 못 잰다. -BASE = dict( - direct_material_krw=Decimal(30_000_000), - direct_labor_krw=Decimal(40_000_000), - direct_expense_krw=Decimal(10_000_000), - estimated_price_krw=Decimal(100_000_000), -) -#: 폐기물이 들면 움직여야 하는 줄 — 나머지(법정경비 등)는 한 푼도 안 움직여야 함. -MOVES = { - "waste_disposal", - "expense", - "net_construction_cost", - "general_overhead", - "profit_before_adjustment", - "profit", - "total_cost", - "vat", - "contract_amount", - "grand_total", -} - - -def _run(waste: Decimal, separate: bool = False): - # ⚠ 절사는 꺼서 본다 — 총공사비가 바뀌면 자동보정액도 따라 바뀌어 **구조**를 못 잰다 - # (2026-09-14 절사 기본 켜짐, 산림청고시 제2025-82호). - return calculate_cost( - CostInput( - **BASE, - waste_disposal_krw=waste, - waste_separate_order=separate, - cut_basis="none", - ) - ) - - -def test_기본은_경비로_들어_일반관리비_이윤_밑수가_는다() -> None: - before, after = _run(Decimal(0)), _run(WASTE) - assert after.line("waste_disposal").amount_krw == WASTE - assert after.amount("expense") - before.amount("expense") == WASTE - assert after.amount("net_construction_cost") - before.amount("net_construction_cost") == WASTE - over = ( - after.line("general_overhead").base_amount_krw - - before.line("general_overhead").base_amount_krw - ) - assert over == WASTE - overhead_gain = after.amount("general_overhead") - before.amount("general_overhead") - assert overhead_gain > 0 - profit_base_gain = ( - after.line("profit_before_adjustment").base_amount_krw - - before.line("profit_before_adjustment").base_amount_krw - ) - assert profit_base_gain == WASTE + overhead_gain - assert "폐기물처리비" in after.line("expense").base_label - - -def test_법정경비_줄은_안_움직인다() -> None: - before, after = _run(Decimal(0)), _run(WASTE) - for line in before.lines: - if line.key not in MOVES: - assert after.amount(line.key) == line.amount_krw, line.key - - -def test_경비_줄보다_앞에_선다() -> None: - keys = [line.key for line in _run(WASTE).lines] - assert keys.index("waste_disposal") < keys.index("expense") < keys.index("general_overhead") - - -def test_분리발주면_총원가_밖_총공사비에만() -> None: - none, separate = _run(Decimal(0)), _run(WASTE, separate=True) - assert separate.amount("total_cost") == none.amount("total_cost") - assert separate.amount("grand_total") - none.amount("grand_total") == WASTE - keys = [line.key for line in separate.lines] - assert keys.index("waste_disposal") > keys.index("contract_amount") - assert separate.totals["waste_disposal"] == WASTE - - -def test_금액이_없으면_줄이_안_선다() -> None: - assert not _run(Decimal(0)).has("waste_disposal") diff --git a/resources/tester/test_b09_water_tank_operating.py b/resources/tester/test_b09_water_tank_operating.py deleted file mode 100644 index b702a38e8..000000000 --- a/resources/tester/test_b09_water_tank_operating.py +++ /dev/null @@ -1,47 +0,0 @@ -"""물탱크(살수차) 운전경비 되살림 — 2026-09-15 브레인 ③ (초류종자살포 5-24 를 막던 넷 중 하나). - -건설품셈 8-4-8 기타기계(원문 L3458)는 36기종이 한 칸에 뭉치고 자동세륜기 「-」 하나가 두 기종 몫(셀 병합)이라 -잡재료 35 · 조종원 34 개로 짝이 안 맞아 **줄째 버려짐** → 물탱크 7204 다섯의 운전경비 레코드가 없어 기계 층이 안 섬. -양끝 맞추기는 우연 일치로 틀린 값이 붙어(자동세륜기 0010 조종원이 「1」 로 읽힘) 못 씀 → 원문 값을 손으로 되살림. -원문: 1,800ℓ 8.2 · 3,800ℓ 8.6 · 5,500ℓ 9.3 · 6,500ℓ 9.4 · 16,000ℓ 12.9 · 잡재료 30 · 조종원 1 -조종원 직종 — 8-1-2 5호 「화물차운전사 … 살수차」(원문 또렷) · 거창 실무 5,500ℓ 호표도 화물차운전사 · 경유 9.3 · 잡품 30 -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_MachineOperating import ( - _CATALOG_SUBPATH, - _read_json, - load_operating_records, -) -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -WATER_TANKS = { - "7204-0018": "8.2", - "7204-0038": "8.6", - "7204-0055": "9.3", - "7204-0065": "9.4", - "7204-0160": "12.9", -} - - -def test_물탱크_다섯은_원문_연료_잡재료_조종원으로_선다() -> None: - records = {r.machine_code: r for r in load_operating_records().records} - truck = _read_json(*_CATALOG_SUBPATH, "labor_const_2026-01-01.json")["variables"]["aliases"][ - "labor_op_truck" - ] - for code, liters in WATER_TANKS.items(): - record = records[code] - assert record.fuel_liters_per_hour == Decimal(liters), record - assert record.fuel_kind == "경유" - assert record.misc_material_percent == Decimal(30) - assert record.operator_person_days == Decimal(1) - assert record.operator_occupation_code == truck, record # 8-1-2 5호 살수차 - - -def test_살수차_기계_층이_서고_막힌_기계에서_빠짐() -> None: - build = cached_build() - assert "X-7204-0055" in build.book.titles - assert not any(m.startswith("7204-0055") for m in build.incomplete_machines) diff --git a/resources/tester/test_b09_wood_chipping.py b/resources/tester/test_b09_wood_chipping.py deleted file mode 100644 index c7c19720f..000000000 --- a/resources/tester/test_b09_wood_chipping.py +++ /dev/null @@ -1,63 +0,0 @@ -"""이동식 임목 파쇄 8-11 — 2026-09-15 브레인 판정(㉰ 둘째). - -원문 L4651: 이동식 임목 파쇄기 93.25KW · Q = 3.5 ㎥/hr · 비고 「잡재료 : 주연료비의 16% · 소모품비(파쇄기날) 0.00125개/hr」 - [주]① 1일 8시간 ② 1일 기계운전자 1인 · 보통인부 2인 ③ 장비 운반비 별도 ④ 우드그랩 별도 ⑤ 연료 13.5ℓ(디젤) -건설품셈 운전경비표(8-4 L3458) 7205-0125 93.25㎾ 줄은 연료·잡품 칸이 「-」 → 우리 운전경비 레코드가 없어 -기계 층이 안 서고 일위대가도 조용히 없었음. 8-11 [주]⑤·비고가 **유일한 값**이라 그대로 씀(판정할 것 없음). -파쇄기날은 규격·단가가 없어 「자재 단가」 칸 + 사유(화약류·치즐과 같은 통로). -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build - -BLADE = "AR-M-249d0a01" -Q = Decimal("3.5") - - -def _rows(book, code: str) -> list: - own = book.details.get(code) or [] - return [ - *own, - *(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]), - ] - - -def test_파쇄기_기계_층이_8_11_주5_연료와_잡재료_16퍼센트로_섬() -> None: - book = cached_build().book - rows = {r.ref_code: r for r in book.details["X-7205-0125"]} - assert rows["M-FUEL-경유"].quantity == Decimal("13.5"), rows - misc = next(r for r in book.details["X-7205-0125"] if r.percent_of_material is not None) - assert misc.percent_of_material == Decimal(16) - assert any(code.endswith("#시간") for code in rows), rows # 기계운전자 1인(조종원 시간당) - - -def test_파쇄_일위대가는_기계_1_Q_와_보통인부_2인_8시간() -> None: - build = cached_build() - book = build.book - assert book.titles["B-FP-08-11"].unit == "㎥" - rows = _rows(book, "B-FP-08-11") - machine = next(r for r in rows if r.ref_code == "X-7205-0125") - assert machine.quantity == Decimal(1) / Q and machine.output == Q, machine - labor = next(r for r in rows if r.ref_code == "1002") - assert labor.quantity == Decimal(2) / Decimal(8) / Q, labor - assert not any(r.ref_code.startswith("1048") for r in rows) # 운전자는 기계 층에 — 두 번 안 셈 - - -def test_파쇄기날은_단가가_없으면_사유_있으면_시간당_수량_나누기_Q() -> None: - build = cached_build() - assert "FP-08-11" in build.material_uses.get(BLADE, []) - assert any("파쇄기날" in label for label in build.unattached.get("FP-08-11", [])) - priced = cached_build(material_prices=((BLADE, "150000", "견적"),)) - blade = next(r for r in _rows(priced.book, "B-FP-08-11") if r.ref_code == BLADE) - assert blade.quantity == Decimal("0.00125") / Q, blade - assert not any("파쇄기날" in label for label in priced.unattached.get("FP-08-11", [])) - - -def test_원문_주의_별도_계상은_사유로() -> None: - from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note - - note = known_gap_note("FP-08-11") - assert "운반비" in note and "우드그랩" in note, note diff --git a/resources/tester/test_b09_work_items_ax.py b/resources/tester/test_b09_work_items_ax.py deleted file mode 100644 index a7b543903..000000000 --- a/resources/tester/test_b09_work_items_ax.py +++ /dev/null @@ -1,27 +0,0 @@ -"""산림 품셈에 절이 없는 공종 `AX-WK-*` (명세 2장 ② 갈래 WK · 2026-09-13). - -지키는 것 — 모르타르 배합 1:3 = 보통인부 0.66인/㎥(건설 품셈 건축 9-1-1, 모래체가름 포함) · -시멘트·모래는 자재 단가 층이 없어 금액에 안 들고 「안 붙은 줄」로 드러남. -""" - -from __future__ import annotations - -from decimal import Decimal - -from B09_Estimation.B09_Estimation_UnitPrice import cached_build -from B09_Estimation.B09_Estimation_WorkItems_AX import MORTAR_MIX - - -def test_모르타르_배합은_보통인부_066인이고_자재는_드러내기만() -> None: - build = cached_build() - title = build.book.title(f"B-{MORTAR_MIX}") - assert (title.name, title.spec, title.unit) == ("모르타르 배합", "1:3", "㎥") - money = build.book.resolve(f"B-{MORTAR_MIX}") - wage = build.book.resolve("1002").labor # 보통인부 - # 일위대가 호표 성분 소계는 원 미만 절사(명세 7장 · PLAN 6장 ①). - assert money.labor == (wage * Decimal("0.66")).to_integral_value("ROUND_FLOOR") - assert money.material == 0 - assert [label.split(" —")[0] for label in build.unattached[MORTAR_MIX]] == [ - "시멘트 510 kg", - "모래 1.10 ㎥", - ] diff --git a/resources/tester/test_b10a_rules_visible_b08.py b/resources/tester/test_b10a_rules_visible_b08.py deleted file mode 100644 index 01850fdec..000000000 --- a/resources/tester/test_b10a_rules_visible_b08.py +++ /dev/null @@ -1,46 +0,0 @@ -"""10-A — 우리가 정한 SW 규칙을 화면에 드러내기(B08 몫) · 2026-09-14 사용자 확정 · PLAN 12장. - -훑기(21건) 결과 안 보이거나 반만 보이던 자리만 채움 — 칸 밑 근거 한 줄 · 사유 문구 · 제안 회색. -번호는 PLAN 10장 「✅ 사용자 확정 — SW 규칙」 줄 차례(PLAN 4장 10-A 절에 목록). -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -B08 = ROOT / "B08_Quantity" - - -def _read(name: str) -> str: - return (B08 / name).read_text(encoding="utf-8") - - -def test_3_뒷채움_폭_칸_신설은_B05_등록부_몫() -> None: - assert "칸을 새로 두는 것은 B05 등록부 몫" in _read( - "B08_Quantity_Engine_UnitQuantity_Masonry.py" - ) - - -def test_4_하위_일위대가_깊이_한도를_미리_보인다() -> None: - assert "하위 일위대가는 5단까지" in _read("B08_Quantity_UI_StructureSheet_UnitPrice.ts") - - -def test_5_계수_열과_돌_종류는_다른_축() -> None: - assert "돌 종류는 그대로" in _read("B08_Quantity_UI_StructureSheet_Spec.ts") - - -def test_9_고친_식은_양식과_프로젝트에_묶임() -> None: - assert "제원(높이 등)을 바꿔도 따라감" in _read("B08_Quantity_UI_StructureSheet_Formula.ts") - - -def test_16_양식_반올림은_m당_값에() -> None: - assert "반올림은 m당 값에 걸고" in _read("B08_Quantity_UI_StructureSheet.ts") - - -def test_22_제근_굴착기_크기_칸() -> None: - page = _read("B08_Quantity_UI_Page.ts") - side = _read("B08_Quantity_UI_Side_RootRemoval.ts") - assert "root_removal_excavator_m3: draft.root_removal_excavator_m3" in page - assert "root_removal_excavator_m3: (stored.root_removal_excavator_m3" in page - assert "appendRootRemovalFields(" in page - assert "choices.suggested" in side and "createButton(" in side - assert side.count("= suggested.value") == 2 # 단추 안에서만 넣음(비우면 「안 정함」) diff --git a/resources/tester/test_base_data_source_match.py b/resources/tester/test_base_data_source_match.py deleted file mode 100644 index eeff8f336..000000000 --- a/resources/tester/test_base_data_source_match.py +++ /dev/null @@ -1,588 +0,0 @@ -"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 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"] - # ⚠ 열 자리로 잡지 않는다 — 원문 md 가 고쳐지며 앞에 빈 칸이 늘기도 한다(2026-09-18). - # 둘째 머리줄에서 **이름으로** 칸 자리를 찾는다. - heads = t["rows"][0] - at = {_sq(h): i for i, h in enumerate(heads) if _sq(h)} - series = {} - for row in t["rows"]: - label = _sq(row[0]) - series[label] = { - name: (row[i].split() if i < len(row) else []) - for name, i in at.items() - if name != "횟수별" - } - ratio = doc["reuse_ratio_pct"] - # ⚠ 원문은 **「합 판」 한 줄에 두 열**(재료별% · 노무비%)을 적고, 「각 재」 줄엔 비율이 없다. - # 앞서 각재 자리에 노무비 열이 들어가 있었다(2026-09-18 바로잡음) — 열을 이름으로 맞댄다. - 합판 = {h: [float(x) for x in v] for h, v in series.get("합판", {}).items() if v} - for key, head in (("plywood_material_pct", "재료별(%)"), ("plywood_labor_pct", "노무비(%)")): - data_val = [ratio[key][str(n)] for n in range(1, 7)] - if 합판.get(head) != data_val: - out[f"reuse_ratio_pct/{key}"] = {"md": 합판.get(head), "data": data_val} - # 각재는 **비어 있어야** 한다 — 다른 열을 갖다 쓰면 안 된다 - 각재 = {h: v for h, v in series.get("각재", {}).items() if v} - if 각재 or "timber" in ratio: - out["reuse_ratio_pct/timber"] = { - "md": 각재 or "비율 칸 비어 있음", - "data": ratio.get("timber", "없음(원문에 없어 비워 둠)"), - } - 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]: - """알려진 어긋남 목록. **「할 일 아님」 칸(`_할일_아님`)도 함께 읽는다.** - - ⚠ 왜 합쳐 읽나 — ㉮(원문에 없음·거절 자리·판정으로 닫힘)는 **바퀴마다 다시 세어지지 않게** - 갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인). 그렇다고 비교에서까지 빠지면 그 줄이 다시 - 「새로 어긋남」으로 빨개진다 — 그래서 **세는 곳에서만 빼고 비교에는 넣는다.** - """ - data = json.loads(KNOWN.read_text(encoding="utf-8")) - for kind, rows in ((data.get("_할일_아님") or {}).get("줄") or {}).items(): - data.setdefault(kind, {}).update(rows) - return data - - -@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: - # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). - # ⚠ 「할 일 아님」 칸(`_할일_아님`)은 **제자리에서** 지운다. `load_known()` 이 합쳐 읽은 것을 - # 그대로 되쓰면 2026-09-18 에 가른 칸이 다시 갈래로 섞여 「없는 할 일」이 되살아난다. - current = current_mismatches(load_sources()) - data = json.loads(KNOWN.read_text(encoding="utf-8")) - parked = (data.get("_할일_아님") or {}).get("줄") or {} - - def _keep(kind: str, rows: dict) -> dict: - return {k: v for k, v in rows.items() if current[kind].get(k) == _without_why(v)} - - for kind in FILES: - before = len(data.get(kind) or {}) - data[kind] = _keep(kind, data.get(kind) or {}) - print(f"{kind}: {before} → {len(data[kind])}") - if kind in parked: - was = len(parked[kind]) - parked[kind] = _keep(kind, parked[kind]) - if len(parked[kind]) != was: - print(f" 할 일 아님 {kind}: {was} → {len(parked[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 deleted file mode 100644 index e46698900..000000000 --- a/resources/tester/test_base_prices_source_match.py +++ /dev/null @@ -1,772 +0,0 @@ -"""원문 ↔ 기초단가 다섯 갈래(노임·기계·자재·유가·제비율) 맞대기 — 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", "secondary_fuel_type", "secondary_fuel_rate_l_per_hour", "electric_power_kw") # fmt: skip - - -#: 연료 토막 — `9.0` 경유([주]① ㉰) · `휘발유0.7` · `중유487.2` · 겸용 `73.7+휘발유54.5` · -#: 전력과 겸용 `1.87㎾ 휘발유1.3`. ⚠ 꼴이 다르면 ValueError — 지어 읽지 않는다. -_FUEL_TOKEN_RE = re.compile(r"(휘발유|중유|경유)?(\d+(?:\.\d+)?)(㎾)?") - - -def _fuel(tok: str) -> tuple: - """`(갈래, ℓ/hr, 겸용 갈래, 겸용 ℓ/hr, 전력㎾)`. 값이 없으면 자리마다 `None`.""" - if tok == "-": - return None, None, None, None, None - rest, fuels, power = tok, [], None - for m in _FUEL_TOKEN_RE.finditer(tok): - rest = rest.replace(m.group(0), "", 1) - if m.group(3): # 전력은 ℓ/hr 가 아니다 — 곁값 - power = float(m.group(2)) - continue - kind = {"휘발유": "gasoline", "중유": "heavy_oil", "경유": "diesel", None: "diesel"}[m.group(1)] # fmt: skip - fuels.append((kind, float(m.group(2)))) - if rest.replace("+", "").strip() or not (fuels or power): - raise ValueError(tok) - first = fuels[0] if fuels else (None, None) - second = fuels[1] if len(fuels) > 1 else (None, None) - return first[0], first[1], second[0], second[1], power - - -def _cells(cell: str, n: int) -> list[str]: - """한 칸을 토막 냄 — **빈 칸은 「없음」** 이고 `동력` 낱말은 뗀다. - - 원문 8-4 는 없음을 「-」 로도 쓰고 빈 칸으로도 두며, 전력 기종은 `동력 15.1㎾` 처럼 - 낱말을 붙인다. 빈 칸을 토막 없음으로 두면 줄을 통째로 못 읽어 **원문에 있는 값까지 - 놓친다**(7360-0090 차선 제거기 — 휘발유 5.53 이 원문에 적혀 있음). - """ - toks = cell.replace("동력", " ").split() - return toks or ["-"] * n - - -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["spec_blank"] = set() # 원문이 규격 칸을 비워 둔 줄 — 자료가 채웠는지는 맞대기에서 봄 - 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"가격 줄 {_t(r[1])[:24]}"] = {"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))) - elif "시간당(10-7)" in head: - cols[LOSS_COEF[3]] = head.index("시간당(10-7)") - else: # 원문 표에 계수 칸이 없음(5220·7995 — 머리 「시 간 당(10-7)」 은 표 위 글줄로만 남음) - out["skipped"][f"손료 계수 칸 없는 표 {t['rows'][0][0][:4] if t['rows'] else '?'}"] = {"what": t["headers"]} # fmt: skip - continue - for n, r in enumerate(t["rows"]): - if not r[0]: - continue - codes, prefix = _codes(r[0], prefix) - # `| 9020- | 비항SD | | … |` 꼴 — **앞자리만 알리는 머리줄**이라 값이 애초에 없다. - # 못 읽은 자리가 아니므로 「못 보는 자리」로 세지 않고 앞자리만 이어받고 지난다. - if codes == [] and not any(re.search(r"\d", c) for c in r[1:]): - continue - 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"손료 줄 {_t(r[0])[:24]}"] = {"what": r[0][:40]} - continue - out["loss"].update( - (c, {f: v[k] for f, v in values.items()}) for k, c in enumerate(codes) - ) - # 규격 — 원문 손료표의 「규 격」 칸(분류번호 바로 뒤). 자료는 이 값을 가격·연료 벌에도 함께 씀. - # 토막이 코드 수와 안 맞는 줄(`2,200×5,150 ×1,000` 처럼 규격 안에 빈칸이 든 것)은 안 담는다 — - # 맞대기에서 자료 값으로 갈음되므로 지어 채우는 일이 없다. - specs = r[1].split() - if len(codes) == 1 and specs: # 한 코드 줄은 규격 안에 빈칸이 들어도 칸을 통째로 씀 - specs = [" ".join(specs)] - if len(specs) == len(codes): - for k, c in enumerate(codes): - out["loss"][c]["specification"] = specs[k] - elif len(codes) == 1 and not specs: - # 원문이 그 칸을 **비워 둔** 줄 — 표에서 「위와 같음」으로 읽히는 자리다. - # 자료가 앞 줄 값을 이어받아 채운 줄만 못 보는 자리로 센다(맞대기에서 판다). - out["spec_blank"].add(codes[0]) - for k in range(at["8-3 기계손료"], at["8-4 운전경비"]): - # 머리 없는 **이어 표** — 책장 넘김에 밀린 줄을 빈 줄 뒤에 따로 적어 둔 자리(9020-0022~0200). - # `|---|` 가 없어 표로 잡히지 않아 **추출기도 이 읽기도 못 봄** — 원문에 있는 값이 조용히 빠진다. - cells = [c.strip() for c in lines[k - 1].strip().strip("|").split("|")] - if len(cells) >= 10 and re.fullmatch(r"\d{4}-\d{4}", cells[0] or ""): - # 칸이 열 이상인 것만 — 축약 손료표(5220·7995)는 「계수 칸 없는 표」 로 이미 세고 있다. - if cells[0] not in out["loss"]: - out["skipped"][f"손료 머리 없는 이어 표 {cells[0]}"] = {"what": lines[k - 1][:48]} - 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 - if heads: # 표 위 머리 글줄(`시 간 당(10-7)` 한 줄)은 표로 읽힘 — 뭉개진 글만 - out["skipped"][f"손료 뭉개진 글 {'·'.join(heads)}"] = {"what": heads} - prefix = None - for t in tables: - if not at["8-4 운전경비"] < t["line"] < at["8-5 기계가격"]: - continue - out["fuel_tables"][len(out["fuel_tables"])] = {"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) - # 서브의 칸 토막내기(빈 칸 = 없음 · `동력` 뗌)를 쓰되, ⚠ **코드가 하나면 칸 통째로** — - # `1.87㎾ 휘발유1.3` 처럼 한 값 안에 공백이 온다(2026-09-18 겸용 연료). - cols = [ - [r[i].replace("동력", " ").strip() or "-"] - if len(codes or []) == 1 - else _cells(r[i], len(codes or [])) - for i in (3, 4, 5) - ] - try: - if not codes or any(len(c) != len(codes) for c in cols): - raise ValueError(r[0]) - parsed = [ - (ft[0], ft[1], _num(m), _num(o), ft[2], ft[3], ft[4]) - for f, m, o in zip(*cols) - for ft in (_fuel(f),) - ] - except ValueError: - out["skipped"][f"연료 표 {codes[0] if codes else _t(r[0])[:24]}"] = { - "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 - - -#: 8-1-7 손료보정 표의 기종 글 — 원문이 낱자 사이에 빈칸을 두므로(`불도저( 1 9 톤이상 제외)`) **빈칸을 지워** 맞댄다. -#: 2026-09-18 md 가 제 표로 고쳐져 두 수(암석작업·전석섞인토사)를 **칸에서 바로** 읽는다(전엔 `덤프트럭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", "specification")), - ("손료", "mach_loss_coef", source["loss"], (*LOSS_COLS, *LOSS_COEF, "specification")), - ("연료", "mach_fuel_rate", source["fuel"], (*FUEL_FIELDS, "specification")), - ): - 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}) - # 원문 8-3 에 `(코드) 이름` 머리글이 아예 없어 **이름이 빈** 기종 — 양쪽이 다 비어 조용히 지나간다. - # 표는 있는데 이름만 없는 자리라 세어 둔다(md 에 머리글이 붙으면 저절로 빠짐). - for code, rec in sorted({r["machine_code"]: r for r in v["mach_loss_coef"]["records"]}.items()): - if not rec.get("machine_name") and source["names"].get(code[:4]) is None: - blind[f"기종 이름 없는 머리글 {code[:4]}"] = {"what": code} - # 원문이 규격 칸을 비워 둔 자리에 **자료가 앞 줄 값을 이어받아 채운** 줄 — 원문에 없는 글이라 사람이 볼 자리. - loss_by_code = {r["machine_code"]: r for r in v["mach_loss_coef"]["records"]} - for code in sorted(source.get("spec_blank", ())): - spec = (loss_by_code.get(code) or {}).get("specification") - if spec is not None: - blind[f"규격 빈칸 {code}"] = {"what": spec} - blind.update(source["skipped"]) - tables = v["mach_fuel_rate"]["source_tables"] - for n, st in enumerate( - tables - ): # ⚠ 줄 번호로 안 맞댐 — md 가 고쳐지면 통째로 밀림. 8-4 안 표 차례로 - t = source["fuel_tables"].get(n) - if t != {"headers": st["headers"], "rows": st["rows"]}: - bad[f"운전경비 원표 {n + 1}번째"] = { - "source": t, - "data": {k: st[k] for k in ("headers", "rows")}, - } - 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"])) - rock_rows = {} # 기종 글(빈칸 지움) → (암석작업, 전석섞인토사) - for t in md_tables(source["lines"]): - if [_t(h) for h in t["headers"]][:2] != ["기종", "가산비율"]: - continue - for r in t["rows"]: - try: - rock_rows[_t(r[0])] = (_num(r[1]), _num(r[2])) - except ValueError: # 머리 곁줄(`암석작업(연암…)` · `전석섞인토사`) - continue - for rule in v["mach_rock_adj"]["rules"]: - group = rule["machine_group"] - pair = (rule["rock_work"], rule["boulder_mixed_soil"]) - if group in ROCK: - got = rock_rows.get(ROCK[group]) - else: # 「불도저(19톤이상)은 보정하지 않는다」 — 표에 줄이 없고 [주]①이 0 을 말한다 - got = (0, 0) if "불도저(19톤이상)의경우는보정하지않는다" in text else None - if got != pair: - bad[f"손료보정 {group}"] = {"source": got, "data": list(pair)} - _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", "300억-1000억미만": "30_to_100_billion", "1000억이상": "gte_100_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]: - """알려진 어긋남 목록. **「할 일 아님」 칸(`_할일_아님`)도 함께 읽는다.** - - ⚠ 왜 합쳐 읽나 — ㉮(원문에 없음·거절 자리·판정으로 닫힘)는 **바퀴마다 다시 세어지지 않게** - 갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인). 그렇다고 비교에서까지 빠지면 그 줄이 다시 - 「새로 어긋남」으로 빨개진다 — 그래서 **세는 곳에서만 빼고 비교에는 넣는다.** - """ - data = json.loads(KNOWN.read_text(encoding="utf-8")) - for kind, rows in ((data.get("_할일_아님") or {}).get("줄") or {}).items(): - data.setdefault(kind, {}).update(rows) - return data - - -# ── 시험 ──────────────────────────────────────────────────────────────── -@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: - # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). - # ⚠ 「할 일 아님」 칸(`_할일_아님`)은 **제자리에서** 지운다. `load_known()` 이 합쳐 읽은 것을 - # 그대로 되쓰면 2026-09-18 에 가른 칸이 다시 갈래로 섞여 「없는 할 일」이 되살아난다. - current = current_mismatches(load_sources()) - data = json.loads(KNOWN.read_text(encoding="utf-8")) - parked = (data.get("_할일_아님") or {}).get("줄") or {} - - def _keep(kind: str, rows: dict) -> dict: - return {k: v for k, v in rows.items() if current[kind].get(k) == _without_why(v)} - - for kind in KIND_NAMES: - before = len(data.get(kind) or {}) - data[kind] = _keep(kind, data.get(kind) or {}) - print(f"{KIND_NAMES[kind]}: {before} → {len(data[kind])}") - if kind in parked: - was = len(parked[kind]) - parked[kind] = _keep(kind, parked[kind]) - if len(parked[kind]) != was: - print(f" 할 일 아님 {KIND_NAMES[kind]}: {was} → {len(parked[kind])}") - KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") diff --git a/resources/tester/test_build_mach.py b/resources/tester/test_build_mach.py deleted file mode 100644 index 229e74b40..000000000 --- a/resources/tester/test_build_mach.py +++ /dev/null @@ -1,163 +0,0 @@ -"""기계 원천 벌 뽑기(`_build_mach.py`) 시험 — 2026-09-18 브레인 일감(랩탑 메인). - -못박는 것 - - 기계 자료가 **지금 8장 md 와 같음** — md 가 고쳐졌는데 안 뽑았으면 빨강(뽑기를 돌리라는 뜻). - - ⚠ **줄이 밀려도 값이 그대로** — 자리는 줄 번호가 아니라 **기계 코드**로 잡음. - - 한 칸에 뭉친 줄(`0101-0007 0010 …`)과 줄마다 푼 판이 **같은 값**을 냄(코덱스 8-4 손질이 그 꼴). - - 토막 수가 안 맞으면 **지어내지 않고** `parse_audit` 에 적음. - - 사람이 맞춘 줄(`parse_method`)·손질 사유(`note`)는 **옛 벌에서 이어받음** — ⚠ 원문에 줄이 있는 것만. -금액 불변 — 뽑은 뒤 `test_work_item_key_gate.py` 원가계산서 시험(직접공사비 5,983,724)이 잼. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "resources" / "data_cost_input_value")) - -import _build_mach as build # noqa: E402 - -GLUED = """ -8-3 기계손료(0101) 불도저(무한궤도) - -| 분류 번호 | 규격 (ton) | 내용 시간 | 연간표준 가동시간 | 상각 비율 | 정비 비율 | 연간 관리 비율 | 시 간 당(10-7) | | | | -|---|---|---|---|---|---|---|---|---|---|---| -| | | | | | | | 상각비 계수 | 정비비 계수 | 관리비 계수 | 계 | -| 0101-0007 | 7 | 12,000 | 1,250 | 0.9 | 0.7 | 0.1 | 750 | 583 | 478 | 1,811 | -| 0010 | 10 | 12,000 | 1,250 | 0.9 | 0.7 | 0.1 | 750 | 583 | 478 | 1,811 | - -8-4 운전경비 산정8-4-1 [00]토공기계 - -| 분류번호 | 기계명 | 규격 | 주연료 (ℓ/hr) | 잡재료 (주연료의%) | 조종원 (인/일) | -|---|---|---|---|---|---| -| 0101-0007 0010 | 불 도 저 | 7ton 10 | 9.0 12.5 | 16% 16 | 1 1 | - -8-5 기계가격8-5-1 [00]토공기계 - -| 기 종 | 분류번호 | 가격(₩) | -|---|---|---| -| 불 도 저 ( 무 한 궤 도 ) | 0101-0007 0010 | 76,547 168,668 | -""" -UNGLUED = GLUED.replace( - "| 0101-0007 0010 | 불 도 저 | 7ton 10 | 9.0 12.5 | 16% 16 | 1 1 |", - "| 0101-0007 | 불도저(무한궤도) | 7ton | 9.0 | 16% | 1 |\n| 0010 | | 10 | 12.5 | 16 | 1 |", -) -BARE = { - "sources": [{"path": "x.md", "sha256": "", "role": "primary"}], - "variables": { - "mach_price": {"unit": "thousand_KRW", "key": "machine_code", "records": []}, - "mach_loss_coef": {"unit": "1/hour", "key": "machine_code", "records": []}, - "mach_fuel_rate": {"unit": "L/hour", "key": "machine_code", "parsed_records": [], "source_tables": []}, - }, -} # fmt: skip - - -def _of(doc: dict, key: str, field: str = "records") -> dict: - return {r["machine_code"]: r for r in doc["variables"][key][field]} - - -def _extract(text: str, old: dict | None = None) -> dict: - return build.extract(json.loads(json.dumps(old or BARE)), text.encode("utf-8"))[0] - - -def test_기계_자료가_지금_md_와_같음() -> None: - """md 가 고쳐졌으면 `_build_mach.py` 를 돌릴 것.""" - old = json.loads(build.TARGET.read_text(encoding="utf-8")) - doc, report = build.extract(old, (ROOT / old["sources"][0]["path"]).read_bytes()) - diff = build.diff_report(old, doc) - stale = {k: {n: len(v) for n, v in item.items() if v} for k, item in diff.items() if any(item.values())} # fmt: skip - assert not stale, f"기계 자료가 md 와 다름 — _build_mach.py 돌릴 것: {stale}" - - -def test_줄이_밀려도_값이_그대로() -> None: - doc = _extract(GLUED) - shifted = _extract(GLUED.replace("8-4 운전경비", "보탠 줄\n\n보탠 줄\n\n8-4 운전경비")) - for key, field in (("mach_price", "records"), ("mach_loss_coef", "records"), ("mach_fuel_rate", "parsed_records")): # fmt: skip - assert _of(doc, key, field) == _of(shifted, key, field) - - -def test_뭉친_줄과_풀린_줄이_같은_값() -> None: - """코덱스가 8-4 를 줄마다 풀어 놓아도 값이 같아야 함 — 이름만 원문 표기를 따름.""" - glued, unglued = _of(_extract(GLUED), "mach_fuel_rate", "parsed_records"), _of(_extract(UNGLUED), "mach_fuel_rate", "parsed_records") # fmt: skip - assert set(glued) == set(unglued) == {"0101-0007", "0101-0010"} - for code in glued: - assert {k: v for k, v in glued[code].items() if k != "machine_name"} == { - k: v for k, v in unglued[code].items() if k != "machine_name" - } - assert glued["0101-0010"]["fuel_rate_l_per_hour"] == 12.5 - - -def test_손료_2단_머리를_읽고_계수를_시간당으로() -> None: - loss = _of(_extract(GLUED), "mach_loss_coef")["0101-0007"] - assert loss["source_coefficient_1e_minus_7"] == 1811 - assert loss["loss_coefficient_per_hour"] == 1811 * 1e-7 - assert (loss["economic_life_hours"], loss["depreciation_ratio"], loss["specification"]) == ( - 12000, - 0.9, - "7", - ) - assert loss["machine_name"] == "불도저(무한궤도)" - - -def test_토막_수가_안_맞으면_지어내지_않고_적어_둠() -> None: - broken = GLUED.replace("| 9.0 12.5 |", "| 9.0 |") - doc, report = build.extract(json.loads(json.dumps(BARE)), broken.encode("utf-8")) - assert "0101-0007" not in _of(doc, "mach_fuel_rate", "parsed_records") - assert doc["parse_audit"]["unparsed_fuel_rows"] == 1 # 자료엔 셈만 - assert report["unparsed_rows"]["fuel"] == [ - {"what": "0101-0007 0010", "codes": 2} - ] # 자세한 것은 알림에 - - -def test_사람이_맞춘_줄과_사유는_이어받음() -> None: - """원문에 줄은 있으나 연료를 못 읽은 자리(`1.49㎾` — 칸 밀림)만 이어받음. - - ⚠ 원문에 아예 없는 기종은 안 이어받음 — 옛 벌에만 있는 줄이 자료에 남으면 안 됨. - """ - md = GLUED.replace( - "| 0101-0007 0010 | 불 도 저 | 7ton 10 | 9.0 12.5 | 16% 16 | 1 1 |", - "| 0101-0007 0010 | 불 도 저 | 7ton 10 | 9.0 12.5 | 16% 16 | 1 1 |\n" - "| 7993-0020 | 양수기 | 1.49㎾ | 1.49㎾ | - | - |", - ) - old = json.loads(json.dumps(BARE)) - old["variables"]["mach_fuel_rate"]["parsed_records"] = [ - { - "machine_code": "7993-0020", - "machine_name": "양수기", - "fuel_type": "gasoline", - "fuel_rate_l_per_hour": 1.3, - "parse_method": "verified_source_row_alignment", - }, - { - "machine_code": "9999-0001", - "machine_name": "원문에 없는 기계", - "fuel_type": "diesel", - "fuel_rate_l_per_hour": 4.3, - "parse_method": "verified_source_row_alignment", - }, - ] # fmt: skip - old["variables"]["mach_loss_coef"]["records"] = [ - { - "machine_code": "0101-0007", - "machine_name": "불도저(무한궤도)", - "note": "칸 밀림 바로잡음", - } - ] - doc = _extract(md, old) - fuel = _of(doc, "mach_fuel_rate", "parsed_records") - assert fuel["7993-0020"]["parse_method"] == "verified_source_row_alignment" - assert fuel["7993-0020"]["fuel_rate_l_per_hour"] == 1.3 - assert "9999-0001" not in fuel - assert _of(doc, "mach_loss_coef")["0101-0007"]["note"] == "칸 밀림 바로잡음" - - -def test_사라진_코드는_알림에() -> None: - old = json.loads(json.dumps(BARE)) - old["variables"]["mach_price"]["records"] = [ - {"machine_code": "9999-0001", "machine_name": "옛 기계", "price_thousand_krw": 1} - ] - doc = _extract(GLUED, old) - assert build.diff_report(old, doc)["mach_price"]["사라진 코드"] == ["9999-0001"] diff --git a/resources/tester/test_build_pum.py b/resources/tester/test_build_pum.py deleted file mode 100644 index ea886b90e..000000000 --- a/resources/tester/test_build_pum.py +++ /dev/null @@ -1,109 +0,0 @@ -"""원천 벌 뽑기(`_build_pum.py`) 시험 — 2026-09-18 브레인 일감(랩탑 메인). - -못박는 것 - - 원천 벌이 **지금 md 와 같음** — md 가 고쳐졌는데 안 뽑았으면 빨강(뽑기를 돌리라는 뜻). - - ⚠ **줄이 밀려도 표 번호·절이 그대로** — 이번 막힘(밑수 238 → 105)의 원인 자리. - - 표가 끼어들어도 **뒤 번호가 안 밀림** · 글자만 고쳐진 표는 **제 번호** · 사라진 표는 **알림**. - - ⚠ 머리글이 같은 이웃 표(12-17-1 펌프카 타설 일곱 표)로 **건너뛰지 않음** — 서브가 겪은 사고. -금액 불변 — 뽑은 뒤 공종 마스터를 다시 짓고 `test_work_item_key_gate.py` 원가계산서 시험 -(직접공사비 5,983,724)이 잼. 이 파일은 그 앞 단계(원천 벌 표 번호·절·칸 글자)가 안 흔들림을 잼. -""" - -from __future__ import annotations - -import copy -import json -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "resources" / "data_cost_input_value")) - -import _build_pum as build # noqa: E402 - -FOREST = build.CATALOGS["pum_forest"] - - -@pytest.fixture(scope="module") -def forest() -> dict: - """지금 md 에서 뽑은 산림 벌 — 아래 시험은 이것에 줄을 보태고·표를 끼우고·고치고·뺌.""" - committed = json.loads(FOREST.read_text(encoding="utf-8")) - return build.extract(committed, build._read, "pum_forest")[0] - - -def _lines_of(doc: dict) -> list[str]: - return (ROOT / doc["sources"][0]["path"]).read_text(encoding="utf-8").splitlines() - - -def _reader(lines: list[str]): - return lambda path: "\n".join(lines).encode("utf-8") - - -def _tables(doc: dict) -> list[dict]: - return doc["variables"]["pum"]["tables"] - - -@pytest.mark.parametrize("dataset", list(build.CATALOGS)) -def test_원천_벌이_지금_md_와_같음(dataset: str) -> None: - """md 가 고쳐졌으면 `_build_pum.py` 를 돌릴 것. sha 는 줄끝(CRLF) 따라 PC 마다 갈려 안 봄.""" - old = json.loads(build.CATALOGS[dataset].read_text(encoding="utf-8")) - doc, report = build.extract(old, build._read, dataset) - stale = {k: v for k, v in report.items() if k != "same" and v} - assert _tables(doc) == _tables(old), ( - f"{dataset} 원천 벌이 md 와 다름 — _build_pum.py 돌릴 것: {stale}" - ) - - -def test_줄이_밀려도_표_번호와_절이_그대로(forest: dict) -> None: - lines = _lines_of(forest) - at = next(i for i, x in enumerate(lines) if x.startswith("### 5-1.")) - shifted = lines[:at] + ["보탠 줄"] * 37 + lines[at:] - doc, report = build.extract(forest, _reader(shifted), "pum_forest") - before, after = _tables(forest), _tables(doc) - assert [(t["table_id"], t["section"]) for t in after] == [(t["table_id"], t["section"]) for t in before] # fmt: skip - assert [t["line"] - b["line"] for t, b in zip(after, before)] == [37 if b["line"] > at else 0 for b in before] # fmt: skip - assert report["changed"] == report["new"] == report["removed"] == [] - - -def test_표가_끼어들어도_뒤_번호가_안_밀리고_새_번호를_받음(forest: dict) -> None: - lines = _lines_of(forest) - at = next(i for i, x in enumerate(lines) if x.startswith("### 9-2.")) - inserted = lines[: at + 1] + ["", "| 새 | 표 |", "|---|---|", "| 1 | 2 |", ""] + lines[at + 1 :] - doc, report = build.extract(forest, _reader(inserted), "pum_forest") - ids = [t["table_id"] for t in _tables(doc)] - assert [t["table_id"] for t in _tables(forest)] == [i for i in ids if i != "F0477"] - assert [(n["table_id"], n["section"]) for n in report["new"]] == [("F0477", "9-2. 노선 굴진 보조원")] # fmt: skip - assert report["removed"] == [] and report["changed"] == [] - - -def test_글자만_고쳐진_표는_제_번호_머리글_같은_이웃으로_안_건너뜀(forest: dict) -> None: - """12-17-1 펌프카 타설 — 같은 머리글 표가 줄지어 있음. 가운데 표 한 칸을 고쳐도 번호가 제자리.""" - lines = _lines_of(forest) - target = next(t for t in _tables(forest) if t["table_id"] == "F0359") - row = target["line"] + 1 # 첫 몸 줄(0부터 셈: 머리 line-1 · 구분줄 line · 몸 line+1) - edited = copy.copy(lines) - edited[row] = edited[row].replace("|", "| 고침", 1) # 첫 칸 글자만 바꿈 - doc, report = build.extract(forest, _reader(edited), "pum_forest") - assert [t["table_id"] for t in _tables(doc)] == [t["table_id"] for t in _tables(forest)] - assert report["changed"] == [{"table_id": "F0359", "line": target["line"]}] - - -def test_표가_빠지면_번호를_버리고_알림(forest: dict) -> None: - lines = _lines_of(forest) - target = next(t for t in _tables(forest) if t["table_id"] == "F0358") - end = target["line"] + 1 + len(target["rows"]) # 머리·구분줄·몸 - removed = lines[: target["line"] - 1] + lines[end:] - doc, report = build.extract(forest, _reader(removed), "pum_forest") - ids = [t["table_id"] for t in _tables(doc)] - assert "F0358" not in ids and ids == [t["table_id"] for t in _tables(forest) if t["table_id"] != "F0358"] # fmt: skip - assert [r["table_id"] for r in report["removed"]] == ["F0358"] and report["new"] == [] - - -def test_2단_머리를_펼치지_않음() -> None: - """윗머리 줄은 표 머리(`headers`)로, 아랫머리는 첫 몸 줄로 **글자 그대로** 남음.""" - lines = ["### 8-3-1 손료", "", "| 분류 번호 | 시 간 당(10-7) | |", "|---|---|---|", "| | 상각비 계수 | 계 |", "| 0101-0007 | 1,811 | 2,000 |"] # fmt: skip - (table,) = build.md_tables(lines) - assert table["headers"] == ["분류 번호", "시 간 당(10-7)", ""] - assert table["rows"][0] == ["", "상각비 계수", "계"] diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py deleted file mode 100644 index 67ec9b595..000000000 --- a/resources/tester/test_master_labels_base_prices.py +++ /dev/null @@ -1,238 +0,0 @@ -"""기초단가 다섯 표 — **열 이름이 영문으로 떨어지면 빨강**. - -이 시험이 있는 까닭(2026-09-15 크로스체크) — 세 창이 각자 초록이었는데도 -**26칸이 화면에 영문 key 로 뜨고 있었다.** 서버는 이름표에서 못 찾으면 영문 key 로 -조용히 떨어지게 짜여 있고, 이름표 쪽 시험은 서버가 실제로 보는 자리를 안 봤다. -**어긋나도 아무도 모르는 자리**였다. - -⚠ 그래서 이 시험은 이름표 파일만 읽지 않고 **서버가 내는 응답 그대로** 잰다. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from Z01_MasterData import Z01_MasterData_BasePrices as base_prices - -ROOT = Path(__file__).resolve().parents[2] -LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json" - - -@pytest.fixture(scope="module") -def labels() -> dict: - return json.loads(LABELS_PATH.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def shown() -> dict[str, list[dict]]: - """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다.""" - out = {} - for kind in base_prices.KINDS: - columns = base_prices._columns(kind, base_prices.rows(kind)) - out[kind] = [base_prices.column_meta(kind, c) for c in columns] - return out - - -def test_다섯_표가_다_선다(shown): - # 2026-09-16 기초데이터 아홉이 더해져 열넷(사용자 지시 · 브레인) — 다섯은 그 안에 그대로 - assert {"labor", "machine", "material", "oil", "rate"} <= set(shown) == set(base_prices.KINDS) - for kind, columns in shown.items(): - assert columns, f"{kind} 에 열이 하나도 없다" - - -def test_영문으로_떨어지는_열이_하나도_없다(shown): - """⭐ 이 시험이 이번 판의 값진 자리 — 이름표가 서버 틀과 어긋나면 여기서 빨강.""" - english = [ - f"{kind}/{column['key']}" - for kind, columns in shown.items() - for column in columns - if column["label"] == column["key"] - ] - assert not english, ( - f"한글 이름이 없어 영문 key 로 뜨는 열 {len(english)}개: {english}\n" - "→ 이름표 `merged_tables[].columns` 에 그 key 를 더할 것." - ) - - -def test_열_이름이_비지_않았다(shown): - blank = [ - f"{kind}/{column['key']}" - for kind, columns in shown.items() - for column in columns - if not str(column["label"]).strip() - ] - assert not blank, blank - - -def test_이름표에_묵은_열이_남아_있지_않다(labels, shown): - """서버가 안 내는 열을 이름표가 들고 있으면 옛 틀이 굳은 것이다.""" - stale = [] - for entry in labels["merged_tables"]: - kind = entry["key"] - live = {c["key"] for c in shown.get(kind, ())} - for column in entry["columns"]: - if column["key"] not in live: - stale.append(f"{kind}/{column['key']}") - assert not stale, f"서버가 안 내는 묵은 열 이름표: {stale}" - - -def test_요율_축이_한_칸으로_안_합쳐졌다(shown): - """⭐ 2026-09-15 브레인 ④ — 합치면 어느 축의 구간인지 사라지고 `@id` 도 흔들린다. - - 축은 서버가 원문 칸 그대로 펴고 이름표가 거기에 맞춘다. - """ - keys = {column["key"] for column in shown["rate"]} - for axis in ( - "grade", - "year", - "work_type", - "target_amount_bracket", - "estimated_amount_bracket", - "estimated_price_bracket", - "direct_cost_bracket", - "duration_bracket", - ): - assert axis in keys, f"요율 축 {axis} 가 사라졌다" - - -def test_요율_줄_열쇠에_축이_살아_있다(): - """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다.""" - rows = base_prices.rows("rate") - multi = [r for r in rows if "/" in str(r["@id"])] - assert multi, "축이 든 줄 열쇠가 하나도 없다" - assert any("=" in str(r["@id"]) for r in multi) - assert len({r["@id"] for r in rows}) == len(rows), "줄 열쇠가 겹친다" - - -def test_기계_시간당_단가는_계산값으로_잠긴다(shown): - """밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다).""" - meta = base_prices.table("machine", page=1, size=1) - assert set(meta["formula"]) == { - "hourly_loss_krw", - "hourly_fuel_krw", - "hourly_operator_krw", - "hourly_total_krw", - } - for key in meta["formula"]: - assert key not in meta["editable"], f"{key} 는 계산값인데 고칠 수 있다" - - -def test_이름표는_고칠수있나를_안_정한다(labels): - """판정 두 벌 금지 — `editable` 은 서버만 정한다.""" - carried = [ - f"{entry['key']}/{column['key']}" - for entry in labels["merged_tables"] - for column in entry["columns"] - if "editable" in column - ] - assert not carried, f"이름표가 고칠 수 있나를 들고 있다: {carried}" - - -def test_단위가_붙어야_할_열에_단위가_있다(shown): - """「유가 1,858」만 떠서는 리터인지 드럼인지 모른다 — 금액·수량 열은 단위를 단다.""" - want = { - "labor": {"daily_wage_krw": "원", "hours_per_day": "시간"}, - "material": {"price_krw": "원"}, - "oil": {"price_krw_per_l": "원/L"}, - "machine": { - "price_thousand_krw": "천원", - "fuel_rate_l_per_hour": "L", - "fuel_price_krw_per_l": "원/L", - "hourly_total_krw": "원", - }, - "rate": {"rate_percent": "%", "base_amount_krw": "원"}, - } - bad = [] - for kind, expected in want.items(): - units = {c["key"]: c["unit"] for c in shown[kind]} - for key, unit in expected.items(): - if units.get(key) != unit: - bad.append(f"{kind}/{key}: {units.get(key)!r} ≠ {unit!r}") - assert not bad, bad - - -def test_줄_열쇠가_서버_축과_같다(labels): - """⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다. - - 이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다. - """ - declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]} - bad = [] - for kind in base_prices.KINDS: - axes = set() - for row in base_prices.rows(kind): - axes |= set(row.get("@axis") or ()) - if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다 - continue - missing = axes - declared.get(kind, set()) - if missing: - bad.append(f"{kind}: 이름표에 없는 축 {sorted(missing)}") - assert not bad, bad - - -def test_구간_이름은_원문_표기_그대로다(): - """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다.""" - for kind in ("masonry_slope", "masonry_back_length"): - brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)} - assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets)) - classes = { - str(r.get("class")) - for r in base_prices.rows("masonry_class") - if r.get("table") == "boulder_diameter" - } - assert classes == {"40~60", "60~80", "80~100"}, sorted(classes) - - -#: ⚠ **줄마다 축이기도 값이기도 한 칸** — `formwork_reuse.reuse_count` 는 -#: `reuse_by_class` 줄에서는 **고치는 값**(갈래별 사용횟수)이고 -#: `reuse_ratio_pct` 줄에서는 **축**(몇 회째인가)이다. 서버는 열 하나에 한 판정만 내므로 -#: 지금은 고칠 수 있는 칸으로 서 있다. 비율표 줄에서 이 칸을 고치면 **다른 줄로 옮겨 간다**. -#: 2026-09-16 크로스체크에서 드러났고 판정 대기 — 고치기 전까지 여기 예외로 적어 둔다. -AXIS_ALSO_VALUE = {"formwork_reuse/reuse_count"} - - -def test_축_칸은_못_고친다(): - """축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다.""" - bad = [] - for kind in base_prices.KINDS: - rows = base_prices.rows(kind) - axes = set() - for row in rows: - axes |= set(row.get("@axis") or ()) - if not axes: - continue - meta = base_prices.table(kind, page=1, size=1) - for axis in axes: - if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE: - bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다") - assert not bad, bad - - -def test_기초단가_상자_이름이_이름표에_있다(labels): - """⭐ 서버가 내는 갈래(`group`)마다 한글 이름이 있어야 화면이 영문으로 안 뜬다. - - 서버가 읽을 자리는 `labels["base_price_groups"][group]["name_ko"]` 다 — - 두 창이 서로 다른 자리를 보면 2026-09-15 의 26칸 사고가 되풀이된다. - """ - named = labels["base_price_groups"] - groups = {base_prices.kind_group(kind) for kind in base_prices.KINDS} - missing = groups - set(named) - assert not missing, f"이름표에 없는 갈래: {sorted(missing)}" - for key, entry in named.items(): - assert entry["name_ko"].strip(), key - assert entry["summary"].strip(), key - assert named["base_price"]["name_ko"] == "기초단가" - assert named["pumsem_basis"]["name_ko"] == "품셈 기준" - - -def test_상자마다_kind_가_있다(): - """빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다.""" - counted: dict[str, int] = {} - for entry in base_prices.kinds(): - counted[entry["group"]] = counted.get(entry["group"], 0) + 1 - assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다" - assert entry["rows"] > 0, entry["kind"] - assert all(n > 0 for n in counted.values()), counted diff --git a/resources/tester/test_master_labels_cover.py b/resources/tester/test_master_labels_cover.py deleted file mode 100644 index d8f95f193..000000000 --- a/resources/tester/test_master_labels_cover.py +++ /dev/null @@ -1,593 +0,0 @@ -"""마스터 이름표(`resources/data_master_labels/labels_2026-01-01.json`) 덮임 시험. - -이름표는 **값을 안 담고 이름만 담는다** — 그래서 마스터가 늘거나 열이 바뀌면 -이름표가 조용히 뒤처진다. 이 시험이 그 어긋남을 잡는다. - -- 마스터에 있는데 이름표에 없는 표·열 → 빨강 -- 이름표에 있는데 마스터에 없는 표·열(묵은 이름표) → 빨강 -- 갈래 나눔은 2026-09-15 브레인 갈래표(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1)와 대조 -""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json" - -#: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다. -#: 2026-09-16 사용자 판정 — `structure_unit_observed`(울진소광 한 현장 관찰값)와 -#: `revetment_sabang`(교본값에 설명 글 섞임)은 기초데이터가 아니라 **참고 사례**다. -BRAIN_KIND_COUNTS = { - "logic": 19, - "base_value": 9, - "byproduct": 6, - "reference": 2, - "seed": 1, -} -MANIFEST_PREFIX = "manifest_" - -META_KEYS = { - "schema_version", - "dataset_id", - "effective_date", - "generated_at", - "publication_date", - "survey_month", - "pum_edition", - "dataset_version", - "source_master_file", - "source_dataset_version", -} - - -@pytest.fixture(scope="module") -def labels() -> dict: - return json.loads(LABELS_PATH.read_text(encoding="utf-8")) - - -def _is_table(node) -> str | None: - """이름표 생성기와 **같은 잣대** — 화면이 표로 그릴 마디인가.""" - if isinstance(node, list) and node and all(isinstance(r, dict) for r in node): - return "list" - if isinstance(node, dict): - values = list(node.values()) - dicts = [v for v in values if isinstance(v, dict)] - if ( - len(values) >= 2 - and len(dicts) == len(values) - and not any( - isinstance(v.get("records"), list) or isinstance(v.get("rows"), list) for v in dicts - ) - ): - keysets = [frozenset(v.keys()) for v in dicts] - if len(set(keysets)) <= max(2, len(keysets) // 3 + 1): - return "map" - return None - - -def _has_table(node, depth: int = 0) -> bool: - if _is_table(node): - return True - if depth > 5 or not isinstance(node, dict): - return False - return any(_has_table(v, depth + 1) for v in node.values()) - - -def _collect(node, path, tables, values, depth: int = 0) -> None: - shape = _is_table(node) - if shape: - tables.append(("/".join(path), shape, node)) - return - if depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()): - for key, value in node.items(): - _collect(value, path + [key], tables, values, depth + 1) - return - values.append("/".join(path)) - - -def _scan(path: Path) -> tuple[list, list]: - doc = json.loads(path.read_text(encoding="utf-8")) - tables, values = [], [] - for key, value in doc.items(): - if key in META_KEYS: - continue - if key == "variables" and isinstance(value, dict) and not _is_table(value): - for sub_key, sub in value.items(): - _collect(sub, ["variables", sub_key], tables, values) - else: - _collect(value, [key], tables, values) - return tables, values - - -#: 사전 모양 표의 **줄 이름 열** — API 가 이 이름으로 내보낸다(2026-09-15 브레인 ①). -ROW_KEY_COLUMN = "@key" - - -def _columns_of(node, shape: str) -> list[str]: - records = node if shape == "list" else list(node.values()) - seen: list[str] = [ROW_KEY_COLUMN] if shape == "map" else [] - for record in records: - if not isinstance(record, dict): - continue - for key in record: - if key not in seen: - seen.append(key) - return seen - - -def test_이름표_파일이_읽힌다(labels): - assert labels["dataset_id"] == "data_master_labels" - assert labels["files"], "이름표에 파일이 하나도 없다" - - -def test_이름표가_가리키는_파일이_다_있다(labels): - missing = [f["path"] for f in labels["files"] if not (ROOT / f["path"]).is_file()] - assert not missing, f"이름표가 없는 파일을 가리킨다: {missing}" - - -def test_갈래_나눔이_브레인_표와_같다(labels): - counted: dict[str, int] = {} - for entry in labels["files"]: - if entry["file_id"].startswith(MANIFEST_PREFIX): - continue - counted[entry["kind"]] = counted.get(entry["kind"], 0) + 1 - assert counted == BRAIN_KIND_COUNTS - # 2026-09-17 31 → 37 — 공종 축 산출 일곱을 이름표에 올림(건설 마스터·미판정·밑수 · 열쇠 장부 둘 · - # 구실 잣대 · 건설 폴더 지문). 지문은 manifest 라 이 셈에서 빠진다. - assert sum(counted.values()) == 37 - - -def test_갈래_이름이_다_풀려_있다(labels): - known = set(labels["kinds"]) - used = {f["kind"] for f in labels["files"]} - assert used <= known, f"뜻을 안 적은 갈래: {sorted(used - known)}" - - -def test_폴더_지문은_부산물이_아니다(labels): - """`_manifest` 는 계산이 남긴 기록이 아니라 **어느 판으로 셈했는지 못박는 표**다 — - 「정본 아님」 딱지가 붙으면 안 된다(2026-09-15 브레인 ②).""" - manifests = [f for f in labels["files"] if f["file_id"].startswith(MANIFEST_PREFIX)] - assert manifests, "폴더 지문이 이름표에 하나도 없다" - for entry in manifests: - assert entry["kind"] == "fingerprint", f"{entry['file_id']} 갈래가 {entry['kind']} 다" - assert "정본" not in labels["kinds"]["fingerprint"]["summary"] - - -def test_마스터의_표가_이름표에_다_있다(labels): - missing = [] - for entry in labels["files"]: - tables, _ = _scan(ROOT / entry["path"]) - labelled = {t["key"] for t in entry["tables"]} - for key, _shape, _node in tables: - if key not in labelled: - missing.append(f"{entry['file_id']}::{key}") - assert not missing, f"이름표에 없는 표: {missing}" - - -def test_이름표의_표가_마스터에_다_있다(labels): - stale = [] - for entry in labels["files"]: - tables, _ = _scan(ROOT / entry["path"]) - actual = {key for key, _s, _n in tables} - for table in entry["tables"]: - if table["key"] not in actual: - stale.append(f"{entry['file_id']}::{table['key']}") - assert not stale, f"마스터에 없는 묵은 이름표: {stale}" - - -def test_마스터의_값_묶음이_이름표에_다_있다(labels): - missing = [] - for entry in labels["files"]: - _tables, values = _scan(ROOT / entry["path"]) - labelled = {v["key"] for v in entry["value_groups"]} - for key in values: - if key not in labelled: - missing.append(f"{entry['file_id']}::{key}") - assert not missing, f"이름표에 없는 값 묶음: {missing}" - - -def test_표_이름이_비지_않았다(labels): - blank = [ - f"{entry['file_id']}::{table['key']}" - for entry in labels["files"] - for table in entry["tables"] - if not table["name_ko"].strip() - ] - assert not blank, f"한글 이름이 빈 표: {blank}" - - -def test_이름이_영문으로_떨어진_칸이_없다(labels): - """⚠ 빈칸이 아니라 **영문 키가 그대로 이름 자리에 앉은** 것을 잡는다(2026-09-17 데스크탑 서브). - - 새 파일을 이름표에 올릴 때 이름을 못 지으면 키를 그대로 베끼기 쉬운데, 그러면 화면에 - `general_provision_roots` 같은 영문이 뜬다. 한글이 한 글자도 없으면 이름을 안 지은 것이다. - """ - hangul = re.compile(r"[가-힣]") - bad = [] - for entry in labels["files"]: - for table in entry["tables"]: - for column in table["columns"]: - if not hangul.search(column["name_ko"]): - bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}") - if not hangul.search(table["name_ko"]): - bad.append(f"{entry['file_id']}::{table['key']}") - for group in entry["value_groups"]: - if not hangul.search(group["name_ko"]): - bad.append(f"{entry['file_id']}::{group['key']}") - for merged in labels["merged_tables"]: - for column in merged["columns"]: - if not hangul.search(column["name_ko"]): - bad.append(f"{merged['key']}/{column['key']}") - assert not bad, f"영문으로 뜨는 칸: {bad}" - - -def test_값_묶음_이름이_비지_않았다(labels): - blank = [ - f"{entry['file_id']}::{group['key']}" - for entry in labels["files"] - for group in entry["value_groups"] - if not group["name_ko"].strip() - ] - assert not blank, f"한글 이름이 빈 값 묶음: {blank}" - - -def test_마스터의_열이_이름표에_다_있다(labels): - missing = [] - for entry in labels["files"]: - tables, _ = _scan(ROOT / entry["path"]) - by_key = {t["key"]: t for t in entry["tables"]} - for key, shape, node in tables: - table = by_key.get(key) - if table is None: - continue - labelled = {c["key"] for c in table["columns"]} - for column in _columns_of(node, shape): - if column not in labelled: - missing.append(f"{entry['file_id']}::{key}/{column}") - assert not missing, f"이름표에 없는 열: {missing}" - - -def test_이름표의_열이_마스터에_다_있다(labels): - stale = [] - for entry in labels["files"]: - tables, _ = _scan(ROOT / entry["path"]) - actual = {key: _columns_of(node, shape) for key, shape, node in tables} - for table in entry["tables"]: - known = set(actual.get(table["key"], ())) - for column in table["columns"]: - if column["key"] not in known: - stale.append(f"{entry['file_id']}::{table['key']}/{column['key']}") - assert not stale, f"마스터에 없는 묵은 열 이름표: {stale}" - - -def test_열_이름은_붙었거나_사유가_있다(labels): - bad = [] - for entry in labels["files"]: - for table in entry["tables"]: - for column in table["columns"]: - if column["name_ko"].strip(): - continue - if not column.get("unknown_reason", "").strip(): - bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}") - assert not bad, f"이름도 사유도 없는 열: {bad}" - - -def test_이름_없는_열은_unknown_에도_적혀_있다(labels): - listed = {u["where"] for u in labels["unknown"]} - for entry in labels["files"]: - for table in entry["tables"]: - for column in table["columns"]: - if column["name_ko"].strip(): - continue - where = f"{entry['file_id']}::{table['key']}/{column['key']}" - assert where in listed, f"unknown 에 안 적힌 빈 열: {where}" - - -def test_찾는_차례가_적혀_있다(labels): - order = labels["lookup_order"]["column"] - assert order[0].startswith("column_overrides") - assert order[1].startswith("columns") - - -def test_덮어쓰기_이름표가_다_쓰인다(labels): - """`column_overrides` 는 「파일id/열key」 꼴이고 그 파일에 실제로 그 열이 있어야 한다.""" - by_file = {} - for entry in labels["files"]: - cols = set() - for table in entry["tables"]: - cols |= {c["key"] for c in table["columns"]} - by_file[entry["file_id"]] = cols - dangling = [] - for key in labels["column_overrides"]: - file_id, _, column = key.partition("/") - if column not in by_file.get(file_id, set()): - dangling.append(key) - assert not dangling, f"쓰이지 않는 열 덮어쓰기: {dangling}" - - -def test_세어_둔_수가_실제와_같다(labels): - counts = labels["counts"] - assert counts["files"] == len(labels["files"]) - assert counts["tables"] == sum(len(f["tables"]) for f in labels["files"]) - assert counts["columns"] == sum(len(t["columns"]) for f in labels["files"] for t in f["tables"]) - assert counts["value_groups"] == sum(len(f["value_groups"]) for f in labels["files"]) - assert counts["unknown"] == len(labels["unknown"]) - - -def test_강우_IDF_캐시는_안_담았다(labels): - assert not [f for f in labels["files"] if "rainfall" in f["path"]] - - -def test_사전_모양_표에는_줄_이름_열이_있다(labels): - """`fx`·`oil` 처럼 사전 모양인 표는 **줄 이름(키) 자체가 한 열**이다. - 그 열이 없으면 화면에 영문 `@key` 로 뜬다(2026-09-15 브레인 ①).""" - bad = [] - for entry in labels["files"]: - for table in entry["tables"]: - if table["shape"] != "map": - continue - first = table["columns"][0] if table["columns"] else None - if first is None or first["key"] != ROW_KEY_COLUMN: - bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열 없음") - elif not first["name_ko"].strip(): - bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열에 한글 이름 없음") - elif not first.get("is_row_key"): - bad.append(f"{entry['file_id']}::{table['key']} — is_row_key 표시 없음") - assert not bad, f"줄 이름 열 문제: {bad}" - - -def test_줄_이름_열은_사전_모양_표에만_있다(labels): - stray = [ - f"{entry['file_id']}::{table['key']}" - for entry in labels["files"] - for table in entry["tables"] - if table["shape"] != "map" and any(c["key"] == ROW_KEY_COLUMN for c in table["columns"]) - ] - assert not stray, f"사전 모양이 아닌데 줄 이름 열이 있다: {stray}" - - -def test_줄_이름_열의_이름이_표마다_제_것이다(labels): - """뜻이 표마다 다르므로 한 이름으로 뭉치면 안 된다 — 같은 이름이 겹치면 살펴볼 것.""" - names = [ - column["name_ko"] - for entry in labels["files"] - for table in entry["tables"] - for column in table["columns"] - if column["key"] == ROW_KEY_COLUMN - ] - assert names, "줄 이름 열이 하나도 없다" - # 「출처 키」만 두 표가 같은 뜻으로 쓴다(출처 사전 둘). 그 밖은 겹치지 않는다. - duplicated = {n for n in names if names.count(n) > 1} - assert duplicated <= {"출처 키"}, f"줄 이름이 겹친다: {sorted(duplicated)}" - - -def test_값_묶음마다_kind_가_낱말로_적혀_있다(labels): - known = set(labels["value_kinds"]) - bad = [] - for entry in labels["files"]: - for group in entry["value_groups"]: - if group.get("kind") not in known: - bad.append(f"{entry['file_id']}::{group['key']} — {group.get('role')!r}") - assert not bad, f"값·설명이 안 갈린 값 묶음: {bad}" - - -def test_값_갈래_잣대와_낱값_마디가_적혀_있다(labels): - rule = labels["policy"]["value_kind_rule"] - assert "금액이 움직이나" in rule - assert labels["policy"]["row_key_column"] - node = labels["synthetic_tables"]["@values"] - assert node["name_ko"] == "낱값" - assert node["summary"] == "표가 아닌 한 값들" - assert [c["key"] for c in node["columns"]] == ["@name", "@value", "@path", "@undecided"] - assert [c["name_ko"] for c in node["columns"]] == ["항목", "값", "원문 자리", "미판정"] - assert node["columns"][2]["visible"] is False - - -def test_값_갈래는_참거짓이_아니라_낱말이다(labels): - """수식 같은 갈래가 늘면 낱말만 더하면 된다 — 참·거짓이면 그때 뜻이 뒤집힌다.""" - for entry in labels["files"]: - for group in entry["value_groups"]: - assert isinstance(group["kind"], str), f"{entry['file_id']}::{group['key']}" - assert set(labels["value_kinds"]) == {"value", "doc"} - - -def test_한_값짜리_요율은_값으로_선다(labels): - """산재 3.56 % 처럼 원가에 그대로 드는 요율이 설명으로 숨으면 트리에서 사라진다.""" - rates = next(f for f in labels["files"] if f["file_id"] == "rates") - by_key = {g["key"]: g["kind"] for g in rates["value_groups"]} - for key in ( - "variables/rate_sanjae", - "variables/rate_health", - "variables/rate_care", - "variables/rate_vat", - "variables/rate_retirement_mutual_aid", - "variables/rate_wage_claim_contribution", - "variables/rate_asbestos_contribution", - ): - assert by_key.get(key) == "value", f"{key} 가 값으로 안 섰다" - assert by_key.get("processing_rules") == "doc" - - -def test_세어_둔_값_갈래_수가_실제와_같다(labels): - counted: dict[str, int] = {} - for entry in labels["files"]: - for group in entry["value_groups"]: - counted[group["kind"]] = counted.get(group["kind"], 0) + 1 - assert counted == labels["counts"]["value_groups_by_kind"] - - -def test_곁값은_값_쪽에_선다(labels): - """값 단위·기준일·걸치는 범위는 **없으면 값의 뜻이 안 선다** — 설명으로 숨기면 - 「유가 1,858」만 떠서 리터인지 드럼인지, 언제 값인지 모른다(2026-09-15 브레인 ②).""" - want = { - "coef": ("variables/surcharge_labor/unit", "variables/surcharge_mat/unit"), - "mach_base": ( - "variables/mach_fuel_rate/unit", - "variables/mach_loss_coef/unit", - "variables/mach_price/unit", - "variables/mach_rock_adj/unit", - ), - "oil_regional": ( - "variables/oil_diesel/unit", - "variables/oil_gasoline/unit", - "variables/oil_diesel/date", - "variables/oil_gasoline/date", - "variables/oil_diesel/scope", - "variables/oil_gasoline/scope", - ), - "revetment_sabang": ("scope",), - "timber_structure_class": ("basis_unit",), - "rates": ( - "variables/rate_equipment_payment_guarantee/base_note", - "variables/rate_environment/forest_road_selection_status", - "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", - ), - } - by_file = { - f["file_id"]: {g["key"]: g["kind"] for g in f["value_groups"]} for f in labels["files"] - } - bad = [] - for file_id, keys in want.items(): - for key in keys: - if by_file.get(file_id, {}).get(key) != "value": - bad.append(f"{file_id}::{key}") - assert not bad, f"곁값이 설명으로 숨었다: {bad}" - # 브레인 쪽지는 「16 쯤」이라 적었으나 짚어 준 자리를 세면 17 이다 - # (unit 8 · date 2 · scope 3 · basis_unit · 밑수 비고 · 요율 적용여부 2). - assert sum(len(v) for v in want.values()) == 17 - - -def test_미결은_미판정으로_표시된다(labels): - """값도 설명도 아닌 「미결」류 — 사용자가 트리를 본 뒤 정한다. 갈래를 임의로 못박지 않는다.""" - marked = { - f"{f['file_id']}::{g['key']}" - for f in labels["files"] - for g in f["value_groups"] - if g.get("undecided") - } | { - f"{f['file_id']}::{t['key']}" - for f in labels["files"] - for t in f["tables"] - if t.get("undecided") - } - assert marked == { - "timber_structure_class::pending_user", - "material_surcharge::not_found", - "material_surcharge::candidates_pending_user/items", - } - column = labels["synthetic_tables"]["@values"]["columns"][3] - assert column["key"] == "@undecided" - assert column["name_ko"] == "미판정" - - -#: 2026-09-16 사용자 판정 — 기초데이터 아홉이 더해져 열넷. -#: 곧이어 코드에 박혀 있던 기계 시공능력을 꺼내 열다섯(브레인 b6fb3725). -#: 잣대 = 계산되는 값이 아니라 **그냥 값만으로 정의되는 데이터셋**. -MERGED_KEYS = ( - "labor", - "material", - "oil", - "rate", - "machine", - "coef", - "material_surcharge", - "formwork_reuse", - "rebar_complexity", - "timber_structure_class", - "masonry_slope", - "masonry_back_length", - "stone_kind", - "masonry_class", - "machine_productivity", - "work_item_forest", # 2026-09-17 공종 축 산림 - "work_item_const", # 2026-09-17 공종 축 건설 -) - - -def test_기초데이터_열넷이_다_있다(labels): - merged = {m["key"]: m for m in labels["merged_tables"]} - assert set(merged) == set(MERGED_KEYS) - for entry in merged.values(): - assert entry["name_ko"].strip() - assert entry["summary"].strip() - assert entry["columns"], entry["key"] - assert entry["sources"], entry["key"] - - -def test_합친_표의_열마다_한글_이름이_있다(labels): - """⚠ 「고칠 수 있나」는 여기서 안 정한다 — 서버 `spec()` 한 곳(판정 두 벌 금지). - 영문으로 떨어지는 열이 있는지는 `test_master_labels_base_prices.py` 가 응답으로 잰다.""" - bad = [ - f"{entry['key']}/{column['key']}" - for entry in labels["merged_tables"] - for column in entry["columns"] - if not column["name_ko"].strip() - ] - assert not bad, bad - - -def test_기계만_다른_표의_값을_문다(labels): - """⭐ 기계 시간당 단가는 유가·노임 표를 물어 나온다 — 갱신 차례가 여기서 갈린다.""" - merged = {m["key"]: m for m in labels["merged_tables"]} - machine = {c["key"]: c for c in merged["machine"]["columns"]} - for key in ("fuel_price_krw_per_l", "operator_daily_wage_krw"): - assert key in machine, f"{key} 이름표가 없다" - assert "표" in machine[key].get("from", ""), f"{key} 가 어디서 오는지 안 적혔다" - for key in ("labor", "material", "oil", "rate"): - assert len(merged[key]["sources"]) <= 3, key - - -def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels): - chain = labels["machine_cost_chain"] - assert len(chain["steps"]) == 5 - for step in chain["steps"]: - assert step["step"].strip() and step["formula"].strip() - assert "밑값" in chain["editable_rule"] and "계산값" in chain["editable_rule"] - # 기계는 유가·노임 표를 문다 — 갱신 차례가 여기서 갈린다. - assert "유가" in chain["cross_table"] and "노임" in chain["cross_table"] - - -def test_열_맞대기_어긋남이_네_갈래로_적혀_있다(labels): - findings = labels["merge_findings"] - assert set(findings) == { - "renamed_same_column", - "one_side_only", - "unit_or_axis_mismatch", - "collision", - } - assert all(findings[k] for k in findings) - - -def test_줄_열쇠가_다섯_다_판정돼_있다(labels): - """덮개가 원본 줄을 붙드는 끈 — 없는 자료는 「없음」으로 내고 지어내지 않는다.""" - keys = {r["table"]: r for r in labels["row_keys"]} - assert set(keys) == set(MERGED_KEYS) - for entry in keys.values(): - assert entry["verdict"].strip() and entry["key"] and entry["checked"].strip() - assert keys["material"]["key"] == ["item_code"] - assert keys["machine"]["key"] == ["machine_code"] - # 한 칸으로는 안 되는 둘 — 묶어야 한다. - assert len(keys["labor"]["key"]) > 1 - assert keys["oil"]["verdict"].startswith("없음") - assert keys["rate"]["verdict"].startswith("없음") - - -def test_null_이_제값인_열이_적혀_있다(labels): - """되돌리기 null 과 원본의 「값 없음」이 겹치는 자리 — 겹치면 미조사가 값 지움으로 바뀐다.""" - block = labels["null_is_real"] - assert block["columns"] - for row in block["columns"]: - assert row["table"] in MERGED_KEYS - assert row["column"].strip() and row["why"].strip() - assert isinstance(row["rows"], int) - # 가장 위험한 자리 — 노임 일 노임이 없는 줄(미조사) - wage = [ - r for r in block["columns"] if r["table"] == "labor" and "daily_wage_krw" in r["column"] - ] - assert wage and wage[0]["rows"] == 30 - assert "되돌리기" in block["rule"] diff --git a/resources/tester/test_master_sources.py b/resources/tester/test_master_sources.py deleted file mode 100644 index ec2a25c13..000000000 --- a/resources/tester/test_master_sources.py +++ /dev/null @@ -1,128 +0,0 @@ -"""자료 출처표(`resources/data_master_sources/sources_2026-09-15.json`) 시험. - -이 표가 있는 까닭 — 마스터 파일 안에는 **제 판만** 있고 「최신이 무엇인지·어디서 받는지」가 -없어서 건설 노임이 반 년 뒤처진 것을 아무도 몰랐다(2026-09-15). - -- `our_edition` 이 **실제 파일과 어긋나면 빨강** — 손으로 적힌 판이 굳는 것을 막는다 -- 뒤처짐 판정이 뒤집히면 빨강 -- 받는 자리를 모르는 줄에 **사유가 없으면 빨강**(주소를 지어내지 않기로 한 자리) -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCES_PATH = ROOT / "resources" / "data_master_sources" / "sources_2026-09-15.json" - - -@pytest.fixture(scope="module") -def sources() -> dict: - return json.loads(SOURCES_PATH.read_text(encoding="utf-8")) - - -def _edition_of(path: Path) -> tuple[str, str]: - doc = json.loads(path.read_text(encoding="utf-8")) - if doc.get("effective_date"): - return doc["effective_date"], "effective_date" - derived = doc.get("derived_from") - if isinstance(derived, dict) and derived.get("effective_date"): - return derived["effective_date"], "derived_from.effective_date" - return "", "" - - -def test_출처표가_읽힌다(sources): - assert sources["dataset_id"] == "data_master_sources" - assert sources["sources"], "출처가 하나도 없다" - - -def test_가리키는_파일이_다_있다(sources): - missing = [ - path for row in sources["sources"] for path in row["files"] if not (ROOT / path).is_file() - ] - assert not missing, f"없는 파일을 가리킨다: {missing}" - - -def test_우리_판이_실제_파일과_같다(sources): - """⭐ 손으로 적힌 판이 굳으면 또 뒤처진다 — 파일에서 읽은 값과 대 본다.""" - wrong = [] - for row in sources["sources"]: - edition, where = _edition_of(ROOT / row["files"][0]) - if row["our_edition"] != edition: - wrong.append(f"{row['source_id']}: 표 {row['our_edition']} ≠ 파일 {edition}") - elif row["our_edition_from"] != where: - wrong.append(f"{row['source_id']}: 읽은 자리 {row['our_edition_from']} ≠ {where}") - assert not wrong, wrong - - -def test_뒤처짐_판정이_맞다(sources): - for row in sources["sources"]: - latest, ours = row["latest_published"], row["our_edition"] - if not latest or not ours: - assert row["status"] == "unknown", row["source_id"] - elif latest > ours: - assert row["status"] == "behind", row["source_id"] - else: - assert row["status"] == "current", row["source_id"] - - -def test_건설_노임이_뒤처진_것으로_선다(sources): - """2026-09-15 에 드러난 자리 — 우리 2026-01-01, 최신 2026-09-01.""" - row = next(r for r in sources["sources"] if r["source_id"] == "labor_const") - assert row["status"] == "behind" - assert row["our_edition"] == "2026-01-01" - assert row["latest_published"] == "2026-09-01" - # 주기를 7월 1일로 알던 것이 틀렸다 — 1.1 / 9.1 이다. - assert "9월 1일" in row["cycle"] - assert row["where_to_get"], "받는 자리를 받았는데 비어 있다" - - -def test_운전경비는_판_모름이_아니다(sources): - """파일 안 `derived_from.effective_date` 에 판이 박혀 있다 — 「모름」으로 적으면 안 된다.""" - row = next(r for r in sources["sources"] if r["source_id"] == "machine_operating") - assert row["our_edition"] == "2026-01-01" - assert row["our_edition_from"] == "derived_from.effective_date" - assert row["status"] != "unknown" - - -def test_모르는_칸에는_사유가_있다(sources): - """주소·최신 공표일을 지어내지 않기로 한 자리 — 빈 칸이면 사유가 있어야 한다.""" - bad = [] - for row in sources["sources"]: - if not row["where_to_get"] and not row.get("where_to_get_missing_reason"): - bad.append(f"{row['source_id']} — 받는 자리 사유 없음") - if not row["latest_published"] and not row.get("latest_missing_reason"): - bad.append(f"{row['source_id']} — 최신 공표일 사유 없음") - assert not bad, bad - - -def test_줄마다_갈래와_주기가_있다(sources): - known = set(sources["kinds"]) - for row in sources["sources"]: - assert row["kind"] in known, row["source_id"] - assert row["kind_ko"] == sources["kinds"][row["kind"]] - assert row["cycle"].strip(), row["source_id"] - assert row["publisher"].strip(), row["source_id"] - assert row["checked_on"].strip(), row["source_id"] - - -def test_기초단가_다섯_갈래를_다_덮는다(sources): - covered = {row["kind"] for row in sources["sources"]} - assert covered == {"labor", "machine", "material", "oil", "rate"} - - -def test_세어_둔_수가_실제와_같다(sources): - counts = sources["counts"] - assert counts["sources"] == len(sources["sources"]) - tallied: dict[str, int] = {} - for row in sources["sources"]: - tallied[row["status"]] = tallied.get(row["status"], 0) + 1 - assert counts["by_status"] == tallied - - -def test_상태_낱말이_다_풀려_있다(sources): - known = set(sources["status"]) - assert {row["status"] for row in sources["sources"]} <= known diff --git a/resources/tester/test_mat_price_zero.py b/resources/tester/test_mat_price_zero.py deleted file mode 100644 index 5c2bfa42f..000000000 --- a/resources/tester/test_mat_price_zero.py +++ /dev/null @@ -1,123 +0,0 @@ -"""관급자재 단가 — **0 원 공시를 건너뛰고 앞 제값을 쓴다**(2026-09-16 브레인). - -왜 — 우리 고르기 규칙이 「물품코드마다 가장 나중 공시」라, 값이 안 실린 공시(`prce = 0`)가 -옛 제값을 덮었다(각형강관 25*25*1.4t `1,460 → 1,500 → 0`). 「가격이 0」이 아니라 -**그 공시에 값이 안 실린 것**이라, 0 을 값으로 받으면 안 된다. - -⚠ 조용히 바꾸지 않는다 — 고친 줄에는 사유가 남고, 공시 날짜·번호도 **채택한 공시의 것**이 된다. -⚠ 앞 공시에도 값이 없어 0 이 남는 줄은 **빨강이 아니라 목록**(`zero_price_unrecovered`)으로 남는다. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -MASTER = ROOT / "resources" / "data_cost_input_value" / "mat_price_public_2026-08-14.json" -RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/나라장터_시설공통자재_2026-08-14.json" -EMPTY_PRICES = {"", "0", "0.0", "0.00"} - - -@pytest.fixture(scope="module") -def master() -> dict: - return json.loads(MASTER.read_text(encoding="utf-8")) - - -@pytest.fixture(scope="module") -def records(master) -> list[dict]: - return master["variables"]["mat_price"]["records"] - - -def _empty(value) -> bool: - return str(value).strip() in EMPTY_PRICES - - -def test_값이_없는_줄은_목록에만_남는다(master, records): - """⭐ 규칙으로 고칠 수 있는 0 원 줄은 하나도 안 남는다. - - 앞 공시에도 값이 없는 줄만 `zero_price_unrecovered` 에 남고, 그 줄은 표에서도 0 이다. - """ - listed = {str(item["item_code"]) for item in master["zero_price_unrecovered"]["items"]} - zeros = {str(r["item_code"]) for r in records if _empty(r.get("price_krw"))} - assert zeros == listed, f"목록에 없는 0 원 줄: {sorted(zeros - listed)}" - - -def test_못_고친_줄에는_사유가_붙는다(master): - for item in master["zero_price_unrecovered"]["items"]: - assert item["reason"].strip(), item["item_code"] - assert master["zero_price_unrecovered"]["note"].strip() - - -def test_고친_줄마다_사유가_남는다(records): - """조용히 바꾸지 않는다 — 「○ 공시에 값이 없어 ○ 값을 씀」이 줄에 남아야 한다.""" - repaired = [r for r in records if r.get("price_adopted_reason")] - assert repaired, "고친 줄이 하나도 없다" - for row in repaired: - reason = row["price_adopted_reason"] - assert "값이 없어" in reason and "씀" in reason, reason - assert not _empty(row["price_krw"]), row["item_code"] - - -def test_고친_줄의_공시_날짜와_번호가_채택한_공시의_것이다(records): - """어느 판 값인지 화면에서 보여야 한다 — 날짜가 0 원 공시에 머물면 안 된다.""" - raw = json.loads(RAW.read_text(encoding="utf-8")) - history: dict[str, list[dict]] = {} - for row in raw: - history.setdefault(str(row.get("prdctIdntNo")), []).append(row) - for rows in history.values(): - rows.sort(key=lambda r: str(r.get("nticeDt")), reverse=True) - - wrong = [] - for row in (r for r in records if r.get("price_adopted_reason")): - adopted = next( - (h for h in history.get(str(row["item_code"]), ()) if not _empty(h.get("prce"))), - None, - ) - assert adopted is not None, row["item_code"] - if str(row["price_krw"]) != str(int(str(adopted["prce"]).strip())): - wrong.append(f"{row['item_code']} 단가 {row['price_krw']} ≠ {adopted['prce']}") - elif row["notice_datetime"] != str(adopted.get("nticeDt", "")): - wrong.append(f"{row['item_code']} 날짜 {row['notice_datetime']}") - elif row["notice_number"] != str(adopted.get("prceNticeNo", "")): - wrong.append(f"{row['item_code']} 번호 {row['notice_number']}") - elif ( - row["price_adopted_reason"].endswith(f"{str(adopted['nticeDt'])[:10]} 값을 씀") is False - ): - wrong.append(f"{row['item_code']} 사유가 채택 공시와 다름") - assert not wrong, wrong - - -def test_0원_아닌_줄은_안_건드린다(records): - """규칙을 통째로 다시 돌리지 않는다 — 사유가 붙은 줄만 손댄 줄이다.""" - touched = [r for r in records if r.get("price_adopted_reason")] - assert len(touched) < len(records) // 10, ( - "손댄 줄이 너무 많다 — 규칙이 전체를 다시 쓴 것 아닌가" - ) - assert len(records) == 6999, "줄 수가 바뀌었다" - - -def test_고르는_방침에_0원_건너뛰기가_적혀_있다(master): - policy = master["selection_policy"] - assert "zero" in policy and "skip" in policy.lower(), policy - - -def test_같은_날짜에_0과_제값이_함께_있어도_제값을_고른다(): - """2025-04-24 는 같은 날 0 과 제값이 함께 실린다 — 날짜만 보면 0 을 집을 수 있다.""" - raw = json.loads(RAW.read_text(encoding="utf-8")) - same_day: dict[str, set[bool]] = {} - for row in raw: - if str(row.get("nticeDt", ""))[:10] != "2025-04-24": - continue - same_day.setdefault(str(row.get("prdctIdntNo")), set()).add(_empty(row.get("prce"))) - both = [code for code, kinds in same_day.items() if kinds == {True, False}] - assert both, "같은 날 0 과 제값이 함께 있는 물품이 없다 — 시험 전제가 바뀌었다" - - master = json.loads(MASTER.read_text(encoding="utf-8")) - rows = {str(r["item_code"]): r for r in master["variables"]["mat_price"]["records"]} - for code in both: - row = rows.get(code) - if row is not None: - assert not _empty(row["price_krw"]), f"{code} 가 0 으로 섰다" diff --git a/resources/tester/test_structure_group_items.py b/resources/tester/test_structure_group_items.py index 85b98ac2c..2c640e8e1 100644 --- a/resources/tester/test_structure_group_items.py +++ b/resources/tester/test_structure_group_items.py @@ -36,7 +36,7 @@ from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 def _define(system: Path, monkeypatch, doc: dict) -> None: monkeypatch.setattr(store, "FOLDER", system) - (system / groups.FILE).write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(groups, "load_definition", lambda: {"그룹": doc, "판": 1}) def test_칸_모양(): diff --git a/resources/tester/test_structures_summary_snapshot.py b/resources/tester/test_structures_summary_snapshot.py index 188d46d24..d34fd3ee6 100644 --- a/resources/tester/test_structures_summary_snapshot.py +++ b/resources/tester/test_structures_summary_snapshot.py @@ -24,6 +24,7 @@ from config import config_system from M02_MasterTemplete import M02_Item_Snapshot as snap from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from resources.tester.helper_m02_master_sample import SAMPLE REAL = SAMPLE # 시험 안 견본(PLAN 26-5a) — 실제 마스터 안 읽음 @@ -220,8 +221,10 @@ def test_원본에_없는_항목은_못만듦으로_남김(world): # ── 채움 ───────────────────────────────────────────── -def test_채움은_지정한_열에만_미지정은_안_셈(world): +def test_채움은_지정한_열에만_미지정은_안_셈(world, monkeypatch): root = world["root"] + # 실제 정의의 그룹(종류마다 여럿)이 미지정 건수에 섞이지 않게 그룹 없음으로 고정 + monkeypatch.setattr(groups, "load_definition", lambda: {"그룹": {}, "판": 1}) _save(root, [_dry(MS), _wet()], [_pipe(PIPE), {**_pipe(None), "chainage_m": 60.0}]) table = json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) doc = fill.fill_table(root, table, {40.0: 12.0, 60.0: 9.0}) diff --git a/resources/tester/test_work_item_master_source_match.py b/resources/tester/test_work_item_master_source_match.py deleted file mode 100644 index 319c395d9..000000000 --- a/resources/tester/test_work_item_master_source_match.py +++ /dev/null @@ -1,516 +0,0 @@ -"""원문 md ↔ 공종 마스터 맞대기 — 2026-09-17 브레인 일감(랩탑 메인). - -안티그래비티가 손으로 하던 대조를 시험으로 옮김. 원문은 -`(산림청고시 제2025-82호) 산림사업 표준품셈.md` 한 벌, 마스터는 `pum_forest_2026.json`(표 원본)과 -`work_item_master_2026-01-01.json`(공종 축). - -- **어긋남이 0 이어야 하는 것** — 표 수·차례 · 셀 값(글자까지). 하나라도 다르면 빨강. -- **알려진 어긋남이 있는 것** — 표 자리(부록 표가 공종에 붙음) · 밑수 · [주] · 표 성격. 지금 어긋난 것은 - `fixtures/work_item_master_source_known.json` 에 적어 두고, **그 목록 밖**에서 새로 어긋나면 빨강. - ⚠ 목록에 있는데 **고쳐졌거나 모습이 바뀐 것도 빨강** — 목록이 낡지 않게. - 고친 뒤 목록 지우기: `python resources/tester/test_work_item_master_source_match.py --prune` - (지우기만 함 · 새 어긋남은 안 보탬 — 새 것은 원인을 보고 사람이 적을 것). - -⚠ **빌더 코드를 안 씀** — 원문 읽기를 여기서 따로 함. 같은 코드로 재면 같은 틀림을 못 잡음. -""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -MD = ( - ROOT - / "resources/knowledge/original/원가계산/산림_표준품셈/첨부" - / "(산림청고시 제2025-82호) 산림사업 표준품셈.md" -) -PUM = ROOT / "resources/data_cost_input_value/pum_forest_2026.json" -MASTER = ROOT / "resources/data_work_item_master/work_item_master_2026-01-01.json" -KNOWN = Path(__file__).resolve().parent / "fixtures/work_item_master_source_known.json" - -#: 목차 표 — 공종 줄이 아니라 목차를 세우는 데 쓰임. -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*") - - -def _cells(line: str) -> list[str]: - s = line.strip() - s = s[1:-1] if s.endswith("|") else s[1:] - return [c.strip() for c in s.split("|")] - - -def md_tables(lines: list[str]) -> list[dict]: - """`|` 표 = 머리 줄 + `|---|` 줄 + 몸 줄. 나온 차례대로 F0001… (줄 번호는 1부터).""" - tables: list[dict] = [] - i = 0 - while i < len(lines): - if ( - lines[i].lstrip().startswith("|") - and i + 1 < len(lines) - and _SEP.fullmatch(lines[i + 1]) - ): - j = i + 2 - while j < len(lines) and lines[j].lstrip().startswith("|"): - j += 1 - tables.append( - { - "id": f"F{len(tables) + 1:04d}", - "line": i + 1, - "end": j, # 표 다음 줄의 0부터 번호 - "headers": _cells(lines[i]), - "rows": [_cells(x) for x in lines[i + 2 : j]], - } - ) - i = j - else: - i += 1 - return tables - - -#: 밑수가 될 수 있는 세는 단위. 「회」「%」「분」은 밑수가 아님(2026-09-17 데스크탑 서브 원문 확인). -_UNIT = "㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공㎥|공|kg|㎏|톤|ton|주|식|대|일|기|조|롤|RM|시간|hr" -_SAME = {"㏊": "ha", "㎞": "km", "m3": "㎥", "m2": "㎡", "㎏": "kg", "ton": "톤"} -_ONE = re.compile(rf"^([\d.]*)\s*({_UNIT})$") -_PAREN_END = re.compile(r"[((]([^()()]*)[))]+\s*$") -_PAREN_ANY = re.compile(r"[((]([^()()]*)[))]") - - -def parse_basis(text: str) -> tuple[float, str] | str | None: - """괄호 속 글 하나 → `(수, 단위)` · 분모가 둘이면 `"둘"` · 밑수 글이 아니면 `None`. - - - `인/100㎡` · `단위 : 인/ha` → 분모가 밑수 · `100본당` · `일 당` → 「당」 앞이 밑수 - - `ha당 소요량` · `1km 소요량기준` → 「소요량」 앞이 밑수 - - ⚠ 「인」「공」은 숫자가 붙을 때만(`(단위 : 인)` 은 품의 단위 — 2026-09-09 원문 대조) - """ - bare = re.match(r"^\s*단\s*위\s*[::]\s*([\d.,]*)\s*(\S+)\s*$", text) - t = re.sub(r"^\s*단\s*위\s*[::]?\s*", "", text).strip() - t = re.sub(r"(\d),(\d{3})", r"\1\2", t) - t = re.sub(r"\s*당?\s*소요량\s*(기준)?$", "당", t) - # ⚠ 한 괄호에 밑수가 둘이면 못 고름 — `(1ha당, 100본당)` 에서 한쪽만 집으면 조용히 틀림. - if len(re.findall(rf"[\d,]*\.?\d*\s*(?:{_UNIT})\s*당", t)) > 1: - return "둘" - if "/" in t: - denominators = t.split("/")[1:] - den = re.sub(r"\s*당$", "", denominators[-1]).strip() - if len(denominators) > 1 or re.search(r"[,,·\s]", den): - return "둘" - elif t.endswith("당"): - den = t[:-1].strip() - elif bare: # `(단위 : ㎥)` — 「당」 없이 단위만 적은 자리(7-2 수라집재 · 7-10 우드그랩) - den = t - else: - return None - m = _ONE.match(den) - if m is None and "," in den or "," in den: - # `덩굴 약제처리, ha당` — 앞이 **숫자 없는 설명 글**일 때만 뒤를 밑수로 봄. - head, _, tail = den.replace(",", ",").rpartition(",") - if not re.search(r"\d", head): - m = _ONE.match(tail.strip()) - if m is None: - return None - raw, unit = m.group(1), m.group(2) - if unit in ("인", "공") and not raw: - return None - try: - return (float(raw) if raw else 1.0), _SAME.get(unit, unit) - except ValueError: - return None - - -def md_basis(lines: list[str], table: dict) -> tuple[tuple[float, str] | None, str]: - """`(밑수, 근거 글)`. 표 바로 위 본문 → 표머리 차례(본문이 정본). 못 찾으면 `(None, "")`. - - 본문은 가까운 줄부터 올라가며 줄 끝 괄호를 봄 — 앞 표·`[주]` 를 만나면 멈추고 제목 줄은 보고 멈춤. - 표머리는 칸 속 괄호와 칸 전체를 봄 — 서로 다른 밑수가 둘 이상이면 못 정함. - """ - for k in range(table["line"] - 2, -1, -1): - s = lines[k].strip() - if s.startswith("|") or s.startswith("[주"): - break - if (m := _PAREN_END.search(s)) and isinstance(hit := parse_basis(m.group(1)), tuple): - return hit, s - if s.startswith("#"): - break - found: dict[tuple[float, str], str] = {} - for head in table["headers"]: - for text in [m.group(1) for m in _PAREN_ANY.finditer(head)] + [head]: - if isinstance(hit := parse_basis(text), tuple): - found.setdefault(hit, head) - # 첫 두 줄의 칸 — **괄호 안이 「…당」일 때만** 봄(8-6-1 「소형헬기 (160ha당)」). - # ⚠ 넓히면 값 칸의 몫(`5인/km` · `Q=3.5㎥/hr`)이 밑수로 둔갑함. - if not found: - for cell in [c for row in table["rows"][:2] for c in row]: - for m in _PAREN_ANY.finditer(cell): - if m.group(1).strip().endswith("당") and isinstance(hit := parse_basis(m.group(1)), tuple): # fmt: skip - found.setdefault(hit, cell) - if len(found) == 1: - ((hit, head),) = found.items() - return hit, f"표·머리 {head}" - # 표 아래 산출식 글에만 적힌 밑수 — `∙ 드론운반공 / 1,000본 = ④ × X ÷ 360 ÷ 5`(10-13) - for k in range(table["end"], min(len(lines), table["end"] + 12)): - text = lines[k].strip() - if text.startswith(("|", "#")): - break - if m := _PER_IN_FORMULA.search(text): - hit = parse_basis(f"{m.group(1)}{m.group(2)}당") - if isinstance(hit, tuple): - return hit, text - return None, "" - - -#: 표 아래 산출식에 「/ N단위 =」 꼴로만 적힌 밑수(10-13 드론·인력 운반). -_PER_IN_FORMULA = re.compile(rf"/\s*([\d,]+)\s*({_UNIT})\s*=") -_NOTE_CONT = re.compile(r"^(-\s*[①-⑳]|-\s*-|[※*·])") -#: `- 4. 고철공제 : A=1㎥×0.008×…` — 번호 목록이지만 **[주] 안쪽 규칙**. 콜론·등호로 알맹이가 이어짐. -#: ⚠ `- 3. 노상 및 노반재료` 처럼 알맹이 없는 짧은 줄은 **새 절 제목**이라 [주]가 아님 -#: (2026-09-18 데스크탑 서브 제안 · 랩탑 서브가 원문 전수로 「1.」「가.」 꼴 [주] 0건 확인). -_NOTE_NUMBERED = re.compile(r"^-\s*(?:\d+|[가-하])\.\s*[^:=]*[:=]") - - -def md_notes(lines: list[str], table: dict) -> list[str]: - """표 바로 아래 `[주]` 덩어리. 첫 글줄이 `[주` 가 아니면 없음. - - 이어지는 줄 = `- ②` 꼴 · `- -` 꼴 · `※`·`*`·`·` 로 시작하는 줄. 빈 줄은 건너뜀. 그 밖의 줄에서 끝. - ⚠ `- 2. 소제목` 같은 본문 목록은 [주]가 아님. - ⚠ [주] 사이에 낀 `> 【산출 예시】` 덩어리는 건너뛰고 이어 봄 — 그 뒤에 `- ⑥ … 100% 가산` 이 옴(4-3). - """ - out: list[str] = [] - for k in range(table["end"], len(lines)): - s = lines[k].strip() - if not s or (out and s.startswith(">")): - continue - if not out and s.startswith("[주"): - out.append(s) - elif out and (_NOTE_CONT.match(s) or _NOTE_NUMBERED.match(s) or s.startswith("[주")): - # 번호 항목 안에 다시 `[주]①` 이 붙는 자리(9-8-2 집토) — 끊지 않고 이어 봄 - out.append(s) - else: - break - return out - - -_WORKER = re.compile( - r"인\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|" - r"용접공|미장공|방수공|배관공|비계공" -) -#: 품 낱말 — 공백을 지운 칸에서 찾음(`주 재 료` → `주재료`). -_WORK_MARK = re.compile( - r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|살포량|처리량|" - r"인력구분|인력구성|적용인부|인원\(명\)|^주재료$|인/|/인|\(시간\)|\(hr\)|[가-힣]\((kg|㎏|g|㎥|ℓ)\)" -) -_QTY_UNIT = {"인", "매", "개", "본", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "대", "톤", "hr", "시간"} -#: 숫자 칸 — `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`. 표에 숫자 칸이 있을 때만 봄. - - ① **짧은 칸(20자 이하)** 에 직종 이름·품 낱말(`소요인력`·`인력구분`·`(인)`·`시멘트(kg)` …) - ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음 - ③ 단위만 늘어선 줄(`| | ㎥ | ㎥ | ㎥ |`) — 값은 아래 줄에 있음(7-1-1 수확) - 긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸). - ⚠ 못 알아보는 품 표는 알려진 목록 `form_blind` 에 까닭과 함께 둠 — 그 표는 성격이 뒤집혀도 못 잡음. - """ - rows = [table["headers"], *table["rows"]] - cells = [c for row in rows for c in row] - 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()) - - -@pytest.fixture(scope="module") -def src() -> dict: - return load_sources() - - -def load_sources() -> dict: - lines = MD.read_text(encoding="utf-8").splitlines() - pum = json.loads(PUM.read_text(encoding="utf-8"))["variables"]["pum"]["tables"] - master = json.loads(MASTER.read_text(encoding="utf-8")) - entries = [(w, t) for w in master["work_items"] for t in w.get("tables", [])] - return { - "lines": lines, - "tables": md_tables(lines), - "pum": pum, - "master": master, - "entries": entries, - "by_id": {t["pum_table_id"]: (w, t) for w, t in entries}, - } - - -def current_mismatches(src: dict) -> dict[str, dict]: - """표 자리·밑수·[주]·표 성격 어긋남 — 알려진 목록과 같은 꼴. 밑수·[주]·성격은 공종에 붙은 표만 봄.""" - lines = src["lines"] - 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"]: - in_appendix = table["line"] > appendix - if table["id"] in orphans and not in_appendix: - out["place"][table["id"]] = { - "section": None, - "md": "본문 표", - "master": "공종에 안 붙음", - } - if table["id"] not in src["by_id"]: - continue - if in_appendix: - out["place"][table["id"]] = { - "section": src["by_id"][table["id"]][0]["number"], - "md": "부록 표", - "master": "공종에 붙음", - } - work, entry = src["by_id"][table["id"]] - section = work["number"] - - md_hit, evidence = md_basis(lines, table) - md_value = list(md_hit) if md_hit else None - unit = entry["basis_unit"] - master_value = [entry["basis_quantity"], _SAME.get(unit, unit)] if unit else None - if md_value != master_value: - out["basis"][table["id"]] = { - "section": section, - "md": md_value, - "md_evidence": evidence, - "master": master_value, - } - - md_note = md_notes(lines, table) - if md_note != entry["notes"]: - out["notes"][table["id"]] = { - "section": section, - "md_only": [x for x in md_note if x not in entry["notes"]], - "master_only": [x for x in entry["notes"] if x not in md_note], - } - - form = entry["pum_form"] - if cost_rows := md_cost_rows(table): - out["cost_rule"][table["id"]] = { - "section": section, - "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 - - -def load_known() -> dict[str, dict]: - """알려진 어긋남 목록. **「할 일 아님」 칸(`_할일_아님`)도 함께 읽는다.** - - ⚠ 왜 합쳐 읽나 — ㉮(원문에 없음·거절 자리·판정으로 닫힘)는 **바퀴마다 다시 세어지지 않게** - 갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인). 그렇다고 비교에서까지 빠지면 그 줄이 다시 - 「새로 어긋남」으로 빨개진다 — 그래서 **세는 곳에서만 빼고 비교에는 넣는다.** - """ - data = json.loads(KNOWN.read_text(encoding="utf-8")) - for kind, rows in ((data.get("_할일_아님") or {}).get("줄") or {}).items(): - data.setdefault(kind, {}).update(rows) - return data - - -#: 비교에서 뺄 칸 — **사람이 적은 메모**다. 자료가 아니라 설명이라 어긋남 판정에 안 쓴다. -#: `class`·`class_why` 는 2026-09-18 에 붙인 갈래(㉮ 영영 아님 · ㉯ 남의 몫 · ㉰ 사용자 판단). -_MEMO_FIELDS = ("why", "class", "class_why", "was", "owner", "owner_ask", "갈래", "표시", "잠정") - - -def _without_why(entry: dict) -> dict: - return {k: v for k, v in entry.items() if k not in _MEMO_FIELDS} - - -def test_할일_아님_칸이_갈려_있다() -> None: - """㉮ — **영영 할 일이 아닌 것**은 갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인). - - ⚠ 왜 못박나 — 섞여 있으면 바퀴마다 「어긋남」으로 다시 세어져 **없는 할 일이 생긴다.** - ⚠ 지우지는 않는다 — 나중에 「이거 왜 안 했지」 할 때 사유를 보아야 한다. - """ - block = json.loads(KNOWN.read_text(encoding="utf-8")).get("_할일_아님") - assert block, "「할 일 아님」 칸이 없다" - rows = block["줄"] - total = sum(len(v) for v in rows.values()) - # 2026-09-18 합침 — 34 → 32. 둘이 **풀려서** 빠졌다: F0257(9-11-1 「(단위: 일당)」)은 밑수 읽기를 - # 넓혀(거절 자리 11 → 10), F0325(11-1)는 다른 창이 원문을 고쳐 표지가 맞아떨어져(그 밖 18 → 17). - # 남은 32 는 그대로 「영영 할 일 아님」이다. - # 2026-09-18 32 → 33. F0406〈돌쌓기의 개수 및 중량의 표준〉이 들어왔다 — 품 표가 아니라 개수·중량 - # 기준표라 사람 판정으로 닫힌 **거절 자리**다(거절 자리 10 → 11). - assert total == 33 - assert block["갈래별"] == {"거절 자리": 11, "그 밖": 17, "부록 예시": 1, "원문 부재": 4} - for kind, got in rows.items(): - for tid, v in got.items(): - assert str(v.get("class", "")).startswith("㉮"), f"{kind}/{tid}" - assert v.get("class_why"), f"{kind}/{tid} 사유 없음" # 왜 할 일이 아닌지 - assert v.get("갈래") in ("원문 부재", "부록 예시", "거절 자리", "그 밖"), ( - f"{kind}/{tid}" - ) - # 세는 칸에서는 빠지고 비교에는 들어간다 — 그래서 빨개지지 않는다 - merged = load_known() - for kind, got in rows.items(): - assert set(got) <= set(merged[kind]) - - -# ── 시험 ──────────────────────────────────────────────────────────────── -def test_표_수와_자리(src: dict) -> None: - """원문 표가 원본(`pum_forest`)에 같은 차례·같은 줄로 있고, 마스터에 빠짐없이 한 번씩 있음.""" - md_ids = [(t["id"], t["line"]) for t in src["tables"]] - pum_ids = [(t["table_id"], t["line"]) for t in src["pum"]] - assert len(md_ids) == len(pum_ids), f"표 수 — 원문 {len(md_ids)} · 원본 {len(pum_ids)}" - moved = [(a, b) for a, b in zip(md_ids, pum_ids) if a != b] - assert not moved, f"표 자리 어긋남 {len(moved)}건 (원문, 원본) — 앞 5: {moved[:5]}" - - attached = [t["pum_table_id"] for _, t in src["entries"]] - doubled = sorted({x for x in attached if attached.count(x) > 1}) - assert not doubled, f"두 공종에 붙은 표: {doubled}" - orphans = [o["pum_table_id"] for o in src["master"]["orphan_tables"]] - all_ids = {i for i, _ in md_ids} - missing = sorted(all_ids - set(attached) - set(orphans) - {TOC_TABLE}) - assert not missing, f"마스터에 없는 원문 표: {missing}" - extra = sorted((set(attached) | set(orphans)) - all_ids) - assert not extra, f"원문에 없는 마스터 표: {extra}" - - -def test_셀_값이_글자까지_같음(src: dict) -> None: - """표머리·줄 칸을 글자까지 맞댐 — 원문 ↔ 원본(`headers`·`rows`) ↔ 마스터(`condition_note`·`raw_row`).""" - diffs: list[str] = [] - - def compare(tid: str, line: int, what: str, md_rows: list, other: list) -> None: - if len(md_rows) != len(other): - diffs.append(f"{tid} L{line} {what} 줄 수 — 원문 {len(md_rows)} · {len(other)}") - for r, (a, b) in enumerate(zip(md_rows, other)): - for c in range(max(len(a), len(b))): - va = a[c] if c < len(a) else "(없음)" - vb = b[c] if c < len(b) else "(없음)" - if va != vb: - diffs.append( - f"{tid} L{line + 2 + r} {what} {r + 1}행 {c + 1}열 — 원문 {va!r} · {vb!r}" - ) - - pum = {t["table_id"]: t for t in src["pum"]} - for table in src["tables"]: - tid, line = table["id"], table["line"] - p = pum.get(tid) - if p is None: - continue # 표 수 시험이 잡음 - if table["headers"] != p["headers"]: - diffs.append(f"{tid} L{line} 원본 표머리 — 원문 {table['headers']} · {p['headers']}") - compare(tid, line, "원본", table["rows"], p["rows"]) - if tid in src["by_id"]: - _, entry = src["by_id"][tid] - if entry["source_line"] != line: - diffs.append( - f"{tid} 마스터 줄 번호 — 원문 L{line} · 마스터 L{entry['source_line']}" - ) - heads = [_norm(h) for h in table["headers"] if _norm(h)] - if heads != entry["condition_note"]: - diffs.append( - f"{tid} L{line} 마스터 표머리 — 원문 {heads} · {entry['condition_note']}" - ) - compare(tid, line, "마스터", table["rows"], entry["raw_row"]) - assert not diffs, f"셀 어긋남 {len(diffs)}건 — 앞 30:\n" + "\n".join(diffs[:30]) - - -@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 tid in sorted(set(now) - set(known)): - lines.append(f" 새로 어긋남 {tid}: {json.dumps(now[tid], ensure_ascii=False)}") - for tid in sorted(set(now) & set(known)): - if now[tid] != known[tid]: - lines.append( - f" 모습 바뀜 {tid}: 목록 {json.dumps(known[tid], ensure_ascii=False)}" - f"\n 지금 {json.dumps(now[tid], ensure_ascii=False)}" - ) - for tid in sorted(set(known) - set(now)): - lines.append(f" 고쳐짐 {tid} — 목록에서 지울 것(--prune)") - assert not lines, f"{KIND_NAMES[kind]} {len(lines)}건 — 원문 ↔ 마스터:\n" + "\n".join(lines) - - -if __name__ == "__main__" and "--prune" in sys.argv: - # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). - # ⚠ 「할 일 아님」 칸(`_할일_아님`)은 **제자리에서** 지운다. `load_known()` 이 합쳐 읽은 것을 - # 그대로 되쓰면 2026-09-18 에 가른 칸이 다시 갈래로 섞여 「없는 할 일」이 되살아난다. - current = current_mismatches(load_sources()) - data = json.loads(KNOWN.read_text(encoding="utf-8")) - parked = (data.get("_할일_아님") or {}).get("줄") or {} - - def _keep(kind: str, rows: dict) -> dict: - return {k: v for k, v in rows.items() if current[kind].get(k) == _without_why(v)} - - for kind in KIND_NAMES: - before = len(data.get(kind) or {}) - data[kind] = _keep(kind, data.get(kind) or {}) - print(f"{KIND_NAMES[kind]}: {before} → {len(data[kind])}") - if kind in parked: - was = len(parked[kind]) - parked[kind] = _keep(kind, parked[kind]) - if len(parked[kind]) != was: - print(f" 할 일 아님 {KIND_NAMES[kind]}: {was} → {len(parked[kind])}") - KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") diff --git a/resources/tester/ui_checks/47-5_일위대가_줄_고름.js b/resources/tester/ui_checks/47-5_일위대가_줄_고름.js index a77595dc5..0edf1bc43 100644 --- a/resources/tester/ui_checks/47-5_일위대가_줄_고름.js +++ b/resources/tester/ui_checks/47-5_일위대가_줄_고름.js @@ -4,7 +4,7 @@ * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) * 하는 일: item1 카드 표 → 일위대가 카드 [수정]으로 상세 → 둘째 줄 아래로 · 위로(제자리) → item1 다시(값 안 바뀜) * 기대 수치: - * - card.btns = ["옵션","삭제"] · detail.btns = ["위로","아래로","옵션","삭제"] · danger = true(삭제 = ui-btn--danger) + * - card.btns = ["옵션","삭제"] · detail.btns = ["옵션","삭제"](▲▼ 는 `.m02-labrow__order` 안 · aria-label 위로/아래로 — 85-3) · danger = true(삭제 = ui-btn--danger) * - gap = "4px"(공용 `ui-btn-group` · `--btn-group-gap`) · 단추 사이 실제 틈 = gap * - pick.selected = 1(누른 줄만 is-selected · 옵션 값 줄 있으면 함께) · pick.modal = 0(줄 누름이 모달을 안 엶) * - option.modal = 1(옵션 단추 = 넣을 값 모달) · 닫은 뒤 0 @@ -107,9 +107,7 @@ const selAt = () => rowsOf(table()).findIndex((r) => r.classList.contains("is-selected")); const press = async (label) => { const tr = table().querySelector(`tr[data-labor-logic="${key}"]`); - btnsOf(tr) - .find((b) => b.innerText.trim() === label) - ?.click(); + tr.querySelector(`.m02-labrow__order button[aria-label="${label}"]`)?.click(); await w(2500); }; await press("아래로"); diff --git a/resources/tester/ui_checks/51-11_사전조합_고르기.js b/resources/tester/ui_checks/51-11_사전조합_고르기.js index 490b84dc0..6fd586a0d 100644 --- a/resources/tester/ui_checks/51-11_사전조합_고르기.js +++ b/resources/tester/ui_checks/51-11_사전조합_고르기.js @@ -67,7 +67,9 @@ ); const items = [...sec.querySelectorAll("[data-precombo]")]; const btn = (root, text) => - [...(root?.querySelectorAll("button") ?? [])].find((b) => b.textContent === text); + [...(root?.querySelectorAll("button") ?? [])].find( + (b) => b.textContent === text || b.getAttribute("aria-label") === text, + ); const edit = () => document.querySelector(".m02-main__host [data-labor]"); const lines = () => [...(edit()?.querySelectorAll("[data-labor-logic]") ?? [])]; const keys = () => lines().map((tr) => tr.querySelector(".m02-labrow__key")?.textContent); diff --git a/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js b/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js index 19f94dc3a..416982a86 100644 --- a/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js +++ b/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js @@ -1,14 +1,14 @@ -/* 51-14 — 일위대가 표 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 단가승격 [옵션] · 일위조합 [수정] 모달 +/* 51-14 — 일위대가 표 순서 칸(▲▼ 순서 단추 `.m02-labrow__order` + 아래 [옵션]·[수정] [삭제] 한 묶음 — 85-3) · 단가승격 [옵션] · 일위조합 [수정] 모달 * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · * 나무에 item3(단가승격 SP-000001 · 일위조합 SC-000001 줄이 있음) * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) * 쓰기 없음 — 모달은 모두 [취소]로 닫음 · PUT 수 = 0 확인(서고 저장 안 함) * 기대 수치: * - card.sp / card.sc: groups 1 · labels [옵션, 삭제] / [수정, 삭제] - * - detail.sp / detail.sc: groups 2 · top [위로, 아래로] · bottom [옵션, 삭제] / [수정, 삭제] · danger = 삭제 + * - detail.sp / detail.sc: groups 1 · order [위로, 아래로](aria-label) · bottom [옵션, 삭제] / [수정, 삭제] · danger = 삭제 * - promote: notice 있음 · foot [취소, 저장] · closed = true * - combo: notice 있음 · add [로직 추가] · foot [취소, 저장] — 모달 안 [로직 추가]로 단가산출 한 줄 넣음(화면만) · - * added.groups 2 · top [위로, 아래로] · bottom [옵션, 삭제] · [취소] = 버림 물음(asked) → 확인 · closed = true · recalc ≥ 1 + * added.groups 1 · order [위로, 아래로] · bottom [옵션, 삭제] · [취소] = 버림 물음(asked) → 확인 · closed = true · recalc ≥ 1 * - puts = 0 */ (async () => { @@ -40,7 +40,9 @@ const last = groups.at(-1) ? btn(groups.at(-1)).at(-1) : null; return { groups: groups.length, - top: groups.length > 1 ? btn(groups[0]).map(txt) : [], + order: [...cell.querySelectorAll(".m02-labrow__order button")].map((b) => + b.getAttribute("aria-label"), + ), bottom: groups.length ? btn(groups.at(-1)).map(txt) : [], danger: last ? last.className : "", _btn: (label) => groups.flatMap(btn).find((b) => txt(b) === label), diff --git a/resources/tester/ui_checks/51-6_사전조합_줄넣기.js b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js index 689cf8434..bb0ac2aba 100644 --- a/resources/tester/ui_checks/51-6_사전조합_줄넣기.js +++ b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js @@ -95,7 +95,9 @@ ); const items = [...sec.querySelectorAll("[data-precombo]")]; const btn = (root, text) => - [...root.querySelectorAll("button")].find((b) => b.textContent === text); + [...root.querySelectorAll("button")].find( + (b) => b.textContent === text || b.getAttribute("aria-label") === text, + ); const edit = () => document.querySelector(".m02-main__host [data-labor]"); const lines = () => [...(edit()?.querySelectorAll("[data-labor-logic]") ?? [])]; const keys = () => lines().map((tr) => tr.querySelector(".m02-labrow__key")?.textContent);