Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-31 11:23:41 +09:00
23 changed files with 1111 additions and 56 deletions
@@ -0,0 +1,114 @@
# 내역 5-C — 아이슬로 설계 기초자료 만들기 화면 실측 (sub7 · 10-31)
- 대상 = M02 마스터 템플릿(구조물 조합 · 일위대가 사전조합 · 일위대가 실무자료) · 계정 = 시스템 관리자(umsangdon · `/api/auth/session` 200 · SYSTEM_ADMIN 확인 뒤 판정) + 일반 사용자(verify_accounts 「일반」 · USER · 회사 3 · 쿠키 따로 문맥).
- ⛔ 쓰기 없음 — [확인] · [저장] · [사전조합 생성] · [복사] 확인 · [파일 업로드] 직전에서 멈추거나 [닫기] · [취소]. 파일 올리기는 올리는 순간 서버에 저장되므로(`M02_Labor_Router.py:197-209` · `M02_Hopyo_Store.py:78-102`) 올리지 않고, 이미 올라와 있는 북부청 엑셀(호표 68)로 셈.
- ORCA 탭은 이번엔 열렸으나 10~15 초마다 페이지가 저절로 다시 읽혀(performance.timeOrigin 실측 · 홈 화면도 같음) 조작이 끊김 → 지난번처럼 자기 tmp 의 headless Chrome(같은 5182)으로 셈. ORCA 탭은 놓음(release).
- 사진 = `tmp/내역_화면5/Aislo_M02_*.png` 16 장. 3-C 에서 찍은 화면(M02 첫 화면 · 사전조합 문서 · 목록표 · B06 구조물 배치 · B08 구조물 조합 선택)은 다시 찍지 않음.
- 엑셀 견본 = `resources/knowledge/original/실무문서/` 의 실무 xlsx 넷(열 짜임만 읽음 · 올리지 않음).
## ① 결론 다섯 줄
1. 엑셀 → 설계 기초자료 길은 네 단계 — 파일 통째 올리기 → 호표마다 맞춤 확인 → 「일위대가 사전조합」 생성 → 「구조물 조합」에 다시 담기. 붙여넣기(Ctrl+V)로 호표 하나 넣는 길과 숫자 그대로 넣는 줄이 없음(`M02_Labor_Router.py:325-328` 금액 · 품 칸 = 400).
2. 가장 큰 벽은 맞춤 — 엑셀 줄 415 중 서버 제안 234(56%). 나머지는 사람이 로직을 고르거나 버림. 제9호표는 줄 6 중 제안 2 · 자재 「큰돌」은 「없음」이라 체크도 못 함 → 만들어진 일위대가가 엑셀 금액과 다름.
3. 구조물 조합 하나 = 카드 셋(도면 · 일위대가 · 면적산출)을 따로 채움 — 일위대가만 줄 7개면 약 50 누름(추정). 수량산출을 안 넣은 줄은 합계에서 조용히 빠짐(돌기슭막이 예: 줄 합 1,910,689 원 → 반영 100,407 원 · 경고 없음).
4. 규격만 다른 조합(H=1.5 → 2.0)은 실무 엑셀에서 수량 4 칸 바꾸기(울진 간선 호표 17~19)인데, 아이슬로는 복사 → 이름 바꾸기 → 도면 · 면적 · 일위대가를 각각 고침(높이 한 칸이 셋으로 번지는 구조 없음). 복사본은 「원래 이름」 그대로라 같은 이름이 둘 생김.
5. 브레인 가설 근거: 실무 일위대가 줄 = 「단가산출 참조 × 수량」. 석축처럼 m 당 금액인 구조물은 설계가 높이로 조합을 고르고 길이를 곱하면 되므로 고정형(엑셀 숫자 그대로)으로도 금액은 이어짐(추정). 설계 값(관 길이 · 높이)이 넣을 값으로 들어가는 구조물만 계산형이 꼭 필요(추정 · 3-C ⑥-1 「관 길이 8 m(7.9)」). 지금은 고정형 줄 자체가 없음.
## ② 새로 본 화면 생김새
### 구조물 조합 항목 (`Aislo_M02_구조물조합_항목.png`)
- 위: 비교 행렬(종류 · 분류1~3 · 일위대가 합계). 행렬 칸을 누르면 아래 카드 셋이 그 항목으로 바뀜.
- 카드 셋 나란히 — 도면 정보(SD-) · 일위대가(SU-) · 면적산출(SA-). 카드마다 [?] [저장] [가져오기] [수정] [삭제]. 면적산출만 [알약] 더 있음.
- 도면 카드: CAD 미리보기 + 「DWG · DXF 파일을 끌어다 놓거나 [삽입 → DWG 불러오기]」.
- 일위대가 카드: 「단가산출 로직」 표(차례 · 구분 ▾ · 이름(GC000260 메쌓기…) · 별칭 · 수량산출 · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 순서). 금액 칸 두 줄 = 위 단위당 · 아래 반영. 수량산출 「—」 줄은 아래 칸도 「—」.
- 면적산출 카드: 엑셀 꼴 시트(서식 · 붓 · 필터 · Σ · 데이터 ▾ · fx) + 아래 알약 줄(메쌓기 ㎡ · 큰돌 채집 개 …). 이 견본 시트에 「블라블라 · 이히리베」 같은 시험 글이 남아 있음.
- [?] 도움말: 「로직을 더하거나 순서를 바꿀 때는 [수정]」(`M02_MasterTemplete_CardHelp.ts:63`).
### 일위대가 상세 (`Aislo_M02_일위대가상세.png` · `_원문띠.png`)
- 머리: [돌아가기] · 「SU-000006 돌기슭막이 메쌓기 1.2m …」 · [도면] [면적산출] [?] · 오른쪽 [새로고침] [저장].
- 단위(m) · 본뜬 곳(「system UA000008」) → 단가산출 로직 표 + [로직 추가] → 자재목록 + [자재 추가] → 다이어그램(넣을 값 → 일위대가 → 노무비 · 재료비 · 경비 칸).
- 오른쪽 접힌 「원문」 띠: 로직 줄을 고르면 품셈 원문(조항 번호 · 표 · [주] ①~⑪) + [원문 다운로드].
### 항목 추가 (`Aislo_M02_새항목창.png` · `_새로만들기창.png` · `_복사생성.png` · `_복사생성_확인.png`)
- [새 항목] → 「새 항목이 들어갈 자리 — 맨 위(폴더 밖) · 나무에서 폴더를 고르면 그 안」 + [새로 만들기] [복사 생성].
- [새로 만들기] → 「항목 추가」 창: 구조물 종류(있는 맨 위 폴더 이름 목록 · 새 이름도 됨) · 항목 이름 · [확인]. 단위 · 분류 · 규격은 안 물음(`M02_MasterTemplete_TreePanel.ts:483-492`). [확인] = 바로 서버에 씀(`:423-424`).
- [복사 생성] → 층 탭 넷(마스터 · 회사 · 개인 · 다른 사람) · 찾기 · 표(이름 · 폴더 · 단위 · 도번 · 일위대가번 · 면적번 · 고친 날). 금액 · 그림 미리보기 없음. 줄 누름 → 「… 복사해 「맨 위 (폴더 밖)」 에 새 항목으로 만듦 — 새 번호 · 원래 이름」 [취소] [확인].
### 일위대가 실무자료 (`Aislo_M02_실무자료.png` · `_실무자료_엑셀보기.png` · `_실무호표_확인창.png`)
- 왼쪽 묶음: [파일 업로드] · 올린 파일 줄(「2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls · 호표 68」) · 「별칭 장부」.
- 엑셀 보기 창: 머리 「시트 일위대가 · 호표 68 · 검산 맞음 · 제안 234 / 줄 415 · [삭제]」. 왼쪽 = 원본 엑셀 그대로(읽기 전용 · 아래 시트 탭 일위대가 · 단가산출). 오른쪽 = 「호표 · 품명 · 규격 찾기」 + 호표 목록(체크 · 제N호표 · [수정] · 이름 · 제안 n/m) + [저장]. 시트 고르기 단계 없음(시트 이름 고정 `M02_Hopyo_Excel.py:35-36`).
- 확인 창(호표 [수정]): 머리 「제9호표 큰돌흙막이(찰쌓기) · ㎡ · 엑셀 단가 148,382 · 금액 칸 위 = 엑셀 · 아래 = 우리(미리 셈) · 제안 2 / 줄 6 · 체크 2」 + [엑셀 보기] [맞음 · 별칭 일괄 체크 (2)] [사전조합 생성].
- 로직 줄: 체크 · 차례 · 구분 + 판정 꼬리표(맞음 · 다른 점 · 후보) · 우리 로직(GF000227 큰돌쌓기 찰쌓기 + 배수 칸) · 별칭(엑셀 이름) · 수량산출 · 금액 넷 · 「품 비교」(「작업반장 0.083 → 0.083 ✓ · 굴착기+집게 0.48 → – ≠」) · [고르기] [원문 보기] [엑셀 보기] [옵션] · 「추가 ▾」(로직없음 · 사용안함).
- 자재 줄: 판정(맞음 · 없음 · 후보) · 엑셀 이름 · 규격 · 자재구분 · 별도계상/직접계산 · 출처 넷 · 「엑셀 / 우리 반영 금액」. 「없음」 줄은 체크 칸이 꺼짐.
### 나누기 (`Aislo_M02_나무_우클릭.png` · `_회사로복사창.png` · `_회사층.png` · `_일반_첫화면.png` · `_일반_회사층.png`)
- 관리자 나무 우클릭(항목): 이름 바꾸기 · 옮기기 · 지우기 · 복사(1개) · 마스터로 복사…(1개) · 회사로 복사…(1개). 「회사로 복사…」 → 「복사할 폴더 [맨 위 (폴더 밖)] [확인]」.
- 관리자 회사 층(회사 1): 비어 있음 — 아직 아무도 회사로 나누지 않음(실측).
- 일반 사용자: 층 단추 「마스터 · 읽기 | 회사 · 읽기 | 개인」. 회사 층 위 막대 「회사용은 복사해서 씀 — 고치려면 고른 열을 개인용으로 복사 [개인용 복사]」. 회사로 올리는 메뉴 없음(`M02_MasterTemplete_UI_Page.ts:84-93` 쓰기 권한 층만).
## ③ K1~K6 누름 표
누름 = 마우스 누름 + 글 칸 입력 한 덩어리. 출발 = M02 화면이 열린 상태(대시보드 → M02 는 3-C T3 의 다섯 누름). 화면 이동 = 페이지 바뀜(창 겹은 괄호).
| 일 | 차례(화면 · 누름) | 누름 | 화면 이동 | 고민 지점 |
| --- | --- | --- | --- | --- |
| K1 엑셀 호표 하나 등록 | 붙여넣기 길 없음 → 실무자료 펼침(1) → [파일 업로드](1) → 파일 창에서 고름(2 · 추정) → (올리는 순간 저장 · 엑셀 보기 저절로 열림) → 찾기 칸 입력(1) → 호표 [수정](1) → 제안 줄 2 는 체크돼 있음 · 나머지 줄마다 [고르기](1) → 찾기(1) → 고름(1) · [옵션] 넣을 값(1~2) → [사전조합 생성](1) | 약 13~19(제9호표 · 추정) | 0 (창 3겹: 파일 창 · 엑셀 보기 · 확인) | 엑셀에서 복사한 줄을 붙일 칸이 없음 · 호표 하나를 위해 파일 통째를 올림 · 올리면 바로 저장(되돌림 = [삭제]) · 판정 낱말(맞음 · 다른 점 · 후보 · 없음) · 품 비교 ≠ 를 어떻게 할지 · 「없음」 자재(큰돌)는 체크 불가 · 결과물은 「사전조합」이라 설계(B06)에서 안 보임 → K3 를 또 해야 함 |
| K2 시트 통째(호표 수십) | 실무자료 펼침(1) → 업로드(3) → 엑셀 보기 호표 체크 ×68(모두 고르기 없음) → [저장 (68)](1) → 확인 1 번(1) — 안 연 호표는 서버 제안 줄로 만듦(`M02_MasterTemplete_Hopyo_Sheet.ts:9-10` · `:416-423`) | 약 74 | 0 (창 2겹) | 체크 68 번 · 제안 줄만 들어가 줄 415 중 234(56%)만 사전조합에 남음 · 체크 줄 없는 호표는 건너뜀 · 하위 호표를 먼저 안 만들면 409 안내 · 「만듦 n / m」 알림 뒤 무엇이 빠졌는지는 호표마다 열어 봐야 앎 |
| K3 새 조합(석축 H=2.0) | [새 항목](1) → [새로 만들기](1) → 종류(1) · 이름(1) → [확인](1 · 서버 씀) → 일위대가 카드 [수정](1) → [로직 추가](1) → 찾기(1) → 고름(1) → 줄마다 [옵션] 넣을 값(2~3) · 수량산출 칸(1) · 수기 또는 면적 칸 고름(1~2) — 실무 호표 17 꼴 7 줄 반복 → [저장](1) → [돌아가기](1) → 면적 카드 칸 입력 · 알약 잇기(줄마다 2~3) → [저장](1) → 도면 [수정] → DWG 불러오기 또는 CAD 그리기 → [저장] | 일위대가만 약 50 · 면적까지 약 65 + 도면(추정) | 화면 2(목록 → 일위대가 상세 → 목록 · 도면 상세 별도) | 「석축」으로 찾으면 헐기 2 건뿐(쌓기는 「돌쌓기 찰쌓기」 이름) · 종류 칸이 자유 글이라 B06 구조물 종류와 맞출 기준이 안 보임 · 단위(m)와 로직 단위(㎡)를 수량산출로 맞춰야 함 · 수량산출 안 채운 줄은 합계에서 빠지는데 경고 없음 · 엑셀의 1.57 같은 「m 당 수량」을 어느 칸에 넣는지(수기 vs 면적 칸) 판단 |
| K4 규격만 다른 조합 | 나무 우클릭(1) → 「복사(1개)」(1) → 붙여넣기(1~2) 또는 [새 항목] → [복사 생성] → 층 → 줄 → [확인](5) → 이름 바꾸기(우클릭 · 메뉴 · 입력 · 확인 4) → 일위대가 [수정] → 줄마다 수량 고침(4 줄 × 2) → [저장] → 면적 칸 고침(2~4) → [저장] → 도면 치수 고침(CAD · n) → [저장] | 약 22~28 + 도면(추정) | 화면 2 | 복사본 이름이 원래 이름 그대로(같은 이름 둘) · 높이를 한 곳에서 바꾸는 칸이 없음 — 도면 · 면적 · 일위대가 각각(`M02_Labor_Doc.py:3-9` · 면적 칸에 이은 수량만 따라감 `:8`) · 실무 엑셀은 같은 줄에 수량만 1.57 → 2.09 → 2.61 ㎡(울진 간선 `일위대가표` 133~158 행) |
| K5 회사 사람과 나누기 | 관리자 · 마스터 계정: 나무 우클릭(1) → 「회사로 복사…」(1) → 폴더(1) → [확인](1). 일반 사용자: 메뉴 없음 → **막힘**(회사 관리자에게 부탁 · 화면 밖). 받는 쪽: 회사 층 → 열 고름(1) → [개인용 복사](1) → 고침 | 관리자 4 · 일반 막힘 | 0 | 일반 실무자는 자기 것을 회사로 올릴 길이 없음(`M02_Layer_Access.py:1-9`) · 회사 사본은 원본과 따로 — 고친 뒤 다시 올려야 · 사전조합 목록표 [복사]는 같은 층 안만(`M02_MasterTemplete_Library_List.ts:197-205`) · 별칭 장부는 회사로 못 올림 |
| K6 설계에서 고르고 수량 · 금액 보기 | B06 구조물 고름(1) → 부품 「구조물 조합 [미지정]」(1) → 층 단추(1) → 나무 펼침(2~3) → 열 고름(1) → [적용](1) → 구조물 [저장](1) → B08(1) 집계표 → B09(1) → 설계내역 탭(1) (3-C 사진 재사용 · `Aislo_B06_구조물배치` · `Aislo_B08_구조물조합선택`) | 약 11~12 | 2(B06 → B08 → B09) | B06 고르기 창은 구조물 조합만 — 사전조합은 B09 단가연결에서만 고름(`B09_Estimation_UI_Tabs_Bill_Link.ts:227-237`) · 사본은 구조물 [저장] 때 생김 · M02 원본을 고쳐도 공사 사본은 그대로 → [재적용](`M02_Item_Snapshot.py:1-20`) · 금액은 저장 뒤(3-C T6) |
## ④ 엑셀을 들고 온 실무자가 처음 막히는 곳 다섯
1. **붙일 칸이 없음** — 엑셀에서 호표 줄을 복사해도 받는 칸이 없음. 로직 표 · 자재 표엔 붙여넣기 처리가 없고(M02 Labor 파일 paste 0 건), 엑셀 보기 창은 읽기 전용(`M02_MasterTemplete_Hopyo_Sheet.ts:337-338`). 파일 통째를 올려야 하고, 올리면 바로 저장 · 원본 파일은 안 남음(`M02_Hopyo_Store.py:1-5`).
2. **엑셀 숫자가 그대로 안 들어감** — 금액 · 품을 보내면 400(`M02_Labor_Router.py:325-328`). 우리 로직으로 다시 셈 → 제9호표 첫 줄 엑셀 134,304 / 우리 136,348, 품 비교 「굴착기+집게 0.48 → –」. 「우리에 없음」 줄 · 「없음」 자재는 빠짐. 실무자 눈엔 「내 단가가 바뀌었다」.
3. **이름이 셋 · 단계가 둘** — 실무자료(HP) → 사전조합(SC) → 구조물 조합(SU · SD · SA). 사전조합까지 만들어도 B06 에선 안 보이고, 구조물 조합에 [로직 추가] → 「일위조합」으로 다시 담아야 함. 화면엔 이 차례를 알려 주는 안내가 없음.
4. **빈 수량 줄이 조용히 빠짐** — 일위대가 카드 줄 7 개 중 수량산출 「—」 6 줄은 반영 금액 「—」. 합계 칸 위 1,910,689 · 아래 100,407 두 숫자가 함께 보여 어느 것이 이 구조물 값인지 헷갈림(`Aislo_M02_일위대가상세.png`). 「수량을 안 넣은 줄 6」 같은 알림 없음.
5. **규격 변형 · 찾기 낱말** — 높이 하나 바꾸려고 카드 셋을 따로 고침 · 복사본 이름이 같음. 실무 말 「석축」으로 찾으면 쌓기 로직이 안 나옴(「돌쌓기 찰쌓기」 · 「찰쌓기(굴착기+부착용 집게)」로 쳐야 나옴). 로직 고르기 목록은 GF · GC 번호와 「산림품셈 13장 구조물」 같은 장 이름이 앞섬.
## ⑤ 지금 화면 중 살릴 것
| 화면 장치 | 어디 | 왜 |
| --- | --- | --- |
| 엑셀 보기 창 — 원본 엑셀 꼴 그대로 + 오른쪽 호표 목록 · 제안 n/m(다 이으면 녹색) | 실무자료 | 엑셀 손버릇과 같음 · 무엇이 남았는지 한눈 |
| 엑셀 두 꼴을 다 읽음 — EST 꼴 「일위대가」(거창 · 호표 14) · STmate 꼴 「일위대가표」+「단가산출근거」(영월 23 · 울진 34) | `M02_Hopyo_Excel.py:35-36` · `M02_Hopyo_Stmate.py:1-20` | 실무 xlsx 넷 모두 받는 시트가 있음(열 짜임 확인) |
| 확인 창 「품 비교」(엑셀 품 → 우리 품 ✓ / ≠) · 금액 위 = 엑셀 · 아래 = 우리 | 실무 호표 확인 | 어디가 다른지 숫자로 봄 — 고정형 갈래를 둘 때도 그대로 쓸 꼴 |
| 호표 체크 + [저장 (n)] 한 번 · [맞음 · 별칭 일괄 체크] | 엑셀 보기 · 확인 창 | 수십 개를 한 번에 만드는 뼈대가 이미 있음 |
| 원문 띠 — 품셈 책 꼴(조항 · 표 · [주]) + [원문 다운로드] | 일위대가 상세 오른쪽 | 근거를 책에서 안 찾음 |
| 카드 셋 한 화면 + 비교 행렬 | 구조물 조합 | 도면 · 일위대가 · 면적을 같이 봄 · 규격별 금액 비교 |
| [복사 생성] 목록(마스터 · 회사 · 개인 · 다른 사람) · 우클릭 「회사로 복사…」 | 새 항목 · 나무 | 변형 · 나누기 길의 자리는 이미 있음 — 이름 바꾸기 · 일반 사용자 권한만 모자람 |
| 회사 층 안내 막대 「회사용은 복사해서 씀 … [개인용 복사]」 | 일반 사용자 회사 층 | 읽기 전용 이유와 할 일을 한 줄로 알려 줌 |
## ⑥ 품셈 보여 주는 꼴 (덧 · 내역 6)
사진: `Aislo_M02_로직추가창.png` · `Aislo_M02_원문띠.png` · `Aislo_M02_실무호표_확인창.png`.
| 물음 | 실측 |
| --- | --- |
| 품셈 책 꼴인가 · 코드 목록 꼴인가 | **고를 때는 코드 목록 꼴.** 「로직 고르기」 창 = 종류(전체 · 단가산출 · 일위조합 · 실무 호표) · 구분(산림품셈 · 건설품셈 공통/토목/건축/기계설비/유지관리 · A 횡단배수 ~ G 노면공 · 실무자료) · 상세구분 · 찾기 · 층 단추 → 줄 = 「단가산출 · 돌쌓기 찰쌓기(인력) · GF000221 · 산림품셈 13장 구조물 · 원/㎡」. 1,471~1,609 줄. **담은 뒤에는 책 꼴** — 원문 띠에 「산림품셈 13-2-3 큰돌 채집 (단위: 100개당)」 표(작업반장 2.6 인 · 굴착기 13.4 h …)와 [주] ①~⑪. |
| 고르기 전에 품 · 수량이 보이나 | **안 보임.** 줄을 누르면 바로 담기고 창이 닫힘(`M02_MasterTemplete_Labor_Pick.ts:161-164`) — 미리보기 · 원문 보기 없음. 단위만 보임(원/㎡ · 원/10㎡). 실무 호표 줄만 「제안 2/6」이 덧붙음. |
| 담은 뒤 품이 보이나 | 일위대가 표엔 품(인 · 시간)이 없음 — 이름 · 옵션 요약(「뒷길이 55cm이하 · 켜쌓기 90%」) · 금액 넷만. 품은 원문 띠(책) · 다이어그램(넣을 값 → 노무비 · 재료비 · 경비)으로 봄. 품이 숫자로 나란히 보이는 곳은 실무 호표 확인 창의 「품 비교」 칸 하나뿐. |
| 복사해 와서 고칠 수 있나 | **품은 못 고침.** 일위대가 줄에서 고치는 것 = 옵션(넣을 값) · 수량산출 · 별칭 · 순서 · 빼기. 로직(품) 고치기 · 복사는 M01 시스템 관리자만(1-C ⑥ 6-2). 엑셀에서 가져온 품도 우리 품으로 바뀜(≠ 는 버려짐). 「복사해 와서 품만 고치는」 길 없음. |
| 낱말 | 「석축」 → 헐기 2 건뿐 · 쌓기는 「돌쌓기 찰쌓기」 · 「큰돌쌓기 찰쌓기」 · 「찰쌓기(굴착기+부착용 집게)」 넷으로 흩어짐(산림 · 건설 품셈 둘) — 실무자는 둘 중 무엇을 골라야 할지 판단 필요. |
## 브레인 가설 확인 거리 (계산형 · 고정형)
- 실무 엑셀 일위대가 = 줄마다 「단가산출 N 참조 × 수량」(울진 간선 호표 17: 찰쌓기 52,995 원/㎡ × 1.57 ㎡ · 고임돌채집 24,816 × 0.24 …). 규격 변형 = 같은 줄에 수량만 다름.
- 아이슬로 설계가 조합에서 쓰는 것(추정): 단위당 금액(일위대가 합계) · 자재 수량(B08 자재) · 넣을 값(설계가 넘기는 관 길이 · 높이 · 측점 — 3-C ⑥-1).
- 설계가 「높이로 조합을 고르고 길이를 곱함」인 구조물(석축 · 돌기슭막이 · 측구) → 고정형(엑셀 숫자)으로도 금액 · 수량이 이어질 것으로 추정.
- 설계 값이 로직 안 품 · 수량을 바꾸는 구조물(관 길이 → 관 · 보호공 수량 등) → 계산형 필요(추정).
- 잃는 것(고정형): 노임 새 판 반영 · 품셈 개정 따라가기 · 넣을 값 옵션. 확인 창 「품 비교」로 고정 ↔ 계산 차이를 보여 줄 수 있음.
- 지금 코드엔 고정형 줄 자리가 없음 — 가까운 것은 개인 「신규 자재」(이름 · 규격 · 단위 · 값 → 재료비 단가 하나 · `M02_Material_Own.py:1-8`).
## 남은 것 · 한계
- 파일 올리기 · [사전조합 생성] · 새 항목 [확인] · 복사 [확인]을 누르지 않아 K1~K4 뒤 단계는 코드 읽기 · 추정. 도면(CAD) 누름 수는 안 셈.
- K6 은 3-C 사진 · 코드로 셈(검증_sub7 은 구조물 조합이 아직 안 이어짐).
- 일반 사용자 화면은 M02 첫 화면 · 회사 층만 봄(브레인검증회사 · 비밀번호 출력 없음).
- ORCA 탭 저절로 다시 읽힘(10~15 초 · 원인 모름) — 지난번 「탭이 사라짐」과 같은 뿌리로 추정.
@@ -0,0 +1,151 @@
# 내역 7 — 경쟁사 기준 단가산출 · 일위대가 자료의 생김새 · 아이슬로 M01 대조 (sub6 · 10-31)
읽기 전용 조사. 경로 줄임:
- `ST/` = `sub_laptop_8/resources/knowledge/original/경쟁사 프로그램/STmate 분석/`
- `EST/` = `sub_laptop_8/resources/knowledge/original/경쟁사 프로그램/EST Plus 2/REA 분석/`
- `DS/` = `sub_laptop_8/resources/knowledge/original/원가계산/다산소프트/` (EST 2026 하반기 배포본 `2026-07-main.estx` 있음)
- `MD/` = `sub_laptop_6/resources/master_data/`
- 「main 직접 집계」 = 이번 조사에서 `2026-07-main.estx` 를 메모리에서만 풀어 센 값(파일 안 만듦 · 줄 번호 없음).
## ① 결론 다섯 줄
1. 경쟁사 기준 자료 = **공급사가 반기마다 통째로 배포하는 「완성된 호표 묶음」** — EST 는 main 하나(산근 1,569 · 일위 1,468) + 공종별 설계참고철 13 개, STmate 는 조달청 표준일위대가를 다시 담은 다섯 파일(토목 · 건축 · 기계설비 · 전기 · 통신 · 암호화).
2. 산근 하나 = **숫자를 박은 글 문서** — 품셈 표 값 · q·K·f·E·Cm 을 숫자로 적고 노임 단가를 곱해 내려씀. 표를 부르는 구조 없음. 규격(관경 · 뒷길이 · 굴착기 크기)마다 호표를 **따로 늘어놓음**(EST 흄관 D250~2000 = 18 건 · 터파기 87 건).
3. 일위대가도 공급사가 줌(EST 1,468 · STmate 표준일위대가). 다만 실무 내역서의 몸통은 산근(실무 3 건 산근 61~74 · 일위 3~14) — 일위는 취수장 · 횡단개거 같은 현장 복합 구조물에만. 실무 일위대가는 「기준 복사 → 이름 · 규격 · 수량 고침」으로 생김.
4. 아이슬로 M01 = **조항 하나 = 로직 하나 · 규격은 입력 칸 · 품셈 표는 따로 두고 부름**(로직 1,469 중 1,399 가 `찾기(표…)`) — 계산 근거는 더 곧고 개정 대응도 쉬움. 대신 실무자가 늘 보던 「석축쌓기 | 메쌓기, T=35cm이하 | ㎡ · 80,663 원」 같은 **이름 · 규격 · 금액이 정해진 한 장**이 없음.
5. 바꿀 것 후보(제안만): 로직은 그대로 두고 **「대표 규격으로 펼친 보기 목록」**(품명 · 규격 · 단위 · 금액)과 **「계산서 꼴 보기」**(경쟁사 산근 글 모양으로 숫자를 채워 찍음)를 겉에 더함 — 숫자 박기로 되돌리지 않음.
## ② 경쟁사 기준 자료 생김새
### 2-1 EST Plus 2
| 물음 | 생김새 | 근거 |
|---|---|---|
| 몇 개 | main.estx 하나: 산근 1,569 · 산근 줄 77,329 · 일위 1,468 · 일위 줄 10,506 · 기계 1,049 · 자재 23,987 · 노임 395 | `DS/ESTX_분석.md:26-27` · `DS/ESTX_테이블사전.md:11-12` |
| 덧 묶음 | 「2026_상반_설계참고철」 공종별 13 파일(가시설 · 건축 · 교량 · 단지 · 도로 · 산림토목 · 설비 · 용역비 · 전기 · 조경 · 측량 · 하천 · 해양) · 형식은 보통 프로젝트 .estx 와 같음 | `EST/01_전체구조/installation_inventory.json:153-231` |
| 설계참고철 크기(상한) | 산근/일위 — 산림토목 422/101 · 도로 308/259 · 하천 418/260 · 전기 0/9,917 · 설비 0/4,786 → 토목은 산근 위주, 설비 · 전기는 일위 위주 | `EST/06_정적교차검증/all_bundle_layout_candidates.json` (머리 크기로 계산 · 추정) |
| 묶는 법 | 장 · 조항 계층 없음 — **평평한 목록**. 차례만 공종 흐름(콘크리트깨기 → 터파기 81~167 → 석축쌓기 428~ → 흄관 655~) | main 직접 집계 |
| 이름 | 품명 · 규격 · 단위 세 칸. 예 `석축쌓기 \| 메쌓기, T=35cm이하 \| ㎡`. 코드 = 접두 글자 + 10 자리(산근 D · 일위 U · 자재 M · 노임 L · 기계 E) · 품셈 조항 번호는 코드에 안 들어감 | main 직접 집계 |
| 산근 안의 꼴 | 자유 글 문서 · 한 줄 = 한 레코드 · 중앙값 24 줄(최대 190). 글 안 숨은 표지로 품명 · 단위 · 연결 단가 · 앞 결과 참조 · 합계를 엔진이 읽음 | `EST/09_수식집계_변경저장_추적_20261029/ESTPlus2_산근LineData_수량연결_정적분석_20261029.md:18-29` · `EST/ESTPlus2_REA_최종보고서_20261030.md:66-68` |
| 변수 | `q = 0.7㎥ f = 1/1.24 = 0.81 K = 0.9` 꼴 — 글자는 사람 보라고 적은 이름, 실제로 넘어가는 것은 숫자(추정). 「전역변수」 메뉴는 있음 | 위 문서:47 · `EST/02_화면구성/captions.json:8896` |
| 숫자 박기 대 표 부르기 | **숫자 박기** — `3인 / 23㎡` · `(3.6+2.7+2.2)/3` · `Cm = 20sec`. 품셈 표를 부르는 길 없음. 「품셈자료 붙여넣기」 = 품셈책 글을 붙이는 기능(추정) | main 직접 집계 · `installation_inventory.json:429-555`(BookData .blb) |
| 규격 변형 | 규격마다 **따로 한 건씩** — 흄관(소켓관) D250~2000 18 건 · (수밀밴드) 18 건 · 터파기 87 건 | main 직접 집계 |
| 조항 출처 | 칸 없음 · **본문 주석 글**로 — `< 공통품셈 8-2-15 >` · `< 2025년 품셈 개정 >` · `2. 메쌓기 (26년 품셈개정)`. 1,569 중 973 건에 「품셈」 · 「n-n-n」 글 있음 | main 직접 집계 |
| 반기 배포 | main 공지 41 행: 노임(협회 2026.1.1) · 중기기초자료 2026.7.1 · 환율 · 유류(석유공사 26년 6월) · **「2026년 개정품셈 적용」과 개정 항목 목록**(건설기계 · 자재손율 · 돌쌓기 · 돌붙임 · 전석쌓기 …) · 시중물가 · 관급 기준일 · 표준시장단가 → **단가와 품셈 개정 둘 다 반영해 다시 배포** | main 직접 집계 · `DS/_meta.md:3,19-20` |
### 2-2 STmate
| 물음 | 생김새 | 근거 |
|---|---|---|
| 몇 개 | 기준자료 패키지(.STBSC) 28 개 · 그중 표준일위대가 = `ST_BSC\일위대가\2026\` 다섯 파일(토목 199,599 B 가 가장 큼) | `ST/20_분석/02_데이터셋과_파일형식.md:10,124` · `ST/30_원자료/system_inventory.csv:119-123` |
| 정체 | 안내 파일 「2026년 상반기 조달청 표준일위대가 / 발표일 2026.01.27 공사원가통합관리시스템(03.17 수정배포)」 — **조달청 자료를 STmate 꼴로 다시 담은 것** | `ST/30_원자료/system_structures.json:31434-31458` |
| 안 보이는 것 | 내용 **암호화** — 호표 수 · 차례 · 식 글 직접 확인 못 함. 산근 전용 패키지는 목록에 없음(일위대가 안에 같이 든 것으로 추정) | `ST/20_분석/29_암호화자료의_뼈대.md:81-102` · `system_inventory.csv:39-129` |
| 이름 | 명칭 · 규격 · 단위 세 칸. 코드 = 대가 B · 산근 D · 중기 X · 노무 L · 자재 M · 경비 S + 프로젝트 안 순번(`B00004 깬잡석찰쌓기`). 출력은 「산근 4호표」 「대가 1호표」 | `ST/20_분석/20_설정파일과_보고서_카탈로그.md:79-83` · `ST/30_원자료/stmate_code_name_list.csv:5,178` |
| 산근 안의 꼴 | 줄 글 식 — 자원 참조 `&X00006L&` · 품+단위 `0.31'Kg'` · 할증 `5'%'` · 중간 이름 `:=A01` · `Q=` 식 | `원가계산/STmate/SIxMemo_구조.md:28-52` · `ST/20_분석/23_COSTN_SMT_전수파싱.md:12-31` |
| 변수 | 이름(q · K · f · E · Cm · V1~ · t1~)을 적되 **값은 숫자**(`q=0.7 , K=0.9 , E=0.45-0.05= 0.40`). 사용자 등록 「중기공식」 — Q 식 10 꼴 · Cm 식 54 꼴 | `ST/20_분석/26_편집기와_사이클식.md:27-38` · `23_…:56-70` · `28_변환_수량산출_식언어.md:36-43` |
| 규격 변형 | **호표를 따로** — 기슭막이 H=2.0/2.5/3.0 = B00010/B00011/B00047 · 파형강관 D800/D1200 = D00020/D00039 · 구성은 같고 수만 다름 | `stmate_code_name_list.csv:11-12,48,178,195` · `ST/20_분석/31_일위대가_레시피_사전.md:89-96` |
| 조항 출처 | 일부만 · 두 꼴 — 산근 머리 글(`건설표준품셈 3-1-3(터파기),11-3(굴삭기) 적용`) · 일위대가 안 수량 0 줄(`건설표준품셈 \| 7-1-2(찰쌓기) \| \| 0.0`). 350 중 82 만 있음 | `31_…:112` · `ST/20_분석/32_특수산식과_현행품셈_대조.md:50` |
| 반기 배포 | 노임기계 반기마다(25 상 · 하 · 26 상 · 하) · 표준일위대가는 26 상반기 한 판. 품셈 개정이 들어가는지는 확인 못 함(조달청이 해마다 개정 반영하므로 따라 들어감 — 추정) | `02_…:130-140` · `31_…:18` |
## ③ 일위대가 기준 자료
- **공급사가 일위대가까지 줌** — EST main 일위 1,468 건 · STmate 조달청 표준일위대가 다섯 파일(`ST/20_분석/21_정정과_트랙통합.md:116-124`).
- EST 는 같은 항목을 산근 · 일위 **두 벌로** 주기도 함 — 흄관(소켓관) D=600 = 산근 91,198 원 · 일위 91,050 원(`DS/ESTX_분석.md:27`).
- 일위대가의 꼴 = **식 없는 격자** — 품명 · 규격 · 단위 · 수량(고정 숫자) · 단가 · 연결(자재 · 노임 · 기계 · 산근 · 다른 일위). 수량 근거는 규격 칸에 글로(`석공 | 3인/27㎡ | 0.111`)(main 직접 집계).
- 실무에서 쓰는 비중: EST 실무 내역서 3 건 = 산근 61~74 · 일위 3~14 — **단가 몸통은 산근**, 일위는 현장 전용 복합 구조물(취수장 · 횡단개거 · 기슭막이)(main · 실무 estx 직접 집계).
- 실무 일위대가가 오는 길: 기준을 가져와 **이름 · 규격 · 수량을 고쳐 프로젝트 사본**으로 씀 — 실무 코드가 D0000000173 처럼 작은 번호(새로 매김 · 추정). 같은 파형강관도 main 은 일위 「파형강관부설및접합」, 실무는 산근 「파형강관 매설」.
- STmate 레시피 사전 350 조합의 출처 = **공급사 자료 아님** · 임도 · 사방 실무 설계서 출력 엑셀 6 개의 「일위대가표 · 중기사용료 · 단가산출근거」 시트에서 뽑은 526 호표 → 겹침 걷어 350(일위 132 · 산근 136 · 중기 82)(`ST/40_스크립트/recipe_extract.py:19,162-173` · `ST/30_원자료/일위대가_레시피_감사.txt:1-10` · `ST/20_분석/31_…:4,24-31`). 수는 그 설계서 당시 판 값 — 현행 원문과 맞음을 확인한 것 2 개뿐(`32_…:30-44`).
- 아이슬로도 같은 실무 호표를 「일위대가 사전조합 · 실무 일위대가」 36 개로 이미 옮겨 둠 — 로직만 담고 수량 · 비율은 프로젝트 몫(`MD/일위대가조합.json:4` · 예 UA000004 기슭막이 `:61`).
## ④ 복사 · 고치기 꼴 (3-A · 3-B 와 겹치는 것은 줄만 가리킴)
| 걸음 | EST | STmate | 아이슬로 |
|---|---|---|---|
| 기준 찾기 | 통합검색 — 일위 · 산근 · 기계 · 자재 체크 · 「Main Data에서 검색 / 사용자 지정 폴더」 · 여러 개 골라 [적용] · 「Main값우선」(`EST/02_화면구성/captions.json:11605,19179,25234`) | 단가목록 `wM_JabPum` — 코드 종류로 거름 · 「명칭,규격 / 산근내용」 검색(`ST/20_분석/34_화면구조_정리.md:56`) | M01 단가산출 로직 목록 · 장 · 소유 · 찾기로 거름(3-C ② M01) |
| 복사되는 곳 | 프로젝트 파일 안 사본 · 코드 새로 매김(추정) | 프로젝트 단가 원장(B · D 코드 행 + 산출 글)(`ST/20_분석/04_계산_데이터계보.md:50`) | 「본떠 만들기」 → 자체 로직(새 키) · 정본은 못 고침(`docs/raw/M01_쉬운단가/쉬운단가_검토_D_지난시도.md:24,40`) |
| 산근 고치기 | 워드 같은 글 편집 + 오른쪽 메뉴로 줄 성격 지정 — 3-A ② 「단가산출 편집기」(`_분석_내역3-A_EST_화면.md:60-69`) | 식 글 편집기 + 문법 오류 목록 — 3-B ② 3(`_분석_내역3-B_STmate_화면.md:43-52`) | 별칭 꼴 식 줄 고침 · 표 바꾸기 · 시험 계산(`M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts:3-8`) |
| 일위 고치기 | 격자 — 3-A ② 「일위대가」(`…3-A…:48-58`) | 격자 — 3-B ② 2(`…3-B…:32-41`) | M02 사전조합 화면(3-C ② M02) |
| 반기 갱신 | 「Main Data로 갱신 / 선택파일로 갱신」 · 「되돌릴 수 없음」 경고(`captions.json:9974,10016`) | `wM_Mrg_BSC` — 맞춤 열쇠(코드 · 명칭 · 규격 · 단위) 고르고 명칭 · 단가를 「내 것 유지 / 새 판으로」(`ST/20_분석/22_UI_폼_분석.md:116-124`) | 요소(노임 · 자재 · 기계) 판만 바뀜 → 로직은 그대로 새 값으로 다시 셈(`MD/_갱신절차.md` 로직 절) |
- 경쟁사 공통점: 사용자가 고치는 대상 = **숫자와 글**(수량 · 이름 · 규격). 계산 틀을 고친다는 감각이 없음.
- 아이슬로 차이: 고치는 대상 = **식**(찾기 · 만약) — 1-C ⑥ 「고치기가 끝내 식 글자로 남음」과 같은 뿌리(`_분석_내역1-C_아이슬로현황.md:141-146`).
## ⑤ 같은 항목 셋 대조
### 5-1 석축(돌쌓기) 메쌓기
| | EST 기준(main) | STmate(실무 호표) | 아이슬로 M01 |
|---|---|---|---|
| 이름 | 산근 D0000002783 `석축쌓기 \| 메쌓기, T=35cm이하 \| ㎡` · 80,663 원 | `돌쌓기 \| 메쌓기,L3=35cm이하 \| m2` | GF000220 「돌쌓기 메쌓기(장비)」 · 원/㎡ · 산림품셈 13-4-2 (`MD/로직_산림품셈_13장_구조물.json:329`) |
| 규격 늘어놓기 | 메 · 찰 × T=35/55/75 = 산근 6 건 | 뒷길이마다 따로 | 로직 하나 · 입력 칸 「뒷길이」 하나 |
| 몸통 | `석공 : 268,908 × 3인 / 23㎡ = 35,074.9` · `보통인부 : 172,068 × 1인 / 23㎡` · `굴착기 Q = 23㎡ / 8hr = 2.88 ㎡/hr` → `59,020 ÷ 2.88` | 근거줄 `7-1-1(메쌓기)` · 석공 0.1 · 보통인부 0.05 · 굴착기 0.39 시간 · 끝에 `켜쌓기적용 \| 전체금액의 % \| 90.0` | 석공 = `찾기(QF000431, 명칭='석공', 뒷길이=뒷길이).수량` · 보통인부 · 굴착기+집게 같은 꼴 · 잡석 = 입력 「잡석량」 |
| 품셈 값 | 3인/23㎡ 등 **글 속 숫자** | 0.1 · 0.05 **숫자** | 표 QF000431(뒷길이 35/55/75 × 석공 0.10/0.09/0.08 …)을 **부름**(`MD/소요량_산림품셈_13장_구조물.json:261`) |
| 조항 표기 | 본문 주석 「(26년 품셈개정)」 | 근거줄(수량 0) | 칸 「출처」 · 입력마다 출처(지역 · 보정작업 = 건설 공통 8-1-7) |
| 더 하는 것 | — | — | 지역 · 보정작업이 기계 경비까지 이어짐 · 원문 주(높이 3m 초과 증가율 · 13-4-1 주③)를 「중간」 식으로 둠(GF000219 `:286`) |
근거: EST main 직접 집계 · STmate `ST/30_원자료/일위대가_레시피.json:8504-8590` · `stmate_code_name_list.csv:11,17`.
### 5-2 파형강관(흄관) 부설
| | EST 기준(main) | STmate(실무 산근 · 일위) | 아이슬로 M01 |
|---|---|---|---|
| 이름 | 일위 U0000001121 `파형강관부설및접합 \| D=600m/m \| M` · 12,328 원 (흄관은 산근 D=250~2000 18 건) | 산근 D00020 `파형강관 부설 및 접합 \| D=800mm \| M` · 일위 `파형강관부설 \| D=800mm \| m` | GC000404 「파형강관 부설 및 접합」 · 원/본 · 건설품셈 토목 6-7-2 (`MD/로직_건설품셈_06장_관부설_및_접합공사.json:1559`) |
| 규격 늘어놓기 | D=250~1500 **11 건 따로** | D800 · D1000 · D1200 따로 | 로직 하나 · 「관경」 고르기 11 값 |
| 몸통 | 파형강관 1 본(단가 0 · 별도) · 배관공 0.17 · 보통인부 0.08 · 공구손료 2% · 크레인 5Ton 0.2 hr · `=> M당 적용금액 \| 소계÷6M \| 0.16667` | 근거줄 `6-7-2` · 파형강관 별도계상 · 배관공 0.04 · 보통인부 0.02 · 공구손료 2% · 크레인 0.0416 시간(이미 m 당으로 나눈 값) | 배관공 = `찾기(QC000699, 관경=관경).배관공_수도 * 요율` · 크레인 = 고른 크레인 로직 × (1 + 8m 직관 가산 %) · 덧줄 공구손료 = 인력품의 2% |
| 품셈 값 | 숫자 | 숫자 | 표 QC000699(관경 11 줄 × 배관공 · 보통인부 · 크레인)를 부름 · 주 ①~⑥ 원문째 보관(`MD/소요량_건설품셈_06장_관부설_및_접합공사.json:736`) |
| 단위 | m 당(본 ÷ 6 m 를 줄로 박음) | m 당 | **본 당** — m 당은 쓰는 쪽(조합 · 수량)에서 나눔 |
| 더 하는 것 | — | — | 시공조건 A · B · C 요율(6-1-1 8) · 크레인 규격 고르기(주④) · 8 m 직관 10% 가산(주③) · 지역 |
근거: EST main 직접 집계 · STmate `ST/30_원자료/일위대가_레시피.json:5196,5224,10752-10870`.
### 5-3 (구조물) 터파기
| | EST 기준(main) | STmate(실무 산근) | 아이슬로 M01 |
|---|---|---|---|
| 이름 | 산근 D0000000812 `터파기 \| 백호(0.7㎥)90%+인력10% \| ㎥` · 9,764 원 | 산근 D00023 `구조물터파기(육상토사0~1m) \| 기계90%+인력10%(굴삭기 0.7M3) \| M3` | GF000101 「구조물터파기 육상토사(0~1m)」 · 원/㎥ · 산림품셈 9-13-1 + 작업량 GF000100 (`MD/로직_산림품셈_09장_토공.json:1057,1074`) |
| 규격 늘어놓기 | 87 건(인력 × 토질 5 · 백호 0.12~1.0 × 비율 · 육상 · 용수 · 수중) | 조합마다 따로 | 조항(깊이 · 육상/용수 · 토사/암)마다 로직 — 산림 9-13 만 18 로직 + 작업량 6 |
| 몸통 | `q = 0.7㎥ f = 1/1.24 = 0.81 K = 0.9 E = 0.6 Cm = 20sec` → `Q = 55.11 ㎥/hr` → `노무비 59,020 ÷ 55.11 × 0.9` · `특별인부 226,122 × 1인 / ((3.6+2.7+2.2)/3) × 0.1` | `▣임도표준품셈 5-1-2 가.육상토사(0~1m) 적용` · `q=0.7 , K=0.9 , E=0.40 / f=1/1.175 / Cm=20` → `Q=38.56` · `경비 23,128 / Q * 0.9` · `보통인부 165,545 * A(0.2) * 0.1` | 작업량 = `반올림(3600 * q * K * f * E / Cm, 3)` · q · K · f · E · Cm 을 각각 표에서 찾기 · 보통인부 = 표 값 × 인력비율 · 굴착기 = 기계비율 ÷ 작업량 |
| 품셈 값 | 숫자(q · K · E · Cm 모두) | 숫자 · 옛 판 값(굴삭기 0.4㎥ 판과 어긋나 값 복사 금지 판정 · `ST/20_분석/33_임도품셈_판_대응표.md:30`) | 표 QF000270 · 계수 CF000127(인력 · 장비 비율)을 부름 |
| 계산 근거 보기 | **한 장에 식 · 숫자 · 결과가 다 보임** | 같음 | 흐름도 · 시험 계산으로 봄 — 한 장 계산서 꼴 없음 |
근거: EST main 직접 집계 · `EST/09_…/linedata_sample_precision_check.json`(엔진 재현 72/72) · STmate `ST/30_원자료/특수_단가산출_21개.json`(호표 21) · `stmate_code_name_list.csv:181`.
### 5-4 아이슬로가 더 하는 것 · 덜 하는 것
더 하는 것:
- 품셈 표 · 계수를 **따로 두고 부름**(로직 1,469 중 1,399 · 소요량 표 2,535) → 품셈 개정 때 표만 바꾸면 그 표를 부르는 로직이 다 바뀜. 경쟁사는 공급사가 호표 글을 다시 써서 다시 배포해야 함.
- 규격 · 조건을 **입력 칸**으로(로직 1,189 개가 입력 가짐) — 관경 · 뒷길이 · 시공조건 · 지역 · 보정작업.
- 원문 주(할증 · 가산 · 상한)를 식으로 담고 못 담은 것은 「비고」에 「미반영 · 설계자 판단」으로 적음(GF000101 비고 「관보호공 30% 가산 미반영」).
- 설계 값이 그대로 들어옴 — 잡석량 · 높이 · 관경이 B06 · B08 설계에서 넘어옴(경쟁사는 사람이 손으로 침 · 내역 7 원문 `사용자요구_내역서.md:37-39`).
- 흐름도 · 시험 계산 · 조항 출처 칸이 줄마다 있음.
덜 하는 것:
- **이름 · 규격 · 금액이 정해진 기준 목록**이 없음 — 실무자가 찾는 「석축쌓기 | 메쌓기, T=35cm이하 | ㎡ · 80,663 원」 같은 줄이 목록에 안 보임(M01 목록 = 키 · 조항 · 로직 이름 — 금액 칸 없음).
- **한 장 계산서 꼴**이 없음 — 경쟁사는 산근 글 그대로가 제출 서류 「단가산출근거」. 아이슬로는 B09 에서 찍지만 M01 에서 기준을 볼 때는 식 · 흐름도.
- 일위대가 기준 묶음이 얇음 — EST 1,468 · 조달청 표준일위대가 대 아이슬로 사전조합 37(실무 36).
- 반기 「무엇이 바뀌었나」 공지 · 개정 항목 목록이 없음(EST main 공지 41 행 같은 것).
- 운반 · 채집 · 잔토처리 같은 실무 자주 쓰는 산근 일부가 「우리 로직에 없음」(`MD/일위대가조합.json` 비고들 · 예 `:58`).
실무자 눈에 읽기 쉬운 쪽:
- **보기(검토 · 제출)** = 경쟁사 쪽이 쉬움 — 숫자 · 노임 단가 · 결과가 한 장에 줄줄이. 계산기로 따라 셀 수 있음.
- **고치기 · 규격 바꾸기** = 아이슬로가 덜 손 감 — 관경만 바꾸면 끝. 경쟁사는 다른 호표를 찾아 바꾸거나 숫자를 손으로 고침(STmate 실무 호표 82/350 만 출처 · 옛 판 값 섞임).
- **낱말** = 경쟁사 쪽이 익숙 — 「산근 · 일위 · 호표 · 품명 · 규격」. 아이슬로 M01 은 「로직 · 찾기 · 중간 · 덧줄 · QF · GF」(3-C ⑥-2 ★ 목록 `_분석_내역3-C_아이슬로_화면.md:126-137`).
## ⑥ 바꿀 것 후보 (제안만 · 고치지 않음)
| # | 무엇 | 경쟁사 꼴 | 아이슬로에 둘 꼴(후보) | 크기 |
|---|---|---|---|---|
| 1 | 이름 | 품명 \| 규격 \| 단위 세 칸 · 「석축쌓기 \| 메쌓기, T=35cm이하 \| ㎡」 | 로직 이름은 두고 보기 목록에 **품명 · 규격 · 단위 세 칸**을 더함 — 규격 = 고른 입력 값을 이어 씀(「뒷길이 35㎝ · 장비」) | 작음 |
| 2 | 묶음 | EST 평평한 목록(공종 흐름 차례) · STmate 공종 트리 추정 | 장 · 조항 차례는 두고 **공종 갈래**(토공 · 돌공 · 관 · 콘크리트 …) 거름을 더함 — 임도 · 사방 자주 쓰는 것 먼저 | 작음 |
| 3 | 규격 늘어놓기 | 규격마다 한 건(흄관 18 · 터파기 87) | 로직은 하나로 두고 **대표 규격 펼친 보기**(관경 11 줄 · 뒷길이 3 줄)를 자동으로 만들어 금액까지 보여 줌 — 실무자는 줄을 고르고, 속은 같은 로직 + 입력 값 | 중간 |
| 4 | 화면 꼴(계산서) | 산근 글 = `석공 : 268,908 × 3인 / 23㎡ = 35,074.9` | 로직 · 표 값으로 **경쟁사 산근 글 모양의 「계산서 보기」**를 찍음(식을 숫자로 풀어 노임 단가 × 품 = 금액 · 맨 위에 「산림품셈 13-4-2 · 2026 판」) — 읽기 전용 | 중간 |
| 5 | 숫자 박기 대 표 부르기 | 숫자 박기 | **표 부르기 그대로**(개정 대응 · 규격 바꾸기의 힘) · 보이는 쪽만 숫자 — 4 의 계산서 보기가 「박은 숫자」 노릇 | 바꾸지 않음 |
| 6 | 출처 표기 | 주석 글 · 근거줄(수량 0) | 이미 칸 있음 · 계산서 보기 맨 위 한 줄로 찍음 | 작음 |
| 7 | 일위대가 기준 | 공급사 일위 1,468 | 실무 사전조합 36 을 「대표 일위대가」 이름(품명 · 규격 · 단위 · 대표 수량)으로 보이게 · 조달청 표준일위대가 원천을 따로 받을지는 사용자 판단 | 중간~큼 |
| 8 | 반기 공지 | main 공지 41 행(개정 품셈 항목 목록) | 판 바뀔 때 「바뀐 요소 · 바뀐 표 · 영향 받은 로직 수」 한 장 | 중간 |
| 9 | 낱말 | 산근 · 일위 · 호표 | 화면 낱말만 「단가산출(산근)」 · 「일위대가」로 맞춤 · 「로직 · 찾기 · 덧줄」은 관리자 화면 속으로 | 작음 |
남은 것 · 한계:
- STmate 공급 원본은 암호화 — 실례는 모두 실무 프로젝트 평문(공급사 원본과 같은지 모름).
- EST 설계참고철(산림토목 등) 13 파일은 이 노트북에 없음 — 크기 상한만.
- EST 수치는 이번에 main.estx 를 메모리에서 풀어 센 값 — 지식DB 에 줄 번호 근거로 남은 것 아님.
@@ -0,0 +1,183 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>내역서 화면 조합안</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; padding: 20px 22px 28px; font: 14px/1.6 system-ui, "Malgun Gothic", sans-serif;
background: var(--background, #fff); color: var(--foreground, #111); }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 26px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border, #ddd); }
.sub { color: var(--muted-foreground, #666); font-size: 13px; }
.lead { margin: 12px 0 0; padding: 12px 14px; border-radius: 8px; background: var(--muted, #f4f4f5); }
.lead b { color: var(--foreground, #111); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid var(--border, #ddd); padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: var(--muted, #f4f4f5); font-weight: 600; }
td.n { text-align: center; white-space: nowrap; }
.block { color: #fff; background: #c0392b; border-radius: 4px; padding: 1px 6px; font-size: 12px; white-space: nowrap; }
.goal { font-weight: 700; color: var(--chart-2, #16a34a); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.card { border: 1px solid var(--border, #ddd); border-radius: 8px; padding: 10px 12px; background: var(--card, transparent); }
.card h3 { font-size: 14px; margin: 0 0 6px; }
.card ul { margin: 0; padding-left: 18px; }
.tag { display: inline-block; font-size: 11px; padding: 0 6px; border-radius: 4px; margin-right: 4px;
border: 1px solid var(--border, #ccc); color: var(--muted-foreground, #555); }
/* 작업대 모형 */
.mock { border: 2px solid var(--border, #bbb); border-radius: 8px; overflow: hidden; font-size: 12px; }
.mock .top { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px;
background: var(--muted, #f1f1f1); border-bottom: 1px solid var(--border, #ccc); }
.mock .btn { border: 1px solid var(--border, #aaa); border-radius: 4px; padding: 1px 8px; background: var(--background, #fff); }
.mock .body { display: grid; grid-template-columns: 150px 1fr 190px; min-height: 300px; }
.mock .left, .mock .right { padding: 8px; background: var(--muted, #f7f7f7); }
.mock .left { border-right: 1px solid var(--border, #ccc); }
.mock .right { border-left: 1px solid var(--border, #ccc); }
.mock .mid { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.mock .lbl { font-weight: 700; margin-bottom: 4px; }
.mock .row { display: grid; grid-template-columns: 1.6fr .6fr .5fr .9fr .9fr; border-bottom: 1px solid var(--border, #ddd); padding: 2px 0; }
.mock .row.h { font-weight: 700; }
.mock .row.sel { background: color-mix(in srgb, var(--chart-1, #2563eb) 14%, transparent); }
.mock .fold { border: 1px dashed var(--chart-1, #2563eb); border-radius: 6px; padding: 6px; }
.mock .frow { display: grid; grid-template-columns: 1.4fr .8fr .8fr 1fr; padding: 2px 0; border-bottom: 1px solid var(--border, #e3e3e3); }
.mock .edited { background: color-mix(in srgb, #f59e0b 30%, transparent); font-weight: 700; }
.mock .base { color: var(--muted-foreground, #777); font-size: 11px; }
.mock .tabs span { display: inline-block; border: 1px solid var(--border, #bbb); border-radius: 4px; padding: 0 5px; margin: 0 2px 4px 0; }
.mock .tabs span.on { background: var(--chart-1, #2563eb); color: #fff; border-color: transparent; }
.mock .item { border-bottom: 1px solid var(--border, #ddd); padding: 3px 0; }
.mock .ctx { margin-top: 8px; border: 1px solid var(--border, #bbb); border-radius: 6px; padding: 4px 6px; background: var(--background, #fff); }
.note { font-size: 12px; color: var(--muted-foreground, #666); margin-top: 6px; }
ol.steps { padding-left: 20px; margin: 0; }
ol.steps li { margin-bottom: 6px; }
.ask { border: 2px solid var(--chart-1, #2563eb); border-radius: 8px; padding: 10px 14px; }
.ask ol { margin: 4px 0 0; padding-left: 20px; }
@media (max-width: 720px) { .grid2 { grid-template-columns: 1fr; } .mock .body { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>내역서 화면 조합안 — 누름과 고민 줄이기</h1>
<div class="sub">한시 브레인 sub8 · 10-31 · 근거 = EST · STmate 화면 설계도 분석 + 아이슬로 실측 (자세한 것: docs/raw/요구_내역서/_종합_내역3_조합안.md)</div>
<div class="lead">
<b>한 줄 결론</b> — 아이슬로는 <b>「설계에서 저절로 오는 것」</b>(수량 · 구조물 묶음)이 경쟁사보다 앞섬.
막히는 곳은 <b>「고쳐 쓰기」</b>(품 고치기 · 복사해 새로 · 지난 공사에서 가져오기 · 금액 바로 보기).
경쟁사의 고쳐 쓰기 화면을 B09 한 화면에 붙이면, 막힌 일 셋이 3~6번 누름으로 줄어듦.
</div>
<h2>1. 대표 일 여섯 — 몇 번 누르나</h2>
<table>
<tr><th>일</th><th>EST</th><th>STmate</th><th>아이슬로 지금</th><th>조합안 목표</th></tr>
<tr><td>T1 새 공사 내역서 시작</td><td class="n">8~11</td><td class="n">약 30<br><span class="sub">지난 공사 복사 12</span></td><td>설계 단계를 지나면 수량이 차 있음 · 첫 화면이 <b>빈 원가계산서</b></td><td>첫 화면 = 설계내역서</td></tr>
<tr><td>T2 일위대가 품 고쳐 이 공사만</td><td class="n">5~7</td><td class="n">7</td><td><span class="block">막힘</span> 품 칸이 어디에도 없음</td><td class="n goal">3</td></tr>
<tr><td>T3 규격만 다른 일위대가 새로</td><td class="n">8~11</td><td class="n">약 16</td><td class="n">12~13<br><span class="sub">페이지 두 번 옮김 · 숨은 목록표</span></td><td class="n goal">4</td></tr>
<tr><td>T4 지난 공사 일위대가 가져오기</td><td class="n">9~14</td><td class="n">약 14</td><td><span class="block">막힘</span> 화면 길이 없음</td><td class="n goal">4</td></tr>
<tr><td>T5 노임 새 판 · 고친 값 지킴</td><td class="n">12~15<br><span class="sub">+ 내 값 다시 넣기</span></td><td class="n">약 20<br><span class="sub">고친 것을 기억</span></td><td class="n">2 + 구조물마다</td><td class="n goal">2 + 확인</td></tr>
<tr><td>T6 줄 추가 → 단가 → 금액 → 총액</td><td class="n">7~8</td><td class="n">약 11</td><td><span class="block">막힘</span> 10번 눌러도 금액 안 보임 · B09 에 [저장] 없음</td><td class="n goal">6</td></tr>
</table>
<div class="note">경쟁사 = 실행이 안 되어 화면 설계도로 센 추정 · 아이슬로 = 검증_sub7 에서 실측 · 목표 = 조합안 기준 추정.</div>
<h2>2. 무엇을 살리고 무엇을 빌리나</h2>
<div class="grid2">
<div class="card">
<h3>아이슬로 — 이미 앞선 것 (그대로)</h3>
<ul>
<li>설계 수량이 내역서에 저절로 옴 — 경쟁사의 가장 큰 손일이 없음</li>
<li>구조물 하나 고르면 도면 · 면적 · 일위대가 묶음이 따라옴</li>
<li>옵션별 일위대가 합계를 나란히 비교하는 표</li>
<li>회사 · 개인 층 · 구조물 자리 그림 · 공기 자동 · 품셈 원문 곁에</li>
<li>엑셀 같은 나무 표(우클릭 · 더블클릭 · F2 · 방향키)</li>
<li>설치 · 동글 없이 브라우저로</li>
</ul>
</div>
<div class="card">
<h3>경쟁사 — 빌려 올 화면 장치</h3>
<ul>
<li><span class="tag">EST</span>셀 하나 고치면 원가계산서까지 저절로</li>
<li><span class="tag">EST</span>모든 표가 같은 틀 · 단가 / 금액 쌍 띠</li>
<li><span class="tag">EST</span>「이 단가 어디 쓰였나」 찾기</li>
<li><span class="tag">STmate</span>내역 표 옆 「재료 창고」 — 두 번 누르면 넣기</li>
<li><span class="tag">STmate</span>원래 값 · 고치기 전 · 지금 값을 곁에</li>
<li><span class="tag">STmate</span>복사해 새로 만들기 한 번</li>
</ul>
</div>
</div>
<div class="card" style="margin-top:12px">
<h3>경쟁사 약점 — 따라 하지 않음</h3>
<ul>
<li>재계산 단추가 여럿이고 사람이 눌러야 함 → 아이슬로는 바로 셈</li>
<li>고친 칸이 화면에 안 보임 → 고친 칸 색 + 품셈 값 곁에</li>
<li>새 판이 오면 고친 값이 덮이고 판단을 사용자에게 넘김 → 고친 칸은 기본으로 지킴</li>
<li>단축키 99개 · 코드 번호 노출 → 단축키 몇 개만 · GF · SC 같은 번호 숨김</li>
</ul>
</div>
<h2>3. 조합안 — B09 「내역서 작업대」 한 화면 (모형)</h2>
<div class="mock">
<div class="top">
<span><b>B09 원가계산</b> · 설계내역서</span>
<span>총액 <b>412,350,000원</b> <span class="sub">(빈 줄 3 — 아는 금액까지)</span> &nbsp; <span class="btn">원가계산서</span> <span class="btn">저장</span></span>
</div>
<div class="body">
<div class="left">
<div class="lbl">데이터 점검</div>
<div class="item">빈 줄 3 ▸</div>
<div class="item">단가 새 판 [업데이트]<br><span class="base">바뀌는 줄 12 · 고친 칸 2 지킴</span></div>
<div class="lbl" style="margin-top:10px">공종 목록</div>
<div class="item">1 토공사</div><div class="item">2 배수공</div><div class="item">3 구조물공</div>
</div>
<div class="mid">
<div>
<div class="row h"><span>명칭 · 규격</span><span>수량</span><span>단위</span><span>단가</span><span>금액</span></div>
<div class="row"><span>절토 (토사)</span><span>1,924</span><span>㎥</span><span>3,120</span><span>6,002,880</span></div>
<div class="row sel"><span>석축 쌓기 H=2.0</span><span>140</span><span>㎡</span><span>58,410</span><span>8,177,400</span></div>
<div class="row"><span>측구 터파기</span><span>140</span><span>㎥</span><span>4,870</span><span>681,800</span></div>
</div>
<div class="fold">
<div class="lbl">▾ 석축 쌓기 H=2.0 — 일위대가 구성 (줄을 누르면 펼침)</div>
<div class="frow" style="font-weight:700"><span>품명</span><span>품 · 수량</span><span>단가</span><span>품셈 값</span></div>
<div class="frow"><span>석공</span><span class="edited">0.080 인</span><span>288,000</span><span class="base">0.072 인 [품셈 값으로]</span></div>
<div class="frow"><span>보통인부</span><span>0.150 인</span><span>171,000</span><span class="base">같음</span></div>
<div class="frow"><span>굴착기 0.7㎥</span><span>0.020 hr</span><span>98,500</span><span class="base">같음</span></div>
<div class="note">노란 칸 = 내가 고친 칸 · 이 공사에만 · 새 판이 와도 지킴</div>
</div>
</div>
<div class="right">
<div class="lbl">재료 창고</div>
<div class="tabs"><span>이 공사</span><span>내 것</span><span>회사</span><span>품셈</span><span class="on">지난 공사</span></div>
<div class="item">🔍 석축</div>
<div class="item">석축 쌓기 H=1.5 <span class="base">· ○○임도 2025</span></div>
<div class="item">석축 쌓기 H=2.0 <span class="base">· △△임도 2026</span></div>
<div class="note">두 번 누르거나 끌어서 내역에 넣기</div>
<div class="ctx"><b>우클릭</b><br>항목 추가 · 줄 삭제<br>복사해 새 일위대가<br>내 자료집에 저장<br>어디 쓰였나 찾기</div>
</div>
</div>
</div>
<div class="note">모형의 숫자는 보기용. 매일 하는 일 = B09 한 곳 · M02 = 내 자료집 정리 · M01 = 관리자 바탕(품셈 기본값). 구조물 고르기(B06 · B08)와 수량 자동은 그대로.</div>
<h2>4. 단계 후보</h2>
<ol class="steps">
<li><b>작은 것부터 (바로 효과)</b> — 첫 화면을 설계내역서로 · 빈 줄 숫자 하나로 · 원가계산서는 아는 금액까지 · B09 [저장] · 연결하면 금액 바로 · 헷갈리는 단추 이름 · 낱말(★ 서른여 개) 정리 <span class="sub">→ T6 · 첫인상</span></li>
<li><b>핵심</b> — 줄 펼쳐 셀에서 고치기(고친 칸 색 · 품셈 값 곁에 · 되돌리기) · 우클릭 「복사해 새 일위대가」 <span class="sub">→ T2 · T3</span></li>
<li><b>재료 창고</b> — 지난 공사 · 내 것 · 회사 · 「내 자료집에 저장」(고친 숫자째) <span class="sub">→ T4</span></li>
<li><b>단가 새 판 한 곳</b> — 바뀌는 줄 미리 보기 · 고친 칸 지킴 · 구조물 재적용과 합침 <span class="sub">→ T5</span></li>
<li><b>뒤</b> — 단가산출 숫자 고치기 → 식 편집</li>
</ol>
<h2>5. 남은 것</h2>
<ul>
<li>데스크탑 REA 물음(EST 줄 색칠 · STmate 저절로 번짐 · 보호 칸 등) — 조합안은 「바로 셈 · 고친 칸 표시 · 기본 지킴」으로 정하므로 급하지 않음</li>
<li>테스트 사용자 질문지 7문항(<span class="sub">질문지_테스트사용자.md</span>) — 답이 오면 누름 수 · 손버릇을 맞춤</li>
<li>sub7 ORCA 탭이 몇 초 만에 닫히는 일 — 원인 모름 · 이번엔 화면 없는 브라우저로 셈</li>
</ul>
<h2>6. 정해 줄 것</h2>
<div class="ask">
<ol>
<li>조합안 방향(B09 작업대 한 화면에서 끝내기)이 맞는지</li>
<li>다음 차례 — 1단계(작은 것) 계획부터 할지, 2단계(줄 펼쳐 고치기) 화면 시안을 먼저 볼지</li>
</ol>
</div>
</body>
</html>
@@ -0,0 +1,182 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설계 기초자료 만들기 — 보여 주는 꼴</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; padding: 20px 22px 28px; font: 14px/1.6 system-ui, "Malgun Gothic", sans-serif;
background: var(--background, #fff); color: var(--foreground, #111); }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 26px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border, #ddd); }
h3 { font-size: 14px; margin: 14px 0 6px; }
.sub { color: var(--muted-foreground, #666); font-size: 12.5px; }
.lead { margin: 12px 0 0; padding: 12px 14px; border-radius: 8px; background: var(--muted, #f4f4f5); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid var(--border, #ddd); padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: var(--muted, #f4f4f5); font-weight: 600; }
td.n { text-align: center; white-space: nowrap; }
.block { color: #fff; background: #c0392b; border-radius: 4px; padding: 1px 6px; font-size: 12px; white-space: nowrap; }
.goal { font-weight: 700; color: var(--chart-2, #16a34a); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.card { border: 1px solid var(--border, #ddd); border-radius: 8px; padding: 10px 12px; }
.card h3 { margin-top: 0; }
.card ul, ul { margin: 0; padding-left: 18px; }
.find { display: grid; grid-template-columns: 26px 1fr; gap: 6px 8px; }
.find .no { font-weight: 700; color: var(--chart-1, #2563eb); }
/* 목형 */
.mock { border: 2px solid var(--border, #bbb); border-radius: 8px; overflow: hidden; font-size: 12px; margin-top: 6px; }
.mock .bar { padding: 6px 10px; background: var(--muted, #f1f1f1); border-bottom: 1px solid var(--border, #ccc); display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.mock .pad { padding: 8px 10px; }
.mock table { font-size: 12px; }
.mock td, .mock th { padding: 3px 6px; }
.t-pum { background: color-mix(in srgb, var(--chart-1, #2563eb) 16%, transparent); border-radius: 3px; padding: 0 5px; font-size: 11px; white-space: nowrap; }
.t-fix { background: color-mix(in srgb, #9ca3af 30%, transparent); border-radius: 3px; padding: 0 5px; font-size: 11px; white-space: nowrap; }
.edited { background: color-mix(in srgb, #f59e0b 30%, transparent); font-weight: 700; }
.base { color: var(--muted-foreground, #777); font-size: 11px; }
.calc { font-family: ui-monospace, Consolas, monospace; font-size: 12px; white-space: pre; line-height: 1.55;
background: var(--muted, #f7f7f7); border-radius: 6px; padding: 8px 10px; overflow-x: auto; }
.btn { border: 1px solid var(--border, #aaa); border-radius: 4px; padding: 0 7px; background: var(--background, #fff); white-space: nowrap; }
.sel { background: color-mix(in srgb, var(--chart-1, #2563eb) 12%, transparent); }
.flow { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.flow .card { text-align: left; }
.arrow { text-align: center; font-size: 13px; margin: 6px 0; color: var(--muted-foreground, #666); }
.ask { border: 2px solid var(--chart-1, #2563eb); border-radius: 8px; padding: 10px 14px; }
.ask ol { margin: 4px 0 0; padding-left: 20px; }
.warn { border-left: 4px solid #f59e0b; padding: 6px 10px; background: color-mix(in srgb, #f59e0b 10%, transparent); border-radius: 4px; }
@media (max-width: 760px) { .grid2, .flow { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>설계 기초자료 만들기 — 무엇을, 어떤 꼴로 보여 줘야 쉬운가</h1>
<div class="sub">한시 브레인 sub8 · 10-31 · 내역 5 · 6 · 7 · 근거 = docs/raw/요구_내역서/_분석_내역5-A · 5-B · 5-C · 7 (사진 ref_내역5)</div>
<div class="lead">
<b>먼저 구분</b> — 경쟁사는 <b>내역서만</b> 만들고 수량은 손으로 침. 아이슬로는 <b>설계 · 수량 · 내역</b>을 한 번에 다룸.
수량 입력은 아이슬로가 덜어 줌. 대신 기초자료를 <b>「우리 품셈 로직」 꼴로만</b> 받게 해서 실무자에게 새 짐을 지움.
그런데 조사해 보니 <b>설계는 그 짐을 요구하지 않음</b>.
</div>
<h2>1. 찾은 것 다섯</h2>
<div class="find">
<div class="no">①</div><div><b>설계가 일위대가에 바라는 것은 「1 단위 값(노무비 · 재료비 · 경비)」 하나뿐</b> — 배수관 · 석축 · 측구 · 세월교 · 토공 다섯 종류 모두 「사람이 규격을 고르고, 설계는 수량만 곱함」. 설계 값(높이 · 관경)이 품 계산에 들어가는 곳은 지금 하나도 없음. <span class="sub">5-B</span></div>
<div class="no">②</div><div><b>실무 엑셀은 전부 「숫자 그대로」 꼴</b> — 8개 파일 모두 일위대가 수량은 손으로 친 값(식 0). 규격 변형(H=1.5 / 2.0 / 2.5)은 따로 만든 호표이고, 같은 줄에 수량만 다름. <span class="sub">5-A</span></div>
<div class="no">③</div><div><b>경쟁사 「붙여넣으면 들어감」의 실체</b> — 구성 줄(이름 · 규격 · 수량 · 단위)을 그대로 넣고, 단가만 이름으로 이 공사 노임 · 자재에 다시 이음. <b>품 · 수량은 남음</b>. 남의 엑셀 호표를 통째로 받는 길은 경쟁사에도 없음. <span class="sub">5-A</span></div>
<div class="no">④</div><div><b>경쟁사 기준 자료 = 공급사가 「완성된 호표 묶음」을 반기마다 배포</b> — EST 단가산출 1,569 · 일위대가 1,468. 목록은 「품명 | 규격 | 단위 | 금액」 한 줄씩, 열면 숫자가 박힌 한 장 계산서. 아이슬로 M01 로직 1,469 는 품셈 표를 불러 쓰고 규격은 입력 칸이라 개정 · 규격 바꾸기에 더 강함. 대신 <b>이름 · 규격 · 금액이 정해진 목록</b>과 <b>한 장 계산서 꼴</b>이 없음. <span class="sub">7</span></div>
<div class="no">⑤</div><div><b>아이슬로 지금</b> — 엑셀 → 기초자료가 네 단계(올리기 → 맞춤 → 사전조합 → 구조물 조합에 다시 담기). 맞춘 줄 56 %만 남고 엑셀 숫자는 우리 값으로 바뀜. 구조물 조합 하나에 약 65번 누름 + 도면. 일반 사용자는 회사로 나눌 길 없음. 품 · 수량 빈 줄은 경고 없이 합계에서 빠짐. <span class="sub">5-C</span></div>
</div>
<h2>2. 기초자료 만들기 K1~K6 — 몇 번 누르나</h2>
<table>
<tr><th>일</th><th>EST</th><th>STmate</th><th>아이슬로 지금</th><th>목표(추정)</th></tr>
<tr><td>K1 엑셀 호표 하나 → 내 자료</td><td class="n">약 13<br><span class="sub">셀 붙여넣기 + 자동연결</span></td><td class="n">10 + 줄당 3~5</td><td>13~19 · 파일 통째 올림 · <b>숫자 바뀜</b> · 뒤에 K3 또 해야</td><td class="n goal">약 5</td></tr>
<tr><td>K2 시트 통째(호표 수십)</td><td class="n">약 36</td><td><span class="block">막힘</span> 남의 엑셀</td><td class="n">약 74 · 56 %만 남음</td><td class="n goal">약 6</td></tr>
<tr><td>K3 새 구조물 조합(석축 H=2.0)</td><td colspan="2" class="sub">경쟁사엔 「구조물 조합」이 없음 — 일위대가 하나 만들기는 EST 19 + 2n · STmate 20~25</td><td class="n">약 65 + 도면</td><td class="n goal">약 20 + 도면</td></tr>
<tr><td>K4 규격만 다른 것(H=1.5 → 2.0)</td><td class="n">8~11 + 2n</td><td class="n">14~18</td><td class="n">22~28 + 도면 · 같은 이름 둘</td><td class="n goal">약 6</td></tr>
<tr><td>K5 회사 사람과 나누기</td><td class="n">약 12 (파일)</td><td class="n">약 26 (폴더)</td><td>관리자 4 · 일반 <span class="block">막힘</span></td><td class="n goal">3</td></tr>
<tr><td>K6 설계에서 고르고 금액 보기</td><td colspan="2" class="sub">경쟁사엔 없음(수량 손)</td><td class="n">11~12</td><td class="n">그대로 (강점)</td></tr>
</table>
<h2>3. 보여 주는 꼴 — 「겉은 실무자가 보던 꼴, 속은 아이슬로」</h2>
<p class="sub" style="margin:0 0 8px">경쟁사도 품셈을 미리 만들어 두고 사용자가 복사해 고침. 차이는 <b>보이는 꼴</b>. 실무자 눈에 익은 꼴은 「목록 한 줄 = 품명 · 규격 · 단위 · 금액」, 「계산서 한 장 = 숫자 × 숫자 = 금액」, 「엑셀 격자 = 호표 머리 + 구성 줄」 셋.</p>
<h3>A. 기준 목록 — 로직 하나를 대표 규격으로 펼쳐 한 줄씩</h3>
<div class="mock">
<div class="bar"><span><b>기준 단가산출</b> · 공종: <span class="btn">토공</span> <span class="btn sel">돌공</span> <span class="btn">관</span> <span class="btn">콘크리트</span></span><span>🔍 석축 <span class="base">(「돌쌓기」도 찾음)</span></span></div>
<div class="pad">
<table>
<tr><th>품명</th><th>규격</th><th>단위</th><th>금액</th><th>출처</th></tr>
<tr><td>돌쌓기 메쌓기(장비)</td><td>뒷길이 35cm 이하</td><td>㎡</td><td>58,512</td><td class="base">산림 13-4-2</td></tr>
<tr class="sel"><td>돌쌓기 메쌓기(장비)</td><td>뒷길이 55cm 이하</td><td>㎡</td><td>55,822</td><td class="base">산림 13-4-2</td></tr>
<tr><td>돌쌓기 메쌓기(장비)</td><td>뒷길이 75cm 이하</td><td>㎡</td><td>53,134</td><td class="base">산림 13-4-2</td></tr>
<tr><td>돌쌓기 찰쌓기(장비)</td><td>뒷길이 35cm 이하</td><td>㎡</td><td>101,530</td><td class="base">산림 13-4-3</td></tr>
</table>
<div class="base" style="margin-top:4px">속은 로직 하나 + 입력 값(뒷길이). GF · QF 같은 번호는 숨김. 숫자는 보기용.</div>
</div>
</div>
<h3>B. 줄을 누르면 「계산서 보기」 — 경쟁사 단가산출 한 장 꼴 (담기 전에 미리 봄)</h3>
<div class="mock">
<div class="bar"><span><b>돌쌓기 메쌓기(장비) · 뒷길이 55cm 이하 · ㎡</b></span><span class="base">산림품셈 13-4-2 · 2026 상반기 노임 &nbsp; <span class="btn">원문 보기</span> <span class="btn">내 일위대가에 담기</span></span></div>
<div class="pad"><div class="calc">석공 268,908 원 × 0.09 인 = 24,201.7
보통인부 172,068 원 × 0.05 인 = 8,603.4
굴착기+집게 59,020 원 × 0.39 hr = 23,017.8
잡석 (설계 값) = 별도
─────────────────────────────────────────────
노무비 32,805 · 경비 23,017 · 계 55,822 원/㎡</div>
<div class="base" style="margin-top:4px">지금은 고르는 순간 바로 담기고 창이 닫혀 품을 미리 못 봄. 원문 띠(책 꼴)는 그대로 곁에.</div></div>
</div>
<h3>C. 내 일위대가 = 엑셀 꼴 격자 하나 — 줄마다 「품셈」 · 「고정」 꼬리표</h3>
<div class="mock">
<div class="bar"><span><b>제17호표 돌기슭막이 H=2.0 · m</b> <span class="base">· 내 것</span></span><span><span class="btn">엑셀에서 붙여넣기</span> <span class="btn">기준 목록에서 담기</span> <span class="btn">저장</span></span></div>
<div class="pad">
<table>
<tr><th></th><th>품명</th><th>규격</th><th>수량</th><th>단위</th><th>노무비</th><th>재료비</th><th>경비</th><th>금액</th></tr>
<tr><td><span class="t-pum">품셈</span></td><td>돌쌓기 찰쌓기</td><td>뒷길이 35cm</td><td class="edited">2.09</td><td>㎡</td><td>41,250</td><td>—</td><td>11,745</td><td>110,759</td></tr>
<tr><td><span class="t-pum">품셈</span></td><td>고임돌 채집</td><td>현장</td><td>0.31</td><td>㎥</td><td>24,816</td><td>—</td><td>—</td><td>7,693</td></tr>
<tr><td><span class="t-fix">고정</span></td><td>야면석 채집 운반</td><td>계곡 내</td><td>0.45</td><td>㎥</td><td>18,300</td><td>—</td><td>6,120</td><td>10,989</td></tr>
<tr><td><span class="t-fix">고정</span></td><td>잡재료</td><td>노무비의</td><td>2</td><td>%</td><td>—</td><td>2,043</td><td>—</td><td>2,043</td></tr>
<tr><td></td><td colspan="7"><b>1 m 당 합계</b> <span class="base">(빈 수량 줄 0 — 있으면 여기 빨간 글로 알림)</span></td><td><b>131,484</b></td></tr>
</table>
<div class="base" style="margin-top:4px">아래 두 줄 = 엑셀에서 붙인 줄 · 숫자 그대로 · 단가는 이 공사 노임에 다시 이음.<br>
<span class="t-pum">품셈</span> = 우리 로직이 품을 셈 · 노임 새 판 · 품셈 개정을 따라감 · 노란 칸 = 내가 고친 칸(곁에 품셈 값). &nbsp;
<span class="t-fix">고정</span> = 붙인 숫자 그대로 · 노임 새 판 땐 단가만 바뀌고 품은 그대로(실무와 같음) · 원하면 [품셈으로 바꾸기].
</div>
</div>
</div>
<h3>D. 규격 변형 = 「규격 표」 — 같은 일위대가에 높이 열을 나란히</h3>
<div class="mock">
<div class="bar"><span><b>돌기슭막이 · m</b> · 규격 표</span><span><span class="btn">+ 열 (H=3.0)</span></span></div>
<div class="pad">
<table>
<tr><th>품명</th><th>H=1.5</th><th class="sel">H=2.0</th><th>H=2.5</th></tr>
<tr><td>돌쌓기 찰쌓기 (㎡)</td><td>1.57</td><td class="sel">2.09</td><td>2.61</td></tr>
<tr><td>고임돌 채집 (㎥)</td><td>0.24</td><td class="sel">0.31</td><td>0.39</td></tr>
<tr><td>야면석 채집 운반 (㎥)</td><td>0.34</td><td class="sel">0.45</td><td>0.56</td></tr>
<tr><td>잡재료 (노무비의 %)</td><td>2</td><td class="sel">2</td><td>2</td></tr>
<tr><td><b>1 m 당</b></td><td>99,000</td><td class="sel"><b>131,484</b></td><td>164,222</td></tr>
</table>
<div class="base" style="margin-top:4px">실무 엑셀의 「규격만 다른 호표 여러 개」를 한 표로. 설계(B06)는 높이로 열을 고름 — 지금 비교 행렬과 같은 자리. 도면 · 면적도 열마다.</div>
</div>
</div>
<h3>E. 만드는 길 셋 — 모두 같은 격자(C)로 끝남</h3>
<div class="flow">
<div class="card"><b>① 기준 목록에서 담기</b><br><span class="sub">A 에서 고름 → B 로 미리 봄 → 담기 · <span class="t-pum">품셈</span> 줄</span></div>
<div class="card"><b>② 엑셀에서 붙여넣기</b><br><span class="sub">엑셀 Ctrl+C → 격자 Ctrl+V · 구성 줄 그대로 <span class="t-fix">고정</span> · 단가는 이름으로 다시 이음(제안) · 못 이은 줄은 엑셀 단가 그대로 · 시트 통째도 같은 길</span></div>
<div class="card"><b>③ 있는 것 복사해 고치기</b><br><span class="sub">내 것 · 회사 · 지난 공사에서 복사 → 이름 · 수량 고침 · 규격 변형은 D 에 열 하나</span></div>
</div>
<div class="arrow">↓ 만든 일위대가는 바로 구조물 조합의 일위대가 카드로 (사전조합 → 구조물 조합 두 단계를 하나로)</div>
<h2>4. 지금 방침과 부딪히는 것</h2>
<div class="warn">
<ul>
<li>지금 M02 는 「확인 없이 들어가는 줄 없음 · 품은 엔진이 셈 · 금액 · 품 칸을 보내면 거절」. 고정형을 받으려면 이 방침을 「고정 줄도 받음」으로 바꿔야 함. 숫자는 서버가 붙여넣은 글 · 엑셀 파일에서 읽어 저장하고, [저장] 때 서버가 정본으로 다시 셈 — 계산 자리 규칙은 그대로.</li>
<li>고정 줄은 노임 새 판 때 단가만 따라가고 품은 그대로 — 실무 엑셀과 같음. 품셈 개정은 안 따라감 → 꼬리표와 [품셈으로 바꾸기]로 알림.</li>
<li>합계만 있는 엑셀 줄(비목 구분 없음)은 어느 비목에 넣을지 정해야 함 — 안 정하면 원가계산서가 빈칸 규칙에 걸림.</li>
<li>일반 사용자가 회사 층에 올리는 길 — 권한 정책 문제.</li>
</ul>
</div>
<h2>5. 덤으로 찾은 흠 (내역 영역)</h2>
<ul>
<li><b>석축 · 옹벽 길이가 B09 원가 곱하기에 안 넘어감</b> — 대가 묶음은 생기나 곱할 수량이 없음(B08 집계가 배수 종류만 셈). 고정형 · 계산형과 상관없이 금액이 안 들어감.</li>
<li>M02 일위대가 카드: 수량 빈 줄이 합계에서 조용히 빠짐(돌기슭막이 줄 합 1,910,689 원 → 반영 100,407 원 · 경고 없음).</li>
<li>M02 복사본이 원래 이름 그대로(같은 이름 둘) · 견본 면적 시트에 시험 글(「블라블라」) 남음.</li>
<li>sub7 ORCA 탭이 10~15초마다 저절로 다시 읽힘 — 원인 모름 · 이번에도 화면 없는 브라우저로 셈.</li>
</ul>
<h2>6. 정해 줄 것</h2>
<div class="ask">
<ol>
<li><b>고정형 일위대가</b>(엑셀 · 손 숫자 그대로, 단가만 다시 이음)를 받을지</li>
<li><b>보여 주는 꼴</b> — 기준 목록(A) · 계산서 보기(B) · 엑셀 꼴 격자 + 꼬리표(C) · 규격 표(D) 방향이 맞는지</li>
<li>다음 차례 — 실제 자료로 움직이는 <b>화면 시안</b>을 먼저 볼지, <b>계획서</b>(PLAN 700번대 · 앞 조합안 B09 작업대와 묶어 단계 짜기)를 먼저 할지</li>
</ol>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+4 -3
View File
@@ -70,7 +70,7 @@ def fake_store(tmp_path, monkeypatch):
calls: list[tuple[str, str]] = []
fail = {"36901002"}
def fake_run(step: str, no: str):
def fake_run(step: str, no: str, part=None):
calls.append((step, no))
if no in fail:
return step, no, {"ok": False, "error": "RuntimeError: 받기 실패"}
@@ -85,8 +85,9 @@ def test_resume_skips_done_and_retries_failed(fake_store) -> None:
sheets = ["36901001", "36901002", "36901003"]
progress = tool.load_progress()
tool.run(sheets, ["e07", "dem"], 1, progress, say=lambda _: None)
assert [s for s, _ in calls] == ["dem"] * 3 + ["e07"] * 3 # 차례 고정
saved = json.loads((root / tool.PROGRESS_NAME).read_text(encoding="utf-8"))
# 차례 고정 · 지형 단계는 0차곡 이웃(3x3)까지 셈(618-1)
assert [s for s, n in calls if n in sheets] == ["dem"] * 3 + ["e07"] * 3
saved = json.loads(tool.progress_path().read_text(encoding="utf-8"))
assert saved["steps"]["dem"]["36901001"]["ok"] is True
assert saved["steps"]["dem"]["36901002"]["ok"] is False
@@ -0,0 +1,195 @@
"""PLAN 618-1 — 전국 미리 계산 두 PC 나눔(`tools/precompute_terrain.py`) 지킴 시험.
나눔 겹침 0 · 합치면 전체 · 이웃 일감 한 PC 만 · PC 별 기록 이어 받기 · CPU 상한(가짜 값) ·
0차곡 남의 몫 이웃 없으면 미룸 · 지정 코어 인자 풀기 ·
셈 함수는 가짜로 바꿔 서버 · 전국 파일 없이 돎.
"""
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 B04_PreProcess.B04_PreProcess_Engine_MapSheet import neighbors_3x3 # noqa: E402
from tools import precompute_terrain as tool # noqa: E402
SHEETS = [f"36901{i:03d}" for i in range(1, 101)] + [f"35810{i:03d}" for i in range(1, 101)]
def test_parse_part_and_cpus() -> None:
assert tool.parse_part("") is None
assert tool.parse_part("0-69/100") == (0, 69, 100)
assert tool.parse_part("70-99/100") == (70, 99, 100)
for bad in ("50-10/100", "0-100/100"):
with pytest.raises(ValueError):
tool.parse_part(bad)
assert tool.parse_cpus("12-19") == list(range(12, 20))
assert tool.parse_cpus("1, 3-4") == [1, 3, 4]
assert tool.parse_cpus("") == []
def test_split_no_overlap_and_union_is_all() -> None:
for spans in (("0-69/100", "70-99/100"), ("0-49/100", "50-99/100")):
a, b = (tool.parse_part(s) for s in spans)
mine = {no for no in SHEETS if tool.in_part(no, a)}
other = {no for no in SHEETS if tool.in_part(no, b)}
assert not mine & other and mine | other == set(SHEETS)
ja, jb = set(tool.dem_jobs(SHEETS, a)), set(tool.dem_jobs(SHEETS, b))
assert not ja & jb and ja | jb == set(tool.dem_jobs(SHEETS, None))
big = {no for no in SHEETS if tool.in_part(no, (0, 69, 100))}
assert 0.6 < len(big) / len(SHEETS) < 0.8
assert tool.in_part("36901001@5186", (0, 99, 100))
def test_dem_jobs_cover_neighbors_once() -> None:
jobs = tool.dem_jobs(["36901055"], None)
assert jobs[0] == "36901055" and set(jobs) == set(neighbors_3x3("36901055"))
# 띠 경계(128° · 5186 | 5187) 넘는 이웃 = 가운데 좌표계로 옮겨 담는 일감
west = [no for no in tool.dem_jobs(["36809001"], None) if "@" in no]
assert west and all(j.endswith(f"@{tool.epsg_of('36809001')}") for j in west)
@pytest.fixture()
def fake_store(tmp_path, monkeypatch):
monkeypatch.setattr(tool.store, "TERRAIN_ROOT", tmp_path)
monkeypatch.setattr(tool.store, "_gpkg_stamp", lambda: "stamp-1")
monkeypatch.setattr(tool, "PC_NAME", "LAPTOP")
monkeypatch.setattr(tool, "CPU_SAMPLE_S", 0.0)
log: list = []
def fake_run(step: str, job: str, part=None):
log.append(("run", step, job))
return step, job, {"ok": True, "s": 0.01, "fresh": True, "empty": False}
monkeypatch.setattr(tool, "run_step", fake_run)
return tmp_path, log
def test_progress_per_pc_and_legacy_resume(fake_store, monkeypatch) -> None:
root, log = fake_store
legacy = {"gpkg": "stamp-1", "cell_m": 2.0, "steps": {"dem": {"36901001": {"ok": True}}}}
(root / tool.PROGRESS_NAME).write_text(json.dumps(legacy), encoding="utf-8")
tool.run(["36901001", "36901002"], ["e07"], 1, tool.load_progress(), say=lambda _: None)
assert (root / "_precompute_progress_LAPTOP.json").is_file()
assert json.loads((root / tool.PROGRESS_NAME).read_text(encoding="utf-8")) == legacy # 안 씀
log.clear()
tool.run(["36901001", "36901002"], ["dem"], 1, tool.load_progress(), say=lambda _: None)
assert ("run", "dem", "36901001") not in log # 옛 기록 성공 = 이어 받음
monkeypatch.setattr(tool, "PC_NAME", "DESKTOP")
desk = tool.load_progress()
assert desk["steps"]["e07"] == {} # 다른 PC 기록은 run 에선 안 섞음
desk["steps"]["e07"]["36901003"] = {"ok": True, "s": 1.0, "fresh": True}
tool.save_progress(desk)
merged = tool.load_progress(every_pc=True)["steps"]["e07"]
assert {"36901001", "36901002", "36901003"} <= set(merged)
def test_cpu_cap_holds_new_work(fake_store) -> None:
_, log = fake_store
values = iter([95, 90, 60, 80, 40])
def cpu() -> float:
value = next(values)
log.append(("cpu", value))
return value
tool.run(
["36901001", "36901002"],
["e07"],
1,
tool.load_progress(),
cpu_cap=70,
cpu=cpu,
say=lambda _: None,
)
assert [x[1] if x[0] == "cpu" else x[2] for x in log] == [
95,
90,
60,
"36901001",
80,
40,
"36901002",
]
def test_zero_defers_when_foreign_neighbor_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(tool.store, "TERRAIN_ROOT", tmp_path)
center = "36901055"
part = (0, 49, 100)
foreign = [no for no in neighbors_3x3(center) if not tool.in_part(no, part)]
assert foreign # 고른 가운데는 남의 몫 이웃이 있음
stored: set[str] = set()
monkeypatch.setattr(
tool.store, "_stored_ok", lambda no, e, c: {"k": 1} if no in stored else None
)
ensured: list[str] = []
monkeypatch.setattr(tool.store, "ensure_sheet_terrain", lambda no, e, c: ensured.append(no))
_, _, info = tool.run_step("zero", center, part)
assert info["ok"] is False and info["wait"] and not ensured # 셈 안 함
stored.update(foreign) # 다른 PC 가 셈 · 동기 옴
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero
monkeypatch.setattr(zero, "sheet_accumulation", lambda no, e, c: tmp_path / "acc.npz")
_, _, info = tool.run_step("zero", center, part)
assert info["ok"] is True and len(ensured) == 9
def test_deferred_rounds_retry_until_done(fake_store, monkeypatch) -> None:
root, log = fake_store
tries: dict[str, int] = {}
def fake_run(step: str, job: str, part=None):
tries[job] = tries.get(job, 0) + 1
if job == "36901002" and tries[job] < 3:
return step, job, {"ok": False, "wait": True, "error": "이웃 지형 없음"}
return step, job, {"ok": True, "s": 0.01, "fresh": True, "empty": False}
monkeypatch.setattr(tool, "run_step", fake_run)
monkeypatch.setattr(tool.time, "sleep", lambda s: None)
progress = tool.load_progress()
tool.run(["36901001", "36901002"], ["zero"], 1, progress, say=lambda _: None, defer_wait=1)
assert tries == {"36901001": 1, "36901002": 3}
out = tool.summary(["36901001", "36901002"], progress)["zero"]
assert out["ok"] == 2 and out["failed"] == {} and out["waiting"] == 0
def test_clean_dead_only_own_part(tmp_path) -> None:
part = (0, 49, 100)
mine = next(no for no in SHEETS if tool.in_part(no, part))
other = next(no for no in SHEETS if not tool.in_part(no, part))
for no in (mine, other):
(tmp_path / no).mkdir()
(tmp_path / no / ".dem_5187_2m.lock").write_text("999999999 0", encoding="utf-8")
assert tool.clean_dead(tmp_path, part) == 1
assert (tmp_path / other / ".dem_5187_2m.lock").exists()
def test_worker_init_pins_first_e_workers(monkeypatch, tmp_path) -> None:
import multiprocessing
import psutil
pinned: list[list[int]] = []
class FakeProc:
def nice(self, _):
pass
def cpu_affinity(self, cpus):
pinned.append(cpus)
monkeypatch.setattr(psutil, "Process", FakeProc)
monkeypatch.setattr(tool.store, "TERRAIN_ROOT", tool.store.TERRAIN_ROOT) # 끝나면 되돌림
counter = multiprocessing.Value("i", 0)
for _ in range(5):
tool.init_worker(str(tmp_path), counter, [12, 13], 3)
assert pinned == [[12, 13]] * 3 and tool.store.TERRAIN_ROOT == tmp_path
+282 -53
View File
@@ -6,16 +6,23 @@
--sheets 36901,36712089 도엽 번호 또는 앞자리(1:50,000 묶음) · 주면 높이 차 거름 안 씀
--box lon0,lat0,lon1,lat1 도엽 가운데가 이 경위도 상자 안
--min-relief 150 도엽 안 높이 차(m) 이상만(산지) · --limit N 앞 N 장만
--workers 4 일꾼 수 · --status 진행 기록 요약만 · --rebuild-index 도엽 목록 다시
--workers 4 일꾼 수 · --status 진행 기록 요약만(모든 PC) · --rebuild-index 도엽 목록 다시
--part 0-69/100 두 PC 나눔(도엽 번호 crc32 % 100 이 0~69 인 것만 · PLAN 618)
--cpu-cap 70 전체 CPU % 가 넘으면 새 도엽 안 줌 · --e-cores 12-19 --e-workers 3
일꾼 셋만 그 논리 CPU 로 지정 · --root 창고 자리(시험용 임시 폴더)
- 도엽 목록 = 전국 등고선 파일 꼭짓점이 닿는 도엽(0.025°) — `_precompute_sheets.json`(전국 파일
지문이 같으면 다시 안 셈) · 차례 = 등고선 길이 큰 것(산지)부터 · 같으면 높이 차.
- 좌표계 = 도엽 가운데 경도의 TM 띠(`tm_epsg_for_lon` · 5185~5187) · 격자 2 m.
- 셈 = 공용 함수만 부름(창고 지형 · D 0차곡 누적 · E07 도엽) — 저장 열쇠가 같으면 함수가 건너뜀 ·
쓰기 = 함수 쪽 원자 쓰기(끊겨도 반쯤 쓴 파일 없음).
- 이어 하기 = `_precompute_progress.json`(단계 · 도엽마다 성공 · 시간) — 성공한 도엽은
다시 안 부름 · 실패는 다음에 다시 · 전국 파일이 바뀌면 기록을 새로 시작.
- 시작 때 죽은 셈의 잠금(주인 프로세스 없음) · 오래된 임시 파일을 치움 ·
- 이어 하기 = PC 별 `_precompute_progress_<컴퓨터 이름>.json`(단계 · 도엽마다 성공 · 시간 ·
시놀로지가 두 PC 사이 옮김 → 한 파일은 한 PC 만 씀) · 옛 `_precompute_progress.json` 의 성공은
읽어서 이어 받음(안 씀) · 성공한 도엽은 다시 안 부름 · 실패는 다음에 다시 · 전국 파일이 바뀌면
기록을 새로 시작.
- 나눔 = 지형 단계가 목록 도엽 + 0차곡이 쓸 이웃(목록 밖 · 띠 넘음 포함)을 해시 몫대로 셈 ·
0차곡은 남의 몫 이웃 지형이 창고에 없으면 미뤘다 다시(다른 PC 셈 · 시놀로지 동기 기다림).
- 시작 때 자기 몫 도엽의 죽은 셈 잠금(주인 프로세스 없음) · 오래된 임시 파일을 치움 ·
낮은 우선순위(BELOW_NORMAL).
"""
@@ -24,11 +31,15 @@ from __future__ import annotations
import argparse
import json
import math
import multiprocessing
import os
import platform
import sqlite3
import sys
import threading
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
import zlib
from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
from pathlib import Path
from typing import Any
@@ -50,7 +61,10 @@ from common_util.common_util_json import atomic_write_json # noqa: E402
STEPS = ("dem", "zero", "e07")
CELL_M = 2.0
PROGRESS_NAME = "_precompute_progress.json"
PROGRESS_NAME = "_precompute_progress.json" # 옛 한 벌 기록(읽기만)
PC_NAME = platform.node() or "pc"
CPU_SAMPLE_S = 3.0 # 전체 CPU 평균 잼 · 상한 쓸 때 새 도엽 주는 간격
DEFER_WAIT_S = 600.0 # 0차곡 이웃 지형 기다림 한 바퀴
INDEX_NAME = "_precompute_sheets.json"
INDEX_CHUNK = 50_000 # 가닥 묶음(약 0.5 GB 읽기)
INDEX_STRIDE = 8 # 꼭짓점 솎음(도엽 2 km 에 비해 넉넉)
@@ -68,8 +82,77 @@ def below_normal() -> None:
psutil.Process().nice(psutil.BELOW_NORMAL_PRIORITY_CLASS)
def epsg_of(sheet_no: str) -> int:
return tm_epsg_for_lon(sheet5k_center(sheet_no)[1])
def epsg_of(job: str) -> int:
"""도엽(또는 `도엽@좌표계` 일감)의 좌표계 — 기본 = 도엽 가운데 경도 띠."""
no, _, epsg = job.partition("@")
return int(epsg) if epsg else tm_epsg_for_lon(sheet5k_center(no)[1])
# ── 나눔 · CPU ───────────────────────────────────────────────────────────────
Part = tuple[int, int, int]
def parse_part(text: str) -> Part | None:
"""`A-B/N` → (A, B, N) · 빈 글 = 나눔 없음."""
if not text:
return None
span, _, n = text.partition("/")
a, _, b = span.partition("-")
part = (int(a), int(b or a), int(n or 100))
if not 0 <= part[0] <= part[1] < part[2]:
raise ValueError(f"--part = A-B/N (0 ≤ A ≤ B < N): {text}")
return part
def in_part(job: str, part: Part | None) -> bool:
"""도엽 번호 crc32 % N 이 A~B — PC 와 실행에 상관없이 같은 몫(겹침 0)."""
if part is None:
return True
value = zlib.crc32(job.partition("@")[0].encode()) % part[2]
return part[0] <= value <= part[1]
def parse_cpus(text: str) -> list[int]:
"""`12-19` · `12,14,16-17` → 논리 CPU 번호."""
cpus: list[int] = []
for piece in filter(None, (p.strip() for p in text.split(","))):
a, _, b = piece.partition("-")
cpus.extend(range(int(a), int(b or a) + 1))
return cpus
class CpuMeter:
"""전체 CPU %(시스템 · 다른 일 포함) — 뒤 실이 `CPU_SAMPLE_S` 평균을 잼 · 부르면 마지막 값."""
def __init__(self) -> None:
import psutil
self.value = psutil.cpu_percent(interval=CPU_SAMPLE_S)
def loop() -> None:
while True:
self.value = psutil.cpu_percent(interval=CPU_SAMPLE_S)
threading.Thread(target=loop, daemon=True).start()
def __call__(self) -> float:
return self.value
def init_worker(root: str, counter: Any, e_cpus: list[int], e_workers: int) -> None:
"""일꾼 시작 — 창고 자리 · 낮은 우선순위 · 앞선 `e_workers` 명은 `e_cpus` 에만(저전력 코어)."""
store.TERRAIN_ROOT = Path(root)
below_normal()
if counter is None:
return
with counter.get_lock():
slot = counter.value
counter.value += 1
if slot < e_workers:
import psutil
psutil.Process().cpu_affinity(e_cpus)
# ── 도엽 목록 ────────────────────────────────────────────────────────────────
@@ -203,9 +286,24 @@ def _mtime(path: Path) -> int | None:
return None
def run_step(step: str, sheet_no: str) -> tuple[str, str, dict[str, Any]]:
"""도엽 하나 · 단계 하나 — 공용 함수만 부름 · fresh = 이번에 새로 셈."""
epsg = epsg_of(sheet_no)
def dem_jobs(sheets: list[str], part: Part | None) -> list[str]:
"""지형 단계 일감 — 목록 도엽 뒤 0차곡이 쓸 3x3 이웃(그 가운데 도엽 좌표계) · 자기 몫만.
이웃이 가운데와 띠가 다르면 `도엽@좌표계`(옮겨 담기) — 목록 밖 이웃도 한 PC 만 셈.
"""
jobs: dict[str, None] = {no: None for no in sheets if in_part(no, part)}
for center in sheets:
epsg = epsg_of(center)
for no in neighbors_3x3(center):
if in_part(no, part):
jobs[no if epsg == epsg_of(no) else f"{no}@{epsg}"] = None
return list(jobs)
def run_step(step: str, job: str, part: Part | None = None) -> tuple[str, str, dict[str, Any]]:
"""일감 하나 · 단계 하나 — 공용 함수만 부름 · fresh = 이번에 새로 셈 · wait = 이웃 기다림."""
sheet_no = job.partition("@")[0]
epsg = epsg_of(job)
started = time.monotonic()
try:
if step == "dem":
@@ -214,7 +312,11 @@ def run_step(step: str, sheet_no: str) -> tuple[str, str, dict[str, Any]]:
elif step == "zero":
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero
for no in neighbors_3x3(sheet_no): # 여유 200 m 이웃 지형(누적 열쇠가 나중에 안 바뀌게)
neighbors = neighbors_3x3(sheet_no)
for no in neighbors: # 남의 몫 이웃 지형이 아직 없음 = 미룸(누적 열쇠가 나중에 바뀜)
if not in_part(no, part) and store._stored_ok(no, epsg, CELL_M) is None:
return step, job, {"ok": False, "wait": True, "error": f"이웃 지형 없음 {no}"}
for no in neighbors: # 여유 200 m 이웃 지형(누적 열쇠가 나중에 안 바뀌게)
store.ensure_sheet_terrain(no, epsg, CELL_M)
target = zero._acc_names(sheet_no, epsg, CELL_M)[0]
before = _mtime(target)
@@ -231,30 +333,55 @@ def run_step(step: str, sheet_no: str) -> tuple[str, str, dict[str, Any]]:
else:
raise ValueError(f"모르는 단계: {step}")
except Exception as exc: # noqa: BLE001 — 한 장이 죽어도 나머지는 감 · 기록에 까닭
return step, sheet_no, {"ok": False, "error": f"{type(exc).__name__}: {exc}"[:300]}
return step, job, {"ok": False, "error": f"{type(exc).__name__}: {exc}"[:300]}
seconds = round(time.monotonic() - started, 2)
return step, sheet_no, {"ok": True, "s": seconds, "fresh": fresh, "empty": empty}
return step, job, {"ok": True, "s": seconds, "fresh": fresh, "empty": empty}
# ── 진행 기록 · 정리 ─────────────────────────────────────────────────────────
def load_progress() -> dict[str, Any]:
path = store.TERRAIN_ROOT / PROGRESS_NAME
stamp = store._gpkg_stamp()
def progress_path() -> Path:
return store.TERRAIN_ROOT / f"_precompute_progress_{PC_NAME}.json"
def progress_files() -> list[Path]:
"""모든 PC 기록(옛 한 벌 · 시놀로지 충돌 사본 포함)."""
return sorted(store.TERRAIN_ROOT.glob("_precompute_progress*.json"))
def _read_progress(path: Path, stamp: str | None) -> dict[str, Any] | None:
try:
saved = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
saved = None
if not saved or saved.get("gpkg") != stamp or saved.get("cell_m") != CELL_M:
saved = {"gpkg": stamp, "cell_m": CELL_M, "steps": {}}
return None
if not isinstance(saved, dict) or saved.get("gpkg") != stamp or saved.get("cell_m") != CELL_M:
return None
return saved
def load_progress(every_pc: bool = False) -> dict[str, Any]:
"""이 PC 기록 + 옛 한 벌의 성공(every_pc = 모든 PC 기록의 성공) — 쓰기는 이 PC 파일만."""
stamp = store._gpkg_stamp()
own_path = progress_path()
saved = _read_progress(own_path, stamp) or {"gpkg": stamp, "cell_m": CELL_M, "steps": {}}
for step in STEPS:
saved["steps"].setdefault(step, {})
others = progress_files() if every_pc else [store.TERRAIN_ROOT / PROGRESS_NAME]
for path in others:
other = None if path == own_path else _read_progress(path, stamp)
for step, records in (other or {}).get("steps", {}).items():
mine = saved["steps"].get(step)
if mine is None:
continue
for job, info in records.items():
if info.get("ok") and not mine.get(job, {}).get("ok"):
mine[job] = info
return saved
def save_progress(progress: dict[str, Any]) -> None:
atomic_write_json(store.TERRAIN_ROOT / PROGRESS_NAME, progress)
atomic_write_json(progress_path(), progress)
def _alive(pid: int) -> bool:
@@ -263,15 +390,18 @@ def _alive(pid: int) -> bool:
return psutil.pid_exists(pid)
def clean_dead(root: Path | None = None) -> int:
"""주인이 죽은 잠금 · 오래된 임시 파일을 지움(끊긴 일괄 셈이 남긴 것) → 지운 수."""
def clean_dead(root: Path | None = None, part: Part | None = None) -> int:
"""자기 몫 도엽의 주인이 죽은 잠금 · 오래된 임시 파일을 지움(끊긴 일괄 셈이 남긴 것) → 지운 수.
남의 몫 도엽 = 다른 PC 잠금(이 PC 에선 주인 번호가 없어 보임)이라 안 건드림.
"""
root = root or store.TERRAIN_ROOT
removed = 0
if not root.is_dir():
return 0
now = time.time()
for folder in root.iterdir():
if not folder.is_dir():
if not folder.is_dir() or not in_part(folder.name, part):
continue
for path in folder.glob(".*"):
try:
@@ -288,47 +418,102 @@ def clean_dead(root: Path | None = None) -> int:
return removed
def _execute(step: str, todo: list[str], workers: int, record, opts: dict[str, Any]) -> None:
"""일감 차례대로 일꾼에 줌 — 상한 쓰면 전체 CPU 가 넘는 동안 새 일 안 줌(도는 것은 그대로)."""
part, cap, cpu = opts["part"], opts["cpu_cap"], opts["cpu"]
def over() -> bool:
return cap is not None and cpu() > cap
if workers <= 1:
for job in todo:
while over():
time.sleep(CPU_SAMPLE_S)
record(run_step(step, job, part))
return
e_cpus, e_workers = opts["e_cpus"], opts["e_workers"]
counter = multiprocessing.Value("i", 0) if e_cpus and e_workers else None
initargs = (str(store.TERRAIN_ROOT), counter, e_cpus, e_workers)
with ProcessPoolExecutor(workers, initializer=init_worker, initargs=initargs) as pool:
queue = list(reversed(todo))
running: set = set()
try:
while queue or running:
while queue and len(running) < workers and not over():
running.add(pool.submit(run_step, step, queue.pop(), part))
if cap is not None:
break # 상한 쓸 때 한 번에 하나 — 다음 잼을 보고 또
if not running:
time.sleep(CPU_SAMPLE_S)
continue
timeout = CPU_SAMPLE_S if cap is not None and queue else None
finished, running = wait(running, timeout=timeout, return_when=FIRST_COMPLETED)
for future in finished:
record(future.result())
except BaseException:
pool.shutdown(wait=False, cancel_futures=True)
raise
def run(
sheets: list[str],
steps: list[str],
workers: int,
progress: dict[str, Any],
say=print,
*,
part: Part | None = None,
cpu_cap: float | None = None,
cpu=None,
e_cpus: list[int] | None = None,
e_workers: int = 0,
defer_wait: float = 0.0,
) -> dict[str, Any]:
"""단계 차례대로 · 단계 안은 일꾼 여럿 · 성공 기록된 도엽은 건너뜀 → 단계별 벽시계 초."""
"""단계 차례대로 · 단계 안은 일꾼 여럿 · 성공 기록된 일감은 건너뜀 → 단계별 벽시계 초.
sheets = 나누기 전 목록(이웃 몫에 씀) · 미룬 0차곡 = defer_wait 초 뒤 다시(0 = 안 기다림).
"""
if cpu_cap is not None and cpu is None:
cpu = CpuMeter()
opts = {"part": part, "cpu_cap": cpu_cap, "cpu": cpu, "e_cpus": e_cpus or []}
opts["e_workers"] = e_workers
walls: dict[str, float] = {}
for step in [s for s in STEPS if s in steps]:
done = progress["steps"][step]
todo = [no for no in sheets if not done.get(no, {}).get("ok")]
say(f"[{step}] {len(todo)} 장 (건너뜀 {len(sheets) - len(todo)})")
jobs = dem_jobs(sheets, part) if step == "dem" else [n for n in sheets if in_part(n, part)]
todo = [job for job in jobs if not done.get(job, {}).get("ok")]
say(f"[{step}] {len(todo)} 장 (건너뜀 {len(jobs) - len(todo)} · 몫 {len(jobs)})")
started = time.monotonic()
saved_at = started
count = 0
waiting: list[str] = []
def record(result: tuple[str, str, dict[str, Any]]) -> None:
nonlocal count, saved_at
_, no, info = result
done[no] = info
_, job, info = result
done[job] = info
count += 1
state = f"{info['s']} s" if info["ok"] else f"실패 {info['error']}"
say(f"[{step}] {count}/{len(todo)} {no} {state}")
if info.get("wait"):
waiting.append(job)
state = (
f"{info['s']} s"
if info["ok"]
else f"{'기다림' if info.get('wait') else '실패'} {info['error']}"
)
say(f"[{step}] {count}/{len(todo)} {job} {state}")
if time.monotonic() - saved_at > SAVE_EVERY_S:
save_progress(progress)
saved_at = time.monotonic()
try:
if workers <= 1:
for no in todo:
record(run_step(step, no))
else:
with ProcessPoolExecutor(workers, initializer=below_normal) as pool:
futures = [pool.submit(run_step, step, no) for no in todo]
try:
for future in as_completed(futures):
record(future.result())
except BaseException:
pool.shutdown(wait=False, cancel_futures=True)
raise
while True:
_execute(step, todo, workers, record, opts)
if not waiting or defer_wait <= 0:
break
save_progress(progress)
say(f"[{step}] 이웃 지형 기다림 {len(waiting)} 장 · {defer_wait:g} s 뒤 다시")
time.sleep(defer_wait)
todo, waiting[:], count = list(waiting), [], 0
finally:
save_progress(progress)
walls[step] = round(time.monotonic() - started, 1)
@@ -341,12 +526,14 @@ def summary(sheets: list[str], progress: dict[str, Any]) -> dict[str, Any]:
for step in STEPS:
done = progress["steps"][step]
infos = [done[no] for no in sheets if no in done]
failed = {no: done[no]["error"] for no in sheets if done.get(no, {}).get("ok") is False}
failed = {
no: done[no]["error"]
for no in sheets
if done.get(no, {}).get("ok") is False and not done[no].get("wait")
}
fresh = [i["s"] for i in infos if i.get("ok") and i.get("fresh")]
sizes = []
sizes = [] # 창고 실물(다른 PC 가 셈 · 시놀로지로 옴 포함)
for no in sheets:
if not done.get(no, {}).get("ok"):
continue
stem = f"{epsg_of(no)}_{CELL_M:g}m"
folder = store.TERRAIN_ROOT / no
total = 0
@@ -358,6 +545,8 @@ def summary(sheets: list[str], progress: dict[str, Any]) -> dict[str, Any]:
"ok": sum(1 for i in infos if i.get("ok")),
"fresh": len(fresh),
"failed": failed,
"waiting": sum(1 for no in sheets if done.get(no, {}).get("wait")),
"stored": len(sizes),
"s_median": round(float(np.median(fresh)), 1) if fresh else None,
"s_mean": round(float(np.mean(fresh)), 1) if fresh else None,
"s_max": round(float(np.max(fresh)), 1) if fresh else None,
@@ -377,7 +566,22 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--status", action="store_true")
parser.add_argument("--rebuild-index", action="store_true")
parser.add_argument("--part", default="", help="A-B/N — 도엽 번호 crc32 %% N 이 A~B 인 것만")
parser.add_argument("--cpu-cap", type=float, default=None, help="전체 CPU %% 상한")
parser.add_argument("--e-cores", default="", help="저전력 논리 CPU(예 12-19)")
parser.add_argument("--e-workers", type=int, default=0, help="그 CPU 로 지정할 일꾼 수")
parser.add_argument("--defer-wait", type=float, default=DEFER_WAIT_S)
parser.add_argument("--root", default="", help="창고 자리(시험용 임시 폴더)")
args = parser.parse_args(argv)
try:
part = parse_part(args.part)
e_cpus = parse_cpus(args.e_cores)
except ValueError as exc:
parser.error(str(exc))
if (args.e_workers and not e_cpus) or args.e_workers > args.workers:
parser.error("--e-workers 는 --e-cores 와 함께 · --workers 이하")
if args.root:
store.TERRAIN_ROOT = Path(args.root)
steps = [s.strip() for s in args.steps.split(",") if s.strip()]
unknown = [s for s in steps if s not in STEPS]
if unknown:
@@ -392,14 +596,39 @@ def main(argv: list[str] | None = None) -> int:
sheets = pick_sheets(
index, sheets=wanted or None, box=box, min_relief=args.min_relief, limit=args.limit or None
)
print(f"도엽 {len(sheets)} 장 (목록 {len(index)} 장) · 단계 {steps} · 일꾼 {args.workers}")
progress = load_progress()
if not args.status:
print(f"치운 잠금 · 임시 파일 {clean_dead()}")
print(
f"도엽 {len(sheets)} 장 (목록 {len(index)} 장) · 단계 {steps} · 일꾼 {args.workers}"
f" · 몫 {args.part or '전부'} · PC {PC_NAME} · 상한 {args.cpu_cap}"
f" · 지정 CPU {e_cpus} × {args.e_workers}"
)
mine = [no for no in sheets if in_part(no, part)]
if args.status:
out = summary(mine, load_progress(every_pc=True))
stamp = store._gpkg_stamp()
out["pc"] = {}
for path in progress_files():
recs = (_read_progress(path, stamp) or {}).get("steps", {})
out["pc"][path.name] = {
step: sum(1 for i in recs.get(step, {}).values() if i.get("ok")) for step in STEPS
}
else:
progress = load_progress()
print(f"치운 잠금 · 임시 파일 {clean_dead(part=part)}")
started = time.monotonic()
walls = run(sheets, steps, args.workers, progress)
walls = run(
sheets,
steps,
args.workers,
progress,
part=part,
cpu_cap=args.cpu_cap,
e_cpus=e_cpus,
e_workers=args.e_workers,
defer_wait=args.defer_wait,
)
print(f"벽시계 {walls} · 전체 {time.monotonic() - started:.1f} s")
print(json.dumps(summary(sheets, progress), ensure_ascii=False, indent=1))
out = summary(mine, progress)
print(json.dumps(out, ensure_ascii=False, indent=1))
return 0