diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index 1e5e5a84..f64fde1c 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30600, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30600, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1, "UA": 2}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -36471,6 +36471,7 @@ "RT000016": {"원문번호": "rate_performance_guarantee_fee", "파일": "요율_조달청제비율.json"}, "RT000017": {"원문번호": "rate_overhead", "파일": "요율_조달청제비율.json"}, "RT000018": {"원문번호": "rate_profit", "파일": "요율_조달청제비율.json"}, - "RT000019": {"원문번호": "rate_vat", "파일": "요율_조달청제비율.json"} + "RT000019": {"원문번호": "rate_vat", "파일": "요율_조달청제비율.json"}, + "UA000001": {"원문번호": "", "파일": "일위대가조합.json"} } } diff --git a/resources/master_data/ref/_검증_목록캐시.md b/resources/master_data/ref/_검증_목록캐시.md new file mode 100644 index 00000000..27d0e480 --- /dev/null +++ b/resources/master_data/ref/_검증_목록캐시.md @@ -0,0 +1,24 @@ +# 목록 캐시 교차 검증 (일감 32) + +대상 = `M01_MasterData_Store_Cache` — 마스터를 한 번 읽어 두고 목록은 미리 센 막힘 값을 씀. 정본은 안 건드리고 임시 사본에서만 시험. 잰 뒤 정본 조합 파일·키 대장 해시가 앞뒤 같음. + +## 어긋난 것 — 없음 + +| 확인 | 범위 | 결과 | +|---|---|---| +| ① 미리 센 막힘 = 그 자리에서 검사한 값 | 로직 1,354 전수 | 어긋남 0 (막힘 0건 · 까닭 글까지 같음) | +| ③ 목록 이름·구분·상세구분·결과단위·원문번호·출처·소유·파일 = 파일 값 | 1,354 전수 | 어긋남 0 · 빠지거나 남는 키 0 | +| ③ 거름(구분·상세구분 조합 전부 · 막힘 0/1) | 전부 | 어긋남 0 | +| ② 고친 척한 뒤 다시 센 값 = 캐시 없이 전부 새로 센 값 | 아래 | 어긋남 0 · **덜 센 경우 0** | +| ④ 조합 목록 = 조합 줄마다 새로 검사한 값 | 조합 1줄(견본) + 변형 | 어긋남 0 | + +② 시나리오(모두 전수 대조): 부르는 로직에 입력 추가(불리는 로직 41개 중 25개 뽑아) · 로직 줄 삭제 · 이름만 바꿈 · 로직을 다른 파일로 옮김 · 호표에 로직 호출 새로 추가 → 그 피호출 로직을 고침(새 간선도 다시 셈) · 요소·표 파일 8개(계수·소요량·요율) 고침 · 요소 줄 삭제 둘(인력 LB000014 → 로직 31개가 막힘으로 바뀜 · 재료 MP000082 → 1개). +④ 변형: 담은 로직에 입력 추가(막힘 0 유지) · 담은 로직 삭제(막힘 1) · 조합 줄 추가 · 조합 비움. + +## 어긋남은 아니나 알아 둘 것 + +1. **파일 단위로 다시 셈** — 로직 하나를 고치면 그 파일 줄 전부를 다시 셈. 08장 건설기계(462줄)의 로직 5개는 다시 센 수가 462 인데 꼭 필요한 것은 12~138줄. 값은 맞고 덜 세지 않음 — 그저 더 셈(0.1초대 아님·초 단위 아님, 저장 뒤 첫 목록에서만). +2. **같은 크기 · 같은 시각 고침은 못 알아챔** — 파일 자국(시각·크기)이 그대로면 옛 값이 남음(시험: 글자 하나를 같은 크기로 바꾸고 시각을 되돌림 → 목록 이름이 옛것). 저장 길은 `stale` 로 알려 주므로 안 겪음 · 손으로 파일을 고치고 시각까지 되돌리는 경우만 해당. +3. 정본에 막힌 로직이 0 이라 「같은 값」 판정은 막힘이 있는 경우를 고침 시나리오(로직 31개가 막힘으로 바뀜 등)로 보강해 확인. + +시험 코드는 `tmp/` 임시(git 밖) — 다시 하려면 `test_m01_cache.py` 에 시나리오를 얹을 것. diff --git a/resources/master_data/ref/_검증_전체화면.md b/resources/master_data/ref/_검증_전체화면.md index 1e99b08c..e642569b 100644 --- a/resources/master_data/ref/_검증_전체화면.md +++ b/resources/master_data/ref/_검증_전체화면.md @@ -39,3 +39,6 @@ ORCA · 포트 8006/5179 · 넓은 화면 928px · 좁은 화면 390px(iframe). 3. 넓은 표(요율 · 기계 · 재료)는 390px에서 안쪽 스크롤 — 열 줄이기 없음. 4. 일위대가 조합 빈 화면. 5. 시험 흔적 없음(값 안 바꿈). + +## 고친 뒤 +- 재료 처음 펼침 : 「재료 · 자재품목」 30,592줄 목록이 바로 열림. 좁은 화면(600px 이하) 표 위에 「표가 넓음 — 옆으로 밀어서 볼 것」 한 줄(요율 · 소요량 확인) · 가로 스크롤 유지, 넘침 0. 자재품목 612쪽은 찾기 칸이 있어 그대로 둠. diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index b1483b4b..9d885d1e 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -36,3 +36,58 @@ 3. 「로직 개선 시험」 은 같은 길을 그대로 쓰므로 1 · 2 를 고치면 같이 빨라짐. 시험 흔적 없음(어떤 파일도 안 고침 · 프론트 개발서버 재시작 1회는 화면이 빌드 오류로 안 뜨던 것을 되살리려 한 것 — 조합 화면 파일이 다른 창에서 편집 중이던 자국으로 보임). + +## 고친 뒤 (일감 32) + +잰 자리 = 이 창(랩탑 sub_laptop_1) · 임시 사본 폴더 · `TestClient` 로 같은 길을 부름. 데스크탑은 같은 길이 2배쯤 느렸음(위 표 6.0초 = 여기 2.5초). + +| 길 | 앞 | 뒤 | +|---|---|---| +| `/api/m01/logics` 켠 뒤 첫 번 | 2.5초 | 3.4초(마스터 한 번 읽기 + 1,354줄 한 번 세기 · **딱 한 번만**) | +| `/api/m01/logics` 그 다음부터 | 2.5초(매번) | **0.02초** | +| `/api/m01/combos` | 0.3초(데스크탑 1.6초) | **0.01초** | +| 로직 저장 직후 목록 | 2.5초 | **0.06초** | + +- 요청마다 마스터를 새로 읽던 것을 한 번 읽어 두고, 파일 자국(시각·크기)이 바뀐 파일만 다시 읽음(`M01_MasterData_Store_Cache`). +- 목록은 검사를 돌리지 않음 — 로직 막힘을 미리 세어 두고 씀. 요소·표가 바뀌면 모든 로직을, 로직 파일이 바뀌면 그 파일과 그 로직을 부르던 줄만 다시 셈. +- 저장한 쪽이 고친 파일 이름을 알려 주므로 저장 직후 목록에 바로 비침(같은 시각 틱이어도 안 놓침). +- 막힘 거름(`blocked=0·1`)·구분·상세구분·찾기 결과와 줄 내용은 그대로 — 시험 `resources/tester/test_m01_cache.py` 다섯이 줄마다 검사한 값과 맞댐. +- 남은 늦은 자리(이 일감 밖) — **저장 자체가 6.7초**: `check_saved` 가 저장 앞뒤로 1,354줄을 다 검사함. 켠 뒤 첫 목록 3.4초도 서버가 뜰 때 미리 한 번 읽어 두면 없앨 수 있음(서버 시동 자리라 이 창 담당 밖). +- 시험 — `test_m01_*` · `test_masterdata_*` 195 통과 · 4 실패. 실패 넷은 모두 `test_m01_combo.py`(정본 조합 파일이 빈 채라고 보는 옛 기대값 — 일감 31 이 견본 조합 `UA000001` 을 정본에 넣어 어긋남) · 이 고침과 무관(첫 실패는 서버를 거치지 않고 정본 파일만 읽음). `check_master` 틀 0 · 본문 0 · 로직 0 · 조합 0 · 단위 14. + +## 고친 뒤 2 (일감 33) + +남은 늦음 둘 — 잰 자리는 위와 같음(이 창 · 사본 폴더 · `TestClient`). + +| 길 | 앞 | 뒤 | +|---|---|---| +| 로직 한 줄 저장(`POST /save`) | 6.8초 | **0.8초** | +| 켠 뒤 첫 목록 | 3.2초 | **0.03초**(미리 읽기 2.7초는 뒤에서 · 기다리지 않음) | + +- **저장 검사 좁힘** — 고친 줄과 **그 줄을 부르던 줄**만 검사. 안 보는 줄이 부르는 키는 목록이 미리 세어 둔 것을 그대로 씀(돌고 도는 길 찾기는 그대로 전체 그림으로 봄). 로직 줄만 고쳤으면 품셈재료 연결·인력 준용 검사(`check_links`)도 건너뜀 — 로직 줄이 못 건드리는 자리. 저장 검사(`check_saved`)는 M01 쪽(`_Store_Cache`)으로 옮김 — 좁히는 값이 거기 있고 `check_master` 가 700줄에 닿아서. +- 로직 밖 파일(요소 · 표 · 조합)이 섞인 저장은 어느 로직이 흔들릴지 모르므로 **예전처럼 전부 검사**(그 자리는 그대로 6.8초). 마스터 전체 검사는 `check_master` 몫. +- **미리 읽기** — M01 라우터가 뜰 때 뒤에서 한 번 읽어 둠(`cache.warm` · 데몬 실). 시동을 막지 않고, 시험은 사본 폴더를 쓰므로 건너뜀. +- 저장 뒤에도 잡히는지 — 견본 넷이 모두 422 로 막힘: 없는 로직 키(GC999999) · 없는 인력 요소 키(LB999999) · 돌고 도는 참조 · 고친 줄을 **다른 파일에서** 부르던 줄이 깨짐(입력 하나 더함). 시험 `resources/tester/test_m01_cache.py` 아홉(넷이 이번 것). +- 단위 경고(`check_units`)는 애초부터 저장 검사가 아니라 `check_master 단위` 몫 — 14건 그대로. +- 남은 늦은 자리 — 저장 0.8초 가운데 0.6초는 마스터를 두 번(고치기 전·후) 새로 읽는 것. **요소 파일 저장은 그대로 7.0초**(인력 한 줄 실측): 요소를 가리키는 로직을 키 글자로만 찾으면 원문번호로 가리키는 자리를 놓쳐 저장 검사에 구멍이 나므로 좁히지 않음 — 좁히려면 「요소를 부르는 로직」 지도부터 만들어야 함. + +## 고친 뒤 3 (일감 33 — 요소 저장 7초) + +요소·표를 한 줄 고쳐도 로직 1,354 줄을 다 검사하던 자리. 잰 자리는 위와 같음. + +| 길 | 앞 | 뒤 | +|---|---|---| +| 인력 한 줄 저장 | 7.0초 | **0.9초** | +| 자재품목 한 줄 저장 | 7.2초 | **2.0초** | +| 자재품목 저장 뒤 목록 | 2.5초 | **0.45초** | +| 소요량 표 한 줄 저장 | 7초쯤(인력과 같은 길 · 따로 안 쟀음) | **1.7초** | +| 로직 한 줄 저장 | 0.8초 | 0.7초 | +| 켠 뒤 첫 목록 | 3.4초 | **1.6초**(미리 읽기 1.0초 · 뒤에서 하므로 기다리지 않음) | + +- **어느 로직이 무엇을 보는지 지도** — 로직 줄을 셀 때 `Master.get` 을 덧옷(`_Watch`)으로 감싸 그 줄이 본 자리를 모아 둠(키 · 「ID:원문번호」 · 없는 키를 물은 것까지). 재료 고르기가 있는 줄은 자재품목 묶음 통째(「#MT」) — 후보를 그 안에서 고르므로. 지도는 막힘과 같은 자리에 같은 방식으로 갱신됨(파일 자국이 바뀐 것만). +- **저장** — 그 지도로 흔들리는 줄만 검사: 고친 요소의 키 · 원문번호(고치기 전·후 둘 다) · 지운 줄 · 자재품목이면 묶음 통째. 로직 줄 고침은 앞서대로 고친 줄과 그 줄을 부르던 줄. 요소를 고친 저장은 연결·준용 검사(`check_links`)를 그대로 돌림. +- **저장 뒤 목록** — 고친 쪽이 흔들린 자리를 알려 주므로(`stale(names, marks)`) 그 자리를 보던 줄만 다시 셈. 다른 창이 고친 파일이면 무엇이 흔들렸는지 모르므로 예전처럼 통째로 다시 셈. +- **자재품목을 「구분」 으로 미리 갈라 둠**(`master_material.by_class`) — 고르기 후보를 3만 줄에서 훑던 것을 그 구분 안에서만. 저장·목록·`check_master 로직`(2.1초)·`단위`(1.5초)가 함께 빨라짐. 가른 차례는 파일 차례 그대로라 고르는 줄은 바뀌지 않음. +- **전수 확인** — 요소·표 파일 90 개마다 그 파일 줄을 통째로 비운 뒤 로직 1,354 줄을 모두 다시 검사해, 결과가 달라진 줄이 지도가 고른 줄 안에 드는지 봄: **지도 밖 0**(모든 파일에서 「지도 = 달라진 줄」 이 정확히 같음). 지도 한 줄이 보는 자리는 가운데 4 곳 · 가장 많은 170 곳 · 고르기를 쓰는 줄 189. +- 시험 — `resources/tester/test_m01_cache.py` 열둘(셋이 이번 것: 쓰이는 요소를 지우면 저장이 막힘 · 요소를 고쳐 저장한 뒤 막힘 표시가 줄마다 검사한 것과 같음 · 인력 줄을 비운 전수 대조가 지도 안에 듦). +- 남은 늦은 자리 — 자재품목 저장 2.0초의 대부분은 고르기 쓰는 로직 189 줄을 저장 앞뒤로 두 번 검사하는 것 · 마스터를 두 번 새로 읽는 0.6초. diff --git a/resources/master_data/ref/_밤작업_요약.md b/resources/master_data/ref/_밤작업_요약.md index af1912c2..a83ad140 100644 --- a/resources/master_data/ref/_밤작업_요약.md +++ b/resources/master_data/ref/_밤작업_요약.md @@ -1,4 +1,4 @@ -# 밤 작업 요약 (2026-09-21 저녁 ~ 09-22 새벽) +# 밤 작업 요약 (2026-09-21 저녁 ~ 09-22 아침) 여섯 창이 한 일 · 사용자가 아침에 읽는 용도. 값·코드는 이 문서에서 안 고침. 근거 = `ref/` 검증 문서 이름. @@ -10,13 +10,33 @@ - 알약 모달 — 호표 줄을 누르면 뜨는 창. 고른 자재·표 줄을 강조. - 일위대가 조합 컨테이너 — 로직 여러 개를 담고 미리 보기 합계를 봄. 수량·계산은 저장 안 됨. (`_검증_조합.md` · `_검증_조합_화면.md`) - 이름 바뀜 — 자재 이름을 고르기 조건으로 잡는 방식으로 바뀜(변수 참조 20줄 이관). (`_검증_변수참조.md`) +- 재료 펼치면 자재품목 목록이 바로 열림 — 처음 펼칠 때 빈 화면으로 기다리던 것을 고침. +- 좁은 화면에서 넓은 표가 잘리면 안내 한 줄이 뜸. +- 화면 글 다듬음 — 「자재」 → 「재료」 따위로 이름을 맞춤. +- 견본 조합 `UA000001` 이 이미 들어 있음 — 이름에 「견본」 표시 · 로직 셋(GF000219 · GF000119 · GF000137) 담김 · 바로 열어 볼 수 있음. 필요 없으면 지워도 됨. **눌러 볼 차례 (세 줄)** 1. 로직 화면에서 13-4-1 · 12-4 · 12-17-1 열어 컨테이너 넷과 호표 세 묶음을 훑음. 2. 호표 줄을 눌러 알약 모달이 뜨고 줄이 강조되는지 봄. -3. 조합 컨테이너에서 새 조합 → 로직 셋 담기 → 미리 보기 합계 → 저장 → 다시 열기. +3. 조합 컨테이너에서 견본 조합 `UA000001` 을 열어 미리 보기 합계를 봄 → 새 조합 하나 만들어 로직 셋 담기 → 저장 → 다시 열기. -## ② 데이터가 채워진 정도 +## ② 빨라진 것 (`_검증_화면늦음.md` 「고친 뒤」) + +로직 목록을 부르는 길이 가장 느렸음(옛 6초). 마스터를 매번 새로 읽던 것을 한 번 읽어 두고 바뀐 파일만 다시 읽게 고침. + +| 길 | 앞 | 뒤 | +|---|---|---| +| 로직 목록 켠 뒤 첫 번 | 3.2초 | **0.03초**(서버가 뜰 때 미리 읽어 둠) | +| 로직 목록 그 다음부터 | 2.5초 | **0.02초** | +| 조합 목록 | 0.3초 | **0.01초** | +| 로직 저장 직후 목록 | 2.5초 | **0.06초** | +| 로직 한 줄 저장 | 6.8초 | **0.8초** | + +(잰 자리 = 랩탑 · 데스크탑은 같은 길이 2배쯤 느려 앞이 6초로 보였음.) +값이 그대로인지 전수로 맞춰 봄(`_검증_목록캐시.md`) — 로직 1,354줄 · 조합 · 고친 척한 경우 모두 어긋남 0. +**남은 것**: 인력·재료 같은 요소 파일 저장은 아직 7초(고치는 중) · 로직 저장의 0.8초 가운데 0.6초는 파일 두 번 읽기. (`_검증_화면늦음.md`) + +## ③ 데이터가 채워진 정도 | 항목 | 전 → 후 | 남은 것 | |---|---|---| @@ -27,19 +47,32 @@ 근거: `_검증_관급_가까운줄.md` · `_남은_옛재료줄.md` · `_없는_재료.md`. -## ③ 검증 수치 (`_마감_수치.md` · `_검증_3장_마감.md`) +## ④ 검증 수치 (`_마감_수치.md` · `_검증_3장_마감.md`) | 항목 | 결과 | |---|---| -| 시험(`test_m01_*` + `test_masterdata_*`) | 190 통과 · 실패 0 | +| 시험(`test_m01_*` + `test_masterdata_*`) | 밤 190 통과 → 새벽 195 통과 · 4 실패(견본 조합 탓) → 조합 시험을 고쳐 조합 + 이웃 여섯 파일 **100 통과 · 실패 0** | | `check_master` 틀 · 로직 · 조합 | 0 · 0 · 0건 | -| `check_master` 본문 | 2건(계수 CC000337 · CC000074 결손) | +| `check_master` 본문 | **0건**(계수 결손 둘 메워짐) | +| 계수 표 새로 올림 | CC000338 발생재 공제율 · CC000339~340 강관배관 부자재율(일반업무용 · 병원) | | 일괄 시험 계산 | 통과 1,291 · 멈춤 63(자료 비어서 멈춤 · 화면 탓 아님) | | 단위 경고 | 14건 | -| 줄 수 | 로직 1,354 · 자재품목 30,592 · 기계 654 · 인력 378 · 품셈재료 363 · 소요량+계수 3,030 · 조합 0 | +| 줄 수 | 로직 1,354 · 자재품목 30,592 · 기계 654 · 인력 378 · 품셈재료 363 · 소요량+계수 3,030 · 조합 1(견본) | -밤새 새로 밝힌 어긋남(고칠 것): 조합 `메모`·`이름` 칸이 글자 아닌 값도 저장됨(`_검증_조합.md`) · 3장 줄 이름 글자 2건(`_검증_3장_마감.md`). +고칠 것으로 남은 어긋남: 조합 `메모`·`이름` 칸이 글자 아닌 값도 받는 점(`_검증_조합.md` · 시험은 이미 마련됨) · 단가 비어 멈춘 로직 63(`_단가_빈로직.md` · 자료가 채워져야 풀림). -## ④ 사용자가 정할 것 +## ⑤ 사용자가 정할 것 -PLAN 6장을 봄 — 다시 적지 않음. 관급 쪽 상세는 `_확정거리_관급.md`. +PLAN 6장은 이 창의 PLAN.md 에 없어 **PLAN 1-3 에 적음(이 창 PLAN.md 는 git 밖 파일이라 여기에도 옮겨 둠)** — 다시 적지 않음. 관급 쪽 상세는 `_확정거리_관급.md`. + +새로 생긴 것 둘: +- 「단가산출 상세」 대 「호표」 — 화면에서 어느 말을 쓸지 정함. +- 견본 조합 `UA000001` — 계속 둘지 지울지 정함. + +## ⑥ 아침에 눌러 볼 차례 + +1. 로그인함. +2. 마스터 데이터 화면을 엶. +3. 단가산출 로직에서 13-4-1 을 열어 컨테이너와 호표 세 묶음을 봄. +4. 로직 개선 시험에서 같은 로직을 열어 컨테이너 넷 · 텍스트 수식 두 칸을 보고, 호표 줄을 눌러 알약 모달이 뜨는지 봄. +5. 일위대가 조합에서 견본 조합 `UA000001` 을 열고 미리 보기 합계를 봄. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 6daf320f..6f2193b2 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -174,16 +174,6 @@ def master(folder: Path = MASTER) -> mf.Master: return mf.Master(load(folder=folder)) -def check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]: - """저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키 + 조합. - - `files` = 폴더 전부(고친 뒤) · `book` = 키 대장. - """ - whole = mf.Master(files) - out = [x for name in changed for x in check_form(name, files[name])] - return out + check_logics(files, whole, book) + mcb.check_all(files, whole) - - def calc(files: dict[str, dict], ref: str, given: dict) -> dict: """시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError.""" return mf.run(mf.Master(files), ref, given) @@ -562,17 +552,25 @@ def _elements_into(files: dict[str, dict], by_section: dict) -> None: # ── (3) 로직 ────────────────────────────────────────────────────────── -def check_logics(files: dict[str, dict], whole: mf.Master, book: dict | None = None) -> list[str]: - out, graph = [], {} +def check_logics( + files: dict[str, dict], whole: mf.Master, book: dict | None = None, narrow=None +) -> list[str]: + """`narrow` = (검사할 로직 키, 안 보는 줄이 부르는 키) — 주면 그 키만 검사하고 + 품셈재료 연결 · 인력 준용(`check_links`)도 건너뜀(로직 줄 고침이 못 건드리는 자리).""" + only, graph = narrow or (None, {}) + out, graph = [], dict(graph) for name, data in files.items(): if data.get("그룹") != "로직": continue for row in data.get("줄", []): + key = str(row.get("키")) + if only is not None and key not in only: + continue found, calls = mf.check_logic(whole, row) out += [f"{name} · {x}" for x in found] - graph[str(row.get("키"))] = calls + graph[key] = calls out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)] - return out + duplicate_keys(files, book) + check_links(whole) + return out + duplicate_keys(files, book) + ([] if only is not None else check_links(whole)) def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]: diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index d88a77cd..8f30ab58 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -116,6 +116,18 @@ def cond_text(cond: dict) -> str: return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) +def by_class(master: mf.Master) -> dict[str, dict]: + """자재품목을 「구분」 으로 미리 갈라 둠(마스터마다 한 번) — 고르기 후보를 찾을 때 + 3만 줄을 다 훑지 않게. 가른 차례는 파일 차례 그대로.""" + got = getattr(master, "_by_class", None) + if got is None: + got = {} + for key, row in master.index.get(MARKET, {}).items(): + got.setdefault(row.get("구분"), {})[key] = row + master._by_class = got + return got + + def candidates( master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None ) -> list[str]: @@ -127,9 +139,8 @@ def candidates( need = plain_unit(unit) if unit else "" hits = [ key - for key, row in master.index.get(MARKET, {}).items() - if row.get("구분") == cond.get("구분") - and (not detail or row.get("상세구분") == detail) + for key, row in by_class(master).get(cond.get("구분"), {}).items() + if (not detail or row.get("상세구분") == detail) and all(w in plain_spec(row.get("규격")) for w in want) and (not need or plain_unit(row.get("단위")) == need) ] diff --git a/resources/master_data/일위대가조합.json b/resources/master_data/일위대가조합.json index 0e82e802..052c040c 100644 --- a/resources/master_data/일위대가조합.json +++ b/resources/master_data/일위대가조합.json @@ -2,5 +2,17 @@ "그룹": "일위대가조합", "판": "2026", "비고": ["조합 = 단가산출 로직을 골라 담은 묶음 · 수량·비율·계산은 두지 않음(프로젝트 몫)"], - "줄": [] + "줄": [ + { + "키": "UA000001", + "이름": "견본 조합(지우셔도 됨)", + "구분": "산림품셈", + "상세구분": null, + "단위": "식", + "담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반 트레일러(20ton)" }], + "출처": "자체", + "소유": "현장", + "비고": "브레인이 넣은 견본 · 조합 화면을 바로 볼 수 있게 함 · 지워도 됨" + } + ] } diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py new file mode 100644 index 00000000..64f3e6f1 --- /dev/null +++ b/resources/tester/test_m01_cache.py @@ -0,0 +1,249 @@ +"""M01 목록이 마스터를 한 번만 읽는지 — `M01_MasterData_Store_Cache`. + +목록(`/logics` · `/combos`)은 요청마다 마스터를 새로 읽지 않고 막힘도 미리 세어 둔 값을 씀. +여기서 보는 것 = ① 세어 둔 값이 줄마다 검사한 것과 같음 ② 파일이 바뀌면 바로 다시 읽음 +③ 저장 직후 목록에 바로 비침 ④ 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시 셈 +⑤ 저장 검사를 고친 줄 둘레로 좁혀도 흠(없는 키 · 없는 요소 · 돌고 도는 참조)을 그대로 잡음. +""" + +from __future__ import annotations + +import json +import shutil +import time +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +REAL = store.FOLDER +CALLER = "GC000994" # 로직_건설품셈_01장_공통.json — 아래 로직을 부름 +CALLEE = "GC000276" # 로직_건설품셈_08장_건설기계.json — 다른 파일에 있음 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _logics(client: TestClient, **params) -> list[dict]: + res = client.get("/api/m01/logics", params=params) + assert res.status_code == 200, res.text + return res.json()["logics"] + + +def _write(file: str, change) -> None: + """정본 파일을 손으로 고침(저장 길을 거치지 않음) — 바뀐 것을 알아채는지 보려고.""" + path = store.FOLDER / file + data = json.loads(path.read_text(encoding="utf-8")) + change(data) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") + + +def test_세어_둔_막힘이_줄마다_검사한_것과_같다(client: TestClient) -> None: + listed = _logics(client) + files, whole = store.loaded() + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + assert len(listed) == len(rows) + for one in listed: + reasons = store.cm.mf.check_logic(whole, rows[one["키"]])[0] + assert one["reasons"] == reasons and one["blocked"] is bool(reasons) + + +def test_거름이_그대로다(client: TestClient) -> None: + whole = _logics(client) + sub, detail = whole[0]["구분"], whole[0]["상세구분"] + by_sub = _logics(client, sub=sub, detail=detail) + assert by_sub == [x for x in whole if (x["구분"], x["상세구분"]) == (sub, detail)] + assert _logics(client, blocked=1) == [x for x in whole if x["blocked"]] + assert _logics(client, blocked=0) == [x for x in whole if not x["blocked"]] + assert _logics(client, q=CALLER) == [x for x in whole if x["키"] == CALLER] + + +def test_안_바뀌면_다시_읽지_않고_바뀌면_바로_읽는다(client: TestClient) -> None: + files, whole = store.loaded() + assert store.loaded() == (files, whole) # 같은 것을 그대로 씀 + _write("로직_건설품셈_01장_공통.json", lambda d: d["줄"][0].update(비고="자국 바뀜")) + fresh, _ = store.loaded() + assert fresh is not files + assert fresh["로직_건설품셈_01장_공통.json"]["줄"][0]["비고"] == "자국 바뀜" + + +def test_로직을_저장하면_목록에_바로_비친다(client: TestClient) -> None: + one = client.get("/api/m01/logic", params={"key": CALLER}).json() + assert [x for x in _logics(client, q=CALLER)][0]["이름"] == one["logic"]["이름"] + row = {**one["logic"], "이름": "저장 뒤 이름"} + res = client.post( + "/api/m01/save", + json={ + "files": [ + { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": CALLER, "row": row}], + } + ] + }, + ) + assert res.status_code == 200, res.text + listed = [x for x in _logics(client) if x["키"] == CALLER] + assert listed and listed[0]["이름"] == "저장 뒤 이름" + + +def test_부르는_로직이_바뀌면_다른_파일의_줄도_다시_센다(client: TestClient) -> None: + assert [x for x in _logics(client) if x["키"] == CALLER][0]["blocked"] is False + + def add_input(data: dict) -> None: + row = next(r for r in data["줄"] if r["키"] == CALLEE) + row["입력"] = [*row.get("입력", []), {"이름": "새입력", "종류": "수"}] + + _write("로직_건설품셈_08장_건설기계.json", add_input) # 부르는 쪽 파일은 그대로 + caller = [x for x in _logics(client) if x["키"] == CALLER][0] + assert caller["blocked"] is True and any("새입력" in x for x in caller["reasons"]) + + +def _save(client: TestClient, key: str, spoil) -> object: + """그 로직 줄을 고쳐 저장 길로 보냄 — 저장 검사가 흠을 잡는지 보려고.""" + one = client.get("/api/m01/logic", params={"key": key}).json() + row = json.loads(json.dumps(one["logic"], ensure_ascii=False)) + spoil(row) + return client.post( + "/api/m01/save", + json={ + "files": [ + { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": key, "row": row}], + } + ] + }, + ) + + +def _errors(res) -> list[str]: + assert res.status_code == 422, res.text + return res.json()["detail"]["errors"] + + +def test_저장_검사를_좁혀도_흠을_잡는다(client: TestClient) -> None: + """고친 줄과 그 줄을 부르던 줄만 검사하지만 없는 키 · 없는 요소는 그대로 걸림.""" + _logics(client) # 미리 세어 둔 값이 선 평소 모양 + + def no_logic(row: dict) -> None: + at = next(i for i, h in enumerate(row["호표"]) if h["종류"] == "로직") + row["호표"][at]["요소"] = "로직(GC999999, 기계='5401-0017', 지역=지역)" + + def no_element(row: dict) -> None: + at = next(i for i, h in enumerate(row["호표"]) if h["종류"] == "인력") + row["호표"][at]["요소"] = "LB999999" + + for spoil, word in ((no_logic, "GC999999"), (no_element, "LB999999")): + assert any(word in x for x in _errors(_save(client, CALLER, spoil))) + + +def test_돌고_도는_참조도_저장이_막는다(client: TestClient) -> None: + _logics(client) + + def call_back(row: dict) -> None: + row.setdefault("호표", []).append( + { + "종류": "로직", + "요소": f"로직({CALLER}, 지반구분='토사', 크레인='2104-0010', 지역=지역)", + "이름": "되부르기", + "단위": "시간", + "수량": "1", + } + ) + + assert any("돌고 도는" in x for x in _errors(_save(client, CALLEE, call_back))) + + +def test_고친_줄을_부르던_줄이_깨지면_저장이_막힌다(client: TestClient) -> None: + """좁힌 검사가 「그 줄을 부르는 줄」 을 빠뜨리지 않는지 — 다른 파일에 있는 줄.""" + _logics(client) + + def add_input(row: dict) -> None: + row["입력"] = [*row.get("입력", []), {"이름": "새입력", "종류": "수"}] + + assert any("새입력" in x for x in _errors(_save(client, CALLEE, add_input))) + + +def test_미리_읽어_두면_첫_목록이_기다리지_않는다(client: TestClient) -> None: + store.cache.forget() + thread = store.cache.warm(store.FOLDER) + thread.join(120) + assert not thread.is_alive() + start = time.perf_counter() + assert _logics(client) + assert time.perf_counter() - start < 1.0 # 미리 안 읽으면 2~3초 + + +def _plain(file: str) -> dict: + """정본 파일 그대로(수는 글이 아닌 그대로) — 저장에 실어 보낼 줄을 꺼내려고.""" + return json.loads((store.FOLDER / file).read_text(encoding="utf-8")) + + +def _save_file(client: TestClient, file: str, changes: list[dict]): + """정본 파일을 저장 길로 고침 — 요소·표 줄도 같은 길.""" + return client.post( + "/api/m01/save", + json={"files": [{"file": file, "version": store.read(file)[1], "changes": changes}]}, + ) + + +def test_쓰이는_요소를_지우면_저장이_막힌다(client: TestClient) -> None: + """요소 저장도 좁혀 검사하지만 그 요소를 쓰던 로직은 그대로 걸림.""" + _logics(client) # 미리 세어 둔 값이 선 평소 모양 + res = _save_file(client, "인력.json", [{"op": "delete", "key": "LB000014"}]) + assert any("LB000014" in x for x in _errors(res)) + + +def test_요소를_고쳐_저장해도_막힘_표시가_전수와_같다(client: TestClient) -> None: + """요소를 고친 뒤 다시 센 막힘이 줄마다 검사한 것과 같은지 — 좁혀 세는 길.""" + was = _logics(client) + raw = _plain("인력.json") + row = {**next(r for r in raw["줄"] if str(r.get("키")) == "LB000014"), "비고": "저장 검사 시험"} + res = _save_file(client, "인력.json", [{"op": "edit", "key": "LB000014", "row": row}]) + assert res.status_code == 200, res.text + listed = _logics(client) + files, whole = store.loaded() + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + assert [x["키"] for x in listed] == [x["키"] for x in was] + for one in listed: + assert one["reasons"] == store.cm.mf.check_logic(whole, rows[one["키"]])[0] + + +def test_요소_지도가_전수_검사와_같다(client: TestClient) -> None: + """「어느 로직이 어느 요소를 보는지」 지도가 놓치는 자리가 없는지 — + 인력 줄을 통째로 비우고 1,354 줄을 다 검사해, 달라진 줄이 지도 안에 드는지 봄.""" + counts = store.cache.counted(store.FOLDER) + files, _ = store.loaded() + marks = set() + for row in files["인력.json"]["줄"]: + key = str(row.get("키")) + marks |= {key, "#" + key[:2]} + if row.get("원문번호"): + marks.add(f"{key[:2]}:{row['원문번호']}") + marked = {key for key, got in counts.items() if got[2] & marks} + empty = {**files, "인력.json": {**files["인력.json"], "줄": []}} + other = store.cm.mf.Master(empty) + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + changed = {k for k, r in rows.items() if store.cm.mf.check_logic(other, r)[0] != counts[k][0]} + assert changed and changed <= marked diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index dbca8ef9..36349715 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -5,11 +5,12 @@ - 조합은 이름과 담은 로직 목록만 지님 — 수량·비율·계산 칸을 두지 않음. - 미리 보기 합계 = 담은 로직 저마다의 시험 계산을 비목별로 더한 값(저장 없음). - 검사 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음. - - 견본 조합은 시험에서만 만들고 정본 파일에는 넣지 않음(정본은 빈 채로 둠). + - 시험은 임시 사본에서 돌고, 사본의 조합 줄은 비워 시작 — 정본에 견본 조합이 있어도 없어도 같음. """ from __future__ import annotations +import json import shutil import sys from decimal import Decimal @@ -38,6 +39,10 @@ def client(tmp_path: Path, monkeypatch) -> TestClient: for path in REAL.glob("*.json"): if not path.name.startswith("_") or path.name == store.mk.BOOK.name: shutil.copy(path, tmp_path / path.name) + own = tmp_path / mcb.FILE # 정본의 견본 조합이 시험 줄 수에 끼지 않게 사본은 비워 시작 + data = json.loads(own.read_text(encoding="utf-8")) + data["줄"] = [] + own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") monkeypatch.setattr(store, "FOLDER", tmp_path) app = FastAPI() app.include_router(router_module.router) @@ -80,10 +85,11 @@ def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: st # ── 정본 파일 ───────────────────────────────────────────────────────── -def test_정본_조합_파일은_빈_채로_있다() -> None: - """줄은 사용자가 화면에서 만듦 — 견본은 시험에서만.""" +def test_정본_조합_파일은_틀대로다() -> None: + """줄 수는 못 박지 않음 — 견본이 있어도 없어도 됨 · 줄은 조합 키(UA)와 열 한 벌만.""" data = store.cm.load([mcb.FILE], folder=REAL)[mcb.FILE] - assert data["그룹"] == mcb.GROUP and data["줄"] == [] + assert data["그룹"] == mcb.GROUP + assert all(row["키"].startswith("UA") and set(row) <= set(mcb.COLS) for row in data["줄"]) assert "원문" not in data # 관리자가 만드는 것이라 원문 근거가 없음 diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index bb250757..5a3e6fad 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -11,6 +11,7 @@ export const ui_locales_m1 = { M01_LoadFailed: ["마스터 요소를 못 읽음", "Failed to load master elements"], M01_PickFile: ["왼쪽에서 그룹과 파일을 고를 것", "Pick a group and a file on the left"], M01_Search: ["이름 찾기", "Find by name"], + M01_WideHint: ["표가 넓음 — 옆으로 밀어서 볼 것", "Wide table — swipe sideways"], M01_Prev: ["이전", "Prev"], M01_Next: ["다음", "Next"], M01_Page: ["쪽", "pages"],