Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -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초.
|
||||
|
||||
@@ -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` 을 열고 미리 보기 합계를 봄.
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -2,5 +2,17 @@
|
||||
"그룹": "일위대가조합",
|
||||
"판": "2026",
|
||||
"비고": ["조합 = 단가산출 로직을 골라 담은 묶음 · 수량·비율·계산은 두지 않음(프로젝트 몫)"],
|
||||
"줄": []
|
||||
"줄": [
|
||||
{
|
||||
"키": "UA000001",
|
||||
"이름": "견본 조합(지우셔도 됨)",
|
||||
"구분": "산림품셈",
|
||||
"상세구분": null,
|
||||
"단위": "식",
|
||||
"담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반 트레일러(20ton)" }],
|
||||
"출처": "자체",
|
||||
"소유": "현장",
|
||||
"비고": "브레인이 넣은 견본 · 조합 화면을 바로 볼 수 있게 함 · 지워도 됨"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 # 관리자가 만드는 것이라 원문 근거가 없음
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user