From 41c975c7bf1bc1ba1723a2846b24d1579a0f27e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 11:51:25 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore(plan):=20=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=20=EC=9D=B4=EA=B4=80=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=20=EB=B8=8C=EB=9E=9C=EC=B9=98=EC=97=90=EB=8F=84=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=E2=80=94=20dev=20=EC=99=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9(=EB=B0=9B=EA=B8=B0=20=EB=A7=89=ED=9E=98=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- ...29_plan_M02구조물집계_계획노선_종단저장.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/raw/plans/2026-09-29_plan_M02구조물집계_계획노선_종단저장.md diff --git a/docs/raw/plans/2026-09-29_plan_M02구조물집계_계획노선_종단저장.md b/docs/raw/plans/2026-09-29_plan_M02구조물집계_계획노선_종단저장.md new file mode 100644 index 00000000..1bca8fbb --- /dev/null +++ b/docs/raw/plans/2026-09-29_plan_M02구조물집계_계획노선_종단저장.md @@ -0,0 +1,313 @@ +# 지난 계획 10~19장 (2026-09-29 이관 · 모두 검증완료) + +## 10. M02 마스터 템플릿 — 표 양식 · 도면 양식 (진행 · 담당 sub1 10-1 · sub3 10-2 · sub2 10-3 · sub4 10-4·10-5) + +사용자 지시 — 대시보드에 M01 처럼 단추로 들어가는 마스터 템플릿 페이지(`M02_MasterTemplete`) · 공통 양식(좌측 패널 + 메인 · 안쪽 컨테이너 · 단추도 양식) · 양식 두 가지(엑셀처럼 도는 표 · 웹캐드 도면) · 프로그램 기본 양식을 두고 **프로젝트를 만들 때 복사**해 쓰고 그 뒤 사용자가 고침 · 첫 표 양식 = 구조물 집계표. +근거 — `tmp/M02_분석/1_얼개.md` · `2_웹캐드.md` · `3_표양식.md` · `4_구조물집계열.md`. +분석 결론 — 표 부품은 라이브러리 대신 **직접 만듦**(라이선스 · 겉모양 · 서버 재계산) · 수식은 옛 B08 TS 식 풀이기를 되살려 화면 · 서버 Node 한 벌 · 웹캐드는 iframe 과 주고받기를 그대로 다시 씀 · 오솔길엔 구조물 집계표 열 규칙이 없음(실무 표는 손으로 짬) · 지금 프로젝트 층 양식 복사는 없음(회사 층만 있음). + +### 10-0. 사용자 확정 + +- 도면 기본 양식 → 마스터 템플릿 폴더로 옮김(`resources/master_template/drawing/`) +- 양식 층 넷 — **시스템 · 회사 · 개인 · 프로젝트** · 시스템은 저장소(git) · 나머지 셋은 영구저장소(storage) 나무에 +- 프로젝트 만들 때 마스터를 프로젝트 폴더로 복사 · [초기화] = 그때 복사한 양식 · 회사 공식 · 같은 회사 남의 양식 · 다른 프로젝트 양식을 가져와 적용 +- 구조물 집계표는 설계값을 받게 만듦 · 마스터엔 값이 없고 **열마다 「프로젝트에서 무엇이 들어가는지」**를 보임 · 시스템 관리자가 열 더하기 · 지우기 · 바꾸기 +- 엑셀 같은 표 기능은 공용 폴더 아래 따로 둔 하위 폴더에(`ui_template/sheet/`) +- 머리 이름 · 열 짜임은 실무를 따르되 체계적으로(브레인 판단) · 열 묶음마다 일위대가 조합과 연결 +- 커플링밴드는 관 길이 ÷ 1본 길이로 자동 — 1본 길이 **8m**(관 종류마다 칸 · 6m 가 필요하면 사용자가 확인 뒤 바꿈) · 이음 수 = 올림(L ÷ 1본) − 1 +- 프로젝트 첫 복사는 **늘 시스템 양식** · 회사 공식이 있어도 · [회사 양식 적용] 을 누르면 회사 양식을 프로젝트 작업본에 덮어씀 +- **이미 만든 프로젝트들**에도 프로젝트 양식(작업본 + `_initial/`)을 손으로 넣어 사용자가 확인하게 함 +- 표는 50줄씩, 넘치면 다음 쪽 +- 엑셀 내보내기 안 함 · 프로젝트 안쪽 화면에 양식 쓰기는 나중(구조물 정의가 끝난 뒤) · 지금은 마스터 양식 구현에 집중 + +브레인 임의 판정: + +- (사용자 바로잡음) M02 = 시스템 관리자가 새 프로젝트 기본값(시스템 층)만 고치는 곳 · 층 고르기 · 프로젝트 고르기 · 프로젝트 층 단추는 M02 에 두지 않음(프로젝트 쪽 화면은 나중) · 서버 층 길 · 프로젝트 복사는 그대로 +- 집계표 설계값 채우기 = 서버 단독(5장 ③) · 계산 열 = 표 식(5장 ② 한 벌) · 같은 측점에 관이 둘이면 줄을 나눔 +- 프로젝트 표 [저장] = 양식(열 · 식 · 변수) + 손 입력 값만 작업본에 · 설계값과 펼친 열은 저장 안 함(열 때마다 설계에서 채움 · 5장 정본의 자리) +- 표 JSON = `3_표양식.md` 4장 틀 + 열 칸 `설명`(들어갈 것) · `바인딩`(설계 출처) · `일위대가` · `펼침`(마스터 한 칸 → 설계 값마다 열) + +### 10-1. 페이지 틀 + +- [X] 대시보드 「시스템 설정」 칸에 단추 · 길 `m02-master-template` · 로그인 · 권한(SYSTEM_ADMIN) · 서버 `/api/m02`(`main.py` 관리자 전용) — `1_얼개.md` 1장 손댈 곳 10 — 검증완료(ORCA 단추 → 페이지) +- [X] 좌측 패널(양식 목록 · 표/도면 · 새로 · 본떠 만들기 · 지우기) + 메인(편집) · M01 모달을 공용 `ui_template` 으로 올려 함께 씀 — 검증완료(ORCA · 층 넷 · 프로젝트 단추 다섯 붙임 · M01 모달 계산값 같음) +- [X] 서버 저장 — 목록 · 읽기 · 저장(판 · 원자 쓰기 · 409) · 지우기 · M01 `Store` 방식 · 자리 `resources/master_template/` — 검증완료(`test_m02_store.py` · ORCA 새로·저장·다시 열기·지우기·409) +- [X] 병합 이어 붙임 — sub2·sub3·sub4 받음 · 층 길 sub4 것 · 도면 길 sub2 것 · `tsconfig` 에 `M0*` · 검증완료(ORCA 시스템 층 표 1 · 도면 5 · 집계표 master 모드 · CAD·자리표 키 · 프로젝트 층 채움 · 단추 다섯 수로 확인 · 시험 양식 지움) + +- 남김 — 회사 층은 여기서 읽기만 · 비관리자는 시스템 층 단추 잠김 · 프로젝트 층 표 [저장] 은 채워진 문서를 그대로 저장(설계값 줄이 작업본에 들어감 — 브레인 판정 필요) · 랩탑_보조 프로젝트엔 포장 열이 없어 포장 A·줄눈 수는 못 봄(커플링 51칸 · 화면=서버=손계산 일치) + +### 10-2. 표 양식 부품 + +- [X] 격자 — 셀 편집 · 줄/열 더하기 · 지우기 · 여러 층 머리(합치기) · 단위 줄 · 합계 줄 · 키보드 · M01 칸 편집 부품 다시 씀 — `ui_template/sheet/ui_template_sheet.ts` `createSheet` · master(식 · 들어갈 것 · 일위대가 줄 · 손 열) · project(바인딩 · 계산 잠금 · 손 열만 · 전구간 맨 위 · 쪽줄 50) · 틀고정 · 열 너비 끌기 · 칸에 `=` = 그 칸만 식 +- [X] 수식 — 옛 `B08_Quantity_Formula.ts` 되살려 공용 · 열 참조 · 셀 참조 · SUM · INT · ROUND · 순환 막기 · 화면 즉시 + [저장] 때 서버 Node 재계산(5장 ②) · `package.json` 깨진 `build:formula` 고침 — `ui_template_sheet_recalc.ts` 한 벌 · `[$이름]` · ROUNDUP · 합계 줄 열마다 식 · 서버 `common_util_sheet_recalc.py` → `common_util_sheet_recalc_node.ts`(sheet 폴더 밖 — M02 화면이 폴더를 훑어 불러옴) +- [X] 저장 JSON — `3_표양식.md` 4장 초안(층 · 열 id · 식 · 줄 · 합계줄 · 보기) — 모양 `ui_template_sheet_types.ts` · 계약 칸(설명 · 바인딩 · 일위대가 · 펼침 · 손 · 변수 · 쪽줄 · 고정) + +- 검증완료 — `test_sheet_recalc.py` 5 개(실무 울진 2공구 합계 캐시값 · Decimal 거울 40 문서) · 포트 5178 M02 메인 칸에 붙여 머리 합치기 · 마스터 조작 · 쪽 50+11 · 잠금 · 손 열 합계 · 틀고정 · 너비 끌기 · 화면 풀이 = 서버 Node 풀이(계산 칸 183 · 합계) + +### 10-3. 도면 양식 + +- [X] B07 의 iframe · 주고받기 부모 흐름을 공용으로 떼어 M02 · B07 한 벌 · 도각 편집 모드로 양식 만들기 · 저장 · 목록 — `ui_template/cad_host/` · `mountDrawingTemplate`(getDoc 은 Promise) · 목록은 10-1 +- [X] 양식 장부(이름 · 종류 · 크기 · 작도 영역 · 자리표 키 · 판) · 자리표 키 목록 보이기 · 작도 영역을 양식 칸으로(지금 `_A1_INNER` 박힘) — 양식 칸 `drawing_area` · `GET /api/m02/drawing-fields` · 이름 · 종류 · 판은 10-5 층 목록 · 크기 칸은 안 둠(작도 영역으로 갈음) +- [X] 도면 기본 양식 `resources/master_template/drawing/` 로 옮김 · B07 도각 읽는 차례 프로젝트 작업본 → 시스템 · [도각 편집] 저장 = 작업본 · [기본 도각으로] = `_initial/` + +- 검증완료 — 시험 22 개 통과 · 포트 5177 B07 도형 수 그대로(표지 15 · 종단 800 · 평면 401 · 토적 287 · 표준횡단 102) · 도각 편집 열기(51) · 취소 · M02 부품 붙여 도각 51 · getDoc 51 · 자동백업 칸 `m02:*` 따로 + +- [X] 옛 회사 도각 → 회사 층 `storage/1/templates/drawing/00_template_A1.json` 복사(원본 그대로) · M02 도면 칸 메인 꽉 채움 + +- 검증완료 — 층 길 회사 목록에 보임 · [회사 양식 적용] 으로 프로젝트 작업본 덮임 · B07 도면이 그 도각으로 그려짐(표준횡단 102 · 토적 287) · [도각 편집] 저장 = 작업본(`_initial/` 그대로) · [기본 도각으로] 뒤 백업과 같음 · M02 자리표 18 · DXF 불러오기 3 · CAD 캔버스 495×571 + +### 10-6. 최종 확인 뒤 개선 (사용자 「반영하고 검증」) + +- [X] sub1 층 단추 눌림 표시 · 한 줄 · 프로젝트 기본값(마지막 고른 것 → 내 최근 것) — 검증완료(브레인 ORCA 5173 · 시스템 단추 채움색 · 글 한 줄) +- [X] sub2 CAD 머리 글 · 저장 단추 하나 · 자동백업 칸에 층 — 검증완료(브레인 ORCA · 머리 「도면 양식 편집」 · 저장 1 · 백업 칸 m02:system · m02:project:id 갈림 · CAD 695×790) +- [X] sub3 딱지(계산 · 설계값 · 값마다 · 손) · 식 줄 이름으로 · 열 폭 · 풍선 · 50줄 쪽 나눔 시험 — 검증완료(`test_sheet_pages.py` · `test_sheet_recalc.py` 8 개 · ORCA 5178 시스템 집계표 딱지 A·줄눈·커플링 = 계산 · 식 줄 「B × L+확폭」 · 머리 잘림 0 · 프로젝트 층 줄 더하기 38 번 → 55줄 쪽 50 · 5 · 합계 2쪽만 · 다시 열기로 17줄 되돌림) +- [X] sub3 머리 칸 「/」 로 층 나눔 · 줄 머리 칸 가로 고정 · 줄 머리 칸 폭 자동 — 검증완료(`test_sheet_pages.py` 9 개 · ORCA 5178 시험 양식 새 열 「관공/Φ800/관매설」 세 층 → 저장 · 다시 열기 그대로 · 끝까지 넘겨 줄 머리 칸 9 개 왼쪽 고정 · 잘림 0 · 맨 위 · 시험 양식 지움) +- [X] sub3 수 칸에 수 아닌 글 → 오류 색 + 「수가 아님」 풍선 · 합계 칸에 「수가 아닌 칸 N 개는 빼고 더함」 — 검증완료(`test_sheet_recalc.py` 6 개 · ORCA 5178 프로젝트 층 abc · 12abc · - 오류 / 5 · 1,234 · 1e3 · 빈 칸 · 공백 · =1+1 정상 · 글 칸 abc 표시 없음 · 연달아 10번 바꿔도 알림 따라감 · 다시 열기로 되돌림) +- [X] sub4 관공 관경마다 짝 · 빈 값 글 · 계산 열 바인딩 떼기 · 옛 프로젝트 표 틀 갱신 — 검증완료(시험 21 개 · ORCA 5179 랩탑_보조 채운 표 머리 = 파형강관 Φ800 · Φ1000 · Φ1500 마다 관매설 · 커플링밴드 짝 · 「높이 미입력」 · 「Φ1000×련수 미입력」 · 「쌓기 방식 미입력 60~80 H=2.5」 · 커플링 칸 is-calc(NO.4+5.05 관 10 → 1) · 옛 프로젝트 6 개 작업본 · `_initial` 12 파일 = 시스템 판 d43943e2) +- [X] 브레인 ORCA 다시 확인 — 검증완료(랩탑_메인 오류 칸 0 · 관매설·커플링 짝 · 합계 50·5·11·1·21·2·10·1 · 랩탑_보조 다시 열기·양식 오가기 오류 0 · 틀린 판 저장 409 · 파일 안 바뀜) +- [X] 재검증(사용자 「마스터 양식 집중 · 재검증」) — 도면 저장 {} 흠 · 빈 문서 400(시스템 · 층) · 머리 / 층 나눔 · 줄 머리 칸 고정 · 저장 뒤 지우기 판 맞춤 — 검증완료(브레인 ORCA · DXF 선1·글자1 · 작도 영역 820 남음 · 스크롤 끝 줄 머리 left 345 그대로 · 저장 뒤 지우기 한 번 · storage 양식 74 파일 깨진 것 0) +- [X] 전수 점검 목록(`tmp/M02_분석/8_점검목록.md`) 두 바퀴 — 흠 10(이름 까닭 · / 이름 · 저장 안 한 고침 · 머리 메뉴로 떠남 · 개인 지우기 · 덮을까 · 저장 뒤 양식 닫힘 · CAD 💾 · 엉터리 파일 500 · 수 칸 글) 고침 — 검증완료(브레인 ORCA 목록 전 항목 됨 · 시험 통과 · 타입 검사 0 · 시험 양식·개인 파일 0 · 랩탑_메인 초기화 _initial 과 같음) + +### 10-7. M02 = 시스템 층만 · 도면 칸 위 공간 줄이기 (사용자 바로잡음) + +- [X] sub1 좌측 패널에서 층 단추 넷 · 프로젝트 고르기 · 프로젝트 층 단추 다섯 뺌 · 목록 = 시스템 표 · 도면 + 새로 · 본떠 · 지우기 · 제목 줄에 도면 칸이 쓸 자리(headerSlot) +- [X] sub2 도면 칸 — 위 자리표 글 줄 뺌(CAD 자리표 패널과 중복) · CAD 제목 줄 숨김 · 작도 영역 · 파일 불러오기는 제목 줄 한 줄로 · 그림 칸 위쪽 448px → 250px 안쪽 +- [X] 브레인 점검 목록 재실행(층 항목은 서버 시험으로) — 검증완료(좌측 = 표 · 도면 목록 + 새로 · 본떠 · 지우기 · 그림 칸 위 448 → 246px · 높이 598 → 800 · 작도 영역 단추 값 다 보임 · 저장 815 남음 · 틀린 값 막힘 · 확인창 · 표 항목 됨 · sub1 창 입력 거절로 sub2 가 대신함) + +### 10-5. 양식 층 · 복사 · 가져오기 (사용자 확인) + +| 층 | 자리 | 누가 씀 | +| -------- | ------------------------------------------------------------------------------------- | ---------------------------- | +| 시스템 | `resources/master_template/{table,drawing}/` (git) | M02 시스템 관리자 | +| 회사 | `storage/{회사}/templates/` (B07 도각이 이미 여기) | 회사 관리자 — 「회사 공식」 | +| 개인 | `storage/{회사}/{사용자}/templates/` | 본인 | +| 프로젝트 | `storage/{회사}/{사용자}/{프로젝트}/templates/` + 초기 사본 `templates/_initial/` | 프로젝트 사용자 | + +- [X] 프로젝트 만들 때 시스템 양식 전부를 프로젝트 `templates/` 와 `_initial/` 에 복사 · manifest 에 출처(층 · 이름 · 판) · `_initial/` 은 그 뒤 안 씀(5장 초기값 규칙) — `M02_Template_Layers.py` · 층 폴더마다 `manifest.json` +- [X] 이미 만든 프로젝트 — storage 의 프로젝트 폴더마다 시스템 양식을 작업본 · `_initial/` 로 넣음(한 번 · 도구로) · 목록을 사용자에게 보임 — `M02_Template_Migrate.py` · 6개 넣음 · 목록 `tmp/M02_분석/7_기존프로젝트_양식.md` · 표 1 + sub2 도면 5 (두 번 돌림 · 없는 것만 더함) +- [X] [초기화] = `_initial/` 을 작업본에 덮어씀 · [회사 양식 적용] = 회사 공식을 작업본에 덮어씀 · [양식 가져오기] = 시스템 · 회사 공식 · 같은 회사 사람의 개인 양식(읽기만) · 같은 회사 다른 프로젝트 양식 → 작업본을 바꿈(초기화 기준은 안 바뀜) — 서버 길 `M02_MasterTemplete_Router_Layers.py` · 단추는 10-1 +- [X] [내 양식으로 저장] → 개인 층 · [회사 공식으로 저장] → 회사 층(회사 관리자만) · 읽는 차례 = 프로젝트 작업본 → 없으면 시스템(옛 프로젝트) — 회사 관리자 = ADMIN · 마스터 · 시스템 관리자 + +- 검증완료 — 시험 10 개 통과(임시 storage · 층 길 · 복사 · 초기화 · 회사 적용 · 가져오기 · 권한 · 넣기 도구) · 실제 storage 6 프로젝트 넣기 뒤 작업본 · `_initial/` · manifest 확인 + +### 10-4. 구조물 집계표 마스터 · 프로젝트 복사 + +- 열 규칙 확정 — `tmp/M02_분석/5_집계표_열규칙.html` (머리 4층 · 설계값 묶음 21 · 손 입력 10 · 계산 열 4 · 일위대가 후보) · **한 표**(집계 · 그외 안 나눔) · 「전구간」 = 표 맨 위 손 입력 한 줄 · 오솔길엔 구조물 집계표 없음(P&B 입력은 도면 글자만 · 횡단 물량표는 토공만) + +- [X] 구조물 집계표 마스터 한 벌 — 위 열 규칙대로 · 종류는 `B05_Profile_Structure_Types.json` 을 가리킴 — `resources/master_template/table/구조물집계표.json` · 채우기 `M02_Table_Fill.py`(`GET …/tables/{이름}/filled`) +- [X] 프로젝트 만들 때 마스터 복사(`B02` `_initialize_project_storage` 끝 · `{프로젝트}/templates/` · manifest 에 판) · 읽기 차례 프로젝트 → 마스터 · [기본으로] = 마스터 다시 복사(`apply` `from: system`) + +- 열 규칙과 다른 곳 — 관보호공은 「유입」 한 칸(설계에 유출 보호공 칸 없음 · 유출은 기슭막이 열) · 큰돌쌓기는 찰·메 · 돌 크기 · **높이**까지 펼침(면적 L×H 를 표 식으로) · 흄관 · VR관 1본 길이 빈칸 → 커플링 식 없음 +- 검증완료 — 시험 5 개 통과 · 랩탑_보조 채운 표 = 설계와 같음(관 Φ800 6 · Φ1000 1 · Φ1500 2 · B06 관 연장 9곳 · 세월교 2 · 옹벽 반중력식 H2.0 · 돌쌓기 찰 H2.5 2 · 메 H2.0 · 메 H2.5 · 큰돌쌓기 1 · 줄 16) + +- [X] 프로젝트 표 [저장] 거름 — 층 길 PUT 에서 서버가 `strip_design` · 작업본 = 양식(펼침 규칙 열 · 식 · 변수 · 머리) + 손 값(측점 줄 키) + 전구간 줄 · 채울 때 설계에 안 쓰인 펼침 열도 `양식열` 로 실어 저장 때 되살림 · 없어진 측점 손 값은 맨 아래 사용자 줄(비고 「설계에서 측점 없어짐」) + +- 검증완료 — 시험 18 개 통과 · 랩탑_보조 채움 → 손 입력 둘(취수정 NO.4+5.05 · 비고 NO.8+13.09) 저장 → 작업본 열 49 · 펼친 열 0 · 설계값 0 · 줄 3 → 다시 열기 둘 다 제자리(관 연장 10 옆) → 초기화 = `_initial` 과 같음 + +## 11. M02 개선 — 컨테이너 · 롱기튜디널 지우기 · 구조물 도면 · 산출근거 (담당 sub1 11-1 · sub2 11-2 · sub3 11-3 · sub4 11-4) + +사용자 지시 — 마스터 템플릿 = 표 양식 · 도면 양식 두 구조 · 그룹은 컨테이너 · 롱기튜디널 지움 · 집계표 일위대가 아래 도면 줄(도번 연동) · 값을 고르면 아래에 설계 컨테이너 + 상세 산출근거 표 · [도면 수정] 으로 도면 편집 · 산출근거는 엑셀과 같은 빈 표. + +### 11-0. 사용자 확정 + +- 구조물 도면 · 산출근거 표 = **집계표 열마다 하나** · 펼침 열(관 지름 · 돌쌓기 높이)은 마스터 열 하나에 도면 하나(프로젝트에서 펼친 규격들이 같은 도면을 씀) +- 도번 = 자동(구조물 도면을 만든 차례로 `구-01` …) · 도면이 없으면 도면 줄에 「없음」 +- 설계 컨테이너 = 그 열의 구조물 도면 미리보기(읽기만) + [도면 수정] · 아래에 상세 산출근거 표 +- 롱기튜디널 두 양식 = 시스템 · 이미 만든 프로젝트 6곳 작업본 · `_initial/` 사본까지 지움 +- 저장 = 새 폴더 `resources/master_template/structure/` · 좌측에서는 「도면 양식」 컨테이너 안 「구조물 도면」 묶음 +- 산출근거 빈 표 머리 = 실무 `07-구조도-소광리.xlsx` 꼴(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) · 줄은 비움 · 채우기는 나중 + +### 11-1. 좌측 컨테이너 · 롱기튜디널 지우기 + +- [x] 좌측 「표 양식」「도면 양식」 = M01 좌측과 같은 접히는 컨테이너(`ui-collapsible ui-sidebar-section` · `attachCollapsible`) · 「도면 양식」 안에 「구조물 도면」 묶음(11-3 목록) +- [x] `01_template_Longitudinal_table.json` · `01_template_Longitudinal_Yaxis_scale.json` 지움 — 시스템 + 프로젝트 6곳 작업본 · `_initial/` · manifest 줄(한 번 도는 정리) +- 검증완료(11-1): M02 시험 38 통과 · ORCA 5174 컨테이너 접고 펴기 · 목록에 롱기튜디널 없음 · 정리 전후 templates 파일 84→60(프로젝트 6곳 · 파일 24 · manifest 12) · SideHandle.select(kind, name) 추가 · 검증 대기 + +### 11-2. 집계표 도면 줄 · 칸 고르기 신호 + +- [x] 마스터 전용 줄(식 · 설명 · 일위대가) 아래 「도면」 줄 — 열의 구조물 도면 도번 · 없으면 「없음」(`ui_template/sheet/`) · 칸 글 = `SheetOptions.drawingLabel(colId)` · 글이 바뀌면 `recalc()` +- [x] 칸을 고르면 페이지로 알림(`SheetOptions.onSelect({ row, col, colId })` · row = 줄 열쇠 · col = 열 차례) · 표 부품은 한 벌(M02 · 뒤에 프로젝트 화면이 같이 씀) +- 검증완료(11-2): 시험 12 통과 · typecheck · ORCA 5177 구조물집계표 일위대가 아래 도면 줄 49칸 「없음」 · 잠김 · 줄 머리 고정 · 방향키 일위대가↔도면↔첫 줄 · 시험 표에서 라벨 · recalc 다시 그림 · onSelect(누르기 · 방향키 · 머리) · project 에 도면 줄 없음 · 검증 대기 + +### 11-3. 구조물 도면 폴더 · 서버 + +- [X] 양식 종류에 `structure` 더함 — 열 id 로 찾는 도면 + 산출근거 표 짝 · 도번 자동 · 목록 · 새로(A1 도각을 깐 빈 도면) · 저장 · 지우기 · 새 프로젝트 복사에도 실림 + - 서버 주소 · 함수(`M02_MasterTemplete_Api_Fetch.ts`) — 목록 `GET /api/m02/templates` → `listStructures()` · 읽기 `GET /api/m02/templates/structure/{열 id}` → `readTemplate("system", "structure", id, null)` · 저장 `PUT` 같은 주소 → `saveTemplate("system", "structure", id, null, 판, 문서)` · 새로 `POST /api/m02/structures/{열 id}` → `createStructure(id)`(이미 있으면 `StaleError` · 없는 열 404) · 지우기 `DELETE` 같은 주소 `?판=` → `deleteTemplate("structure", id, 판)` · 도번 목록 `GET /api/m02/structures/numbers` → `fetchStructureNumbers()` = `{열 id: 도번}`(키 없음 = 「없음」) · 문서 타입 `StructureDoc` +- 검증완료 — 시험 42 개 통과(새 `test_m02_structure.py` 4 + 기존 M02) · 8005 서버 직접 호출: 새로 구-01 · 다시 409 · 없는 열 404 · 도각 개체 51 · 산출근거 머리 다섯 · 저장 · 도번 목록 · 층 목록 · 지우기 · 시험 파일 지움 + +### 11-4. 설계 컨테이너 · 산출근거 빈 표 · [도면 수정] + +- [x] 집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기(읽기만) + [도면 수정](없으면 새로 만들어 엶 · 같은 페이지 CAD 편집으로 옮김 · 저장 안 한 고침 확인창) +- [x] 그 아래 상세 산출근거 표 — 11-0 머리 · 빈 줄 · 저장 +- 11-4 코드 = 9d1a4f9b(dsub) · M02 시험 14 통과 · typecheck · ORCA 5174 화면 검증완료(도번 구-01 · 산출근거 저장 · 열 바꿈 · 확인창) · 7e694a88 · 검증 대기 +- 브레인 임의 판정: 설계 컨테이너 미리보기는 읽기만이라 CAD 도구 모음을 숨김 · 243d00c2 검증완료(미리보기 리본 display:none · 편집 화면 높이 122) + +### 11-5. 좌측 패널 공용 틀 (담당 sub1) + +- [x] M01 카드 · 항목 모양을 `ui_template_overlay.css` 공용 클래스로 · M01 · M02 좌측이 같이 씀 · 처음 펼침 · 접힘 = M01 과 같게 · 새로/본떠/지우기 단추 줄은 칸 안 · 확인 = ORCA 로 M01 · M02 나란히 수치 대조(사용자가 보고 판단) +- 검증완료(11-5): ef5a7e97 · ORCA 5174 M01 · M02 머리 14px · 여백 12 · 둥글기 8 · 선택 표시 · 처음 접힘 같음 · M01 전후 같음 · 새로 · 지우기 됨 · 사용자 판단 대기 + +### 11-6. 구조물 도면 컨테이너 따로 · 빈 화면 (담당 sub1) + +- 사용자 바로잡음: 구조물도와 도면 양식은 다른 개념 — 좌측 「구조물 도면」 은 「표 양식」「도면 양식」 과 같은 층 컨테이너(11-0 「도면 양식 안 묶음」 을 갈음) · 비어 있으면 안 됨 +- [x] 좌측 컨테이너 셋(표 양식 · 도면 양식 · 구조물 도면) · 구조물 도면 = 구조물집계표 열마다 한 줄(도면 없어도 열 이름 + 「없음」) +- [x] 사용자 확정: 빈 도면도 열마다 미리 만들어 도번(구-01 … 열 차례) 연결 · 집계표에 열을 더해 [저장]하면 그 열 빈 도면도 만들어짐 +- [x] 집계표에서 열을 고르면 아래에 컨테이너 둘(공용 접힘 카드) — 「도면 정보」(열 · 도번 · 미리보기 · [도면 수정]) · 「상세 산출근거」(표) +- 사용자 바로잡음: 구조물 도면 항목 = 도면만(산출근거 없음) · 구조물도는 도각 없음(그린 만큼 설계 영역) +- 검증완료(11-6): 65f43ca0 · 열 49 빈 구조물 도면(도각 없음) · 열 더해 저장 → 서버가 만듦 · 좌측 줄 누르면 CAD · 시험 49 · typecheck 0 · 흠: 도각 없는 빈 CAD 열면 빨간 오류 알림(도면 양식 새로도 같음 · 전부터) + +### 11-7. 산출근거 엑셀 · 구조물 도면 다듬기 (검토 중 · sub2 엑셀 · sub4 구조물 도면) + +- 사용자 확정: 산출근거 = 엑셀 · 구글 시트와 같은 자체 스프레드시트(A00_Common 새 폴더 · 시간 걸려도 됨) · 집계표 일부를 가져오는 것 아님 +- 사용자 확정: 도면 정보 미리보기에 CAD 특성 · 도면층 패널 없음 · 마스터 CAD 리본 처음 접힘 +- [x] 깊은 검토 — 엑셀(tmp/M02_분석/9_엑셀검토.md) · 구조물 도면(10_구조물도면검토.md) → 사용자 판단 뒤 계획 +- 사용자 확정: 엑셀 파일 가져오기 안 함 → 엑셀 · 구글에서 복사해 붙여넣기 · 인쇄 · PDF · 내보내기는 비활성(나중에 한 번에) · 산출근거 = 구조물집계표 열마다 따로 파일 · 도면 치수 참조는 따로 협의 +- [x] 구조물 도면 다듬기 — 미리보기 특성 · 도면층 숨김 · 도면층 없는 도면 오류 가드(sub1 마무리 중) · 마스터 CAD 리본 처음 접힘 · 편집 제목 열 머리 글(10_구조물도면검토.md 일감 A · C) +- 검증완료(11-7 다듬기): 리본 처음 접힘 26px · 단추로 펼침 122px · B07 펼침 그대로 · 제목 구-01 · 열 머리 글 · 특성 패널 숨김 · import 가드(sub1) · 서버 · 화면 새 문서 기본 도면층 + 49 파일 채움 dd7300aa + +### 11-8. 산출근거 스프레드시트 1단계 (A00_Common/spreadsheet · 근거 9_엑셀검토.md) + +- [x] 0 계약 — 타입 · 엔진 공개 함수 머리 · 저장 JSON 견본(열마다 파일) · 브레인 승인 뒤 A~F 병행 +- 검증완료(0 계약): 26fd4fb7 · spreadsheet_types.ts · spreadsheet_view_types.ts · 겉모양 함수 · 견본 JSON · 크기 예산 압축 뒤 54 KB · 저장 resources/master_template/basis/<열 id> +- [x] A 엔진(주소 · 수식 풀이 · 의존 재계산 · 순환 · 행열 넣기 참조 옮김 · 되돌리기) · B 함수 약 45 · 숫자 형식 · 채우기 +- [x] C 격자 그리기(병합 · 틀 고정 · 숨김 · 테두리) · D 입력(한글 · 수식 입력줄 · 참조 가리키기 · 채우기 끌기) +- [x] E 도구 · 시트 탭 · 클립보드(엑셀 · 구글과 서로 붙여넣기) · F 서버 같은 계산 · M02 끼우기 · 실무 수식 2,699칸 값 대조 시험(시험만 xlsx 읽음) +- 담당: A sub3 · B sub5 · C sub6 · D sub7(잇기) · E sub8 · F sub2(임시 [스프레드시트 시험] 단추 · 금빛 시험) +- 검증완료(A): 736dac4e · 엔진 · 오류 · 순환 · 딸린 칸만 재계산 · 행열 참조 옮김 · 되돌리기 · 규칙표 시험 9 묶음 · 압축 10.5 KB +- 검증완료(D): 고름 · 편집기 · 한글 조합(compositionstart) · 수식 입력줄 · 주소 상자 · 참조 가리키기 · 단축키 · 채우기 끌기 · 되돌리기 · 진짜 A 엔진과 잇기 · 규칙 시험 7 묶음 · ORCA 시험 틀 · 압축 약 10 KB · C · B 채우기 · E 는 빈 몸이라 가짜로 잇고 들어오면 다시 잇기 +- 검증완료(D 잇기): 가짜 부품 걷고 A · B · C · E · 2단계와 createSpreadsheet 잇기 · 찾기 패널(Ctrl+F · Shift+F5 · Ctrl+H) · 메모 풍선 · Shift+F2 · 서식 붓 · 골라 붙여넣기 · 폭 자동 맞춤 · 함수 자동 완성 · M02 [스프레드시트 시험] ORCA 한 바퀴 수치 · 크기 시험 넘침(locale 약 45 KB · 엔진) 브레인 보고 +- 검증완료(D 잇기 2): 정렬 · 필터 · 셀 서식(Ctrl+1 · [서식…]) · 조건부 서식 · 유효성(막음 · 목록 ▼) · 이름 상자 · 눈금선 · 여러 범위 · 수식 분석 · 자동 합계 · 상태 표시줄 · 시트 숨김/탭 색 · M02 ORCA 수치 · 크기 시험 통과(화면 27.2 KB) · 묶어 접기(sub6) 안 옴 +- 검증완료(D 잇기 3): 칸 넣기/지우기 · 이동(F5 · Ctrl+G) · 틀 고정 · 데이터 도구 3 · 묶기/풀기(Shift+Alt+→/←) · 단축키 표 「안됨」(Ctrl+B/I/U/5 · 바깥 테두리 · Ctrl+; · Ctrl+9/0 · Ctrl+PageDown · Shift+F11 · F4) · 이름 풀이 · 자동 완성 105 · 숨긴 시트 · 탭 색 · 필터 행 숨김 · M02 ORCA 수치 · 크기 시험 통과(화면 27.0 KB · 합 60.9 KB) · 조건부 서식 색 · 초록 세모(sub6) 안 옴 +- 검증완료(D 잇기 3 끝): sub6 조건부 서식 칸 색 · 초록 세모 · 필터 행 숨김 M02 ORCA 수치 · 골라 붙여넣기 늦게 불러 크기 시험 통과(화면 27.6 KB · 합 61.5 KB) +- 검증완료(D 식 오류 막음): 편집기 확정 때 parseFormula 실패면 편집 유지 · 오류 알림 · 커서 오류 자리 · M02 ORCA =SUM() 막힘 · =SUM(1,2) → 3 · 크기 시험 통과 +- 검증완료(D 크기 줄임): sub2 · sub8 병합 뒤 화면 30,126 B → 28,102 B(예산 28,672) — 함수 도움말 · 이름 상자/관리 · 서식 붓 · 메모 편집 · 열 너비 맞춤을 처음 쓸 때 불러옴 · M02 ORCA 산출근거 카드 · 편집 화면 · 도구 모음 기본 글꼴(굴림/10 흐리게) 확인 +- 검증완료(B): 7725b39e · 함수 48 · 숫자 형식 해석 · 채우기 · 시험 69 +- A 더함: 뺀 셋(행 넣기 서식 따라감 · 자동 교차 · Sheet.기본) · Sheet.comments · 무게 시험 4 통과(sub3) +- 검증완료(E): bfd71238 · 도구 · 탭 · 우클릭 · 클립보드(안↔안 · 구글 R1C1 · 엑셀 수식 표시) · 가짜 A · B 로 ORCA · 실제 엑셀 앱 시험은 없음(앱 없음) +- 검증완료(D): 48a21351 · 한글 첫 글자 · 수식 입력줄 · 참조 가리키기 · 방향키 · 채우기 끌기 · 되돌리기 · 시험 7 · 가짜 부품으로 · 편집 좌표 = 판 안 기준(브레인 판정) +- 검증완료(F): 85fa62ae · M02 카드 · 임시 단추(inspect) · 금빛 시험 실무 수식 2,693칸 대조 갈림 0 · B 뒷일 b93e03e5(moveFormula 잇기 · 실무 숫자 형식 66 대조 · 흠 4 고침 · 분수 형식 없음) +- E 뒷일 fddaa4ed · 진짜 엔진으로 다시 · 엑셀 · 구글 HTML 견본 붙여넣기 시험 · 실제 앱 복사는 못 함(엑셀 없음 · 구글 창 포커스) · 2단계 메모 0d2d5614(sub1) +- 검증완료(C): 7d9fc6ee · 격자 4판 · 병합 · 틀 고정 · 숨김 · 폭 끌기 · hair 선 · 1만 행 스크롤 칸 수 일정 · data-addr · 메모 세모 +- 잇기 ce3df392(sub7) · M02 시험 화면에서 한글 · 재계산 · 되돌리기 · 채우기 · 서식 · 값만 붙여넣기 · 바꾸기 · 메모 · 서식 붓 · 폭 맞춤 · 정렬 · 필터(sub3) · 브레인 판정: 크기 예산 엔진 32 KB · 화면 28 KB 그대로(공용 글자표 뺌) +- 3단계 더함: 조건부 서식 · 유효성 · 이름 정의(sub4) · 분수 형식 · 함수 30(d60b1ccc · 시험 135 · sub5) — 잇기는 sub7 +- 셀 서식 창 Ctrl+1 여섯 탭 · 단축키 표 53(sub1 · e1fc529f) +- 격자 2단계 327226e2(sub6 · 확대 10~400% · 눈금선 · 여러 범위 · 고름 테두리 비낌 고침) · 통계 13 · 날짜 9 · 날짜 형식(sub5 · 시험 180 · EDATE 월말 흠 고침) +- 크기: 시험 단추 먼저 눌러도 63.3 KB · 213 KB 조각 안 받음(sub2 · 4e7f2e2c) · 엔진 31.4 · 화면 27.1 · css 3.9 = 62.5 / 64 KB +- 흠 고침: 이름 풀이 610ac856(sub3) · 자동 완성 105 함수(sub5) · 숨긴 시트 · 탭 색 ddfff14f(sub8) · 데이터 도구 · 칸 삽입 창 · 이동 · 틀 고정 메뉴 de94486a(sub1) +- [x] 2단계(인쇄 · 내보내기 뺌) — 찾기 · 바꾸기 · 메모 · 골라 붙여넣기 · 서식 붓 · 폭 자동 맞춤 · 여러 범위 · 확대 · 눈금선 끄기 · 함수 자동 완성 · 도움말(sub1) +- [x] 브레인 브라우저 검증 — [스프레드시트 시험] 단추 · 전수 목록 + 예외 입력 → 임시 단추 지움 +- 검증완료(브레인 · 메인 5173 · 9-27 23시): 점검 39 + 예외 12 · 1차 흠 10 → 고침(상태 표시줄 글색 · 확대 % · 먼 칸 스크롤 · 자동 맞춤 255자 · ##### · 틀 풀기 · 엑셀 서식 붙여넣기 · 병합 일부 막기 · 인자 수 막기 · Enter 막기) · 2차 통과 · 남은 작은 것: 도구 모음 글꼴 칸이 기본 글꼴일 때 빔 · 임시 [스프레드시트 시험] 단추는 사용자 확인 뒤 지움 +- [x] 사용자 지시(9-27 밤): 임시 시험 단추 지움 · 산출근거 카드 빈 격자 늘 보임 · 도면 정보 미리보기 카드 안에 맞춤(sub2) · 도구 모음 기본 글꼴 표시(sub8) · 뒤에 전역 깃 싱크(각 창 폴더까지) · 엑셀 실제 복사는 사용자가 확인 +- [x] 사용자 지시: 산출근거 카드 행 늘려 높이 키움 · 좌측 「산출근거」 컨테이너(구조물 도면과 같게 · 열마다 · 표번 표-01 자동) · 수정은 별도 편집 컨테이너(좌측 줄 · 카드 [산출근거 수정])(sub2) +- 검증완료(9-28): 임시 단추 지움 · 빈 격자 30행 · 도면 정보 넘침 · 표번 · 좌측 산출근거 · 전체 편집 격자 높이(e4eef2a4) · 기본 글꼴 흐리게(cd7a81a4) · 크기 62.6 KB(87417b3a) · 전역 깃 싱크 = 랩탑 메인 + sub1~8 트리 53bbad2e 같음 · 데스크탑 넷은 원격만 옮김(PC 꺼짐 · 켜면 받기) + +## 12. M02 구조물 번호 체계 · 집계표 줄 · 스프레드시트 다듬기 (담당 sub4 · sub2 · sub5 · sub6 · sub7 · sub8 · sub1) + +- 사용자 확정: 도번 SD · 표번 SC + 숫자 6자리 [종류 2][분류1 2][분류2 2] · 없는 층 00 · 한 번 준 번호 유지(새 열은 그 무리 다음 번호 · 지운 번호 비움) · 조회 후 선택 = 다른 열 것을 복사해 가져옴(열마다 따로 동작 · 같이 쓰기 없음) +- 사용자 확정: 집계표 제목행 = 종류 · 분류1 · 분류2(글 자유) · 같은 종류에 분류1 더하기 · 마스터 줄 = 일위대가 · 도면 · 산출근거(조회 후 선택) · 열 더함 = 도번 · 표번 · 파일 자동 · 열 지움 = 같이 지움 · 빈 산출근거 = 진짜 빈 시트 · 산출근거에 시트 탭 없음 +- 브레인 임의 판정: 구조물 = 꼴=수 열만 · NO · 측점 · 비고 같은 글 열은 번호 · 파일 없음 +- [x] 서버 번호 · 자동 만들기/지우기 · 옛 50 파일 새 번호로 옮김 · 빈 산출근거 · 복사 가져오기 길 · 좌측 「번호 + 구조물 이름 + 변수값」(sub4 · 3a4b3066 · fc249c00 · 2623633b · 시험 29 · SD010101~SD360000 · 46+46) +- [x] 집계표 제목행 이름 · 분류1 더하기 · 산출근거 줄 · 세 줄 조회 후 선택(sub2) +- 검증완료(12 sub2): 시험 34 통과 · typecheck · ORCA 5177 층 이름 종류 · 분류1 · 분류2(두 번 눌러 고침) · 「분류1 더하기」 = 같은 종류 끝에 [종류, 새 분류, 새 열] · 산출근거 줄 SC 번호 · 글 열(NO · 측점 · 비고) 세 줄 빈칸 · 잠김 · 일위대가 목록 37 찾아 고름 · 도면 복사 = 다른 열 45(자기 · 글 열 빠짐) → 덮어쓰기 확인 → 도번 · 열 그대로 내용만 같아짐(파일 되돌림) · 검증 대기 +- [x] 스프레드시트 테마 연동(sub6) · 엑셀 붙여넣기 서식(스타일 묶음 · 선 모양 · 색)(sub8) · 엑셀 이름 형식 Fixed · Standard · Currency 등(sub5) · 산출근거 시트 탭 없앰 · [산출근거 수정] 느림 원인(sub7) · 집계표 항목 고른 뒤 CAD 불러오기 오류(sub1) +- 검증완료(12 스프레드시트 쪽): 테마 ee64781a · 붙여넣기 서식 b2e2fcff · 이름 형식 4bbef4ac(₩ 는 재현 안 됨 · 실제 복사 내용 필요) · 시트 탭 없앰 · 편집 화면 0.08초(느림 재현 안 됨 · 옛 높이 0 흠 추정) · CAD 오류 22835ca9 +- 검증완료(sub7 산출근거 탭 · 느림): createSpreadsheet tabs:false — 카드 · 전체 편집 탭 줄 없음 · 시트 단축키 · 시트 메뉴 꺼짐 · [산출근거 수정] 5182 실측 75~83 ms(격자 만들기 ~50 · 서버 읽기 19~24 · 새 조각 0) · 찬 서버 카드 0.2 s — 느림 재현 안 됨 +- 조사(코덱스 · 9-28): 오토캐드 Ctrl+C → 웹 Ctrl+V 는 브라우저만으론 안 됨(전용 형식 · 임시 DWG 경로를 브라우저가 못 읽음) · 추천 = 오토캐드 .NET 전용 명령이 고른 개체를 DXF 글로 클립보드에 → 웹이 글 붙여넣기 → 기존 DXF 변환기(B07_DesignDetail_Engine_Frame_Import.py) 재사용 · 해치 · 치수 의미는 일부 잃음 — 착수는 사용자 판단 +- 검증완료(12 · 브레인 · 메인 5173): 좌측 SD 47 · SC 47 줄 번호 맨 앞 · 옛 구-/표- 0 · 집계표 제목 종류 · 분류1 · 분류2 · 일위대가 · 도면 · 산출근거 줄 · 사용자 열 c1 = SD010102 로 살림(충돌 dbda85e8) + +## 13. 집계표 분류3 · 면적산출 · 재료산출 · 알약 연결 (담당 sub3 조사 · sub4 서버 · sub2 집계표 · sub7 알약 · sub8 단위) + +- 사용자 확정: 제목행 = 종류 · 분류1 · 분류2 · 분류3 · 번호 8자리 [종류][분류1][분류2][분류3] · 식 · 설명 줄 지움(식 3열 계산도 지움) · 산출근거 → 면적산출(SA) · 재료산출(SM) 줄 더함 · 도면 SD · 열마다 따로 엑셀 +- 사용자 확정: 면적산출 엑셀 위 알약 = 그 열 일위대가의 입력값 · 칸을 골라 알약에 연결하면 알약 흐려짐(연결 현황) · 재료산출 알약 = 자재DB 에서 골라 칸 연결 · 엑셀에 단위 기호 고르기 +- 사용자 알림: 분류2 더하기 안 됨 → 고침 · 우클릭 「열 더하기」 → 「분류2 더하기」 · 「분류3 더하기」 새로(sub2) +- [x] 일위대가 입력값 목록 · 자재DB 목록이 어디서 오는지 조사(sub3 · 13_알약자료.md) · 브레인 판정: 면적 알약 = 물량(결과단위 분모)만 · 같은 이름 · 단위 합침 · 설계 조건 빼고 +- [x] 서버 — 8자리 · SA 로 옮김 · SM 새로 · 자동 만들기/지우기 · 복사 · 좌측 「면적산출」 · 「재료산출」(sub4) / 집계표 — 분류3 층 · 식 · 설명 줄 지움 · 면적산출 · 재료산출 줄 · 카드 · 편집 화면(sub2) + - 검증완료(sub2 집계표 몫) — 분류1 · 2 · 3 더하기 · 4층 · 줄 넷 · 카드 셋 · 복사 가져오기 · 알약 연결 · 풀기 · 저장 · 다시 열기(ORCA 5177) · 남음: 식 지움으로 test_m02_table_fill 둘 깨짐(sub4 서버 채우기) +- [x] 스프레드시트 알약 막대(sub7 · 연결 = Sheet.연결) · 단위 기호 고르기(sub8 · 74b6937b) · 알약 목록 fetchComboInputs · searchMaterials(sub3 · 32d73aa2 · ba91e9e6) +- 검증완료(sub7 알약): createSpreadsheet({links: {항목, 모드, 자재찾기}}) · 계약 Sheet.연결(시트에 둠 — 되돌림이 시트통째 한 길) · 시험 틀 harness_links 로 ORCA — 연결 · 흐림 · 주소 · 칸 세모 · 이동 · 우클릭 풀기 · 되돌리기/다시 · 행 넣기 밀림 · 열 지우기 빠짐 · 자재 찾기 · 고르기 · 연결 · getLinkedValues(식 칸 37.5) · 크기 화면 28,518 B +- 검증완료(13 · 브레인 · 메인 5173 · 사용자처럼): 좌측 SD · SA · SM 각 47 · 8자리 번호 맨 앞 · 제목 4층 · 줄 = 일위대가 · 도면 · 면적산출 · 재료산출 · 분류2/3/1 더하기 → SD02010600 · 02010601 · 02020000 + 파일 셋 · 열 지움 → 파일 셋 지움 · 면적 알약 10(UA000004) 연결 · 저장 · 다시 열기 · 풀기 · 재료 자재 찾기 · 연결 · 서버 계산값 · 단위 기호 · 테마 · 시트 탭 없음 · 복사 가져오기 · CAD 오류 0 · 전역 싱크 트리 2b125933(데스크탑 메인만 엑셀 잠금 파일로 대기) +- 검증완료(9-28 오후 · 브레인): ₩ 원인 = 엑셀 형식 \; 잘못 잘라 끝 역슬래시 → 굴림이 ₩ 로 그림 · 고침(e0f9df9b · 41a717f6 · c5ad429d) · 사용자 재료산출 표 ₩ 0칸 · 사용자 실제 복사본(87390278)엔 수식 없음 확인 · 크기 한도 128 KB · WEBCLIP(AutoLISP · 웹 Ctrl+V DXF · 흐린 안내) 브레인 붙여넣기 1→3 · 도면층 생김 · 되돌림 · 전역 싱크 트리 92ad16a8(랩탑 9 · 데스크탑 메인 · 보조 · 코덱스) +- [x] 사용자 확정: 엑셀 · 오토캐드에 파일 설치 안 함 → 엑셀 = 수식 복사 안내(흐린 글 · 알림)(sub8) · 오토캐드 = WBLOCK / DXFOUT 파일 끌어다 놓기 · LISP 지움 · 안내 바꿈(sub1) +- [x] 사용자 지시: 분류 없는 열도 제목 4칸(빈값 = 선택 없음) · 면적산출 · 재료산출 카드 2열 · 도면 정보 안 열림 원인(sub2) +- 검증완료(9-28 14:55 · 브레인): 분류 없는 열 제목 4칸(소단 rowspan 1) · 카드 = 도면 정보 한 줄 + 면적 · 재료 2열 · c4 · pv_b · sg 도면 정보 CAD 뜸 · CAD 편집 오른쪽 위 「오토캐드에서 WBLOCK(또는 DXFOUT)으로 저장 → 파일을 여기로 끌어다 놓기」 · 사용자 열 c2~c5 · rv 면적산출 표 살림(충돌 ec8812e8) · 전역 싱크 트리 eeb12002(랩탑 9 · 데스크탑 메인 · 보조 · 코덱스) +- [x] 시험이 실제 마스터 양식을 읽어 사용자 편집(rv 제목 바꿈)에 깨짐(test_m02_structure 5 · test_spreadsheet_store 1) — 시험을 견본 양식으로 떼기(sub4) + +## 14. CAD · 스프레드시트 다듬기 · 구조물 조합 표 (담당 sub4 · sub1 · sub6) + +- 사용자 확정: 집계표 단위 줄 정의 보류 · 일위대가 입력값 = 엑셀 칸 연결(이미 됨) · 마스터 집계표 = 프로젝트 복사 때 쓸 기본 정보 · 로직 +- [x] 미리보기 CAD 특성 · 도면층 패널 숨김 원복 → 처음 접힘(panelsCollapsed) · 리본 펼친 뒤 밖을 누르면 접힘(sub4) +- [x] CAD 어두운 테마 자동색(7 · 검정 → 흰) · 바탕 · 격자 · 글 점검(sub1) / 스프레드시트 자동색(검정 글 · 선 · 흰 채움 → 테마)(sub6) +- [x] 사용자 확정: 「구조물집계표 조합」 = 집계표 열마다 한 줄(프론트엔드 그대로 · 서버가 집계표 저장 때 맞춤) · 브레인 판정: 키 SS000001~ · 파일 resources/master_template/구조물집계표조합.json · 구조물 열만(sub4 · 앞 일 뒤 이어서) +- [x] 좌측 패널 손잡이 본문과 안 겹치게(sub3) · CAD 가운데 단추 팬 때 페이지 안 움직임 · 미리보기 끌기 = 화면 옮기기만 · 안내 글 숨김(sub1) +- [x] 사용자 지시: [새로] · [본떠 만들기] · [지우기] 를 표 양식 · 도면 양식 컨테이너 안으로 · 본떠 만들기 기능 재확인(sub4 · 흠 셋 고침) +- [x] 사용자 지시: 구조물 도면(카드 · 편집)에서 작도 영역 없앰 · 도면 양식은 그대로(sub1) +- 검증완료(9-28 17:2x · 브레인 · 사용자 주소): 좌측 단추 = 표 · 도면 양식 컨테이너 안(밖 0) · 구조물 카드 작도 영역 없음 · CAD 뜸 · 카드 높이 561 셋 같음 · 메인 스크롤 없음(1024/1024) · 0,0 기준점은 sub1 시험 5 통과(화면 캡처는 ORCA 실패 — 사용자 눈 확인 거리) · 전역 싱크 트리 6153a6a7 + +## 15. 조합 · 자재 목록 고치기 · 소형 단추 · 머리 글 (담당 sub2 · sub3 검토) + +- 사용자 확정: 같은 조합을 여러 열이 쓸 수도 · 안 쓸 수도 · 갈라 낸 조합을 또 고쳐도 매번 새로 · 새 이름 = 자동 + 모달에서 고침 · 키는 자동(UA · UM + 번호 · 본뜬키 칸으로 계보) · 새 조합은 M01 목록에도 보임 +- 사용자 확정: 자재 목록 = 마스터 표 「자재 조합」(resources/master_data/자재조합.json · UM) · 고치면 일위대가처럼 모달 후 갈라 냄 · 분류 더하기로 만든 열은 일위대가 · 자재조합 · 도면 · 면적 · 재료가 원본을 따라감(뒤 고침은 따로) · 연결된 알약을 누르면 칸 하이라이트 +- 사용자 요구: 면적산출에서 일위대가 조합(담은 로직) 고치기 · 재료산출에 자재 목록 표(더하기 · 빼기) · 고치면 모달로 「새 조합을 만듦」 알림 후 새로 만듦(자재 목록도) — 검토 먼저 +- [x] 검토 — C:/Aislo_wt/sub_laptop_3/tmp/M02_분석/14_조합편집검토.md(sub3) → 사용자 판단 대기 +- [x] 공용 소형 단추 size sm · M02 카드 · 좌측 컨테이너 · M01 표 안 단추 적용 · 상세 화면 번호 + 이름 잘림 없앰(sub2) — 검증완료(ff0d9775 · 검증 대기) +- [x] M01: 알약 키 이름|단위 · 본뜬키 칸 · 자재 조합 표(UM) · 화면 · 지우기 전 쓰는 열 검사(sub3) / M02 서버: branchCombo · branchMaterialCombo · 분류 더하기 따라가기(sub4) / 알약 막대: 자재 항목 호스트 콜백 · 연결 칸 하이라이트(sub7) / 화면: 조합 줄 · [조합 고치기] · 자재 목록 옆 판 · 모달(sub2) — 검증완료(sub3 b6e71c72 · 4c8ee682 · sub4 e76e6380 · 본뜬키 충돌 풂 625c1012 · sub2 e06d43a6 · 검증 대기) +- [x] 스프레드시트 메뉴 어두운 테마 검정 글 — 전수 대비 측정 · 테마 토큰(sub6) — 검증완료(2d70be32 · 브레인 재측정 대비 3 미만 0건 · 검증 대기) +- 검증완료(15 · 브레인 · 5177 IP · 사용자처럼): 조합 고치기 모달 [취소] 무변경 · [새로 만듦] UA000038(본뜬 UA000009 · 이 열만 · 옆 열 그대로) · 같은 내용 되돌림 UA000009 재사용 · 자재 조합 UM000001 → 두 번째 고침 UM000002(본뜬 UM000001) → 되돌림 재사용 · 이은 자재 빼기 확인창 · 저장 · 다시 열기 · 분류 더하기 c6 = UA · UM · SD/SA/SM 복사(연결 B3 포함) · 열 지우기 파일 셋 삭제 · SS 번호 재사용 없음 · M01 본뜬키 열 · 자재 조합 목록 · 쓰는 조합 지우기 409 · 알약 반짝임 칸 일치(B3 · O6) · 어두운 테마 메뉴 0건 · 흠 넷 → 아래 +- [x] 15장 검증 흠: 옛 알약 연결 키(c4 면적산출 3개) 이름|단위로 맞춤 · 옆 판 이은 칸 즉시 갱신 · 새 조합 이름 겹침 · 첫 자재 조합 모달 글(sub2) — 검증완료(fb5aee7e · 브레인 재검증: 메인 c4 알약 10 · 연결 3 제 자리 · 따로 붙음 0 · 이음 · 풂 즉시 · 이름 「돌붙임 찰붙임」 · 첫 모달 「이 열에 새 자재 조합을 만듦」 · 전역 트리 fc58e8a1 · 검증 대기) +- [x] 진짜 마우스 검증에서 사진으로만 보인 흠: 공용 창(ui-modal) 뒤 막 투명 · 그림자 없음(확인 창처럼 어둡게) · 표시 형식 칸 흐린 글(sub2) — 검증완료(527e68db · 브레인 진짜 마우스 사진: 겹친 창 밝은 · 어두운 테마 또렷 · 흐린 글 「표시 형식」 · 전역 트리 d22732b6 · 검증 대기) + +## 16. 전체 점검 · 개선 · 최종 검증 (M02 마스터 템플릿 + M01 조합 · 자재 조합) + +- 사용자 지시: 전체를 다 개선하고 브레인이 최종 검증. +- 방식: 영역마다 전수 목록(기능 + 예외: 빈 값 · 같은 이름 · 긴 이름 · 취소 · 저장 안 하고 떠나기 · 다시 열기 · 쓰는 것 지우기 · 밝은/어두운 테마 · IP 주소) → 진짜 마우스 · 사진 판정 → 제 몫 흠은 고치고 목록 전체 재실행 · 남의 몫은 보고 → 사용자 열 저장 안 함 · 만든 것 되돌림. +- 사용자 조건: M01 로직(단가산출 로직 · 일위대가 계산 · 조합의 뜻)은 바꾸지 않음 — M01 은 화면 흠만 · 로직 흠은 보고 +- [x] 구조물집계표 · 좌측 패널 새로 · 본떠 만들기 · 지우기 · 번호(sub4) — 검증완료(6606ef16 쓰는 양식 지우기 막음 · 16de9eed 지운 열 번호 재사용 막음 · 이름 창 커서 · Enter · 바인딩 열 손 입력 끔 · f8dda99f 도움말 대비 6.3 · 검증 대기) +- [x] 쓰는 양식 지우기 — 확인 창 전에 바로 거절 알림(sub5) — 검증완료(214c55a3 · 방위표 · 구조물집계표 알림만 · 안 쓰는 양식은 확인 창 뒤 지움 · 검증 대기) · 자재 조합 지우기도 같게(sub7 몫) +- [x] 구조물 도면 · 도면 양식 CAD(sub1) — 목록 28 · 흠 넷 고침: 값 없는 테마가 호스트(밝음)와 어긋나 CAD 만 어두움 · 첫 표시 뒤 iframe 이 커져 맞춤 어긋남(빈 도면은 기준점이 귀퉁이) · 도면 양식 📂 불러온 뒤 떠날 때 안 물음 · 작도 영역 빈 칸 안내 글 — 검증완료(IP · localhost · 밝음 · 어두움 전 목록 재실행 · 검증 대기) +- [x] 좌측 목록 두 줄 동시 선택 표시(sub5) — 검증완료(990e8e67 · 무엇으로 열든 연 직후 좌측 표시를 한 곳에서 다시 그림 · 17걸음 선택 줄 1 · 검증 대기) +- [x] 면적산출 · 재료산출 화면 · 조합 고치기 · 알약 · 자재 옆 판(sub2) — 검증완료(841af0a0 · 목록 50 · 흠 8 고침 · 검증 대기) +- [x] 조합 고치기 차례 · 메모도 내용으로(바뀌면 새 조합) · 거절 이유 글 그대로(sub2) — 검증완료(e4e2dc2a · 60246df6 떠나기 [취소] 초점 · 검증 대기) +- [x] 도구 막대 ✕ · 안내 · 확대 글 대비(sub6 — 검증완료 7389335c · 밝은 5.88 · 어두운 7.91 · 검증 대기) · 공용 확인 창 닫힘 뒤 초점 되돌림(sub2) — 로직 고르기 css 자체 로드는 sub3 검증완료 · 공용 확인 창 초점 검증완료(ee0475d0) + - [x] sub2 몫 — 같은 내용 = 차례 · 메모까지(자재는 차례) · 되돌리면 원래 키 · 거절 까닭 알림에 그대로 · 자동 이름 끝 (n) 뗌 — 검증완료(IP 차례만 · 메모만 · 되돌림 UA000009 · 422 셋 · 검증 대기) + - [x] 떠나기 확인 창 [취소] 뒤 초점을 시트 · 집계표 · 도면으로 되돌림(sub2 · sub8 발견) — 검증완료(IP 면적산출 시트 H13 · 집계표 · 화살표 이동 · 검증 대기) + - [x] 공용 확인 창 — 열 때 초점 기억 · 취소 · 확인 · Esc 로 닫히면 되돌림 · 열리면 [취소]에 초점(sub2) — 검증완료(IP M02 조합 고치기 · 떠나기 · M01 조합 뒤로 · B01 새 프로젝트 · Esc 는 확인 창만 닫음 · 검증 대기) + - [x] 조합 적용 알림 — 새로 만듦 / 같은 내용의 있는 조합 씀을 키 · 이름과 함께 · 첫 안내에 「같은 내용이 있으면 그것을 씀」(sub2) — 검증완료(IP 자재 새로 UM000001 · 다른 열 같은 내용 다시 씀 · 일위대가 되돌림 UA000009 · 검증 대기) +- [x] M01 일위대가 조합 · 자재 조합 화면(sub3) — 검증완료(ae47bcd9 · 화면 파일만 · 찾기 초점 · Enter · 떠나기 확인 · 본뜬키 칸 · 표 폭 · 금액 오른쪽 · 알림 글 · 밝은 테마 흐린 글 · 이름 60자 · 지우기 창에 키 · 이름 · 남김: 판 충돌 파일 단위 · 좌측 머리 접힘은 M01 규칙이라 그대로 · 검증 대기) +- [x] 공용 입력칸 · 고르기칸 높이 맞춤(sub3) — 검증완료(M01 미리 보기 32쌍 30px · 로직 계산 30 · 서식 창 28 · B01 날짜 42 · 밝은 · 어두운 같음 · 검증 대기) +- [x] 스프레드시트 도구 막대 · 메뉴 · 대화 창(sub6) — 검증완료(7389335c · 목록 80 · 밝은/어두운 80/80 · 검증 대기) +- [x] 스프레드시트 칸 다루기 · 식 · 복사 · 채우기 · 되돌리기(sub8) — 검증완료(52d441e3 · 흠 11 · 고름 테두리 · 채우기 손잡이 · 틀 고정 빈 띠 · 입력 형식 · 잘라 붙이기 · 어두운 테마 오류 글 · 저장본 102권 서버 재계산 같음 시험 · 검증 대기) +- [x] 긴 글 옆 빈 칸으로 넘쳐 보이기(sub8) — 검증완료(cd269045 · 수치 17 · 남김: 왼쪽 밖으로 스크롤된 칸의 넘침은 안 보임) · 떠나기 창 [취소] 뒤 초점(sub2 60246df6) +- [x] 데이터 메뉴 자리 · 머리 우클릭 잘라내기 · 붙여넣기(sub6) — 검증완료(7389335c · 검증 대기) +- [x] 사용자 지시: 자재 조합 = 마스터 템플릿 표(resources/master_template/자재조합.json · 번호는 표 안 「다음」) · M02 좌측 「자재 조합」 컨테이너(목록 · 상세 · 새로 · 본떠 만들기 · 지우기 · 쓰는 열 막기) · M01 화면 · 길에서 뺌 · 일위대가 조합은 그대로(sub7) — 검증완료(sub7 · 새로 · 이름 · 자재 빼기 · 본떠 만들기 · 찾기 · 쓰는 조합 바로 거절 · 옆 판 갈라 낸 UM 이 목록에 · 담는 자재는 자재 마스터 코드만 · 프로젝트는 복사 안 하고 마스터 키를 가리킴 · 검증 대기) +- [x] 최종 검증 흠: 자재 조합 새로 · 본떠 만들기 이름 칸 초점(sub7) · 첫 자재 조합 안내 「같은 내용이면 있는 조합」 · 새로 만듦/다시 씀 알림(sub2) · 집계표 F2 커서 끝(sub5) +- [x] 700줄 넘는 파일 나누기: spreadsheet_grid.ts 1052(sub8) · check_master.py 704(sub1) · 어두운 테마 기본 부품 color-scheme(sub3) +- [x] 전역 싱크 → 브레인 최종 검증(전체 목록 · 진짜 마우스 · 사진) → 보고 — 검증완료(12곳 트리 bedfcf83 · CAD · 스프레드시트 빌드 두 PC · 영역 9 통과 · https://claude.ai/artifact/FmWV9uoQiFBpDjQfY2FvPo) + +## 17. 구조물 집계 양식 한 묶음 · 좌측 패널 재구성 (담당 sub2 · sub4) + +- 확정: 구조물집계표 열(SS 키) = 일위대가(UA) · 자재조합(UM) · 도면(SD) · 면적산출(SA) · 재료산출(SM) 한 벌 — 구조물집계표조합.json 줄마다 다섯 칸 · 자재 조합 = 재료산출 알약에 쓴 자재 목록(master_template/자재조합.json). +- [x] 집계표 도구 막대에서 「손 입력 열」 · 「줄 더하기」 · 「줄 지우기」 단추 삭제(데이터 규칙은 그대로)(sub4) +- [x] 좌측: 「표 양식」 → 「구조물 집계 양식」 이름 바꿈 · 새로 · 본떠 만들기 · 지우기 단추 삭제 · 그 아래에 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 컨테이너를 하위로 넣음(도면 양식은 그대로 별도)(sub2) — 검증완료(IP 집계표 줄 하나 · 하위 넷 들여 8px · 같이 접힘 · 밝음 · 어두움 잘림 · 대비 흠 없음 · 검증 대기) +- [x] M02 첫 화면 = 구조물집계표(왼쪽 고르라는 빈 화면 대신)(sub2) — 검증완료(들어오면 집계표 · 좌측 고름 집계표 줄 · 자재 조합 지운 뒤도 집계표 · 검증 대기) +- [x] 카드 [수정]으로 연 상세(도면 · 면적산출 · 재료산출)의 왼쪽 위 제목 라벨 오른쪽에 [돌아가기] — 누르면 구조물집계표로 돌아가고 그 열이 고른 채(저장 안 한 고침이면 확인 창)(sub4) +- [x] 면적산출 · 재료산출 편집 화면의 알약 막대를 시트 아래(하단)로 옮김(sub7) +- [x] 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널(B05 배수유역 패널처럼 끌어서 폭 조절) — 면적산출은 일위대가 조합 목록 · 재료산출은 자재 조합 목록 · 이 열의 조합 바꾸기(고르기) · 조합 고치기(갈라 내기) 를 패널 안에서(sub2) — 검증완료(공용 사이드바 카드 · 접기 · 폭 손잡이 · 접기 손잡이 그대로 · 폭 384→503 · 최소 280 · 최대 60% · 새로고침 뒤 폭 기억 · 겹침 0 · 페이지 스크롤 0 · h_rip_c UA000009→UA000011→UA000009 · 자재 UM000001 · 이름|단위 맞는 이은 칸 유지 · 밝음 · 어두움 대비 흠 없음 · 검증 대기) +- 검증완료(17 · 브레인 · 메인 5173 · 진짜 마우스 · 사진 v1 · v2): 첫 화면 집계표 · 좌측 구조물 집계 양식 하위 넷 · 도구 막대 단추 넷만 · 상세 [돌아가기] → 집계표 h_rip_c 고른 채 카드 · 알약 막대 시트 아래(y 988 · 스크롤 0) · 오른쪽 패널(이 열의 조합 · 조합 목록 · 손잡이 · 폭 384) · 흠 1: [이 열에 쓰기] 단추가 목록 끝 글과 겹침 → sub2 — 고침 071676e0(고른 줄 아래 · 겹침 0) + +## 18. 계획노선(B04 평면도) 다듬기 (담당 dmain · dsub) + +- [x] 종단 기울기 라벨 — 줌인해 측점 하나가 보이는 크기부터 측점과 측점 사이에 기울기(%) 라벨(dmain) — 검증완료(자리 = B05 계획노선 편집 창 · 지반고 기울기 · 측점 간격 90px 이상에서만 · 6.12px/m 에서 화면 안 11개 · 30° 회전에서 12개 · 글 바로 섬 · 2.66px/m 에서 0개 · 검증 대기) +- [x] 등고선 1m 간격 보이기 토글 단추 · 5m 간격 선은 색을 조금 짙게(dmain) — 검증완료(「1m 등고선」 단추 기본 꺼짐 · LAS 없으면 잠김 · 켬 117,489px 바뀜 · 끔 0px 차이 · 5m 선 #818cf8 · 1m 선 0.5px 옅게 · 5m 선이 솎이지 않는 배율에서만 · 검증 대기) +- [x] 계획노선 회전 값 저장 — 다시 들어오면 보던 화면(회전 · 위치 · 줌)으로(dmain) — 검증완료(화면 취향 `routeedit-view` 프로젝트 칸 · 계정에도 올라감 · 다시 열기 가운데 · 6.12px/m 그대로 · 30° 그대로 · 창 크기 달라도 같은 자리 시험 · 검증 대기) +- [x] 거리 · 기울기 측정을 예정 노선에서도(dsub) — 검증완료(자리 = B05 계획노선 편집 창 · 데스크탑_보조 · 예상노선 28+11.4→29+19.7 28.3m · 15.2% · 초록 구간이 점선을 따라감 · 계획노선 23+12.6→29+11.0 118.5m · −5.0% · 검증 대기) +- [x] 횡단도 보기 선택 표시(노선 위 작은 선)에 라벨 추가(dsub) — 검증완료(측점 눈금 = 라벨 11/11 · 27/27 · 되꺾인 노선 61/61 · 비켜 놓음 28 · 겹침 0 · 공용 `drawStationTicks` 라 B04 지도 · 배수유역도도 같음 · 검증 대기) +- [x] 횡단도 보기 항목의 좌우 횡단 반폭만큼 평면도에 선 — 노선이 바뀌면 횡단 보기 켜진 동안 실시간으로 따라감(dsub) — 검증완료(반폭 = 사면이 원지반과 만나는 좌·우 · 못 만나면 샘플 반폭 · 측점 틀 파이썬·TS 짝 + 거울 시험 · 서버 샘플 좌표와 차 ≤5e-7m(측점 5곳) · 노드 끄는 중 선 (527,491)→(519,547) · 놓으면 반폭 재계산 · 되돌리기 · 취소 뒤 노선 그대로 · 검증 대기) + +## 19. 종단 저장 정합 — 사용자가 고른 값이 저장 · 다시 열기 뒤 그대로 (담당 sub5 · sub6 · sub8) + +- 사용자 확정: 저장 뒤 다시 들어왔을 때 프론트에서 사용자가 고른 정보가 그대로여야 함 · B05·B06 은 한 페이지처럼 동작. 측점 기준선 통일(계획노선 CSV vs B05 경로)은 따로 대화. +- 읽기 조사 결과: 시설 속성 붙이기(attach · carry_facility_attributes)가 거리 제한 없이 가장 가까운 관에 붙어 한 칸 밀림 · 정수 m 격자 붙이기 · 잔여거리 0.1m 표시값 재읽기로 측점 재저장 · 사용자 배치(manual) 서버 미존중 · 옛 관 이동 예약(culvertmove) 저장 때 적용. +- [x] 시설 속성 붙이기 = 측점이 같은(허용 오차 안) 관에만 · 화면(B05_Profile_UI_Drainage_Facility.ts attach)과 서버(common_util_drainage_pipes.py carry_facility_attributes) 같은 규칙 · 옛 culvertmove 적용 제거(sub5) — 검증완료(sub5 · attach 같은 측점만 · 관 옮기면 속성 따라감 · culvertmove 제거 · 시험 97 · 검증 대기) +- [x] 옵션 · 메모만 바꿀 때 측점 재쓰기 금지(Structures_Fields · Panel_Commit) · 사용자가 놓은 구조물(manual)은 격자 붙이기 · 반올림에서 제외 · 비정규 측점 키 바뀜으로 station_offsets 지워지지 않게(sub6) — 검증완료(cc3a8a71 · aecf04ac · 격자 붙이기는 브레인 판정 (다)로 그대로 · 검증 대기) +- [x] 왕복 시험: 환경 배정 storage 프로젝트에서 B05 · B06 값(관 이동 · 구조물 측점 · 옵션 · 메모 · 계획고 수정)을 바꿔 저장 → 다시 열기 → 파일(pipe_points.json · structures.json · 종단) · 화면값 대조 시험 · sub5 · sub6 push 뒤 돌려 0 차이(sub8) + From 08e493f13228c5cbf0eaecb9080cb65354f7132d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 11:54:51 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(B05=C2=B7B06=C2=B7B07):=20=EC=86=8C?= =?UTF-8?q?=EC=88=98=20=EC=B8=A1=EC=A0=90=20=ED=9A=A1=EB=8B=A8=20=E2=80=94?= =?UTF-8?q?=20=EA=B4=80=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A7=88=EB=8B=A4=20=EB=94=B0=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=86=8C=EC=88=98=202=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=C2=B7=20=ED=99=94=EB=A9=B4=20=ED=91=9C=EA=B8=B0=EB=A7=8C=20?= =?UTF-8?q?=EC=A0=95=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 20장 소수 측점 횡단(sub6). 사용자 확정: 관 · 구조물이 놓인 소수 측점(예 119.8)에 그 자리 횡단을 따로 둠 · 백엔드 · 파일 · 저장값은 소수 그대로 · 화면 표기만 「측점 + 거리」의 거리를 정수로(5+20). - 이름 규칙 한 벌(B05_Profile_Engine_Sections): cross_key · cross_filename · parse_cross_key · CROSS_KEY_PATTERN — 정수 m 는 옛 이름 그대로(cross_00120m) · 소수 측점은 cross_00119.80m. find_cross_file = 읽기용(새 이름이 없으면 옛 정수 m 이름) · prune_stale_cross_files 도 이것으로 가려 옛 프로젝트의 정수 이름 파일을 지우지 않음 - generate_sections: 정수 m 격자 붙임을 풂 — 같은 이름이 되는 0.005m 안 겹침만 하나로 - B06: 찾기 · 패치 입력(Router_Design 둘) · 샘플 넓힘(SampleExtend)이 find_cross_file · DB 설계 찾기 · 병합(get_cross_section_design · merge_…_by_round)은 ROUND(chainage_m)=정수 → 0.005m 안 매칭 - B07: 도면 id · 장 id(cross_{key}m · cross_s{key}m) 정규식 · 측점 키(cross_station — 정수는 int 그대로라 옛 id · 수량표 키 안 바뀜) · 측점은 파일 속 값으로(옛 정수 이름 파일도) · 횡단 파일 찾기 - 화면 표기 한 벌 common_util/common_util_station.ts(stationLabel · stationPartsLabel · 순수 함수) — common_util_svg 가 다시 내보내고 B05 formatStation 을 대신함(B05 목록 · 표시 · 폼 범위 · 계획노선 편집 · 비정규 측점 라벨 · B06 카드 · 종단 · 유토) · 입력 칸 · 저장값은 소수 그대로 - B05_Profile_UI_Structures_Panel 700줄 → 690(순수 도우미 셋을 Panel_Types 로) - M02_Table_Fill 관연장 찾기는 이미 측점 값(정확 먼저 · 0.5m 안)이라 그대로 시험: test_b05_station_roundtrip(표기 5+20 · 옛 정수 이름 찾기 · 안 지움 · 119.8 과 120 따로) · test_b05_extra_stations_supplier(새 규칙) · test_b06_patch_station(119.8 한 건) · test_b05_b06_save_roundtrip(보낸 관 측점마다 소수 이름 횡단 파일 있음) 등 고친 대상 108 통과 · test_b05_station_merge 통과 · 타입검사 통과. ORCA(IP 5181 · 사본 프로젝트 sub6_검증 — 끝나고 DB 행 · 폴더 지움 · 원본 안 씀): 노출형 횡단수로를 5+19.8 에 놓고 저장 → structures.json 119.8 · 종단 측점 119.8(irregular) + 120 · 파일 cross_00119.80m.json 과 cross_00120m.json · B05 목록 「5+20」 · B06 카드 「5+20 · 119.8m」 열림. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg --- B05_Profile/B05_Profile_Engine_Sections.py | 35 ++++++++++++++++- .../B05_Profile_Engine_Sections_Core.py | 29 ++++---------- .../B05_Profile_UI_IrregularStations.ts | 5 ++- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 2 +- .../B05_Profile_UI_RouteEdit_Measure.ts | 2 +- .../B05_Profile_UI_RouteEdit_Render.ts | 2 +- B05_Profile/B05_Profile_UI_Structures_List.ts | 2 +- .../B05_Profile_UI_Structures_Marks.ts | 2 +- .../B05_Profile_UI_Structures_Panel.ts | 37 +++++------------- .../B05_Profile_UI_Structures_Panel_Types.ts | 39 ++++++++++++++++++- B05_Profile/B05_Profile_Util_Station.ts | 9 +---- .../B06_Section_Engine_SampleExtend.py | 4 +- B06_Section/B06_Section_Repository.py | 20 +++++----- B06_Section/B06_Section_Router_Design.py | 6 +-- .../B07_DesignDetail_Engine_Cad_Sheet.py | 7 ++-- B07_DesignDetail/B07_DesignDetail_Router.py | 35 ++++++++++------- .../B07_DesignDetail_Router_Support.py | 26 +++++++++---- .../B07_DesignDetail_Router_Support_Io.py | 6 +-- common_util/common_util_station.ts | 25 ++++++++++++ common_util/common_util_svg.ts | 13 +------ .../tester/helper_b05_station_roundtrip.cjs | 12 ++++++ .../tester/test_b05_b06_save_roundtrip.py | 6 +++ .../test_b05_extra_stations_supplier.py | 31 ++++++++------- .../tester/test_b05_station_roundtrip.py | 33 ++++++++++++++-- resources/tester/test_b06_patch_station.py | 6 +++ 25 files changed, 254 insertions(+), 140 deletions(-) create mode 100644 common_util/common_util_station.ts diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index d1491a4b..f804f7e1 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -98,9 +98,39 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]: ) +# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ──────────── +#: 이름 속 측점 글 — 정수 m `00120` · 소수 측점 `00119.80` +CROSS_KEY_PATTERN = r"(\d+(?:\.\d{2})?)" + + +def cross_station(chainage_m: float) -> int | float: + """측점 → 이름 열쇠 값 — 정수 m(0.005m 안)면 int(옛 이름 · 키 그대로) · 아니면 소수 2자리.""" + value = round(float(chainage_m), 2) + whole = round(value) + return int(whole) if abs(value - whole) < 0.005 else value + + +def cross_key(chainage_m: float) -> str: + """이름 속 측점 글 — `00120`(정수 · 옛 이름 그대로) · `00119.80`(소수 측점).""" + station = cross_station(chainage_m) + return f"{station:05d}" if isinstance(station, int) else f"{station:08.2f}" + + +def parse_cross_key(text: str) -> int | float: + """`cross_key` 되읽기.""" + return cross_station(float(text)) + + def cross_filename(chainage_m: float) -> str: """측점 chainage에 대응하는 횡단면 파일명(단일 규칙).""" - return f"cross_{int(round(float(chainage_m))):05d}m.json" + return f"cross_{cross_key(chainage_m)}m.json" + + +def find_cross_file(cross_dir: Path, chainage_m: float) -> Path: + """읽기용 — 지금 이름이 없으면 옛 정수 m 이름(소수 측점이 정수로 붙던 옛 프로젝트).""" + path = cross_dir / cross_filename(chainage_m) + legacy = cross_dir / f"cross_{int(round(float(chainage_m))):05d}m.json" + return path if path.exists() or not legacy.exists() else legacy def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]: @@ -109,8 +139,9 @@ def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]: 측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고 빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다). """ + # 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 찾기 규칙(find_cross_file)으로 가려야 안 지워짐 valid = { - cross_filename(station["chainage_m"]) + find_cross_file(cross_dir, station["chainage_m"]).name for station in stations if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float)) } diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index a76490ee..72279681 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -341,34 +341,19 @@ def generate_sections( if 0.0 <= float(chainage) <= total } if extras: - # 횡단 파일명이 정수 미터 반올림이라(cross_filename) 한 정수 미터에 측점은 하나만 둔다. - # 관 · 구조물 자체 측점은 소수 그대로 — 격자 측점과 같은 정수 미터면 격자 대신 그 측점을 - # 쓴다(시 · 종점 격자만 그대로 두고 라벨을 얹음). 비정규끼리 겹치면 앞선 것만 남긴다. - # ponytail: 파일명이 정수 미터인 동안의 가드. 소수 이름 규칙으로 바꾸면 지운다. + # 관 · 구조물 자리마다 그 소수 측점의 횡단을 따로 둔다(2026-09-29 사용자 확정) — 이름은 + # 소수 2자리(cross_00119.80m · cross_filename). 같은 이름이 되는 0.005m 안 겹침만 하나로: + # 격자와 겹치면 격자 측점에 라벨을 얹고, 비정규끼리 겹치면 앞선 것만 남긴다. grid = [round(float(value), 6) for value in station_chainage] - ends = {grid[0], grid[-1]} - buckets = {int(round(value)): value for value in grid} - taken: set[int] = set() - dropped: set[float] = set() snapped: dict[float, str] = {} for chainage in sorted(extras): - bucket = int(round(chainage)) - if bucket in taken: + near = next((value for value in grid if abs(value - chainage) < 0.005), chainage) + if any(abs(near - taken) < 0.005 for taken in snapped): continue - taken.add(bucket) - regular = buckets.get(bucket) - if regular in ends: - snapped[regular] = extras[chainage] - continue - if regular is not None: - dropped.add(regular) - snapped[chainage] = extras[chainage] + snapped[near] = extras[chainage] extras = snapped station_chainage = np.unique( - np.r_[ - np.array([value for value in grid if value not in dropped], dtype=np.float64), - np.array(sorted(extras), dtype=np.float64), - ] + np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)] ) station_xy = _interpolate_xy(points, route_chainage, station_chainage) tangents = np.vstack( diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index e3837b76..798caaf8 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -10,6 +10,7 @@ * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다. * ========================================================================== */ +import { stationPartsLabel } from "../common_util/common_util_station"; import { ESCAPE_ROUTE_DEFAULT_WIDTH_M, ESCAPE_ROUTE_WIDTHS_M, @@ -130,9 +131,9 @@ export function irregularStationId(id: string): string { return `irregular:${id}`; } -/** 측점번호+잔여거리 표기 (예: 3+18.0). */ +/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */ export function irregularLabel(station: IrregularStation): string { - return `${station.station}+${station.remainder.toFixed(1)}`; + return stationPartsLabel(station.station, station.remainder); } function field(labelText: string, input: HTMLElement): HTMLLabelElement { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 2ed105aa..65065cb9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -24,7 +24,7 @@ import { designHalfWidths, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; export interface CrossPreviewParams { projectId: string; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 0d05da59..b7cffb06 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -13,7 +13,7 @@ import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; type Vertex = [number, number]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index c4ed86a3..7e603d50 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -26,7 +26,7 @@ import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame"; import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 노드 반지름(px). */ const NODE_R = 4; diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index ea48714c..a5d63704 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -14,7 +14,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; export interface StructureListParams { /** 목록을 담을
    . 그릴 때마다 통째로 갈아 끼운다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 1c52a483..6b96079a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -20,7 +20,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 7e393dca..d11c095b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -36,13 +36,19 @@ import { loadPipeForm as loadPipeFormInto, } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { formatStation } from "./B05_Profile_Util_Station"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; -import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; +import { + DEFAULT_INTERVAL_LENGTH_M, + GROUP_LABELS, + hasComputedRange, + readBeforeOption, + readLengthOption, +} from "./B05_Profile_UI_Structures_Panel_Types"; import type { PipeFacilityItem, StructuresCallbacks, @@ -89,35 +95,12 @@ export function createStructuresSection( return type.managed_by ? (type.type_id as PipeFacility) : null; } - /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 - * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 - * 준다(2026-08-19 사용자 지시). */ - function hasComputedRange(type: StructureType | null): boolean { - return ( - !!type && - !type.managed_by && - type.placement === "interval" && - type.options.some((option) => option.key === "length_m" && isB05Option(option)) - ); - } - - /** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */ function readLengthM(): number | null { - const entry = optionInputs.find((input) => input.key === "length_m"); - if (!entry || entry.isEmpty()) return null; - const length = Number(entry.read()); - return Number.isFinite(length) && length > 0 ? length : null; + return readLengthOption(optionInputs); } - /** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0 - * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */ function readBeforeM(): number { - const entry = optionInputs.find((input) => input.key === "before_m"); - if (!entry || entry.isEmpty()) return 0; - const before = Number(entry.read()); - if (!Number.isFinite(before) || before < 0) return 0; - const length = readLengthM(); - return length !== null && before > length ? length : before; + return readBeforeOption(optionInputs); } /** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index a04f3f5f..c883afc2 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -9,7 +9,11 @@ import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; -import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { + isB05Option, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ @@ -98,3 +102,36 @@ export interface StructuresCallbacks { */ onReveal?: () => void; } + +type OptionEntry = { key: string; read: () => string | number; isEmpty: () => boolean }; + +/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */ +export function readLengthOption(inputs: OptionEntry[]): number | null { + const entry = inputs.find((input) => input.key === "length_m"); + if (!entry || entry.isEmpty()) return null; + const length = Number(entry.read()); + return Number.isFinite(length) && length > 0 ? length : null; +} + +/** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0 + * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */ +export function readBeforeOption(inputs: OptionEntry[]): number { + const entry = inputs.find((input) => input.key === "before_m"); + if (!entry || entry.isEmpty()) return 0; + const before = Number(entry.read()); + if (!Number.isFinite(before) || before < 0) return 0; + const length = readLengthOption(inputs); + return length !== null && before > length ? length : before; +} + +/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 + * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 + * 준다(2026-08-19 사용자 지시). */ +export function hasComputedRange(type: StructureType | null): boolean { + return ( + !!type && + !type.managed_by && + type.placement === "interval" && + type.options.some((option) => option.key === "length_m" && isB05Option(option)) + ); +} diff --git a/B05_Profile/B05_Profile_Util_Station.ts b/B05_Profile/B05_Profile_Util_Station.ts index b6859a0f..60a862d2 100644 --- a/B05_Profile/B05_Profile_Util_Station.ts +++ b/B05_Profile/B05_Profile_Util_Station.ts @@ -4,7 +4,8 @@ * * 구조물 위치는 저장은 누가거리(m)로 하되 **화면 입출력은 측점번호+잔여거리**로 한다 * (2026-08-17 사용자 확정). 근거: 중심선측량 측점 20m + 구조물 지점 보조말뚝 - * (지식DB 설계제원_총괄 §8). 표기 예: "3+18.0" = 측점 3번에서 18.0m. + * (지식DB 설계제원_총괄 §8). 표기 예: "3+18" = 측점 3번에서 18m — 화면 표기는 + * `common_util_station.stationLabel` 한 벌(거리는 정수로 보이기만 · 값은 소수). * * 변환식은 비정규 측점 UI(`B05_Profile_UI_IrregularStations.ts`)와 같다 — * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 호출 시점에 받는다 @@ -33,12 +34,6 @@ export function chainageToStation( return { station, remainder }; } -/** 누가거리 → "3+18.0" 표기. */ -export function formatStation(chainageM: number, intervalM: number): string { - const { station, remainder } = chainageToStation(chainageM, intervalM); - return `${station}+${remainder.toFixed(1)}`; -} - /** "3+18.0" · "3 + 18" · "76.5"(누가거리 직접) 입력을 누가거리로 해석한다. * 해석 불가하면 null — 호출부가 칸을 붉히고 진행을 멈춘다. */ export function parseStationText(text: string, intervalM: number): number | null { diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 4e1d5d84..5cfe32ce 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -19,7 +19,7 @@ from typing import Any, Callable import numpy as np -from B05_Profile.B05_Profile_Engine_Sections import cross_filename +from B05_Profile.B05_Profile_Engine_Sections import find_cross_file from B06_Section.B06_Section_Engine_Design import ( _SLOPE_CLOSE_TOLERANCE_M, compute_cross_design, @@ -175,7 +175,7 @@ def extend_unclosed_sections( recomputed[key] = design[key] section["samples"] = samples section["design"] = recomputed - cross_path = cross_dir / cross_filename(chainage) + cross_path = find_cross_file(cross_dir, chainage) if cross_path.is_file(): stored = json.loads(cross_path.read_text(encoding="utf-8")) atomic_write_json(cross_path, {**stored, "samples": samples}) diff --git a/B06_Section/B06_Section_Repository.py b/B06_Section/B06_Section_Repository.py index 72807284..b9d243f6 100644 --- a/B06_Section/B06_Section_Repository.py +++ b/B06_Section/B06_Section_Repository.py @@ -406,23 +406,23 @@ async def merge_cross_section_design_by_round( connection: aiomysql.Connection, *, route_id: int, - chainage_int: int, + chainage: float, patch: dict[str, Any], ) -> bool: - """정수 chainage(m) 측점의 data.design에 patch를 병합 저장한다. + """측점(도면 id 측점 — 정수 m 또는 소수 2자리)의 data.design에 patch를 병합 저장한다. B07 도면 확정(전체 설계 재계산 결과) 및 확정 해제(status 되돌림)에서 사용한다. - 도면 ID가 정수 m라 ROUND로 매칭한다. 갱신 여부를 반환한다. + 이름 규칙(cross_key)의 0.005m 안으로 매칭한다. 갱신 여부를 반환한다. """ async with connection.cursor() as cursor: await cursor.execute( """ SELECT id, data FROM cross_sections - WHERE route_id = %s AND ROUND(chainage_m) = %s + WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005 ORDER BY id DESC LIMIT 1 """, - (route_id, chainage_int), + (route_id, chainage), ) row = await cursor.fetchone() if not row: @@ -445,21 +445,21 @@ async def merge_cross_section_design_by_round( async def get_cross_section_design( - connection: aiomysql.Connection, route_id: int, chainage_int: int + connection: aiomysql.Connection, route_id: int, chainage: float ) -> dict[str, Any] | None: - """정수 chainage(m)에 해당하는 측점의 잠정 설계(data.design)를 반환한다. + """도면 id 측점(cross_{정수m}m · cross_{소수2자리}m)의 잠정 설계(data.design)를 반환한다. - B08이 도면 ID(cross_{정수m}m)로 조회하므로 ROUND로 근사 매칭한다. + 이름 규칙(cross_key)의 0.005m 안으로 매칭한다. """ async with connection.cursor() as cursor: await cursor.execute( """ SELECT data FROM cross_sections - WHERE route_id = %s AND ROUND(chainage_m) = %s + WHERE route_id = %s AND ABS(chainage_m - %s) < 0.005 ORDER BY id DESC LIMIT 1 """, - (route_id, chainage_int), + (route_id, chainage), ) row = await cursor.fetchone() if not row or not row[0]: diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f570baf2..b3104c32 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -5,7 +5,7 @@ import logging from pathlib import Path from typing import Any -from B05_Profile.B05_Profile_Engine_Sections import cross_filename +from B05_Profile.B05_Profile_Engine_Sections import find_cross_file from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets @@ -309,7 +309,7 @@ def read_cross_design_inputs( design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) suggested = pavement.get(round(chainage_m, 3), False) cross_dir = longitudinal_path.parent.parent / "cross_sections" - cross_path = (cross_dir / cross_filename(chainage_m)).resolve() + cross_path = find_cross_file(cross_dir, chainage_m).resolve() if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file(): raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") cross = json.loads(cross_path.read_text(encoding="utf-8")) @@ -492,7 +492,7 @@ def compute_default_designs( ford_drops = ford_surface_drops(root) results: list[tuple[float, dict[str, Any]]] = [] for chainage_m in chainages: - cross_path = cross_dir / cross_filename(chainage_m) + cross_path = find_cross_file(cross_dir, chainage_m) if not cross_path.is_file(): continue try: diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 7a67125f..17b07270 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -9,6 +9,7 @@ import re from typing import Any +from B05_Profile.B05_Profile_Engine_Sections import cross_key from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( CROSS_MM, CROSS_TABLE_LAYER_ID, @@ -38,7 +39,7 @@ from config.config_system import DRAWING_SCALE_CROSS # 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m). # 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다. -CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?") +CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?") # 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. # 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거). @@ -151,7 +152,7 @@ def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[flo return slots -def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]: +def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]: """(측점, 폭, 높이) 목록을 A1 장으로 나눈다. 한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다. @@ -170,7 +171,7 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, # id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가 # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). - "id": f"cross_s{chainages[0]:05d}m", + "id": f"cross_s{cross_key(chainages[0])}m", "number": len(sheets) + 1, "chainages": chainages, "rows": max(rows, 1), diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 592477a5..d07804e4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -13,6 +13,11 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_Sections import ( + CROSS_KEY_PATTERN, + cross_station, + parse_cross_key, +) from B06_Section.B06_Section_Repository import ( get_confirmed_route_context, get_cross_section_design, @@ -65,7 +70,7 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) -_CROSS_ID = re.compile(r"^cross_(\d+)m$") +_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") @@ -249,13 +254,13 @@ async def title_block_fields(project_id: UUID) -> dict[str, str]: return {key: str(value) for key, value in fields.items() if value} -async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]: +async def _designs_by_chainage(route_id: int) -> dict[int | float, dict[str, Any]]: """노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다.""" pool = get_db_pool() async with pool.acquire() as connection: rows = await get_cross_section_designs(connection, route_id) return { - int(round(float(row["chainage_m"]))): row["design"] + cross_station(row["chainage_m"]): row["design"] for row in rows if isinstance(row, dict) and isinstance(row.get("design"), dict) } @@ -304,7 +309,7 @@ async def get_design_drawing( pool = get_db_pool() async with pool.acquire() as connection: design = await get_cross_section_design( - connection, route_id, int(cross_match.group(1)) + connection, route_id, parse_cross_key(cross_match.group(1)) ) source_design = design elif CROSS_SHEET_ID.fullmatch(drawing_id): @@ -448,25 +453,25 @@ async def confirm_design_drawing( # 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②). cross_match = _CROSS_ID.fullmatch(drawing_id) pool = get_db_pool() - targets: list[int] = [] + targets: list[int | float] = [] if sheet is not None: targets = list(sheet["chainages"]) elif item.kind == "cross" and cross_match: - targets = [int(cross_match.group(1))] + targets = [parse_cross_key(cross_match.group(1))] confirmed_designs = [ - (chainage_int, {**designs[chainage_int], "status": "confirmed"}) - for chainage_int in targets - if designs.get(chainage_int) + (chainage, {**designs[chainage], "status": "confirmed"}) + for chainage in targets + if designs.get(chainage) ] async with pool.acquire() as connection: await connection.begin() try: - for chainage_int, _design in confirmed_designs: + for chainage, _design in confirmed_designs: await merge_cross_section_design_by_round( connection, route_id=route_id, - chainage_int=chainage_int, + chainage=chainage, patch={"status": "confirmed"}, ) async with connection.cursor() as cursor: @@ -519,9 +524,9 @@ async def invalidate_design_drawing( await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) cross_match = _CROSS_ID.fullmatch(drawing_id) - stale: list[int] = [] + stale: list[int | float] = [] if cross_match: - stale = [int(cross_match.group(1))] + stale = [parse_cross_key(cross_match.group(1))] elif CROSS_SHEET_ID.fullmatch(drawing_id): plan = await asyncio.to_thread( _cross_sheet_plan, project_root, longitudinal_path, designs @@ -533,11 +538,11 @@ async def invalidate_design_drawing( await connection.begin() try: # 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다. - for chainage_int in stale: + for chainage in stale: await merge_cross_section_design_by_round( connection, route_id=route_id, - chainage_int=chainage_int, + chainage=chainage, patch={"status": "provisional"}, ) async with connection.cursor() as cursor: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 08d99000..4287f1b2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -9,6 +9,12 @@ import re from pathlib import Path from typing import Any +from B05_Profile.B05_Profile_Engine_Sections import ( + CROSS_KEY_PATTERN, + cross_station, + find_cross_file, + parse_cross_key, +) from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, @@ -94,7 +100,7 @@ logger = logging.getLogger(__name__) _STAGE_DIR = "B07_DesignDetail" -_CROSS_ID = re.compile(r"^cross_(\d+)m$") +_CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") # 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. MASS_HAUL_ID = "mass_haul" @@ -107,7 +113,7 @@ CROSS_STANDARD_ID = "cross_standard" def _drawing_list( project_root: Path, longitudinal_path: Path, - designs: dict[int, dict[str, Any]] | None = None, + designs: dict[int | float, dict[str, Any]] | None = None, ) -> list[DesignDrawingItem]: longitudinal = _read_json(longitudinal_path) station_by_chainage = _station_map(longitudinal) @@ -226,7 +232,7 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem] def _cross_sheet_plan( project_root: Path, longitudinal_path: Path, - designs: dict[int, dict[str, Any]] | None = None, + designs: dict[int | float, dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다. @@ -239,8 +245,9 @@ def _cross_sheet_plan( match = _CROSS_ID.fullmatch(path.stem) if not match: continue - chainage = int(match.group(1)) source = _read_json(path) + # 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎 + chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1))) design = (designs or {}).get(chainage) design_line = _cross_design_line(longitudinal_path, source, design) width, height = section_block_size(source, design_line) @@ -352,11 +359,11 @@ def _cross_design_line( def _cross_section_input( longitudinal_path: Path, longitudinal: dict[str, Any], - chainage: int, + chainage: int | float, design: dict[str, Any] | None, ) -> dict[str, Any] | None: """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.""" - path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json" + path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage) if not path.is_file(): return None source = _read_json(path) @@ -565,9 +572,12 @@ def _read_drawing( if CROSS_SHEET_ID.fullmatch(drawing_id): return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design) - if not _CROSS_ID.fullmatch(drawing_id): + cross_match = _CROSS_ID.fullmatch(drawing_id) + if not cross_match: raise ValueError("올바르지 않은 도면 ID입니다.") - path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json" + path = find_cross_file( + longitudinal_path.parent.parent / "cross_sections", parse_cross_key(cross_match.group(1)) + ) if not path.is_file(): raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") source = _read_json(path) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py index ad9630c9..00303a40 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -9,7 +9,7 @@ import re from pathlib import Path from typing import Any -from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files +from B05_Profile.B05_Profile_Engine_Sections import cross_station, prune_stale_cross_files logger = logging.getLogger(__name__) @@ -47,12 +47,12 @@ def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[ return files -def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: +def _station_map(longitudinal: dict[str, Any]) -> dict[int | float, dict[str, Any]]: stations = longitudinal.get("stations", []) if not isinstance(stations, list): return {} return { - round(float(station.get("chainage_m", 0))): station + cross_station(station.get("chainage_m", 0)): station for station in stations if isinstance(station, dict) } diff --git a/common_util/common_util_station.ts b/common_util/common_util_station.ts new file mode 100644 index 00000000..3fb2d306 --- /dev/null +++ b/common_util/common_util_station.ts @@ -0,0 +1,25 @@ +/* ============================================================================= + * common_util_station.ts + * 측점 화면 표기 한 벌 — B05 · B06 목록 · 폼 · 툴팁 · 종단 표시가 모두 이것을 씀 + * (`common_util_svg` 가 다시 내보냄 · B05 는 `formatStation` 이름으로 가져다 씀). 가져오는 것이 없는 + * 순수 함수라 서버 Node · 시험 헬퍼에서도 그대로 돎. + * ========================================================================== */ + +/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0` · `5+20`). + * 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로. */ +export function stationLabel(chainage: number, interval: number): string { + const safeInterval = interval > 0 ? interval : 1; + let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); + let remainder = chainage - stationNumber * safeInterval; + if (Math.abs(remainder) < 0.05) remainder = 0; + if (remainder >= safeInterval - 0.05) { + stationNumber += 1; + remainder = 0; + } + return stationPartsLabel(stationNumber, remainder); +} + +/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만. */ +export function stationPartsLabel(stationNumber: number, remainder: number): string { + return `${stationNumber}+${Math.round(remainder)}`; +} diff --git a/common_util/common_util_svg.ts b/common_util/common_util_svg.ts index d2498638..e03e9e6c 100644 --- a/common_util/common_util_svg.ts +++ b/common_util/common_util_svg.ts @@ -49,15 +49,4 @@ export function inferStationInterval(stations: Array<{ chainage_m: number }>): n ); } -/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0.0`). */ -export function stationLabel(chainage: number, interval: number): string { - const safeInterval = interval > 0 ? interval : 1; - let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); - let remainder = chainage - stationNumber * safeInterval; - if (Math.abs(remainder) < 0.05) remainder = 0; - if (remainder >= safeInterval - 0.05) { - stationNumber += 1; - remainder = 0; - } - return `${stationNumber}+${remainder.toFixed(1)}`; -} +export { stationLabel } from "./common_util_station"; diff --git a/resources/tester/helper_b05_station_roundtrip.cjs b/resources/tester/helper_b05_station_roundtrip.cjs index c31ff5cd..a967df82 100644 --- a/resources/tester/helper_b05_station_roundtrip.cjs +++ b/resources/tester/helper_b05_station_roundtrip.cjs @@ -101,4 +101,16 @@ out.moved = store.edits(); store.resetStation(200); out.deleted = store.edits(); +// 화면 표기 한 벌 — 거리는 정수로 보이기만(PLAN 20장 · 119.8 → 5+20) +const { stationLabel, stationPartsLabel } = loadTs( + path.join(ROOT, "common_util", "common_util_station.ts"), +); +out.labels = [ + stationLabel(119.8, INTERVAL), + stationLabel(120, INTERVAL), + stationLabel(84.3, INTERVAL), + stationLabel(123.37, INTERVAL), + stationPartsLabel(5, 19.8), +]; + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py index a3b25de8..dbbcd235 100644 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -22,6 +22,8 @@ from typing import Any import pytest +from B05_Profile.B05_Profile_Engine_Sections import cross_filename + ROOT = Path(__file__).resolve().parents[2] PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조` SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID @@ -425,6 +427,10 @@ class Flow: near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0] if not any(abs(n - c) < 0.005 for n in near): d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})") + # 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음 + name = cross_filename(c) + if not (root / "B06_Section" / "cross_sections" / name).is_file(): + d.append(f"관 측점 {c} — 횡단 파일 {name} 없음") self._check_designs() def _check_designs(self) -> None: diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index 689ea264..237ce6a7 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -192,7 +192,7 @@ def test_matching_signature_loads_points(tmp_path): ] -# ── 횡단 파일명 충돌 — 한 정수 미터에 측점 하나 · 관 · 구조물 자체 측점 우선(PLAN 19장) ── +# ── 소수 측점 횡단 — 관 · 구조물 자리마다 따로(이름 소수 2자리 · PLAN 20장) ────────────── def _stations(extra_stations): @@ -204,31 +204,32 @@ def _stations(extra_stations): return result["longitudinal"]["stations"] -def test_extra_station_in_grid_meter_replaces_grid_station(): +def test_extra_station_in_grid_meter_keeps_both_with_own_names(): stations = _stations(((100.4, "X"),)) chainages = [station["chainage_m"] for station in stations] - # 관 · 구조물 자체 측점 그대로 — 같은 정수 미터 격자 100 은 빠져 파일(cross_00100m)은 한 장 - assert 100.4 in chainages and 100.0 not in chainages + assert 100.4 in chainages and 100.0 in chainages labelled = [station for station in stations if station.get("structure") == "X"] assert [(station["chainage_m"], station["kind"]) for station in labelled] == [ (100.4, "irregular") ] + assert cross_filename(100.4) == "cross_00100.40m.json" assert len({cross_filename(c) for c in chainages}) == len(chainages) -def test_extra_station_at_route_end_keeps_end_station(): - stations = _stations(((0.3, "S"), (349.8, "E"))) - chainages = [station["chainage_m"] for station in stations] - # 시 · 종점은 격자 그대로 두고 라벨만 얹음 - assert chainages[0] == 0.0 and chainages[-1] == 350.0 - assert 0.3 not in chainages and 349.8 not in chainages - assert stations[0].get("structure") == "S" and stations[-1].get("structure") == "E" +def test_extra_station_on_grid_within_5mm_labels_grid_station(): + stations = _stations(((100.003, "X"), (0.3, "S"))) + labelled = {station["chainage_m"]: station for station in stations if station.get("structure")} + assert sorted(labelled) == [0.3, 100.0] + assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular" -def test_two_extras_in_same_meter_bucket_keep_one(): +def test_two_extras_in_same_meter_both_kept(): stations = _stations(((80.2, "A"), (80.4, "B"))) - assert [station["chainage_m"] for station in stations if station.get("structure")] == [80.2] - assert 80.0 not in [station["chainage_m"] for station in stations] + assert [station["chainage_m"] for station in stations if station.get("structure")] == [ + 80.2, + 80.4, + ] + assert 80.0 in [station["chainage_m"] for station in stations] def test_live_extras_stay_irregular_and_filenames_stay_unique(): @@ -287,7 +288,7 @@ def test_run_section_generation_derives_stations_from_truth_files(project): ] cross_dir = project / "B06_Section" / "cross_sections" - for name in ("cross_00084m", "cross_00150m", "cross_00201m", "cross_00276m"): + for name in ("cross_00084.30m", "cross_00149.73m", "cross_00200.92m", "cross_00275.71m"): assert (cross_dir / f"{name}.json").is_file() # 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다. assert len(result["cross_sections"]) == len(stations) diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 68e956c5..22d11394 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -37,6 +37,10 @@ def test_칸을_안_고치면_실어_둔_측점_그대로(results): assert results["r119"] == ["5", "19.8", 119.8] +def test_화면_표기는_거리를_정수로(results): + assert results["labels"] == ["5+20", "6+0", "4+4", "6+3", "5+20"] + + def test_칸을_고치면_친_값(results): assert results["edited"] == pytest.approx(125.2) assert results["normalized"] == ["2", "10.0", 50] @@ -96,8 +100,31 @@ def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path): SectionGenerationOptions(extra_stations=extras), ) chainages = [round(s["chainage_m"], 3) for s in sections["longitudinal"]["stations"]] - # 횡단 측점도 자체 측점 119.8(같은 정수 미터 격자 120 대신 · 파일 이름 cross_00120m 한 장) - assert 119.8 in chainages and 120.0 not in chainages - assert cross_filename(119.8) == cross_filename(120.0) + # 소수 측점 횡단을 따로 둠(PLAN 20장) — 119.8 · 격자 120 둘 다 · 이름도 따로 + assert 119.8 in chainages and 120.0 in chainages + assert (cross_filename(119.8), cross_filename(120.0)) == ( + "cross_00119.80m.json", + "cross_00120m.json", + ) _, again = load_structures(str(tmp_path)) assert [s.chainage_m for s in again] == [119.8] and again[0].memo == "메모만" + + +def test_옛_프로젝트_정수_이름_횡단은_찾고_안_지움(tmp_path): + """소수 측점이 정수 m 이름으로 저장된 옛 프로젝트 — 열 때 지우지 않고 그 파일을 씀.""" + from B05_Profile.B05_Profile_Engine_Sections import find_cross_file, prune_stale_cross_files + + (tmp_path / "cross_00084m.json").write_text("{}", encoding="utf-8") # 옛 84.3 + (tmp_path / "cross_00100m.json").write_text("{}", encoding="utf-8") + (tmp_path / "cross_00119.80m.json").write_text("{}", encoding="utf-8") # 새 이름 + (tmp_path / "cross_00500m.json").write_text("{}", encoding="utf-8") # 잔재 + stations = [{"chainage_m": 84.3}, {"chainage_m": 100.0}, {"chainage_m": 119.8}] + valid = prune_stale_cross_files(tmp_path, stations) + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "cross_00084m.json", + "cross_00100m.json", + "cross_00119.80m.json", + ] + assert valid == {"cross_00084m.json", "cross_00100m.json", "cross_00119.80m.json"} + assert find_cross_file(tmp_path, 84.3).name == "cross_00084m.json" + assert find_cross_file(tmp_path, 119.8).name == "cross_00119.80m.json" diff --git a/resources/tester/test_b06_patch_station.py b/resources/tester/test_b06_patch_station.py index 58481163..9abb3e39 100644 --- a/resources/tester/test_b06_patch_station.py +++ b/resources/tester/test_b06_patch_station.py @@ -34,3 +34,9 @@ def test_행_없는_측점_수정이면_측점을_세움(monkeypatch): def test_모든_수정에_행이_있으면_안_부름(monkeypatch): assert _calls(monkeypatch, [120.0, 140.004], [120.0, 140.0]) == [] assert _calls(monkeypatch, [], [120.0]) == [] + + +def test_소수_측점_수정은_정수_격자_행으로_갈음하지_않음(monkeypatch): + # 119.8 은 격자 120 과 다른 측점(횡단 cross_00119.80m · PLAN 20장) — 제 행이 없으면 세움 + assert _calls(monkeypatch, [119.8], [120.0]) == [PROJECT] + assert _calls(monkeypatch, [119.8], [119.8, 120.0]) == [] From b8195aff2e5a3d497dcc9243b56603fea3a0c424 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 12:01:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(M02):=20=EC=B8=B5=20=EC=9D=B8=EC=8B=9D?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=E2=80=94=20=ED=9A=8C=EC=82=AC=20=C2=B7=20=EA=B0=9C=EC=9D=B8=20?= =?UTF-8?q?=EC=B8=B5=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=ED=95=AD=EB=AA=A9=20=EB=B3=B5=EC=82=AC=20(PLAN=2021-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store · Matset · Branch 가 지금 층 폴더(store.at · 기본 시스템 층)를 봄 — 시스템 층 동작 · 옛 길 그대로 - 회사 · 개인 층 첫 진입 때 빈 뼈대(집계표 열 0 · 조합 장부 다음 1 · 자재 조합 빈 표) - /api/m02/layers/{layer}/… 집계표 길(번호 · 조합 장부 · 구조물 새로 · 복사 · 조합 갈라 내기 · 자재 조합) · 권한 = 시스템 SYSTEM_ADMIN · 회사 관리자 · 개인 본인 - 층 양식 저장(시스템 · 회사 · 개인)은 집계표 규칙(새 열 파일 · 번호 · SS) 거침 · 시스템 층 쓰기는 SYSTEM_ADMIN · 쓰는 양식 지우기 409 - GET /api/m02/items(한 층 · ?all=1 세 층) · POST /api/m02/layers/personal/items/copy · 회사 없음 409 「회사부터 등록」 - 시험 test_m02_layers_store Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- .../M02_MasterTemplete_Items.py | 130 +++++++++ .../M02_MasterTemplete_Matset.py | 4 +- .../M02_MasterTemplete_Router_Items.py | 263 ++++++++++++++++++ .../M02_MasterTemplete_Router_Layers.py | 49 +++- .../M02_MasterTemplete_Store.py | 45 ++- main.py | 2 + resources/tester/test_m02_layers_store.py | 172 ++++++++++++ resources/tester/test_m02_template_layers.py | 5 +- 8 files changed, 645 insertions(+), 25 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Items.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Router_Items.py create mode 100644 resources/tester/test_m02_layers_store.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py new file mode 100644 index 00000000..e6953465 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -0,0 +1,130 @@ +"""M02 층 집계표 항목 — 회사 · 개인 층 빈 뼈대 · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2). + +항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) · 자재조합(UM) · +도번 SD · 면적번 SA · 재료번 SM. +층 폴더(시스템 · 회사 · 개인)는 모양이 같아 저장소 함수를 `store.at(층 폴더)` 안에서 그대로 부름. +권한은 부르는 쪽(`M02_MasterTemplete_Router_Items.py`) 몫 — 여기는 폴더와 파일만. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Matset as matset +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`) +ITEM_FIELDS = ( + "열", + "종류", + "분류1", + "분류2", + "분류3", + "단위", + "일위대가", + "자재조합", + "도번", + "면적번", + "재료번", +) + + +def ensure(folder: Path) -> bool: + """회사 · 개인 층 첫 진입 — 빈 집계표(열 0 · 층 이름 · 줄 머리는 시스템 것) · + 조합 장부(`다음` 1) · 자재 조합 빈 표 · 종류 폴더. + 이미 집계표가 있으면 아무것도 안 함(시스템 층은 늘 있음).""" + table = Path(folder) / "table" / f"{store.STRUCTURE_TABLE}.json" + if table.is_file(): + return False + system = json.loads((store.FOLDER / "table" / f"{store.STRUCTURE_TABLE}.json").read_bytes()) + doc = { + **system, + "열": [], + "줄": [{**r, "값": {}} for r in system.get("줄", []) if isinstance(r, dict)], + "합계줄": [], + "보기": {}, + } + with store.at(folder): + for kind in store.KINDS: + (Path(folder) / kind).mkdir(parents=True, exist_ok=True) + with store._LOCK: + if table.is_file(): # 그 사이 다른 요청이 만듦 + return False + atomic_write_json(table, doc) + store.sync_combos() + mat = Path(folder) / matset.FILE + if not mat.is_file(): + atomic_write_json(mat, matset._load()[0]) + return True + + +def items(folder: Path) -> list[dict[str, Any]]: + """그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, + 도번, 면적번, 재료번}`.""" + with store.at(folder): + rows = store.read_combos().get("줄", []) + return [{"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}} for r in rows] + + +def _fresh_id(want: str, used: set[str]) -> str: + out, n = want, 2 + while out in used: + out, n = f"{want}_{n}", n + 1 + return out + + +def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]: + """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · + 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 자재 조합 줄(새 UM) 복사 · + 새 번호 · 새 SS 키 · + 열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`.""" + with store.at(source): + row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None) + if row is None: + raise store.StoreError(404, f"없는 항목 「{key}」") + col_id = row["열"] + col = next(c for c in store._structure_columns() if c["id"] == col_id) + docs = {} + for kind, _, _, _ in store.PARTS: + try: + docs[kind] = store.read(kind, col_id)["문서"] + except store.StoreError: + pass # 없는 파일은 새 층에서 빈 문서로 섬 + um = None + if col.get("자재조합"): + try: + um = matset.one(col["자재조합"]) + except store.StoreError: + um = None # 가리키던 줄이 없으면 비움 + ensure(target) + with store.at(target): + new_um = None + if um: + names = matset.listing()["줄"] + taken = {str(r.get("이름") or "") for r in names} + name, n = str(um.get("이름") or key), 2 + base = name + while name in taken: + name, n = f"{base} ({n})", n + 1 + got = {k: um.get(k) for k in ("담은자재", "소유", "비고")} + new_um = matset.create({**got, "이름": name, "본뜬키": None})["키"] + table = store.read("table", store.STRUCTURE_TABLE) + doc = table["문서"] + new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}) + made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin} + doc["열"] = [*doc.get("열", []), made] + store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS + for kind, got in docs.items(): + number = store._KEY[kind] + have = store.read(kind, new_id)["문서"] + body = {k: v for k, v in got.items() if k != "계산값"} + body = {**body, "열": new_id, number: have.get(number)} + if kind in store.SHEETS: + body = store._server_values(body) + with store._LOCK: + atomic_write_json(store._path(kind, new_id), body) + ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) + return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Matset.py b/M02_MasterTemplete/M02_MasterTemplete_Matset.py index 39f416f6..df87e2fc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Matset.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Matset.py @@ -22,7 +22,7 @@ _HEAD = "UM" def _load() -> tuple[dict[str, Any], str]: """(표, 판) — 파일이 없으면 빈 표 · 판 빈 글.""" - path = store.FOLDER / FILE + path = store.root() / FILE if not path.is_file(): return {"그룹": mmc.GROUP, "판": "2026", "비고": [], "다음": 1, "줄": []}, "" raw = path.read_bytes() @@ -95,7 +95,7 @@ def _save(doc: dict, version: str) -> str: """틀 검사 → 판이 같을 때만 씀 → 새 판.""" if errors := mmc.check_form(FILE, doc): raise store.StoreError(422, {"errors": errors}) - path = store.FOLDER / FILE + path = store.root() / FILE with store._LOCK: have = store.version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py new file mode 100644 index 00000000..1e8fa03b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -0,0 +1,263 @@ +"""M02 층 집계표 API — `/api/m02/layers/{layer}/…` · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2). + +⚠ 등록은 `main.py`(로그인만) · 층 권한은 여기서(`Router_Layers._layer_dir` 와 같은 규칙): + system 읽기 = 로그인 누구나(항목 고르기) · 쓰기 = SYSTEM_ADMIN + company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) + personal 읽기 · 쓰기 = 본인(SYSTEM_ADMIN 은 누구나) · 회사 없음 = 쓰기 409 「회사부터 등록」 +옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로. +회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_MasterTemplete_Branch as branch +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Matset as matset +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_MasterTemplete_Router import ( + BranchBody, + MaterialBranchBody, + MatsetBody, + UseComboBody, +) + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) + +Layer = Literal["system", "company", "personal"] +Session = dict[str, Any] +NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음" + + +class CopyItemBody(BaseModel): + from_layer: Literal["system", "company"] + from_owner: str | None = None + key: str + + +def _admin(session: Session) -> bool: + return session.get("role") == "SYSTEM_ADMIN" + + +def _company_admin(session: Session) -> bool: + return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) + + +def _owner(session: Session, layer: str, owner: str | None) -> str | None: + """층 주인 글 — 회사 `{회사}` · 개인 `{회사}/{사용자}` · 시스템 None · + 남의 것은 SYSTEM_ADMIN 만.""" + if layer == "system": + return None + company, user = session.get("company_id"), session.get("user_id") + mine = str(company) if layer == "company" else f"{company}/{user}" + if owner in (None, "", mine): + return None if company is None else mine + if not _admin(session): + raise HTTPException(status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표입니다.") + return owner + + +def _folder(layer: str, owner: str | None) -> Path: + if layer == "system": + return store.FOLDER + try: + if layer == "company": + return layers.company_dir(owner) + company, user = str(owner).split("/", 1) + return layers.personal_dir(company, user) + except ValueError as error: + raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error + + +def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: + """층 폴더(권한까지) — 회사 · 개인은 없으면 빈 뼈대를 만듦. 회사 없는 사람: 쓰기 409.""" + if layer == "system": + if write and not _admin(session): + raise HTTPException(status_code=403, detail="마스터 템플릿은 시스템 관리자만 고침.") + return store.FOLDER + who = _owner(session, layer, owner) + if who is None: + raise HTTPException(status_code=409, detail=NO_COMPANY) + if write and layer == "company" and not _company_admin(session): + raise HTTPException( + status_code=403, detail="회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." + ) + folder = _folder(layer, who) + items_store.ensure(folder) + return folder + + +def _call(folder: Path, fn, *args): + with store.at(folder): + try: + return fn(*args) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + + +# ── 항목 목록 · 복사 ─────────────────────────────────── + + +def _listing(session: Session, layer: str, owner: str | None) -> dict[str, Any]: + who = None if layer == "system" else _owner(session, layer, owner) + if layer != "system" and who is None: + return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹 + folder = _root(session, layer, who) + return {"layer": layer, "owner": who, "items": items_store.items(folder)} + + +@router.get("/items") +def get_items( + layer: Layer = "system", + owner: str | None = None, + all: bool = False, # noqa: A002 — 21-1 약속 이름 `?all=1` + session: Session = Depends(verify_session), +): + """21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번, + 재료번}]` · + `?all=1` 이면 세 층 `[{layer, owner, items}]`(회사 · 개인은 세션 것 · 회사 없으면 빈 목록).""" + if all: + return [_listing(session, one, None) for one in ("system", "company", "personal")] + return _listing(session, layer, owner)["items"] + + +@router.post("/layers/personal/items/copy") +def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> dict: + """회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`).""" + target = _root(session, "personal", None, write=True) + source = _root(session, body.from_layer, body.from_owner) + owner = ( + None if body.from_layer == "system" else _owner(session, body.from_layer, body.from_owner) + ) + origin = {"layer": body.from_layer, "owner": owner, "key": body.key} + try: + return items_store.copy_item(source, target, body.key, origin) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + + +# ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── + + +@router.get("/layers/{layer}/structures/numbers") +def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): + return _call(_root(session, layer, owner), store.structure_numbers) + + +@router.get("/layers/{layer}/structure-combos") +def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): + return _call(_root(session, layer, owner), store.read_combos) + + +@router.post("/layers/{layer}/structures/{column}") +def post_structure( + layer: Layer, column: str, owner: str | None = None, session: Session = Depends(verify_session) +): + return _call(_root(session, layer, owner, write=True), store.create_structure, column) + + +@router.post("/layers/{layer}/structures/{column}/combo") +def post_combo( + layer: Layer, + column: str, + body: BranchBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만.""" + folder = _root(session, layer, owner, write=True) + if not _admin(session): + raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.") + return _call( + folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판 + ) + + +@router.post("/layers/{layer}/structures/{column}/material-combo") +def post_material_combo_branch( + layer: Layer, + column: str, + body: MaterialBranchBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + args = (column, body.본뜬키, body.담은자재, body.이름, body.판) + return _call(folder, branch.branch_material_combo, *args) + + +@router.post("/layers/{layer}/structures/{column}/use-combo") +def post_use_combo( + layer: Layer, + column: str, + body: UseComboBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + return _call(folder, branch.use_combo, column, body.종류, body.키, body.판) + + +@router.post("/layers/{layer}/{kind}/{column}/copy") +def post_copy( + layer: Layer, + kind: Literal["structures", "area", "material"], + column: str, + source: str = Query(alias="from"), + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + part = "structure" if kind == "structures" else kind + return _call(folder, store.copy_from, part, column, source) + + +@router.get("/layers/{layer}/material-combos") +def get_matsets(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): + return _call(_root(session, layer, owner), matset.listing) + + +@router.get("/layers/{layer}/material-combos/{key}") +def get_matset( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +): + return _call(_root(session, layer, owner), matset.one, key) + + +@router.post("/layers/{layer}/material-combos") +def post_matset( + layer: Layer, + body: MatsetBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + return _call(_root(session, layer, owner, write=True), matset.create, body.줄, body.판) + + +@router.put("/layers/{layer}/material-combos/{key}") +def put_matset( + layer: Layer, + key: str, + body: MatsetBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + return _call(_root(session, layer, owner, write=True), matset.edit, key, body.줄, body.판) + + +@router.delete("/layers/{layer}/material-combos/{key}") +def delete_matset( + layer: Layer, + key: str, + 판: str = "", + owner: str | None = None, + session: Session = Depends(verify_session), +): + return _call(_root(session, layer, owner, write=True), matset.delete, key, 판) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index ec54af44..5743a60b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -1,7 +1,7 @@ """M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」. ⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서: - system 읽기만(고치기는 `/api/m02/templates` 시스템 관리자 길) + system 읽기 · 쓰기는 시스템 관리자(옛 `/api/m02/templates` 길과 같은 저장소) company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자) personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기) project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀 @@ -26,6 +26,8 @@ from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers @@ -152,8 +154,8 @@ async def _project(session: dict[str, Any], project_id: str | None) -> dict[str, def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int: company_id = project["company_id"] if project else session.get("company_id") - if company_id is None: - raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + if company_id is None: # 회사 없는 사용자는 없음(21-0) — 회사 · 개인 층은 회사부터 + raise HTTPException(status_code=409, detail="회사부터 등록 — 회사 연결이 필요합니다.") return int(company_id) @@ -174,8 +176,8 @@ async def _layer_dir( ) -> Path: """층 폴더 — 읽기·쓰기 권한까지 여기서 가름.""" if layer == "system": - if write: - raise HTTPException(status_code=403, detail="시스템 양식은 마스터 템플릿 화면에서만.") + if write and not _is_system_admin(session): + raise HTTPException(status_code=403, detail="시스템 양식은 시스템 관리자만.") return layers.system_dir() if layer == "project": return layers.project_dir((await _project(session, project_id))["root"]) @@ -184,16 +186,22 @@ async def _layer_dir( if layer == "company": if write and not _is_company_admin(session): raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.") - return layers.company_dir(company_id) + return _ensured(layers.company_dir(company_id)) if layer == "personal": owner = int(user_id) if user_id is not None else int(session["user_id"]) if write and owner != session.get("user_id"): raise HTTPException(status_code=403, detail="개인 양식은 본인만 고칩니다.") await _same_company_user(session, owner, company_id) - return layers.personal_dir(company_id, owner) + return _ensured(layers.personal_dir(company_id, owner)) raise HTTPException(status_code=404, detail="없는 층입니다.") +def _ensured(folder: Path) -> Path: + """회사 · 개인 층 첫 진입 — 빈 구조물집계표 뼈대(PLAN 21-2 · `Items.ensure`).""" + items_store.ensure(folder) + return folder + + def _bad(error: ValueError) -> HTTPException: return HTTPException(status_code=400, detail=str(error)) @@ -261,6 +269,13 @@ async def save_layer_template( if kind == "table" and fill.is_fillable(document): # 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음) document = fill.strip_design(document) + if layer != "project": + # 시스템 · 회사 · 개인 층 = 마스터 저장소 규칙 그대로(집계표 저장 때 새 열 파일 · + # 번호 · SS 장부 · + # 계산값 서버 풀이 · 번호 칸 보존 — `M02_MasterTemplete_Store.write`) + return await asyncio.to_thread( + _store_save, folder, kind, name, body.판 or "", document, layer + ) if kind in ("area", "material"): # 면적산출 · 재료산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음 # (브라우저 값 버림 · 5장 ②) @@ -287,6 +302,15 @@ async def save_layer_template( return {"종류": kind, "이름": name, "판": version, "층": layer} +def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict: + with store.at(folder): + try: + info = store.write(kind, name, version, doc) + except store.StoreError as error: + raise HTTPException(status_code=error.status, detail=error.detail) from error + return {"종류": kind, "이름": name, "판": info["판"], "층": layer} + + @router.delete("/layers/{layer}/templates/{kind}/{name}") async def delete_layer_template( layer: Layer, @@ -296,10 +320,15 @@ async def delete_layer_template( project_id: str | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - """개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 · 프로젝트 층은 거절 · 판이 다르면 409.""" - if layer in ("system", "project"): - raise HTTPException(status_code=403, detail="개인 · 회사 층 양식만 지울 수 있습니다.") + """개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 = 시스템 관리자 · 프로젝트 층은 거절 · + 판이 다르면 409 · 프로그램이 이름으로 부르는 양식(`store.IN_USE` — 구조물집계표 등)은 409.""" + if layer == "project": + raise HTTPException( + status_code=403, detail="개인 · 회사 · 시스템 층 양식만 지울 수 있습니다." + ) folder = await _layer_dir(session, layer, project_id=project_id, write=True) + if (kind, name) in store.IN_USE: + raise HTTPException(status_code=409, detail=f"쓰는 양식이라 못 지움 「{name}」") try: layers.check_kind(kind) layers.check_name(name) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 306a8d72..f068b29a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -1,4 +1,4 @@ -"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`). +"""M02 마스터 템플릿 — 층 저장소 · 기본 시스템 층(`resources/master_template/{종류}/<이름>.json`). M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기. 권한은 등록하는 쪽(`main.py`)이 붙임. @@ -10,6 +10,9 @@ import hashlib import json import re import threading +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar from datetime import datetime from pathlib import Path from typing import Any @@ -20,6 +23,26 @@ from M02_MasterTemplete import M02_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈 +# 지금 다루는 층 폴더 — 층 라우터가 `at(층 폴더)` 로 바꿈 · 안 바꾸면 시스템 층(`FOLDER`). +# 회사 · 개인 층도 시스템과 같은 모양(`table/구조물집계표.json` · 조합 장부 · +# structure/area/material · 자재조합.json — PLAN 21-1)이라 아래 함수는 그대로 씀 +_ROOT: ContextVar[Path | None] = ContextVar("m02_layer_root", default=None) + + +def root() -> Path: + return _ROOT.get() or FOLDER + + +@contextmanager +def at(folder: str | Path) -> Iterator[Path]: + """이 안에서 부르는 저장소 함수는 `folder` 층을 봄(요청 하나 · 스레드 하나 안에서만).""" + token = _ROOT.set(Path(folder)) + try: + yield Path(folder) + finally: + _ROOT.reset(token) + + # 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) — # 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/` # (둘 다 스프레드시트 통합문서) @@ -75,7 +98,7 @@ def _path(kind: str, name: str) -> Path: or _BAD_NAME.search(name) ): raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」") - return FOLDER / kind / f"{name}.json" + return root() / kind / f"{name}.json" def _info(kind: str, path: Path) -> dict[str, Any]: @@ -91,7 +114,7 @@ def list_all() -> list[dict[str, Any]]: rows = [ _info(kind, p) for kind in KINDS - for p in sorted((FOLDER / kind).glob("*.json")) + for p in sorted((root() / kind).glob("*.json")) if not p.name.startswith(".") ] return rows @@ -162,7 +185,7 @@ def delete(kind: str, name: str, version: str | None = None) -> None: def _docs(kind: str) -> list[tuple[str, Any]]: - folder = FOLDER / kind + folder = root() / kind return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] @@ -260,7 +283,7 @@ def _new_code(column: str) -> str: def _retired() -> list[str]: """지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌).""" - path = FOLDER / COMBO_FILE + path = root() / COMBO_FILE return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else [] @@ -318,7 +341,7 @@ def _take_origins(doc: Any) -> dict[str, str]: source = col.pop("본뜬열", None) if not source or source not in by_id or not col.get("id"): continue - if (FOLDER / "structure" / f"{col['id']}.json").is_file(): + if (root() / "structure" / f"{col['id']}.json").is_file(): continue out[col["id"]] = source for field in ("일위대가", "자재조합"): @@ -360,14 +383,14 @@ def prune_removed_structures() -> list[str]: codes: set[str] = set() with _LOCK: for kind, key, _, _ in PARTS: - for path in sorted((FOLDER / kind).glob("[!.]*.json")): + for path in sorted((root() / kind).glob("[!.]*.json")): if path.stem not in keep: code = _code(json.loads(path.read_bytes()), key) codes.update([code] if code else []) path.unlink() gone.append(f"{kind}/{path.stem}") if codes: # 지운 번호는 장부에 남겨 새 열이 다시 안 받게 - book = FOLDER / COMBO_FILE + book = root() / COMBO_FILE doc = json.loads(book.read_bytes()) if book.is_file() else {} doc["지운번호"] = sorted(codes | set(doc.get("지운번호", []))) atomic_write_json(book, doc) @@ -377,7 +400,7 @@ def prune_removed_structures() -> list[str]: def _move_basis() -> list[str]: """12장 산출근거 `basis/` → 면적산출 `area/` (내용 · 알약 연결 그대로 · 표번 칸 → 면적번 · 시트 이름 산출근거 → 면적산출) · 옮긴 뒤 빈 `basis/` 폴더를 지움.""" - old = FOLDER / "basis" + old = root() / "basis" if not old.is_dir(): return [] moved: list[str] = [] @@ -478,7 +501,7 @@ def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[s def sync_combos() -> dict[str, Any]: """조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 · 줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀.""" - path = FOLDER / COMBO_FILE + path = root() / COMBO_FILE with _LOCK: old = json.loads(path.read_bytes()) if path.is_file() else {} keys = {row["열"]: row["키"] for row in old.get("줄", [])} @@ -502,5 +525,5 @@ def sync_combos() -> dict[str, Any]: def read_combos() -> dict[str, Any]: """조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦.""" - path = FOLDER / COMBO_FILE + path = root() / COMBO_FILE return json.loads(path.read_bytes()) if path.is_file() else sync_combos() diff --git a/main.py b/main.py index 12d20e8c..2513076b 100644 --- a/main.py +++ b/main.py @@ -63,6 +63,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_r from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( @@ -638,6 +639,7 @@ app.include_router(m01_master_data_router, dependencies=system_admin_only) # 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서). app.include_router(m02_master_template_router, dependencies=system_admin_only) app.include_router(m02_layers_router, dependencies=protected) +app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py new file mode 100644 index 00000000..715ed51f --- /dev/null +++ b/resources/tester/test_m02_layers_store.py @@ -0,0 +1,172 @@ +"""M02 층 집계표(PLAN 21-2) — 층별 읽기 · 쓰기 · 권한 · 첫 진입 뼈대 · 항목 목록 · 항목 복사 · +시스템 회귀. + +임시 storage(회사 · 개인 층) + 견본 시스템 집계표(`write_sample_table`)로만 봄 — +실제 양식은 안 읽음(시스템 층 두 자리 `store.FOLDER` · `layers.SYSTEM_ROOT` 모두 사본). +자재 코드는 실제 M01 마스터에 있는 MT 키를 씀(자재 조합 저장이 마스터 대조를 함). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TABLE = "table/구조물집계표" + + +def _material() -> dict[str, Any]: + for data in m01.loaded()[0].values(): + for row in data.get("줄") or []: + if str(row.get("키", "")).startswith("MT"): + return {"코드": row["키"], "이름": row.get("이름"), "규격": None, "단위": None} + raise AssertionError("마스터에 자재 키가 없음") + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(layers_router.router) + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "storage": storage, "system": system} + + +def _as(world: dict, role: str = "USER", user: int = 42, company: int | None = 7) -> TestClient: + world["session"].update(role=role, user_id=user, company_id=company) + return world["client"] + + +def _add_column(client: TestClient, layer: str, col: dict[str, Any]) -> None: + url = f"/api/m02/layers/{layer}/templates/{TABLE}" + got = client.get(url).json() + got["문서"]["열"].append(col) + put = client.put(url, json={"판": got["판"], "문서": got["문서"]}) + assert put.status_code == 200, put.text + + +COL = {"id": "cw", "머리": ["돌기슭막이", "찰쌓기", "회사형", None], "단위": "m", "꼴": "수"} + + +def test_시스템_층은_그대로_항목_목록은_SS_줄(world): + client = _as(world) + rows = client.get("/api/m02/items").json() + ss = store.read_combos()["줄"] + assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001" + assert set(rows[0]) == {"key", *items_router.items_store.ITEM_FIELDS} + assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos() + # 시스템 층 쓰기는 SYSTEM_ADMIN 만 + assert client.post("/api/m02/layers/system/structures/rv").status_code == 403 + url = f"/api/m02/layers/system/templates/{TABLE}" + got = client.get(url).json() + assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 403 + assert client.delete(f"{url}?판={got['판']}").status_code == 403 + admin = _as(world, "SYSTEM_ADMIN") + assert admin.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 + assert admin.delete(f"{url}?판={got['판']}").status_code == 409 # 쓰는 양식 + + +def test_회사_층_첫_진입_뼈대와_쓰기_권한(world): + client = _as(world) + assert client.get("/api/m02/items?layer=company").json() == [] + root = world["storage"] / "7" / "templates" + table = json.loads((root / "table/구조물집계표.json").read_text("utf-8")) + assert ( + table["열"] == [] + and table["층"] + == json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))["층"] + ) + assert json.loads((root / "구조물집계표조합.json").read_text("utf-8"))["다음"] == 1 + assert json.loads((root / "자재조합.json").read_text("utf-8"))["다음"] == 1 + url = f"/api/m02/layers/company/templates/{TABLE}" + got = client.get(url).json() + got["문서"]["열"].append(COL) + assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 403 + _add_column(_as(world, "ADMIN"), "company", COL) + items = _as(world).get("/api/m02/items?layer=company").json() # 같은 회사 일반 사용자 + assert [(r["key"], r["열"]) for r in items] == [("SS000001", "cw")] + assert items[0]["도번"].startswith("SD") and (root / "structure/cw.json").is_file() + assert client.get("/api/m02/items?layer=company&owner=9").status_code == 403 # 남의 회사 + # 일위대가(UA) 갈라 내기는 M01 시스템 마스터에 쓰므로 회사 관리자도 못 함 + body = {"본뜬키": "UA000001", "담은로직": [], "이름": "x"} + admin = _as(world, "ADMIN") + assert admin.post("/api/m02/layers/company/structures/cw/combo", json=body).status_code == 403 + assert admin.delete(f"{url}?판={client.get(url).json()['판']}").status_code == 409 # 쓰는 양식 + + +def test_개인_층은_본인만_회사_없으면_409(world): + client = _as(world) + _add_column(client, "personal", {**COL, "id": "p1"}) + assert [r["열"] for r in client.get("/api/m02/items?layer=personal").json()] == ["p1"] + assert (world["storage"] / "7" / "42" / "templates" / "area" / "p1.json").is_file() + other = client.get("/api/m02/items?layer=personal&owner=7/43") + assert other.status_code == 403 + lonely = _as(world, company=None) + groups = lonely.get("/api/m02/items?all=1").json() + assert [(g["layer"], g["items"]) for g in groups][1:] == [("company", []), ("personal", [])] + assert len(groups[0]["items"]) == len(store.read_combos()["줄"]) # 마스터는 보임 + made = lonely.post( + "/api/m02/layers/personal/material-combos", json={"줄": {"이름": "a", "담은자재": []}} + ) + assert made.status_code == 409 and "회사부터" in made.json()["detail"] + + +def test_회사_항목을_개인으로_복사(world): + admin = _as(world, "ADMIN") + _add_column(admin, "company", COL) + base = "/api/m02/layers/company" + drawing = admin.get(f"{base}/templates/structure/cw").json() + doc = {**drawing["문서"], "도면": {**drawing["문서"]["도면"], "entities": [{"type": "line"}]}} + admin.put(f"{base}/templates/structure/cw", json={"판": drawing["판"], "문서": doc}) + 판 = admin.get(f"{base}/material-combos").json()["판"] + row = {"이름": "회사 자재", "담은자재": [_material()]} + um = admin.post(f"{base}/material-combos", json={"판": 판, "줄": row}).json()["키"] + admin.post(f"{base}/structures/cw/use-combo", json={"종류": "자재조합", "키": um}) + ss = admin.get("/api/m02/items?layer=company").json()[0]["key"] + + user = _as(world) + body = {"from_layer": "company", "from_owner": "7", "key": ss} + got = user.post("/api/m02/layers/personal/items/copy", json=body) + assert got.status_code == 200, got.text + made = got.json() + assert made["열"] == "cw" and made["key"] == "SS000001" + mine = "/api/m02/layers/personal" + copy = user.get(f"{mine}/templates/structure/cw").json()["문서"] + assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw" + col = next(c for c in user.get(f"{mine}/templates/{TABLE}").json()["문서"]["열"]) + assert col["본뜬"] == {"layer": "company", "owner": "7", "key": ss} + mat = user.get(f"{mine}/material-combos/{col['자재조합']}").json() + assert mat["이름"] == "회사 자재" and mat["담은자재"][0]["코드"] == _material()["코드"] + # 또 복사하면 새 열 id · 새 SS 키 · 자재 조합 이름 (2) + again = user.post("/api/m02/layers/personal/items/copy", json=body).json() + assert (again["열"], again["key"]) == ("cw_2", "SS000002") + names = [r["이름"] for r in user.get(f"{mine}/material-combos").json()["줄"]] + assert names == ["회사 자재", "회사 자재 (2)"] + # 마스터 항목도 개인으로 · 없는 키 404 + master = {"from_layer": "system", "key": "SS000001"} + assert user.post("/api/m02/layers/personal/items/copy", json=master).status_code == 200 + missing = {**body, "key": "SS999999"} + assert user.post("/api/m02/layers/personal/items/copy", json=missing).status_code == 404 diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index c9723a67..05ac697b 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -143,12 +143,13 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor session.update(user_id=43) theirs = mine + "&user_id=42" assert client.get(theirs).status_code == 200 + # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 put = client.put( - f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}&user_id=42", + f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42", json={"판": None, "문서": {"열": []}}, ) assert put.status_code == 200 # user_id 는 쓰기에서 무시 — 본인(43) 자리에 새로 씀 - assert (world["storage"] / "7/43/templates/table/구조물집계표.json").is_file() + assert (world["storage"] / "7/43/templates/table/내표.json").is_file() got = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} )