Merge remote-tracking branch 'origin/dev' into main_desktop_1

This commit is contained in:
2026-09-22 03:10:55 +09:00
11 changed files with 427 additions and 34 deletions
+3 -2
View File
@@ -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"}
}
}
@@ -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` 에 시나리오를 얹을 것.
@@ -39,3 +39,6 @@ ORCA · 포트 8006/5179 · 넓은 화면 928px · 좁은 화면 390px(iframe).
3. 넓은 표(요율 · 기계 · 재료)는 390px에서 안쪽 스크롤 — 열 줄이기 없음.
4. 일위대가 조합 빈 화면.
5. 시험 흔적 없음(값 안 바꿈).
## 고친 뒤
- 재료 처음 펼침 : 「재료 · 자재품목」 30,592줄 목록이 바로 열림. 좁은 화면(600px 이하) 표 위에 「표가 넓음 — 옆으로 밀어서 볼 것」 한 줄(요율 · 소요량 확인) · 가로 스크롤 유지, 넘침 0. 자재품목 612쪽은 찾기 칸이 있어 그대로 둠.
@@ -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초.
+43 -10
View File
@@ -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` 을 열고 미리 보기 합계를 봄.
+12 -14
View File
@@ -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]:
@@ -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)
]
+13 -1
View File
@@ -2,5 +2,17 @@
"그룹": "일위대가조합",
"판": "2026",
"비고": ["조합 = 단가산출 로직을 골라 담은 묶음 · 수량·비율·계산은 두지 않음(프로젝트 몫)"],
"줄": []
"줄": [
{
"키": "UA000001",
"이름": "견본 조합(지우셔도 됨)",
"구분": "산림품셈",
"상세구분": null,
"단위": "식",
"담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반 트레일러(20ton)" }],
"출처": "자체",
"소유": "현장",
"비고": "브레인이 넣은 견본 · 조합 화면을 바로 볼 수 있게 함 · 지워도 됨"
}
]
}
+249
View File
@@ -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
+10 -4
View File
@@ -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 # 관리자가 만드는 것이라 원문 근거가 없음